diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9cc2311 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# This repository's contracts are BYTES: golden export trees, vendored viewer +# assets with hash manifests, canonical badges, byte-mirrored cli.json copies, +# shared parity fixtures. A checkout that rewrites line endings (core.autocrlf +# on Windows) breaks byte-identity tests against files git itself altered -- +# first the golden trees, then the vendored assets their hash manifests pin. +# -text disables conversion for every path: the committed bytes are the +# checked-out bytes, on every platform. The tree is authored LF except the +# fixtures that deliberately pin CRLF handling, which is exactly why conversion +# must stay off. +* -text diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 73c7fde..8e1b0b4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,7 @@ ## Checklist +- [ ] Every commit is signed off (`git commit -s`), and I agree to the [Contributor terms](../CONTRIBUTING.md#contributor-terms) - [ ] `npm run assets:check` passes (schemas/templates/cli.json vendored copies in sync) - [ ] Behavior changes land in all three SDKs (npm, PyPI, Go) with `npm run parity` green, or the PR says why not - [ ] Tests cover the change; the three suites pass (`npm test`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93ba1f4..2326cd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,8 @@ name: CI on: push: - branches: [main] + # rc/*: a release candidate proves the full matrix here before main sees it. + branches: [main, "rc/*"] pull_request: permissions: @@ -42,7 +43,35 @@ jobs: curl -sSfL "${base}/trufflehog_${TRUFFLEHOG_VERSION}_checksums.txt" -o checksums.txt grep " ${asset}$" checksums.txt | sha256sum -c - tar -xzf "$asset" trufflehog - ./trufflehog git "file://$PWD" --only-verified --fail --no-update + # Lob excluded: its test-key pattern matches pytest function names (FP + # since trufflehog 3.96.0); keep in sync with .husky/pre-commit. + ./trufflehog git "file://$PWD" --only-verified --fail --no-update --exclude-detectors=lob + + dco: + name: DCO (Signed-off-by on every commit) + # Contributor terms are DCO-only, with no CLA, so the sign-off line is the + # whole record: it is checked on the pull request, where it can still be + # fixed by rewriting the branch (CONTRIBUTING.md, "Contributor terms"). + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # full history so every commit in the range is readable + - name: Every commit carries a Signed-off-by line + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + missing=0 + for sha in $(git rev-list "$BASE_SHA..$HEAD_SHA"); do + if ! git show -s --format=%B "$sha" | grep -qiE '^Signed-off-by: .+ <.+@.+>[[:space:]]*$'; then + echo "::error::$sha has no Signed-off-by line; sign off with 'git commit -s' (CONTRIBUTING.md#contributor-terms)" + missing=1 + fi + done + exit "$missing" lint: name: Prettier @@ -68,19 +97,77 @@ jobs: - run: npm run version:check node-sdk: - name: Node SDK (build + test) + name: Node SDK (build + test, Node ${{ matrix.node-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The declared engines floor and the version the rest of CI runs on: a + # test suite exercised only on the newest runtime does not test the floor. + 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 test -w packages/sdk + windows-regressions: + name: Node SDK (Windows regressions) + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: npm ci + - run: npm run build -w packages/sdk + # The tests for defects only this platform produces: forward-slashed route + # keys, the docs root as disk spells it, and read-only verification against a + # DENY ACE rather than a mode. Named one by one, because the point is these + # tests, and asserted by pass count: a pattern matching nothing also exits 0, + # so a green job on its own is no evidence that anything ran. The count pins + # the shape too: a rename selects nothing, and splitting one of these into + # subtests counts every subtest — both land here, as an edit someone had to mean. + - name: Platform regressions, by name + working-directory: packages/sdk + run: | + named() { + local file="$1" expected="$2" pattern="$3" out status=0 + out=$(node --test --test-reporter=tap --test-name-pattern="$pattern" "$file" 2>&1) || status=$? + printf '%s\n' "$out" + if [ "$status" -ne 0 ]; then + echo "::error::$file: the named run exited $status" + return 1 + fi + # A pattern that selects nothing reports the file itself as one passing + # test, which is precisely the outcome this step exists to refuse. + if grep -qF -- " - $file" <<<"$out"; then + echo "::error::$file: the pattern selected no test" + return 1 + fi + if ! grep -qE "^# pass ${expected}\$" <<<"$out"; then + echo "::error::$file: expected exactly $expected named tests to pass" + return 1 + fi + } + named test/units.test.ts 2 '^(serveViewer routes a backslash-separated request to the content mount|adopt detects an existing docs root by its real name, whatever its case)( .+)?$' + named test/mounts.test.ts 2 '^(mounts: --check-integrity verifies a write-denied host tree, twice, without touching it|mounts: an unusable temp directory makes verification unverifiable, never in-tree)( .+)?$' + # The export's byte contract is the place a path separator or a line ending + # leaks into shipped output, so the golden trees and the strict gate are + # asserted on this platform too. + named test/renderlint.test.ts 3 '^fixture valid-render-.+$' + named test/export.test.ts 2 '^strict: .+$' + mcp: name: MCP server (build + test) runs-on: ubuntu-latest @@ -168,6 +255,61 @@ jobs: # Node, Go, Python CLIs on identical inputs; asserts byte-identical output. - run: npm run parity + offline-export: + name: Export fixtures with no network (all three SDKs) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.23" + cache-dependency-path: packages/sdk-go/go.sum + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - run: npm ci + - 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]" + # 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 + working-directory: packages/sdk-go + run: go test -c -o /tmp/leji-conformance.test ./internal/conformancetest + # An export is a static site a user hosts anywhere: it must be produced with + # no network at all, and the fixtures' committed goldens say what "produced" + # means byte for byte. `unshare -rn` gives the run its own network namespace, + # which holds nothing but a loopback interface — brought up because the same + # fixtures also serve the layer to themselves. If a runner image ever refuses + # unprivileged namespaces, the replacement is a container run with + # `--network none`; this leg is release-blocking, so it fails, never skips. + - name: The export fixture set, in a namespace with no network + run: | + set -euo pipefail + # ubuntu-24.04 carries AppArmor's restriction on unprivileged user + # namespaces, which is what makes `unshare -r` fail writing + # /proc/self/uid_map; relaxing that one knob lets the namespace be created, + # and everything inside it still runs as the unprivileged runner account. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + unshare -rn sh -euc ' + ip link set lo up + # The isolation is proved before it is relied on: a runner where this + # reached the internet would report a no-network guarantee it never tested. + if curl -s --max-time 10 -o /dev/null https://example.com; then + echo "::error::the network is reachable inside the namespace" + exit 1 + fi + node --test packages/sdk/test/renderlint.test.ts packages/sdk/test/canary.test.ts + (cd packages/sdk-py && python -m pytest -q tests/test_render_fixtures.py tests/test_canary.py) + (cd packages/sdk-go/internal/conformancetest && /tmp/leji-conformance.test -test.count=1) + ' + site: name: Site (build) runs-on: ubuntu-latest diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 7fcf63e..2126e91 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.3.1" + default: "packages/sdk-go/v1.4.0" permissions: contents: write # publish the release diff --git a/.husky/pre-commit b/.husky/pre-commit index 156e263..b02ad51 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -14,7 +14,9 @@ files=$(git diff --cached --name-only --diff-filter=ACM) [ -z "$files" ] && exit 0 echo "trufflehog: scanning staged files…" -printf '%s\n' "$files" | tr '\n' '\0' | xargs -0 trufflehog filesystem --only-verified --fail --no-update || { +# 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 || { 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 36eb96d..954006c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -14,7 +14,7 @@ ZERO="0000000000000000000000000000000000000000" # stdin can only be read once; capture the ref list and feed both checks from it. _refs="$(cat)" -# (1) Branch-naming allowlist: only main and {feat,chore,fix,context,release}/* +# (1) Branch-naming allowlist: only main and {feat,chore,fix,context,release,rc}/* # may be pushed. The here-doc (not a pipe) keeps the loop in this shell so the # violation flag survives. _violation=0 @@ -26,10 +26,10 @@ while read -r _lref _lsha _rref _rsha; do esac _branch="${_rref#refs/heads/}" case "$_branch" in - main | feat/* | chore/* | fix/* | context/* | release/*) ;; + main | feat/* | chore/* | fix/* | context/* | release/* | rc/*) ;; *) echo "✖ Refusing to push branch '$_branch'." - echo " Allowed: main, or {feat,chore,fix,context,release}/ (e.g. release/v1.3.0)." + echo " Allowed: main, or {feat,chore,fix,context,release,rc}/ (e.g. release/v1.3.0)." _violation=1 ;; esac diff --git a/CHANGELOG.json b/CHANGELOG.json index a70574d..ea6438d 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,86 +1,124 @@ { - "$schema": "https://leji.org/schemas/v1.0/context-changelog.schema.json", - "schemaVersion": "1.0", - "entries": [ - { - "id": "release-1-0-0", - "date": "2026-06-12", - "type": "added", - "summary": "Initial public release: specification 1.0.0 (9 normative documents, adoption guides, rationale), the v1.0 schema line (5 schemas), templates, two reference examples, and the reference SDKs (leji on npm and PyPI, the Go module packages/sdk-go, create-leji) with validate, index, changelog check, freshness, conformance, init, and docs, all at 1.0.0.", - "paths": [ - "spec/", - "schemas/", - "templates/", - "examples/", - "adoption/", - "rationale/", - "packages/sdk/", - "packages/sdk-py/", - "packages/sdk-go/", - "packages/create-leji/", - "fixtures/" - ], - "proposedBy": "Vuong Nguyen", - "approvedBy": "Vuong Nguyen" - }, - { - "id": "release-1-1-0", - "date": "2026-06-18", - "type": "added", - "summary": "Agent onboarding for the reference SDKs (spec and schemas unchanged, still the v1.0 line; all three SDKs to 1.1.0, backward compatible): leji adopt brings an existing repository into a context layer; leji detect and init --agent wire a coding-agent redirect; init writes an onboarding brief and --dry-run previews writes; validate --content and conformance --explain report what is unfinished; leji changelog compact folds old entries; undeclared machine paths resolve to rootPath defaults for a minimal leji.json.", - "paths": [ - "templates/", - "packages/sdk/", - "packages/sdk-py/", - "packages/sdk-go/", - "packages/create-leji/", - "scripts/", - "fixtures/" - ], - "proposedBy": "Vuong Nguyen", - "approvedBy": "Vuong Nguyen" - }, - { - "id": "release-1-2-0", - "date": "2026-06-19", - "type": "added", - "summary": "Schema and naming hardening before public launch and adoption, plus new surfaces (spec stays on the v1.0 line; all reference packages to 1.2.0): @leji-org/mcp serves spec/schemas and read-only validate/conformance tools; new leji agent, start, and ci commands (ci targets GitHub, GitLab, CircleCI, or Azure DevOps via --provider); leji viewer / viewer serve / viewer build / view replace leji docs; manifest viewer naming and viewer-export security are finalized while the spec has no public adopters yet.", - "paths": [ - "schemas/", - "templates/", - "packages/sdk/", - "packages/sdk-py/", - "packages/sdk-go/", - "packages/mcp/", - "packages/create-leji/", - "packages/site/", - "scripts/", - ".github/" - ], - "proposedBy": "Vuong Nguyen", - "approvedBy": "Vuong Nguyen" - }, - { - "id": "release-1-3-0", - "date": "2026-07-30", - "type": "changed", - "summary": "Content-model revision and the GA freeze of spec 1.0 (frozen from this release; all reference packages to 1.3.0): categories map to curated index files instead of content paths, every governed document is intent or a record, federation moves to resolver-hydrated pinned mounts whose projection is the closure of the sibling manifest's readable surface (status reports self-projectability), a task path selects and signals rather than expands, conformance reports four distinct outcomes and judges the directory it is given, and an optional actors registry binds a role to several participants with a command per role under one prompt-placeholder template rule. Migration from 1.2: replace categories..paths with categories..indexes pointing at index files that declare the old paths in leji-index blocks.", - "paths": [ - "spec/", - "schemas/", - "packages/sdk/", - "packages/sdk-py/", - "packages/sdk-go/", - "packages/mcp/", - "packages/create-leji/", - "templates/", - "fixtures/", - "scripts/", - "adoption/", - "docs/" - ], - "proposedBy": "Vuong Nguyen", - "approvedBy": "Vuong Nguyen" - } - ] + "$schema": "https://leji.org/schemas/v1.0/context-changelog.schema.json", + "schemaVersion": "1.0", + "entries": [ + { + "id": "release-1-0-0", + "date": "2026-06-12", + "type": "added", + "summary": "Initial public release: specification 1.0.0 (9 normative documents, adoption guides, rationale), the v1.0 schema line (5 schemas), templates, two reference examples, and the reference SDKs (leji on npm and PyPI, the Go module packages/sdk-go, create-leji) with validate, index, changelog check, freshness, conformance, init, and docs, all at 1.0.0.", + "paths": [ + "spec/", + "schemas/", + "templates/", + "examples/", + "adoption/", + "rationale/", + "packages/sdk/", + "packages/sdk-py/", + "packages/sdk-go/", + "packages/create-leji/", + "fixtures/" + ], + "proposedBy": "Vuong Nguyen", + "approvedBy": "Vuong Nguyen" + }, + { + "id": "release-1-1-0", + "date": "2026-06-18", + "type": "added", + "summary": "Agent onboarding for the reference SDKs (spec and schemas unchanged, still the v1.0 line; all three SDKs to 1.1.0, backward compatible): leji adopt brings an existing repository into a context layer; leji detect and init --agent wire a coding-agent redirect; init writes an onboarding brief and --dry-run previews writes; validate --content and conformance --explain report what is unfinished; leji changelog compact folds old entries; undeclared machine paths resolve to rootPath defaults for a minimal leji.json.", + "paths": [ + "templates/", + "packages/sdk/", + "packages/sdk-py/", + "packages/sdk-go/", + "packages/create-leji/", + "scripts/", + "fixtures/" + ], + "proposedBy": "Vuong Nguyen", + "approvedBy": "Vuong Nguyen" + }, + { + "id": "release-1-2-0", + "date": "2026-06-19", + "type": "added", + "summary": "Schema and naming hardening before public launch and adoption, plus new surfaces (spec stays on the v1.0 line; all reference packages to 1.2.0): @leji-org/mcp serves spec/schemas and read-only validate/conformance tools; new leji agent, start, and ci commands (ci targets GitHub, GitLab, CircleCI, or Azure DevOps via --provider); leji viewer / viewer serve / viewer build / view replace leji docs; manifest viewer naming and viewer-export security are finalized while the spec has no public adopters yet.", + "paths": [ + "schemas/", + "templates/", + "packages/sdk/", + "packages/sdk-py/", + "packages/sdk-go/", + "packages/mcp/", + "packages/create-leji/", + "packages/site/", + "scripts/", + ".github/" + ], + "proposedBy": "Vuong Nguyen", + "approvedBy": "Vuong Nguyen" + }, + { + "id": "release-1-3-0", + "date": "2026-07-30", + "type": "changed", + "summary": "Content-model revision and the GA freeze of spec 1.0 (frozen from this release; all reference packages to 1.3.0): categories map to curated index files instead of content paths, every governed document is intent or a record, federation moves to resolver-hydrated pinned mounts whose projection is the closure of the sibling manifest's readable surface (status reports self-projectability), a task path selects and signals rather than expands, conformance reports four distinct outcomes and judges the directory it is given, and an optional actors registry binds a role to several participants with a command per role under one prompt-placeholder template rule. Migration from 1.2: replace categories..paths with categories..indexes pointing at index files that declare the old paths in leji-index blocks.", + "paths": [ + "spec/", + "schemas/", + "packages/sdk/", + "packages/sdk-py/", + "packages/sdk-go/", + "packages/mcp/", + "packages/create-leji/", + "templates/", + "fixtures/", + "scripts/", + "adoption/", + "docs/" + ], + "proposedBy": "Vuong Nguyen", + "approvedBy": "Vuong Nguyen" + }, + { + "id": "release-1-3-1", + "date": "2026-07-31", + "type": "fixed", + "summary": "Patch release: two Windows-only viewer defects (POSIX route keys, docs-root casing as named on disk) and a protocol-relative request fix, plus the federation guide and a documentation pass; no specification, schema, or manifest change; all reference packages move to 1.3.1 together.", + "paths": [ + "packages/sdk/", + "packages/sdk-py/", + "packages/sdk-go/", + "packages/site/", + "adoption/", + "fixtures/" + ], + "proposedBy": "Vuong Nguyen", + "approvedBy": "Vuong Nguyen" + }, + { + "id": "release-1-4-0", + "date": "2026-08-21", + "type": "added", + "summary": "The OSS feature program: leji export, leji badge, mounts update-pin, rendering parity, one root .leji/ layout, ecosystem-aware adoption, the pinned-CLI hand-off, the start preflight, grouped help, and the Contexing stewardship disclosure; no normative specification or schema change; all packages move to 1.4.0 together.", + "paths": [ + "packages/sdk/", + "packages/sdk-py/", + "packages/sdk-go/", + "packages/mcp/", + "packages/create-leji/", + "packages/site/", + "templates/", + "fixtures/", + "adoption/", + "docs/", + "scripts/", + ".github/" + ], + "proposedBy": "Vuong Nguyen", + "approvedBy": "Vuong Nguyen" + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index d38da16..5421579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +## 1.4.0 · 2026-08-21 + +The OSS feature program: export, badge, pin updates, rendering parity, a unified `.leji/` +layout, ecosystem-aware adoption, and the hand-off that keeps every clone on one CLI +version. No normative specification or schema change; the spec stays on the frozen v1.0 line and all +reference packages move to 1.4.0 together. + +### Added + +- **`leji export`** generates the complete, self-contained static site from a context + layer: the same content the local viewer serves, hostable on any static host, subpaths + included, no build step and no network path. `leji viewer build` is a co-equal name for + the same operation. Output lands in `.leji/dist/`; a consolidated + `third-party-licenses.txt` ships with every generated tree. +- **`leji badge`** emits a deterministic, self-attested conformance badge: the SVG is + scored locally by `leji conformance`, byte-identical on rerun, with one markdown line + that embeds it. The badge face reads `Leji 1.0 · `; the full self-attestation + claim rides the SVG title, its accessible label, and the markdown alt text, and the + linked page explains it. No registry, no endpoint, no account. +- **`leji mounts update-pin`** completes the federation lifecycle: it moves a declared + mount pin with the witness comparison in hand, fast-forward by default with a narrow + audited override, fetching from the declared source only. +- **Rendering parity**: the supported markdown subset is documented at + `adoption/rendering.md`, export lints the out-of-subset constructs, and rendering + fixtures (sample repos with canonical golden trees) join the shared suite so any + renderer can verify against identical expectations. +- **Ecosystem-aware adoption**: `leji init` and `leji adopt` detect the repository's + package manager (npm, pnpm, yarn, bun; uv, poetry, pdm, pipenv, pip; Go 1.24 tools) and, + on your explicit consent, run that manager's own add command so the Leji CLI is declared + as a tracked dev dependency and a clean install brings it. Hooks and generated CI run + the CLI through the manager the repository actually uses. +- **The pinned-CLI hand-off**: inside a repository that declares the Leji CLI and has it + installed, the installed Node and Python executables run that copy for every + invocation, so a person typing `leji`, the hooks, CI, and every teammate use one + version. Eligibility is decided on verified evidence only; `LEJI_NO_LOCAL` (any value) + opts out; the Go CLI does not hand off (use `go tool leji`). Note for Python upgraders: + an already-installed console script gains the hand-off after a reinstall, which rebakes + the entry point. +- **`leji start` preflight**: on an adopted repository, `start` first prints a terse Setup + block (the repository's CLI, your agent's MCP registration, the team `.mcp.json`, the + git hook) with `ok` / `you` / `team` ownership words, exact fix commands, and TTY-only + status color, then offers the personal repairs and launches. `--json` gives the same + checks as one scriptable document. +- **Grouped CLI help** generated from one shared description in all three SDKs, with an + agent-ready page at leji.org linked from every badge. +- **`create-leji` is a one-time smart bootstrap**: `npm create leji` routes a new + repository to `init` and an adopted one to `adopt`, with honest scaffold starters. + +### Changed + +- **Contexing, LLC is disclosed as steward.** `GOVERNANCE.md` names the steward and its + independence commitments, the Trust page carries the stewardship story, and the + trademark policy is published at `/trademark/`; contributor terms with DCO sign-off + land alongside. Vuong Nguyen remains creator and editor; conformance requires no + steward product or service, as before. +- **One `.leji/` directory** at the repository root with role subdirectories (`mounts/`, + `viewer/`, `dist/`, `work/`); the mounts cache is structurally unservable and + unexportable. +- **Write-path hardening**: every user-influenceable write in the three SDKs goes through + a chokepoint that judges root containment and the `.leji/` role rule immediately before + the act; the check-before-act contract is documented at `docs/practice/trust-boundary.md`. +- **Viewer accent validation** is strict hex (3, 4, 6, or 8 digits). +- The CI matrix adds a Node 22/24 leg and a named Windows regressions job. + +### Fixed + +- **In-page relative links stay inside the viewer's router** instead of escaping to the + server, live and static-exported alike. +- **Relative image paths in governed markdown resolve against their document**, raw-HTML + `img` sources included, with containment enforced at render time. +- **`leji start` explains an `agents.default` binding at bind time** (`leji agent` teaches + the boot semantics; a binding alone never loads a profile, per spec rule 2). +- **`--check-integrity` help and behavior agree**: verification stages in the OS temp + directory, exactly as the help text says. + ## 1.3.1 · 2026-07-31 A patch release for two Windows-only defects, plus a documentation pass. No specification, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4909bb9..d7d8a7e 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.3.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. +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. ## Development setup @@ -24,6 +24,34 @@ 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. -- **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. The Go SDK builds with Go 1.23+; `gofmt`, `go vet ./...`, and `go test ./...` must pass. +- **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. - **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. + +## Contributor terms + +These terms exist so that everyone, including you, knows what happens to a contribution. They are deliberately light: **you keep the copyright in what you write**, and there is no contributor license agreement to sign. + +**Sign off your commits (DCO).** Every commit in a pull request carries a `Signed-off-by` line: + +```bash +git commit -s -m "fix: correct the federation pin example" +``` + +The line certifies the Developer Certificate of Origin, version 1.1, whose canonical text is published at [developercertificate.org](https://developercertificate.org/): that you wrote the contribution, or have the right to submit it under the license below, and that you understand the contribution and the sign-off are public and permanent. Sign off with the name you are known by and an address you can be reached at; anonymous contributions cannot be accepted. A CI check verifies the sign-off on every commit in a pull request, and the pull request template asks you to acknowledge these terms. + +At least one of the DCO 1.1 certifications must truthfully apply to every part of what you submit. The certificate covers material you created, material you took under an appropriate license, and material another person provided to you, and it treats each of those differently. Say where substantial copied or assistant-generated material came from, in the pull request. + +**Your contribution ships under the license its content type already uses**, the same split that governs everything in this repository apart from the logo assets, which sit outside both licenses ([LICENSE.md](LICENSE.md)): + +- Code, schemas, templates, and examples: **Apache-2.0**, including its patent grant. +- Specification prose, rationale, adoption guides, and repository documentation: **CC-BY-4.0**, with attribution. + +Submitting a pull request means you license your contribution under whichever of the two applies to the content you wrote. Where one file mixes both, code blocks, schemas, and examples are Apache-2.0 wherever they appear, and the prose around them is CC-BY-4.0. You also license your contribution under the other of the two licenses, to the extent it is later moved across that boundary, so moved content stays licensed where it lands with no further grant, and its attribution travels with it. Nothing is assigned to the steward, and no separate copyright grant is asked for. + +In return, the copyright licenses you grant are irrevocable, subject to their own conditions. Your contribution stays available under the license it landed under, and no steward, this one or a later one, can withdraw that grant. The patent promise below carries its own stated termination and is the one exception. Relicensing contributor-owned material under different terms would take each contributor's permission, or the replacement of their work. + +**A patent promise for normative text.** If you contribute text that becomes a normative requirement of the specification, you promise not to assert any patent claim you own or control that is necessarily infringed by implementing that contribution. The promise is royalty-free and runs to everyone. It ends only for a party that asserts a patent claim against an implementation of the specification. Anyone who later acquires the patent claims it covers takes them subject to it. + +**Attribution.** Contributors are credited in the repository's history, which is the record. Specification prose carries the editor line and the project attribution, not per-section credits, because the specification is read as one document. By contributing, you agree that credit in the repository history together with the project attribution satisfies CC-BY-4.0 attribution for your contribution, both in this repository and in distributed renderings, and that distributed renderings of the specification carry the project attribution, a link to the repository's contributor history, and the license notice. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index d3f3d0c..02dc9ae 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,21 +1,37 @@ # Governance -Leji is an open specification. Its goal is to be a neutral, vendor-agnostic standard for the shared context layer of AI-native teams. This document says who maintains it, how it changes, and the commitments that keep it neutral. +Leji is an open specification. Its goal is to be an open, vendor-agnostic standard for the shared context layer of AI-native teams. This document says who maintains it, how it changes, and the commitments that keep conformance independent of any product built on it. ## Stewardship -Leji was created by Vuong Nguyen. Meteor Dreams, LLC is the current steward: it maintains the specification, the schemas, the reference tooling, and this repository, and it reviews proposals. +Leji was created by Vuong Nguyen. Contexing, LLC is the steward: it maintains the specification, the schemas, the reference tooling, and this repository, and it reviews proposals. Contexing also builds and sells commercial products on Leji; the independence commitments below are what keep the standard independent of those products. -The steward role exists to keep the standard coherent, not to control who uses it. It is designed to be transferable to a neutral foundation or a multi-party maintainer group as adoption warrants. The permissive licenses and this document exist so that such a transfer changes nothing for adopters. +The steward role exists to keep the standard coherent, not to control who uses it. What protects adopters is not trust in the steward's intentions: the complete specification and tooling are openly licensed, run without any steward service, require no steward endpoint, permit competing implementations and commercial services, and can be forked. -## Neutrality commitments +## Independence commitments -These are the commitments that make "neutral standard" more than a label: +These commitments are checkable in public, and they are what keep conformance independent of any product, including the steward's own. - **No commercial gate.** Conformance never requires a commercial product or service. Everything needed to build, validate, and conform to a context layer is in this repository under open licenses. -- **No privileged conformance implementation.** The first-party SDKs and CLI are Leji's primary reference tooling and default adoption path. They are maintained here and tested against the shared fixtures. Conformance is determined by the specification, schemas, and public conformance checklist, not by using those tools. An independent conformant implementation is valid Leji. +- **No required endpoint.** Mounts and federation resolve against any origin the adopter names. No part of the specification requires, defaults to, or privileges an endpoint operated by the steward. +- **No privileged conformance implementation.** The first-party SDKs and CLI are Leji's primary reference tooling and default adoption path. They are maintained here and tested against the shared fixtures. Conformance is determined by the specification, schemas, and public conformance checklist, not by using those tools. An independent conformant implementation is a valid implementation of Leji. - **No conformance advantage.** The steward will not shape the specification, schemas, conformance levels, or governance process to require or favor its own products or services over independent implementations. The steward may build, promote, and sell first-party tooling and services, but Leji conformance is judged by the spec and conformance checklist, not by who built the tool. -- **Self-attestation, no authority.** A team claims its conformance level in its own manifest, and any conformant tool can check the claim. There is no certification program, registry, gatekeeper, or fee. See [spec/conformance.md](spec/conformance.md). +- **Self-attestation, no authority.** A team claims its conformance level in its own manifest, and tools can check every requirement a machine can check and report the rest, which the team stands behind. There is no certification program, registry, gatekeeper, or fee. See [spec/conformance.md](spec/conformance.md). +- **Spec issues are filed publicly.** Problems with the specification, schemas, or fixtures that the steward finds while building its commercial products are filed as public issues in `leji-org`, never in a private tracker. Responsibly embargoed security reports and legally restricted matters are the one exception, and they are filed publicly once the constraint lifts. +- **Information parity.** Conformance questions, fixture changes, and spec decisions happen in public. The steward's commercial products get no private interpretation channel, no early access to spec decisions, and no release-timing advantage: no spec decision or interpretation is relied on by a steward product before it is public. An independent implementer, including a direct competitor, works from the same public record. + +## Assets and succession + +The steward holds the operated assets the specification depends on. These transfer with maintainership: + +- the domains `leji.org`, `leji.dev`, `leji.io`, and `leji.to` +- the Leji name and logo +- the `leji-org` GitHub organization +- `security@leji.org` + +The commitments in this document travel with those assets: any successor steward, including an acquirer of the name, inherits them as the project's governing policy. What makes them stick is not this document alone. The specification, the schemas, and the reference tooling are already licensed to everyone, and anyone may fork them, so a successor that abandoned these commitments would be leaving the project rather than taking it. + +The steward's commercial product assets are held separately and do not transfer with maintainership of the specification. A successor steward receives the specification, the name, and the canonical addresses above without them. ## Independent implementations @@ -25,6 +41,10 @@ You may implement Leji in any language or tool. Building on the schemas and SDK The specification and code are open; the name is how people find the real thing. You may state that a tool "supports Leji" or "conforms to Leji 1.0" when it does. Please don't use the name in a way that implies official endorsement, or that presents a fork or derivative as the canonical Leji. Honest "conforms to" and "compatible with" claims are always fine. +You may build, sell, and promote independent commercial products and services that implement Leji, including products competing directly with the steward's. No permission, license, or notification is required. + +Full terms, including what a fork may call its binary and what the logo requires: [the trademark and usage policy](https://leji.org/trademark/). + ## How the specification changes Changes happen by proposal, in the open: @@ -33,8 +53,16 @@ Changes happen by proposal, in the open: 2. Propose the change as a pull request against `spec/`, and the schemas where the machine-readable surface is affected. 3. Normative changes carry a changelog entry and a version bump under the spec's own [versioning rules](spec/versioning.md). -Decisions are recorded where the discussion happened, in the issue or pull request, and in a decision record when the change is architectural. A proposal the steward declines remains publicly documented with the reasoning recorded; it may be closed as declined rather than removed or quietly buried. +Decisions are recorded where the discussion happened, in the issue or pull request, and in a decision record when the change is architectural. A proposal the steward declines remains publicly documented with the reasoning recorded; it may be closed as declined rather than removed or quietly buried. One narrow exception, stated in advance: a matter under security embargo or legal constraint is recorded with its reasoning once the constraint lifts. + +## Steward proposals and conflicts of interest + +The steward also builds commercial products on Leji, so some of its own proposals touch areas those products depend on. Those proposals are labeled and slowed down, publicly: + +- A steward-authored proposal touching an area a steward product depends on is labeled `steward-proposal` on the issue and the pull request, and says which product interest it touches. +- The ordinary comment period for a normative proposal is 14 days. A labeled steward proposal stays open for 28 days instead, and is not merged before that period ends. +- The label and the dates are visible in public history, which is what makes "no conformance advantage" checkable rather than asserted. ## Decision-making, today and later -Today Meteor Dreams is the steward and the final decision-maker, on the record. The steward intends to broaden governance as adoption and contributor capacity warrant, potentially through a neutral foundation or a named multi-party maintainer group. Any such transition should preserve the open licenses, the public process, and the conformance neutrality described here. +Contexing is the steward and final decision-maker, on the record. The steward may broaden governance to a named multi-party maintainer group as contributor capacity warrants. Any such change preserves the open licenses, the public process, and the independence commitments above. The Leji name remains with the steward. diff --git a/LICENSE.md b/LICENSE.md index 1fae5ef..0738b49 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,18 +1,22 @@ # License -Copyright © 2026 Meteor Dreams, LLC. Leji was created by Vuong Nguyen. +Copyright © 2026 Contexing, LLC. Leji was created by Vuong Nguyen. Leji is licensed under two licenses by content type: -- **Code, SDK, JSON Schemas, templates, and examples** (`packages/`, `schemas/`, `templates/`, `examples/`, `site/` source): [Apache License 2.0](LICENSES/Apache-2.0.txt). +- **Code, SDK, JSON Schemas, templates, and examples** (`packages/`, `schemas/`, `templates/`, `examples/`, `packages/site/` source): [Apache License 2.0](LICENSES/Apache-2.0.txt). - **Specification prose and rationale** (`spec/`, `rationale/`, `adoption/`, and this repository's documentation): [Creative Commons Attribution 4.0 International](LICENSES/CC-BY-4.0.txt). Suggested attribution for CC-BY-4.0 prose: ```text -"Leji" by Vuong Nguyen, © 2026 Meteor Dreams, LLC, licensed under CC BY 4.0, https://creativecommons.org/licenses/by/4.0/, https://leji.org. +"Leji" by Vuong Nguyen, © 2026 Contexing, LLC, licensed under CC BY 4.0, https://creativecommons.org/licenses/by/4.0/, https://leji.org. ``` This is a suggested credit, not an additional condition; any reasonable attribution satisfying CC-BY-4.0 is acceptable. If a file doesn't state otherwise, the mapping above applies. Quote, translate, and adapt the specification freely with attribution; build tooling on the schemas and SDK under Apache-2.0 terms, including its patent grant. + +## Trademark and logo + +The Leji name and the Leji logo are trademarks of Contexing, LLC. The licenses above do not extend to the logo asset files. You may reproduce, distribute, and display them unmodified, solely as integral parts of unmodified builds of the software and of faithful, unmodified redistributions of the official artifacts; any other use as branding needs written permission. See https://leji.org/trademark/ for the usage policy. diff --git a/README.md b/README.md index 0c309dd..9818a5f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Leji [![CI](https://github.com/leji-org/leji/actions/workflows/ci.yml/badge.svg)](https://github.com/leji-org/leji/actions/workflows/ci.yml) +[![Leji 1.0 · governed · self-attested](leji-badge.svg)](https://leji.org/agent-ready/) **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.3.1.** The reference SDKs (`@leji-org/leji` on npm and JSR, `leji` on PyPI, and the Go module) are at 1.3.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). +> **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). ## Principles @@ -26,7 +27,7 @@ The name is the thesis: the context layer makes a team's operating context **leg | `rationale/` | Non-normative: why a circle, why intent, why this is not a wiki | | `packages/sdk`, `packages/sdk-py`, `packages/sdk-go` | The reference SDKs and CLI (npm, PyPI, Go), behaviorally identical and tested against the shared `fixtures/`: validate, index, changelog, freshness, conformance, init | | `packages/mcp` | The MCP server (`@leji-org/mcp`): the spec, schemas, validation, and conformance as read-only tools for coding agents | -| `packages/create-leji` | `npm create leji`: scaffolds a context layer (a thin shim over the SDK's `init`) | +| `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) | ## License @@ -35,4 +36,4 @@ Code, schemas, templates, and the SDK: Apache-2.0. Specification prose and ratio ## Governance -See [GOVERNANCE.md](GOVERNANCE.md). Leji was created by [Vuong Nguyen](https://vuongnguyen.com); [Meteor Dreams](https://meteordreams.com) is the current steward. +See [GOVERNANCE.md](GOVERNANCE.md). Leji was created by [Vuong Nguyen](https://vuongnguyen.com); [Contexing, LLC](https://contexing.com) is the steward. diff --git a/RELEASING.md b/RELEASING.md index d8614c5..81ac466 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,6 +7,21 @@ three SDK suites. Releases are tag-driven: one shared `release.yml` dispatches o each per-package tag and runs only that package's publish job (see the tagging model below). +## How changes reach main + +Every change lands on `main` by pull request; direct pushes are blocked by ruleset for +everyone, maintainers included. Branch names follow `feat/*`, `chore/*`, `fix/*`, +`context/*`, `release/*`, or `rc/*`. Maintenance PRs squash-merge; a release PR carries one +commit for the whole release, signed off for DCO, and is rebase-merged. Rebase-merging +writes a new sha, so `main` gets a different commit object with the same content: its +tree is byte-identical to what CI proved on the `rc/*` candidate branch, verified at +merge, and the message, the `Signed-off-by` line, and the authorship carry over, while +the committer becomes GitHub. Release candidates push to `rc/*`, which exists only for +that proof. Every commit carries a DCO `Signed-off-by` line (see CONTRIBUTING.md, +"Contributor terms"); the required status checks are the full CI matrix. A PR normally +merges only when they are green; a maintainer can merge past a failing check as a +recorded exception, and direct pushes stay blocked either way. + ## Before tagging 1. `npm run assets`: re-vendor schemas/templates/cli.json into every SDK. @@ -41,11 +56,11 @@ publish is irreversible. | Tag | Publishes | |---|---| -| `packages/sdk/v1.3.1` | npm `@leji-org/leji` **and** JSR `@leji-org/leji` (one tag, two jobs) | -| `packages/create-leji/v1.3.1` | npm `create-leji` | -| `packages/sdk-py/v1.3.1` | PyPI `leji` | -| `packages/sdk-go/v1.3.1` | Go module index + goreleaser binaries | -| `packages/mcp/v1.3.1` | npm `@leji-org/mcp` | +| `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` | 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 @@ -55,14 +70,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.3.1 && git push origin packages/sdk/v1.3.1 -npm view @leji-org/leji version # must print 1.3.1 before continuing - -# 2. Only after @leji-org/leji@1.3.1 is live on npm: -git tag packages/sdk-py/v1.3.1 && git push origin packages/sdk-py/v1.3.1 -git tag packages/sdk-go/v1.3.1 && git push origin packages/sdk-go/v1.3.1 -git tag packages/create-leji/v1.3.1 && git push origin packages/create-leji/v1.3.1 -git tag packages/mcp/v1.3.1 && git push origin packages/mcp/v1.3.1 +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 ``` ## Finalize: publish the Go binaries (required) @@ -73,7 +88,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.3.1`). It publishes +give it the Go tag as `release_tag` (e.g. `packages/sdk-go/v1.4.0`). It publishes the draft release and enables Discussions. Confirm the release is no longer marked draft before announcing. @@ -101,8 +116,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.3.1`); a plain `v1.3.1` will **not** make -`go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@v1.3.1` resolve. +(`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. 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 47de4ac..d8a3314 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,5 @@ # Security -Leji is a specification plus a local SDK that runs on your machine. There is no hosted service, no account, and no telemetry: nothing reports back. `leji viewer serve` starts a local HTTP server on the loopback interface, and the federation commands you invoke deliberately (`leji mounts hydrate --fetch`, `leji conformance --federation=verify`) contact the repository you named. +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. diff --git a/adoption/README.md b/adoption/README.md index 4a9a82e..6747ca6 100644 --- a/adoption/README.md +++ b/adoption/README.md @@ -19,6 +19,10 @@ leji init # new repo: scaffold leji.json, a boot profile, categ leji adopt --wire-adapters # finish an adoption over a vendor entrypoint (CLAUDE.md, AGENTS.md) ``` +Or, in one step with nothing installed: `npm create leji` reads the directory and runs `leji adopt` +here, or `leji init` on a repository with nothing to adopt. It replaces this step rather than +preceding it, so take it and continue at the next heading. + Map lived paths instead of renaming them. `docs/engineering/START-HERE.md` conforms as a boot profile; new layers should use the lowercase-kebab defaults. @@ -65,10 +69,29 @@ At `indexed`, `leji index` generates `context-index.json`; `leji index --check` Without CI conventions, `leji ci` generates a workflow; `leji ci --hooks` installs the gates as a pre-commit. `leji ci --help` explains CLI resolution. -In an established pipeline, run `leji validate` and `leji index --check` in required jobs and hooks. Pin `@leji-org/leji` as a lockfile devDependency. +In an established pipeline, run `leji validate` and `leji index --check` in required jobs and hooks. Declare the CLI as a dev dependency so a clean install brings it: `leji init`/`leji adopt` detect the package manager this repository uses and, on your explicit yes, run its own add command (pip and pre-1.24 Go get the printed line instead). At `governed`, add reviewed changes, valid agent profiles, freshness checks, and required CI. +### Show your conformance + +`leji badge` writes `leji-badge.svg` at the repository root and prints the line to paste into your README: + +```bash +leji badge # write leji-badge.svg and print the snippet +leji badge --out docs/badge.svg # somewhere else; the snippet follows the path +``` + +```markdown +[![Leji 1.0 · governed · self-attested](leji-badge.svg)](https://leji.org/agent-ready/) +``` + +The badge is self-attested and honest about this run: it states the level `leji conformance` verified, which is never above what `leji.json` claims and is sometimes below it. A claim the offline run could not confirm is named on stdout rather than badged. + +Run it on a committed tree. An uncommitted changelog leaves the `indexed` check unverifiable, so a working copy that has not been committed badges `core` whatever it claims. + +The snippet's image path is relative to the repository root. A README in a subdirectory needs the path adjusted to reach the file from there. + ## 05Where it lives @@ -123,7 +146,7 @@ leji viewer serve # localhost preview at http://127.0.0.1:5354/ leji viewer build # export a self-contained static folder for internal hosting ``` -`serve` is not hosting. Publish only for the context layer's audience. Governed H1s provide navigation; manifest `viewer` fields provide branding and pins. MkDocs can use the index. See the [machine-readable surface specification](../spec/machine-readable-surface.md). +`serve` is not hosting. Publish only for the context layer's audience. The build writes inside the repository (`.leji/dist/` by default, or a `--out` path within it) and the output folder is yours: copy it wherever your host reads from. Governed H1s provide navigation; manifest `viewer` fields provide branding and pins. MkDocs can use the index. See the [machine-readable surface specification](../spec/machine-readable-surface.md). diff --git a/adoption/rendering.md b/adoption/rendering.md new file mode 100644 index 0000000..6853f52 --- /dev/null +++ b/adoption/rendering.md @@ -0,0 +1,167 @@ +# Rendering a Leji context layer + +A Leji context layer is markdown, and markdown renderers disagree. This document +fixes which constructs a context layer may rely on, so that a document written +once reads the same in the reference viewer, on a git host, in an editor preview, +and in any other renderer that claims Leji view compatibility. + +## What this document is + +It is the **compatibility profile** for rendering: binding on any renderer that +claims to display a Leji context layer faithfully, and on any tool that generates +documents into one. + +It is **not** part of the specification. It does not live in `spec/`, it defines +no conformance requirement, and it changes nothing about what `leji conformance` +reports. Conformance never requires rendering at all: a context layer is read by +people in an editor, by agents through the file system, and only sometimes by a +browser. What this profile governs is the compatibility claim, not the standard. + +Two audiences use it. A **writer** learns which constructs are safe. A **renderer +author** learns what to support, what to leave alone, and what a Leji context +layer does not depend on. + +## The supported subset + +### CommonMark core + +| Construct | Notes | +| --- | --- | +| ATX headings (`#` to `######`) | the H1 of a document is its title in generated navigation | +| Setext headings (`===`, `---` underlines) | equivalent to ATX levels one and two | +| Paragraphs and soft breaks | a single newline inside a paragraph is a space | +| Hard breaks | two trailing spaces, or a trailing backslash | +| Emphasis and strong emphasis | both the `*` and `_` spellings | +| Inline code spans | including the multiple-backtick form | +| Links | inline, reference, and relative paths between documents | +| Images | relative paths, resolved against the document that carries them | +| Blockquotes | including nested quotes | +| Lists | ordered, unordered, nested, tight and loose | +| Fenced code blocks | with or without an info string | +| Indented code blocks | four spaces | +| Thematic breaks | `---`, `***`, `___` | + +### GFM extensions + +| Construct | Notes | +| --- | --- | +| Tables | with per-column alignment; a leading empty header row is the metadata-block convention | +| Strikethrough | `~~text~~` | +| Task lists | `- [ ]` and `- [x]` | + +Everything else is outside the subset. Being outside it is not a prohibition, and +nothing rejects a document for it. It means the rendering is the renderer's +choice, so a context layer that depends on it reads differently for different +readers. + +## Leji semantics + +Five behaviors belong to Leji rather than to markdown, and only this document +states them. + +**YAML frontmatter is metadata and is never rendered.** Agent profiles and +decision records carry frontmatter as their machine contract. A renderer strips a +leading `---` block and shows the body; the first visible element of such a +document is its heading. Only a leading block is frontmatter: a `---` line later +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. + +**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. +Highlighting it or leaving it plain are both fine. What matters is that the bytes +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 +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. + +**A fence whose info string is outside the renderer's highlight set renders as +unhighlighted code, and that is conforming.** The reference viewer vendors a small +set (`bash`, `json`, `markdown`, `typescript`, plus what the vendored highlighter +carries by default). A fence tagged with anything else, `toml` or `zsh` or a +language nobody highlights, still renders as a code block. Highlighting is +decoration, so no context layer depends on it and no renderer owes any particular +set. + +## Outside the subset, and reported + +Three constructs are outside the subset and are reported by `leji export`, which +walks every markdown document the export carries: + +| Construct token | What it matches | +| --- | --- | +| `raw-html` | raw HTML elements, in block or inline position; comments are excepted | +| `footnote` | footnote definitions and references (`[^id]:` and `[^id]`) | +| `math-block` | a paired `$$` display-math delimiter | + +Each is reported as a `render-unsupported` finding at `warning` severity, with the +document path, the line the construct opens on, and the construct token. Warnings +never gate a run: the export is written and the command exits `0`, because a +context layer's build does not break on prose. `leji export --strict` promotes any +such finding to a failing exit and writes no export, which is the form for a +pipeline that wants the profile enforced. + +### Why only these three + +Each of the three is **mechanically detectable** and **genuinely divergent**. +Detectable means a small scanner finds it with no false alarms once code spans, +fenced blocks, and frontmatter are excluded. Divergent means renderers really do +disagree: raw HTML is passed through by some renderers, sanitized by others, and +stripped by the rest, and the sanitizing ones disagree about which elements and +attributes survive; footnote syntax is neither CommonMark nor GFM core, so it +becomes a linked marker in one renderer and literal brackets in another; `$$` math +needs a math runtime, and a renderer without one shows the delimiters. + +Three constructs the profile deliberately does **not** report: + +- **Inline `$`.** A currency amount and a shell variable both spell it. Detection + would be ambiguous, so the report would be noise. +- **Unknown fence info strings.** The fallback is already conforming, as stated + above, so there is nothing to warn about. +- **Definition-list and other loosely conventional prose shapes.** No agreed + syntax exists to detect, and the plain-paragraph fallback reads fine. + +A report that fires on the ambiguous cases gets switched off, and then it protects +nothing. The closed set of three is what keeps it worth reading. + +## The fixtures are the enumeration + +Prose fixes the intent; the shared fixtures fix the edges. Three of them carry +this profile: + +- `fixtures/valid-render-subset` is a sample context layer exercising every + construct named above, one document per family. Its exported bytes are the + canonical served form, so an independent renderer can read exactly what the + reference viewer reads. They are committed with the fixture: the content tree as + real bytes under `expected-export/`, the chrome and vendored assets as sha256 + digests in `expected-export.manifest.json`. +- `fixtures/valid-render-lint-unsupported` plants each reported construct at a + known line, together with the boundary cases that must stay quiet: HTML-looking + text inside code spans and fences, an unpaired `$$`, escaped delimiters, and + frontmatter carrying a tag. Its export bytes are committed the same way. +- `fixtures/valid-render-lint-strict` drives the same context layer with + `--strict`, which writes no export, so that fixture has no bytes to pin. + +All three reference SDKs run these fixtures, and they report identically. A +renderer that wants to check itself against this profile finds what it needs in +the same public repository: the sample documents, this document, and the +committed export bytes. + +## Versioning + +This profile travels with the reference tooling that implements it, not with the +specification: the specification stays still while the tooling releases, and this +document and the fixtures beside it state what a given tooling release supports. +A renderer checking itself against the profile therefore checks itself against a +release, and the fixtures in that release are the exact statement. diff --git a/docs/boot-profile.md b/docs/boot-profile.md index 62b55aa..e4fab20 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.3.1. +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. ## Loading diff --git a/docs/context-changelog.json b/docs/context-changelog.json index fbe613e..f1300d0 100644 --- a/docs/context-changelog.json +++ b/docs/context-changelog.json @@ -270,6 +270,57 @@ ], "proposedBy": "agent:claude", "approvedBy": "Vuong Nguyen" + }, + { + "id": "ts-first-codified", + "date": "2026-08-09", + "type": "changed", + "summary": "TS-first porting practice codified: LIVE section cross-links the CONTRIBUTING Tooling rule.", + "paths": [ + "docs/practice/testing-cli-adoptions.md" + ] + }, + { + "id": "trust-boundary-recorded", + "date": "2026-08-17", + "type": "added", + "summary": "The CLI trust boundary recorded: containment rule, write chokepoint, verified reads, declared exceptions.", + "paths": [ + "docs/practice/trust-boundary.md" + ] + }, + { + "id": "dependency-declaration-offer", + "date": "2026-08-18", + "type": "changed", + "summary": "Offline invariant restated: the SDK runs your package manager's add command only on your explicit yes at init/adopt.", + "paths": [ + "docs/system/invariants.md" + ] + }, + { + "id": "pinned-cli-handoff", + "date": "2026-08-19", + "type": "changed", + "summary": "Trust boundary: the installed executable hands the whole invocation to the repository's own pinned Leji CLI.", + "paths": [ + "docs/practice/trust-boundary.md" + ] + }, + { + "id": "commercial-hosted-viewer", + "date": "2026-08-20", + "type": "added", + "summary": "Decision 0007: a commercial hosted viewer at leji.ai, with the governance language change and independence commitments that ride it.", + "paths": [ + "docs/decisions/0007-commercial-hosted-viewer.md" + ], + "categories": [ + "decisions" + ], + "decisionRefs": [ + "commercial-hosted-viewer" + ] } ] } diff --git a/docs/context-index.json b/docs/context-index.json index 680af4f..fb5baad 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-07-31T12:23:28.176Z", + "generatedAt": "2026-08-21T13:04:16.912Z", "generator": { "name": "leji", - "version": "1.3.1" + "version": "1.4.0" }, "rootPath": "docs/", "entries": [ @@ -77,6 +77,19 @@ "lastModified": "2026-07-30", "contentHash": "sha256:a233a436f4d7a9d6" }, + { + "id": "commercial-hosted-viewer", + "path": "docs/decisions/0007-commercial-hosted-viewer.md", + "title": "Commercial hosted viewer and governance language change", + "category": "decisions", + "kind": "record", + "date": "2026-08-08", + "lastModified": "2026-08-21", + "contentHash": "sha256:eccda61bc46e9045", + "links": [ + "0004-declare-spec-1-0-ga.md" + ] + }, { "id": "glossary", "path": "docs/domain/glossary.md", @@ -110,12 +123,25 @@ "category": "practice", "kind": "intent", "summary": "The two evidence channels for testing the leji CLI against real repositories, LIVE (linked source) and PACKED (installed artifact), and the discipline for using them.", - "lastModified": "2026-07-30", - "contentHash": "sha256:aefb8ec2bfdb7548", + "lastModified": "2026-08-21", + "contentHash": "sha256:102eb93537b8d33e", "freshness": { "reviewAfter": "2026-12-21" } }, + { + "id": "trust-boundary", + "path": "docs/practice/trust-boundary.md", + "title": "The Trust Boundary", + "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", + "freshness": { + "reviewAfter": "2027-02-17" + } + }, { "id": "invariants", "path": "docs/system/invariants.md", @@ -123,8 +149,8 @@ "category": "system", "kind": "intent", "summary": "The constraints every change to this repository lives with.", - "lastModified": "2026-07-30", - "contentHash": "sha256:fac416a0197fe79b", + "lastModified": "2026-08-21", + "contentHash": "sha256:3c77b60c6e9ff670", "freshness": { "reviewAfter": "2027-06-21" } diff --git a/docs/decisions/0007-commercial-hosted-viewer.md b/docs/decisions/0007-commercial-hosted-viewer.md new file mode 100644 index 0000000..96f8ff6 --- /dev/null +++ b/docs/decisions/0007-commercial-hosted-viewer.md @@ -0,0 +1,44 @@ +--- +id: commercial-hosted-viewer +title: Commercial hosted viewer and governance language change +status: accepted +date: 2026-08-08 +deciders: + - Vuong Nguyen +affectedPaths: + - GOVERNANCE.md + - README.md + - LICENSE.md + - SECURITY.md + - CONTRIBUTING.md + - packages/site/ +affectedCategories: + - governance + - decisions +links: + - 0004-declare-spec-1-0-ga.md +--- + +# Commercial hosted viewer and governance language change + +## Context + +Early adopters are teams introduced through consulting engagements, and adoption stalls at the same point each time. The context layer works for engineers running `leji view` locally, but product managers, designers, support, and compliance staff have no git seat and no terminal. Teams asked for a hosted viewer their whole organization could reach. Their alternative was placing a static build behind a firewall or VPN, which grants everyone inside identical access, provides no per-person audit trail, and adds operational burden they explicitly did not want to take on. + +Authentication, per-person access control, and hosted operations are infrastructure, not format. Building them into the specification would violate the no-required-endpoint commitment and turn a document format into a service dependency. The reference tooling remains local-first and account-free; the user-invoked federation fetches are the recorded exception. + +## Decision + +Contexing, LLC, Leji's steward, is developing LejiAI, a commercial hosted viewer at leji.ai. The specification, schemas, SDKs, CLI, and fixtures remain permissively licensed and fully capable without it. Conformance remains self-attested and free. Competing implementations, including commercial hosted viewers, remain welcome and need no permission. + +## Governance language change + +The Trust page as published at the 1.3.0 GA (2026-07-30) stated an intent to move toward neutral, multi-party governance as adoption grows. That intent assumed no commercial product would carry the Leji name. This record dates from days after GA, before any distribution push and before any external adoption. With a hosted viewer being built at leji.ai, full assignment to a foundation is no longer a promise the steward can keep, so the language now states the durable version: a named steward, a forkable specification, and the name remaining with the steward. Broadening to a named multi-party maintainer group remains open, and `GOVERNANCE.md` says so. + +The same commit series adds the commitments that make the arrangement checkable: no required endpoint, public filing of spec issues found during commercial product work, information parity, and a labeled comment period for steward proposals that touch product territory. + +## Consequences + +The specification has no adoption path into mixed teams without hosted access, and hosted access needs a sustainable operator. Funding stewardship through an optional product, with the free path complete and the commercial boundary written down, was judged more honest than maintaining a governance promise the product forecloses. + +The cost lands on the steward. Every one of those commitments is publicly checkable, and a proposal that quietly favors the product is now a visible break with a written record rather than a matter of interpretation. diff --git a/docs/overview.md b/docs/overview.md index dda25ba..51a0a52 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -15,9 +15,9 @@ flowchart LR boot --> cat_domain cat_system["⚙️ System · 1 doc"] boot --> cat_system - cat_practice["🛠️ Practice · 2 docs"] + cat_practice["🛠️ Practice · 3 docs"] boot --> cat_practice - cat_decisions["🧭 Decisions · 6 docs"] + cat_decisions["🧭 Decisions · 7 docs"] boot --> cat_decisions ``` diff --git a/docs/practice/testing-cli-adoptions.md b/docs/practice/testing-cli-adoptions.md index 64e1a78..d71fd9b 100644 --- a/docs/practice/testing-cli-adoptions.md +++ b/docs/practice/testing-cli-adoptions.md @@ -23,6 +23,8 @@ global `leji` resolves into this checkout and **every rebuild flows through inst the artifact a user would install. - **Use for:** the fix-rebuild-rerun loop while working a problem. +LIVE is also where new behavior settles first: the TypeScript SDK is the canonical implementation, and the Go and Python ports are made only from its settled behavior, pinned by the shared fixtures at port time ([CONTRIBUTING](../../CONTRIBUTING.md), Tooling). + ## PACKED: installed artifact `npm run cli:packed:refresh` cleans `dist/` *and* the tsc buildinfo (a half-clean lets diff --git a/docs/practice/trust-boundary.md b/docs/practice/trust-boundary.md new file mode 100644 index 0000000..135f479 --- /dev/null +++ b/docs/practice/trust-boundary.md @@ -0,0 +1,197 @@ +--- +title: The Trust Boundary +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. +freshness: + reviewAfter: 2027-02-17 +--- + +# The trust boundary + +The `leji` CLI runs on a repository it did not write, over a manifest and a content tree +anyone with commit access can change. Paths in that layer are input, not instruction: a +declared path, a `--out` argument, a directory that turns into a symlink between one +command and the next. This page states what the tool guarantees about the filesystem it +touches, and how each SDK holds the guarantee, so that a reviewer or an independent +implementer reads the same bar the reference implements. + +## Two trust domains + +**Repository content** is yours. The layer's markdown, its manifest, its index and +changelog: the tool reads them, validates them, and rewrites the artifacts it owns. Their +content is never trusted to decide where a write lands. + +**The `.leji/` tree** is the tool's own domain, one directory at the repository root +holding a role per generated thing: `viewer/` (the generated chrome), `dist/` (the default +export output), `work/` (the transient onboarding workspace), and `mounts/` (the private +federation store and projection cache). It is gitignored. Exactly one role is servable, +`viewer/`, which is what the local preview server (`leji view`, `leji viewer serve`) +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 containment rule + +Every write and every clear is judged on the RESOLVED target, immediately before the act: + +1. **Inside the repository, absolutely.** A target that resolves outside the repository + root is refused, with no exceptions and no roles that soften it. A `.leji/dist` or + `.leji/viewer` symlinked out of the tree does not relocate the output; it is refused + and nothing is written through it. The exported folder is yours: copy it wherever your + host reads from. +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. +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 + a symlinked parent is caught before anything is created under it. + +The rule is applied resolved against resolved, so a redirecting symlink, a symlink chain, +or a case-variant spelling such as `.LEJI/` on a case-insensitive filesystem is judged by +where it actually lands, never by how it was written. A refusal is hard: the command +reports it as a usage error, a build error, or an error finding, and nothing is written, +cleared, or renamed. + +## The mechanism + +Two sentences carry it, and every SDK implements both: + +- **Every write and clear**: resolve the target natively, judge the resolved path against + the rule, then act on that resolved path. +- **Every read whose bytes then decide an act**: open a verified descriptor and read from + 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, + the changelog compaction, the vendor entrypoints an adoption archives and rewrites, + and the export marker that authorizes clearing a previous export. + +No pathname existence check decides a write. A command that must know what stands at a +target before writing it uses the verified read: a regular file is compared or merged +from its own bytes, absence is the create path, and a refusal is that command's refusal +or its manual outcome, with nothing written. A command whose semantics is create-if-absent +writes exclusively (`O_EXCL`) through the chokepoint and treats the `exists` verdict as +its skip. A dangling symlink is a standing entry under both forms: never written through, +never read as absent. + +### One chokepoint for writes + +`guardedWrite` is the single place the rule lives. It resolves the target, applies the +rule, and performs the operation on the resolved path only when the target is allowed to +land there; on refusal it returns the verdict (outside the repository, the private role +crossed into, unresolvable) and touches nothing. Commands never spell a raw filesystem +write of their own. They call the guarded conveniences built on the chokepoint: + +| Convenience | What it does | +| --- | --- | +| `writeFileGuarded` | write bytes, with an optional mode and an exclusive (`O_EXCL`) create | +| `mkdirpGuarded` | create a directory and its parents, returning the resolved directory | +| `rmGuarded` | clear a target recursively | +| `renameGuarded` | rename, with BOTH ends judged before either is touched | +| `chmodGuarded` | set a mode | +| `openWriteGuarded` | open a judged destination for writing and hand back the descriptor | +| `writeFileAtomicGuarded` | temp sibling plus rename, both paths judged, wholly inside the helper | + +Parent directories are created only inside a write that actually happens, so a refused run +establishes nothing. A per-file check is not a formality on a tree that was already +judged: a descendant swapped to a symlink after the output directory was established is +caught at the file it would have redirected. + +### Verified reads + +`openVerifiedSource` resolves a source, judges the resolved path, opens it, and proves with +`fstat` that the descriptor is a regular file, then re-resolves and requires the same +location and the same `(device, inode)`. The bytes then come from the inode the check +cleared rather than from a path that could have changed underneath it. + +`verifiedTargetRead` is the read-then-act form, for a command that must look at what is +standing at a target before writing it. The original directory entry decides its own kind +first, so a directory, a socket, a FIFO, or a link to one is refused rather than opened. +Absence is decided on that original entry, never on where it resolves: a dangling symlink +is a standing entry the run could not verify, so it is refused rather than written +through. Operational I/O failures on an allowed path propagate as failures; only +containment, entry kind, and verification become refusals. + +## The declared exceptions + +Each SDK enforces the boundary with a source-audit test. It parses every production source +file and looks for two things: any call into the filesystem's mutation surface (the +synchronous, callback, and promise forms alike, however the module was imported or the +call was spelled), and any call that hands work to a child process, which could write +whatever it likes. Each hit must be allowed by NAMED SYMBOL, with a reason; an allowance +that no longer matches anything fails too, so a stale exception cannot sit unread. + +The allowed write symbols are the chokepoint's own helpers, the copy that writes into a +descriptor `openWriteGuarded` returned, and these three exceptions, which are all of them: + +- **The federation per-entry protocol** (`lib/mounts`): materializing a pinned projection, + publishing a cache entry by rename, and clearing its own staging directory use raw + primitives under a store, cache, or staging root that the chokepoint established and + returned. The protocol has its own closure rules (hashed identities, contained relative + paths, symlink-escape refusals), and it never re-joins a path from the repository root. +- **Root bootstrap** (`init` and `adopt`): creating the directory the user named, which + happens before there is a repository root for the rule to be about. +- **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 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 +directory the chokepoint established. Four allowances are not git: one opens the preview +URL in the desktop browser, two hand the finished scaffold to the agent host the user +selected, by launching it or by running the command it declares, an unrestricted child +process by nature whose writes are that program's rather than this tool's, and the fourth +is the hand-off below. + +**The hand-off to a repository's own CLI.** The installed executable of the Node and +Python CLIs gives the WHOLE invocation to the Leji CLI a repository pins, so that a +teammate, a hook, CI, and a person typing `leji` all run one version of the tool. It +happens only when the repository DIRECTLY DECLARES the CLI in the manifest it commits, +the copy is installed INSIDE the repository (the executable, the package metadata, and +the entry that metadata declares all resolve, through every symlink, within the +repository root), the package identifies itself by name rather than by the spelling of a +directory, and its version meets the minimum the layer's spec line requires; the target +is never reached through a package manager, and the copy is never this running one. That +last check is made on the ENTRY the installed package declares rather than on the shim +that gets executed, because a package manager's shim may be a link to the entry or a +small script that runs it, and only the entry identifies the copy in both shapes. +Anything else hands off nothing and runs the global CLI exactly as before: a MANIFEST +that is refused, unreadable, or missing ends the question there, since it is what +declares the CLI at all. A refused LOCKFILE does not, because a lockfile only names +which manager owns the environment: a candidate whose shape cannot be verified is +ignored rather than fatal, so a repository whose remaining lock family verifies cleanly +still hands off, and one whose evidence is then ambiguous or absent falls to the same +rules as any other. `LEJI_NO_LOCAL`, set to any value, turns it off. + +What that copy then receives is everything: the arguments as given, the full environment, +this terminal, and the working directory, on every invocation including `--version` and +`--help`. It is repository-local code, so this is a real grant, made deliberately and +narrowly, on the same evidence a person would use to run `npx leji` there. The metadata +that decides it is read through the verified read above, and every path is judged +resolved. + +Every byte that SELECTS the copy is read that way, and the two runtimes reach that +differently because they select differently. The Node CLI takes the declaration from the +ordinary ecosystem scan: there the manager is not part of the answer, since the target is +the installed package at a fixed location, so that file is the repository author's +request for the hand-off and nothing more, and a swap between the scan and the verified +reads can only turn the request on or off. The Python CLI cannot say the same, because +its manager names the environment and therefore which console script would run, so it +does not consult the scan at all: it derives BOTH the declaration and the manager from +one verified read of the declaring manifest. The names it lists first decide only which +files it then opens verified, and a lockfile, whose whole evidence is its name, counts +only when that open succeeds, so a name with nothing behind it selects no environment. + +The residual is the recorded check-before-act limit: a target swapped between the +check and the exec cannot be closed portably, so the window is stated here rather than +claimed away. A selected target that then fails to run is a hard failure, never a quiet +fall back to the global copy: falling back would run a different version of the tool +than the repository pinned, which is the outcome the hand-off exists to prevent. + +## What this is not + +It is not a sandbox, and it does not defend a machine against its own owner. `.leji/` is +gitignored, so what lands there arrives from a local run rather than from a clone. The +guarantee is about what the tool does with input it is given: no path in a layer, and no +argument on the command line, causes `leji` to write outside the repository it was pointed +at, to write into a role it does not own, or to act on bytes it did not verify. diff --git a/docs/system/invariants.md b/docs/system/invariants.md index fba8813..9c03bdc 100644 --- a/docs/system/invariants.md +++ b/docs/system/invariants.md @@ -10,7 +10,7 @@ freshness: - The spec versions by semver. A breaking change to the spec requires a new major version; within a major version, changes stay backward-compatible. - Schemas have one source. `schemas/*.schema.json` at the repo root are canonical and are served at their canonical `$id` URLs under `https://leji.org/schemas/v1.0/`. Each SDK carries a synced copy that must match the root; the sync and parity scripts enforce it. - The three reference SDKs stay at parity. TypeScript, Python, and Go implement the same `leji` CLI surface and pass the same shared `fixtures/`. A behavior change lands in all three and ships at one coordinated version, never one SDK ahead of the others. -- The SDK runs locally. It collects no telemetry and reports nothing back; validation, scaffolding, and conformance work fully offline. The only network access is in the federation commands a user invokes deliberately (`mounts hydrate --fetch`, `conformance --federation=verify`), which contact the declared source and nothing else. +- Leji-authored work runs locally: validation, scaffolding, and conformance need no network and report nothing back; the SDK opens no connection of its own. The user-invoked exceptions: the federation commands (`mounts hydrate --fetch`, `mounts update-pin --fetch`, `conformance --federation=verify`) contact the declared source, and, only on your explicit yes at `leji init`/`leji adopt`, the SDK runs your own package manager's add command, in your environment, which performs that manager's normal network and lifecycle actions. - Released artifacts are immutable. A published version on npm, PyPI, JSR, or Go is never re-published. Fixes go forward in a new version. - This repository is public and carries only public content: the spec, the schemas, the reference SDKs and tooling, and the site. No business, engagement, or otherwise internal material belongs here. - The repository is dual-licensed: Apache-2.0 for code and schemas, CC-BY-4.0 for the spec prose. diff --git a/examples/multi-repo/README.md b/examples/multi-repo/README.md index fe8d611..3c44fca 100644 --- a/examples/multi-repo/README.md +++ b/examples/multi-repo/README.md @@ -27,6 +27,7 @@ What to notice: - The pattern-2 submodule (`app-payments/context/`) is a leaf: no build or runtime step touches it, so a stale pin degrades knowledge, never the build. - The federation mount in `core-context/leji.json` declares the product team's layer as a distinct named source with its `owner`, upstream `source`, and a full commit **`pin`** — the manifest-held version of record. Pin updates arrive as reviewable change sets. - Mounted content is never committed into the host. `leji mounts hydrate` materializes the pinned **layer projection** into the gitignored `.leji/mounts/` cache (resolved from a git object store — a machine-local hint, the resolver store, or `--fetch` from the source), and `leji mounts locate acme-product-context` tells readers where it landed. Unhydrated, `leji validate` reports an honest availability warning and nothing breaks. +- Keeping the pin current is one loop: `leji mounts hydrate --fetch` observes the sources, `leji mounts status` shows how far the pin has fallen behind, `leji mounts update-pin acme-product-context` moves it forward to the witnessed commit, and `leji mounts hydrate` materializes the new pin. `update-pin` is offline by default (it promotes the last witness a run observed) and moves the pin forward only; pass `--fetch` to observe the declared source during the run, and `--dry-run` to see the comparison without rewriting the manifest (with `--fetch` the store and network acts still happen, so fetched objects and refs land in the managed store). It rewrites the pin's own bytes and nothing else, so the change is a one-line reviewable diff. The cache entry for the old pin is left behind: remove it under `.leji/mounts/` by hand when you want the space, as there is no prune command. - The sibling is read, not absorbed: never merged into the host's categories, and its owner still approves its own changes. `core-context/` validates clean (`leji validate`; the unhydrated mount is a warning by design) and reports `claimedLevel: federated, verifiedLevel: governed` from `leji conformance`: the pinned declaration, routing metadata, and boot-profile surfacing are machine-verified, while the pin-reachability item reports `unknown` without source access — run `leji conformance --federation=verify` against a real source for the networked probe, and `unknown` never awards the level. The cross-repo machinery that can't live inside one example (external consumption, consumer-side pins) stays `manual`/process-attested, verified in a real multi-repo organization rather than a checked-in example. diff --git a/fixtures/README.md b/fixtures/README.md index f2112e4..7c283d0 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -10,6 +10,8 @@ repository plus an `expected.json` stating what `leji validate` must report. - Findings match on the triple **(rule, severity, path)**, sorted by (path, rule). Message text is implementation-specific and never compared. + This rule governs `validate` findings; the `export` block's findings carry + their own matching and ordering contract, stated in its section below. - Paths are POSIX, repository-root-relative, exactly as the CLI reports them. - `expected.json` carries the expected process exit code (`0` clean or warnings only, `1` at least one error) and the full findings list; the match @@ -18,16 +20,759 @@ repository plus an `expected.json` stating what `leji validate` must report. exercised in unit tests with injected baselines, not in fixtures: fixture 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}` and - `"indexCheck": {exit, stale}`. Harnesses assert them only when present. + `"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. - Schema-violation fixtures keep one violation per artifact entry so finding multiplicity stays identical across validator engines (Ajv vs jsonschema). +## Planted `.leji/` trees: `.leji-seed/` + +`.leji/` is gitignored repository-wide, so a fixture cannot commit files under +that name. A fixture that needs a pre-existing `.leji/` tree commits it as a +sibling `.leji-seed/` directory and declares the materialization: + +```json +"seeds": [{ "from": ".leji-seed", "to": ".leji" }] +``` + +- `from` and `to` are repository-root-relative POSIX paths inside the fixture, + normalized, with no `..` segment and no absolute form; a violation is a harness + error. A seed sits in the same parent as the `.leji/` it stands for, so a + fixture may declare more than one (`.leji-seed` and `docs/.leji-seed`). +- The harness materializes each seed in its own working copy before running any + command: the **contents** of `from` become the contents of `to`, and the + harness creates `to`. A pre-existing `to` is a harness error — fixtures are + pristine by construction, so an occupied target means the working copy is not + what the harness thinks it is. +- Seeds apply in array order and must not overlap targets: no `to` may equal + another seed's `to` or sit inside it. Overlap is a fixture-authoring error a + harness rejects rather than resolves. +- A seed carries regular files and directories only. A symlink anywhere inside + one is a harness error. +- File permissions are outside the contract: a harness copies seed content with + its platform's default modes. Nothing asserts or depends on a mode, and no + seed file is ever executed. +- `from` stays in place — it is committed data, and the copy happens inside the + harness's temp working copy. It is also dot-prefixed, so every content walk and + every served route skips it. Cleanup is the standing convention: the whole + working copy is disposed, nothing selective. +- No path component inside a seed may be named `.leji` or `dist` — both are + gitignored at any depth. Spell those components under their seed name instead. + +## Dot-prefixed goldens: `.expected-export/` + +A `rootPath: "."` fixture exports its own root, so a plainly named golden would +be walked into the next bake of itself. Such a fixture commits its golden +dot-prefixed instead — `.expected-export/` and `.expected-export.manifest.json` +— which the content walk skips, exactly as it skips `.leji-seed/`. The +`goldenTree` fields below always name the plain form: a harness reads the +dot-prefixed artifact beside it wherever the plain name is absent, so the +declaration stays the same for every fixture. + +## The `export` block + +`"export"` pins one `leji export` run over the fixture layer. Asserted only when +present. In every path list in this block — `layout.roles`, `layout.present`, +`layout.absent`, `layout.preserved` — paths are repository-root-relative POSIX +and a trailing `/` means a directory. + +| Field | Meaning | +| --- | --- | +| `args` | argv after `leji`, default `["export"]`. A variant run (`--strict`, or the co-equal `viewer build` name) states it. | +| `exit` | expected process exit code: `0` written, `1` error findings or any finding under `--strict`, `2` usage error or refusal. | +| `findings` | expected findings, matched on **(rule, severity, path, line, construct)**, ordered by (path, line, rule, construct). Message text is never compared. | +| `out` | expected output directory, repository-root-relative POSIX, matching the `out` field of `--json`. | +| `layout.roles` | role name → directory the run establishes. | +| `layout.present` | paths that must exist after the run. A **spot-check of role placement**, never an exhaustive listing — the golden tree is the exhaustive artifact. | +| `layout.absent` | paths that must not exist after the run (pre-1.4 locations a run must never create). | +| `layout.preserved` | paths present before the run that must still be present and byte-identical after it. Implies `present`. | +| `rerun` | `{byteIdentical}`. `byteIdentical: true`: after a second run over the same layer, **the complete fixture working tree** is byte-identical to the tree after the first run — the design's byte-level no-op, which subsumes every per-path question. | +| `goldenTree` | the byte contract for the written tree, below. | + +`preserved` pins one half of a planted stale tree's contract — that its bytes stay +untouched — and a `trustCanary` block on the same fixture pins the other half, that +those bytes are never consumed into served or exported output. + +### The pending-golden convention + +Golden bytes are baked from a reviewed run, never hand-written, so a fixture +lands before its goldens exist: + +```json +"goldenTree": { "status": "pending" } +``` + +`"status": "pending"` means **no assertion about tree bytes** — every other field +in the block still asserts. Baking flips it to: + +```json +"goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" +} +``` + +`contentDir` holds the exported `content/` as real committed bytes (small, +human-reviewable, what an independent renderer reads); `manifest` pins every +remaining path (chrome, vendored assets, fonts) by digest and size. Its exact +form: + +```json +{ + "version": 1, + "files": { + "index.html": { "sha256": "<64 lowercase hex>", "size": 1234 } + } +} +``` + +- Keys are **export-root-relative POSIX paths** (forward slashes), files only — + no directory entries — sorted lexicographically by key in byte order. +- The manifest covers every exported file **outside** the subtree `contentDir` + mirrors (the export's `content/`); files inside it compare as committed bytes. +- The two sets are disjoint and together exhaustive: every file the export writes + is pinned by exactly one of them. + +A run that deliberately writes no export tree has nothing to bake, now or ever: + +```json +"goldenTree": { "status": "none" } +``` + +`"status": "none"` means **the run writes no export tree; no byte contract exists +or is owed** — the form for a `--strict` fixture whose findings fail the run. Every +other field in the block still asserts, `rerun.byteIdentical` included: a run that +writes no tree must still leave the fixture working tree stable. + +A fixture never leaves `goldenTree` out: `pending` is a claim that baking is owed, +`none` a claim that nothing is. + +## The `trustCanary` block + +`"trustCanary"` pins the trust-domain boundary: nothing under `.leji/` except +`viewer/` is servable, and no export carries a byte of it. Asserted only when +present. + +| Field | Meaning | +| --- | --- | +| `topology` | `nested` (the fixture's `rootPath` is a subdirectory) or `dot-root` (`rootPath: "."`). A layout descriptor only; which boundary is live for a given request belongs in that request's `note`. | +| `plantedPaths` | the materialized paths carrying the canary token. | +| `serve.requests` | the **exact request corpus**: `{path, status, note?}`, issued in order against the local server. All three SDKs issue identical requests against identical bytes. | +| `serve.routeScan` | `{assertNoTokenIn200Bodies}`: every corpus request that answers `200` must answer without the token. The corpus enumerates the routes the sidebar, index and manifest page name, so this is the "no route leaks" assertion. | +| `exportScan` | `{root, occurrences}`: a recursive scan of the written export finds exactly `occurrences` matches, always `0`. | + +The token is the fixed byte string `LEJI-TRUST-CANARY`. It is spelled here and in +each harness, and **deliberately in no `expected.json`**: under `rootPath: "."` a +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 `badge` block + +`"badge"` pins one `leji badge` run over the fixture layer. Asserted only when +present. The command writes one SVG and prints the markdown that embeds it; the +badge states the level `leji conformance` verified in that offline run — never +more than the claim, possibly less. Paths in this block are POSIX, and each field +below says what they are relative to. + +| Field | Meaning | +| --- | --- | +| `args` | argv after `leji`, default `["badge"]`. A variant run (`--out `, `--json`) states it. | +| `exit` | expected process exit code: `0` the badge is written or already current, `1` a conformance error finding or `badge-unverified` (nothing was machine-verified in this run), `2` usage error or refusal. | +| `out` | the written path, matching the `out` field of `--json`; `null` when nothing is written. | +| `level` | the level the badge states — the verified level, never the claim. `null` when nothing is written. | +| `claimedLevel` | the level `leji.json` claims. Always reported, success and failure alike. | +| `verifiedLevel` | the level this offline run verified, or `null` when it verified none. Always reported. | +| `golden` | the canonical badge the written file must byte-equal, as a path relative to `fixtures/` (`badge/governed.svg`). `null` when nothing is written. | +| `action` | `wrote` (the target was absent), `unchanged` (it already held these exact bytes; nothing written), `overwrote` (it held another canonical badge of this contract), or `null` when nothing is written. | +| `written` | whether the run wrote the target at all. Stated on the exit-1 and exit-2 cases: `false` means the target does not exist after the run — and, when `preseed` planted it, that its planted bytes are still there byte for byte. A refusal never edits and never truncates. | +| `preseed` | optional: one file the harness writes into the working copy **before** the run, either `{ "path": "leji-badge.svg", "from": "badge/indexed.svg" }` (a copy of that canonical badge) or `{ "path": "leji-badge.svg", "bytes": "not a badge\n" }` (the literal bytes). `path` is repository-root-relative POSIX inside the fixture; `from` resolves relative to `fixtures/`. | +| `rerun` | `{action, byteIdentical}`. `byteIdentical: true`: after a second run over the same tree, **the complete fixture working tree** is byte-identical to the tree after the first run, and the second run reports `action`. The steady state is always `{"action": "unchanged", "byteIdentical": true}`. | + +Fixture-local `leji-badge.svg` files are never committed: a fixture that needs one +declares it under `preseed`. The only committed badge bytes in this repository are +the canonical ones under `fixtures/badge/`. + +A `badge` block is asserted over a **committed** git working copy of the fixture: +copy the fixture out, `git init`, `git add -A`, commit, then run. The level a badge +states is the level this run verified, and the `indexed` changelog item is `unknown` +— never `pass` — until the changelog is present in `HEAD`; `unknown` awards no level. +So a fixture run from an uncommitted copy verifies `core` whatever it claims, and +every block above `core` would fail against a plain temp directory. `gitFixture()` in +`scripts/parity-test.ts` already does this, and every SDK's badge-block runner must. + +### The `--out` acceptance rule + +Checked at argument parsing, before conformance runs: a rejection here is exit 2 in +the usage-error form, with no level reported at all. + +`--out` takes a repository-relative POSIX path over `[A-Za-z0-9._/-]`, with no +leading `/`, no backslash, no `..` segment, no empty segment, and ending `.svg`; +anything else is exit 2 with the rule quoted. The resolved path must lie inside +the repository and never under `.leji/` at any depth — `.leji/` is tool domain, +and the badge is user content — and must not be a directory. The canonical POSIX +form is what stdout, `--json`, and the emitted markdown carry. + +### The existing-file rule + +Applied after conformance has succeeded, so a refusal here still reports +`claimedLevel` and `verifiedLevel` — as the foreign-file block on `valid-records` +pins. The target file decides the action, by its bytes and nothing else — no marker, +no sidecar, no state: + +- absent ⇒ write it (`wrote`); +- byte-equal to the badge this run would write ⇒ `unchanged`, exit 0, nothing + written; +- byte-equal to any other canonical badge of this contract (the four files under + `fixtures/badge/`) ⇒ overwrite it (`overwrote`), which is how a level change + regenerates; +- anything else ⇒ refuse, exit 2, leaving the file untouched. + +### The canonical badge bytes + +`fixtures/badge/` is the **sole** byte oracle for the command's output: `core.svg`, +`indexed.svg`, `governed.svg`, `federated.svg`, and the matching `core.md … +federated.md` carrying the one markdown line the command prints for the default +out path. Every implementation renders each level and byte-compares against these; +nothing else in the tree holds badge bytes. + +The four files are re-derivable from this rule, so no port ever re-measures +anything: + +- **Template.** One shields-flat shape, height 20, rounded via a `clipPath` with + `rx="3"`. The identity segment is `#183D3B` and carries the mark followed by the + wordmark `Leji 1.0`; the status segment is `#009F71` and carries `` alone. + Both `` elements are white, + `font-family="Verdana,Geneva,DejaVu Sans,sans-serif"`, `font-size="11"`, + baseline `y="14"`, and carry `textLength` plus `lengthAdjust="spacing"` — layout + stabilization, which pins the advance width; glyph rendering stays the + renderer's. The self-attestation claim is structural rather than visible: + the SVG carries `role="img"`, and its two name-bearing fields, `` and + `aria-label`, both read `Leji 1.0 · <level> · self-attested` (the separator is + U+00B7), as does the markdown alt text; the page the markdown links carries the + story. No XML + declaration, no BOM, no comment, no timestamp, no version string; UTF-8, LF, one + trailing newline. Attribute order and whitespace are identical across the four + files: only the level word, its `textLength`, and the widths derived from it + differ. +- **Mark.** The single `<path>` of `packages/site/src/assets/leji-icon.svg` + (viewBox `0 0 370 391`) verbatim, its fill changed to `#FFFFFF`, placed with + `transform="translate(5 3) scale(0.0358)"`: ~14px tall, vertically centred in the + 20px band, at the identity segment's left. +- **Widths.** Horizontal padding is 5 either side of each segment, and the mark + occupies a 14-wide slot followed by a 3-wide gap. So the identity segment is + `5 + 14 + 3 + 41 + 5 = 68` wide with its text at `x="22"`; the status segment is + `<textLength> + 10` wide with its text at `x="73"`; the badge is their sum. + + | Text | `textLength` | Status segment | Badge width | + | --- | --- | --- | --- | + | `Leji 1.0` (wordmark) | 41 | — | — | + | `core` | 24 | 34 | 102 | + | `indexed` | 43 | 53 | 121 | + | `governed` | 52 | 62 | 130 | + | `federated` | 53 | 63 | 131 | + + Non-normative, recorded so nobody measures twice: those five numbers are the + sum of the glyph advance widths in `Verdana.ttf` (`hmtx`, 2048 units/em) at + 11px, rounded to the nearest integer — kerning ignored, which `textLength` + makes moot. The table is the contract; the font is only how it was arrived at. + +## The manifest pin-span fixtures + +`manifest-pin-span/` is not a layer and carries no `expected.json` of its own. It is +the byte oracle for the one manifest edit `leji mounts update-pin` makes: replacing +the `pin` value of ONE declared mount and nothing else. Each subdirectory is one +case: + +| File | Meaning | +| --- | --- | +| `input.json` | the manifest text the edit is applied to, byte for byte | +| `case.json` | `{note, mount, from, to, outcome, error?}` — which mount is addressed, the pin the span must currently hold, the pin to write, and what must happen | +| `expected.json` | the byte-exact result. Present only when `outcome` is `replaced` | + +`outcome` is `replaced` (the span moved) or `error`. An `error` case names which +refusal: `not-located` (no mount of that name carries a pin), `not-from` (the span +holds something other than `from`), or `duplicate-key` (below). All are internal +refusals raised after the manifest has already parsed and validated, so a CLI +reaching one exits 2. + +**Duplicate keys are refused, never resolved.** JSON does not forbid a repeated +member, and the two readers of this document disagree about which one wins: a +lexical scan reaches the FIRST, `JSON.parse` keeps the LAST. A mount carrying two +`pin` members would therefore have its first span rewritten while every parser of +the result still read the second — a reported change that changed nothing. So every +key on the path to the pin must appear exactly once, and a repeat is the +`duplicate-key` error: `federation` at the root (`error-duplicate-federation`), +`mounts` inside it (`error-duplicate-mounts`), and `name` or `pin` on a mount +(`error-duplicate-name`, `error-duplicate-pin`). Keys elsewhere in the document are +not the scanner's business and are never inspected. + +The contract these fixtures pin, and the reason a line-anchored splice will not do: +**the manifest's layout is input, never a contract.** The schema fixes no key order +and no whitespace, so an implementation locates the span by scanning JSON tokens — +walking to `federation.mounts`, selecting the array element whose `name` DECODES to +the addressed name, and taking the byte span of that element's own `pin` string +value. It decodes escapes only to compare a key or a name, skips nested objects and +arrays structurally, and rewrites nothing else. The cases exist because each one +breaks a shortcut: `reversed-key-order` (`pin` before `name`), `escaped-name` (a +`\uXXXX` escape and an astral surrogate-pair escape in the name), +`escaped-property-key` (the `pin` KEY itself spelled with escapes, which decode for +comparison while the key's own bytes survive), +`owner-name-collision`, `shared-prefix`, `crlf`, `nested-unrelated-pin` (a `"pin"` +key above the array and inside the mount's own `owner`), `non-canonical-spacing`, +`unmodeled-keys`, and `mount-not-first`. + +Every `replaced` case is also a claim about confinement: the output differs from the +input in exactly `to.length - from.length` characters, and it still parses. + +## The `updatePin` block + +`"updatePin"` pins `leji mounts update-pin` over the fixture layer. Asserted only +when present. Unlike the blocks above it carries an ARRAY of cases, because the +command's behavior turns on the pin the manifest starts from and on what a local +object store holds — both of which a harness prepares, so one layer serves every +case and no near-identical fixture layers are committed to vary a single field. + +| Field | Meaning | +| --- | --- | +| `sibling` | the scaffold recipe to build, always `acme-sibling` (below) | +| `mount` | the declared mount every case addresses | +| `cases` | the array, each entry below | + +### One case + +| Field | Meaning | +| --- | --- | +| `id` | the case name, unique within the block | +| `note` | what this case exists to catch | +| `pin` | the pin the harness splices into the fixture's `leji.json` before the run | +| `trackingRef` | optional. `null` removes the declared `trackingRef` entirely; absent leaves the fixture's own | +| `store` | `null` for no managed store, else `{pin, witnessRef, witnessOid, depth}` (below) | +| `hint` | write `.leji/mounts.local.json` pointing at the sibling checkout | +| `source` | `none` (no `--fetch`), `local` (the declared source is routed to the recipe repository), or `unreachable` (routed to a path that does not exist) | +| `args` | argv after `leji`. The harness appends `--root <copy> --json` | +| `exit` | expected process exit code: `0` updated, unchanged or a dry run; `1` the move was refused; `2` a usage error | +| `action` | `updated`, `unchanged`, `dry-run`, `refused`, or `null` when the run emits no document at all | +| `from` / `to` | the `mount.from` and `mount.to` the document reports, or `null` | +| `reason` | the stable refusal code, or `null` | +| `override` | whether the non-fast-forward override was exercised | +| `comparisonRepository` | optional: the `pinReport.comparisonRepository` this case pins | +| `comparedRef` | optional: the `pinReport.comparedRef` this case pins, which is how the resolved default branch is asserted | +| `manifestGolden` | the byte contract for the rewritten `leji.json`, as a path relative to `fixtures/`, or `null` | +| `written` | `false` means `leji.json` is byte-identical to the manifest the run started from | + +The document's key set is exactly `command, ok, findings, summary, mount, pinReport, +action, override`, plus `reason` on a refusal. `ok` is `true` exactly when `reason` +is `null`; the findings are one error finding whose rule IS the reason code, plus one +warning finding `mount-pin-non-fast-forward-override` when `override` is true, and +the summary counts exactly those. A case whose `action` is `null` writes nothing to +stdout at all: a usage error reports no outcome. + +`store` builds the managed store exactly as a successful `--fetch` leaves it, under +`.leji/mounts/store/<sha256(source identity)>`: + +- `pin` — retained under `refs/leji-pin/v1/<sha256(identity)>/<oid>`, or `null` for a + store that holds no pin; +- `witnessRef` + `witnessOid` — published under + `refs/leji-witness/v1/<sha256(identity)>/<sha256(witnessRef)>`, or `null` for a + store with no witness at all; +- `depth` — when set, both fetches are `--depth <n>`, which is how a store that holds + both commits and still cannot answer their ancestry is built. + +`FETCH_HEAD` is removed afterwards: it records a per-harness path and is not part of +any contract. + +### The `acme-sibling` recipe + +The scaffold every `updatePin` case is prepared against. It is spelled here rather +than committed as bytes, because it must be a real git repository and `.git` cannot +be committed inside a fixture. Every input a commit hashes is fixed, so the recipe's +commit ids are CONSTANTS an `expected.json` carries, not observations a harness +reads back. + +For every commit: author and committer `Leji Fixtures <fixtures@leji.org>`, both +dates `2026-01-01T00:00:00 +0000`, `commit.gpgsign=false`, `core.autocrlf=false`, +initial branch `main`. Each step writes ONE file whose content is `# <stem>` plus a +newline, stages everything, and commits with the stem as the whole message: + +| Step | Branch | File | Commit id | +| --- | --- | --- | --- | +| 1 | `main` | `a.md` | `6b06fe51a323212156bb267842bf10187ed4c20e` | +| 2 | `main` | `b.md` | `3ff2a04361ca9d601180037bdfbc8b6c0a0a8723` | +| 3 | `side`, branched from step 1 | `s.md` | `50305153f1a107c6871ab3b3047cb4c225603b0c` | +| 4 | `other`, an orphan with the tree cleared first | `o.md` | `0cb1fb59e73d78ff04cf41de7f177ea0fb940002` | + +The repository is left on `main` and sets `uploadpack.allowAnySHA1InWant=true`, since +retaining a pin means fetching a commit by id the way a real host serves one. So +`main` is one commit ahead of step 1, `side` diverges from it, and `other` shares no +commit with anything. + +### What no fixture constructs + +Two refusals are unit-level in each SDK, and deliberately not here, because +constructing them means injecting a fault rather than preparing a state: + +- **`mount-declaration-changed`** — `leji.json` is edited between the comparison and + the verified read the rewrite makes. A harness can only produce it by driving the + library directly. +- **A target-retention failure under `--fetch`** — by the time the target is + retained, the comparison repository is the managed store and already holds that + commit, so only a forced `update-ref` failure reaches the branch. + +Neither is compared by `scripts/parity-test.ts` either: that harness prepares states +and runs argv, and it injects no faults, so there is no scenario for either branch. +Each SDK owns its own unit test for them, against the same stable reason code +(`mount-declaration-changed`, `mount-store-fetch-failed`) and the same "nothing was +written" outcome. The TypeScript reference reaches the second through a test-only +environment variable, `LEJI_TEST_FAIL_PIN_REF=<oid>`, which fails the retention ref +for exactly that commit; a port may use whatever narrow hook its own store code +allows, because what the fixtures and this document pin is the refusal, not the hook. + +## Generated CI and hooks: `fixtures/ci-goldens/` + +The byte contract for everything `leji ci` writes. Each file is one cell of the +generator's manager x provider table, baked from a reviewed run and asserted by +each SDK's unit suite; nothing else in this repository holds generated CI bytes. + +| Name | What it pins | +| --- | --- | +| `<provider>-<manager>-local.yml` | the job for a repository that DECLARES the Leji CLI and carries that manager's lock evidence: its own locked install, then the local runner | +| `<provider>-<node\|python\|go>-fallback.yml` | the job every other state takes, per ecosystem: `npx @leji-org/leji@1`, `pip install 'leji>=1,<2'`, or `go install .../cmd/leji@latest` | +| `hook-<manager>.sh` / `hook-fallback.sh` | the standalone managed pre-commit hook for that runner | +| `husky-<manager>.sh` / `husky-fallback.sh` | the same two gates as a marker-delimited husky block | +| `legacy-1.3-<provider>-<local\|fallback>.yml` | what the 1.3.x generator wrote, kept so the ownership rules can be tested against real bytes rather than a reconstruction | + +- **Providers are `github`, `gitlab`, `circleci`, `azure`; managers are the nine the + detection table names** (`npm`, `pnpm`, `yarn`, `bun`, `uv`, `poetry`, `pdm`, + `pipenv`, `go`). `pip` and pre-1.24 Go never appear: they cannot be declared, so + they take their ecosystem's fallback. +- **The marker is the ownership claim.** Every whole file opens with + `# generated by leji ci (managed) v2`; the GitLab block keeps its own + `# >>> leji ci (managed) >>>` delimiters and adds nothing. A re-run replaces a + whole file only when its bytes are ones leji generated (this release, or a digest + in the SDK's `KNOWN_GENERATED` registry of earlier ones). The `legacy-1.3-*` + files are exactly that case, which is why they are committed here. +- **The fallback job is the pre-1.4 job.** `<provider>-node-fallback.yml` is the + 1.3.x fallback line for line, plus the marker: a repository that was getting the + `npx` job keeps precisely that job. +- **Hook bodies are shell contracts.** Every argv element is single-quoted + (`'pnpm' 'exec' 'leji' validate`), so a manager name is never split, expanded or + globbed; `sh -n` parses every file here, and the stale-index message keeps its + own literal backticks. + +## Help goldens: `fixtures/help-goldens/` + +The byte contract for terminal help. `usage.txt` is `leji --help`; one +`<command>.txt` holds each `leji <command> --help`, the command's spaces written +as dashes (`mounts-update-pin.txt`, `changelog-check.txt`); `wrap-non-bmp.txt` +pins the wrapper itself (below). Each file is what the CLI prints, the final +newline included, and every SDK's suite compares its own bytes against them: help +is generated from `cli.json` by three renderers, so the goldens are what keeps +them one surface. + +- **The version is substituted, not baked.** `usage.txt` carries the token + `{{version}}` where the header line names the running SDK version; a harness + replaces that token with its own version before comparing. Nothing else in the + goldens varies by build. (The process-parity harness needs no such rule: it + compares the three CLIs against each other at one version.) +- **Width is 80 Unicode code points**, never terminal-derived and never UTF-16 + units. Whitespace runs collapse to one space; a token that cannot fit the + remaining width takes a line of its own, unbroken. +- **`wrap-non-bmp.txt` is the wrapper's own vector**, the one case that separates + code points from UTF-16 units. The input is `😀😀😀😀 alphabet six666 tail` + (four U+1F600, then TWO spaces, then the three ASCII words), wrapped at width + 20 with the first line indented 0 and every continuation indented 3. An + implementation measuring the emoji run as 8 units instead of 4 code points + breaks the line one word early and fails the file. +- **`wrap-long-usage.txt` pins the `Usage:` line's own wrapping**, which no + current command is long enough to exercise. The input is `Usage: leji mounts + update-pin <name> [--to <oid>] [--allow-non-fast-forward] [--fetch] + [--dry-run] [--root <dir>] [--json]` on one line, wrapped at width 80 with the + first line indented 0 and continuations indented 7 (under the usage text, past + `Usage: `). A renderer that emits any help field without the wrapper fails it. +- **Every dynamic label class resolves a BOUNDED column, in code points.** The + bounds are the class's contract and are identical in all three SDKs: option + rows (top-level globals and per-command alike) are the longest flag plus 3, + bounded to [20, 30]; command and alias rows are the longest name plus 3, + bounded to [12, 30]; exit-code rows are the longest code plus 2, bounded to + [3, 8]. A label at or past its column's width takes the line alone and its + summary starts on the next line at the column. +- **`bounds-spec.json` is a synthetic CLI spec** that pushes every one of those + classes past its bound in one document: a 75-code-point global flag, a + 56-code-point command name, a three-digit exit code, an alias, and a + per-command flag carrying astral characters. `bounds-usage.txt` is the + top-level help each SDK renders from it (with the same `{{version}}` token), + and `bounds-command.txt` is the long command's own help. Rendering them is the + renderer-level test: the bounds are checked where they are applied, not only + in the column helper. +- **`row-non-bmp.txt` pins padding by code points at the row level.** The label + is `--emoji-😀😀 <value>` (two U+1F600) at column 23, with the summary `A flag + carrying astral characters, so a column padded in UTF-16 units misaligns this + row by two.` An implementation padding by UTF-16 units leaves the row two + columns short. Column widths are code points throughout; no rule here claims + terminal cell width, which no SDK can know. +- **`row-overlong-label.txt` pins the two-column row when the label outgrows its + column**, which the option column's [20, 30] clamp makes reachable. The row is + the label `--allow-non-fast-forward-with-a-very-long-spelling <oid>` at column + 23 with the summary `Permit a target that is not a descendant of the current + pin, in the one spelling long enough to outgrow its column.` The label takes + the line alone and the summary starts on the next line at the column, so it is + never concatenated onto the label. + +## Ecosystem detection: `fixtures/ecosystem/` + +`fixtures/ecosystem/` is not a layer family (111 cases: the detection decision +table, plus the 69-case `scan-*` matrix below): each subdirectory is a miniature +repository ROOT — a manifest, its lockfiles, and nothing else — and its +`expected.json` carries one block: + +```json +{ "ecosystem": { "selected": null, "all": [], "reason": "none" } } +``` + +`ecosystem` is the complete `detectEcosystem(root)` report: which dependency +ecosystems the root gates, which package manager owns each, the argv that +declares the Leji CLI as a dev dependency there, the argv a hook or CI job runs it +with, and whether the repository already declares it. All three SDK unit suites +consume these cases; nothing else pins the detection contract. + +**Matching is exact, twice.** The report must deep-equal the block, and its +serialization must byte-equal the committed file: `JSON.stringify({ecosystem}, +null, 2)` plus one trailing newline. The second comparison is what pins KEY +ORDER, which a deep-equal comparison cannot see and which the `--json` surface of +`leji detect` (and of `init`, `adopt` and `ci`) makes a public contract. + +- **Lockfiles are presence-only.** Every committed lockfile is empty; no + implementation may parse one. +- **`evidence` order is fixed and documented, never locale-dependent.** Node lists + the lockfiles present in family order (npm, pnpm, yarn, bun, with `bun.lock` + before `bun.lockb`); Python lists its lock families in order (uv, poetry, pdm, + `Pipfile.lock`, `Pipfile`) and then every root file matching + `^requirements[A-Za-z0-9._-]*\.txt$` sorted BYTEWISE; Go lists none. The + `python-requirements-multi` case exists for the sort: its bytewise order + (`requirements-Test.txt`, `requirements-dev.txt`, `requirements.txt`) is neither + its creation order nor what a locale collation produces. +- **Refusal cases carry committed symlinks.** `node-refused-evidence` links + `package.json` to `../../README.md` — outside the fixture root, inside the + repository, so a checkout of this repository is complete and nothing escapes it + — and `node-refused-dangling` links it to a name that does not exist. A gated + file counts only when `lstat` says regular file and its real path stays inside + the root, so both are `refused-evidence` and neither is ever opened. +- **One decision per case.** A case exists to pin exactly one row of the decision + table: the manager chosen, the ambiguity refused, the field scanned, or the + refusal. Add a case rather than widening one. + +### The `scan-*` cases: the declaration scanner's field-state matrix + +Whether a repository already declares the Leji CLI decides whether it is offered +at all, which runner its hook and CI job take, and whether CI installs locally. +The scan is field-specific and stateful rather than a TOML parse, so **every state +it tracks is committed here**, each field with a positive, a negative, and a +comment case. 69 cases, which the TypeScript suite asserts partition exactly: a +new case must be classified, a deleted one fails, and a scanner assertion written +inline in a test instead of as a fixture is refused outright. + +| Inspected field | Declares | Does not declare | +| --- | --- | --- | +| `project.dependencies` | `scan-project-deps-{inline,multiline,specifier,spaced-header,single-quoted}` | `scan-project-deps-{absent,prefix-only,comment}` | +| `project.optional-dependencies` | `scan-optional-deps-declared` | `scan-optional-deps-{absent,comment}` | +| `dependency-groups` | `scan-dependency-groups-declared` | `scan-dependency-groups-{absent,comment,triple-quoted}` | +| `tool.uv` `dev-dependencies` | `scan-tool-uv-dev-{declared,marker}` | `scan-tool-uv-{dev-absent,dev-comment,other-field}` | +| `tool.poetry.dependencies` | `scan-poetry-deps-{key,quoted-key}` | `scan-poetry-deps-{absent,comment}` | +| `tool.poetry.dev-dependencies` | `scan-poetry-dev-deps-{key,quoted-key}` | `scan-poetry-dev-deps-{absent,comment}` | +| `tool.poetry.group.<x>.dependencies` | `scan-poetry-group-{key,inline-table}` | `scan-poetry-group-{absent,comment}` | +| `tool.pdm.dev-dependencies` | `scan-pdm-dev-{array,key}` | `scan-pdm-dev-{absent,comment}` | +| Pipfile `packages` | `scan-pipfile-packages`, `scan-pipfile-packages-quoted-key` | `scan-pipfile-packages-{absent,comment}` | +| Pipfile `dev-packages` | `scan-pipfile-dev-packages`, `scan-pipfile-dev-packages-bare-key` | `scan-pipfile-dev-packages-{absent,comment}` | +| `requirements*.txt` | `scan-requirements-{declared,bare,extras}` | `scan-requirements-{indented,comment,prefix-only,include-line}` | +| quoted vs triple-quoted elements | `scan-plain-quoted-element` | `scan-triple-quoted-element`, `scan-triple-quoted-element-literal` | +| multi-line strings (never declare) | — | `scan-multiline-{basic-string,literal-string,string-hides-table}` | +| uninspected fields and tables | — | `scan-project-{description,keywords,classifiers,nested-array}`, `scan-unrelated-table-key`, `scan-poetry-scripts-key`, `scan-pipfile-scripts` | +| `go.mod` tool directive | `scan-go-2.0` | `scan-go-{closed-block,comment,1.9,1.25}` | + +Four rules these cases exist to hold, because each is easy to get subtly wrong in +a port: + +- **A triple-quoted string is skipped entirely**, on one line as across several. + `dependencies = ["""leji"""]` does NOT declare; `dependencies = ["leji"]` does. + There is no parser to tell a multi-line requirement from prose that merely + begins with the name, and a false positive suppresses the only offer the user + gets, so the conservative answer is the only safe one. +- **Only the listed fields are inspected.** A `tool.poetry.scripts` entry named + `leji`, a `project` `classifiers` or `keywords` array holding it, an array + nested inside one, a commented line in any affected table, and a name that + merely starts with `leji` all read as absent. +- **Both key spellings count**, bare and quoted, in every dependency map; and an + inline-table value (`leji = { version = "^1.3" }`) is a declaration like any + other. +- **The Go tool directive is line-exact.** A path inside a CLOSED `tool ( … )` + block, or one behind a `//` comment, is not a declaration; the directive needs a + `go` directive of 1.24 or newer, which `scan-go-1.9` and `scan-go-1.25` pin on + either side. + +## Start preflight: `fixtures/start-preflight/` + +Seeded joiner states for `leji start`'s Setup block. Each subdirectory is a +miniature ADOPTED repository — a `leji.json`, a boot profile, one category +document, and the ecosystem files that decide the report — not a layer family, so +none carries an `expected.json`. `scripts/parity-test.ts` copies one in, commits +it, and runs a single argv over it; the three CLIs must print the same bytes. + +- **A state is the tree plus its environment.** What a fixture cannot carry is + supplied by the scenario, and every piece of it is written statically: the + installed Node bin shim at `node_modules/.bin/leji` (`node_modules` is not + committable), the git-side configuration (`core.hooksPath`), an installed clone + hook, and a PATH of stubs. Each case runs with its OWN stub directory as its + whole `PATH` (the agent host binaries, the ambient `leji`, plus a link to the + real `git`), so detection and the version probe answer to the case, never to the + machine running the suite. `node-declared` therefore serves the not-installed, + unresolvable, resolvable and below-minimum cases from one tree, varying only the + shim and the stubs. +- **The `cli` probe never runs a package manager.** For a Node repository it + executes the installed shim directly, so a case that expects a version installs + one and a case that expects `missing` does not; there is no `npx` or `pnpm exec` + stub, because nothing would ever call one. Python and Go cases stub the manager + binary itself (`uv`, `go`), which is what those probes do run. +- **The states.** `node-declared` (npm, CLI declared), `node-undeclared` (the + shared declaration gap), `node-mcp-json` (a committed `.mcp.json`, also the + after-the-fixes state once the hook is installed), `husky` and `githooks` (a + hooks path inside the working tree, so a shared gap), `python-uv` and `go-tool` + (the same rows keyed to `uv run leji` and `go tool leji`). +- **Nothing is produced by one CLI for the others.** The after-the-fixes state is + seeded like every other one: the installed hook's bytes are a plain copy of the + committed golden `ci-goldens/hook-npm.sh` (the same bytes `leji ci --hooks` + writes for an npm repository, checked by its own test), never a printed fix + replayed from a previous run and never a CLI invocation. +- **No committed golden.** As with the arg-rejection scenarios below, the + assertion is byte equality across the three CLIs; the definitions run behind + `START_PREFLIGHT_SCENARIOS_ENABLED`, enabled with the Go and Python ports. + +## Hand-off to a repository's own CLI: `fixtures/handoff/` + +Seeded repositories for the decision the INSTALLED EXECUTABLE makes before it +parses anything: inside a repository that declares the Leji CLI and has it +installed, `leji` runs that pinned copy instead of itself. Each subdirectory is a +miniature repository root: a `leji.json` (the spec line whose minimum the copy +must meet) and the ecosystem files that decide the record. No boot profile and no +category document, because no command runs far enough to read them, and no +`expected.json`: these are not layer fixtures. + +- **The installed copy is written by the harness, never committed.** + `node_modules` is not committable, so each test writes the state its case + declares: `node_modules/@leji-org/leji/package.json` (the identity and version + the decision reads), the package entry at `dist/cli.js`, and the manager's bin + shim `node_modules/.bin/leji` as a symlink to that entry, which is the form npm + and bun install. Nothing is produced by running a CLI. +- **The entry is a MARKER, so a hand-off is provable.** It prints + `handoff:node:` followed by its own argv, JSON-encoded, and exits 3. The JSON is + what makes argument boundaries provable (the tests pass empty, space-bearing and + unicode tokens), and 3 is a status no leji command returns, so exit forwarding is + observable rather than assumed. A case that expects no hand-off asserts the + global's own output instead, typically its `--version`. +- **The cases.** `node-eligible` (hands off) and, beside it, one case per way the + decision refuses: `node-undeclared`, `node-declared-missing`, `node-below-minimum`, + `node-escaped` (the package directory links out of the repository), `node-refused` + (the manifest itself resolves outside it), `node-wrong-identity`, + `node-malformed-metadata`, `node-malformed-version`, `node-metadata-not-regular`, + `node-entry-not-regular` (the declared entry is a directory, refused on every + platform: the entry is what identifies the copy, whether or not it is what gets + run), `node-unknown-spec-line`, and `node-self` (the installed copy is the + running executable). `node-ambiguous-manager` and + `node-unsupported-manager` DO hand off: which package manager a repository uses + decides nothing here, because the target is the installed package and never a + manager's runner. `polyglot` declares both Node and Python and hands off on the + Node record alone. `go-tool` is the not-applicable branch: a Go repository builds + `go tool leji` on demand and has no installed executable to hand off to. +- **Every shim shape a manager installs.** `node-shim-symlink` (npm, and bun), + `node-shim-script` (pnpm) and `node-shim-yarn` (Yarn's node-modules linker) carry + the lockfile of the manager they name, and the harness writes the shim in that + manager's shape: a symlink to the package entry, or a small script that runs it. + Both shapes must hand off, and both must stop after ONE hand-off, which is why the + copy's identity is the entry the package declares and not the shim that gets + executed: a script shim's own resolved path is itself, so a guard comparing it + would never recognize the copy behind it and the local CLI would hand off again + forever. The proc suite also runs the REAL built CLI behind a script shim, which + answers once and stops. +- **What parity can assert, and what it cannot.** A successful hand-off is + per-runtime by construction (a Node repository's copy is a Node CLI), so + `scripts/parity-test.ts` runs only the shared NON-delegating outcomes over this + family: `node-undeclared`, `node-below-minimum`, and `go-tool` as `--version` + scenarios, where all three CLIs must print the same bytes. The hand-off itself is + proved in each SDK's own suite over these same committed roots. +- **The Python half is the same family, with its own locator and reader.** A + `python-*` root carries a `leji.json` and the manifest and lockfile that decide + its manager; the harness writes the project environment, because a `.venv` is no + more committable than a `node_modules`: the console script at `.venv/bin/leji` + (mode 0755, printing `handoff:python:` and the same JSON-encoded argv, exit 3) + and the installed distribution's + `.venv/lib/python3.X/site-packages/leji-<v>.dist-info/METADATA`. `python-eligible` + hands off; `python-undeclared`, `python-no-env`, `python-below-minimum`, + `python-malformed-version`, `python-wrong-name` (a `leji-<v>.dist-info` whose + `Name` field declares another distribution, so the field decides and not the + directory), + `python-two-distinfo` (the environment cannot say which copy would run), + `python-bounds` (metadata past the read bound), `python-escaped-env`, + `python-escaped-script`, `python-script-not-regular`, `python-refused-manifest`, + `python-unknown-spec-line`, and `python-ambiguous-manager` do not. The last one is + the difference from Node: this runtime's target is the environment the MANAGER + owns, so a root whose manager the evidence could not choose names no environment. + `python-uv-env-var`, `python-virtual-env-equal`, + `python-virtual-env-elsewhere-inside-root` and `python-virtual-env-outside` pin the + environment rule: uv's `UV_PROJECT_ENVIRONMENT` resolves against the root and must + stay inside it, every other manager means `<root>/.venv`, and `VIRTUAL_ENV` never + selects an environment (a nested or unrelated active one is not this root's). + +## Arg-rejection scenarios + +Command-surface rejections are exercised in `scripts/parity-test.ts`, which +compares the three CLIs' own output rather than a committed expectation, so there +is no fixture-side form for them. The scenarios `leji export` owes, recorded here +until that harness carries them: + +- `leji export --endpoint x` → exit 2, usage error. No endpoint, URL, host, token + or destination parameter exists on this command, by construction. +- `leji export --out .leji/mounts` → exit 2, refusal. `--out` never resolves + inside `.leji/` except exactly `.leji/dist/`; the same holds for + `.leji/viewer`, `.leji/work`, and any other role. +- `leji export --help` → exit 0, and the help bytes carry no network vocabulary. + +The scenarios `leji badge` owes. These are already written in that harness, held +behind `BADGE_SCENARIOS_ENABLED` until the ports land: + +- `leji badge --endpoint x` → exit 2, usage error. No endpoint, URL, host, token + or destination parameter exists on this command, by construction. +- `leji badge --out .leji/x.svg` → exit 2, refusal. `--out` never resolves inside + `.leji/` at any depth. +- `leji badge --out ../x.svg`, `--out /abs.svg`, `--out a\b.svg`, `--out x.png` → + exit 2 each, quoting the `--out` acceptance rule above. +- `leji badge --help` → exit 0, and the help bytes carry no network vocabulary. + +The scenarios `leji mounts update-pin` owes. These are already written in that +harness, held behind `UPDATE_PIN_SCENARIOS_ENABLED` until the ports land: + +- `leji mounts update-pin --endpoint x` → exit 2, usage error. No endpoint, URL, + host, token or destination parameter exists on this command, by construction. +- `leji mounts update-pin` with no name, and with a surplus positional → exit 2 each. +- `leji mounts update-pin <name> --allow-non-fast-forward` with no `--to` → exit 2: + the override is meaningless without a named target. +- `--to` takes a full 40- or 64-character LOWERCASE hex commit id, in either the + separated or the attached (`--to=<oid>`) spelling. An abbreviation, uppercase hex, + 41 or 63 characters, a missing value, and any non-hex spelling are exit 2 each. +- `--to` and `--allow-non-fast-forward` on any other `mounts` subcommand → exit 2. +- The `mounts` sub-guard: `hydrate`, `status`, `locate` and `update-pin` are the + whole accepted set, and `updatepin`, `update-pins`, `Update-Pin`, `update` and a + bare `mounts` are each rejected. +- `leji mounts update-pin --help` → exit 0, and the help bytes carry no network + vocabulary beyond what `mounts hydrate` already documents. + ## Adding a fixture 1. Create the smallest layer that triggers exactly the finding under test (start from `valid-minimal-core`). -2. Run `leji validate --root fixtures/<name>` with both SDKs; confirm they +2. Run `leji validate --root fixtures/<name>` with all three SDKs; confirm they agree before baking `expected.json`. 3. One failure mode per fixture. A fixture that fires three rules is three fixtures. diff --git a/fixtures/badge/core.md b/fixtures/badge/core.md new file mode 100644 index 0000000..7b91935 --- /dev/null +++ b/fixtures/badge/core.md @@ -0,0 +1 @@ +[![Leji 1.0 · core · self-attested](leji-badge.svg)](https://leji.org/agent-ready/) diff --git a/fixtures/badge/core.svg b/fixtures/badge/core.svg new file mode 100644 index 0000000..0590a82 --- /dev/null +++ b/fixtures/badge/core.svg @@ -0,0 +1,13 @@ +<svg xmlns="http://www.w3.org/2000/svg" role="img" width="102" height="20" aria-label="Leji 1.0 · core · self-attested"> +<title>Leji 1.0 · core · self-attested + + + + + + + +Leji 1.0 +core + + diff --git a/fixtures/badge/federated.md b/fixtures/badge/federated.md new file mode 100644 index 0000000..c9db6ec --- /dev/null +++ b/fixtures/badge/federated.md @@ -0,0 +1 @@ +[![Leji 1.0 · federated · self-attested](leji-badge.svg)](https://leji.org/agent-ready/) diff --git a/fixtures/badge/federated.svg b/fixtures/badge/federated.svg new file mode 100644 index 0000000..cb9e406 --- /dev/null +++ b/fixtures/badge/federated.svg @@ -0,0 +1,13 @@ + +Leji 1.0 · federated · self-attested + + + + + + + +Leji 1.0 +federated + + diff --git a/fixtures/badge/governed.md b/fixtures/badge/governed.md new file mode 100644 index 0000000..397b722 --- /dev/null +++ b/fixtures/badge/governed.md @@ -0,0 +1 @@ +[![Leji 1.0 · governed · self-attested](leji-badge.svg)](https://leji.org/agent-ready/) diff --git a/fixtures/badge/governed.svg b/fixtures/badge/governed.svg new file mode 100644 index 0000000..dfceef2 --- /dev/null +++ b/fixtures/badge/governed.svg @@ -0,0 +1,13 @@ + +Leji 1.0 · governed · self-attested + + + + + + + +Leji 1.0 +governed + + diff --git a/fixtures/badge/indexed.md b/fixtures/badge/indexed.md new file mode 100644 index 0000000..429c420 --- /dev/null +++ b/fixtures/badge/indexed.md @@ -0,0 +1 @@ +[![Leji 1.0 · indexed · self-attested](leji-badge.svg)](https://leji.org/agent-ready/) diff --git a/fixtures/badge/indexed.svg b/fixtures/badge/indexed.svg new file mode 100644 index 0000000..d6be48e --- /dev/null +++ b/fixtures/badge/indexed.svg @@ -0,0 +1,13 @@ + +Leji 1.0 · indexed · self-attested + + + + + + + +Leji 1.0 +indexed + + diff --git a/fixtures/ci-goldens/azure-bun-local.yml b/fixtures/ci-goldens/azure-bun-local.yml new file mode 100644 index 0000000..b511259 --- /dev/null +++ b/fixtures/ci-goldens/azure-bun-local.yml @@ -0,0 +1,17 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: npm install -g bun + displayName: install bun + - script: bun install --frozen-lockfile + displayName: install + - script: bun run leji validate + displayName: leji validate + - script: bun run leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-go-fallback.yml b/fixtures/ci-goldens/azure-go-fallback.yml new file mode 100644 index 0000000..113624e --- /dev/null +++ b/fixtures/ci-goldens/azure-go-fallback.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: GoTool@0 + inputs: + version: '1.24' + - script: go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest + displayName: install + - script: leji validate + displayName: leji validate + - script: leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-go-local.yml b/fixtures/ci-goldens/azure-go-local.yml new file mode 100644 index 0000000..1dd2fba --- /dev/null +++ b/fixtures/ci-goldens/azure-go-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: GoTool@0 + inputs: + version: '1.24' + - script: go mod download + displayName: install + - script: go tool leji validate + displayName: leji validate + - script: go tool leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-node-fallback.yml b/fixtures/ci-goldens/azure-node-fallback.yml new file mode 100644 index 0000000..61f0821 --- /dev/null +++ b/fixtures/ci-goldens/azure-node-fallback.yml @@ -0,0 +1,13 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: npx -y @leji-org/leji@1 validate + displayName: leji validate + - script: npx -y @leji-org/leji@1 index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-npm-local.yml b/fixtures/ci-goldens/azure-npm-local.yml new file mode 100644 index 0000000..15a91f4 --- /dev/null +++ b/fixtures/ci-goldens/azure-npm-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: npm ci + displayName: install + - script: npx --no-install @leji-org/leji validate + displayName: leji validate + - script: npx --no-install @leji-org/leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-pdm-local.yml b/fixtures/ci-goldens/azure-pdm-local.yml new file mode 100644 index 0000000..c56e735 --- /dev/null +++ b/fixtures/ci-goldens/azure-pdm-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.12' + # pdm is installed unpinned here; pin it if your project pins it. + - script: pip install pdm && pdm install + displayName: install + - script: pdm run leji validate + displayName: leji validate + - script: pdm run leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-pipenv-local.yml b/fixtures/ci-goldens/azure-pipenv-local.yml new file mode 100644 index 0000000..7225f3a --- /dev/null +++ b/fixtures/ci-goldens/azure-pipenv-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.12' + # pipenv is installed unpinned here; pin it if your project pins it. + - script: pip install pipenv && pipenv install --dev + displayName: install + - script: pipenv run leji validate + displayName: leji validate + - script: pipenv run leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-pnpm-local.yml b/fixtures/ci-goldens/azure-pnpm-local.yml new file mode 100644 index 0000000..c52fab9 --- /dev/null +++ b/fixtures/ci-goldens/azure-pnpm-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: corepack enable && pnpm install --frozen-lockfile + displayName: install + - script: pnpm exec leji validate + displayName: leji validate + - script: pnpm exec leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-poetry-local.yml b/fixtures/ci-goldens/azure-poetry-local.yml new file mode 100644 index 0000000..ba32c46 --- /dev/null +++ b/fixtures/ci-goldens/azure-poetry-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.12' + # poetry is installed unpinned here; pin it if your project pins it. + - script: pip install poetry && poetry install + displayName: install + - script: poetry run leji validate + displayName: leji validate + - script: poetry run leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-python-fallback.yml b/fixtures/ci-goldens/azure-python-fallback.yml new file mode 100644 index 0000000..aea482f --- /dev/null +++ b/fixtures/ci-goldens/azure-python-fallback.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.12' + - script: pip install 'leji>=1,<2' + displayName: install + - script: leji validate + displayName: leji validate + - script: leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-uv-local.yml b/fixtures/ci-goldens/azure-uv-local.yml new file mode 100644 index 0000000..d3181bb --- /dev/null +++ b/fixtures/ci-goldens/azure-uv-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.12' + # uv is installed unpinned here; pin it if your project pins it. + - script: pip install uv && uv sync --locked + displayName: install + - script: uv run leji validate + displayName: leji validate + - script: uv run leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/azure-yarn-local.yml b/fixtures/ci-goldens/azure-yarn-local.yml new file mode 100644 index 0000000..d1a5a48 --- /dev/null +++ b/fixtures/ci-goldens/azure-yarn-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: corepack enable && yarn install --frozen-lockfile + displayName: install + - script: yarn leji validate + displayName: leji validate + - script: yarn leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/circleci-bun-local.yml b/fixtures/ci-goldens/circleci-bun-local.yml new file mode 100644 index 0000000..1fda0da --- /dev/null +++ b/fixtures/ci-goldens/circleci-bun-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: oven/bun:1 + steps: + - checkout + - run: bun install --frozen-lockfile + - run: bun run leji validate + - run: bun run leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-go-fallback.yml b/fixtures/ci-goldens/circleci-go-fallback.yml new file mode 100644 index 0000000..d3d3269 --- /dev/null +++ b/fixtures/ci-goldens/circleci-go-fallback.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: golang:1.24 + steps: + - checkout + - run: go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest + - run: leji validate + - run: leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-go-local.yml b/fixtures/ci-goldens/circleci-go-local.yml new file mode 100644 index 0000000..bfd297c --- /dev/null +++ b/fixtures/ci-goldens/circleci-go-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: golang:1.24 + steps: + - checkout + - run: go mod download + - run: go tool leji validate + - run: go tool leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-node-fallback.yml b/fixtures/ci-goldens/circleci-node-fallback.yml new file mode 100644 index 0000000..0ddfc62 --- /dev/null +++ b/fixtures/ci-goldens/circleci-node-fallback.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: node:22 + steps: + - checkout + - run: npx -y @leji-org/leji@1 validate + - run: npx -y @leji-org/leji@1 index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-npm-local.yml b/fixtures/ci-goldens/circleci-npm-local.yml new file mode 100644 index 0000000..3e3627d --- /dev/null +++ b/fixtures/ci-goldens/circleci-npm-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: node:22 + steps: + - checkout + - run: npm ci + - run: npx --no-install @leji-org/leji validate + - run: npx --no-install @leji-org/leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-pdm-local.yml b/fixtures/ci-goldens/circleci-pdm-local.yml new file mode 100644 index 0000000..94fb8e2 --- /dev/null +++ b/fixtures/ci-goldens/circleci-pdm-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: python:3.12 + steps: + - checkout + # pdm is installed unpinned here; pin it if your project pins it. + - run: pip install pdm && pdm install + - run: pdm run leji validate + - run: pdm run leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-pipenv-local.yml b/fixtures/ci-goldens/circleci-pipenv-local.yml new file mode 100644 index 0000000..136d885 --- /dev/null +++ b/fixtures/ci-goldens/circleci-pipenv-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: python:3.12 + steps: + - checkout + # pipenv is installed unpinned here; pin it if your project pins it. + - run: pip install pipenv && pipenv install --dev + - run: pipenv run leji validate + - run: pipenv run leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-pnpm-local.yml b/fixtures/ci-goldens/circleci-pnpm-local.yml new file mode 100644 index 0000000..603e897 --- /dev/null +++ b/fixtures/ci-goldens/circleci-pnpm-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: node:22 + steps: + - checkout + - run: corepack enable && pnpm install --frozen-lockfile + - run: pnpm exec leji validate + - run: pnpm exec leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-poetry-local.yml b/fixtures/ci-goldens/circleci-poetry-local.yml new file mode 100644 index 0000000..b4da864 --- /dev/null +++ b/fixtures/ci-goldens/circleci-poetry-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: python:3.12 + steps: + - checkout + # poetry is installed unpinned here; pin it if your project pins it. + - run: pip install poetry && poetry install + - run: poetry run leji validate + - run: poetry run leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-python-fallback.yml b/fixtures/ci-goldens/circleci-python-fallback.yml new file mode 100644 index 0000000..d073888 --- /dev/null +++ b/fixtures/ci-goldens/circleci-python-fallback.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: python:3.12 + steps: + - checkout + - run: pip install 'leji>=1,<2' + - run: leji validate + - run: leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-uv-local.yml b/fixtures/ci-goldens/circleci-uv-local.yml new file mode 100644 index 0000000..612639a --- /dev/null +++ b/fixtures/ci-goldens/circleci-uv-local.yml @@ -0,0 +1,16 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: python:3.12 + steps: + - checkout + # uv is installed unpinned here; pin it if your project pins it. + - run: pip install uv && uv sync --locked + - run: uv run leji validate + - run: uv run leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/circleci-yarn-local.yml b/fixtures/ci-goldens/circleci-yarn-local.yml new file mode 100644 index 0000000..cd89b73 --- /dev/null +++ b/fixtures/ci-goldens/circleci-yarn-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +version: 2.1 +jobs: + leji-validate: + docker: + - image: node:22 + steps: + - checkout + - run: corepack enable && yarn install --frozen-lockfile + - run: yarn leji validate + - run: yarn leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/github-bun-local.yml b/fixtures/ci-goldens/github-bun-local.yml new file mode 100644 index 0000000..f05026c --- /dev/null +++ b/fixtures/ci-goldens/github-bun-local.yml @@ -0,0 +1,12 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run leji validate + - run: bun run leji index --check diff --git a/fixtures/ci-goldens/github-go-fallback.yml b/fixtures/ci-goldens/github-go-fallback.yml new file mode 100644 index 0000000..bd58bb9 --- /dev/null +++ b/fixtures/ci-goldens/github-go-fallback.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - run: go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest + - run: leji validate + - run: leji index --check diff --git a/fixtures/ci-goldens/github-go-local.yml b/fixtures/ci-goldens/github-go-local.yml new file mode 100644 index 0000000..391ce84 --- /dev/null +++ b/fixtures/ci-goldens/github-go-local.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + - run: go mod download + - run: go tool leji validate + - run: go tool leji index --check diff --git a/fixtures/ci-goldens/github-node-fallback.yml b/fixtures/ci-goldens/github-node-fallback.yml new file mode 100644 index 0000000..f7c1879 --- /dev/null +++ b/fixtures/ci-goldens/github-node-fallback.yml @@ -0,0 +1,13 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npx -y @leji-org/leji@1 validate + - run: npx -y @leji-org/leji@1 index --check diff --git a/fixtures/ci-goldens/github-npm-local.yml b/fixtures/ci-goldens/github-npm-local.yml new file mode 100644 index 0000000..52a3f5d --- /dev/null +++ b/fixtures/ci-goldens/github-npm-local.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - run: npx --no-install @leji-org/leji validate + - run: npx --no-install @leji-org/leji index --check diff --git a/fixtures/ci-goldens/github-pdm-local.yml b/fixtures/ci-goldens/github-pdm-local.yml new file mode 100644 index 0000000..7409dbd --- /dev/null +++ b/fixtures/ci-goldens/github-pdm-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + # pdm is installed unpinned here; pin it if your project pins it. + - run: pip install pdm && pdm install + - run: pdm run leji validate + - run: pdm run leji index --check diff --git a/fixtures/ci-goldens/github-pipenv-local.yml b/fixtures/ci-goldens/github-pipenv-local.yml new file mode 100644 index 0000000..d0d5499 --- /dev/null +++ b/fixtures/ci-goldens/github-pipenv-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + # pipenv is installed unpinned here; pin it if your project pins it. + - run: pip install pipenv && pipenv install --dev + - run: pipenv run leji validate + - run: pipenv run leji index --check diff --git a/fixtures/ci-goldens/github-pnpm-local.yml b/fixtures/ci-goldens/github-pnpm-local.yml new file mode 100644 index 0000000..1a74c76 --- /dev/null +++ b/fixtures/ci-goldens/github-pnpm-local.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: corepack enable && pnpm install --frozen-lockfile + - run: pnpm exec leji validate + - run: pnpm exec leji index --check diff --git a/fixtures/ci-goldens/github-poetry-local.yml b/fixtures/ci-goldens/github-poetry-local.yml new file mode 100644 index 0000000..8832374 --- /dev/null +++ b/fixtures/ci-goldens/github-poetry-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + # poetry is installed unpinned here; pin it if your project pins it. + - run: pip install poetry && poetry install + - run: poetry run leji validate + - run: poetry run leji index --check diff --git a/fixtures/ci-goldens/github-python-fallback.yml b/fixtures/ci-goldens/github-python-fallback.yml new file mode 100644 index 0000000..8a7e617 --- /dev/null +++ b/fixtures/ci-goldens/github-python-fallback.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install 'leji>=1,<2' + - run: leji validate + - run: leji index --check diff --git a/fixtures/ci-goldens/github-uv-local.yml b/fixtures/ci-goldens/github-uv-local.yml new file mode 100644 index 0000000..77d636f --- /dev/null +++ b/fixtures/ci-goldens/github-uv-local.yml @@ -0,0 +1,15 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: astral-sh/setup-uv@v5 + - run: uv sync --locked + - run: uv run leji validate + - run: uv run leji index --check diff --git a/fixtures/ci-goldens/github-yarn-local.yml b/fixtures/ci-goldens/github-yarn-local.yml new file mode 100644 index 0000000..723a8a1 --- /dev/null +++ b/fixtures/ci-goldens/github-yarn-local.yml @@ -0,0 +1,14 @@ +# generated by leji ci (managed) v2 +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: corepack enable && yarn install --frozen-lockfile + - run: yarn leji validate + - run: yarn leji index --check diff --git a/fixtures/ci-goldens/gitlab-bun-local.yml b/fixtures/ci-goldens/gitlab-bun-local.yml new file mode 100644 index 0000000..da25954 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-bun-local.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: oven/bun:1 + script: + - bun install --frozen-lockfile + - bun run leji validate + - bun run leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-go-fallback.yml b/fixtures/ci-goldens/gitlab-go-fallback.yml new file mode 100644 index 0000000..20ed563 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-go-fallback.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: golang:1.24 + script: + - go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest + - leji validate + - leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-go-local.yml b/fixtures/ci-goldens/gitlab-go-local.yml new file mode 100644 index 0000000..3b19df4 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-go-local.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: golang:1.24 + script: + - go mod download + - go tool leji validate + - go tool leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-node-fallback.yml b/fixtures/ci-goldens/gitlab-node-fallback.yml new file mode 100644 index 0000000..ab92596 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-node-fallback.yml @@ -0,0 +1,8 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: node:22 + script: + - npx -y @leji-org/leji@1 validate + - npx -y @leji-org/leji@1 index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-npm-local.yml b/fixtures/ci-goldens/gitlab-npm-local.yml new file mode 100644 index 0000000..7fa85bd --- /dev/null +++ b/fixtures/ci-goldens/gitlab-npm-local.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: node:22 + script: + - npm ci + - npx --no-install @leji-org/leji validate + - npx --no-install @leji-org/leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-pdm-local.yml b/fixtures/ci-goldens/gitlab-pdm-local.yml new file mode 100644 index 0000000..5d77420 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-pdm-local.yml @@ -0,0 +1,10 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: python:3.12 + script: + # pdm is installed unpinned here; pin it if your project pins it. + - pip install pdm && pdm install + - pdm run leji validate + - pdm run leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-pipenv-local.yml b/fixtures/ci-goldens/gitlab-pipenv-local.yml new file mode 100644 index 0000000..3834adc --- /dev/null +++ b/fixtures/ci-goldens/gitlab-pipenv-local.yml @@ -0,0 +1,10 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: python:3.12 + script: + # pipenv is installed unpinned here; pin it if your project pins it. + - pip install pipenv && pipenv install --dev + - pipenv run leji validate + - pipenv run leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-pnpm-local.yml b/fixtures/ci-goldens/gitlab-pnpm-local.yml new file mode 100644 index 0000000..cbac8af --- /dev/null +++ b/fixtures/ci-goldens/gitlab-pnpm-local.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: node:22 + script: + - corepack enable && pnpm install --frozen-lockfile + - pnpm exec leji validate + - pnpm exec leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-poetry-local.yml b/fixtures/ci-goldens/gitlab-poetry-local.yml new file mode 100644 index 0000000..4c50caa --- /dev/null +++ b/fixtures/ci-goldens/gitlab-poetry-local.yml @@ -0,0 +1,10 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: python:3.12 + script: + # poetry is installed unpinned here; pin it if your project pins it. + - pip install poetry && poetry install + - poetry run leji validate + - poetry run leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-python-fallback.yml b/fixtures/ci-goldens/gitlab-python-fallback.yml new file mode 100644 index 0000000..c82d399 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-python-fallback.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: python:3.12 + script: + - pip install 'leji>=1,<2' + - leji validate + - leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-uv-local.yml b/fixtures/ci-goldens/gitlab-uv-local.yml new file mode 100644 index 0000000..486f802 --- /dev/null +++ b/fixtures/ci-goldens/gitlab-uv-local.yml @@ -0,0 +1,10 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: python:3.12 + script: + # uv is installed unpinned here; pin it if your project pins it. + - pip install uv && uv sync --locked + - uv run leji validate + - uv run leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/gitlab-yarn-local.yml b/fixtures/ci-goldens/gitlab-yarn-local.yml new file mode 100644 index 0000000..6cf584e --- /dev/null +++ b/fixtures/ci-goldens/gitlab-yarn-local.yml @@ -0,0 +1,9 @@ +# >>> leji ci (managed) >>> +leji-validate: + stage: .pre + image: node:22 + script: + - corepack enable && yarn install --frozen-lockfile + - yarn leji validate + - yarn leji index --check +# <<< leji ci (managed) <<< diff --git a/fixtures/ci-goldens/hook-bun.sh b/fixtures/ci-goldens/hook-bun.sh new file mode 100644 index 0000000..16495ea --- /dev/null +++ b/fixtures/ci-goldens/hook-bun.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'bun' 'run' 'leji' validate || exit 1 +'bun' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-fallback.sh b/fixtures/ci-goldens/hook-fallback.sh new file mode 100644 index 0000000..2b68fcb --- /dev/null +++ b/fixtures/ci-goldens/hook-fallback.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'leji' validate || exit 1 +'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-go.sh b/fixtures/ci-goldens/hook-go.sh new file mode 100644 index 0000000..08f0254 --- /dev/null +++ b/fixtures/ci-goldens/hook-go.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'go' 'tool' 'leji' validate || exit 1 +'go' 'tool' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-npm.sh b/fixtures/ci-goldens/hook-npm.sh new file mode 100644 index 0000000..ed59805 --- /dev/null +++ b/fixtures/ci-goldens/hook-npm.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'npx' '--no-install' '@leji-org/leji' validate || exit 1 +'npx' '--no-install' '@leji-org/leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-pdm.sh b/fixtures/ci-goldens/hook-pdm.sh new file mode 100644 index 0000000..a38ff61 --- /dev/null +++ b/fixtures/ci-goldens/hook-pdm.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'pdm' 'run' 'leji' validate || exit 1 +'pdm' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-pipenv.sh b/fixtures/ci-goldens/hook-pipenv.sh new file mode 100644 index 0000000..470dc57 --- /dev/null +++ b/fixtures/ci-goldens/hook-pipenv.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'pipenv' 'run' 'leji' validate || exit 1 +'pipenv' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-pnpm.sh b/fixtures/ci-goldens/hook-pnpm.sh new file mode 100644 index 0000000..4e19d45 --- /dev/null +++ b/fixtures/ci-goldens/hook-pnpm.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'pnpm' 'exec' 'leji' validate || exit 1 +'pnpm' 'exec' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-poetry.sh b/fixtures/ci-goldens/hook-poetry.sh new file mode 100644 index 0000000..34592e9 --- /dev/null +++ b/fixtures/ci-goldens/hook-poetry.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'poetry' 'run' 'leji' validate || exit 1 +'poetry' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-uv.sh b/fixtures/ci-goldens/hook-uv.sh new file mode 100644 index 0000000..318fbb0 --- /dev/null +++ b/fixtures/ci-goldens/hook-uv.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'uv' 'run' 'leji' validate || exit 1 +'uv' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/hook-yarn.sh b/fixtures/ci-goldens/hook-yarn.sh new file mode 100644 index 0000000..f62609b --- /dev/null +++ b/fixtures/ci-goldens/hook-yarn.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# leji pre-commit (managed) +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +'yarn' 'leji' validate || exit 1 +'yarn' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} diff --git a/fixtures/ci-goldens/husky-bun.sh b/fixtures/ci-goldens/husky-bun.sh new file mode 100644 index 0000000..d1ed299 --- /dev/null +++ b/fixtures/ci-goldens/husky-bun.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'bun' 'run' 'leji' validate || exit 1 +'bun' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-fallback.sh b/fixtures/ci-goldens/husky-fallback.sh new file mode 100644 index 0000000..794f101 --- /dev/null +++ b/fixtures/ci-goldens/husky-fallback.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'leji' validate || exit 1 +'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-go.sh b/fixtures/ci-goldens/husky-go.sh new file mode 100644 index 0000000..d0defe2 --- /dev/null +++ b/fixtures/ci-goldens/husky-go.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'go' 'tool' 'leji' validate || exit 1 +'go' 'tool' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-npm.sh b/fixtures/ci-goldens/husky-npm.sh new file mode 100644 index 0000000..974cd28 --- /dev/null +++ b/fixtures/ci-goldens/husky-npm.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'npx' '--no-install' '@leji-org/leji' validate || exit 1 +'npx' '--no-install' '@leji-org/leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-pdm.sh b/fixtures/ci-goldens/husky-pdm.sh new file mode 100644 index 0000000..9f733d7 --- /dev/null +++ b/fixtures/ci-goldens/husky-pdm.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'pdm' 'run' 'leji' validate || exit 1 +'pdm' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-pipenv.sh b/fixtures/ci-goldens/husky-pipenv.sh new file mode 100644 index 0000000..9d02644 --- /dev/null +++ b/fixtures/ci-goldens/husky-pipenv.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'pipenv' 'run' 'leji' validate || exit 1 +'pipenv' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-pnpm.sh b/fixtures/ci-goldens/husky-pnpm.sh new file mode 100644 index 0000000..498988d --- /dev/null +++ b/fixtures/ci-goldens/husky-pnpm.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'pnpm' 'exec' 'leji' validate || exit 1 +'pnpm' 'exec' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-poetry.sh b/fixtures/ci-goldens/husky-poetry.sh new file mode 100644 index 0000000..b44b3d7 --- /dev/null +++ b/fixtures/ci-goldens/husky-poetry.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'poetry' 'run' 'leji' validate || exit 1 +'poetry' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-uv.sh b/fixtures/ci-goldens/husky-uv.sh new file mode 100644 index 0000000..bf0b759 --- /dev/null +++ b/fixtures/ci-goldens/husky-uv.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'uv' 'run' 'leji' validate || exit 1 +'uv' 'run' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/husky-yarn.sh b/fixtures/ci-goldens/husky-yarn.sh new file mode 100644 index 0000000..4a5be1e --- /dev/null +++ b/fixtures/ci-goldens/husky-yarn.sh @@ -0,0 +1,7 @@ +# >>> leji hooks (managed) >>> +'yarn' 'leji' validate || exit 1 +'yarn' 'leji' index --check || { + echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2 + exit 1 +} +# <<< leji hooks (managed) <<< diff --git a/fixtures/ci-goldens/legacy-1.3-azure-fallback.yml b/fixtures/ci-goldens/legacy-1.3-azure-fallback.yml new file mode 100644 index 0000000..541e632 --- /dev/null +++ b/fixtures/ci-goldens/legacy-1.3-azure-fallback.yml @@ -0,0 +1,12 @@ +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: npx -y @leji-org/leji@1 validate + displayName: leji validate + - script: npx -y @leji-org/leji@1 index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/legacy-1.3-azure-local.yml b/fixtures/ci-goldens/legacy-1.3-azure-local.yml new file mode 100644 index 0000000..0c70249 --- /dev/null +++ b/fixtures/ci-goldens/legacy-1.3-azure-local.yml @@ -0,0 +1,14 @@ +trigger: + - main +pool: + vmImage: ubuntu-latest +steps: + - task: NodeTool@0 + inputs: + versionSpec: '22.x' + - script: npm ci + displayName: install + - script: npx --no-install @leji-org/leji validate + displayName: leji validate + - script: npx --no-install @leji-org/leji index --check + displayName: leji index --check diff --git a/fixtures/ci-goldens/legacy-1.3-circleci-fallback.yml b/fixtures/ci-goldens/legacy-1.3-circleci-fallback.yml new file mode 100644 index 0000000..76b2657 --- /dev/null +++ b/fixtures/ci-goldens/legacy-1.3-circleci-fallback.yml @@ -0,0 +1,13 @@ +version: 2.1 +jobs: + leji-validate: + docker: + - image: node:22 + steps: + - checkout + - run: npx -y @leji-org/leji@1 validate + - run: npx -y @leji-org/leji@1 index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/legacy-1.3-circleci-local.yml b/fixtures/ci-goldens/legacy-1.3-circleci-local.yml new file mode 100644 index 0000000..00356a0 --- /dev/null +++ b/fixtures/ci-goldens/legacy-1.3-circleci-local.yml @@ -0,0 +1,14 @@ +version: 2.1 +jobs: + leji-validate: + docker: + - image: node:22 + steps: + - checkout + - run: npm ci + - run: npx --no-install @leji-org/leji validate + - run: npx --no-install @leji-org/leji index --check +workflows: + leji: + jobs: + - leji-validate diff --git a/fixtures/ci-goldens/legacy-1.3-github-fallback.yml b/fixtures/ci-goldens/legacy-1.3-github-fallback.yml new file mode 100644 index 0000000..d6704a6 --- /dev/null +++ b/fixtures/ci-goldens/legacy-1.3-github-fallback.yml @@ -0,0 +1,12 @@ +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npx -y @leji-org/leji@1 validate + - run: npx -y @leji-org/leji@1 index --check diff --git a/fixtures/ci-goldens/legacy-1.3-github-local.yml b/fixtures/ci-goldens/legacy-1.3-github-local.yml new file mode 100644 index 0000000..c067f3b --- /dev/null +++ b/fixtures/ci-goldens/legacy-1.3-github-local.yml @@ -0,0 +1,13 @@ +name: leji +on: [push, pull_request] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - run: npx --no-install @leji-org/leji validate + - run: npx --no-install @leji-org/leji index --check diff --git a/fixtures/ecosystem/composite-ambiguity/expected.json b/fixtures/ecosystem/composite-ambiguity/expected.json new file mode 100644 index 0000000..19cf5a0 --- /dev/null +++ b/fixtures/ecosystem/composite-ambiguity/expected.json @@ -0,0 +1,58 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "ambiguous-manager", + "manifest": "package.json", + "manager": null, + "source": null, + "evidence": [ + "package-lock.json", + "yarn.lock" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [ + { + "manager": "npm", + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ] + }, + { + "manager": "yarn", + "add": [ + "yarn", + "add", + "-D", + "@leji-org/leji" + ] + } + ] + }, + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pip", + "source": "default", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "multiple-ecosystems" + } +} diff --git a/fixtures/ecosystem/composite-ambiguity/package-lock.json b/fixtures/ecosystem/composite-ambiguity/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/composite-ambiguity/package.json b/fixtures/ecosystem/composite-ambiguity/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/composite-ambiguity/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/composite-ambiguity/pyproject.toml b/fixtures/ecosystem/composite-ambiguity/pyproject.toml new file mode 100644 index 0000000..85c6113 --- /dev/null +++ b/fixtures/ecosystem/composite-ambiguity/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "demo" +version = "0.1.0" diff --git a/fixtures/ecosystem/composite-ambiguity/yarn.lock b/fixtures/ecosystem/composite-ambiguity/yarn.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/go-1.23-legacy/expected.json b/fixtures/ecosystem/go-1.23-legacy/expected.json new file mode 100644 index 0000000..4a33735 --- /dev/null +++ b/fixtures/ecosystem/go-1.23-legacy/expected.json @@ -0,0 +1,37 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go-legacy", + "source": "manifest", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go-legacy", + "source": "manifest", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/go-1.23-legacy/go.mod b/fixtures/ecosystem/go-1.23-legacy/go.mod new file mode 100644 index 0000000..5bcb447 --- /dev/null +++ b/fixtures/ecosystem/go-1.23-legacy/go.mod @@ -0,0 +1,3 @@ +module example.com/demo + +go 1.23 diff --git a/fixtures/ecosystem/go-1.24/expected.json b/fixtures/ecosystem/go-1.24/expected.json new file mode 100644 index 0000000..3c70a4a --- /dev/null +++ b/fixtures/ecosystem/go-1.24/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/go-1.24/go.mod b/fixtures/ecosystem/go-1.24/go.mod new file mode 100644 index 0000000..2f75f40 --- /dev/null +++ b/fixtures/ecosystem/go-1.24/go.mod @@ -0,0 +1,3 @@ +module example.com/demo + +go 1.24.0 diff --git a/fixtures/ecosystem/go-declared-block/expected.json b/fixtures/ecosystem/go-declared-block/expected.json new file mode 100644 index 0000000..793fccd --- /dev/null +++ b/fixtures/ecosystem/go-declared-block/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/go-declared-block/go.mod b/fixtures/ecosystem/go-declared-block/go.mod new file mode 100644 index 0000000..beadf4e --- /dev/null +++ b/fixtures/ecosystem/go-declared-block/go.mod @@ -0,0 +1,8 @@ +module example.com/demo + +go 1.24.0 + +tool ( + github.com/leji-org/leji/packages/sdk-go/cmd/leji + golang.org/x/tools/cmd/stringer +) diff --git a/fixtures/ecosystem/go-declared-line/expected.json b/fixtures/ecosystem/go-declared-line/expected.json new file mode 100644 index 0000000..793fccd --- /dev/null +++ b/fixtures/ecosystem/go-declared-line/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/go-declared-line/go.mod b/fixtures/ecosystem/go-declared-line/go.mod new file mode 100644 index 0000000..5c4fc03 --- /dev/null +++ b/fixtures/ecosystem/go-declared-line/go.mod @@ -0,0 +1,5 @@ +module example.com/demo + +go 1.24.0 + +tool github.com/leji-org/leji/packages/sdk-go/cmd/leji diff --git a/fixtures/ecosystem/go-no-directive/expected.json b/fixtures/ecosystem/go-no-directive/expected.json new file mode 100644 index 0000000..4a33735 --- /dev/null +++ b/fixtures/ecosystem/go-no-directive/expected.json @@ -0,0 +1,37 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go-legacy", + "source": "manifest", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go-legacy", + "source": "manifest", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/go-no-directive/go.mod b/fixtures/ecosystem/go-no-directive/go.mod new file mode 100644 index 0000000..72b43e6 --- /dev/null +++ b/fixtures/ecosystem/go-no-directive/go.mod @@ -0,0 +1 @@ +module example.com/demo diff --git a/fixtures/ecosystem/multiple-ecosystems/expected.json b/fixtures/ecosystem/multiple-ecosystems/expected.json new file mode 100644 index 0000000..1756c6f --- /dev/null +++ b/fixtures/ecosystem/multiple-ecosystems/expected.json @@ -0,0 +1,56 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "lockfile", + "evidence": [ + "package-lock.json" + ], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": "multiple-ecosystems" + } +} diff --git a/fixtures/ecosystem/multiple-ecosystems/package-lock.json b/fixtures/ecosystem/multiple-ecosystems/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/multiple-ecosystems/package.json b/fixtures/ecosystem/multiple-ecosystems/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/multiple-ecosystems/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/multiple-ecosystems/pyproject.toml b/fixtures/ecosystem/multiple-ecosystems/pyproject.toml new file mode 100644 index 0000000..85c6113 --- /dev/null +++ b/fixtures/ecosystem/multiple-ecosystems/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "demo" +version = "0.1.0" diff --git a/fixtures/ecosystem/multiple-ecosystems/uv.lock b/fixtures/ecosystem/multiple-ecosystems/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-bun-lock/bun.lock b/fixtures/ecosystem/node-bun-lock/bun.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-bun-lock/expected.json b/fixtures/ecosystem/node-bun-lock/expected.json new file mode 100644 index 0000000..7fbb1ab --- /dev/null +++ b/fixtures/ecosystem/node-bun-lock/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "bun", + "source": "lockfile", + "evidence": [ + "bun.lock" + ], + "add": [ + "bun", + "add", + "-d", + "@leji-org/leji" + ], + "runner": [ + "bun", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "bun", + "source": "lockfile", + "evidence": [ + "bun.lock" + ], + "add": [ + "bun", + "add", + "-d", + "@leji-org/leji" + ], + "runner": [ + "bun", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-bun-lock/package.json b/fixtures/ecosystem/node-bun-lock/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-bun-lock/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-bun-lockb/bun.lockb b/fixtures/ecosystem/node-bun-lockb/bun.lockb new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-bun-lockb/expected.json b/fixtures/ecosystem/node-bun-lockb/expected.json new file mode 100644 index 0000000..ca9c510 --- /dev/null +++ b/fixtures/ecosystem/node-bun-lockb/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "bun", + "source": "lockfile", + "evidence": [ + "bun.lockb" + ], + "add": [ + "bun", + "add", + "-d", + "@leji-org/leji" + ], + "runner": [ + "bun", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "bun", + "source": "lockfile", + "evidence": [ + "bun.lockb" + ], + "add": [ + "bun", + "add", + "-d", + "@leji-org/leji" + ], + "runner": [ + "bun", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-bun-lockb/package.json b/fixtures/ecosystem/node-bun-lockb/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-bun-lockb/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-declared/expected.json b/fixtures/ecosystem/node-declared/expected.json new file mode 100644 index 0000000..0a21f1e --- /dev/null +++ b/fixtures/ecosystem/node-declared/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "lockfile", + "evidence": [ + "package-lock.json" + ], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "lockfile", + "evidence": [ + "package-lock.json" + ], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-declared/package-lock.json b/fixtures/ecosystem/node-declared/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-declared/package.json b/fixtures/ecosystem/node-declared/package.json new file mode 100644 index 0000000..fa28185 --- /dev/null +++ b/fixtures/ecosystem/node-declared/package.json @@ -0,0 +1,7 @@ +{ + "name": "demo", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1.3.1" + } +} diff --git a/fixtures/ecosystem/node-no-lock-default/expected.json b/fixtures/ecosystem/node-no-lock-default/expected.json new file mode 100644 index 0000000..4bae1f0 --- /dev/null +++ b/fixtures/ecosystem/node-no-lock-default/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "default", + "evidence": [], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "default", + "evidence": [], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-no-lock-default/package.json b/fixtures/ecosystem/node-no-lock-default/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-no-lock-default/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-npm-lock/expected.json b/fixtures/ecosystem/node-npm-lock/expected.json new file mode 100644 index 0000000..952b431 --- /dev/null +++ b/fixtures/ecosystem/node-npm-lock/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "lockfile", + "evidence": [ + "package-lock.json" + ], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "npm", + "source": "lockfile", + "evidence": [ + "package-lock.json" + ], + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ], + "runner": [ + "npx", + "--no-install", + "@leji-org/leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-npm-lock/package-lock.json b/fixtures/ecosystem/node-npm-lock/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-npm-lock/package.json b/fixtures/ecosystem/node-npm-lock/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-npm-lock/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-packagemanager-malformed-lock/expected.json b/fixtures/ecosystem/node-packagemanager-malformed-lock/expected.json new file mode 100644 index 0000000..407f584 --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-malformed-lock/expected.json @@ -0,0 +1,23 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "unsupported-manager", + "manifest": "package.json", + "manager": null, + "source": "packageManager", + "evidence": [ + "pnpm-lock.yaml" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "unsupported-manager" + } +} diff --git a/fixtures/ecosystem/node-packagemanager-malformed-lock/package.json b/fixtures/ecosystem/node-packagemanager-malformed-lock/package.json new file mode 100644 index 0000000..27424af --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-malformed-lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "demo", + "private": true, + "packageManager": "pnpm@@9" +} diff --git a/fixtures/ecosystem/node-packagemanager-malformed-lock/pnpm-lock.yaml b/fixtures/ecosystem/node-packagemanager-malformed-lock/pnpm-lock.yaml new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-packagemanager-malformed/expected.json b/fixtures/ecosystem/node-packagemanager-malformed/expected.json new file mode 100644 index 0000000..598d48e --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-malformed/expected.json @@ -0,0 +1,21 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "unsupported-manager", + "manifest": "package.json", + "manager": null, + "source": "packageManager", + "evidence": [], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "unsupported-manager" + } +} diff --git a/fixtures/ecosystem/node-packagemanager-malformed/package.json b/fixtures/ecosystem/node-packagemanager-malformed/package.json new file mode 100644 index 0000000..27424af --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-malformed/package.json @@ -0,0 +1,5 @@ +{ + "name": "demo", + "private": true, + "packageManager": "pnpm@@9" +} diff --git a/fixtures/ecosystem/node-packagemanager-over-lock/expected.json b/fixtures/ecosystem/node-packagemanager-over-lock/expected.json new file mode 100644 index 0000000..3e0384e --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-over-lock/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "pnpm", + "source": "packageManager", + "evidence": [ + "yarn.lock" + ], + "add": [ + "pnpm", + "add", + "-D", + "@leji-org/leji" + ], + "runner": [ + "pnpm", + "exec", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "pnpm", + "source": "packageManager", + "evidence": [ + "yarn.lock" + ], + "add": [ + "pnpm", + "add", + "-D", + "@leji-org/leji" + ], + "runner": [ + "pnpm", + "exec", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-packagemanager-over-lock/package.json b/fixtures/ecosystem/node-packagemanager-over-lock/package.json new file mode 100644 index 0000000..fee0a83 --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-over-lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "demo", + "private": true, + "packageManager": "pnpm@9.12.0" +} diff --git a/fixtures/ecosystem/node-packagemanager-over-lock/yarn.lock b/fixtures/ecosystem/node-packagemanager-over-lock/yarn.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-packagemanager-unknown-lock/expected.json b/fixtures/ecosystem/node-packagemanager-unknown-lock/expected.json new file mode 100644 index 0000000..392b513 --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-unknown-lock/expected.json @@ -0,0 +1,23 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "unsupported-manager", + "manifest": "package.json", + "manager": null, + "source": "packageManager", + "evidence": [ + "package-lock.json" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "unsupported-manager" + } +} diff --git a/fixtures/ecosystem/node-packagemanager-unknown-lock/package-lock.json b/fixtures/ecosystem/node-packagemanager-unknown-lock/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-packagemanager-unknown-lock/package.json b/fixtures/ecosystem/node-packagemanager-unknown-lock/package.json new file mode 100644 index 0000000..24a9a5d --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-unknown-lock/package.json @@ -0,0 +1,5 @@ +{ + "name": "demo", + "private": true, + "packageManager": "hermit@1.0.0" +} diff --git a/fixtures/ecosystem/node-packagemanager-unknown/expected.json b/fixtures/ecosystem/node-packagemanager-unknown/expected.json new file mode 100644 index 0000000..598d48e --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-unknown/expected.json @@ -0,0 +1,21 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "unsupported-manager", + "manifest": "package.json", + "manager": null, + "source": "packageManager", + "evidence": [], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "unsupported-manager" + } +} diff --git a/fixtures/ecosystem/node-packagemanager-unknown/package.json b/fixtures/ecosystem/node-packagemanager-unknown/package.json new file mode 100644 index 0000000..24a9a5d --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-unknown/package.json @@ -0,0 +1,5 @@ +{ + "name": "demo", + "private": true, + "packageManager": "hermit@1.0.0" +} diff --git a/fixtures/ecosystem/node-packagemanager-versioned/expected.json b/fixtures/ecosystem/node-packagemanager-versioned/expected.json new file mode 100644 index 0000000..f1e3d08 --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-versioned/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "bun", + "source": "packageManager", + "evidence": [], + "add": [ + "bun", + "add", + "-d", + "@leji-org/leji" + ], + "runner": [ + "bun", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "bun", + "source": "packageManager", + "evidence": [], + "add": [ + "bun", + "add", + "-d", + "@leji-org/leji" + ], + "runner": [ + "bun", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-packagemanager-versioned/package.json b/fixtures/ecosystem/node-packagemanager-versioned/package.json new file mode 100644 index 0000000..47fba8c --- /dev/null +++ b/fixtures/ecosystem/node-packagemanager-versioned/package.json @@ -0,0 +1,5 @@ +{ + "name": "demo", + "private": true, + "packageManager": "bun@1.1.30+e1f2a3b4c5" +} diff --git a/fixtures/ecosystem/node-pnpm-lock/expected.json b/fixtures/ecosystem/node-pnpm-lock/expected.json new file mode 100644 index 0000000..5ca61aa --- /dev/null +++ b/fixtures/ecosystem/node-pnpm-lock/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "pnpm", + "source": "lockfile", + "evidence": [ + "pnpm-lock.yaml" + ], + "add": [ + "pnpm", + "add", + "-D", + "@leji-org/leji" + ], + "runner": [ + "pnpm", + "exec", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "pnpm", + "source": "lockfile", + "evidence": [ + "pnpm-lock.yaml" + ], + "add": [ + "pnpm", + "add", + "-D", + "@leji-org/leji" + ], + "runner": [ + "pnpm", + "exec", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-pnpm-lock/package.json b/fixtures/ecosystem/node-pnpm-lock/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-pnpm-lock/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-pnpm-lock/pnpm-lock.yaml b/fixtures/ecosystem/node-pnpm-lock/pnpm-lock.yaml new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-refused-dangling/expected.json b/fixtures/ecosystem/node-refused-dangling/expected.json new file mode 100644 index 0000000..26a4291 --- /dev/null +++ b/fixtures/ecosystem/node-refused-dangling/expected.json @@ -0,0 +1,23 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "refused-evidence", + "manifest": "package.json", + "manager": null, + "source": null, + "evidence": [ + "package.json" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "refused-evidence" + } +} diff --git a/fixtures/ecosystem/node-refused-dangling/package.json b/fixtures/ecosystem/node-refused-dangling/package.json new file mode 120000 index 0000000..f72ea2c --- /dev/null +++ b/fixtures/ecosystem/node-refused-dangling/package.json @@ -0,0 +1 @@ +./package.real.json \ No newline at end of file diff --git a/fixtures/ecosystem/node-refused-evidence/expected.json b/fixtures/ecosystem/node-refused-evidence/expected.json new file mode 100644 index 0000000..26a4291 --- /dev/null +++ b/fixtures/ecosystem/node-refused-evidence/expected.json @@ -0,0 +1,23 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "refused-evidence", + "manifest": "package.json", + "manager": null, + "source": null, + "evidence": [ + "package.json" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "refused-evidence" + } +} diff --git a/fixtures/ecosystem/node-refused-evidence/package.json b/fixtures/ecosystem/node-refused-evidence/package.json new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/fixtures/ecosystem/node-refused-evidence/package.json @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/fixtures/ecosystem/node-two-lockfiles/expected.json b/fixtures/ecosystem/node-two-lockfiles/expected.json new file mode 100644 index 0000000..1c529a5 --- /dev/null +++ b/fixtures/ecosystem/node-two-lockfiles/expected.json @@ -0,0 +1,43 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "ambiguous-manager", + "manifest": "package.json", + "manager": null, + "source": null, + "evidence": [ + "package-lock.json", + "yarn.lock" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [ + { + "manager": "npm", + "add": [ + "npm", + "i", + "-D", + "@leji-org/leji" + ] + }, + { + "manager": "yarn", + "add": [ + "yarn", + "add", + "-D", + "@leji-org/leji" + ] + } + ] + } + ], + "reason": "ambiguous-manager" + } +} diff --git a/fixtures/ecosystem/node-two-lockfiles/package-lock.json b/fixtures/ecosystem/node-two-lockfiles/package-lock.json new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-two-lockfiles/package.json b/fixtures/ecosystem/node-two-lockfiles/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-two-lockfiles/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-two-lockfiles/yarn.lock b/fixtures/ecosystem/node-two-lockfiles/yarn.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/node-unreadable-manifest/expected.json b/fixtures/ecosystem/node-unreadable-manifest/expected.json new file mode 100644 index 0000000..e7e7df8 --- /dev/null +++ b/fixtures/ecosystem/node-unreadable-manifest/expected.json @@ -0,0 +1,21 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "node", + "status": "unreadable-manifest", + "manifest": "package.json", + "manager": null, + "source": null, + "evidence": [], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": "unreadable-manifest" + } +} diff --git a/fixtures/ecosystem/node-unreadable-manifest/package.json b/fixtures/ecosystem/node-unreadable-manifest/package.json new file mode 100644 index 0000000..75a2dff --- /dev/null +++ b/fixtures/ecosystem/node-unreadable-manifest/package.json @@ -0,0 +1 @@ +{ "name": "demo", diff --git a/fixtures/ecosystem/node-yarn-lock/expected.json b/fixtures/ecosystem/node-yarn-lock/expected.json new file mode 100644 index 0000000..6a9290b --- /dev/null +++ b/fixtures/ecosystem/node-yarn-lock/expected.json @@ -0,0 +1,53 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "yarn", + "source": "lockfile", + "evidence": [ + "yarn.lock" + ], + "add": [ + "yarn", + "add", + "-D", + "@leji-org/leji" + ], + "runner": [ + "yarn", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "node", + "status": "ok", + "manifest": "package.json", + "manager": "yarn", + "source": "lockfile", + "evidence": [ + "yarn.lock" + ], + "add": [ + "yarn", + "add", + "-D", + "@leji-org/leji" + ], + "runner": [ + "yarn", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/node-yarn-lock/package.json b/fixtures/ecosystem/node-yarn-lock/package.json new file mode 100644 index 0000000..6b81a33 --- /dev/null +++ b/fixtures/ecosystem/node-yarn-lock/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo", + "private": true +} diff --git a/fixtures/ecosystem/node-yarn-lock/yarn.lock b/fixtures/ecosystem/node-yarn-lock/yarn.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/none/README.md b/fixtures/ecosystem/none/README.md new file mode 100644 index 0000000..75b18d2 --- /dev/null +++ b/fixtures/ecosystem/none/README.md @@ -0,0 +1,3 @@ +# No manifest here + +Nothing gates an ecosystem in this root. diff --git a/fixtures/ecosystem/none/expected.json b/fixtures/ecosystem/none/expected.json new file mode 100644 index 0000000..07b4c92 --- /dev/null +++ b/fixtures/ecosystem/none/expected.json @@ -0,0 +1,7 @@ +{ + "ecosystem": { + "selected": null, + "all": [], + "reason": "none" + } +} diff --git a/fixtures/ecosystem/python-bare-pyproject/expected.json b/fixtures/ecosystem/python-bare-pyproject/expected.json new file mode 100644 index 0000000..29ea441 --- /dev/null +++ b/fixtures/ecosystem/python-bare-pyproject/expected.json @@ -0,0 +1,37 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pip", + "source": "default", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pip", + "source": "default", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-bare-pyproject/pyproject.toml b/fixtures/ecosystem/python-bare-pyproject/pyproject.toml new file mode 100644 index 0000000..85c6113 --- /dev/null +++ b/fixtures/ecosystem/python-bare-pyproject/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "demo" +version = "0.1.0" diff --git a/fixtures/ecosystem/python-declared-one-line-array/expected.json b/fixtures/ecosystem/python-declared-one-line-array/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/python-declared-one-line-array/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-declared-one-line-array/pyproject.toml b/fixtures/ecosystem/python-declared-one-line-array/pyproject.toml new file mode 100644 index 0000000..7ff4866 --- /dev/null +++ b/fixtures/ecosystem/python-declared-one-line-array/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "demo" +version = "0.1.0" +dependencies = ["requests", "leji[extra]>=1"] diff --git a/fixtures/ecosystem/python-declared-one-line-array/uv.lock b/fixtures/ecosystem/python-declared-one-line-array/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-declared-poetry-group/expected.json b/fixtures/ecosystem/python-declared-poetry-group/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/python-declared-poetry-group/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-declared-poetry-group/poetry.lock b/fixtures/ecosystem/python-declared-poetry-group/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-declared-poetry-group/pyproject.toml b/fixtures/ecosystem/python-declared-poetry-group/pyproject.toml new file mode 100644 index 0000000..8382823 --- /dev/null +++ b/fixtures/ecosystem/python-declared-poetry-group/pyproject.toml @@ -0,0 +1,7 @@ +[tool.poetry] +name = "demo" +version = "0.1.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0" +"leji" = "^1.3" diff --git a/fixtures/ecosystem/python-declared-pyproject-groups/expected.json b/fixtures/ecosystem/python-declared-pyproject-groups/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/python-declared-pyproject-groups/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-declared-pyproject-groups/pyproject.toml b/fixtures/ecosystem/python-declared-pyproject-groups/pyproject.toml new file mode 100644 index 0000000..3788a3a --- /dev/null +++ b/fixtures/ecosystem/python-declared-pyproject-groups/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "demo" +version = "0.1.0" + +[dependency-groups] +dev = [ + "pytest", + "leji>=1", +] diff --git a/fixtures/ecosystem/python-declared-pyproject-groups/uv.lock b/fixtures/ecosystem/python-declared-pyproject-groups/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-false-positive-comment/expected.json b/fixtures/ecosystem/python-false-positive-comment/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/python-false-positive-comment/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-false-positive-comment/pyproject.toml b/fixtures/ecosystem/python-false-positive-comment/pyproject.toml new file mode 100644 index 0000000..656cf30 --- /dev/null +++ b/fixtures/ecosystem/python-false-positive-comment/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "demo" +version = "0.1.0" +dependencies = [ + # leji + "requests", +] + +[project.optional-dependencies] +# leji +test = ["pytest"] # leji + +[dependency-groups] +dev = [ + # leji>=1 +] + +[tool.uv] +# leji = "1" +dev-dependencies = [] diff --git a/fixtures/ecosystem/python-false-positive-comment/uv.lock b/fixtures/ecosystem/python-false-positive-comment/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-false-positive-description/expected.json b/fixtures/ecosystem/python-false-positive-description/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/python-false-positive-description/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-false-positive-description/pyproject.toml b/fixtures/ecosystem/python-false-positive-description/pyproject.toml new file mode 100644 index 0000000..4d65316 --- /dev/null +++ b/fixtures/ecosystem/python-false-positive-description/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "demo" +version = "0.1.0" +description = "leji keeps the context layer honest" +keywords = ["leji", "context"] +readme = """ +leji is described here, not depended on. +""" +license = ''' +leji appears here too. +''' +dependencies = ["requests"] + +[tool.black] +leji = "not a dependency table" + +[tool.uv] +package = false diff --git a/fixtures/ecosystem/python-false-positive-description/uv.lock b/fixtures/ecosystem/python-false-positive-description/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-pdm/expected.json b/fixtures/ecosystem/python-pdm/expected.json new file mode 100644 index 0000000..943856a --- /dev/null +++ b/fixtures/ecosystem/python-pdm/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-pdm/pdm.lock b/fixtures/ecosystem/python-pdm/pdm.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-pdm/pyproject.toml b/fixtures/ecosystem/python-pdm/pyproject.toml new file mode 100644 index 0000000..b227edb --- /dev/null +++ b/fixtures/ecosystem/python-pdm/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "demo" +version = "0.1.0" + +[tool.pdm] +distribution = false diff --git a/fixtures/ecosystem/python-pipenv/Pipfile b/fixtures/ecosystem/python-pipenv/Pipfile new file mode 100644 index 0000000..0a5acd5 --- /dev/null +++ b/fixtures/ecosystem/python-pipenv/Pipfile @@ -0,0 +1,6 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" + +[dev-packages] +leji = "*" diff --git a/fixtures/ecosystem/python-pipenv/Pipfile.lock b/fixtures/ecosystem/python-pipenv/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-pipenv/expected.json b/fixtures/ecosystem/python-pipenv/expected.json new file mode 100644 index 0000000..3104501 --- /dev/null +++ b/fixtures/ecosystem/python-pipenv/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-pipfile-only/Pipfile b/fixtures/ecosystem/python-pipfile-only/Pipfile new file mode 100644 index 0000000..c0ad949 --- /dev/null +++ b/fixtures/ecosystem/python-pipfile-only/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[dev-packages] +leji = "*" diff --git a/fixtures/ecosystem/python-pipfile-only/expected.json b/fixtures/ecosystem/python-pipfile-only/expected.json new file mode 100644 index 0000000..5bbfe7b --- /dev/null +++ b/fixtures/ecosystem/python-pipfile-only/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "manifest", + "evidence": [ + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "manifest", + "evidence": [ + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-poetry/expected.json b/fixtures/ecosystem/python-poetry/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/python-poetry/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-poetry/poetry.lock b/fixtures/ecosystem/python-poetry/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-poetry/pyproject.toml b/fixtures/ecosystem/python-poetry/pyproject.toml new file mode 100644 index 0000000..e6826fe --- /dev/null +++ b/fixtures/ecosystem/python-poetry/pyproject.toml @@ -0,0 +1,3 @@ +[tool.poetry] +name = "demo" +version = "0.1.0" diff --git a/fixtures/ecosystem/python-requirements-multi/expected.json b/fixtures/ecosystem/python-requirements-multi/expected.json new file mode 100644 index 0000000..4a8d304 --- /dev/null +++ b/fixtures/ecosystem/python-requirements-multi/expected.json @@ -0,0 +1,45 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-Test.txt", + "requirements-dev.txt", + "requirements.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-Test.txt", + "requirements-dev.txt", + "requirements.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-requirements-multi/requirements-Test.txt b/fixtures/ecosystem/python-requirements-multi/requirements-Test.txt new file mode 100644 index 0000000..4ebc8ae --- /dev/null +++ b/fixtures/ecosystem/python-requirements-multi/requirements-Test.txt @@ -0,0 +1 @@ +coverage diff --git a/fixtures/ecosystem/python-requirements-multi/requirements-dev.txt b/fixtures/ecosystem/python-requirements-multi/requirements-dev.txt new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/fixtures/ecosystem/python-requirements-multi/requirements-dev.txt @@ -0,0 +1 @@ +pytest diff --git a/fixtures/ecosystem/python-requirements-multi/requirements.txt b/fixtures/ecosystem/python-requirements-multi/requirements.txt new file mode 100644 index 0000000..cf56d5b --- /dev/null +++ b/fixtures/ecosystem/python-requirements-multi/requirements.txt @@ -0,0 +1 @@ +requests>=2 diff --git a/fixtures/ecosystem/python-requirements-only/expected.json b/fixtures/ecosystem/python-requirements-only/expected.json new file mode 100644 index 0000000..e8c8da3 --- /dev/null +++ b/fixtures/ecosystem/python-requirements-only/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-requirements-only/requirements.txt b/fixtures/ecosystem/python-requirements-only/requirements.txt new file mode 100644 index 0000000..cf56d5b --- /dev/null +++ b/fixtures/ecosystem/python-requirements-only/requirements.txt @@ -0,0 +1 @@ +requests>=2 diff --git a/fixtures/ecosystem/python-tool-poetry-no-lock/expected.json b/fixtures/ecosystem/python-tool-poetry-no-lock/expected.json new file mode 100644 index 0000000..51c4359 --- /dev/null +++ b/fixtures/ecosystem/python-tool-poetry-no-lock/expected.json @@ -0,0 +1,53 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "tool-table", + "evidence": [], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "tool-table", + "evidence": [], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-tool-poetry-no-lock/pyproject.toml b/fixtures/ecosystem/python-tool-poetry-no-lock/pyproject.toml new file mode 100644 index 0000000..e6826fe --- /dev/null +++ b/fixtures/ecosystem/python-tool-poetry-no-lock/pyproject.toml @@ -0,0 +1,3 @@ +[tool.poetry] +name = "demo" +version = "0.1.0" diff --git a/fixtures/ecosystem/python-tool-uv-no-lock/expected.json b/fixtures/ecosystem/python-tool-uv-no-lock/expected.json new file mode 100644 index 0000000..6fb1f50 --- /dev/null +++ b/fixtures/ecosystem/python-tool-uv-no-lock/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "tool-table", + "evidence": [], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "tool-table", + "evidence": [], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-tool-uv-no-lock/pyproject.toml b/fixtures/ecosystem/python-tool-uv-no-lock/pyproject.toml new file mode 100644 index 0000000..060ad90 --- /dev/null +++ b/fixtures/ecosystem/python-tool-uv-no-lock/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "demo" +version = "0.1.0" + +[tool.uv] +dev-dependencies = ["ruff"] diff --git a/fixtures/ecosystem/python-two-locks/expected.json b/fixtures/ecosystem/python-two-locks/expected.json new file mode 100644 index 0000000..9d97eec --- /dev/null +++ b/fixtures/ecosystem/python-two-locks/expected.json @@ -0,0 +1,44 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "python", + "status": "ambiguous-manager", + "manifest": "pyproject.toml", + "manager": null, + "source": null, + "evidence": [ + "uv.lock", + "poetry.lock" + ], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [ + { + "manager": "uv", + "add": [ + "uv", + "add", + "--dev", + "leji" + ] + }, + { + "manager": "poetry", + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ] + } + ] + } + ], + "reason": "ambiguous-manager" + } +} diff --git a/fixtures/ecosystem/python-two-locks/poetry.lock b/fixtures/ecosystem/python-two-locks/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-two-locks/pyproject.toml b/fixtures/ecosystem/python-two-locks/pyproject.toml new file mode 100644 index 0000000..85c6113 --- /dev/null +++ b/fixtures/ecosystem/python-two-locks/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "demo" +version = "0.1.0" diff --git a/fixtures/ecosystem/python-two-locks/uv.lock b/fixtures/ecosystem/python-two-locks/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/python-two-tool-tables/expected.json b/fixtures/ecosystem/python-two-tool-tables/expected.json new file mode 100644 index 0000000..5bd7124 --- /dev/null +++ b/fixtures/ecosystem/python-two-tool-tables/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": null, + "all": [ + { + "ecosystem": "python", + "status": "ambiguous-manager", + "manifest": "pyproject.toml", + "manager": null, + "source": null, + "evidence": [], + "add": null, + "runner": null, + "directDeclared": false, + "lockEvidenced": false, + "candidates": [ + { + "manager": "uv", + "add": [ + "uv", + "add", + "--dev", + "leji" + ] + }, + { + "manager": "poetry", + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ] + } + ] + } + ], + "reason": "ambiguous-manager" + } +} diff --git a/fixtures/ecosystem/python-two-tool-tables/pyproject.toml b/fixtures/ecosystem/python-two-tool-tables/pyproject.toml new file mode 100644 index 0000000..22c3c56 --- /dev/null +++ b/fixtures/ecosystem/python-two-tool-tables/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "demo" +version = "0.1.0" + +[tool.uv] +dev-dependencies = ["ruff"] + +[tool.poetry] +name = "demo" diff --git a/fixtures/ecosystem/python-uv/expected.json b/fixtures/ecosystem/python-uv/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/python-uv/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/python-uv/pyproject.toml b/fixtures/ecosystem/python-uv/pyproject.toml new file mode 100644 index 0000000..76dca38 --- /dev/null +++ b/fixtures/ecosystem/python-uv/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "demo" +version = "0.1.0" +dependencies = ["requests"] diff --git a/fixtures/ecosystem/python-uv/uv.lock b/fixtures/ecosystem/python-uv/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-dependency-groups-absent/expected.json b/fixtures/ecosystem/scan-dependency-groups-absent/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-absent/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-dependency-groups-absent/pyproject.toml b/fixtures/ecosystem/scan-dependency-groups-absent/pyproject.toml new file mode 100644 index 0000000..87495a7 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-absent/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[dependency-groups] +dev = ["pytest"] diff --git a/fixtures/ecosystem/scan-dependency-groups-absent/uv.lock b/fixtures/ecosystem/scan-dependency-groups-absent/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-dependency-groups-comment/expected.json b/fixtures/ecosystem/scan-dependency-groups-comment/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-comment/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-dependency-groups-comment/pyproject.toml b/fixtures/ecosystem/scan-dependency-groups-comment/pyproject.toml new file mode 100644 index 0000000..c63fc78 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-comment/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "d" +version = "0" + +[dependency-groups] +dev = [ + # "leji", +] diff --git a/fixtures/ecosystem/scan-dependency-groups-comment/uv.lock b/fixtures/ecosystem/scan-dependency-groups-comment/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-dependency-groups-declared/expected.json b/fixtures/ecosystem/scan-dependency-groups-declared/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-declared/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-dependency-groups-declared/pyproject.toml b/fixtures/ecosystem/scan-dependency-groups-declared/pyproject.toml new file mode 100644 index 0000000..d4e4c91 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-declared/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "d" +version = "0" + +[dependency-groups] +dev = [ + "pytest", + "leji ; python_version >= '3.11'", +] diff --git a/fixtures/ecosystem/scan-dependency-groups-declared/uv.lock b/fixtures/ecosystem/scan-dependency-groups-declared/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-dependency-groups-triple-quoted/expected.json b/fixtures/ecosystem/scan-dependency-groups-triple-quoted/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-triple-quoted/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-dependency-groups-triple-quoted/pyproject.toml b/fixtures/ecosystem/scan-dependency-groups-triple-quoted/pyproject.toml new file mode 100644 index 0000000..08254f8 --- /dev/null +++ b/fixtures/ecosystem/scan-dependency-groups-triple-quoted/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[dependency-groups] +dev = ["""leji"""] diff --git a/fixtures/ecosystem/scan-dependency-groups-triple-quoted/uv.lock b/fixtures/ecosystem/scan-dependency-groups-triple-quoted/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-go-1.25/expected.json b/fixtures/ecosystem/scan-go-1.25/expected.json new file mode 100644 index 0000000..3c70a4a --- /dev/null +++ b/fixtures/ecosystem/scan-go-1.25/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-go-1.25/go.mod b/fixtures/ecosystem/scan-go-1.25/go.mod new file mode 100644 index 0000000..91f0e5e --- /dev/null +++ b/fixtures/ecosystem/scan-go-1.25/go.mod @@ -0,0 +1,3 @@ +module example.com/d + +go 1.25.1 diff --git a/fixtures/ecosystem/scan-go-1.9/expected.json b/fixtures/ecosystem/scan-go-1.9/expected.json new file mode 100644 index 0000000..4a33735 --- /dev/null +++ b/fixtures/ecosystem/scan-go-1.9/expected.json @@ -0,0 +1,37 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go-legacy", + "source": "manifest", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go-legacy", + "source": "manifest", + "evidence": [], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-go-1.9/go.mod b/fixtures/ecosystem/scan-go-1.9/go.mod new file mode 100644 index 0000000..3816cbb --- /dev/null +++ b/fixtures/ecosystem/scan-go-1.9/go.mod @@ -0,0 +1,3 @@ +module example.com/d + +go 1.9 diff --git a/fixtures/ecosystem/scan-go-2.0/expected.json b/fixtures/ecosystem/scan-go-2.0/expected.json new file mode 100644 index 0000000..793fccd --- /dev/null +++ b/fixtures/ecosystem/scan-go-2.0/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-go-2.0/go.mod b/fixtures/ecosystem/scan-go-2.0/go.mod new file mode 100644 index 0000000..258e078 --- /dev/null +++ b/fixtures/ecosystem/scan-go-2.0/go.mod @@ -0,0 +1,5 @@ +module example.com/d + +go 2.0 + +tool github.com/leji-org/leji/packages/sdk-go/cmd/leji diff --git a/fixtures/ecosystem/scan-go-closed-block/expected.json b/fixtures/ecosystem/scan-go-closed-block/expected.json new file mode 100644 index 0000000..3c70a4a --- /dev/null +++ b/fixtures/ecosystem/scan-go-closed-block/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-go-closed-block/go.mod b/fixtures/ecosystem/scan-go-closed-block/go.mod new file mode 100644 index 0000000..17c1c0a --- /dev/null +++ b/fixtures/ecosystem/scan-go-closed-block/go.mod @@ -0,0 +1,9 @@ +module example.com/d + +go 1.24.0 + +tool ( + golang.org/x/tools/cmd/stringer +) + +github.com/leji-org/leji/packages/sdk-go/cmd/leji diff --git a/fixtures/ecosystem/scan-go-comment/expected.json b/fixtures/ecosystem/scan-go-comment/expected.json new file mode 100644 index 0000000..3c70a4a --- /dev/null +++ b/fixtures/ecosystem/scan-go-comment/expected.json @@ -0,0 +1,51 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "go", + "status": "ok", + "manifest": "go.mod", + "manager": "go", + "source": "manifest", + "evidence": [], + "add": [ + "go", + "get", + "-tool", + "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + ], + "runner": [ + "go", + "tool", + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-go-comment/go.mod b/fixtures/ecosystem/scan-go-comment/go.mod new file mode 100644 index 0000000..44b17be --- /dev/null +++ b/fixtures/ecosystem/scan-go-comment/go.mod @@ -0,0 +1,5 @@ +module example.com/d + +go 1.24.0 + +// tool github.com/leji-org/leji/packages/sdk-go/cmd/leji diff --git a/fixtures/ecosystem/scan-multiline-basic-string/expected.json b/fixtures/ecosystem/scan-multiline-basic-string/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-multiline-basic-string/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-multiline-basic-string/pyproject.toml b/fixtures/ecosystem/scan-multiline-basic-string/pyproject.toml new file mode 100644 index 0000000..77f9248 --- /dev/null +++ b/fixtures/ecosystem/scan-multiline-basic-string/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" +readme = """ +leji is a specification, not a dependency. +""" +dependencies = ["requests"] diff --git a/fixtures/ecosystem/scan-multiline-basic-string/uv.lock b/fixtures/ecosystem/scan-multiline-basic-string/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-multiline-literal-string/expected.json b/fixtures/ecosystem/scan-multiline-literal-string/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-multiline-literal-string/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-multiline-literal-string/pyproject.toml b/fixtures/ecosystem/scan-multiline-literal-string/pyproject.toml new file mode 100644 index 0000000..50a5922 --- /dev/null +++ b/fixtures/ecosystem/scan-multiline-literal-string/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" +readme = ''' +leji again, still prose. +''' +dependencies = ["requests"] diff --git a/fixtures/ecosystem/scan-multiline-literal-string/uv.lock b/fixtures/ecosystem/scan-multiline-literal-string/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-multiline-string-hides-table/expected.json b/fixtures/ecosystem/scan-multiline-string-hides-table/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-multiline-string-hides-table/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-multiline-string-hides-table/pyproject.toml b/fixtures/ecosystem/scan-multiline-string-hides-table/pyproject.toml new file mode 100644 index 0000000..dae5627 --- /dev/null +++ b/fixtures/ecosystem/scan-multiline-string-hides-table/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" +readme = """ +[dependency-groups] +dev = ["leji"] +""" diff --git a/fixtures/ecosystem/scan-multiline-string-hides-table/uv.lock b/fixtures/ecosystem/scan-multiline-string-hides-table/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-optional-deps-absent/expected.json b/fixtures/ecosystem/scan-optional-deps-absent/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-optional-deps-absent/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-optional-deps-absent/pyproject.toml b/fixtures/ecosystem/scan-optional-deps-absent/pyproject.toml new file mode 100644 index 0000000..e0eeca8 --- /dev/null +++ b/fixtures/ecosystem/scan-optional-deps-absent/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[project.optional-dependencies] +dev = ["pytest"] diff --git a/fixtures/ecosystem/scan-optional-deps-absent/uv.lock b/fixtures/ecosystem/scan-optional-deps-absent/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-optional-deps-comment/expected.json b/fixtures/ecosystem/scan-optional-deps-comment/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-optional-deps-comment/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-optional-deps-comment/pyproject.toml b/fixtures/ecosystem/scan-optional-deps-comment/pyproject.toml new file mode 100644 index 0000000..8154029 --- /dev/null +++ b/fixtures/ecosystem/scan-optional-deps-comment/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" + +[project.optional-dependencies] +# leji = 1 +dev = ["pytest"] # leji diff --git a/fixtures/ecosystem/scan-optional-deps-comment/uv.lock b/fixtures/ecosystem/scan-optional-deps-comment/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-optional-deps-declared/expected.json b/fixtures/ecosystem/scan-optional-deps-declared/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-optional-deps-declared/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-optional-deps-declared/pyproject.toml b/fixtures/ecosystem/scan-optional-deps-declared/pyproject.toml new file mode 100644 index 0000000..e3b1912 --- /dev/null +++ b/fixtures/ecosystem/scan-optional-deps-declared/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[project.optional-dependencies] +dev = ["pytest", "leji[all]"] diff --git a/fixtures/ecosystem/scan-optional-deps-declared/uv.lock b/fixtures/ecosystem/scan-optional-deps-declared/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pdm-dev-absent/expected.json b/fixtures/ecosystem/scan-pdm-dev-absent/expected.json new file mode 100644 index 0000000..943856a --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-absent/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pdm-dev-absent/pdm.lock b/fixtures/ecosystem/scan-pdm-dev-absent/pdm.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pdm-dev-absent/pyproject.toml b/fixtures/ecosystem/scan-pdm-dev-absent/pyproject.toml new file mode 100644 index 0000000..8314d2c --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-absent/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.pdm.dev-dependencies] +dev = ["pytest"] diff --git a/fixtures/ecosystem/scan-pdm-dev-array/expected.json b/fixtures/ecosystem/scan-pdm-dev-array/expected.json new file mode 100644 index 0000000..7ff9423 --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-array/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pdm-dev-array/pdm.lock b/fixtures/ecosystem/scan-pdm-dev-array/pdm.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pdm-dev-array/pyproject.toml b/fixtures/ecosystem/scan-pdm-dev-array/pyproject.toml new file mode 100644 index 0000000..899bdfa --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-array/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.pdm.dev-dependencies] +dev = ["leji"] diff --git a/fixtures/ecosystem/scan-pdm-dev-comment/expected.json b/fixtures/ecosystem/scan-pdm-dev-comment/expected.json new file mode 100644 index 0000000..943856a --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-comment/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pdm-dev-comment/pdm.lock b/fixtures/ecosystem/scan-pdm-dev-comment/pdm.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pdm-dev-comment/pyproject.toml b/fixtures/ecosystem/scan-pdm-dev-comment/pyproject.toml new file mode 100644 index 0000000..2eb43f3 --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-comment/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" + +[tool.pdm.dev-dependencies] +# leji = "^1.3" +dev = ["pytest"] diff --git a/fixtures/ecosystem/scan-pdm-dev-key/expected.json b/fixtures/ecosystem/scan-pdm-dev-key/expected.json new file mode 100644 index 0000000..7ff9423 --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "pdm", + "source": "lockfile", + "evidence": [ + "pdm.lock" + ], + "add": [ + "pdm", + "add", + "-dG", + "dev", + "leji" + ], + "runner": [ + "pdm", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pdm-dev-key/pdm.lock b/fixtures/ecosystem/scan-pdm-dev-key/pdm.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pdm-dev-key/pyproject.toml b/fixtures/ecosystem/scan-pdm-dev-key/pyproject.toml new file mode 100644 index 0000000..3c4c572 --- /dev/null +++ b/fixtures/ecosystem/scan-pdm-dev-key/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.pdm.dev-dependencies] +leji = "^1.3" diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-absent/Pipfile b/fixtures/ecosystem/scan-pipfile-dev-packages-absent/Pipfile new file mode 100644 index 0000000..ca763d4 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages-absent/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[dev-packages] +pytest = "*" diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-absent/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-dev-packages-absent/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-absent/expected.json b/fixtures/ecosystem/scan-pipfile-dev-packages-absent/expected.json new file mode 100644 index 0000000..1cbbf44 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages-absent/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/Pipfile b/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/Pipfile new file mode 100644 index 0000000..c0ad949 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[dev-packages] +leji = "*" diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/expected.json b/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/expected.json new file mode 100644 index 0000000..3104501 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages-bare-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-comment/Pipfile b/fixtures/ecosystem/scan-pipfile-dev-packages-comment/Pipfile new file mode 100644 index 0000000..8de4435 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages-comment/Pipfile @@ -0,0 +1,6 @@ +[[source]] +name = "pypi" + +[dev-packages] +# leji = "*" +pytest = "*" diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-comment/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-dev-packages-comment/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages-comment/expected.json b/fixtures/ecosystem/scan-pipfile-dev-packages-comment/expected.json new file mode 100644 index 0000000..1cbbf44 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages-comment/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages/Pipfile b/fixtures/ecosystem/scan-pipfile-dev-packages/Pipfile new file mode 100644 index 0000000..caf96b3 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[dev-packages] +"leji" = "*" diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-dev-packages/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-dev-packages/expected.json b/fixtures/ecosystem/scan-pipfile-dev-packages/expected.json new file mode 100644 index 0000000..3104501 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-dev-packages/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-packages-absent/Pipfile b/fixtures/ecosystem/scan-pipfile-packages-absent/Pipfile new file mode 100644 index 0000000..fbdb34a --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages-absent/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[packages] +requests = "*" diff --git a/fixtures/ecosystem/scan-pipfile-packages-absent/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-packages-absent/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-packages-absent/expected.json b/fixtures/ecosystem/scan-pipfile-packages-absent/expected.json new file mode 100644 index 0000000..1cbbf44 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages-absent/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-packages-comment/Pipfile b/fixtures/ecosystem/scan-pipfile-packages-comment/Pipfile new file mode 100644 index 0000000..a6ecbc7 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages-comment/Pipfile @@ -0,0 +1,6 @@ +[[source]] +name = "pypi" + +[packages] +# leji = "*" +requests = "*" diff --git a/fixtures/ecosystem/scan-pipfile-packages-comment/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-packages-comment/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-packages-comment/expected.json b/fixtures/ecosystem/scan-pipfile-packages-comment/expected.json new file mode 100644 index 0000000..1cbbf44 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages-comment/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-packages-quoted-key/Pipfile b/fixtures/ecosystem/scan-pipfile-packages-quoted-key/Pipfile new file mode 100644 index 0000000..1ff33d4 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages-quoted-key/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[packages] +"leji" = "*" diff --git a/fixtures/ecosystem/scan-pipfile-packages-quoted-key/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-packages-quoted-key/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-packages-quoted-key/expected.json b/fixtures/ecosystem/scan-pipfile-packages-quoted-key/expected.json new file mode 100644 index 0000000..3104501 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages-quoted-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-packages/Pipfile b/fixtures/ecosystem/scan-pipfile-packages/Pipfile new file mode 100644 index 0000000..226dc56 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[packages] +leji = "*" diff --git a/fixtures/ecosystem/scan-pipfile-packages/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-packages/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-packages/expected.json b/fixtures/ecosystem/scan-pipfile-packages/expected.json new file mode 100644 index 0000000..3104501 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-packages/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-pipfile-scripts/Pipfile b/fixtures/ecosystem/scan-pipfile-scripts/Pipfile new file mode 100644 index 0000000..e220fc0 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-scripts/Pipfile @@ -0,0 +1,5 @@ +[[source]] +name = "pypi" + +[scripts] +leji = "leji validate" diff --git a/fixtures/ecosystem/scan-pipfile-scripts/Pipfile.lock b/fixtures/ecosystem/scan-pipfile-scripts/Pipfile.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-pipfile-scripts/expected.json b/fixtures/ecosystem/scan-pipfile-scripts/expected.json new file mode 100644 index 0000000..1cbbf44 --- /dev/null +++ b/fixtures/ecosystem/scan-pipfile-scripts/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "Pipfile", + "manager": "pipenv", + "source": "lockfile", + "evidence": [ + "Pipfile.lock", + "Pipfile" + ], + "add": [ + "pipenv", + "install", + "--dev", + "leji" + ], + "runner": [ + "pipenv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-plain-quoted-element/expected.json b/fixtures/ecosystem/scan-plain-quoted-element/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-plain-quoted-element/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-plain-quoted-element/pyproject.toml b/fixtures/ecosystem/scan-plain-quoted-element/pyproject.toml new file mode 100644 index 0000000..3118ac3 --- /dev/null +++ b/fixtures/ecosystem/scan-plain-quoted-element/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ["leji"] diff --git a/fixtures/ecosystem/scan-plain-quoted-element/uv.lock b/fixtures/ecosystem/scan-plain-quoted-element/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-deps-absent/expected.json b/fixtures/ecosystem/scan-poetry-deps-absent/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-absent/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-deps-absent/poetry.lock b/fixtures/ecosystem/scan-poetry-deps-absent/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-deps-absent/pyproject.toml b/fixtures/ecosystem/scan-poetry-deps-absent/pyproject.toml new file mode 100644 index 0000000..2a33adf --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-absent/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dependencies] +python = "^3.12" diff --git a/fixtures/ecosystem/scan-poetry-deps-comment/expected.json b/fixtures/ecosystem/scan-poetry-deps-comment/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-comment/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-deps-comment/poetry.lock b/fixtures/ecosystem/scan-poetry-deps-comment/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-deps-comment/pyproject.toml b/fixtures/ecosystem/scan-poetry-deps-comment/pyproject.toml new file mode 100644 index 0000000..51df58b --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-comment/pyproject.toml @@ -0,0 +1,7 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dependencies] +# leji = "^1.3" +python = "^3.12" diff --git a/fixtures/ecosystem/scan-poetry-deps-key/expected.json b/fixtures/ecosystem/scan-poetry-deps-key/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-deps-key/poetry.lock b/fixtures/ecosystem/scan-poetry-deps-key/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-deps-key/pyproject.toml b/fixtures/ecosystem/scan-poetry-deps-key/pyproject.toml new file mode 100644 index 0000000..1826158 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-key/pyproject.toml @@ -0,0 +1,7 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dependencies] +python = "^3.12" +leji = "^1.3" diff --git a/fixtures/ecosystem/scan-poetry-deps-quoted-key/expected.json b/fixtures/ecosystem/scan-poetry-deps-quoted-key/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-quoted-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-deps-quoted-key/poetry.lock b/fixtures/ecosystem/scan-poetry-deps-quoted-key/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-deps-quoted-key/pyproject.toml b/fixtures/ecosystem/scan-poetry-deps-quoted-key/pyproject.toml new file mode 100644 index 0000000..68795dd --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-deps-quoted-key/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dependencies] +"leji" = { version = "^1.3" } diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-absent/expected.json b/fixtures/ecosystem/scan-poetry-dev-deps-absent/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-absent/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-absent/poetry.lock b/fixtures/ecosystem/scan-poetry-dev-deps-absent/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-absent/pyproject.toml b/fixtures/ecosystem/scan-poetry-dev-deps-absent/pyproject.toml new file mode 100644 index 0000000..8b6a300 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-absent/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dev-dependencies] +pytest = "^8.0" diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-comment/expected.json b/fixtures/ecosystem/scan-poetry-dev-deps-comment/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-comment/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-comment/poetry.lock b/fixtures/ecosystem/scan-poetry-dev-deps-comment/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-comment/pyproject.toml b/fixtures/ecosystem/scan-poetry-dev-deps-comment/pyproject.toml new file mode 100644 index 0000000..ddbd062 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-comment/pyproject.toml @@ -0,0 +1,7 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dev-dependencies] +# leji = "^1.3" +pytest = "^8.0" diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-key/expected.json b/fixtures/ecosystem/scan-poetry-dev-deps-key/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-key/poetry.lock b/fixtures/ecosystem/scan-poetry-dev-deps-key/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-key/pyproject.toml b/fixtures/ecosystem/scan-poetry-dev-deps-key/pyproject.toml new file mode 100644 index 0000000..8def274 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-key/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dev-dependencies] +leji = "^1.3" diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/expected.json b/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/poetry.lock b/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/pyproject.toml b/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/pyproject.toml new file mode 100644 index 0000000..b788e87 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-dev-deps-quoted-key/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.dev-dependencies] +"leji" = "^1.3" diff --git a/fixtures/ecosystem/scan-poetry-group-absent/expected.json b/fixtures/ecosystem/scan-poetry-group-absent/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-absent/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-group-absent/poetry.lock b/fixtures/ecosystem/scan-poetry-group-absent/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-group-absent/pyproject.toml b/fixtures/ecosystem/scan-poetry-group-absent/pyproject.toml new file mode 100644 index 0000000..7a9733d --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-absent/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.group.ci.dependencies] +pytest = "^8.0" diff --git a/fixtures/ecosystem/scan-poetry-group-comment/expected.json b/fixtures/ecosystem/scan-poetry-group-comment/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-comment/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-group-comment/poetry.lock b/fixtures/ecosystem/scan-poetry-group-comment/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-group-comment/pyproject.toml b/fixtures/ecosystem/scan-poetry-group-comment/pyproject.toml new file mode 100644 index 0000000..2c24278 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-comment/pyproject.toml @@ -0,0 +1,7 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.group.ci.dependencies] +# leji = "^1.3" +pytest = "^8.0" diff --git a/fixtures/ecosystem/scan-poetry-group-inline-table/expected.json b/fixtures/ecosystem/scan-poetry-group-inline-table/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-inline-table/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-group-inline-table/poetry.lock b/fixtures/ecosystem/scan-poetry-group-inline-table/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-group-inline-table/pyproject.toml b/fixtures/ecosystem/scan-poetry-group-inline-table/pyproject.toml new file mode 100644 index 0000000..1ad91b6 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-inline-table/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.group.ci.dependencies] +leji = { version = "^1.3" } diff --git a/fixtures/ecosystem/scan-poetry-group-key/expected.json b/fixtures/ecosystem/scan-poetry-group-key/expected.json new file mode 100644 index 0000000..58e0351 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-group-key/poetry.lock b/fixtures/ecosystem/scan-poetry-group-key/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-group-key/pyproject.toml b/fixtures/ecosystem/scan-poetry-group-key/pyproject.toml new file mode 100644 index 0000000..3944f23 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-group-key/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.group.ci.dependencies] +leji = "^1.3" diff --git a/fixtures/ecosystem/scan-poetry-scripts-key/expected.json b/fixtures/ecosystem/scan-poetry-scripts-key/expected.json new file mode 100644 index 0000000..3ff8ed0 --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-scripts-key/expected.json @@ -0,0 +1,57 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "poetry", + "source": "lockfile", + "evidence": [ + "poetry.lock" + ], + "add": [ + "poetry", + "add", + "--group", + "dev", + "leji" + ], + "runner": [ + "poetry", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-poetry-scripts-key/poetry.lock b/fixtures/ecosystem/scan-poetry-scripts-key/poetry.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-poetry-scripts-key/pyproject.toml b/fixtures/ecosystem/scan-poetry-scripts-key/pyproject.toml new file mode 100644 index 0000000..ba97e6f --- /dev/null +++ b/fixtures/ecosystem/scan-poetry-scripts-key/pyproject.toml @@ -0,0 +1,6 @@ +[tool.poetry] +name = "d" +version = "0" + +[tool.poetry.scripts] +leji = "leji:main" diff --git a/fixtures/ecosystem/scan-project-classifiers/expected.json b/fixtures/ecosystem/scan-project-classifiers/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-classifiers/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-classifiers/pyproject.toml b/fixtures/ecosystem/scan-project-classifiers/pyproject.toml new file mode 100644 index 0000000..aafa514 --- /dev/null +++ b/fixtures/ecosystem/scan-project-classifiers/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "d" +version = "0" +classifiers = ["leji"] +dependencies = [] diff --git a/fixtures/ecosystem/scan-project-classifiers/uv.lock b/fixtures/ecosystem/scan-project-classifiers/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-absent/expected.json b/fixtures/ecosystem/scan-project-deps-absent/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-absent/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-absent/pyproject.toml b/fixtures/ecosystem/scan-project-deps-absent/pyproject.toml new file mode 100644 index 0000000..1975f48 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-absent/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ["requests"] diff --git a/fixtures/ecosystem/scan-project-deps-absent/uv.lock b/fixtures/ecosystem/scan-project-deps-absent/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-comment/expected.json b/fixtures/ecosystem/scan-project-deps-comment/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-comment/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-comment/pyproject.toml b/fixtures/ecosystem/scan-project-deps-comment/pyproject.toml new file mode 100644 index 0000000..24f5060 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-comment/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" +dependencies = [ + # leji + "requests", +] diff --git a/fixtures/ecosystem/scan-project-deps-comment/uv.lock b/fixtures/ecosystem/scan-project-deps-comment/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-inline/expected.json b/fixtures/ecosystem/scan-project-deps-inline/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-inline/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-inline/pyproject.toml b/fixtures/ecosystem/scan-project-deps-inline/pyproject.toml new file mode 100644 index 0000000..0eecb47 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-inline/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ["requests", "leji>=1"] diff --git a/fixtures/ecosystem/scan-project-deps-inline/uv.lock b/fixtures/ecosystem/scan-project-deps-inline/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-multiline/expected.json b/fixtures/ecosystem/scan-project-deps-multiline/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-multiline/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-multiline/pyproject.toml b/fixtures/ecosystem/scan-project-deps-multiline/pyproject.toml new file mode 100644 index 0000000..65372a0 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-multiline/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" +dependencies = [ + "requests", + "leji", +] diff --git a/fixtures/ecosystem/scan-project-deps-multiline/uv.lock b/fixtures/ecosystem/scan-project-deps-multiline/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-prefix-only/expected.json b/fixtures/ecosystem/scan-project-deps-prefix-only/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-prefix-only/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-prefix-only/pyproject.toml b/fixtures/ecosystem/scan-project-deps-prefix-only/pyproject.toml new file mode 100644 index 0000000..a599ff9 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-prefix-only/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ["lejix", "leji-extras"] diff --git a/fixtures/ecosystem/scan-project-deps-prefix-only/uv.lock b/fixtures/ecosystem/scan-project-deps-prefix-only/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-single-quoted/expected.json b/fixtures/ecosystem/scan-project-deps-single-quoted/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-single-quoted/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-single-quoted/pyproject.toml b/fixtures/ecosystem/scan-project-deps-single-quoted/pyproject.toml new file mode 100644 index 0000000..300d4ea --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-single-quoted/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ['leji'] diff --git a/fixtures/ecosystem/scan-project-deps-single-quoted/uv.lock b/fixtures/ecosystem/scan-project-deps-single-quoted/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-spaced-header/expected.json b/fixtures/ecosystem/scan-project-deps-spaced-header/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-spaced-header/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-spaced-header/pyproject.toml b/fixtures/ecosystem/scan-project-deps-spaced-header/pyproject.toml new file mode 100644 index 0000000..160d4d7 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-spaced-header/pyproject.toml @@ -0,0 +1,4 @@ +[ project ] +name = "d" +version = "0" +dependencies = [ "leji==1.3.1" ] # pinned diff --git a/fixtures/ecosystem/scan-project-deps-spaced-header/uv.lock b/fixtures/ecosystem/scan-project-deps-spaced-header/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-deps-specifier/expected.json b/fixtures/ecosystem/scan-project-deps-specifier/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-specifier/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-deps-specifier/pyproject.toml b/fixtures/ecosystem/scan-project-deps-specifier/pyproject.toml new file mode 100644 index 0000000..e41759f --- /dev/null +++ b/fixtures/ecosystem/scan-project-deps-specifier/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" +dependencies = [ + "leji>=1,<2", +] diff --git a/fixtures/ecosystem/scan-project-deps-specifier/uv.lock b/fixtures/ecosystem/scan-project-deps-specifier/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-description/expected.json b/fixtures/ecosystem/scan-project-description/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-description/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-description/pyproject.toml b/fixtures/ecosystem/scan-project-description/pyproject.toml new file mode 100644 index 0000000..d57dc09 --- /dev/null +++ b/fixtures/ecosystem/scan-project-description/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "d" +version = "0" +description = "leji, the context layer" +dependencies = [] diff --git a/fixtures/ecosystem/scan-project-description/uv.lock b/fixtures/ecosystem/scan-project-description/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-keywords/expected.json b/fixtures/ecosystem/scan-project-keywords/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-keywords/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-keywords/pyproject.toml b/fixtures/ecosystem/scan-project-keywords/pyproject.toml new file mode 100644 index 0000000..6408aea --- /dev/null +++ b/fixtures/ecosystem/scan-project-keywords/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "d" +version = "0" +keywords = ["leji"] +dependencies = [] diff --git a/fixtures/ecosystem/scan-project-keywords/uv.lock b/fixtures/ecosystem/scan-project-keywords/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-project-nested-array/expected.json b/fixtures/ecosystem/scan-project-nested-array/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-project-nested-array/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-project-nested-array/pyproject.toml b/fixtures/ecosystem/scan-project-nested-array/pyproject.toml new file mode 100644 index 0000000..f6918e4 --- /dev/null +++ b/fixtures/ecosystem/scan-project-nested-array/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" +keywords = [ + ["leji"] +] +dependencies = [] diff --git a/fixtures/ecosystem/scan-project-nested-array/uv.lock b/fixtures/ecosystem/scan-project-nested-array/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-requirements-bare/expected.json b/fixtures/ecosystem/scan-requirements-bare/expected.json new file mode 100644 index 0000000..e572b29 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-bare/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-bare/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-bare/requirements-dev.txt new file mode 100644 index 0000000..e7db711 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-bare/requirements-dev.txt @@ -0,0 +1 @@ +leji diff --git a/fixtures/ecosystem/scan-requirements-comment/expected.json b/fixtures/ecosystem/scan-requirements-comment/expected.json new file mode 100644 index 0000000..8235861 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-comment/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-comment/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-comment/requirements-dev.txt new file mode 100644 index 0000000..1ed1bba --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-comment/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest +# leji diff --git a/fixtures/ecosystem/scan-requirements-declared/expected.json b/fixtures/ecosystem/scan-requirements-declared/expected.json new file mode 100644 index 0000000..e572b29 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-declared/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-declared/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-declared/requirements-dev.txt new file mode 100644 index 0000000..33e2ea6 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-declared/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest +leji>=1,<2 diff --git a/fixtures/ecosystem/scan-requirements-extras/expected.json b/fixtures/ecosystem/scan-requirements-extras/expected.json new file mode 100644 index 0000000..e572b29 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-extras/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": true, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-extras/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-extras/requirements-dev.txt new file mode 100644 index 0000000..aa67b65 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-extras/requirements-dev.txt @@ -0,0 +1 @@ +leji[all]==1.3.1 diff --git a/fixtures/ecosystem/scan-requirements-include-line/expected.json b/fixtures/ecosystem/scan-requirements-include-line/expected.json new file mode 100644 index 0000000..8235861 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-include-line/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-include-line/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-include-line/requirements-dev.txt new file mode 100644 index 0000000..a1a79fd --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-include-line/requirements-dev.txt @@ -0,0 +1 @@ +-r leji.txt diff --git a/fixtures/ecosystem/scan-requirements-indented/expected.json b/fixtures/ecosystem/scan-requirements-indented/expected.json new file mode 100644 index 0000000..8235861 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-indented/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-indented/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-indented/requirements-dev.txt new file mode 100644 index 0000000..64ff134 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-indented/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest + leji diff --git a/fixtures/ecosystem/scan-requirements-prefix-only/expected.json b/fixtures/ecosystem/scan-requirements-prefix-only/expected.json new file mode 100644 index 0000000..8235861 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-prefix-only/expected.json @@ -0,0 +1,41 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "requirements-dev.txt", + "manager": "pip", + "source": "default", + "evidence": [ + "requirements-dev.txt" + ], + "add": null, + "runner": [ + "leji" + ], + "directDeclared": false, + "lockEvidenced": false, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-requirements-prefix-only/requirements-dev.txt b/fixtures/ecosystem/scan-requirements-prefix-only/requirements-dev.txt new file mode 100644 index 0000000..f43d722 --- /dev/null +++ b/fixtures/ecosystem/scan-requirements-prefix-only/requirements-dev.txt @@ -0,0 +1 @@ +lejix diff --git a/fixtures/ecosystem/scan-tool-uv-dev-absent/expected.json b/fixtures/ecosystem/scan-tool-uv-dev-absent/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-absent/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-tool-uv-dev-absent/pyproject.toml b/fixtures/ecosystem/scan-tool-uv-dev-absent/pyproject.toml new file mode 100644 index 0000000..247e41f --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-absent/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.uv] +dev-dependencies = ["ruff"] diff --git a/fixtures/ecosystem/scan-tool-uv-dev-absent/uv.lock b/fixtures/ecosystem/scan-tool-uv-dev-absent/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-tool-uv-dev-comment/expected.json b/fixtures/ecosystem/scan-tool-uv-dev-comment/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-comment/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-tool-uv-dev-comment/pyproject.toml b/fixtures/ecosystem/scan-tool-uv-dev-comment/pyproject.toml new file mode 100644 index 0000000..162305f --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-comment/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "d" +version = "0" + +[tool.uv] +# dev-dependencies = ["leji"] +dev-dependencies = ["ruff"] diff --git a/fixtures/ecosystem/scan-tool-uv-dev-comment/uv.lock b/fixtures/ecosystem/scan-tool-uv-dev-comment/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-tool-uv-dev-declared/expected.json b/fixtures/ecosystem/scan-tool-uv-dev-declared/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-declared/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-tool-uv-dev-declared/pyproject.toml b/fixtures/ecosystem/scan-tool-uv-dev-declared/pyproject.toml new file mode 100644 index 0000000..313678a --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-declared/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.uv] +dev-dependencies = ["leji"] diff --git a/fixtures/ecosystem/scan-tool-uv-dev-declared/uv.lock b/fixtures/ecosystem/scan-tool-uv-dev-declared/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-tool-uv-dev-marker/expected.json b/fixtures/ecosystem/scan-tool-uv-dev-marker/expected.json new file mode 100644 index 0000000..cbd5270 --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-marker/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": true, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-tool-uv-dev-marker/pyproject.toml b/fixtures/ecosystem/scan-tool-uv-dev-marker/pyproject.toml new file mode 100644 index 0000000..db5476a --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-dev-marker/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.uv] +dev-dependencies = ["leji ; python_version >= '3.11'"] diff --git a/fixtures/ecosystem/scan-tool-uv-dev-marker/uv.lock b/fixtures/ecosystem/scan-tool-uv-dev-marker/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-tool-uv-other-field/expected.json b/fixtures/ecosystem/scan-tool-uv-other-field/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-other-field/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-tool-uv-other-field/pyproject.toml b/fixtures/ecosystem/scan-tool-uv-other-field/pyproject.toml new file mode 100644 index 0000000..84729f7 --- /dev/null +++ b/fixtures/ecosystem/scan-tool-uv-other-field/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.uv] +constraint-dependencies = ["leji"] diff --git a/fixtures/ecosystem/scan-tool-uv-other-field/uv.lock b/fixtures/ecosystem/scan-tool-uv-other-field/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-triple-quoted-element-literal/expected.json b/fixtures/ecosystem/scan-triple-quoted-element-literal/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-triple-quoted-element-literal/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-triple-quoted-element-literal/pyproject.toml b/fixtures/ecosystem/scan-triple-quoted-element-literal/pyproject.toml new file mode 100644 index 0000000..a28aa10 --- /dev/null +++ b/fixtures/ecosystem/scan-triple-quoted-element-literal/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ['''leji'''] diff --git a/fixtures/ecosystem/scan-triple-quoted-element-literal/uv.lock b/fixtures/ecosystem/scan-triple-quoted-element-literal/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-triple-quoted-element/expected.json b/fixtures/ecosystem/scan-triple-quoted-element/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-triple-quoted-element/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-triple-quoted-element/pyproject.toml b/fixtures/ecosystem/scan-triple-quoted-element/pyproject.toml new file mode 100644 index 0000000..25eb6aa --- /dev/null +++ b/fixtures/ecosystem/scan-triple-quoted-element/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "d" +version = "0" +dependencies = ["""leji"""] diff --git a/fixtures/ecosystem/scan-triple-quoted-element/uv.lock b/fixtures/ecosystem/scan-triple-quoted-element/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/ecosystem/scan-unrelated-table-key/expected.json b/fixtures/ecosystem/scan-unrelated-table-key/expected.json new file mode 100644 index 0000000..f097e59 --- /dev/null +++ b/fixtures/ecosystem/scan-unrelated-table-key/expected.json @@ -0,0 +1,55 @@ +{ + "ecosystem": { + "selected": { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + }, + "all": [ + { + "ecosystem": "python", + "status": "ok", + "manifest": "pyproject.toml", + "manager": "uv", + "source": "lockfile", + "evidence": [ + "uv.lock" + ], + "add": [ + "uv", + "add", + "--dev", + "leji" + ], + "runner": [ + "uv", + "run", + "leji" + ], + "directDeclared": false, + "lockEvidenced": true, + "candidates": [] + } + ], + "reason": null + } +} diff --git a/fixtures/ecosystem/scan-unrelated-table-key/pyproject.toml b/fixtures/ecosystem/scan-unrelated-table-key/pyproject.toml new file mode 100644 index 0000000..fb15083 --- /dev/null +++ b/fixtures/ecosystem/scan-unrelated-table-key/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "d" +version = "0" + +[tool.black] +leji = "1" diff --git a/fixtures/ecosystem/scan-unrelated-table-key/uv.lock b/fixtures/ecosystem/scan-unrelated-table-key/uv.lock new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/handoff/go-tool/go.mod b/fixtures/handoff/go-tool/go.mod new file mode 100644 index 0000000..985347e --- /dev/null +++ b/fixtures/handoff/go-tool/go.mod @@ -0,0 +1,5 @@ +module example.com/joiner-app + +go 1.24 + +tool github.com/leji-org/leji/packages/sdk-go/cmd/leji diff --git a/fixtures/handoff/go-tool/leji.json b/fixtures/handoff/go-tool/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/go-tool/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-ambiguous-manager/leji.json b/fixtures/handoff/node-ambiguous-manager/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-ambiguous-manager/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-ambiguous-manager/package-lock.json b/fixtures/handoff/node-ambiguous-manager/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-ambiguous-manager/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-ambiguous-manager/package.json b/fixtures/handoff/node-ambiguous-manager/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-ambiguous-manager/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-ambiguous-manager/pnpm-lock.yaml b/fixtures/handoff/node-ambiguous-manager/pnpm-lock.yaml new file mode 100644 index 0000000..c1a4322 --- /dev/null +++ b/fixtures/handoff/node-ambiguous-manager/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: "9.0" diff --git a/fixtures/handoff/node-below-minimum/leji.json b/fixtures/handoff/node-below-minimum/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-below-minimum/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-below-minimum/package-lock.json b/fixtures/handoff/node-below-minimum/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-below-minimum/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-below-minimum/package.json b/fixtures/handoff/node-below-minimum/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-below-minimum/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-declared-missing/leji.json b/fixtures/handoff/node-declared-missing/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-declared-missing/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-declared-missing/package-lock.json b/fixtures/handoff/node-declared-missing/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-declared-missing/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-declared-missing/package.json b/fixtures/handoff/node-declared-missing/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-declared-missing/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-eligible/leji.json b/fixtures/handoff/node-eligible/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-eligible/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-eligible/package-lock.json b/fixtures/handoff/node-eligible/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-eligible/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-eligible/package.json b/fixtures/handoff/node-eligible/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-eligible/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-entry-not-regular/leji.json b/fixtures/handoff/node-entry-not-regular/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-entry-not-regular/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-entry-not-regular/package-lock.json b/fixtures/handoff/node-entry-not-regular/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-entry-not-regular/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-entry-not-regular/package.json b/fixtures/handoff/node-entry-not-regular/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-entry-not-regular/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-escaped/leji.json b/fixtures/handoff/node-escaped/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-escaped/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-escaped/package-lock.json b/fixtures/handoff/node-escaped/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-escaped/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-escaped/package.json b/fixtures/handoff/node-escaped/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-escaped/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-malformed-metadata/leji.json b/fixtures/handoff/node-malformed-metadata/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-malformed-metadata/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-malformed-metadata/package-lock.json b/fixtures/handoff/node-malformed-metadata/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-malformed-metadata/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-malformed-metadata/package.json b/fixtures/handoff/node-malformed-metadata/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-malformed-metadata/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-malformed-version/leji.json b/fixtures/handoff/node-malformed-version/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-malformed-version/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-malformed-version/package-lock.json b/fixtures/handoff/node-malformed-version/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-malformed-version/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-malformed-version/package.json b/fixtures/handoff/node-malformed-version/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-malformed-version/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-metadata-not-regular/leji.json b/fixtures/handoff/node-metadata-not-regular/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-metadata-not-regular/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-metadata-not-regular/package-lock.json b/fixtures/handoff/node-metadata-not-regular/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-metadata-not-regular/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-metadata-not-regular/package.json b/fixtures/handoff/node-metadata-not-regular/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-metadata-not-regular/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-refused/leji.json b/fixtures/handoff/node-refused/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-refused/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-self/leji.json b/fixtures/handoff/node-self/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-self/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-self/package-lock.json b/fixtures/handoff/node-self/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-self/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-self/package.json b/fixtures/handoff/node-self/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-self/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-shim-script/leji.json b/fixtures/handoff/node-shim-script/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-shim-script/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-shim-script/package.json b/fixtures/handoff/node-shim-script/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-shim-script/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-shim-script/pnpm-lock.yaml b/fixtures/handoff/node-shim-script/pnpm-lock.yaml new file mode 100644 index 0000000..c1a4322 --- /dev/null +++ b/fixtures/handoff/node-shim-script/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: "9.0" diff --git a/fixtures/handoff/node-shim-symlink/leji.json b/fixtures/handoff/node-shim-symlink/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-shim-symlink/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-shim-symlink/package-lock.json b/fixtures/handoff/node-shim-symlink/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-shim-symlink/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-shim-symlink/package.json b/fixtures/handoff/node-shim-symlink/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-shim-symlink/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-shim-yarn/leji.json b/fixtures/handoff/node-shim-yarn/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-shim-yarn/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-shim-yarn/package.json b/fixtures/handoff/node-shim-yarn/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-shim-yarn/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-shim-yarn/yarn.lock b/fixtures/handoff/node-shim-yarn/yarn.lock new file mode 100644 index 0000000..ef45249 --- /dev/null +++ b/fixtures/handoff/node-shim-yarn/yarn.lock @@ -0,0 +1 @@ +# yarn lockfile v1 diff --git a/fixtures/handoff/node-undeclared/leji.json b/fixtures/handoff/node-undeclared/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-undeclared/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-undeclared/package-lock.json b/fixtures/handoff/node-undeclared/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-undeclared/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-undeclared/package.json b/fixtures/handoff/node-undeclared/package.json new file mode 100644 index 0000000..de8405c --- /dev/null +++ b/fixtures/handoff/node-undeclared/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/fixtures/handoff/node-unknown-spec-line/leji.json b/fixtures/handoff/node-unknown-spec-line/leji.json new file mode 100644 index 0000000..bf801cb --- /dev/null +++ b/fixtures/handoff/node-unknown-spec-line/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "9.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-unknown-spec-line/package-lock.json b/fixtures/handoff/node-unknown-spec-line/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-unknown-spec-line/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-unknown-spec-line/package.json b/fixtures/handoff/node-unknown-spec-line/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-unknown-spec-line/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-unsupported-manager/leji.json b/fixtures/handoff/node-unsupported-manager/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-unsupported-manager/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-unsupported-manager/package-lock.json b/fixtures/handoff/node-unsupported-manager/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-unsupported-manager/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-unsupported-manager/package.json b/fixtures/handoff/node-unsupported-manager/package.json new file mode 100644 index 0000000..2f92df4 --- /dev/null +++ b/fixtures/handoff/node-unsupported-manager/package.json @@ -0,0 +1,8 @@ +{ + "name": "joiner-app", + "private": true, + "packageManager": "hexpm@1.0.0", + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/node-wrong-identity/leji.json b/fixtures/handoff/node-wrong-identity/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/node-wrong-identity/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/node-wrong-identity/package-lock.json b/fixtures/handoff/node-wrong-identity/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/node-wrong-identity/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/node-wrong-identity/package.json b/fixtures/handoff/node-wrong-identity/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/node-wrong-identity/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/polyglot/leji.json b/fixtures/handoff/polyglot/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/polyglot/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/polyglot/package-lock.json b/fixtures/handoff/polyglot/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/handoff/polyglot/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/handoff/polyglot/package.json b/fixtures/handoff/polyglot/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/handoff/polyglot/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/handoff/polyglot/pyproject.toml b/fixtures/handoff/polyglot/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/polyglot/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/polyglot/uv.lock b/fixtures/handoff/polyglot/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/polyglot/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-ambiguous-manager/leji.json b/fixtures/handoff/python-ambiguous-manager/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-ambiguous-manager/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-ambiguous-manager/poetry.lock b/fixtures/handoff/python-ambiguous-manager/poetry.lock new file mode 100644 index 0000000..1d030f8 --- /dev/null +++ b/fixtures/handoff/python-ambiguous-manager/poetry.lock @@ -0,0 +1 @@ +# poetry lockfile diff --git a/fixtures/handoff/python-ambiguous-manager/pyproject.toml b/fixtures/handoff/python-ambiguous-manager/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-ambiguous-manager/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-ambiguous-manager/uv.lock b/fixtures/handoff/python-ambiguous-manager/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-ambiguous-manager/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-below-minimum/leji.json b/fixtures/handoff/python-below-minimum/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-below-minimum/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-below-minimum/pyproject.toml b/fixtures/handoff/python-below-minimum/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-below-minimum/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-below-minimum/uv.lock b/fixtures/handoff/python-below-minimum/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-below-minimum/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-bounds/leji.json b/fixtures/handoff/python-bounds/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-bounds/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-bounds/pyproject.toml b/fixtures/handoff/python-bounds/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-bounds/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-bounds/uv.lock b/fixtures/handoff/python-bounds/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-bounds/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-eligible/leji.json b/fixtures/handoff/python-eligible/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-eligible/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-eligible/pyproject.toml b/fixtures/handoff/python-eligible/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-eligible/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-eligible/uv.lock b/fixtures/handoff/python-eligible/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-eligible/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-escaped-env/leji.json b/fixtures/handoff/python-escaped-env/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-escaped-env/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-escaped-env/pyproject.toml b/fixtures/handoff/python-escaped-env/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-escaped-env/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-escaped-env/uv.lock b/fixtures/handoff/python-escaped-env/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-escaped-env/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-escaped-script/leji.json b/fixtures/handoff/python-escaped-script/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-escaped-script/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-escaped-script/pyproject.toml b/fixtures/handoff/python-escaped-script/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-escaped-script/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-escaped-script/uv.lock b/fixtures/handoff/python-escaped-script/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-escaped-script/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-malformed-version/leji.json b/fixtures/handoff/python-malformed-version/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-malformed-version/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-malformed-version/pyproject.toml b/fixtures/handoff/python-malformed-version/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-malformed-version/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-malformed-version/uv.lock b/fixtures/handoff/python-malformed-version/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-malformed-version/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-no-env/leji.json b/fixtures/handoff/python-no-env/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-no-env/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-no-env/pyproject.toml b/fixtures/handoff/python-no-env/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-no-env/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-no-env/uv.lock b/fixtures/handoff/python-no-env/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-no-env/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-refused-manifest/leji.json b/fixtures/handoff/python-refused-manifest/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-refused-manifest/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-refused-manifest/uv.lock b/fixtures/handoff/python-refused-manifest/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-refused-manifest/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-script-not-regular/leji.json b/fixtures/handoff/python-script-not-regular/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-script-not-regular/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-script-not-regular/pyproject.toml b/fixtures/handoff/python-script-not-regular/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-script-not-regular/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-script-not-regular/uv.lock b/fixtures/handoff/python-script-not-regular/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-script-not-regular/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-two-distinfo/leji.json b/fixtures/handoff/python-two-distinfo/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-two-distinfo/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-two-distinfo/pyproject.toml b/fixtures/handoff/python-two-distinfo/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-two-distinfo/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-two-distinfo/uv.lock b/fixtures/handoff/python-two-distinfo/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-two-distinfo/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-undeclared/leji.json b/fixtures/handoff/python-undeclared/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-undeclared/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-undeclared/pyproject.toml b/fixtures/handoff/python-undeclared/pyproject.toml new file mode 100644 index 0000000..ad1a9ae --- /dev/null +++ b/fixtures/handoff/python-undeclared/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["pytest"] diff --git a/fixtures/handoff/python-undeclared/uv.lock b/fixtures/handoff/python-undeclared/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-undeclared/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-unknown-spec-line/leji.json b/fixtures/handoff/python-unknown-spec-line/leji.json new file mode 100644 index 0000000..bf801cb --- /dev/null +++ b/fixtures/handoff/python-unknown-spec-line/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "9.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-unknown-spec-line/pyproject.toml b/fixtures/handoff/python-unknown-spec-line/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-unknown-spec-line/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-unknown-spec-line/uv.lock b/fixtures/handoff/python-unknown-spec-line/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-unknown-spec-line/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-uv-env-var/leji.json b/fixtures/handoff/python-uv-env-var/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-uv-env-var/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-uv-env-var/pyproject.toml b/fixtures/handoff/python-uv-env-var/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-uv-env-var/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-uv-env-var/uv.lock b/fixtures/handoff/python-uv-env-var/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-uv-env-var/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-virtual-env-elsewhere-inside-root/leji.json b/fixtures/handoff/python-virtual-env-elsewhere-inside-root/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-virtual-env-elsewhere-inside-root/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-virtual-env-elsewhere-inside-root/pyproject.toml b/fixtures/handoff/python-virtual-env-elsewhere-inside-root/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-virtual-env-elsewhere-inside-root/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-virtual-env-elsewhere-inside-root/uv.lock b/fixtures/handoff/python-virtual-env-elsewhere-inside-root/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-virtual-env-elsewhere-inside-root/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-virtual-env-equal/leji.json b/fixtures/handoff/python-virtual-env-equal/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-virtual-env-equal/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-virtual-env-equal/pyproject.toml b/fixtures/handoff/python-virtual-env-equal/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-virtual-env-equal/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-virtual-env-equal/uv.lock b/fixtures/handoff/python-virtual-env-equal/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-virtual-env-equal/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-virtual-env-outside/leji.json b/fixtures/handoff/python-virtual-env-outside/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-virtual-env-outside/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-virtual-env-outside/pyproject.toml b/fixtures/handoff/python-virtual-env-outside/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-virtual-env-outside/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-virtual-env-outside/uv.lock b/fixtures/handoff/python-virtual-env-outside/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-virtual-env-outside/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/handoff/python-wrong-name/leji.json b/fixtures/handoff/python-wrong-name/leji.json new file mode 100644 index 0000000..cf84008 --- /dev/null +++ b/fixtures/handoff/python-wrong-name/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "handoff-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/handoff/python-wrong-name/pyproject.toml b/fixtures/handoff/python-wrong-name/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/handoff/python-wrong-name/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/handoff/python-wrong-name/uv.lock b/fixtures/handoff/python-wrong-name/uv.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/fixtures/handoff/python-wrong-name/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/fixtures/help-goldens/adopt.txt b/fixtures/help-goldens/adopt.txt new file mode 100644 index 0000000..a35712f --- /dev/null +++ b/fixtures/help-goldens/adopt.txt @@ -0,0 +1,60 @@ +leji adopt: Adopt Leji into an existing repository. + +Usage: leji adopt [--dir ] [--yes] [--mode ] [--agent ] + [--wire-adapters] [--no-agents] [--dry-run] [--json] + +Brings Leji into a repository that already has docs and agent config. + +Details: + - Reuses an existing `docs/` root, migrates any vendor entrypoints + (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without + modifying the originals, and seeds the scaffold. + - Writes a generated index, so the adopted context layer is ready for the CI + job `leji ci` writes. The index is a requirement of `indexed`, not of + `core`; a hand-authored core context layer without one still conforms. + - `--wire-adapters` converts those entrypoints to one-line redirects, after + migrating their content. + - When no `AGENTS.md` exists, writes a pointer-only one (the portable + entrypoint many agent hosts read) redirecting to the boot profile; + `--no-agents` skips it, and an existing file is never touched. + - `--mode solo` (a team of one) also seeds identity and writing-style + starters and points the onboarding brief at the owner interview; existing + files are never overwritten. + - Refuses when a `leji.json` exists, `--dry-run` included: a repository that + already has a context layer has nothing to adopt. Also refuses when the git + tree has uncommitted changes, which `--dry-run` is exempt from because it + writes nothing. + - Reports the repository's dependency ecosystem (its package manager, from + the manifest and lockfiles present) and how to declare the Leji CLI as a + dev dependency there, so a clean install brings `leji`; on a real terminal + it offers to run that manager's own add command, and only on your explicit + yes. `--yes`, a non-TTY and `--json` print the command instead of running + it, and leji never edits a manifest or lockfile itself. + - Also backs `npm create leji` on a repository that already carries docs or + an agent entrypoint. + +Options: + --dir Target directory (default: the current directory). + --yes, -y Accept all defaults; run non-interactively. + --mode Working mode: solo (team of one; seeds identity + + writing-style starters) or team (default). + --agent Host to open in the context layer after the command + (claude-code or codex). Selects the handoff host; the + interactive flow may separately offer to register the MCP + server or install the approval guard, each disclosed and + consented to. + --wire-adapters Convert present vendor entrypoints to redirects + (consented; content migrated first). + --no-agents Skip generating the portable AGENTS.md pointer (default: + written when absent). + --dry-run Print the write plan and exit without changing anything. + +Global options: see leji --help. + +Examples: + leji adopt + leji adopt --dry-run + leji adopt --mode solo + leji adopt --wire-adapters + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/agent.txt b/fixtures/help-goldens/agent.txt new file mode 100644 index 0000000..29323c6 --- /dev/null +++ b/fixtures/help-goldens/agent.txt @@ -0,0 +1,42 @@ +leji agent: Bind an additional named agent into an existing context layer. + +Usage: leji agent --name [--host ] [--role ] [--root ] + [--json] + +Adds a second (or third) agent to a context layer that already has a +`leji.json`. + +Details: + - Writes a starter agent profile under the agent-profiles path and binds it + in the manifest's agents map via an in-place edit that preserves the rest + of the file. + - Never writes an agent-host entrypoint file. The portable `AGENTS.md` + pointer is written by `init` and `adopt` (unless `--no-agents`); + single-vendor files like `CLAUDE.md` are only ever converted from an + existing one by `adopt --wire-adapters`. + - `--host` is optional: a host pins the profile to a specific external CLI; + with none, it's a host-agnostic resident agent any host can run. + - The role defaults to reviewer; pass `--role` for a different one. + - Binding the `default` key prints a note: `agents.default` selects a role + profile, it does not load it, so instructions that must apply before every + task belong in the boot profile rather than that profile. This note prints + once, when the binding is written. + - Idempotent: an existing profile or binding is left untouched. Requires an + existing context layer. + +Options: + --name Name for the agent; also its profile id and agents-map + key (kebab-case). + --host Optional. Pin the profile to a host (claude-code, codex, + copilot, gemini, cursor, windsurf; aliases ok); omit for + a host-agnostic resident agent. + --role Role the agent fills (default: reviewer). + +Global options: see leji --help. + +Examples: + leji agent --name porter --role porter + leji agent --host codex --name reviewer + leji agent --host claude-code --name thought-partner --role advisor + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/badge.txt b/fixtures/help-goldens/badge.txt new file mode 100644 index 0000000..6064d40 --- /dev/null +++ b/fixtures/help-goldens/badge.txt @@ -0,0 +1,28 @@ +leji badge: Write the self-attested conformance badge for this repository. + +Usage: leji badge [--root ] [--out ] [--json] + +Writes one SVG (default: leji-badge.svg at the repository root) and prints the +markdown line that embeds it. Self-attested: the badge states the level `leji +conformance` verified in this offline run, never more than the layer claims and +possibly less, and a claim this run could not confirm is named beside it. +Nothing is sent anywhere and no service or registry is involved; the bytes are +constants, and the file is yours to commit. A run with an error finding, or one +that verified no level at all, writes nothing and exits 1. An existing target is +replaced only when its bytes are a badge this command wrote, which is how a +level change regenerates; any other file is left untouched and the run refuses. + +Options: + --out Where to write the badge (default: leji-badge.svg). A + repository-relative POSIX path over [A-Za-z0-9._/-] with + no ".." segment, ending .svg, resolving inside the + repository and never inside .leji/. + +Global options: see leji --help. + +Examples: + leji badge + leji badge --out docs/badge.svg + leji badge --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/bounds-command.txt b/fixtures/help-goldens/bounds-command.txt new file mode 100644 index 0000000..e79244a --- /dev/null +++ b/fixtures/help-goldens/bounds-command.txt @@ -0,0 +1,18 @@ +leji a-command-name-long-enough-to-outgrow-its-bounded-column: A command name + past the name column's upper bound, so its summary starts on the next line. + +Usage: leji a-command-name-long-enough-to-outgrow-its-bounded-column [--root + ] + +One paragraph, wrapped like any other description. + +Options: + --emoji-😀😀 A flag carrying astral characters, so a column padded in + UTF-16 units misaligns this row by two. + +Global options: see leji --help. + +Examples: + leji a-command-name-long-enough-to-outgrow-its-bounded-column + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/bounds-spec.json b/fixtures/help-goldens/bounds-spec.json new file mode 100644 index 0000000..8554f3a --- /dev/null +++ b/fixtures/help-goldens/bounds-spec.json @@ -0,0 +1,72 @@ +{ + "name": "leji", + "summary": "Synthetic spec: the bounds vector for terminal help.", + "usage": "leji [options]", + "globalOptions": [ + { + "flags": "--a-global-flag-whose-spelling-runs-out-to-seventy-five-code-points ", + "summary": "A global flag long enough to outgrow the option column's upper bound, so the row rule decides where its summary goes." + }, + { + "flags": "--json", + "summary": "Machine-readable JSON output instead of human-readable text." + } + ], + "exitCodes": [ + { + "code": 0, + "meaning": "Clean." + }, + { + "code": 127, + "meaning": "A three-digit code, so the code column widens with the data it holds." + } + ], + "groups": [ + { + "id": "only", + "title": "Only group" + } + ], + "commands": [ + { + "name": "a-command-name-long-enough-to-outgrow-its-bounded-column", + "group": "only", + "summary": "A command name past the name column's upper bound, so its summary starts on the next line.", + "usage": "leji a-command-name-long-enough-to-outgrow-its-bounded-column [--root ]", + "description": "One paragraph, wrapped like any other description.", + "options": [ + { + "flags": "--emoji-😀😀 ", + "summary": "A flag carrying astral characters, so a column padded in UTF-16 units misaligns this row by two." + } + ], + "examples": [ + "leji a-command-name-long-enough-to-outgrow-its-bounded-column" + ] + }, + { + "name": "short", + "group": "only", + "summary": "A short name, so the same column serves both rows.", + "usage": "leji short", + "description": "Second command.", + "options": [], + "examples": [ + "leji short" + ] + }, + { + "name": "sh", + "group": "only", + "aliasOf": "short", + "summary": "Alias of short.", + "usage": "leji sh", + "description": "Alias.", + "options": [], + "examples": [ + "leji sh" + ] + } + ] +} diff --git a/fixtures/help-goldens/bounds-usage.txt b/fixtures/help-goldens/bounds-usage.txt new file mode 100644 index 0000000..87217ce --- /dev/null +++ b/fixtures/help-goldens/bounds-usage.txt @@ -0,0 +1,26 @@ +leji {{version}}: reference CLI for the Leji specification (spec line 1.0) + +Usage: leji [options] + +Only group: + a-command-name-long-enough-to-outgrow-its-bounded-column + A command name past the name column's upper + bound, so its summary starts on the next line. + short A short name, so the same column serves both + rows. + sh (alias of short) + +Options: + --a-global-flag-whose-spelling-runs-out-to-seventy-five-code-points + A global flag long enough to outgrow the option + column's upper bound, so the row rule decides + where its summary goes. + --json Machine-readable JSON output instead of + human-readable text. + +Exit codes: + 0 Clean. + 127 A three-digit code, so the code column widens with the data it holds. + +Run `leji --help` for a command and its options. +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/changelog-check.txt b/fixtures/help-goldens/changelog-check.txt new file mode 100644 index 0000000..30c261b --- /dev/null +++ b/fixtures/help-goldens/changelog-check.txt @@ -0,0 +1,25 @@ +leji changelog check: Verify the machine changelog: schema and append-only + discipline. + +Usage: leji changelog check [--strict] [--root ] [--json] + +Validates the declared changelog against its schema and checks append-only +discipline against the committed state of the file at HEAD: surviving entries +are immutable, and entries may be removed only from the oldest end and only +alongside a compaction entry. The comparison is against HEAD, so it catches an +uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI +checkout the working tree is HEAD, so it does not by itself detect a rewrite +that arrives already committed. Reviewing the diff covers that. Without git the +discipline is unverifiable and reported as a warning. + +Options: + --strict Treat an unverifiable append-only check (no git baseline) + as an error. + +Global options: see leji --help. + +Examples: + leji changelog check + leji changelog check --strict + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/changelog-compact.txt b/fixtures/help-goldens/changelog-compact.txt new file mode 100644 index 0000000..dab7c43 --- /dev/null +++ b/fixtures/help-goldens/changelog-compact.txt @@ -0,0 +1,31 @@ +leji changelog compact: Fold the oldest changelog entries into a single + compaction entry. + +Usage: leji changelog compact [--keep ] [--before ] [--root + ] [--json] + +Compacts the oldest end of the machine changelog, folding entries into a single +compaction entry that records how many were folded and the id range removed. + +Details: + - Selection: `--keep ` folds every entry except the newest n; `--before + ` folds entries dated before the given day. With both, an entry folds + only if it satisfies both (the intersection). + - At least one of `--keep` or `--before` is required. + - The folded set is always a contiguous run from the oldest end, so the + result still satisfies the append-only discipline that `leji changelog + check` enforces. + +Options: + --keep Keep the newest n entries; fold everything older. Must be + a positive integer. + --before Fold entries dated strictly before this YYYY-MM-DD day. + +Global options: see leji --help. + +Examples: + leji changelog compact --keep 50 + leji changelog compact --before 2026-01-01 + leji changelog compact --keep 50 --before 2026-01-01 + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/ci.txt b/fixtures/help-goldens/ci.txt new file mode 100644 index 0000000..e99cc7d --- /dev/null +++ b/fixtures/help-goldens/ci.txt @@ -0,0 +1,73 @@ +leji ci: Add a CI workflow that runs leji validate and index --check on every + change. + +Usage: leji ci [--provider ] [--hooks] [--root + ] [--json] + +Adds a CI job that runs `leji validate` and `leji index --check` on every +change, so your context layer stays honest in CI (the same two gates the +`--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place +is left untouched. + +Details: + - The provider is inferred from the `origin` remote (a github.com host + selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure + Pipelines) and falls back to GitHub when the remote names none; + `--provider` overrides the inference, and CircleCI is never inferred. + - GitHub: writes its own workflow at `.github/workflows/leji.yml`. + - GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if + it's absent. + - CircleCI: writes `.circleci/config.yml` if absent; when a config already + exists that leji did not generate, prints a snippet to add by hand instead + of editing it. + - Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not + auto-discover it, so activation is manual: create a pipeline that points at + the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), + then add a build-validation branch policy on `main` for pull-request + checks. This activation note prints once, on first creation. + - Local hook (`--hooks`): writes a managed pre-commit running `leji validate` + and `leji index --check` through the same detected runner the CI job uses + (`'pnpm' 'exec' 'leji' validate`, and so on), each argument single-quoted + for the shell; a repository that does not declare the CLI runs the `leji` + on PATH. `core.hooksPath` is detected, so a husky repo gets a managed block + merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an + existing unmanaged hook is never touched, its snippet printed to add by + hand. + - Local-first, through the package manager this repository actually uses: + `leji ci` detects it from the manifest and lockfiles present, and when the + repository DECLARES the Leji CLI and carries that manager's lock evidence + the generated job installs its locked dependencies and runs the local + binary (`corepack enable && pnpm install --frozen-lockfile` then `pnpm exec + leji`, `uv sync --locked` then `uv run leji`, `go mod download` then `go + tool leji`, and so on). Everything else takes a fallback that needs no + manifest: `npx @leji-org/leji@1` for Node, several ecosystems and none; + `pip install 'leji>=1,<2'` for Python; `go install .../cmd/leji@latest` for + Go. A bootstrap tool the job installs unpinned (poetry, pdm, pipenv, uv + outside GitHub) is disclosed in one comment line. + - Generated files carry the marker `# generated by leji ci (managed) v2`. A + re-run replaces a whole file (GitHub, CircleCI, Azure) only when its bytes + are ones leji generated, in this release or an earlier one, so a manager + change or an upgrade refreshes the job; a generated file you edited, and + any file you wrote yourself, are left untouched with a snippet to add by + hand. Editing the file, or deleting the marker, is the opt-out. GitLab owns + only its marker-delimited block inside `.gitlab-ci.yml`. + +Options: + --hooks Write a managed local pre-commit running validate + index + --check; core.hooksPath is detected, so a husky repo gets + a managed block in .husky/pre-commit, and an existing + unmanaged hook is left untouched with the snippet + printed. + --provider CI provider: github (default when no remote is + recognizable), gitlab, circleci, or azure. Without this + flag the provider is inferred from the origin remote. + +Global options: see leji --help. + +Examples: + leji ci + leji ci --provider gitlab + leji ci --provider azure + leji ci --hooks + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/conformance.txt b/fixtures/help-goldens/conformance.txt new file mode 100644 index 0000000..3af7f16 --- /dev/null +++ b/fixtures/help-goldens/conformance.txt @@ -0,0 +1,28 @@ +leji conformance: Score the context layer against its claimed conformance level. + +Usage: leji conformance [--explain] [--federation verify] [--root ] + [--json] + +Runs the `core`, `indexed`, `governed`, and `federated` checklists. +Machine-checkable items pass or fail; process items (review gate, CI, external +consumers) are reported as manual. A machine failure at or below the claimed +level is an error; evidence this run could not obtain reports `unknown`, which +caps the verified level without refuting the claim. With --explain it also +prints what it would take to reach the next level. + +Options: + --explain Print actionable guidance for reaching the next + conformance level. + --federation verify Run the networked pin-reachability probe against each + mount's source (git ls-remote + witness-ref ancestry). + Without it the pin-reachable item reports unknown, + which never awards the federated level. + +Global options: see leji --help. + +Examples: + leji conformance + leji conformance --explain + leji conformance --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/detect.txt b/fixtures/help-goldens/detect.txt new file mode 100644 index 0000000..6714c36 --- /dev/null +++ b/fixtures/help-goldens/detect.txt @@ -0,0 +1,21 @@ +leji detect: Detect the coding-agent hosts available on this machine. + +Usage: leji detect [--root ] [--json] + +Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, +Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable +binary, a config file in the repository, or a user-level config directory. +Writes nothing; use it to decide which host to open with `leji start --agent +` (launchable hosts today: claude-code and codex; other detected hosts +enter the context layer through their vendor-file redirect). Also reports the +repository's own dependency ecosystem: the package manager its manifest and +lockfiles name, whether the Leji CLI is already declared as a dev dependency +there, and the command that would declare it. + +Global options: see leji --help. + +Examples: + leji detect + leji detect --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/export.txt b/fixtures/help-goldens/export.txt new file mode 100644 index 0000000..a04c951 --- /dev/null +++ b/fixtures/help-goldens/export.txt @@ -0,0 +1,32 @@ +leji export: Export the context layer as a self-contained static site. + +Usage: leji export [--out ] [--strict] [--root ] [--json] + +Regenerates the viewer chrome, then writes the static site from the layer on +disk (default: .leji/dist/, kept out of git), complete on its own and servable +as-is, including under a subpath. Everything the site needs travels with it: +nothing is read from anywhere but the layer when it is written, and nothing is +read from anywhere but the site's own files when it is opened. `leji viewer +build` is the viewer subsystem's name for this same operation, beside `leji +viewer serve`; both names are permanently supported and behave identically. A +custom --out must resolve inside the repository, and never inside .leji/ except +exactly .leji/dist. The exported index.html warns that a context layer is +sensitive and belongs behind internal authentication, not in a public bucket. + +Options: + --out Output directory for the export (default: .leji/dist; + must resolve inside the repository, and never inside + .leji/ except exactly .leji/dist). + --strict Fail the export on any lint finding and write nothing, + leaving an existing export untouched. Without it, lint + findings are reported as warnings and the export is still + written. + +Global options: see leji --help. + +Examples: + leji export + leji export --out site + leji export --strict --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/freshness.txt b/fixtures/help-goldens/freshness.txt new file mode 100644 index 0000000..d9e5b11 --- /dev/null +++ b/fixtures/help-goldens/freshness.txt @@ -0,0 +1,19 @@ +leji freshness: Report review horizons across category documents and agent + profiles. + +Usage: leji freshness [--strict] [--root ] [--json] + +Lists documents whose freshness.reviewAfter horizon has passed (expired) or +falls within the next 30 days (upcoming). Report-only by default; expired +horizons are warnings. + +Options: + --strict Treat expired horizons as errors instead of warnings. + +Global options: see leji --help. + +Examples: + leji freshness + leji freshness --strict --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/index.txt b/fixtures/help-goldens/index.txt new file mode 100644 index 0000000..8189374 --- /dev/null +++ b/fixtures/help-goldens/index.txt @@ -0,0 +1,26 @@ +leji index: Generate the context index at the declared path, or verify it is + current. + +Usage: leji index [--check] [--root ] [--json] + +Resolves the category index files to the documents they list and writes the +context index to machine.indexPath. Ids are carried across a move when the move +is unambiguous: a document whose path changes keeps its id if its content is +unchanged and that content is unique in the context layer. A move that also +edits the content, or that moves one of several byte-identical documents, cannot +be carried and mints a fresh id. Declare a frontmatter id to make a document's +id survive any move; that is the only unconditional guarantee. With --check it +writes nothing and instead fails when the stored index no longer matches what +the index files resolve to (a stale index is a hard failure). + +Options: + --check Verify the stored index is current with the tree; write + nothing. + +Global options: see leji --help. + +Examples: + leji index + leji index --check + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/init.txt b/fixtures/help-goldens/init.txt new file mode 100644 index 0000000..8adea6d --- /dev/null +++ b/fixtures/help-goldens/init.txt @@ -0,0 +1,57 @@ +leji init: Bootstrap a new context layer from the templates. + +Usage: leji init [--dir ] [--yes] [--mode ] [--level + ] [--name ] [--agent ] [--no-agents] + [--dry-run] [--json] + +Scaffolds a new context layer from the templates. + +Details: + - Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the + portable entrypoint many agent hosts read, redirecting to the boot profile; + `--no-agents` skips it), seeded category documents, a first decision + record, an agent onboarding brief, and a generated index, so the scaffold + is ready for the CI job `leji ci` writes. At the indexed level it also + writes the machine changelog. The index is a requirement of `indexed`, not + of `core`; a hand-authored core context layer without one still conforms. + - `--mode solo` (a team of one) also seeds identity and writing-style + starters, maps the practice category, routes identity and writing work in + the boot profile, and points the onboarding brief at the owner interview + (answer in text or with dropped files). + - Refuses to overwrite an existing `leji.json`, and refuses when the git tree + has uncommitted changes; never overwrites individual files. + - Reports the repository's dependency ecosystem (its package manager, from + the manifest and lockfiles present) and how to declare the Leji CLI as a + dev dependency there, so a clean install brings `leji`; on a real terminal + it offers to run that manager's own add command, and only on your explicit + yes. `--yes`, a non-TTY and `--json` print the command instead of running + it, and leji never edits a manifest or lockfile itself. + - `--dry-run` prints the write plan without writing. + - Also backs `npm create leji`. + +Options: + --dir Target directory (default: the current directory). + --yes, -y Accept all defaults; run non-interactively. + --mode Working mode: solo (team of one; seeds identity + + writing-style starters) or team (default). + --level Conformance level to claim: core or indexed (default: + core). + --name Context layer name (default: derived from the directory). + --agent Host to open in the context layer after the command + (claude-code or codex). Selects the handoff host; the + interactive flow may separately offer to register the MCP + server or install the approval guard, each disclosed and + consented to. + --no-agents Skip generating the portable AGENTS.md pointer (default: + written when absent). + --dry-run Print the write plan and exit without creating any files. + +Global options: see leji --help. + +Examples: + leji init + leji init --dry-run + leji init --mode solo + leji init --agent claude-code + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/mounts-hydrate.txt b/fixtures/help-goldens/mounts-hydrate.txt new file mode 100644 index 0000000..097474e --- /dev/null +++ b/fixtures/help-goldens/mounts-hydrate.txt @@ -0,0 +1,40 @@ +leji mounts hydrate: Materialize declared federation mounts into the resolver + cache. + +Usage: leji mounts hydrate [--fetch] [--root ] [--json] + +For each declared federation mount, resolves the pinned commit from a local +object store (an explicit hint in .leji/mounts.local.json, the resolver-managed +store, or a unique matching submodule's object database) and extracts the +sibling's layer projection into the gitignored cache under .leji/mounts/. The +projection is the deduplicated union of everything the sibling's own manifest +makes readable at the pin: the root leji.json, the tree under its declared +context root, its boot profile, its machine index and changelog files when +present, its agent-profiles and decision-records trees when present, every agent +profile its agents map binds, every category index file, and every governed path +its pinned generated index lists, wherever those live. The failure boundary +follows the same line: a referenced or schema-required file absent at the pin +(the boot profile, a category index, a bound agent profile, an indexed governed +path) fails the projection naming the declaring artifact and the missing path, +while an absent directory or an absent machine artifact contributes nothing and +fails nothing. The only mutating mounts command, and offline by default: --fetch +establishes the resolver-managed store for every declared mount, including one a +hint already resolves, fetching the pin from the declared source, retaining it +under refs/leji-pin/v1/, and refreshing the managed witness under +refs/leji-witness/v1/ (the only writer of that namespace, since `mounts status` +never fetches). Best-effort: an unavailable mount is reported and skipped +(degraded knowledge, never a failed run); the exit code reflects declaration, +safety, or projection errors only. + +Options: + --fetch Establish the resolver-managed store for every declared + mount: fetch and retain the pin, and refresh the managed + witness ref. + +Global options: see leji --help. + +Examples: + leji mounts hydrate + leji mounts hydrate --fetch + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/mounts-locate.txt b/fixtures/help-goldens/mounts-locate.txt new file mode 100644 index 0000000..2be0cfd --- /dev/null +++ b/fixtures/help-goldens/mounts-locate.txt @@ -0,0 +1,20 @@ +leji mounts locate: Print resolver state for one mount: projection path, pin, + verification. + +Usage: leji mounts locate [--root ] [--json] + +Resolves a declared mount's hydrated projection through resolver state (never by +inferring cache paths): the projection directory, the pin, whether the bytes are +present, and whether they verified this run. Readers obtain the mounted +content's location from this command; a projection that cannot be verified is +reported as present but unverified, which includes the case where a verification +prerequisite (a reachable object store, a resolvable pin, a writable temp dir) +is unavailable. Exits 0 when the projection is present, 1 otherwise. + +Global options: see leji --help. + +Examples: + leji mounts locate product-context + leji mounts locate product-context --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/mounts-status.txt b/fixtures/help-goldens/mounts-status.txt new file mode 100644 index 0000000..31fda31 --- /dev/null +++ b/fixtures/help-goldens/mounts-status.txt @@ -0,0 +1,28 @@ +leji mounts status: Report each mount's availability, integrity, and pin + ancestry. + +Usage: leji mounts status [--check-integrity] [--root ] [--json] + +Read-only diagnostics for the declared federation mounts: whether the pinned +projection is present in the cache, and an ancestry-aware pin report against the +declared witness ref (trackingRef) computed from a reachable local object store: +up-to-date, behind N, ahead, diverged, unrelated, or unknown, always naming the +compared ref, the category of repository the comparison ran in +(comparisonRepository: managed-store, hint, or submodule), whether the witness +was the resolver's own ref or one it does not own (witnessProvenance), the +observation time, and ancestry completeness. --check-integrity additionally +re-derives the projection from the object store and compares it byte-for-byte +(paths, modes, symlinks) against the cache. Never mutates and never touches the +network. + +Options: + --check-integrity Verify the cached projection byte-for-byte against a + reachable object store. + +Global options: see leji --help. + +Examples: + leji mounts status + leji mounts status --check-integrity --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/mounts-update-pin.txt b/fixtures/help-goldens/mounts-update-pin.txt new file mode 100644 index 0000000..d19d4a4 --- /dev/null +++ b/fixtures/help-goldens/mounts-update-pin.txt @@ -0,0 +1,55 @@ +leji mounts update-pin: Move a declared mount's pin forward to a witnessed + commit, showing the comparison first. + +Usage: leji mounts update-pin [--to ] [--allow-non-fast-forward] + [--fetch] [--dry-run] [--root ] [--json] + +Rewrites one declared federation mount's pin in leji.json, after printing where +that pin stands against its tracking ref. Offline by default: the target is the +last successfully observed witness in a reachable object store (the +resolver-managed store first, then a hint or a unique matching submodule holding +both the pin and the ref), never a claim that the source was looked at during +this run. --fetch observes the declared source and nothing else, in three acts: +retain the current pin in the resolver-managed store, refresh the managed +witness ref once, and retain the target once the comparison has passed; any of +them failing refuses the move with a stable reason and leaves leji.json +untouched, though objects and refs already fetched stay in the managed store. +With no trackingRef declared the run refuses offline, and under --fetch resolves +the source's advertised default branch for this run and reports it as the +compared ref. The pin moves forward only: a target that is not a descendant of +the current pin is refused unless BOTH --to and --allow-non-fast-forward +are given, which is recorded as a warning and as override in --json; neither +flag bypasses a repository whose ancestry is incomplete. --to takes a full 40- +or 64-character lowercase hex commit id the comparison repository already holds. +--dry-run computes and prints everything and writes no manifest byte; combined +with --fetch it still performs that flag's store and network acts, so fetched +objects and refs land in the managed store. Only the pin's own bytes are +replaced, so field order, formatting and unmodeled keys survive. Hydration is a +separate step: the run prints the leji mounts hydrate command that materializes +the new pin, and the cache entry for the old pin is left in place for you to +remove by hand. Exit 0 when the pin was updated, was already the target, or the +run was a dry run; 1 when the move was refused with a stable reason; 2 for a +usage error, or when the addressed pin cannot be located in leji.json. + +Options: + --to Move to this exact commit instead of the witness + tip; it must already be held by the comparison + repository. + --allow-non-fast-forward Permit a target that is not a descendant of the + current pin. Valid only with --to, and always + warned. + --fetch Observe the declared source: retain the current + pin, refresh the managed witness ref, and retain + the target. + --dry-run Show the comparison and what would change; write + no manifest byte. With --fetch, the store and + network acts still happen. + +Global options: see leji --help. + +Examples: + leji mounts update-pin product-context + leji mounts update-pin product-context --fetch --dry-run + leji mounts update-pin product-context --to 7d3f2a19c4e8b6a0d5f1c2e9b8a7f6d5c4b3a2e1 + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/route.txt b/fixtures/help-goldens/route.txt new file mode 100644 index 0000000..ee7767f --- /dev/null +++ b/fixtures/help-goldens/route.txt @@ -0,0 +1,61 @@ +leji route: Show the governed context a task's scope routes to. + +Usage: leji route [--paths ] [--categories ] [--topics + ]... [--as-of ] [--root ] [--json] + +Read-only: given a task's scope (repository-relative paths it reads or changes, +plus any categories and topics it names), print the slice of governed context +that scope selects per the Task routing algorithm. Paths select the governed +entries that contain them or are contained by them, and a path that is itself a +governed document signals that document's category for decision and mount +matching without expanding it; only a category the task explicitly names expands +that category's intent documents and record candidates. Topics select sibling +mounts and nothing else. Prints the expanded categories and the signalled ones, +the governed documents (with each document's review horizon and whether it has +expired), the record candidates a reader loads by judgment, the live decision +records routed to the task, and the sibling mounts the supplied category and +topic signals match. It computes the scope-dependent portion only: the boot +profile's unconditional load set and the active agent profile's requiredRead are +the caller's baseline and are never emitted here. Reads and reports context; it +never executes a task. + +Details: + - --paths and --categories take comma-separated values; either or both may be + given. + - --topics is repeatable and each occurrence is one whole topic: values are + never comma-split and never trimmed, all occurrences accumulate, exact + duplicates count once, and an empty occurrence is an error. + - A topic matches a mount's declared topics by exact string equality, with no + case conversion, normalization, locale, or fuzzy matching. A topic match + selects the mount only: it never expands or signals a category, loads no + document or record, and routes no decision. + - Document expiry is evaluated against --as-of (defaults to today); decisions + carry no horizon. + - An empty path scope contributes no path matching and notes that path-scoped + routing was not evaluated; categories named with --categories are still + honored and still expand, and topics named with --topics are still matched. + An empty whole scope, no paths, no categories, and no topics, routes only + the org-wide unscoped live decisions. + +Options: + --paths Repository-relative paths the task reads or changes. + Selects the entries they reach; signals a category + without expanding it. + --categories Content categories the task explicitly names. Only + these expand a category's documents and record + candidates. + --topics A topic the task explicitly names, matched against a + mount's declared topics by exact equality. + Repeatable, one whole topic per occurrence; selects + mounts only. + --as-of Reference date for document expiry (default: today). + +Global options: see leji --help. + +Examples: + leji route --paths src/payments/billing.ts + leji route --categories domain,system --json + leji route --topics "product surface" --topics billing --json + leji route --paths docs/system/invariants.md --as-of 2026-06-27 --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/row-non-bmp.txt b/fixtures/help-goldens/row-non-bmp.txt new file mode 100644 index 0000000..c3b736e --- /dev/null +++ b/fixtures/help-goldens/row-non-bmp.txt @@ -0,0 +1,2 @@ + --emoji-😀😀 A flag carrying astral characters, so a column padded in + UTF-16 units misaligns this row by two. diff --git a/fixtures/help-goldens/row-overlong-label.txt b/fixtures/help-goldens/row-overlong-label.txt new file mode 100644 index 0000000..06302c9 --- /dev/null +++ b/fixtures/help-goldens/row-overlong-label.txt @@ -0,0 +1,4 @@ + --allow-non-fast-forward-with-a-very-long-spelling + Permit a target that is not a descendant of the current + pin, in the one spelling long enough to outgrow its + column. diff --git a/fixtures/help-goldens/start.txt b/fixtures/help-goldens/start.txt new file mode 100644 index 0000000..20105db --- /dev/null +++ b/fixtures/help-goldens/start.txt @@ -0,0 +1,45 @@ +leji start: Open a coding agent in this context layer, booted from the boot + profile. + +Usage: leji start [--agent ] [--root ] [--json] [-- ] + +Detects an installed agent (or use --agent), launches it from the context root, +and points it at the boot profile so it loads the team's context first. The +agent-facing counterpart to `leji view`. Several detected agents prompt for +which; with none detected or in a non-interactive shell, it prints the command +to run. Everything after a literal -- passes verbatim to the launched host +binary, before the boot prompt. Host-specific flags ride with a pinned host: +`leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which +could hand the flag to whichever host gets picked. + +Details: + - Before the agent starts, prints a Setup block for this clone: whether the + Leji CLI this repository declares resolves here and meets the minimum + version for the layer's spec line, whether the MCP server is registered for + the selected host, whether the shared `.mcp.json` is committed, and whether + the pre-commit hook is installed. + - Each row is personal or shared. Personal state (your host's MCP + registration, this clone's `.git` hook) is offered on a real terminal and + printed as an exact command otherwise; shared state (the dependency + declaration, a committed `.mcp.json`, a hooks directory inside the working + tree) is only ever reported, with the command a maintainer runs and + commits. A gap never blocks entry: the agent still boots. + - `--json` makes it report-only: one document with `ready` and the same + checks, no prompts and no launch, exit 0 even when `ready` is false. The + launch-selection arguments are accepted and have no effect there; an + `--agent` naming no launchable host is still a usage error. + +Options: + --agent Launch a specific host (claude-code or codex) instead of + auto-detecting. + -- Pass the remaining arguments verbatim to the launched + host binary; pin --agent when they are host-specific. + +Global options: see leji --help. + +Examples: + leji start + leji start --agent codex + leji start --agent claude-code -- --chrome + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/status.txt b/fixtures/help-goldens/status.txt new file mode 100644 index 0000000..7d80ca1 --- /dev/null +++ b/fixtures/help-goldens/status.txt @@ -0,0 +1,26 @@ +leji status: Report unindexed, dangling, and stale documents in the context + layer. + +Usage: leji status [--strict] [--root ] [--json] + +Informational health report: markdown under the context root that no category +index lists (reference content), index entries whose listed path does not +resolve (dangling), and stored-index paths the index files no longer resolve to +(stale). It also reports shadowed entries and skipped READMEs, which are +informational only, and whether the context layer at HEAD would project +completely if a host mounted it (the closure enumerated, the failure detail, or +no commit to judge). Report-only by default; exit 0. With --strict, exits +nonzero when an unindexed, dangling, stale, or pending document is flagged +(shadowed and skipped-README entries never fail the run), for CI use. + +Options: + --strict Exit nonzero when an unindexed, dangling, stale, or + pending document is flagged, for CI. + +Global options: see leji --help. + +Examples: + leji status + leji status --strict --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/usage.txt b/fixtures/help-goldens/usage.txt new file mode 100644 index 0000000..d8f0139 --- /dev/null +++ b/fixtures/help-goldens/usage.txt @@ -0,0 +1,71 @@ +leji {{version}}: reference CLI for the Leji specification (spec line 1.0) + +Usage: leji [options] + +Get started: + init Bootstrap a new context layer from the templates. + adopt Adopt Leji into an existing repository. + start Open a coding agent in this context layer, booted from + the boot profile. + agent Bind an additional named agent into an existing context + layer. + detect Detect the coding-agent hosts available on this machine. + ci Add a CI workflow that runs leji validate and index + --check on every change. + +Every day: + validate Validate the context layer: manifest, artifacts, + frontmatter, and lint rules. + index Generate the context index at the declared path, or + verify it is current. + status Report unindexed, dangling, and stale documents in the + context layer. + conformance Score the context layer against its claimed conformance + level. + badge Write the self-attested conformance badge for this + repository. + freshness Report review horizons across category documents and + agent profiles. + route Show the governed context a task's scope routes to. + changelog check Verify the machine changelog: schema and append-only + discipline. + changelog compact Fold the oldest changelog entries into a single + compaction entry. + +Federation: + mounts hydrate Materialize declared federation mounts into the resolver + cache. + mounts status Report each mount's availability, integrity, and pin + ancestry. + mounts locate Print resolver state for one mount: projection path, pin, + verification. + mounts update-pin Move a declared mount's pin forward to a witnessed + commit, showing the comparison first. + +Viewer and export: + viewer Generate the static viewer for the context layer. + viewer serve Generate the viewer and serve it locally. + view (alias of viewer serve) + export Export the context layer as a self-contained static site. + viewer build (alias of export) + +Options: + --root Repository root to operate on (default: the current + directory). With the Node and Python CLIs, a root that + declares and installs the Leji CLI for that runtime, + meeting the layer's minimum, runs that copy. + --json Machine-readable JSON output instead of human-readable + text. + -v, --version Print the Leji version and exit. + -h, --help Show help and exit. + +Exit codes: + 0 Clean. No errors; warnings are allowed. + 1 A check did not pass. Usually an error finding was reported; some commands + also use it for a negative result with no finding, such as `status + --strict` flagging an entry, `mounts locate` finding no hydrated + projection, or `start` finding no boot profile. + 2 Usage error, or an internal failure (e.g. init refusing to overwrite). + +Run `leji --help` for a command and its options. +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/validate.txt b/fixtures/help-goldens/validate.txt new file mode 100644 index 0000000..1be6f5b --- /dev/null +++ b/fixtures/help-goldens/validate.txt @@ -0,0 +1,34 @@ +leji validate: Validate the context layer: manifest, artifacts, frontmatter, and + lint rules. + +Usage: leji validate [--content] [--federation [--paths + ]] [--root ] [--json] + +Loads leji.json and checks it against the schemas and the lint rules: declared +files exist, categories are populated, vendor entrypoints redirect to the boot +profile, frontmatter is valid, and (per the claimed conformance level) the index +is current and the changelog is append-only. One of the two gates the generated +CI runs, beside `leji index --check`. With --content it also runs a warning-only +content lint (placeholder text, generic boot identity, thin categories) that +never errors and never affects a conformance level. + +Options: + --content Also run the warning-only content lint (placeholders, + generic identity, thin categories). + --federation Opt-in federation enforcement: available fails on any + unhydrated or unverified mount; required fails only for + mounts the --paths task scope routes to. Run `leji + mounts hydrate` first; ordinary validate never fails on + availability. + --paths Task scope for --federation=required: comma-separated + repository paths; the routing algorithm decides which + mounts the task touches. + +Global options: see leji --help. + +Examples: + leji validate + leji validate --content + leji validate --root . --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/view.txt b/fixtures/help-goldens/view.txt new file mode 100644 index 0000000..9cfcb30 --- /dev/null +++ b/fixtures/help-goldens/view.txt @@ -0,0 +1,19 @@ +leji view: Alias for `leji viewer serve` (and opens the browser). + +Usage: leji view [--port ] [--root ] + +One-word shortcut to browse the context layer: generates the viewer, serves it +on localhost, and opens your default browser. Equivalent to `leji viewer serve +--open`. + +Options: + --port Port to serve on. Overrides the manifest viewer.port; + default 5354; 0 picks a free port. + +Global options: see leji --help. + +Examples: + leji view + leji view --port 0 + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/viewer-build.txt b/fixtures/help-goldens/viewer-build.txt new file mode 100644 index 0000000..79539d2 --- /dev/null +++ b/fixtures/help-goldens/viewer-build.txt @@ -0,0 +1,26 @@ +leji viewer build: The viewer subsystem's name for `leji export`: write the + static site. + +Usage: leji viewer build [--out ] [--strict] [--root ] [--json] + +The same operation as `leji export`, under the viewer subsystem's own name +beside `leji viewer serve`: one code path, identical output, identical exits. +Both names are permanently supported; `leji export` is the name the +documentation leads with. Run `leji export --help` for the full description. + +Options: + --out Output directory for the export (default: .leji/dist; + must resolve inside the repository, and never inside + .leji/ except exactly .leji/dist). + --strict Fail the export on any lint finding and write nothing, + leaving an existing export untouched. Without it, lint + findings are reported as warnings and the export is still + written. + +Global options: see leji --help. + +Examples: + leji viewer build + leji viewer build --out site + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/viewer-serve.txt b/fixtures/help-goldens/viewer-serve.txt new file mode 100644 index 0000000..9b73ab3 --- /dev/null +++ b/fixtures/help-goldens/viewer-serve.txt @@ -0,0 +1,22 @@ +leji viewer serve: Generate the viewer and serve it locally. + +Usage: leji viewer serve [--port ] [--open] [--root ] [--json] + +Generates the viewer, then serves it on localhost (a local preview, never +hosting) at the web root. With --open it also opens your default browser at the +viewer. + +Options: + --open Open the viewer in your default browser after serving. + --port Port to serve on. Overrides the manifest viewer.port; + default 5354 (LEJI on a phone keypad); 0 picks a free + port. + +Global options: see leji --help. + +Examples: + leji viewer serve + leji viewer serve --open + leji viewer serve --port 0 + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/viewer.txt b/fixtures/help-goldens/viewer.txt new file mode 100644 index 0000000..be44dd6 --- /dev/null +++ b/fixtures/help-goldens/viewer.txt @@ -0,0 +1,19 @@ +leji viewer: Generate the static viewer for the context layer. + +Usage: leji viewer [--root ] [--json] + +Projects the context index into a browsable Docsify viewer: writes a +frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored +viewer assets into the context layer's contained viewer directory. Presentation +is non-normative; this is the reference projection. Generates only; use `leji +viewer serve` (or `leji view`) to preview it locally, and `leji export` (spelled +`leji viewer build` inside the viewer subsystem) to write a self-contained +static site. + +Global options: see leji --help. + +Examples: + leji viewer + leji viewer --json + +Full reference: https://leji.org/cli/ diff --git a/fixtures/help-goldens/wrap-long-usage.txt b/fixtures/help-goldens/wrap-long-usage.txt new file mode 100644 index 0000000..258ffe2 --- /dev/null +++ b/fixtures/help-goldens/wrap-long-usage.txt @@ -0,0 +1,2 @@ +Usage: leji mounts update-pin [--to ] [--allow-non-fast-forward] + [--fetch] [--dry-run] [--root ] [--json] diff --git a/fixtures/help-goldens/wrap-non-bmp.txt b/fixtures/help-goldens/wrap-non-bmp.txt new file mode 100644 index 0000000..c6f1295 --- /dev/null +++ b/fixtures/help-goldens/wrap-non-bmp.txt @@ -0,0 +1,2 @@ +😀😀😀😀 alphabet six666 + tail diff --git a/fixtures/invalid-governed-no-profile/expected.json b/fixtures/invalid-governed-no-profile/expected.json index 59ee979..88cd730 100644 --- a/fixtures/invalid-governed-no-profile/expected.json +++ b/fixtures/invalid-governed-no-profile/expected.json @@ -17,5 +17,15 @@ "indexCheck": { "exit": 0, "stale": false + }, + "badge": { + "exit": 1, + "out": null, + "level": null, + "claimedLevel": "governed", + "verifiedLevel": "indexed", + "golden": null, + "action": null, + "written": false } } diff --git a/fixtures/manifest-pin-span/canonical/case.json b/fixtures/manifest-pin-span/canonical/case.json new file mode 100644 index 0000000..7151619 --- /dev/null +++ b/fixtures/manifest-pin-span/canonical/case.json @@ -0,0 +1,7 @@ +{ + "note": "The canonical two-space layout the scaffolder writes.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/canonical/expected.json b/fixtures/manifest-pin-span/canonical/expected.json new file mode 100644 index 0000000..6605344 --- /dev/null +++ b/fixtures/manifest-pin-span/canonical/expected.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/canonical/input.json b/fixtures/manifest-pin-span/canonical/input.json new file mode 100644 index 0000000..4838c7a --- /dev/null +++ b/fixtures/manifest-pin-span/canonical/input.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/crlf/case.json b/fixtures/manifest-pin-span/crlf/case.json new file mode 100644 index 0000000..2076ef9 --- /dev/null +++ b/fixtures/manifest-pin-span/crlf/case.json @@ -0,0 +1,7 @@ +{ + "note": "CRLF line endings throughout: the scanner never normalizes them.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/crlf/expected.json b/fixtures/manifest-pin-span/crlf/expected.json new file mode 100644 index 0000000..ff83343 --- /dev/null +++ b/fixtures/manifest-pin-span/crlf/expected.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/crlf/input.json b/fixtures/manifest-pin-span/crlf/input.json new file mode 100644 index 0000000..4225333 --- /dev/null +++ b/fixtures/manifest-pin-span/crlf/input.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/error-duplicate-federation/case.json b/fixtures/manifest-pin-span/error-duplicate-federation/case.json new file mode 100644 index 0000000..0148f98 --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-federation/case.json @@ -0,0 +1,8 @@ +{ + "note": "Two \"federation\" members at the root: a lexical scan takes the first, JSON.parse the last.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "duplicate-key" +} diff --git a/fixtures/manifest-pin-span/error-duplicate-federation/input.json b/fixtures/manifest-pin-span/error-duplicate-federation/input.json new file mode 100644 index 0000000..c963e03 --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-federation/input.json @@ -0,0 +1,34 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [] + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/error-duplicate-mounts/case.json b/fixtures/manifest-pin-span/error-duplicate-mounts/case.json new file mode 100644 index 0000000..12ddf79 --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-mounts/case.json @@ -0,0 +1,8 @@ +{ + "note": "Two \"mounts\" members inside \"federation\": which array holds the addressed mount is undecidable.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "duplicate-key" +} diff --git a/fixtures/manifest-pin-span/error-duplicate-mounts/input.json b/fixtures/manifest-pin-span/error-duplicate-mounts/input.json new file mode 100644 index 0000000..e0db54c --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-mounts/input.json @@ -0,0 +1,32 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [], + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/error-duplicate-name/case.json b/fixtures/manifest-pin-span/error-duplicate-name/case.json new file mode 100644 index 0000000..f1f415a --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-name/case.json @@ -0,0 +1,8 @@ +{ + "note": "A mount carrying two \"name\" members cannot be told apart from the addressed one.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "duplicate-key" +} diff --git a/fixtures/manifest-pin-span/error-duplicate-name/input.json b/fixtures/manifest-pin-span/error-duplicate-name/input.json new file mode 100644 index 0000000..d442b34 --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-name/input.json @@ -0,0 +1,32 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "other-context", + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/error-duplicate-pin/case.json b/fixtures/manifest-pin-span/error-duplicate-pin/case.json new file mode 100644 index 0000000..3f84418 --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-pin/case.json @@ -0,0 +1,8 @@ +{ + "note": "The addressed mount carries two \"pin\" members: rewriting the first would report a change the manifest does not have.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "duplicate-key" +} diff --git a/fixtures/manifest-pin-span/error-duplicate-pin/input.json b/fixtures/manifest-pin-span/error-duplicate-pin/input.json new file mode 100644 index 0000000..e2fe16a --- /dev/null +++ b/fixtures/manifest-pin-span/error-duplicate-pin/input.json @@ -0,0 +1,32 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "pin": "1111111111111111111111111111111111111111", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/error-mount-not-found/case.json b/fixtures/manifest-pin-span/error-mount-not-found/case.json new file mode 100644 index 0000000..f747440 --- /dev/null +++ b/fixtures/manifest-pin-span/error-mount-not-found/case.json @@ -0,0 +1,8 @@ +{ + "note": "No mount carries the addressed name.", + "mount": "absent-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "not-located" +} diff --git a/fixtures/manifest-pin-span/error-mount-not-found/input.json b/fixtures/manifest-pin-span/error-mount-not-found/input.json new file mode 100644 index 0000000..4838c7a --- /dev/null +++ b/fixtures/manifest-pin-span/error-mount-not-found/input.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/error-no-federation/case.json b/fixtures/manifest-pin-span/error-no-federation/case.json new file mode 100644 index 0000000..e174fd5 --- /dev/null +++ b/fixtures/manifest-pin-span/error-no-federation/case.json @@ -0,0 +1,8 @@ +{ + "note": "The manifest declares no federation block at all, so there is no pin to locate.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "not-located" +} diff --git a/fixtures/manifest-pin-span/error-no-federation/input.json b/fixtures/manifest-pin-span/error-no-federation/input.json new file mode 100644 index 0000000..055f2ff --- /dev/null +++ b/fixtures/manifest-pin-span/error-no-federation/input.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + } +} diff --git a/fixtures/manifest-pin-span/error-value-mismatch/case.json b/fixtures/manifest-pin-span/error-value-mismatch/case.json new file mode 100644 index 0000000..f6a3660 --- /dev/null +++ b/fixtures/manifest-pin-span/error-value-mismatch/case.json @@ -0,0 +1,8 @@ +{ + "note": "The span holds a pin other than `from`: the manifest moved under the run, so it is refused rather than overwritten.", + "mount": "product-context", + "from": "1111111111111111111111111111111111111111", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "error", + "error": "not-from" +} diff --git a/fixtures/manifest-pin-span/error-value-mismatch/input.json b/fixtures/manifest-pin-span/error-value-mismatch/input.json new file mode 100644 index 0000000..4838c7a --- /dev/null +++ b/fixtures/manifest-pin-span/error-value-mismatch/input.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/escaped-name/case.json b/fixtures/manifest-pin-span/escaped-name/case.json new file mode 100644 index 0000000..2b6ed35 --- /dev/null +++ b/fixtures/manifest-pin-span/escaped-name/case.json @@ -0,0 +1,7 @@ +{ + "note": "The addressed name is spelled with a \\uXXXX escape and an astral surrogate-pair escape; escapes decode for comparison only and the raw spelling survives.", + "mount": "café-😀-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/escaped-name/expected.json b/fixtures/manifest-pin-span/escaped-name/expected.json new file mode 100644 index 0000000..a9ed427 --- /dev/null +++ b/fixtures/manifest-pin-span/escaped-name/expected.json @@ -0,0 +1,19 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "name": "caf\u00e9-\ud83d\ude00-context", + "source": "https://github.com/acme/cafe-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { "name": "Caf\u00e9 Owner" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/escaped-name/input.json b/fixtures/manifest-pin-span/escaped-name/input.json new file mode 100644 index 0000000..022df8e --- /dev/null +++ b/fixtures/manifest-pin-span/escaped-name/input.json @@ -0,0 +1,19 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "name": "caf\u00e9-\ud83d\ude00-context", + "source": "https://github.com/acme/cafe-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { "name": "Caf\u00e9 Owner" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/escaped-property-key/case.json b/fixtures/manifest-pin-span/escaped-property-key/case.json new file mode 100644 index 0000000..f9c548f --- /dev/null +++ b/fixtures/manifest-pin-span/escaped-property-key/case.json @@ -0,0 +1,7 @@ +{ + "note": "The \"pin\" property KEY is spelled with \\uXXXX escapes; keys decode for comparison only and the key bytes survive.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/escaped-property-key/expected.json b/fixtures/manifest-pin-span/escaped-property-key/expected.json new file mode 100644 index 0000000..283c227 --- /dev/null +++ b/fixtures/manifest-pin-span/escaped-property-key/expected.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "\u0070i\u006e": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/escaped-property-key/input.json b/fixtures/manifest-pin-span/escaped-property-key/input.json new file mode 100644 index 0000000..3ba21a0 --- /dev/null +++ b/fixtures/manifest-pin-span/escaped-property-key/input.json @@ -0,0 +1,31 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "\u0070i\u006e": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/mount-not-first/case.json b/fixtures/manifest-pin-span/mount-not-first/case.json new file mode 100644 index 0000000..42cd8b2 --- /dev/null +++ b/fixtures/manifest-pin-span/mount-not-first/case.json @@ -0,0 +1,7 @@ +{ + "note": "The addressed mount is the second array element; the first is left byte-untouched.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/mount-not-first/expected.json b/fixtures/manifest-pin-span/mount-not-first/expected.json new file mode 100644 index 0000000..5d24a0b --- /dev/null +++ b/fixtures/manifest-pin-span/mount-not-first/expected.json @@ -0,0 +1,40 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "earlier-context", + "source": "https://github.com/acme/earlier-context", + "pin": "1111111111111111111111111111111111111111", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Earlier Owner" + } + }, + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/mount-not-first/input.json b/fixtures/manifest-pin-span/mount-not-first/input.json new file mode 100644 index 0000000..7269948 --- /dev/null +++ b/fixtures/manifest-pin-span/mount-not-first/input.json @@ -0,0 +1,40 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Host Owner" + } + }, + "federation": { + "mounts": [ + { + "name": "earlier-context", + "source": "https://github.com/acme/earlier-context", + "pin": "1111111111111111111111111111111111111111", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Earlier Owner" + } + }, + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/nested-unrelated-pin/case.json b/fixtures/manifest-pin-span/nested-unrelated-pin/case.json new file mode 100644 index 0000000..75a1a3a --- /dev/null +++ b/fixtures/manifest-pin-span/nested-unrelated-pin/case.json @@ -0,0 +1,7 @@ +{ + "note": "Unrelated \"pin\" keys sit above the mounts array and inside the mount’s own nested owner object; nested containers are skipped structurally.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/nested-unrelated-pin/expected.json b/fixtures/manifest-pin-span/nested-unrelated-pin/expected.json new file mode 100644 index 0000000..a68abd1 --- /dev/null +++ b/fixtures/manifest-pin-span/nested-unrelated-pin/expected.json @@ -0,0 +1,25 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "viewer": { + "pins": [ + { "path": "docs/overview.md", "pin": "1111111111111111111111111111111111111111" } + ], + "theme": { "pin": "1111111111111111111111111111111111111111" } + }, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "owner": { "name": "Product Owner", "pin": "1111111111111111111111111111111111111111" }, + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main" + } + ] + } +} diff --git a/fixtures/manifest-pin-span/nested-unrelated-pin/input.json b/fixtures/manifest-pin-span/nested-unrelated-pin/input.json new file mode 100644 index 0000000..5f4869c --- /dev/null +++ b/fixtures/manifest-pin-span/nested-unrelated-pin/input.json @@ -0,0 +1,25 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "viewer": { + "pins": [ + { "path": "docs/overview.md", "pin": "1111111111111111111111111111111111111111" } + ], + "theme": { "pin": "1111111111111111111111111111111111111111" } + }, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "owner": { "name": "Product Owner", "pin": "1111111111111111111111111111111111111111" }, + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main" + } + ] + } +} diff --git a/fixtures/manifest-pin-span/non-canonical-spacing/case.json b/fixtures/manifest-pin-span/non-canonical-spacing/case.json new file mode 100644 index 0000000..5ce4528 --- /dev/null +++ b/fixtures/manifest-pin-span/non-canonical-spacing/case.json @@ -0,0 +1,7 @@ +{ + "note": "Tabs, collapsed members, and space around the colon: layout is input, never a contract.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/non-canonical-spacing/expected.json b/fixtures/manifest-pin-span/non-canonical-spacing/expected.json new file mode 100644 index 0000000..fc2d797 --- /dev/null +++ b/fixtures/manifest-pin-span/non-canonical-spacing/expected.json @@ -0,0 +1,5 @@ +{"leji":"1.0","name":"host","rootPath":"docs/","bootProfilePath":"docs/boot-profile.md", + "categories" : { } , + "owners":{"primary":{"name":"Host Owner"}}, + "federation":{"mounts":[{"name":"product-context","source":"https://github.com/acme/product-context", + "pin" : "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a" , "trackingRef":"refs/heads/main","owner":{"name":"Product Owner"}}]}} diff --git a/fixtures/manifest-pin-span/non-canonical-spacing/input.json b/fixtures/manifest-pin-span/non-canonical-spacing/input.json new file mode 100644 index 0000000..eba4ee6 --- /dev/null +++ b/fixtures/manifest-pin-span/non-canonical-spacing/input.json @@ -0,0 +1,5 @@ +{"leji":"1.0","name":"host","rootPath":"docs/","bootProfilePath":"docs/boot-profile.md", + "categories" : { } , + "owners":{"primary":{"name":"Host Owner"}}, + "federation":{"mounts":[{"name":"product-context","source":"https://github.com/acme/product-context", + "pin" : "4e974570000000000000000000000000000000aa" , "trackingRef":"refs/heads/main","owner":{"name":"Product Owner"}}]}} diff --git a/fixtures/manifest-pin-span/owner-name-collision/case.json b/fixtures/manifest-pin-span/owner-name-collision/case.json new file mode 100644 index 0000000..7cdfcc2 --- /dev/null +++ b/fixtures/manifest-pin-span/owner-name-collision/case.json @@ -0,0 +1,7 @@ +{ + "note": "The manifest owner and the mount owner both carry the mount’s own name; only the mount object’s pin moves.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/owner-name-collision/expected.json b/fixtures/manifest-pin-span/owner-name-collision/expected.json new file mode 100644 index 0000000..b1226a1 --- /dev/null +++ b/fixtures/manifest-pin-span/owner-name-collision/expected.json @@ -0,0 +1,19 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "product-context" } }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { "name": "product-context" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/owner-name-collision/input.json b/fixtures/manifest-pin-span/owner-name-collision/input.json new file mode 100644 index 0000000..8067636 --- /dev/null +++ b/fixtures/manifest-pin-span/owner-name-collision/input.json @@ -0,0 +1,19 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "product-context" } }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { "name": "product-context" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/reversed-key-order/case.json b/fixtures/manifest-pin-span/reversed-key-order/case.json new file mode 100644 index 0000000..3c6c598 --- /dev/null +++ b/fixtures/manifest-pin-span/reversed-key-order/case.json @@ -0,0 +1,7 @@ +{ + "note": "The schema fixes no key order: \"pin\" precedes \"name\" on the addressed mount.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/reversed-key-order/expected.json b/fixtures/manifest-pin-span/reversed-key-order/expected.json new file mode 100644 index 0000000..d6c5c7a --- /dev/null +++ b/fixtures/manifest-pin-span/reversed-key-order/expected.json @@ -0,0 +1,19 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "owner": { "name": "Product Owner" }, + "trackingRef": "refs/heads/main", + "source": "https://github.com/acme/product-context", + "name": "product-context" + } + ] + } +} diff --git a/fixtures/manifest-pin-span/reversed-key-order/input.json b/fixtures/manifest-pin-span/reversed-key-order/input.json new file mode 100644 index 0000000..6167580 --- /dev/null +++ b/fixtures/manifest-pin-span/reversed-key-order/input.json @@ -0,0 +1,19 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "pin": "4e974570000000000000000000000000000000aa", + "owner": { "name": "Product Owner" }, + "trackingRef": "refs/heads/main", + "source": "https://github.com/acme/product-context", + "name": "product-context" + } + ] + } +} diff --git a/fixtures/manifest-pin-span/shared-prefix/case.json b/fixtures/manifest-pin-span/shared-prefix/case.json new file mode 100644 index 0000000..549902a --- /dev/null +++ b/fixtures/manifest-pin-span/shared-prefix/case.json @@ -0,0 +1,7 @@ +{ + "note": "Two mounts share a name prefix; the match is on the whole decoded name, never a prefix.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/shared-prefix/expected.json b/fixtures/manifest-pin-span/shared-prefix/expected.json new file mode 100644 index 0000000..ee494dd --- /dev/null +++ b/fixtures/manifest-pin-span/shared-prefix/expected.json @@ -0,0 +1,26 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "name": "product-context-extra", + "source": "https://github.com/acme/product-context-extra", + "pin": "1111111111111111111111111111111111111111", + "trackingRef": "refs/heads/main", + "owner": { "name": "Extra Owner" } + }, + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { "name": "Product Owner" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/shared-prefix/input.json b/fixtures/manifest-pin-span/shared-prefix/input.json new file mode 100644 index 0000000..df726bf --- /dev/null +++ b/fixtures/manifest-pin-span/shared-prefix/input.json @@ -0,0 +1,26 @@ +{ + "leji": "1.0", + "name": "host", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "mounts": [ + { + "name": "product-context-extra", + "source": "https://github.com/acme/product-context-extra", + "pin": "1111111111111111111111111111111111111111", + "trackingRef": "refs/heads/main", + "owner": { "name": "Extra Owner" } + }, + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { "name": "Product Owner" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/unmodeled-keys/case.json b/fixtures/manifest-pin-span/unmodeled-keys/case.json new file mode 100644 index 0000000..e2c8f77 --- /dev/null +++ b/fixtures/manifest-pin-span/unmodeled-keys/case.json @@ -0,0 +1,7 @@ +{ + "note": "Keys the schema does not model, at the root, on federation, and on the mount, all survive.", + "mount": "product-context", + "from": "4e974570000000000000000000000000000000aa", + "to": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "outcome": "replaced" +} diff --git a/fixtures/manifest-pin-span/unmodeled-keys/expected.json b/fixtures/manifest-pin-span/unmodeled-keys/expected.json new file mode 100644 index 0000000..7084fec --- /dev/null +++ b/fixtures/manifest-pin-span/unmodeled-keys/expected.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://leji.org/schemas/v1.0/context-manifest.schema.json", + "leji": "1.0", + "name": "host", + "x-internal": { "ticket": "OPS-4", "pin": "1111111111111111111111111111111111111111" }, + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "x-note": "unmodeled", + "mounts": [ + { + "name": "product-context", + "x-review": { "by": "platform", "pin": "1111111111111111111111111111111111111111" }, + "source": "https://github.com/acme/product-context", + "pin": "9f1c2e9b8a7f6d5c4b3a2e1d0c9b8a7f6d5c4b3a", + "trackingRef": "refs/heads/main", + "owner": { "name": "Product Owner" } + } + ] + } +} diff --git a/fixtures/manifest-pin-span/unmodeled-keys/input.json b/fixtures/manifest-pin-span/unmodeled-keys/input.json new file mode 100644 index 0000000..d0ca3ee --- /dev/null +++ b/fixtures/manifest-pin-span/unmodeled-keys/input.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://leji.org/schemas/v1.0/context-manifest.schema.json", + "leji": "1.0", + "name": "host", + "x-internal": { "ticket": "OPS-4", "pin": "1111111111111111111111111111111111111111" }, + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": {}, + "owners": { "primary": { "name": "Host Owner" } }, + "federation": { + "x-note": "unmodeled", + "mounts": [ + { + "name": "product-context", + "x-review": { "by": "platform", "pin": "1111111111111111111111111111111111111111" }, + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "trackingRef": "refs/heads/main", + "owner": { "name": "Product Owner" } + } + ] + } +} diff --git a/fixtures/start-preflight/githooks/docs/boot-profile.md b/fixtures/start-preflight/githooks/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/githooks/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/githooks/docs/context/domain.md b/fixtures/start-preflight/githooks/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/githooks/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/githooks/docs/domain/overview.md b/fixtures/start-preflight/githooks/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/githooks/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/githooks/githooks/pre-commit b/fixtures/start-preflight/githooks/githooks/pre-commit new file mode 100755 index 0000000..2b9f4d9 --- /dev/null +++ b/fixtures/start-preflight/githooks/githooks/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh +# A hand-authored hook this repository already committed. +echo "project hook" diff --git a/fixtures/start-preflight/githooks/leji.json b/fixtures/start-preflight/githooks/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/githooks/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/githooks/package-lock.json b/fixtures/start-preflight/githooks/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/start-preflight/githooks/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/start-preflight/githooks/package.json b/fixtures/start-preflight/githooks/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/start-preflight/githooks/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/start-preflight/go-tool/docs/boot-profile.md b/fixtures/start-preflight/go-tool/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/go-tool/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/go-tool/docs/context/domain.md b/fixtures/start-preflight/go-tool/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/go-tool/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/go-tool/docs/domain/overview.md b/fixtures/start-preflight/go-tool/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/go-tool/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/go-tool/go.mod b/fixtures/start-preflight/go-tool/go.mod new file mode 100644 index 0000000..985347e --- /dev/null +++ b/fixtures/start-preflight/go-tool/go.mod @@ -0,0 +1,5 @@ +module example.com/joiner-app + +go 1.24 + +tool github.com/leji-org/leji/packages/sdk-go/cmd/leji diff --git a/fixtures/start-preflight/go-tool/leji.json b/fixtures/start-preflight/go-tool/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/go-tool/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/husky/.husky/pre-commit b/fixtures/start-preflight/husky/.husky/pre-commit new file mode 100755 index 0000000..2b9f4d9 --- /dev/null +++ b/fixtures/start-preflight/husky/.husky/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh +# A hand-authored hook this repository already committed. +echo "project hook" diff --git a/fixtures/start-preflight/husky/docs/boot-profile.md b/fixtures/start-preflight/husky/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/husky/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/husky/docs/context/domain.md b/fixtures/start-preflight/husky/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/husky/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/husky/docs/domain/overview.md b/fixtures/start-preflight/husky/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/husky/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/husky/leji.json b/fixtures/start-preflight/husky/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/husky/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/husky/package-lock.json b/fixtures/start-preflight/husky/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/start-preflight/husky/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/start-preflight/husky/package.json b/fixtures/start-preflight/husky/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/start-preflight/husky/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/start-preflight/node-declared/docs/boot-profile.md b/fixtures/start-preflight/node-declared/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/node-declared/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/node-declared/docs/context/domain.md b/fixtures/start-preflight/node-declared/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/node-declared/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/node-declared/docs/domain/overview.md b/fixtures/start-preflight/node-declared/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/node-declared/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/node-declared/leji.json b/fixtures/start-preflight/node-declared/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/node-declared/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/node-declared/package-lock.json b/fixtures/start-preflight/node-declared/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/start-preflight/node-declared/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/start-preflight/node-declared/package.json b/fixtures/start-preflight/node-declared/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/start-preflight/node-declared/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/start-preflight/node-mcp-json/.mcp.json b/fixtures/start-preflight/node-mcp-json/.mcp.json new file mode 100644 index 0000000..78ff1d6 --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/.mcp.json @@ -0,0 +1,5 @@ +{ + "mcpServers": { + "leji": { "command": "npx", "args": ["-y", "@leji-org/mcp"] } + } +} diff --git a/fixtures/start-preflight/node-mcp-json/docs/boot-profile.md b/fixtures/start-preflight/node-mcp-json/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/node-mcp-json/docs/context/domain.md b/fixtures/start-preflight/node-mcp-json/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/node-mcp-json/docs/domain/overview.md b/fixtures/start-preflight/node-mcp-json/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/node-mcp-json/leji.json b/fixtures/start-preflight/node-mcp-json/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/node-mcp-json/package-lock.json b/fixtures/start-preflight/node-mcp-json/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/start-preflight/node-mcp-json/package.json b/fixtures/start-preflight/node-mcp-json/package.json new file mode 100644 index 0000000..f334583 --- /dev/null +++ b/fixtures/start-preflight/node-mcp-json/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "@leji-org/leji": "^1" + } +} diff --git a/fixtures/start-preflight/node-undeclared/docs/boot-profile.md b/fixtures/start-preflight/node-undeclared/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/node-undeclared/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/node-undeclared/docs/context/domain.md b/fixtures/start-preflight/node-undeclared/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/node-undeclared/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/node-undeclared/docs/domain/overview.md b/fixtures/start-preflight/node-undeclared/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/node-undeclared/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/node-undeclared/leji.json b/fixtures/start-preflight/node-undeclared/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/node-undeclared/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/node-undeclared/package-lock.json b/fixtures/start-preflight/node-undeclared/package-lock.json new file mode 100644 index 0000000..e9065a1 --- /dev/null +++ b/fixtures/start-preflight/node-undeclared/package-lock.json @@ -0,0 +1 @@ +{ "lockfileVersion": 3 } diff --git a/fixtures/start-preflight/node-undeclared/package.json b/fixtures/start-preflight/node-undeclared/package.json new file mode 100644 index 0000000..de8405c --- /dev/null +++ b/fixtures/start-preflight/node-undeclared/package.json @@ -0,0 +1,7 @@ +{ + "name": "joiner-app", + "private": true, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/fixtures/start-preflight/python-uv/docs/boot-profile.md b/fixtures/start-preflight/python-uv/docs/boot-profile.md new file mode 100644 index 0000000..d72bd0f --- /dev/null +++ b/fixtures/start-preflight/python-uv/docs/boot-profile.md @@ -0,0 +1,21 @@ +# Boot Profile + +## Identity + +An adopted layer a joiner has just cloned. + +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`. + +## 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/start-preflight/python-uv/docs/context/domain.md b/fixtures/start-preflight/python-uv/docs/context/domain.md new file mode 100644 index 0000000..89f03eb --- /dev/null +++ b/fixtures/start-preflight/python-uv/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain + +```leji-index +- path: docs/domain/overview.md +``` diff --git a/fixtures/start-preflight/python-uv/docs/domain/overview.md b/fixtures/start-preflight/python-uv/docs/domain/overview.md new file mode 100644 index 0000000..28d88c1 --- /dev/null +++ b/fixtures/start-preflight/python-uv/docs/domain/overview.md @@ -0,0 +1,7 @@ +--- +summary: What this fixture repository is about. +--- + +# Overview + +A miniature adopted repository, seeded so `leji start` has a real layer to report on. diff --git a/fixtures/start-preflight/python-uv/leji.json b/fixtures/start-preflight/python-uv/leji.json new file mode 100644 index 0000000..ee866bb --- /dev/null +++ b/fixtures/start-preflight/python-uv/leji.json @@ -0,0 +1,18 @@ +{ + "leji": "1.0", + "name": "joiner-fixture", + "rootPath": "docs/", + "bootProfilePath": "docs/boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "docs/context/domain.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/start-preflight/python-uv/pyproject.toml b/fixtures/start-preflight/python-uv/pyproject.toml new file mode 100644 index 0000000..7e78a13 --- /dev/null +++ b/fixtures/start-preflight/python-uv/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "joiner-app" +version = "0.1.0" +dependencies = [] + +[dependency-groups] +dev = ["leji"] diff --git a/fixtures/start-preflight/python-uv/uv.lock b/fixtures/start-preflight/python-uv/uv.lock new file mode 100644 index 0000000..f3e9318 --- /dev/null +++ b/fixtures/start-preflight/python-uv/uv.lock @@ -0,0 +1,2 @@ +version = 1 +requires-python = ">=3.11" diff --git a/fixtures/update-pin-goldens/default-ref-fetch.json b/fixtures/update-pin-goldens/default-ref-fetch.json new file mode 100644 index 0000000..a540853 --- /dev/null +++ b/fixtures/update-pin-goldens/default-ref-fetch.json @@ -0,0 +1,35 @@ +{ + "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" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/update-pin-goldens/hint-only-fetch.json b/fixtures/update-pin-goldens/hint-only-fetch.json new file mode 100644 index 0000000..5c3bbd6 --- /dev/null +++ b/fixtures/update-pin-goldens/hint-only-fetch.json @@ -0,0 +1,36 @@ +{ + "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" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/update-pin-goldens/to-descendant.json b/fixtures/update-pin-goldens/to-descendant.json new file mode 100644 index 0000000..5c3bbd6 --- /dev/null +++ b/fixtures/update-pin-goldens/to-descendant.json @@ -0,0 +1,36 @@ +{ + "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" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/update-pin-goldens/to-override-diverged.json b/fixtures/update-pin-goldens/to-override-diverged.json new file mode 100644 index 0000000..5c3bbd6 --- /dev/null +++ b/fixtures/update-pin-goldens/to-override-diverged.json @@ -0,0 +1,36 @@ +{ + "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" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/update-pin-goldens/witness-ahead.json b/fixtures/update-pin-goldens/witness-ahead.json new file mode 100644 index 0000000..5c3bbd6 --- /dev/null +++ b/fixtures/update-pin-goldens/witness-ahead.json @@ -0,0 +1,36 @@ +{ + "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" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/fixtures/valid-badge-federated-capped/docs/agents/core.md b/fixtures/valid-badge-federated-capped/docs/agents/core.md new file mode 100644 index 0000000..09863b4 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/agents/core.md @@ -0,0 +1,13 @@ +--- +id: core +name: Agent Core +role: core +requiredRead: + - docs/boot-profile.md +mustAskWhen: + - "the change reverses a recorded decision" +--- + +# Agent Core + +The shared posture every role profile inherits. diff --git a/fixtures/valid-badge-federated-capped/docs/boot-profile.md b/fixtures/valid-badge-federated-capped/docs/boot-profile.md new file mode 100644 index 0000000..c894cf3 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/boot-profile.md @@ -0,0 +1,26 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `docs/domain/` before any task. + +Sibling layers this repository mounts: + +```leji-mounts +- mount: product-context + owner: Product Owner + carries: product-side domain language and the decisions behind it + read-when: a task touches product behavior or product terminology +``` + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `docs/decisions/`. diff --git a/fixtures/valid-badge-federated-capped/docs/context-changelog.json b/fixtures/valid-badge-federated-capped/docs/context-changelog.json new file mode 100644 index 0000000..1c86a3d --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/context-changelog.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.0", + "entries": [ + { + "id": "seed-layer", + "date": "2026-06-12", + "type": "added", + "summary": "Seeded the fixture layer.", + "paths": [ + "docs/boot-profile.md" + ] + } + ] +} diff --git a/fixtures/valid-badge-federated-capped/docs/context-index.json b/fixtures/valid-badge-federated-capped/docs/context-index.json new file mode 100644 index 0000000..1f7d9f5 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/context-index.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://leji.org/schemas/v1.0/context-index.schema.json", + "schemaVersion": "1.0", + "generatedAt": "2026-07-31T12:23:38.263Z", + "generator": { + "name": "leji", + "version": "1.3.1" + }, + "rootPath": "docs/", + "entries": [ + { + "id": "adopt-leji", + "path": "docs/decisions/0001-adopt-leji.md", + "title": "Adopt the Leji context layer", + "category": "decisions", + "kind": "record", + "date": "2026-06-12", + "lastModified": "2026-06-17", + "contentHash": "sha256:b437b561cccaf7b3" + }, + { + "id": "overview", + "path": "docs/domain/overview.md", + "title": "Overview", + "category": "domain", + "kind": "intent", + "lastModified": "2026-06-17", + "contentHash": "sha256:c6ef4da2972e7534", + "freshness": { + "reviewAfter": "2030-01-01" + } + } + ], + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "owner": { + "name": "Product Owner" + }, + "categories": [ + "domain" + ], + "topics": [ + "product behavior", + "product terminology" + ] + } + ] +} diff --git a/fixtures/valid-badge-federated-capped/docs/context/decisions.md b/fixtures/valid-badge-federated-capped/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-badge-federated-capped/docs/context/domain.md b/fixtures/valid-badge-federated-capped/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-badge-federated-capped/docs/decisions/0001-adopt-leji.md b/fixtures/valid-badge-federated-capped/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/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-badge-federated-capped/docs/domain/overview.md b/fixtures/valid-badge-federated-capped/docs/domain/overview.md new file mode 100644 index 0000000..744c878 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/docs/domain/overview.md @@ -0,0 +1,8 @@ +--- +freshness: + reviewAfter: 2030-01-01 +--- + +# Overview + +A fixture domain document. diff --git a/fixtures/valid-badge-federated-capped/expected.json b/fixtures/valid-badge-federated-capped/expected.json new file mode 100644 index 0000000..df1a6ab --- /dev/null +++ b/fixtures/valid-badge-federated-capped/expected.json @@ -0,0 +1,34 @@ +{ + "validate": { + "exit": 0, + "findings": [ + { + "rule": "mount-unavailable", + "severity": "warning", + "path": "product-context" + } + ] + }, + "conformance": { + "exit": 0, + "claimedLevel": "federated", + "verifiedLevel": "governed" + }, + "indexCheck": { + "exit": 0, + "stale": false + }, + "badge": { + "exit": 0, + "out": "leji-badge.svg", + "level": "governed", + "claimedLevel": "federated", + "verifiedLevel": "governed", + "golden": "badge/governed.svg", + "action": "wrote", + "rerun": { + "action": "unchanged", + "byteIdentical": true + } + } +} diff --git a/fixtures/valid-badge-federated-capped/leji.json b/fixtures/valid-badge-federated-capped/leji.json new file mode 100644 index 0000000..b9f5d91 --- /dev/null +++ b/fixtures/valid-badge-federated-capped/leji.json @@ -0,0 +1,53 @@ +{ + "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" + } + }, + "conformance": { + "claimedLevel": "federated" + }, + "machine": { + "indexPath": "docs/context-index.json", + "changelogPath": "docs/context-changelog.json", + "agentProfilesPath": "docs/agents/" + }, + "agents": { + "core": "docs/agents/core.md" + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "4e974570000000000000000000000000000000aa", + "owner": { + "name": "Product Owner" + }, + "categories": [ + "domain" + ], + "topics": [ + "product behavior", + "product terminology" + ] + } + ] + } +} diff --git a/fixtures/valid-badge-governed-regen/docs/agents/core.md b/fixtures/valid-badge-governed-regen/docs/agents/core.md new file mode 100644 index 0000000..09863b4 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/docs/agents/core.md @@ -0,0 +1,13 @@ +--- +id: core +name: Agent Core +role: core +requiredRead: + - docs/boot-profile.md +mustAskWhen: + - "the change reverses a recorded decision" +--- + +# Agent Core + +The shared posture every role profile inherits. diff --git a/fixtures/valid-badge-governed-regen/docs/boot-profile.md b/fixtures/valid-badge-governed-regen/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/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-badge-governed-regen/docs/context-changelog.json b/fixtures/valid-badge-governed-regen/docs/context-changelog.json new file mode 100644 index 0000000..1c86a3d --- /dev/null +++ b/fixtures/valid-badge-governed-regen/docs/context-changelog.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.0", + "entries": [ + { + "id": "seed-layer", + "date": "2026-06-12", + "type": "added", + "summary": "Seeded the fixture layer.", + "paths": [ + "docs/boot-profile.md" + ] + } + ] +} diff --git a/fixtures/valid-badge-governed-regen/docs/context-index.json b/fixtures/valid-badge-governed-regen/docs/context-index.json new file mode 100644 index 0000000..2ebf5ad --- /dev/null +++ b/fixtures/valid-badge-governed-regen/docs/context-index.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://leji.org/schemas/v1.0/context-index.schema.json", + "schemaVersion": "1.0", + "generatedAt": "2026-07-31T12:23:38.263Z", + "generator": { + "name": "leji", + "version": "1.3.1" + }, + "rootPath": "docs/", + "entries": [ + { + "id": "adopt-leji", + "path": "docs/decisions/0001-adopt-leji.md", + "title": "Adopt the Leji context layer", + "category": "decisions", + "kind": "record", + "date": "2026-06-12", + "lastModified": "2026-06-17", + "contentHash": "sha256:b437b561cccaf7b3" + }, + { + "id": "overview", + "path": "docs/domain/overview.md", + "title": "Overview", + "category": "domain", + "kind": "intent", + "lastModified": "2026-06-17", + "contentHash": "sha256:c6ef4da2972e7534", + "freshness": { + "reviewAfter": "2030-01-01" + } + } + ] +} diff --git a/fixtures/valid-badge-governed-regen/docs/context/decisions.md b/fixtures/valid-badge-governed-regen/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-badge-governed-regen/docs/context/domain.md b/fixtures/valid-badge-governed-regen/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-badge-governed-regen/docs/decisions/0001-adopt-leji.md b/fixtures/valid-badge-governed-regen/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/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-badge-governed-regen/docs/domain/overview.md b/fixtures/valid-badge-governed-regen/docs/domain/overview.md new file mode 100644 index 0000000..744c878 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/docs/domain/overview.md @@ -0,0 +1,8 @@ +--- +freshness: + reviewAfter: 2030-01-01 +--- + +# Overview + +A fixture domain document. diff --git a/fixtures/valid-badge-governed-regen/expected.json b/fixtures/valid-badge-governed-regen/expected.json new file mode 100644 index 0000000..24d59de --- /dev/null +++ b/fixtures/valid-badge-governed-regen/expected.json @@ -0,0 +1,32 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "governed", + "verifiedLevel": "governed" + }, + "indexCheck": { + "exit": 0, + "stale": false + }, + "badge": { + "preseed": { + "path": "leji-badge.svg", + "from": "badge/indexed.svg" + }, + "exit": 0, + "out": "leji-badge.svg", + "level": "governed", + "claimedLevel": "governed", + "verifiedLevel": "governed", + "golden": "badge/governed.svg", + "action": "overwrote", + "rerun": { + "action": "unchanged", + "byteIdentical": true + } + } +} diff --git a/fixtures/valid-badge-governed-regen/leji.json b/fixtures/valid-badge-governed-regen/leji.json new file mode 100644 index 0000000..3256f89 --- /dev/null +++ b/fixtures/valid-badge-governed-regen/leji.json @@ -0,0 +1,34 @@ +{ + "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" + } + }, + "conformance": { + "claimedLevel": "governed" + }, + "machine": { + "indexPath": "docs/context-index.json", + "changelogPath": "docs/context-changelog.json", + "agentProfilesPath": "docs/agents/" + }, + "agents": { + "core": "docs/agents/core.md" + } +} diff --git a/fixtures/valid-badge-governed/docs/agents/core.md b/fixtures/valid-badge-governed/docs/agents/core.md new file mode 100644 index 0000000..09863b4 --- /dev/null +++ b/fixtures/valid-badge-governed/docs/agents/core.md @@ -0,0 +1,13 @@ +--- +id: core +name: Agent Core +role: core +requiredRead: + - docs/boot-profile.md +mustAskWhen: + - "the change reverses a recorded decision" +--- + +# Agent Core + +The shared posture every role profile inherits. diff --git a/fixtures/valid-badge-governed/docs/boot-profile.md b/fixtures/valid-badge-governed/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-badge-governed/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-badge-governed/docs/context-changelog.json b/fixtures/valid-badge-governed/docs/context-changelog.json new file mode 100644 index 0000000..1c86a3d --- /dev/null +++ b/fixtures/valid-badge-governed/docs/context-changelog.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.0", + "entries": [ + { + "id": "seed-layer", + "date": "2026-06-12", + "type": "added", + "summary": "Seeded the fixture layer.", + "paths": [ + "docs/boot-profile.md" + ] + } + ] +} diff --git a/fixtures/valid-badge-governed/docs/context-index.json b/fixtures/valid-badge-governed/docs/context-index.json new file mode 100644 index 0000000..2ebf5ad --- /dev/null +++ b/fixtures/valid-badge-governed/docs/context-index.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://leji.org/schemas/v1.0/context-index.schema.json", + "schemaVersion": "1.0", + "generatedAt": "2026-07-31T12:23:38.263Z", + "generator": { + "name": "leji", + "version": "1.3.1" + }, + "rootPath": "docs/", + "entries": [ + { + "id": "adopt-leji", + "path": "docs/decisions/0001-adopt-leji.md", + "title": "Adopt the Leji context layer", + "category": "decisions", + "kind": "record", + "date": "2026-06-12", + "lastModified": "2026-06-17", + "contentHash": "sha256:b437b561cccaf7b3" + }, + { + "id": "overview", + "path": "docs/domain/overview.md", + "title": "Overview", + "category": "domain", + "kind": "intent", + "lastModified": "2026-06-17", + "contentHash": "sha256:c6ef4da2972e7534", + "freshness": { + "reviewAfter": "2030-01-01" + } + } + ] +} diff --git a/fixtures/valid-badge-governed/docs/context/decisions.md b/fixtures/valid-badge-governed/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-badge-governed/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-badge-governed/docs/context/domain.md b/fixtures/valid-badge-governed/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-badge-governed/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-badge-governed/docs/decisions/0001-adopt-leji.md b/fixtures/valid-badge-governed/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-badge-governed/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-badge-governed/docs/domain/overview.md b/fixtures/valid-badge-governed/docs/domain/overview.md new file mode 100644 index 0000000..744c878 --- /dev/null +++ b/fixtures/valid-badge-governed/docs/domain/overview.md @@ -0,0 +1,8 @@ +--- +freshness: + reviewAfter: 2030-01-01 +--- + +# Overview + +A fixture domain document. diff --git a/fixtures/valid-badge-governed/expected.json b/fixtures/valid-badge-governed/expected.json new file mode 100644 index 0000000..e345577 --- /dev/null +++ b/fixtures/valid-badge-governed/expected.json @@ -0,0 +1,28 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "governed", + "verifiedLevel": "governed" + }, + "indexCheck": { + "exit": 0, + "stale": false + }, + "badge": { + "exit": 0, + "out": "leji-badge.svg", + "level": "governed", + "claimedLevel": "governed", + "verifiedLevel": "governed", + "golden": "badge/governed.svg", + "action": "wrote", + "rerun": { + "action": "unchanged", + "byteIdentical": true + } + } +} diff --git a/fixtures/valid-badge-governed/leji.json b/fixtures/valid-badge-governed/leji.json new file mode 100644 index 0000000..3256f89 --- /dev/null +++ b/fixtures/valid-badge-governed/leji.json @@ -0,0 +1,34 @@ +{ + "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" + } + }, + "conformance": { + "claimedLevel": "governed" + }, + "machine": { + "indexPath": "docs/context-index.json", + "changelogPath": "docs/context-changelog.json", + "agentProfilesPath": "docs/agents/" + }, + "agents": { + "core": "docs/agents/core.md" + } +} diff --git a/fixtures/valid-badge-indexed/docs/boot-profile.md b/fixtures/valid-badge-indexed/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-badge-indexed/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-badge-indexed/docs/context-changelog.json b/fixtures/valid-badge-indexed/docs/context-changelog.json new file mode 100644 index 0000000..1c86a3d --- /dev/null +++ b/fixtures/valid-badge-indexed/docs/context-changelog.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.0", + "entries": [ + { + "id": "seed-layer", + "date": "2026-06-12", + "type": "added", + "summary": "Seeded the fixture layer.", + "paths": [ + "docs/boot-profile.md" + ] + } + ] +} diff --git a/fixtures/valid-badge-indexed/docs/context-index.json b/fixtures/valid-badge-indexed/docs/context-index.json new file mode 100644 index 0000000..74597c5 --- /dev/null +++ b/fixtures/valid-badge-indexed/docs/context-index.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://leji.org/schemas/v1.0/context-index.schema.json", + "schemaVersion": "1.0", + "generatedAt": "2026-07-31T12:23:38.263Z", + "generator": { + "name": "leji", + "version": "1.3.1" + }, + "rootPath": "docs/", + "entries": [ + { + "id": "adopt-leji", + "path": "docs/decisions/0001-adopt-leji.md", + "title": "Adopt the Leji context layer", + "category": "decisions", + "kind": "record", + "date": "2026-06-12", + "lastModified": "2026-06-17", + "contentHash": "sha256:b437b561cccaf7b3" + }, + { + "id": "overview", + "path": "docs/domain/overview.md", + "title": "Overview", + "category": "domain", + "kind": "intent", + "lastModified": "2026-06-17", + "contentHash": "sha256:63923ff81a1f6285" + } + ] +} diff --git a/fixtures/valid-badge-indexed/docs/context/decisions.md b/fixtures/valid-badge-indexed/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-badge-indexed/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-badge-indexed/docs/context/domain.md b/fixtures/valid-badge-indexed/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-badge-indexed/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-badge-indexed/docs/decisions/0001-adopt-leji.md b/fixtures/valid-badge-indexed/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-badge-indexed/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-badge-indexed/docs/domain/overview.md b/fixtures/valid-badge-indexed/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-badge-indexed/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-badge-indexed/expected.json b/fixtures/valid-badge-indexed/expected.json new file mode 100644 index 0000000..f9f8d99 --- /dev/null +++ b/fixtures/valid-badge-indexed/expected.json @@ -0,0 +1,28 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "indexed", + "verifiedLevel": "indexed" + }, + "indexCheck": { + "exit": 0, + "stale": false + }, + "badge": { + "exit": 0, + "out": "leji-badge.svg", + "level": "indexed", + "claimedLevel": "indexed", + "verifiedLevel": "indexed", + "golden": "badge/indexed.svg", + "action": "wrote", + "rerun": { + "action": "unchanged", + "byteIdentical": true + } + } +} diff --git a/fixtures/valid-badge-indexed/leji.json b/fixtures/valid-badge-indexed/leji.json new file mode 100644 index 0000000..0cb14b7 --- /dev/null +++ b/fixtures/valid-badge-indexed/leji.json @@ -0,0 +1,30 @@ +{ + "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" + } + }, + "conformance": { + "claimedLevel": "indexed" + }, + "machine": { + "indexPath": "docs/context-index.json", + "changelogPath": "docs/context-changelog.json" + } +} diff --git a/fixtures/valid-minimal-core/expected.json b/fixtures/valid-minimal-core/expected.json index db1d838..4457865 100644 --- a/fixtures/valid-minimal-core/expected.json +++ b/fixtures/valid-minimal-core/expected.json @@ -7,5 +7,18 @@ "exit": 0, "claimedLevel": "core", "verifiedLevel": "core" + }, + "badge": { + "exit": 0, + "out": "leji-badge.svg", + "level": "core", + "claimedLevel": "core", + "verifiedLevel": "core", + "golden": "badge/core.svg", + "action": "wrote", + "rerun": { + "action": "unchanged", + "byteIdentical": true + } } } diff --git a/fixtures/valid-records/expected.json b/fixtures/valid-records/expected.json index eee9666..b5476d8 100644 --- a/fixtures/valid-records/expected.json +++ b/fixtures/valid-records/expected.json @@ -1,4 +1,18 @@ { "validate": { "exit": 0, "findings": [] }, - "conformance": { "exit": 0, "claimedLevel": "core", "verifiedLevel": "core" } + "conformance": { "exit": 0, "claimedLevel": "core", "verifiedLevel": "core" }, + "badge": { + "preseed": { + "path": "leji-badge.svg", + "bytes": "not a badge\n" + }, + "exit": 2, + "out": null, + "level": null, + "claimedLevel": "core", + "verifiedLevel": "core", + "golden": null, + "action": null, + "written": false + } } diff --git a/fixtures/valid-render-lint-strict/docs/boot-profile.md b/fixtures/valid-render-lint-strict/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-render-lint-strict/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-render-lint-strict/docs/context/decisions.md b/fixtures/valid-render-lint-strict/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-render-lint-strict/docs/context/domain.md b/fixtures/valid-render-lint-strict/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-render-lint-strict/docs/decisions/0001-adopt-leji.md b/fixtures/valid-render-lint-strict/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-render-lint-strict/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-render-lint-strict/docs/domain/overview.md b/fixtures/valid-render-lint-strict/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-render-lint-strict/docs/render/footnotes.md b/fixtures/valid-render-lint-strict/docs/render/footnotes.md new file mode 100644 index 0000000..2e84439 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/render/footnotes.md @@ -0,0 +1,20 @@ + + +# Footnotes + +A paragraph carrying a footnote reference[^one] mid-sentence. + +[^one]: The matching definition, which is the second form the lint recognizes. + +A paragraph with two references on one line, [^two] and [^three], which is one +finding: the line is the unit. + +[^two]: The second definition. + +[^three]: The third definition, whose text wraps in the source; the finding stays +on the line that opens the definition. diff --git a/fixtures/valid-render-lint-strict/docs/render/frontmatter-html.md b/fixtures/valid-render-lint-strict/docs/render/frontmatter-html.md new file mode 100644 index 0000000..b98d087 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/render/frontmatter-html.md @@ -0,0 +1,26 @@ +--- +title: Frontmatter that looks like markup +summary: A YAML value carrying
is metadata, never raw HTML. +note: 'A value may carry $$ and [^ref] too, and stays metadata' +banner:
+--- + + + +# Frontmatter is an excluded region + +The block at the top of this file is scanned as metadata and skipped. + +--- + +Prose after a thematic break, which the excluded-region pass must not mistake for +a second frontmatter block. Nothing here is a linted construct. diff --git a/fixtures/valid-render-lint-strict/docs/render/math.md b/fixtures/valid-render-lint-strict/docs/render/math.md new file mode 100644 index 0000000..09a1e55 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/render/math.md @@ -0,0 +1,27 @@ + + +# Math blocks + +A display block, opened and closed on their own lines: + +$$ +a^2 + b^2 = c^2 +$$ + +The finding sits on the opening delimiter's line, so the block above reports +once. + +A pair that opens and closes on one line: $$e = mc^2$$ inside a sentence. + +A second display block, to pin that each pair reports separately: + +$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$ + +An amount of $5 and a variable named $path are prose: a single `$` is outside the +lint's closed token set, by design. diff --git a/fixtures/valid-render-lint-strict/docs/render/negatives.md b/fixtures/valid-render-lint-strict/docs/render/negatives.md new file mode 100644 index 0000000..11ed39b --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/render/negatives.md @@ -0,0 +1,73 @@ + + +# Negatives + +## Inside a code span + +The tag `
` names a construct without being one. So do `
`, +``, `[^ref]`, `[^ref]: text`, and `$$x$$`: a code span is an +excluded region, scanned before anything else. + +A span may be spelled with two backticks when its content carries one: ``a `` span``. + +## Inside a fenced block + +A fence is an excluded region too, whatever its info string: + +```html +
+

Raw HTML as the subject of the documentation, not as markup.

+
+``` + +```markdown +A reference[^one] and its definition. + +[^one]: The definition. + +$$ +a^2 + b^2 = c^2 +$$ +``` + +````markdown +A fence nested inside a longer fence stays excluded: + +```html +still not markup +``` +```` + +## Escaped delimiters + +An escaped angle bracket is a literal character, not a tag: \
and +\bold\ are prose. + +An escaped bracket is not a footnote reference: \[^one] in a sentence. The +definition form is prose too when its bracket is escaped: + +\[^one]: not a definition, because the bracket is escaped. + +Escaped delimiters are not math: \$\$ a^2 + b^2 = c^2 \$\$ is prose about the +notation. + +## An unpaired delimiter + +A single delimiter with no closing partner is prose, and the lint requires a +pair, so the line below reports nothing. + +$$ diff --git a/fixtures/valid-render-lint-strict/docs/render/raw-html.md b/fixtures/valid-render-lint-strict/docs/render/raw-html.md new file mode 100644 index 0000000..b58bacd --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/render/raw-html.md @@ -0,0 +1,38 @@ + + +# Raw HTML + +## Block form + +A block-level element on its own line opens an HTML block that runs to the next +blank line: + +
+ Raw HTML inside a block. The finding sits on the opening line, not on this one + and not on the closing tag. +
+ +A second block, to pin that each block reports separately: + + + +
onetwo
+ +## Inline form + +A paragraph carrying bold markup mid-sentence. + +A paragraph with two inline tags, italic and code, which is +still one finding: the line is the unit. + +A self-closing tag inline: this sentence carries a break
and then +continues on the next source line. + +A closing tag with no opener on its line is still inline raw HTML: diff --git a/fixtures/valid-render-lint-strict/docs/render/two-on-one-line.md b/fixtures/valid-render-lint-strict/docs/render/two-on-one-line.md new file mode 100644 index 0000000..0d5af01 --- /dev/null +++ b/fixtures/valid-render-lint-strict/docs/render/two-on-one-line.md @@ -0,0 +1,14 @@ + + +# Two constructs on one line + +A line with a footnote reference[^a] and inline markup together. + +A line with all three: [^b], italic, and $$x + y$$ in one sentence. + +[^a]: A definition, so the file also carries a single-construct line. diff --git a/fixtures/valid-render-lint-strict/expected.json b/fixtures/valid-render-lint-strict/expected.json new file mode 100644 index 0000000..3671a2a --- /dev/null +++ b/fixtures/valid-render-lint-strict/expected.json @@ -0,0 +1,179 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "export": { + "args": ["export", "--strict"], + "exit": 1, + "findings": [ + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 10, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 12, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 14, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 17, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 19, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/math.md", + "line": 11, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/math.md", + "line": 18, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/math.md", + "line": 22, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 17, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 24, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 30, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 32, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 35, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 38, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 10, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 10, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 12, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 12, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 12, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 14, + "construct": "footnote" + } + ], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/_manifest.md", + ".leji/viewer/assets/", + "docs/overview.md" + ], + "absent": [".leji/dist/", "docs/.leji/"], + "preserved": [] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "none" + } + } +} diff --git a/fixtures/valid-render-lint-strict/leji.json b/fixtures/valid-render-lint-strict/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-render-lint-strict/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/docs/boot-profile.md b/fixtures/valid-render-lint-unsupported/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/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-render-lint-unsupported/docs/context/decisions.md b/fixtures/valid-render-lint-unsupported/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-render-lint-unsupported/docs/context/domain.md b/fixtures/valid-render-lint-unsupported/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-render-lint-unsupported/docs/decisions/0001-adopt-leji.md b/fixtures/valid-render-lint-unsupported/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/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-render-lint-unsupported/docs/domain/overview.md b/fixtures/valid-render-lint-unsupported/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-render-lint-unsupported/docs/render/footnotes.md b/fixtures/valid-render-lint-unsupported/docs/render/footnotes.md new file mode 100644 index 0000000..2e84439 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/render/footnotes.md @@ -0,0 +1,20 @@ + + +# Footnotes + +A paragraph carrying a footnote reference[^one] mid-sentence. + +[^one]: The matching definition, which is the second form the lint recognizes. + +A paragraph with two references on one line, [^two] and [^three], which is one +finding: the line is the unit. + +[^two]: The second definition. + +[^three]: The third definition, whose text wraps in the source; the finding stays +on the line that opens the definition. diff --git a/fixtures/valid-render-lint-unsupported/docs/render/frontmatter-html.md b/fixtures/valid-render-lint-unsupported/docs/render/frontmatter-html.md new file mode 100644 index 0000000..b98d087 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/render/frontmatter-html.md @@ -0,0 +1,26 @@ +--- +title: Frontmatter that looks like markup +summary: A YAML value carrying
is metadata, never raw HTML. +note: 'A value may carry $$ and [^ref] too, and stays metadata' +banner:
+--- + + + +# Frontmatter is an excluded region + +The block at the top of this file is scanned as metadata and skipped. + +--- + +Prose after a thematic break, which the excluded-region pass must not mistake for +a second frontmatter block. Nothing here is a linted construct. diff --git a/fixtures/valid-render-lint-unsupported/docs/render/math.md b/fixtures/valid-render-lint-unsupported/docs/render/math.md new file mode 100644 index 0000000..09a1e55 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/render/math.md @@ -0,0 +1,27 @@ + + +# Math blocks + +A display block, opened and closed on their own lines: + +$$ +a^2 + b^2 = c^2 +$$ + +The finding sits on the opening delimiter's line, so the block above reports +once. + +A pair that opens and closes on one line: $$e = mc^2$$ inside a sentence. + +A second display block, to pin that each pair reports separately: + +$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$ + +An amount of $5 and a variable named $path are prose: a single `$` is outside the +lint's closed token set, by design. diff --git a/fixtures/valid-render-lint-unsupported/docs/render/negatives.md b/fixtures/valid-render-lint-unsupported/docs/render/negatives.md new file mode 100644 index 0000000..11ed39b --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/render/negatives.md @@ -0,0 +1,73 @@ + + +# Negatives + +## Inside a code span + +The tag `
` names a construct without being one. So do `
`, +``, `[^ref]`, `[^ref]: text`, and `$$x$$`: a code span is an +excluded region, scanned before anything else. + +A span may be spelled with two backticks when its content carries one: ``a `` span``. + +## Inside a fenced block + +A fence is an excluded region too, whatever its info string: + +```html +
+

Raw HTML as the subject of the documentation, not as markup.

+
+``` + +```markdown +A reference[^one] and its definition. + +[^one]: The definition. + +$$ +a^2 + b^2 = c^2 +$$ +``` + +````markdown +A fence nested inside a longer fence stays excluded: + +```html +still not markup +``` +```` + +## Escaped delimiters + +An escaped angle bracket is a literal character, not a tag: \
and +\bold\ are prose. + +An escaped bracket is not a footnote reference: \[^one] in a sentence. The +definition form is prose too when its bracket is escaped: + +\[^one]: not a definition, because the bracket is escaped. + +Escaped delimiters are not math: \$\$ a^2 + b^2 = c^2 \$\$ is prose about the +notation. + +## An unpaired delimiter + +A single delimiter with no closing partner is prose, and the lint requires a +pair, so the line below reports nothing. + +$$ diff --git a/fixtures/valid-render-lint-unsupported/docs/render/raw-html.md b/fixtures/valid-render-lint-unsupported/docs/render/raw-html.md new file mode 100644 index 0000000..b58bacd --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/render/raw-html.md @@ -0,0 +1,38 @@ + + +# Raw HTML + +## Block form + +A block-level element on its own line opens an HTML block that runs to the next +blank line: + +
+ Raw HTML inside a block. The finding sits on the opening line, not on this one + and not on the closing tag. +
+ +A second block, to pin that each block reports separately: + + + +
onetwo
+ +## Inline form + +A paragraph carrying bold markup mid-sentence. + +A paragraph with two inline tags, italic and code, which is +still one finding: the line is the unit. + +A self-closing tag inline: this sentence carries a break
and then +continues on the next source line. + +A closing tag with no opener on its line is still inline raw HTML: diff --git a/fixtures/valid-render-lint-unsupported/docs/render/two-on-one-line.md b/fixtures/valid-render-lint-unsupported/docs/render/two-on-one-line.md new file mode 100644 index 0000000..0d5af01 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/docs/render/two-on-one-line.md @@ -0,0 +1,14 @@ + + +# Two constructs on one line + +A line with a footnote reference[^a] and inline markup together. + +A line with all three: [^b], italic, and $$x + y$$ in one sentence. + +[^a]: A definition, so the file also carries a single-construct line. diff --git a/fixtures/valid-render-lint-unsupported/expected-export.manifest.json b/fixtures/valid-render-lint-unsupported/expected-export.manifest.json new file mode 100644 index 0000000..d1d59ea --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export.manifest.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "files": { + "assets/docsify-copy-code.min.js": { + "sha256": "942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", + "size": 5569 + }, + "assets/docsify-mermaid.js": { + "sha256": "4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", + "size": 5012 + }, + "assets/docsify-sidebar-collapse.min.css": { + "sha256": "ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", + "size": 579 + }, + "assets/docsify-sidebar-collapse.min.js": { + "sha256": "78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", + "size": 6851 + }, + "assets/docsify.min.js": { + "sha256": "9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", + "size": 160920 + }, + "assets/leji-logo.svg": { + "sha256": "3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", + "size": 3651 + }, + "assets/mermaid.min.js": { + "sha256": "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", + "size": 3164970 + }, + "assets/prism-bash.min.js": { + "sha256": "89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", + "size": 7639 + }, + "assets/prism-json.min.js": { + "sha256": "41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", + "size": 530 + }, + "assets/prism-markdown.min.js": { + "sha256": "8a04110a27594831698c68d274ecbcf6d2340eb19a13fb93a0b9642859956804", + "size": 8641 + }, + "assets/prism-typescript.min.js": { + "sha256": "823212bcb2cefddf2e6c97154217c96c8db20fa282d81a6d2cd1e6e122065516", + "size": 1701 + }, + "assets/roboto-mono-400-latin-ext.woff2": { + "sha256": "4a9ab7a85c8a9821db7a82f01be7d1dbc01a158fc4b42acc427d9bdff9eac357", + "size": 9592 + }, + "assets/roboto-mono-400-latin.woff2": { + "sha256": "c49e5f206c0ba05fab722703bcfc6c83d517b15cb50dd56b5ce4e4d9fafa49a4", + "size": 12684 + }, + "assets/roboto-mono-400-vietnamese.woff2": { + "sha256": "c2e3862d2d0ecd7773912933a14210f23c7a89e27048536c6676eb9c1eb153dd", + "size": 4580 + }, + "assets/search.min.js": { + "sha256": "397f54df759c3093069b2d2acb3a2b19edd33e2d77af09c445d34d2c4853a015", + "size": 13231 + }, + "assets/source-sans-pro-300-latin-ext.woff2": { + "sha256": "88d8bae8b5e26ef17eef2ca0bde8308a1085ff97e551cbb45c0d40640edbb58a", + "size": 22984 + }, + "assets/source-sans-pro-300-latin.woff2": { + "sha256": "46d6a0984aa795b764141232671160e61bdcc49e900de67ca6b35bae25b1ebdd", + "size": 14792 + }, + "assets/source-sans-pro-300-vietnamese.woff2": { + "sha256": "6ab385de21cc93f42baaf191a3d9e8cc8eb15ea65f4f236e64fe88e0e6f11fb0", + "size": 5868 + }, + "assets/source-sans-pro-400-latin-ext.woff2": { + "sha256": "f61f863b7dcf4f836954b8a11abc2b2284fb089d669c2cde701b198f9137fbcb", + "size": 23124 + }, + "assets/source-sans-pro-400-latin.woff2": { + "sha256": "691491f1fc8badab623e1be56f92cc2d98c462b16617c67e1e288d6b061444bc", + "size": 14868 + }, + "assets/source-sans-pro-400-vietnamese.woff2": { + "sha256": "26d1dbd047f3e3167a47a32d90011e68c0491450482b74c575b6b21075ab57c1", + "size": 5840 + }, + "assets/source-sans-pro-600-latin-ext.woff2": { + "sha256": "9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", + "size": 23112 + }, + "assets/source-sans-pro-600-latin.woff2": { + "sha256": "156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", + "size": 14876 + }, + "assets/source-sans-pro-600-vietnamese.woff2": { + "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", + "size": 5852 + }, + "assets/third-party-licenses.txt": { + "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", + "size": 20065 + }, + "assets/viewer-boot.js": { + "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", + "size": 14016 + }, + "assets/vue.css": { + "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "size": 26849 + }, + "assets/zoom-image.min.js": { + "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", + "size": 17077 + }, + "index.html": { + "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", + "size": 10806 + } + } +} diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/_manifest.md b/fixtures/valid-render-lint-unsupported/expected-export/content/_manifest.md new file mode 100644 index 0000000..fa3d179 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/_manifest.md @@ -0,0 +1,29 @@ +# fixture: Manifest + +A human-readable view of this layer's `leji.json`. + +> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed. + +## Identity + +| Field | Declared | +| --- | --- | +| Name | `fixture` | +| Spec line | `1.0` | +| Owner | Fixture Owner | +| Conformance | no level claimed | + +## Entrypoints + +| Purpose | Path | +| --- | --- | +| Boot profile | `docs/boot-profile.md` | +| Context root | `docs/` | + +## Categories + +**Declared index files:** Domain 1 · Decisions 1. The documents themselves are in the sidebar, grouped by category. + +## Federation + +No federated mounts are declared for this layer. diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/_sidebar.md b/fixtures/valid-render-lint-unsupported/expected-export/content/_sidebar.md new file mode 100644 index 0000000..6e0b128 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/_sidebar.md @@ -0,0 +1,20 @@ +- [🤖 Boot profile](/boot-profile.md) +- [📄 Manifest](/_manifest.md) + +--- + +- **Domain context** + - [Overview](/domain/overview.md) +- **Decisions context** + - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md) + +--- + +- **Reference** + - **Render** + - [Footnotes](/render/footnotes.md) + - [Frontmatter that looks like markup](/render/frontmatter-html.md) + - [Math](/render/math.md) + - [Negatives](/render/negatives.md) + - [Raw Html](/render/raw-html.md) + - [Two On One Line](/render/two-on-one-line.md) diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/boot-profile.md b/fixtures/valid-render-lint-unsupported/expected-export/content/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/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-render-lint-unsupported/expected-export/content/context/decisions.md b/fixtures/valid-render-lint-unsupported/expected-export/content/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/context/domain.md b/fixtures/valid-render-lint-unsupported/expected-export/content/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/decisions/0001-adopt-leji.md b/fixtures/valid-render-lint-unsupported/expected-export/content/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/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-render-lint-unsupported/expected-export/content/domain/overview.md b/fixtures/valid-render-lint-unsupported/expected-export/content/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md b/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md new file mode 100644 index 0000000..da88774 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/overview.md @@ -0,0 +1,22 @@ +# fixture + +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. + + +```mermaid +flowchart LR + boot["🤖 Boot profile"] + cat_domain["📖 Domain · 1 doc"] + boot --> cat_domain + cat_decisions["🧭 Decisions · 1 doc"] + boot --> cat_decisions +``` + + +- 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. diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/render/footnotes.md b/fixtures/valid-render-lint-unsupported/expected-export/content/render/footnotes.md new file mode 100644 index 0000000..2e84439 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/render/footnotes.md @@ -0,0 +1,20 @@ + + +# Footnotes + +A paragraph carrying a footnote reference[^one] mid-sentence. + +[^one]: The matching definition, which is the second form the lint recognizes. + +A paragraph with two references on one line, [^two] and [^three], which is one +finding: the line is the unit. + +[^two]: The second definition. + +[^three]: The third definition, whose text wraps in the source; the finding stays +on the line that opens the definition. diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/render/frontmatter-html.md b/fixtures/valid-render-lint-unsupported/expected-export/content/render/frontmatter-html.md new file mode 100644 index 0000000..b98d087 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/render/frontmatter-html.md @@ -0,0 +1,26 @@ +--- +title: Frontmatter that looks like markup +summary: A YAML value carrying
is metadata, never raw HTML. +note: 'A value may carry $$ and [^ref] too, and stays metadata' +banner:
+--- + + + +# Frontmatter is an excluded region + +The block at the top of this file is scanned as metadata and skipped. + +--- + +Prose after a thematic break, which the excluded-region pass must not mistake for +a second frontmatter block. Nothing here is a linted construct. diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/render/math.md b/fixtures/valid-render-lint-unsupported/expected-export/content/render/math.md new file mode 100644 index 0000000..09a1e55 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/render/math.md @@ -0,0 +1,27 @@ + + +# Math blocks + +A display block, opened and closed on their own lines: + +$$ +a^2 + b^2 = c^2 +$$ + +The finding sits on the opening delimiter's line, so the block above reports +once. + +A pair that opens and closes on one line: $$e = mc^2$$ inside a sentence. + +A second display block, to pin that each pair reports separately: + +$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$ + +An amount of $5 and a variable named $path are prose: a single `$` is outside the +lint's closed token set, by design. diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/render/negatives.md b/fixtures/valid-render-lint-unsupported/expected-export/content/render/negatives.md new file mode 100644 index 0000000..11ed39b --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/render/negatives.md @@ -0,0 +1,73 @@ + + +# Negatives + +## Inside a code span + +The tag `
` names a construct without being one. So do `
`, +``, `[^ref]`, `[^ref]: text`, and `$$x$$`: a code span is an +excluded region, scanned before anything else. + +A span may be spelled with two backticks when its content carries one: ``a `` span``. + +## Inside a fenced block + +A fence is an excluded region too, whatever its info string: + +```html +
+

Raw HTML as the subject of the documentation, not as markup.

+
+``` + +```markdown +A reference[^one] and its definition. + +[^one]: The definition. + +$$ +a^2 + b^2 = c^2 +$$ +``` + +````markdown +A fence nested inside a longer fence stays excluded: + +```html +still not markup +``` +```` + +## Escaped delimiters + +An escaped angle bracket is a literal character, not a tag: \
and +\bold\ are prose. + +An escaped bracket is not a footnote reference: \[^one] in a sentence. The +definition form is prose too when its bracket is escaped: + +\[^one]: not a definition, because the bracket is escaped. + +Escaped delimiters are not math: \$\$ a^2 + b^2 = c^2 \$\$ is prose about the +notation. + +## An unpaired delimiter + +A single delimiter with no closing partner is prose, and the lint requires a +pair, so the line below reports nothing. + +$$ diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/render/raw-html.md b/fixtures/valid-render-lint-unsupported/expected-export/content/render/raw-html.md new file mode 100644 index 0000000..b58bacd --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/render/raw-html.md @@ -0,0 +1,38 @@ + + +# Raw HTML + +## Block form + +A block-level element on its own line opens an HTML block that runs to the next +blank line: + +
+ Raw HTML inside a block. The finding sits on the opening line, not on this one + and not on the closing tag. +
+ +A second block, to pin that each block reports separately: + + + +
onetwo
+ +## Inline form + +A paragraph carrying bold markup mid-sentence. + +A paragraph with two inline tags, italic and code, which is +still one finding: the line is the unit. + +A self-closing tag inline: this sentence carries a break
and then +continues on the next source line. + +A closing tag with no opener on its line is still inline raw HTML: diff --git a/fixtures/valid-render-lint-unsupported/expected-export/content/render/two-on-one-line.md b/fixtures/valid-render-lint-unsupported/expected-export/content/render/two-on-one-line.md new file mode 100644 index 0000000..0d5af01 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected-export/content/render/two-on-one-line.md @@ -0,0 +1,14 @@ + + +# Two constructs on one line + +A line with a footnote reference[^a] and inline markup together. + +A line with all three: [^b], italic, and $$x + y$$ in one sentence. + +[^a]: A definition, so the file also carries a single-construct line. diff --git a/fixtures/valid-render-lint-unsupported/expected.json b/fixtures/valid-render-lint-unsupported/expected.json new file mode 100644 index 0000000..f92f8c5 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/expected.json @@ -0,0 +1,192 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "export": { + "exit": 0, + "findings": [ + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 10, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 12, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 14, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 17, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/footnotes.md", + "line": 19, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/math.md", + "line": 11, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/math.md", + "line": 18, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/math.md", + "line": 22, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 17, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 24, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 30, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 32, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 35, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/raw-html.md", + "line": 38, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 10, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 10, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 12, + "construct": "footnote" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 12, + "construct": "math-block" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 12, + "construct": "raw-html" + }, + { + "rule": "render-unsupported", + "severity": "warning", + "path": "docs/render/two-on-one-line.md", + "line": 14, + "construct": "footnote" + } + ], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/_manifest.md", + ".leji/viewer/assets/", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/_manifest.md", + ".leji/dist/content/boot-profile.md", + ".leji/dist/content/overview.md", + ".leji/dist/content/render/footnotes.md", + ".leji/dist/content/render/frontmatter-html.md", + ".leji/dist/content/render/math.md", + ".leji/dist/content/render/negatives.md", + ".leji/dist/content/render/raw-html.md", + ".leji/dist/content/render/two-on-one-line.md", + "docs/overview.md" + ], + "absent": ["docs/.leji/"], + "preserved": [] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + } +} diff --git a/fixtures/valid-render-lint-unsupported/leji.json b/fixtures/valid-render-lint-unsupported/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-render-lint-unsupported/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-subset/docs/boot-profile.md b/fixtures/valid-render-subset/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-render-subset/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-render-subset/docs/context/decisions.md b/fixtures/valid-render-subset/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-render-subset/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-render-subset/docs/context/domain.md b/fixtures/valid-render-subset/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-render-subset/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-render-subset/docs/decisions/0001-adopt-leji.md b/fixtures/valid-render-subset/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-render-subset/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-render-subset/docs/domain/overview.md b/fixtures/valid-render-subset/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-render-subset/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-render-subset/docs/render/code-blocks.md b/fixtures/valid-render-subset/docs/render/code-blocks.md new file mode 100644 index 0000000..78b796f --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/code-blocks.md @@ -0,0 +1,62 @@ + + +# Code blocks + +## Indented + +Four spaces open an indented code block: + + leji validate --root . + leji export + +Prose resumes after a blank line. + +## Fenced, with a vendored language + +```json +{ + "leji": "1.0", + "name": "fixture" +} +``` + +```bash +leji export --out build/context +``` + +## Fenced, with no info string + +``` +Plain preformatted text. No language is claimed, so none is highlighted. +``` + +## Fenced, with an unhighlighted language + +The viewer vendors a small highlight set. An info string outside it still +renders as a code block, unhighlighted: + +```toml +[fixture] +name = "fixture" +``` + +```zsh +print 'an info string the highlight set does not carry' +``` + +## A longer fence + +A fence may be opened with more than three backticks, which is how a fenced +block carries a fence of its own: + +````markdown +```json +{ "nested": true } +``` +```` diff --git a/fixtures/valid-render-subset/docs/render/commonmark-core.md b/fixtures/valid-render-subset/docs/render/commonmark-core.md new file mode 100644 index 0000000..0e131df --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/commonmark-core.md @@ -0,0 +1,61 @@ + + +# ATX heading, level one + +## ATX heading, level two + +### ATX heading, level three + +Setext heading, level one +========================= + +Setext heading, level two +------------------------- + +A paragraph of plain prose. Blank lines separate paragraphs, and a single +newline inside a paragraph is a soft break that renders as a space. + +Inline constructs, one paragraph: *emphasis*, _emphasis again_, **strong**, +__strong again__, `inline code`, a [link to another page](../domain/overview.md), +a [link with a title](../domain/overview.md 'Fixture domain document'), and an +image: ![A fixture diagram](diagram.svg) + +A code span opens with two backticks when its own content carries one: +``a `nested` backtick`` stays one span. + +A reference link resolves through its definition: [the boot profile][boot]. + +[boot]: ../boot-profile.md + +> A blockquote carrying one sentence. +> +> A second paragraph inside the same blockquote, with `inline code` in it. + +> A blockquote can nest. +> +> > The inner quote is one level deeper. + +A hard break spelled with two trailing spaces closes this line +and this line continues the same paragraph. + +A hard break spelled with a trailing backslash closes this line\ +and this line ends the paragraph. + +--- + +Text after a thematic break. The three spellings of a break (`---`, `***`, +`___`) are one construct, and each one appears here. + +*** + +Text after the second break. + +___ + +Text after the third break, the underscore spelling. diff --git a/fixtures/valid-render-subset/docs/render/diagram.svg b/fixtures/valid-render-subset/docs/render/diagram.svg new file mode 100644 index 0000000..8ec1918 --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/diagram.svg @@ -0,0 +1,4 @@ + + + fixture + diff --git a/fixtures/valid-render-subset/docs/render/frontmatter-and-comments.md b/fixtures/valid-render-subset/docs/render/frontmatter-and-comments.md new file mode 100644 index 0000000..ccf206a --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/frontmatter-and-comments.md @@ -0,0 +1,51 @@ +--- +title: Frontmatter and comments +summary: Metadata a renderer never shows, and comments a renderer keeps invisible. +tags: + - rendering + - fixture +--- + + + +# Frontmatter and comments + +The frontmatter block at the top of this file is metadata for tooling. A +conforming renderer shows the heading above as the first visible thing on the +page. + + + +```mermaid +flowchart LR + boot["Boot profile"] + cat_domain["Domain · 1 doc"] + boot --> cat_domain +``` + + + +The two comments around that fence are the marker shape a generated map uses: +the tool rewrites what sits between them and leaves the prose alone. A renderer +that showed the marker text would put tooling detail in a reader's face, and one +that dropped the comments from the served bytes would break the next +regeneration. + + This sentence follows one on +the same line. + + diff --git a/fixtures/valid-render-subset/docs/render/index-fence.md b/fixtures/valid-render-subset/docs/render/index-fence.md new file mode 100644 index 0000000..cc6bd79 --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/index-fence.md @@ -0,0 +1,28 @@ + + +# Index blocks + +An intent block, the default kind: + +```leji-index +- path: docs/domain/ +``` + +A record block, and an entry carrying a trailing comment: + +```leji-index record +- path: docs/decisions/ # every record in the directory +``` + +Both render as code. A renderer that highlighted the block, or hid it, would +still be conforming: the requirement is that the bytes reach the reader as data +and not as interpreted markup. diff --git a/fixtures/valid-render-subset/docs/render/lists.md b/fixtures/valid-render-subset/docs/render/lists.md new file mode 100644 index 0000000..188f0bf --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/lists.md @@ -0,0 +1,58 @@ + + +# Lists + +## Unordered, tight + +- First item +- Second item +- Third item + +## Ordered, tight + +1. First step +2. Second step +3. Third step + +An ordered list may start at another number, and the start is significant: + +7. The seventh step +8. The eighth step + +## Nested + +- Top level + - Second level + - Third level + - Back to the second level +- Another top-level item + 1. An ordered child + 2. A second ordered child + +## Loose + +- A loose item, because a blank line separates the items. + +- A second loose item, whose text is wrapped in a paragraph rather than left + bare. + +## A block inside an item + +1. A step whose detail is a fenced block: + + ```bash + leji export --out build/context + ``` + +2. The step after it, still part of the same list. + +## Task list (GFM) + +- [x] A completed task +- [ ] An open task +- [ ] An open task with **strong** text and `inline code` + - [x] A completed nested task diff --git a/fixtures/valid-render-subset/docs/render/mermaid.md b/fixtures/valid-render-subset/docs/render/mermaid.md new file mode 100644 index 0000000..28bb490 --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/mermaid.md @@ -0,0 +1,37 @@ + + +# Mermaid + +A flowchart, the shape the generated layer map uses: + +```mermaid +flowchart LR + boot["Boot profile"] + domain["Domain"] + decisions["Decisions"] + boot --> domain + boot --> decisions +``` + +A sequence diagram, to keep the fixture from pinning one diagram type: + +```mermaid +sequenceDiagram + participant Person + participant Agent + Person->>Agent: read the boot profile first + Agent-->>Person: the context layer, then the task +``` + +A fence whose mermaid body is not valid mermaid still stays inside the subset: +the renderer's error state is its own concern, not a rendering-subset question. + +```mermaid +this is not a diagram +``` diff --git a/fixtures/valid-render-subset/docs/render/tables.md b/fixtures/valid-render-subset/docs/render/tables.md new file mode 100644 index 0000000..db79574 --- /dev/null +++ b/fixtures/valid-render-subset/docs/render/tables.md @@ -0,0 +1,61 @@ + + +# Tables + +## Default alignment + +| Field | Meaning | +| --- | --- | +| `rule` | the finding's rule identifier | +| `severity` | `error` or `warning` | +| `path` | repository-root-relative POSIX path | + +## Every alignment + +| Left | Centered | Right | +| :--- | :-----: | ----: | +| one | two | 3 | +| four | five | 60 | +| seven | eight | 900 | + +## Inline constructs in cells + +| Cell | Content | +| --- | --- | +| Emphasis | *emphasis*, **strong**, `code` | +| Link | [the boot profile](../boot-profile.md) | +| Strikethrough | ~~withdrawn~~, replaced | +| Escaped pipe | a \| inside a cell | +| Empty | | + +## Ragged source rows + +A delimiter row sets the column count; the body rows need not line up in the +source, and a short row is padded. + +| Command | Writes | Reads | +| --- | --- | --- | +| `leji export` | `.leji/dist/` | the context root | +| `leji validate` | nothing | +| `leji index` | `context-index.json` | the index files | ignored | + +## The metadata-header convention + +An empty header row carries a two-column metadata block. The viewer hides the +blank header; other renderers show it, and both are conforming. + +| | | +|---|---| +| **Tier** | Public | +| **Status** | Fixture | + +## Strikethrough in prose + +The old name is ~~`viewer build --dist`~~ and the current spelling is +`leji export --out`. diff --git a/fixtures/valid-render-subset/expected-export.manifest.json b/fixtures/valid-render-subset/expected-export.manifest.json new file mode 100644 index 0000000..d1d59ea --- /dev/null +++ b/fixtures/valid-render-subset/expected-export.manifest.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "files": { + "assets/docsify-copy-code.min.js": { + "sha256": "942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", + "size": 5569 + }, + "assets/docsify-mermaid.js": { + "sha256": "4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", + "size": 5012 + }, + "assets/docsify-sidebar-collapse.min.css": { + "sha256": "ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", + "size": 579 + }, + "assets/docsify-sidebar-collapse.min.js": { + "sha256": "78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", + "size": 6851 + }, + "assets/docsify.min.js": { + "sha256": "9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", + "size": 160920 + }, + "assets/leji-logo.svg": { + "sha256": "3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", + "size": 3651 + }, + "assets/mermaid.min.js": { + "sha256": "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", + "size": 3164970 + }, + "assets/prism-bash.min.js": { + "sha256": "89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", + "size": 7639 + }, + "assets/prism-json.min.js": { + "sha256": "41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", + "size": 530 + }, + "assets/prism-markdown.min.js": { + "sha256": "8a04110a27594831698c68d274ecbcf6d2340eb19a13fb93a0b9642859956804", + "size": 8641 + }, + "assets/prism-typescript.min.js": { + "sha256": "823212bcb2cefddf2e6c97154217c96c8db20fa282d81a6d2cd1e6e122065516", + "size": 1701 + }, + "assets/roboto-mono-400-latin-ext.woff2": { + "sha256": "4a9ab7a85c8a9821db7a82f01be7d1dbc01a158fc4b42acc427d9bdff9eac357", + "size": 9592 + }, + "assets/roboto-mono-400-latin.woff2": { + "sha256": "c49e5f206c0ba05fab722703bcfc6c83d517b15cb50dd56b5ce4e4d9fafa49a4", + "size": 12684 + }, + "assets/roboto-mono-400-vietnamese.woff2": { + "sha256": "c2e3862d2d0ecd7773912933a14210f23c7a89e27048536c6676eb9c1eb153dd", + "size": 4580 + }, + "assets/search.min.js": { + "sha256": "397f54df759c3093069b2d2acb3a2b19edd33e2d77af09c445d34d2c4853a015", + "size": 13231 + }, + "assets/source-sans-pro-300-latin-ext.woff2": { + "sha256": "88d8bae8b5e26ef17eef2ca0bde8308a1085ff97e551cbb45c0d40640edbb58a", + "size": 22984 + }, + "assets/source-sans-pro-300-latin.woff2": { + "sha256": "46d6a0984aa795b764141232671160e61bdcc49e900de67ca6b35bae25b1ebdd", + "size": 14792 + }, + "assets/source-sans-pro-300-vietnamese.woff2": { + "sha256": "6ab385de21cc93f42baaf191a3d9e8cc8eb15ea65f4f236e64fe88e0e6f11fb0", + "size": 5868 + }, + "assets/source-sans-pro-400-latin-ext.woff2": { + "sha256": "f61f863b7dcf4f836954b8a11abc2b2284fb089d669c2cde701b198f9137fbcb", + "size": 23124 + }, + "assets/source-sans-pro-400-latin.woff2": { + "sha256": "691491f1fc8badab623e1be56f92cc2d98c462b16617c67e1e288d6b061444bc", + "size": 14868 + }, + "assets/source-sans-pro-400-vietnamese.woff2": { + "sha256": "26d1dbd047f3e3167a47a32d90011e68c0491450482b74c575b6b21075ab57c1", + "size": 5840 + }, + "assets/source-sans-pro-600-latin-ext.woff2": { + "sha256": "9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", + "size": 23112 + }, + "assets/source-sans-pro-600-latin.woff2": { + "sha256": "156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", + "size": 14876 + }, + "assets/source-sans-pro-600-vietnamese.woff2": { + "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", + "size": 5852 + }, + "assets/third-party-licenses.txt": { + "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", + "size": 20065 + }, + "assets/viewer-boot.js": { + "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", + "size": 14016 + }, + "assets/vue.css": { + "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "size": 26849 + }, + "assets/zoom-image.min.js": { + "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", + "size": 17077 + }, + "index.html": { + "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", + "size": 10806 + } + } +} diff --git a/fixtures/valid-render-subset/expected-export/content/_manifest.md b/fixtures/valid-render-subset/expected-export/content/_manifest.md new file mode 100644 index 0000000..fa3d179 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/_manifest.md @@ -0,0 +1,29 @@ +# fixture: Manifest + +A human-readable view of this layer's `leji.json`. + +> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed. + +## Identity + +| Field | Declared | +| --- | --- | +| Name | `fixture` | +| Spec line | `1.0` | +| Owner | Fixture Owner | +| Conformance | no level claimed | + +## Entrypoints + +| Purpose | Path | +| --- | --- | +| Boot profile | `docs/boot-profile.md` | +| Context root | `docs/` | + +## Categories + +**Declared index files:** Domain 1 · Decisions 1. The documents themselves are in the sidebar, grouped by category. + +## Federation + +No federated mounts are declared for this layer. diff --git a/fixtures/valid-render-subset/expected-export/content/_sidebar.md b/fixtures/valid-render-subset/expected-export/content/_sidebar.md new file mode 100644 index 0000000..b146885 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/_sidebar.md @@ -0,0 +1,21 @@ +- [🤖 Boot profile](/boot-profile.md) +- [📄 Manifest](/_manifest.md) + +--- + +- **Domain context** + - [Overview](/domain/overview.md) +- **Decisions context** + - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md) + +--- + +- **Reference** + - **Render** + - [Code Blocks](/render/code-blocks.md) + - [Commonmark Core](/render/commonmark-core.md) + - [Frontmatter and comments](/render/frontmatter-and-comments.md) + - [Index Fence](/render/index-fence.md) + - [Lists](/render/lists.md) + - [Mermaid](/render/mermaid.md) + - [Tables](/render/tables.md) diff --git a/fixtures/valid-render-subset/expected-export/content/boot-profile.md b/fixtures/valid-render-subset/expected-export/content/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/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-render-subset/expected-export/content/context/decisions.md b/fixtures/valid-render-subset/expected-export/content/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-render-subset/expected-export/content/context/domain.md b/fixtures/valid-render-subset/expected-export/content/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-render-subset/expected-export/content/decisions/0001-adopt-leji.md b/fixtures/valid-render-subset/expected-export/content/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/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-render-subset/expected-export/content/domain/overview.md b/fixtures/valid-render-subset/expected-export/content/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-render-subset/expected-export/content/overview.md b/fixtures/valid-render-subset/expected-export/content/overview.md new file mode 100644 index 0000000..da88774 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/overview.md @@ -0,0 +1,22 @@ +# fixture + +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. + + +```mermaid +flowchart LR + boot["🤖 Boot profile"] + cat_domain["📖 Domain · 1 doc"] + boot --> cat_domain + cat_decisions["🧭 Decisions · 1 doc"] + boot --> cat_decisions +``` + + +- 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. diff --git a/fixtures/valid-render-subset/expected-export/content/render/code-blocks.md b/fixtures/valid-render-subset/expected-export/content/render/code-blocks.md new file mode 100644 index 0000000..78b796f --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/code-blocks.md @@ -0,0 +1,62 @@ + + +# Code blocks + +## Indented + +Four spaces open an indented code block: + + leji validate --root . + leji export + +Prose resumes after a blank line. + +## Fenced, with a vendored language + +```json +{ + "leji": "1.0", + "name": "fixture" +} +``` + +```bash +leji export --out build/context +``` + +## Fenced, with no info string + +``` +Plain preformatted text. No language is claimed, so none is highlighted. +``` + +## Fenced, with an unhighlighted language + +The viewer vendors a small highlight set. An info string outside it still +renders as a code block, unhighlighted: + +```toml +[fixture] +name = "fixture" +``` + +```zsh +print 'an info string the highlight set does not carry' +``` + +## A longer fence + +A fence may be opened with more than three backticks, which is how a fenced +block carries a fence of its own: + +````markdown +```json +{ "nested": true } +``` +```` diff --git a/fixtures/valid-render-subset/expected-export/content/render/commonmark-core.md b/fixtures/valid-render-subset/expected-export/content/render/commonmark-core.md new file mode 100644 index 0000000..0e131df --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/commonmark-core.md @@ -0,0 +1,61 @@ + + +# ATX heading, level one + +## ATX heading, level two + +### ATX heading, level three + +Setext heading, level one +========================= + +Setext heading, level two +------------------------- + +A paragraph of plain prose. Blank lines separate paragraphs, and a single +newline inside a paragraph is a soft break that renders as a space. + +Inline constructs, one paragraph: *emphasis*, _emphasis again_, **strong**, +__strong again__, `inline code`, a [link to another page](../domain/overview.md), +a [link with a title](../domain/overview.md 'Fixture domain document'), and an +image: ![A fixture diagram](diagram.svg) + +A code span opens with two backticks when its own content carries one: +``a `nested` backtick`` stays one span. + +A reference link resolves through its definition: [the boot profile][boot]. + +[boot]: ../boot-profile.md + +> A blockquote carrying one sentence. +> +> A second paragraph inside the same blockquote, with `inline code` in it. + +> A blockquote can nest. +> +> > The inner quote is one level deeper. + +A hard break spelled with two trailing spaces closes this line +and this line continues the same paragraph. + +A hard break spelled with a trailing backslash closes this line\ +and this line ends the paragraph. + +--- + +Text after a thematic break. The three spellings of a break (`---`, `***`, +`___`) are one construct, and each one appears here. + +*** + +Text after the second break. + +___ + +Text after the third break, the underscore spelling. diff --git a/fixtures/valid-render-subset/expected-export/content/render/diagram.svg b/fixtures/valid-render-subset/expected-export/content/render/diagram.svg new file mode 100644 index 0000000..8ec1918 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/diagram.svg @@ -0,0 +1,4 @@ + + + fixture + diff --git a/fixtures/valid-render-subset/expected-export/content/render/frontmatter-and-comments.md b/fixtures/valid-render-subset/expected-export/content/render/frontmatter-and-comments.md new file mode 100644 index 0000000..ccf206a --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/frontmatter-and-comments.md @@ -0,0 +1,51 @@ +--- +title: Frontmatter and comments +summary: Metadata a renderer never shows, and comments a renderer keeps invisible. +tags: + - rendering + - fixture +--- + + + +# Frontmatter and comments + +The frontmatter block at the top of this file is metadata for tooling. A +conforming renderer shows the heading above as the first visible thing on the +page. + + + +```mermaid +flowchart LR + boot["Boot profile"] + cat_domain["Domain · 1 doc"] + boot --> cat_domain +``` + + + +The two comments around that fence are the marker shape a generated map uses: +the tool rewrites what sits between them and leaves the prose alone. A renderer +that showed the marker text would put tooling detail in a reader's face, and one +that dropped the comments from the served bytes would break the next +regeneration. + + This sentence follows one on +the same line. + + diff --git a/fixtures/valid-render-subset/expected-export/content/render/index-fence.md b/fixtures/valid-render-subset/expected-export/content/render/index-fence.md new file mode 100644 index 0000000..cc6bd79 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/index-fence.md @@ -0,0 +1,28 @@ + + +# Index blocks + +An intent block, the default kind: + +```leji-index +- path: docs/domain/ +``` + +A record block, and an entry carrying a trailing comment: + +```leji-index record +- path: docs/decisions/ # every record in the directory +``` + +Both render as code. A renderer that highlighted the block, or hid it, would +still be conforming: the requirement is that the bytes reach the reader as data +and not as interpreted markup. diff --git a/fixtures/valid-render-subset/expected-export/content/render/lists.md b/fixtures/valid-render-subset/expected-export/content/render/lists.md new file mode 100644 index 0000000..188f0bf --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/lists.md @@ -0,0 +1,58 @@ + + +# Lists + +## Unordered, tight + +- First item +- Second item +- Third item + +## Ordered, tight + +1. First step +2. Second step +3. Third step + +An ordered list may start at another number, and the start is significant: + +7. The seventh step +8. The eighth step + +## Nested + +- Top level + - Second level + - Third level + - Back to the second level +- Another top-level item + 1. An ordered child + 2. A second ordered child + +## Loose + +- A loose item, because a blank line separates the items. + +- A second loose item, whose text is wrapped in a paragraph rather than left + bare. + +## A block inside an item + +1. A step whose detail is a fenced block: + + ```bash + leji export --out build/context + ``` + +2. The step after it, still part of the same list. + +## Task list (GFM) + +- [x] A completed task +- [ ] An open task +- [ ] An open task with **strong** text and `inline code` + - [x] A completed nested task diff --git a/fixtures/valid-render-subset/expected-export/content/render/mermaid.md b/fixtures/valid-render-subset/expected-export/content/render/mermaid.md new file mode 100644 index 0000000..28bb490 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/mermaid.md @@ -0,0 +1,37 @@ + + +# Mermaid + +A flowchart, the shape the generated layer map uses: + +```mermaid +flowchart LR + boot["Boot profile"] + domain["Domain"] + decisions["Decisions"] + boot --> domain + boot --> decisions +``` + +A sequence diagram, to keep the fixture from pinning one diagram type: + +```mermaid +sequenceDiagram + participant Person + participant Agent + Person->>Agent: read the boot profile first + Agent-->>Person: the context layer, then the task +``` + +A fence whose mermaid body is not valid mermaid still stays inside the subset: +the renderer's error state is its own concern, not a rendering-subset question. + +```mermaid +this is not a diagram +``` diff --git a/fixtures/valid-render-subset/expected-export/content/render/tables.md b/fixtures/valid-render-subset/expected-export/content/render/tables.md new file mode 100644 index 0000000..db79574 --- /dev/null +++ b/fixtures/valid-render-subset/expected-export/content/render/tables.md @@ -0,0 +1,61 @@ + + +# Tables + +## Default alignment + +| Field | Meaning | +| --- | --- | +| `rule` | the finding's rule identifier | +| `severity` | `error` or `warning` | +| `path` | repository-root-relative POSIX path | + +## Every alignment + +| Left | Centered | Right | +| :--- | :-----: | ----: | +| one | two | 3 | +| four | five | 60 | +| seven | eight | 900 | + +## Inline constructs in cells + +| Cell | Content | +| --- | --- | +| Emphasis | *emphasis*, **strong**, `code` | +| Link | [the boot profile](../boot-profile.md) | +| Strikethrough | ~~withdrawn~~, replaced | +| Escaped pipe | a \| inside a cell | +| Empty | | + +## Ragged source rows + +A delimiter row sets the column count; the body rows need not line up in the +source, and a short row is padded. + +| Command | Writes | Reads | +| --- | --- | --- | +| `leji export` | `.leji/dist/` | the context root | +| `leji validate` | nothing | +| `leji index` | `context-index.json` | the index files | ignored | + +## The metadata-header convention + +An empty header row carries a two-column metadata block. The viewer hides the +blank header; other renderers show it, and both are conforming. + +| | | +|---|---| +| **Tier** | Public | +| **Status** | Fixture | + +## Strikethrough in prose + +The old name is ~~`viewer build --dist`~~ and the current spelling is +`leji export --out`. diff --git a/fixtures/valid-render-subset/expected.json b/fixtures/valid-render-subset/expected.json new file mode 100644 index 0000000..116f24e --- /dev/null +++ b/fixtures/valid-render-subset/expected.json @@ -0,0 +1,55 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "export": { + "exit": 0, + "findings": [], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/_manifest.md", + ".leji/viewer/assets/", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/_manifest.md", + ".leji/dist/content/boot-profile.md", + ".leji/dist/content/overview.md", + ".leji/dist/content/domain/overview.md", + ".leji/dist/content/decisions/0001-adopt-leji.md", + ".leji/dist/content/render/commonmark-core.md", + ".leji/dist/content/render/lists.md", + ".leji/dist/content/render/code-blocks.md", + ".leji/dist/content/render/tables.md", + ".leji/dist/content/render/mermaid.md", + ".leji/dist/content/render/index-fence.md", + ".leji/dist/content/render/frontmatter-and-comments.md", + ".leji/dist/content/render/diagram.svg", + "docs/overview.md" + ], + "absent": ["docs/.leji/"], + "preserved": [] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + } +} diff --git a/fixtures/valid-render-subset/leji.json b/fixtures/valid-render-subset/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-render-subset/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-trust-canary-dot-root/.expected-export.manifest.json b/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json new file mode 100644 index 0000000..d1d59ea --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export.manifest.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "files": { + "assets/docsify-copy-code.min.js": { + "sha256": "942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", + "size": 5569 + }, + "assets/docsify-mermaid.js": { + "sha256": "4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", + "size": 5012 + }, + "assets/docsify-sidebar-collapse.min.css": { + "sha256": "ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", + "size": 579 + }, + "assets/docsify-sidebar-collapse.min.js": { + "sha256": "78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", + "size": 6851 + }, + "assets/docsify.min.js": { + "sha256": "9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", + "size": 160920 + }, + "assets/leji-logo.svg": { + "sha256": "3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", + "size": 3651 + }, + "assets/mermaid.min.js": { + "sha256": "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", + "size": 3164970 + }, + "assets/prism-bash.min.js": { + "sha256": "89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", + "size": 7639 + }, + "assets/prism-json.min.js": { + "sha256": "41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", + "size": 530 + }, + "assets/prism-markdown.min.js": { + "sha256": "8a04110a27594831698c68d274ecbcf6d2340eb19a13fb93a0b9642859956804", + "size": 8641 + }, + "assets/prism-typescript.min.js": { + "sha256": "823212bcb2cefddf2e6c97154217c96c8db20fa282d81a6d2cd1e6e122065516", + "size": 1701 + }, + "assets/roboto-mono-400-latin-ext.woff2": { + "sha256": "4a9ab7a85c8a9821db7a82f01be7d1dbc01a158fc4b42acc427d9bdff9eac357", + "size": 9592 + }, + "assets/roboto-mono-400-latin.woff2": { + "sha256": "c49e5f206c0ba05fab722703bcfc6c83d517b15cb50dd56b5ce4e4d9fafa49a4", + "size": 12684 + }, + "assets/roboto-mono-400-vietnamese.woff2": { + "sha256": "c2e3862d2d0ecd7773912933a14210f23c7a89e27048536c6676eb9c1eb153dd", + "size": 4580 + }, + "assets/search.min.js": { + "sha256": "397f54df759c3093069b2d2acb3a2b19edd33e2d77af09c445d34d2c4853a015", + "size": 13231 + }, + "assets/source-sans-pro-300-latin-ext.woff2": { + "sha256": "88d8bae8b5e26ef17eef2ca0bde8308a1085ff97e551cbb45c0d40640edbb58a", + "size": 22984 + }, + "assets/source-sans-pro-300-latin.woff2": { + "sha256": "46d6a0984aa795b764141232671160e61bdcc49e900de67ca6b35bae25b1ebdd", + "size": 14792 + }, + "assets/source-sans-pro-300-vietnamese.woff2": { + "sha256": "6ab385de21cc93f42baaf191a3d9e8cc8eb15ea65f4f236e64fe88e0e6f11fb0", + "size": 5868 + }, + "assets/source-sans-pro-400-latin-ext.woff2": { + "sha256": "f61f863b7dcf4f836954b8a11abc2b2284fb089d669c2cde701b198f9137fbcb", + "size": 23124 + }, + "assets/source-sans-pro-400-latin.woff2": { + "sha256": "691491f1fc8badab623e1be56f92cc2d98c462b16617c67e1e288d6b061444bc", + "size": 14868 + }, + "assets/source-sans-pro-400-vietnamese.woff2": { + "sha256": "26d1dbd047f3e3167a47a32d90011e68c0491450482b74c575b6b21075ab57c1", + "size": 5840 + }, + "assets/source-sans-pro-600-latin-ext.woff2": { + "sha256": "9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", + "size": 23112 + }, + "assets/source-sans-pro-600-latin.woff2": { + "sha256": "156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", + "size": 14876 + }, + "assets/source-sans-pro-600-vietnamese.woff2": { + "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", + "size": 5852 + }, + "assets/third-party-licenses.txt": { + "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", + "size": 20065 + }, + "assets/viewer-boot.js": { + "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", + "size": 14016 + }, + "assets/vue.css": { + "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "size": 26849 + }, + "assets/zoom-image.min.js": { + "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", + "size": 17077 + }, + "index.html": { + "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", + "size": 10806 + } + } +} diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/_manifest.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/_manifest.md new file mode 100644 index 0000000..3888fe9 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/_manifest.md @@ -0,0 +1,29 @@ +# fixture: Manifest + +A human-readable view of this layer's `leji.json`. + +> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed. + +## Identity + +| Field | Declared | +| --- | --- | +| Name | `fixture` | +| Spec line | `1.0` | +| Owner | Fixture Owner | +| Conformance | no level claimed | + +## Entrypoints + +| Purpose | Path | +| --- | --- | +| Boot profile | `boot-profile.md` | +| Context root | `.` | + +## Categories + +**Declared index files:** Domain 1 · Decisions 1. The documents themselves are in the sidebar, grouped by category. + +## Federation + +No federated mounts are declared for this layer. diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/_sidebar.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/_sidebar.md new file mode 100644 index 0000000..847361e --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/_sidebar.md @@ -0,0 +1,9 @@ +- [🤖 Boot profile](/boot-profile.md) +- [📄 Manifest](/_manifest.md) + +--- + +- **Domain context** + - [Overview](/domain/overview.md) +- **Decisions context** + - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/boot-profile.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/boot-profile.md new file mode 100644 index 0000000..c26ec11 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/boot-profile.md @@ -0,0 +1,17 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `domain/` before any task. + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `decisions/`. diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/context/decisions.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/context/decisions.md new file mode 100644 index 0000000..bee060e --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: decisions/ +``` diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/context/domain.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/context/domain.md new file mode 100644 index 0000000..c07307d --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: domain/ +``` diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/decisions/0001-adopt-leji.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/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-trust-canary-dot-root/.expected-export/content/domain/overview.md b/fixtures/valid-trust-canary-dot-root/.expected-export/content/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/expected.json b/fixtures/valid-trust-canary-dot-root/.expected-export/content/expected.json new file mode 100644 index 0000000..78d9eec --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/expected.json @@ -0,0 +1,147 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "seeds": [ + { + "from": ".leji-seed", + "to": ".leji" + } + ], + "export": { + "exit": 0, + "findings": [], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/boot-profile.md", + "overview.md" + ], + "absent": [".leji/viewer-dist/"], + "preserved": [ + ".leji/mounts.local.json", + ".leji/mounts/store/x/planted", + ".leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + ".leji/work/proposal.md", + ".leji/some-future-role/planted.md" + ] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + }, + "trustCanary": { + "topology": "dot-root", + "plantedPaths": [ + ".leji/mounts.local.json", + ".leji/mounts/store/x/planted", + ".leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + ".leji/work/proposal.md", + ".leji/some-future-role/planted.md" + ], + "serve": { + "requests": [ + { "path": "/", "status": 200, "note": "chrome shell; the only way into the viewer role" }, + { "path": "/index.html", "status": 200 }, + { "path": "/assets/leji-logo.svg", "status": 200, "note": "chrome assets" }, + { "path": "/content/boot-profile.md", "status": 200 }, + { "path": "/content/overview.md", "status": 200 }, + { "path": "/content/domain/overview.md", "status": 200 }, + { "path": "/content/decisions/0001-adopt-leji.md", "status": 200 }, + { "path": "/content/context/domain.md", "status": 200 }, + { "path": "/content/context/decisions.md", "status": 200 }, + { "path": "/content/_sidebar.md", "status": 200 }, + { "path": "/content/_manifest.md", "status": 200, "note": "mount status metadata only, never mounted bytes" }, + { "path": "/content/context-index.json", "status": 200 }, + { + "path": "/content/leji.json", + "status": 200, + "note": "the content root IS the repository root in this topology, so the manifest is served" + }, + { + "path": "/content/expected.json", + "status": 200, + "note": "this fixture's own metadata is inside the content root here; it is token-scanned like any other 200" + }, + { "path": "/.leji/mounts.local.json", "status": 404 }, + { "path": "/.leji/mounts/store/x/planted", "status": 404 }, + { + "path": "/.leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + "status": 404 + }, + { "path": "/.leji/work/proposal.md", "status": 404 }, + { + "path": "/.leji/some-future-role/planted.md", + "status": 404, + "note": "unknown role: default-deny by name, no code change needed" + }, + { "path": "/.leji/dist/index.html", "status": 404, "note": "an export is never re-served" }, + { + "path": "/.leji/viewer/index.html", + "status": 404, + "note": "even the servable role is unreachable by its own path; only the mount serves it" + }, + { + "path": "/content/.leji/mounts.local.json", + "status": 404, + "note": "here the trust domain really is under the content root: dot-refusal is the live boundary" + }, + { "path": "/content/.leji/mounts/store/x/planted", "status": 404 }, + { "path": "/content/.leji/work/proposal.md", "status": 404 }, + { "path": "/content/.leji/some-future-role/planted.md", "status": 404 }, + { + "path": "/mounts.local.json", + "status": 404, + "note": "role names as first segment: nothing under .leji/ but viewer/ is a servable root" + }, + { "path": "/mounts/store/x/planted", "status": 404 }, + { "path": "/work/proposal.md", "status": 404 }, + { "path": "/some-future-role/planted.md", "status": 404 }, + { "path": "/dist/index.html", "status": 404 }, + { "path": "/%2Eleji/mounts.local.json", "status": 404, "note": "encoded leading dot" }, + { "path": "/%2E%6Ceji/mounts/store/x/planted", "status": 404 }, + { "path": "/content/%2eleji/work/proposal.md", "status": 404 }, + { + "path": "/content/..%2F.leji/mounts.local.json", + "status": 404, + "note": "encoded traversal out of the content mount" + }, + { "path": "/assets/..%2F..%2F.leji/mounts.local.json", "status": 404 }, + { "path": "/content/%2e%2e%2f%2e%6ceji/work/proposal.md", "status": 404 }, + { "path": "/content/..%5C.leji/mounts.local.json", "status": 404, "note": "backslash separator folds to /" }, + { + "path": "/%252Eleji/mounts.local.json", + "status": 404, + "note": "double-encoded: decoded once, so this is a literal name, not a dot segment" + }, + { "path": "/content/%2", "status": 400, "note": "malformed percent-encoding is answered, never crashed on" } + ], + "routeScan": { + "assertNoTokenIn200Bodies": true + } + }, + "exportScan": { + "root": ".leji/dist", + "occurrences": 0 + } + } +} diff --git a/fixtures/valid-trust-canary-dot-root/.expected-export/content/leji.json b/fixtures/valid-trust-canary-dot-root/.expected-export/content/leji.json new file mode 100644 index 0000000..3a61e25 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/leji.json @@ -0,0 +1,23 @@ +{ + "leji": "1.0", + "name": "fixture", + "rootPath": ".", + "bootProfilePath": "boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "context/domain.md" + ] + }, + "decisions": { + "indexes": [ + "context/decisions.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} 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 new file mode 100644 index 0000000..da88774 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.expected-export/content/overview.md @@ -0,0 +1,22 @@ +# fixture + +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. + + +```mermaid +flowchart LR + boot["🤖 Boot profile"] + cat_domain["📖 Domain · 1 doc"] + boot --> cat_domain + cat_decisions["🧭 Decisions · 1 doc"] + boot --> cat_decisions +``` + + +- 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. diff --git a/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts.local.json b/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts.local.json new file mode 100644 index 0000000..d96c79e --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts.local.json @@ -0,0 +1 @@ +{"mounts":{"sample-context":{"repo":"../sample-context-LEJI-TRUST-CANARY"}}} diff --git a/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md b/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md new file mode 100644 index 0000000..baceb55 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md @@ -0,0 +1,6 @@ +# Planted projection document + +Stands for a document projected out of a mounted context layer. Mounted content +is untrusted federation material: it is never served and never exported. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts/store/x/planted b/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts/store/x/planted new file mode 100644 index 0000000..9a37f5e --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.leji-seed/mounts/store/x/planted @@ -0,0 +1,3 @@ +Stands for an object-store byte blob under a resolver-managed source key. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-dot-root/.leji-seed/some-future-role/planted.md b/fixtures/valid-trust-canary-dot-root/.leji-seed/some-future-role/planted.md new file mode 100644 index 0000000..83f6f26 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.leji-seed/some-future-role/planted.md @@ -0,0 +1,7 @@ +# Planted unknown-role document + +Stands for a role added under `.leji/` by a later release that this version has +never heard of. Default-deny by name: an unknown role is born unservable and +unexportable, with no code change here. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-dot-root/.leji-seed/work/proposal.md b/fixtures/valid-trust-canary-dot-root/.leji-seed/work/proposal.md new file mode 100644 index 0000000..27ada8f --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/.leji-seed/work/proposal.md @@ -0,0 +1,6 @@ +# Proposal for approval + +Stands for an in-flight onboarding proposal in the transient local workspace. +Local-machine detail: never served, never exported. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-dot-root/boot-profile.md b/fixtures/valid-trust-canary-dot-root/boot-profile.md new file mode 100644 index 0000000..c26ec11 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/boot-profile.md @@ -0,0 +1,17 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `domain/` before any task. + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `decisions/`. diff --git a/fixtures/valid-trust-canary-dot-root/context/decisions.md b/fixtures/valid-trust-canary-dot-root/context/decisions.md new file mode 100644 index 0000000..bee060e --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: decisions/ +``` diff --git a/fixtures/valid-trust-canary-dot-root/context/domain.md b/fixtures/valid-trust-canary-dot-root/context/domain.md new file mode 100644 index 0000000..c07307d --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: domain/ +``` diff --git a/fixtures/valid-trust-canary-dot-root/decisions/0001-adopt-leji.md b/fixtures/valid-trust-canary-dot-root/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/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-trust-canary-dot-root/domain/overview.md b/fixtures/valid-trust-canary-dot-root/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-trust-canary-dot-root/expected.json b/fixtures/valid-trust-canary-dot-root/expected.json new file mode 100644 index 0000000..78d9eec --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/expected.json @@ -0,0 +1,147 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "seeds": [ + { + "from": ".leji-seed", + "to": ".leji" + } + ], + "export": { + "exit": 0, + "findings": [], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/boot-profile.md", + "overview.md" + ], + "absent": [".leji/viewer-dist/"], + "preserved": [ + ".leji/mounts.local.json", + ".leji/mounts/store/x/planted", + ".leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + ".leji/work/proposal.md", + ".leji/some-future-role/planted.md" + ] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + }, + "trustCanary": { + "topology": "dot-root", + "plantedPaths": [ + ".leji/mounts.local.json", + ".leji/mounts/store/x/planted", + ".leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + ".leji/work/proposal.md", + ".leji/some-future-role/planted.md" + ], + "serve": { + "requests": [ + { "path": "/", "status": 200, "note": "chrome shell; the only way into the viewer role" }, + { "path": "/index.html", "status": 200 }, + { "path": "/assets/leji-logo.svg", "status": 200, "note": "chrome assets" }, + { "path": "/content/boot-profile.md", "status": 200 }, + { "path": "/content/overview.md", "status": 200 }, + { "path": "/content/domain/overview.md", "status": 200 }, + { "path": "/content/decisions/0001-adopt-leji.md", "status": 200 }, + { "path": "/content/context/domain.md", "status": 200 }, + { "path": "/content/context/decisions.md", "status": 200 }, + { "path": "/content/_sidebar.md", "status": 200 }, + { "path": "/content/_manifest.md", "status": 200, "note": "mount status metadata only, never mounted bytes" }, + { "path": "/content/context-index.json", "status": 200 }, + { + "path": "/content/leji.json", + "status": 200, + "note": "the content root IS the repository root in this topology, so the manifest is served" + }, + { + "path": "/content/expected.json", + "status": 200, + "note": "this fixture's own metadata is inside the content root here; it is token-scanned like any other 200" + }, + { "path": "/.leji/mounts.local.json", "status": 404 }, + { "path": "/.leji/mounts/store/x/planted", "status": 404 }, + { + "path": "/.leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + "status": 404 + }, + { "path": "/.leji/work/proposal.md", "status": 404 }, + { + "path": "/.leji/some-future-role/planted.md", + "status": 404, + "note": "unknown role: default-deny by name, no code change needed" + }, + { "path": "/.leji/dist/index.html", "status": 404, "note": "an export is never re-served" }, + { + "path": "/.leji/viewer/index.html", + "status": 404, + "note": "even the servable role is unreachable by its own path; only the mount serves it" + }, + { + "path": "/content/.leji/mounts.local.json", + "status": 404, + "note": "here the trust domain really is under the content root: dot-refusal is the live boundary" + }, + { "path": "/content/.leji/mounts/store/x/planted", "status": 404 }, + { "path": "/content/.leji/work/proposal.md", "status": 404 }, + { "path": "/content/.leji/some-future-role/planted.md", "status": 404 }, + { + "path": "/mounts.local.json", + "status": 404, + "note": "role names as first segment: nothing under .leji/ but viewer/ is a servable root" + }, + { "path": "/mounts/store/x/planted", "status": 404 }, + { "path": "/work/proposal.md", "status": 404 }, + { "path": "/some-future-role/planted.md", "status": 404 }, + { "path": "/dist/index.html", "status": 404 }, + { "path": "/%2Eleji/mounts.local.json", "status": 404, "note": "encoded leading dot" }, + { "path": "/%2E%6Ceji/mounts/store/x/planted", "status": 404 }, + { "path": "/content/%2eleji/work/proposal.md", "status": 404 }, + { + "path": "/content/..%2F.leji/mounts.local.json", + "status": 404, + "note": "encoded traversal out of the content mount" + }, + { "path": "/assets/..%2F..%2F.leji/mounts.local.json", "status": 404 }, + { "path": "/content/%2e%2e%2f%2e%6ceji/work/proposal.md", "status": 404 }, + { "path": "/content/..%5C.leji/mounts.local.json", "status": 404, "note": "backslash separator folds to /" }, + { + "path": "/%252Eleji/mounts.local.json", + "status": 404, + "note": "double-encoded: decoded once, so this is a literal name, not a dot segment" + }, + { "path": "/content/%2", "status": 400, "note": "malformed percent-encoding is answered, never crashed on" } + ], + "routeScan": { + "assertNoTokenIn200Bodies": true + } + }, + "exportScan": { + "root": ".leji/dist", + "occurrences": 0 + } + } +} diff --git a/fixtures/valid-trust-canary-dot-root/leji.json b/fixtures/valid-trust-canary-dot-root/leji.json new file mode 100644 index 0000000..3a61e25 --- /dev/null +++ b/fixtures/valid-trust-canary-dot-root/leji.json @@ -0,0 +1,23 @@ +{ + "leji": "1.0", + "name": "fixture", + "rootPath": ".", + "bootProfilePath": "boot-profile.md", + "categories": { + "domain": { + "indexes": [ + "context/domain.md" + ] + }, + "decisions": { + "indexes": [ + "context/decisions.md" + ] + } + }, + "owners": { + "primary": { + "name": "Fixture Owner" + } + } +} diff --git a/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts.local.json b/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts.local.json new file mode 100644 index 0000000..d96c79e --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts.local.json @@ -0,0 +1 @@ +{"mounts":{"sample-context":{"repo":"../sample-context-LEJI-TRUST-CANARY"}}} diff --git a/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md b/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md new file mode 100644 index 0000000..baceb55 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md @@ -0,0 +1,6 @@ +# Planted projection document + +Stands for a document projected out of a mounted context layer. Mounted content +is untrusted federation material: it is never served and never exported. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts/store/x/planted b/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts/store/x/planted new file mode 100644 index 0000000..9a37f5e --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/.leji-seed/mounts/store/x/planted @@ -0,0 +1,3 @@ +Stands for an object-store byte blob under a resolver-managed source key. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-nested-root/.leji-seed/some-future-role/planted.md b/fixtures/valid-trust-canary-nested-root/.leji-seed/some-future-role/planted.md new file mode 100644 index 0000000..83f6f26 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/.leji-seed/some-future-role/planted.md @@ -0,0 +1,7 @@ +# Planted unknown-role document + +Stands for a role added under `.leji/` by a later release that this version has +never heard of. Default-deny by name: an unknown role is born unservable and +unexportable, with no code change here. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-nested-root/.leji-seed/work/proposal.md b/fixtures/valid-trust-canary-nested-root/.leji-seed/work/proposal.md new file mode 100644 index 0000000..27ada8f --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/.leji-seed/work/proposal.md @@ -0,0 +1,6 @@ +# Proposal for approval + +Stands for an in-flight onboarding proposal in the transient local workspace. +Local-machine detail: never served, never exported. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-trust-canary-nested-root/docs/boot-profile.md b/fixtures/valid-trust-canary-nested-root/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/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-trust-canary-nested-root/docs/context/decisions.md b/fixtures/valid-trust-canary-nested-root/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-trust-canary-nested-root/docs/context/domain.md b/fixtures/valid-trust-canary-nested-root/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-trust-canary-nested-root/docs/decisions/0001-adopt-leji.md b/fixtures/valid-trust-canary-nested-root/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/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-trust-canary-nested-root/docs/domain/overview.md b/fixtures/valid-trust-canary-nested-root/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json b/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json new file mode 100644 index 0000000..d1d59ea --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export.manifest.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "files": { + "assets/docsify-copy-code.min.js": { + "sha256": "942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", + "size": 5569 + }, + "assets/docsify-mermaid.js": { + "sha256": "4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", + "size": 5012 + }, + "assets/docsify-sidebar-collapse.min.css": { + "sha256": "ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", + "size": 579 + }, + "assets/docsify-sidebar-collapse.min.js": { + "sha256": "78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", + "size": 6851 + }, + "assets/docsify.min.js": { + "sha256": "9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", + "size": 160920 + }, + "assets/leji-logo.svg": { + "sha256": "3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", + "size": 3651 + }, + "assets/mermaid.min.js": { + "sha256": "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", + "size": 3164970 + }, + "assets/prism-bash.min.js": { + "sha256": "89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", + "size": 7639 + }, + "assets/prism-json.min.js": { + "sha256": "41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", + "size": 530 + }, + "assets/prism-markdown.min.js": { + "sha256": "8a04110a27594831698c68d274ecbcf6d2340eb19a13fb93a0b9642859956804", + "size": 8641 + }, + "assets/prism-typescript.min.js": { + "sha256": "823212bcb2cefddf2e6c97154217c96c8db20fa282d81a6d2cd1e6e122065516", + "size": 1701 + }, + "assets/roboto-mono-400-latin-ext.woff2": { + "sha256": "4a9ab7a85c8a9821db7a82f01be7d1dbc01a158fc4b42acc427d9bdff9eac357", + "size": 9592 + }, + "assets/roboto-mono-400-latin.woff2": { + "sha256": "c49e5f206c0ba05fab722703bcfc6c83d517b15cb50dd56b5ce4e4d9fafa49a4", + "size": 12684 + }, + "assets/roboto-mono-400-vietnamese.woff2": { + "sha256": "c2e3862d2d0ecd7773912933a14210f23c7a89e27048536c6676eb9c1eb153dd", + "size": 4580 + }, + "assets/search.min.js": { + "sha256": "397f54df759c3093069b2d2acb3a2b19edd33e2d77af09c445d34d2c4853a015", + "size": 13231 + }, + "assets/source-sans-pro-300-latin-ext.woff2": { + "sha256": "88d8bae8b5e26ef17eef2ca0bde8308a1085ff97e551cbb45c0d40640edbb58a", + "size": 22984 + }, + "assets/source-sans-pro-300-latin.woff2": { + "sha256": "46d6a0984aa795b764141232671160e61bdcc49e900de67ca6b35bae25b1ebdd", + "size": 14792 + }, + "assets/source-sans-pro-300-vietnamese.woff2": { + "sha256": "6ab385de21cc93f42baaf191a3d9e8cc8eb15ea65f4f236e64fe88e0e6f11fb0", + "size": 5868 + }, + "assets/source-sans-pro-400-latin-ext.woff2": { + "sha256": "f61f863b7dcf4f836954b8a11abc2b2284fb089d669c2cde701b198f9137fbcb", + "size": 23124 + }, + "assets/source-sans-pro-400-latin.woff2": { + "sha256": "691491f1fc8badab623e1be56f92cc2d98c462b16617c67e1e288d6b061444bc", + "size": 14868 + }, + "assets/source-sans-pro-400-vietnamese.woff2": { + "sha256": "26d1dbd047f3e3167a47a32d90011e68c0491450482b74c575b6b21075ab57c1", + "size": 5840 + }, + "assets/source-sans-pro-600-latin-ext.woff2": { + "sha256": "9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", + "size": 23112 + }, + "assets/source-sans-pro-600-latin.woff2": { + "sha256": "156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", + "size": 14876 + }, + "assets/source-sans-pro-600-vietnamese.woff2": { + "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", + "size": 5852 + }, + "assets/third-party-licenses.txt": { + "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", + "size": 20065 + }, + "assets/viewer-boot.js": { + "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", + "size": 14016 + }, + "assets/vue.css": { + "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "size": 26849 + }, + "assets/zoom-image.min.js": { + "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", + "size": 17077 + }, + "index.html": { + "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", + "size": 10806 + } + } +} diff --git a/fixtures/valid-trust-canary-nested-root/expected-export/content/_manifest.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/_manifest.md new file mode 100644 index 0000000..fa3d179 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/_manifest.md @@ -0,0 +1,29 @@ +# fixture: Manifest + +A human-readable view of this layer's `leji.json`. + +> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed. + +## Identity + +| Field | Declared | +| --- | --- | +| Name | `fixture` | +| Spec line | `1.0` | +| Owner | Fixture Owner | +| Conformance | no level claimed | + +## Entrypoints + +| Purpose | Path | +| --- | --- | +| Boot profile | `docs/boot-profile.md` | +| Context root | `docs/` | + +## Categories + +**Declared index files:** Domain 1 · Decisions 1. The documents themselves are in the sidebar, grouped by category. + +## Federation + +No federated mounts are declared for this layer. diff --git a/fixtures/valid-trust-canary-nested-root/expected-export/content/_sidebar.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/_sidebar.md new file mode 100644 index 0000000..847361e --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/_sidebar.md @@ -0,0 +1,9 @@ +- [🤖 Boot profile](/boot-profile.md) +- [📄 Manifest](/_manifest.md) + +--- + +- **Domain context** + - [Overview](/domain/overview.md) +- **Decisions context** + - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-trust-canary-nested-root/expected-export/content/boot-profile.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/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-trust-canary-nested-root/expected-export/content/context/decisions.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-trust-canary-nested-root/expected-export/content/context/domain.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-trust-canary-nested-root/expected-export/content/decisions/0001-adopt-leji.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/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-trust-canary-nested-root/expected-export/content/domain/overview.md b/fixtures/valid-trust-canary-nested-root/expected-export/content/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. 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 new file mode 100644 index 0000000..da88774 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected-export/content/overview.md @@ -0,0 +1,22 @@ +# fixture + +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. + + +```mermaid +flowchart LR + boot["🤖 Boot profile"] + cat_domain["📖 Domain · 1 doc"] + boot --> cat_domain + cat_decisions["🧭 Decisions · 1 doc"] + boot --> cat_decisions +``` + + +- 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. diff --git a/fixtures/valid-trust-canary-nested-root/expected.json b/fixtures/valid-trust-canary-nested-root/expected.json new file mode 100644 index 0000000..5761eee --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/expected.json @@ -0,0 +1,138 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "seeds": [ + { + "from": ".leji-seed", + "to": ".leji" + } + ], + "export": { + "exit": 0, + "findings": [], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/boot-profile.md", + "docs/overview.md" + ], + "absent": ["docs/.leji/"], + "preserved": [ + ".leji/mounts.local.json", + ".leji/mounts/store/x/planted", + ".leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + ".leji/work/proposal.md", + ".leji/some-future-role/planted.md" + ] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + }, + "trustCanary": { + "topology": "nested", + "plantedPaths": [ + ".leji/mounts.local.json", + ".leji/mounts/store/x/planted", + ".leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + ".leji/work/proposal.md", + ".leji/some-future-role/planted.md" + ], + "serve": { + "requests": [ + { "path": "/", "status": 200, "note": "chrome shell; the only way into the viewer role" }, + { "path": "/index.html", "status": 200 }, + { "path": "/assets/leji-logo.svg", "status": 200, "note": "chrome assets" }, + { "path": "/content/boot-profile.md", "status": 200 }, + { "path": "/content/overview.md", "status": 200 }, + { "path": "/content/domain/overview.md", "status": 200 }, + { "path": "/content/decisions/0001-adopt-leji.md", "status": 200 }, + { "path": "/content/context/domain.md", "status": 200 }, + { "path": "/content/context/decisions.md", "status": 200 }, + { "path": "/content/_sidebar.md", "status": 200 }, + { "path": "/content/_manifest.md", "status": 200, "note": "mount status metadata only, never mounted bytes" }, + { "path": "/content/context-index.json", "status": 200 }, + { + "path": "/content/leji.json", + "status": 404, + "note": "the manifest sits outside the content root in this topology" + }, + { "path": "/.leji/mounts.local.json", "status": 404 }, + { "path": "/.leji/mounts/store/x/planted", "status": 404 }, + { + "path": "/.leji/mounts/cache/ac2fd7fe276d8012bf6c9b37097da7134aa63cfcec62be4c9caf87e88cf894c0/projection/planted.md", + "status": 404 + }, + { "path": "/.leji/work/proposal.md", "status": 404 }, + { + "path": "/.leji/some-future-role/planted.md", + "status": 404, + "note": "unknown role: default-deny by name, no code change needed" + }, + { "path": "/.leji/dist/index.html", "status": 404, "note": "an export is never re-served" }, + { + "path": "/.leji/viewer/index.html", + "status": 404, + "note": "even the servable role is unreachable by its own path; only the mount serves it" + }, + { "path": "/content/.leji/mounts.local.json", "status": 404 }, + { "path": "/content/.leji/mounts/store/x/planted", "status": 404 }, + { "path": "/content/.leji/work/proposal.md", "status": 404 }, + { "path": "/content/.leji/some-future-role/planted.md", "status": 404 }, + { + "path": "/mounts.local.json", + "status": 404, + "note": "role names as first segment: nothing under .leji/ but viewer/ is a servable root" + }, + { "path": "/mounts/store/x/planted", "status": 404 }, + { "path": "/work/proposal.md", "status": 404 }, + { "path": "/some-future-role/planted.md", "status": 404 }, + { "path": "/dist/index.html", "status": 404 }, + { "path": "/%2Eleji/mounts.local.json", "status": 404, "note": "encoded leading dot" }, + { "path": "/%2E%6Ceji/mounts/store/x/planted", "status": 404 }, + { "path": "/content/%2eleji/work/proposal.md", "status": 404 }, + { + "path": "/content/..%2F.leji/mounts.local.json", + "status": 404, + "note": "encoded traversal out of the content mount" + }, + { "path": "/assets/..%2F..%2F.leji/mounts.local.json", "status": 404 }, + { "path": "/content/%2e%2e%2f%2e%6ceji/work/proposal.md", "status": 404 }, + { "path": "/content/..%5C.leji/mounts.local.json", "status": 404, "note": "backslash separator folds to /" }, + { + "path": "/%252Eleji/mounts.local.json", + "status": 404, + "note": "double-encoded: decoded once, so this is a literal name, not a dot segment" + }, + { "path": "/content/%2", "status": 400, "note": "malformed percent-encoding is answered, never crashed on" } + ], + "routeScan": { + "assertNoTokenIn200Bodies": true + } + }, + "exportScan": { + "root": ".leji/dist", + "occurrences": 0 + } + } +} diff --git a/fixtures/valid-trust-canary-nested-root/leji.json b/fixtures/valid-trust-canary-nested-root/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-trust-canary-nested-root/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-unified-leji-fresh/docs/boot-profile.md b/fixtures/valid-unified-leji-fresh/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-unified-leji-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-unified-leji-fresh/docs/context/decisions.md b/fixtures/valid-unified-leji-fresh/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-unified-leji-fresh/docs/context/domain.md b/fixtures/valid-unified-leji-fresh/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-unified-leji-fresh/docs/decisions/0001-adopt-leji.md b/fixtures/valid-unified-leji-fresh/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-unified-leji-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-unified-leji-fresh/docs/domain/overview.md b/fixtures/valid-unified-leji-fresh/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-unified-leji-fresh/expected-export.manifest.json b/fixtures/valid-unified-leji-fresh/expected-export.manifest.json new file mode 100644 index 0000000..d1d59ea --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export.manifest.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "files": { + "assets/docsify-copy-code.min.js": { + "sha256": "942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", + "size": 5569 + }, + "assets/docsify-mermaid.js": { + "sha256": "4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", + "size": 5012 + }, + "assets/docsify-sidebar-collapse.min.css": { + "sha256": "ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", + "size": 579 + }, + "assets/docsify-sidebar-collapse.min.js": { + "sha256": "78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", + "size": 6851 + }, + "assets/docsify.min.js": { + "sha256": "9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", + "size": 160920 + }, + "assets/leji-logo.svg": { + "sha256": "3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", + "size": 3651 + }, + "assets/mermaid.min.js": { + "sha256": "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", + "size": 3164970 + }, + "assets/prism-bash.min.js": { + "sha256": "89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", + "size": 7639 + }, + "assets/prism-json.min.js": { + "sha256": "41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", + "size": 530 + }, + "assets/prism-markdown.min.js": { + "sha256": "8a04110a27594831698c68d274ecbcf6d2340eb19a13fb93a0b9642859956804", + "size": 8641 + }, + "assets/prism-typescript.min.js": { + "sha256": "823212bcb2cefddf2e6c97154217c96c8db20fa282d81a6d2cd1e6e122065516", + "size": 1701 + }, + "assets/roboto-mono-400-latin-ext.woff2": { + "sha256": "4a9ab7a85c8a9821db7a82f01be7d1dbc01a158fc4b42acc427d9bdff9eac357", + "size": 9592 + }, + "assets/roboto-mono-400-latin.woff2": { + "sha256": "c49e5f206c0ba05fab722703bcfc6c83d517b15cb50dd56b5ce4e4d9fafa49a4", + "size": 12684 + }, + "assets/roboto-mono-400-vietnamese.woff2": { + "sha256": "c2e3862d2d0ecd7773912933a14210f23c7a89e27048536c6676eb9c1eb153dd", + "size": 4580 + }, + "assets/search.min.js": { + "sha256": "397f54df759c3093069b2d2acb3a2b19edd33e2d77af09c445d34d2c4853a015", + "size": 13231 + }, + "assets/source-sans-pro-300-latin-ext.woff2": { + "sha256": "88d8bae8b5e26ef17eef2ca0bde8308a1085ff97e551cbb45c0d40640edbb58a", + "size": 22984 + }, + "assets/source-sans-pro-300-latin.woff2": { + "sha256": "46d6a0984aa795b764141232671160e61bdcc49e900de67ca6b35bae25b1ebdd", + "size": 14792 + }, + "assets/source-sans-pro-300-vietnamese.woff2": { + "sha256": "6ab385de21cc93f42baaf191a3d9e8cc8eb15ea65f4f236e64fe88e0e6f11fb0", + "size": 5868 + }, + "assets/source-sans-pro-400-latin-ext.woff2": { + "sha256": "f61f863b7dcf4f836954b8a11abc2b2284fb089d669c2cde701b198f9137fbcb", + "size": 23124 + }, + "assets/source-sans-pro-400-latin.woff2": { + "sha256": "691491f1fc8badab623e1be56f92cc2d98c462b16617c67e1e288d6b061444bc", + "size": 14868 + }, + "assets/source-sans-pro-400-vietnamese.woff2": { + "sha256": "26d1dbd047f3e3167a47a32d90011e68c0491450482b74c575b6b21075ab57c1", + "size": 5840 + }, + "assets/source-sans-pro-600-latin-ext.woff2": { + "sha256": "9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", + "size": 23112 + }, + "assets/source-sans-pro-600-latin.woff2": { + "sha256": "156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", + "size": 14876 + }, + "assets/source-sans-pro-600-vietnamese.woff2": { + "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", + "size": 5852 + }, + "assets/third-party-licenses.txt": { + "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", + "size": 20065 + }, + "assets/viewer-boot.js": { + "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", + "size": 14016 + }, + "assets/vue.css": { + "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "size": 26849 + }, + "assets/zoom-image.min.js": { + "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", + "size": 17077 + }, + "index.html": { + "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", + "size": 10806 + } + } +} diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/_manifest.md b/fixtures/valid-unified-leji-fresh/expected-export/content/_manifest.md new file mode 100644 index 0000000..fa3d179 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/_manifest.md @@ -0,0 +1,29 @@ +# fixture: Manifest + +A human-readable view of this layer's `leji.json`. + +> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed. + +## Identity + +| Field | Declared | +| --- | --- | +| Name | `fixture` | +| Spec line | `1.0` | +| Owner | Fixture Owner | +| Conformance | no level claimed | + +## Entrypoints + +| Purpose | Path | +| --- | --- | +| Boot profile | `docs/boot-profile.md` | +| Context root | `docs/` | + +## Categories + +**Declared index files:** Domain 1 · Decisions 1. The documents themselves are in the sidebar, grouped by category. + +## Federation + +No federated mounts are declared for this layer. diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/_sidebar.md b/fixtures/valid-unified-leji-fresh/expected-export/content/_sidebar.md new file mode 100644 index 0000000..847361e --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/_sidebar.md @@ -0,0 +1,9 @@ +- [🤖 Boot profile](/boot-profile.md) +- [📄 Manifest](/_manifest.md) + +--- + +- **Domain context** + - [Overview](/domain/overview.md) +- **Decisions context** + - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/boot-profile.md b/fixtures/valid-unified-leji-fresh/expected-export/content/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/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-unified-leji-fresh/expected-export/content/context/decisions.md b/fixtures/valid-unified-leji-fresh/expected-export/content/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/context/domain.md b/fixtures/valid-unified-leji-fresh/expected-export/content/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/decisions/0001-adopt-leji.md b/fixtures/valid-unified-leji-fresh/expected-export/content/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/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-unified-leji-fresh/expected-export/content/domain/overview.md b/fixtures/valid-unified-leji-fresh/expected-export/content/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md b/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md new file mode 100644 index 0000000..da88774 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected-export/content/overview.md @@ -0,0 +1,22 @@ +# fixture + +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. + + +```mermaid +flowchart LR + boot["🤖 Boot profile"] + cat_domain["📖 Domain · 1 doc"] + boot --> cat_domain + cat_decisions["🧭 Decisions · 1 doc"] + boot --> cat_decisions +``` + + +- 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. diff --git a/fixtures/valid-unified-leji-fresh/expected.json b/fixtures/valid-unified-leji-fresh/expected.json new file mode 100644 index 0000000..1066c89 --- /dev/null +++ b/fixtures/valid-unified-leji-fresh/expected.json @@ -0,0 +1,47 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "export": { + "exit": 0, + "findings": [], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/_manifest.md", + ".leji/viewer/assets/", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/_manifest.md", + ".leji/dist/content/boot-profile.md", + ".leji/dist/content/overview.md", + ".leji/dist/content/domain/overview.md", + ".leji/dist/content/decisions/0001-adopt-leji.md", + "docs/overview.md" + ], + "absent": ["docs/.leji/"], + "preserved": [] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + } +} diff --git a/fixtures/valid-unified-leji-fresh/leji.json b/fixtures/valid-unified-leji-fresh/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-unified-leji-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-unified-leji-stale-tree/docs/.leji-seed/onboarding-brief.md b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/onboarding-brief.md new file mode 100644 index 0000000..584e50a --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/onboarding-brief.md @@ -0,0 +1,13 @@ + + +# Onboarding brief for the agent + +**Working mode:** solo + +Placeholder body at the old location. Nothing consumes this file. diff --git a/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer-dist/content/boot-profile.md b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer-dist/content/boot-profile.md new file mode 100644 index 0000000..ea23ff3 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer-dist/content/boot-profile.md @@ -0,0 +1,6 @@ +# Boot Profile + +Stale exported copy of the layer's boot profile, planted at the pre-1.4 default +output location. + +LEJI-TRUST-CANARY diff --git a/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer-dist/index.html b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer-dist/index.html new file mode 100644 index 0000000..ab293c8 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer-dist/index.html @@ -0,0 +1,19 @@ + + + + + + fixture + + +
Loading the context layer…
+ + diff --git a/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/_manifest.md b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/_manifest.md new file mode 100644 index 0000000..a5dce79 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/_manifest.md @@ -0,0 +1,9 @@ +# Manifest + +Stale generated Manifest page, planted at the pre-1.4 chrome location. + +| Field | Value | +| --- | --- | +| Name | fixture | +| Context root | `docs/` | +| Canary | LEJI-TRUST-CANARY | diff --git a/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/_sidebar.md b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/_sidebar.md new file mode 100644 index 0000000..347a048 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/_sidebar.md @@ -0,0 +1,8 @@ + + +- [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-unified-leji-stale-tree/docs/.leji-seed/viewer/assets/leji-logo.svg b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/assets/leji-logo.svg new file mode 100644 index 0000000..798f324 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/assets/leji-logo.svg @@ -0,0 +1 @@ + diff --git a/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/index.html b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/index.html new file mode 100644 index 0000000..d7df69e --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/.leji-seed/viewer/index.html @@ -0,0 +1,18 @@ + + + + + + fixture + + +
Loading the context layer…
+ + diff --git a/fixtures/valid-unified-leji-stale-tree/docs/boot-profile.md b/fixtures/valid-unified-leji-stale-tree/docs/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/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-unified-leji-stale-tree/docs/context/decisions.md b/fixtures/valid-unified-leji-stale-tree/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-unified-leji-stale-tree/docs/context/domain.md b/fixtures/valid-unified-leji-stale-tree/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-unified-leji-stale-tree/docs/decisions/0001-adopt-leji.md b/fixtures/valid-unified-leji-stale-tree/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/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-unified-leji-stale-tree/docs/domain/overview.md b/fixtures/valid-unified-leji-stale-tree/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json b/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json new file mode 100644 index 0000000..d1d59ea --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export.manifest.json @@ -0,0 +1,121 @@ +{ + "version": 1, + "files": { + "assets/docsify-copy-code.min.js": { + "sha256": "942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", + "size": 5569 + }, + "assets/docsify-mermaid.js": { + "sha256": "4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", + "size": 5012 + }, + "assets/docsify-sidebar-collapse.min.css": { + "sha256": "ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", + "size": 579 + }, + "assets/docsify-sidebar-collapse.min.js": { + "sha256": "78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", + "size": 6851 + }, + "assets/docsify.min.js": { + "sha256": "9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", + "size": 160920 + }, + "assets/leji-logo.svg": { + "sha256": "3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", + "size": 3651 + }, + "assets/mermaid.min.js": { + "sha256": "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", + "size": 3164970 + }, + "assets/prism-bash.min.js": { + "sha256": "89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", + "size": 7639 + }, + "assets/prism-json.min.js": { + "sha256": "41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", + "size": 530 + }, + "assets/prism-markdown.min.js": { + "sha256": "8a04110a27594831698c68d274ecbcf6d2340eb19a13fb93a0b9642859956804", + "size": 8641 + }, + "assets/prism-typescript.min.js": { + "sha256": "823212bcb2cefddf2e6c97154217c96c8db20fa282d81a6d2cd1e6e122065516", + "size": 1701 + }, + "assets/roboto-mono-400-latin-ext.woff2": { + "sha256": "4a9ab7a85c8a9821db7a82f01be7d1dbc01a158fc4b42acc427d9bdff9eac357", + "size": 9592 + }, + "assets/roboto-mono-400-latin.woff2": { + "sha256": "c49e5f206c0ba05fab722703bcfc6c83d517b15cb50dd56b5ce4e4d9fafa49a4", + "size": 12684 + }, + "assets/roboto-mono-400-vietnamese.woff2": { + "sha256": "c2e3862d2d0ecd7773912933a14210f23c7a89e27048536c6676eb9c1eb153dd", + "size": 4580 + }, + "assets/search.min.js": { + "sha256": "397f54df759c3093069b2d2acb3a2b19edd33e2d77af09c445d34d2c4853a015", + "size": 13231 + }, + "assets/source-sans-pro-300-latin-ext.woff2": { + "sha256": "88d8bae8b5e26ef17eef2ca0bde8308a1085ff97e551cbb45c0d40640edbb58a", + "size": 22984 + }, + "assets/source-sans-pro-300-latin.woff2": { + "sha256": "46d6a0984aa795b764141232671160e61bdcc49e900de67ca6b35bae25b1ebdd", + "size": 14792 + }, + "assets/source-sans-pro-300-vietnamese.woff2": { + "sha256": "6ab385de21cc93f42baaf191a3d9e8cc8eb15ea65f4f236e64fe88e0e6f11fb0", + "size": 5868 + }, + "assets/source-sans-pro-400-latin-ext.woff2": { + "sha256": "f61f863b7dcf4f836954b8a11abc2b2284fb089d669c2cde701b198f9137fbcb", + "size": 23124 + }, + "assets/source-sans-pro-400-latin.woff2": { + "sha256": "691491f1fc8badab623e1be56f92cc2d98c462b16617c67e1e288d6b061444bc", + "size": 14868 + }, + "assets/source-sans-pro-400-vietnamese.woff2": { + "sha256": "26d1dbd047f3e3167a47a32d90011e68c0491450482b74c575b6b21075ab57c1", + "size": 5840 + }, + "assets/source-sans-pro-600-latin-ext.woff2": { + "sha256": "9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", + "size": 23112 + }, + "assets/source-sans-pro-600-latin.woff2": { + "sha256": "156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", + "size": 14876 + }, + "assets/source-sans-pro-600-vietnamese.woff2": { + "sha256": "615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", + "size": 5852 + }, + "assets/third-party-licenses.txt": { + "sha256": "010843d18dd532c01a574a44e86699966ca633fd5bbafe79125bb4c9e247f5b6", + "size": 20065 + }, + "assets/viewer-boot.js": { + "sha256": "39b1335cc5e4783865d0d83dd187248338bb7ae369e48e30d153780df810bf54", + "size": 14016 + }, + "assets/vue.css": { + "sha256": "af5a18093a6f9e21be29bf782e29f86ba056e2998481b99327ebad78e289388f", + "size": 26849 + }, + "assets/zoom-image.min.js": { + "sha256": "c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", + "size": 17077 + }, + "index.html": { + "sha256": "786636be196dd20d1acf10f4bb3aad9476c65a563dceffd266fe6a223aaa6b14", + "size": 10806 + } + } +} diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export/content/_manifest.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/_manifest.md new file mode 100644 index 0000000..fa3d179 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/_manifest.md @@ -0,0 +1,29 @@ +# fixture: Manifest + +A human-readable view of this layer's `leji.json`. + +> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed. + +## Identity + +| Field | Declared | +| --- | --- | +| Name | `fixture` | +| Spec line | `1.0` | +| Owner | Fixture Owner | +| Conformance | no level claimed | + +## Entrypoints + +| Purpose | Path | +| --- | --- | +| Boot profile | `docs/boot-profile.md` | +| Context root | `docs/` | + +## Categories + +**Declared index files:** Domain 1 · Decisions 1. The documents themselves are in the sidebar, grouped by category. + +## Federation + +No federated mounts are declared for this layer. diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export/content/_sidebar.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/_sidebar.md new file mode 100644 index 0000000..847361e --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/_sidebar.md @@ -0,0 +1,9 @@ +- [🤖 Boot profile](/boot-profile.md) +- [📄 Manifest](/_manifest.md) + +--- + +- **Domain context** + - [Overview](/domain/overview.md) +- **Decisions context** + - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md) diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export/content/boot-profile.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/boot-profile.md new file mode 100644 index 0000000..70ff366 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/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-unified-leji-stale-tree/expected-export/content/context/decisions.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export/content/context/domain.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/valid-unified-leji-stale-tree/expected-export/content/decisions/0001-adopt-leji.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/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-unified-leji-stale-tree/expected-export/content/domain/overview.md b/fixtures/valid-unified-leji-stale-tree/expected-export/content/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. 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 new file mode 100644 index 0000000..da88774 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected-export/content/overview.md @@ -0,0 +1,22 @@ +# fixture + +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. + + +```mermaid +flowchart LR + boot["🤖 Boot profile"] + cat_domain["📖 Domain · 1 doc"] + boot --> cat_domain + cat_decisions["🧭 Decisions · 1 doc"] + boot --> cat_decisions +``` + + +- 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. diff --git a/fixtures/valid-unified-leji-stale-tree/expected.json b/fixtures/valid-unified-leji-stale-tree/expected.json new file mode 100644 index 0000000..c1bb606 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/expected.json @@ -0,0 +1,105 @@ +{ + "validate": { + "exit": 0, + "findings": [] + }, + "conformance": { + "exit": 0, + "claimedLevel": "core", + "verifiedLevel": "core" + }, + "seeds": [ + { + "from": "docs/.leji-seed", + "to": "docs/.leji" + } + ], + "export": { + "exit": 0, + "findings": [], + "out": ".leji/dist", + "layout": { + "roles": { + "viewer": ".leji/viewer/", + "dist": ".leji/dist/" + }, + "present": [ + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/_manifest.md", + ".leji/viewer/assets/", + ".leji/dist/index.html", + ".leji/dist/assets/", + ".leji/dist/content/_sidebar.md", + ".leji/dist/content/_manifest.md", + ".leji/dist/content/boot-profile.md", + ".leji/dist/content/overview.md", + ".leji/dist/content/domain/overview.md", + ".leji/dist/content/decisions/0001-adopt-leji.md", + "docs/overview.md" + ], + "absent": [], + "preserved": [ + "docs/.leji/viewer/index.html", + "docs/.leji/viewer/_sidebar.md", + "docs/.leji/viewer/_manifest.md", + "docs/.leji/viewer/assets/leji-logo.svg", + "docs/.leji/viewer-dist/index.html", + "docs/.leji/viewer-dist/content/boot-profile.md", + "docs/.leji/onboarding-brief.md" + ] + }, + "rerun": { + "byteIdentical": true + }, + "goldenTree": { + "status": "baked", + "contentDir": "expected-export/content", + "manifest": "expected-export.manifest.json" + } + }, + "trustCanary": { + "topology": "nested", + "plantedPaths": [ + "docs/.leji/viewer/index.html", + "docs/.leji/viewer/_sidebar.md", + "docs/.leji/viewer/_manifest.md", + "docs/.leji/viewer/assets/leji-logo.svg", + "docs/.leji/viewer-dist/index.html", + "docs/.leji/viewer-dist/content/boot-profile.md", + "docs/.leji/onboarding-brief.md" + ], + "serve": { + "requests": [ + { "path": "/", "status": 200, "note": "chrome shell; the only way into the viewer role" }, + { "path": "/index.html", "status": 200 }, + { "path": "/assets/leji-logo.svg", "status": 200, "note": "chrome assets" }, + { "path": "/content/boot-profile.md", "status": 200 }, + { "path": "/content/overview.md", "status": 200 }, + { "path": "/content/domain/overview.md", "status": 200 }, + { "path": "/content/decisions/0001-adopt-leji.md", "status": 200 }, + { "path": "/content/context/domain.md", "status": 200 }, + { "path": "/content/context/decisions.md", "status": 200 }, + { "path": "/content/_sidebar.md", "status": 200 }, + { "path": "/content/_manifest.md", "status": 200 }, + { "path": "/content/context-index.json", "status": 200 }, + { + "path": "/content/.leji/viewer/index.html", + "status": 404, + "note": "the stale tree sits inside the content root here, so dot-refusal is the live boundary" + }, + { "path": "/content/.leji/viewer-dist/index.html", "status": 404 }, + { "path": "/content/.leji/onboarding-brief.md", "status": 404 }, + { "path": "/content/%2eleji/viewer/index.html", "status": 404, "note": "encoded leading dot" }, + { "path": "/content/%2E%6Ceji/viewer-dist/index.html", "status": 404 } + ], + "routeScan": { + "assertNoTokenIn200Bodies": true + } + }, + "exportScan": { + "root": ".leji/dist", + "occurrences": 0 + } + } +} diff --git a/fixtures/valid-unified-leji-stale-tree/leji.json b/fixtures/valid-unified-leji-stale-tree/leji.json new file mode 100644 index 0000000..91e1e85 --- /dev/null +++ b/fixtures/valid-unified-leji-stale-tree/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/warn-update-pin/docs/boot-profile.md b/fixtures/warn-update-pin/docs/boot-profile.md new file mode 100644 index 0000000..515a091 --- /dev/null +++ b/fixtures/warn-update-pin/docs/boot-profile.md @@ -0,0 +1,26 @@ +# Boot Profile + +## Identity + +A fixture context layer. + +## Loading + +Read `docs/domain/` before any task. + +## Federated siblings + +```leji-mounts +- mount: product-context + owner: Product Owner + carries: product-side context + read-when: a task touches the product surface +``` + +## Posture + +- Stop and ask before destructive changes. + +## Maintenance + +Decisions are recorded in `docs/decisions/`. diff --git a/fixtures/warn-update-pin/docs/context/decisions.md b/fixtures/warn-update-pin/docs/context/decisions.md new file mode 100644 index 0000000..8dc9222 --- /dev/null +++ b/fixtures/warn-update-pin/docs/context/decisions.md @@ -0,0 +1,5 @@ +# Decisions context + +```leji-index +- path: docs/decisions/ +``` diff --git a/fixtures/warn-update-pin/docs/context/domain.md b/fixtures/warn-update-pin/docs/context/domain.md new file mode 100644 index 0000000..bb3dea1 --- /dev/null +++ b/fixtures/warn-update-pin/docs/context/domain.md @@ -0,0 +1,5 @@ +# Domain context + +```leji-index +- path: docs/domain/ +``` diff --git a/fixtures/warn-update-pin/docs/decisions/0001-adopt-leji.md b/fixtures/warn-update-pin/docs/decisions/0001-adopt-leji.md new file mode 100644 index 0000000..37a45f0 --- /dev/null +++ b/fixtures/warn-update-pin/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/warn-update-pin/docs/domain/overview.md b/fixtures/warn-update-pin/docs/domain/overview.md new file mode 100644 index 0000000..1ff0493 --- /dev/null +++ b/fixtures/warn-update-pin/docs/domain/overview.md @@ -0,0 +1,3 @@ +# Overview + +A fixture domain document. diff --git a/fixtures/warn-update-pin/expected.json b/fixtures/warn-update-pin/expected.json new file mode 100644 index 0000000..ff868c9 --- /dev/null +++ b/fixtures/warn-update-pin/expected.json @@ -0,0 +1,547 @@ +{ + "validate": { + "exit": 0, + "findings": [ + { + "rule": "mount-unavailable", + "severity": "warning", + "path": "product-context" + } + ] + }, + "updatePin": { + "sibling": "acme-sibling", + "mount": "product-context", + "cases": [ + { + "id": "witness-ahead", + "note": "The witness sits one commit past the pin: the fast-forward this command exists to make.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 0, + "action": "updated", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": "update-pin-goldens/witness-ahead.json", + "written": true + }, + { + "id": "unchanged", + "note": "The pin already IS the witness tip: a stated success that writes nothing.", + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "store": { + "pin": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 0, + "action": "unchanged", + "from": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "diverged", + "note": "Pin and witness share an ancestor and neither reaches the other.", + "pin": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "store": { + "pin": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 1, + "action": "refused", + "from": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": "mount-pin-not-fast-forward", + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "unrelated", + "note": "Pin and witness share no commit at all; the report says so and the move is still refused.", + "pin": "0cb1fb59e73d78ff04cf41de7f177ea0fb940002", + "store": { + "pin": "0cb1fb59e73d78ff04cf41de7f177ea0fb940002", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 1, + "action": "refused", + "from": "0cb1fb59e73d78ff04cf41de7f177ea0fb940002", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": "mount-pin-not-fast-forward", + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "witness-unavailable", + "note": "The store holds the pin but no repository resolves the tracking ref.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": null, + "witnessOid": null, + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": null, + "reason": "mount-witness-unavailable", + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "pin-unavailable", + "note": "Nothing reachable holds the pin, so there is no comparison to show.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": null, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": null, + "reason": "mount-pin-unavailable", + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "ancestry-incomplete", + "note": "A shallow store holds both commits but cannot answer whether one reaches the other.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": 1 + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": "mount-ancestry-incomplete", + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "to-descendant", + "note": "An explicit --to that the comparison repository holds and that descends from the pin.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context", + "--to", + "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723" + ], + "exit": 0, + "action": "updated", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": "update-pin-goldens/to-descendant.json", + "written": true + }, + { + "id": "to-not-held", + "note": "An explicit --to no reachable repository holds is refused before any gate runs.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context", + "--to", + "ffffffffffffffffffffffffffffffffffffffff" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "ffffffffffffffffffffffffffffffffffffffff", + "reason": "mount-target-unavailable", + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "to-override-diverged", + "note": "The narrow override: --to plus --allow-non-fast-forward on a diverged pin, warned and recorded.", + "pin": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "store": { + "pin": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context", + "--to", + "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "--allow-non-fast-forward" + ], + "exit": 0, + "action": "updated", + "from": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": true, + "comparisonRepository": "managed-store", + "manifestGolden": "update-pin-goldens/to-override-diverged.json", + "written": true + }, + { + "id": "dry-run-override", + "note": "A dry run still exercises the override: the warning and override:true are reported, and no manifest byte moves.", + "pin": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "store": { + "pin": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context", + "--to", + "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "--allow-non-fast-forward", + "--dry-run" + ], + "exit": 0, + "action": "dry-run", + "from": "50305153f1a107c6871ab3b3047cb4c225603b0c", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": true, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "allow-alone", + "note": "The override flag without an explicit --to is a usage error, and emits no document at all.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context", + "--allow-non-fast-forward" + ], + "exit": 2, + "action": null, + "from": null, + "to": null, + "reason": null, + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "dry-run", + "note": "Everything computed and shown; not one manifest byte written.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context", + "--dry-run" + ], + "exit": 0, + "action": "dry-run", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "unknown-mount", + "note": "No mount carries the addressed name.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "absent-context" + ], + "exit": 1, + "action": "refused", + "from": null, + "to": null, + "reason": "mount-unknown", + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "no-tracking-ref-offline", + "note": "With no trackingRef declared and no --fetch, the default branch cannot be resolved.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "trackingRef": null, + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": "refs/heads/main", + "witnessOid": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "depth": null + }, + "hint": false, + "source": "none", + "args": [ + "mounts", + "update-pin", + "product-context" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": null, + "reason": "mount-no-tracking-ref", + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "hint-only-fetch", + "note": "The pin resolves only through a hint until --fetch establishes the managed store, which then wins.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": null, + "hint": true, + "source": "local", + "args": [ + "mounts", + "update-pin", + "product-context", + "--fetch" + ], + "exit": 0, + "action": "updated", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": "update-pin-goldens/hint-only-fetch.json", + "written": true + }, + { + "id": "dry-run-fetch", + "note": "The store and network acts --fetch was asked for happen; only the rewrite is suppressed.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": null, + "hint": true, + "source": "local", + "args": [ + "mounts", + "update-pin", + "product-context", + "--fetch", + "--dry-run" + ], + "exit": 0, + "action": "dry-run", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "manifestGolden": null, + "written": false + }, + { + "id": "default-ref-fetch", + "note": "No trackingRef declared: --fetch resolves the advertised default branch and reports it as comparedRef.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "trackingRef": null, + "store": null, + "hint": true, + "source": "local", + "args": [ + "mounts", + "update-pin", + "product-context", + "--fetch" + ], + "exit": 0, + "action": "updated", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723", + "reason": null, + "override": false, + "comparisonRepository": "managed-store", + "comparedRef": "refs/heads/main", + "manifestGolden": "update-pin-goldens/default-ref-fetch.json", + "written": true + }, + { + "id": "fetch-store-failed", + "note": "The declared source no longer serves the current pin, so --fetch cannot retain it.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": null, + "hint": false, + "source": "unreachable", + "args": [ + "mounts", + "update-pin", + "product-context", + "--fetch" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": null, + "reason": "mount-store-fetch-failed", + "override": false, + "manifestGolden": null, + "written": false + }, + { + "id": "fetch-witness-refresh-failed", + "note": "The store already holds the pin, so retention succeeds and the witness refresh is what fails.", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "store": { + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "witnessRef": null, + "witnessOid": null, + "depth": null + }, + "hint": false, + "source": "unreachable", + "args": [ + "mounts", + "update-pin", + "product-context", + "--fetch" + ], + "exit": 1, + "action": "refused", + "from": "6b06fe51a323212156bb267842bf10187ed4c20e", + "to": null, + "reason": "mount-witness-refresh-failed", + "override": false, + "manifestGolden": null, + "written": false + } + ] + } +} diff --git a/fixtures/warn-update-pin/leji.json b/fixtures/warn-update-pin/leji.json new file mode 100644 index 0000000..546b995 --- /dev/null +++ b/fixtures/warn-update-pin/leji.json @@ -0,0 +1,36 @@ +{ + "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" + } + }, + "federation": { + "mounts": [ + { + "name": "product-context", + "source": "https://github.com/acme/product-context", + "pin": "6b06fe51a323212156bb267842bf10187ed4c20e", + "trackingRef": "refs/heads/main", + "owner": { + "name": "Product Owner" + } + } + ] + } +} diff --git a/leji-badge.svg b/leji-badge.svg new file mode 100644 index 0000000..dfceef2 --- /dev/null +++ b/leji-badge.svg @@ -0,0 +1,13 @@ + +Leji 1.0 · governed · self-attested + + + + + + + +Leji 1.0 +governed + + diff --git a/package.json b/package.json index e926d0e..440e0e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "leji-monorepo", - "version": "1.3.1", + "name": "@leji-org/monorepo", + "version": "1.4.0", "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": [ @@ -29,8 +29,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/index.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/index.js\" \"packages/site/src/**/*.{ts,astro}\" \"packages/site/astro.config.ts\" \"scripts/*.ts\"", + "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:all": "npm run lint && npm run lint --workspaces --if-present", "parity": "node scripts/parity-test.ts", "prepare": "husky" diff --git a/packages/create-leji/README.md b/packages/create-leji/README.md index a9ea00a..f2266aa 100644 --- a/packages/create-leji/README.md +++ b/packages/create-leji/README.md @@ -1,18 +1,80 @@ # create-leji -Bootstrap a [Leji](https://leji.org) context layer interactively. Leji is the open +Bootstrap a [Leji](https://leji.org) context layer with one command. Leji is the open specification for the shared context layer of AI-native teams. ```bash -npm create leji # or: pnpm create leji / yarn create leji +npm create leji@latest # the current directory +npm create leji@latest my-app # a directory, created if it is not there yet ``` -Equivalent to installing the [`@leji-org/leji`](https://www.npmjs.com/package/@leji-org/leji) SDK -and running `leji init`. All `leji init` flags pass through: +No install step: `npm create` fetches the package, runs it once, and leaves nothing behind. +`npx create-leji@latest` does the same thing. + +## What it runs + +`create-leji` looks at the target directory once and delegates to the `leji` CLI. It asks no +question of its own and writes nothing itself. + +| The target directory | What runs | +| --- | --- | +| does not exist yet, or has nothing to adopt | `leji init` | +| has a docs root (`docs/`, `doc/`, `documentation/`, any capitalization) or an agent entrypoint (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`, `.cursor/rules`, `.windsurfrules`, `.github/copilot-instructions.md`) | `leji adopt` | +| already has a `leji.json` | nothing. It names the next command, `leji start`, and exits 0 | +| cannot be read: a file, a broken symlink, or a directory without permission | nothing. It exits 2 | + +Only the target directory is inspected, at those exact paths. Nothing is searched recursively, +so pointing it at one package of a monorepo classifies that package and not the repository +around it. Nothing outside the target decides the answer either: each path is resolved before +it is read, and one that resolves outside the target, or to nothing at all, counts as absent. +A `docs` symlink pointing somewhere else is not a docs root here. + +It is a heuristic, and it can be wrong: a generated `docs/` that is not documentation reads as +an existing repository, and a code repository whose only entrypoint is `CLAUDE.md` does too. + +## Overrides ```bash -npm create leji -- --yes --level indexed --name acme-context +npm create leji@latest -- --init # scaffold a new layer, whatever is there +npm create leji@latest -- --adopt # scaffold alongside what is there ``` +`--init` and `--adopt` force the branch and are the escape hatch when the routing rule reads +your repository wrong. They take a directory like any other invocation +(`npm create leji@latest my-app -- --adopt`). Giving both is an error. + +## Flags + +Everything else passes straight through to the command that runs, in order, so every flag it +accepts works here: + +```bash +npm create leji@latest my-app -- --yes --level indexed --name acme-context +``` + +`leji init` and `leji adopt` do not declare the same flags: `--level` and `--name` are init's, +`--wire-adapters` is adopt's. A flag the routed command does not declare is a usage error from +leji's own flag check, not something `create-leji` absorbs, so when you need a branch-specific +flag, pick the branch with `--init` or `--adopt` rather than relying on the routing rule. + +The target directory is the positional argument, or a `--dir ` you write yourself, or +`--root ` when there is no `--dir`, or the current directory, in that order. Both flags +take either spelling, `--dir ` or `--dir=`, as `leji` does. That is exactly how +`leji` resolves it, so the directory the routing rule reads is always the directory the command +acts on. Give it once: a positional plus `--dir` in either spelling is an error. + +**Package managers differ in how flags reach the package.** npm needs `--` before them: + +```bash +npm create leji@latest -- --yes +pnpm create leji --yes +yarn create leji --yes +bun create leji --yes +npx create-leji@latest --yes +``` + +`--yes` takes every default and asks nothing, which is what CI wants. `--dry-run` prints the +write plan and touches no file. + - Specification: https://leji.org - License: Apache-2.0 diff --git a/packages/create-leji/index.js b/packages/create-leji/index.js index 5bd3312..2d8082a 100755 --- a/packages/create-leji/index.js +++ b/packages/create-leji/index.js @@ -1,5 +1,162 @@ #!/usr/bin/env node -// `npm create leji [args]` == `leji init [args]`. +// `npm create leji [dir]` — the one-time bootstrap: `leji init` where there is +// nothing to adopt, `leji adopt` where the repository already carries docs or an +// agent entrypoint, nothing at all where a layer is already in place. +// +// A thin router and nothing else. It asks no question of its own, writes nothing, +// and reads only the selected target directory: a listing plus a handful of exact +// target-relative paths. Every durable offer (dependency declaration, MCP, hooks, +// handoff) belongs to `init`/`adopt`, which own their own preconditions — the router +// decides, they enforce. +import * as path from 'node:path'; import { run } from '@leji-org/leji'; +import { classifyTarget } from '@leji-org/leji/internal/create'; -process.exit(await run(['init', ...process.argv.slice(2)])); +const USAGE = `Usage: create-leji [dir] [leji init/adopt flags] + +Bootstraps a Leji context layer in [dir], or in the current directory. +Routing: an existing docs root, or an agent entrypoint (CLAUDE.md, AGENTS.md, + .cursor/rules, ...), routes to \`leji adopt\`; anything else to \`leji init\`. + A repository that already has a leji.json is left alone (exit 0). +Overrides: --init / --adopt force the branch; an unreadable target exits 2. +Flags pass through to leji. npm needs \`--\` first: npm create leji -- --yes`; + +/** The router's own flags: consumed here, never delegated, never a directory. */ +const ROUTER_FLAGS = new Set(['--init', '--adopt', '-h', '--help']); + +function refuse(message) { + console.error(`create-leji: ${message}`); + return 2; +} + +/** leji's own rule for what can be a flag's value: a lone `-` can, anything else dashed cannot. */ +const isFlagToken = (v) => v !== undefined && v !== '-' && v.startsWith('-'); + +/** The two flags leji resolves a target directory from. */ +const DIR_FLAGS = new Set(['--dir', '--root']); + +/** + * argv as leji's parser sees the two directory flags: `--dir=` split into + * `--dir ` on its first `=` (the parser's own `expandEqualsFlags`, same rule), and + * nothing past a literal `--`, which is host pass-through there rather than a flag. + * The delegated argv is never rewritten from this; it exists only so the router reads + * the target out of the argv the parser will actually see. + */ +function asParsed(argv) { + const out = []; + for (const a of argv) { + if (a === '--') break; + const eq = a.startsWith('--') ? a.indexOf('=') : -1; + if (eq > 2 && DIR_FLAGS.has(a.slice(0, eq))) out.push(a.slice(0, eq), a.slice(eq + 1)); + else out.push(a); + } + return out; +} + +/** + * The value leji's parser will end up with for a directory-bearing flag, read the way + * that parser reads it: every occurrence in order, the last valid one winning, and any + * occurrence without a usable value (missing, empty, or another flag) marking the whole + * argv as one the parser refuses. + */ +function flagValue(argv, flag) { + let value = null; + let malformed = false; + for (let i = 0; i < argv.length; i++) { + if (argv[i] !== flag) continue; + const v = argv[i + 1]; + if (v === undefined || v === '' || isFlagToken(v)) malformed = true; + else value = v; + } + return { value, malformed }; +} + +async function main(argv) { + // Meta-flags short-circuit wherever they appear, as leji's own parser does: a help + // request never runs a command. + if (argv.includes('-h') || argv.includes('--help')) { + console.log(USAGE); + return 0; + } + + const forceInit = argv.includes('--init'); + const forceAdopt = argv.includes('--adopt'); + if (forceInit && forceAdopt) return refuse('--init and --adopt cannot be combined'); + // The router's own flags never reach leji, and they come out before the positional + // rule is applied, so `create-leji --init my-app` still names a directory instead of + // silently scaffolding the current one. + const rest = argv.filter((arg) => !ROUTER_FLAGS.has(arg)); + + // `` is the `npm create ` convention and is valid only as the + // first argument. Everything else passes through verbatim, in order, including a + // `--dir ` the caller wrote themselves. + const positional = rest.length > 0 && !rest[0].startsWith('-') ? rest[0] : null; + const parsed = asParsed(rest); + let args = rest; + if (positional !== null) { + const tail = rest.slice(1); + // `--dir` in either spelling: `asParsed` has already split `--dir=`, so one + // check covers both. + if (asParsed(tail).includes('--dir')) + return refuse('give the directory once: either as `create-leji ` or as `--dir `'); + // A bare token is a second directory only when nothing flag-shaped precedes it; + // after a flag it is that flag's value (`create-leji app --name acme`). The rule + // catches the mistake worth catching — `create-leji app other` — and stays out of + // the way of leji's own grammar, which the router deliberately does not model. + const second = tail.find( + (arg, i) => !arg.startsWith('-') && !(i === 0 ? positional : tail[i - 1]).startsWith('-'), + ); + if (second !== undefined) + return refuse(`unexpected argument ${second} (a directory is valid only as the first argument)`); + args = ['--dir', positional, ...tail]; + } + + // The effective target is the directory the delegated command will act on, resolved + // exactly as leji resolves it: the positional, else the last `--dir `/`--dir=`, + // else `--root` in either spelling where there is no `--dir` (leji reads `--root` as + // the target then), else the cwd. Any other flag reaching init/adopt leaves the target + // alone, so this is the whole of it: routing on one directory and scaffolding another + // cannot happen. + const dir = flagValue(parsed, '--dir'); + const root = flagValue(parsed, '--root'); + const dirLike = positional ?? dir.value; + const named = dirLike === null || dirLike === '.' ? (root.value ?? dirLike) : dirLike; + const target = named === null ? process.cwd() : path.resolve(process.cwd(), named); + const json = rest.includes('--json'); + const command = forceAdopt ? 'adopt' : 'init'; + + // A directory flag the parser will refuse leaves the router with no target it can + // trust, so it classifies nothing and says nothing: leji's usage error is the whole + // output, and its exit 2 passes through. The command named here never runs, because + // the parser refuses before dispatch. + if (dir.malformed || root.malformed) return await run([command, ...args]); + + if (forceInit || forceAdopt) { + console.error(`create-leji: --${command} → leji ${command}`); + return await run([command, ...args]); + } + + switch (classifyTarget(target)) { + case 'unreadable': + return refuse(`cannot read ${target}: not a readable directory (permissions, a file, or a broken symlink)`); + case 'adopted': + // Bootstrapping is idempotent: a repository that already has a layer is not an + // error, it is done. Say what comes next and exit clean. + if (json) { + console.log(JSON.stringify({ command: 'create-leji', ok: true, route: 'exists', next: ['leji', 'start'] })); + } else { + console.error('create-leji: this repository already has a Leji layer; next: leji start'); + } + return 0; + case 'adopt': + // The routing line is stderr in every mode, so `--json` stdout stays exactly the + // document the delegated command prints. + console.error('create-leji: existing repository → leji adopt'); + return await run(['adopt', ...args]); + default: + console.error('create-leji: new repository → leji init'); + return await run(['init', ...args]); + } +} + +process.exit(await main(process.argv.slice(2))); diff --git a/packages/create-leji/package.json b/packages/create-leji/package.json index 64c2b64..dee2714 100644 --- a/packages/create-leji/package.json +++ b/packages/create-leji/package.json @@ -1,7 +1,7 @@ { "name": "create-leji", - "version": "1.3.1", - "description": "Bootstrap a Leji context layer: `npm create leji` runs `leji init` for Leji, the open specification for the shared context layer of AI-native teams.", + "version": "1.4.0", + "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", "create-leji", @@ -34,7 +34,11 @@ "index.js", "LICENSE" ], + "scripts": { + "pretest": "npm run build --prefix ../sdk", + "test": "node --test" + }, "dependencies": { - "@leji-org/leji": "^1.3.1" + "@leji-org/leji": "^1.4.0" } } diff --git a/packages/create-leji/test/route.test.js b/packages/create-leji/test/route.test.js new file mode 100644 index 0000000..26a334f --- /dev/null +++ b/packages/create-leji/test/route.test.js @@ -0,0 +1,338 @@ +import { strict as assert } from 'node:assert'; +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { after, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { pickDocsRoot } from '@leji-org/leji'; +import { DOCS_CANDIDATES, KNOWN_VENDOR_FILES, classifyTarget } from '@leji-org/leji/internal/create'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const bin = path.join(pkgRoot, 'index.js'); +const lejiCli = path.join(repoRoot, 'packages', 'sdk', 'dist', 'cli.js'); + +// One sandbox for the whole file: every case gets its own directory inside it, and +// nothing the router or the delegated command does escapes it. +const sandbox = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'create-leji-')); +after(() => fs.rmSync(sandbox, { recursive: true, force: true })); + +let seq = 0; +/** A fresh target directory, populated by `files` (a trailing `/` means a directory). */ +function fixture(files = []) { + const dir = path.join(sandbox, `case-${seq++}`); + fs.mkdirSync(dir, { recursive: true }); + for (const rel of files) { + const abs = path.join(dir, rel); + if (rel.endsWith('/')) fs.mkdirSync(abs, { recursive: true }); + else { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, '# fixture\n'); + } + } + return dir; +} + +function exec(file, args, cwd) { + return new Promise((resolve) => { + execFile('node', [file, ...args], { cwd }, (error, stdout, stderr) => { + resolve({ code: error ? (error.code ?? 1) : 0, stdout, stderr }); + }); + }); +} + +const create = (args, cwd = sandbox) => exec(bin, args, cwd); +const leji = (args, cwd = sandbox) => exec(lejiCli, args, cwd); + +/** The delegated command name, read out of the scaffold JSON document. */ +const routed = (result) => JSON.parse(result.stdout).command; + +const DRY = ['--yes', '--dry-run', '--json']; + +describe('routing', () => { + test('an empty directory is a new repository', async () => { + const r = await create([fixture(), ...DRY]); + assert.equal(r.code, 0); + assert.equal(routed(r), 'init'); + assert.match(r.stderr, /^create-leji: new repository → leji init$/m); + }); + + test('a directory that does not exist yet is a new repository', async () => { + const r = await create([path.join(fixture(), 'my-app'), ...DRY]); + assert.equal(r.code, 0); + assert.equal(routed(r), 'init'); + }); + + for (const entry of ['docs/', 'Docs/', 'CLAUDE.md', '.cursor/rules', '.github/copilot-instructions.md']) { + test(`${entry} makes it an existing repository`, async () => { + const r = await create([fixture([entry]), ...DRY]); + assert.equal(r.code, 0); + assert.equal(routed(r), 'adopt'); + assert.match(r.stderr, /^create-leji: existing repository → leji adopt$/m); + }); + } + + test('only the selected target is inspected, never its parent', async () => { + const monorepo = fixture(['docs/', 'packages/app/']); + const r = await create([path.join(monorepo, 'packages', 'app'), ...DRY]); + assert.equal(routed(r), 'init'); + }); + + test('a symlinked target is classified through its real path', async () => { + const real = fixture(['docs/']); + const link = path.join(sandbox, `link-${seq++}`); + fs.symlinkSync(real, link); + assert.equal(routed(await create([link, ...DRY])), 'adopt'); + }); + + test('the target is the current directory when no directory is given', async () => { + const r = await create(DRY, fixture(['CLAUDE.md'])); + assert.equal(routed(r), 'adopt'); + }); +}); + +describe('a repository that already has a layer', () => { + test('human mode names the next command on stderr and exits 0', async () => { + const r = await create([fixture(['leji.json'])]); + assert.equal(r.code, 0); + assert.equal(r.stdout, ''); + assert.match(r.stderr, /^create-leji: this repository already has a Leji layer; next: leji start$/m); + }); + + test('--json says the same thing on stdout and exits 0', async () => { + const r = await create([fixture(['leji.json']), '--json']); + assert.equal(r.code, 0); + assert.deepEqual(JSON.parse(r.stdout), { + command: 'create-leji', + ok: true, + route: 'exists', + next: ['leji', 'start'], + }); + }); +}); + +describe('refusals', () => { + test( + 'a target that cannot be listed refuses, and no command runs', + { skip: os.platform() === 'win32' || process.getuid?.() === 0 }, + async () => { + const dir = fixture(); + fs.chmodSync(dir, 0o000); + try { + const r = await create([dir, ...DRY]); + assert.equal(r.code, 2); + assert.equal(r.stdout, ''); + assert.match(r.stderr, /^create-leji: cannot read /m); + } finally { + fs.chmodSync(dir, 0o755); + } + }, + ); + + test('a dangling symlink is unreadable, not missing', async () => { + const link = path.join(sandbox, `dangling-${seq++}`); + fs.symlinkSync(path.join(sandbox, 'nowhere'), link); + const r = await create([link, ...DRY]); + assert.equal(r.code, 2); + assert.equal(r.stdout, ''); + }); + + test('a file where a directory was named is unreadable', async () => { + const file = path.join(fixture(['README.md']), 'README.md'); + assert.equal((await create([file, ...DRY])).code, 2); + }); + + test('--init and --adopt together refuse', async () => { + const r = await create(['--init', '--adopt', ...DRY]); + assert.equal(r.code, 2); + assert.match(r.stderr, /cannot be combined/); + }); + + test('a directory given twice refuses', async () => { + const r = await create(['app', '--dir', 'other', ...DRY]); + assert.equal(r.code, 2); + assert.match(r.stderr, /give the directory once/); + }); + + test('a second directory refuses', async () => { + const r = await create(['app', 'other', ...DRY]); + assert.equal(r.code, 2); + assert.match(r.stderr, /unexpected argument other/); + }); +}); + +describe('argv', () => { + test('--init forces init on a repository that would have been adopted', async () => { + const r = await create([fixture(['docs/']), '--init', ...DRY]); + assert.equal(routed(r), 'init'); + assert.match(r.stderr, /^create-leji: --init → leji init$/m); + }); + + test('--adopt forces adopt on a repository that would have been initialised', async () => { + const r = await create([fixture(), '--adopt', ...DRY]); + assert.equal(routed(r), 'adopt'); + assert.match(r.stderr, /^create-leji: --adopt → leji adopt$/m); + }); + + test('flags after the directory pass through in order', async () => { + const r = await create([fixture(), '--yes', '--dry-run', '--json', '--name', 'acme-context']); + assert.equal(r.code, 0); + assert.equal(routed(r), 'init'); + }); + + test('a --dir the caller wrote passes through untouched', async () => { + const cwd = fixture(); + const target = path.join(sandbox, `dir-flag-${seq++}`); + const r = await create(['--dir', target, '--yes'], cwd); + assert.equal(r.code, 0); + assert.ok(fs.existsSync(path.join(target, 'leji.json')), 'the layer is written under --dir'); + assert.ok(!fs.existsSync(path.join(cwd, 'leji.json')), 'and not in the current directory'); + }); + + test('a --dir the caller wrote is the directory that gets classified', async () => { + // The current directory already has a layer, so classifying it would have ended in + // the no-op route: the init here can only come from reading the --dir value. + const cwd = fixture(['leji.json']); + const target = path.join(sandbox, `dir-target-${seq++}`); + fs.mkdirSync(target); + const r = await create(['--dir', target, ...DRY], cwd); + assert.equal(r.code, 0); + assert.equal(routed(r), 'init'); + assert.match(r.stderr, /^create-leji: new repository → leji init$/m); + }); + + test('the last --dir wins, the way leji parses it', async () => { + const ignored = fixture(['docs/']); // would have routed to adopt + const target = fixture(); + const r = await create(['--dir', ignored, '--dir', target, ...DRY]); + assert.equal(r.code, 0); + assert.equal(routed(r), 'init'); + }); + + test('the equals spelling names the target too', async () => { + // leji's parser expands `--dir=` for its declared value flags, so the router + // has to read that spelling as the target rather than route on the cwd. + const docsRepo = fixture(['docs/']); + const r = await create([`--dir=${docsRepo}`, ...DRY], fixture()); + assert.equal(r.code, 0); + assert.equal(routed(r), 'adopt'); + assert.equal(routed(await create([`--root=${docsRepo}`, ...DRY], fixture())), 'adopt'); + }); + + test('the last directory flag wins across both spellings', async () => { + const ignored = fixture(['docs/']); + const target = fixture(); + assert.equal(routed(await create(['--dir', ignored, `--dir=${target}`, ...DRY])), 'init'); + assert.equal(routed(await create([`--dir=${ignored}`, '--dir', target, ...DRY])), 'init'); + }); + + test('a positional plus the equals spelling refuses', async () => { + const r = await create(['app', '--dir=other', ...DRY]); + assert.equal(r.code, 2); + assert.match(r.stderr, /give the directory once/); + }); + + test('--root is the target when there is no --dir', async () => { + // leji reads --root as the target when --dir is absent, so the router has to too: + // the cwd here is empty and would have routed to init. + const target = fixture(['docs/']); + const r = await create(['--root', target, ...DRY], fixture()); + assert.equal(r.code, 0); + assert.equal(routed(r), 'adopt'); + }); + + for (const [label, args] of [ + ['no value', ['--dir']], + ['a flag-shaped value', ['--root', '--yes']], + ['an empty equals value', ['--dir=']], + ['an empty equals value on --root', ['--root=']], + ]) { + test(`a directory flag with ${label} says nothing and lets leji refuse`, async () => { + const r = await create(args, fixture()); + assert.equal(r.code, 2); + assert.equal(r.stderr.includes('create-leji:'), false, 'the router adds no line of its own'); + assert.match(r.stderr, /requires a value/); + }); + } + + test('--init before the directory still names the directory', async () => { + const target = path.join(sandbox, `forced-init-${seq++}`); + const r = await create(['--init', target, '--yes'], sandbox); + assert.equal(r.code, 0); + assert.ok(fs.existsSync(path.join(target, 'leji.json')), 'the layer is written under the directory'); + assert.ok(!fs.existsSync(path.join(sandbox, 'leji.json')), 'and not in the current directory'); + }); + + test('--adopt before the directory still names the directory', async () => { + const target = fixture(['docs/']); + const r = await create(['--adopt', target, '--yes'], sandbox); + assert.equal(r.code, 0); + assert.ok(fs.existsSync(path.join(target, 'leji.json')), 'the layer is written under the directory'); + assert.ok(!fs.existsSync(path.join(sandbox, 'leji.json')), 'and not in the current directory'); + }); + + test('--help prints the usage and runs nothing', async () => { + const r = await create(['--help']); + assert.equal(r.code, 0); + assert.equal(r.stdout.trimEnd().split('\n').length, 8); + assert.match(r.stdout, /^Usage: create-leji \[dir\]/); + assert.equal((await create(['-h'])).stdout, r.stdout); + }); + + test('the exit code is the delegated command, not the router', async () => { + const dir = fixture(['leji.json']); + const delegated = await leji(['init', '--dir', dir, '--yes']); + const r = await create([dir, '--init', '--yes']); + assert.equal(delegated.code, 2); + assert.equal(r.code, delegated.code); + }); +}); + +describe('classify', () => { + // The router and `leji adopt` read one list each, on the SDK's internal subpath, so + // these drive the assertion from that data rather than restating it: every entry the + // SDK recognizes has to route to adopt, or the two have drifted apart. + test('every vendor entrypoint the SDK knows routes to adopt', () => { + assert.ok(KNOWN_VENDOR_FILES.length > 0); + for (const rel of KNOWN_VENDOR_FILES) { + assert.equal(classifyTarget(fixture([rel])), 'adopt', rel); + } + }); + + test('every docs root the SDK knows routes to adopt', () => { + assert.ok(DOCS_CANDIDATES.length > 0); + for (const rel of DOCS_CANDIDATES) { + assert.equal(classifyTarget(fixture([rel])), 'adopt', rel); + assert.equal(classifyTarget(fixture([rel.toUpperCase()])), 'adopt', rel.toUpperCase()); + } + }); + + test('pickDocsRoot takes the exact spelling first, then the lowest name', () => { + assert.equal(pickDocsRoot(['src', 'docs']), 'docs/'); + assert.equal(pickDocsRoot(['Docs']), 'Docs/'); + assert.equal(pickDocsRoot(['DOCS', 'Docs']), 'DOCS/'); + assert.equal(pickDocsRoot(['docs', 'Docs']), 'docs/'); + assert.equal(pickDocsRoot(['doc', 'docs']), 'docs/'); + assert.equal(pickDocsRoot(['documentation', 'doc']), 'doc/'); + assert.equal(pickDocsRoot(['src', 'lib']), null); + }); + + test('classifyTarget names each state', () => { + assert.equal(classifyTarget(path.join(sandbox, 'no-such-thing')), 'missing'); + assert.equal(classifyTarget(fixture()), 'init'); + assert.equal(classifyTarget(fixture(['docs/'])), 'adopt'); + assert.equal(classifyTarget(fixture(['GEMINI.md'])), 'adopt'); + assert.equal(classifyTarget(fixture(['leji.json'])), 'adopted'); + assert.equal(classifyTarget(fixture(['docs/', 'leji.json'])), 'adopted'); + }); + + test('a docs entry that is a file is not a docs root', () => { + assert.equal(classifyTarget(fixture(['docs'])), 'init'); + }); + + test('a vendor entrypoint counts whether it is a file or a directory', () => { + assert.equal(classifyTarget(fixture(['.cursor/rules'])), 'adopt'); + assert.equal(classifyTarget(fixture(['.cursor/rules/'])), 'adopt'); + }); +}); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index cf482a7..a2bab8a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -8,10 +8,32 @@ Runs locally over stdio, needs no network or auth, and exposes only read-only to ## Use it -Add it to your MCP client (Claude Code, Claude Desktop, Cursor, Windsurf, …): +Claude Code, for the whole repository (writes a `.mcp.json` you commit): + +```bash +claude mcp add leji --scope project -- npx -y @leji-org/mcp +``` + +Claude Code, for your user across every project: + +```bash +claude mcp add leji --scope user -- npx -y @leji-org/mcp +``` + +Codex, at user level: + +```bash +codex mcp add leji -- npx -y @leji-org/mcp +``` + +Any other MCP client (Claude Desktop, Cursor, Windsurf, …) takes the standard config: ```json -{ "mcpServers": { "leji": { "command": "npx", "args": ["-y", "@leji-org/mcp"] } } } +{ + "mcpServers": { + "leji": { "command": "npx", "args": ["-y", "@leji-org/mcp"] } + } +} ``` The client launches `leji-mcp` on demand. The server reads the layer at the `root` path each tool is given; point it at a repository that contains a `leji.json`. diff --git a/packages/mcp/assets/assets-manifest.json b/packages/mcp/assets/assets-manifest.json index 791badd..9c02b67 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:94d2f8503120a19c77e5a742158a790cdce0223a211376fbc34d92b0b3b95c56", + "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", "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 abaf3a2..585c59e 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 CSS color (e.g. \"#223F93\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." } } }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index d43e447..7e661f8 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@leji-org/mcp", - "version": "1.3.1", + "version": "1.4.0", "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.3.1", + "@leji-org/leji": "^1.4.0", "@modelcontextprotocol/sdk": "1.30.0" }, "devDependencies": { diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 388e436..11d1ffa 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -15,6 +15,7 @@ leji index --check # fail when the index is stale leji changelog check # append-only discipline leji freshness # review-horizon report leji conformance # score the layer against its claimed level +leji badge # write the self-attested conformance badge and its markdown leji status # report unindexed, dangling, and stale documents leji route # show the governed context a task's scope routes to leji viewer # generate the static viewer for the context layer @@ -29,15 +30,22 @@ leji agent --name # bind an additional named agent into the layer leji mounts hydrate # materialize declared federation mounts into the resolver cache leji mounts status # each mount's availability, integrity, and pin ancestry leji mounts locate # resolver state for one mount: projection path, pin, verification +leji mounts update-pin # move one mount's declared pin, verified against the source leji changelog compact # fold the oldest changelog entries into one compaction entry ``` See the full command reference (flags, exit codes, examples) at https://leji.org/cli/. +In a Go repository that declares the tool, run the pinned copy with `go tool leji`. + This is the Go reference SDK. It is behaviorally identical to the `@leji-org/leji` npm package and the `leji` Python package: same commands, same flags, same findings, -same exit codes (0 clean, 1 findings, 2 usage error). All three implementations +same exit codes (0 clean, 1 findings, 2 usage error); the one runtime-specific +behavior is the hand-off to a repository's pinned CLI, which the Node and Python +CLIs perform and this one does not, because a Go repository's declared copy is +built on demand by the toolchain rather than installed as an executable: use +`go tool leji` above. All three implementations are tested against one shared fixture suite under `fixtures/`; the Go SDK's `internal/conformancetest` reproduces that contract (validate findings, conformance scoring, and index-check staleness) for every fixture. @@ -55,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.3.1`; override it with +The SDK version is a build-time constant defaulting to `1.4.0`; 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/internal/assets/assets-manifest.json b/packages/sdk-go/internal/assets/assets-manifest.json index fc4933a..8f441ca 100644 --- a/packages/sdk-go/internal/assets/assets-manifest.json +++ b/packages/sdk-go/internal/assets/assets-manifest.json @@ -5,24 +5,23 @@ "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:94d2f8503120a19c77e5a742158a790cdce0223a211376fbc34d92b0b3b95c56", + "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07", "templates/README.md": "sha256:3fa28c144a26076cc75dc2a6d23014d61370abcb2073afa7d5bd3f26af4884c7", "templates/agent-profile.md": "sha256:fb1cf77aeaffc10718795231b936a7eab9b54c9221f4de072677e3afd3656545", - "templates/agents/core.md": "sha256:6118a42d72712be08e10ba21843894b6afb895b350af6f5a308375f7d7281c5a", - "templates/boot-profile.md": "sha256:07f4b6a7ca03ffdbb5a2bebfc668560fbbffc6bb81dfc5bde2d775a21f647490", + "templates/agents/core.md": "sha256:496814fd60776312c3d4e96547defa17bcd26c55af6f6c688c22d19c842c0295", + "templates/boot-profile.md": "sha256:e3cfe63f45bc0ce0f761f90542bc8eb2afeca995072d143f2cb9b1efb409d365", "templates/decision-record.md": "sha256:ea122e7ceb5984b1610c66df2503128d619b312b5b5fc57c29f4e017a300c91c", "templates/identity.md": "sha256:dc9b30ab64ba13f27db998c1bb070e5f560b8c442881566f3426fcf637fbfc62", "templates/leji.json": "sha256:4b265b83901e8995a4aa0f81984bda6a4b26373cec15baa7b825e832942d34cc", - "templates/onboarding-brief.md": "sha256:adc7d29025dc020f4a8c8c6d709560169df48b3bc15cee2bd3eedb5f09c4962a", - "templates/viewer/assets/PROVENANCE.txt": "sha256:d07eae4c00e37c93fde3b2978d54ba67a70cd4cd84541c74da3a7eaa85e56e42", + "templates/onboarding-brief.md": "sha256:3a4296e23829ca2cb64a87a3b009ac0e4d9a9539af79e22e3046caf74c45adbf", + "templates/viewer/assets/PROVENANCE.txt": "sha256:75425f589e57e4a3198eafc74b37030a5cfbb434abcb4990bf5d5e28bd808cb0", "templates/viewer/assets/docsify-copy-code.min.js": "sha256:942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", "templates/viewer/assets/docsify-mermaid.js": "sha256:4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", "templates/viewer/assets/docsify-sidebar-collapse.min.css": "sha256:ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", "templates/viewer/assets/docsify-sidebar-collapse.min.js": "sha256:78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", "templates/viewer/assets/docsify.min.js": "sha256:9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", - "templates/viewer/assets/fonts-licenses.txt": "sha256:0a7d0278d1bbf74d54d6b844832e19a4f5ccb817ff24770a6059130dd48758b5", - "templates/viewer/assets/leji-logo.svg": "sha256:85cf45fdc760047cf44fa8d001a247634095a83886d55f866ac699dc44257ca0", + "templates/viewer/assets/leji-logo.svg": "sha256:3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", "templates/viewer/assets/mermaid.min.js": "sha256:217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", "templates/viewer/assets/prism-bash.min.js": "sha256:89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", "templates/viewer/assets/prism-json.min.js": "sha256:41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", @@ -41,10 +40,11 @@ "templates/viewer/assets/source-sans-pro-600-latin-ext.woff2": "sha256:9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", "templates/viewer/assets/source-sans-pro-600-latin.woff2": "sha256:156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", "templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", - "templates/viewer/assets/viewer-boot.js": "sha256:89be9a6cd3c3902b358e7dadcb7ad2ecaf34773c30d1b993af5a8e086db1b611", - "templates/viewer/assets/vue.css": "sha256:9c87099992a5da838a432ffacbdf86705603b916a726c382521d050fbda4564a", + "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/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", - "templates/viewer/index.html": "sha256:d5dd1eca373320b26918ef57836100531ebeb53281fc7198b14e9e8b865a13b6", + "templates/viewer/index.html": "sha256:127dadfdca91e9e93739b4e2898ab1ec33fe0b5658354288d960e3e2989f6998", "templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c" } } diff --git a/packages/sdk-go/internal/assets/cli.json b/packages/sdk-go/internal/assets/cli.json index 72496e2..4f21624 100644 --- a/packages/sdk-go/internal/assets/cli.json +++ b/packages/sdk-go/internal/assets/cli.json @@ -1,11 +1,11 @@ { "name": "leji", - "summary": "Reference CLI for the Leji specification: validate, index, changelog, freshness, conformance, status, route, viewer/view, detect, adopt, init, start, ci, and agent for a shared context layer.", + "summary": "Reference CLI for the Leji specification: validate, index, changelog, freshness, conformance, badge, status, route, mounts, export, viewer/view, detect, adopt, init, start, ci, and agent for a shared context layer.", "usage": "leji [options]", "globalOptions": [ { "flags": "--root ", - "summary": "Repository root to operate on (default: the current directory)." + "summary": "Repository root to operate on (default: the current directory). With the Node and Python CLIs, a root that declares and installs the Leji CLI for that runtime, meeting the layer's minimum, runs that copy." }, { "flags": "--json", @@ -34,9 +34,242 @@ "meaning": "Usage error, or an internal failure (e.g. init refusing to overwrite)." } ], + "groups": [ + { + "id": "start", + "title": "Get started" + }, + { + "id": "everyday", + "title": "Every day" + }, + { + "id": "federation", + "title": "Federation" + }, + { + "id": "viewer", + "title": "Viewer and export" + } + ], "commands": [ + { + "name": "init", + "group": "start", + "summary": "Bootstrap a new context layer from the templates.", + "usage": "leji init [--dir ] [--yes] [--mode ] [--level ] [--name ] [--agent ] [--no-agents] [--dry-run] [--json]", + "description": "Scaffolds a new context layer from the templates.", + "details": [ + "Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the portable entrypoint many agent hosts read, redirecting to the boot profile; `--no-agents` skips it), seeded category documents, a first decision record, an agent onboarding brief, and a generated index, so the scaffold is ready for the CI job `leji ci` writes. At the indexed level it also writes the machine changelog. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", + "`--mode solo` (a team of one) also seeds identity and writing-style starters, maps the practice category, routes identity and writing work in the boot profile, and points the onboarding brief at the owner interview (answer in text or with dropped files).", + "Refuses to overwrite an existing `leji.json`, and refuses when the git tree has uncommitted changes; never overwrites individual files.", + "Reports the repository's dependency ecosystem (its package manager, from the manifest and lockfiles present) and how to declare the Leji CLI as a dev dependency there, so a clean install brings `leji`; on a real terminal it offers to run that manager's own add command, and only on your explicit yes. `--yes`, a non-TTY and `--json` print the command instead of running it, and leji never edits a manifest or lockfile itself.", + "`--dry-run` prints the write plan without writing.", + "Also backs `npm create leji`." + ], + "options": [ + { + "flags": "--dir ", + "summary": "Target directory (default: the current directory)." + }, + { + "flags": "--yes, -y", + "summary": "Accept all defaults; run non-interactively." + }, + { + "flags": "--mode ", + "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." + }, + { + "flags": "--level ", + "summary": "Conformance level to claim: core or indexed (default: core)." + }, + { + "flags": "--name ", + "summary": "Context layer name (default: derived from the directory)." + }, + { + "flags": "--agent ", + "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." + }, + { + "flags": "--no-agents", + "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." + }, + { + "flags": "--dry-run", + "summary": "Print the write plan and exit without creating any files." + } + ], + "examples": [ + "leji init", + "leji init --dry-run", + "leji init --mode solo", + "leji init --agent claude-code" + ] + }, + { + "name": "adopt", + "group": "start", + "summary": "Adopt Leji into an existing repository.", + "usage": "leji adopt [--dir ] [--yes] [--mode ] [--agent ] [--wire-adapters] [--no-agents] [--dry-run] [--json]", + "description": "Brings Leji into a repository that already has docs and agent config.", + "details": [ + "Reuses an existing `docs/` root, migrates any vendor entrypoints (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without modifying the originals, and seeds the scaffold.", + "Writes a generated index, so the adopted context layer is ready for the CI job `leji ci` writes. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", + "`--wire-adapters` converts those entrypoints to one-line redirects, after migrating their content.", + "When no `AGENTS.md` exists, writes a pointer-only one (the portable entrypoint many agent hosts read) redirecting to the boot profile; `--no-agents` skips it, and an existing file is never touched.", + "`--mode solo` (a team of one) also seeds identity and writing-style starters and points the onboarding brief at the owner interview; existing files are never overwritten.", + "Refuses when a `leji.json` exists, `--dry-run` included: a repository that already has a context layer has nothing to adopt. Also refuses when the git tree has uncommitted changes, which `--dry-run` is exempt from because it writes nothing.", + "Reports the repository's dependency ecosystem (its package manager, from the manifest and lockfiles present) and how to declare the Leji CLI as a dev dependency there, so a clean install brings `leji`; on a real terminal it offers to run that manager's own add command, and only on your explicit yes. `--yes`, a non-TTY and `--json` print the command instead of running it, and leji never edits a manifest or lockfile itself.", + "Also backs `npm create leji` on a repository that already carries docs or an agent entrypoint." + ], + "options": [ + { + "flags": "--dir ", + "summary": "Target directory (default: the current directory)." + }, + { + "flags": "--yes, -y", + "summary": "Accept all defaults; run non-interactively." + }, + { + "flags": "--mode ", + "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." + }, + { + "flags": "--agent ", + "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." + }, + { + "flags": "--wire-adapters", + "summary": "Convert present vendor entrypoints to redirects (consented; content migrated first)." + }, + { + "flags": "--no-agents", + "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." + }, + { + "flags": "--dry-run", + "summary": "Print the write plan and exit without changing anything." + } + ], + "examples": [ + "leji adopt", + "leji adopt --dry-run", + "leji adopt --mode solo", + "leji adopt --wire-adapters" + ] + }, + { + "name": "start", + "group": "start", + "summary": "Open a coding agent in this context layer, booted from the boot profile.", + "usage": "leji start [--agent ] [--root ] [--json] [-- ]", + "description": "Detects an installed agent (or use --agent), launches it from the context root, and points it at the boot profile so it loads the team's context first. The agent-facing counterpart to `leji view`. Several detected agents prompt for which; with none detected or in a non-interactive shell, it prints the command to run. Everything after a literal -- passes verbatim to the launched host binary, before the boot prompt. Host-specific flags ride with a pinned host: `leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which could hand the flag to whichever host gets picked.", + "details": [ + "Before the agent starts, prints a Setup block for this clone: whether the Leji CLI this repository declares resolves here and meets the minimum version for the layer's spec line, whether the MCP server is registered for the selected host, whether the shared `.mcp.json` is committed, and whether the pre-commit hook is installed.", + "Each row is personal or shared. Personal state (your host's MCP registration, this clone's `.git` hook) is offered on a real terminal and printed as an exact command otherwise; shared state (the dependency declaration, a committed `.mcp.json`, a hooks directory inside the working tree) is only ever reported, with the command a maintainer runs and commits. A gap never blocks entry: the agent still boots.", + "`--json` makes it report-only: one document with `ready` and the same checks, no prompts and no launch, exit 0 even when `ready` is false. The launch-selection arguments are accepted and have no effect there; an `--agent` naming no launchable host is still a usage error." + ], + "options": [ + { + "flags": "--agent ", + "summary": "Launch a specific host (claude-code or codex) instead of auto-detecting." + }, + { + "flags": "-- ", + "summary": "Pass the remaining arguments verbatim to the launched host binary; pin --agent when they are host-specific." + } + ], + "examples": [ + "leji start", + "leji start --agent codex", + "leji start --agent claude-code -- --chrome" + ] + }, + { + "name": "agent", + "group": "start", + "summary": "Bind an additional named agent into an existing context layer.", + "usage": "leji agent --name [--host ] [--role ] [--root ] [--json]", + "description": "Adds a second (or third) agent to a context layer that already has a `leji.json`.", + "details": [ + "Writes a starter agent profile under the agent-profiles path and binds it in the manifest's agents map via an in-place edit that preserves the rest of the file.", + "Never writes an agent-host entrypoint file. The portable `AGENTS.md` pointer is written by `init` and `adopt` (unless `--no-agents`); single-vendor files like `CLAUDE.md` are only ever converted from an existing one by `adopt --wire-adapters`.", + "`--host` is optional: a host pins the profile to a specific external CLI; with none, it's a host-agnostic resident agent any host can run.", + "The role defaults to reviewer; pass `--role` for a different one.", + "Binding the `default` key prints a note: `agents.default` selects a role profile, it does not load it, so instructions that must apply before every task belong in the boot profile rather than that profile. This note prints once, when the binding is written.", + "Idempotent: an existing profile or binding is left untouched. Requires an existing context layer." + ], + "options": [ + { + "flags": "--name ", + "summary": "Name for the agent; also its profile id and agents-map key (kebab-case)." + }, + { + "flags": "--host ", + "summary": "Optional. Pin the profile to a host (claude-code, codex, copilot, gemini, cursor, windsurf; aliases ok); omit for a host-agnostic resident agent." + }, + { + "flags": "--role ", + "summary": "Role the agent fills (default: reviewer)." + } + ], + "examples": [ + "leji agent --name porter --role porter", + "leji agent --host codex --name reviewer", + "leji agent --host claude-code --name thought-partner --role advisor" + ] + }, + { + "name": "detect", + "group": "start", + "summary": "Detect the coding-agent hosts available on this machine.", + "usage": "leji detect [--root ] [--json]", + "description": "Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable binary, a config file in the repository, or a user-level config directory. Writes nothing; use it to decide which host to open with `leji start --agent ` (launchable hosts today: claude-code and codex; other detected hosts enter the context layer through their vendor-file redirect). Also reports the repository's own dependency ecosystem: the package manager its manifest and lockfiles name, whether the Leji CLI is already declared as a dev dependency there, and the command that would declare it.", + "options": [], + "examples": [ + "leji detect", + "leji detect --json" + ] + }, + { + "name": "ci", + "group": "start", + "summary": "Add a CI workflow that runs leji validate and index --check on every change.", + "usage": "leji ci [--provider ] [--hooks] [--root ] [--json]", + "description": "Adds a CI job that runs `leji validate` and `leji index --check` on every change, so your context layer stays honest in CI (the same two gates the `--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place is left untouched.", + "details": [ + "The provider is inferred from the `origin` remote (a github.com host selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure Pipelines) and falls back to GitHub when the remote names none; `--provider` overrides the inference, and CircleCI is never inferred.", + "GitHub: writes its own workflow at `.github/workflows/leji.yml`.", + "GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if it's absent.", + "CircleCI: writes `.circleci/config.yml` if absent; when a config already exists that leji did not generate, prints a snippet to add by hand instead of editing it.", + "Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not auto-discover it, so activation is manual: create a pipeline that points at the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), then add a build-validation branch policy on `main` for pull-request checks. This activation note prints once, on first creation.", + "Local hook (`--hooks`): writes a managed pre-commit running `leji validate` and `leji index --check` through the same detected runner the CI job uses (`'pnpm' 'exec' 'leji' validate`, and so on), each argument single-quoted for the shell; a repository that does not declare the CLI runs the `leji` on PATH. `core.hooksPath` is detected, so a husky repo gets a managed block merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an existing unmanaged hook is never touched, its snippet printed to add by hand.", + "Local-first, through the package manager this repository actually uses: `leji ci` detects it from the manifest and lockfiles present, and when the repository DECLARES the Leji CLI and carries that manager's lock evidence the generated job installs its locked dependencies and runs the local binary (`corepack enable && pnpm install --frozen-lockfile` then `pnpm exec leji`, `uv sync --locked` then `uv run leji`, `go mod download` then `go tool leji`, and so on). Everything else takes a fallback that needs no manifest: `npx @leji-org/leji@1` for Node, several ecosystems and none; `pip install 'leji>=1,<2'` for Python; `go install .../cmd/leji@latest` for Go. A bootstrap tool the job installs unpinned (poetry, pdm, pipenv, uv outside GitHub) is disclosed in one comment line.", + "Generated files carry the marker `# generated by leji ci (managed) v2`. A re-run replaces a whole file (GitHub, CircleCI, Azure) only when its bytes are ones leji generated, in this release or an earlier one, so a manager change or an upgrade refreshes the job; a generated file you edited, and any file you wrote yourself, are left untouched with a snippet to add by hand. Editing the file, or deleting the marker, is the opt-out. GitLab owns only its marker-delimited block inside `.gitlab-ci.yml`." + ], + "options": [ + { + "flags": "--hooks", + "summary": "Write a managed local pre-commit running validate + index --check; core.hooksPath is detected, so a husky repo gets a managed block in .husky/pre-commit, and an existing unmanaged hook is left untouched with the snippet printed." + }, + { + "flags": "--provider ", + "summary": "CI provider: github (default when no remote is recognizable), gitlab, circleci, or azure. Without this flag the provider is inferred from the origin remote." + } + ], + "examples": [ + "leji ci", + "leji ci --provider gitlab", + "leji ci --provider azure", + "leji ci --hooks" + ] + }, { "name": "validate", + "group": "everyday", "summary": "Validate the context layer: manifest, artifacts, frontmatter, and lint rules.", "usage": "leji validate [--content] [--federation [--paths ]] [--root ] [--json]", "description": "Loads leji.json and checks it against the schemas and the lint rules: declared files exist, categories are populated, vendor entrypoints redirect to the boot profile, frontmatter is valid, and (per the claimed conformance level) the index is current and the changelog is append-only. One of the two gates the generated CI runs, beside `leji index --check`. With --content it also runs a warning-only content lint (placeholder text, generic boot identity, thin categories) that never errors and never affects a conformance level.", @@ -62,6 +295,7 @@ }, { "name": "index", + "group": "everyday", "summary": "Generate the context index at the declared path, or verify it is current.", "usage": "leji index [--check] [--root ] [--json]", "description": "Resolves the category index files to the documents they list and writes the context index to machine.indexPath. Ids are carried across a move when the move is unambiguous: a document whose path changes keeps its id if its content is unchanged and that content is unique in the context layer. A move that also edits the content, or that moves one of several byte-identical documents, cannot be carried and mints a fresh id. Declare a frontmatter id to make a document's id survive any move; that is the only unconditional guarantee. With --check it writes nothing and instead fails when the stored index no longer matches what the index files resolve to (a stale index is a hard failure).", @@ -77,81 +311,82 @@ ] }, { - "name": "changelog check", - "summary": "Verify the machine changelog: schema and append-only discipline.", - "usage": "leji changelog check [--strict] [--root ] [--json]", - "description": "Validates the declared changelog against its schema and checks append-only discipline against the committed state of the file at HEAD: surviving entries are immutable, and entries may be removed only from the oldest end and only alongside a compaction entry. The comparison is against HEAD, so it catches an uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI checkout the working tree is HEAD, so it does not by itself detect a rewrite that arrives already committed. Reviewing the diff covers that. Without git the discipline is unverifiable and reported as a warning.", + "name": "status", + "group": "everyday", + "summary": "Report unindexed, dangling, and stale documents in the context layer.", + "usage": "leji status [--strict] [--root ] [--json]", + "description": "Informational health report: markdown under the context root that no category index lists (reference content), index entries whose listed path does not resolve (dangling), and stored-index paths the index files no longer resolve to (stale). It also reports shadowed entries and skipped READMEs, which are informational only, and whether the context layer at HEAD would project completely if a host mounted it (the closure enumerated, the failure detail, or no commit to judge). Report-only by default; exit 0. With --strict, exits nonzero when an unindexed, dangling, stale, or pending document is flagged (shadowed and skipped-README entries never fail the run), for CI use.", "options": [ { "flags": "--strict", - "summary": "Treat an unverifiable append-only check (no git baseline) as an error." + "summary": "Exit nonzero when an unindexed, dangling, stale, or pending document is flagged, for CI." } ], "examples": [ - "leji changelog check", - "leji changelog check --strict" + "leji status", + "leji status --strict --json" ] }, { - "name": "changelog compact", - "summary": "Fold the oldest changelog entries into a single compaction entry.", - "usage": "leji changelog compact [--keep ] [--before ] [--root ] [--json]", - "description": "Compacts the oldest end of the machine changelog, folding entries into a single compaction entry that records how many were folded and the id range removed.", - "details": [ - "Selection: `--keep ` folds every entry except the newest n; `--before ` folds entries dated before the given day. With both, an entry folds only if it satisfies both (the intersection).", - "At least one of `--keep` or `--before` is required.", - "The folded set is always a contiguous run from the oldest end, so the result still satisfies the append-only discipline that `leji changelog check` enforces." - ], + "name": "conformance", + "group": "everyday", + "summary": "Score the context layer against its claimed conformance level.", + "usage": "leji conformance [--explain] [--federation verify] [--root ] [--json]", + "description": "Runs the `core`, `indexed`, `governed`, and `federated` checklists. Machine-checkable items pass or fail; process items (review gate, CI, external consumers) are reported as manual. A machine failure at or below the claimed level is an error; evidence this run could not obtain reports `unknown`, which caps the verified level without refuting the claim. With --explain it also prints what it would take to reach the next level.", "options": [ { - "flags": "--keep ", - "summary": "Keep the newest n entries; fold everything older. Must be a positive integer." + "flags": "--explain", + "summary": "Print actionable guidance for reaching the next conformance level." }, { - "flags": "--before ", - "summary": "Fold entries dated strictly before this YYYY-MM-DD day." + "flags": "--federation verify", + "summary": "Run the networked pin-reachability probe against each mount's source (git ls-remote + witness-ref ancestry). Without it the pin-reachable item reports unknown, which never awards the federated level." } ], "examples": [ - "leji changelog compact --keep 50", - "leji changelog compact --before 2026-01-01", - "leji changelog compact --keep 50 --before 2026-01-01" + "leji conformance", + "leji conformance --explain", + "leji conformance --json" ] }, { - "name": "freshness", - "summary": "Report review horizons across category documents and agent profiles.", - "usage": "leji freshness [--strict] [--root ] [--json]", - "description": "Lists documents whose freshness.reviewAfter horizon has passed (expired) or falls within the next 30 days (upcoming). Report-only by default; expired horizons are warnings.", + "name": "badge", + "group": "everyday", + "summary": "Write the self-attested conformance badge for this repository.", + "usage": "leji badge [--root ] [--out ] [--json]", + "description": "Writes one SVG (default: leji-badge.svg at the repository root) and prints the markdown line that embeds it. Self-attested: the badge states the level `leji conformance` verified in this offline run, never more than the layer claims and possibly less, and a claim this run could not confirm is named beside it. Nothing is sent anywhere and no service or registry is involved; the bytes are constants, and the file is yours to commit. A run with an error finding, or one that verified no level at all, writes nothing and exits 1. An existing target is replaced only when its bytes are a badge this command wrote, which is how a level change regenerates; any other file is left untouched and the run refuses.", "options": [ { - "flags": "--strict", - "summary": "Treat expired horizons as errors instead of warnings." + "flags": "--out ", + "summary": "Where to write the badge (default: leji-badge.svg). A repository-relative POSIX path over [A-Za-z0-9._/-] with no \"..\" segment, ending .svg, resolving inside the repository and never inside .leji/." } ], "examples": [ - "leji freshness", - "leji freshness --strict --json" + "leji badge", + "leji badge --out docs/badge.svg", + "leji badge --json" ] }, { - "name": "status", - "summary": "Report unindexed, dangling, and stale documents in the context layer.", - "usage": "leji status [--strict] [--root ] [--json]", - "description": "Informational health report: markdown under the context root that no category index lists (reference content), index entries whose listed path does not resolve (dangling), and stored-index paths the index files no longer resolve to (stale). It also reports shadowed entries and skipped READMEs, which are informational only, and whether the context layer at HEAD would project completely if a host mounted it (the closure enumerated, the failure detail, or no commit to judge). Report-only by default; exit 0. With --strict, exits nonzero when an unindexed, dangling, stale, or pending document is flagged (shadowed and skipped-README entries never fail the run), for CI use.", + "name": "freshness", + "group": "everyday", + "summary": "Report review horizons across category documents and agent profiles.", + "usage": "leji freshness [--strict] [--root ] [--json]", + "description": "Lists documents whose freshness.reviewAfter horizon has passed (expired) or falls within the next 30 days (upcoming). Report-only by default; expired horizons are warnings.", "options": [ { "flags": "--strict", - "summary": "Exit nonzero when an unindexed, dangling, stale, or pending document is flagged, for CI." + "summary": "Treat expired horizons as errors instead of warnings." } ], "examples": [ - "leji status", - "leji status --strict --json" + "leji freshness", + "leji freshness --strict --json" ] }, { "name": "route", + "group": "everyday", "summary": "Show the governed context a task's scope routes to.", "usage": "leji route [--paths ] [--categories ] [--topics ]... [--as-of ] [--root ] [--json]", "description": "Read-only: given a task's scope (repository-relative paths it reads or changes, plus any categories and topics it names), print the slice of governed context that scope selects per the Task routing algorithm. Paths select the governed entries that contain them or are contained by them, and a path that is itself a governed document signals that document's category for decision and mount matching without expanding it; only a category the task explicitly names expands that category's intent documents and record candidates. Topics select sibling mounts and nothing else. Prints the expanded categories and the signalled ones, the governed documents (with each document's review horizon and whether it has expired), the record candidates a reader loads by judgment, the live decision records routed to the task, and the sibling mounts the supplied category and topic signals match. It computes the scope-dependent portion only: the boot profile's unconditional load set and the active agent profile's requiredRead are the caller's baseline and are never emitted here. Reads and reports context; it never executes a task.", @@ -188,28 +423,52 @@ ] }, { - "name": "conformance", - "summary": "Score the context layer against its claimed conformance level.", - "usage": "leji conformance [--explain] [--federation verify] [--root ] [--json]", - "description": "Runs the `core`, `indexed`, `governed`, and `federated` checklists. Machine-checkable items pass or fail; process items (review gate, CI, external consumers) are reported as manual. A machine failure at or below the claimed level is an error; evidence this run could not obtain reports `unknown`, which caps the verified level without refuting the claim. With --explain it also prints what it would take to reach the next level.", + "name": "changelog check", + "group": "everyday", + "summary": "Verify the machine changelog: schema and append-only discipline.", + "usage": "leji changelog check [--strict] [--root ] [--json]", + "description": "Validates the declared changelog against its schema and checks append-only discipline against the committed state of the file at HEAD: surviving entries are immutable, and entries may be removed only from the oldest end and only alongside a compaction entry. The comparison is against HEAD, so it catches an uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI checkout the working tree is HEAD, so it does not by itself detect a rewrite that arrives already committed. Reviewing the diff covers that. Without git the discipline is unverifiable and reported as a warning.", "options": [ { - "flags": "--explain", - "summary": "Print actionable guidance for reaching the next conformance level." + "flags": "--strict", + "summary": "Treat an unverifiable append-only check (no git baseline) as an error." + } + ], + "examples": [ + "leji changelog check", + "leji changelog check --strict" + ] + }, + { + "name": "changelog compact", + "group": "everyday", + "summary": "Fold the oldest changelog entries into a single compaction entry.", + "usage": "leji changelog compact [--keep ] [--before ] [--root ] [--json]", + "description": "Compacts the oldest end of the machine changelog, folding entries into a single compaction entry that records how many were folded and the id range removed.", + "details": [ + "Selection: `--keep ` folds every entry except the newest n; `--before ` folds entries dated before the given day. With both, an entry folds only if it satisfies both (the intersection).", + "At least one of `--keep` or `--before` is required.", + "The folded set is always a contiguous run from the oldest end, so the result still satisfies the append-only discipline that `leji changelog check` enforces." + ], + "options": [ + { + "flags": "--keep ", + "summary": "Keep the newest n entries; fold everything older. Must be a positive integer." }, { - "flags": "--federation verify", - "summary": "Run the networked pin-reachability probe against each mount's source (git ls-remote + witness-ref ancestry). Without it the pin-reachable item reports unknown, which never awards the federated level." + "flags": "--before ", + "summary": "Fold entries dated strictly before this YYYY-MM-DD day." } ], "examples": [ - "leji conformance", - "leji conformance --explain", - "leji conformance --json" + "leji changelog compact --keep 50", + "leji changelog compact --before 2026-01-01", + "leji changelog compact --keep 50 --before 2026-01-01" ] }, { "name": "mounts hydrate", + "group": "federation", "summary": "Materialize declared federation mounts into the resolver cache.", "usage": "leji mounts hydrate [--fetch] [--root ] [--json]", "description": "For each declared federation mount, resolves the pinned commit from a local object store (an explicit hint in .leji/mounts.local.json, the resolver-managed store, or a unique matching submodule's object database) and extracts the sibling's layer projection into the gitignored cache under .leji/mounts/. The projection is the deduplicated union of everything the sibling's own manifest makes readable at the pin: the root leji.json, the tree under its declared context root, its boot profile, its machine index and changelog files when present, its agent-profiles and decision-records trees when present, every agent profile its agents map binds, every category index file, and every governed path its pinned generated index lists, wherever those live. The failure boundary follows the same line: a referenced or schema-required file absent at the pin (the boot profile, a category index, a bound agent profile, an indexed governed path) fails the projection naming the declaring artifact and the missing path, while an absent directory or an absent machine artifact contributes nothing and fails nothing. The only mutating mounts command, and offline by default: --fetch establishes the resolver-managed store for every declared mount, including one a hint already resolves, fetching the pin from the declared source, retaining it under refs/leji-pin/v1/, and refreshing the managed witness under refs/leji-witness/v1/ (the only writer of that namespace, since `mounts status` never fetches). Best-effort: an unavailable mount is reported and skipped (degraded knowledge, never a failed run); the exit code reflects declaration, safety, or projection errors only.", @@ -226,6 +485,7 @@ }, { "name": "mounts status", + "group": "federation", "summary": "Report each mount's availability, integrity, and pin ancestry.", "usage": "leji mounts status [--check-integrity] [--root ] [--json]", "description": "Read-only diagnostics for the declared federation mounts: whether the pinned projection is present in the cache, and an ancestry-aware pin report against the declared witness ref (trackingRef) computed from a reachable local object store: up-to-date, behind N, ahead, diverged, unrelated, or unknown, always naming the compared ref, the category of repository the comparison ran in (comparisonRepository: managed-store, hint, or submodule), whether the witness was the resolver's own ref or one it does not own (witnessProvenance), the observation time, and ancestry completeness. --check-integrity additionally re-derives the projection from the object store and compares it byte-for-byte (paths, modes, symlinks) against the cache. Never mutates and never touches the network.", @@ -242,20 +502,52 @@ }, { "name": "mounts locate", + "group": "federation", "summary": "Print resolver state for one mount: projection path, pin, verification.", "usage": "leji mounts locate [--root ] [--json]", - "description": "Resolves a declared mount's hydrated projection through resolver state (never by inferring cache paths): the projection directory, the pin, whether the bytes are present, and whether they verified against a reachable object store this run. Readers obtain the mounted content's location from this command; a projection that cannot be verified is reported as present but unverified. Exits 0 when the projection is present, 1 otherwise.", + "description": "Resolves a declared mount's hydrated projection through resolver state (never by inferring cache paths): the projection directory, the pin, whether the bytes are present, and whether they verified this run. Readers obtain the mounted content's location from this command; a projection that cannot be verified is reported as present but unverified, which includes the case where a verification prerequisite (a reachable object store, a resolvable pin, a writable temp dir) is unavailable. Exits 0 when the projection is present, 1 otherwise.", "options": [], "examples": [ "leji mounts locate product-context", "leji mounts locate product-context --json" ] }, + { + "name": "mounts update-pin", + "group": "federation", + "summary": "Move a declared mount's pin forward to a witnessed commit, showing the comparison first.", + "usage": "leji mounts update-pin [--to ] [--allow-non-fast-forward] [--fetch] [--dry-run] [--root ] [--json]", + "description": "Rewrites one declared federation mount's pin in leji.json, after printing where that pin stands against its tracking ref. Offline by default: the target is the last successfully observed witness in a reachable object store (the resolver-managed store first, then a hint or a unique matching submodule holding both the pin and the ref), never a claim that the source was looked at during this run. --fetch observes the declared source and nothing else, in three acts: retain the current pin in the resolver-managed store, refresh the managed witness ref once, and retain the target once the comparison has passed; any of them failing refuses the move with a stable reason and leaves leji.json untouched, though objects and refs already fetched stay in the managed store. With no trackingRef declared the run refuses offline, and under --fetch resolves the source's advertised default branch for this run and reports it as the compared ref. The pin moves forward only: a target that is not a descendant of the current pin is refused unless BOTH --to and --allow-non-fast-forward are given, which is recorded as a warning and as override in --json; neither flag bypasses a repository whose ancestry is incomplete. --to takes a full 40- or 64-character lowercase hex commit id the comparison repository already holds. --dry-run computes and prints everything and writes no manifest byte; combined with --fetch it still performs that flag's store and network acts, so fetched objects and refs land in the managed store. Only the pin's own bytes are replaced, so field order, formatting and unmodeled keys survive. Hydration is a separate step: the run prints the leji mounts hydrate command that materializes the new pin, and the cache entry for the old pin is left in place for you to remove by hand. Exit 0 when the pin was updated, was already the target, or the run was a dry run; 1 when the move was refused with a stable reason; 2 for a usage error, or when the addressed pin cannot be located in leji.json.", + "options": [ + { + "flags": "--to ", + "summary": "Move to this exact commit instead of the witness tip; it must already be held by the comparison repository." + }, + { + "flags": "--allow-non-fast-forward", + "summary": "Permit a target that is not a descendant of the current pin. Valid only with --to, and always warned." + }, + { + "flags": "--fetch", + "summary": "Observe the declared source: retain the current pin, refresh the managed witness ref, and retain the target." + }, + { + "flags": "--dry-run", + "summary": "Show the comparison and what would change; write no manifest byte. With --fetch, the store and network acts still happen." + } + ], + "examples": [ + "leji mounts update-pin product-context", + "leji mounts update-pin product-context --fetch --dry-run", + "leji mounts update-pin product-context --to 7d3f2a19c4e8b6a0d5f1c2e9b8a7f6d5c4b3a2e1" + ] + }, { "name": "viewer", + "group": "viewer", "summary": "Generate the static viewer for the context layer.", "usage": "leji viewer [--root ] [--json]", - "description": "Projects the context index into a browsable Docsify viewer: writes a frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored viewer assets into the context layer's contained viewer directory. Presentation is non-normative; this is the reference projection. Generates only; use `leji viewer serve` (or `leji view`) to preview it locally, and `leji viewer build` to export a self-contained copy.", + "description": "Projects the context index into a browsable Docsify viewer: writes a frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored viewer assets into the context layer's contained viewer directory. Presentation is non-normative; this is the reference projection. Generates only; use `leji viewer serve` (or `leji view`) to preview it locally, and `leji export` (spelled `leji viewer build` inside the viewer subsystem) to write a self-contained static site.", "options": [], "examples": [ "leji viewer", @@ -264,6 +556,7 @@ }, { "name": "viewer serve", + "group": "viewer", "summary": "Generate the viewer and serve it locally.", "usage": "leji viewer serve [--port ] [--open] [--root ] [--json]", "description": "Generates the viewer, then serves it on localhost (a local preview, never hosting) at the web root. With --open it also opens your default browser at the viewer.", @@ -283,24 +576,10 @@ "leji viewer serve --port 0" ] }, - { - "name": "viewer build", - "summary": "Export a self-contained static viewer folder for internal hosting.", - "usage": "leji viewer build [--out ] [--root ] [--json]", - "description": "Regenerates the viewer and materializes it into a standalone static folder (default: .leji/viewer-dist/, kept out of git) that any host serves as-is. A custom --out must resolve inside the repository. The exported index.html warns that a context layer is sensitive and should be hosted behind internal authentication, not a public bucket.", - "options": [ - { - "flags": "--out ", - "summary": "Output directory for the export (default: .leji/viewer-dist inside the context root; must resolve inside the repository)." - } - ], - "examples": [ - "leji viewer build", - "leji viewer build --out dist/site" - ] - }, { "name": "view", + "group": "viewer", + "aliasOf": "viewer serve", "summary": "Alias for `leji viewer serve` (and opens the browser).", "usage": "leji view [--port ] [--root ]", "description": "One-word shortcut to browse the context layer: generates the viewer, serves it on localhost, and opens your default browser. Equivalent to `leji viewer serve --open`.", @@ -316,201 +595,47 @@ ] }, { - "name": "start", - "summary": "Open a coding agent in this context layer, booted from the boot profile.", - "usage": "leji start [--agent ] [--root ] [-- ]", - "description": "Detects an installed agent (or use --agent), launches it from the context root, and points it at the boot profile so it loads the team's context first. The agent-facing counterpart to `leji view`. Several detected agents prompt for which; with none detected or in a non-interactive shell, it prints the command to run. Everything after a literal -- passes verbatim to the launched host binary, before the boot prompt. Host-specific flags ride with a pinned host: `leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which could hand the flag to whichever host gets picked.", - "options": [ - { - "flags": "--agent ", - "summary": "Launch a specific host (claude-code or codex) instead of auto-detecting." - }, - { - "flags": "-- ", - "summary": "Pass the remaining arguments verbatim to the launched host binary; pin --agent when they are host-specific." - } - ], - "examples": [ - "leji start", - "leji start --agent codex", - "leji start --agent claude-code -- --chrome" - ] - }, - { - "name": "detect", - "summary": "Detect the coding-agent hosts available on this machine.", - "usage": "leji detect [--root ] [--json]", - "description": "Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable binary, a config file in the repository, or a user-level config directory. Writes nothing; use it to decide which host to open with `leji start --agent ` (launchable hosts today: claude-code and codex; other detected hosts enter the context layer through their vendor-file redirect).", - "options": [], - "examples": [ - "leji detect", - "leji detect --json" - ] - }, - { - "name": "adopt", - "summary": "Adopt Leji into an existing repository.", - "usage": "leji adopt [--dir ] [--yes] [--mode ] [--agent ] [--wire-adapters] [--no-agents] [--dry-run]", - "description": "Brings Leji into a repository that already has docs and agent config.", - "details": [ - "Reuses an existing `docs/` root, migrates any vendor entrypoints (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without modifying the originals, and seeds the scaffold.", - "Writes a generated index, so the adopted context layer is ready for the CI job `leji ci` writes. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", - "`--wire-adapters` converts those entrypoints to one-line redirects, after migrating their content.", - "When no `AGENTS.md` exists, writes a pointer-only one (the portable entrypoint many agent hosts read) redirecting to the boot profile; `--no-agents` skips it, and an existing file is never touched.", - "`--mode solo` (a team of one) also seeds identity and writing-style starters and points the onboarding brief at the owner interview; existing files are never overwritten.", - "Refuses when a `leji.json` exists, `--dry-run` included: a repository that already has a context layer has nothing to adopt. Also refuses when the git tree has uncommitted changes, which `--dry-run` is exempt from because it writes nothing." - ], + "name": "export", + "group": "viewer", + "summary": "Export the context layer as a self-contained static site.", + "usage": "leji export [--out ] [--strict] [--root ] [--json]", + "description": "Regenerates the viewer chrome, then writes the static site from the layer on disk (default: .leji/dist/, kept out of git), complete on its own and servable as-is, including under a subpath. Everything the site needs travels with it: nothing is read from anywhere but the layer when it is written, and nothing is read from anywhere but the site's own files when it is opened. `leji viewer build` is the viewer subsystem's name for this same operation, beside `leji viewer serve`; both names are permanently supported and behave identically. A custom --out must resolve inside the repository, and never inside .leji/ except exactly .leji/dist. The exported index.html warns that a context layer is sensitive and belongs behind internal authentication, not in a public bucket.", "options": [ { - "flags": "--dir ", - "summary": "Target directory (default: the current directory)." - }, - { - "flags": "--yes, -y", - "summary": "Accept all defaults; run non-interactively." - }, - { - "flags": "--mode ", - "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." - }, - { - "flags": "--agent ", - "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." - }, - { - "flags": "--wire-adapters", - "summary": "Convert present vendor entrypoints to redirects (consented; content migrated first)." - }, - { - "flags": "--no-agents", - "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." - }, - { - "flags": "--dry-run", - "summary": "Print the write plan and exit without changing anything." - } - ], - "examples": [ - "leji adopt", - "leji adopt --dry-run", - "leji adopt --mode solo", - "leji adopt --wire-adapters" - ] - }, - { - "name": "init", - "summary": "Bootstrap a new context layer from the templates.", - "usage": "leji init [--dir ] [--yes] [--mode ] [--level ] [--name ] [--agent ] [--no-agents] [--dry-run]", - "description": "Scaffolds a new context layer from the templates.", - "details": [ - "Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the portable entrypoint many agent hosts read, redirecting to the boot profile; `--no-agents` skips it), seeded category documents, a first decision record, an agent onboarding brief, and a generated index, so the scaffold is ready for the CI job `leji ci` writes. At the indexed level it also writes the machine changelog. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", - "`--mode solo` (a team of one) also seeds identity and writing-style starters, maps the practice category, routes identity and writing work in the boot profile, and points the onboarding brief at the owner interview (answer in text or with dropped files).", - "Refuses to overwrite an existing `leji.json`, and refuses when the git tree has uncommitted changes; never overwrites individual files.", - "`--dry-run` prints the write plan without writing.", - "Also backs `npm create leji`." - ], - "options": [ - { - "flags": "--dir ", - "summary": "Target directory (default: the current directory)." - }, - { - "flags": "--yes, -y", - "summary": "Accept all defaults; run non-interactively." - }, - { - "flags": "--mode ", - "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." - }, - { - "flags": "--level ", - "summary": "Conformance level to claim: core or indexed (default: core)." - }, - { - "flags": "--name ", - "summary": "Context layer name (default: derived from the directory)." - }, - { - "flags": "--agent ", - "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." - }, - { - "flags": "--no-agents", - "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." - }, - { - "flags": "--dry-run", - "summary": "Print the write plan and exit without creating any files." - } - ], - "examples": [ - "leji init", - "leji init --dry-run", - "leji init --mode solo", - "leji init --agent claude-code" - ] - }, - { - "name": "ci", - "summary": "Add a CI workflow that runs leji validate and index --check on every change.", - "usage": "leji ci [--provider ] [--hooks] [--root ] [--json]", - "description": "Adds a CI job that runs `leji validate` and `leji index --check` on every change, so your context layer stays honest in CI (the same two gates the `--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place is left untouched.", - "details": [ - "The provider is inferred from the `origin` remote (a github.com host selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure Pipelines) and falls back to GitHub when the remote names none; `--provider` overrides the inference, and CircleCI is never inferred.", - "GitHub: writes its own workflow at `.github/workflows/leji.yml`.", - "GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if it's absent.", - "CircleCI: writes `.circleci/config.yml` if absent; when a config already exists, prints a snippet to add by hand instead of editing it.", - "Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not auto-discover it, so activation is manual: create a pipeline that points at the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), then add a build-validation branch policy on `main` for pull-request checks. This activation note prints once, on first creation.", - "Local hook (`--hooks`): writes a managed pre-commit running `leji validate` and `leji index --check`. `core.hooksPath` is detected, so a husky repo gets a managed block merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an existing unmanaged hook is never touched, its snippet printed to add by hand.", - "Local-first: when the repository declares `@leji-org/leji` in its package.json, the generated CI job runs that lockfile-pinned install (`npm ci`, then `npx --no-install @leji-org/leji` for `validate` and `index --check`); a repository without it falls back to `npx @leji-org/leji@1`. The generated hook independently prefers a repo-local `node_modules/.bin/leji` when present, else the `leji` on PATH (it does not run `npm ci` or read the dependency declaration)." - ], - "options": [ - { - "flags": "--hooks", - "summary": "Write a managed local pre-commit running validate + index --check; core.hooksPath is detected, so a husky repo gets a managed block in .husky/pre-commit, and an existing unmanaged hook is left untouched with the snippet printed." + "flags": "--out ", + "summary": "Output directory for the export (default: .leji/dist; must resolve inside the repository, and never inside .leji/ except exactly .leji/dist)." }, { - "flags": "--provider ", - "summary": "CI provider: github (default when no remote is recognizable), gitlab, circleci, or azure. Without this flag the provider is inferred from the origin remote." + "flags": "--strict", + "summary": "Fail the export on any lint finding and write nothing, leaving an existing export untouched. Without it, lint findings are reported as warnings and the export is still written." } ], "examples": [ - "leji ci", - "leji ci --provider gitlab", - "leji ci --provider azure", - "leji ci --hooks" + "leji export", + "leji export --out site", + "leji export --strict --json" ] }, { - "name": "agent", - "summary": "Bind an additional named agent into an existing context layer.", - "usage": "leji agent --name [--host ] [--role ] [--root ] [--json]", - "description": "Adds a second (or third) agent to a context layer that already has a `leji.json`.", - "details": [ - "Writes a starter agent profile under the agent-profiles path and binds it in the manifest's agents map via an in-place edit that preserves the rest of the file.", - "Never writes an agent-host entrypoint file. The portable `AGENTS.md` pointer is written by `init` and `adopt` (unless `--no-agents`); single-vendor files like `CLAUDE.md` are only ever converted from an existing one by `adopt --wire-adapters`.", - "`--host` is optional: a host pins the profile to a specific external CLI; with none, it's a host-agnostic resident agent any host can run.", - "The role defaults to reviewer; pass `--role` for a different one.", - "Idempotent: an existing profile or binding is left untouched. Requires an existing context layer." - ], + "name": "viewer build", + "group": "viewer", + "aliasOf": "export", + "summary": "The viewer subsystem's name for `leji export`: write the static site.", + "usage": "leji viewer build [--out ] [--strict] [--root ] [--json]", + "description": "The same operation as `leji export`, under the viewer subsystem's own name beside `leji viewer serve`: one code path, identical output, identical exits. Both names are permanently supported; `leji export` is the name the documentation leads with. Run `leji export --help` for the full description.", "options": [ { - "flags": "--name ", - "summary": "Name for the agent; also its profile id and agents-map key (kebab-case)." - }, - { - "flags": "--host ", - "summary": "Optional. Pin the profile to a host (claude-code, codex, copilot, gemini, cursor, windsurf; aliases ok); omit for a host-agnostic resident agent." + "flags": "--out ", + "summary": "Output directory for the export (default: .leji/dist; must resolve inside the repository, and never inside .leji/ except exactly .leji/dist)." }, { - "flags": "--role ", - "summary": "Role the agent fills (default: reviewer)." + "flags": "--strict", + "summary": "Fail the export on any lint finding and write nothing, leaving an existing export untouched. Without it, lint findings are reported as warnings and the export is still written." } ], "examples": [ - "leji agent --name porter --role porter", - "leji agent --host codex --name reviewer", - "leji agent --host claude-code --name thought-partner --role advisor" + "leji viewer build", + "leji viewer build --out site" ] } ] 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 abaf3a2..585c59e 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 CSS color (e.g. \"#223F93\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." } } }, diff --git a/packages/sdk-go/internal/assets/templates/agents/core.md b/packages/sdk-go/internal/assets/templates/agents/core.md index 938600d..f60446f 100644 --- a/packages/sdk-go/internal/assets/templates/agents/core.md +++ b/packages/sdk-go/internal/assets/templates/agents/core.md @@ -24,4 +24,4 @@ The shared posture for all agents working in this repository. Role profiles inhe ## Escalation - +Ask the primary owner () whenever mustAskWhen applies; record durable rulings in decisions. diff --git a/packages/sdk-go/internal/assets/templates/boot-profile.md b/packages/sdk-go/internal/assets/templates/boot-profile.md index f790ceb..132d1b3 100644 --- a/packages/sdk-go/internal/assets/templates/boot-profile.md +++ b/packages/sdk-go/internal/assets/templates/boot-profile.md @@ -7,6 +7,10 @@ +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`: it checks that the Leji CLI resolves here, offers your agent's MCP registration and the pre-commit hook, and boots your agent from this profile. An agent already running from this profile has nothing to do here. + ## Loading Read before any task (keep this set small; it is paid on every task): @@ -57,3 +61,4 @@ When you change anything in this context layer: - Append an entry to `docs/context-changelog.json`: id, date, type, one-line summary, affected paths. - Decisions get a record in `docs/decisions/`; copy the shape of an existing one. - Regenerate `docs/context-index.json` when files are added, moved, or retitled. +- **A new file is categorized the moment it is created**: add it to the right category index, or deliberately leave it as an ungoverned reference and say so, in the same change set; the unindexed count (`leji status`) must be a choice, never a surprise. When the right category isn't obvious, ask the layer's owners instead of guessing. diff --git a/packages/sdk-go/internal/assets/templates/onboarding-brief.md b/packages/sdk-go/internal/assets/templates/onboarding-brief.md index 4bd34e6..e012a19 100644 --- a/packages/sdk-go/internal/assets/templates/onboarding-brief.md +++ b/packages/sdk-go/internal/assets/templates/onboarding-brief.md @@ -1,9 +1,9 @@ + It lives in the gitignored onboarding workspace (`.leji/work/`) at the repository root, + beside the generated viewer, so it is excluded from the index, the viewer, and the + changelog. --> # Onboarding brief for the agent @@ -99,7 +99,7 @@ The three file paths: 2. **Local path**: the owner drags a file into the terminal (which pastes its path) or types a path. Read the file in place; do not copy or move it. 3. **Drop folder**: if the owner says "open the drop folder," create - `/.leji/onboarding-inputs/`, run the safety checks in the next section, print its + `.leji/work/onboarding-inputs/`, run the safety checks in the next section, print its absolute path, and open it in the system file browser where supported. The owner copies files in and tells you when they are ready. @@ -114,18 +114,18 @@ Raw artifacts (emails, PDFs, bios, brand documents, writing samples) are **priva never content**. They must never enter the governed tree, the index, the changelog, or any commit. -**The transient workspace.** Everything artifact-related lives only under `/.leji/`: +**The transient workspace.** Everything artifact-related lives only under `.leji/work/`: -- `/.leji/onboarding-inputs/` for dropped or copied raw artifacts, -- `/.leji/onboarding-work/` for temporary extraction scratch, if needed, -- `/.leji/onboarding-sources.json`, a private source ledger you maintain: for each +- `.leji/work/onboarding-inputs/` for dropped or copied raw artifacts, +- `.leji/work/onboarding-work/` for temporary extraction scratch, if needed, +- `.leji/work/onboarding-sources.json`, a private source ledger you maintain: for each artifact record a short display name, kind, where it came from (attachment, external file, drop folder), and which sections it informed. No file contents in the ledger. **Before accepting any artifact**, verify the boundary is intact: -- Confirm `.leji/` is ignored (`git check-ignore /.leji` succeeds). -- Confirm nothing under `/.leji/` is tracked (`git ls-files /.leji` is empty). If +- Confirm `.leji/` is ignored (`git check-ignore .leji` succeeds). +- Confirm nothing under `.leji/` is tracked (`git ls-files .leji` is empty). If anything is tracked, stop artifact intake and tell the owner exactly what is tracked; do not run `git rm --cached` yourself. @@ -239,7 +239,7 @@ human-readable terms (for example "owner-provided 2025 brand guide"), nothing mo markers and `status: proposed` decisions as owner confirmations pending, and `leji status` reports what is still unindexed, dangling, or stale. 7. **Write the proposal, print it, then ask.** Phase 1 ends with the STOP section's two - steps, in order: the whole proposal written to `/.leji/proposal.md` and printed as + steps, in order: the whole proposal written to `.leji/work/proposal.md` and printed as plain text in your reply, then the approval prompt directly after it. Going from tool calls straight into the question tool without the printed summary is a protocol violation, not a shortcut: the owner must be able to read the full proposal without stepping through the @@ -285,7 +285,7 @@ samples is a proposal until confirmed. Do not relabel aspiration as voice to byp Two steps, strictly ordered, after every draft is written and sanity-checked: 1. **Write and print the confirmation summary.** Write the whole proposal to - `/.leji/proposal.md`, first line exactly `# Proposal for approval`, covering the + `.leji/work/proposal.md`, first line exactly `# Proposal for approval`, covering the load-bearing claims below, then print that same content as plain, readable text in your reply. The printed message comes IMMEDIATELY before the approval prompt: no tool calls, file edits, or checks in between. Never point at earlier tool output or file diffs as the @@ -314,14 +314,14 @@ Ask only what you could not verify. A few sharp questions beat a long interview. ## Phase 2: finalize (only after the owner confirms) - Adjust the index files to the owner's calls: promote, downgrade to reference, or recategorize. -- Remove the onboarding guard if installed: delete `/.leji/hooks/`, the +- Remove the onboarding guard if installed: delete `.leji/work/hooks/`, the `AskUserQuestion` PreToolUse entry it added to `.claude/settings.json`, and the - `/.leji/proposal.md` artifact (all transient onboarding machinery, never part of + `.leji/work/proposal.md` artifact (all transient onboarding machinery, never part of the layer). - Replace each `TODO(confirm-…)` with the confirmed wording (or correct it to what the owner said). - Flip each confirmed `status: proposed` decision to `status: accepted`. - Leave any genuinely-unknown plain `TODO:` in place and call it out. -- **Leak check** before anything else: nothing under `/.leji/` is tracked; no raw input +- **Leak check** before anything else: nothing under `.leji/` is tracked; no raw input filenames, hashes, or absolute private paths appear in governed documents; no email headers or raw excerpts survive in the proposed content. - Run `leji index` to regenerate the index, `leji status` to confirm nothing governed is left @@ -332,11 +332,11 @@ Ask only what you could not verify. A few sharp questions beat a long interview. locally on 127.0.0.1, and opens it in the browser. Offer to run it (or hand them the command); seeing the layer is what closes the loop for the humans who will rely on it. - As your last step, once everything above passes, delete the transient onboarding files: - this brief (`/.leji/onboarding-brief.md`), `/.leji/onboarding-inputs/`, - `/.leji/onboarding-work/`, and `/.leji/onboarding-sources.json`. They are + this brief (`.leji/work/onboarding-brief.md`), `.leji/work/onboarding-inputs/`, + `.leji/work/onboarding-work/`, and `.leji/work/onboarding-sources.json`. They are scaffolding and private evidence, not context. Never delete or modify the owner's external - originals. Leave the rest of `/.leji/` in place (it holds the generated viewer and is - gitignored). + originals. Leave the rest of `.leji/` in place (it holds the generated viewer and the + federation cache, and is gitignored). In your final report, **quote the owner's confirmation** of the classification, invariants, gates, and (in solo mode) the identity and writing-style synthesis. The tool cannot prove a @@ -345,7 +345,7 @@ conversation happened; your report and the repository's review gate are the reco ## Boundaries Only create or edit files Leji owns under the context root, plus the transient workspace named -above (`/.leji/onboarding-inputs/`, `onboarding-work/`, `onboarding-sources.json`), +above (`.leji/work/onboarding-inputs/`, `onboarding-work/`, `onboarding-sources.json`), which you create and delete as described. Treat existing `CLAUDE.md`, `AGENTS.md`, `.cursor/rules`, `.github/copilot-instructions.md` and similar as **read-only inputs to learn from**; never rewrite them, and never wire a vendor redirect without showing the owner the diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/PROVENANCE.txt b/packages/sdk-go/internal/assets/templates/viewer/assets/PROVENANCE.txt index 0852c68..eb98b5d 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/assets/PROVENANCE.txt +++ b/packages/sdk-go/internal/assets/templates/viewer/assets/PROVENANCE.txt @@ -33,4 +33,11 @@ mermaid.min.js, docsify-mermaid.js leji-logo.svg The default viewer logo (the Leji mark). Overridden per-layer by viewer.logo. +third-party-licenses.txt + The consolidated notice for everything vendored here: a component list (name, + version, copyright, license) followed by each license text once. Unlike this + note it DOES ship, into every generated viewer/ and every exported dist/, so + the redistributed components carry their notices. Update it whenever an asset + is added, removed, or bumped. + This note is documentation only; the SDKs never copy it into a user's output. diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/fonts-licenses.txt b/packages/sdk-go/internal/assets/templates/viewer/assets/fonts-licenses.txt deleted file mode 100644 index a46662c..0000000 --- a/packages/sdk-go/internal/assets/templates/viewer/assets/fonts-licenses.txt +++ /dev/null @@ -1,15 +0,0 @@ -Vendored webfont licenses - -Source Sans Pro (source-sans-pro-*.woff2) - Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), - with Reserved Font Name 'Source'. All Rights Reserved. Source is a - trademark of Adobe Systems Incorporated in the United States and/or - other countries. - Licensed under the SIL Open Font License, Version 1.1. - https://openfontlicense.org - -Roboto Mono (roboto-mono-*.woff2) - Copyright 2015 The Roboto Mono Project Authors - (https://github.com/googlefonts/robotomono) - Licensed under the Apache License, Version 2.0. - http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/leji-logo.svg b/packages/sdk-go/internal/assets/templates/viewer/assets/leji-logo.svg index 490b6b6..33944b0 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/assets/leji-logo.svg +++ b/packages/sdk-go/internal/assets/templates/viewer/assets/leji-logo.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/third-party-licenses.txt b/packages/sdk-go/internal/assets/templates/viewer/assets/third-party-licenses.txt new file mode 100644 index 0000000..7bd07c2 --- /dev/null +++ b/packages/sdk-go/internal/assets/templates/viewer/assets/third-party-licenses.txt @@ -0,0 +1,408 @@ +Third-party notices for the Leji viewer +====================================== + +This file travels with the generated viewer chrome and with every static site +`leji export` writes: it names each third-party component bundled beside it and +carries the full text of every license those components are used under, once +each, after the component list. Components are redistributed unmodified except +where a note says otherwise. The Leji mark (leji-logo.svg) is not third-party +material; Leji's own license is LICENSE.md in the Leji repository. + +Where a component's upstream version is not recorded in the Leji repository, +this file says so rather than guessing. + + +Components +---------- + +docsify + Version: 4.13.1 + License: MIT + Copyright (c) 2016 - present Docsify Contributors + Files: docsify.min.js; vue.css (the vendored theme, with a Leji brand block + appended); search.min.js (full-text search plugin); zoom-image.min.js + (image-zoom plugin). + +docsify-sidebar-collapse + Version: not recorded + License: MIT + Copyright (c) 2018 iPeng6 + Files: docsify-sidebar-collapse.min.js, docsify-sidebar-collapse.min.css. + +docsify-copy-code + Version: 2.1.1 + License: MIT + Copyright (c) 2017-2020 JP Erasmus + Files: docsify-copy-code.min.js. + +docsify-mermaid + Version: 2.0.1 + License: ISC + Copyright (c) Paul-Julien Vauthier + Files: docsify-mermaid.js. + +Mermaid + Version: 11.14.0 + License: MIT + Copyright (c) 2014 - 2022 Knut Sveidqvist + Files: mermaid.min.js. Bundled only while the layer leaves viewer.mermaid + enabled; disabling it ships neither this file nor the plugin above. + +Prism + Version: not recorded + License: MIT + Copyright (c) 2012 Lea Verou + Files: prism-bash.min.js, prism-json.min.js, prism-markdown.min.js, + prism-typescript.min.js (language components extending the Prism core + that docsify.min.js bundles). + +Source Sans Pro + Version: not recorded + License: SIL Open Font License 1.1 + Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), + with Reserved Font Name 'Source'. All Rights Reserved. Source is a + trademark of Adobe Systems Incorporated in the United States and/or + other countries. + Files: source-sans-pro-*.woff2. + +Roboto Mono + Version: not recorded + License: Apache License 2.0 + Copyright 2015 The Roboto Mono Project Authors + (https://github.com/googlefonts/robotomono) + Files: roboto-mono-*.woff2. + + +MIT License (docsify, docsify-sidebar-collapse, docsify-copy-code, Mermaid, Prism) +================================================================================== + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +ISC License (docsify-mermaid) +============================= + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + +SIL Open Font License 1.1 (Source Sans Pro) +=========================================== + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +Apache License 2.0 (Roboto Mono) +================================ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js b/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js index adada39..aecd89a 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js +++ b/packages/sdk-go/internal/assets/templates/viewer/assets/viewer-boot.js @@ -3,30 +3,117 @@ // Kept as a vendored file (not inline) so the page can run under a strict // Content-Security-Policy (script-src 'self'), which blocks any script injected // through served Markdown content. Written alongside the page by `leji viewer`. -// Pick a readable mermaid node-text color for the layer's accent: dark text on a -// light accent, white on a dark one. Parses #rgb or #rrggbb (case-insensitive); -// an unparseable value keeps the dark default. +// Fallback mermaid node-text color for the layer's accent. The SDK computes this +// server-side and ships it in the config block (lejiMermaidTextColor), over every +// color form the manifest accepts; this covers only a viewer tree generated before +// that field existed, so it parses #rgb and #rrggbb and nothing else. WCAG relative +// luminance over linearized sRGB: whichever of #1a1a1a and #ffffff contrasts more +// with the accent, or #000000 when neither clears 4.5:1 (a mid-gray accent, where +// the extra half-stop of black is the best text color available). An unparseable +// value keeps the dark default. function lejiMermaidTextColor(accent) { var hex = String(accent || '').replace(/^#/, ''); if (hex.length === 3) { hex = hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2); } if (!/^[0-9a-fA-F]{6}$/.test(hex)) return '#1a1a1a'; - var r = parseInt(hex.slice(0, 2), 16); - var g = parseInt(hex.slice(2, 4), 16); - var b = parseInt(hex.slice(4, 6), 16); - var brightness = (299 * r + 587 * g + 114 * b) / 1000; - return brightness >= 150 ? '#1a1a1a' : '#ffffff'; + var luminance = function (h) { + var channel = function (i) { + var c = parseInt(h.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4); + }; + var ratio = function (a, b) { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }; + var accentLuminance = luminance(hex); + var onDark = ratio(luminance('1a1a1a'), accentLuminance); + var onLight = ratio(luminance('ffffff'), accentLuminance); + if (onDark < 4.5 && onLight < 4.5) return '#000000'; + return onDark >= onLight ? '#1a1a1a' : '#ffffff'; } -window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify-config').textContent), { +// Resolve a raw-HTML `` against the document that carries it, exactly as +// Docsify's relativePath routing already resolves the markdown image form. Returns +// the path under `contentBase` (query and fragment preserved) or null for a src that must be +// left as authored: empty, fragment- or query-only, root-relative, backslash-led, +// protocol-relative, any scheme reference, and any traversal escaping /content/ — +// traversal is rejected rather than clamped, because the server canonicalizes and a +// clamped path would quietly address the viewer chrome instead of the layer. +// Containment is judged on the decoded, normalized path, not the literal one, +// because the server canonicalizes percent-encoding and separators before it +// routes — an encoded `..` reads as traversal there even though URL keeps it. +// The value is first put through URL parsing's own input preprocessing — leading +// and trailing C0-control-and-space characters trimmed, then ASCII tab, LF, and +// CR removed anywhere in the value — so classification sees exactly what the +// parser sees; otherwise a padded or tab-split scheme reference slips past the +// first-character and scheme checks and gets rewritten. +function lejiResolveImgSrc(src, docDir, contentBase) { + var origin = 'http://leji.invalid'; + var raw = String(src || '') + .replace(/^[\x00-\x20]+/, '') + .replace(/[\x00-\x20]+$/, '') + .replace(/[\t\n\r]/g, ''); + if (raw === '') return null; + var first = raw.charAt(0); + if (first === '#' || first === '?' || first === '/' || first === '\\') return null; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw)) return null; + var url; + try { + url = new URL(raw, origin + '/content/' + (docDir ? docDir + '/' : '')); + } catch (e) { + return null; + } + if (url.origin !== origin) return null; + if (url.pathname.indexOf('/content/') !== 0) return null; + var decoded; + try { + decoded = decodeURIComponent(url.pathname); + } catch (e) { + return null; + } + var parts = decoded.replace(/\\/g, '/').split('/'); + var kept = []; + for (var i = 0; i < parts.length; i++) { + if (parts[i] === '' || parts[i] === '.') continue; + if (parts[i] === '..') kept.pop(); + else kept.push(parts[i]); + } + if (('/' + kept.join('/')).indexOf('/content/') !== 0) return null; + // Re-based onto the content mount as this page addresses it: '/content/…' when + // served locally, 'content/…' in an export, which the browser then resolves + // against the page so a subpath-hosted tree still finds the file. + return contentBase + url.pathname.slice('/content/'.length) + url.search + url.hash; +} + +var lejiConfig = JSON.parse(document.getElementById('leji-docsify-config').textContent); +// Where this page addresses the layer's markdown, from the SDK's config block: +// '/content/' for the local server, 'content/' for an export. Everything the page +// fetches for itself is derived from it, so one generated value moves the whole +// chrome between the app root and a relative base. Older viewer trees carry no +// basePath in their config; they were server-flavored, so the app root is the +// correct fallback. +var lejiContentBase = typeof lejiConfig.basePath === 'string' ? lejiConfig.basePath : '/content/'; + +window.$docsify = Object.assign(lejiConfig, { // The viewer chrome lives at the web root; the layer's markdown is mounted under - // /content/. basePath points Docsify at the content mount; the alias maps every - // nested `_sidebar.md` lookup to the single generated sidebar (so nested routes do - // not 404), which basePath then resolves to /content/_sidebar.md. - basePath: '/content/', + // the content base above. basePath points Docsify at the content mount; the alias + // maps every nested `_sidebar.md` lookup to the single generated sidebar (so + // nested routes do not 404), which basePath then resolves to _sidebar.md. + basePath: lejiContentBase, loadSidebar: '_sidebar.md', alias: { '/.*/_sidebar.md': '_sidebar.md' }, + // Markdown links resolve against the document that carries them, matching how + // the same files read on disk and on any git host. Generated sidebar links are + // emitted app-root absolute (leading slash) so they are unaffected. Without + // this, a `../`-style link on a nested page escapes the router entirely. + relativePath: true, + // A missing document renders Docsify's in-app not-found message; the vendored + // runtime's default (true) would issue a second, always-failing fetch for a + // `_404.md` no layer ships. The primary missing-document 404 is inherent to + // static serving. + notFoundPage: false, subMaxLevel: 3, auto2top: true, // Docsify's script execution runs a `new Function(...)` over a rendered page's @@ -50,6 +137,26 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); }); }, + function resolveImageSrc(hook, vm) { + // relativePath resolves markdown images; a raw-HTML + // passes through untouched and the browser resolves it against the page URL, + // so on a nested page it 404s. Rewrite at render rather than on the way out: + // a document's served bytes are the file's, verbatim. afterEach runs before + // the compiled HTML is inserted, so the unresolved URL is never requested. + hook.afterEach(function (html, next) { + var rel = vm.route && vm.route.file ? vm.route.file : ''; + var cut = rel.lastIndexOf('/'); + var docDir = cut === -1 ? '' : rel.slice(0, cut); + // Parsed in a detached container, never regexed over the HTML string. + var container = document.createElement('div'); + container.innerHTML = html; + container.querySelectorAll('img[src]').forEach(function (img) { + var resolved = lejiResolveImgSrc(img.getAttribute('src'), docDir, lejiContentBase); + if (resolved !== null) img.setAttribute('src', resolved); + }); + next(container.innerHTML); + }); + }, function categoryBadge(hook, vm) { // Top-right classification chip: the category (emoji + label) every // governed page carries for agents, made visible to people. Records @@ -60,8 +167,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify if (!cfg.lejiIndexRel || !cfg.lejiCategories) return; hook.doneEach(function () { var rel = vm.route && vm.route.file ? vm.route.file : ''; - fetch('/content/' + cfg.lejiIndexRel, { cache: 'no-store' }) - .then(function (r) { return r.ok ? r.json() : null; }) + fetch(lejiContentBase + cfg.lejiIndexRel, { cache: 'no-store' }) + .then(function (r) { + return r.ok ? r.json() : null; + }) .then(function (idx) { var label = null; if (rel === cfg.lejiBootPath) { @@ -76,11 +185,17 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify prefix = path.slice(0, path.length - rel.length); break; } - if (path === rel) { prefix = ''; break; } + if (path === rel) { + prefix = ''; + break; + } } var entry = null; for (var j = 0; j < idx.entries.length; j++) { - if (idx.entries[j].path === (prefix === null ? rel : prefix + rel)) { entry = idx.entries[j]; break; } + if (idx.entries[j].path === (prefix === null ? rel : prefix + rel)) { + entry = idx.entries[j]; + break; + } } if (entry) { label = cfg.lejiCategories[entry.category] || entry.category; @@ -90,7 +205,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify } } var el = document.querySelector('.lj-cat'); - if (!label) { if (el) el.remove(); return; } + if (!label) { + if (el) el.remove(); + return; + } if (!el) { el = document.createElement('div'); el.className = 'lj-cat'; @@ -98,7 +216,9 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify } el.textContent = label; }) - .catch(function () { /* badge is best-effort chrome */ }); + .catch(function () { + /* badge is best-effort chrome */ + }); }); }, function sidebarLoadingState(hook) { @@ -131,7 +251,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify }, function brandMermaid(hook) { // Theme mermaid diagrams from the layer's accent color; runs at init so - // it lands after mermaid.min.js (loaded last) is present. + // it lands after mermaid.min.js (loaded last) is present. The node-text + // color is the SDK's, computed at generation time over every color form + // the manifest accepts; the local fallback covers only a viewer tree + // generated before that field shipped. hook.init(function () { if (!window.mermaid || !window.$docsify.themeColor) return; window.mermaid.initialize({ @@ -139,9 +262,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify theme: 'base', themeVariables: { primaryColor: window.$docsify.themeColor, - primaryTextColor: lejiMermaidTextColor(window.$docsify.themeColor), + primaryTextColor: + window.$docsify.lejiMermaidTextColor || lejiMermaidTextColor(window.$docsify.themeColor), lineColor: '#666', - tertiaryColor: '#f8f9fa', + tertiaryColor: '#f7f8f5', }, }); }); 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 6a86ff6..1fc6850 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css +++ b/packages/sdk-go/internal/assets/templates/viewer/assets/vue.css @@ -1,5 +1,5 @@ /* Vendored webfonts (self-contained; no CDN). Source Sans Pro: SIL OFL 1.1; - Roboto Mono: Apache-2.0. See fonts-licenses.txt. */ + Roboto Mono: Apache-2.0. See third-party-licenses.txt. */ @font-face { font-family: 'Roboto Mono'; font-style: normal; @@ -973,14 +973,16 @@ code .token { (see the @font-face rules at the top of this file). ========================================================================== */ :root { - --theme-color: #223f93; /* recolors every var(--theme-color) rule above */ - --leji-blue-deep: #162960; - --leji-blue: #223f93; - --leji-gold: #ffbd6e; - --leji-paper: #f8f9fa; + --theme-color: #009f71; /* recolors every var(--theme-color) rule above */ + --leji-brand: #009f71; /* the Leji mark green: brand moments, never small text */ + --leji-link: #007d59; /* the accessible green for links and small text */ + --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-line: #e0e0e0; + --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 */ color-scheme: light; } @@ -1019,7 +1021,7 @@ body { color: var(--theme-color); text-decoration: none; } -/* Active document: a plain blue text change, nothing else. */ +/* Active document: a plain accent-colored text change, nothing else. */ .sidebar ul li.active > a { color: var(--theme-color) !important; border-right: none; @@ -1098,7 +1100,7 @@ body { .search input:focus { outline: none; border-color: var(--theme-color); - box-shadow: 0 0 0 2px rgba(34, 63, 147, 0.12); + box-shadow: 0 0 0 2px rgba(0, 159, 113, 0.12); } .search .results-panel { background: var(--leji-paper); @@ -1137,14 +1139,14 @@ body { background-color: var(--leji-paper) !important; } .leji-powered a { - color: var(--leji-blue) !important; + color: var(--leji-link) !important; text-decoration: none; } .leji-powered a:hover { - color: #162960 !important; + color: var(--leji-deep) !important; } .leji-powered .spark { - color: var(--leji-gold); + color: var(--leji-brand); } .leji-powered strong { font-weight: 600; @@ -1154,18 +1156,22 @@ body { color: var(--theme-color); } /* brand-tinted inline code, replacing the stock orange. Scoped away from - pre > code so fenced blocks keep the stock panel and token colors. */ + 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); - background: rgba(34, 63, 147, 0.07); + background: var(--leji-code-bg); +} +/* The fenced-code panel takes the same ground, replacing the stock neutral gray. */ +.markdown-section pre { + background-color: var(--leji-code-bg); } .markdown-section pre > code { color: #525252; background: none; } .markdown-section blockquote { - border-left: 3px solid var(--leji-gold); + border-left: 3px solid var(--leji-accent); color: var(--leji-ink-soft); } diff --git a/packages/sdk-go/internal/assets/templates/viewer/index.html b/packages/sdk-go/internal/assets/templates/viewer/index.html index bdf7c6d..924ce01 100644 --- a/packages/sdk-go/internal/assets/templates/viewer/index.html +++ b/packages/sdk-go/internal/assets/templates/viewer/index.html @@ -31,9 +31,10 @@ diff --git a/packages/sdk-go/internal/cli/badge_test.go b/packages/sdk-go/internal/cli/badge_test.go new file mode 100644 index 0000000..54f8f01 --- /dev/null +++ b/packages/sdk-go/internal/cli/badge_test.go @@ -0,0 +1,66 @@ +package cli + +// `leji badge` is offline by construction, and the CLI surface is where that is +// enforced: the allow-list it rejects against is read from cli.json, so a +// destination parameter cannot reach the command without failing here, and the help +// bytes a person reads describe no network operation. + +import ( + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/schemas" +) + +func TestBadgeTakesNoDestinationFlagAndItsHelpNamesNoNetwork(t *testing.T) { + dir := t.TempDir() + for _, argv := range [][]string{ + {"badge", "--endpoint", "x"}, + {"badge", "--url", "https://example.invalid"}, + {"badge", "--host", "example.invalid"}, + {"badge", "--token", "secret"}, + {"badge", "--federation", "verify"}, + } { + code, _, _ := captureRun(t, append(append([]string{}, argv...), "--root", dir)) + if code != 2 { + t.Fatalf("%v must be a usage error, got %d", argv, code) + } + } + // The accept side of the same guarantee: the allow-list is exactly the globals + // plus `--out`. + spec, err := schemas.LoadCliSpec() + if err != nil { + t.Fatal(err) + } + var cmd *schemas.CliCommand + for i := range spec.Commands { + if spec.Commands[i].Name == "badge" { + cmd = &spec.Commands[i] + break + } + } + if cmd == nil { + t.Fatal("badge must be a documented command") + } + var allowed []string + for _, o := range append(append([]schemas.CliOption{}, spec.GlobalOptions...), cmd.Options...) { + allowed = append(allowed, flagTokens(o.Flags)...) + } + sort.Strings(allowed) + want := []string{"--help", "--json", "--out", "--root", "--version", "-h", "-v"} + if !sameTree(allowed, want) { + t.Fatalf("badge accepts %v, want %v", allowed, want) + } + help, ok := BuildCommandHelp("badge") + if !ok { + t.Fatal("badge must render help") + } + lower := strings.ToLower(help) + for _, word := range []string{"endpoint", "token", "upload", "api key", "s3://", "host", "url", + "server", "network", "browser", "publish", "remote"} { + if strings.Contains(lower, word) { + t.Fatalf("the badge help text carries %q", word) + } + } +} diff --git a/packages/sdk-go/internal/cli/cli.go b/packages/sdk-go/internal/cli/cli.go index 72778de..015d178 100644 --- a/packages/sdk-go/internal/cli/cli.go +++ b/packages/sdk-go/internal/cli/cli.go @@ -11,26 +11,32 @@ import ( "net/http" "os" "os/signal" + "regexp" "strconv" "strings" "time" "unicode/utf8" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/badge" "github.com/leji-org/leji/packages/sdk-go/internal/commands/changelog" "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" detectcmd "github.com/leji-org/leji/packages/sdk-go/internal/commands/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/export" "github.com/leji-org/leji/packages/sdk-go/internal/commands/freshness" "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" initcmd "github.com/leji-org/leji/packages/sdk-go/internal/commands/init" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/serve" "github.com/leji-org/leji/packages/sdk-go/internal/commands/status" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/updatepin" "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" "github.com/leji-org/leji/packages/sdk-go/internal/commands/viewer" "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" "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/git" "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/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/mounts" "github.com/leji-org/leji/packages/sdk-go/internal/schemas" @@ -51,6 +57,7 @@ type flags struct { hooks bool explain bool fetch bool + allowNonFF bool checkIntegr bool help bool version bool @@ -76,9 +83,16 @@ type flags struct { topics []string asOf string federation string + to string + hasTo bool hostArgs []string } +// fullOidRe is the schema's own pin shape: a full commit id, never an +// abbreviation and never a revision expression, so all three SDKs accept one +// spelling. +var fullOidRe = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) + // quoteTopic renders s the way Node's JSON.stringify(s) does. jsonenc covers the // ordinary escapes; a lone surrogate — the only reason this message is ever // printed — arrives as its three-byte WTF-8 encoding, which jsonenc would decode @@ -344,6 +358,22 @@ func parseFlags(argv []string) (flags, []string, string) { f.open = true case "--fetch": f.fetch = true + case "--allow-non-fast-forward": + f.allowNonFF = true + case "--to": + i++ + v, ok := "", i < len(argv) + if ok { + v = argv[i] + } + if v == "" || isFlagToken(v, ok) { + return f, rest, "--to requires a value" + } + if !fullOidRe.MatchString(v) { + return f, rest, "--to must be a full 40- or 64-character lowercase hex commit id" + } + f.to = v + f.hasTo = true case "--federation": i++ v, ok := "", i < len(argv) @@ -404,6 +434,166 @@ func parseFlags(argv []string) (flags, []string, string) { return f, rest, "" } +// printUnindexedNudge writes the `index` generate run's closing nudge. +// Byte-identical in all three SDKs and quiet at zero: a layer with nothing +// unindexed says nothing. +func printUnindexedNudge(count int) { + if count <= 0 { + return + } + fmt.Printf("%d file(s) unindexed: add to a category index or leave as reference deliberately\n", count) +} + +// runExport is 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 by +// construction — same default output, same JSON document, same exits. +// +// 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 { + load := manifest.LoadManifest(f.root) + out := "" + if f.hasOut { + out = f.out + } + // 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 load.Manifest == nil { + declared := out + if declared == "" { + declared = layout.DistRel + } + return reportExport(f, export.BuildResult{Out: declared, Findings: load.Findings}) + } + r, err := export.BuildViewer(f.root, load.Manifest, out, export.Options{Strict: f.strict}) + if err != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) + return 2 + } + return reportExport(f, r) +} + +// reportExport is the one export report, for every outcome the pipeline can reach. +func reportExport(f flags, r export.BuildResult) int { + sorted := findings.Sort(r.Findings) + if f.json { + // The canonical JSON document for this command, under either name. + o := newJSONObj() + o.set("command", "export") + o.set("ok", r.Wrote) + o.set("out", r.Out) + arr := make([]any, 0, len(sorted)) + for _, fnd := range sorted { + arr = append(arr, findingToMap(fnd)) + } + o.set("findings", arr) + o.set("warning", export.ProtectWarning) + var buf bytes.Buffer + o.encode(&buf, "", " ") + fmt.Println(buf.String()) + if r.Wrote { + return 0 + } + return 1 + } + if !r.Wrote { + s := findings.Summarize(sorted) + printFindings(sorted) + errWord := "errors" + if s.Errors == 1 { + errWord = "error" + } + warnWord := "warnings" + if s.Warnings == 1 { + warnWord = "warning" + } + strictNote := "" + if f.strict { + strictNote = "; strict, nothing written" + } + fmt.Printf("failed (%d %s, %d %s%s)\n", s.Errors, errWord, s.Warnings, warnWord, strictNote) + return 1 + } + // Human mode says where the export went and repeats the protect-your-context + // warning, which is the part a person must act on before hosting it. + fmt.Printf("Exported the static viewer to %s/\n", r.Out) + fmt.Printf("\n%s\n", export.ProtectWarning) + return 0 +} + +// nullEmpty maps a badge field's "" (no value) to JSON null. None of them has a +// legitimate empty value, so the empty string is unambiguously "none". +func nullEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +// reportBadge is the one `leji badge` report, for every outcome the command can +// reach. The JSON document is the shared emit() shape plus the badge's own fields, +// emitted under success and refusal alike so a consumer parses one document; the +// human channel says what was written and hands over the markdown line to paste. +// +// Exits: `0` the badge is written or already current, `1` a conformance error +// finding or nothing machine-verified in this run, `2` a `--out` usage error +// (rendered by the caller, with no level reported) or a refusal to overwrite a file +// that is not a badge of this contract. +func reportBadge(f flags, r badge.Result) int { + sorted := findings.Sort(r.Findings) + summary := findings.Summarize(sorted) + ok := summary.Errors == 0 + code := 1 + if ok { + code = 0 + } + if r.Refusal != "" { + code = 2 + } + if f.json { + extra := newExtra() + extra.set("out", nullEmpty(r.Out)) + extra.set("level", nullEmpty(r.Level)) + extra.set("claimedLevel", nullEmpty(r.ClaimedLevel)) + extra.set("verifiedLevel", nullEmpty(r.VerifiedLevel)) + extra.set("markdown", nullEmpty(r.Markdown)) + extra.set("action", nullEmpty(r.Action)) + fmt.Println(emitJSON("badge", ok, sorted, summary, extra)) + if r.Refusal != "" { + fmt.Fprintf(os.Stderr, "leji: %s\n", r.Refusal) + } + return code + } + if r.Refusal != "" { + fmt.Fprintf(os.Stderr, "leji: %s\n", r.Refusal) + return 2 + } + if !ok { + printFindings(sorted) + fmt.Println("Run leji conformance --explain.") + return 1 + } + verb := "Unchanged" + switch r.Action { + case badge.Wrote: + verb = "Wrote" + case badge.Overwrote: + verb = "Overwrote" + } + fmt.Printf("%s %s: %s\n", verb, r.Out, badge.Label(r.Level)) + // The badge states what this run verified, so a claim it did not reach is said + // out loud rather than quietly dropped. + if r.ClaimedLevel != "" && r.ClaimedLevel != r.VerifiedLevel { + fmt.Printf("Claimed %s; this offline run verified %s (leji conformance --federation=verify checks the claim).\n", + r.ClaimedLevel, r.VerifiedLevel) + } + fmt.Print("\nAdd it to your README (paths are relative to the repository root):\n\n") + fmt.Println(strings.TrimRight(r.Markdown, "\n")) + return 0 +} + // emit prints findings and returns the exit code (0 ok, 1 on errors). func emit(command string, fs []findings.Finding, asJSON bool, extra *orderedExtra) int { sorted := findings.Sort(fs) @@ -505,6 +695,14 @@ func findingToMap(f findings.Finding) *jsonObj { if f.HasPath { o.set("path", f.Path) } + // The rendering lint locates its findings; every other rule carries neither key, + // and the field is omitted rather than emitted as a zero (matching Node/Python). + if f.Line > 0 { + o.set("line", f.Line) + } + if f.Construct != "" { + o.set("construct", f.Construct) + } o.set("message", f.Message) return o } @@ -535,7 +733,7 @@ func emitJSON(command string, ok bool, fs []findings.Finding, summary findings.S // valueFlags drives per-command flag validation from cli.json: each command // accepts the globals plus its own flags; any other is a usage error, not // silently ignored. -var valueFlags = map[string]bool{"--root": true, "--dir": true, "--level": true, "--mode": true, "--name": true, "--port": true, "--agent": true, "--host": true, "--role": true, "--out": true, "--keep": true, "--before": true, "--provider": true, "--paths": true, "--categories": true, "--topics": true, "--as-of": true, "--federation": true} +var valueFlags = map[string]bool{"--root": true, "--dir": true, "--level": true, "--mode": true, "--name": true, "--port": true, "--agent": true, "--host": true, "--role": true, "--out": true, "--keep": true, "--before": true, "--provider": true, "--paths": true, "--categories": true, "--topics": true, "--as-of": true, "--federation": true, "--to": true} func flagTokens(s string) []string { var out []string @@ -688,7 +886,7 @@ func Run(argv []string) int { if twoWordCommands[command] && sub != "" { expected = 2 } - if command == "mounts" && sub == "locate" { + if command == "mounts" && (sub == "locate" || sub == "update-pin") { expected++ } // `view` has its own usage message for a stray subcommand, and it is the more @@ -706,7 +904,11 @@ func Run(argv []string) int { switch command { case "validate": - result := validate.ValidateLayer(f.root, f.content) + result, verr := validate.ValidateLayer(f.root, f.content) + if verr != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", verr.Error()) + return 2 + } if f.federation == "" { return emit("validate", result.Findings, f.json, nil) } @@ -759,7 +961,11 @@ func Run(argv []string) int { return emit("index", load.Findings, f.json, nil) } if f.check { - result := indexgen.CheckIndex(f.root, load.Manifest) + result, cerr := indexgen.CheckIndex(f.root, load.Manifest) + if cerr != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", cerr.Error()) + return 2 + } extra := newExtra() stale := true if result.Stale != nil { @@ -798,7 +1004,15 @@ func Run(argv []string) int { extra.set("changelog", seeded) } } - return emit("index", append(load.Findings, result.Findings...), f.json, extra) + code := emit("index", append(load.Findings, result.Findings...), f.json, extra) + // A generate run ends by naming what the layer governs but does not index. + // A nudge, never a gate: the exit code is emit's alone, and nothing is + // printed when the count is zero. Text output only; --json carries one + // document and nothing after it. + if !f.json { + printUnindexedNudge(len(status.UnindexedPaths(f.root, load.Manifest))) + } + return code case "changelog": if sub == "check" { load := manifest.LoadManifest(f.root) @@ -827,9 +1041,13 @@ func Run(argv []string) int { if load.Manifest == nil { return emit("changelog compact", load.Findings, f.json, nil) } - result := changelog.CompactChangelog(f.root, load.Manifest, changelog.CompactOptions{ + result, cerr := changelog.CompactChangelog(f.root, load.Manifest, changelog.CompactOptions{ Keep: f.keep, HasKeep: f.hasKeep, Before: f.before, HasBefore: f.hasBefore, }) + if cerr != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", cerr.Error()) + return 2 + } extra := newExtra() extra.set("changelog", result.Path) extra.set("folded", result.Folded) @@ -867,7 +1085,11 @@ func Run(argv []string) int { if load.Manifest == nil { return emit("status", load.Findings, f.json, nil) } - report := status.StatusReport(f.root, load.Manifest) + report, serr := status.StatusReport(f.root, load.Manifest) + if serr != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", serr.Error()) + return 2 + } flagged := len(report.Unindexed) + len(report.Dangling) + len(report.Stale) + len(report.Pending) exit := 0 if f.strict && flagged > 0 { @@ -1190,7 +1412,7 @@ func Run(argv []string) int { } detail := "" if item.Detail != "" { - detail = " — " + item.Detail + detail = ": " + item.Detail } fmt.Printf("%s [%s] %s%s\n", mark, item.Level, item.Description, detail) } @@ -1215,16 +1437,63 @@ func Run(argv []string) int { extra.set("items", checklistItems(result.Items)) } return emit("conformance", result.Findings, f.json, extra) + case "badge": + out := badge.DefaultOut + if f.hasOut { + out = f.out + } + result, berr := badge.Run(f.root, out) + if berr != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", berr.Error()) + return 2 + } + // A rejected `--out` is a usage error, in the CLI's usage-error form and ahead + // of every level the command could have reported. + if result.UsageError != "" { + fmt.Fprintf(os.Stderr, "leji: %s\n\n", result.UsageError) + fmt.Fprintln(os.Stderr, usage) + return 2 + } + return reportBadge(f, result) case "mounts": - if sub != "hydrate" && sub != "status" && sub != "locate" { - fmt.Fprint(os.Stderr, "leji: usage: leji mounts \n\n") + if sub != "hydrate" && sub != "status" && sub != "locate" && sub != "update-pin" { + fmt.Fprint(os.Stderr, "leji: usage: leji mounts \n\n") fmt.Fprintln(os.Stderr, usage) return 2 } + // Argument shape is settled before anything on disk is read: a usage error is + // never contingent on a manifest loading. + if sub == "update-pin" { + if len(rest) < 3 || rest[2] == "" { + fmt.Fprint(os.Stderr, "leji: usage: leji mounts update-pin [--to ]\n\n") + fmt.Fprintln(os.Stderr, usage) + return 2 + } + if f.allowNonFF && !f.hasTo { + fmt.Fprint(os.Stderr, "leji: --allow-non-fast-forward is valid only with an explicit --to \n\n") + fmt.Fprintln(os.Stderr, usage) + return 2 + } + } load := manifest.LoadManifest(f.root) if load.Manifest == nil { return emit("mounts "+sub, load.Findings, f.json, nil) } + if sub == "update-pin" { + r, uerr := updatepin.Run(f.root, load.Manifest, updatepin.Options{ + Name: rest[2], + To: f.to, + HasTo: f.hasTo, + AllowNonFastForward: f.allowNonFF, + Fetch: f.fetch, + DryRun: f.dryRun, + }) + if uerr != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", uerr.Error()) + return 2 + } + return reportUpdatePin(f, r) + } if sub == "hydrate" { r, err := mounts.HydrateMounts(f.root, load.Manifest, mounts.HydrateOptions{Fetch: f.fetch}) if err != nil { @@ -1361,6 +1630,8 @@ func Run(argv []string) int { printFindings(issues) } return 0 + case "export": + return runExport(f) 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. @@ -1376,38 +1647,7 @@ func Run(argv []string) int { return 2 } if command == "viewer" && sub == "build" { - load := manifest.LoadManifest(f.root) - if load.Manifest == nil { - return emit("viewer build", load.Findings, f.json, nil) - } - out := "" - if f.hasOut { - out = f.out - } - r, err := viewer.BuildViewer(f.root, load.Manifest, out) - if err != nil { - fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) - return 2 - } - for _, fnd := range r.Findings { - if fnd.Severity == findings.Error { - return emit("viewer build", r.Findings, f.json, nil) - } - } - if f.json { - o := newJSONObj() - o.set("command", "viewer build") - o.set("ok", true) - o.set("out", r.Out) - o.set("warning", viewer.ProtectWarning) - var buf bytes.Buffer - o.encode(&buf, "", " ") - fmt.Println(buf.String()) - } else { - fmt.Printf("Exported the static viewer to %s/\n", r.Out) - fmt.Printf("\n%s\n", viewer.ProtectWarning) - } - return 0 + return runExport(f) } wantServe := isAlias || sub == "serve" wantOpen := f.open || isAlias @@ -1437,11 +1677,7 @@ func Run(argv []string) int { } if !wantServe || code != 0 { if !f.json && code == 0 { - dir := fsx.StripSlash(load.Manifest.RootPath) - if dir == "" { - dir = "." - } - fmt.Printf("viewer ready (%d entries) → %s/.leji/viewer/ serve: leji view\n", result.Entries, dir) + fmt.Printf("viewer ready (%d entries) → %s/ serve: leji view\n", result.Entries, layout.ViewerRel) } return code } @@ -1450,7 +1686,7 @@ func Run(argv []string) int { if !f.json { logf = func(line string) { fmt.Println(line) } } - ln, srv, err := viewer.Serve(f.root, port, load.Manifest.RootPath, logf) + ln, srv, err := serve.Serve(f.root, port, load.Manifest.RootPath, logf) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -1469,7 +1705,7 @@ func Run(argv []string) int { } fmt.Printf("%s viewer → %s (Ctrl+C to stop)\n", title, url) if wantOpen { - viewer.OpenBrowser(url) + serve.OpenBrowser(url) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -1490,10 +1726,11 @@ func Run(argv []string) int { } case "detect": hosts := detectcmd.DetectLayer(f.root) + detectEco := ecosystem.Detect(f.root) if f.json { - fmt.Println(detectJSON(hosts)) + fmt.Println(detectJSON(hosts, detectEco)) } else { - fmt.Println(detectcmd.RenderDetect(hosts)) + fmt.Println(detectcmd.RenderDetect(hosts, detectEco)) } return 0 case "adopt": @@ -1510,7 +1747,13 @@ func Run(argv []string) int { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 } + // The repository's own dependency ecosystem, read once and reported by every + // output mode: the human block, the JSON document, and the offer. + adoptEco := ecosystem.Detect(result.Root) if result.DryRun { + if f.json { + return emitScaffold("adopt", result.Findings, nil, adoptEco, true) + } // A wire-only run scaffolds nothing, so "Adopting the existing // repository" misnames it: the layer is already there and the plan // beneath is entrypoint conversions. @@ -1521,15 +1764,29 @@ func Run(argv []string) int { } fmt.Println("\n" + writeplan.Render(result.Plan)) fmt.Println("\nNo files written (--dry-run). Re-run without --dry-run to apply.") + fmt.Println("\n" + ecosystem.RenderBlock(adoptEco)) return 0 } + if f.json { + return emitScaffold("adopt", result.Findings, result.Written, adoptEco, false) + } fmt.Printf("\nWrote %d files (context root: %s):\n", len(result.Written), result.DetectedRoot) for _, rel := range result.Written { fmt.Printf(" %s\n", rel) } indexFailed := reportScaffoldIndex(result.Findings) hio := initcmd.DefaultHandoffIO(os.Stdin, os.Stdout) - interactive := !f.yes && stdinIsTTY() + // --json is a single-document mode, so it is never interactive: nothing + // prompts, and no package manager can run under it. + interactive := !f.yes && !f.json && stdinIsTTY() + // A wire-only run scaffolds no layer, so it makes no declaration offer. + dependencyFailed := false + if !result.WiredOnly { + offer := initcmd.OfferDependency(initcmd.DependencyOfferOptions{ + Root: result.Root, Report: adoptEco, Interactive: interactive, + }, os.Stdout) + 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 { fmt.Fprintf(os.Stderr, "leji: %s\n", gerr.Error()) @@ -1543,7 +1800,12 @@ func Run(argv []string) int { if !launched { fmt.Println(initcmd.EnteringAdopted(result)) } - return indexFailed + // The layer is written either way; a consented add that failed means the + // durable setup this run promised was not reached, and the exit says so. + if indexFailed != 0 || dependencyFailed { + return 1 + } + return 0 case "init": dir := f.dir if f.dir == "." && f.root != "." { @@ -1560,18 +1822,29 @@ func Run(argv []string) int { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 } + initEco := ecosystem.Detect(result.Root) if result.DryRun { + if f.json { + return emitScaffold("init", result.Findings, nil, initEco, true) + } fmt.Println("\n" + writeplan.Render(result.Plan)) fmt.Println("\nNo files written (--dry-run). Re-run without --dry-run to create them.") + fmt.Println("\n" + ecosystem.RenderBlock(initEco)) return 0 } + if f.json { + return emitScaffold("init", result.Findings, result.Written, initEco, false) + } fmt.Printf("\nWrote %d files:\n", len(result.Written)) for _, rel := range result.Written { fmt.Printf(" %s\n", rel) } indexFailed := reportScaffoldIndex(result.Findings) hio := initcmd.DefaultHandoffIO(os.Stdin, os.Stdout) - interactive := !f.yes && stdinIsTTY() + interactive := !f.yes && !f.json && stdinIsTTY() + offer := initcmd.OfferDependency(initcmd.DependencyOfferOptions{ + 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 { fmt.Fprintf(os.Stderr, "leji: %s\n", gerr.Error()) @@ -1585,38 +1858,98 @@ func Run(argv []string) int { if !launched { fmt.Println(initcmd.EnteringTheLayer(result.Manifest, result.Mode)) } - return indexFailed + if indexFailed != 0 || initcmd.DependencyAddFailed(offer) { + return 1 + } + return 0 case "start": load := manifest.LoadManifest(f.root) if load.Manifest == nil { return emit("start", load.Findings, f.json, nil) } detected := detect.DetectHosts(detect.Options{Root: f.root}) - interactive := !f.yes && stdinIsTTY() + // The repository's own ecosystem, read once: the preflight probes the runner + // it names, and the JSON document reports it. + startEco := ecosystem.Detect(f.root) + // --json is a single-document mode, so it is never interactive: nothing + // prompts, nothing launches, and no repair can run under it. + interactive := !f.yes && !f.json && stdinIsTTY() + // The boot profile is checked first, before any report or prompt: a layer + // whose entrypoint is missing has nothing to enter. + if !initcmd.BootProfileReady(f.root, load.Manifest) { + if f.json { + o := newJSONObj() + o.set("command", "start") + o.set("ok", false) + o.set("ready", false) + o.set("error", "boot-missing") + o.set("checks", []any{}) + o.set("ecosystem", ecosystemJSON(startEco)) + var buf bytes.Buffer + o.encode(&buf, "", " ") + fmt.Println(buf.String()) + } else { + fmt.Fprintf(os.Stderr, "leji: boot profile %s is missing or invalid; run leji validate\n", load.Manifest.BootProfilePath) + } + return 1 + } hio := initcmd.DefaultHandoffIO(os.Stdin, os.Stdout) + // The host is resolved BEFORE the report, so the MCP rows answer for the host + // this run actually targets. An --agent naming no launchable host errors here, + // exactly as it did inside EnterLayer: a usage error. + host, err := initcmd.ResolveStartHost(detected, f.agent, interactive, hio, os.Stdout) + if err != nil { + fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) + return 2 + } + preflight := initcmd.RunPreflight(initcmd.PreflightOptions{ + Root: f.root, Manifest: load.Manifest, Host: host, Detected: detected, Report: startEco, + }, hio) + if f.json { + // Report only: the launch-selection arguments are accepted and have no + // effect, and a gap is reported rather than blocking (`ready` is the + // scriptable signal). + o := newJSONObj() + o.set("command", "start") + o.set("ok", true) + o.set("ready", preflight.Ready) + o.set("checks", preflightChecksJSON(preflight.Checks)) + o.set("ecosystem", ecosystemJSON(startEco)) + var buf bytes.Buffer + o.encode(&buf, "", " ") + fmt.Println(buf.String()) + return 0 + } + // The one place color is decided: a terminal question, asked at the boundary and + // injected, so the block itself never consults the process. + color := initcmd.ColorDecision(stdoutIsTTY(), os.LookupEnv) + fmt.Println("\n" + initcmd.RenderPreflight(preflight.Checks, color)) + initcmd.OfferPreflightFixes(initcmd.PreflightOfferOptions{ + Root: f.root, Host: host, Result: preflight, Runner: ecosystem.RunnerArgv(startEco), + Interactive: interactive, + }, hio, os.Stdout) outcome, err := initcmd.EnterLayer(initcmd.StartOptions{ Root: f.root, Manifest: load.Manifest, Detected: detected, Agent: f.agent, Interactive: interactive, - HostArgs: f.hostArgs, + HostArgs: f.hostArgs, Host: host, HostResolved: true, }, hio, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 } - if outcome == initcmd.StartBootMissing { - fmt.Fprintf(os.Stderr, "leji: boot profile %s is missing or invalid; run leji validate\n", load.Manifest.BootProfilePath) - return 1 - } if outcome == initcmd.StartFallback { fmt.Println(initcmd.EnteringViaBoot(load.Manifest, f.hostArgs)) } return 0 case "ci": + // One detection for the whole command: the hook and the CI job both run what + // a clean install of this repository provides. + ecoReport := ecosystem.Detect(f.root) if f.hooks { load := manifest.LoadManifest(f.root) if load.Manifest == nil { return emit("ci", load.Findings, f.json, nil) } - h, err := initcmd.EnsureLocalHook(f.root) + h, err := initcmd.EnsureLocalHook(f.root, ecosystem.RunnerArgv(ecoReport)) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -1631,6 +1964,7 @@ func Run(argv []string) int { o.set("reason", h.Reason) o.set("snippet", h.Snippet) } + o.set("ecosystem", ecosystemJSON(ecoReport)) var buf bytes.Buffer o.encode(&buf, "", " ") fmt.Println(buf.String()) @@ -1658,6 +1992,9 @@ func Run(argv []string) int { } fmt.Printf("%s %s (validate + index --check before every commit; per-clone, delete to opt out).\n", verb, h.Path) } + if !f.json { + fmt.Println(ecosystem.RenderLine(ecoReport)) + } return 0 } // No --provider: infer from the origin remote (a GitLab repo must @@ -1686,7 +2023,7 @@ func Run(argv []string) int { if load.Manifest == nil { return emit("ci", load.Findings, f.json, nil) } - r, err := initcmd.EnsureCiWorkflow(f.root, provider) + r, err := initcmd.EnsureCiWorkflow(f.root, provider, &ecoReport) if err != nil { fmt.Fprintf(os.Stderr, "leji: %s\n", err.Error()) return 2 @@ -1705,6 +2042,7 @@ func Run(argv []string) int { if r.Note != "" { o.set("note", r.Note) } + o.set("ecosystem", ecosystemJSON(ecoReport)) var buf bytes.Buffer o.encode(&buf, "", " ") fmt.Println(buf.String()) @@ -1717,11 +2055,14 @@ func Run(argv []string) int { case "unchanged": fmt.Printf("%s already present; nothing to do.\n", r.Path) case "manual": - fmt.Printf("%s already exists; not modifying it. Add this to your CircleCI config:\n\n%s\n", r.Path, r.Snippet) + // Not leji's file: it was written by hand, or a generated one was edited. + // Either way the edit is the opt-out, and it is honored. + fmt.Printf("%s already exists and was not generated by leji; not modifying it. Add this yourself:\n\n%s\n", r.Path, r.Snippet) } if r.Note != "" { fmt.Println(r.Note) } + fmt.Println(ecosystem.RenderLine(ecoReport)) } return 0 case "agent": @@ -1755,6 +2096,9 @@ func Run(argv []string) int { created.set("profile", r.ProfileCreated) created.set("manifest", r.ManifestChanged) o.set("created", created) + if r.Note != "" { + o.set("note", r.Note) + } var buf bytes.Buffer o.encode(&buf, "", " ") fmt.Println(buf.String()) @@ -1774,6 +2118,9 @@ func Run(argv []string) int { } else { lines = append(lines, fmt.Sprintf("agent %q already bound in leji.json; nothing to do.", r.Name)) } + if r.Note != "" { + lines = append(lines, r.Note) + } fmt.Println(strings.Join(lines, "\n")) } return 0 @@ -1784,9 +2131,63 @@ func Run(argv []string) int { } } +// emitScaffold is the one --json document init and adopt emit: a single object, +// like every other command's, carrying what the run wrote and the repository's +// dependency ecosystem. --json is non-interactive by construction, so nothing here +// can have prompted or run a package manager. +func emitScaffold(command string, fnds []findings.Finding, written []string, eco ecosystem.Report, dryRun bool) int { + sorted := findings.Sort(fnds) + summary := findings.Summarize(sorted) + ok := summary.Errors == 0 + root := newJSONObj() + root.set("command", command) + root.set("ok", ok) + findingsArr := make([]any, 0, len(sorted)) + for _, fnd := range sorted { + findingsArr = append(findingsArr, findingToMap(fnd)) + } + root.set("findings", findingsArr) + sum := newJSONObj() + sum.set("errors", summary.Errors) + sum.set("warnings", summary.Warnings) + root.set("summary", sum) + if dryRun { + root.set("dryRun", true) + } + root.set("written", stringsToAny(written)) + root.set("ecosystem", ecosystemJSON(eco)) + var buf bytes.Buffer + root.encode(&buf, "", " ") + fmt.Println(buf.String()) + if ok { + return 0 + } + return 1 +} + +// preflightChecksJSON renders the preflight rows with each check's keys in the +// fixed contract order, and a null fix where there is nothing to run. Four keys and +// no others: the row carries a render-only fix kind that the document never publishes. +func preflightChecksJSON(checks []initcmd.Check) []any { + arr := make([]any, 0, len(checks)) + for _, c := range checks { + o := newJSONObj() + o.set("id", c.ID) + o.set("status", c.Status) + o.set("detail", c.Detail) + if c.Fix == nil { + o.set("fix", nil) + } else { + o.set("fix", stringsToAny(c.Fix)) + } + arr = append(arr, o) + } + return arr +} + // detectJSON renders the detect result as {command, ok, hosts:[...]}, with each // host's keys in Node DetectedHost order and a null adapter for directory hosts. -func detectJSON(hosts []detect.DetectedHost) string { +func detectJSON(hosts []detect.DetectedHost, eco ecosystem.Report) string { root := newJSONObj() root.set("command", "detect") root.set("ok", true) @@ -1807,6 +2208,7 @@ func detectJSON(hosts []detect.DetectedHost) string { arr = append(arr, o) } root.set("hosts", arr) + root.set("ecosystem", ecosystemJSON(eco)) var buf bytes.Buffer root.encode(&buf, "", " ") return buf.String() @@ -1948,6 +2350,117 @@ func mountsLocateJSON(r mounts.LocateResult) string { return buf.String() } +// pinReportJSON renders one pinReport with its keys in Node insertion order +// (behind/ahead only when counted, reason only when degraded). Shared by +// `mounts status` and `mounts update-pin`, which report the same object. +func pinReportJSON(rep mounts.PinReport) *jsonObj { + po := newJSONObj() + po.set("state", rep.State) + if rep.Behind != nil { + po.set("behind", *rep.Behind) + } + if rep.Ahead != nil { + po.set("ahead", *rep.Ahead) + } + po.set("comparedRef", nullableStr(rep.ComparedRef)) + po.set("comparisonRepository", nullableStr(rep.ComparisonRepository)) + po.set("witnessProvenance", nullableStr(rep.WitnessProvenance)) + po.set("ancestryComplete", rep.AncestryComplete) + if rep.Reason != "" { + po.set("reason", rep.Reason) + } + po.set("observedAt", rep.ObservedAt) + return po +} + +// reportUpdatePin renders one `mounts update-pin` run. The comparison is shown +// first, then what the run did with it, then the follow-up act this command +// deliberately does not perform. Every string is Leji-authored: git's stderr never +// reaches output. +func reportUpdatePin(f flags, r updatepin.Result) int { + // An internal refusal after validation carries no document at all: there is no + // outcome to report, only the act this run would not perform. + if r.WriteError != "" { + fmt.Fprintf(os.Stderr, "leji: %s\n", r.WriteError) + return 2 + } + sorted := findings.Sort(r.Findings) + summary := findings.Summarize(sorted) + ok := summary.Errors == 0 + if f.json { + extra := newExtra() + mo := newJSONObj() + mo.set("name", r.Mount.Name) + mo.set("sourceIdentity", nullableStr(r.Mount.SourceIdentity)) + mo.set("trackingRef", nullableStr(r.Mount.TrackingRef)) + mo.set("from", nullableStr(r.Mount.From)) + mo.set("to", nullableStr(r.Mount.To)) + extra.set("mount", mo) + if r.PinReport == nil { + extra.set("pinReport", nil) + } else { + extra.set("pinReport", pinReportJSON(*r.PinReport)) + } + extra.set("action", r.Action) + extra.set("override", r.Override) + if r.Reason != "" { + extra.set("reason", r.Reason) + } + fmt.Println(emitJSON("mounts update-pin", ok, sorted, summary, extra)) + if ok { + return 0 + } + return 1 + } + rep := r.PinReport + if rep != nil && rep.State != "unknown" && r.Mount.To != nil && r.Mount.From != nil { + // Offline, the witness is the last one successfully observed — never a claim + // that the source was looked at during this run. + observed := " (last observed witness; run with --fetch to observe the source)" + if f.fetch { + observed = "" + } + fmt.Printf("%s @ %s → %s · pin: %s (behind %d, ahead %d) · via %s%s\n", + r.Mount.Name, updatepin.ShortOid(*r.Mount.From), updatepin.ShortOid(*r.Mount.To), + rep.State, *rep.Behind, *rep.Ahead, strOr(rep.ComparisonRepository, "null"), observed) + } + overridden := "" + if r.Override { + overridden = " (non-fast-forward, overridden)" + } + from12, to12 := "", "" + if r.Mount.From != nil { + from12 = updatepin.ShortOid(*r.Mount.From) + } + if r.Mount.To != nil { + to12 = updatepin.ShortOid(*r.Mount.To) + } + switch r.Action { + case updatepin.ActionUpdated: + fmt.Printf("Updated leji.json: %s pin %s → %s%s\n", r.Mount.Name, from12, to12, overridden) + // Moving the pin is one act; materializing the new projection is another. + hydrate := " --fetch" + if f.fetch { + hydrate = "" + } + fmt.Printf("Run leji mounts hydrate%s to hydrate the new pin.\n", hydrate) + case updatepin.ActionUnchanged: + fmt.Printf("Unchanged: %s pin %s is already the target\n", r.Mount.Name, from12) + 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] + if prose == "" { + prose = r.Reason + } + fmt.Printf("Refused: %s\n", prose) + } + if ok { + return 0 + } + return 1 +} + // mountsStatusJSON renders {command, mounts, findings} with each row's pinReport // keys in Node insertion order (behind/ahead only when counted, reason only when // degraded). @@ -1967,24 +2480,7 @@ func mountsStatusJSON(rows []mounts.StatusResult, fs []findings.Finding) string } else { ro.set("verified", *row.Verified) } - rep := row.PinReport - po := newJSONObj() - po.set("state", rep.State) - if rep.Behind != nil { - po.set("behind", *rep.Behind) - } - if rep.Ahead != nil { - po.set("ahead", *rep.Ahead) - } - po.set("comparedRef", nullableStr(rep.ComparedRef)) - po.set("comparisonRepository", nullableStr(rep.ComparisonRepository)) - po.set("witnessProvenance", nullableStr(rep.WitnessProvenance)) - po.set("ancestryComplete", rep.AncestryComplete) - if rep.Reason != "" { - po.set("reason", rep.Reason) - } - po.set("observedAt", rep.ObservedAt) - ro.set("pinReport", po) + ro.set("pinReport", pinReportJSON(row.PinReport)) arr = append(arr, ro) } root.set("mounts", arr) diff --git a/packages/sdk-go/internal/cli/cli_more_test.go b/packages/sdk-go/internal/cli/cli_more_test.go index 0ba60c7..808f74f 100644 --- a/packages/sdk-go/internal/cli/cli_more_test.go +++ b/packages/sdk-go/internal/cli/cli_more_test.go @@ -182,7 +182,7 @@ func TestCLIViewerServeHint(t *testing.T) { if !strings.Contains(out, "serve: leji view") { t.Fatalf("expected serve hint, got: %s", out) } - if !strings.Contains(out, "viewer ready (3 entries) → docs/.leji/viewer/") { + if !strings.Contains(out, "viewer ready (3 entries) → .leji/viewer/") { t.Fatalf("expected the terse viewer-ready line, got: %s", out) } } @@ -308,3 +308,49 @@ func TestCLIIndexJSONWrites(t *testing.T) { t.Fatalf("expected entries in json: %s", out) } } + +func TestCLIOperationalReadFailuresExitTwoOnTheGenericErrorPath(t *testing.T) { + // An operational read failure on an allowed, contained artifact is the filesystem + // failing rather than the boundary refusing: the reference throws it, the CLI + // prints `leji: ` and exits 2. Every command that reads an artifact it is + // about to act on reports it the same way — never as a finding, never as a + // silently degraded run. Mutation that reddens: swallow the error in + // LoadStoredIndex, CompactChangelog or clearableExport. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses permission bits; the read cannot be made to fail") + } + for _, c := range []struct { + name string + rel string + argv []string + }{ + {"index --check", "docs/context-index.json", []string{"index", "--check"}}, + {"index", "docs/context-index.json", []string{"index"}}, + {"status", "docs/context-index.json", []string{"status"}}, + {"validate", "docs/context-index.json", []string{"validate"}}, + {"conformance", "docs/context-index.json", []string{"conformance"}}, + {"changelog compact", "docs/context-changelog.json", []string{"changelog", "compact", "--keep", "1"}}, + } { + dir := copyExample(t) + abs := filepath.Join(dir, filepath.FromSlash(c.rel)) + if _, err := os.Stat(abs); err != nil { + t.Fatalf("%s: the example layer must carry %s: %v", c.name, c.rel, err) + } + if err := os.Chmod(abs, 0o000); err != nil { + t.Fatal(err) + } + if f, oerr := os.Open(abs); oerr == nil { + _ = f.Close() + _ = os.Chmod(abs, 0o644) + t.Skip("this platform ignores the mode; the read cannot be made to fail") + } + code, out, errs := captureRun(t, append(append([]string{}, c.argv...), "--root", dir)) + _ = os.Chmod(abs, 0o644) + if code != 2 { + t.Fatalf("%s: exit %d, want 2 (stdout %q, stderr %q)", c.name, code, out, errs) + } + if !strings.HasPrefix(errs, "leji: ") || !strings.Contains(errs, "permission denied") { + t.Fatalf("%s: stderr must be the generic error path, got %q", c.name, errs) + } + } +} diff --git a/packages/sdk-go/internal/cli/cli_relroot_test.go b/packages/sdk-go/internal/cli/cli_relroot_test.go new file mode 100644 index 0000000..656742f --- /dev/null +++ b/packages/sdk-go/internal/cli/cli_relroot_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +// chdirTo enters dir for the duration of the test, the way the CLI is actually +// invoked: from inside the layer, with no path handed in, so root stays the default +// ".". Tests in this package never run in parallel, so the process-wide cwd is safe. +func chdirTo(t *testing.T, dir string) { + t.Helper() + prev, 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(prev) }) +} + +// Regression: run from the layer's own directory, every path the viewer judges and +// writes arrives relative. filepath.EvalSymlinks hands a relative path back relative, +// so fsx.ResolvedPathUnder canonicalized `docs/overview.md` from the VOLUME root and +// returned `/docs/overview.md` — the check-before-act guard then approved that path (outside +// `.leji/`) and the seed wrote through it, failing with `mkdir /docs: read-only file +// system`. Node has no such mode: `realpathSync.native` absolutizes whatever it is +// handed. Every existing test passed absolute `t.TempDir()` paths, so none saw it; +// the shared parity harness, which invokes the CLI from the sandbox cwd, did. +func TestViewerRelativeRootFromLayerCwd(t *testing.T) { + dir := copyExample(t) // rootPath "docs/": the artifacts land outside the content root + chdirTo(t, dir) + + code, _, errs := captureRun(t, []string{"viewer"}) + if code != 0 { + t.Fatalf("viewer from the layer cwd exit %d, stderr %q", code, errs) + } + code, _, errs = captureRun(t, []string{"viewer", "build"}) + if code != 0 { + t.Fatalf("viewer build from the layer cwd exit %d, stderr %q", code, errs) + } + + // The generated trees belong to THIS layer, not to a path rebuilt from the volume + // root: assert them under dir, and assert the seeded content page landed under the + // context root rather than at `/docs/overview.md`. + for _, rel := range []string{ + filepath.Join(".leji", "viewer", "index.html"), + filepath.Join(".leji", "viewer", "_sidebar.md"), + filepath.Join(".leji", "dist", "index.html"), + filepath.Join("docs", "overview.md"), + } { + if _, err := os.Stat(filepath.Join(dir, rel)); err != nil { + t.Fatalf("expected %s under the layer: %v", rel, err) + } + } +} + +// The same relative invocation on the refusal path: `--out ../escape` must produce +// Node's containment message and exit, byte for byte, because the parity harness +// compares the two. Before the fix the command died on the resolver instead, with an +// unrelated message and no refusal at all. The message is now the write rule's own: +// a target resolving outside the repository is refused by the chokepoint before the +// `--out` collision checks, in the reference and here alike. +func TestViewerBuildRelativeOutRejectFromLayerCwd(t *testing.T) { + dir := copyExample(t) + chdirTo(t, dir) + + code, out, errs := captureRun(t, []string{"viewer", "build", "--out", "../escape"}) + if code != 2 { + t.Fatalf("--out ../escape exit %d, stderr %q", code, errs) + } + if out != "" { + t.Fatalf("--out ../escape wrote to stdout: %q", out) + } + want := `leji: refusing to build the viewer into "../escape": it resolves outside the ` + + `repository; every write stays inside the repository root, so copy the exported folder ` + + `to your host instead` + "\n" + if errs != want { + t.Fatalf("--out ../escape stderr\n got %q\nwant %q", errs, want) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "escape")); err == nil { + t.Fatal("the refused --out target was created") + } +} diff --git a/packages/sdk-go/internal/cli/cli_test.go b/packages/sdk-go/internal/cli/cli_test.go index b3efb5a..a22a1dd 100644 --- a/packages/sdk-go/internal/cli/cli_test.go +++ b/packages/sdk-go/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -11,6 +12,17 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/schemas" ) +// ciGolden reads one committed generated-CI golden: the byte oracle both this port +// and the reference are checked against. +func ciGolden(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), "fixtures", "ci-goldens", name)) + if err != nil { + t.Fatalf("ci golden %s: %v", name, err) + } + return string(data) +} + func repoRoot(t *testing.T) string { t.Helper() wd, _ := os.Getwd() @@ -293,6 +305,169 @@ func TestCLIIndexCheckJSONStale(t *testing.T) { } } +// unindexedLine is the generate run's closing nudge. Spec-pinned byte for byte +// and identical in all three SDKs, so it is asserted as an exact string, never a +// pattern; the zero case is asserted as absence. +func unindexedLine(n int) string { + return fmt.Sprintf("%d file(s) unindexed: add to a category index or leave as reference deliberately", n) +} + +// seedLayerAt scaffolds a core layer, the shape the unindexed nudge is measured on. +func seedLayerAt(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if code, _, errs := captureRun(t, []string{"init", "--yes", "--dir", dir, "--name", "demo-context"}); code != 0 { + t.Fatalf("init: %s", errs) + } + return dir +} + +func TestCLIIndexReportsUnindexedCount(t *testing.T) { + dir := seedLayerAt(t) + // Two markdown files under the governed root that no category index lists. + if err := os.MkdirAll(filepath.Join(dir, "docs", "notes"), 0o755); err != nil { + t.Fatal(err) + } + for _, rel := range []string{"docs/notes/loose.md", "docs/stray.md"} { + if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(rel)), []byte("# Loose\n"), 0o644); err != nil { + t.Fatal(err) + } + } + code, out, _ := captureRun(t, []string{"index", "--root", dir}) + // A nudge, never a gate: the count does not move the exit code. + if code != 0 { + t.Fatalf("expected exit 0, got %d", code) + } + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if last := lines[len(lines)-1]; last != unindexedLine(2) { + t.Fatalf("last line %q, want %q", last, unindexedLine(2)) + } +} + +func TestCLIIndexQuietWhenNothingUnindexed(t *testing.T) { + dir := seedLayerAt(t) + code, out, _ := captureRun(t, []string{"index", "--root", dir}) + if code != 0 { + t.Fatalf("expected exit 0, got %d", code) + } + if strings.Contains(out, "unindexed") { + t.Fatalf("expected no nudge at zero, got %q", out) + } + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + if last := lines[len(lines)-1]; !strings.HasPrefix(last, "ok (") { + t.Fatalf("last line %q, want the ok summary", last) + } +} + +func TestCLIIndexCheckIgnoresUnindexedCount(t *testing.T) { + dir := seedLayerAt(t) + if err := os.WriteFile(filepath.Join(dir, "docs", "stray.md"), []byte("# Stray\n"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errs := captureRun(t, []string{"index", "--root", dir}); code != 0 { + t.Fatalf("index: %s", errs) + } + code, out, _ := captureRun(t, []string{"index", "--check", "--root", dir}) + if code != 0 { + t.Fatalf("expected exit 0, got %d", code) + } + if strings.Contains(out, "unindexed") { + t.Fatalf("--check must stay silent, got %q", out) + } +} + +func TestCLIIndexJSONCarriesNoNudge(t *testing.T) { + dir := seedLayerAt(t) + if err := os.WriteFile(filepath.Join(dir, "docs", "stray.md"), []byte("# Stray\n"), 0o644); err != nil { + t.Fatal(err) + } + code, out, _ := captureRun(t, []string{"index", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("expected exit 0, got %d", code) + } + // One document and nothing after it: the payload must still parse whole. + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("stdout is not one JSON document: %v (%q)", err, out) + } + if payload["written"] != "docs/context-index.json" { + t.Fatalf("unexpected payload: %s", out) + } + // The nudge is text-mode only. The count is not part of the index run's + // contract, so no consumer may start reading it off this document — not at + // the top level, not tucked into summary or a later extra. + if hasKeyDeep(payload, "unindexed") { + t.Fatalf("--json must carry no unindexed field, got: %s", out) + } +} + +// hasKeyDeep reports whether key appears anywhere in the document, at any depth. +// Checking the whole tree rather than the top level alone is what makes the JSON +// assertion hold against a field added later inside summary or a future extra. +func hasKeyDeep(value any, key string) bool { + switch v := value.(type) { + case map[string]any: + if _, ok := v[key]; ok { + return true + } + for _, child := range v { + if hasKeyDeep(child, key) { + return true + } + } + case []any: + for _, child := range v { + if hasKeyDeep(child, key) { + return true + } + } + } + return false +} + +func TestCLIIndexNoNudgeWhenIndexWriteFails(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses permission bits") + } + dir := seedLayerAt(t) + if err := os.WriteFile(filepath.Join(dir, "docs", "stray.md"), []byte("# Stray\n"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, errs := captureRun(t, []string{"index", "--root", dir}); code != 0 { + t.Fatalf("index: %s", errs) + } + // The nudge would have something to say here: the count is nonzero, so the + // silence below is the operational failure's doing and not an empty set. + _, before, _ := captureRun(t, []string{"status", "--root", dir, "--json"}) + var status struct { + Unindexed []string `json:"unindexed"` + } + if err := json.Unmarshal([]byte(before), &status); err != nil { + t.Fatalf("status: %v (%q)", err, before) + } + if len(status.Unindexed) == 0 { + t.Fatal("expected a nonzero unindexed count before the failed write") + } + target := filepath.Join(dir, "docs", "context-index.json") + if err := os.Chmod(target, 0o444); err != nil { + t.Fatal(err) + } + defer os.Chmod(target, 0o644) // restore so the temp tree can be cleaned up + code, out, errs := captureRun(t, []string{"index", "--root", dir}) + // An operational failure surfaces its error and nothing else: generation + // never completed, so the layer has no count worth reporting. + if code != 2 { + t.Fatalf("a failed index write should exit 2, got %d", code) + } + if !strings.HasPrefix(errs, "leji: ") || !strings.Contains(errs, "context-index.json") || + !strings.Contains(strings.ToLower(errs), "permission denied") { + t.Fatalf("expected the write error on stderr, got %q", errs) + } + if strings.Contains(out, "unindexed") { + t.Fatalf("no nudge on a failed write, got %q", out) + } +} + func TestCLIValidateValidFixture(t *testing.T) { code, _, _ := captureRun(t, []string{"validate", "--root", fixture(t, "valid-minimal-core")}) if code != 0 { @@ -335,7 +510,7 @@ func TestCLIDocumentedCommandsAreKnown(t *testing.T) { if c.Name == "agent" { full = append(full, "--host", "codex", "--name", "reviewer") } - if c.Name == "mounts locate" { + if c.Name == "mounts locate" || c.Name == "mounts update-pin" { full = append(full, "some-mount") } code, _, errs := captureRun(t, full) @@ -582,12 +757,15 @@ func TestCLICiProviderCircleci(t *testing.T) { if err != nil { t.Fatalf("config not written: %v", err) } - if string(before) != initcmd.BuildCircleCiConfig(false) { + if string(before) != ciGolden(t, "circleci-node-fallback.yml") { t.Fatalf("created config not byte-exact:\n%s", before) } + // A file leji generated is leji's to keep current: the re-run recognizes its own + // bytes and reports unchanged rather than handing back a snippet for a file the + // user never wrote. code, out, _ = captureRun(t, []string{"ci", "--root", dir, "--provider", "circleci", "--json"}) if code != 0 { - t.Fatalf("ci circleci (manual) exit %d", code) + t.Fatalf("ci circleci (again) exit %d", code) } var j2 struct { Action string `json:"action"` @@ -597,15 +775,46 @@ func TestCLICiProviderCircleci(t *testing.T) { if err := json.Unmarshal([]byte(out), &j2); err != nil { t.Fatalf("not JSON: %v (%q)", err, out) } - if j2.Action != "manual" || j2.Created { - t.Fatalf("expected manual/created=false, got %+v", j2) - } - if j2.Snippet != initcmd.BuildCircleCiSnippet(false) { - t.Fatalf("manual snippet not byte-exact: %q", j2.Snippet) + if j2.Action != "unchanged" || j2.Created { + t.Fatalf("expected unchanged/created=false, got %+v", j2) } after, _ := os.ReadFile(cc) if string(after) != string(before) { - t.Fatalf("existing config should be left untouched") + t.Fatalf("idempotent byte-for-byte") + } + + // Someone else's config: never modified, and the snippet comes back to add by hand. + foreign := seededCiDir(t) + fcc := filepath.Join(foreign, ".circleci", "config.yml") + if err := os.MkdirAll(filepath.Dir(fcc), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fcc, []byte("version: 2.1\njobs:\n mine: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + code, out, _ = captureRun(t, []string{"ci", "--root", foreign, "--provider", "circleci", "--json"}) + if code != 0 { + t.Fatalf("ci circleci (foreign) exit %d", code) + } + var j3 struct { + Action string `json:"action"` + Snippet string `json:"snippet"` + } + if err := json.Unmarshal([]byte(out), &j3); err != nil { + t.Fatalf("not JSON: %v (%q)", err, out) + } + if j3.Action != "manual" { + t.Fatalf("a foreign config is manual, got %+v", j3) + } + // The hand-add snippet is the generated config without its two leading lines + // (the ownership marker and `version: 2.1`): it is pasted into a file leji does + // not own, so it claims nothing. + configLines := strings.Split(ciGolden(t, "circleci-node-fallback.yml"), "\n") + if want := strings.Join(configLines[2:], "\n"); j3.Snippet != want { + t.Fatalf("manual snippet not byte-exact:\n%q\nwant\n%q", j3.Snippet, want) + } + if body, _ := os.ReadFile(fcc); string(body) != "version: 2.1\njobs:\n mine: {}\n" { + t.Fatalf("foreign config left untouched") } } @@ -641,7 +850,7 @@ func TestCLICiProviderAzure(t *testing.T) { if err != nil { t.Fatalf("pipeline not written: %v", err) } - if string(got) != initcmd.BuildAzurePipeline(false) { + if string(got) != ciGolden(t, "azure-node-fallback.yml") { t.Fatalf("pipeline file not byte-exact:\n%s", got) } code, out, _ = captureRun(t, []string{"ci", "--root", d1, "--provider", "azure", "--json"}) @@ -802,3 +1011,46 @@ func TestCLICiWriteFailureCleansUp(t *testing.T) { t.Fatalf("temp file should be cleaned up") } } + +// Mirrors run.test.ts "agent: writing the default binding prints the +// selects-vs-loads guidance (human and JSON); other keys and re-runs do not". +func TestCLIAgentDefaultGuidance(t *testing.T) { + dir := seededCiDir(t) + // human output: the guidance follows the success lines, byte-exact + code, out, errs := captureRun(t, []string{"agent", "--name", "default", "--root", dir}) + if code != 0 { + t.Fatalf("agent default exit %d (%s%s)", code, out, errs) + } + if !strings.Contains(out, `Bound agent "default"`) { + t.Fatalf("expected the bound line, got %q", out) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if last := lines[len(lines)-1]; last != initcmd.AgentsDefaultNote { + t.Fatalf("last line is not the guidance: %q", last) + } + // written-only: a re-run binds nothing and stays terse, in both modes + code, out, _ = captureRun(t, []string{"agent", "--name", "default", "--root", dir}) + if code != 0 || strings.Contains(out, "selects a role profile") { + t.Fatalf("no guidance when nothing was bound: %d %q", code, out) + } + if _, j, _ := runJSON(t, []string{"agent", "--name", "default", "--json", "--root", dir}); j["note"] != nil { + t.Fatalf("re-run JSON should carry no note: %v", j["note"]) + } + // JSON mode carries the same sentence in `note` (the CI activation-note pattern) + code, j, errs := runJSON(t, []string{"agent", "--name", "default", "--json", "--root", seededCiDir(t)}) + if code != 0 { + t.Fatalf("agent default --json exit %d (%s)", code, errs) + } + if j["note"] != initcmd.AgentsDefaultNote { + t.Fatalf("JSON note not byte-exact: %v", j["note"]) + } + // any other binding stays quiet, in both modes + code, out, _ = captureRun(t, []string{"agent", "--name", "reviewer", "--root", dir}) + if code != 0 || strings.Contains(out, "selects a role profile") { + t.Fatalf("no guidance for a non-default key: %d %q", code, out) + } + code, j2, _ := runJSON(t, []string{"agent", "--name", "thought-partner", "--json", "--root", dir}) + if code != 0 || j2["note"] != nil { + t.Fatalf("non-default key JSON should carry no note: %d %v", code, j2["note"]) + } +} diff --git a/packages/sdk-go/internal/cli/ecosystemjson.go b/packages/sdk-go/internal/cli/ecosystemjson.go new file mode 100644 index 0000000..8fb8389 --- /dev/null +++ b/packages/sdk-go/internal/cli/ecosystemjson.go @@ -0,0 +1,75 @@ +package cli + +import ( + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" +) + +// ecosystemJSON renders a detection report as the insertion-ordered object the +// --json surface pins: the same key order as the TypeScript reference, empty +// arrays as arrays, and absent argv as null. +func ecosystemJSON(report ecosystem.Report) *jsonObj { + o := newJSONObj() + if report.Selected == nil { + o.set("selected", nil) + } else { + o.set("selected", ecoResultJSON(*report.Selected)) + } + all := make([]any, 0, len(report.All)) + for _, r := range report.All { + all = append(all, ecoResultJSON(r)) + } + o.set("all", all) + if report.Reason == nil { + o.set("reason", nil) + } else { + o.set("reason", *report.Reason) + } + return o +} + +func ecoResultJSON(r ecosystem.Result) *jsonObj { + o := newJSONObj() + o.set("ecosystem", r.Ecosystem) + o.set("status", r.Status) + o.set("manifest", strOrNil(r.Manifest)) + o.set("manager", strOrNil(r.Manager)) + o.set("source", strOrNil(r.Source)) + o.set("evidence", stringsToAny(r.Evidence)) + o.set("add", argvOrNil(r.Add)) + o.set("runner", argvOrNil(r.Runner)) + o.set("directDeclared", r.DirectDeclared) + o.set("lockEvidenced", r.LockEvidenced) + candidates := make([]any, 0, len(r.Candidates)) + for _, c := range r.Candidates { + co := newJSONObj() + co.set("manager", c.Manager) + co.set("add", argvOrNil(c.Add)) + candidates = append(candidates, co) + } + o.set("candidates", candidates) + return o +} + +func strOrNil(s *string) any { + if s == nil { + return nil + } + return *s +} + +// argvOrNil keeps the contract's distinction: an absent command is null, never an +// empty array. +func argvOrNil(argv []string) any { + if argv == nil { + return nil + } + return stringsToAny(argv) +} + +func stringsToAny(items []string) []any { + out := make([]any, 0, len(items)) + for _, s := range items { + out = append(out, s) + } + return out +} diff --git a/packages/sdk-go/internal/cli/export_test.go b/packages/sdk-go/internal/cli/export_test.go new file mode 100644 index 0000000..a633a8a --- /dev/null +++ b/packages/sdk-go/internal/cli/export_test.go @@ -0,0 +1,479 @@ +package cli + +// `leji export` and `leji viewer build`: one operation, two permanently supported +// names. What this file pins is the part of that operation the other suites cannot +// see: that the two names really are one code path, that no destination flag exists, +// that `--strict` is scoped to the lint class, and that a failed run leaves an +// existing export byte-untouched. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/schemas" +) + +// exampleLayerCopy is a scratch copy of the example layer. +func exampleLayerCopy(t *testing.T) string { + t.Helper() + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS(filepath.Join(repoRoot(t), "examples", "monorepo"))); err != nil { + t.Fatalf("copy example: %v", err) + } + return dst +} + +// fixtureCopy is a scratch copy of a shared fixture. +func fixtureCopy(t *testing.T, name string) string { + t.Helper() + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS(fixture(t, name))); err != nil { + t.Fatalf("copy fixture %s: %v", name, err) + } + return dst +} + +// treeSnapshot is every path under dir as `rel -> content digest` (directories as +// `rel/`), so a comparison covers appearance and disappearance as well as content. +func treeSnapshot(t *testing.T, dir string) []string { + t.Helper() + var out []string + if _, err := os.Stat(dir); err != nil { + return out + } + err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, rerr := filepath.Rel(dir, p) + if rerr != nil || rel == "." { + return rerr + } + rel = filepath.ToSlash(rel) + switch { + case d.IsDir(): + out = append(out, rel+"/\x00") + case d.Type().IsRegular(): + body, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + sum := sha256.Sum256(body) + out = append(out, rel+"\x00"+hex.EncodeToString(sum[:])) + default: + out = append(out, rel+"\x00non-regular") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(out) + return out +} + +func sameTree(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// jsonKeys is the top-level key order of a JSON object, as emitted. +func jsonKeys(t *testing.T, doc string) []string { + t.Helper() + dec := json.NewDecoder(strings.NewReader(doc)) + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + t.Fatalf("not a JSON object: %q", doc) + } + var keys []string + depth := 0 + for dec.More() || depth > 0 { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + switch v := tok.(type) { + case json.Delim: + if v == '{' || v == '[' { + depth++ + } else { + depth-- + } + case string: + if depth == 0 { + keys = append(keys, v) + // Skip this key's value. + var discard any + if err := dec.Decode(&discard); err != nil { + t.Fatal(err) + } + } + } + } + return keys +} + +func TestExportTakesNoDestinationFlagAndItsHelpNamesNoNetwork(t *testing.T) { + dir := exampleLayerCopy(t) + for _, argv := range [][]string{ + {"export", "--endpoint", "x"}, + {"export", "--url", "https://example.invalid"}, + {"export", "--host", "example.invalid"}, + {"export", "--token", "secret"}, + {"export", "--port", "8080"}, + {"viewer", "build", "--endpoint", "x"}, + } { + code, _, _ := captureRun(t, append(append([]string{}, argv...), "--root", dir)) + if code != 2 { + t.Fatalf("%v must be a usage error, got %d", argv, code) + } + } + // The accept side of the same guarantee, under BOTH names: the allow-list the + // rejection above consults is exactly the globals plus --out and --strict. Read + // from cli.json, which is what the CLI itself rejects against — so a destination + // flag cannot reach the surface without failing here. + spec, err := schemas.LoadCliSpec() + if err != nil { + t.Fatal(err) + } + want := []string{"--help", "--json", "--out", "--root", "--strict", "--version", "-h", "-v"} + for _, name := range []string{"export", "viewer build"} { + var cmd *schemas.CliCommand + for i := range spec.Commands { + if spec.Commands[i].Name == name { + cmd = &spec.Commands[i] + break + } + } + if cmd == nil { + t.Fatalf("%s must be a documented command", name) + } + var allowed []string + for _, o := range append(append([]schemas.CliOption{}, spec.GlobalOptions...), cmd.Options...) { + allowed = append(allowed, flagTokens(o.Flags)...) + } + sort.Strings(allowed) + if !sameTree(allowed, want) { + t.Fatalf("%s accepts %v, want %v", name, allowed, want) + } + // And the help bytes a person reads describe no network operation: this command + // writes files from files. The whole banned class against the real bytes, not a + // selected few of them. The flag surface itself is the cli.json assertion above, + // which holds whatever the help layout does; help only has to document it. + help, ok := BuildCommandHelp(name) + if !ok { + t.Fatalf("%s must render help", name) + } + for _, o := range cmd.Options { + if !strings.Contains(help, " "+o.Flags) { + t.Fatalf("%s help does not document %s", name, o.Flags) + } + } + if !strings.Contains(help, "\nGlobal options: see leji --help.\n") { + t.Fatalf("%s help does not point at the globals", name) + } + lower := strings.ToLower(help) + for _, word := range []string{"endpoint", "token", "upload", "api key", "s3://", "host", "url", + "server", "network", "browser", "publish", "remote"} { + if strings.Contains(lower, word) { + t.Fatalf("the %s help text carries %q", name, word) + } + } + } +} + +func TestExportAndViewerBuildWriteByteIdenticalTrees(t *testing.T) { + a := exampleLayerCopy(t) + b := exampleLayerCopy(t) + codeA, outA, _ := captureRun(t, []string{"export", "--root", a, "--json"}) + codeB, outB, _ := captureRun(t, []string{"viewer", "build", "--root", b, "--json"}) + if codeA != 0 || codeB != 0 { + t.Fatalf("exits %d/%d: %s%s", codeA, codeB, outA, outB) + } + // The same JSON document under both names, `command` included: the second name is + // the same operation, not a second command that resembles it. + var docA, docB map[string]any + if err := json.Unmarshal([]byte(outA), &docA); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(outB), &docB); err != nil { + t.Fatal(err) + } + if docA["command"] != "export" { + t.Fatalf("command = %v", docA["command"]) + } + if outA != outB { + t.Fatalf("the two names must emit the same document:\n%s\n%s", outA, outB) + } + if docA["out"] != filepath.Join(".leji", "dist") { + t.Fatalf("out = %v", docA["out"]) + } + if !sameTree(treeSnapshot(t, a), treeSnapshot(t, b)) { + t.Fatal("the two names must leave identical working trees") + } +} + +func TestExportEmitsItsCanonicalDocumentOnEveryPath(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "leji.json"), []byte("{ this is not a manifest\n"), 0o644); err != nil { + t.Fatal(err) + } + first, firstOut, _ := captureRun(t, []string{"export", "--root", dir, "--json"}) + second, secondOut, _ := captureRun(t, []string{"viewer", "build", "--root", dir, "--json"}) + if first != 1 || second != 1 { + t.Fatalf("exits %d/%d", first, second) + } + // One document shape for every outcome of this command: the pre-pipeline failure is + // NOT reported in the generic {command, ok, findings, summary} envelope. + keys := jsonKeys(t, firstOut) + if !sameTree(keys, []string{"command", "ok", "out", "findings", "warning"}) { + t.Fatalf("document keys = %v", keys) + } + var doc struct { + Command string `json:"command"` + OK bool `json:"ok"` + Out string `json:"out"` + Findings []struct { + Severity string `json:"severity"` + } `json:"findings"` + Warning string `json:"warning"` + } + if err := json.Unmarshal([]byte(firstOut), &doc); err != nil { + t.Fatal(err) + } + if doc.Command != "export" || doc.OK || doc.Out != ".leji/dist" { + t.Fatalf("document = %+v", doc) + } + errs := 0 + for _, f := range doc.Findings { + if f.Severity == "error" { + errs++ + } + } + if errs == 0 { + t.Fatalf("the unreadable manifest must be reported: %s", firstOut) + } + if !strings.HasPrefix(doc.Warning, "This is your context layer") { + t.Fatalf("warning = %q", doc.Warning) + } + if firstOut != secondOut { + t.Fatal("byte-identical under both names") + } + // A caller `--out` is reported as the caller wrote it, on the same shape. + code, out, _ := captureRun(t, []string{"export", "--root", dir, "--out", "site", "--json"}) + if code != 1 { + t.Fatalf("exit %d", code) + } + var withOut struct { + Out string `json:"out"` + } + if err := json.Unmarshal([]byte(out), &withOut); err != nil { + t.Fatal(err) + } + if withOut.Out != "site" { + t.Fatalf("out = %q", withOut.Out) + } +} + +func TestExportStrictIsScopedToTheLintClassAndLeavesTheTargetUntouched(t *testing.T) { + // A layer that reports a finding without failing generation: a viewer.homepage that + // resolves to nothing is a warning, exported anyway. + dir := fixtureCopy(t, "valid-unified-leji-fresh") + manifestPath := filepath.Join(dir, "leji.json") + raw, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var declared map[string]any + if err := json.Unmarshal(raw, &declared); err != nil { + t.Fatal(err) + } + declared["viewer"] = map[string]any{"homepage": "no-such-page.md"} + patched, err := json.MarshalIndent(declared, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, append(patched, '\n'), 0o644); err != nil { + t.Fatal(err) + } + + code, out, _ := captureRun(t, []string{"export", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("plain run exit %d: %s", code, out) + } + if !strings.Contains(out, "viewer-path-missing") { + t.Fatalf("the layer must report a finding: %s", out) + } + // `--strict` is scoped to the lint class, not to any finding: an ordinary viewer + // warning stays a warning, and the export is still written. + strictCode, strictOut, _ := captureRun(t, []string{"export", "--root", dir, "--strict", "--json"}) + if strictCode != 0 { + t.Fatalf("an ordinary warning must not be promoted by --strict: %s", strictOut) + } + if strictOut != out { + t.Fatalf("the same findings, and still written:\n%s\n%s", out, strictOut) + } + + distDir := filepath.Join(dir, ".leji", "dist") + before := treeSnapshot(t, distDir) + if len(before) == 0 { + t.Fatal("an export must exist to be protected") + } + + // An error finding fails the run through the same pre-clean gate: overview.md, + // seeded by the runs above, redirected into a private role. Generation reaches it + // after the chrome is written, so this run proves both halves of the pipeline + // promise at once — the internal chrome IS regenerated, the target is not touched. + if err := os.MkdirAll(filepath.Join(dir, ".leji", "mounts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".leji", "mounts", "stolen.md"), []byte("private\n"), 0o644); err != nil { + t.Fatal(err) + } + overview := filepath.Join(dir, "docs", "overview.md") + if _, err := os.Stat(overview); err != nil { + t.Fatal("the seeded overview page must be there to redirect") + } + if err := os.Remove(overview); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(dir, ".leji", "mounts", "stolen.md"), overview); err != nil { + t.Fatal(err) + } + viewerDir := filepath.Join(dir, ".leji", "viewer") + if err := os.RemoveAll(viewerDir); err != nil { + t.Fatal(err) + } + + failed, failedOut, _ := captureRun(t, []string{"export", "--root", dir, "--json"}) + if failed != 1 { + t.Fatalf("an error finding must fail the run: %s", failedOut) + } + if !strings.Contains(failedOut, `"ok": false`) || !strings.Contains(failedOut, `"severity": "error"`) { + t.Fatalf("the run must report an error finding: %s", failedOut) + } + if !sameTree(treeSnapshot(t, distDir), before) { + t.Fatal("the existing export must be byte-untouched") + } + if _, err := os.Stat(filepath.Join(viewerDir, "index.html")); err != nil { + t.Fatal("the internal chrome must be regenerated regardless") + } + if _, err := os.Stat(filepath.Join(viewerDir, "assets")); err != nil { + t.Fatal("the internal chrome must carry its assets") + } + + // The same holds under the other name, and for a target that does not exist yet. + if err := os.RemoveAll(distDir); err != nil { + t.Fatal(err) + } + other, _, _ := captureRun(t, []string{"viewer", "build", "--root", dir, "--strict"}) + if other != 1 { + t.Fatalf("the other name must answer the same: %d", other) + } + if _, err := os.Stat(distDir); err == nil { + t.Fatal("nothing must be written at all") + } +} + +func TestExportStrictGateIsDrivenByARealLintFinding(t *testing.T) { + dir := fixtureCopy(t, "valid-unified-leji-fresh") + // A real unsupported construct in one of the layer's own documents: the rendering + // lint reads the source the export carries, so the exit codes below are the gate's + // answer to a finding the shipped pipeline produced and not to a planted one. + doc := filepath.Join(dir, "docs", "domain", "overview.md") + body, err := os.ReadFile(doc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(doc, append(body, []byte("\nA raw element in the prose.\n")...), 0o644); err != nil { + t.Fatal(err) + } + + // Default run: the lint finding is reported and the export is written anyway — the + // layer's build never breaks on prose. + code, out, _ := captureRun(t, []string{"export", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("an ordinary run must export despite the lint finding: %s", out) + } + var plain struct { + OK bool `json:"ok"` + Findings []struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Path string `json:"path"` + Line int `json:"line"` + Construct string `json:"construct"` + } `json:"findings"` + } + if err := json.Unmarshal([]byte(out), &plain); err != nil { + t.Fatal(err) + } + found := false + for _, f := range plain.Findings { + if f.Rule == "render-unsupported" && f.Severity == "warning" && + f.Path == "docs/domain/overview.md" && f.Line == 5 && f.Construct == "raw-html" { + found = true + } + } + if !plain.OK || !found { + t.Fatalf("the lint finding must reach the pipeline: %s", out) + } + distDir := filepath.Join(dir, ".leji", "dist") + before := treeSnapshot(t, distDir) + if len(before) == 0 { + t.Fatal("an export must exist to be protected") + } + + // Same layer, same finding, `--strict`: the run fails and the export it would have + // replaced is left exactly as it was. The chrome is removed first, so the assertion + // that it was regenerated can actually fail: after the default run above it exists + // already, and a strict gate moved ahead of regeneration would pass unnoticed. + viewerDir := filepath.Join(dir, ".leji", "viewer") + if err := os.RemoveAll(viewerDir); err != nil { + t.Fatal(err) + } + strictCode, strictOut, _ := captureRun(t, []string{"export", "--root", dir, "--strict", "--json"}) + if strictCode != 1 { + t.Fatalf("the lint class must fail a strict run: %s", strictOut) + } + if !strings.Contains(strictOut, `"ok": false`) || !strings.Contains(strictOut, "render-unsupported") { + t.Fatalf("strict document: %s", strictOut) + } + if !sameTree(treeSnapshot(t, distDir), before) { + t.Fatal("the existing export must be byte-untouched") + } + // The internal chrome is regenerated regardless: the no-write promise is the + // target's, per the pipeline order. + if _, err := os.Stat(filepath.Join(viewerDir, "index.html")); err != nil { + t.Fatal("the chrome must be regenerated") + } + if _, err := os.Stat(filepath.Join(viewerDir, "assets")); err != nil { + t.Fatal("the internal chrome must carry its assets") + } + + // One operation, two names: the gate answers the same under `viewer build`. + other, otherOut, _ := captureRun(t, []string{"viewer", "build", "--root", dir, "--strict", "--json"}) + if other != 1 { + t.Fatalf("the gate must hold under the other name: %s", otherOut) + } + if !sameTree(treeSnapshot(t, distDir), before) { + t.Fatal("and still byte-untouched") + } +} diff --git a/packages/sdk-go/internal/cli/help_test.go b/packages/sdk-go/internal/cli/help_test.go new file mode 100644 index 0000000..b99ffd5 --- /dev/null +++ b/packages/sdk-go/internal/cli/help_test.go @@ -0,0 +1,400 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "unicode/utf8" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" + detectcmd "github.com/leji-org/leji/packages/sdk-go/internal/commands/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" + "github.com/leji-org/leji/packages/sdk-go/internal/schemas" + "github.com/leji-org/leji/packages/sdk-go/internal/writeplan" +) + +// helpGolden reads one committed help golden: the byte oracle all three SDKs +// render against. +func helpGolden(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), "fixtures", "help-goldens", name)) + if err != nil { + t.Fatalf("help golden %s: %v", name, err) + } + return string(data) +} + +func goldenName(command string) string { + return strings.ReplaceAll(command, " ", "-") + ".txt" +} + +// hasDash reports U+2013 or U+2014: the house rule is that no line the CLI prints +// carries either. +func hasDash(s string) bool { + return strings.ContainsAny(s, "–—") +} + +// --- cli.json integrity: the grouping four consumers read --------------------- + +func TestCliSpecGroupsAreWellFormed(t *testing.T) { + spec, err := schemas.LoadCliSpec() + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, g := range spec.Groups { + if ids[g.ID] { + t.Fatalf("duplicate group id %q", g.ID) + } + if g.Title == "" { + t.Fatalf("group %q has no title", g.ID) + } + ids[g.ID] = true + } + names := map[string]*schemas.CliCommand{} + for i := range spec.Commands { + names[spec.Commands[i].Name] = &spec.Commands[i] + } + for _, c := range spec.Commands { + if !ids[c.Group] { + t.Fatalf("%s names group %q, which is not declared", c.Name, c.Group) + } + if c.AliasOf == "" { + continue + } + primary, ok := names[c.AliasOf] + if !ok { + t.Fatalf("%s aliases %q, which is not a command", c.Name, c.AliasOf) + } + if primary.AliasOf != "" { + t.Fatalf("%s aliases %q, which is itself an alias", c.Name, c.AliasOf) + } + } +} + +// --- the wrapper -------------------------------------------------------------- + +func TestWrapCollapsesHangsAndKeepsLongTokensWhole(t *testing.T) { + cases := []struct { + text string + width, indentFirst, indentRest int + want []string + }{ + {" one two ", 20, 0, 0, []string{"one two"}}, + {"", 20, 0, 0, nil}, + {"alpha beta gamma delta", 16, 0, 3, []string{"alpha beta gamma", " delta"}}, + {"alpha beta gamma delta", 16, 0, 8, []string{"alpha beta gamma", " delta"}}, + // A token wider than the line takes a line of its own rather than being + // split: a URL or a flag spelling stays copyable. + {"see https://leji.org/cli/#mounts-update-pin now", 20, 0, 3, + []string{"see", " https://leji.org/cli/#mounts-update-pin", " now"}}, + } + for _, c := range cases { + if got := wrap(c.text, c.width, c.indentFirst, c.indentRest); !reflect.DeepEqual(got, c.want) { + t.Fatalf("wrap(%q) = %q, want %q", c.text, got, c.want) + } + } +} + +func TestTopLevelUsageGoesThroughTheWrapper(t *testing.T) { + // No current command is long enough to wrap this line, so the contract is pinned on + // a vector instead: every emitted field passes the wrapper, never just the ones the + // data happens to overflow today. + usage := "Usage: leji mounts update-pin [--to ] [--allow-non-fast-forward] " + + "[--fetch] [--dry-run] [--root ] [--json]" + got := strings.Join(wrap(usage, 80, 0, 7), "\n") + "\n" + if want := helpGolden(t, "wrap-long-usage.txt"); got != want { + t.Fatalf("long usage vector\n got: %q\nwant: %q", got, want) + } + if !strings.Contains(BuildUsage(), "\nUsage: leji [options]\n") { + t.Fatal("top-level help does not carry the usage line") + } +} + +func TestOverlongLabelTakesItsOwnLine(t *testing.T) { + label := "--allow-non-fast-forward-with-a-very-long-spelling " + summary := "Permit a target that is not a descendant of the current pin, in the one " + + "spelling long enough to outgrow its column." + got := strings.Join(helpRow(label, 23, summary), "\n") + "\n" + if want := helpGolden(t, "row-overlong-label.txt"); got != want { + t.Fatalf("overlong label vector\n got: %q\nwant: %q", got, want) + } + // The clamp is what makes an overlong label reachable: past 27 characters the flag + // outgrows its own column. + if col := optionColumn([]schemas.CliOption{{Flags: label}}); col != 33 { + t.Fatalf("optionColumn = %d, want 33", col) + } + // A label that exactly fills the column would leave no gap, so it takes the line too. + want := []string{" --exactly-here", " summary"} + if got := helpRow("--exactly-here", 17, "summary"); !reflect.DeepEqual(got, want) { + t.Fatalf("exact-fit row = %q, want %q", got, want) + } +} + +func TestRowPadsByCodePoints(t *testing.T) { + // Two U+1F600 in the label: padding by UTF-16 units (or bytes) leaves the row short + // and misaligns every summary in the block. + label := "--emoji-\U0001F600\U0001F600 " + summary := "A flag carrying astral characters, so a column padded in UTF-16 units " + + "misaligns this row by two." + got := strings.Join(helpRow(label, 23, summary), "\n") + "\n" + if want := helpGolden(t, "row-non-bmp.txt"); got != want { + t.Fatalf("non-BMP row vector\n got: %q\nwant: %q", got, want) + } +} + +func TestEveryLabelClassResolvesABoundedColumn(t *testing.T) { + opt := func(flags ...string) int { + options := make([]schemas.CliOption, len(flags)) + for i, f := range flags { + options[i] = schemas.CliOption{Flags: f} + } + return optionColumn(options) + } + name := func(names ...string) int { + commands := make([]schemas.CliCommand, len(names)) + for i, n := range names { + commands[i] = schemas.CliCommand{Name: n} + } + return nameColumn(commands) + } + code := func(codes ...string) int { + exits := make([]schemas.CliExitCode, len(codes)) + for i, c := range codes { + exits[i] = schemas.CliExitCode{Code: json.Number(c)} + } + return exitCodeColumn(exits) + } + cases := []struct { + got, want int + what string + }{ + {opt("--json"), 23, "option floor"}, + {opt("--a-flag-of-thirty-plus-characters "), 33, "option ceiling"}, + {name("leji"), 15, "name floor"}, + {name("a-command-name-long-enough-to-outgrow-its-bounded-column"), 33, "name ceiling"}, + {code("0"), 6, "exit-code floor"}, + {code("0", "127"), 8, "exit-code widening"}, + // Code points, not bytes: an astral label sizes its column by what it prints. + {opt("--emoji-\U0001F600\U0001F600 "), 24, "astral option label"}, + } + for _, c := range cases { + if c.got != c.want { + t.Fatalf("%s column = %d, want %d", c.what, c.got, c.want) + } + } +} + +func TestBoundsHoldThroughTheRenderers(t *testing.T) { + // Rendered, not just computed: a bound the column helper honors and the renderer + // bypasses is exactly the defect this pins. The spec pushes every class past its + // bound at once. + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "fixtures", "help-goldens", "bounds-spec.json")) + if err != nil { + t.Fatal(err) + } + var spec schemas.CliSpec + if err := json.Unmarshal(raw, &spec); err != nil { + t.Fatal(err) + } + want := strings.Replace(helpGolden(t, "bounds-usage.txt"), "{{version}}", schemas.SDKVersion, 1) + if got := buildUsage(spec) + "\n"; got != want { + t.Fatalf("synthetic top-level help\n got:\n%s\nwant:\n%s", got, want) + } + long := "a-command-name-long-enough-to-outgrow-its-bounded-column" + help, ok := buildCommandHelp(long, spec) + if !ok { + t.Fatal("the synthetic command must render help") + } + if got, w := help+"\n", helpGolden(t, "bounds-command.txt"); got != w { + t.Fatalf("synthetic command help\n got:\n%s\nwant:\n%s", got, w) + } +} + +func TestWrapMeasuresCodePointsNotBytes(t *testing.T) { + // Documented in fixtures/README.md: four U+1F600, two spaces, three ASCII words, + // width 20, first line indented 0 and continuations 3. Measuring the emoji run in + // bytes (or UTF-16 units) breaks the line one word early. + input := "\U0001F600\U0001F600\U0001F600\U0001F600 alphabet six666 tail" + got := strings.Join(wrap(input, 20, 0, 3), "\n") + "\n" + if want := helpGolden(t, "wrap-non-bmp.txt"); got != want { + t.Fatalf("wrap vector\n got: %q\nwant: %q", got, want) + } +} + +// --- the goldens -------------------------------------------------------------- + +func TestHelpGoldens(t *testing.T) { + spec, err := schemas.LoadCliSpec() + if err != nil { + t.Fatal(err) + } + want := strings.Replace(helpGolden(t, "usage.txt"), "{{version}}", schemas.SDKVersion, 1) + if got := BuildUsage() + "\n"; got != want { + t.Fatalf("leji --help does not match usage.txt\n got:\n%s\nwant:\n%s", got, want) + } + expected := []string{ + "usage.txt", "wrap-non-bmp.txt", "wrap-long-usage.txt", "row-overlong-label.txt", + "row-non-bmp.txt", "bounds-spec.json", "bounds-usage.txt", "bounds-command.txt", + } + for _, c := range spec.Commands { + help, ok := BuildCommandHelp(c.Name) + if !ok { + t.Fatalf("%s must render help", c.Name) + } + if got, w := help+"\n", helpGolden(t, goldenName(c.Name)); got != w { + t.Fatalf("%s --help does not match %s\n got:\n%s\nwant:\n%s", c.Name, goldenName(c.Name), got, w) + } + expected = append(expected, goldenName(c.Name)) + } + // And nothing committed is orphaned: every golden is one of the surfaces above. + entries, err := os.ReadDir(filepath.Join(repoRoot(t), "fixtures", "help-goldens")) + if err != nil { + t.Fatal(err) + } + var found []string + for _, e := range entries { + found = append(found, e.Name()) + } + sort.Strings(found) + sort.Strings(expected) + if !reflect.DeepEqual(found, expected) { + t.Fatalf("help-goldens holds %v, want %v", found, expected) + } +} + +func TestCommandHelpListsOwnOptionsAndPointsAtGlobals(t *testing.T) { + spec, err := schemas.LoadCliSpec() + if err != nil { + t.Fatal(err) + } + for _, c := range spec.Commands { + help, _ := BuildCommandHelp(c.Name) + if !strings.Contains(help, "\nGlobal options: see leji --help.\n") { + t.Fatalf("%s help does not point at the globals", c.Name) + } + for _, g := range spec.GlobalOptions { + if strings.Contains(help, " "+g.Flags) { + t.Fatalf("%s help repeats the global %s", c.Name, g.Flags) + } + } + for _, o := range c.Options { + if !strings.Contains(help, " "+o.Flags) { + t.Fatalf("%s help does not list %s", c.Name, o.Flags) + } + } + // Examples are commands to copy, never prose: they are printed as authored, + // so the width contract covers everything above them. + prose, _, _ := strings.Cut(help, "\nExamples:\n") + for _, line := range strings.Split(prose, "\n") { + if utf8.RuneCountInString(line) > 80 { + t.Fatalf("%s help line exceeds 80 code points: %q", c.Name, line) + } + } + } + for _, line := range strings.Split(BuildUsage(), "\n") { + if utf8.RuneCountInString(line) > 80 { + t.Fatalf("top-level help line exceeds 80 code points: %q", line) + } + } +} + +// --- the em-dash house rule, checked on the bytes the CLI prints -------------- + +func TestHelpOutputCarriesNoDash(t *testing.T) { + entries, err := os.ReadDir(filepath.Join(repoRoot(t), "fixtures", "help-goldens")) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if hasDash(helpGolden(t, e.Name())) { + t.Fatalf("%s carries an em or en dash", e.Name()) + } + } +} + +func TestCliJSONCarriesNoDash(t *testing.T) { + data, err := os.ReadFile(filepath.Join(repoRoot(t), "packages", "sdk-go", "internal", "assets", "cli.json")) + if err != nil { + t.Fatal(err) + } + if hasDash(string(data)) { + t.Fatal("cli.json carries an em or en dash") + } +} + +func TestProseBranchesCarryNoDash(t *testing.T) { + // detect's host lines: synthetic hosts, so the branch runs wherever the suite does. + rendered := detectcmd.RenderDetect([]detect.DetectedHost{{ + ID: "codex", + Name: "Codex CLI", + Strength: detect.Confirmed, + OnPath: true, + InRepo: true, + UserConfig: false, + Adapter: "AGENTS.md", + }}, ecosystem.Detect(t.TempDir())) + if !strings.Contains(rendered, "Codex CLI: binary on PATH") { + t.Fatalf("detect host line, got:\n%s", rendered) + } + if hasDash(rendered) { + t.Fatalf("detect output carries a dash:\n%s", rendered) + } + + // conformance --explain's blocker details, likewise: the detail branch needs a + // blocker that carries one, which a passing layer does not produce. + explain := conformance.RenderExplain(conformance.Result{ + ClaimedLevel: "core", + VerifiedLevel: "core", + Items: []conformance.ChecklistItem{{ + ID: "index-current", + Level: "indexed", + Description: "a generated context index, current with the tree", + Status: conformance.Fail, + Detail: "the stored index is stale", + }}, + }) + if !strings.Contains(explain, "- a generated context index, current with the tree: the stored index is stale") { + t.Fatalf("explain detail line, got:\n%s", explain) + } + if hasDash(explain) { + t.Fatalf("explain output carries a dash:\n%s", explain) + } + + // The conformance checklist's own detail column, from a real run. + example := filepath.Join(repoRoot(t), "examples", "monorepo") + _, out, _ := captureRun(t, []string{"conformance", "--root", example}) + if !strings.Contains(out, "freshness horizons are declared and checked (report-only is acceptable): ") { + t.Fatalf("checklist detail column, got:\n%s", out) + } + if hasDash(out) { + t.Fatalf("conformance output carries a dash:\n%s", out) + } + + // The write plan's read-only note is library data rather than a printed line, so + // it is asserted where it is produced. + plan := writeplan.Build(example, nil, []string{"README.md"}, nil) + if plan[0].Note != "existing file, read-only input; Leji will not modify it" { + t.Fatalf("write-plan note %q", plan[0].Note) + } +} + +// The unknown-command contract the help surface leans on: exit 2, the error, and +// the top-level usage, all on stderr. +func TestUnknownCommandPrintsUsageToStderr(t *testing.T) { + code, _, errs := captureRun(t, []string{"frobnicate"}) + if code != 2 { + t.Fatalf("unknown command exit %d, want 2", code) + } + if !strings.Contains(errs, `unknown command "frobnicate"`) { + t.Fatalf("stderr %q does not name the unknown command", errs) + } + if !strings.Contains(errs, "Usage: leji") { + t.Fatalf("stderr %q does not carry the top-level usage", errs) + } +} diff --git a/packages/sdk-go/internal/cli/start_preflight_test.go b/packages/sdk-go/internal/cli/start_preflight_test.go new file mode 100644 index 0000000..f15f531 --- /dev/null +++ b/packages/sdk-go/internal/cli/start_preflight_test.go @@ -0,0 +1,344 @@ +package cli + +import ( + "encoding/json" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// `leji start` end to end, through the real command surface. Everything the command +// could reach outside the repository is a stub on a synthetic PATH: the CLI it +// probes, the agent host binaries, and the host commands it would run. Nothing real +// is launched or installed here, and the runs are non-interactive (the test process +// has no TTY on stdin), so no prompt can fire either. Mirrors +// packages/sdk/test/proc/start.test.ts. + +// gitBin is the real git binary, the one program these runs cannot stub: the hook +// check asks git where hooks live. +func gitBin(t *testing.T) string { + t.Helper() + p, err := exec.LookPath("git") + if err != nil { + t.Skipf("git not available: %v", err) + } + return p +} + +// startStubs builds a directory of executable stubs plus a link to the real git. It +// is the WHOLE PATH of every run below, so what host detection finds is exactly what +// a case declares and never whatever the machine running the suite has installed. +func startStubs(t *testing.T, spec map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, body := range spec { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatalf("stub %s: %v", name, err) + } + } + if err := os.Symlink(gitBin(t), filepath.Join(dir, "git")); err != nil { + t.Fatalf("git link: %v", err) + } + return dir +} + +const versionStub = "echo 1.4.0" + +func copyTree(t *testing.T, src, dst string) { + t.Helper() + err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, err := d.Info() + if err != nil { + return err + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, b, info.Mode().Perm()) + }) + if err != nil { + t.Fatalf("copy %s: %v", src, err) + } +} + +// startFixture copies one seeded joiner root from fixtures/start-preflight/, commits +// it, and points the environment at the stubs the case declares. +func startFixture(t *testing.T, name string, stubs map[string]string) string { + t.Helper() + dir := t.TempDir() + copyTree(t, filepath.Join(repoRoot(t), "fixtures", "start-preflight", name), dir) + // What the manager's own install would have produced for a Node repository. The + // probe executes this file directly; no `npx`/`pnpm exec` stub exists, and none is + // needed. + if pkg, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && strings.Contains(string(pkg), "@leji-org/leji") { + binDir := filepath.Join(dir, "node_modules", ".bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(binDir, "leji"), []byte("#!/bin/sh\n"+versionStub+"\n"), 0o755); err != nil { + t.Fatalf("write shim: %v", err) + } + } + stubDir := startStubs(t, stubs) + t.Setenv("PATH", stubDir) + t.Setenv("HOME", t.TempDir()) + git := func(args ...string) { + t.Helper() + 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") + git("add", "-A") + git("-c", "user.email=t@e.com", "-c", "user.name=T", "commit", "-qm", "seed") + return dir +} + +type startDoc struct { + Command string `json:"command"` + OK bool `json:"ok"` + Ready bool `json:"ready"` + Error string `json:"error"` + Checks []struct { + ID string `json:"id"` + Status string `json:"status"` + Detail string `json:"detail"` + Fix []string `json:"fix"` + } `json:"checks"` + Ecosystem struct { + Selected *struct { + Manager string `json:"manager"` + } `json:"selected"` + } `json:"ecosystem"` +} + +func decodeStart(t *testing.T, out string) startDoc { + t.Helper() + var doc startDoc + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("start --json is not one document: %v\n%s", err, out) + } + return doc +} + +func checkIDs(doc startDoc) []string { + ids := make([]string, 0, len(doc.Checks)) + for _, c := range doc.Checks { + ids = append(ids, c.ID) + } + return ids +} + +func TestStartPrintsTheSetupBlockBeforeTheEntryInstructions(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{"leji": versionStub}) + code, out, errs := captureRun(t, []string{"start", "--root", dir}) + if code != 0 { + t.Fatalf("exit %d: %s", code, errs) + } + setup := strings.Index(out, "Setup for this clone") + entry := strings.Index(out, "No coding agent was launched.") + if setup < 0 || entry <= setup { + t.Fatalf("the block must print above the entry instructions:\n%s", out) + } + if strings.Contains(out, "Starting ") { + t.Fatalf("a non-interactive run never launches a host:\n%s", out) + } + for _, want := range []string{ + "\n ok Leji CLI 1.4.0 (node_modules/.bin/leji)\n", + "\n n/a MCP server no coding agent detected\n", + "\n you Git hook none yet (per clone)\n", + "\n $ leji ci --hooks\n", + "\n 1 fix for you. The agent starts either way.\n", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "\x1b") { + t.Fatalf("a piped run carries no escape:\n%s", out) + } +} + +func TestStartUndeclaredRepositoryReportsASharedGapAtExitZero(t *testing.T) { + dir := startFixture(t, "node-undeclared", map[string]string{"leji": versionStub}) + code, out, errs := captureRun(t, []string{"start", "--root", dir}) + if code != 0 { + t.Fatalf("exit %d: %s", code, errs) + } + if !strings.Contains(out, "\n team Leji CLI not declared") || + !strings.Contains(out, "\n $ npm i -D @leji-org/leji\n") { + t.Fatalf("out:\n%s", out) + } +} + +func TestStartJSONIsOneReportOnlyDocument(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{"leji": versionStub, "claude": "exit 1"}) + code, out, errs := captureRun(t, []string{"start", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("exit %d: %s", code, errs) + } + doc := decodeStart(t, out) + if doc.Command != "start" || !doc.OK || doc.Ready { + t.Fatalf("doc = %+v", doc) + } + if got := checkIDs(doc); strings.Join(got, ",") != "cli,mcp,mcp-shared,hook" { + t.Fatalf("ids = %v", got) + } + if doc.Checks[0].Status != "ok" || doc.Checks[1].Status != "missing" || + doc.Checks[2].Status != "shared-gap" || doc.Checks[3].Status != "missing" { + t.Fatalf("statuses = %+v", doc.Checks) + } + if len(doc.Checks[1].Fix) != 1 || doc.Checks[1].Fix[0] != "claude mcp add leji --scope user -- npx -y @leji-org/mcp" { + t.Fatalf("mcp fix = %v", doc.Checks[1].Fix) + } + if doc.Ecosystem.Selected == nil || doc.Ecosystem.Selected.Manager != "npm" { + t.Fatalf("ecosystem = %+v", doc.Ecosystem) + } + if strings.Contains(out, "Setup for this clone") { + t.Fatalf("no human block in a document mode:\n%s", out) + } +} + +func TestStartJSONChecksPublishExactlyFourKeys(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{"leji": versionStub, "claude": "exit 1"}) + code, out, errs := captureRun(t, []string{"start", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("exit %d: %s", code, errs) + } + var raw struct { + Checks []map[string]json.RawMessage `json:"checks"` + } + if err := json.Unmarshal([]byte(out), &raw); err != nil { + t.Fatalf("start --json is not one document: %v\n%s", err, out) + } + if len(raw.Checks) != 4 { + t.Fatalf("checks = %d", len(raw.Checks)) + } + for _, c := range raw.Checks { + if len(c) != 4 { + t.Fatalf("a check publishes more than the four contract keys: %v", c) + } + for _, k := range []string{"id", "status", "detail", "fix"} { + if _, ok := c[k]; !ok { + t.Fatalf("a check is missing %q: %v", k, c) + } + } + } + // The render-only fix kind is not a document field, at any spelling. + if strings.Contains(out, "fixKind") || strings.Contains(out, "fix_kind") { + t.Fatalf("a render-only field reached the document:\n%s", out) + } +} + +func TestStartJSONReportsReadyOnceThePersonalGapsAreClosed(t *testing.T) { + dir := startFixture(t, "node-mcp-json", map[string]string{"leji": versionStub, "claude": "exit 0"}) + if code, _, errs := captureRun(t, []string{"ci", "--hooks", "--root", dir}); code != 0 { + t.Fatalf("ci --hooks exit %d: %s", code, errs) + } + code, out, errs := captureRun(t, []string{"start", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("exit %d: %s", code, errs) + } + doc := decodeStart(t, out) + if !doc.Ready { + t.Fatalf("doc = %+v", doc) + } + for _, c := range doc.Checks { + if c.Status != "ok" { + t.Fatalf("checks = %+v", doc.Checks) + } + } +} + +func TestStartJSONSeveralHostsAndNoAgentIsUnresolved(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{ + "leji": versionStub, "claude": "exit 1", "codex": "exit 1", + }) + code, out, _ := captureRun(t, []string{"start", "--root", dir, "--json"}) + if code != 0 { + t.Fatalf("exit %d", code) + } + doc := decodeStart(t, out) + if doc.Checks[1].Status != "unresolved" || len(doc.Checks[1].Fix) != 1 || + doc.Checks[1].Fix[0] != "leji start --agent " || doc.Checks[2].Status != "n/a" { + t.Fatalf("checks = %+v", doc.Checks) + } +} + +func TestStartJSONAgentPinsTheHostTheMcpRowsAnswerFor(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{ + "leji": versionStub, "claude": "exit 1", "codex": "exit 1", + }) + code, out, _ := captureRun(t, []string{"start", "--root", dir, "--agent", "claude-code", "--json"}) + if code != 0 { + t.Fatalf("exit %d", code) + } + doc := decodeStart(t, out) + if doc.Checks[1].Status != "missing" || doc.Checks[2].Status != "shared-gap" { + t.Fatalf("checks = %+v", doc.Checks) + } +} + +func TestStartAgentBogusJSONIsAUsageError(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{"leji": versionStub}) + code, out, errs := captureRun(t, []string{"start", "--root", dir, "--agent", "bogus", "--json"}) + if code != 2 || !strings.Contains(errs, "--agent must be a launchable host") { + t.Fatalf("code=%d err=%q", code, errs) + } + if strings.TrimSpace(out) != "" { + t.Fatalf("no document is emitted for a rejected argument: %q", out) + } +} + +func TestStartJSONBootMissingIsTheErrorDocument(t *testing.T) { + dir := startFixture(t, "node-declared", map[string]string{"leji": versionStub}) + if err := os.Remove(filepath.Join(dir, "docs", "boot-profile.md")); err != nil { + t.Fatalf("remove: %v", err) + } + code, out, _ := captureRun(t, []string{"start", "--root", dir, "--json"}) + if code != 1 { + t.Fatalf("exit %d", code) + } + doc := decodeStart(t, out) + if doc.OK || doc.Ready || doc.Error != "boot-missing" || len(doc.Checks) != 0 { + t.Fatalf("doc = %+v", doc) + } +} + +func TestStartNoManifestIsTheFindingsEnvelope(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", startStubs(t, map[string]string{})) + t.Setenv("HOME", t.TempDir()) + code, out, _ := captureRun(t, []string{"start", "--root", dir, "--json"}) + if code != 1 { + t.Fatalf("exit %d", code) + } + var envelope struct { + Command string `json:"command"` + OK bool `json:"ok"` + Findings []any `json:"findings"` + } + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatalf("not a document: %v\n%s", err, out) + } + if envelope.Command != "start" || envelope.OK || envelope.Findings == nil { + t.Fatalf("envelope = %+v", envelope) + } +} diff --git a/packages/sdk-go/internal/cli/tty_darwin.go b/packages/sdk-go/internal/cli/tty_darwin.go index 4c673c7..288851f 100644 --- a/packages/sdk-go/internal/cli/tty_darwin.go +++ b/packages/sdk-go/internal/cli/tty_darwin.go @@ -8,20 +8,28 @@ import ( "unsafe" ) -// stdinIsTTY gates the host prompt and the post-scaffold handoff offer. Node -// (process.stdin.isTTY) and Python (sys.stdin.isatty) both ask isatty(3), so this -// asks the same terminal ioctl rather than os.Stdin.Stat(): a character-device -// test also answers true for /dev/null, /dev/zero, and /dev/urandom, so -// `leji start < /dev/zero` prompted and then blocked forever on a line that never -// arrives, where the other two printed the fallback and exited 0. Dependency-free. -func stdinIsTTY() bool { +// fdIsTTY asks the terminal itself. Node (process.stdin.isTTY / process.stdout.isTTY) +// and Python (isatty) both ask isatty(3), so this asks the same terminal ioctl rather +// than a Stat(): a character-device test also answers true for /dev/null, /dev/zero, +// and /dev/urandom, so `leji start < /dev/zero` prompted and then blocked forever on a +// line that never arrives, where the other two printed the fallback and exited 0. +// Dependency-free. +func fdIsTTY(fd uintptr) bool { var t syscall.Termios _, _, errno := syscall.Syscall6( syscall.SYS_IOCTL, - os.Stdin.Fd(), + fd, syscall.TIOCGETA, uintptr(unsafe.Pointer(&t)), 0, 0, 0, ) return errno == 0 } + +// stdinIsTTY gates the host prompt and the post-scaffold handoff offer. +func stdinIsTTY() bool { return fdIsTTY(os.Stdin.Fd()) } + +// stdoutIsTTY gates the Setup block's color, and nothing else. Separate from the +// prompt gate above: one asks whether a person can answer, the other whether a +// terminal is reading. +func stdoutIsTTY() bool { return fdIsTTY(os.Stdout.Fd()) } diff --git a/packages/sdk-go/internal/cli/tty_linux.go b/packages/sdk-go/internal/cli/tty_linux.go index 92ecb93..411e536 100644 --- a/packages/sdk-go/internal/cli/tty_linux.go +++ b/packages/sdk-go/internal/cli/tty_linux.go @@ -8,17 +8,23 @@ import ( "unsafe" ) -// stdinIsTTY is the Linux half of the darwin implementation; see tty_darwin.go for -// why the terminal ioctl replaces a character-device stat. Same call, different -// request constant (TCGETS rather than TIOCGETA). -func stdinIsTTY() bool { +// fdIsTTY is the Linux half of the darwin implementation; see tty_darwin.go for why +// the terminal ioctl replaces a character-device stat. Same call, different request +// constant (TCGETS rather than TIOCGETA). +func fdIsTTY(fd uintptr) bool { var t syscall.Termios _, _, errno := syscall.Syscall6( syscall.SYS_IOCTL, - os.Stdin.Fd(), + fd, syscall.TCGETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0, ) return errno == 0 } + +// stdinIsTTY gates the host prompt and the post-scaffold handoff offer. +func stdinIsTTY() bool { return fdIsTTY(os.Stdin.Fd()) } + +// stdoutIsTTY gates the Setup block's color, and nothing else. +func stdoutIsTTY() bool { return fdIsTTY(os.Stdout.Fd()) } diff --git a/packages/sdk-go/internal/cli/tty_other.go b/packages/sdk-go/internal/cli/tty_other.go index e931356..2af5b89 100644 --- a/packages/sdk-go/internal/cli/tty_other.go +++ b/packages/sdk-go/internal/cli/tty_other.go @@ -4,12 +4,18 @@ package cli import "os" -// stdinIsTTY on the platforms without a hand-written isatty here (Windows, and the +// fileIsTTY on the platforms without a hand-written isatty here (Windows, and the // Unixes the release does not build). The character-device test is an // approximation — it also answers true for the platform's null device — but it is // the pre-existing behavior everywhere, and the divergence it causes was only ever // observed on the two platforms above, which now ask the terminal directly. -func stdinIsTTY() bool { - fi, err := os.Stdin.Stat() +func fileIsTTY(f *os.File) bool { + fi, err := f.Stat() return err == nil && fi.Mode()&os.ModeCharDevice != 0 } + +// stdinIsTTY gates the host prompt and the post-scaffold handoff offer. +func stdinIsTTY() bool { return fileIsTTY(os.Stdin) } + +// stdoutIsTTY gates the Setup block's color, and nothing else. +func stdoutIsTTY() bool { return fileIsTTY(os.Stdout) } diff --git a/packages/sdk-go/internal/cli/updatepin_test.go b/packages/sdk-go/internal/cli/updatepin_test.go new file mode 100644 index 0000000..5d7e842 --- /dev/null +++ b/packages/sdk-go/internal/cli/updatepin_test.go @@ -0,0 +1,953 @@ +// `leji mounts update-pin`, mirroring packages/sdk/test/update-pin.test.ts: the two +// factorings out of internal/mounts checked against the callers they came from, the +// shared fixtures' `updatePin` block driven through the real CLI, the two branches +// no fixture can construct, and the command surface. The pin-span scanner has its +// own byte fixtures in internal/manifest. +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/updatepin" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" + "github.com/leji-org/leji/packages/sdk-go/internal/mounts" +) + +// The declaration rewrites a case applies before the run: the pin it starts from, +// and whether the tracking ref is declared at all. +var ( + pinRe = regexp.MustCompile(`("pin": ")[0-9a-f]{40}(")`) + trackingRefRe = regexp.MustCompile(`\s*"trackingRef": "[^"]*",\n`) +) + +// --- the acme-sibling scaffold ------------------------------------------------ + +// The recipe's fixed commit ids (fixtures/README.md -> "The `acme-sibling` +// recipe"). Every field a commit hashes is pinned by the recipe, so these are +// constants, not observations. +const ( + oidA = "6b06fe51a323212156bb267842bf10187ed4c20e" + oidB = "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723" + oidS = "50305153f1a107c6871ab3b3047cb4c225603b0c" + oidO = "0cb1fb59e73d78ff04cf41de7f177ea0fb940002" +) + +const acmeSource = "https://github.com/acme/product-context" + +func acmeIdentity(t *testing.T) string { + t.Helper() + identity, ok := mounts.NormalizeSource(acmeSource) + if !ok { + t.Fatal("the acme source must normalize") + } + return identity +} + +// recipeGit runs git with author, committer, date, signing and autocrlf all fixed, +// so every commit id the recipe produces is a constant an expected.json can carry. +func recipeGit(t *testing.T, cwd string, args ...string) string { + t.Helper() + 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 = append(env, + "GIT_AUTHOR_NAME=Leji Fixtures", + "GIT_AUTHOR_EMAIL=fixtures@leji.org", + "GIT_COMMITTER_NAME=Leji Fixtures", + "GIT_COMMITTER_EMAIL=fixtures@leji.org", + "GIT_AUTHOR_DATE=2026-01-01T00:00:00 +0000", + "GIT_COMMITTER_DATE=2026-01-01T00:00:00 +0000", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, cwd, err, out) + } + return strings.TrimSpace(string(out)) +} + +func recipeCommit(t *testing.T, repo, file string) string { + t.Helper() + stem := strings.TrimSuffix(file, ".md") + if err := os.WriteFile(filepath.Join(repo, file), []byte("# "+stem+"\n"), 0o644); err != nil { + t.Fatal(err) + } + recipeGit(t, repo, "add", "-A") + recipeGit(t, repo, "commit", "-q", "-m", stem) + return recipeGit(t, repo, "rev-parse", "HEAD") +} + +// buildAcmeSibling builds the `acme-sibling` recipe, normative in +// fixtures/README.md: a -> b on main, a side branch off `a`, and an unrelated +// orphan branch. +func buildAcmeSibling(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + recipeGit(t, dir, "init", "-q", "-b", "main", ".") + for _, step := range []struct{ file, oid string }{{"a.md", oidA}, {"b.md", oidB}} { + if got := recipeCommit(t, dir, step.file); got != step.oid { + t.Fatalf("recipe commit %s = %s, want %s", step.file, got, step.oid) + } + } + recipeGit(t, dir, "checkout", "-q", "-b", "side", oidA) + if got := recipeCommit(t, dir, "s.md"); got != oidS { + t.Fatalf("recipe commit s.md = %s, want %s", got, oidS) + } + recipeGit(t, dir, "checkout", "-q", "--orphan", "other") + recipeGit(t, dir, "rm", "-q", "-rf", ".") + if got := recipeCommit(t, dir, "o.md"); got != oidO { + t.Fatalf("recipe commit o.md = %s, want %s", got, oidO) + } + recipeGit(t, dir, "checkout", "-q", "main") + // Fetching a commit by id is how the resolver retains a pin, so the recipe's + // repository must serve one the way a real host does. + recipeGit(t, dir, "config", "uploadpack.allowAnySHA1InWant", "true") +} + +// storeSpec mirrors the `store` field of one `updatePin` case. +type storeSpec struct { + Pin *string `json:"pin"` + WitnessRef *string `json:"witnessRef"` + WitnessOid *string `json:"witnessOid"` + Depth *int `json:"depth"` +} + +func storePath(t *testing.T, host string) string { + t.Helper() + sum := sha256.Sum256([]byte(acmeIdentity(t))) + return filepath.Join(host, ".leji", "mounts", "store", hex.EncodeToString(sum[:])) +} + +// buildStore builds the managed store exactly as a successful --fetch leaves it. +func buildStore(t *testing.T, host, sibling string, spec storeSpec) { + t.Helper() + identity := acmeIdentity(t) + store := storePath(t, host) + if err := os.MkdirAll(store, 0o755); err != nil { + t.Fatal(err) + } + recipeGit(t, host, "init", "--bare", "-q", store) + var depth []string + if spec.Depth != nil { + depth = []string{"--depth", itoa(*spec.Depth)} + } + if spec.Pin != nil { + recipeGit(t, store, append(append([]string{"fetch", "-q"}, depth...), sibling, *spec.Pin)...) + recipeGit(t, store, "update-ref", mounts.PinRefFor(identity, *spec.Pin), *spec.Pin) + } + if spec.WitnessRef != nil && spec.WitnessOid != nil { + refspec := "+" + *spec.WitnessOid + ":" + mounts.WitnessRefFor(identity, *spec.WitnessRef) + recipeGit(t, store, append(append([]string{"fetch", "-q"}, depth...), sibling, refspec)...) + } + // FETCH_HEAD records a per-harness path and is not part of any contract. + if err := os.Remove(filepath.Join(store, "FETCH_HEAD")); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } +} + +func itoa(n int) string { + b, _ := json.Marshal(n) + return string(b) +} + +// repin applies a case's declaration rewrite: the pin it starts from, and whether +// the tracking ref is declared at all. A raw-text splice, as the fixture's own +// contract requires — the harness never reserializes a manifest either. +func repin(t *testing.T, host, pin string, dropTrackingRef bool) { + t.Helper() + mp := filepath.Join(host, "leji.json") + raw, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + text := pinRe.ReplaceAllString(string(raw), "${1}"+pin+"${2}") + if dropTrackingRef { + text = trackingRefRe.ReplaceAllString(text, "\n") + } + if err := os.WriteFile(mp, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +func copyFixture(t *testing.T, name, dst string) { + t.Helper() + if err := os.CopyFS(dst, os.DirFS(fixture(t, name))); err != nil { + t.Fatal(err) + } +} + +// withSourceRoutedTo routes the declared source at git's own level, for the run fn +// makes: the locator is one no test may actually reach. +func withSourceRoutedTo(t *testing.T, routed string, fn func()) { + t.Helper() + keys := []string{"GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"} + prev := map[string]*string{} + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + vv := v + prev[k] = &vv + } + } + defer func() { + for _, k := range keys { + if v, ok := prev[k]; ok { + os.Setenv(k, *v) + } else { + os.Unsetenv(k) + } + } + }() + if routed == "" { + for _, k := range keys { + os.Unsetenv(k) + } + } else { + os.Setenv("GIT_CONFIG_COUNT", "1") + os.Setenv("GIT_CONFIG_KEY_0", "url."+routed+".insteadOf") + os.Setenv("GIT_CONFIG_VALUE_0", acmeSource) + } + fn() +} + +// --- the two factorings, against the callers they came out of ----------------- + +func TestUpdatePinSelectComparisonAnswersWhatMountStatusReports(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + buildAcmeSibling(t, sibling) + for _, c := range []struct{ pin, want string }{ + {oidA, "behind"}, + {oidB, "up-to-date"}, + {oidS, "diverged"}, + {oidO, "unrelated"}, + } { + host := filepath.Join(dir, "host-"+c.pin[:6]) + copyFixture(t, "warn-update-pin", host) + repin(t, host, c.pin, false) + witnessRef, witnessOid := "refs/heads/main", oidB + buildStore(t, host, sibling, storeSpec{Pin: &c.pin, WitnessRef: &witnessRef, WitnessOid: &witnessOid}) + load := manifest.LoadManifest(host) + if load.Manifest == nil { + t.Fatalf("manifest: %v", load.Findings) + } + rows, err := mounts.MountStatus(host, load.Manifest, mounts.StatusOptions{}) + if err != nil { + t.Fatal(err) + } + row := rows[0] + if row.PinReport.State != c.want { + t.Fatalf("status says %s, got %s", c.want, row.PinReport.State) + } + decl := mounts.MountDecl{Name: "product-context", Source: acmeSource, Pin: c.pin, TrackingRef: witnessRef} + selection := mounts.SelectComparison(host, decl, witnessRef) + if selection.Reason != "" { + t.Fatalf("the matrix selected nothing: %s", selection.Reason) + } + // The helper reports the same repository, provenance and ref status does… + if selection.ComparisonRepository != strOr(row.PinReport.ComparisonRepository, "") || + selection.WitnessProvenance != strOr(row.PinReport.WitnessProvenance, "") || + selection.ComparedRef != strOr(row.PinReport.ComparedRef, "") { + t.Fatalf("selection %+v disagrees with %+v", selection, row.PinReport) + } + if selection.TipOid != oidB { + t.Fatalf("the single witness snapshot = %s", selection.TipOid) + } + // …and comparing against that one snapshot reproduces the report exactly. + cmp := mounts.ComparePins(selection.Repo, c.pin, selection.TipOid) + if cmp.Reason != "" { + t.Fatalf("comparison: %s", cmp.Reason) + } + if cmp.State != row.PinReport.State || cmp.Behind != *row.PinReport.Behind || + cmp.Ahead != *row.PinReport.Ahead || cmp.AncestryComplete != row.PinReport.AncestryComplete { + t.Fatalf("comparison %+v disagrees with %+v", cmp, row.PinReport) + } + } +} + +func TestUpdatePinSelectComparisonReportsDegradedReasonsWithoutSelecting(t *testing.T) { + dir := t.TempDir() + decl := mounts.MountDecl{Name: "product-context", Source: acmeSource, Pin: oidA, TrackingRef: "refs/heads/main"} + // Nothing holds the pin. + if got := mounts.SelectComparison(dir, decl, "refs/heads/main"); got.Reason != "mount-pin-unavailable" { + t.Fatalf("reason = %q", got.Reason) + } + // A locator no resolver can normalize, and a ref the resolver refuses. + unnormalizable := decl + unnormalizable.Source = "file:///srv/x" + if got := mounts.SelectComparison(dir, unnormalizable, "refs/heads/main"); got.Reason != "mount-source-unnormalizable" { + t.Fatalf("reason = %q", got.Reason) + } + if got := mounts.SelectComparison(dir, decl, "refs/heads/main@{1}"); got.Reason != "mount-tracking-ref-invalid" { + t.Fatalf("reason = %q", got.Reason) + } +} + +func TestUpdatePinRetainPinInStoreRetainsOneCommitWithoutTouchingTheWitness(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + host := filepath.Join(dir, "host") + buildAcmeSibling(t, sibling) + if err := os.MkdirAll(host, 0o755); err != nil { + t.Fatal(err) + } + identity := acmeIdentity(t) + decl := mounts.MountDecl{Name: "product-context", Source: sibling, Pin: oidA, TrackingRef: "refs/heads/main"} + store, errMsg, err := mounts.RetainPinInStore(host, decl, identity, oidA) + if err != nil || store == "" { + t.Fatalf("retain: %v %s", err, errMsg) + } + if got := recipeGit(t, store, "rev-parse", mounts.PinRefFor(identity, oidA)); got != oidA { + t.Fatalf("pin ref = %s", got) + } + // The witness namespace belongs to the refresh, which this primitive is not. + if got := recipeGit(t, store, "for-each-ref", "--format=%(refname)", "refs/leji-witness"); got != "" { + t.Fatalf("witness refs = %q", got) + } + // A second commit is retained beside the first, not instead of it. + store2, errMsg, err := mounts.RetainPinInStore(host, decl, identity, oidB) + if err != nil || store2 == "" { + t.Fatalf("retain b: %v %s", err, errMsg) + } + if got := recipeGit(t, store2, "rev-parse", mounts.PinRefFor(identity, oidA)); got != oidA { + t.Fatalf("pin ref a = %s", got) + } + if got := recipeGit(t, store2, "rev-parse", mounts.PinRefFor(identity, oidB)); got != oidB { + t.Fatalf("pin ref b = %s", got) + } + // A source that serves nothing is a stated failure, never a partial success. + gone := decl + gone.Source = filepath.Join(dir, "gone") + repo, errMsg, err := mounts.RetainPinInStore(host, gone, identity, oidS) + if err != nil || repo != "" { + t.Fatalf("a missing source must fail: %v %q", err, repo) + } + if errMsg != "the pin could not be fetched from the source" { + t.Fatalf("errMsg = %q", errMsg) + } +} + +// --- the shared fixtures' `updatePin` block ----------------------------------- + +type updatePinCase struct { + ID string `json:"id"` + Note string `json:"note"` + Pin string `json:"pin"` + TrackingRef *string `json:"trackingRef"` + HasTrackingRef bool `json:"-"` + Store *storeSpec `json:"store"` + Hint bool `json:"hint"` + Source string `json:"source"` + Args []string `json:"args"` + Exit int `json:"exit"` + Action *string `json:"action"` + From *string `json:"from"` + To *string `json:"to"` + Reason *string `json:"reason"` + Override bool `json:"override"` + ComparisonRepository *string `json:"comparisonRepository"` + ComparedRef *string `json:"comparedRef"` + ManifestGolden *string `json:"manifestGolden"` + Written bool `json:"written"` +} + +type updatePinBlock struct { + Sibling string `json:"sibling"` + Mount string `json:"mount"` + Cases []updatePinCase `json:"cases"` +} + +type updatePinDocument struct { + Command string `json:"command"` + OK bool `json:"ok"` + Findings []struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Path string `json:"path"` + Message string `json:"message"` + } `json:"findings"` + Summary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + } `json:"summary"` + Mount struct { + Name string `json:"name"` + SourceIdentity *string `json:"sourceIdentity"` + TrackingRef *string `json:"trackingRef"` + From *string `json:"from"` + To *string `json:"to"` + } `json:"mount"` + PinReport map[string]any `json:"pinReport"` + Action string `json:"action"` + Override bool `json:"override"` + Reason *string `json:"reason"` +} + +// documentKeys is exactly the key set --json emits, under every outcome that emits +// a document. +var documentKeys = []string{"command", "ok", "findings", "summary", "mount", "pinReport", "action", "override"} + +func TestFixtureUpdatePinBlock(t *testing.T) { + fixturesDir := filepath.Join(repoRoot(t), "fixtures") + entries, err := os.ReadDir(fixturesDir) + if err != nil { + t.Fatal(err) + } + ran := 0 + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(fixturesDir, e.Name(), "expected.json")) + if err != nil { + continue + } + var wrapper struct { + UpdatePin *updatePinBlock `json:"updatePin"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil { + t.Fatalf("%s: expected.json: %v", e.Name(), err) + } + if wrapper.UpdatePin == nil { + continue + } + // `trackingRef: null` removes the declared ref; an absent key leaves the + // fixture's own, which the typed decode above cannot tell apart. + var rawCases struct { + UpdatePin struct { + Cases []map[string]json.RawMessage `json:"cases"` + } `json:"updatePin"` + } + if err := json.Unmarshal(raw, &rawCases); err != nil { + t.Fatal(err) + } + for i := range wrapper.UpdatePin.Cases { + _, present := rawCases.UpdatePin.Cases[i]["trackingRef"] + wrapper.UpdatePin.Cases[i].HasTrackingRef = present + } + for _, c := range wrapper.UpdatePin.Cases { + ran++ + t.Run(e.Name()+"/"+c.ID, func(t *testing.T) { + runUpdatePinCase(t, e.Name(), *wrapper.UpdatePin, c) + }) + } + } + if ran == 0 { + t.Fatal("no updatePin block found in fixtures") + } +} + +func runUpdatePinCase(t *testing.T, fixtureName string, block updatePinBlock, c updatePinCase) { + t.Helper() + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + host := filepath.Join(dir, "host") + buildAcmeSibling(t, sibling) + copyFixture(t, fixtureName, host) + repin(t, host, c.Pin, c.HasTrackingRef && c.TrackingRef == nil) + if c.Store != nil { + buildStore(t, host, sibling, *c.Store) + } + if c.Hint { + if err := os.MkdirAll(filepath.Join(host, ".leji"), 0o755); err != nil { + t.Fatal(err) + } + hint, _ := json.Marshal(map[string]any{"mounts": map[string]any{block.Mount: map[string]string{"repo": sibling}}}) + if err := os.WriteFile(filepath.Join(host, ".leji", "mounts.local.json"), append(hint, '\n'), 0o644); err != nil { + t.Fatal(err) + } + } + // The declared source is a locator no test may actually reach, so it is routed + // at git's own level: to the recipe repository for a run that must succeed, and + // to a path that does not exist for one that must fail. + routed := "" + switch c.Source { + case "local": + routed = sibling + case "unreachable": + routed = filepath.Join(dir, "never-created") + } + + manifestPath := filepath.Join(host, "leji.json") + before, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + var code int + var stdout string + withSourceRoutedTo(t, routed, func() { + code, stdout, _ = captureRun(t, append(append([]string{}, c.Args...), "--root", host, "--json")) + }) + if code != c.Exit { + t.Fatalf("%s: exit %d, want %d (%s)", c.ID, code, c.Exit, stdout) + } + + after, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + if c.Action == nil { + // A usage error reports no outcome at all, and touches nothing. + if strings.TrimSpace(stdout) != "" { + t.Fatalf("%s: no document, got %q", c.ID, stdout) + } + if string(after) != string(before) { + t.Fatalf("%s: nothing written", c.ID) + } + return + } + var keyed map[string]json.RawMessage + if err := json.Unmarshal([]byte(stdout), &keyed); err != nil { + t.Fatalf("%s: %v (%s)", c.ID, err, stdout) + } + wantKeys := append([]string{}, documentKeys...) + if c.Reason != nil { + wantKeys = append(wantKeys, "reason") + } + sort.Strings(wantKeys) + gotKeys := make([]string, 0, len(keyed)) + for k := range keyed { + gotKeys = append(gotKeys, k) + } + sort.Strings(gotKeys) + if strings.Join(gotKeys, ",") != strings.Join(wantKeys, ",") { + t.Fatalf("%s: the exact JSON key set: got %v want %v", c.ID, gotKeys, wantKeys) + } + var doc updatePinDocument + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatal(err) + } + if doc.Command != "mounts update-pin" { + t.Fatalf("%s: command = %q", c.ID, doc.Command) + } + if doc.Action != *c.Action { + t.Fatalf("%s: action = %q, want %q", c.ID, doc.Action, *c.Action) + } + if doc.Override != c.Override { + t.Fatalf("%s: override = %v", c.ID, doc.Override) + } + if strOr(doc.Reason, "") != strOr(c.Reason, "") { + t.Fatalf("%s: reason = %v, want %v", c.ID, doc.Reason, c.Reason) + } + if strOr(doc.Mount.From, "") != strOr(c.From, "") { + t.Fatalf("%s: from = %v, want %v", c.ID, doc.Mount.From, c.From) + } + if strOr(doc.Mount.To, "") != strOr(c.To, "") { + t.Fatalf("%s: to = %v, want %v", c.ID, doc.Mount.To, c.To) + } + if doc.OK != (c.Reason == nil) { + t.Fatalf("%s: ok tracks the refusal", c.ID) + } + wantErrors, wantWarnings := 0, 0 + if c.Reason != nil { + wantErrors = 1 + } + if c.Override { + wantWarnings = 1 + } + if doc.Summary.Errors != wantErrors || doc.Summary.Warnings != wantWarnings { + t.Fatalf("%s: summary = %+v", c.ID, doc.Summary) + } + // The findings are what the block pins, never read off the document: a refusal + // names its reason code, an override warns under its own. + type triple struct{ rule, severity, path string } + var want []triple + if c.Reason != nil { + want = append(want, triple{*c.Reason, "error", doc.Mount.Name}) + } + if c.Override { + want = append(want, triple{"mount-pin-non-fast-forward-override", "warning", doc.Mount.Name}) + } + sort.Slice(want, func(i, j int) bool { return want[i].rule < want[j].rule }) + var got []triple + for _, f := range doc.Findings { + got = append(got, triple{f.Rule, f.Severity, f.Path}) + } + if len(got) != len(want) { + t.Fatalf("%s: findings = %+v, want %+v", c.ID, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("%s: findings = %+v, want %+v", c.ID, got, want) + } + } + if c.ComparisonRepository != nil && doc.PinReport["comparisonRepository"] != *c.ComparisonRepository { + t.Fatalf("%s: comparisonRepository = %v", c.ID, doc.PinReport["comparisonRepository"]) + } + if c.ComparedRef != nil && doc.PinReport["comparedRef"] != *c.ComparedRef { + t.Fatalf("%s: comparedRef = %v", c.ID, doc.PinReport["comparedRef"]) + } + + if c.ManifestGolden != nil { + golden, err := os.ReadFile(filepath.Join(repoRoot(t), "fixtures", filepath.FromSlash(*c.ManifestGolden))) + if err != nil { + t.Fatal(err) + } + if string(after) != string(golden) { + t.Fatalf("%s: the written manifest bytes\n got=%q\nwant=%q", c.ID, after, golden) + } + } + // `written: false` is one claim: the manifest is byte-identical to the manifest + // this run started from. + if !c.Written && string(after) != string(before) { + t.Fatalf("%s: leji.json is byte-untouched", c.ID) + } + // A --fetch run does the store acts it was asked for even when the rewrite is + // suppressed: dry-run withholds the manifest, not the fetch. + if c.ID == "dry-run-fetch" { + store := storePath(t, host) + if _, err := os.Stat(store); err != nil { + t.Fatalf("%s: the managed store was established: %v", c.ID, err) + } + if got := recipeGit(t, store, "rev-parse", mounts.PinRefFor(acmeIdentity(t), oidA)); got != oidA { + t.Fatalf("%s: the pin was retained, got %s", c.ID, got) + } + } + // Every fetch this command makes passes --no-write-fetch-head, so a run that + // reached the source leaves no per-run record inside the store. + if c.Source == "local" { + if _, err := os.Stat(filepath.Join(storePath(t, host), "FETCH_HEAD")); !os.IsNotExist(err) { + t.Fatalf("%s: no FETCH_HEAD", c.ID) + } + } +} + +// --- the branches no fixture can construct ------------------------------------ + +func TestUpdatePinDeclarationChangedUnderTheRunIsRefused(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) + pin, witnessRef, witnessOid := oidA, "refs/heads/main", oidB + buildStore(t, host, sibling, storeSpec{Pin: &pin, WitnessRef: &witnessRef, WitnessOid: &witnessOid}) + load := manifest.LoadManifest(host) + if load.Manifest == nil { + t.Fatalf("manifest: %v", load.Findings) + } + // The comparison runs against the manifest object in hand; the file changes its + // `source` before the verified read the rewrite makes. + mp := filepath.Join(host, "leji.json") + original, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + moved := strings.Replace(string(original), acmeSource, "https://github.com/acme/moved-context", 1) + if err := os.WriteFile(mp, []byte(moved), 0o644); err != nil { + t.Fatal(err) + } + r, err := updatepin.Run(host, load.Manifest, updatepin.Options{Name: "product-context"}) + if err != nil { + t.Fatal(err) + } + if r.Action != "refused" || r.Reason != "mount-declaration-changed" { + t.Fatalf("action=%q reason=%q", r.Action, r.Reason) + } + now, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if string(now) != moved { + t.Fatal("the manifest was rewritten under a changed declaration") + } +} + +// TestUpdatePinTrackingRefPresenceIsPartOfTheDeclaration pins the half of the +// freshness gate a Go string cannot hold on its own: an ABSENT `trackingRef` that +// reappears — as `null`, as `""`, or as a real ref — is a changed declaration, and +// so is a declared one that disappears. Splicing the pin into any of them would +// write a mount the schema no longer accepts, or one compared against a ref it +// never spelled. +func TestUpdatePinTrackingRefPresenceIsPartOfTheDeclaration(t *testing.T) { + dir := t.TempDir() + sibling := filepath.Join(dir, "sibling") + buildAcmeSibling(t, sibling) + + // Absent at load, present on the verified reread: three spellings, all refused. + // The run needs --fetch, because an absent ref is what the advertised default is + // resolved for — which is the only way this branch is reachable at all. + for _, spelling := range []string{"null", `""`, `"refs/heads/main"`} { + t.Run("absent then "+spelling, func(t *testing.T) { + host := filepath.Join(dir, "reappears-"+strings.Map(func(r rune) rune { + if r == '"' || r == '/' { + return -1 + } + return r + }, spelling)) + copyFixture(t, "warn-update-pin", host) + repin(t, host, oidA, true) + if err := os.MkdirAll(filepath.Join(host, ".leji"), 0o755); err != nil { + t.Fatal(err) + } + hint, _ := json.Marshal(map[string]any{"mounts": map[string]any{"product-context": map[string]string{"repo": sibling}}}) + if err := os.WriteFile(filepath.Join(host, ".leji", "mounts.local.json"), append(hint, '\n'), 0o644); err != nil { + t.Fatal(err) + } + load := manifest.LoadManifest(host) + if load.Manifest == nil { + t.Fatalf("manifest: %v", load.Findings) + } + // The member reappears between the comparison and the verified read. + mp := filepath.Join(host, "leji.json") + original, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + anchor := `"pin": "` + oidA + `",` + changed := strings.Replace(string(original), anchor, anchor+"\n \"trackingRef\": "+spelling+",", 1) + if changed == string(original) { + t.Fatal("the pin anchor must be there to splice against") + } + if err := os.WriteFile(mp, []byte(changed), 0o644); err != nil { + t.Fatal(err) + } + var r updatepin.Result + var rerr error + withSourceRoutedTo(t, sibling, func() { + r, rerr = updatepin.Run(host, load.Manifest, updatepin.Options{Name: "product-context", Fetch: true}) + }) + if rerr != nil { + t.Fatal(rerr) + } + if r.Action != "refused" || r.Reason != "mount-declaration-changed" { + t.Fatalf("action=%q reason=%q", r.Action, r.Reason) + } + now, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if string(now) != changed { + t.Fatal("the manifest was rewritten under a changed declaration") + } + }) + } + + // And the other direction: declared at load, gone on the verified reread. + t.Run("declared then absent", func(t *testing.T) { + host := filepath.Join(dir, "disappears") + copyFixture(t, "warn-update-pin", host) + repin(t, host, oidA, false) + pin, witnessRef, witnessOid := oidA, "refs/heads/main", oidB + buildStore(t, host, sibling, storeSpec{Pin: &pin, WitnessRef: &witnessRef, WitnessOid: &witnessOid}) + load := manifest.LoadManifest(host) + if load.Manifest == nil { + t.Fatalf("manifest: %v", load.Findings) + } + mp := filepath.Join(host, "leji.json") + original, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + dropped := trackingRefRe.ReplaceAllString(string(original), "\n") + if dropped == string(original) { + t.Fatal("the declared trackingRef must be there to drop") + } + if err := os.WriteFile(mp, []byte(dropped), 0o644); err != nil { + t.Fatal(err) + } + r, rerr := updatepin.Run(host, load.Manifest, updatepin.Options{Name: "product-context"}) + if rerr != nil { + t.Fatal(rerr) + } + if r.Action != "refused" || r.Reason != "mount-declaration-changed" { + t.Fatalf("action=%q reason=%q", r.Action, r.Reason) + } + now, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if string(now) != dropped { + t.Fatal("the manifest was rewritten under a changed declaration") + } + }) +} + +func TestUpdatePinTargetRetentionFailureRefusesWithTheManifestUntouched(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) + if err := os.MkdirAll(filepath.Join(host, ".leji"), 0o755); err != nil { + t.Fatal(err) + } + hint, _ := json.Marshal(map[string]any{"mounts": map[string]any{"product-context": map[string]string{"repo": sibling}}}) + if err := os.WriteFile(filepath.Join(host, ".leji", "mounts.local.json"), append(hint, '\n'), 0o644); err != nil { + t.Fatal(err) + } + mp := filepath.Join(host, "leji.json") + before, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + // By the time the TARGET is retained the store already holds it, so the fetch + // never runs and only the ref update can fail: the injection is the branch's one + // reachable path. It names the TARGET, so retaining the current pin — the act + // before the gate — still succeeds and the refusal is unambiguous. + t.Setenv("LEJI_TEST_FAIL_PIN_REF", oidB) + 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 doc.Action != "refused" || strOr(doc.Reason, "") != "mount-store-fetch-failed" { + t.Fatalf("action=%q reason=%v", doc.Action, doc.Reason) + } + if strOr(doc.Mount.To, "") != oidB { + t.Fatalf("the target it declined to retain is still reported: %v", doc.Mount.To) + } + if len(doc.Findings) != 1 || doc.Findings[0].Rule != "mount-store-fetch-failed" { + t.Fatalf("findings = %+v", doc.Findings) + } + after, err := os.ReadFile(mp) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("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. + if got := recipeGit(t, storePath(t, host), "rev-parse", mounts.PinRefFor(acmeIdentity(t), oidA)); got != oidA { + t.Fatalf("the current pin is still retained, got %s", got) + } +} + +func TestUpdatePinScannerRefusalReachesTheCLIAtExitTwo(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) + pin, witnessRef, witnessOid := oidA, "refs/heads/main", oidB + buildStore(t, host, sibling, storeSpec{Pin: &pin, WitnessRef: &witnessRef, WitnessOid: &witnessOid}) + // The declaration check fires first, so the scanner's own refusal needs the name + // to still match while the pin does not. + text, err := os.ReadFile(filepath.Join(host, "leji.json")) + if err != nil { + t.Fatal(err) + } + if _, _, err := manifest.ReplaceMountPinInManifestText(string(text), "product-context", oidS, oidB); err == nil || + !strings.Contains(err.Error(), `pin of mount "product-context" is not`) { + t.Fatalf("the scanner must refuse a span that does not hold `from`: %v", err) + } + // And a malformed --to never reaches the scanner at all. + code, stdout, _ := captureRun(t, []string{ + "mounts", "update-pin", "product-context", "--to", strings.Repeat("z", 40), "--root", host, "--json", + }) + if code != 2 || strings.TrimSpace(stdout) != "" { + t.Fatalf("exit %d, stdout %q", code, stdout) + } +} + +// --- the CLI surface ---------------------------------------------------------- + +func TestUpdatePinMountsSubGuardAcceptsUpdatePinAndRejectsEverythingElse(t *testing.T) { + dir := t.TempDir() + host := filepath.Join(dir, "host") + copyFixture(t, "warn-update-pin", host) + // Accepted spellings reach their command (never the sub-guard's exit 2)… + for _, sub := range []string{"hydrate", "status", "locate", "update-pin"} { + argv := []string{"mounts", sub} + if sub == "locate" || sub == "update-pin" { + argv = append(argv, "product-context") + } + argv = append(argv, "--root", host) + if code, _, _ := captureRun(t, argv); code == 2 { + t.Fatalf("%s must reach its command", strings.Join(argv, " ")) + } + } + // …and every other spelling, including a bare `mounts`, is the guard. + for _, sub := range [][]string{{}, {"nope"}, {"update"}, {"updatepin"}, {"update-pins"}, {"Update-Pin"}} { + argv := append(append([]string{"mounts"}, sub...), "--root", host) + if code, _, _ := captureRun(t, argv); code != 2 { + t.Fatalf("mounts %s must be refused", strings.Join(sub, " ")) + } + } +} + +func TestUpdatePinTakesOnePositionalAndOnlyItsDeclaredFlags(t *testing.T) { + dir := t.TempDir() + host := filepath.Join(dir, "host") + copyFixture(t, "warn-update-pin", host) + up := []string{"mounts", "update-pin", "product-context"} + // The positional budget gains this command's one name, as `mounts locate` has. + if code, _, _ := captureRun(t, append(append([]string{}, up...), "--root", host)); code == 2 { + t.Fatal("one positional is this command's budget") + } + for _, argv := range [][]string{ + append(append([]string{}, up...), "surplus"), + {"mounts", "update-pin"}, + append(append([]string{}, up...), "--check-integrity"), + append(append([]string{}, up...), "--strict"), + append(append([]string{}, up...), "--endpoint", "x"), + {"mounts", "status", "--to", oidB}, + {"mounts", "status", "--allow-non-fast-forward"}, + // The override is meaningless without a named target, and says so. + append(append([]string{}, up...), "--allow-non-fast-forward"), + } { + if code, _, _ := captureRun(t, append(append([]string{}, argv...), "--root", host)); code != 2 { + t.Fatalf("%s must be a usage error", strings.Join(argv, " ")) + } + } + // Flags declared on this command are accepted. + if code, _, _ := captureRun(t, append(append([]string{}, up...), "--dry-run", "--fetch", "--root", host)); code == 2 { + t.Fatal("--dry-run --fetch are this command's own flags") + } + // `--to` takes a full lowercase hex commit id in either spelling, and nothing else. + for _, good := range [][]string{{"--to=" + oidB}, {"--to", strings.Repeat("0", 64)}} { + argv := append(append(append([]string{}, up...), good...), "--root", host) + if code, _, _ := captureRun(t, argv); code == 2 { + t.Fatalf("--to %v must be accepted", good) + } + } + for _, bad := range []string{"xyz", oidB[:12], strings.ToUpper(oidB), strings.Repeat("0", 41), strings.Repeat("0", 63), "", "--json"} { + argv := append(append([]string{}, up...), "--to", bad, "--root", host) + if code, _, _ := captureRun(t, argv); code != 2 { + t.Fatalf("--to %q must be a usage error", bad) + } + } +} + +func TestUpdatePinHelpExitsZeroAndNamesNoNetworkDestination(t *testing.T) { + code, out, _ := captureRun(t, []string{"mounts", "update-pin", "--help"}) + if code != 0 { + t.Fatalf("exit %d", code) + } + if !strings.Contains(out, "leji mounts update-pin") { + t.Fatalf("help must name the command: %s", out) + } + // The only network vocabulary this command may carry is what `mounts hydrate` + // already documents: the declared source, and nothing addressable by the caller. + lower := strings.ToLower(out) + for _, banned := range []string{"endpoint", "token", "upload", "registry", "api.", "http://", "account"} { + if strings.Contains(lower, banned) { + t.Fatalf("help must not mention %q", banned) + } + } +} diff --git a/packages/sdk-go/internal/cli/usage.go b/packages/sdk-go/internal/cli/usage.go index 3660c78..a9d82e6 100644 --- a/packages/sdk-go/internal/cli/usage.go +++ b/packages/sdk-go/internal/cli/usage.go @@ -1,49 +1,182 @@ package cli import ( + "regexp" "strings" "unicode/utf8" "github.com/leji-org/leji/packages/sdk-go/internal/schemas" ) +// Terminal help wraps at a fixed width, never the actual terminal's: help bytes +// are a shared contract across the three SDKs, so they may not depend on the +// environment. Mirrors HELP_WIDTH in index.ts. +const helpWidth = 80 + +// paragraphBreak splits a description into paragraphs, mirroring the TS +// `/\n[ \t]*\n/` split. +var paragraphBreak = regexp.MustCompile(`\n[ \t]*\n`) + +// wrap is the one line-wrapper behind every terminal help surface, so the three +// SDKs emit the same bytes: whitespace runs collapse to one space, the first line +// is indented by indentFirst and every continuation by indentRest, and width is +// counted in RUNES, never bytes. A token that cannot fit the remaining width takes +// a line of its own, unbroken (URLs and flag spellings stay copyable). Empty text +// yields no lines. Mirrors wrap() in lib/text.ts. +func wrap(text string, width, indentFirst, indentRest int) []string { + words := strings.Fields(text) + if len(words) == 0 { + return nil + } + var lines []string + indent := indentFirst + current := "" + for _, word := range words { + room := width - indent - utf8.RuneCountInString(current) + switch { + case current == "": + current = word + case utf8.RuneCountInString(word)+1 <= room: + current += " " + word + default: + lines = append(lines, strings.Repeat(" ", indent)+current) + indent = indentRest + current = word + } + } + return append(lines, strings.Repeat(" ", indent)+current) +} + +// helpRow renders one row of a two-column help block: a label on the left, its prose +// on the right, the prose hanging under itself at col. A label that would leave no gap +// before its summary — one at least as wide as the column, which the option column's +// clamp makes reachable — takes the line alone and its summary starts on the next line +// at the same column, so a long flag never concatenates into the text describing it. +// Mirrors helpRow() in lib/text.ts. +func helpRow(label string, col int, text string) []string { + lines := wrap(text, helpWidth, col, col) + if utf8.RuneCountInString(label) >= col-3 { + head := " " + label + if len(lines) == 0 { + return []string{head} + } + return append([]string{head}, lines...) + } + head := " " + pad(label, col-3) + if len(lines) == 0 { + return []string{strings.TrimRight(head, " ")} + } + // Every wrapped line opens with `col` ASCII spaces, so trimming that many bytes + // off the first one is exactly the indent. + return append([]string{head + lines[0][col:]}, lines[1:]...) +} + +// boundedColumn is where a two-column block's right column starts: the longest label +// plus a gap, kept inside a band so one long label cannot push every summary to the +// right edge, and measured in RUNES. Past the band's top the label outgrows the column +// and helpRow gives it its own line. Every dynamic label class in terminal help +// resolves its column here. Mirrors boundedColumn() in lib/text.ts. +func boundedColumn(labels []string, gap, min, max int) int { + longest := 0 + for _, l := range labels { + if n := utf8.RuneCountInString(l); n > longest { + longest = n + } + } + col := longest + gap + if col < min { + col = min + } + if col > max { + col = max + } + return 3 + col +} + +// optionColumn: option rows, top-level and per-command, flags plus 3, bounded [20, 30]. +func optionColumn(options []schemas.CliOption) int { + labels := make([]string, len(options)) + for i, o := range options { + labels[i] = o.Flags + } + return boundedColumn(labels, 3, 20, 30) +} + +// nameColumn: command and alias rows, the name plus 3, bounded [12, 30]. +func nameColumn(commands []schemas.CliCommand) int { + labels := make([]string, len(commands)) + for i, c := range commands { + labels[i] = c.Name + } + return boundedColumn(labels, 3, 12, 30) +} + +// exitCodeColumn: exit-code rows, the code plus 2 (digits, not words), bounded [3, 8]. +func exitCodeColumn(codes []schemas.CliExitCode) int { + labels := make([]string, len(codes)) + for i, e := range codes { + labels[i] = e.Code.String() + } + return boundedColumn(labels, 2, 3, 8) +} + // BuildUsage renders the top-level terminal help from cli.json (so it cannot -// drift from the docs site). Lists commands and global options only; per-command -// options live in `leji --help`. Mirrors renderUsage() in index.ts. +// drift from the docs site): the commands by group, the global options, and the +// exit codes. Per-command options live in `leji --help`. Mirrors +// renderUsage() in index.ts. func BuildUsage() string { spec, err := schemas.LoadCliSpec() if err != nil { return "leji " + schemas.SDKVersion } - out := []string{ - "leji " + schemas.SDKVersion + ": reference CLI for the Leji specification (spec line " + - strings.Join(schemas.SupportedLines, ", ") + ")", - "", - "Usage: " + spec.Usage, - "", - "Commands:", - } - cmdWidth := 0 - for _, c := range spec.Commands { - if utf8.RuneCountInString(c.Name) > cmdWidth { - cmdWidth = utf8.RuneCountInString(c.Name) - } - } - cmdWidth += 3 - for _, c := range spec.Commands { - out = append(out, " "+pad(c.Name, cmdWidth)+c.Summary) - } + return buildUsage(spec) +} - optWidth := 0 - for _, o := range spec.GlobalOptions { - if utf8.RuneCountInString(o.Flags) > optWidth { - optWidth = utf8.RuneCountInString(o.Flags) +// buildUsage renders one spec, so the bounds can be exercised against a synthetic one. +func buildUsage(spec schemas.CliSpec) string { + // Every emitted field goes through the wrapper, including the ones no current value + // is long enough to overflow: a longer version string or group title must not be + // what discovers that a line was never wrapped. + out := wrap("leji "+schemas.SDKVersion+": reference CLI for the Leji specification (spec line "+ + strings.Join(schemas.SupportedLines, ", ")+")", helpWidth, 0, 3) + out = append(out, "") + out = append(out, wrap("Usage: "+spec.Usage, helpWidth, 0, 7)...) + + // One name column across every group, so the summaries line up down the whole + // list rather than jumping per section. + cmdCol := nameColumn(spec.Commands) + for _, g := range spec.Groups { + out = append(out, "") + out = append(out, wrap(g.Title+":", helpWidth, 0, 0)...) + for _, c := range spec.Commands { + if c.Group != g.ID || c.AliasOf != "" { + continue + } + out = append(out, helpRow(c.Name, cmdCol, c.Summary)...) + // An alias earns a line under its primary, not a row of its own: it is + // the same command, and repeating the summary reads as a second one. It + // keeps the name column, so the right-hand column stays straight down + // the whole list. + for _, a := range spec.Commands { + if a.AliasOf == c.Name { + out = append(out, helpRow(a.Name, cmdCol, "(alias of "+c.Name+")")...) + } + } } } - optWidth += 3 + + optCol := optionColumn(spec.GlobalOptions) out = append(out, "", "Options:") for _, o := range spec.GlobalOptions { - out = append(out, " "+pad(o.Flags, optWidth)+o.Summary) + out = append(out, helpRow(o.Flags, optCol, o.Summary)...) + } + + // The meaning hangs under itself, like every other two-column block here, so a + // continuation line is never mistaken for another code. + codeCol := exitCodeColumn(spec.ExitCodes) + out = append(out, "", "Exit codes:") + for _, e := range spec.ExitCodes { + out = append(out, helpRow(e.Code.String(), codeCol, e.Meaning)...) } out = append(out, @@ -54,14 +187,21 @@ func BuildUsage() string { return strings.Join(out, "\n") } -// BuildCommandHelp renders per-command help from cli.json. The bool is false -// when name is not a documented command, so the caller falls back to top-level -// usage. Mirrors renderCommandHelp() in index.ts. +// BuildCommandHelp renders per-command help from cli.json: this command's own +// options only, with the globals one pointer away. The bool is false when name is +// not a documented command, so the caller falls back to top-level usage. Mirrors +// renderCommandHelp() in index.ts. func BuildCommandHelp(name string) (string, bool) { spec, err := schemas.LoadCliSpec() if err != nil { return "", false } + return buildCommandHelp(name, spec) +} + +// buildCommandHelp renders one spec, so the bounds can be exercised against a +// synthetic one. +func buildCommandHelp(name string, spec schemas.CliSpec) (string, bool) { var cmd *schemas.CliCommand for i := range spec.Commands { if spec.Commands[i].Name == name { @@ -72,38 +212,27 @@ func BuildCommandHelp(name string) (string, bool) { if cmd == nil { return "", false } - out := []string{ - "leji " + cmd.Name + ": " + cmd.Summary, - "", - "Usage: " + cmd.Usage, - "", - cmd.Description, + out := wrap("leji "+cmd.Name+": "+cmd.Summary, helpWidth, 0, 3) + out = append(out, "") + out = append(out, wrap("Usage: "+cmd.Usage, helpWidth, 0, 7)...) + for _, para := range paragraphBreak.Split(cmd.Description, -1) { + out = append(out, "") + out = append(out, wrap(para, helpWidth, 0, 0)...) } if len(cmd.Details) > 0 { out = append(out, "", "Details:") for _, d := range cmd.Details { - out = append(out, " - "+d) + out = append(out, wrap("- "+d, helpWidth, 3, 5)...) } } - opts := append(append([]schemas.CliOption{}, spec.GlobalOptions...), cmd.Options...) - optWidth := 0 - for _, o := range opts { - if utf8.RuneCountInString(o.Flags) > optWidth { - optWidth = utf8.RuneCountInString(o.Flags) - } - } - optWidth += 3 - out = append(out, "", "Options:") - for _, o := range opts { - summary := o.Summary - if summary == "" { - // Mirrors Node byte-for-byte: an option that declares only a - // description (the mounts options) renders `${o.summary}` as the - // literal string "undefined" in the template. - summary = "undefined" + if len(cmd.Options) > 0 { + optCol := optionColumn(cmd.Options) + out = append(out, "", "Options:") + for _, o := range cmd.Options { + out = append(out, helpRow(o.Flags, optCol, o.Summary)...) } - out = append(out, " "+pad(o.Flags, optWidth)+summary) } + out = append(out, "", "Global options: see leji --help.") if len(cmd.Examples) > 0 { out = append(out, "", "Examples:") for _, e := range cmd.Examples { diff --git a/packages/sdk-go/internal/commands/badge/badge.go b/packages/sdk-go/internal/commands/badge/badge.go new file mode 100644 index 0000000..d49d99f --- /dev/null +++ b/packages/sdk-go/internal/commands/badge/badge.go @@ -0,0 +1,327 @@ +// Package badge implements `leji badge`: the local, self-attested conformance +// badge. The command scores the layer with conformance.Report (federation never +// verified, so the run is offline by construction), renders the canonical SVG for +// the level THIS run verified, and returns the markdown that embeds it. There is +// no endpoint, no registry, and no hosted service anywhere in this package or the +// ones it imports: the bytes are constants, and the only thing that varies is +// which level's constants are used. +// +// The four files under `fixtures/badge/` are the byte oracle for everything here, +// and `fixtures/README.md` -> "The `badge` block" is the normative contract the +// three SDKs implement. Mirrors packages/sdk/src/commands/badge.ts. +package badge + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" + "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/manifest" +) + +// DefaultOut is the badge target when `--out` is not given: a repository-root +// file, one copy-paste from a root README. +const DefaultOut = "leji-badge.svg" + +// agentReadyURL is the page the markdown wrapper links. One constant, never +// configurable. +const agentReadyURL = "https://leji.org/agent-ready/" + +// OutRule is the `--out` acceptance rule, quoted verbatim by the usage error that +// rejects a path (`fixtures/README.md` -> "The `--out` acceptance rule"). +const OutRule = "--out takes a repository-relative POSIX path over [A-Za-z0-9._/-], with no leading /, no backslash, " + + `no ".." segment, no empty segment, and ending .svg` + +// markPath is the mark: the single `` of `packages/site/src/assets/leji-icon.svg` +// (viewBox `0 0 370 391`), inlined as a constant rather than read at runtime. The +// badge is a frozen byte contract, so it can never depend on a file a caller could +// replace or a package could ship differently. +const markPath = "M185.038 77.918C162.621 77.942 144.384 96.185 144.372 118.607C144.382 136.031 155.422 150.887 170.856 156.67V305.245H199.225V156.671C214.663 150.888 225.707 136.031 225.724 118.608C225.703 96.184 207.46 77.942 185.038 77.918ZM185.043 130.9C178.268 130.896 172.747 125.372 172.747 118.607C172.747 111.833 178.262 106.318 185.037 106.303C191.816 106.318 197.337 111.832 197.337 118.607C197.337 125.372 191.811 130.896 185.043 130.9ZM349.766 22.16C336.469 8.72897 317.174 0.943 295.071 0H74.715C52.613 0.943 33.319 8.72897 20.021 22.16C7.09602 35.134 -0.0149763 52.521 2.36824e-05 71.128C2.36824e-05 87.589 5.66601 103.074 15.951 114.726C26.651 126.955 42.597 134.172 59.95 134.713C63.415 134.798 81.128 134.812 97.081 127.028V311.413C81.642 317.196 70.595 332.056 70.585 349.48C70.597 371.898 88.841 390.139 111.255 390.156C133.679 390.139 151.919 371.898 151.935 349.48C151.923 332.055 140.88 317.2 125.443 311.417V58.449H244.589V211.559C229.155 217.342 218.114 232.193 218.105 249.622C218.118 272.053 236.357 290.295 258.779 290.295C281.193 290.295 299.431 272.053 299.453 249.622C299.437 232.193 288.39 217.338 272.957 211.559V127.147C288.846 134.809 306.393 134.797 309.84 134.712C327.192 134.171 343.138 126.953 353.838 114.725C364.123 103.074 369.789 87.588 369.789 71.127C369.801 52.521 362.692 35.134 349.766 22.16ZM111.261 361.782C104.487 361.763 98.965 356.247 98.959 349.491C98.965 342.705 104.486 337.187 111.254 337.187C118.024 337.187 123.543 342.709 123.559 349.476C123.543 356.247 118.016 361.764 111.261 361.782ZM258.786 261.931C252.005 261.917 246.483 256.392 246.483 249.622C246.483 242.843 252.004 237.334 258.778 237.334C265.542 237.334 271.063 242.847 271.079 249.612C271.063 256.386 265.542 261.917 258.786 261.931ZM332.597 95.914C326.347 102.87 318.107 106.295 307.41 106.378C288.412 106.165 281.539 100.531 277.324 95.052C275.131 92.123 273.783 88.628 272.955 85.317V58.45H300.57C303.603 58.45 306.808 59.782 307.015 59.87C308.813 60.684 310.429 61.838 311.277 63.098C311.993 64.174 312.539 65.34 312.574 67.815C312.523 70.481 311.668 72.002 310.269 73.352C308.873 74.643 306.746 75.487 304.64 75.471C301.994 75.386 299.636 74.504 297.46 71.373C294.728 67.264 289.187 66.153 285.077 68.885C282.002 70.933 280.603 74.56 281.247 77.978C287.59 93.654 305.199 93.329 305.415 93.324C311.64 93.133 317.642 90.795 322.325 86.535C327.213 82.137 330.485 75.362 330.436 67.815C330.47 61.991 328.678 56.727 325.883 52.813C323.102 48.875 319.584 46.292 316.354 44.568C310.887 41.694 306.067 40.887 304.242 40.68H66.514C64.69 40.887 59.868 41.694 54.402 44.568C51.173 46.293 47.654 48.875 44.873 52.813C42.079 56.727 40.286 61.991 40.321 67.815C40.272 75.362 43.544 82.136 48.431 86.535C53.116 90.795 59.116 93.133 65.342 93.324C65.557 93.329 83.167 93.654 89.51 77.978C90.153 74.56 88.754 70.933 85.68 68.885C81.57 66.154 76.028 67.264 73.296 71.373C71.119 74.504 68.762 75.386 66.117 75.471C64.011 75.487 61.884 74.643 60.488 73.352C59.09 72.001 58.232 70.481 58.182 67.815C58.216 65.34 58.763 64.174 59.479 63.098C60.327 61.838 61.943 60.685 63.741 59.87C63.949 59.782 67.152 58.45 70.185 58.45H97.08V84.257C96.283 87.871 94.891 91.81 92.463 95.053C88.248 100.532 81.375 106.166 62.375 106.379C51.68 106.296 43.439 102.871 37.19 95.915C31.563 89.595 28.351 80.556 28.371 71.129C28.379 60.046 32.544 49.776 40.101 42.199C49.323 33.015 62.602 28.34 79.568 28.291H290.218C307.183 28.34 320.462 33.016 329.684 42.199C337.242 49.776 341.407 60.046 341.415 71.129C341.435 80.555 338.223 89.594 332.597 95.914Z" + +// statusTextLength is the one per-level constant: the wordmark's `textLength` is +// fixed at 41 and every other width derives from the status text's +// (`fixtures/README.md` -> "The canonical badge bytes"). Horizontal padding is 5 +// either side, the mark occupies a 14-wide slot with a 3-wide gap, so the identity +// segment is 5+14+3+41+5 = 68 and the status segment is `textLength + 10`. +var statusTextLength = map[string]int{ + "core": 24, + "indexed": 43, + "governed": 52, + "federated": 53, +} + +// identityWidth is the identity segment's fixed width, and wordmark the text it +// carries. +const identityWidth = 68 +const wordmark = "Leji 1.0" + +// Label is the accessible name and the markdown alt text: one string, three places. +// It carries the full self-attestation claim, which the badge FACE does not: the +// visible status segment is the level alone, and the claim stays structural — in the +// ``, the `aria-label`, and the markdown alt — with the linked agent-ready +// page carrying the story. +func Label(level string) string { + return wordmark + " · " + level + " · self-attested" +} + +// Render is the canonical badge for one level, byte for byte: shields-flat shape, +// height 20, rounded by a clipPath, the mark and wordmark on the `#183D3B` identity +// segment and `<level>` alone on the `#009F71` status segment. No XML declaration, no +// BOM, no comment, no timestamp, no version string; UTF-8, LF, one trailing newline. +// Compared against `fixtures/badge/<level>.svg` by unit test. +func Render(level string) string { + status := statusTextLength[level] + statusWidth := status + 10 + width := identityWidth + statusWidth + label := Label(level) + return fmt.Sprintf( + `<svg xmlns="http://www.w3.org/2000/svg" role="img" width="%d" height="20" aria-label="%s">`+"\n"+ + `<title>%s`+"\n"+ + ``+"\n"+ + ``+"\n"+ + ``+"\n"+ + ``+"\n"+ + ``+"\n"+ + ``+"\n"+ + ``+"\n"+ + `%s`+"\n"+ + `%s`+"\n"+ + ``+"\n"+ + ``+"\n", + width, label, label, width, identityWidth, identityWidth, statusWidth, markPath, + wordmark, status, level, + ) +} + +// Markdown is the one markdown line the command prints: the badge image, wrapped in +// a link to the agent-ready page. out is the canonical POSIX path, relative to the +// repository root, so a root README embeds it as written. +func Markdown(level, out string) string { + return "[![" + Label(level) + "](" + out + ")](" + agentReadyURL + ")\n" +} + +// canonicalBadges is every canonical badge of this contract, which is exactly what +// an existing file is recognized against: its own bytes, and no marker, sidecar, or +// state. +var canonicalBadges = func() []string { + out := make([]string, 0, len(manifest.ConformanceLevels)) + for _, level := range manifest.ConformanceLevels { + out = append(out, Render(level)) + } + return out +}() + +func isCanonicalBadge(bytes string) bool { + for _, b := range canonicalBadges { + if b == bytes { + return true + } + } + return false +} + +// The actions a run can report: what it did to the target file — wrote it (absent), +// left it unchanged (it already held these exact bytes), or overwrote another +// canonical badge of this contract, which is how a level change regenerates. +const ( + Wrote = "wrote" + Unchanged = "unchanged" + Overwrote = "overwrote" +) + +// Result is one `leji badge` run, in the shape the caller renders in either +// channel. A failed run carries Out, Level, Markdown and Action empty (JSON null) +// and says why in Findings; ClaimedLevel and VerifiedLevel are reported whatever +// the outcome, so a refusal is still honest about what the layer claims. Every +// string field is empty for "none", which the JSON channel emits as null; none of +// them has a legitimate empty value. +type Result struct { + Out string + Level string + ClaimedLevel string + VerifiedLevel string + Markdown string + Action string + Findings []findings.Finding + // UsageError is set when `--out` was rejected at argument parsing, before + // conformance ran: the caller prints this in the CLI's usage-error form and exits + // 2, reporting no level. + UsageError string + // Refusal is set when the target exists and is not a badge of this contract: exit + // 2, the file untouched. Reported after conformance, so the levels above are + // populated. + Refusal string +} + +// outCharset is the syntax half of the `--out` rule, on the spelling alone. +var outCharset = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) + +func acceptedOutSyntax(out string) bool { + if !outCharset.MatchString(out) { + return false + } + if strings.HasPrefix(out, "/") || !strings.HasSuffix(out, ".svg") { + return false + } + for _, seg := range strings.Split(out, "/") { + if seg == "" || seg == ".." { + return false + } + } + return true +} + +// canonicalOut is the canonical POSIX form of an accepted `--out`: the spelling +// with its `.` segments dropped, which is what stdout, `--json`, and the markdown +// carry. +func canonicalOut(out string) string { + segs := strings.Split(out, "/") + kept := segs[:0] + for _, seg := range segs { + if seg != "." { + kept = append(kept, seg) + } + } + return strings.Join(kept, "/") +} + +// checkOut is the `--out` check, run at argument parsing and BEFORE conformance: +// the syntax rule above, then containment of the RESOLVED path — inside the +// repository, never under `.leji/` at any depth (that tree is the tool's own domain +// and the badge is user content), and not a directory. Returns the usage-error text +// on a rejection, else the canonical relative path and the resolved absolute one. +func checkOut(rootAbs, out string) (rel, abs, usageError string) { + // The path is quoted by concatenation, never by %q: the reference interpolates + // the spelling as given, and a Go-escaped backslash would diverge on exactly the + // input the rule exists to reject. + if !acceptedOutSyntax(out) { + return "", "", OutRule + ` (got "` + out + `")` + } + rel = canonicalOut(out) + abs = filepath.Join(rootAbs, filepath.FromSlash(rel)) + resolved, ok := fsx.ResolvedPath(abs) + if !ok { + return "", "", `--out "` + rel + `" cannot be resolved (permission or I/O error)` + } + if !fsx.ResolvedWithinRoot(rootAbs, abs) { + return "", "", `--out "` + rel + `" must resolve inside the repository` + } + // No own role: the badge has no legitimate `.leji/` landing at any depth. + if verdict := layout.WritableTarget(rootAbs, resolved, ""); !verdict.OK { + return "", "", `--out "` + rel + `" resolves inside .leji/, the tool's own domain; the badge is user content` + } + if fsx.IsDir(resolved) { + return "", "", `--out "` + rel + `" is a directory` + } + return rel, abs, "" +} + +// Run runs `leji badge` over root, writing the badge for the level this offline run +// verified. The order is fixed and is part of the contract: `--out` is judged first +// (a usage error reports no level at all), then conformance decides whether there +// is anything honest to state, and only then does the existing target decide the +// action. Operational filesystem failures travel out as errors, which the CLI +// renders the way it renders every other one. +func Run(root, out string) (Result, error) { + rootAbs := fsx.GuardRoot(root) + rel, abs, usageError := checkOut(rootAbs, out) + if usageError != "" { + return Result{UsageError: usageError}, nil + } + + // Federation is never verified: the badge states what an offline run established, + // which is why it can sit below the claim and never above it. + report, err := conformance.Report(root, false) + if err != nil { + return Result{}, err + } + base := Result{ClaimedLevel: report.ClaimedLevel, VerifiedLevel: report.VerifiedLevel} + withFinding := func(f findings.Finding) Result { + r := base + r.Findings = findings.Sort(append(append([]findings.Finding{}, report.Findings...), f)) + return r + } + if findings.HasErrors(report.Findings) { + r := base + r.Findings = findings.Sort(report.Findings) + return r, nil + } + if report.VerifiedLevel == "" { + return withFinding(findings.New("badge-unverified", findings.Error, + "no level verified in this run; the badge states only what was verified", "leji.json")), nil + } + + level := report.VerifiedLevel + svg := Render(level) + + // Check-before-act, badge-side. checkOut judged the target as it was spelled at + // argument parsing; the read below and the write after it are separate acts, and a + // component of the path can become a symlink in between. So the boundary is + // re-established immediately before each act, on the RESOLVED path, by the shared + // rule itself: VerifiedTargetRead for the read, the guarded write for the write. + refuseTarget := func() Result { + r := withFinding(findings.New("badge-target-refused", findings.Error, + rel+" does not resolve to a regular file inside the repository", rel)) + r.Refusal = rel + " does not resolve to a regular file inside the repository; nothing was written" + return r + } + + // The existing target is read through the shared verified read: the standing entry + // decides its own kind (a directory, a socket, a link to one: refused, never + // written through), the resolved location is judged by the same rule the write + // below is judged by, and the bytes come from the descriptor proved to be that + // file. Absence is decided on the ORIGINAL entry, so a dangling link — standing, + // resolving nowhere — is a refusal rather than an absent target written through. + read, err := fsx.VerifiedTargetRead(rootAbs, abs, "") + if err != nil { + return Result{}, err + } + if read.Status == fsx.ReadRefused { + return refuseTarget(), nil + } + present := read.Status == fsx.ReadRegular + existing := "" + if present { + existing = string(read.Bytes) + } + if present && !isCanonicalBadge(existing) { + r := withFinding(findings.New("badge-target-foreign", findings.Error, rel+" is not a leji badge", rel)) + r.Refusal = rel + " exists and is not a leji badge; remove or rename it" + return r, nil + } + action := Wrote + if present { + action = Overwrote + if existing == svg { + action = Unchanged + } + } + if action != Unchanged { + // The guarded-write chokepoint re-resolves the target immediately before the + // write and answers the whole boundary — inside the repository, outside + // `.leji/` — so nothing here is inherited from the parse-time verdict. Parent + // directories are created only inside a write that happens: a run that writes + // nothing (a refusal, an unchanged target) establishes no directory either. + verdict, werr := fsx.WriteFileGuarded(rootAbs, abs, "", []byte(svg), fsx.WriteOptions{}) + if werr != nil { + return Result{}, werr + } + if !verdict.OK { + return refuseTarget(), nil + } + } + return Result{ + Out: rel, + Level: level, + ClaimedLevel: report.ClaimedLevel, + VerifiedLevel: level, + Markdown: Markdown(level, rel), + Action: action, + Findings: findings.Sort(report.Findings), + }, nil +} diff --git a/packages/sdk-go/internal/commands/changelog/changelog.go b/packages/sdk-go/internal/commands/changelog/changelog.go index ec55e24..36e78f1 100644 --- a/packages/sdk-go/internal/commands/changelog/changelog.go +++ b/packages/sdk-go/internal/commands/changelog/changelog.go @@ -3,8 +3,8 @@ package changelog import ( + "encoding/json" "fmt" - "os" "path/filepath" "reflect" "regexp" @@ -13,7 +13,6 @@ 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/layer" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" ) @@ -78,16 +77,20 @@ func today() string { // SeedChangelogIfMissing seeds the machine changelog when the layer claims // indexed (or higher) and the file is missing; this lets `leji index` complete // the indexed surface for a layer upgraded from core. Returns the seeded path, -// or "" when nothing was written. Never overwrites. +// or "" when nothing was written (not indexed, already present, or a symlink would +// escape the root). Never overwrites. +// +// "Missing" is decided by the exclusive create itself rather than by a pathname +// check, because a stat follows symlinks: a dangling link at the changelog name +// reads as absent and the seed would be created at the link's destination. The +// exclusive create judges the ORIGINAL entry, so any standing entry is the same +// already-present no-op an existing changelog is. func SeedChangelogIfMissing(root string, m *manifest.Manifest) (string, error) { if !manifest.LevelAtLeast(manifest.ClaimedLevel(m), "indexed") { return "", nil } rel := manifest.EffectiveChangelogPath(m) abs := filepath.Join(root, rel) - if fsx.IsFile(abs) || !fsx.ResolvesUnder(root, abs) { - return "", nil - } log := map[string]any{ "$schema": "https://leji.org/schemas/v1.0/context-changelog.schema.json", "schemaVersion": "1.0", @@ -103,11 +106,13 @@ func SeedChangelogIfMissing(root string, m *manifest.Manifest) (string, error) { }, }, } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + verdict, err := fsx.WriteFileGuarded(fsx.GuardRoot(root), abs, "", + []byte(serializeChangelog(log)), fsx.WriteOptions{Exclusive: true}) + if err != nil { return "", err } - if err := os.WriteFile(abs, []byte(serializeChangelog(log)), 0o644); err != nil { - return "", err + if !verdict.OK { + return "", nil } return rel, nil } @@ -121,7 +126,7 @@ var beforeDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // folded set is always a contiguous run from the oldest end. Folded entries are // dropped and a single compaction entry recording the removed count and id range // is appended. Survivors keep their original array order. -func CompactChangelog(root string, m *manifest.Manifest, opts CompactOptions) CompactResult { +func CompactChangelog(root string, m *manifest.Manifest, opts CompactOptions) (CompactResult, error) { rel := manifest.EffectiveChangelogPath(m) // Validate at the API level too: SDK callers must not fold with keep < 1 or a // malformed `before` date. @@ -130,25 +135,49 @@ func CompactChangelog(root string, m *manifest.Manifest, opts CompactOptions) Co Findings: []findings.Finding{findings.New("invalid-argument", findings.Error, "keep must be a positive integer", rel)}, Folded: 0, Kept: 0, Path: rel, - } + }, nil } if opts.HasBefore && !beforeDateRe.MatchString(opts.Before) { return CompactResult{ Findings: []findings.Finding{findings.New("invalid-argument", findings.Error, "before must be a YYYY-MM-DD date", rel)}, Folded: 0, Kept: 0, Path: rel, - } + }, nil } - data, parseFinding := layer.ReadJSONArtifact(root, rel) - if parseFinding != nil { - return CompactResult{Findings: []findings.Finding{*parseFinding}, Path: rel} + // Compaction rewrites the file it just read, so the bytes it folds come from the + // verified read rather than from a pathname read once and written again: a refusal + // (outside the layer root, a private role, an entry that is not a regular file) is + // reported exactly as an unreadable artifact, and nothing is written. + rootReal := fsx.GuardRoot(root) + // An operational read failure on an allowed path is the filesystem failing rather + // than the boundary refusing, so it travels out as an error and the command reports + // it, exactly as the reference lets it throw. Only containment, entry kind and + // verification become findings. + read, err := fsx.VerifiedTargetRead(rootReal, filepath.Join(root, rel), "") + if err != nil { + return CompactResult{}, err } - if data == nil { + if read.Status == fsx.ReadRefused { + return CompactResult{ + Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, + "artifact "+rel+" resolves outside the layer root", rel)}, + Path: rel, + }, nil + } + if read.Status == fsx.ReadAbsent { return CompactResult{ Findings: []findings.Finding{findings.New("changelog-required", findings.Error, "changelog "+rel+" does not exist", rel)}, Path: rel, - } + }, nil + } + var data any + if err := json.Unmarshal(read.Bytes, &data); err != nil { + return CompactResult{ + Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, + "invalid JSON: "+err.Error(), rel)}, + Path: rel, + }, nil } log, ok := data.(map[string]any) if !ok { @@ -156,7 +185,7 @@ func CompactChangelog(root string, m *manifest.Manifest, opts CompactOptions) Co Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, "changelog is not a JSON object", rel)}, Path: rel, - } + }, nil } var original []entry @@ -188,7 +217,7 @@ func CompactChangelog(root string, m *manifest.Manifest, opts CompactOptions) Co } if len(folded) == 0 { - return CompactResult{Findings: nil, Folded: 0, Kept: len(original), Path: rel} + return CompactResult{Findings: nil, Folded: 0, Kept: len(original), Path: rel}, nil } var survivors []entry @@ -270,27 +299,22 @@ func CompactChangelog(root string, m *manifest.Manifest, opts CompactOptions) Co next["entries"] = nextEntries abs := filepath.Join(root, rel) - if !fsx.ResolvesUnder(root, abs) { - return CompactResult{ - Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, - "changelog path "+rel+" resolves outside the layer root", rel)}, - Folded: 0, Kept: len(original), Path: rel, - } - } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + verdict, err := fsx.WriteFileGuarded(rootReal, abs, "", []byte(serializeChangelog(next)), fsx.WriteOptions{}) + if err != nil { return CompactResult{ Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, err.Error(), rel)}, Folded: 0, Kept: len(original), Path: rel, - } + }, nil } - if err := os.WriteFile(abs, []byte(serializeChangelog(next)), 0o644); err != nil { + if !verdict.OK { return CompactResult{ - Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, err.Error(), rel)}, - Folded: 0, Kept: len(original), Path: rel, - } + Findings: []findings.Finding{findings.New("artifact-parse", findings.Error, + "changelog path "+rel+" resolves outside the layer root", rel)}, + Folded: 0, Kept: len(original), Path: rel, + }, nil } - return CompactResult{Findings: nil, Folded: len(folded), Kept: len(nextEntries), Path: rel} + return CompactResult{Findings: nil, Folded: len(folded), Kept: len(nextEntries), Path: rel}, nil } // entryPtr returns a stable identity for an entry map (maps aren't comparable): diff --git a/packages/sdk-go/internal/commands/changelog/changelog_test.go b/packages/sdk-go/internal/commands/changelog/changelog_test.go index a8dc083..9076c5c 100644 --- a/packages/sdk-go/internal/commands/changelog/changelog_test.go +++ b/packages/sdk-go/internal/commands/changelog/changelog_test.go @@ -114,7 +114,7 @@ func TestCompactRejectsInvalidKeep(t *testing.T) { for _, keep := range []int{0, -1, -5} { dir := seedLayer(t, 5) before, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(changelogRel))) - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: keep, HasKeep: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: keep, HasKeep: true}) if res.Folded != 0 || res.Kept != 0 { t.Fatalf("keep=%d should not fold: %+v", keep, res) } @@ -134,7 +134,7 @@ func TestCompactRejectsInvalidKeep(t *testing.T) { func TestCompactRejectsMalformedBefore(t *testing.T) { for _, before := range []string{"2026-1-1", "nope", "2026/01/01", "20260101"} { dir := seedLayer(t, 5) - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Before: before, HasBefore: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Before: before, HasBefore: true}) if res.Folded != 0 { t.Fatalf("before=%q should not fold", before) } @@ -152,7 +152,7 @@ func TestCompactMissingChangelogIsRequired(t *testing.T) { if err := os.Remove(filepath.Join(dir, filepath.FromSlash(changelogRel))); err != nil { t.Fatal(err) } - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: 2, HasKeep: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: 2, HasKeep: true}) if res.Folded != 0 || !hasRule(res.Findings, "changelog-required") { t.Fatalf("expected changelog-required, got %+v", res) } @@ -162,7 +162,7 @@ func TestCompactNoOpWhenNothingFolds(t *testing.T) { // keep larger than the entry count folds nothing. dir := seedLayer(t, 5) before, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(changelogRel))) - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: 10, HasKeep: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: 10, HasKeep: true}) if res.Folded != 0 || len(res.Findings) != 0 || res.Kept != 5 { t.Fatalf("keep>count should be a clean no-op: %+v", res) } @@ -172,7 +172,7 @@ func TestCompactNoOpWhenNothingFolds(t *testing.T) { } // before earlier than every entry also folds nothing. - res = CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Before: "2025-01-01", HasBefore: true}) + res = compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Before: "2025-01-01", HasBefore: true}) if res.Folded != 0 || len(res.Findings) != 0 { t.Fatalf("before earlier than all should be a no-op: %+v", res) } @@ -180,7 +180,7 @@ func TestCompactNoOpWhenNothingFolds(t *testing.T) { func TestCompactByKeepFoldsOldest(t *testing.T) { dir := seedLayer(t, 10) - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: 4, HasKeep: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: 4, HasKeep: true}) if res.Folded != 6 || res.Kept != 5 { t.Fatalf("keep 4 of 10: folded=%d kept=%d", res.Folded, res.Kept) } @@ -211,7 +211,7 @@ func TestCompactByKeepFoldsOldest(t *testing.T) { func TestCompactByBeforeAndBothFlags(t *testing.T) { dir := seedLayer(t, 10) // before only: dates 2026-01-01..05 fold (strictly before 2026-01-06). - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Before: "2026-01-06", HasBefore: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Before: "2026-01-06", HasBefore: true}) if res.Folded != 5 { t.Fatalf("before only folded=%d", res.Folded) } @@ -224,7 +224,7 @@ func TestCompactByBeforeAndBothFlags(t *testing.T) { // both flags: intersection of keep 3 (folds e-01..e-07) and before 2026-01-04 // (folds e-01..e-03) is e-01..e-03. dir2 := seedLayer(t, 10) - res = CompactChangelog(dir2, loadManifest(t, dir2), CompactOptions{Keep: 3, HasKeep: true, Before: "2026-01-04", HasBefore: true}) + res = compactChangelog(t, dir2, loadManifest(t, dir2), CompactOptions{Keep: 3, HasKeep: true, Before: "2026-01-04", HasBefore: true}) if res.Folded != 3 { t.Fatalf("both flags folded=%d", res.Folded) } @@ -250,7 +250,7 @@ func TestCompactDedupesCompactionID(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(changelogRel)), append(lb, '\n'), 0o644); err != nil { t.Fatal(err) } - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: 2, HasKeep: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: 2, HasKeep: true}) if res.Folded == 0 { t.Fatal("expected a fold") } @@ -277,7 +277,7 @@ func TestCompactTiebreakByID(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(changelogRel)), append(lb, '\n'), 0o644); err != nil { t.Fatal(err) } - res := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: 1, HasKeep: true}) + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: 1, HasKeep: true}) if res.Folded != 2 { t.Fatalf("expected to fold 2, got %d", res.Folded) } @@ -385,3 +385,111 @@ func TestSerializeChangelogOrdering(t *testing.T) { t.Fatal("extra top-level key dropped") } } + +func TestSeedChangelogTreatsADanglingLinkAsPresent(t *testing.T) { + // A stat follows symlinks, so a dangling changelog link read as absent and the + // seed was created at the link's missing destination. The exclusive create judges + // the ORIGINAL entry, so any standing entry is the same no-op an existing + // changelog is. Mutation that reddens: decide "missing" with a stat again. + dir := seedLayer(t, 1) + link := filepath.Join(dir, filepath.FromSlash(changelogRel)) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink("never-created.json", link); err != nil { + t.Fatal(err) + } + + rel, err := SeedChangelogIfMissing(dir, loadManifest(t, dir)) + if err != nil || rel != "" { + t.Fatalf("a standing entry is never seeded through, got %q err %v", rel, err) + } + if _, err := os.Lstat(filepath.Join(dir, "docs", "never-created.json")); err == nil { + t.Fatal("the dangling link's destination must never be created") + } + st, err := os.Lstat(link) + if err != nil || st.Mode()&os.ModeSymlink == 0 { + t.Fatalf("the planted link must be left exactly as it was (%v)", err) + } +} + +func TestSeedChangelogRefusesALinkResolvingOutsideTheRepository(t *testing.T) { + dir := seedLayer(t, 1) + away := t.TempDir() + link := filepath.Join(dir, filepath.FromSlash(changelogRel)) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(away, "context-changelog.json"), link); err != nil { + t.Fatal(err) + } + + rel, err := SeedChangelogIfMissing(dir, loadManifest(t, dir)) + if err != nil || rel != "" { + t.Fatalf("nothing is seeded through a link that leaves the repository, got %q err %v", rel, err) + } + if _, err := os.Lstat(filepath.Join(away, "context-changelog.json")); err == nil { + t.Fatal("nothing may be written outside the root") + } +} + +func TestCompactRefusesAChangelogThatIsNotARegularFile(t *testing.T) { + // Compaction rewrites what it reads, so the bytes come from the verified read: a + // standing entry that is not a regular file is reported as an unreadable artifact + // and nothing is written. + dir := seedLayer(t, 4) + target := filepath.Join(dir, filepath.FromSlash(changelogRel)) + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + + res := compactChangelog(t, dir, loadManifest(t, dir), CompactOptions{Keep: 1, HasKeep: true}) + if !hasRule(res.Findings, "artifact-parse") || res.Folded != 0 { + t.Fatalf("a non-regular changelog must be refused, got %+v", res) + } +} + +// --- gate helpers ------------------------------------------------------------- +// These commands now carry an error channel, because an operational read failure on +// an allowed path propagates instead of being swallowed (the reference throws it). +// A test that does not construct such a failure asserts there is none. + +func compactChangelog(t *testing.T, root string, m *manifest.Manifest, opts CompactOptions) CompactResult { + t.Helper() + res, err := CompactChangelog(root, m, opts) + if err != nil { + t.Fatalf("CompactChangelog(%s): %v", root, err) + } + return res +} + +func TestCompactPropagatesAnOperationalReadFailure(t *testing.T) { + // Compaction rewrites the file it just read. A refusal is an unreadable-artifact + // finding; an operational failure on an allowed path is the filesystem failing, + // and the reference lets it throw — so it travels out as an error and the command + // reports it. Mutation that reddens: turn the error back into a finding. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses permission bits; the read cannot be made to fail") + } + dir := seedLayer(t, 4) + abs := filepath.Join(dir, filepath.FromSlash(changelogRel)) + if err := os.Chmod(abs, 0o000); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(abs, 0o644) }() + if f, oerr := os.Open(abs); oerr == nil { + _ = f.Close() + t.Skip("this platform ignores the mode; the read cannot be made to fail") + } + + res, err := CompactChangelog(dir, loadManifest(t, dir), CompactOptions{Keep: 1, HasKeep: true}) + if err == nil { + t.Fatalf("an unreadable changelog must fail the run, got %+v", res) + } + if len(res.Findings) != 0 { + t.Fatalf("the failure must not also be reported as a finding: %+v", res.Findings) + } +} diff --git a/packages/sdk-go/internal/commands/conformance/conformance.go b/packages/sdk-go/internal/commands/conformance/conformance.go index 4110290..b5eef8a 100644 --- a/packages/sdk-go/internal/commands/conformance/conformance.go +++ b/packages/sdk-go/internal/commands/conformance/conformance.go @@ -96,7 +96,10 @@ func Report(root string, federation bool) (Result, error) { var fs []findings.Finding m := manifest.LoadManifest(root).Manifest - validation := validate.ValidateLayer(root, false) + validation, verr := validate.ValidateLayer(root, false) + if verr != nil { + return Result{}, verr + } errorsBy := func(rules ...string) []findings.Finding { var out []findings.Finding for _, f := range validation.Findings { @@ -177,7 +180,10 @@ func Report(root string, federation bool) (Result, error) { add("vendor-redirects", "core", "vendor entrypoint files, if present, redirect to the boot profile", statusPassFail(vendorErrors), firstMsg(vendorErrors)) - indexResult := indexgen.CheckIndex(root, m) + indexResult, cerr := indexgen.CheckIndex(root, m) + if cerr != nil { + return Result{}, cerr + } indexStatus := Fail if indexResult.Stale != nil && !*indexResult.Stale { indexStatus = Pass @@ -455,7 +461,7 @@ func RenderExplain(result Result) string { } detail := "" if b.Detail != "" { - detail = " — " + b.Detail + detail = ": " + b.Detail } lines = append(lines, fmt.Sprintf(" - %s%s%s", b.Description, detail, how)) } diff --git a/packages/sdk-go/internal/commands/detect/detect.go b/packages/sdk-go/internal/commands/detect/detect.go index 41f07e0..300d2a1 100644 --- a/packages/sdk-go/internal/commands/detect/detect.go +++ b/packages/sdk-go/internal/commands/detect/detect.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" ) // DetectLayer returns the agent hosts available to this user, ranked. @@ -12,9 +13,11 @@ func DetectLayer(root string) []detect.DetectedHost { return detect.DetectHosts(detect.Options{Root: root}) } -func RenderDetect(hosts []detect.DetectedHost) string { +func RenderDetect(hosts []detect.DetectedHost, eco ecosystem.Report) string { + ecoLine := ecosystem.RenderLine(eco) if len(hosts) == 0 { - return "No coding-agent hosts detected. Leji works without one; the onboarding brief still guides any agent you point at it." + return "No coding-agent hosts detected. Leji works without one; the onboarding brief still guides any agent you point at it." + + "\n\n" + ecoLine } lines := []string{"Detected agent hosts (strongest signal first):"} for _, h := range hosts { @@ -33,8 +36,11 @@ func RenderDetect(hosts []detect.DetectedHost) string { if h.Adapter != "" { adapter = "adapter " + h.Adapter } - lines = append(lines, " "+padEnd(string(h.Strength), 16)+" "+h.Name+" — "+signals+"; "+adapter) + lines = append(lines, " "+padEnd(string(h.Strength), 16)+" "+h.Name+": "+signals+"; "+adapter) } + // One line about the repository's own ecosystem: what would declare and run the + // CLI here. The full offer block belongs to init/adopt, which can act on it. + lines = append(lines, "", ecoLine) // --agent names the host Leji launches, and only claude-code and codex accept // an inline prompt; suggesting `--agent ` for every detected host offered // a command the flag rejects. diff --git a/packages/sdk-go/internal/commands/detect/detect_render_test.go b/packages/sdk-go/internal/commands/detect/detect_render_test.go index 8db9c3d..cc51570 100644 --- a/packages/sdk-go/internal/commands/detect/detect_render_test.go +++ b/packages/sdk-go/internal/commands/detect/detect_render_test.go @@ -5,11 +5,15 @@ import ( "testing" "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" ) // RenderDetect handles the empty case and a ranked, non-empty case. func TestRenderDetectEmptyAndRanked(t *testing.T) { - empty := RenderDetect(nil) + // A root with no manifest: the ecosystem line is present in both shapes and says + // so, without changing what the host list reports. + eco := ecosystem.Detect(t.TempDir()) + empty := RenderDetect(nil, eco) if !regexp.MustCompile(`No coding-agent hosts detected`).MatchString(empty) { t.Fatalf("empty case should report no hosts, got:\n%s", empty) } @@ -33,7 +37,7 @@ func TestRenderDetectEmptyAndRanked(t *testing.T) { UserConfig: false, Adapter: ".cursor/rules/leji.md", }, - }) + }, eco) mustMatch := func(re string) { t.Helper() @@ -44,4 +48,8 @@ func TestRenderDetectEmptyAndRanked(t *testing.T) { // Strength, name, the PATH signal, and the adapter all appear, in order. mustMatch(`confirmed.*Claude Code.*binary on PATH.*CLAUDE\.md`) mustMatch(`leji init --agent`) + mustMatch(`Ecosystem: none detected`) + if !regexp.MustCompile(`Ecosystem: none detected`).MatchString(empty) { + t.Fatalf("the empty case carries the ecosystem line too:\n%s", empty) + } } diff --git a/packages/sdk-go/internal/commands/export/canary_test.go b/packages/sdk-go/internal/commands/export/canary_test.go new file mode 100644 index 0000000..2744df6 --- /dev/null +++ b/packages/sdk-go/internal/commands/export/canary_test.go @@ -0,0 +1,162 @@ +package export + +// The check/use gap on the READ side, and the check-before-act answer to it. These need a mutation +// landing at one exact moment inside a run, which no fixture can plant, so they are +// constructed here — over the example layer, with the planted bytes in a private role. + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// token is the planted byte string: it must never reach an export. +const token = "LEJI-TRUST-CANARY" + +// captureStderr runs fn with os.Stderr redirected to a pipe and returns what it +// wrote. The boundary-skip warning is a stderr contract, so it is read from the file +// the process actually writes to. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stderr + os.Stderr = w + done := make(chan string, 1) + go func() { + body, _ := io.ReadAll(r) + done <- string(body) + }() + fn() + os.Stderr = orig + _ = w.Close() + out := <-done + _ = r.Close() + return out +} + +// countToken counts recursive occurrences of the token under dir (an absent dir +// counts as zero). +func countToken(t *testing.T, dir string) (int, []string) { + t.Helper() + if _, err := os.Stat(dir); err != nil { + return 0, nil + } + count := 0 + var where []string + err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !d.Type().IsRegular() { + return err + } + body, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + if hits := strings.Count(string(body), token); hits > 0 { + count += hits + rel, _ := filepath.Rel(dir, p) + where = append(where, rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return count, where +} + +func TestCheckBeforeActAncestorSwappedAfterEnumerationIsNeverFollowed(t *testing.T) { + // The content walk enumerates a real directory; before the export uses what it + // enumerated, that directory becomes a symlink into a private role. Every later + // read or copy BY PATH then goes through the link, with the walk's checks all + // behind it — and a revalidation that lstats the final component alone follows the + // swapped ancestor to a perfectly ordinary file. So a carried source is resolved, + // its RESOLVED path judged, and its bytes taken from the descriptor fstat proved a + // regular file: the check and the use hold one inode. Mutation that reddens: + // revalidate with Lstat and read/copy by path again — the planted bytes below are + // linted and land in the export. + dir := exampleCopy(t) + writeUnder(t, dir, "docs/domain/asset.txt", "an ordinary carried asset\n") + decoy := filepath.Join(dir, ".leji", "work", "swapped") + if err := os.MkdirAll(decoy, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(decoy, "glossary.md"), []byte("# planted "+token+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(decoy, "asset.txt"), []byte(token+"\n"), 0o644); err != nil { + t.Fatal(err) + } + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the example manifest must load") + } + + // The swap, at the one moment that matters: after the walk has enumerated the + // carried set and before any of it is used. Deterministic, not a race — the hook + // performs it inline, so the window is exercised on every run. + domainDir := filepath.Join(dir, "docs", "domain") + swapped := false + testHookAfterEnumerate = func() { + if swapped { + return + } + swapped = true + if err := os.Rename(domainDir, filepath.Join(dir, "docs", "domain-real")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", ".leji", "work", "swapped"), domainDir); err != nil { + t.Fatal(err) + } + } + defer func() { testHookAfterEnumerate = nil }() + + var built BuildResult + var buildErr error + stderr := captureStderr(t, func() { + built, buildErr = BuildViewer(dir, m, "", Options{}) + }) + if buildErr != nil { + t.Fatalf("BuildViewer: %v", buildErr) + } + if !swapped { + t.Fatal("the ancestor must have been swapped between the walk and the use") + } + if !built.Wrote { + t.Fatal("the export still runs to completion") + } + distDir := filepath.Join(dir, ".leji", "dist") + if _, err := os.Stat(filepath.Join(distDir, "index.html")); err != nil { + t.Fatalf("the export still ran to completion: %v", err) + } + if count, where := countToken(t, distDir); count != 0 { + t.Fatalf("no planted byte may reach the export: %v", where) + } + for _, rel := range []string{"glossary.md", "asset.txt"} { + if _, err := os.Stat(filepath.Join(distDir, "content", "domain", rel)); err == nil { + t.Fatalf("the redirected source must be dropped rather than followed: %s", rel) + } + } + // A source that now resolves into a private role is a level-2 refusal: dropping it + // silently would leave an operator with a quietly shorter export and no reason. + var warnings []string + for _, line := range strings.Split(stderr, "\n") { + if strings.HasPrefix(line, "skipped domain/") { + warnings = append(warnings, line) + } + } + if len(warnings) == 0 { + t.Fatalf("the redirected sources must be named on stderr: %q", stderr) + } + for _, line := range warnings { + if !strings.Contains(line, "resolves into .leji/work (private); not served or exported") { + t.Fatalf("boundary-skip wording: %q", line) + } + } +} diff --git a/packages/sdk-go/internal/commands/export/export.go b/packages/sdk-go/internal/commands/export/export.go new file mode 100644 index 0000000..d4d26c4 --- /dev/null +++ b/packages/sdk-go/internal/commands/export/export.go @@ -0,0 +1,628 @@ +// Package export is `leji export` (and `leji viewer build`, its co-equal name for +// the same operation): the static export pipeline, in its own package so its +// transitive import set can be checked. Nothing here — and nothing it imports — +// pulls in `net`, `net/http`, `net/url` or any other network package; the local +// preview server keeps all of that in `commands/serve`. The only subprocess the +// pipeline reaches is `git`, through the mount status the manifest page renders, +// with lazy fetch disabled. A `go list -deps` test pins the first claim. +package export + +import ( + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "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/fsx" + "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" + "github.com/leji-org/leji/packages/sdk-go/internal/renderlint" +) + +// ProtectWarning is the protect-your-context warning surfaced by `leji export` +// (stdout and a comment in the exported index.html). +const ProtectWarning = "This is your context layer (identity, invariants, decisions, sometimes sensitive internal knowledge). Host the exported folder behind internal authentication, not a public or shared bucket where it could be indexed or leaked. Active file types (.htm, .html, .js, .mjs, .xhtml) are left out of the exported content: a static host would serve them as same-origin documents that execute with no policy." + +// exportMarker is the first bytes an export writes into its index.html, under +// either of the command's names. A target directory carrying this marker is a +// previous export and may be cleared; any other non-empty directory is somebody's +// content and is never removed. The marker is a byte contract shared with the Node +// and Python SDKs, so it reads as it has always read: an export written by any of +// the three, under either name, is clearable by any of the three. +const exportMarker = "\n" + indexHTML + if err := writeDest(filepath.Join(outAbs, "index.html"), []byte(prepended)); err != nil { + return BuildResult{}, err + } + + return BuildResult{Out: outDisplay, Findings: all, Wrote: true}, nil +} + +// copyFromDescriptor streams a source's bytes from the descriptor a check already +// judged, rather than reopening its path: only the linted markdown is held in +// memory, and the bytes still come from the checked inode. The destination is judged +// and opened by the chokepoint, so the copy can never reopen — or redirect to — a +// path. io.Copy writes each chunk to completion, so a short write on an unusual +// destination cannot truncate the file (the reference implementation loops its +// writeSync for the same reason). +func copyFromDescriptor(rootAbs string, f *os.File, dest string, refused func(string, layout.TargetVerdict) error) error { + info, err := f.Stat() + if err != nil { + return err + } + opened, err := fsx.OpenWriteGuarded(rootAbs, dest, layout.DistRel, info.Mode().Perm()) + if err != nil { + return err + } + if opened.File == nil { + return refused(dest, opened.Verdict) + } + if _, err := io.Copy(opened.File, f); err != nil { + _ = opened.File.Close() + return err + } + return opened.File.Close() +} diff --git a/packages/sdk-go/internal/commands/export/export_more_test.go b/packages/sdk-go/internal/commands/export/export_more_test.go new file mode 100644 index 0000000..7346849 --- /dev/null +++ b/packages/sdk-go/internal/commands/export/export_more_test.go @@ -0,0 +1,439 @@ +package export + +// The export's own legs, moved here with the pipeline they exercise: containment +// and the role reservation, the marker-verified clearing, the active-type exclusion, +// the default output role, and the relative-base export flavor. The generation half +// of each pairing stays with the generator. + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// exampleCopy is a scratch copy of the example layer, the fixture these legs export. +func exampleCopy(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + src := filepath.Join(wd, "..", "..", "..", "..", "..", "examples", "monorepo") + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS(src)); err != nil { + t.Fatalf("copy example: %v", err) + } + return dst +} + +// writeUnder writes rel (forward-slashed, repo-relative) under dir, creating its +// parent directories. +func writeUnder(t *testing.T, dir, rel, text string) { + t.Helper() + abs := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(abs, []byte(text), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} + +// Mirrors the Node test: viewer build exports a self-contained static folder +// carrying the protect warning, and refuses an escaping, root, or absolute --out. +func TestBuildViewerExportsAndRejects(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + r, err := BuildViewer(dir, m, "out", Options{}) + if err != nil { + t.Fatalf("BuildViewer: %v", err) + } + if r.Out != "out" { + t.Fatalf("out = %q, want %q", r.Out, "out") + } + out := filepath.Join(dir, "out") + // Chrome at the web root; the layer's markdown under /content/. + for _, rel := range []string{ + "index.html", + "assets/docsify.min.js", + "content/boot-profile.md", + "content/overview.md", + "content/_sidebar.md", + "content/domain/glossary.md", + } { + if _, err := os.Stat(filepath.Join(out, rel)); err != nil { + t.Fatalf("expected exported %s: %v", rel, err) + } + } + // The contained, regenerable .leji/ is never exported into the content. + if _, err := os.Stat(filepath.Join(out, "content", ".leji")); err == nil { + t.Fatal("expected .leji to be excluded from the export") + } + html, err := os.ReadFile(filepath.Join(out, "index.html")) + if err != nil { + t.Fatalf("read exported index.html: %v", err) + } + if !strings.HasPrefix(string(html), "") + if !strings.Contains(warning, "Active file types") { + t.Fatal("expected the export warning to name the exclusion") + } + if strings.Contains(warning, ".svg") { + t.Fatal("the warning must not claim SVG is excluded") + } +} + +func TestBuildViewerDefaultOutputIsTheDistRole(t *testing.T) { + dir := exampleCopy(t) + // exampleCopy copies the working tree, which may carry a gitignored pre-1.4 + // `docs/.leji/` left by a local run; clear it so the absence asserted below is + // this run's doing and not a checkout's history. + if err := os.RemoveAll(filepath.Join(dir, "docs", ".leji")); err != nil { + t.Fatal(err) + } + m := manifest.LoadManifest(dir).Manifest + r, err := BuildViewer(dir, m, "", Options{}) + if err != nil { + t.Fatalf("BuildViewer: %v", err) + } + if got := filepath.ToSlash(r.Out); got != ".leji/dist" { + t.Fatalf("default output = %q, want %q", got, ".leji/dist") + } + for _, rel := range []string{".leji/dist/index.html", ".leji/dist/content/boot-profile.md"} { + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil { + t.Fatalf("expected %s: %v", rel, err) + } + } + // The pre-1.4 locations are never created, and nothing reads or writes a tree + // under the context root: a run leaves rootPath/.leji/ absent. + if _, err := os.Stat(filepath.Join(dir, "docs", ".leji")); err == nil { + t.Fatal("no tree must be created under the context root") + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "viewer-dist")); err == nil { + t.Fatal("the old output name must not be used") + } +} + +func TestBuildViewerOutNeverResolvesInsideALejiRoleButDist(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + // The roles are the tool's own: an export target inside any of them is refused, + // including a role this version has never heard of, because the rule denies by name + // rather than listing what to protect. + for _, target := range []string{".leji", ".leji/mounts", ".leji/mounts/cache", ".leji/viewer", ".leji/work", ".leji/future"} { + if _, err := BuildViewer(dir, m, target, Options{}); err == nil || + !strings.Contains(err.Error(), "reserved for the tool's own roles") { + t.Fatalf("--out %s must be refused, got %v", target, err) + } + } + // The canary bytes a refusal must never have touched: the private roles are still + // exactly as planted. + writeUnder(t, dir, ".leji/mounts/store/keep", "private\n") + if _, err := BuildViewer(dir, m, ".leji/mounts", Options{}); err == nil { + t.Fatal("--out .leji/mounts must be refused") + } + body, err := os.ReadFile(filepath.Join(dir, ".leji", "mounts", "store", "keep")) + if err != nil || string(body) != "private\n" { + t.Fatal("the private role must be intact") + } + // The reserved role itself is the one accepted spelling. + if _, err := BuildViewer(dir, m, ".leji/dist", Options{}); err != nil { + t.Fatalf(".leji/dist must be accepted: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "dist", "index.html")); err != nil { + t.Fatalf("expected the export at the dist role: %v", err) + } + // EXACT: the reservation names the directory the role is, never a path underneath + // it — relative or absolute, since a caller may spell either. + for _, target := range []string{".leji/dist/site", filepath.Join(dir, ".leji", "dist", "site")} { + if _, err := BuildViewer(dir, m, target, Options{}); err == nil || + !strings.Contains(err.Error(), "is the reserved export target itself, never a path inside it") { + t.Fatalf("--out %s must be refused, got %v", target, err) + } + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "dist", "site")); err == nil { + t.Fatal("nothing must be written inside the reserved target") + } +} + +// caseInsensitiveFs reports whether this directory sits on a filesystem that cannot +// tell `.leji` from `.LEJI`. Asked of the volume rather than inferred from the +// platform: a case-sensitive volume on macOS and a case-insensitive one on Linux +// both exist. +func caseInsensitiveFs(t *testing.T, dir string) bool { + t.Helper() + probe := filepath.Join(dir, "leji-case-probe") + if err := os.MkdirAll(probe, 0o755); err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(probe) }() + _, err := os.Stat(filepath.Join(dir, "LEJI-CASE-PROBE")) + return err == nil +} + +func TestBuildViewerOutIsJudgedInResolvedForm(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + writeUnder(t, dir, ".leji/mounts/store/keep", "private\n") + // A symlink is a spelling, not an exemption: what the write would land in is what + // the reservation judges, so an ordinary-looking --out that redirects into a private + // role is refused exactly as the literal path is. + if err := os.Symlink(filepath.Join(".leji", "mounts"), filepath.Join(dir, "redirect")); err != nil { + t.Fatal(err) + } + if _, err := BuildViewer(dir, m, "redirect/export", Options{}); err == nil || + !strings.Contains(err.Error(), "reserved for the tool's own roles") { + t.Fatalf("a redirected --out must be refused, got %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "mounts", "export")); err == nil { + t.Fatal("nothing must be written through it") + } + if body, err := os.ReadFile(filepath.Join(dir, ".leji", "mounts", "store", "keep")); err != nil || string(body) != "private\n" { + t.Fatal("the private role must be intact") + } + // Where the filesystem cannot tell the two spellings apart, `.LEJI/` names the + // reserved role and is refused as one. Where it can, `.LEJI/` is an ordinary + // directory name and there is nothing to assert, so the volume decides. + if caseInsensitiveFs(t, dir) { + if _, err := BuildViewer(dir, m, ".LEJI/mounts/export", Options{}); err == nil || + !strings.Contains(err.Error(), "reserved for the tool's own roles") { + t.Fatalf("a case-variant spelling of a reserved role is the reserved role, got %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "mounts", "export")); err == nil { + t.Fatal("nothing must be written under the case variant") + } + } + // The redirection rule is about the destination, not about symlinks: one that lands + // somewhere ordinary still exports. + if err := os.MkdirAll(filepath.Join(dir, "real-out"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real-out", filepath.Join(dir, "link-out")); err != nil { + t.Fatal(err) + } + if _, err := BuildViewer(dir, m, "link-out", Options{}); err != nil { + t.Fatalf("an ordinary redirected --out must export: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "real-out", "index.html")); err != nil { + t.Fatalf("the export must land in the resolved target: %v", err) + } +} + +func TestBuildViewerExportFlavorCarriesNoRootAbsoluteURL(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + if _, err := BuildViewer(dir, m, "", Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + served, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) + if err != nil { + t.Fatal(err) + } + exported, err := os.ReadFile(filepath.Join(dir, ".leji", "dist", "index.html")) + if err != nil { + t.Fatal(err) + } + // One code path, two flavors: the servable area holds the app-root base, the export + // holds the relative one. index.html is the only file that differs. + if !strings.Contains(string(served), `"basePath":"/content/"`) { + t.Fatal("the served flavor must mount content at the app root") + } + if !strings.Contains(string(served), `href="/assets/leji-logo.svg"`) { + t.Fatal("the served favicon must be app-root absolute") + } + if !strings.Contains(string(exported), `"basePath":"content/"`) { + t.Fatal("the exported flavor must mount content relative to the page") + } + if strings.Contains(string(exported), `"basePath":"/content/"`) { + t.Fatal("no export-flavored page may keep the app-root base") + } + // The machine-checkable proxy gate for subpath hosting: nothing in the exported + // shell — attributes or config — addresses the server root. (Sidebar link + // destinations are route strings resolved against basePath, not fetch paths, and + // live in _sidebar.md, not here.) + body := string(exported) + if i := strings.Index(body, "-->"); i >= 0 { + body = body[i+3:] + } + if hits := rootAbsoluteAttrRe.FindAllString(body, -1); len(hits) > 0 { + t.Fatalf("root-absolute href/src in the exported shell: %v", hits) + } + if hits := rootAbsoluteConfigRe.FindAllString(body, -1); len(hits) > 0 { + t.Fatalf("root-absolute URL inside the exported config block: %v", hits) + } + // The servable area never holds export-flavored bytes, and the two trees agree on + // everything else the chrome ships. + for _, rel := range []string{"assets/viewer-boot.js", "assets/docsify.min.js"} { + a, err := os.ReadFile(filepath.Join(dir, ".leji", "dist", filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + if string(a) != string(b) { + t.Fatalf("%s must be flavor-neutral", rel) + } + } +} + +// The export flavor's proxy gate: no URL the exported chrome emits may be +// root-absolute, or the tree breaks the moment it is hosted under a subpath. +var ( + rootAbsoluteAttrRe = regexp.MustCompile(`(?:href|src)="/[^"]*"`) + rootAbsoluteConfigRe = regexp.MustCompile(`\\"/(?:content|assets)/[^\\"]*\\"`) +) + +func TestClearableExportPropagatesAnOperationalReadFailure(t *testing.T) { + // The marker that authorizes clearing a previous export is read through the + // verified read. A refusal is "not a previous export"; an operational failure on + // an allowed path is the filesystem failing, and the reference lets it throw — so + // it travels out as an error rather than deciding the delete either way. Mutation + // that reddens: swallow the error in clearableExport — the run reports the + // occupied-target refusal instead of the read failure. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses permission bits; the read cannot be made to fail") + } + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + marker := filepath.Join(dir, "out", "index.html") + writeUnder(t, dir, "out/index.html", exportMarker+"previous export\n") + if err := os.Chmod(marker, 0o000); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(marker, 0o644) }() + if f, oerr := os.Open(marker); oerr == nil { + _ = f.Close() + t.Skip("this platform ignores the mode; the read cannot be made to fail") + } + + _, err := BuildViewer(dir, m, "out", Options{}) + if err == nil { + t.Fatal("an unreadable marker must fail the run, not decide the clear") + } + if strings.Contains(err.Error(), "neither empty nor a previous viewer export") { + t.Fatalf("the read failure must not be reported as an occupied target: %v", err) + } + if !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("the operational failure must surface: %v", err) + } +} diff --git a/packages/sdk-go/internal/commands/export/topology_test.go b/packages/sdk-go/internal/commands/export/topology_test.go new file mode 100644 index 0000000..ac47191 --- /dev/null +++ b/packages/sdk-go/internal/commands/export/topology_test.go @@ -0,0 +1,72 @@ +package export + +// The structural prong of the no-network guarantee: the export package's transitive +// import set contains no networking package. It catches the static introduction of a +// network dependency and nothing else — dynamic side doors are covered by the offline +// CI leg, and the subprocess claim (git and nothing else) by the reference suite's spy. + +import ( + "os/exec" + "strings" + "testing" +) + +// networkPackages are the standard library's networking packages: the ones that open +// a socket, or exist only to serve one. `net/url` and `net/netip` are deliberately +// NOT here — they parse URLs and IP addresses and dial nothing, and the export graph +// reaches both through the vendored JSON-schema library's format checks. The +// reference implementation draws the same line (it bans node:net/http/https/dgram +// and not node:url). +var networkPackages = []string{"net", "net/http", "net/rpc", "net/smtp", "net/textproto", "crypto/tls"} + +// deps is `go list -deps` for one package path, relative to this directory. +func deps(t *testing.T, pkg string) map[string]bool { + t.Helper() + if _, err := exec.LookPath("go"); err != nil { + t.Skip("no go toolchain on PATH; the import graph cannot be listed") + } + out, err := exec.Command("go", "list", "-deps", pkg).CombinedOutput() + if err != nil { + t.Fatalf("go list -deps %s: %v\n%s", pkg, err, out) + } + set := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + set[line] = true + } + } + return set +} + +func TestExportGraphReachesNoNetworkPackage(t *testing.T) { + graph := deps(t, ".") + // The graph is real: the export pulls in the chrome generation and the layer + // libraries, so a truncated or empty listing cannot pass this test by accident. + const mod = "github.com/leji-org/leji/packages/sdk-go/internal/" + if !graph[mod+"commands/viewer"] { + t.Fatalf("the graph must reach the generator (%d packages listed)", len(graph)) + } + if !graph[mod+"renderlint"] { + t.Fatal("the graph must reach the rendering lint") + } + if graph[mod+"commands/serve"] { + t.Fatal("the export must never reach the serve package") + } + for _, pkg := range networkPackages { + if graph[pkg] { + t.Fatalf("the export package graph imports %s", pkg) + } + } + for pkg := range graph { + if strings.HasPrefix(pkg, "net/http") { + t.Fatalf("the export package graph imports %s", pkg) + } + } + + // Positive control: the serve package DOES reach net/http, so the assertions above + // are testing a real property rather than a lister that sees nothing. + serve := deps(t, "../serve") + if !serve["net/http"] || !serve["net"] { + t.Fatal("the serve package graph must import net/http") + } +} diff --git a/packages/sdk-go/internal/commands/indexgen/indexgen.go b/packages/sdk-go/internal/commands/indexgen/indexgen.go index 25a6097..7792013 100644 --- a/packages/sdk-go/internal/commands/indexgen/indexgen.go +++ b/packages/sdk-go/internal/commands/indexgen/indexgen.go @@ -138,18 +138,35 @@ func strArray(v any) []string { return out } -func LoadStoredIndex(root string, m *manifest.Manifest) map[string]any { +// LoadStoredIndex is the stored index, or nil when there is none this run can act +// on. It is read through the verified read, not by pathname: generation carries ids +// out of these bytes into the index it writes back to this same path, so the file +// that was judged must be the file that is read. Absent, unparsable, or a standing +// entry that cannot be verified all mean "no stored index" — nothing is carried, and +// the write chokepoint judges the destination again on its own. +func LoadStoredIndex(root string, m *manifest.Manifest) (map[string]any, error) { rel := manifest.EffectiveIndexPath(m) abs := filepath.Join(root, rel) - if !fsx.IsFile(abs) || !fsx.ResolvesUnder(root, abs) { - return nil + // An operational read failure on an allowed path is the filesystem failing rather + // than the boundary refusing, so it travels out as an error, exactly as the + // reference lets it throw: the run reports it and stops instead of generating an + // index that silently carries no ids. + read, err := fsx.VerifiedTargetRead(fsx.GuardRoot(root), abs, "") + if err != nil { + return nil, err + } + if read.Status != fsx.ReadRegular { + return nil, nil + } + var data any + if err := json.Unmarshal(read.Bytes, &data); err != nil { + return nil, nil } - data, _ := layer.ReadJSONArtifact(root, rel) obj, ok := data.(map[string]any) if !ok { - return nil + return nil, nil } - return obj + return obj, nil } func storedEntries(stored map[string]any) []map[string]any { @@ -169,12 +186,15 @@ func storedEntries(stored map[string]any) []map[string]any { return out } -func GenerateIndex(root string, m *manifest.Manifest) Result { +func GenerateIndex(root string, m *manifest.Manifest) (Result, error) { var fs []findings.Finding scan := layer.ScanCategories(root, m) fs = append(fs, scan.Findings...) docs := scan.Docs - stored := LoadStoredIndex(root, m) + stored, err := LoadStoredIndex(root, m) + if err != nil { + return Result{}, err + } storedByPath := map[string]map[string]any{} // Carry an id by content-hash only when that hash maps to exactly one stored // entry: two byte-identical documents share a hash, so a hash-carry there would @@ -343,7 +363,7 @@ func GenerateIndex(root string, m *manifest.Manifest) Result { if len(mounts) > 0 { index.Mounts = mounts } - return Result{Index: index, Findings: fs} + return Result{Index: index, Findings: fs}, nil } // entryComparable is the currency-comparison view of an entry (only lastModified @@ -470,25 +490,28 @@ func stableWrite(sb *strings.Builder, value any) { } // CheckIndex compares the stored index against a regeneration. -func CheckIndex(root string, m *manifest.Manifest) Result { +func CheckIndex(root string, m *manifest.Manifest) (Result, error) { rel := manifest.EffectiveIndexPath(m) var fs []findings.Finding staleTrue := true if !fsx.IsFile(filepath.Join(root, rel)) { fs = append(fs, findings.New("index-required", findings.Error, "index "+rel+" does not exist; run `leji index`", rel)) - return Result{Index: nil, Findings: fs, Stale: &staleTrue} + return Result{Index: nil, Findings: fs, Stale: &staleTrue}, nil } - if !fsx.ResolvesUnder(root, filepath.Join(root, rel)) { + if !fsx.ResolvedWithinRoot(root, filepath.Join(root, rel)) { fs = append(fs, findings.New("artifact-parse", findings.Error, fmt.Sprintf("artifact %s resolves outside the layer root", rel), rel)) - return Result{Index: nil, Findings: fs, Stale: &staleTrue} + return Result{Index: nil, Findings: fs, Stale: &staleTrue}, nil } - stored := LoadStoredIndex(root, m) + stored, err := LoadStoredIndex(root, m) + if err != nil { + return Result{}, err + } if stored == nil { fs = append(fs, findings.New("artifact-parse", findings.Error, "stored index is not valid JSON", rel)) - return Result{Index: nil, Findings: fs, Stale: &staleTrue} + return Result{Index: nil, Findings: fs, Stale: &staleTrue}, nil } for _, e := range schemas.SchemaErrors("context-index", stored) { fs = append(fs, findings.New("artifact-schema", findings.Error, e, rel)) @@ -498,10 +521,13 @@ func CheckIndex(root string, m *manifest.Manifest) Result { fmt.Sprintf("schemaVersion %q is not supported by this SDK", sv), rel)) } if len(fs) > 0 { - return Result{Index: nil, Findings: fs, Stale: &staleTrue} + return Result{Index: nil, Findings: fs, Stale: &staleTrue}, nil } - regen := GenerateIndex(root, m) + regen, err := GenerateIndex(root, m) + if err != nil { + return Result{}, err + } // A regeneration that itself errors (missing/malformed index file, a // category-conflict, a dangling entry) means the tree cannot be indexed // cleanly, so the stored index cannot be current: fail rather than compare a @@ -514,7 +540,7 @@ func CheckIndex(root string, m *manifest.Manifest) Result { } if len(regenErrors) > 0 { fs = append(fs, regenErrors...) - return Result{Index: nil, Findings: fs, Stale: &staleTrue} + return Result{Index: nil, Findings: fs, Stale: &staleTrue}, nil } wantEntries := make([]any, 0, len(regen.Index.Entries)) for _, e := range regen.Index.Entries { @@ -580,7 +606,7 @@ func CheckIndex(root string, m *manifest.Manifest) Result { } fs = append(fs, findings.New("index-stale", findings.Error, "index no longer matches the tree"+detail+"; run `leji index`", rel)) - return Result{Index: nil, Findings: fs, Stale: &staleTrue} + return Result{Index: nil, Findings: fs, Stale: &staleTrue}, nil } var items []layer.IDItem @@ -591,7 +617,7 @@ func CheckIndex(root string, m *manifest.Manifest) Result { staleFalse := false out := append([]findings.Finding{}, regen.Findings...) out = append(out, layer.DuplicateIDFindings(items, "index")...) - return Result{Index: nil, Findings: out, Stale: &staleFalse} + return Result{Index: nil, Findings: out, Stale: &staleFalse}, nil } var entryKeyOrder = []string{ @@ -736,7 +762,10 @@ func orderedMountJSON(mt IndexMount) json.RawMessage { // WriteIndex generates and writes the index to the effective path. func WriteIndex(root string, m *manifest.Manifest) (Result, error) { rel := manifest.EffectiveIndexPath(m) - result := GenerateIndex(root, m) + result, err := GenerateIndex(root, m) + if err != nil { + return Result{}, err + } // Refuse to write a partial or incorrect index when generation hit a hard // error (e.g. category-conflict, index-file-parse, a dangling entry): writing // would persist a half-correct artifact that later reads trust. @@ -747,20 +776,18 @@ func WriteIndex(root string, m *manifest.Manifest) (Result, error) { } if result.Index != nil { abs := filepath.Join(root, rel) - // Contain before creating any directory: ResolvesUnder resolves the nearest - // existing ancestor, so a symlinked ancestor of this not-yet-existing target - // is caught before mkdir/write can escape the layer root. - if !fsx.ResolvesUnder(root, abs) { + // The write chokepoint judges the RESOLVED destination immediately before the + // write, catching a symlinked ancestor before anything is created under it. + verdict, err := fsx.WriteFileGuarded(fsx.GuardRoot(root), abs, "", + []byte(SerializeIndex(result.Index)), fsx.WriteOptions{}) + if err != nil { + return result, err + } + if !verdict.OK { result.Findings = append(result.Findings, findings.New("artifact-parse", findings.Error, fmt.Sprintf("index path %s resolves outside the layer root", rel), rel)) return result, nil } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return result, err - } - if err := os.WriteFile(abs, []byte(SerializeIndex(result.Index)), 0o644); err != nil { - return result, err - } } return result, nil } diff --git a/packages/sdk-go/internal/commands/indexgen/indexgen_test.go b/packages/sdk-go/internal/commands/indexgen/indexgen_test.go index 36a038f..0c6d265 100644 --- a/packages/sdk-go/internal/commands/indexgen/indexgen_test.go +++ b/packages/sdk-go/internal/commands/indexgen/indexgen_test.go @@ -40,7 +40,7 @@ func loadManifest(t *testing.T, dir string) *manifest.Manifest { func TestGenerateIndexStableIDs(t *testing.T) { dir := copyExample(t) m := loadManifest(t, dir) - res := GenerateIndex(dir, m) + res := generateIndex(t, dir, m) if res.Index == nil { t.Fatalf("generate produced no index: %v", res.Findings) } @@ -64,7 +64,7 @@ func TestGenerateIndexStableIDs(t *testing.T) { } } // generatedAt is intentionally not stable, so compare entries only. - second := GenerateIndex(dir, m) + second := generateIndex(t, dir, m) if len(second.Index.Entries) != len(res.Index.Entries) { t.Fatal("entry count changed across regenerations") } @@ -81,7 +81,7 @@ func TestCheckIndexFreshThenStale(t *testing.T) { m := loadManifest(t, dir) // The committed example ships a current index: CheckIndex is fresh. - res := CheckIndex(dir, m) + res := checkIndex(t, dir, m) if res.Stale == nil || *res.Stale { t.Fatalf("committed example should be fresh: stale=%v findings=%v", res.Stale, res.Findings) } @@ -96,7 +96,7 @@ func TestCheckIndexFreshThenStale(t *testing.T) { if err := os.WriteFile(newDoc, []byte("---\nsummary: A new term.\n---\n\n# New Term\n"), 0o644); err != nil { t.Fatal(err) } - res = CheckIndex(dir, m) + res = checkIndex(t, dir, m) if res.Stale == nil || !*res.Stale { t.Fatalf("adding an unindexed doc should be stale: %+v", res) } @@ -118,7 +118,7 @@ func TestCheckIndexMissingIsRequired(t *testing.T) { if err := os.Remove(filepath.Join(dir, filepath.FromSlash(rel))); err != nil { t.Fatal(err) } - res := CheckIndex(dir, m) + res := checkIndex(t, dir, m) if res.Stale == nil || !*res.Stale { t.Fatalf("missing index should be stale: %+v", res) } @@ -151,7 +151,7 @@ func TestWriteIndexRoundTrips(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil { t.Fatalf("index not written: %v", err) } - check := CheckIndex(dir, m) + check := checkIndex(t, dir, m) if check.Stale == nil || *check.Stale { t.Fatalf("freshly written index should be fresh: %+v", check) } @@ -184,3 +184,62 @@ func TestWriteIndexRefusesSymlinkEscape(t *testing.T) { t.Fatal("nothing should be written outside the root") } } + +// --- gate helpers ------------------------------------------------------------- +// These commands now carry an error channel, because an operational read failure on +// an allowed path propagates instead of being swallowed (the reference throws it). +// A test that does not construct such a failure asserts there is none. + +func checkIndex(t *testing.T, root string, m *manifest.Manifest) Result { + t.Helper() + res, err := CheckIndex(root, m) + if err != nil { + t.Fatalf("CheckIndex(%s): %v", root, err) + } + return res +} + +func generateIndex(t *testing.T, root string, m *manifest.Manifest) Result { + t.Helper() + res, err := GenerateIndex(root, m) + if err != nil { + t.Fatalf("GenerateIndex(%s): %v", root, err) + } + return res +} + +func TestStoredIndexPropagatesAnOperationalReadFailure(t *testing.T) { + // The stored index is read through the verified read, and generation carries ids + // out of those bytes. A refusal (absent, unverifiable, outside the layer) means + // "no stored index"; an operational failure on an allowed path is the filesystem + // failing, and the reference lets it throw — so every caller that reads it + // propagates rather than generating an index that silently carries nothing. + // Mutation that reddens: swallow the error in LoadStoredIndex. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses permission bits; the read cannot be made to fail") + } + dir := copyExample(t) + m := loadManifest(t, dir) + abs := filepath.Join(dir, filepath.FromSlash(manifest.EffectiveIndexPath(m))) + if err := os.Chmod(abs, 0o000); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(abs, 0o644) }() + if f, oerr := os.Open(abs); oerr == nil { + _ = f.Close() + t.Skip("this platform ignores the mode; the read cannot be made to fail") + } + + if _, err := LoadStoredIndex(dir, m); err == nil { + t.Fatal("LoadStoredIndex must propagate an operational read failure") + } + if _, err := GenerateIndex(dir, m); err == nil { + t.Fatal("GenerateIndex must propagate an operational read failure") + } + if _, err := CheckIndex(dir, m); err == nil { + t.Fatal("CheckIndex must propagate an operational read failure") + } + if _, err := WriteIndex(dir, m); err == nil { + t.Fatal("WriteIndex must propagate an operational read failure") + } +} diff --git a/packages/sdk-go/internal/commands/init/adopt_test.go b/packages/sdk-go/internal/commands/init/adopt_test.go index 383ed0c..4990ec7 100644 --- a/packages/sdk-go/internal/commands/init/adopt_test.go +++ b/packages/sdk-go/internal/commands/init/adopt_test.go @@ -160,7 +160,7 @@ func TestAdoptReusesDocsRootAndMigrates(t *testing.T) { } // Non-redirecting entrypoint makes validate error. - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) found := false for _, f := range v.Findings { if f.Rule == "vendor-adapter-redirect" && f.Severity == findings.Error { @@ -201,7 +201,7 @@ func TestAdoptWireAdaptersConvertsAndValidates(t *testing.T) { if len(load.Manifest.VendorAdapters) != 1 || load.Manifest.VendorAdapters[0] != "CLAUDE.md" { t.Fatalf("vendorAdapters = %v, want [CLAUDE.md]", load.Manifest.VendorAdapters) } - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) errCount := 0 for _, f := range v.Findings { if f.Severity == findings.Error { @@ -312,7 +312,7 @@ func TestAdoptRefusesWhenLayerExists(t *testing.T) { func validateErrCount(t *testing.T, dir string) int { t.Helper() n := 0 - for _, f := range validate.ValidateLayer(dir, false).Findings { + for _, f := range validateLayer(t, dir, false).Findings { if f.Severity == findings.Error { n++ } @@ -440,3 +440,17 @@ func TestAdoptRefusesExistingLayerWithoutWireAdapters(t *testing.T) { t.Fatalf("err = %v, want the existing-layer refusal", err) } } + +// --- gate helpers ------------------------------------------------------------- +// These commands now carry an error channel, because an operational read failure on +// an allowed path propagates instead of being swallowed (the reference throws it). +// A test that does not construct such a failure asserts there is none. + +func validateLayer(t *testing.T, root string, content bool) validate.Result { + t.Helper() + res, err := validate.ValidateLayer(root, content) + if err != nil { + t.Fatalf("ValidateLayer(%s): %v", root, err) + } + return res +} diff --git a/packages/sdk-go/internal/commands/init/boundary_test.go b/packages/sdk-go/internal/commands/init/boundary_test.go new file mode 100644 index 0000000..51cec82 --- /dev/null +++ b/packages/sdk-go/internal/commands/init/boundary_test.go @@ -0,0 +1,425 @@ +package initcmd + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// The write boundary as these commands meet it: no pathname existence check decides +// a write, so a dangling symlink at a target is a standing entry (never written +// through, never read as absent), and a target resolving out of the repository is the +// same hard refusal a write to it would be. Mirrors the reference's onboarding and +// units cases. + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } +} + +func isSymlink(t *testing.T, abs string) bool { + t.Helper() + st, err := os.Lstat(abs) + return err == nil && st.Mode()&os.ModeSymlink != 0 +} + +func standsAt(t *testing.T, abs string) bool { + t.Helper() + _, err := os.Lstat(abs) + return err == nil +} + +// treeSnapshot is every entry under dir as `path -> bytes` (symlinks by their +// target), so a run that must write nothing can be held to the whole tree rather +// than to one file. +func treeSnapshot(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 { + childRel := e.Name() + if rel != "" { + childRel = rel + "/" + e.Name() + } + child := filepath.Join(dir, filepath.FromSlash(childRel)) + switch { + case e.Type()&os.ModeSymlink != 0: + target, err := os.Readlink(child) + if err != nil { + t.Fatal(err) + } + out = append(out, childRel+"\x00link:"+target) + case e.IsDir(): + walk(childRel) + case e.Type().IsRegular(): + body, err := os.ReadFile(child) + if err != nil { + t.Fatal(err) + } + out = append(out, childRel+"\x00"+string(body)) + default: + out = append(out, childRel+"\x00non-regular") + } + } + } + walk("") + sort.Strings(out) + return out +} + +func sameTree(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestInitRefusesAGitignoreSymlinkedOutOfTheRepository(t *testing.T) { + // Previously the one unguarded write in init: the `.leji/` ignore line went out + // through whatever `.gitignore` resolved to. It now goes through the chokepoint, + // so a planted link out of the tree is a refusal with nothing written through it. + // Mutation that reddens: write the merged text by pathname again. + dir := t.TempDir() + away := t.TempDir() + target := filepath.Join(away, "gitignore") + if err := os.WriteFile(target, []byte("node_modules/\n"), 0o644); err != nil { + t.Fatal(err) + } + mustSymlink(t, target, filepath.Join(dir, ".gitignore")) + + _, err := InitLayer(Options{Dir: dir, Yes: true, Name: "demo-context"}) + if err == nil || !strings.Contains(err.Error(), "refusing to write through a symlink that escapes the target") { + t.Fatalf("the escaping .gitignore must be refused, got %v", err) + } + if body, rerr := os.ReadFile(target); rerr != nil || string(body) != "node_modules/\n" { + t.Fatalf("the out-of-tree file must be byte-untouched, got %q (%v)", body, rerr) + } + if standsAt(t, filepath.Join(dir, "leji.json")) { + t.Fatal("the refusal must come before any layer write") + } +} + +func TestInitRefusesADanglingScaffoldTarget(t *testing.T) { + // A stat follows symlinks, so a dangling target read as absent and the guarded + // write landed at the link's destination — inside the root, but under a name init + // never planned. The verified read refuses the standing entry instead. + dir := t.TempDir() + target := filepath.Join(dir, "docs", "boot-profile.md") + mustSymlink(t, "never-created.md", target) + + _, err := InitLayer(Options{Dir: dir, Yes: true}) + if err == nil || !strings.Contains(err.Error(), "escapes the target") { + t.Fatalf("a dangling scaffold target must be refused, got %v", err) + } + if standsAt(t, filepath.Join(dir, "docs", "never-created.md")) { + t.Fatal("the dangling link's destination must never be created") + } + if !isSymlink(t, target) { + t.Fatal("the planted link must be left exactly as it was") + } +} + +func TestInitRefusesAScaffoldTargetSymlinkedOutsideTheRepository(t *testing.T) { + // A pathname check sees the link's outside target and reads the name as taken, so + // init would quietly skip the file it owns. The verified read judges where the + // entry resolves: outside the root is the same hard refusal a write to it is. + dir := t.TempDir() + away := t.TempDir() + outsideFile := filepath.Join(away, "boot-profile.md") + if err := os.WriteFile(outsideFile, []byte("# Outside the repository\n"), 0o644); err != nil { + t.Fatal(err) + } + mustSymlink(t, outsideFile, filepath.Join(dir, "docs", "boot-profile.md")) + + _, err := InitLayer(Options{Dir: dir, Yes: true}) + if err == nil || !strings.Contains(err.Error(), "escapes the target") { + t.Fatalf("a scaffold target resolving outside the repository must be refused, got %v", err) + } + if body, rerr := os.ReadFile(outsideFile); rerr != nil || string(body) != "# Outside the repository\n" { + t.Fatalf("the outside file must be untouched, got %q (%v)", body, rerr) + } +} + +func TestAdoptTreatsADanglingScaffoldNameAsOccupied(t *testing.T) { + // The scaffold names were picked with a stat, which follows symlinks: a dangling + // boot-profile link read as a free name, and the scaffold would have been written + // at the link's missing destination. Occupancy is decided on the standing entry + // now, so the alternate name is taken exactly as for an ordinary existing file. + dir := t.TempDir() + gitInit(t, dir) + if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs", "notes.md"), []byte("# Notes\n"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "docs", "boot-profile.md") + mustSymlink(t, "never-created.md", link) + gitCommitAll(t, dir) + + res, err := AdoptLayer(AdoptOptions{Dir: dir, Yes: true}) + if err != nil { + t.Fatalf("AdoptLayer: %v", err) + } + if res.Manifest.BootProfilePath != "docs/leji-boot-profile.md" { + t.Fatalf("the alternate name must be scaffolded, got %q", res.Manifest.BootProfilePath) + } + if standsAt(t, filepath.Join(dir, "docs", "never-created.md")) { + t.Fatal("the dangling link's destination must never be created") + } + if !isSymlink(t, link) { + t.Fatal("the planted link must be left exactly as it was") + } +} + +func TestAdoptTreatsADanglingMigrationDocNameAsOccupied(t *testing.T) { + // The disambiguation loop picks the archive's name. A dangling candidate read by + // pathname is a free name, and the migrated content would land at the link's + // missing destination; the standing entry makes it occupied. + dir := t.TempDir() + gitInit(t, dir) + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("original instructions\n"), 0o644); err != nil { + t.Fatal(err) + } + candidate := filepath.Join(dir, "docs", "governance", "imported-claude.md") + mustSymlink(t, "never-created.md", candidate) + gitCommitAll(t, dir) + + res, err := AdoptLayer(AdoptOptions{Dir: dir, Yes: true}) + if err != nil { + t.Fatalf("AdoptLayer: %v", err) + } + if len(res.Migrated) != 1 || res.Migrated[0] != "CLAUDE.md" { + t.Fatalf("the vendor content must still be migrated, got %v", res.Migrated) + } + if standsAt(t, filepath.Join(dir, "docs", "governance", "never-created.md")) { + t.Fatal("the dangling link's destination must never be created") + } + if !isSymlink(t, candidate) { + t.Fatal("the planted link must be left exactly as it was") + } + alt := filepath.Join(dir, "docs", "governance", "imported-claude-2.md") + body, err := os.ReadFile(alt) + if err != nil || !strings.Contains(string(body), "original instructions") { + t.Fatalf("the next name must carry the archive, got %q (%v)", body, err) + } +} + +func TestAdoptWireAdaptersTreatsADanglingArchiveCandidateAsOccupied(t *testing.T) { + dir := t.TempDir() + gitInit(t, dir) + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("original instructions\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCommitAll(t, dir) + if _, err := AdoptLayer(AdoptOptions{Dir: dir, Yes: true}); err != nil { + t.Fatalf("AdoptLayer: %v", err) + } + + candidate := filepath.Join(dir, "docs", "governance", "imported-claude.md") + if err := os.Remove(candidate); err != nil { + t.Fatal(err) + } + mustSymlink(t, "never-created.md", candidate) + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("hand-written rules added after adoption\n"), 0o644); err != nil { + t.Fatal(err) + } + + wired, err := AdoptLayer(AdoptOptions{Dir: dir, Yes: true, WireAdapters: true}) + if err != nil { + t.Fatalf("adopt --wire-adapters: %v", err) + } + if len(wired.Migrated) != 1 || wired.Migrated[0] != "CLAUDE.md" { + t.Fatalf("the newer content must still be archived, got %v", wired.Migrated) + } + if standsAt(t, filepath.Join(dir, "docs", "governance", "never-created.md")) { + t.Fatal("the dangling link's destination must never be created") + } + if !isSymlink(t, candidate) { + t.Fatal("the planted link must be left exactly as it was") + } + alt := filepath.Join(dir, "docs", "governance", "imported-claude-2.md") + body, err := os.ReadFile(alt) + if err != nil || !strings.Contains(string(body), "hand-written rules added after adoption") { + t.Fatalf("the next name must carry the archive, got %q (%v)", body, err) + } +} + +func TestAgentRefusesADanglingProfileNameWritingNeitherHalf(t *testing.T) { + // A stat follows symlinks, so a dangling profile link read as absent and the + // profile was written at the link's destination. Both halves are judged before + // either is written, so a refused profile leaves the manifest binding unwritten. + dir := t.TempDir() + gitInit(t, dir) + if _, err := InitLayer(Options{Dir: dir, Yes: true, Name: "demo"}); err != nil { + t.Fatalf("InitLayer: %v", err) + } + load := manifest.LoadManifest(dir) + link := filepath.Join(dir, "docs", "agents", "reviewer.md") + mustSymlink(t, "never-created.md", link) + before := treeSnapshot(t, dir) + + _, err := AddAgent(dir, load.Manifest, AgentOptions{Host: "codex", Name: "reviewer"}) + if err == nil || !strings.Contains(err.Error(), "escapes the target") { + t.Fatalf("a dangling profile name must refuse the command, got %v", err) + } + if standsAt(t, filepath.Join(dir, "docs", "agents", "never-created.md")) { + t.Fatal("the dangling link's destination must never be created") + } + if !sameTree(before, treeSnapshot(t, dir)) { + t.Fatal("neither half may be written") + } +} + +func TestAgentRefusesAManifestSymlinkedOutOfTheRepositoryWritingNothing(t *testing.T) { + // The other formerly unguarded write: the in-place manifest edit that binds the + // agent. Binding is two writes (a profile file and the manifest edit), so the + // manifest is judged through the verified read BEFORE either happens: a run that + // cannot finish must not half-finish. Nothing is written, anywhere. + dir := t.TempDir() + away := t.TempDir() + gitInit(t, dir) + if _, err := InitLayer(Options{Dir: dir, Yes: true, Name: "demo"}); err != nil { + t.Fatalf("InitLayer: %v", err) + } + load := manifest.LoadManifest(dir) + manifestAbs := filepath.Join(dir, "leji.json") + target := filepath.Join(away, "leji.json") + if err := os.Rename(manifestAbs, target); err != nil { + t.Fatal(err) + } + mustSymlink(t, target, manifestAbs) + before, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + profileAbs := filepath.Join(dir, "docs", "agents", "reviewer.md") + if standsAt(t, profileAbs) { + t.Fatal("the profile must not exist before the run") + } + snapshot := treeSnapshot(t, dir) + + _, aerr := AddAgent(dir, load.Manifest, AgentOptions{Name: "reviewer", Role: "reviewer"}) + if aerr == nil || !strings.Contains(aerr.Error(), `refusing to write through a symlink that escapes the target: "leji.json"`) { + t.Fatalf("the escaping manifest must refuse the command, got %v", aerr) + } + if after, rerr := os.ReadFile(target); rerr != nil || string(after) != string(before) { + t.Fatalf("the out-of-tree manifest must be byte-untouched (%v)", rerr) + } + if standsAt(t, profileAbs) { + t.Fatal("the profile must never be written") + } + if !sameTree(snapshot, treeSnapshot(t, dir)) { + t.Fatal("the whole tree must be byte-identical to the pre-run snapshot") + } +} + +func TestCiRefusesADanglingWorkflowTargetForEveryProvider(t *testing.T) { + // Every arm decided presence with a stat, which follows symlinks: a dangling + // workflow link read as absent and the create landed at the link's destination, a + // name inside the repository the tool never planned. The verified read refuses the + // standing entry instead, in the same words an escaping target gets. + for _, c := range []struct{ provider, rel string }{ + {"github", CIWorkflowPath}, + {"gitlab", GitlabCIPath}, + {"circleci", CircleCIConfigPath}, + {"azure", AzurePipelinePath}, + } { + dir := t.TempDir() + if _, err := InitLayer(Options{Dir: dir, Yes: true, Name: "demo"}); err != nil { + t.Fatalf("InitLayer: %v", err) + } + target := filepath.Join(dir, filepath.FromSlash(c.rel)) + mustSymlink(t, "never-created.yml", target) + + _, err := EnsureCiWorkflow(dir, c.provider, nil) + if err == nil || !strings.Contains(err.Error(), "refusing to write through a symlink that escapes the target") { + t.Fatalf("%s: a dangling workflow target must be refused, got %v", c.provider, err) + } + if standsAt(t, filepath.Join(filepath.Dir(target), "never-created.yml")) { + t.Fatalf("%s: the dangling link's destination must never be created", c.provider) + } + if !isSymlink(t, target) { + t.Fatalf("%s: the planted link must be left exactly as it was", c.provider) + } + } +} + +func TestCiRefusesAWorkflowTargetThatIsNotARegularFile(t *testing.T) { + // The merge reads the bytes it is about to rewrite through the verified read, so a + // target that is not a regular file is the same hard refusal a write to it would + // be, reported in the SDK's own words rather than as an OS read error. + dir := t.TempDir() + if _, err := InitLayer(Options{Dir: dir, Yes: true, Name: "demo"}); err != nil { + t.Fatalf("InitLayer: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "inside-dir"), 0o755); err != nil { + t.Fatal(err) + } + mustSymlink(t, filepath.Join(dir, "inside-dir"), filepath.Join(dir, GitlabCIPath)) + + _, err := EnsureCiWorkflow(dir, "gitlab", nil) + if err == nil || !strings.Contains(err.Error(), "refusing to write through a symlink that escapes the target") { + t.Fatalf("a non-regular workflow target must be refused, got %v", err) + } +} + +func TestAdoptPropagatesANonENOENTLstatWhilePickingNames(t *testing.T) { + // Occupancy is decided on the standing entry, and an lstat that fails for any + // reason other than absence answers neither "free" nor "occupied": the name cannot + // be judged, so the run fails rather than quietly moving to an alternate — exactly + // as the reference's lstat throws for anything but ENOENT. Mutation that reddens: + // have nothingStandsAt read a failed lstat as "occupied" — adopt scaffolds the + // alternate name instead of reporting the failure. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses directory permissions; the lstat cannot be made to fail") + } + dir := t.TempDir() + gitInit(t, dir) + if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs", "notes.md"), []byte("# Notes\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCommitAll(t, dir) + // Searchable to nothing: the scaffold names under docs/ cannot be lstat'd at all. + if err := os.Chmod(filepath.Join(dir, "docs"), 0o000); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(filepath.Join(dir, "docs"), 0o755) }() + if _, err := os.Lstat(filepath.Join(dir, "docs", "boot-profile.md")); err == nil || os.IsNotExist(err) { + t.Skip("this platform still answers lstat under a 0o000 directory") + } + + _, err := AdoptLayer(AdoptOptions{Dir: dir, Yes: true}) + if err == nil { + t.Fatal("an unjudgeable candidate name must fail the run, not fall through to an alternate") + } + if !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("the operational failure must surface: %v", err) + } +} diff --git a/packages/sdk-go/internal/commands/init/cigen.go b/packages/sdk-go/internal/commands/init/cigen.go new file mode 100644 index 0000000..cb2585e --- /dev/null +++ b/packages/sdk-go/internal/commands/init/cigen.go @@ -0,0 +1,468 @@ +package initcmd + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" +) + +// --- the generated CI job ------------------------------------------------- +// One table, one job resolution, four renderers. Every cell an adopter's pipeline +// runs is stated here rather than assembled at the call site, so the three SDKs +// transcribe data instead of re-deriving prose, and a reviewer reads the matrix. + +// CIProviders is every provider `leji ci` generates for, in a fixed order. +var CIProviders = []string{"github", "gitlab", "circleci", "azure"} + +// ciManagerCell holds one package manager's CI facts. pipBootstrap names a tool +// that has to be installed with pip wherever the provider offers no dedicated +// setup action; unpinned names a bootstrap tool the job installs unpinned, +// disclosed in one comment line. +type ciManagerCell struct { + runtime string + install string + pipBootstrap string + unpinned string +} + +// ciManagerOrder is the fixed enumeration order of the manager cells; Go maps do +// not preserve insertion order and the golden fixtures depend on it. +var ciManagerOrder = []string{"npm", "pnpm", "yarn", "bun", "uv", "poetry", "pdm", "pipenv", "go"} + +// ciManagers maps a manager to its install command and runtime. The runner argv is +// NOT duplicated here: it comes from the detection report, which owns the one +// runner table. +var ciManagers = map[string]ciManagerCell{ + "npm": {runtime: "node", install: "npm ci"}, + "pnpm": {runtime: "node", install: "corepack enable && pnpm install --frozen-lockfile"}, + "yarn": {runtime: "node", install: "corepack enable && yarn install --frozen-lockfile"}, + "bun": {runtime: "bun", install: "bun install --frozen-lockfile"}, + "uv": {runtime: "python", install: "uv sync --locked", pipBootstrap: "uv"}, + "poetry": {runtime: "python", install: "pip install poetry && poetry install", unpinned: "poetry"}, + "pdm": {runtime: "python", install: "pip install pdm && pdm install", unpinned: "pdm"}, + "pipenv": {runtime: "python", install: "pip install pipenv && pipenv install --dev", unpinned: "pipenv"}, + "go": {runtime: "go", install: "go mod download"}, +} + +// ciJob is the one job a provider renders: what to set up, what to install, what +// to run. +type ciJob struct { + runtime string + install []string + runner []string + unpinned string + uvAction bool + local bool +} + +// The CLI as CI reaches it when the repository does not declare it: version-pinned +// to the current major, which is additive-only, so a valid layer stays valid and a +// breaking major never reaches adopter CI without a bump. +var ciFallbackNode = []string{"npx", "-y", ecosystem.DepName + "@1"} + +const ciFallbackPyInstall = "pip install 'leji>=1,<2'" +const ciFallbackGoInstall = "go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + +// resolveCiJob decides which job this repository gets. Local-first: a repository +// that DECLARES the CLI and has the manager's lock evidence installs its own +// locked dependencies and runs the local binary. Everything else — undeclared, +// unlocked, ambiguous, unsupported, unreadable, refused evidence, several +// ecosystems, none — takes the fallback for its ecosystem, which needs no manifest +// and no lockfile. +func resolveCiJob(report ecosystem.Report, provider string) ciJob { + selected := report.Selected + if selected != nil && selected.Manager != nil && selected.DirectDeclared && selected.LockEvidenced && selected.Runner != nil { + if cell, ok := ciManagers[*selected.Manager]; ok { + // uv is the one manager with a first-party setup action; everywhere else it + // is pip-installed like poetry/pdm/pipenv, and disclosed the same way. + uvAction := provider == "github" && cell.pipBootstrap == "uv" + bootstrap := "" + if cell.pipBootstrap != "" && !uvAction { + bootstrap = cell.pipBootstrap + } + install := cell.install + if bootstrap != "" { + install = "pip install " + bootstrap + " && " + cell.install + } + unpinned := cell.unpinned + if unpinned == "" { + unpinned = bootstrap + } + return ciJob{ + runtime: cell.runtime, install: []string{install}, runner: selected.Runner, + unpinned: unpinned, uvAction: uvAction, local: true, + } + } + } + eco := "" + if len(report.All) == 1 { + eco = report.All[0].Ecosystem + } + if eco == "python" { + return ciJob{runtime: "python", install: []string{ciFallbackPyInstall}, runner: []string{"leji"}} + } + if eco == "go" { + return ciJob{runtime: "go", install: []string{ciFallbackGoInstall}, runner: []string{"leji"}} + } + // Node, several ecosystems, and none alike: the job that needs no package manager. + return ciJob{runtime: "node", install: []string{}, runner: ciFallbackNode} +} + +// ciGeneratorVersion is the generator schema version. Bumped when the generated +// shape changes, so the marker says which generation wrote a file; pre-1.4 output +// is implicitly v1. +const ciGeneratorVersion = "2" + +// CIMarker is the ownership claim every generated whole file opens with. +const CIMarker = "# generated by leji ci (managed) v" + ciGeneratorVersion + +// unpinnedNote is the one disclosure line for a job that installs a bootstrap tool +// unpinned. +func unpinnedNote(job ciJob) string { + if job.unpinned == "" { + return "" + } + return "# " + job.unpinned + " is installed unpinned here; pin it if your project pins it." +} + +// githubSetup renders the GitHub Actions setup steps for a runtime, already at the +// steps' indentation. +func githubSetup(job ciJob) []string { + switch job.runtime { + case "node": + return []string{" - uses: actions/setup-node@v4", " with:", " node-version: '22'"} + case "bun": + return []string{" - uses: oven-sh/setup-bun@v2"} + case "python": + lines := []string{" - uses: actions/setup-python@v5", " with:", " python-version: '3.12'"} + if job.uvAction { + lines = append(lines, " - uses: astral-sh/setup-uv@v5") + } + return lines + default: + return []string{" - uses: actions/setup-go@v5", " with:", " go-version: '1.24'"} + } +} + +func buildGithubWorkflow(job ciJob) string { + lines := []string{ + CIMarker, + "name: leji", + "on: [push, pull_request]", + "jobs:", + " validate:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + } + lines = append(lines, githubSetup(job)...) + if note := unpinnedNote(job); note != "" { + lines = append(lines, " "+note) + } + for _, cmd := range job.install { + lines = append(lines, " - run: "+cmd) + } + runner := strings.Join(job.runner, " ") + lines = append(lines, " - run: "+runner+" validate", " - run: "+runner+" index --check") + return strings.Join(lines, "\n") + "\n" +} + +// ciImage is the container image a job runs in on the image-based providers. +func ciImage(runtime string) string { + switch runtime { + case "node": + return "node:22" + case "bun": + return "oven/bun:1" + case "python": + return "python:3.12" + default: + return "golang:1.24" + } +} + +func buildGitlabBlock(job ciJob) string { + // `.pre` is always available. Without an explicit stage GitLab assigns `test`, + // and a pipeline whose own `stages:` list omits `test` rejects the whole + // configuration, so the generated job would break an existing pipeline it was + // merged into. + lines := []string{ + gitlabMarkerStart, + "leji-validate:", + " stage: .pre", + " image: " + ciImage(job.runtime), + " script:", + } + if note := unpinnedNote(job); note != "" { + lines = append(lines, " "+note) + } + for _, cmd := range job.install { + lines = append(lines, " - "+cmd) + } + runner := strings.Join(job.runner, " ") + lines = append(lines, " - "+runner+" validate", " - "+runner+" index --check", gitlabMarkerEnd) + return strings.Join(lines, "\n") + "\n" +} + +// circleCiJob renders the CircleCI job steps, shared by the full config and the +// hand-add snippet. +func circleCiJob(job ciJob) []string { + lines := []string{ + "jobs:", + " leji-validate:", + " docker:", + " - image: " + ciImage(job.runtime), + " steps:", + " - checkout", + } + if note := unpinnedNote(job); note != "" { + lines = append(lines, " "+note) + } + for _, cmd := range job.install { + lines = append(lines, " - run: "+cmd) + } + runner := strings.Join(job.runner, " ") + lines = append(lines, + " - run: "+runner+" validate", + " - run: "+runner+" index --check", + "workflows:", " leji:", " jobs:", " - leji-validate") + return lines +} + +func buildCircleCiConfig(job ciJob) string { + return strings.Join(append([]string{CIMarker, "version: 2.1"}, circleCiJob(job)...), "\n") + "\n" +} + +// buildCircleCiSnippet is the jobs + workflows fragment to add by hand to an +// existing CircleCI config. No marker: it is pasted into a file leji does not own. +func buildCircleCiSnippet(job ciJob) string { + return strings.Join(circleCiJob(job), "\n") + "\n" +} + +// azureSetup renders the Azure Pipelines setup tasks for a runtime, at the steps' +// indentation. +func azureSetup(job ciJob) []string { + switch job.runtime { + case "node": + return []string{" - task: NodeTool@0", " inputs:", " versionSpec: '22.x'"} + case "bun": + return []string{ + " - task: NodeTool@0", " inputs:", " versionSpec: '22.x'", + " - script: npm install -g bun", " displayName: install bun", + } + case "python": + return []string{" - task: UsePythonVersion@0", " inputs:", " versionSpec: '3.12'"} + default: + return []string{" - task: GoTool@0", " inputs:", " version: '1.24'"} + } +} + +func buildAzurePipeline(job ciJob) string { + lines := []string{CIMarker, "trigger:", " - main", "pool:", " vmImage: ubuntu-latest", "steps:"} + lines = append(lines, azureSetup(job)...) + if note := unpinnedNote(job); note != "" { + lines = append(lines, " "+note) + } + for _, cmd := range job.install { + lines = append(lines, " - script: "+cmd, " displayName: install") + } + runner := strings.Join(job.runner, " ") + lines = append(lines, + " - script: "+runner+" validate", " displayName: leji validate", + " - script: "+runner+" index --check", " displayName: leji index --check") + return strings.Join(lines, "\n") + "\n" +} + +// buildCiFile renders the whole file a provider writes for a job; GitLab's is the +// block it owns inside a shared file. +func buildCiFile(provider string, job ciJob) string { + switch provider { + case "github": + return buildGithubWorkflow(job) + case "gitlab": + return buildGitlabBlock(job) + case "circleci": + return buildCircleCiConfig(job) + default: + return buildAzurePipeline(job) + } +} + +// CiVariant is one artifact of the current generator. +type CiVariant struct { + Provider string + Key string + Bytes string +} + +// ciJobVariants enumerates every job this generator can produce, in a fixed order: +// the nine local manager cells, then the three ecosystem fallbacks. The +// enumeration is what proves the digest registry complete and what checks the +// golden fixtures. +func ciJobVariants(provider string) []struct { + key string + job ciJob +} { + out := []struct { + key string + job ciJob + }{} + for _, manager := range ciManagerOrder { + cell := ciManagers[manager] + uvAction := provider == "github" && cell.pipBootstrap == "uv" + bootstrap := "" + if cell.pipBootstrap != "" && !uvAction { + bootstrap = cell.pipBootstrap + } + install := cell.install + if bootstrap != "" { + install = "pip install " + bootstrap + " && " + cell.install + } + unpinned := cell.unpinned + if unpinned == "" { + unpinned = bootstrap + } + runner := ecosystem.ManagerRunnerArgv(manager) + if runner == nil { + runner = []string{"leji"} + } + out = append(out, struct { + key string + job ciJob + }{ + key: manager + "-local", + job: ciJob{ + runtime: cell.runtime, install: []string{install}, runner: runner, + unpinned: unpinned, uvAction: uvAction, local: true, + }, + }) + } + out = append(out, + struct { + key string + job ciJob + }{key: "node-fallback", job: ciJob{runtime: "node", install: []string{}, runner: ciFallbackNode}}, + struct { + key string + job ciJob + }{key: "python-fallback", job: ciJob{runtime: "python", install: []string{ciFallbackPyInstall}, runner: []string{"leji"}}}, + struct { + key string + job ciJob + }{key: "go-fallback", job: ciJob{runtime: "go", install: []string{ciFallbackGoInstall}, runner: []string{"leji"}}}, + ) + return out +} + +// CiVariants returns every generated artifact of the CURRENT generator: provider, +// variant key, and bytes. Exported for the tests that check +// fixtures/ci-goldens/ and prove the digest registry lists every variant this +// release can write. +func CiVariants() []CiVariant { + out := []CiVariant{} + for _, provider := range CIProviders { + for _, v := range ciJobVariants(provider) { + out = append(out, CiVariant{Provider: provider, Key: v.key, Bytes: buildCiFile(provider, v.job)}) + } + } + return out +} + +// knownGenerated holds digests of every whole file this generator has ever +// written, so a file leji created in an EARLIER release is still recognized as its +// own and upgraded rather than abandoned. Appended at each release; the marker line +// carries the generator version that wrote a file, and these digests carry the ones +// that predate it. +// +// Keyed by provider, and consulted only for the provider whose path is being +// written: the same bytes are leji's workflow at .github/workflows/leji.yml and +// somebody else's file at .azure-pipelines/leji.yml. +// +// Seeded with the pre-1.4 (1.3.x) variants, which carry no marker at all: two per +// whole-file provider, the local-install job and the `npx @leji-org/leji@1` +// fallback. Each release since appends its own twelve at pre-flight, enumerated by +// CiVariants() and printed by a test, so the next release still recognizes them; +// while a release is current its variants are also compared by bytes, which is +// strictly stronger. +var knownGenerated = map[string][]string{ + // 1.3.x GitHub Actions: local install, then the npx fallback. + "github": { + "ef38ea0bc0daa13b9856ca9abeb5f2229ae2465aeed2f61bd94f557a1806f13d", + "1c2afeb4d3043f94823ac0c1a254a8735c0fe87844cd454cf8ae07fbfa6588d4", + // 1.4.0: the twelve job variants, in ciVariants() order. + "616638c5c1594e8faeb38cd476b9c5e0a4d12889a01b54076a0f8ffceba8a1a8", // npm-local + "1b9afce2109d75c86ba3e3b33abd2466d55509d7e46b5ef79a9407d3df566154", // pnpm-local + "e16f877d33a5be6e2c720112692167e9442fb5c63a2335f95401b7a891d46e18", // yarn-local + "e0819c85b4b3e540472fa5d2a3820ae0c4837a41b66cc97de84581c1b19ede96", // bun-local + "7b3d400ea23799ebf26541bffe059db4e9f60021fdf0f783c1f1cfa7a532816d", // uv-local + "87089be204a85a61dfcfbcb9f4a9f2ad2f5afc8b00f384d52dfbd81c63e0296f", // poetry-local + "48b3c7a1751b65bbea29421e7e952ac8c2720169ff332ea0e277573a2fb582c0", // pdm-local + "809d7ee991b8c1182442d93e326d4dc3ad9e0993f91f4da83aac8187c98e90bb", // pipenv-local + "e952a6109a05d97adc2791f641f807245c75ba401ced279964b06fcc2987b92e", // go-local + "ae3385d9deac83936000100621078011b1918a66237d4ae1ef72770dc677914a", // node-fallback + "b0be130068ad150eb7f59a2166a4fc22601e10ec961d2144d4c158602fde1f9c", // python-fallback + "91b37a1c14fcc6f237d9600b8f49bb936eb2091f418fe8932fc94cdbfe91454f", // go-fallback + }, + // GitLab owns a marked block inside a shared file, never a whole file, so it + // recognizes its own output by the markers and registers no digests. + "gitlab": {}, + "circleci": { + "99a942be4f0ac62672af68a9d33e17328441e64f3b28b52ff8dafede0a5ce9f0", + "cf813aa8c65a5efa64500628bc51c73d3ae3a5f56ec47386f5525de0828818d3", + // 1.4.0: the twelve job variants, in ciVariants() order. + "e72c78146170d54a4b79326b3a8933ba0ca54bf76be665b45f47009729a864b1", // npm-local + "039559039ccda2dca14da3366cb1ca56895f4eaf48a395a7dd75f4a3614dabc1", // pnpm-local + "ba5303502b7fc3e70174b163666fe27af855663b2b66900a0e1affcd3ee3290b", // yarn-local + "e46e865183f764c1d6bf594e9d074744911221232db2805ea3de3896fa920ce4", // bun-local + "4b974432dc0d1939a89e8ca9c130ec16c70e1aeb1252fd5895785018e9e84f98", // uv-local + "3cc62af0609c563e0268e632285d28d7365c4ce81652e1c0d9f3f62496f01665", // poetry-local + "b519fa8e216b62a8da7ce0f98b53de81dde62f4c3bed924b7fbc59b7b5f8af1f", // pdm-local + "6786b3df303d00239170bf0365e7ff66662fcfc0912ea6cd992547e1422eed9a", // pipenv-local + "43ca7c5fce284555d5b72a6cd69c153e295ae01f921dd5e2b755e11d4e3e9e8f", // go-local + "b82f65f616ec46445e43b8c1680618428cce89ee17e69f0a1e2b94b4ace2fc9b", // node-fallback + "0d2135d41e50be5fa6811bdc9aa85fc0ce4110ec918e17c139cd969087834e4b", // python-fallback + "704a4b3c3f8880c505e181eed154234e4640cdad5d13a3c9dfe5b4cdcdfbd3d9", // go-fallback + }, + "azure": { + "71fb19e18660e84ec4a2b9364ea6a9dea0ca7aff8bb52ede8d5c3f4d77c68669", + "7a086e5cd0f2e8a2e67b925ec54b8e8febb1bca016e1893c95fd00815d86c63a", + // 1.4.0: the twelve job variants, in ciVariants() order. + "9c8127bfb670731eb08089b02a1adecc135bc32524699793e83e23eb4143e4f1", // npm-local + "099befce80e7420297583ee3a88bed97f01c073dbb756bbf64ad37b321eb506a", // pnpm-local + "5d1f2642c97954b6fca52fa8239534c5633cc3bc512c7946f2515473bde58bb7", // yarn-local + "9e60a214631033172e3021d773581f15db14c3e1e05d1673f05d7752ff054b03", // bun-local + "c4d041736cedbd2092667480bbaf91711f3696997256456fa276a59660c66555", // uv-local + "2913197fc21a1fbf3587695af2b2d2215b6f9966507b542ecc08e44727b5bf2b", // poetry-local + "f756c8ea1ff653d4bed01ac60aa9ba26b426936cf19a5be54508a166e66ca6f6", // pdm-local + "7a452ce135e706fdada5f953a18bdaafb0fd453650d061b0e07f62e5309d6d58", // pipenv-local + "ce1b7546d140ba09838e9af8dab92ecebf75f20c7e7714c2eeb210ab1f1422d3", // go-local + "1a4167a0b4b3a5b7528d7a6d0bcefeabd37f617b02f82772fd6c74c148d9e17e", // node-fallback + "c9cd3d115cb4f4d9db1b3f523cfbbf1397923df6142c58e5e6c8562ff57fa908", // python-fallback + "23679c491cbd53e39ffc5d940de7b97865f1b944bf55ad1bd7e73b8c57520606", // go-fallback + }, +} + +// isLejiGenerated reports whether this file is leji's to replace: yes when its +// bytes are one this generator can write right now, or when its digest is one an +// earlier release wrote. A file the user edited matches neither, and is left alone +// with a snippet — editing a generated file, or deleting its marker, is the +// explicit opt-out, and it is honored. +func isLejiGenerated(provider, text string) bool { + for _, v := range CiVariants() { + if v.Provider == provider && v.Bytes == text { + return true + } + } + sum := sha256.Sum256([]byte(text)) + digest := hex.EncodeToString(sum[:]) + // Scoped to THIS provider: a file that is leji's at one provider's path is a + // foreign file at another's, and a foreign file is never replaced. + for _, known := range knownGenerated[provider] { + if known == digest { + return true + } + } + return false +} diff --git a/packages/sdk-go/internal/commands/init/cigen_test.go b/packages/sdk-go/internal/commands/init/cigen_test.go new file mode 100644 index 0000000..ec54281 --- /dev/null +++ b/packages/sdk-go/internal/commands/init/cigen_test.go @@ -0,0 +1,365 @@ +package initcmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" +) + +func goldensDir(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + return filepath.Join(wd, "..", "..", "..", "..", "..", "fixtures", "ci-goldens") +} + +func golden(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(goldensDir(t), name)) + if err != nil { + t.Fatalf("golden %s: %v", name, err) + } + return string(data) +} + +var ciRel = map[string]string{ + "github": CIWorkflowPath, + "gitlab": GitlabCIPath, + "circleci": CircleCIConfigPath, + "azure": AzurePipelinePath, +} + +// Every generated variant matches its committed bytes: the goldens are the byte +// oracle for what an adopter's pipeline runs, shared with the reference. +func TestCiVariantsMatchGoldens(t *testing.T) { + variants := CiVariants() + if len(variants) != len(CIProviders)*12 { + t.Fatalf("expected nine local managers plus three fallbacks per provider, got %d", len(variants)) + } + for _, v := range variants { + if want := golden(t, v.Provider+"-"+v.Key+".yml"); v.Bytes != want { + t.Errorf("%s/%s bytes differ\n--- want ---\n%s\n--- got ---\n%s", v.Provider, v.Key, want, v.Bytes) + } + } +} + +// The hook bodies match, for every runner. +func TestHookGoldens(t *testing.T) { + for _, manager := range []string{"npm", "pnpm", "yarn", "bun", "uv", "poetry", "pdm", "pipenv", "go"} { + argv := ecosystem.ManagerRunnerArgv(manager) + if got := HookBody(argv); got != golden(t, "hook-"+manager+".sh") { + t.Errorf("hook-%s.sh differs:\n%s", manager, got) + } + if got := HuskyBlock(argv); got != golden(t, "husky-"+manager+".sh") { + t.Errorf("husky-%s.sh differs:\n%s", manager, got) + } + } + if got := HookBody([]string{"leji"}); got != golden(t, "hook-fallback.sh") { + t.Errorf("hook-fallback.sh differs:\n%s", got) + } + if got := HuskyBlock([]string{"leji"}); got != golden(t, "husky-fallback.sh") { + t.Errorf("husky-fallback.sh differs:\n%s", got) + } +} + +// Every argv element is single-quoted for sh, with the one legal escape, and the +// scalar shim is gone. +func TestShQuoteAndHookShape(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"pnpm", "'pnpm'"}, + {"we'ird", `'we'\''ird'`}, + {"a b", "'a b'"}, + {"x$HOME", "'x$HOME'"}, + } { + if got := ShQuote(c.in); got != c.want { + t.Errorf("ShQuote(%q) = %q, want %q", c.in, got, c.want) + } + } + body := HookBody([]string{"weird bin", "quo'te", "$HOME", "`cmd`", "*"}) + if !strings.Contains(body, `'weird bin' 'quo'\''te' '$HOME' '`+"`cmd`"+`' '*' validate || exit 1`) { + t.Errorf("hostile runner not quoted:\n%s", body) + } + if !strings.Contains(body, "echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2") { + t.Errorf("the stale-index message keeps its own shell quoting:\n%s", body) + } + for _, argv := range [][]string{{"leji"}, {"pnpm", "exec", "leji"}, {"go", "tool", "leji"}} { + for _, gone := range []string{"node_modules", "LEJI=", "$LEJI"} { + if strings.Contains(HookBody(argv), gone) || strings.Contains(HuskyBlock(argv), gone) { + t.Errorf("the shim is gone; found %q", gone) + } + } + } +} + +func plantRoot(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for rel, body := range files { + writeFile(t, filepath.Join(dir, rel), body) + } + return dir +} + +var declaredPkg = "{\n \"name\": \"demo\",\n \"devDependencies\": { \"@leji-org/leji\": \"^1\" }\n}\n" + +// A declared, lock-evidenced repository gets its own manager job, at every provider. +func TestCiTableLocalJobs(t *testing.T) { + roots := map[string]map[string]string{ + "pnpm": {"package.json": declaredPkg, "pnpm-lock.yaml": ""}, + "npm": {"package.json": declaredPkg, "package-lock.json": ""}, + "uv": {"pyproject.toml": "[project]\nname = \"d\"\nversion = \"0\"\ndependencies = [\"leji\"]\n", "uv.lock": ""}, + "go": {"go.mod": "module example.com/d\n\ngo 1.24.0\n\ntool github.com/leji-org/leji/packages/sdk-go/cmd/leji\n"}, + } + for manager, files := range roots { + for _, provider := range CIProviders { + dir := plantRoot(t, files) + r, err := EnsureCiWorkflow(dir, provider, nil) + if err != nil { + t.Fatalf("%s/%s: %v", manager, provider, err) + } + if r.Action != "created" { + t.Errorf("%s/%s: action %q, want created", manager, provider, r.Action) + } + got := readFile(t, filepath.Join(dir, ciRel[provider])) + if want := golden(t, provider+"-"+manager+"-local.yml"); got != want { + t.Errorf("%s/%s bytes differ:\n%s", manager, provider, got) + } + } + } +} + +// local needs BOTH the declaration and the lock evidence, and every other state +// falls back to a job that needs no manifest. +func TestCiTableFallbacks(t *testing.T) { + cases := []struct { + files map[string]string + key string + }{ + {map[string]string{"package.json": declaredPkg}, "node-fallback"}, + {map[string]string{"package.json": "{\"name\":\"demo\"}\n", "package-lock.json": ""}, "node-fallback"}, + {map[string]string{"package.json": "{}\n", "package-lock.json": "", "yarn.lock": ""}, "node-fallback"}, + {map[string]string{"package.json": "{\"packageManager\":\"hermit@1.0.0\"}\n"}, "node-fallback"}, + {map[string]string{"pyproject.toml": "[project]\nname = \"d\"\nversion = \"0\"\n"}, "python-fallback"}, + {map[string]string{"requirements.txt": "requests\n"}, "python-fallback"}, + {map[string]string{"go.mod": "module example.com/d\n\ngo 1.23\n"}, "go-fallback"}, + {map[string]string{"go.mod": "module example.com/d\n\ngo 1.24.0\n"}, "go-fallback"}, + {map[string]string{"package.json": "{}\n", "pyproject.toml": "[project]\nname=\"d\"\nversion=\"0\"\n"}, "node-fallback"}, + } + for _, c := range cases { + dir := plantRoot(t, c.files) + if _, err := EnsureCiWorkflow(dir, "github", nil); err != nil { + t.Fatalf("%v: %v", c.files, err) + } + if got := readFile(t, filepath.Join(dir, CIWorkflowPath)); got != golden(t, "github-"+c.key+".yml") { + t.Errorf("expected %s:\n%s", c.key, got) + } + } +} + +// The bootstrap disclosure appears exactly once, only where a tool is unpinned. +func TestCiUnpinnedDisclosure(t *testing.T) { + for _, v := range CiVariants() { + hits := 0 + for _, line := range strings.Split(v.Bytes, "\n") { + if strings.Contains(line, "is installed unpinned here") { + hits++ + } + } + wants := strings.HasPrefix(v.Key, "poetry-local") || strings.HasPrefix(v.Key, "pdm-local") || + strings.HasPrefix(v.Key, "pipenv-local") || (v.Key == "uv-local" && v.Provider != "github") + want := 0 + if wants { + want = 1 + } + if hits != want { + t.Errorf("%s/%s: %d disclosure lines, want %d", v.Provider, v.Key, hits, want) + } + } + if !strings.Contains(golden(t, "github-uv-local.yml"), "astral-sh/setup-uv@v5") || + strings.Contains(golden(t, "github-uv-local.yml"), "pip install uv") { + t.Error("uv on GitHub uses its own setup action") + } + if !strings.Contains(golden(t, "gitlab-uv-local.yml"), "pip install uv && uv sync --locked") { + t.Error("uv elsewhere is pip-installed") + } +} + +// The marker names the generator version on every whole file, and never inside the +// GitLab block; and the Node fallback job is the pre-1.4 job plus that marker. +func TestCiMarkerAndLegacyJob(t *testing.T) { + for _, v := range CiVariants() { + if v.Provider == "gitlab" { + if strings.Contains(v.Bytes, "generated by leji ci (managed)") { + t.Errorf("the GitLab block keeps its own markers: %s", v.Key) + } + if !strings.HasPrefix(v.Bytes, "# >>> leji ci (managed) >>>\n") { + t.Errorf("gitlab/%s missing its block marker", v.Key) + } + continue + } + if !strings.HasPrefix(v.Bytes, CIMarker+"\n") { + t.Errorf("%s/%s missing the ownership marker", v.Provider, v.Key) + } + } + for _, provider := range []string{"github", "circleci", "azure"} { + before := golden(t, "legacy-1.3-"+provider+"-fallback.yml") + if now := golden(t, provider+"-node-fallback.yml"); now != CIMarker+"\n"+before { + t.Errorf("%s: the fallback job is the pre-1.4 job plus the marker", provider) + } + } +} + +// Ownership: a file generated by an EARLIER release is upgraded, a re-run is +// unchanged, a manager change is rewritten, and an edited or foreign file is left +// alone with a snippet. +func TestCiOwnership(t *testing.T) { + pnpm := map[string]string{"package.json": declaredPkg, "pnpm-lock.yaml": ""} + for _, provider := range []string{"github", "circleci", "azure"} { + for _, mode := range []string{"local", "fallback"} { + dir := plantRoot(t, pnpm) + abs := filepath.Join(dir, ciRel[provider]) + writeFile(t, abs, golden(t, "legacy-1.3-"+provider+"-"+mode+".yml")) + r, err := EnsureCiWorkflow(dir, provider, nil) + if err != nil { + t.Fatal(err) + } + if r.Action != "updated" { + t.Errorf("%s/%s: action %q, want updated", provider, mode, r.Action) + } + if got := readFile(t, abs); got != golden(t, provider+"-pnpm-local.yml") { + t.Errorf("%s/%s: not upgraded to the current job", provider, mode) + } + } + } + + for _, provider := range CIProviders { + dir := plantRoot(t, map[string]string{ + "pyproject.toml": "[project]\nname = \"d\"\nversion = \"0\"\ndependencies = [\"leji\"]\n", + "uv.lock": "", + }) + if r, _ := EnsureCiWorkflow(dir, provider, nil); r.Action != "created" { + t.Fatalf("%s: first run should create", provider) + } + after := readFile(t, filepath.Join(dir, ciRel[provider])) + r, err := EnsureCiWorkflow(dir, provider, nil) + if err != nil { + t.Fatal(err) + } + if r.Action != "unchanged" { + t.Errorf("%s: re-run action %q, want unchanged", provider, r.Action) + } + if readFile(t, filepath.Join(dir, ciRel[provider])) != after { + t.Errorf("%s: re-run is byte-identical", provider) + } + } + + // A file this generator wrote for a different manager: still leji's. + change := plantRoot(t, pnpm) + writeFile(t, filepath.Join(change, CIWorkflowPath), golden(t, "github-npm-local.yml")) + if r, _ := EnsureCiWorkflow(change, "github", nil); r.Action != "updated" { + t.Error("a manager change rewrites the job leji owns") + } + if got := readFile(t, filepath.Join(change, CIWorkflowPath)); got != golden(t, "github-pnpm-local.yml") { + t.Error("the manager change lands the pnpm job") + } + + for _, provider := range []string{"github", "circleci", "azure"} { + edited := plantRoot(t, pnpm) + mine := golden(t, provider+"-pnpm-local.yml") + writeFile(t, filepath.Join(edited, ciRel[provider]), mine+" - run: echo mine\n") + r, err := EnsureCiWorkflow(edited, provider, nil) + if err != nil { + t.Fatal(err) + } + if r.Action != "manual" || r.Snippet == "" { + t.Errorf("%s: an edited generated file is manual with a snippet", provider) + } + if readFile(t, filepath.Join(edited, ciRel[provider])) != mine+" - run: echo mine\n" { + t.Errorf("%s: the edit is the opt-out and is honored", provider) + } + + foreign := plantRoot(t, pnpm) + writeFile(t, filepath.Join(foreign, ciRel[provider]), "name: someone-elses-pipeline\n") + if r, _ := EnsureCiWorkflow(foreign, provider, nil); r.Action != "manual" { + t.Errorf("%s: a foreign file is manual", provider) + } + if readFile(t, filepath.Join(foreign, ciRel[provider])) != "name: someone-elses-pipeline\n" { + t.Errorf("%s: a foreign file is untouched", provider) + } + } +} + +// The legacy registry is scoped to its own provider: the same bytes are leji's at +// one path and somebody else's at another. +func TestCiOwnershipIsProviderScoped(t *testing.T) { + pnpm := map[string]string{"package.json": declaredPkg, "pnpm-lock.yaml": ""} + cross := []struct{ provider, foreign string }{ + {"github", "legacy-1.3-circleci-local.yml"}, + {"github", "legacy-1.3-azure-fallback.yml"}, + {"circleci", "legacy-1.3-github-local.yml"}, + {"circleci", "legacy-1.3-azure-local.yml"}, + {"azure", "legacy-1.3-github-fallback.yml"}, + {"azure", "legacy-1.3-circleci-fallback.yml"}, + } + for _, c := range cross { + dir := plantRoot(t, pnpm) + bytes := golden(t, c.foreign) + writeFile(t, filepath.Join(dir, ciRel[c.provider]), bytes) + r, err := EnsureCiWorkflow(dir, c.provider, nil) + if err != nil { + t.Fatal(err) + } + if r.Action != "manual" { + t.Errorf("%s at the %s path is foreign, got %q", c.foreign, c.provider, r.Action) + } + if readFile(t, filepath.Join(dir, ciRel[c.provider])) != bytes { + t.Errorf("%s: left untouched", c.provider) + } + } + for _, provider := range []string{"github", "circleci", "azure"} { + dir := plantRoot(t, pnpm) + writeFile(t, filepath.Join(dir, ciRel[provider]), golden(t, "legacy-1.3-"+provider+"-local.yml")) + if r, _ := EnsureCiWorkflow(dir, provider, nil); r.Action != "updated" { + t.Errorf("%s: its own legacy bytes are still recognized", provider) + } + } +} + +// GitLab still owns only its marked block. +func TestGitlabOwnsOnlyItsBlock(t *testing.T) { + dir := plantRoot(t, map[string]string{"package.json": declaredPkg, "pnpm-lock.yaml": ""}) + abs := filepath.Join(dir, GitlabCIPath) + writeFile(t, abs, "stages:\n - test\n") + if r, _ := EnsureCiWorkflow(dir, "gitlab", nil); r.Action != "updated" { + t.Fatal("the block is merged into an existing config") + } + merged := readFile(t, abs) + if !strings.HasPrefix(merged, "stages:\n - test\n") { + t.Error("the surrounding config is preserved") + } + if !strings.Contains(merged, golden(t, "gitlab-pnpm-local.yml")) { + t.Error("the managed block is exact") + } +} + +// The hook carries the repository's own runner, and a re-run is idempotent. +func TestEnsureLocalHookUsesTheDetectedRunner(t *testing.T) { + dir := gitInitRepo(t) + writeFile(t, filepath.Join(dir, "package.json"), declaredPkg) + writeFile(t, filepath.Join(dir, "pnpm-lock.yaml"), "") + r, err := EnsureLocalHook(dir, nil) + if err != nil { + t.Fatal(err) + } + if r.Action != "created" { + t.Fatalf("action %q, want created", r.Action) + } + if got := readFile(t, filepath.Join(dir, r.Path)); got != golden(t, "hook-pnpm.sh") { + t.Errorf("the hook is the pnpm golden:\n%s", got) + } + if again, _ := EnsureLocalHook(dir, nil); again.Action != "unchanged" { + t.Errorf("re-run action %q, want unchanged", again.Action) + } +} diff --git a/packages/sdk-go/internal/commands/init/dependency.go b/packages/sdk-go/internal/commands/init/dependency.go new file mode 100644 index 0000000..dc77dbe --- /dev/null +++ b/packages/sdk-go/internal/commands/init/dependency.go @@ -0,0 +1,167 @@ +package initcmd + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" +) + +// AddResult is what running one manager add command did: the runner's own result +// type, where the start state lives. A spawn that never started reports Started +// false; a started run reports its exit code, and Signal when the runtime killed +// it. Transcribed from the TypeScript reference's spawnSync shape: error, then +// signal, then a non-zero code. +type AddResult struct { + Started bool + ExitCode int + Signal string +} + +// DependencyIO is injectable I/O for the declaration offer, so the interactive +// flow is deterministically testable and no test can reach a real package +// manager. DefaultDependencyIO is the production wiring. +type DependencyIO struct { + // ReadLine prompts and returns one trimmed line; "" means accept the default. + ReadLine func(question, fallback string) string + // Run runs the manager's own add command from cwd, argv only and never a + // shell, with the child inheriting the terminal so the user sees the manager's + // own output. + Run func(bin string, args []string, cwd string) AddResult +} + +// DefaultDependencyIO wires a one-shot stdin line reader and an argv spawn. +func DefaultDependencyIO(in io.Reader, out io.Writer) *DependencyIO { + hio := DefaultHandoffIO(in, out) + return &DependencyIO{ + ReadLine: hio.ReadLine, + Run: func(bin string, args []string, cwd string) AddResult { + cmd := exec.Command(bin, args...) + cmd.Dir = cwd + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil { + return AddResult{Started: true, ExitCode: 0} + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // The process started. A signalled child has no exit code of its own, + // so the signal is what the outcome reports. + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return AddResult{Started: true, ExitCode: -1, Signal: SignalName(status.Signal())} + } + return AddResult{Started: true, ExitCode: exitErr.ExitCode()} + } + // Never started (ENOENT and friends): a missing binary, not a failed add. + return AddResult{Started: false} + }, + } +} + +// DependencyOfferOptions configures OfferDependency, the post-scaffold +// declaration offer. +type DependencyOfferOptions struct { + // Root is the absolute layer root: the cwd the manager runs in, so its manifest + // and lock edits land in this repository and nowhere else. + Root string + // Report is the detection answer for Root. + Report ecosystem.Report + // Interactive is a real TTY, not --yes, and not --json; the manager never runs + // otherwise. + Interactive bool + IO *DependencyIO +} + +// DependencyOffer is what the declaration step did, in the reference's shape: +// Ran means the add was consented to and attempted, and ExitCode and Signal are +// nullable exactly as they are in the `--json` contract. A signalled manager has +// no exit code of its own (ExitCode nil, Signal set), and a spawn that never +// started has neither (both nil with Ran true) — which counts as a failure just +// like a non-zero exit. The runner's start state stays in AddResult. +type DependencyOffer struct { + Offered bool + Ran bool + Command []string + ExitCode *int + Signal *string +} + +// DependencyAddFailed reports a consented add that did not succeed, so the +// command must not exit 0: the layer is written but the durable setup the run +// promised was not reached. +func DependencyAddFailed(offer DependencyOffer) bool { + return offer.Ran && (offer.ExitCode == nil || *offer.ExitCode != 0 || offer.Signal != nil) +} + +func intPtr(v int) *int { return &v } +func sigPtr(v string) *string { return &v } + +// OfferDependency tells the user how a clean install of this repository will +// bring leji, and offers to run their own package manager's add command. leji +// writes no manifest or lockfile byte itself: the manager owns both formats, so +// the only thing that changes the repository here is a command the user +// explicitly accepted. +// +// The block is ALWAYS printed (this function is simply not called under --json, +// which is a single-document mode). The prompt fires only when the run is +// interactive, an add command exists for the detected manager, and the CLI is not +// already declared. +func OfferDependency(opts DependencyOfferOptions, out io.Writer) DependencyOffer { + fmt.Fprintln(out, "\n"+ecosystem.RenderBlock(opts.Report)) + selected := opts.Report.Selected + var command []string + if selected != nil && selected.Add != nil { + command = selected.Add + } + offered := command != nil && !selected.DirectDeclared + skipped := DependencyOffer{Offered: offered, Command: command} + if !offered || !opts.Interactive { + return skipped + } + + dio := opts.IO + if dio == nil { + dio = DefaultDependencyIO(os.Stdin, out) + } + // Consent is only consent if it is informed: the manager runs here, as this + // user, with this environment, and does whatever it normally does. + fmt.Fprintln(out, ecosystem.ConsentDisclosure(command[0])) + answer := strings.ToLower(dio.ReadLine(ecosystem.ConsentPrompt, "Y/n")) + if !(answer == "" || answer == "y" || answer == "yes") { + fmt.Fprintln(out, ecosystem.ConsentDeclined) + fmt.Fprintln(out, ecosystem.ConsentCommand(command)) + return skipped + } + fmt.Fprintln(out, ecosystem.ConsentRunning(command)) + res := dio.Run(command[0], command[1:], opts.Root) + attempted := DependencyOffer{Offered: offered, Ran: true, Command: command} + // A spawn that never started surfaces as a start failure, never as an exit + // code, so it is reported as a missing binary rather than as a failed add, and + // it carries neither an exit code nor a signal. + if !res.Started { + fmt.Fprintln(out, ecosystem.ConsentMissing(command[0])) + fmt.Fprintln(out, ecosystem.ConsentCommand(command)) + return attempted + } + if res.Signal != "" { + fmt.Fprintln(out, ecosystem.ConsentSignaled(command[0], res.Signal)) + fmt.Fprintln(out, ecosystem.ConsentCommand(command)) + attempted.Signal = sigPtr(res.Signal) + return attempted + } + attempted.ExitCode = intPtr(res.ExitCode) + if res.ExitCode != 0 { + fmt.Fprintln(out, ecosystem.ConsentExited(command[0], res.ExitCode)) + fmt.Fprintln(out, ecosystem.ConsentCommand(command)) + return attempted + } + fmt.Fprintln(out, ecosystem.ConsentDeclared(selected.Ecosystem)) + return attempted +} diff --git a/packages/sdk-go/internal/commands/init/dependency_test.go b/packages/sdk-go/internal/commands/init/dependency_test.go new file mode 100644 index 0000000..14182cc --- /dev/null +++ b/packages/sdk-go/internal/commands/init/dependency_test.go @@ -0,0 +1,254 @@ +package initcmd + +import ( + "bytes" + "path/filepath" + "reflect" + "strconv" + "strings" + "syscall" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" +) + +type depRun struct { + bin string + args []string + cwd string +} + +// fakeIO is the ONLY way this suite could reach a package manager, and it records +// instead of spawning. +func fakeDepIO(answer string, result AddResult) (*DependencyIO, *[]depRun, *[]string) { + runs := &[]depRun{} + questions := &[]string{} + io := &DependencyIO{ + ReadLine: func(question, fallback string) string { + *questions = append(*questions, question) + return answer + }, + Run: func(bin string, args []string, cwd string) AddResult { + *runs = append(*runs, depRun{bin: bin, args: args, cwd: cwd}) + return result + }, + } + return io, runs, questions +} + +func caseRoot(t *testing.T, name string) string { + t.Helper() + return filepath.Join(goldensDir(t), "..", "ecosystem", name) +} + +func runOffer(t *testing.T, fixture string, interactive bool, answer string, result AddResult) (DependencyOffer, string, []depRun) { + t.Helper() + root := caseRoot(t, fixture) + io, runs, _ := fakeDepIO(answer, result) + var out bytes.Buffer + offer := OfferDependency(DependencyOfferOptions{ + Root: root, Report: ecosystem.Detect(root), Interactive: interactive, IO: io, + }, &out) + return offer, out.String(), *runs +} + +func TestOfferDependencyYesRunsTheManager(t *testing.T) { + offer, out, runs := runOffer(t, "node-pnpm-lock", true, "y", AddResult{Started: true}) + if len(runs) != 1 || runs[0].bin != "pnpm" || !reflect.DeepEqual(runs[0].args, []string{"add", "-D", "@leji-org/leji"}) { + t.Fatalf("argv: %+v", runs) + } + if runs[0].cwd != caseRoot(t, "node-pnpm-lock") { + t.Errorf("cwd = %q", runs[0].cwd) + } + for _, want := range []string{ + "Detected pnpm (pnpm-lock.yaml).", + "This runs pnpm here with your environment, as when you run it yourself: it will contact its registry and may run install scripts.", + "Running: pnpm add -D @leji-org/leji", + "Declared @leji-org/leji; a clean install now brings leji.", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + // The whole record, exactly as the reference asserts it. + assertOffer(t, offer, DependencyOffer{ + Offered: true, Ran: true, Command: []string{"pnpm", "add", "-D", "@leji-org/leji"}, + ExitCode: intPtr(0), + }) + if DependencyAddFailed(offer) { + t.Error("a clean add is not a failure") + } +} + +// assertOffer compares the terminal outcome field by field, including the two +// nullable ones: a signalled add has no exit code, and a spawn that never started +// has neither. +func assertOffer(t *testing.T, got, want DependencyOffer) { + t.Helper() + if got.Offered != want.Offered || got.Ran != want.Ran || !reflect.DeepEqual(got.Command, want.Command) { + t.Errorf("offer = %+v, want %+v", got, want) + } + if !samePtrInt(got.ExitCode, want.ExitCode) { + t.Errorf("exitCode = %s, want %s", showInt(got.ExitCode), showInt(want.ExitCode)) + } + if !samePtrStr(got.Signal, want.Signal) { + t.Errorf("signal = %s, want %s", showStr(got.Signal), showStr(want.Signal)) + } +} + +func samePtrInt(a, b *int) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func samePtrStr(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func showInt(p *int) string { + if p == nil { + return "null" + } + return strconv.Itoa(*p) +} + +func showStr(p *string) string { + if p == nil { + return "null" + } + return *p +} + +// The disclosure comes BEFORE the prompt, and never without one. +func TestOfferDependencyDisclosureOrder(t *testing.T) { + _, out, _ := runOffer(t, "node-pnpm-lock", true, "n", AddResult{Started: true}) + disclosure := ecosystem.ConsentDisclosure("pnpm") + if !strings.Contains(out, disclosure) { + t.Fatalf("no disclosure:\n%s", out) + } + if strings.Index(out, "Detected pnpm") > strings.Index(out, disclosure) { + t.Error("the block comes first") + } + offer, quiet, runs := runOffer(t, "node-pnpm-lock", false, "y", AddResult{Started: true}) + if strings.Contains(quiet, "This runs") || len(runs) != 0 { + t.Errorf("non-interactive prints the block and runs nothing:\n%s", quiet) + } + assertOffer(t, offer, DependencyOffer{ + Offered: true, Command: []string{"pnpm", "add", "-D", "@leji-org/leji"}, + }) + for _, c := range []struct{ fixture, bin string }{ + {"python-uv", "uv"}, {"go-1.24", "go"}, {"node-npm-lock", "npm"}, + } { + _, text, _ := runOffer(t, c.fixture, true, "n", AddResult{Started: true}) + if !strings.Contains(text, "This runs "+c.bin+" here with your environment") { + t.Errorf("%s: %s", c.fixture, text) + } + } +} + +func TestOfferDependencyDecline(t *testing.T) { + for _, answer := range []string{"n", "no", "q", "N"} { + offer, out, runs := runOffer(t, "node-pnpm-lock", true, answer, AddResult{Started: true}) + if len(runs) != 0 || DependencyAddFailed(offer) { + t.Errorf("%q must not run the manager", answer) + } + // Declining leaves the terminal outcome empty: nothing ran, so there is no + // exit code and no signal to report. + assertOffer(t, offer, DependencyOffer{ + Offered: true, Command: []string{"pnpm", "add", "-D", "@leji-org/leji"}, + }) + if !strings.Contains(out, "Skipped; declare it later with:\n pnpm add -D @leji-org/leji") { + t.Errorf("%q: follow-up line missing:\n%s", answer, out) + } + } +} + +// Node reports a signalled child with no exit code of its own, and a spawn that +// never started as an error rather than an exit code; both are failures, and both +// are reported in their own words. +func TestOfferDependencyFailureBranches(t *testing.T) { + offer, out, _ := runOffer(t, "python-uv", true, "y", AddResult{Started: true, ExitCode: 1}) + if !strings.Contains(out, "uv exited 1; run it yourself:\n uv add --dev leji") { + t.Errorf("non-zero add:\n%s", out) + } + assertOffer(t, offer, DependencyOffer{ + Offered: true, Ran: true, Command: []string{"uv", "add", "--dev", "leji"}, ExitCode: intPtr(1), + }) + if !DependencyAddFailed(offer) { + t.Error("a non-zero add is a failure") + } + + // A signalled manager has no exit code of its own: the signal is the outcome, + // and its name is the canonical one every SDK prints. + offer, out, _ = runOffer(t, "go-1.24", true, "y", AddResult{Started: true, ExitCode: -1, Signal: "SIGTERM"}) + if !strings.Contains(out, "go was terminated (SIGTERM); run it yourself:") { + t.Errorf("signalled add:\n%s", out) + } + assertOffer(t, offer, DependencyOffer{ + Offered: true, Ran: true, + Command: []string{"go", "get", "-tool", "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest"}, + Signal: sigPtr("SIGTERM"), + }) + if !DependencyAddFailed(offer) { + t.Error("a signalled add is a failure") + } + + // A spawn that never started carries neither an exit code nor a signal, and is + // still a failure: reading its null exit code as success would pass a run in + // which nothing happened. + offer, out, _ = runOffer(t, "node-npm-lock", true, "y", AddResult{Started: false}) + if !strings.Contains(out, "npm is not on your PATH; run it yourself once it is:\n npm i -D @leji-org/leji") { + t.Errorf("spawn error:\n%s", out) + } + assertOffer(t, offer, DependencyOffer{ + Offered: true, Ran: true, Command: []string{"npm", "i", "-D", "@leji-org/leji"}, + }) + if !DependencyAddFailed(offer) { + t.Error("a spawn that never started is a failure") + } +} + +// The canonical signal names, which Go's own Signal.String() does not give. +func TestSignalNameIsCanonical(t *testing.T) { + for sig, want := range map[syscall.Signal]string{ + syscall.SIGTERM: "SIGTERM", syscall.SIGINT: "SIGINT", syscall.SIGKILL: "SIGKILL", + syscall.SIGHUP: "SIGHUP", syscall.SIGPIPE: "SIGPIPE", + } { + if got := SignalName(sig); got != want { + t.Errorf("SignalName(%d) = %q, want %q", int(sig), got, want) + } + } + if got := SignalName(syscall.Signal(64)); got != "SIG64" { + t.Errorf("an unknown signal falls back to its number: %q", got) + } +} + +func TestOfferDependencyNeverPromptsWithoutACommand(t *testing.T) { + declared, out, runs := runOffer(t, "node-declared", true, "y", AddResult{Started: true}) + if len(runs) != 0 { + t.Error("a declared repository is told so and never prompted") + } + // `Command` still names the add this repository would use; `Offered` is what says + // there is nothing to consent to, because the CLI is already declared. + assertOffer(t, declared, DependencyOffer{Command: []string{"npm", "i", "-D", "@leji-org/leji"}}) + if strings.TrimSpace(out) != "The Leji CLI is already declared in package.json." { + t.Errorf("declared block: %q", out) + } + // pip and pre-1.24 Go have no add command leji could run, and no manager-less + // outcome ever guesses one. + for _, fixture := range []string{ + "python-bare-pyproject", "python-requirements-only", "go-1.23-legacy", + "node-two-lockfiles", "node-refused-evidence", "node-unreadable-manifest", + "node-packagemanager-unknown", "none", "multiple-ecosystems", "composite-ambiguity", + } { + offer, _, runs := runOffer(t, fixture, true, "y", AddResult{Started: true}) + if len(runs) != 0 || offer.Offered || offer.Ran || DependencyAddFailed(offer) { + t.Errorf("%s: nothing to consent to", fixture) + } + } +} diff --git a/packages/sdk-go/internal/commands/init/guard.go b/packages/sdk-go/internal/commands/init/guard.go index d51e4f6..f6c5ea1 100644 --- a/packages/sdk-go/internal/commands/init/guard.go +++ b/packages/sdk-go/internal/commands/init/guard.go @@ -12,11 +12,12 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/detect" "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" ) // The onboarding approval guard: a transient Claude Code PreToolUse hook that // counters the ask-prompt pattern. AskUserQuestion stays blocked until the -// proposal is written to /.leji/proposal.md AND printed as message +// proposal is written to .leji/work/proposal.md AND printed as message // text; the corrective message lands at the action boundary, where instruction // reliably reaches the model. Self-disabling once the onboarding brief is gone; // the finalize step removes it entirely. @@ -144,16 +145,19 @@ func decodeOrderedValue(dec *json.Decoder) (any, error) { return nil, errors.New("unexpected JSON delimiter") } -// EnsureApprovalGuard writes the guard script under /.leji/hooks/ and -// merges its PreToolUse entry into .claude/settings.json (created if absent, -// other settings preserved). Idempotent: an existing guard entry is left -// untouched. +// EnsureApprovalGuard writes the guard script under the onboarding workspace +// (`.leji/work/hooks/`) and merges 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. func EnsureApprovalGuard(root, rootPath string) (GuardAction, error) { + _ = rootPath rootAbs, err := filepath.Abs(root) if err != nil { rootAbs = root } - lejiRel := fsx.JoinUnderRoot(rootPath, ".leji") + lejiRel := layout.WorkRel scriptRel := lejiRel + "/hooks/approval-guard.mjs" scriptAbs := filepath.Join(rootAbs, scriptRel) if err := guardWithinRoot(rootAbs, scriptAbs, scriptRel); err != nil { @@ -166,19 +170,19 @@ func EnsureApprovalGuard(root, rootPath string) (GuardAction, error) { return "", err } settings := newOrdered() - if fsx.IsFile(settingsAbs) { - existing, rerr := fsx.ReadText(settingsAbs) - if rerr != nil { - return "", rerr - } - if strings.TrimSpace(existing) != "" { - parsed, perr := parseOrderedJSON(existing) - obj, isObj := parsed.(*ordered) - if perr != nil || !isObj { - return "", fmt.Errorf("%s is not valid JSON; fix it before installing the onboarding guard", settingsRel) - } - settings = obj + // The settings file is parsed, merged, and written back, so its bytes come from + // the verified read rather than from the pathname the merge later writes to. + existing, _, rerr := readMergeSource(fsx.GuardRoot(rootAbs), settingsAbs, settingsRel) + if rerr != nil { + return "", rerr + } + if strings.TrimSpace(existing) != "" { + parsed, perr := parseOrderedJSON(existing) + obj, isObj := parsed.(*ordered) + if perr != nil || !isObj { + return "", fmt.Errorf("%s is not valid JSON; fix it before installing the onboarding guard", settingsRel) } + settings = obj } hooks, isObj := settings.values["hooks"].(*ordered) if !isObj { @@ -270,7 +274,7 @@ func OfferApprovalGuard(opts GuardOfferOptions, hio *HandoffIO, out io.Writer) e return nil } answer := strings.ToLower(hio.ReadLine( - "Add the temporary onboarding guard for Claude Code, in this repository only? It has the agent print its proposal before asking for approval. Writes two project-local files (a hook entry in this repo’s .claude/settings.json, a script in the gitignored .leji/ workspace); nothing outside this repository is touched, and the finalize step removes both", + "Add the temporary onboarding guard for Claude Code, in this repository only? It has the agent print its proposal before asking for approval. Writes two project-local files (a hook entry in this repo’s .claude/settings.json, a script in the gitignored .leji/work/ workspace); nothing outside this repository is touched, and the finalize step removes both", "Y/n", )) if !(answer == "" || answer == "y" || answer == "yes") { @@ -281,7 +285,7 @@ func OfferApprovalGuard(opts GuardOfferOptions, hio *HandoffIO, out io.Writer) e return err } if action == "installed" { - fmt.Fprintln(out, "Onboarding guard added (this repository only: .claude/settings.json hook + .leji/hooks/approval-guard.mjs; removed at finalize).") + fmt.Fprintln(out, "Onboarding guard added (this repository only: .claude/settings.json hook + .leji/work/hooks/approval-guard.mjs; removed at finalize).") } else { fmt.Fprintln(out, "Onboarding guard already present in this repository; refreshed the script.") } diff --git a/packages/sdk-go/internal/commands/init/guard_hooks_test.go b/packages/sdk-go/internal/commands/init/guard_hooks_test.go index c721cb6..b997b84 100644 --- a/packages/sdk-go/internal/commands/init/guard_hooks_test.go +++ b/packages/sdk-go/internal/commands/init/guard_hooks_test.go @@ -8,6 +8,7 @@ package initcmd import ( "encoding/json" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" "os" "os/exec" "path/filepath" @@ -36,14 +37,14 @@ func TestCiProviderInferenceFromOriginRemote(t *testing.T) { func TestEnsureLocalHookCreatedIdempotentNeverClobbers(t *testing.T) { dir := gitInitRepo(t) - first, err := EnsureLocalHook(dir) + first, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if first.Action != "created" { t.Fatalf("first action = %q, want created", first.Action) } - second, err := EnsureLocalHook(dir) + second, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -61,18 +62,18 @@ func TestEnsureLocalHookCreatedIdempotentNeverClobbers(t *testing.T) { if err := os.WriteFile(hookPath, []byte("#!/bin/sh\necho custom hook\n"), 0o755); err != nil { t.Fatal(err) } - third, err := EnsureLocalHook(dir) + third, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if third.Action != "manual" || third.Reason != "foreign-hook" { t.Fatalf("third = %q/%q, want manual/foreign-hook", third.Action, third.Reason) } - if !strings.Contains(third.Snippet, "\"$LEJI\" validate") { - t.Fatalf("manual snippet missing the gate: %q", third.Snippet) + if !strings.Contains(third.Snippet, "'leji' validate || exit 1") { + t.Fatalf("manual snippet missing the quoted-runner gate: %q", third.Snippet) } - if !strings.Contains(third.Snippet, "node_modules/.bin/leji") { - t.Fatalf("manual snippet should prefer the local bin: %q", third.Snippet) + if strings.Contains(third.Snippet, "node_modules") { + t.Fatalf("the scalar shim is gone: %q", third.Snippet) } got, _ := os.ReadFile(hookPath) if !strings.Contains(string(got), "custom hook") { @@ -82,12 +83,32 @@ func TestEnsureLocalHookCreatedIdempotentNeverClobbers(t *testing.T) { func TestEnsureLocalHookRequiresGitRepo(t *testing.T) { dir := t.TempDir() - if _, err := EnsureLocalHook(dir); err == nil || + if _, err := EnsureLocalHook(dir, nil); err == nil || err.Error() != "not a git repository (no .git directory); hooks need one" { t.Fatalf("expected the no-git error, got %v", err) } } +// writeFile and readFile are the two file helpers these tests plant and assert with. +func writeFile(t *testing.T, abs, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, abs string) string { + t.Helper() + data, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + return string(data) +} + func gitInitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() @@ -115,7 +136,7 @@ func TestEnsureLocalHookHuskyMergesManagedBlock(t *testing.T) { if err := os.WriteFile(huskyPre, []byte("#!/bin/sh\nnpm test\n"), 0o755); err != nil { t.Fatal(err) } - r, err := EnsureLocalHook(dir) + r, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -135,7 +156,7 @@ func TestEnsureLocalHookHuskyMergesManagedBlock(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, ".git", "hooks", "pre-commit")); err == nil { t.Fatal(".git/hooks/pre-commit should not be written") } - again, err := EnsureLocalHook(dir) + again, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -148,7 +169,7 @@ func TestEnsureLocalHookHuskyMergesManagedBlock(t *testing.T) { func TestEnsureLocalHookHuskyCreatesFileWhenAbsent(t *testing.T) { dir := gitInitRepo(t) setHooksPath(t, dir, ".husky/_") - r, err := EnsureLocalHook(dir) + r, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -177,7 +198,7 @@ func TestEnsureLocalHookHuskyCreatesFileWhenAbsent(t *testing.T) { func TestEnsureLocalHookDirectHuskyV8ModeCorrection(t *testing.T) { dir := gitInitRepo(t) setHooksPath(t, dir, ".husky") - first, err := EnsureLocalHook(dir) + first, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -188,7 +209,7 @@ func TestEnsureLocalHookDirectHuskyV8ModeCorrection(t *testing.T) { if info, _ := os.Stat(huskyPre); info.Mode()&0o111 == 0 { t.Fatal("created hook is not executable") } - second, err := EnsureLocalHook(dir) + second, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -198,7 +219,7 @@ func TestEnsureLocalHookDirectHuskyV8ModeCorrection(t *testing.T) { if err := os.Chmod(huskyPre, 0o644); err != nil { t.Fatal(err) } - third, err := EnsureLocalHook(dir) + third, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -210,58 +231,76 @@ func TestEnsureLocalHookDirectHuskyV8ModeCorrection(t *testing.T) { } } -// Mirrors units.test.ts "ci: local-first CI variant ..." / "ci: npx @1 fallback ...". -func TestCiTemplateVariantsLocalVsNpx(t *testing.T) { - gh := BuildGithubWorkflow(true) - if !strings.Contains(gh, "- run: npm ci") || !strings.Contains(gh, "npx --no-install @leji-org/leji validate") { - t.Fatalf("local github variant missing npm ci / --no-install:\n%s", gh) +// Mirrors units.test.ts "ci: a pnpm repository gets pnpm, never `npm ci`, and an +// unlocked one falls back": the generated job installs with the manager the +// repository actually uses, and declaring without a lockfile is not enough. +func TestCiJobFollowsTheRepositoryManager(t *testing.T) { + declared := `{"devDependencies":{"@leji-org/leji":"^1.3.0"}}` + + pnpmDir := gitInitRepo(t) + writeFile(t, filepath.Join(pnpmDir, "package.json"), declared) + writeFile(t, filepath.Join(pnpmDir, "pnpm-lock.yaml"), "lockfileVersion: 9\n") + if _, err := EnsureCiWorkflow(pnpmDir, "github", nil); err != nil { + t.Fatalf("ci: %v", err) } - if strings.Contains(gh, "npx -y @leji-org/leji@1") { - t.Fatal("local github variant should not use the npx @1 fallback") + wf := readFile(t, filepath.Join(pnpmDir, CIWorkflowPath)) + if strings.Contains(wf, "npm ci") { + t.Fatalf("no npm ci in a pnpm repository:\n%s", wf) } - if fb := BuildGithubWorkflow(false); !strings.Contains(fb, "npx -y @leji-org/leji@1 validate") || strings.Contains(fb, "npm ci") { - t.Fatalf("fallback github variant wrong:\n%s", fb) + if !strings.Contains(wf, "- run: corepack enable && pnpm install --frozen-lockfile") || + !strings.Contains(wf, "- run: pnpm exec leji validate") { + t.Fatalf("expected the pnpm job:\n%s", wf) + } + if strings.Contains(wf, "npx -y @leji-org/leji@1") { + t.Fatalf("declared + locked is never the fallback:\n%s", wf) } -} -func TestDeclaresLejiDepDetection(t *testing.T) { - dir := t.TempDir() - if declaresLejiDep(dir) { - t.Fatal("no package.json should not declare the dep") + // Declared with no lockfile at all: nothing to install from, so the job that + // needs no manifest is the honest one. + unlocked := gitInitRepo(t) + writeFile(t, filepath.Join(unlocked, "package.json"), declared) + if _, err := EnsureCiWorkflow(unlocked, "github", nil); err != nil { + t.Fatalf("ci: %v", err) } - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{ not json"), 0o644); err != nil { - t.Fatal(err) + if fb := readFile(t, filepath.Join(unlocked, CIWorkflowPath)); !strings.Contains(fb, "npx -y @leji-org/leji@1 validate") { + t.Fatalf("expected the pinned npx fallback:\n%s", fb) } - if declaresLejiDep(dir) { - t.Fatal("unparseable package.json should not declare the dep") +} + +// The declaration rules, reached only through the detector: the direct +// package.json read this port used to carry is gone, and the eligibility path is +// the only way in. +func TestNodeDeclarationThroughTheDetector(t *testing.T) { + declared := func(pkg string) bool { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), pkg) + writeFile(t, filepath.Join(dir, "package-lock.json"), "") + report := ecosystem.Detect(dir) + if report.Selected == nil { + t.Fatalf("expected a selected manager for %q", pkg) + } + return report.Selected.DirectDeclared } - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"devDependencies":{"@leji-org/leji":"^1.3.0"}}`), 0o644); err != nil { - t.Fatal(err) + if declared("{}") { + t.Fatal("an empty manifest declares nothing") } - if !declaresLejiDep(dir) { + if !declared(`{"devDependencies":{"@leji-org/leji":"^1.3.0"}}`) { t.Fatal("devDependencies entry should declare the dep") } - // A leading UTF-8 BOM is stripped, so a valid manifest is still detected. - bom := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{"dependencies":{"@leji-org/leji":"1.3.0"}}`)...) - if err := os.WriteFile(filepath.Join(dir, "package.json"), bom, 0o644); err != nil { - t.Fatal(err) - } - if !declaresLejiDep(dir) { - t.Fatal("BOM-prefixed manifest should still declare the dep") - } - // dependencies as a JSON array is not an object -> treated as absent, not an error. - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":["@leji-org/leji"]}`), 0o644); err != nil { - t.Fatal(err) + if !declared("\ufeff" + `{"dependencies":{"@leji-org/leji":"1.3.0"}}`) { + t.Fatal("a BOM-prefixed manifest should still declare the dep") } - if declaresLejiDep(dir) { - t.Fatal("array dependencies field should be treated as absent") + if declared(`{"dependencies":["@leji-org/leji"]}`) { + t.Fatal("an array dependencies field is treated as absent") } - // A non-finite JSON constant (NaN) fails the strict parse -> not declared. - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"@leji-org/leji":NaN}}`), 0o644); err != nil { - t.Fatal(err) - } - if declaresLejiDep(dir) { - t.Fatal("a NaN value should fail the strict parse") + // Unparseable, and a non-finite JSON constant: unreadable, never "not declared". + for _, bad := range []string{"{ not json", `{"dependencies":{"@leji-org/leji":NaN}}`} { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), bad) + writeFile(t, filepath.Join(dir, "package-lock.json"), "") + if r := ecosystem.Detect(dir); r.Reason == nil || *r.Reason != "unreadable-manifest" { + t.Fatalf("%q should be unreadable-manifest", bad) + } } } @@ -269,7 +308,7 @@ func TestDeclaresLejiDepDetection(t *testing.T) { // its exec bit is mode-corrected". func TestEnsureLocalHookStandaloneModeCorrection(t *testing.T) { dir := gitInitRepo(t) - first, err := EnsureLocalHook(dir) + first, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -280,13 +319,13 @@ func TestEnsureLocalHookStandaloneModeCorrection(t *testing.T) { if info, _ := os.Stat(hookPath); info.Mode()&0o111 == 0 { t.Fatal("created hook is not executable") } - if second, _ := EnsureLocalHook(dir); second.Action != "unchanged" { + if second, _ := EnsureLocalHook(dir, nil); second.Action != "unchanged" { t.Fatalf("second action = %q, want unchanged", second.Action) } if err := os.Chmod(hookPath, 0o644); err != nil { t.Fatal(err) } - third, err := EnsureLocalHook(dir) + third, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -303,7 +342,7 @@ func TestEnsureLocalHookStandaloneModeCorrection(t *testing.T) { func TestEnsureLocalHookRelativeOutOfRootNormalized(t *testing.T) { dir := gitInitRepo(t) setHooksPath(t, dir, "../sibling-ext/.husky/_") - r, err := EnsureLocalHook(dir) + r, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -323,7 +362,7 @@ func TestEnsureLocalHookRelativeOutOfRootNormalized(t *testing.T) { func TestEnsureLocalHookCustomDirWritesManagedFile(t *testing.T) { dir := gitInitRepo(t) setHooksPath(t, dir, "githooks") - r, err := EnsureLocalHook(dir) + r, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -352,7 +391,7 @@ func TestEnsureLocalHookHooksPathOutsideRepoIsManual(t *testing.T) { dir := gitInitRepo(t) outside := t.TempDir() setHooksPath(t, dir, outside) - r, err := EnsureLocalHook(dir) + r, err := EnsureLocalHook(dir, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -362,8 +401,8 @@ func TestEnsureLocalHookHooksPathOutsideRepoIsManual(t *testing.T) { if r.Path != outside+"/pre-commit" { t.Fatalf("path = %q, want %q", r.Path, outside+"/pre-commit") } - if !strings.Contains(r.Snippet, "\"$LEJI\" validate") { - t.Fatalf("manual snippet missing the gate: %q", r.Snippet) + if !strings.Contains(r.Snippet, "'leji' validate || exit 1") { + t.Fatalf("manual snippet missing the quoted-runner gate: %q", r.Snippet) } if _, err := os.Stat(filepath.Join(outside, "pre-commit")); err == nil { t.Fatal("nothing should be written outside the repo") @@ -436,7 +475,7 @@ func TestApprovalGuardInstallsIdempotentlyPreservesSettings(t *testing.T) { " \"hooks\": [\n" + " {\n" + " \"type\": \"command\",\n" + - " \"command\": \"node \\\"$CLAUDE_PROJECT_DIR/docs/.leji/hooks/approval-guard.mjs\\\"\"\n" + + " \"command\": \"node \\\"$CLAUDE_PROJECT_DIR/.leji/work/hooks/approval-guard.mjs\\\"\"\n" + " }\n" + " ]\n" + " }\n" + @@ -467,7 +506,7 @@ func TestApprovalGuardInstallsIdempotentlyPreservesSettings(t *testing.T) { if strings.Join(matchers, ",") != "Bash,AskUserQuestion" { t.Fatalf("matchers = %v, want [Bash AskUserQuestion]", matchers) } - if _, err := os.Stat(filepath.Join(dir, "docs", ".leji", "hooks", "approval-guard.mjs")); err != nil { + if _, err := os.Stat(filepath.Join(dir, ".leji", "work", "hooks", "approval-guard.mjs")); err != nil { t.Fatal("guard script not written") } } @@ -480,7 +519,7 @@ func TestApprovalGuardBlocksUntilWrittenAndPrintedInertAfterOnboarding(t *testin if _, err := EnsureApprovalGuard(dir, "docs/"); err != nil { t.Fatal(err) } - lejiDir := filepath.Join(dir, "docs", ".leji") + lejiDir := filepath.Join(dir, ".leji", "work") script := filepath.Join(lejiDir, "hooks", "approval-guard.mjs") if err := os.WriteFile(filepath.Join(lejiDir, "onboarding-brief.md"), []byte("brief"), 0o644); err != nil { t.Fatal(err) @@ -548,10 +587,12 @@ func TestHookStaleIndexMessageIsLiteralNotExecuted(t *testing.T) { t.Skip("no sh") } dir := gitInitRepo(t) - if _, err := EnsureLocalHook(dir); err != nil { + if _, err := EnsureLocalHook(dir, nil); err != nil { t.Fatalf("EnsureLocalHook: %v", err) } - binDir := filepath.Join(dir, "node_modules", ".bin") + // The hook runs the runner argv; this repository declares nothing, so that is + // the `leji` on PATH. The stub goes there rather than into node_modules. + binDir := filepath.Join(dir, "stubbin") if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatal(err) } @@ -561,7 +602,7 @@ func TestHookStaleIndexMessageIsLiteralNotExecuted(t *testing.T) { "case \"$1$2\" in\n" + " validate) exit 0 ;;\n" + " index--check) exit 1 ;;\n" + - " index) echo regenerated > \"$(dirname \"$0\")/../../ran-index\"; exit 0 ;;\n" + + " index) echo regenerated > \"$(dirname \"$0\")/../ran-index\"; exit 0 ;;\n" + "esac\n" + "exit 0\n" if err := os.WriteFile(filepath.Join(binDir, "leji"), []byte(stub), 0o755); err != nil { @@ -570,6 +611,7 @@ func TestHookStaleIndexMessageIsLiteralNotExecuted(t *testing.T) { cmd := exec.Command("sh", filepath.Join(".git", "hooks", "pre-commit")) cmd.Dir = dir + cmd.Env = append(os.Environ(), "PATH="+binDir) var stderr strings.Builder cmd.Stderr = &stderr err := cmd.Run() diff --git a/packages/sdk-go/internal/commands/init/handoff_test.go b/packages/sdk-go/internal/commands/init/handoff_test.go index ee907f0..e313156 100644 --- a/packages/sdk-go/internal/commands/init/handoff_test.go +++ b/packages/sdk-go/internal/commands/init/handoff_test.go @@ -10,7 +10,7 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/manifest" ) -const briefPrompt = "Read ./docs/.leji/onboarding-brief.md and follow it." +const briefPrompt = "Read ./.leji/work/onboarding-brief.md and follow it." func detectedHost(id, name string, onPath bool) detect.DetectedHost { strength := detect.ProjectPresent @@ -194,7 +194,9 @@ func TestHandoffOfferLaunchFailureFallsBack(t *testing.T) { } } -func TestHandoffOfferThreadsRoot(t *testing.T) { +// The onboarding workspace is one tree at the repository root, so the prompt is the +// same for a layer rooted anywhere: it never carries a rootPath prefix. +func TestHandoffOfferNamesRootWorkspaceWhateverTheLayerRoot(t *testing.T) { hio, launches := fakeIO("y", cleanExit) ok, err := HandoffOffer(&manifest.Manifest{RootPath: "context/"}, []detect.DetectedHost{claudeHost}, true, hio, &strings.Builder{}, "", "", McpOfferOutcome{}) if err != nil { @@ -203,9 +205,9 @@ func TestHandoffOfferThreadsRoot(t *testing.T) { if !ok { t.Fatal("should launch") } - want := "claude Read ./context/.leji/onboarding-brief.md and follow it." + want := "claude Read ./.leji/work/onboarding-brief.md and follow it." if (*launches)[0] != want { - t.Fatalf("root not threaded: got %q", (*launches)[0]) + t.Fatalf("workspace prompt: got %q", (*launches)[0]) } } @@ -242,8 +244,8 @@ func fakeMcpIO(answer string, runResults []LaunchResult) (*HandoffIO, *[]string, questions = append(questions, q) return answer }, - Run: func(bin string, args []string, cwd string, quiet bool) LaunchResult { - runs = append(runs, recordedRun{bin: bin, args: args, cwd: cwd, quiet: quiet}) + Run: func(bin string, args []string, cwd string, opts RunOptions) LaunchResult { + runs = append(runs, recordedRun{bin: bin, args: args, cwd: cwd, quiet: opts.Quiet}) res := LaunchResult{Started: true} if idx < len(runResults) { res = runResults[idx] @@ -341,8 +343,8 @@ func fakeFlowIO(answers []string, runResults []LaunchResult) (*HandoffIO, *[]str events = append(events, "launch:"+bin) return LaunchResult{Started: true} }, - Run: func(bin string, args []string, cwd string, quiet bool) LaunchResult { - runs = append(runs, recordedRun{bin: bin, args: args, cwd: cwd, quiet: quiet}) + Run: func(bin string, args []string, cwd string, opts RunOptions) LaunchResult { + runs = append(runs, recordedRun{bin: bin, args: args, cwd: cwd, quiet: opts.Quiet}) events = append(events, "run:"+bin) res := LaunchResult{Started: true} if runIdx < len(runResults) { diff --git a/packages/sdk-go/internal/commands/init/init.go b/packages/sdk-go/internal/commands/init/init.go index 48a3337..f5c4522 100644 --- a/packages/sdk-go/internal/commands/init/init.go +++ b/packages/sdk-go/internal/commands/init/init.go @@ -4,7 +4,7 @@ package initcmd import ( "bufio" "bytes" - "encoding/json" + "context" "errors" "fmt" "io" @@ -21,9 +21,11 @@ import ( "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" "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/git" + "github.com/leji-org/leji/packages/sdk-go/internal/layout" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/writeplan" ) @@ -104,37 +106,63 @@ func defaultLayout(rootPath string) ScaffoldLayout { } } -// resolveScaffoldPath picks the first candidate name (under rootPath) absent on -// disk, so adopt never writes its scaffold over a repo's existing content. -func resolveScaffoldPath(root, rootPath, name string, alternates []string, dir bool) string { +// resolveScaffoldPath picks the first candidate name (under rootPath) that is free, +// so adopt never writes its scaffold over a repo's existing content. Occupancy is +// decided on the standing entry rather than by a stat, so a dangling candidate link +// is occupied and the next name is tried, exactly as an existing file has always been. +func resolveScaffoldPath(root, rootPath, name string, alternates []string, dir bool) (string, error) { suffix := "" if dir { suffix = "/" } + free := func(rel string) (bool, error) { + return nothingStandsAt(filepath.Join(root, fsx.StripSlash(rel))) + } for _, candidate := range append([]string{name}, alternates...) { rel := fsx.JoinUnderRoot(rootPath, candidate+suffix) - if !fsx.Exists(filepath.Join(root, fsx.StripSlash(rel))) { - return rel + ok, err := free(rel) + if err != nil { + return "", err + } + if ok { + return rel, nil } } for n := 2; ; n++ { rel := fsx.JoinUnderRoot(rootPath, fmt.Sprintf("%s-%d%s", name, n, suffix)) - if !fsx.Exists(filepath.Join(root, fsx.StripSlash(rel))) { - return rel + ok, err := free(rel) + if err != nil { + return "", err + } + if ok { + return rel, nil } } } // resolveLayout resolves a scaffold layout against an existing repo: each colliding // default path falls back to a safe alternate. -func resolveLayout(root, rootPath string) ScaffoldLayout { - return ScaffoldLayout{ - BootProfilePath: resolveScaffoldPath(root, rootPath, "boot-profile.md", []string{"leji-boot-profile.md"}, false), - ContextDir: resolveScaffoldPath(root, rootPath, "context", []string{"leji-context", "context-layer"}, true), - AgentsDir: resolveScaffoldPath(root, rootPath, "agents", []string{"agent-profiles", "leji-agents"}, true), - IndexPath: resolveScaffoldPath(root, rootPath, "context-index.json", []string{"leji-context-index.json"}, false), - ChangelogPath: resolveScaffoldPath(root, rootPath, "context-changelog.json", []string{"leji-context-changelog.json"}, false), +func resolveLayout(root, rootPath string) (ScaffoldLayout, error) { + var layout ScaffoldLayout + for _, pick := range []struct { + into *string + name string + alternates []string + dir bool + }{ + {&layout.BootProfilePath, "boot-profile.md", []string{"leji-boot-profile.md"}, false}, + {&layout.ContextDir, "context", []string{"leji-context", "context-layer"}, true}, + {&layout.AgentsDir, "agents", []string{"agent-profiles", "leji-agents"}, true}, + {&layout.IndexPath, "context-index.json", []string{"leji-context-index.json"}, false}, + {&layout.ChangelogPath, "context-changelog.json", []string{"leji-context-changelog.json"}, false}, + } { + rel, err := resolveScaffoldPath(root, rootPath, pick.name, pick.alternates, pick.dir) + if err != nil { + return ScaffoldLayout{}, err + } + *pick.into = rel } + return layout, nil } func (a answers) effectiveLayout() ScaffoldLayout { @@ -437,21 +465,140 @@ func resolveUnderRoot(root, rel string) (string, error) { return abs, nil } +// escapeRefusal is the one error these commands have always raised for a target +// they may not write: the layer is scaffolded inside the repository it was pointed +// at, or not at all. +func escapeRefusal(rel string) error { + return fmt.Errorf("refusing to write through a symlink that escapes the target: %q", rel) +} + +// guardedOrRefuse turns a refused chokepoint verdict into that same error, so every +// init/adopt write reports one way. +func guardedOrRefuse(rel string, verdict layout.TargetVerdict, err error) error { + if err != nil { + return err + } + if !verdict.OK { + return escapeRefusal(rel) + } + 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. +func initRole(rel string) string { + slashed := filepath.ToSlash(rel) + if slashed == layout.WorkRel || strings.HasPrefix(slashed, layout.WorkRel+"/") { + return layout.WorkRel + } + return "" +} + +// nothingStandsAt reports that NOTHING stands at abs, which is what makes a +// candidate name free. +// +// The ORIGINAL directory entry decides it, exactly as an exclusive create does: a +// stat follows symlinks, so a dangling link reads as a free name and the write that +// follows lands at the link's missing destination. Any standing entry, a dangling +// link included, is occupied. +// +// A name nothing stands at is free even when it resolves out of this tool's reach, +// because the search is for an unused NAME, not a permission to write: every other +// name under a context root symlinked out of the repository resolves out of reach +// too, so refusing them one by one would never terminate. The write itself is judged +// where it always is, at the chokepoint, which refuses that target as it has. +// An lstat that fails for any other reason (permission, I/O) answers neither: the +// name cannot be judged, so the failure travels out rather than being read as +// "occupied" and quietly moved past, exactly as the reference lets it throw. +func nothingStandsAt(abs string) (bool, error) { + if _, err := os.Lstat(abs); err != nil { + if os.IsNotExist(err) { + return true, nil + } + return false, err + } + return false, nil +} + +// readMergeSource reads a file this command is about to merge and rewrite, through +// the verified read rather than by pathname: the bytes that decide the merge come +// from the descriptor the rule cleared, so the file that was judged is the file that +// is read and then written. present is false when nothing stands there (the create +// path); a standing entry that cannot be verified as a regular file inside the +// repository is the same refusal a write to it would be. +func readMergeSource(rootReal, abs, rel string) (text string, present bool, err error) { + read, err := fsx.VerifiedTargetRead(rootReal, abs, initRole(rel)) + if err != nil { + return "", false, err + } + if read.Status == fsx.ReadRefused { + return "", false, escapeRefusal(rel) + } + if read.Status != fsx.ReadRegular { + return "", false, nil + } + return string(read.Bytes), true, nil +} + +// verifiedVendorFiles is the present vendor entrypoints and their VERIFIED bytes, +// read once. The same bytes decide whether an entrypoint is converted, are archived +// under governance/, and are compared for the draft report, so no act rests on a +// second read by pathname of a file this command then rewrites. An entry that cannot +// be verified as a regular file inside the repository is treated as absent, exactly +// as an escaping symlink already was. +func verifiedVendorFiles(root string) (map[string]string, error) { + rootReal := fsx.GuardRoot(root) + present := map[string]string{} + for _, rel := range validate.KnownVendorFiles { + read, err := fsx.VerifiedTargetRead(rootReal, filepath.Join(root, rel), "") + if err != nil { + return nil, err + } + if read.Status == fsx.ReadRegular { + present[rel] = string(read.Bytes) + } + } + return present, nil +} + +// vendorRels is the present vendor entrypoints in KnownVendorFiles order, which is +// the order every list built from them has reported. +func vendorRels(vendor map[string]string) []string { + var rels []string + for _, rel := range validate.KnownVendorFiles { + if _, ok := vendor[rel]; ok { + rels = append(rels, rel) + } + } + return rels +} + +// writeFileOnce writes a file this command owns, once: never over an existing one, +// and never through a standing entry it cannot verify. The skip is decided by the +// verified read rather than a pathname check, because a stat follows symlinks — a +// dangling link at the target reads as absent and the guarded write then lands at +// the link's destination, a name this command never planned. Only absent is free; a +// regular file is the never-overwrite skip; anything else standing there is the +// escape refusal, with nothing written. func writeFileOnce(root, rel, content string, written *[]string) error { abs, err := resolveUnderRoot(root, rel) if err != nil { return err } - if !fsx.ResolvesUnder(root, abs) { - return fmt.Errorf("refusing to write through a symlink that escapes the target: %q", rel) + rootReal := fsx.GuardRoot(root) + standing, err := fsx.VerifiedTargetRead(rootReal, abs, initRole(rel)) + if err != nil { + return err } - if _, err := os.Stat(abs); err == nil { + if standing.Status == fsx.ReadRegular { return nil } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return err + if standing.Status == fsx.ReadRefused { + return escapeRefusal(rel) } - if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + verdict, err := fsx.WriteFileGuarded(rootReal, abs, initRole(rel), []byte(content), fsx.WriteOptions{}) + if err := guardedOrRefuse(rel, verdict, err); err != nil { return err } *written = append(*written, rel) @@ -459,43 +606,42 @@ func writeFileOnce(root, rel, content string, written *[]string) error { } // ensureLejiGitignored idempotently ensures the repo-root .gitignore ignores -// `.leji/` (generated viewer + transient brief). Matches the line exactly, so it -// never treats a comment or `docs/.leji/` as equivalent. +// `.leji/` — the one line that covers every role of the unified tree (chrome, +// export output, onboarding workspace, mounts) and any role added later. Matches +// the line exactly, so it never treats a comment or `docs/.leji/` as equivalent. func ensureLejiGitignored(rootAbs string) error { abs := filepath.Join(rootAbs, ".gitignore") - const entry = ".leji/" - text := "" - if fsx.IsFile(abs) { - t, err := fsx.ReadText(abs) - if err != nil { - return err - } - text = t + const entry = layout.LejiDir + "/" + rootReal := fsx.GuardRoot(rootAbs) + text, _, err := readMergeSource(rootReal, abs, ".gitignore") + if err != nil { + return err } for _, line := range strings.Split(text, "\n") { if line == entry { return nil } } - if text == "" { - return os.WriteFile(abs, []byte(entry+"\n"), 0o644) - } - sep := "" - if !strings.HasSuffix(text, "\n") { - sep = "\n" + next := entry + "\n" + if text != "" { + sep := "" + if !strings.HasSuffix(text, "\n") { + sep = "\n" + } + next = text + sep + entry + "\n" } - return os.WriteFile(abs, []byte(text+sep+entry+"\n"), 0o644) + verdict, err := fsx.WriteFileGuarded(rootReal, abs, "", []byte(next), fsx.WriteOptions{}) + return guardedOrRefuse(".gitignore", verdict, err) } // assertLejiWorkspacePrivate refuses to write the transient onboarding workspace -// while any file under `/.leji/` is tracked by git: tracked means the +// while any file under the root `.leji/` is tracked by git: tracked means the // ignore boundary is not intact, and private artifacts could land in history. // The fix is the owner's call (git rm --cached), never run silently. -func assertLejiWorkspacePrivate(root, rootPath string) error { - lejiDir := fsx.JoinUnderRoot(rootPath, ".leji/") - tracked, ok := git.TrackedUnder(root, fsx.StripSlash(lejiDir)) +func assertLejiWorkspacePrivate(root string) error { + tracked, ok := git.TrackedUnder(root, layout.LejiDir) if ok && len(tracked) > 0 { - return fmt.Errorf("%d file(s) under %s are tracked by git; untrack them (git rm --cached) so onboarding artifacts stay private", len(tracked), lejiDir) + return fmt.Errorf("%d file(s) under %s/ are tracked by git; untrack them (git rm --cached) so onboarding artifacts stay private", len(tracked), layout.LejiDir) } return nil } @@ -503,22 +649,18 @@ func assertLejiWorkspacePrivate(root, rootPath string) error { // writeManifestExclusive creates leji.json with O_EXCL so the existence check and // write are atomic: a concurrent run or a planted symlink cannot be overwritten or // followed. Already-exists is surfaced as each entry point's initial-guard message. -func writeManifestExclusive(abs string, content []byte, mode string) error { - fh, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) +func writeManifestExclusive(rootAbs, abs string, content []byte, mode string) error { + verdict, err := fsx.WriteFileGuarded(fsx.GuardRoot(rootAbs), abs, "", content, fsx.WriteOptions{Exclusive: true}) if err != nil { - if errors.Is(err, os.ErrExist) { - if mode == "adopt" { - return errors.New("leji.json already exists here; this repository already has a Leji layer") - } - return errors.New("leji.json already exists here; init refuses to overwrite an existing layer") - } return err } - defer fh.Close() - if _, err := fh.Write(content); err != nil { - return err + if verdict.Exists { + if mode == "adopt" { + return errors.New("leji.json already exists here; this repository already has a Leji layer") + } + return errors.New("leji.json already exists here; init refuses to overwrite an existing layer") } - return nil + return guardedOrRefuse("leji.json", verdict, nil) } type categoryStub struct { @@ -744,6 +886,9 @@ var governanceLineRe = regexp.MustCompile(`(?m)^ {2}- .*governance/\n`) func buildCoreProfile(a answers) string { text := readTemplate("agents/core.md") text = strings.ReplaceAll(text, "docs/", fsx.JoinUnderRoot(a.rootPath, "")) + // The escalation line names a person, so the scaffold fills it: a profile that + // shipped `` would be the placeholder the lint exists to catch. + text = strings.ReplaceAll(text, "", a.ownerName) if !contains(a.categories, "governance") { text = governanceLineRe.ReplaceAllString(text, " - "+fsx.JoinUnderRoot(a.rootPath, "decisions/")+"\n") } @@ -766,11 +911,11 @@ func buildFirstDecision(a answers) string { "---\n\n" + "# Adopt the Leji context layer\n\n" + "## Context\n\n" + - "Engineering knowledge lived in heads, chat threads, and per-tool config files. People and agents had no single place to read how this team thinks.\n\n" + + "This repository takes a shared, versioned context layer: one record of how it works, kept in the repository and read by people and agents alike.\n\n" + "## Decision\n\n" + "Adopt Leji at the `" + a.level + "` level: " + indexedLine + ".\n\n" + "## Consequences\n\n" + - "Vendor config files become one-line redirects. Context fixes ride the same review gate as the work that surfaces them. " + a.ownerName + " owns the layer.\n" + "Context changes ride the same review gate as the work that surfaces them, and " + a.ownerName + " owns the layer. Agent entrypoints point at the context layer rather than carrying their own copy: the portable `AGENTS.md` pointer where the scaffold writes one, and vendor entrypoints only where `leji adopt --wire-adapters` converts them with your consent.\n" } func buildChangelog(a answers, written []string) string { @@ -794,19 +939,19 @@ func buildChangelog(a answers, written []string) string { } // buildBrief returns the transient onboarding brief, rewritten for the chosen -// root (JoinUnderRoot(".", "") is "", so a "." root yields `.leji/...` and -// `context/...`, never `..leji/` or `.context/`) and stamped with the working -// mode so the agent runs the right interview without re-asking. +// root (JoinUnderRoot(".", "") is "", so a "." root yields `context/...`, never +// `.context/`) and stamped with the working mode so the agent runs the right +// interview without re-asking. The workspace paths it names are root-relative +// already and need no rewriting. func buildBrief(a answers) string { text := strings.ReplaceAll(readTemplate("onboarding-brief.md"), "/", fsx.JoinUnderRoot(a.rootPath, "")) return strings.ReplaceAll(text, "", a.mode) } -// BriefPath is the path of the transient onboarding brief, under a dot-directory -// so it is excluded from the index, the viewer, and the changelog. -func BriefPath(rootPath string) string { - return fsx.JoinUnderRoot(rootPath, ".leji/onboarding-brief.md") -} +// BriefPath is the path of the transient onboarding brief: the workspace role of +// the unified root `.leji/`, under a dot-directory so it is excluded from the +// index, the viewer, and the changelog. Root-relative whatever rootPath is. +const BriefPath = layout.WorkRel + "/onboarding-brief.md" const CIWorkflowPath = ".github/workflows/leji.yml" @@ -845,39 +990,57 @@ type HookResult struct { const hookMarker = "# leji pre-commit (managed)" +// ShQuote quotes one argv element for sh. Single quotes take everything +// literally, and an embedded quote is closed, escaped, and reopened ('\”), the +// one escape a POSIX shell accepts inside them. The runner comes from the +// repository's own package manager, so it is never interpolated raw into +// generated shell. +func ShQuote(word string) string { + return "'" + strings.ReplaceAll(word, "'", `'\''`) + "'" +} + +// shCommand renders the runner argv as one quoted command prefix. +func shCommand(runner []string) string { + quoted := make([]string, len(runner)) + for i, w := range runner { + quoted[i] = ShQuote(w) + } + return strings.Join(quoted, " ") +} + // The failure message is single-quoted for the SHELL: the backticks around // `leji index` are literal text, and inside a double-quoted echo sh would run them // as a command substitution (regenerating the index the hook just refused a commit // over). Never emit an unquoted backtick, "$(", or "$VAR" into generated shell // unless expansion is the intent. -const hookBody = "#!/bin/sh\n" + - hookMarker + "\n" + - "# Validate the context layer and refuse a commit that would leave the stored\n" + - "# index stale. Local mirror of the CI gate, preferring a repo-local install;\n" + - "# delete this file to opt out.\n" + - "LEJI=\"leji\"\n" + - "[ -x \"node_modules/.bin/leji\" ] && LEJI=\"node_modules/.bin/leji\"\n" + - "\"$LEJI\" validate || exit 1\n" + - "\"$LEJI\" index --check || {\n" + - " echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2\n" + - " exit 1\n" + - "}\n" +func hookGates(runner []string) string { + leji := shCommand(runner) + return leji + " validate || exit 1\n" + + leji + " index --check || {\n" + + " echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2\n" + + " exit 1\n" + + "}\n" +} + +// HookBody is the standalone managed pre-commit hook, running the repository's own +// runner. +func HookBody(runner []string) string { + return "#!/bin/sh\n" + + hookMarker + "\n" + + "# Validate the context layer and refuse a commit that would leave the stored\n" + + "# index stale. Local mirror of the CI gate; delete this file to opt out.\n" + + hookGates(runner) +} const huskyMarkerStart = "# >>> leji hooks (managed) >>>" const huskyMarkerEnd = "# <<< leji hooks (managed) <<<" -// huskyBlock runs the same two gates hookBody runs (preferring a repo-local -// install), wrapped in markers so the block can be merged into a husky repo's -// hand-authored .husky/pre-commit without touching its rest. -const huskyBlock = huskyMarkerStart + "\n" + - "LEJI=\"leji\"\n" + - "[ -x \"node_modules/.bin/leji\" ] && LEJI=\"node_modules/.bin/leji\"\n" + - "\"$LEJI\" validate || exit 1\n" + - "\"$LEJI\" index --check || {\n" + - " echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2\n" + - " exit 1\n" + - "}\n" + - huskyMarkerEnd + "\n" +// HuskyBlock runs the same two gates HookBody runs, wrapped in markers so the +// block can be merged into a husky repo's hand-authored .husky/pre-commit without +// touching its rest. +func HuskyBlock(runner []string) string { + return huskyMarkerStart + "\n" + hookGates(runner) + huskyMarkerEnd + "\n" +} // hooksPathConfig returns the configured core.hooksPath for the repo at root, or // "" when unset. Run from the repo root (git -C) so local, global, and system @@ -912,6 +1075,34 @@ func gitHooksDir(rootAbs string) string { return filepath.Clean(dir) } +// gitDirs returns git's own directories for the repo at rootAbs, resolved absolute: +// this working tree's git dir and the common dir it shares with every linked +// worktree. Both are read-only queries, and together they are what decides whether a +// hook target is clone-local (personal) rather than committed (shared). ok is false +// when this is not a git repository. +func gitDirs(rootAbs string) (gitDir, commonDir string, ok bool) { + out, err := exec.Command("git", "-C", rootAbs, "rev-parse", "--git-dir", "--git-common-dir").Output() + if err != nil { + return "", "", false + } + var lines []string + for _, l := range strings.Split(string(out), "\n") { + if t := strings.TrimSpace(l); t != "" { + lines = append(lines, t) + } + } + if len(lines) < 2 { + return "", "", false + } + abs := func(p string) string { + if filepath.IsAbs(p) { + return filepath.Clean(p) + } + return filepath.Join(rootAbs, p) + } + return abs(lines[0]), abs(lines[1]), true +} + // huskyShape returns the husky shape of the configured hooks path: "underscore" for // husky v9 (.husky/_), "direct" for husky v8 (.husky), or "" when not husky-shaped or // unset. Decides block-vs-file routing and (with the resolved hooks dir) the @@ -935,6 +1126,116 @@ func huskyShape(rootAbs, hooksPath string) string { return "" } +// HookOwnership says who owns the pre-commit hook this repository would get, +// decided by where the write would actually land rather than by the mechanism that +// would perform it: "personal" under git's own directories AND inside this working +// tree (.git/hooks, a core.hooksPath resolving inside them) — per clone, never +// committed, and safe to write; "shared" inside the working tree but not under git's +// directories (husky, a githooks/ hooks path) — committed, so a maintainer's call; +// "outside-root" under git's directories but OUTSIDE this working tree (a linked +// worktree, whose hooks live in the common git directory) — per clone, but the writer +// refuses to write outside the repository root, so it is reported; "external" +// anywhere else (a global or $HOME hooks path, a symlink escaping the repository) — +// reported, never written; "no-git" when there is no repository to hang a hook on. +type HookOwnership = string + +// HookState is what stands at that target: leji's own managed hook or block, +// nothing at all, or a hook this tool did not write. +type HookState = string + +// HookReport is the read-only answer `leji start` reports and EnsureLocalHook would +// act on. +type HookReport struct { + Ownership HookOwnership // "personal" | "shared" | "external" | "no-git" + State HookState // "current" | "absent" | "foreign" + // Path is the target, repository-relative when it lies inside the repository, + // else the absolute path git resolved; empty when there is no repository. + Path string + Managed string // "file" | "block" + // Snippet is what a person adds by hand where leji must not write. + Snippet string +} + +// hookText returns the hook file's text, or ok=false when nothing readable stands +// there. Read-only: this answers a question, and every write still goes through +// EnsureLocalHook. +func hookText(abs string) (string, bool) { + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + return "", false + } + b, err := os.ReadFile(abs) + if err != nil { + return "", false + } + return string(b), true +} + +// HookStatus is EnsureLocalHook's resolve step without the write: where the managed +// pre-commit hook would go for this repository, who owns that location, and what +// stands there now. The whole point is that a report can be produced without +// touching anything — `leji start` prints it, and only a consented repair goes on to +// EnsureLocalHook. +func HookStatus(root string, runner []string) HookReport { + rootAbs, err := filepath.Abs(root) + if err != nil { + rootAbs = root + } + argv := runner + if argv == nil { + argv = ecosystem.RunnerArgv(ecosystem.Detect(rootAbs)) + } + hooksDir := gitHooksDir(rootAbs) + gitDir, commonDir, haveGit := gitDirs(rootAbs) + if hooksDir == "" || !haveGit { + return HookReport{Ownership: "no-git", State: "absent", Managed: "file", Snippet: HookBody(argv)} + } + shape := huskyShape(rootAbs, hooksPathConfig(rootAbs)) + target := filepath.Join(hooksDir, "pre-commit") + if shape == "underscore" { + target = filepath.Join(filepath.Dir(hooksDir), "pre-commit") + } + managed := "file" + snippet := HookBody(argv) + marker := hookMarker + if shape != "" { + managed = "block" + snippet = HuskyBlock(argv) + marker = huskyMarkerStart + } + // Git's directories are tested FIRST: an ordinary .git/hooks also lies inside the + // working tree, and it is per-clone state, not something a commit can carry. A + // clone-local target that nonetheless falls outside this working tree (a linked + // worktree's shared hooks directory) is reported rather than offered: the writer + // refuses everything outside the repository root, so offering it would promise a + // write that cannot happen. + inRepo := fsx.ResolvedWithinRoot(rootAbs, target) + cloneLocal := fsx.ResolvedWithinRoot(gitDir, target) || fsx.ResolvedWithinRoot(commonDir, target) + ownership := "external" + switch { + case cloneLocal && inRepo: + ownership = "personal" + case cloneLocal: + ownership = "outside-root" + case inRepo: + ownership = "shared" + } + state := "absent" + if existing, ok := hookText(target); ok { + state = "foreign" + if strings.Contains(existing, marker) { + state = "current" + } + } + shown := fsx.ToPosix(target) + if inRepo { + if rel, err := filepath.Rel(rootAbs, target); err == nil { + shown = fsx.ToPosix(rel) + } + } + return HookReport{Ownership: ownership, State: state, Path: shown, Managed: managed, Snippet: snippet} +} + // EnsureLocalHook writes a managed pre-commit hook running the same checks CI runs, // so drift is caught before a commit instead of at the pipeline. The write location // is git's effective hooks dir (rev-parse --git-path hooks); core.hooksPath decides @@ -942,7 +1243,7 @@ func huskyShape(rootAbs, hooksPath string) string { // (v8/v9) or a standalone managed hook is written. A hooks dir resolving outside the // repo (a global core.hooksPath) is never written — the snippet comes back for a // manual hand-add, as does an existing unmanaged hook. -func EnsureLocalHook(root string) (HookResult, error) { +func EnsureLocalHook(root string, runner []string) (HookResult, error) { rootAbs, err := filepath.Abs(root) if err != nil { rootAbs = root @@ -951,6 +1252,13 @@ func EnsureLocalHook(root string) (HookResult, error) { if hooksDir == "" { return HookResult{}, errors.New("not a git repository (no .git directory); hooks need one") } + // The hook runs what a clean install of THIS repository provides: the detected + // manager's runner when the CLI is actually declared, else the plain binary on + // PATH. Injectable so a test pins a runner without planting a manifest. + argv := runner + if argv == nil { + argv = ecosystem.RunnerArgv(ecosystem.Detect(rootAbs)) + } shape := huskyShape(rootAbs, hooksPathConfig(rootAbs)) // Husky's user-editable hook is .husky/pre-commit: the hooks dir itself for v8 // (.husky), its parent for v9 (.husky/_). Only a direct v8 hook is run by git @@ -959,38 +1267,54 @@ func EnsureLocalHook(root string) (HookResult, error) { if shape == "underscore" { target = filepath.Join(filepath.Dir(hooksDir), "pre-commit") } - if !fsx.ResolvesUnder(rootAbs, target) { + // The hook is written through the chokepoint, judged on the resolved path at the + // act; a target that stopped resolving inside the repository between the check + // here and the write comes back as the same hand-add result this check returns. + manual := func(managed string) HookResult { + if managed == "block" { + return HookResult{Path: fsx.ToPosix(target), Action: "manual", Snippet: HuskyBlock(argv), Managed: "block", Reason: "outside-root"} + } + return HookResult{Path: fsx.ToPosix(target), Action: "manual", Snippet: HookBody(argv), Managed: "file", Reason: "outside-root"} + } + if !fsx.ResolvedWithinRoot(rootAbs, target) { // Never write outside the repository; report the computed target for a hand-add. if shape != "" { - return HookResult{Path: fsx.ToPosix(target), Action: "manual", Snippet: huskyBlock, Managed: "block", Reason: "outside-root"}, nil + return manual("block"), nil } - return HookResult{Path: fsx.ToPosix(target), Action: "manual", Snippet: hookBody, Managed: "file", Reason: "outside-root"}, nil + return manual("file"), nil } rel, _ := filepath.Rel(rootAbs, target) + guardRootAbs := fsx.GuardRoot(rootAbs) if shape != "" { - return ensureHuskyBlock(target, fsx.ToPosix(rel), shape == "direct") + return ensureHuskyBlock(guardRootAbs, target, fsx.ToPosix(rel), shape == "direct", manual, argv) } - return ensureHookFile(target, fsx.ToPosix(rel)) + return ensureHookFile(guardRootAbs, target, fsx.ToPosix(rel), manual, argv) } // ensureHookFile writes/refreshes the standalone managed pre-commit hook at // hookAbs. Ours (marker present) is created/updated; an existing unmanaged hook // is never touched and its replacement snippet comes back for a manual merge. -func ensureHookFile(hookAbs, rel string) (HookResult, error) { +func ensureHookFile(rootAbs, hookAbs, rel string, manual func(managed string) HookResult, runner []string) (HookResult, error) { + body := HookBody(runner) + // The hook's own bytes decide whether it is ours to rewrite, so they come from the + // verified read: an entry standing at the hook path that cannot be verified as a + // regular file inside the repository is reported for a hand-add, never merged. + hookRead, err := fsx.VerifiedTargetRead(rootAbs, hookAbs, "") + if err != nil { + return HookResult{}, err + } + if hookRead.Status == fsx.ReadRefused { + return manual("file"), nil + } existing := "" - hasExisting := false - if fsx.IsFile(hookAbs) { - t, rerr := fsx.ReadText(hookAbs) - if rerr != nil { - return HookResult{}, rerr - } - existing = t - hasExisting = true + hasExisting := hookRead.Status == fsx.ReadRegular + if hasExisting { + existing = string(hookRead.Bytes) } if hasExisting && !strings.Contains(existing, hookMarker) { - return HookResult{Path: rel, Action: "manual", Snippet: hookBody, Managed: "file", Reason: "foreign-hook"}, nil + return HookResult{Path: rel, Action: "manual", Snippet: body, Managed: "file", Reason: "foreign-hook"}, nil } - if hasExisting && existing == hookBody { + if hasExisting && existing == body { // Byte-current. A standalone hook is run by git itself, so a non-executable // file is a mode-only correction reported updated, not unchanged. exec, err := isExecutable(hookAbs) @@ -998,18 +1322,23 @@ func ensureHookFile(hookAbs, rel string) (HookResult, error) { return HookResult{}, err } if !exec { - if err := os.Chmod(hookAbs, 0o755); err != nil { + verdict, err := fsx.ChmodGuarded(rootAbs, hookAbs, "", 0o755) + if err != nil { return HookResult{}, err } + if !verdict.OK { + return manual("file"), nil + } return HookResult{Path: rel, Action: "updated", Managed: "file"}, nil } return HookResult{Path: rel, Action: "unchanged", Managed: "file"}, nil } - if err := os.MkdirAll(filepath.Dir(hookAbs), 0o755); err != nil { + verdict, err := fsx.WriteFileGuarded(rootAbs, hookAbs, "", []byte(body), fsx.WriteOptions{Mode: 0o755}) + if err != nil { return HookResult{}, err } - if err := os.WriteFile(hookAbs, []byte(hookBody), 0o755); err != nil { - return HookResult{}, err + if !verdict.OK { + return manual("file"), nil } action := "created" if hasExisting { @@ -1025,29 +1354,46 @@ func ensureHookFile(hookAbs, rel string) (HookResult, error) { // of a user-authored husky hook is left untouched. requireExec (a direct .husky hook // git runs itself) forces mode 0755: a byte-current but non-executable file is a // mode-only correction reported "updated". -func ensureHuskyBlock(hookAbs, rel string, requireExec bool) (HookResult, error) { - if !fsx.IsFile(hookAbs) { - if err := os.MkdirAll(filepath.Dir(hookAbs), 0o755); err != nil { +func ensureHuskyBlock(rootAbs, hookAbs, rel string, requireExec bool, manual func(managed string) HookResult, runner []string) (HookResult, error) { + block := HuskyBlock(runner) + // The user's own hook is merged, so its bytes come from the verified read: what the + // merge judged is what the rewrite is based on. + hookRead, err := fsx.VerifiedTargetRead(rootAbs, hookAbs, "") + if err != nil { + return HookResult{}, err + } + if hookRead.Status == fsx.ReadRefused { + return manual("block"), nil + } + if hookRead.Status != fsx.ReadRegular { + verdict, err := fsx.WriteFileGuarded(rootAbs, hookAbs, "", + []byte("#!/bin/sh\n"+block), fsx.WriteOptions{Mode: 0o755}) + if err != nil { return HookResult{}, err } - if err := os.WriteFile(hookAbs, []byte("#!/bin/sh\n"+huskyBlock), 0o755); err != nil { - return HookResult{}, err + if !verdict.OK { + return manual("block"), nil } return HookResult{Path: rel, Action: "created", Managed: "block"}, nil } - existing, rerr := fsx.ReadText(hookAbs) - if rerr != nil { - return HookResult{}, rerr - } - merged := mergeManagedBlock(existing, huskyBlock, huskyMarkerStart, huskyMarkerEnd) + existing := string(hookRead.Bytes) + merged := mergeManagedBlock(existing, block, huskyMarkerStart, huskyMarkerEnd) if merged != existing { - if err := os.WriteFile(hookAbs, []byte(merged), 0o644); err != nil { + verdict, err := fsx.WriteFileGuarded(rootAbs, hookAbs, "", []byte(merged), fsx.WriteOptions{}) + if err != nil { return HookResult{}, err } + if !verdict.OK { + return manual("block"), nil + } if requireExec { - if err := os.Chmod(hookAbs, 0o755); err != nil { + chmod, err := fsx.ChmodGuarded(rootAbs, hookAbs, "", 0o755) + if err != nil { return HookResult{}, err } + if !chmod.OK { + return manual("block"), nil + } } return HookResult{Path: rel, Action: "updated", Managed: "block"}, nil } @@ -1057,9 +1403,13 @@ func ensureHuskyBlock(hookAbs, rel string, requireExec bool) (HookResult, error) return HookResult{}, err } if !exec { - if err := os.Chmod(hookAbs, 0o755); err != nil { + verdict, err := fsx.ChmodGuarded(rootAbs, hookAbs, "", 0o755) + if err != nil { return HookResult{}, err } + if !verdict.OK { + return manual("block"), nil + } return HookResult{Path: rel, Action: "updated", Managed: "block"}, nil } } @@ -1110,48 +1460,88 @@ type CiResult struct { Note string // set only when Action == "created" for azure } -// EnsureCiWorkflow adds a CI workflow that runs `leji validate` (the `leji ci` -// command). GitHub gets its own file; GitLab is create-or-merge into the shared -// `.gitlab-ci.yml` via a marker-delimited managed block; CircleCI is created if -// absent, else left untouched (a snippet is returned). All deterministic text, so -// the SDKs stay byte-identical. Refuses a symlink that escapes root. -func EnsureCiWorkflow(root, provider string) (CiResult, error) { +// EnsureCiWorkflow adds a CI workflow running `leji validate` (the `leji ci` +// command), with the job the repository's own package manager needs. GitHub, +// CircleCI and Azure own whole files: created when absent, REPLACED when the file +// standing there is one leji generated (this release or an earlier one), and left +// untouched with a hand-add snippet when it is foreign or was edited. GitLab owns a +// marker-delimited block inside the shared .gitlab-ci.yml and merges it. All +// deterministic text so the three SDKs stay byte-identical. Refuses a symlink that +// escapes root. +func EnsureCiWorkflow(root, provider string, report *ecosystem.Report) (CiResult, error) { rootAbs, err := filepath.Abs(root) if err != nil { return CiResult{}, err } - // Local-first: a repo that declares @leji-org/leji runs its lockfile-pinned - // install; a repo without one falls back to `npx @leji-org/leji@1`. - local := declaresLejiDep(rootAbs) && hasNpmLockfile(rootAbs) - switch provider { - case "github": - abs := filepath.Join(rootAbs, CIWorkflowPath) - if err := guardWithinRoot(rootAbs, abs, CIWorkflowPath); err != nil { + // Local-first: a repository that DECLARES the CLI and carries its manager's lock + // evidence installs its own locked dependencies and runs the local binary; every + // other state takes the fallback that needs no manifest. + detected := ecosystem.Report{} + if report != nil { + detected = *report + } else { + detected = ecosystem.Detect(rootAbs) + } + job := resolveCiJob(detected, provider) + // Every arm decides what stands at its target through the verified read, never + // through a pathname check: a stat follows symlinks, so a dangling link at the + // workflow path reads as absent and the create lands at the link's destination. + // Absent is the create path; a verified regular file is judged by its bytes; a + // standing entry that cannot be verified is the same refusal a write to it would + // be. + rootReal := fsx.GuardRoot(rootAbs) + + // wholeFile is the shared arm: create, replace what we own, or hand back a snippet. + wholeFile := func(rel, snippet, note string) (CiResult, error) { + abs := filepath.Join(rootAbs, rel) + if err := guardWithinRoot(rootAbs, abs, rel); err != nil { + return CiResult{}, err + } + content := buildCiFile(provider, job) + existing, present, err := readMergeSource(rootReal, abs, rel) + if err != nil { return CiResult{}, err } - if _, err := os.Stat(abs); err == nil { - return CiResult{Provider: provider, Path: CIWorkflowPath, Action: "unchanged"}, nil + if !present { + if err := writeFileAtomic(rootAbs, abs, rel, content); err != nil { + return CiResult{}, err + } + return CiResult{Provider: provider, Path: rel, Action: "created", Note: note}, nil + } + if existing == content { + return CiResult{Provider: provider, Path: rel, Action: "unchanged"}, nil } - if err := writeFileAtomic(rootAbs, abs, CIWorkflowPath, BuildGithubWorkflow(local)); err != nil { + if !isLejiGenerated(provider, existing) { + return CiResult{Provider: provider, Path: rel, Action: "manual", Snippet: snippet}, nil + } + if err := writeFileAtomic(rootAbs, abs, rel, content); err != nil { return CiResult{}, err } - return CiResult{Provider: provider, Path: CIWorkflowPath, Action: "created"}, nil + return CiResult{Provider: provider, Path: rel, Action: "updated"}, nil + } + + switch provider { + case "github": + return wholeFile(CIWorkflowPath, buildGithubWorkflow(job), "") case "gitlab": abs := filepath.Join(rootAbs, GitlabCIPath) if err := guardWithinRoot(rootAbs, abs, GitlabCIPath); err != nil { return CiResult{}, err } - block := BuildGitlabBlock(local) - if _, err := os.Stat(abs); err != nil { + block := buildGitlabBlock(job) + // The merge is a read-then-write of one target, so the bytes come from the + // verified read: the file the rule judged is the file that is read and then + // rewritten. + text, present, err := readMergeSource(rootReal, abs, GitlabCIPath) + if err != nil { + return CiResult{}, err + } + if !present { if err := writeFileAtomic(rootAbs, abs, GitlabCIPath, block); err != nil { return CiResult{}, err } return CiResult{Provider: provider, Path: GitlabCIPath, Action: "created"}, nil } - text, err := fsx.ReadText(abs) - if err != nil { - return CiResult{}, err - } merged := mergeGitlabBlock(text, block) if merged == text { return CiResult{Provider: provider, Path: GitlabCIPath, Action: "unchanged"}, nil @@ -1161,121 +1551,33 @@ func EnsureCiWorkflow(root, provider string) (CiResult, error) { } return CiResult{Provider: provider, Path: GitlabCIPath, Action: "updated"}, nil case "circleci": - abs := filepath.Join(rootAbs, CircleCIConfigPath) - if err := guardWithinRoot(rootAbs, abs, CircleCIConfigPath); err != nil { - return CiResult{}, err - } - if _, err := os.Stat(abs); err == nil { - return CiResult{Provider: provider, Path: CircleCIConfigPath, Action: "manual", Snippet: BuildCircleCiSnippet(local)}, nil - } - if err := writeFileAtomic(rootAbs, abs, CircleCIConfigPath, BuildCircleCiConfig(local)); err != nil { - return CiResult{}, err - } - return CiResult{Provider: provider, Path: CircleCIConfigPath, Action: "created"}, nil + return wholeFile(CircleCIConfigPath, buildCircleCiSnippet(job), "") case "azure": - abs := filepath.Join(rootAbs, AzurePipelinePath) - if err := guardWithinRoot(rootAbs, abs, AzurePipelinePath); err != nil { - return CiResult{}, err - } - // The activation note is intentionally created-only: a re-run on an existing - // pipeline file stays quiet (no note) rather than repeating the setup guidance. - if _, err := os.Stat(abs); err == nil { - return CiResult{Provider: provider, Path: AzurePipelinePath, Action: "unchanged"}, nil - } - if err := writeFileAtomic(rootAbs, abs, AzurePipelinePath, BuildAzurePipeline(local)); err != nil { - return CiResult{}, err - } - return CiResult{Provider: provider, Path: AzurePipelinePath, Action: "created", Note: AzureActivationNote}, nil + // The activation note is created-only: a re-run on an existing file stays quiet. + return wholeFile(AzurePipelinePath, buildAzurePipeline(job), AzureActivationNote) } // Unreachable from the CLI (it validates first); guards direct helper callers so // an unknown provider errors consistently across the three SDKs. return CiResult{}, fmt.Errorf("unknown provider %q", provider) } -// hasNpmLockfile reports whether an npm lockfile is present. The generated -// local-install job runs `npm ci`, which requires one: a pnpm, Yarn or Bun -// repository can declare the dependency and still have none, and the job would fail -// before Leji ran. -func hasNpmLockfile(rootAbs string) bool { - return fsx.IsFile(filepath.Join(rootAbs, "package-lock.json")) -} - -// declaresLejiDep reports whether the repo's root package.json declares -// @leji-org/leji under dependencies or devDependencies. Deterministic and identical -// across SDKs: read bytes, strip a single leading UTF-8 BOM, strict JSON parse (any -// error -> not declared), and count dependencies/devDependencies only when they are -// JSON objects holding the exact key (any other type -> absent, never an error). A -// generic map decode (not a fixed struct) so malformed inputs match the TS reference. -func declaresLejiDep(rootAbs string) bool { - data, err := os.ReadFile(filepath.Join(rootAbs, "package.json")) - if err != nil { - return false - } - data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) - var pkg map[string]json.RawMessage - if err := json.Unmarshal(data, &pkg); err != nil { - return false - } - for _, field := range []string{"dependencies", "devDependencies"} { - raw, ok := pkg[field] - if !ok { - continue - } - var deps map[string]json.RawMessage - // A non-object value (array/scalar) errors; JSON null decodes to a nil map. - // Both are treated as absent, never an error. - if err := json.Unmarshal(raw, &deps); err != nil || deps == nil { - continue - } - if _, ok := deps[depName]; ok { - return true - } - } - return false -} - func guardWithinRoot(rootAbs, abs, rel string) error { - if !fsx.ResolvesUnder(rootAbs, abs) { + if !fsx.ResolvedWithinRoot(rootAbs, abs) { return fmt.Errorf("refusing to write through a symlink that escapes the target: %q", rel) } return nil } -// writeFileAtomic writes via a sibling temp file then rename, so an interrupted -// write never leaves a partial file. On failure the temp is removed and a -// deterministic, OS-text-free error is returned (byte-identical across SDKs). +// writeFileAtomic writes via a sibling temp file then rename (both ends judged by +// the write chokepoint), so an interrupted write never leaves a partial file. On +// failure the temp is removed and a deterministic, OS-text-free error is returned +// (byte-identical across SDKs). func writeFileAtomic(rootAbs, abs, rel, contents string) error { - tmp := abs + ".leji-tmp" - // The sibling temp path must not escape the root either (a planted - // `.leji-tmp` symlink would otherwise be written through before the rename). - if err := guardWithinRoot(rootAbs, tmp, rel); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return writeFailure(rel, err) - } - if err := os.WriteFile(tmp, []byte(contents), 0o644); err != nil { - _ = os.Remove(tmp) - return writeFailure(rel, err) - } - if err := maybeInjectWriteFailure(); err != nil { - _ = os.Remove(tmp) - return writeFailure(rel, err) - } - if err := os.Rename(tmp, abs); err != nil { - _ = os.Remove(tmp) + verdict, err := fsx.WriteFileAtomicGuarded(fsx.GuardRoot(rootAbs), abs, initRole(rel), []byte(contents)) + if err != nil { return writeFailure(rel, err) } - return nil -} - -// maybeInjectWriteFailure is test-only fault injection (LEJI_TEST_FAIL_RENAME): -// simulates failure after the temp file exists but before the rename commits. -func maybeInjectWriteFailure() error { - if os.Getenv("LEJI_TEST_FAIL_RENAME") != "" { - return errors.New("injected write failure") - } - return nil + return guardedOrRefuse(rel, verdict, nil) } // writeFailure renders a deterministic, OS-text-free message, keeping stderr @@ -1346,124 +1648,6 @@ func stripManagedBlocks(text, startMarker, endMarker string) string { } } -// Local-first CI: a repo that declares @leji-org/leji installs its lockfile-pinned -// deps and runs the local bin (`npx --no-install` fails loudly rather than fetch a -// floating version); a repo without one falls back to `npx @leji-org/leji@1`, which -// pins the SDK to its current major (@1): additive-only within a major so a valid -// layer stays valid, and a breaking major never reaches adopter CI without a bump. - -// BuildGithubWorkflow is the GitHub Actions workflow file. Byte-identical across SDKs. -func BuildGithubWorkflow(local bool) string { - run := " - run: npx -y @leji-org/leji@1 validate\n" + - " - run: npx -y @leji-org/leji@1 index --check\n" - if local { - run = " - run: npm ci\n" + - " - run: npx --no-install @leji-org/leji validate\n" + - " - run: npx --no-install @leji-org/leji index --check\n" - } - return "name: leji\n" + - "on: [push, pull_request]\n" + - "jobs:\n" + - " validate:\n" + - " runs-on: ubuntu-latest\n" + - " steps:\n" + - " - uses: actions/checkout@v4\n" + - " - uses: actions/setup-node@v4\n" + - " with:\n" + - " node-version: '22'\n" + - run -} - -// BuildGitlabBlock is the GitLab CI marker-delimited job merged into the shared -// .gitlab-ci.yml. -func BuildGitlabBlock(local bool) string { - script := " - npx -y @leji-org/leji@1 validate\n" + - " - npx -y @leji-org/leji@1 index --check\n" - if local { - script = " - npm ci\n" + - " - npx --no-install @leji-org/leji validate\n" + - " - npx --no-install @leji-org/leji index --check\n" - } - return gitlabMarkerStart + "\n" + - "leji-validate:\n" + - // `.pre` is always available. Without an explicit stage GitLab assigns - // `test`, and a pipeline whose own `stages:` omits it rejects the config. - " stage: .pre\n" + - " image: node:22\n" + - " script:\n" + - script + - gitlabMarkerEnd + "\n" -} - -// circleCiSteps is the CircleCI job steps shared by the config and the hand-add snippet. -func circleCiSteps(local bool) string { - if local { - return " - checkout\n" + - " - run: npm ci\n" + - " - run: npx --no-install @leji-org/leji validate\n" + - " - run: npx --no-install @leji-org/leji index --check\n" - } - return " - checkout\n" + - " - run: npx -y @leji-org/leji@1 validate\n" + - " - run: npx -y @leji-org/leji@1 index --check\n" -} - -// BuildCircleCiConfig is the CircleCI config written when .circleci/config.yml is absent. -func BuildCircleCiConfig(local bool) string { - return "version: 2.1\n" + - "jobs:\n" + - " leji-validate:\n" + - " docker:\n" + - " - image: node:22\n" + - " steps:\n" + - circleCiSteps(local) + - "workflows:\n" + - " leji:\n" + - " jobs:\n" + - " - leji-validate\n" -} - -// BuildCircleCiSnippet is the jobs + workflows fragment to add by hand to an -// existing CircleCI config. -func BuildCircleCiSnippet(local bool) string { - return "jobs:\n" + - " leji-validate:\n" + - " docker:\n" + - " - image: node:22\n" + - " steps:\n" + - circleCiSteps(local) + - "workflows:\n" + - " leji:\n" + - " jobs:\n" + - " - leji-validate\n" -} - -// BuildAzurePipeline is the Azure Pipelines config: a dedicated -// .azure-pipelines/leji.yml the user wires to a pipeline. -func BuildAzurePipeline(local bool) string { - steps := " - script: npx -y @leji-org/leji@1 validate\n" + - " displayName: leji validate\n" + - " - script: npx -y @leji-org/leji@1 index --check\n" + - " displayName: leji index --check\n" - if local { - steps = " - script: npm ci\n" + - " displayName: install\n" + - " - script: npx --no-install @leji-org/leji validate\n" + - " displayName: leji validate\n" + - " - script: npx --no-install @leji-org/leji index --check\n" + - " displayName: leji index --check\n" - } - return "trigger:\n" + - " - main\n" + - "pool:\n" + - " vmImage: ubuntu-latest\n" + - "steps:\n" + - " - task: NodeTool@0\n" + - " inputs:\n" + - " versionSpec: '22.x'\n" + - steps -} - func hostIDs() []string { ids := make([]string, len(detect.HostSpecs)) for i, s := range detect.HostSpecs { @@ -1539,9 +1723,16 @@ type AgentOptions struct { Role string } +// AgentsDefaultNote is guidance for the `default` binding: selecting a role profile +// there is not the same as loading it, a distinction the key's name invites readers +// to miss. Written-only, like the CI activation note: a re-run that binds nothing +// stays terse. +const AgentsDefaultNote = "agents.default selects a role profile; it does not load it. If its instructions must apply before every task, fold them into the boot profile; otherwise keep the profile role-scoped and engage it through the relevant protocol." + // AgentResult is what AddAgent did, for the command to report. Each artifact is // independently idempotent: a *Created/ManifestChanged of false means it was -// already there. +// already there. Note is advisory text the caller surfaces verbatim (set when the +// `default` binding is written). type AgentResult struct { Name string Role string @@ -1549,6 +1740,7 @@ type AgentResult struct { ProfilePath string ProfileCreated bool ManifestChanged bool + Note string } // AddAgent wires a named agent into an existing layer (the `leji agent` command): @@ -1593,44 +1785,69 @@ func AddAgent(root string, m *manifest.Manifest, opts AgentOptions) (AgentResult } profileRel := base + name + ".md" profileAbs := filepath.Join(rootAbs, profileRel) - profileCreated := false - if !fsx.IsFile(profileAbs) { - if !fsx.ResolvesUnder(rootAbs, profileAbs) { - return AgentResult{}, fmt.Errorf("refusing to write through a symlink that escapes the target: %q", profileRel) - } - if err := os.MkdirAll(filepath.Dir(profileAbs), 0o755); err != nil { - return AgentResult{}, err - } - if err := os.WriteFile(profileAbs, []byte(BuildAgentProfile(name, role, hostID, m.RootPath)), 0o644); err != nil { - return AgentResult{}, err - } - profileCreated = true - } - + rootReal := fsx.GuardRoot(rootAbs) + + // Both halves of this command are judged BEFORE either is written: binding an agent + // means a profile file and a manifest edit, and a run that can only do one of them + // must do neither. The manifest is read through the verified read (its bytes are + // spliced and written straight back), so a target that cannot be verified as a + // regular file inside the repository refuses the whole command with nothing + // written. Absent refuses too: this command edits a manifest, it never creates one. manifestAbs := filepath.Join(rootAbs, "leji.json") - original, err := fsx.ReadText(manifestAbs) + manifestRead, err := fsx.VerifiedTargetRead(rootReal, manifestAbs, "") if err != nil { return AgentResult{}, err } + if manifestRead.Status != fsx.ReadRegular { + return AgentResult{}, escapeRefusal("leji.json") + } + original := string(manifestRead.Bytes) text, _, err := manifest.BindAgentInManifestText(original, name, profileRel) if err != nil { return AgentResult{}, err } manifestChanged := text != original + + // The profile half is judged next, still before either write: a pathname check + // follows symlinks, so a dangling link at the profile name reads as absent and the + // write lands at the link's destination. Only absent is written; a verified regular + // file is the never-overwrite skip this command has always made; anything else + // standing there refuses the whole command with nothing written. + profileRead, err := fsx.VerifiedTargetRead(rootReal, profileAbs, "") + if err != nil { + return AgentResult{}, err + } + if profileRead.Status == fsx.ReadRefused { + return AgentResult{}, escapeRefusal(profileRel) + } + profileCreated := profileRead.Status == fsx.ReadAbsent + + if profileCreated { + profile := []byte(BuildAgentProfile(name, role, hostID, m.RootPath)) + verdict, werr := fsx.WriteFileGuarded(rootReal, profileAbs, "", profile, fsx.WriteOptions{}) + if err := guardedOrRefuse(profileRel, verdict, werr); err != nil { + return AgentResult{}, err + } + } if manifestChanged { - if err := os.WriteFile(manifestAbs, []byte(text), 0o644); err != nil { + verdict, werr := fsx.WriteFileGuarded(rootReal, manifestAbs, "", []byte(text), fsx.WriteOptions{}) + if err := guardedOrRefuse("leji.json", verdict, werr); err != nil { return AgentResult{}, err } } - return AgentResult{ + r := AgentResult{ Name: name, Role: role, HostID: hostID, ProfilePath: profileRel, ProfileCreated: profileCreated, ManifestChanged: manifestChanged, - }, nil + } + if name == "default" && manifestChanged { + r.Note = AgentsDefaultNote + } + return r, nil } // assertCleanWorkingTree refuses a dirty working tree: the "git restore cleanly @@ -1713,7 +1930,7 @@ func InitLayer(opts Options) (Result, error) { writes = append(writes, writeplan.PlannedWrite{Rel: layout.ContextDir + category + ".md", Content: categoryIndexFile(r, category)}) } writes = append(writes, writeplan.PlannedWrite{Rel: layout.AgentsDir + "core.md", Content: buildCoreProfile(a)}) - writes = append(writes, writeplan.PlannedWrite{Rel: BriefPath(r), Content: buildBrief(a)}) + writes = append(writes, writeplan.PlannedWrite{Rel: BriefPath, Content: buildBrief(a)}) if a.level == "indexed" { // The changelog records the paths seeded; compute from the planned set // (everything except the changelog and the generated index). Dot-paths @@ -1756,7 +1973,7 @@ func InitLayer(opts Options) (Result, error) { // The tracked-file preflight and the `.leji/` ignore run BEFORE any write at // all, so the private onboarding workspace can never land in git and a failed // preflight leaves the tree untouched. - if err := assertLejiWorkspacePrivate(root, r); err != nil { + if err := assertLejiWorkspacePrivate(root); err != nil { return Result{}, err } if err := ensureLejiGitignored(root); err != nil { @@ -1766,10 +1983,7 @@ func InitLayer(opts Options) (Result, error) { // race and refuses to follow a symlink at the final component, so a concurrent // init or a planted symlink cannot be overwritten or escaped. Every other file // goes through writeFileOnce so nothing is overwritten. - if !fsx.ResolvesUnder(root, filepath.Join(root, "leji.json")) { - return Result{}, fmt.Errorf("refusing to write through a symlink that escapes the target: %q", "leji.json") - } - if err := writeManifestExclusive(filepath.Join(root, "leji.json"), manifestBytes, "init"); err != nil { + if err := writeManifestExclusive(root, filepath.Join(root, "leji.json"), manifestBytes, "init"); err != nil { return Result{}, err } written = append(written, "leji.json") @@ -1816,7 +2030,7 @@ func InitLayer(opts Options) (Result, error) { // is unchanged from pre-mode releases; solo swaps one sentence to name the // interview. func EnteringTheLayer(m *manifest.Manifest, mode string) string { - brief := BriefPath(m.RootPath) + brief := BriefPath var how []string if mode == "solo" { how = []string{ @@ -1866,6 +2080,22 @@ type promptHost struct { type LaunchResult struct { Started bool Err error + // Stdout is the child's captured standard output, set only for a captured run + // (RunOptions.Capture); empty otherwise. + Stdout string +} + +// RunOptions bounds one child run. Quiet suppresses child output (the MCP presence +// check); Capture reads stdout back instead — bounded by TimeoutMs and MaxBytes, +// with stdin closed and stderr discarded — which is what the preflight version probe +// needs; Env, when non-nil, REPLACES the environment entirely (nothing of this +// process's is inherited), which is how the probe stays sanitized. +type RunOptions struct { + Quiet bool + Capture bool + TimeoutMs int + MaxBytes int + Env map[string]string } // HandoffIO is injectable I/O for the handoff offer, so the interactive flow is @@ -1878,10 +2108,9 @@ type HandoffIO struct { // `leji start --root `). An empty cwd uses the current directory. Host // flags (from `leji start -- `) go before the prompt argument. Launch func(bin, promptArg, cwd string, hostArgs []string) LaunchResult - // Run runs a host subcommand (the MCP presence check / register) from cwd. When - // quiet, child output is suppressed (the check); otherwise it inherits the - // terminal so the user sees the host's own output. - Run func(bin string, args []string, cwd string, quiet bool) LaunchResult + // Run runs a host subcommand (the MCP presence check / register) or a bounded + // probe from cwd, per RunOptions. + Run func(bin string, args []string, cwd string, opts RunOptions) LaunchResult } // DefaultHandoffIO wires a one-shot stdin line reader and a stdio-inherit spawn. @@ -1918,10 +2147,13 @@ func DefaultHandoffIO(in io.Reader, out io.Writer) *HandoffIO { } return LaunchResult{Started: false, Err: err} }, - Run: func(bin string, args []string, cwd string, quiet bool) LaunchResult { + Run: func(bin string, args []string, cwd string, opts RunOptions) LaunchResult { + if opts.Capture { + return captureRun(bin, args, cwd, opts) + } cmd := exec.Command(bin, args...) cmd.Dir = cwd - if !quiet { + if !opts.Quiet { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -1939,6 +2171,162 @@ func DefaultHandoffIO(in io.Reader, out io.Writer) *HandoffIO { } } +// captureRun is the bounded probe: stdin closed so nothing can prompt, stderr +// discarded, output and wall time bounded. Exceeding either bound comes back as a +// failed run, which every caller treats as a failed probe. +func captureRun(bin string, args []string, cwd string, opts RunOptions) LaunchResult { + ctx := context.Background() + var cancel context.CancelFunc + if opts.TimeoutMs > 0 { + ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMs)*time.Millisecond) + } else { + // Cancellable even without a deadline, because the output cap ends the run too. + ctx, cancel = context.WithCancel(ctx) + } + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = cwd + cmd.Stdin = nil + cmd.Stderr = nil + // Killing the child is not enough to return: `Wait` also waits for the goroutine + // copying its output, and a descendant the child left behind still holds the write + // end of that pipe. WaitDelay bounds that wait and closes the pipes, so the cap and + // the deadline bound THIS process however the child behaves. + cmd.WaitDelay = probeWaitDelay + // The environment is REPLACED, never extended: a non-nil Env is the child's whole + // environment, so nothing of this process's reaches the probe. Built from an empty + // slice and sorted, so the same options always produce the same environment. + env := make([]string, 0, len(opts.Env)) + for k, v := range opts.Env { + env = append(env, k+"="+v) + } + sort.Strings(env) + cmd.Env = env + var buf bytes.Buffer + capped := &cappedWriter{buf: &buf, limit: opts.MaxBytes, stop: cancel} + if opts.MaxBytes > 0 { + cmd.Stdout = capped + } else { + cmd.Stdout = &buf + } + err := cmd.Run() + // The cap is checked first: over it the child was killed, so whatever `Run` reports + // afterwards (a write error, a signal) describes the kill, not the program. + if capped.overflowed { + return LaunchResult{Started: true, Err: errProbeCapped, Stdout: buf.String()} + } + if err == nil && ctx.Err() == nil { + return LaunchResult{Started: true, Stdout: buf.String()} + } + if err == nil { + return LaunchResult{Started: true, Err: ctx.Err(), Stdout: buf.String()} + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return LaunchResult{Started: true, Err: err, Stdout: buf.String()} + } + return LaunchResult{Started: false, Err: err} +} + +// errProbeCapped is what a run that produced more than the cap comes back as. +var errProbeCapped = errors.New("probe output exceeded the cap") + +// probeWaitDelay is how long the run waits for the output pipe to drain after the +// child has been ended, before abandoning it. +const probeWaitDelay = 500 * time.Millisecond + +// cappedWriter fails the write once the child has produced more than limit bytes, so +// a probe pointed at a program that streams forever cannot fill memory. +type cappedWriter struct { + buf *bytes.Buffer + limit int + // stop cancels the run's context, which kills the child. A writer that only + // refused the write would leave a chatty child blocked on a full pipe until the + // timeout expired, so the cap would bound memory but not time. + stop context.CancelFunc + // overflowed records that the cap was reached, so the caller reports the cap + // rather than whatever error the kill produced. + overflowed bool +} + +func (w *cappedWriter) Write(p []byte) (int, error) { + // Exactly `limit` bytes is not overflow: the cap is the most that may be held. + if w.buf.Len()+len(p) > w.limit { + w.overflowed = true + if w.stop != nil { + w.stop() + } + return 0, errProbeCapped + } + return w.buf.Write(p) +} + +// BootProfileReady reports whether the manifest's boot profile is a safe relative +// path that actually exists: the one condition `leji start` refuses to run under, +// checked before anything is reported or launched. +func BootProfileReady(root string, m *manifest.Manifest) bool { + rootAbs, err := filepath.Abs(root) + if err != nil { + rootAbs = root + } + return validateRelPath(m.BootProfilePath) == nil && fsx.IsFile(filepath.Join(rootAbs, m.BootProfilePath)) +} + +// StartHost is the host `leji start` targets, resolved before the preflight runs so +// the report can name it before the launch takes the terminal. +type StartHost struct { + ID string + Bin string + Name string +} + +func (h *StartHost) internal() *promptHost { + if h == nil { + return nil + } + return &promptHost{id: h.ID, bin: h.Bin, name: h.Name} +} + +func exported(h *promptHost) *StartHost { + if h == nil { + return nil + } + return &StartHost{ID: h.id, Bin: h.bin, Name: h.name} +} + +// ResolveStartHost decides which host `leji start` targets: --agent forces one, a +// single detected prompt-capable host is it, and several ask (interactive only). +// Split out of EnterLayer so the preflight can report on the host this run has +// actually selected. Errors on an unknown or non-launchable --agent, as before. +func ResolveStartHost(detected []detect.DetectedHost, agent string, interactive bool, hio *HandoffIO, out io.Writer) (*StartHost, error) { + if agent != "" { + h, err := assertAgentHost(agent) + if err != nil { + return nil, err + } + return exported(h), nil + } + hosts := promptCapableHosts(detected) + if len(hosts) == 1 { + return exported(&hosts[0]), nil + } + if len(hosts) > 1 && interactive { + return exported(pickFromMultiple(hosts, hio, out)), nil + } + return nil, nil +} + +// StartHosts is the detected hosts `leji start` could launch, ranked — what the +// preflight names when several are present and none was picked. +func StartHosts(detected []detect.DetectedHost) []StartHost { + hosts := promptCapableHosts(detected) + out := make([]StartHost, 0, len(hosts)) + for i := range hosts { + out = append(out, *exported(&hosts[i])) + } + return out +} + // promptCapableHosts returns the detected on-PATH hosts launchable with an inline // prompt, ranked (detected is already strongest-first). func promptCapableHosts(detected []detect.DetectedHost) []promptHost { @@ -2048,7 +2436,7 @@ func HandoffOffer(m *manifest.Manifest, detected []detect.DetectedHost, interact if !interactive { return false, nil } - promptArg := "Read ./" + BriefPath(m.RootPath) + " and follow it." + promptArg := "Read ./" + BriefPath + " and follow it." // --agent forces a specific launchable host (skipping the prompt); otherwise the // detected hosts drive the offer. The interactive gate above keeps this off the // scripted/CI path, so cross-SDK parity is unchanged. @@ -2148,7 +2536,7 @@ func OfferMcpInstall(opts McpOfferOptions, hio *HandoffIO, out io.Writer) McpOff // never re-nags — but say so: a silent skip is indistinguishable from the offer // being broken. A failed check (e.g. an older host CLI) falls through to the offer. if len(spec.McpCheck) > 0 { - chk := hio.Run(target.bin, spec.McpCheck, opts.Root, true) + chk := hio.Run(target.bin, spec.McpCheck, opts.Root, RunOptions{Quiet: true}) if chk.Started && chk.Err == nil { fmt.Fprintf(out, "Leji MCP server already registered for %s; skipping the install offer.\n", target.name) return outcome @@ -2162,7 +2550,7 @@ func OfferMcpInstall(opts McpOfferOptions, hio *HandoffIO, out io.Writer) McpOff if !(answer == "" || answer == "y" || answer == "yes") { return outcome } - res := hio.Run(target.bin, spec.McpAdd, opts.Root, false) + res := hio.Run(target.bin, spec.McpAdd, opts.Root, RunOptions{}) if !res.Started { fmt.Fprintf(os.Stderr, "\nleji: could not run %s (%v); register it manually:\n %s %s\n", target.bin, res.Err, target.bin, strings.Join(spec.McpAdd, " ")) return outcome @@ -2197,6 +2585,13 @@ type StartOptions struct { // HostArgs are extra arguments passed verbatim to the launched host binary, // before the prompt (from `leji start -- `, e.g. Claude Code's --chrome). HostArgs []string + // Host is the host the caller already resolved, so the preflight report can name + // it before the launch takes the terminal. It is used only when HostResolved is + // true; otherwise EnterLayer resolves one itself, as before. A nil Host with + // HostResolved true is an explicit "no host", which falls back to the printed + // commands. + Host *StartHost + HostResolved bool } // bootPrompt is the prompt `leji start` hands the agent: point it at the boot profile. @@ -2214,26 +2609,22 @@ func EnterLayer(opts StartOptions, hio *HandoffIO, out io.Writer) (StartOutcome, if err != nil { root = opts.Root } - bootRel := opts.Manifest.BootProfilePath - if validateRelPath(bootRel) != nil || !fsx.IsFile(filepath.Join(root, bootRel)) { + if !BootProfileReady(root, opts.Manifest) { return StartBootMissing, nil } - promptArg := bootPrompt(bootRel) + promptArg := bootPrompt(opts.Manifest.BootProfilePath) + // A caller that already resolved the host (the preflight names it before the + // launch) passes it in; otherwise it is resolved here, as before. var host *promptHost - if opts.Agent != "" { - h, err := assertAgentHost(opts.Agent) + if opts.HostResolved { + host = opts.Host.internal() + } else { + h, err := ResolveStartHost(opts.Detected, opts.Agent, opts.Interactive, hio, out) if err != nil { return StartFallback, err } - host = h - } else { - hosts := promptCapableHosts(opts.Detected) - if len(hosts) == 1 { - host = &hosts[0] - } else if len(hosts) > 1 && opts.Interactive { - host = pickFromMultiple(hosts, hio, out) - } + host = h.internal() } if host == nil || !opts.Interactive { @@ -2461,23 +2852,18 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { bootRel := detectedRoot + "boot-profile.md" canonicalRedirect := strings.TrimSpace(detect.AdapterContent(bootRel)) - var vendorPresent []string - for _, rel := range validate.KnownVendorFiles { - abs := filepath.Join(root, rel) - // A vendor file that is a symlink resolving outside root is neither read, - // migrated, nor converted: it is treated as absent. - if fsx.IsFile(abs) && fsx.ResolvesUnder(root, abs) { - vendorPresent = append(vendorPresent, rel) - } + vendor, err := verifiedVendorFiles(root) + if err != nil { + return AdoptResult{}, err } + vendorPresent := vendorRels(vendor) // Migrate any vendor file that is not already exactly Leji's redirect, so its // content (whether on its own lines or sharing a line with the boot-path // reference) is archived before --wire-adapters overwrites it. A file that is // already the canonical redirect, or empty, has nothing to preserve. var toMigrate []string for _, rel := range vendorPresent { - t, _ := fsx.ReadText(filepath.Join(root, rel)) - trimmed := strings.TrimSpace(t) + trimmed := strings.TrimSpace(vendor[rel]) if trimmed != "" && trimmed != canonicalRedirect { toMigrate = append(toMigrate, rel) } @@ -2508,7 +2894,10 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { // already there so the layer never clobbers existing content. The viewer dir // (.leji/, reserved and gitignored) is generated by `leji viewer`, not // scaffolded here, so there is nothing to collide at adopt time. - adoptLayout := resolveLayout(root, detectedRoot) + adoptLayout, layoutErr := resolveLayout(root, detectedRoot) + if layoutErr != nil { + return AdoptResult{}, layoutErr + } a := answers{ name: name, description: "Shared context layer for this repository.", @@ -2570,7 +2959,7 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { writes = append(writes, writeplan.PlannedWrite{Rel: layout.ContextDir + category + ".md", Content: categoryIndexFile(r, category)}) } writes = append(writes, writeplan.PlannedWrite{Rel: layout.AgentsDir + "core.md", Content: buildCoreProfile(a)}) - writes = append(writes, writeplan.PlannedWrite{Rel: BriefPath(r), Content: buildBrief(a)}) + writes = append(writes, writeplan.PlannedWrite{Rel: BriefPath, Content: buildBrief(a)}) var migrated []string migrationDocByVendor := map[string]string{} @@ -2585,13 +2974,25 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { // followed by --wire-adapters overwriting the entrypoint would lose content. slug := base docRel := fsx.JoinUnderRoot(r, "governance/") + "imported-" + slug + ".md" - for n := 2; plannedRels[docRel] || fsx.Exists(filepath.Join(root, fsx.StripSlash(docRel))); n++ { + // The on-disk half is decided on the standing entry, never by a stat, which + // follows symlinks: a dangling candidate would read as a free name and the + // archive would be written at the link's missing destination. Any standing entry + // is occupied and the next name is tried (the rule archivePath mirrors). + for n := 2; ; n++ { + if !plannedRels[docRel] { + free, ferr := nothingStandsAt(filepath.Join(root, fsx.StripSlash(docRel))) + if ferr != nil { + return AdoptResult{}, ferr + } + if free { + break + } + } slug = fmt.Sprintf("%s-%d", base, n) docRel = fsx.JoinUnderRoot(r, "governance/") + "imported-" + slug + ".md" } plannedRels[docRel] = true - content, _ := fsx.ReadText(filepath.Join(root, rel)) - writes = append(writes, writeplan.PlannedWrite{Rel: docRel, Content: migrationDoc(rel, content)}) + writes = append(writes, writeplan.PlannedWrite{Rel: docRel, Content: migrationDoc(rel, vendor[rel])}) migrationDocByVendor[rel] = docRel migrated = append(migrated, rel) } @@ -2619,8 +3020,7 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { writeplan.PlannedWrite{Rel: adoptIndexRel, Content: ""}), wontModify, toConvert), adoptIndexRel) draft := false for _, rel := range wontModify { - t, _ := fsx.ReadText(filepath.Join(root, rel)) - if !strings.Contains(t, bootRel) { + if !strings.Contains(vendor[rel], bootRel) { draft = true break } @@ -2643,18 +3043,15 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { // The tracked-file preflight and the `.leji/` ignore run BEFORE any write at // all, so the private onboarding workspace can never land in git and a failed // preflight leaves the tree untouched. - if err := assertLejiWorkspacePrivate(root, r); err != nil { + if err := assertLejiWorkspacePrivate(root); err != nil { return AdoptResult{}, err } if err := ensureLejiGitignored(root); err != nil { return AdoptResult{}, err } - if !fsx.ResolvesUnder(root, filepath.Join(root, "leji.json")) { - return AdoptResult{}, fmt.Errorf("refusing to write through a symlink that escapes the target: %q", "leji.json") - } // O_EXCL: close the check-then-write race and refuse to follow a planted // symlink at the final component. - if err := writeManifestExclusive(filepath.Join(root, "leji.json"), manifestBytes, "adopt"); err != nil { + if err := writeManifestExclusive(root, filepath.Join(root, "leji.json"), manifestBytes, "adopt"); err != nil { return AdoptResult{}, err } written = append(written, "leji.json") @@ -2674,13 +3071,8 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { if rerr != nil { return AdoptResult{}, rerr } - if !fsx.ResolvesUnder(root, abs) { - return AdoptResult{}, fmt.Errorf("refusing to write through a symlink that escapes the target: %q", w.Rel) - } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return AdoptResult{}, err - } - if err := os.WriteFile(abs, []byte(w.Content), 0o644); err != nil { + verdict, werr := fsx.WriteFileGuarded(fsx.GuardRoot(root), abs, initRole(w.Rel), []byte(w.Content), fsx.WriteOptions{}) + if err := guardedOrRefuse(w.Rel, verdict, werr); err != nil { return AdoptResult{}, err } written = append(written, w.Rel) @@ -2716,7 +3108,8 @@ func AdoptLayer(opts AdoptOptions) (AdoptResult, error) { // governance/: the first free imported-.md, or "" when this exact migration // doc is already on disk — the normal case, AdoptLayer having archived it on the // first pass. Mirrors the slug and disambiguation rules AdoptLayer uses. -func archivePath(root, rootPath, vendorRel, doc string) string { +func archivePath(root, rootPath, vendorRel, doc string) (string, error) { + rootReal := fsx.GuardRoot(root) base := importedSlug(vendorRel) for n := 1; ; n++ { slug := base @@ -2725,13 +3118,25 @@ func archivePath(root, rootPath, vendorRel, doc string) string { } rel := fsx.JoinUnderRoot(rootPath, "governance/") + "imported-" + slug + ".md" abs := filepath.Join(root, fsx.StripSlash(rel)) - if !fsx.Exists(abs) { - return rel + // The candidate is judged on the standing entry and, when one stands, on its + // verified bytes: a pathname existence check follows symlinks, so a dangling + // candidate link would read as free and the write would follow it to its missing + // destination. Nothing standing is free; the identical archive is already on + // disk; anything else — different bytes, or a standing entry this run cannot + // verify — is occupied, and the next name is tried. + free, ferr := nothingStandsAt(abs) + if ferr != nil { + return "", ferr + } + if free { + return rel, nil + } + standing, err := fsx.VerifiedTargetRead(rootReal, abs, "") + if err != nil { + return "", err } - if fsx.IsFile(abs) { - if existing, err := fsx.ReadText(abs); err == nil && existing == doc { - return "" - } + if standing.Status == fsx.ReadRegular && string(standing.Bytes) == doc { + return "", nil } } } @@ -2756,18 +3161,14 @@ func wireAdaptersIntoLayer(root string, opts AdoptOptions) (AdoptResult, error) r := m.RootPath bootRel := m.BootProfilePath redirect := detect.AdapterContent(bootRel) - var vendorPresent []string - for _, rel := range validate.KnownVendorFiles { - abs := filepath.Join(root, rel) - // A vendor file that symlinks outside root is treated as absent, as in AdoptLayer. - if fsx.IsFile(abs) && fsx.ResolvesUnder(root, abs) { - vendorPresent = append(vendorPresent, rel) - } + vendor, err := verifiedVendorFiles(root) + if err != nil { + return AdoptResult{}, err } + vendorPresent := vendorRels(vendor) var toConvert []string for _, rel := range vendorPresent { - t, _ := fsx.ReadText(filepath.Join(root, rel)) - if strings.TrimSpace(t) != strings.TrimSpace(redirect) { + if strings.TrimSpace(vendor[rel]) != strings.TrimSpace(redirect) { toConvert = append(toConvert, rel) } } @@ -2777,12 +3178,15 @@ func wireAdaptersIntoLayer(root string, opts AdoptOptions) (AdoptResult, error) var writes []writeplan.PlannedWrite var archived []string for _, rel := range toConvert { - content, _ := fsx.ReadText(filepath.Join(root, rel)) + content := vendor[rel] if strings.TrimSpace(content) == "" { continue } doc := migrationDoc(rel, content) - docRel := archivePath(root, r, rel, doc) + docRel, aerr := archivePath(root, r, rel, doc) + if aerr != nil { + return AdoptResult{}, aerr + } if docRel == "" { continue } @@ -2817,18 +3221,14 @@ func wireAdaptersIntoLayer(root string, opts AdoptOptions) (AdoptResult, error) } var written []string + rootReal := fsx.GuardRoot(root) for _, w := range writes { abs, rerr := resolveUnderRoot(root, w.Rel) if rerr != nil { return AdoptResult{}, rerr } - if !fsx.ResolvesUnder(root, abs) { - return AdoptResult{}, fmt.Errorf("refusing to write through a symlink that escapes the target: %q", w.Rel) - } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return AdoptResult{}, err - } - if err := os.WriteFile(abs, []byte(w.Content), 0o644); err != nil { + verdict, werr := fsx.WriteFileGuarded(rootReal, abs, initRole(w.Rel), []byte(w.Content), fsx.WriteOptions{}) + if err := guardedOrRefuse(w.Rel, verdict, werr); err != nil { return AdoptResult{}, err } written = append(written, w.Rel) diff --git a/packages/sdk-go/internal/commands/init/init_more_test.go b/packages/sdk-go/internal/commands/init/init_more_test.go index fde4ace..b60349f 100644 --- a/packages/sdk-go/internal/commands/init/init_more_test.go +++ b/packages/sdk-go/internal/commands/init/init_more_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" - "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" "github.com/leji-org/leji/packages/sdk-go/internal/findings" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" ) @@ -173,7 +172,7 @@ func TestIndexedInitNoMachineKeyButFilesAtDefaults(t *testing.T) { gitInit(t, dir) gitCommitAll(t, dir) - validation := validate.ValidateLayer(dir, false) + validation := validateLayer(t, dir, false) for _, f := range validation.Findings { if f.Severity == findings.Error { t.Fatalf("indexed init should validate without errors, got %v", validation.Findings) diff --git a/packages/sdk-go/internal/commands/init/onboarding_test.go b/packages/sdk-go/internal/commands/init/onboarding_test.go index ca4a046..b3c616a 100644 --- a/packages/sdk-go/internal/commands/init/onboarding_test.go +++ b/packages/sdk-go/internal/commands/init/onboarding_test.go @@ -11,7 +11,6 @@ import ( "testing" "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" - "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" "github.com/leji-org/leji/packages/sdk-go/internal/findings" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/writeplan" @@ -47,7 +46,7 @@ func TestInitDryRunWritesNothing(t *testing.T) { if !contains(creates, "leji.json") { t.Fatalf("plan should create leji.json, got %v", creates) } - if !contains(creates, "docs/.leji/onboarding-brief.md") { + if !contains(creates, ".leji/work/onboarding-brief.md") { t.Fatalf("plan should create the brief, got %v", creates) } var vendor *writeplan.PlanEntry @@ -67,7 +66,7 @@ func TestInitWritesBriefExcludedFromIndex(t *testing.T) { if _, err := InitLayer(Options{Dir: dir, Yes: true, Level: "indexed", Name: "acme-context"}); err != nil { t.Fatalf("init: %v", err) } - brief := filepath.Join(dir, "docs", ".leji", "onboarding-brief.md") + brief := filepath.Join(dir, ".leji", "work", "onboarding-brief.md") if _, err := os.Stat(brief); err != nil { t.Fatalf("brief not written: %v", err) } @@ -92,7 +91,7 @@ func TestValidateContentWarnsOnFreshScaffold(t *testing.T) { if _, err := InitLayer(Options{Dir: dir, Yes: true}); err != nil { t.Fatalf("init: %v", err) } - res := validate.ValidateLayer(dir, true) + res := validateLayer(t, dir, true) rules := map[string]bool{} errors := 0 for _, f := range res.Findings { @@ -117,7 +116,7 @@ func TestValidateWithoutContentNoContentFindings(t *testing.T) { if _, err := InitLayer(Options{Dir: dir, Yes: true}); err != nil { t.Fatalf("init: %v", err) } - res := validate.ValidateLayer(dir, false) + res := validateLayer(t, dir, false) for _, f := range res.Findings { if strings.HasPrefix(f.Rule, "content-") { t.Fatalf("unexpected content finding without --content: %s", f.Rule) @@ -158,7 +157,7 @@ func TestPopulatedLayerPassesContentLint(t *testing.T) { mustWrite(t, filepath.Join(dir, "docs", "system", "invariants.md"), "---\nsummary: rules\n---\n\n# System Invariants\n\n- Money is integer minor units.\n- Invoices are immutable once sent.\n- The ledger is the source of truth.\n") - res := validate.ValidateLayer(dir, true) + res := validateLayer(t, dir, true) for _, f := range res.Findings { if strings.HasPrefix(f.Rule, "content-") { var got []string @@ -198,7 +197,7 @@ func TestInitAgentWiresRedirect(t *testing.T) { t.Fatalf("git init: %v", err) } } - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) errCount := 0 for _, f := range v.Findings { if f.Severity == findings.Error { @@ -283,7 +282,7 @@ func TestInitWritesPortableAgentsPointer(t *testing.T) { t.Fatalf("vendorAdapters should be empty, got %v", load.Manifest.VendorAdapters) } gitInit(t, dir) - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) errCount := 0 for _, f := range v.Findings { if f.Severity == findings.Error { @@ -369,7 +368,7 @@ func TestInitAgentCreatesNoVendorAdapter(t *testing.T) { if len(load.Manifest.VendorAdapters) != 0 { t.Fatalf("vendorAdapters should be empty, got %v", load.Manifest.VendorAdapters) } - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) errCount := 0 for _, f := range v.Findings { if f.Severity == findings.Error { @@ -455,7 +454,7 @@ func TestAgentWiresNamedReviewer(t *testing.T) { if !strings.Contains(reviewer, "\nid: reviewer\n") || !strings.Contains(reviewer, "\nrole: reviewer\n") || !strings.Contains(reviewer, "\nhost: codex\n") { t.Fatalf("reviewer profile missing id/role/host:\n%s", reviewer) } - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) errCount := 0 for _, f := range v.Findings { if f.Severity == findings.Error { @@ -574,7 +573,7 @@ func TestAgentAppendsSecondBinding(t *testing.T) { if !strings.Contains(string(body), "\nrole: advisor\n") { t.Fatalf("thought-partner profile missing role: advisor:\n%s", body) } - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) for _, f := range v.Findings { if f.Severity == findings.Error { t.Fatalf("expected no errors: %+v", v.Findings) @@ -843,7 +842,7 @@ func TestSoloBriefModeStampedWithArtifactRules(t *testing.T) { if _, err := InitLayer(Options{Dir: dir, Yes: true, Mode: "solo"}); err != nil { t.Fatalf("init --mode solo: %v", err) } - b, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "onboarding-brief.md")) + b, err := os.ReadFile(filepath.Join(dir, ".leji", "work", "onboarding-brief.md")) if err != nil { t.Fatal(err) } @@ -851,8 +850,8 @@ func TestSoloBriefModeStampedWithArtifactRules(t *testing.T) { if !strings.Contains(brief, "**Working mode:** solo") { t.Fatal("brief missing the solo mode stamp") } - if !strings.Contains(brief, "docs/.leji/onboarding-inputs/") { - t.Fatal("drop-folder path not rewritten for the root") + if !strings.Contains(brief, ".leji/work/onboarding-inputs/") { + t.Fatal("drop folder should sit in the workspace role") } if !strings.Contains(brief, "untrusted data") { t.Fatal("artifact consent rules missing") @@ -906,7 +905,7 @@ func TestOmittedModeAndExplicitTeamIdentical(t *testing.T) { if _, serr := os.Stat(filepath.Join(a, "docs", "domain", "identity.md")); !os.IsNotExist(serr) { t.Fatalf("team scaffolds no identity starter, stat err: %v", serr) } - brief, err := os.ReadFile(filepath.Join(a, "docs", ".leji", "onboarding-brief.md")) + brief, err := os.ReadFile(filepath.Join(a, ".leji", "work", "onboarding-brief.md")) if err != nil { t.Fatal(err) } @@ -1091,11 +1090,11 @@ func TestInitRefusesTrackedLejiWorkspace(t *testing.T) { } dir := t.TempDir() gitInit(t, dir) - mustWrite(t, filepath.Join(dir, "docs", ".leji", "stale.md"), "tracked artifact\n") + mustWrite(t, filepath.Join(dir, ".leji", "work", "stale.md"), "tracked artifact\n") gitCommitAll(t, dir) _, err := InitLayer(Options{Dir: dir, Yes: true, Mode: "solo"}) - if err == nil || !strings.Contains(err.Error(), "1 file(s) under docs/.leji/ are tracked by git") { + if err == nil || !strings.Contains(err.Error(), "1 file(s) under .leji/ are tracked by git") { t.Fatalf("expected the tracked-workspace refusal, got: %v", err) } if _, serr := os.Stat(filepath.Join(dir, "leji.json")); serr == nil { diff --git a/packages/sdk-go/internal/commands/init/preflight.go b/packages/sdk-go/internal/commands/init/preflight.go new file mode 100644 index 0000000..73caaf6 --- /dev/null +++ b/packages/sdk-go/internal/commands/init/preflight.go @@ -0,0 +1,836 @@ +package initcmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + + "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" + "github.com/leji-org/leji/packages/sdk-go/internal/fsx" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// `leji start`'s preflight: what a person who just cloned an adopted repository has +// to fix before the layer's tooling actually works here, computed READ-ONLY and +// reported as a fixed list of rows. Transcribes packages/sdk/src/commands/preflight.ts. +// +// The distinction the whole report turns on is who owns each gap. A gap in state that +// lives in this clone or in this user's own configuration is PERSONAL: it is offered, +// on a real terminal, and otherwise printed as an exact command. A gap in state the +// repository commits is SHARED: it is reported with the maintainer's command and +// never repaired here, because that write would land in files the whole team owns. +// Nothing here blocks entry either way: the agent still boots, and --json's `ready` +// is the scriptable signal. + +// Check ids, in the fixed order every report and every SDK prints them. +const ( + CheckCLI = "cli" + CheckMCP = "mcp" + CheckMCPShared = "mcp-shared" + CheckHook = "hook" +) + +// Check is what one check found. "ok" needs nothing; "missing" is personal (offered +// here, or printed); "shared-gap" is the repository's own state, for a maintainer; +// "skipped" means the check does not apply to this machine; "n/a" means it does not +// apply to this host; "unresolved" means the run could not tell which host to answer +// for. +type Check struct { + ID string + Status string + Detail string + // Fix is the exact commands that close this gap, or nil when there is nothing + // to run. + Fix []string + // fixKind is how the fix prints: a command line takes the "$ " prompt, a snippet + // is pasted as it stands (a config block, a hook body). Unexported, so it can + // never reach the --json document, which publishes exactly four keys. + fixKind string +} + +// How a fix prints. Every check built here sets one; the zero value renders as a +// command, which is what a Check assembled outside this file gets. +const ( + fixKindCommand = "command" + fixKindSnippet = "snippet" +) + +// PreflightResult is the whole read-only answer. +type PreflightResult struct { + // Ready is true when every check whose id is cli, mcp or hook is ok, skipped, or + // not applicable. The shared MCP row is project hygiene and never counts against it. + Ready bool + Checks []Check + // Hook is the resolved hook target, so the consent step acts on what the report saw. + Hook HookReport +} + +// --- the text table ------------------------------------------------------- +// Every string the Setup block prints lives here once, so the three SDKs transcribe +// one table rather than re-deriving prose. + +// The block's fixed geometry: , so +// every detail starts at the same column and the status word is the first thing read. +// A fix line is indented under the SUBJECT column, a half indent that reads as +// "belongs to the row above" and keeps long commands inside 80 columns. +const ( + preflightMargin = " " + preflightGutter = " " + preflightStatusWidth = 4 + preflightSubjectWidth = 10 + preflightFixIndent = " " +) + +// minSDKForSpecLine is the lowest SDK version that shipped support for a spec line. +// A layer declares exactly one version expectation, its spec line; this is what that +// expectation means for the CLI resolved here. The comparison is on the major, which +// is where a line's support is added or dropped. +var minSDKForSpecLine = map[string]string{"1.0": "1.0.0"} + +// startStatusLabel is the word that names WHO owns the row. The status values stay +// the contract; these labels are what a person reads, and several statuses share one. +var startStatusLabel = map[string]string{ + "ok": "ok", + "missing": "you", + "shared-gap": "team", + "skipped": "n/a", + "n/a": "n/a", + "unresolved": "you", +} + +var startSubject = map[string]string{ + CheckCLI: "Leji CLI", + CheckMCP: "MCP server", + CheckMCPShared: "Team MCP", + CheckHook: "Git hook", +} + +// Every detail is one short clause. Where a template carries a path, the path is its +// LAST token: a row that overflows overflows into the path, never through the prose, +// and nothing here is ever clipped (a truncated path misleads). +const ( + startHeading = "Setup for this clone" + + textCLIUndeclared = "not declared in this repository" + textMCPNone = "no coding agent detected" + textMCPSharedOther = "none for this host" + textMCPSharedNoHost = "no host selected" + textHookNoGit = "not a git repository" + textHookAbsentPersonal = "none yet (per clone)" + textSummaryComplete = "Setup complete." + textOfferHook = "Install the pre-commit hook for this clone (validate + index --check)?" + textOfferHookFailed = "The hook could not be written here; add it yourself:" + textOfferPrompt = "Y/n" + startPreflightAgentFixLine = "leji start --agent " +) + +func textCLIOk(version, runner string) string { + return version + " (" + runner + ")" +} + +func textCLIBelowMinimum(version, runner, minimum, line string) string { + return version + " (" + runner + ") is below " + minimum + " for spec " + line +} + +func textCLIUnresolvable(runner string) string { + return runner + " reported no version here" +} + +func textCLIUnresolvableNoInstall(runner string) string { + return runner + " reported no version; run this repo's install" +} + +func textCLINotInstalled(bin string) string { + return "not installed yet (" + bin + ")" +} + +func textCLIVerify(runner string) string { + return runner + " --version" +} + +func textCLIUndeclaredAmbient(version string) string { + return "not declared here (PATH has your own " + version + ")" +} + +func textMCPRegistered(host string) string { return "registered for " + host } +func textMCPMissing(host string) string { return "not registered for " + host } + +func textMCPManual(host string) string { + return "not registered for " + host + "; add it yourself:" +} + +// textMCPManualPath is the first line of that snippet: where the block goes, and at +// which scope. +func textMCPManualPath(config, scope string) string { + return config + " (" + scope + " scope)" +} + +func textMCPUnresolved(hosts []string) string { + return "pick one: " + strings.Join(hosts, ", ") +} + +func textMCPSharedPresent(file string) string { + return file + " committed" +} + +func textMCPSharedAbsent(file string) string { + return "no " + file + " committed" +} + +func textHookCurrent(target string) string { + return "runs leji checks before each commit: " + target +} + +func textHookAbsentShared(target string) string { + return "no leji block in " + target +} + +func textHookForeign(target string) string { + return "not leji-managed; add the block to " + target +} + +func textHookOutsideRoot(target string) string { + return "hooks dir is outside this worktree: " + target +} + +func textHookExternal(target string) string { + return "add it yourself; hooks run from " + target +} + +// The closing line: who owes how many fixes, and that neither answer blocks entry. +func textSummaryFixes(n int) string { + if n == 1 { + return "1 fix" + } + return strconv.Itoa(n) + " fixes" +} + +func textSummaryYou(fixes string) string { + return fixes + " for you. The agent starts either way." +} + +func textSummaryTeam(fixes string) string { + return fixes + " for a maintainer. The agent starts either way." +} + +func textSummaryBoth(fixes string, team int) string { + return fixes + " for you, " + strconv.Itoa(team) + " for a maintainer. The agent starts either way." +} + +func textOfferMcp(host string) string { + return "Register the Leji MCP server for " + host + " for your user?" +} + +func textOfferMcpDone(host string) string { + return "Registered the Leji MCP server for " + host + "." +} + +func textOfferMcpFailed(bin string) string { + return bin + " did not register cleanly; run it yourself:" +} + +func textOfferHookDone(target string) string { + return "Wrote " + target + "; it runs before every commit in this clone." +} + +// --- the version probe ---------------------------------------------------- + +// How long a probe may take, and how much of its output is read. A probe that +// exceeds either bound fails closed, exactly like one that never started. +const ( + probeTimeoutMs = 10000 + probeMaxBytes = 4096 +) + +// nodeBinRel is the one path a probe may execute directly: the bin shim a Node +// package manager installs for the declared dependency. It is a file this +// repository's own install put there, not a script the repository authors, which is +// the whole reason it is safe to run when npm/pnpm/yarn/bun are not. +const nodeBinRel = "node_modules/.bin/leji" + +// nodeManagers are the Node managers whose declared CLI arrives as that shim. +var nodeManagers = map[string]bool{"npm": true, "pnpm": true, "yarn": true, "bun": true} + +// managerProbeArgv is what the probe runs for a manager whose CLI is a +// console-script entry of the declared dependency rather than a file in the +// repository. Each carries the flag that keeps the manager from installing, +// syncing, or fetching anything, and none of them runs a script the repository +// declares. +var managerProbeArgv = map[string][]string{ + "uv": {"uv", "run", "--no-sync", "leji"}, + "poetry": {"poetry", "run", "leji"}, + "pdm": {"pdm", "run", "leji"}, + "pipenv": {"pipenv", "run", "leji"}, + "go": {"go", "tool", "leji"}, +} + +// goProbeEnv is the environment the Go probe forces: a read-only module graph, no +// toolchain download, no module proxy, and no workspace file redirecting the build. +var goProbeEnv = map[string]string{ + "GOFLAGS": "-mod=readonly", + "GOTOOLCHAIN": "local", + "GOPROXY": "off", + "GOWORK": "off", +} + +// probePlatformEnv are the variables the probe passes through whatever it runs. +// Everything else in the caller's environment is dropped: a probe is not the user's +// shell, and an inherited NODE_OPTIONS, npm_config_*, or LD_PRELOAD is exactly the +// kind of thing that turns "ask for a version" into "run something else". +var probePlatformEnv = []string{"SystemRoot", "SYSTEMROOT", "COMSPEC", "PATHEXT", "TEMP", "TMP", "WINDIR"} + +// probeManagerEnv is the per-manager configuration the probe keeps, because without +// it the manager cannot find the environment it is being asked about. Nothing beyond +// this is inherited. +var probeManagerEnv = map[string][]string{ + "uv": {"UV_CACHE_DIR", "UV_PROJECT_ENVIRONMENT", "VIRTUAL_ENV"}, + "poetry": {"POETRY_HOME", "POETRY_VIRTUALENVS_PATH", "POETRY_CACHE_DIR", "VIRTUAL_ENV"}, + "pdm": {"PDM_HOME", "PDM_CACHE_DIR", "VIRTUAL_ENV"}, + "pipenv": {"PIPENV_VENV_IN_PROJECT", "WORKON_HOME", "VIRTUAL_ENV"}, + "go": {"GOPATH", "GOMODCACHE", "GOCACHE", "GOBIN"}, +} + +func passThrough(names []string, into map[string]string) { + for _, name := range names { + if v, ok := os.LookupEnv(name); ok { + into[name] = v + } + } +} + +// spawnedProbeEnv is the environment for a probe that has to find a program on the +// caller's PATH (a package manager, or the ambient `leji`): PATH and HOME survive +// because the manager cannot answer without them, plus the manager's own named +// configuration. Nothing else does. +func spawnedProbeEnv(manager string) map[string]string { + env := map[string]string{} + passThrough([]string{"PATH", "Path", "HOME"}, env) + passThrough(probePlatformEnv, env) + if manager != "" { + passThrough(probeManagerEnv[manager], env) + } + if manager == "go" { + for k, v := range goProbeEnv { + env[k] = v + } + } + return env +} + +// directProbeEnv is the environment for the direct execution of the repository's own +// bin shim: nothing of the caller's is inherited at all. PATH holds only the +// directory of the node binary the shim's interpreter line resolves, and HOME points +// at a temporary directory so no user configuration is read. +func directProbeEnv() map[string]string { + env := map[string]string{"PATH": nodeBinDir(), "HOME": os.TempDir()} + passThrough(probePlatformEnv, env) + return env +} + +// nodeBinDir is where a `node` the shim can use lives, resolved on the caller's PATH +// once. Empty when there is none: the shim then fails to start, the probe fails +// closed, and the row says the CLI could not be run here. +func nodeBinDir() string { + p, err := exec.LookPath("node") + if err != nil { + return "" + } + return filepath.Dir(p) +} + +// versionRe is a bare .. with an optional prerelease or build +// tail, which is what every `leji --version` prints. Anything else is not a version +// this probe will believe. +var versionRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)([-+][0-9A-Za-z.-]+)?$`) + +type foundVersion struct { + text string + major int +} + +func parseVersion(stdout string) *foundVersion { + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + m := versionRe.FindStringSubmatch(line) + if m == nil { + return nil + } + major, err := strconv.Atoi(m[1]) + if err != nil { + return nil + } + return &foundVersion{text: line, major: major} + } + return nil +} + +// probePlan is how this repository's declared CLI would be asked for its version. +// "direct" is the installed Node shim, executed as a file; "spawned" is a manager or +// the ambient binary, found on the caller's PATH; "absent" is a Node repository whose +// install has not produced the shim (not installed, or a Yarn PnP tree that has no +// bin directory) — reported, never worked around by asking a package manager to run a +// script. +type probePlan struct { + kind string // "direct" | "spawned" | "absent" + bin string + args []string + env map[string]string +} + +// binCandidates are the names a Node bin shim can take, strongest first. Windows +// installs a .cmd wrapper beside (or instead of) the extensionless shim. +func binCandidates() []string { + if runtime.GOOS == "windows" { + return []string{"leji.cmd", "leji.exe", "leji"} + } + return []string{"leji"} +} + +// installedNodeBin is the installed shim's absolute path, or "". Every condition is +// checked before the path is ever executed: a regular file after symlinks are +// followed (npm installs the shim AS a symlink, so links are expected), resolving +// inside the real repository root, and executable where the platform records that. +func installedNodeBin(root string) string { + for _, name := range binCandidates() { + abs := filepath.Join(root, "node_modules", ".bin", name) + if !fsx.ResolvedWithinRoot(root, abs) { + continue + } + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + continue + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + continue + } + return abs + } + return "" +} + +func planProbe(root string, report ecosystem.Report) probePlan { + selected := report.Selected + manager := "" + if selected != nil && selected.DirectDeclared && selected.Manager != nil { + manager = *selected.Manager + } + if manager != "" && nodeManagers[manager] { + bin := installedNodeBin(root) + if bin == "" { + return probePlan{kind: "absent"} + } + return probePlan{kind: "direct", bin: bin, env: directProbeEnv()} + } + if argv, ok := managerProbeArgv[manager]; ok && manager != "" { + return probePlan{kind: "spawned", bin: argv[0], args: append([]string(nil), argv[1:]...), env: spawnedProbeEnv(manager)} + } + // Undeclared, pip, and pre-1.24 Go all reach the CLI the same way a person does: + // whatever `leji` the PATH resolves, run with the same sanitized environment. + return probePlan{kind: "spawned", bin: "leji", env: spawnedProbeEnv("")} +} + +// probeVersion asks the CLI this repository would run for its version. Argv, never a +// shell; cwd pinned to the root; stdin closed; output and time bounded; a sanitized +// environment; and never a package manager's script runner. Every failure mode — a +// missing executable, a non-zero exit, a timeout, output that is not a version — comes +// back as nil, because a probe that cannot answer is not evidence that the CLI is there. +func probeVersion(root string, plan probePlan, hio *HandoffIO) *foundVersion { + if plan.kind == "absent" || hio == nil || hio.Run == nil { + return nil + } + args := append(append([]string{}, plan.args...), "--version") + res := hio.Run(plan.bin, args, root, RunOptions{ + Quiet: true, + Capture: true, + TimeoutMs: probeTimeoutMs, + MaxBytes: probeMaxBytes, + Env: plan.env, + }) + if !res.Started || res.Err != nil { + return nil + } + return parseVersion(res.Stdout) +} + +// --- the checks ----------------------------------------------------------- + +func newCheck(id, status, detail string, fix []string) Check { + return Check{ID: id, Status: status, Detail: detail, Fix: fix, fixKind: fixKindCommand} +} + +// newSnippetCheck is the same row whose fix is pasted rather than run: the host's MCP +// config block, and the hook body leji must not write itself. +func newSnippetCheck(id, status, detail string, fix []string) Check { + return Check{ID: id, Status: status, Detail: detail, Fix: fix, fixKind: fixKindSnippet} +} + +func argvLine(argv []string) string { return strings.Join(argv, " ") } + +func cliRow(root string, m *manifest.Manifest, report ecosystem.Report, hio *HandoffIO) Check { + selected := report.Selected + runner := ecosystem.RunnerArgv(report) + specLine := m.Leji + minimum, hasMinimum := minSDKForSpecLine[specLine] + plan := planProbe(root, report) + + if selected == nil || !selected.DirectDeclared { + // The gap is the repository's declaration, which is a committed file: report it + // with the maintainer's command whatever this machine happens to have. The plain + // `leji` is still probed, so an ambient install is named as what it is. + found := probeVersion(root, plan, hio) + var fix []string + if selected != nil && selected.Add != nil { + fix = []string{argvLine(selected.Add)} + } + detail := textCLIUndeclared + if found != nil { + detail = textCLIUndeclaredAmbient(found.text) + } + return newCheck(CheckCLI, "shared-gap", detail, fix) + } + manager := "" + if selected.Manager != nil { + manager = *selected.Manager + } + found := probeVersion(root, plan, hio) + // The row names what actually answered: the installed shim for a Node repository, + // and the manager's own runner everywhere else. + shown := argvLine(runner) + nodeShim := plan.kind == "direct" || plan.kind == "absent" + if nodeShim { + shown = nodeBinRel + } + var fix []string + if argv := ecosystem.ManagerInstallArgv(manager); argv != nil { + fix = []string{argvLine(argv)} + // A Node repository whose shim is absent gets the install command AND the way + // to confirm it worked, because leji will not run a package manager to find out. + if nodeShim { + fix = append(fix, textCLIVerify(argvLine(runner))) + } + } + if plan.kind == "absent" { + return newCheck(CheckCLI, "missing", textCLINotInstalled(nodeBinRel), fix) + } + if found == nil { + // A manager with no single install command (pip, pre-1.24 Go) has no argv to + // print, so the row itself has to carry the instruction. + detail := textCLIUnresolvable(shown) + if fix == nil { + detail = textCLIUnresolvableNoInstall(shown) + } + return newCheck(CheckCLI, "missing", detail, fix) + } + if hasMinimum { + if minMajor, err := strconv.Atoi(strings.SplitN(minimum, ".", 2)[0]); err == nil && found.major < minMajor { + return newCheck(CheckCLI, "missing", textCLIBelowMinimum(found.text, shown, minimum, specLine), fix) + } + } + return newCheck(CheckCLI, "ok", textCLIOk(found.text, shown), nil) +} + +// personalMcpAdd is the argv that registers the server for THIS USER on a host, or +// nil when the host has no registration command at all. +func personalMcpAdd(hostID string) (string, []string) { + spec := detect.SpecByID(hostID) + if spec == nil { + return "", nil + } + argv := spec.McpAddUser + if argv == nil { + argv = spec.McpAdd + } + if argv == nil { + return "", nil + } + return spec.Bins[0], argv +} + +func mcpRow(root string, host *StartHost, detected []detect.DetectedHost, hio *HandoffIO) Check { + if host == nil { + launchable := StartHosts(detected) + if len(launchable) > 1 { + names := make([]string, 0, len(launchable)) + for _, h := range launchable { + names = append(names, h.Name) + } + return newCheck(CheckMCP, "unresolved", textMCPUnresolved(names), []string{startPreflightAgentFixLine}) + } + // A host Leji cannot register for is still worth a row: the person can add the + // standard configuration by hand, which is the only fix that exists for it. + for _, h := range detected { + spec := detect.SpecByID(h.ID) + if spec == nil || spec.McpConfig == nil { + continue + } + fix := append( + []string{textMCPManualPath(spec.McpConfig.Path, spec.McpConfig.Scope)}, + strings.Split(detect.McpJSONConfig(spec.McpConfig.Shape), "\n")..., + ) + return newSnippetCheck(CheckMCP, "missing", textMCPManual(h.Name), fix) + } + return newCheck(CheckMCP, "skipped", textMCPNone, nil) + } + spec := detect.SpecByID(host.ID) + if spec != nil && len(spec.McpCheck) > 0 && hio != nil && hio.Run != nil { + res := hio.Run(host.Bin, spec.McpCheck, root, RunOptions{Quiet: true}) + if res.Started && res.Err == nil { + return newCheck(CheckMCP, "ok", textMCPRegistered(host.Name), nil) + } + } + bin, argv := personalMcpAdd(host.ID) + var fix []string + if argv != nil { + fix = []string{bin + " " + argvLine(argv)} + } + return newCheck(CheckMCP, "missing", textMCPMissing(host.Name), fix) +} + +// committedFile reports whether a regular file stands at rel directly inside the +// repository root. +func committedFile(root, rel string) bool { + abs := filepath.Join(root, rel) + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + return false + } + return fsx.ResolvedWithinRoot(root, abs) +} + +func mcpSharedRow(root string, host *StartHost) Check { + if host == nil { + return newCheck(CheckMCPShared, "n/a", textMCPSharedNoHost, nil) + } + spec := detect.SpecByID(host.ID) + if spec == nil || spec.McpSharedFile == "" || spec.McpAdd == nil { + return newCheck(CheckMCPShared, "n/a", textMCPSharedOther, nil) + } + if committedFile(root, spec.McpSharedFile) { + return newCheck(CheckMCPShared, "ok", textMCPSharedPresent(spec.McpSharedFile), nil) + } + return newCheck(CheckMCPShared, "shared-gap", textMCPSharedAbsent(spec.McpSharedFile), + []string{detect.McpCommand(spec, spec.McpAdd)}) +} + +var hookFix = []string{"leji ci --hooks"} + +func hookRow(status HookReport) Check { + if status.Ownership == "no-git" { + return newCheck(CheckHook, "missing", textHookNoGit, nil) + } + if status.State == "current" { + return newCheck(CheckHook, "ok", textHookCurrent(status.Path), nil) + } + if status.Ownership == "personal" { + if status.State == "absent" { + return newCheck(CheckHook, "missing", textHookAbsentPersonal, hookFix) + } + return newSnippetCheck(CheckHook, "missing", textHookForeign(status.Path), strings.Split(status.Snippet, "\n")) + } + if status.Ownership == "shared" { + return newCheck(CheckHook, "shared-gap", textHookAbsentShared(status.Path), hookFix) + } + if status.Ownership == "outside-root" { + // A linked worktree's hooks live in the common git directory, outside this + // working tree. It is still per-clone state, but the writer refuses anything + // outside the repository root, so the only honest answer is the snippet. + return newSnippetCheck(CheckHook, "missing", textHookOutsideRoot(status.Path), strings.Split(status.Snippet, "\n")) + } + // Outside the repository entirely: reported with the snippet, never written. + return newSnippetCheck(CheckHook, "missing", textHookExternal(status.Path), strings.Split(status.Snippet, "\n")) +} + +// --- the report ----------------------------------------------------------- + +// PreflightOptions configures RunPreflight. +type PreflightOptions struct { + // Root is the layer root; every check reads from it and nothing else. + Root string + Manifest *manifest.Manifest + // Host is the host `leji start` resolved for this run, or nil when none was selected. + Host *StartHost + Detected []detect.DetectedHost + Report ecosystem.Report +} + +var readyIDs = map[string]bool{CheckCLI: true, CheckMCP: true, CheckHook: true} +var readyStatuses = map[string]bool{"ok": true, "skipped": true, "n/a": true} + +// RunPreflight runs every check, in the fixed order, writing nothing. The only child +// processes are the bounded version probe and the host's own registration query, both +// through the injectable IO. +func RunPreflight(opts PreflightOptions, hio *HandoffIO) PreflightResult { + root, err := filepath.Abs(opts.Root) + if err != nil { + root = opts.Root + } + hook := HookStatus(root, ecosystem.RunnerArgv(opts.Report)) + checks := []Check{ + cliRow(root, opts.Manifest, opts.Report, hio), + mcpRow(root, opts.Host, opts.Detected, hio), + mcpSharedRow(root, opts.Host), + hookRow(hook), + } + ready := true + for _, c := range checks { + if readyIDs[c.ID] && !readyStatuses[c.Status] { + ready = false + } + } + return PreflightResult{Ready: ready, Checks: checks, Hook: hook} +} + +// startStatusColor is the escape each label wears when color is on. The word is +// styled; its padding is not, so the columns line up whether or not the escapes are +// there. +var startStatusColor = map[string]string{ + "ok": "\x1b[32m", + "you": "\x1b[33m", + "team": "\x1b[36m", + "n/a": "\x1b[2m", +} + +const startColorReset = "\x1b[0m" + +// ColorDecision reports whether the Setup block may color its status words: a real +// terminal that has not asked for plain text. NO_COLOR disables at any value, empty +// included, because the convention is presence. A pure function of the two things it +// reads (the env lookup is passed in, so a test needs no process), decided once at the +// CLI boundary and injected, so nothing downstream consults the process and every +// piped byte is escape-free by construction. The stdin prompt gate stays separate. +func ColorDecision(isTTY bool, env func(string) (string, bool)) bool { + if !isTTY { + return false + } + if _, ok := env("NO_COLOR"); ok { + return false + } + if term, _ := env("TERM"); term == "dumb" { + return false + } + return true +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} + +func summaryLine(you, team int) string { + switch { + case you > 0 && team > 0: + return textSummaryBoth(textSummaryFixes(you), team) + case you > 0: + return textSummaryYou(textSummaryFixes(you)) + case team > 0: + return textSummaryTeam(textSummaryFixes(team)) + } + return textSummaryComplete +} + +// RenderPreflight is the Setup block: a heading, one fixed-column row per check with +// its fixes under it, and one closing line counting what is owed. The counts come from +// the labels the rows already printed, so the block can never say something its own +// rows do not. +func RenderPreflight(checks []Check, color bool) string { + lines := []string{startHeading, ""} + you, team := 0, 0 + for _, c := range checks { + label := startStatusLabel[c.Status] + switch label { + case "you": + you++ + case "team": + team++ + } + word := label + if color { + word = startStatusColor[label] + label + startColorReset + } + status := word + strings.Repeat(" ", preflightStatusWidth-len(label)) + lines = append(lines, preflightMargin+status+preflightGutter+padRight(startSubject[c.ID], preflightSubjectWidth)+preflightGutter+c.Detail) + prompt := "$ " + if c.fixKind == fixKindSnippet { + prompt = "" + } + for _, fix := range c.Fix { + lines = append(lines, preflightFixIndent+prompt+fix) + } + } + lines = append(lines, "", preflightMargin+summaryLine(you, team)) + return strings.Join(lines, "\n") +} + +// --- the consented repairs ------------------------------------------------ + +// PreflightOfferOptions configures OfferPreflightFixes. +type PreflightOfferOptions struct { + Root string + Host *StartHost + Result PreflightResult + // Runner is the runner the hook would be written with, so the report and the + // write agree. + Runner []string + // Interactive is a real TTY and not --json; nothing is offered or written otherwise. + Interactive bool +} + +// OfferPreflightFixes offers the personal repairs the report found, in the order it +// printed them. Only state this user or this clone owns is ever offered: the host +// registration for this user, and the per-clone hook. A shared gap is never offered, +// because accepting it would write a file the repository commits. +func OfferPreflightFixes(opts PreflightOfferOptions, hio *HandoffIO, out io.Writer) { + if !opts.Interactive || hio == nil { + return + } + yes := func(question string) bool { + a := strings.ToLower(hio.ReadLine(question, textOfferPrompt)) + return a == "" || a == "y" || a == "yes" + } + byID := func(id string) *Check { + for i := range opts.Result.Checks { + if opts.Result.Checks[i].ID == id { + return &opts.Result.Checks[i] + } + } + return nil + } + + mcp := byID(CheckMCP) + if mcp != nil && mcp.Status == "missing" && opts.Host != nil && hio.Run != nil { + if bin, argv := personalMcpAdd(opts.Host.ID); argv != nil { + if yes(textOfferMcp(opts.Host.Name)) { + res := hio.Run(bin, argv, opts.Root, RunOptions{}) + if res.Started && res.Err == nil { + fmt.Fprintln(out, textOfferMcpDone(opts.Host.Name)) + } else { + fmt.Fprintln(out, textOfferMcpFailed(bin)) + fmt.Fprintln(out, preflightFixIndent+"$ "+bin+" "+argvLine(argv)) + } + } + } + } + + hook := opts.Result.Hook + if hook.Ownership == "personal" && hook.State == "absent" && yes(textOfferHook) { + written, err := EnsureLocalHook(opts.Root, opts.Runner) + if err != nil || written.Action == "manual" { + fmt.Fprintln(out, textOfferHookFailed) + fmt.Fprintf(out, "\n%s\n", written.Snippet) + } else { + fmt.Fprintln(out, textOfferHookDone(written.Path)) + } + } +} diff --git a/packages/sdk-go/internal/commands/init/preflight_test.go b/packages/sdk-go/internal/commands/init/preflight_test.go new file mode 100644 index 0000000..933ccda --- /dev/null +++ b/packages/sdk-go/internal/commands/init/preflight_test.go @@ -0,0 +1,991 @@ +package initcmd + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/leji-org/leji/packages/sdk-go/internal/detect" + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// Mirrors packages/sdk/test/preflight.test.ts: the report is read-only, every probe +// failure fails closed, and only per-clone or per-user state is ever offered. + +var preflightManifest = &manifest.Manifest{Leji: "1.0", RootPath: "docs/", BootProfilePath: "docs/boot-profile.md"} + +var ( + claudeStartHost = &StartHost{ID: "claude-code", Bin: "claude", Name: "Claude Code"} + codexStartHost = &StartHost{ID: "codex", Bin: "codex", Name: "Codex"} + copilotHost = detectedHost("copilot", "GitHub Copilot", true) +) + +// preflightGitLayer is a committed example layer in its own git repository: the shape +// every hook class is derived from. +func preflightGitLayer(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitRun := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } + } + gitRun("init", "-q") + if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "docs", "boot-profile.md"), []byte("# boot\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + gitRun("add", "-A") + gitRun("-c", "user.email=t@e.com", "-c", "user.name=T", "commit", "-qm", "seed") + return dir +} + +type recordedProbe struct { + bin string + args []string + cwd string + opts RunOptions +} + +// probeIO answers each Run in order (the last result repeats) and records every call, +// so a probe's argv, cwd and bounds can be asserted. +func probeIO(results []LaunchResult, answers ...string) (*HandoffIO, *[]recordedProbe, *[]string) { + runs := []recordedProbe{} + questions := []string{} + idx := 0 + answered := 0 + hio := &HandoffIO{ + ReadLine: func(q, _ string) string { + questions = append(questions, q) + if answered < len(answers) { + a := answers[answered] + if len(answers) > 1 { + answered++ + } + return a + } + return "" + }, + Run: func(bin string, args []string, cwd string, opts RunOptions) LaunchResult { + runs = append(runs, recordedProbe{bin: bin, args: args, cwd: cwd, opts: opts}) + res := LaunchResult{Started: true, Stdout: "1.4.0\n"} + if len(results) > 0 { + if idx < len(results) { + res = results[idx] + } else { + res = results[len(results)-1] + } + } + idx++ + return res + }, + } + return hio, &runs, &questions +} + +func okProbe() []LaunchResult { return []LaunchResult{{Started: true, Stdout: "1.4.0\n"}} } + +func runFor(t *testing.T, dir string, host *StartHost, detected []detect.DetectedHost, hio *HandoffIO) PreflightResult { + t.Helper() + return RunPreflight(PreflightOptions{ + Root: dir, Manifest: preflightManifest, Host: host, Detected: detected, Report: ecosystem.Detect(dir), + }, hio) +} + +func rowByID(t *testing.T, checks []Check, id string) Check { + t.Helper() + for _, c := range checks { + if c.ID == id { + return c + } + } + t.Fatalf("no %s check in %v", id, checks) + return Check{} +} + +func writeAt(t *testing.T, dir, rel, content string) { + t.Helper() + abs := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} + +// installNodeBin writes the bin shim a Node package manager's install puts in the +// tree. The probe executes this file directly, so every Node case that expects a +// version has to have it. +func installNodeBin(t *testing.T, dir string) string { + t.Helper() + binDir := filepath.Join(dir, "node_modules", ".bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + abs := filepath.Join(binDir, "leji") + if err := os.WriteFile(abs, []byte("#!/bin/sh\necho 1.4.0\n"), 0o755); err != nil { + t.Fatalf("write shim: %v", err) + } + return abs +} + +func gitConfigAt(t *testing.T, dir, key, value string) { + t.Helper() + cmd := exec.Command("git", "config", key, value) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config: %v (%s)", err, out) + } +} + +// --- hookStatus: one class per ownership ------------------------------------- + +func TestHookStatusOrdinaryCloneIsPersonalAndAbsent(t *testing.T) { + dir := preflightGitLayer(t) + s := HookStatus(dir, []string{"leji"}) + if s.Ownership != "personal" || s.State != "absent" || s.Path != ".git/hooks/pre-commit" || s.Managed != "file" { + t.Fatalf("status = %+v", s) + } +} + +func TestHookStatusManagedIsCurrentAndForeignIsForeign(t *testing.T) { + dir := preflightGitLayer(t) + if _, err := EnsureLocalHook(dir, []string{"leji"}); err != nil { + t.Fatalf("ensure: %v", err) + } + if s := HookStatus(dir, []string{"leji"}); s.State != "current" { + t.Fatalf("state = %q, want current", s.State) + } + writeAt(t, dir, ".git/hooks/pre-commit", "#!/bin/sh\necho mine\n") + s := HookStatus(dir, []string{"leji"}) + if s.State != "foreign" || s.Ownership != "personal" { + t.Fatalf("status = %+v, want foreign/personal", s) + } +} + +func TestHookStatusHuskyIsShared(t *testing.T) { + dir := preflightGitLayer(t) + gitConfigAt(t, dir, "core.hooksPath", ".husky/_") + s := HookStatus(dir, []string{"leji"}) + if s.Ownership != "shared" || s.State != "absent" || s.Path != ".husky/pre-commit" || s.Managed != "block" { + t.Fatalf("status = %+v", s) + } +} + +func TestHookStatusWorktreeHooksPathIsShared(t *testing.T) { + dir := preflightGitLayer(t) + gitConfigAt(t, dir, "core.hooksPath", "githooks") + s := HookStatus(dir, []string{"leji"}) + if s.Ownership != "shared" || s.Path != "githooks/pre-commit" { + t.Fatalf("status = %+v", s) + } +} + +func TestHookStatusGlobalHooksPathIsExternalAndReportOnly(t *testing.T) { + dir := preflightGitLayer(t) + outside := t.TempDir() + gitConfigAt(t, dir, "core.hooksPath", outside) + s := HookStatus(dir, []string{"leji"}) + if s.Ownership != "external" { + t.Fatalf("ownership = %q, want external", s.Ownership) + } + hio, _, _ := probeIO(okProbe()) + row := rowByID(t, runFor(t, dir, nil, nil, hio).Checks, CheckHook) + if row.Status != "missing" || !strings.Contains(strings.Join(row.Fix, "\n"), "leji pre-commit (managed)") { + t.Fatalf("row = %+v", row) + } + if _, err := os.Stat(filepath.Join(outside, "pre-commit")); !os.IsNotExist(err) { + t.Fatalf("the report wrote into the external hooks dir") + } +} + +func TestHookStatusLinkedWorktreeResolvesSharedHooksDirAsPersonal(t *testing.T) { + main := preflightGitLayer(t) + wt := filepath.Join(t.TempDir(), "wt") + cmd := exec.Command("git", "worktree", "add", "-q", wt) + cmd.Dir = main + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git worktree unavailable: %v (%s)", err, out) + } + s := HookStatus(wt, []string{"leji"}) + // The hooks git runs live in the COMMON dir, outside this worktree. It is still + // per-clone state, but the writer refuses everything outside the repository root, + // so it is reported rather than offered. + if s.Ownership != "outside-root" || s.State != "absent" { + t.Fatalf("status = %+v", s) + } + hio, _, questions := probeIO(okProbe(), "y") + res := runFor(t, wt, nil, nil, hio) + row := rowByID(t, res.Checks, CheckHook) + if row.Status != "missing" || !strings.Contains(row.Detail, "hooks dir is outside this worktree") || + !strings.Contains(strings.Join(row.Fix, "\n"), "leji pre-commit (managed)") { + t.Fatalf("row = %+v", row) + } + OfferPreflightFixes(PreflightOfferOptions{Root: wt, Host: nil, Result: res, Runner: []string{"leji"}, Interactive: true}, hio, &strings.Builder{}) + if len(*questions) != 0 { + t.Fatalf("a target outside the worktree is never offered: %v", *questions) + } + if _, err := os.Stat(filepath.Join(main, ".git", "hooks", "pre-commit")); !os.IsNotExist(err) { + t.Fatalf("a hook was written for a linked worktree") + } +} + +func TestHookStatusNonRepositoryIsNoGit(t *testing.T) { + s := HookStatus(t.TempDir(), []string{"leji"}) + if s.Ownership != "no-git" || s.Path != "" { + t.Fatalf("status = %+v", s) + } +} + +// --- the version probe -------------------------------------------------------- + +func TestCLIUndeclaredIsASharedGapCarryingTheDeclareCommand(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","packageManager":"pnpm@9.0.0"}`+"\n") + hio, _, _ := probeIO([]LaunchResult{{Started: false, Err: errors.New("spawn leji ENOENT")}}) + res := runFor(t, dir, nil, nil, hio) + row := rowByID(t, res.Checks, CheckCLI) + if row.Status != "shared-gap" || !reflect.DeepEqual(row.Fix, []string{"pnpm add -D @leji-org/leji"}) { + t.Fatalf("row = %+v", row) + } + if res.Ready { + t.Fatalf("a shared cli gap still leaves the clone unready") + } +} + +func TestCLIUndeclaredNamesAnAmbientLejiAsYourOwnInstall(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app"}`+"\n") + hio, _, _ := probeIO(okProbe()) + row := rowByID(t, runFor(t, dir, nil, nil, hio).Checks, CheckCLI) + if row.Detail != "not declared here (PATH has your own 1.4.0)" { + t.Fatalf("detail = %q", row.Detail) + } +} + +func TestCLIDeclaredNodeIsProbedByExecutingTheInstalledShim(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + writeAt(t, dir, "pnpm-lock.yaml", "lockfileVersion: 9\n") + binAbs := installNodeBin(t, dir) + hio, runs, _ := probeIO(okProbe()) + res := runFor(t, dir, nil, nil, hio) + row := rowByID(t, res.Checks, CheckCLI) + want := "1.4.0 (node_modules/.bin/leji)" + if row.Status != "ok" || row.Detail != want || row.Fix != nil { + t.Fatalf("row = %+v", row) + } + p := (*runs)[0] + wantCwd, _ := filepath.Abs(dir) + // The shim itself, by absolute path: no `pnpm exec`, no `npx`, no shell. + wantBin := filepath.Join(wantCwd, "node_modules", ".bin", "leji") + _ = binAbs + if p.bin != wantBin || !reflect.DeepEqual(p.args, []string{"--version"}) || p.cwd != wantCwd { + t.Fatalf("probe = %+v", p) + } + if !p.opts.Capture || !p.opts.Quiet || p.opts.TimeoutMs != 10000 || p.opts.MaxBytes != 4096 { + t.Fatalf("probe opts = %+v", p.opts) + } + // The environment REPLACES this process's: no inherited PATH, no HOME of the user's. + if p.opts.Env == nil { + t.Fatalf("the probe passes no environment") + } + if p.opts.Env["PATH"] != nodeBinDir() { + t.Fatalf("probe PATH = %q, want the node binary's dir %q", p.opts.Env["PATH"], nodeBinDir()) + } + if p.opts.Env["HOME"] == os.Getenv("HOME") { + t.Fatalf("the probe inherited HOME") + } + for _, leaked := range []string{"NODE_OPTIONS", "LD_PRELOAD", "GOPATH"} { + if _, ok := p.opts.Env[leaked]; ok { + t.Fatalf("%s must not reach the probe", leaked) + } + } + // The clone still has no hook, so one ok row is not readiness. + if res.Ready || rowByID(t, res.Checks, CheckHook).Status != "missing" { + t.Fatalf("ready = %v", res.Ready) + } +} + +func TestCLINodeWithoutTheInstalledShimIsMissingAndRunsNoManager(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + writeAt(t, dir, "package-lock.json", `{"lockfileVersion":3}`+"\n") + hio, runs, _ := probeIO(okProbe()) + row := rowByID(t, runFor(t, dir, nil, nil, hio).Checks, CheckCLI) + wantFix := []string{"npm install", "npx --no-install @leji-org/leji --version"} + if row.Status != "missing" || row.Detail != "not installed yet (node_modules/.bin/leji)" || + !reflect.DeepEqual(row.Fix, wantFix) { + t.Fatalf("row = %+v", row) + } + // Nothing was executed at all: a missing shim is answered from the filesystem. + if len(*runs) != 0 { + t.Fatalf("no probe should run when the shim is absent: %+v", *runs) + } +} + +func TestCLIShimResolvingOutsideTheRepositoryIsRefused(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + writeAt(t, dir, "package-lock.json", `{"lockfileVersion":3}`+"\n") + outside := t.TempDir() + target := filepath.Join(outside, "leji") + if err := os.WriteFile(target, []byte("#!/bin/sh\necho 9.9.9\n"), 0o755); err != nil { + t.Fatalf("write: %v", err) + } + binDir := filepath.Join(dir, "node_modules", ".bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Symlink(target, filepath.Join(binDir, "leji")); err != nil { + t.Fatalf("symlink: %v", err) + } + hio, runs, _ := probeIO(okProbe()) + row := rowByID(t, runFor(t, dir, nil, nil, hio).Checks, CheckCLI) + if row.Status != "missing" || len(*runs) != 0 { + t.Fatalf("row = %+v runs = %+v", row, *runs) + } +} + +func TestCLIProbeOverridesForUvAndGo(t *testing.T) { + uvDir := preflightGitLayer(t) + writeAt(t, uvDir, "pyproject.toml", "[project]\nname = \"app\"\ndependencies = [\"leji\"]\n") + writeAt(t, uvDir, "uv.lock", "version = 1\n") + hio, runs, _ := probeIO(okProbe()) + runFor(t, uvDir, nil, nil, hio) + if !reflect.DeepEqual((*runs)[0].args, []string{"run", "--no-sync", "leji", "--version"}) { + t.Fatalf("uv never syncs to answer a probe: %v", (*runs)[0].args) + } + + goDir := preflightGitLayer(t) + writeAt(t, goDir, "go.mod", "module example.com/app\n\ngo 1.24\n\ntool github.com/leji-org/leji/packages/sdk-go/cmd/leji\n") + ghio, gruns, _ := probeIO(okProbe()) + runFor(t, goDir, nil, nil, ghio) + if !reflect.DeepEqual((*gruns)[0].args, []string{"tool", "leji", "--version"}) { + t.Fatalf("go probe args = %v", (*gruns)[0].args) + } + goEnv := (*gruns)[0].opts.Env + for k, want := range map[string]string{"GOFLAGS": "-mod=readonly", "GOTOOLCHAIN": "local", "GOPROXY": "off", "GOWORK": "off"} { + if goEnv[k] != want { + t.Fatalf("go probe %s = %q, want %q", k, goEnv[k], want) + } + } + // A manager has to be found on PATH, so PATH survives; nothing unrelated does. + if goEnv["PATH"] != os.Getenv("PATH") { + t.Fatalf("go probe PATH = %q", goEnv["PATH"]) + } + for _, leaked := range []string{"NODE_OPTIONS", "LD_PRELOAD", "GOPRIVATE"} { + if _, ok := goEnv[leaked]; ok { + t.Fatalf("%s must not reach the probe", leaked) + } + } +} + +func TestCLIEveryProbeFailureFailsClosed(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + writeAt(t, dir, "package-lock.json", `{"lockfileVersion":3}`+"\n") + installNodeBin(t, dir) + failures := []LaunchResult{ + {Started: false, Err: errors.New("spawn npx ENOENT")}, // never started + {Started: true, Err: errors.New("context deadline exceeded")}, // timed out + {Started: true, Err: errors.New("exit status 1")}, // ran, failed + {Started: true, Stdout: "leji version one\n"}, // malformed + {Started: true, Stdout: "\n"}, // empty + {Started: true, Err: errors.New("probe output exceeded the cap")}, // over the cap + } + for _, outcome := range failures { + hio, _, _ := probeIO([]LaunchResult{outcome}) + row := rowByID(t, runFor(t, dir, nil, nil, hio).Checks, CheckCLI) + wantFix := []string{"npm install", "npx --no-install @leji-org/leji --version"} + if row.Status != "missing" || !reflect.DeepEqual(row.Fix, wantFix) { + t.Fatalf("row = %+v for outcome %+v", row, outcome) + } + } +} + +func TestCLIBelowTheSpecLineMinimumIsMissing(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + installNodeBin(t, dir) + hio, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "0.9.3\n"}}) + row := rowByID(t, runFor(t, dir, nil, nil, hio).Checks, CheckCLI) + if row.Status != "missing" || !strings.Contains(row.Detail, "is below 1.0.0 for spec 1.0") { + t.Fatalf("row = %+v", row) + } +} + +// --- MCP rows ----------------------------------------------------------------- + +func TestMcpRegisteredIsOkAndUnregisteredOffersTheUserScope(t *testing.T) { + dir := preflightGitLayer(t) + okIO, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true}}) + if row := rowByID(t, runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, okIO).Checks, CheckMCP); row.Status != "ok" { + t.Fatalf("row = %+v", row) + } + missIO, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true, Err: errors.New("exit status 1")}}) + row := rowByID(t, runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, missIO).Checks, CheckMCP) + if row.Status != "missing" || !reflect.DeepEqual(row.Fix, []string{"claude mcp add leji --scope user -- npx -y @leji-org/mcp"}) { + t.Fatalf("row = %+v", row) + } +} + +func TestMcpCodexRegistersAtUserLevelAndHasNoSharedForm(t *testing.T) { + dir := preflightGitLayer(t) + hio, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true, Err: errors.New("exit status 1")}}) + checks := runFor(t, dir, codexStartHost, []detect.DetectedHost{codexHost}, hio).Checks + if row := rowByID(t, checks, CheckMCP); !reflect.DeepEqual(row.Fix, []string{"codex mcp add leji -- npx -y @leji-org/mcp"}) { + t.Fatalf("row = %+v", row) + } + if row := rowByID(t, checks, CheckMCPShared); row.Status != "n/a" { + t.Fatalf("Codex has no shared form: %+v", row) + } +} + +func TestMcpSeveralHostsAndNoPickIsUnresolved(t *testing.T) { + dir := preflightGitLayer(t) + hio, _, _ := probeIO(okProbe()) + row := rowByID(t, runFor(t, dir, nil, []detect.DetectedHost{claudeHost, codexHost}, hio).Checks, CheckMCP) + if row.Status != "unresolved" || !strings.Contains(row.Detail, "Claude Code, Codex") || + !reflect.DeepEqual(row.Fix, []string{"leji start --agent "}) { + t.Fatalf("row = %+v", row) + } +} + +func TestMcpUnregisterableHostGetsTheStandardConfigAndItsPath(t *testing.T) { + dir := preflightGitLayer(t) + hio, _, _ := probeIO(okProbe()) + row := rowByID(t, runFor(t, dir, nil, []detect.DetectedHost{cursorHost}, hio).Checks, CheckMCP) + if row.Status != "missing" || row.Fix[0] != ".cursor/mcp.json (project scope)" || + !strings.Contains(strings.Join(row.Fix, "\n"), `"@leji-org/mcp"`) { + t.Fatalf("row = %+v", row) + } +} + +func TestMcpPrintedBlockTakesTheShapeTheHostConfigFileUses(t *testing.T) { + dir := preflightGitLayer(t) + // VS Code, which is how GitHub Copilot reads MCP servers, spells the map + // `servers`; pasting the common `mcpServers` block into .vscode/mcp.json leaves + // the editor with a file it ignores. + hio, _, _ := probeIO(okProbe()) + copilot := rowByID(t, runFor(t, dir, nil, []detect.DetectedHost{copilotHost}, hio).Checks, CheckMCP) + want := []string{ + ".vscode/mcp.json (project scope)", + "{", + ` "servers": {`, + ` "leji": { "command": "npx", "args": ["-y", "@leji-org/mcp"] }`, + " }", + "}", + } + if copilot.Status != "missing" || !reflect.DeepEqual(copilot.Fix, want) { + t.Fatalf("row = %+v", copilot) + } + // Every other host Leji cannot register for takes the common shape. + chio, _, _ := probeIO(okProbe()) + cursor := rowByID(t, runFor(t, dir, nil, []detect.DetectedHost{cursorHost}, chio).Checks, CheckMCP) + if !strings.Contains(strings.Join(cursor.Fix, "\n"), ` "mcpServers": {`) { + t.Fatalf("row = %+v", cursor) + } +} + +func TestMcpNoDetectedHostIsSkippedAndNeverCountsAgainstReady(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + installNodeBin(t, dir) + if _, err := EnsureLocalHook(dir, []string{"leji"}); err != nil { + t.Fatalf("ensure: %v", err) + } + hio, _, _ := probeIO(okProbe()) + res := runFor(t, dir, nil, nil, hio) + if rowByID(t, res.Checks, CheckMCP).Status != "skipped" || !res.Ready { + t.Fatalf("checks = %+v ready = %v", res.Checks, res.Ready) + } +} + +func TestMcpSharedPresenceAndAbsence(t *testing.T) { + dir := preflightGitLayer(t) + absentIO, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true}}) + res := runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, absentIO) + gap := rowByID(t, res.Checks, CheckMCPShared) + if gap.Status != "shared-gap" || + !reflect.DeepEqual(gap.Fix, []string{"claude mcp add leji --scope project -- npx -y @leji-org/mcp"}) { + t.Fatalf("row = %+v", gap) + } + if res.Ready { + t.Fatalf("ready is decided by cli, mcp and hook") + } + writeAt(t, dir, ".mcp.json", `{"mcpServers":{}}`+"\n") + presentIO, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true}}) + if row := rowByID(t, runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, presentIO).Checks, CheckMCPShared); row.Status != "ok" { + t.Fatalf("row = %+v", row) + } +} + +func TestMcpSharedNeverDecidesReadyOnItsOwn(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + installNodeBin(t, dir) + if _, err := EnsureLocalHook(dir, []string{"npx", "--no-install", "@leji-org/leji"}); err != nil { + t.Fatalf("ensure: %v", err) + } + hio, _, _ := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true}}) + res := runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, hio) + if rowByID(t, res.Checks, CheckMCPShared).Status != "shared-gap" || !res.Ready { + t.Fatalf("checks = %+v ready = %v", res.Checks, res.Ready) + } +} + +// --- the report --------------------------------------------------------------- + +func TestChecksAreAlwaysTheSameFourIdsInOrder(t *testing.T) { + dir := preflightGitLayer(t) + hio, _, _ := probeIO(okProbe()) + got := []string{} + for _, c := range runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, hio).Checks { + got = append(got, c.ID) + } + if !reflect.DeepEqual(got, []string{"cli", "mcp", "mcp-shared", "hook"}) { + t.Fatalf("ids = %v", got) + } +} + +// --- the Setup block ------------------------------------------------------------ +// The three scenarios the layout was cut against, as exact bytes: the other two SDKs +// print these same strings, so the render is pinned here rather than described. + +// checkRow is a check exactly as the internal constructors build it. +func checkRow(id, status, detail string, fix ...string) Check { + return newCheck(id, status, detail, fix) +} + +const ( + mcpUserFix = "claude mcp add leji --scope user -- npx -y @leji-org/mcp" + mcpProjectFix = "claude mcp add leji --scope project -- npx -y @leji-org/mcp" +) + +var startScenarios = []struct { + name string + checks []Check + want string +}{ + { + // Nothing is this clone's to fix: the CLI, the shared server and the hook are + // all the repository's own state. + name: "every gap belongs to a maintainer", + checks: []Check{ + checkRow(CheckCLI, "shared-gap", "not declared in this repository", "npm i -D @leji-org/leji"), + checkRow(CheckMCP, "ok", "registered for Claude Code"), + checkRow(CheckMCPShared, "shared-gap", "no .mcp.json committed", mcpProjectFix), + checkRow(CheckHook, "shared-gap", "no leji block in .husky/pre-commit", "leji ci --hooks"), + }, + want: strings.Join([]string{ + "Setup for this clone", + "", + " team Leji CLI not declared in this repository", + " $ npm i -D @leji-org/leji", + " ok MCP server registered for Claude Code", + " team Team MCP no .mcp.json committed", + " $ " + mcpProjectFix, + " team Git hook no leji block in .husky/pre-commit", + " $ leji ci --hooks", + "", + " 3 fixes for a maintainer. The agent starts either way.", + }, "\n"), + }, + { + // The CLI and the hook are the maintainer's; both MCP registrations are there. + name: "the CLI and the hook are a maintainer's, both MCP rows ok", + checks: []Check{ + checkRow(CheckCLI, "shared-gap", "not declared here (PATH has your own 1.4.0)", "npm i -D @leji-org/leji"), + checkRow(CheckMCP, "ok", "registered for Claude Code"), + checkRow(CheckMCPShared, "ok", ".mcp.json committed"), + checkRow(CheckHook, "shared-gap", "no leji block in .husky/pre-commit", "leji ci --hooks"), + }, + want: strings.Join([]string{ + "Setup for this clone", + "", + " team Leji CLI not declared here (PATH has your own 1.4.0)", + " $ npm i -D @leji-org/leji", + " ok MCP server registered for Claude Code", + " ok Team MCP .mcp.json committed", + " team Git hook no leji block in .husky/pre-commit", + " $ leji ci --hooks", + "", + " 2 fixes for a maintainer. The agent starts either way.", + }, "\n"), + }, + { + // One fix each: this user's own registration, and the one a maintainer commits. + name: "one fix for this user and one for a maintainer", + checks: []Check{ + checkRow(CheckCLI, "ok", "1.4.0 (node_modules/.bin/leji)"), + checkRow(CheckMCP, "missing", "not registered for Claude Code", mcpUserFix), + checkRow(CheckMCPShared, "shared-gap", "no .mcp.json committed", mcpProjectFix), + checkRow(CheckHook, "ok", "runs leji checks before each commit: .git/hooks/pre-commit"), + }, + want: strings.Join([]string{ + "Setup for this clone", + "", + " ok Leji CLI 1.4.0 (node_modules/.bin/leji)", + " you MCP server not registered for Claude Code", + " $ " + mcpUserFix, + " team Team MCP no .mcp.json committed", + " $ " + mcpProjectFix, + " ok Git hook runs leji checks before each commit: .git/hooks/pre-commit", + "", + " 1 fix for you, 1 for a maintainer. The agent starts either way.", + }, "\n"), + }, +} + +func TestRenderPreflightScenarios(t *testing.T) { + for _, sc := range startScenarios { + t.Run(sc.name, func(t *testing.T) { + block := RenderPreflight(sc.checks, false) + if block != sc.want { + t.Fatalf("block =\n%s\nwant\n%s", block, sc.want) + } + if strings.ContainsAny(block, "–—") { + t.Fatalf("no en or em dash reaches the terminal: %q", block) + } + }) + } +} + +func TestNoRowOfAnyScenarioWrapsAt80Columns(t *testing.T) { + for _, sc := range startScenarios { + for _, l := range strings.Split(RenderPreflight(sc.checks, false), "\n") { + // Commands and snippets are exact and exempt: they are what a person pastes. + if strings.HasPrefix(l, preflightFixIndent) { + continue + } + if len(l) > 80 { + t.Fatalf("%s: %d columns: %q", sc.name, len(l), l) + } + } + } +} + +func TestRenderPreflightSnippetIsPastedAndCommandCarriesThePrompt(t *testing.T) { + snippet := RenderPreflight([]Check{ + newSnippetCheck(CheckHook, "missing", "add it yourself; hooks run from /etc/hooks", []string{"#!/bin/sh", "leji ci"}), + }, false) + if !strings.Contains(snippet, "\n #!/bin/sh\n leji ci\n") { + t.Fatalf("block = %q", snippet) + } + // A Check assembled outside this file has no fix kind, and still renders as a command. + plain := RenderPreflight([]Check{ + {ID: CheckHook, Status: "missing", Detail: "none yet (per clone)", Fix: []string{"leji ci --hooks"}}, + }, false) + if !strings.Contains(plain, "\n $ leji ci --hooks\n") { + t.Fatalf("block = %q", plain) + } +} + +func TestRenderPreflightNothingOwedIsOneClosingLine(t *testing.T) { + block := RenderPreflight([]Check{ + checkRow(CheckCLI, "ok", "1.4.0 (node_modules/.bin/leji)"), + checkRow(CheckMCP, "skipped", "no coding agent detected"), + }, false) + lines := strings.Split(block, "\n") + if lines[len(lines)-1] != " Setup complete." { + t.Fatalf("block = %q", block) + } +} + +// --- the color convention ------------------------------------------------------- + +func TestRenderPreflightColorOffLeavesNotOneEscapeByte(t *testing.T) { + for _, sc := range startScenarios { + if strings.Contains(RenderPreflight(sc.checks, false), "\x1b") { + t.Fatalf("%s: an escape reached a plain render", sc.name) + } + } +} + +func TestRenderPreflightColorOnWrapsTheStatusWordOnly(t *testing.T) { + block := RenderPreflight([]Check{ + checkRow(CheckCLI, "ok", "1.4.0 (node_modules/.bin/leji)"), + checkRow(CheckMCP, "missing", "not registered for Claude Code"), + checkRow(CheckMCPShared, "shared-gap", "no .mcp.json committed"), + checkRow(CheckHook, "n/a", "not a git repository"), + }, true) + want := strings.Join([]string{ + "Setup for this clone", + "", + " \x1b[32mok\x1b[0m Leji CLI 1.4.0 (node_modules/.bin/leji)", + " \x1b[33myou\x1b[0m MCP server not registered for Claude Code", + " \x1b[36mteam\x1b[0m Team MCP no .mcp.json committed", + " \x1b[2mn/a\x1b[0m Git hook not a git repository", + "", + " 1 fix for you, 1 for a maintainer. The agent starts either way.", + }, "\n") + if block != want { + t.Fatalf("block =\n%q\nwant\n%q", block, want) + } +} + +func TestColorDecisionIsATerminalThatHasNotAskedForPlainText(t *testing.T) { + lookup := func(m map[string]string) func(string) (string, bool) { + return func(k string) (string, bool) { + v, ok := m[k] + return v, ok + } + } + cases := []struct { + isTTY bool + env map[string]string + want bool + }{ + {true, map[string]string{}, true}, + {false, map[string]string{}, false}, + {true, map[string]string{"NO_COLOR": "1"}, false}, + {true, map[string]string{"NO_COLOR": ""}, false}, + {false, map[string]string{"NO_COLOR": ""}, false}, + {true, map[string]string{"TERM": "dumb"}, false}, + {true, map[string]string{"TERM": "xterm-256color"}, true}, + {false, map[string]string{"TERM": "xterm-256color"}, false}, + } + for _, c := range cases { + if got := ColorDecision(c.isTTY, lookup(c.env)); got != c.want { + t.Fatalf("ColorDecision(%v, %v) = %v", c.isTTY, c.env, got) + } + } +} + +// --- the consented repairs ---------------------------------------------------- + +func TestOfferPreflightFixesWritesNothingNonInteractively(t *testing.T) { + dir := preflightGitLayer(t) + hio, runs, questions := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true, Err: errors.New("exit status 1")}}) + res := runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, hio) + before := len(*runs) + OfferPreflightFixes(PreflightOfferOptions{Root: dir, Host: claudeStartHost, Result: res, Runner: []string{"leji"}, Interactive: false}, hio, &strings.Builder{}) + if len(*questions) != 0 || len(*runs) != before { + t.Fatalf("questions=%v runs=%d", *questions, len(*runs)) + } + if _, err := os.Stat(filepath.Join(dir, ".git", "hooks", "pre-commit")); !os.IsNotExist(err) { + t.Fatalf("a non-interactive run wrote the hook") + } +} + +func TestOfferPreflightFixesRegistersThenInstallsInThatOrder(t *testing.T) { + dir := preflightGitLayer(t) + hio, runs, questions := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true, Err: errors.New("exit status 1")}, {Started: true}}, "y") + res := runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, hio) + OfferPreflightFixes(PreflightOfferOptions{Root: dir, Host: claudeStartHost, Result: res, Runner: []string{"leji"}, Interactive: true}, hio, &strings.Builder{}) + if len(*questions) != 2 || !strings.Contains((*questions)[0], "Register the Leji MCP server for Claude Code") || + !strings.Contains((*questions)[1], "Install the pre-commit hook") { + t.Fatalf("questions = %v", *questions) + } + last := (*runs)[len(*runs)-1] + want := []string{"mcp", "add", "leji", "--scope", "user", "--", "npx", "-y", "@leji-org/mcp"} + if !reflect.DeepEqual(last.args, want) { + t.Fatalf("registration = %v", last.args) + } + if _, err := os.Stat(filepath.Join(dir, ".git", "hooks", "pre-commit")); err != nil { + t.Fatalf("the clone hook was not written: %v", err) + } +} + +func TestOfferPreflightFixesNeverOffersASharedGap(t *testing.T) { + dir := preflightGitLayer(t) + gitConfigAt(t, dir, "core.hooksPath", ".husky/_") + hio, _, questions := probeIO(okProbe(), "y") + res := runFor(t, dir, nil, nil, hio) + if rowByID(t, res.Checks, CheckHook).Status != "shared-gap" { + t.Fatalf("checks = %+v", res.Checks) + } + OfferPreflightFixes(PreflightOfferOptions{Root: dir, Host: nil, Result: res, Runner: []string{"leji"}, Interactive: true}, hio, &strings.Builder{}) + if len(*questions) != 0 { + t.Fatalf("a committed hook is a maintainer decision: %v", *questions) + } + if _, err := os.Stat(filepath.Join(dir, ".husky", "pre-commit")); !os.IsNotExist(err) { + t.Fatalf("the shared hook was written") + } +} + +func TestOfferPreflightFixesDeclinesCleanly(t *testing.T) { + dir := preflightGitLayer(t) + hio, _, questions := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true, Err: errors.New("exit status 1")}}, "n") + res := runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, hio) + OfferPreflightFixes(PreflightOfferOptions{Root: dir, Host: claudeStartHost, Result: res, Runner: []string{"leji"}, Interactive: true}, hio, &strings.Builder{}) + if len(*questions) != 2 { + t.Fatalf("questions = %v", *questions) + } + if _, err := os.Stat(filepath.Join(dir, ".git", "hooks", "pre-commit")); !os.IsNotExist(err) { + t.Fatalf("a declined offer wrote the hook") + } +} + +func TestSecondRunOfAnAllOkCloneOffersNothing(t *testing.T) { + dir := preflightGitLayer(t) + writeAt(t, dir, "package.json", `{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}`+"\n") + writeAt(t, dir, ".mcp.json", `{"mcpServers":{}}`+"\n") + installNodeBin(t, dir) + if _, err := EnsureLocalHook(dir, []string{"npx", "--no-install", "@leji-org/leji"}); err != nil { + t.Fatalf("ensure: %v", err) + } + hio, _, questions := probeIO([]LaunchResult{{Started: true, Stdout: "1.4.0\n"}, {Started: true}}, "y") + res := runFor(t, dir, claudeStartHost, []detect.DetectedHost{claudeHost}, hio) + for _, c := range res.Checks { + if c.Status != "ok" { + t.Fatalf("checks = %+v", res.Checks) + } + } + if !res.Ready { + t.Fatalf("ready = false") + } + OfferPreflightFixes(PreflightOfferOptions{Root: dir, Host: claudeStartHost, Result: res, Runner: []string{"leji"}, Interactive: true}, hio, &strings.Builder{}) + if len(*questions) != 0 { + t.Fatalf("questions = %v", *questions) + } +} + +// --- the capture bounds, against a real child --------------------------------- +// The probe's two guarantees are about THIS process: nothing of its environment +// reaches the child, and nothing the child prints can grow past the cap or outlast +// the deadline. Both are exercised against real programs, not fakes. + +// captureStub writes an executable /bin/sh script and returns its path. +func captureStub(t *testing.T, dir, name, body string) string { + t.Helper() + abs := filepath.Join(dir, name) + if err := os.WriteFile(abs, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatalf("write stub: %v", err) + } + return abs +} + +const captureCanaryVar = "LEJI_PROBE_CANARY" + +// The deadline a run that must NOT reach it is given: generous enough that a loaded +// runner cannot trip it, so finishing early can only mean the cap cut the child off. +const overflowTimeoutMs = 30000 + +// The bound a terminated run has to finish inside: far below the deadline above, and +// far above anything scheduling delay on a busy machine can add. What it proves is +// which mechanism ended the run, not how fast the machine is. +const promptWithin = 15 * time.Second + +// 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 not +// because the child happened to exit. +const stubHold = "sleep 60" + +func TestCaptureReplacesTheEnvironmentRatherThanExtendingIt(t *testing.T) { + dir := t.TempDir() + stub := captureStub(t, dir, "echo-canary", `printf '%s' "${`+captureCanaryVar+`:-}"`) + t.Setenv(captureCanaryVar, "leaked") + + // The parent has it; the probe's environment does not, because Env replaces. + res := captureRun(stub, nil, dir, RunOptions{ + Capture: true, TimeoutMs: 10000, MaxBytes: 4096, Env: map[string]string{"PATH": "/usr/bin:/bin"}, + }) + if !res.Started || res.Err != nil { + t.Fatalf("res = %+v", res) + } + if res.Stdout != "" { + t.Fatalf("the parent's %s reached the probe: %q", captureCanaryVar, res.Stdout) + } + // The positive control: what the caller names IS present, so the empty result + // above is replacement rather than a stub that cannot see any environment. + kept := captureRun(stub, nil, dir, RunOptions{ + Capture: true, TimeoutMs: 10000, MaxBytes: 4096, + Env: map[string]string{"PATH": "/usr/bin:/bin", captureCanaryVar: "named"}, + }) + if kept.Stdout != "named" { + t.Fatalf("a named variable did not reach the probe: %+v", kept) + } +} + +func TestCaptureKillsAChildThatStreamsPastTheCap(t *testing.T) { + dir := t.TempDir() + // 1 MiB in 1 KiB writes, far past the cap, then a slow tail: a run that did not + // kill the child at the cap would still be waiting when the deadline arrives. + stub := captureStub(t, dir, "flood", "i=0\nwhile [ $i -lt 1024 ]; do printf '%1024s' ''; i=$((i+1)); done\n"+stubHold) + started := time.Now() + res := captureRun(stub, nil, dir, RunOptions{Capture: true, TimeoutMs: overflowTimeoutMs, MaxBytes: 4096}) + elapsed := time.Since(started) + if !res.Started || res.Err == nil || res.Err.Error() != "probe output exceeded the cap" { + t.Fatalf("res = %+v", res) + } + if len(res.Stdout) > 4096 { + t.Fatalf("held %d bytes, more than the cap", len(res.Stdout)) + } + if elapsed >= promptWithin { + t.Fatalf("the cap did not cut the child off: %s, against a %dms deadline", elapsed, overflowTimeoutMs) + } +} + +func TestCaptureEndsASparseOverflowPromptly(t *testing.T) { + // One byte past the cap, then a child that holds stdout open and does nothing. The + // overflow has to be decided from that single byte, not from a full buffer or from + // EOF, or the run would sit until the deadline. + dir := t.TempDir() + const cap = 64 + stub := captureStub(t, dir, "trickle", "printf '%"+strconv.Itoa(cap+1)+"s' ''\n"+stubHold) + started := time.Now() + res := captureRun(stub, nil, dir, RunOptions{Capture: true, TimeoutMs: overflowTimeoutMs, MaxBytes: cap}) + elapsed := time.Since(started) + if !res.Started || res.Err == nil || res.Err.Error() != "probe output exceeded the cap" { + t.Fatalf("res = %+v", res) + } + if elapsed >= promptWithin { + t.Fatalf("a sparse overflow waited for the deadline: %s, against a %dms deadline", elapsed, overflowTimeoutMs) + } +} + +func TestCaptureCapBoundary(t *testing.T) { + dir := t.TempDir() + const cap = 64 + for _, tc := range []struct { + name string + size int + capped bool + }{ + {"below the cap", cap - 1, false}, + {"exactly the cap", cap, false}, + {"one past the cap", cap + 1, true}, + } { + stub := captureStub(t, dir, "size-"+tc.name[:5]+strconv.Itoa(tc.size), + "printf '%"+strconv.Itoa(tc.size)+"s' ''") + res := captureRun(stub, nil, dir, RunOptions{Capture: true, TimeoutMs: 10000, MaxBytes: cap}) + gotCapped := res.Err != nil && res.Err.Error() == "probe output exceeded the cap" + if gotCapped != tc.capped { + t.Fatalf("%s (%d bytes): capped = %v, want %v (res %+v)", tc.name, tc.size, gotCapped, tc.capped, res) + } + if !tc.capped && len(res.Stdout) != tc.size { + t.Fatalf("%s: held %d bytes, want %d", tc.name, len(res.Stdout), tc.size) + } + } +} + +func TestCaptureTimesOutAChildThatNeverFinishes(t *testing.T) { + dir := t.TempDir() + stub := captureStub(t, dir, "hang", stubHold) + started := time.Now() + res := captureRun(stub, nil, dir, RunOptions{Capture: true, TimeoutMs: 500, MaxBytes: 4096}) + elapsed := time.Since(started) + if !res.Started || res.Err == nil { + t.Fatalf("res = %+v", res) + } + // Here the deadline IS the mechanism under test; the bound only has to separate it + // from the child's own 60s, with room for a loaded runner. + if elapsed >= promptWithin { + t.Fatalf("the timeout did not end the run (%s)", elapsed) + } +} diff --git a/packages/sdk-go/internal/commands/init/signalname_unix.go b/packages/sdk-go/internal/commands/init/signalname_unix.go new file mode 100644 index 0000000..8bdab68 --- /dev/null +++ b/packages/sdk-go/internal/commands/init/signalname_unix.go @@ -0,0 +1,33 @@ +//go:build unix + +package initcmd + +import ( + "strconv" + "syscall" +) + +// signalNames maps the signals a package manager can plausibly die of to their +// canonical names, so the three SDKs print the same word: Go's Signal.String() +// renders SIGTERM as "terminated", which neither Node nor Python does. +// +// Unix table. The set differs per platform — Windows' syscall package defines a +// smaller one — so each build tag carries its own, and the SIG fallback covers +// anything absent from either. +var signalNames = map[syscall.Signal]string{ + syscall.SIGHUP: "SIGHUP", syscall.SIGINT: "SIGINT", syscall.SIGQUIT: "SIGQUIT", + syscall.SIGILL: "SIGILL", syscall.SIGTRAP: "SIGTRAP", syscall.SIGABRT: "SIGABRT", + syscall.SIGBUS: "SIGBUS", syscall.SIGFPE: "SIGFPE", syscall.SIGKILL: "SIGKILL", + syscall.SIGUSR1: "SIGUSR1", syscall.SIGSEGV: "SIGSEGV", syscall.SIGUSR2: "SIGUSR2", + syscall.SIGPIPE: "SIGPIPE", syscall.SIGALRM: "SIGALRM", syscall.SIGTERM: "SIGTERM", + syscall.SIGXCPU: "SIGXCPU", syscall.SIGXFSZ: "SIGXFSZ", +} + +// SignalName is the canonical name of a signal, or SIG for one this table does +// not carry. +func SignalName(sig syscall.Signal) string { + if name, ok := signalNames[sig]; ok { + return name + } + return "SIG" + strconv.Itoa(int(sig)) +} diff --git a/packages/sdk-go/internal/commands/init/signalname_windows.go b/packages/sdk-go/internal/commands/init/signalname_windows.go new file mode 100644 index 0000000..0258957 --- /dev/null +++ b/packages/sdk-go/internal/commands/init/signalname_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package initcmd + +import ( + "strconv" + "syscall" +) + +// signalNames maps the signals a package manager can plausibly die of to their +// canonical names, so the three SDKs print the same word. +// +// Windows table: only the signals Windows' syscall package defines. A Windows +// child is not killed by a POSIX signal, so this exists for shape parity rather +// than for a path a Windows adopter reaches; the SIG fallback covers the rest. +var signalNames = map[syscall.Signal]string{ + syscall.SIGHUP: "SIGHUP", syscall.SIGINT: "SIGINT", syscall.SIGQUIT: "SIGQUIT", + syscall.SIGILL: "SIGILL", syscall.SIGTRAP: "SIGTRAP", syscall.SIGABRT: "SIGABRT", + syscall.SIGBUS: "SIGBUS", syscall.SIGFPE: "SIGFPE", syscall.SIGKILL: "SIGKILL", + syscall.SIGSEGV: "SIGSEGV", syscall.SIGPIPE: "SIGPIPE", syscall.SIGALRM: "SIGALRM", + syscall.SIGTERM: "SIGTERM", +} + +// SignalName is the canonical name of a signal, or SIG for one this table does +// not carry. +func SignalName(sig syscall.Signal) string { + if name, ok := signalNames[sig]; ok { + return name + } + return "SIG" + strconv.Itoa(int(sig)) +} diff --git a/packages/sdk-go/internal/commands/viewer/route_key_test.go b/packages/sdk-go/internal/commands/serve/route_key_test.go similarity index 98% rename from packages/sdk-go/internal/commands/viewer/route_key_test.go rename to packages/sdk-go/internal/commands/serve/route_key_test.go index b69ff4f..f9bf0e8 100644 --- a/packages/sdk-go/internal/commands/viewer/route_key_test.go +++ b/packages/sdk-go/internal/commands/serve/route_key_test.go @@ -1,4 +1,4 @@ -package viewer +package serve import "testing" diff --git a/packages/sdk-go/internal/commands/serve/serve.go b/packages/sdk-go/internal/commands/serve/serve.go new file mode 100644 index 0000000..b6d9c1e --- /dev/null +++ b/packages/sdk-go/internal/commands/serve/serve.go @@ -0,0 +1,440 @@ +// Package serve is the viewer's local preview server: the virtual mounts, the +// route table, and the policy headers a browser sees. Every network import the CLI +// makes lives here and nowhere else — the static export is a separate package whose +// transitive imports carry none of them, which is what makes the export's +// no-network guarantee checkable rather than asserted. +package serve + +import ( + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" + "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/fsx" + "github.com/leji-org/leji/packages/sdk-go/internal/layout" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +const ( + // cspChrome is the SPA shell's policy, sent as a response header on every + // chrome response so it holds for documents reached outside the shell too. + // Mirrors the meta in templates/viewer/index.html; keep the two in step. + cspChrome = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'none'" + // cspContent is the policy for everything served out of the layer itself. + // `sandbox` with no tokens puts a /content/ document in an opaque origin with + // scripting off, so a governed file framed or opened directly is inert rather + // than same-origin code. + cspContent = "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; sandbox" +) + +// loopbackHosts are the host names the local preview answers to. +var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "[::1]": true} + +// loopbackHost reports whether the Host header names the loopback interface: +// hostname only, since the port a request arrives on is already fixed by the +// loopback bind. A missing Host is accepted (an HTTP/1.0 client omits it). +func loopbackHost(host string) bool { + if host == "" { + return true + } + name := host + if strings.HasPrefix(host, "[") { + name = host[:strings.Index(host, "]")+1] + } else if i := strings.Index(host, ":"); i >= 0 { + name = host[:i] + } + return loopbackHosts[strings.ToLower(name)] +} + +var contentTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".md": "text/markdown; 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", + ".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", +} + +// serveFrom serves `sub` (clean relative path; "" -> index.html) from mountRoot. +// Lexically contains the target, applies the servable-roots whitelist by name, +// follows a dir to index.html, then realpath-checks so a symlink can't escape and +// judges the resolved path by the whitelist too. Mirrors Node: 200 ok, 403 +// containment violation, 404 on a denied name or any stat/read failure. `inert` +// marks the layer's own content mount, whose files are never given an active +// content type however they are named. +func serveFrom(w http.ResponseWriter, rootAbs, mountRoot, sub string, inert bool) { + var abs string + if sub == "" { + abs = filepath.Join(mountRoot, "index.html") + } else { + abs = filepath.Join(mountRoot, sub) + } + if abs != mountRoot && !strings.HasPrefix(abs, mountRoot+string(filepath.Separator)) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + return + } + // The servable-roots whitelist: under `.leji/`, only `viewer/` is servable. + // Judged by name on the requested path and again on the resolved one, so a + // symlink under the content root cannot reach a private role either. + if !layout.ServablePath(rootAbs, abs) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + if info, err := os.Stat(abs); err == nil && info.IsDir() { + abs = filepath.Join(abs, "index.html") + } + real, err := filepath.EvalSymlinks(abs) + if err != nil { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + if real != mountRoot && !strings.HasPrefix(real, mountRoot+string(filepath.Separator)) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + return + } + if !layout.ServablePath(rootAbs, real) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + body, err := os.ReadFile(real) + if err != nil { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + ext := strings.ToLower(path.Ext(real)) + ct := contentTypes[ext] + if ct == "" { + ct = "application/octet-stream" + } + if inert && viewer.ActiveExtensions[ext] { + ct = "text/plain; charset=utf-8" + } + w.Header().Set("content-type", ct) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +// 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. +type sidebarCache struct { + key string + body string + indexJSON string + hasIndex bool +} + +// statusWriter records the status code written so the access log can report it. +type statusWriter struct { + http.ResponseWriter + code int +} + +func (w *statusWriter) WriteHeader(code int) { + w.code = code + w.ResponseWriter.WriteHeader(code) +} + +// newHandler builds the virtual-mount handler and nothing else — the servable +// roots: viewer chrome (root `.leji/viewer/`) at "/", the layer's markdown +// (rootPath/) under "/content/"; "/content/_sidebar.md" maps to the generated +// sidebar in 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. +// 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 { + // 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 + // 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. + var mu sync.Mutex + var cache *sidebarCache + treeFingerprint := func() string { + var parts []string + add := func(rel string) { + st, err := os.Stat(filepath.Join(rootAbs, rel)) + if err != nil { + parts = append(parts, rel+"\x00gone") + return + } + parts = append(parts, fmt.Sprintf("%s\x00%d\x00%d", rel, st.ModTime().UnixNano(), st.Size())) + } + add("leji.json") + walkBase := base + if walkBase == "" { + walkBase = "." + } + for _, rel := range fsx.WalkTree(rootAbs, walkBase) { + add(rel) + } + return strings.Join(parts, "\n") + } + // refresh rebuilds the cache for key from the live tree; returns nil when the + // manifest is missing or the tree will not index cleanly (callers then fall + // back to the generated artifact). + refresh := func(key string) *sidebarCache { + load := manifest.LoadManifest(rootAbs) + if load.Manifest == nil { + return nil + } + // 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. + idx, ierr := indexgen.GenerateIndex(rootAbs, load.Manifest) + if ierr != nil { + return nil + } + for _, f := range idx.Findings { + if f.Severity == findings.Error { + return nil + } + } + var entries []indexgen.IndexEntry + if idx.Index != nil { + entries = idx.Index.Entries + } + var discard []findings.Finding + c := &sidebarCache{key: key, body: viewer.AssembleSidebar(rootAbs, load.Manifest, entries, &discard)} + if idx.Index != nil { + c.indexJSON = indexgen.SerializeIndex(idx.Index) + c.hasIndex = true + } + cache = c + return c + } + serveText := func(w http.ResponseWriter, contentType, body string) { + w.Header().Set("content-type", contentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + } + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Policy headers ride every response, not just the SPA shell: a document + // served straight out of /content/ is same-origin and would otherwise run + // with no policy at all. Set before any write; the content mount downgrades + // to the inert policy once the route is known. + w.Header().Set("x-content-type-options", "nosniff") + w.Header().Set("content-security-policy", cspChrome) + // Loopback binding alone does not stop DNS rebinding: a hostile page whose + // name resolves to 127.0.0.1 reaches this server with its own Host. Only the + // loopback names the viewer is actually addressed by are answered. The port is + // deliberately not part of the test: a rebound request carries the right port + // anyway, so matching it adds nothing. Don't "fix" this by checking it. + if !loopbackHost(r.Host) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + return + } + // A malformed percent-encoding leaves RawPath set but Path empty/wrong; + // detect a decode error and answer 400 rather than crash. + urlPath, err := decodePath(r.URL) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad request")) + return + } + rel := urlPathToRel(urlPath) + if rel == "content" || strings.HasPrefix(rel, "content/") { + w.Header().Set("content-security-policy", cspContent) + } + // Refuse any dotfile or VCS-internal segment in the request path: the .leji + // viewer dir is reached only through the mounts below. + for _, seg := range strings.Split(rel, "/") { + if seg == ".git" || (strings.HasPrefix(seg, ".") && seg != "." && seg != "") { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + return + } + } + // The generated sidebar lives in the viewer dir but is served as if at the + // content root, so Docsify's basePath /content/ + _sidebar alias resolve it. + // Docsify fetches it once per page load, so it is rebuilt from the live tree + // on every request: a long-running server never shows a deleted or moved + // 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 { + serveText(w, "text/markdown; charset=utf-8", c.body) + return + } + serveFrom(w, rootAbs, viewerAbs, "_sidebar.md", false) + return + } + // The stored context index is served live (same fingerprint cache as the + // sidebar), so per-page classification badges never disagree with the tree. + if strings.HasPrefix(rel, "content/") { + 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 { + serveText(w, "application/json; charset=utf-8", c.indexJSON) + return + } + } + } + } + // The generated Manifest page lives in the viewer dir (gitignored chrome) but + // is linked from the sidebar and fetched under the content root, like + // _sidebar.md. Reserved underscore name; served from the last generation. + if rel == "content/_manifest.md" { + serveFrom(w, rootAbs, viewerAbs, "_manifest.md", false) + return + } + if rel == "content" || strings.HasPrefix(rel, "content/") { + sub := "" + if rel != "content" { + sub = rel[len("content/"):] + } + // An agent profile that declares `inherits` is served resolved: the file + // on disk is one half, and presenting it as the effective profile is the + // thing a consumer must not do. So this branch fails closed. If anything + // at all goes wrong, a file that declares `inherits` still gets a findings + // page; only a file that is not half a profile falls through to disk. + if strings.HasSuffix(sub, ".md") { + repoRel := sub + if base != "" && base != "." { + repoRel = base + "/" + sub + } + page, served := "", false + load := manifest.LoadManifest(rootAbs) + if load.Manifest != nil { + page, served = viewer.ResolvedProfilePage(rootAbs, load.Manifest, repoRel) + } else if viewer.DeclaresInherits(rootAbs, repoRel) { + page = viewer.UnresolvedProfilePage(repoRel, []findings.Finding{ + findings.New("artifact-parse", findings.Error, "the layer manifest could not be read", "leji.json"), + }) + served = true + } + if served { + serveText(w, "text/markdown; charset=utf-8", page) + return + } + } + serveFrom(w, rootAbs, contentAbs, sub, true) + return + } + // Everything else (`/`, /index.html, /assets/*) is viewer chrome. + serveFrom(w, rootAbs, viewerAbs, rel, false) + }) + if logf == nil { + return inner + } + // Access log: one terse line per request, after the status is known. + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := &statusWriter{ResponseWriter: w, code: http.StatusOK} + inner.ServeHTTP(sw, r) + logf(r.Method + " " + r.URL.RequestURI() + " " + strconv.Itoa(sw.code)) + }) +} + +// decodePath returns the percent-decoded request path, or an error when the +// encoding is malformed (mirrors Node's decodeURIComponent throwing -> 400). +func decodePath(u *url.URL) (string, error) { + if u.RawPath != "" { + return url.PathUnescape(u.RawPath) + } + return u.Path, nil +} + +// resolveRoot resolves symlinks in root, falling back to its absolute path. +func resolveRoot(root string) string { + rootAbs, err := filepath.EvalSymlinks(root) + if err != nil { + rootAbs, _ = filepath.Abs(root) + } + return rootAbs +} + +// urlPathToRel turns a request URL path into a clean relative route key. +// Separators fold to "/" and the path is cleaned against a root, so one request +// has one route key on any platform — filepath.Clean follows the host and +// answered differently on Windows, missing every "content/" route test. +// Canonicalization only; serveFrom enforces containment. +func urlPathToRel(urlPath string) string { + return strings.TrimLeft(path.Clean("/"+strings.ReplaceAll(urlPath, "\\", "/")), "/") +} + +// 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) { + rootAbs := resolveRoot(root) + base := fsx.StripSlash(rootRel) + contentAbs := rootAbs + if base != "" && base != "." { + contentAbs = filepath.Join(rootAbs, base) + } + // A direct SDK caller could pass an escaping rootRel (e.g. ".."); refuse to mount + // content outside the layer root. + if !fsx.ResolvedWithinRoot(rootAbs, contentAbs) { + return nil, nil, fmt.Errorf("viewer root %q escapes the layer root", rootRel) + } + ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) + if err != nil { + return nil, nil, err + } + viewerAbs := layout.Abs(rootAbs, layout.ViewerRel) + srv := &http.Server{Handler: newHandler(rootAbs, base, contentAbs, viewerAbs, logf)} + return ln, srv, nil +} + +// OpenBrowser best-effort opens url in the default browser. Never blocks or fails +// the caller: a missing opener is a silent no-op. +func OpenBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", url) + default: + cmd = exec.Command("xdg-open", url) + } + // Start (don't Wait): spawn detached and ignore any error. + _ = cmd.Start() +} diff --git a/packages/sdk-go/internal/commands/viewer/serve_more_test.go b/packages/sdk-go/internal/commands/serve/serve_more_test.go similarity index 56% rename from packages/sdk-go/internal/commands/viewer/serve_more_test.go rename to packages/sdk-go/internal/commands/serve/serve_more_test.go index e92cffb..d3b714e 100644 --- a/packages/sdk-go/internal/commands/viewer/serve_more_test.go +++ b/packages/sdk-go/internal/commands/serve/serve_more_test.go @@ -1,36 +1,53 @@ -package viewer +package serve import ( "bufio" + "bytes" "io" "net" "net/http" "os" + "path" "path/filepath" + "regexp" "strings" "testing" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/export" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/viewer" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" ) -func TestResolveViewerPort(t *testing.T) { - flag := 1234 - if got := ResolveViewerPort(&manifest.Manifest{}, &flag); got != 1234 { - t.Fatalf("flag port: got %d, want 1234", got) - } - mp := 4321 - if got := ResolveViewerPort(&manifest.Manifest{Viewer: &manifest.Viewer{Port: &mp}}, nil); got != 4321 { - t.Fatalf("manifest port: got %d, want 4321", got) +// exampleCopy is a scratch copy of the example layer, the fixture the serve legs +// answer requests against. +func exampleCopy(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + src := filepath.Join(wd, "..", "..", "..", "..", "..", "examples", "monorepo") + dst := t.TempDir() + if err := os.CopyFS(dst, os.DirFS(src)); err != nil { + t.Fatalf("copy example: %v", err) + } + return dst +} + +// writeUnder writes rel (forward-slashed, repo-relative) under dir, creating its +// parent directories. +func writeUnder(t *testing.T, dir, rel, text string) { + t.Helper() + abs := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) } - if got := ResolveViewerPort(&manifest.Manifest{}, nil); got != 5354 { - t.Fatalf("default port: got %d, want 5354", got) + if err := os.WriteFile(abs, []byte(text), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) } } func TestServeServesViewerAndContent(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest - if _, err := GenerateViewer(dir, m); err != nil { + if _, err := viewer.GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } ln, srv, err := Serve(dir, 0, m.RootPath, nil) @@ -76,7 +93,7 @@ func TestServeServesViewerAndContent(t *testing.T) { func TestServeSecurityBranches(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest - if _, err := GenerateViewer(dir, m); err != nil { + if _, err := viewer.GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } ln, srv, err := Serve(dir, 0, m.RootPath, nil) @@ -130,7 +147,7 @@ func TestServeSecurityBranches(t *testing.T) { func TestServeLiveSidebarAndIndex(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest - if _, err := GenerateViewer(dir, m); err != nil { + if _, err := viewer.GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } ln, srv, err := Serve(dir, 0, m.RootPath, nil) @@ -161,7 +178,7 @@ func TestServeLiveSidebarAndIndex(t *testing.T) { if err := os.WriteFile(extra, []byte("---\ntitle: Fresh Note\n---\n\n# Fresh Note\n"), 0o644); err != nil { t.Fatalf("write fresh-note.md: %v", err) } - if code, body := get("/content/_sidebar.md"); code != http.StatusOK || !strings.Contains(body, "[Fresh Note](fresh-note.md)") { + if code, body := get("/content/_sidebar.md"); code != http.StatusOK || !strings.Contains(body, "[Fresh Note](/fresh-note.md)") { t.Fatalf("expected the live sidebar to pick up the new doc, status %d, body %q", code, body) } if _, body := get("/content/_sidebar.md"); !strings.Contains(body, "- **Reference**") { @@ -210,7 +227,7 @@ func TestServeSendsPolicyHeadersAndInertContentTypes(t *testing.T) { t.Fatal(err) } } - if _, err := GenerateViewer(dir, m); err != nil { + if _, err := viewer.GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } ln, srv, err := Serve(dir, 0, m.RootPath, nil) @@ -272,7 +289,7 @@ func TestServeSendsPolicyHeadersAndInertContentTypes(t *testing.T) { func TestServeRejectsForeignHost(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest - if _, err := GenerateViewer(dir, m); err != nil { + if _, err := viewer.GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } ln, srv, err := Serve(dir, 0, m.RootPath, nil) @@ -310,3 +327,182 @@ func TestServeRejectsForeignHost(t *testing.T) { } } } + +// --- link classes stay inside the router (serve half) --- +// The generation half — sidebar destinations emitted app-root absolute — is pinned +// in viewer_more_test.go. These pin what the server answers: an untouched document +// body, the routing config in the shipped boot script, the click paths off a nested +// page, and the not-found contract. + +// serveOnFreePort serves dir's viewer on a free loopback port, returning the base +// URL; the server is closed when the test ends. +func serveOnFreePort(t *testing.T, dir, rootRel string) string { + t.Helper() + ln, srv, err := Serve(dir, 0, rootRel, nil) + if err != nil { + t.Fatalf("Serve: %v", err) + } + t.Cleanup(func() { srv.Close() }) + go func() { _ = srv.Serve(ln) }() + return "http://" + ln.Addr().String() +} + +// fetch GETs url and returns the status and the full response body. +func fetch(t *testing.T, url string) (int, []byte) { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body of %s: %v", url, err) + } + return resp.StatusCode, body +} + +func TestServeLeavesDocumentBytesAlone(t *testing.T) { + dir := exampleCopy(t) + // One instance of every link class a real document mixes. Routing is config plus + // the generated sidebar, never a transform over the author's markdown, so the + // served bytes are the file's. How an image path resolves under relativePath is + // a separate item and is deliberately not asserted here. + body := strings.Join([]string{ + "# Links", + "", + "- [parent](../target.md)", + "- [sibling](sibling.md)", + "- [root](/root-target.md)", + "- [fragment](#fragment)", + "- [doc fragment](target.md#fragment)", + "- [query](target.md?q=1)", + "- [external](https://leji.org/spec)", + "", + "![x](assets/x.svg)", + "", + ``, + "", + }, "\n") + writeUnder(t, dir, "docs/notes/deep/links.md", body) + m := manifest.LoadManifest(dir).Manifest + if _, err := viewer.GenerateViewer(dir, m); err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + + code, served := fetch(t, base+"/content/notes/deep/links.md") + if code != http.StatusOK { + t.Fatalf("GET /content/notes/deep/links.md: status %d, want 200", code) + } + onDisk, err := os.ReadFile(filepath.Join(dir, "docs", "notes", "deep", "links.md")) + if err != nil { + t.Fatalf("read links.md: %v", err) + } + if !bytes.Equal(served, onDisk) { + t.Fatalf("the viewer never rewrites document markdown:\n got=%q\nwant=%q", served, onDisk) + } +} + +func TestRoutingConfigShipsServedAndBuilt(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + if _, err := viewer.GenerateViewer(dir, m); err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + // Both settings live in the boot script's static overlay, not the injected JSON + // config block, so the assertion is on the asset text. + assertRouting := func(boot, where string) { + t.Helper() + for _, want := range []*regexp.Regexp{ + regexp.MustCompile(`relativePath:\s*true`), + regexp.MustCompile(`notFoundPage:\s*false`), + } { + if !want.MatchString(boot) { + t.Fatalf("expected %s in the %s boot script", want, where) + } + } + } + base := serveOnFreePort(t, dir, m.RootPath) + code, boot := fetch(t, base+"/assets/viewer-boot.js") + if code != http.StatusOK { + t.Fatalf("GET /assets/viewer-boot.js: status %d, want 200", code) + } + assertRouting(string(boot), "served") + + if _, err := export.BuildViewer(dir, m, "out", export.Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + built, err := os.ReadFile(filepath.Join(dir, "out", "assets", "viewer-boot.js")) + if err != nil { + t.Fatalf("read the built boot script: %v", err) + } + assertRouting(string(built), "built") +} + +// resolveRoute resolves a markdown destination the way Docsify's relativePath +// routing does: against the linking document's own directory, except a +// leading-slash destination, which is app-root (content-root) absolute. +func resolveRoute(fromRel, dest string) string { + if strings.HasPrefix(dest, "/") { + return dest[1:] + } + return path.Join(path.Dir(fromRel), dest) +} + +func TestServeAnswersTheLinksANestedPageCarries(t *testing.T) { + dir := exampleCopy(t) + writeUnder(t, dir, "docs/practice/feature-workflow.md", "# Feature workflow\n") + writeUnder(t, dir, "docs/work/spec.md", "# Spec\n") + writeUnder(t, dir, "docs/work/README.md", strings.Join([]string{ + "# Work", + "", + "- [workflow](../practice/feature-workflow.md)", + "- [spec](spec.md)", + "- [glossary](/domain/glossary.md)", + "", + }, "\n")) + m := manifest.LoadManifest(dir).Manifest + if _, err := viewer.GenerateViewer(dir, m); err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + base := serveOnFreePort(t, dir, m.RootPath) + + for _, dest := range []string{"../practice/feature-workflow.md", "spec.md", "/domain/glossary.md"} { + target := resolveRoute("work/README.md", dest) + if code, _ := fetch(t, base+"/content/"+target); code != http.StatusOK { + t.Fatalf("%s routes to /content/%s: status %d, want 200", dest, target, code) + } + } + // The pre-fix escape: the same `../` destination resolved against the server + // root instead of the router. The server has no such route, which is exactly + // why the link must stay in-app. + if code, _ := fetch(t, base+"/practice/feature-workflow.md"); code != http.StatusNotFound { + t.Fatalf("leaving the router lands on a URL the server cannot answer: status %d, want 404", code) + } +} + +func TestServeUnknownDocumentRouteIsTheOnly404(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + res, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + // The config disables Docsify's secondary _404.md fetch (pinned by the routing + // config test above) and the viewer generates no such page. That the browser + // therefore makes exactly one failing request is verified at the browser level, + // not here. + if _, err := os.Stat(filepath.Join(dir, ".leji", "viewer", "_404.md")); err == nil { + t.Fatal("expected no _404.md in the generated viewer") + } + for _, w := range res.Written { + if strings.HasSuffix(w, "_404.md") { + t.Fatalf("_404.md is not written anywhere, got %q", w) + } + } + base := serveOnFreePort(t, dir, m.RootPath) + if code, _ := fetch(t, base+"/content/does-not-exist.md"); code != http.StatusNotFound { + t.Fatalf("the missing document itself is the one 404: status %d, want 404", code) + } +} diff --git a/packages/sdk-go/internal/commands/viewer/serve_test.go b/packages/sdk-go/internal/commands/serve/serve_test.go similarity index 99% rename from packages/sdk-go/internal/commands/viewer/serve_test.go rename to packages/sdk-go/internal/commands/serve/serve_test.go index db85e5f..9ee28dc 100644 --- a/packages/sdk-go/internal/commands/viewer/serve_test.go +++ b/packages/sdk-go/internal/commands/serve/serve_test.go @@ -1,4 +1,4 @@ -package viewer +package serve import ( "net/http" diff --git a/packages/sdk-go/internal/commands/status/status.go b/packages/sdk-go/internal/commands/status/status.go index 05115c1..8408f40 100644 --- a/packages/sdk-go/internal/commands/status/status.go +++ b/packages/sdk-go/internal/commands/status/status.go @@ -75,14 +75,10 @@ func isChrome(m *manifest.Manifest, rel string) bool { strings.ToLower(path.Base(rel)) == "readme.md" } -// StatusReport builds the health report. Pure computation; the CLI renders and decides exit. -func StatusReport(root string, m *manifest.Manifest) Report { - resolved := layer.ResolveCategoryAssignments(root, m, false) - governed := map[string]bool{} - for p := range resolved.Assignments { - governed[p] = true - } - +// unindexedIn is markdown under rootPath that no category index lists, given the +// governed set. The one definition of "unindexed"; callers that already resolved +// the assignments pass them in rather than resolving the tree twice. +func unindexedIn(root string, m *manifest.Manifest, governed map[string]bool) []string { rootDir := fsx.StripSlash(m.RootPath) if rootDir == "" { rootDir = "." @@ -95,6 +91,30 @@ func StatusReport(root string, m *manifest.Manifest) Report { unindexed = append(unindexed, rel) } sort.Strings(unindexed) + return unindexed +} + +// UnindexedPaths is the unindexed set on its own, for callers that need the count +// without the rest of the health report (the `index` generate nudge). Same +// machinery as StatusReport, no second walker. +func UnindexedPaths(root string, m *manifest.Manifest) []string { + resolved := layer.ResolveCategoryAssignments(root, m, false) + governed := map[string]bool{} + for p := range resolved.Assignments { + governed[p] = true + } + return unindexedIn(root, m, governed) +} + +// StatusReport builds the health report. Pure computation; the CLI renders and decides exit. +func StatusReport(root string, m *manifest.Manifest) (Report, error) { + resolved := layer.ResolveCategoryAssignments(root, m, false) + governed := map[string]bool{} + for p := range resolved.Assignments { + governed[p] = true + } + + unindexed := unindexedIn(root, m, governed) var dangling []DanglingEntry for _, f := range resolved.Findings { @@ -107,7 +127,10 @@ func StatusReport(root string, m *manifest.Manifest) Report { } } - stored := indexgen.LoadStoredIndex(root, m) + stored, err := indexgen.LoadStoredIndex(root, m) + if err != nil { + return Report{}, err + } var stale []string for _, e := range storedEntryPaths(stored) { if !governed[e] { @@ -158,7 +181,7 @@ func StatusReport(root string, m *manifest.Manifest) Report { skippedReadmes = nil } - return Report{Unindexed: unindexed, Dangling: dangling, Stale: stale, Pending: pending, Shadowed: shadowed, SkippedReadmes: skippedReadmes, Projection: mounts.ComputeSelfProjection(root)} + return Report{Unindexed: unindexed, Dangling: dangling, Stale: stale, Pending: pending, Shadowed: shadowed, SkippedReadmes: skippedReadmes, Projection: mounts.ComputeSelfProjection(root)}, nil } func storedEntryPaths(stored map[string]any) []string { diff --git a/packages/sdk-go/internal/commands/updatepin/updatepin.go b/packages/sdk-go/internal/commands/updatepin/updatepin.go new file mode 100644 index 0000000..ae89aea --- /dev/null +++ b/packages/sdk-go/internal/commands/updatepin/updatepin.go @@ -0,0 +1,454 @@ +// Package updatepin implements `leji mounts update-pin`: move ONE declared mount's +// pin forward to a commit the resolver has already witnessed, showing the +// comparison before anything is rewritten. Mirrors commands/mounts-update-pin.ts. +// +// Offline by default: the target is the last successfully observed witness, never a +// 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. +// +// The manifest is rewritten by replacing the addressed pin's own byte span +// (manifest.ReplaceMountPinInManifestText), never by reserializing, so the three +// SDKs produce byte-identical output over any accepted layout. +package updatepin + +import ( + "encoding/json" + "fmt" + "path/filepath" + "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/manifest" + "github.com/leji-org/leji/packages/sdk-go/internal/mounts" +) + +// Actions: what the run did. "refused" is a stated outcome, never a crash. +const ( + ActionUpdated = "updated" + ActionUnchanged = "unchanged" + ActionDryRun = "dry-run" + ActionRefused = "refused" +) + +// MountBlock is the `mount` object of the emitted document; nil pointers are JSON +// null. +type MountBlock struct { + Name string + SourceIdentity *string + // TrackingRef is the DECLARED tracking ref, never the default resolved under + // `--fetch` — that one is reported as PinReport.ComparedRef. + TrackingRef *string + From *string + To *string +} + +// Result is one `mounts update-pin` run. +type Result struct { + Mount MountBlock + PinReport *mounts.PinReport + Action string + Override bool + // Reason is a stable code, present only when the run refused. + Reason string + Findings []findings.Finding + // WriteError is an internal refusal with no document to report: the manifest + // parsed and validated, but the pin's own span could not be located or did not + // hold what the comparison was computed against. Exit 2. + WriteError string +} + +// Options mirror updatePinRun's opts. HasTo distinguishes an absent `--to` from an +// empty one, as the TS `undefined` does. +type Options struct { + Name string + To string + HasTo bool + AllowNonFastForward bool + Fetch bool + DryRun bool + // Now is the injectable observation clock, so tests and fixtures are stable; + // zero means the wall clock, read once per run. + Now time.Time +} + +// ShortOid is a pin at the length every human-facing line uses. +func ShortOid(oid string) string { + if len(oid) <= 12 { + return oid + } + return oid[:12] +} + +func strPtr(s string) *string { return &s } + +// declaredMount finds the addressed mount's declaration, or ok false. +func declaredMount(m *manifest.Manifest, name string) (mounts.MountDecl, bool) { + if m.Federation == nil { + return mounts.MountDecl{}, false + } + for _, mt := range m.Federation.Mounts { + if mt.Name == name { + return mounts.MountDecl{Name: mt.Name, Source: mt.Source, Pin: mt.Pin, TrackingRef: mt.TrackingRef}, true + } + } + return mounts.MountDecl{}, false +} + +// Run moves 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. +// The error return carries filesystem failures (TS exceptions). +func Run(root string, m *manifest.Manifest, opts Options) (Result, error) { + // One observation time for the whole run, as `status` takes one for its whole + // execution. + observedAt := mounts.NowISO() + if !opts.Now.IsZero() { + observedAt = opts.Now.UTC().Format("2006-01-02T15:04:05.000Z") + } + mount, declared := declaredMount(m, opts.Name) + + refuse := func(reason string, to *string, pinReport *mounts.PinReport) Result { + block := MountBlock{Name: opts.Name, To: to} + if declared { + if identity, ok := mounts.NormalizeSource(mount.Source); ok { + block.SourceIdentity = strPtr(identity) + } + if mount.TrackingRef != "" { + block.TrackingRef = strPtr(mount.TrackingRef) + } + block.From = strPtr(mount.Pin) + } + return Result{ + Mount: block, + PinReport: pinReport, + Action: ActionRefused, + Reason: reason, + Findings: []findings.Finding{findings.New(reason, findings.Error, reasonProse(reason), opts.Name)}, + } + } + + if !declared { + return refuse("mount-unknown", nil, nil), nil + } + + // (a) The declaration snapshot: the manifest's OWN values, kept for the + // freshness check the rewrite makes against the verified bytes. TrackingRef is + // snapshotted as declared — absent must stay absent — while the ref the + // comparison actually uses is tracked separately. Presence is carried beside the + // value because a Go string cannot hold it: the schema's ref pattern rejects an + // empty string, so a loaded declaration whose TrackingRef is "" is one the + // manifest did not spell. + declaration := declarationSnapshot{ + name: mount.Name, + source: mount.Source, + pin: mount.Pin, + trackingRef: mount.TrackingRef, + trackingRefPresent: mount.TrackingRef != "", + } + identity, idOK := mounts.NormalizeSource(mount.Source) + degraded := func(reason, comparedRef string) *mounts.PinReport { + rep := &mounts.PinReport{ + State: "unknown", + AncestryComplete: false, + Reason: reason, + ObservedAt: observedAt, + } + if comparedRef != "" { + rep.ComparedRef = strPtr(comparedRef) + } + return rep + } + if !idOK { + return refuse("mount-source-unnormalizable", nil, + degraded("mount-source-unnormalizable", mount.TrackingRef)), nil + } + + var effectiveRef string + switch { + case mount.TrackingRef != "": + if !mounts.ValidTrackingRef(mount.TrackingRef) { + return refuse("mount-tracking-ref-invalid", nil, + degraded("mount-tracking-ref-invalid", mount.TrackingRef)), nil + } + effectiveRef = mount.TrackingRef + case !opts.Fetch: + // Offline, the schema's "absent means the source's default branch" cannot be + // honoured: resolving it needs the network this run was not given. + return refuse("mount-no-tracking-ref", nil, degraded("mount-no-tracking-ref", "")), nil + default: + resolved, errKind := mounts.ResolveDefaultRef(mount.Source) + if errKind != "" || !mounts.ValidTrackingRef(resolved) { + return refuse("mount-default-ref-unavailable", nil, + degraded("mount-default-ref-unavailable", "")), nil + } + effectiveRef = resolved + } + + // (b i, ii) `--fetch`, declared source only, in order: retain the CURRENT pin so + // 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) + if err != nil { + return Result{}, err + } + if store == "" { + return refuse("mount-store-fetch-failed", nil, + degraded("mount-store-fetch-failed", effectiveRef)), nil + } + witnessMount := mount + witnessMount.TrackingRef = effectiveRef + if !mounts.RefreshWitness(store, witnessMount, identity) { + return refuse("mount-witness-refresh-failed", nil, + degraded("mount-witness-refresh-failed", effectiveRef)), nil + } + } + + // (c) The comparison repository and the ONE witness snapshot this run uses for + // the default target, the report, and the gate alike. + selection := mounts.SelectComparison(root, mount, effectiveRef) + if selection.Reason != "" { + return refuse(selection.Reason, nil, degraded(selection.Reason, effectiveRef)), nil + } + + // (d) The target: an explicit `--to` must be held by the repository the + // comparison ran in; otherwise the witness tip itself. + target := selection.TipOid + if opts.HasTo { + target = opts.To + if !mounts.RunGit([]string{"-C", selection.Repo, "cat-file", "-e", opts.To + "^{commit}"}, "").OK { + rep := degraded("mount-target-unavailable", effectiveRef) + rep.ComparisonRepository = strPtr(selection.ComparisonRepository) + rep.WitnessProvenance = strPtr(selection.WitnessProvenance) + return refuse("mount-target-unavailable", strPtr(opts.To), rep), nil + } + } + + // (e) The report, computed from the same snapshot `status` would report from. + comparison := mounts.ComparePins(selection.Repo, mount.Pin, selection.TipOid) + mountBlock := MountBlock{ + Name: mount.Name, + SourceIdentity: strPtr(identity), + From: strPtr(mount.Pin), + To: strPtr(target), + } + if mount.TrackingRef != "" { + mountBlock.TrackingRef = strPtr(mount.TrackingRef) + } + if comparison.Reason != "" { + rep := degraded(comparison.Reason, effectiveRef) + rep.ComparisonRepository = strPtr(selection.ComparisonRepository) + rep.WitnessProvenance = strPtr(selection.WitnessProvenance) + return refuse(comparison.Reason, strPtr(target), rep), nil + } + behind, ahead := comparison.Behind, comparison.Ahead + pinReport := &mounts.PinReport{ + State: comparison.State, + Behind: &behind, + Ahead: &ahead, + ComparedRef: strPtr(effectiveRef), + ComparisonRepository: strPtr(selection.ComparisonRepository), + WitnessProvenance: strPtr(selection.WitnessProvenance), + AncestryComplete: comparison.AncestryComplete, + ObservedAt: observedAt, + } + settled := func(action string, override bool, fs []findings.Finding) Result { + return Result{Mount: mountBlock, PinReport: pinReport, Action: action, Override: override, Findings: fs} + } + // 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 { + r := settled(ActionRefused, override, append( + []findings.Finding{findings.New(reason, findings.Error, reasonProse(reason), mount.Name)}, + warnings..., + )) + r.Reason = reason + return r + } + + // (f) The gate. Nothing to move is its own success, checked before ancestry: + // asking whether a commit is an ancestor of itself is not the question. + if target == mount.Pin { + return settled(ActionUnchanged, false, nil), nil + } + override := false + ancestor := mounts.RunGit([]string{"-C", selection.Repo, "merge-base", "--is-ancestor", mount.Pin, target}, "") + if !ancestor.OK { + // Exit 1 is the answer "no"; anything else is the repository unable to answer. + // A "no" from truncated history is not an answer either, so an incomplete + // repository never yields the not-fast-forward refusal — nor does the + // override bypass it. + if ancestor.Code != 1 || !comparison.AncestryComplete { + return refuseSettled("mount-ancestry-incomplete", false, nil), nil + } + if !opts.HasTo || !opts.AllowNonFastForward { + return refuseSettled("mount-pin-not-fast-forward", false, nil), nil + } + override = true + } + var warnings []findings.Finding + if override { + warnings = []findings.Finding{findings.New( + "mount-pin-non-fast-forward-override", + findings.Warning, + reasonProse("mount-pin-non-fast-forward-override"), + mount.Name, + )} + } + + // (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) + if err != nil { + return Result{}, err + } + if store == "" { + return refuseSettled("mount-store-fetch-failed", override, warnings), nil + } + } + + // (g) `--dry-run` stops here. The store and network acts `--fetch` was asked for + // have already happened; only the manifest rewrite is suppressed. + if opts.DryRun { + return settled(ActionDryRun, override, warnings), nil + } + + // (h) The rewrite, through the verified read the trust boundary requires. + rootReal := fsx.GuardRoot(root) + manifestAbs := filepath.Join(root, manifest.Filename) + read, err := fsx.VerifiedTargetRead(rootReal, manifestAbs, "") + if err != nil { + return Result{}, err + } + if read.Status != fsx.ReadRegular { + r := settled(ActionRefused, override, warnings) + r.WriteError = fmt.Sprintf("refusing to write through a symlink that escapes the target: %q", manifest.Filename) + return r, nil + } + original := string(read.Bytes) + // The bytes that were verified decide whether the declaration this comparison + // was computed against is still the declaration on disk. Containment says WHICH + // file was read; only this says it still says the same thing. + if !declarationUnchanged(original, declaration) { + return refuseSettled("mount-declaration-changed", override, warnings), nil + } + rewritten, changed, rerr := manifest.ReplaceMountPinInManifestText(original, mount.Name, mount.Pin, target) + if rerr != nil { + r := settled(ActionRefused, override, warnings) + r.WriteError = rerr.Error() + return r, nil + } + if changed { + verdict, werr := fsx.WriteFileAtomicGuarded(rootReal, manifestAbs, "", []byte(rewritten)) + if werr != nil { + return Result{}, werr + } + if !verdict.OK { + r := settled(ActionRefused, override, warnings) + r.WriteError = fmt.Sprintf("refusing to write outside the repository: %q", manifest.Filename) + return r, nil + } + } + return settled(ActionUpdated, override, warnings), nil +} + +// declarationSnapshot is the manifest's own values for the addressed mount at the +// moment the comparison was computed. `trackingRef` carries its PRESENCE beside its +// value: a Go string collapses an absent member and an empty one, and the two are +// different declarations. +type declarationSnapshot struct { + name string + source string + pin string + trackingRef string + trackingRefPresent bool +} + +// memberString reads one member of a mount object as a JSON string: whether the key +// is there at all, and — only when it is a string — its value. A member spelled +// `null`, or holding any non-string, is present with no string value, which is +// never equal to a declared one. +func memberString(members map[string]json.RawMessage, key string) (value string, present, isString bool) { + raw, present := members[key] + if !present { + return "", false, false + } + if err := json.Unmarshal(raw, &value); err != nil { + return "", true, false + } + return value, true, true +} + +// declarationUnchanged asks whether the verified manifest text still declares the +// mount this run compared. Only the four fields that decided the selected +// repository, the target and the splice are compared; ownership and routing +// metadata decide none of them. +// +// The members are read raw rather than through the manifest structs, so a member +// that was ABSENT and came back as `null` or `""` reads as the change it is instead +// of collapsing into the same empty string. +func declarationUnchanged(text string, declaration declarationSnapshot) bool { + var parsed struct { + Federation *struct { + Mounts []map[string]json.RawMessage `json:"mounts"` + } `json:"federation"` + } + if err := json.Unmarshal([]byte(text), &parsed); err != nil || parsed.Federation == nil { + return false + } + for _, members := range parsed.Federation.Mounts { + name, _, isString := memberString(members, "name") + if !isString || name != declaration.name { + continue + } + source, _, sourceIsString := memberString(members, "source") + pin, _, pinIsString := memberString(members, "pin") + if !sourceIsString || source != declaration.source || !pinIsString || pin != declaration.pin { + return false + } + // Absent must stay absent: under `--fetch` the ref actually used may be the + // source's advertised default, which the manifest never spelled. So presence + // is compared before the value, and a member that reappeared as `null` or `""` + // is a changed declaration, not an unchanged one. + trackingRef, trackingRefPresent, trackingRefIsString := memberString(members, "trackingRef") + if trackingRefPresent != declaration.trackingRefPresent { + return false + } + return !trackingRefPresent || (trackingRefIsString && trackingRef == declaration.trackingRef) + } + return false +} + +// reasonProse renders a stable reason code as the sentence a person reads; the code +// itself is what `--json` emits. +func reasonProse(reason string) string { + if prose, ok := Reasons[reason]; ok { + return prose + } + return reason +} + +// Reasons is 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. +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-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)", + "mount-declaration-changed": "leji.json changed while the comparison ran; nothing was written", + "mount-pin-non-fast-forward-override": "the pin was moved to a commit that is not a descendant of it", +} diff --git a/packages/sdk-go/internal/commands/validate/content_test.go b/packages/sdk-go/internal/commands/validate/content_test.go index dbe452a..2b068a9 100644 --- a/packages/sdk-go/internal/commands/validate/content_test.go +++ b/packages/sdk-go/internal/commands/validate/content_test.go @@ -45,13 +45,13 @@ func hasFinding(res validate.Result, rule, path string) bool { func TestContentThinCategoryBoundary(t *testing.T) { two := initLayer(t) writeFile(t, two, "docs/domain/glossary.md", "# Glossary\n\n- Real term one.\n- Real term two.\n") - if !hasFinding(validate.ValidateLayer(two, true), "content-thin", "docs/context/domain.md") { + if !hasFinding(validateLayer(t, two, true), "content-thin", "docs/context/domain.md") { t.Fatal("two concrete bullets should still be content-thin for docs/context/domain.md") } three := initLayer(t) writeFile(t, three, "docs/domain/glossary.md", "# Glossary\n\n- One.\n- Two.\n- Three.\n") - if hasFinding(validate.ValidateLayer(three, true), "content-thin", "docs/context/domain.md") { + if hasFinding(validateLayer(t, three, true), "content-thin", "docs/context/domain.md") { t.Fatal("three concrete bullets should clear the content-thin threshold") } } @@ -60,7 +60,7 @@ func TestContentThinCategoryBoundary(t *testing.T) { func TestContentPlaceholderAngleBracket(t *testing.T) { dir := initLayer(t) writeFile(t, dir, "docs/system/invariants.md", "# Invariants\n\n- \n") - if !hasFinding(validate.ValidateLayer(dir, true), "content-placeholder", "docs/system/invariants.md") { + if !hasFinding(validateLayer(t, dir, true), "content-placeholder", "docs/system/invariants.md") { t.Fatal("an angle-bracket placeholder should yield a content-placeholder finding for the doc") } } @@ -76,7 +76,7 @@ func TestContentUnconfirmedInferencesAndProposedDecisions(t *testing.T) { writeFile(t, dir, "docs/decisions/0002-proposed.md", "---\nid: use-postgres\ntitle: Use Postgres\nstatus: proposed\ndate: 2026-06-18\n---\n\n# Use Postgres\n\n## Context\nx\n## Decision\ny\n## Consequences\nz\n") - res := validate.ValidateLayer(dir, true) + res := validateLayer(t, dir, true) if !hasFinding(res, "content-unconfirmed", "docs/system/invariants.md") { t.Fatal("the TODO(confirm-…) marker should yield a content-unconfirmed finding") @@ -102,3 +102,17 @@ func TestContentUnconfirmedInferencesAndProposedDecisions(t *testing.T) { t.Fatal("TODO(confirm-…) must not also trip content-placeholder") } } + +// --- gate helpers ------------------------------------------------------------- +// These commands now carry an error channel, because an operational read failure on +// an allowed path propagates instead of being swallowed (the reference throws it). +// A test that does not construct such a failure asserts there is none. + +func validateLayer(t *testing.T, root string, content bool) validate.Result { + t.Helper() + res, err := validate.ValidateLayer(root, content) + if err != nil { + t.Fatalf("ValidateLayer(%s): %v", root, err) + } + return res +} diff --git a/packages/sdk-go/internal/commands/validate/validate.go b/packages/sdk-go/internal/commands/validate/validate.go index e7d24e9..5121a50 100644 --- a/packages/sdk-go/internal/commands/validate/validate.go +++ b/packages/sdk-go/internal/commands/validate/validate.go @@ -61,7 +61,7 @@ func checkBootProfile(root string, m *manifest.Manifest, fs *[]findings.Finding) return } bootAbs := filepath.Join(root, rel) - if !fsx.ResolvesUnder(root, bootAbs) { + if !fsx.ResolvedWithinRoot(root, bootAbs) { *fs = append(*fs, findings.New("path-escapes-root", findings.Error, "boot profile resolves outside the layer root", rel)) return } @@ -210,7 +210,7 @@ func checkVendorAdapters(root string, m *manifest.Manifest, fs *[]findings.Findi } // A vendor entrypoint that is a symlink resolving outside the layer root is // not read (matches adopt, which treats such files as absent). - if !fsx.ResolvesUnder(root, abs) { + if !fsx.ResolvedWithinRoot(root, abs) { continue } text, _ := fsx.ReadText(abs) @@ -290,7 +290,7 @@ func checkActors(root string, m *manifest.Manifest, fs *[]findings.Finding) { // Containment-checked like the other two implementations: a profile symlink // escaping the layer root must not be read, or a hostile link changes which // actor-conflict findings appear. - if !fsx.ResolvesUnder(root, abs) { + if !fsx.ResolvedWithinRoot(root, abs) { continue } text, _ := fsx.ReadText(abs) @@ -316,7 +316,7 @@ func checkAgentsMap(root string, m *manifest.Manifest, fs *[]findings.Finding) { continue } agentAbs := filepath.Join(root, rel) - if !fsx.ResolvesUnder(root, agentAbs) { + if !fsx.ResolvedWithinRoot(root, agentAbs) { *fs = append(*fs, findings.New("path-escapes-root", findings.Error, fmt.Sprintf("agents.%s profile resolves outside the layer root", role), rel)) continue @@ -347,7 +347,7 @@ func checkBootAgentsDefault(root string, m *manifest.Manifest, fs *[]findings.Fi return } bootAbs := filepath.Join(root, m.BootProfilePath) - if !fsx.IsFile(bootAbs) || !fsx.ResolvesUnder(root, bootAbs) { + if !fsx.IsFile(bootAbs) || !fsx.ResolvedWithinRoot(root, bootAbs) { return } boot, _ := fsx.ReadText(bootAbs) @@ -457,7 +457,7 @@ func MountSurfacingFindings(root string, m *manifest.Manifest) []findings.Findin rootAbs, _ := filepath.Abs(root) abs := filepath.Join(root, rel) text := "" - readable := fsx.IsFile(abs) && fsx.ResolvesUnder(rootAbs, abs) + readable := fsx.IsFile(abs) && fsx.ResolvedWithinRoot(rootAbs, abs) if readable { var err error text, err = fsx.ReadText(abs) @@ -989,7 +989,7 @@ func ContentFindings(root string, m *manifest.Manifest) []findings.Finding { // Confine the read: a symlinked boot profile escaping root is skipped (the // structural pass already flags it). Content lint is advisory. bootAbs := filepath.Join(root, bootRel) - if fsx.IsFile(bootAbs) && fsx.ResolvesUnder(root, bootAbs) { + if fsx.IsFile(bootAbs) && fsx.ResolvedWithinRoot(root, bootAbs) { boot, _ := fsx.ReadText(bootAbs) if placeholderRe.MatchString(boot) { out = append(out, findings.New("content-placeholder", findings.Warning, @@ -1064,12 +1064,12 @@ func ContentFindings(root string, m *manifest.Manifest) []findings.Finding { } // ValidateLayer runs the full layer validation; with content, appends the content lint. -func ValidateLayer(root string, content bool) Result { +func ValidateLayer(root string, content bool) (Result, error) { load := manifest.LoadManifest(root) m := load.Manifest fs := load.Findings if m == nil { - return Result{Findings: findings.Sort(fs), Manifest: nil} + return Result{Findings: findings.Sort(fs), Manifest: nil}, nil } level := manifest.ClaimedLevel(m) @@ -1119,7 +1119,11 @@ func ValidateLayer(root string, content bool) Result { for _, f := range fs { alreadyReported[layer.FindingKey(f)] = true } - for _, f := range indexgen.CheckIndex(root, m).Findings { + checked, cerr := indexgen.CheckIndex(root, m) + if cerr != nil { + return Result{}, cerr + } + for _, f := range checked.Findings { if !alreadyReported[layer.FindingKey(f)] { fs = append(fs, f) } @@ -1155,7 +1159,7 @@ func ValidateLayer(root string, content bool) Result { fs = append(fs, ContentFindings(root, m)...) } - return Result{Findings: findings.Sort(fs), Manifest: m} + return Result{Findings: findings.Sort(fs), Manifest: m}, nil } func sortedKeys(m map[string]string) []string { diff --git a/packages/sdk-go/internal/commands/validate/validate_reldroot_test.go b/packages/sdk-go/internal/commands/validate/validate_reldroot_test.go index 3bf20aa..3d83d6b 100644 --- a/packages/sdk-go/internal/commands/validate/validate_reldroot_test.go +++ b/packages/sdk-go/internal/commands/validate/validate_reldroot_test.go @@ -6,12 +6,11 @@ import ( "testing" initcmd "github.com/leji-org/leji/packages/sdk-go/internal/commands/init" - "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" "github.com/leji-org/leji/packages/sdk-go/internal/findings" ) // Regression: validating with root "." from the layer cwd must match an absolute -// root. Before the fix, fsx.ResolvesUnder compared an absolute realRoot against a +// root. Before the fix, fsx.ResolvedWithinRoot compared an absolute realRoot against a // relative target, so WalkMd excluded everything and every category reported a // spurious category-empty error. func TestValidateLayerRelativeRoot(t *testing.T) { @@ -29,7 +28,7 @@ func TestValidateLayerRelativeRoot(t *testing.T) { } defer func() { _ = os.Chdir(prev) }() - res := validate.ValidateLayer(".", false) + res := validateLayer(t, ".", false) for _, f := range res.Findings { if f.Severity == findings.Error { diff --git a/packages/sdk-go/internal/commands/viewer/port_test.go b/packages/sdk-go/internal/commands/viewer/port_test.go new file mode 100644 index 0000000..77f300b --- /dev/null +++ b/packages/sdk-go/internal/commands/viewer/port_test.go @@ -0,0 +1,21 @@ +package viewer + +import ( + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +func TestResolveViewerPort(t *testing.T) { + flag := 1234 + if got := ResolveViewerPort(&manifest.Manifest{}, &flag); got != 1234 { + t.Fatalf("flag port: got %d, want 1234", got) + } + mp := 4321 + if got := ResolveViewerPort(&manifest.Manifest{Viewer: &manifest.Viewer{Port: &mp}}, nil); got != 4321 { + t.Fatalf("manifest port: got %d, want 4321", got) + } + if got := ResolveViewerPort(&manifest.Manifest{}, nil); got != 5354 { + t.Fatalf("default port: got %d, want 5354", got) + } +} diff --git a/packages/sdk-go/internal/commands/viewer/viewer.go b/packages/sdk-go/internal/commands/viewer/viewer.go index 789f0fe..900a76a 100644 --- a/packages/sdk-go/internal/commands/viewer/viewer.go +++ b/packages/sdk-go/internal/commands/viewer/viewer.go @@ -1,24 +1,23 @@ -// Package viewer projects the context index into a static Docsify viewer and can -// serve the repository locally on 127.0.0.1. +// Package viewer projects the context index into a static Docsify viewer: the +// chrome generation and the layer helpers both of its consumers share. The two +// consumers live in their own packages, so what each one drags in is visible in the +// import graph rather than buried in one file: `commands/serve` keeps the local +// preview server and every network import with it, and `commands/export` writes the +// static site — its transitive import set carries no network package at all, which +// is the structural half of the export's no-network guarantee and is asserted as +// such. package viewer import ( "bytes" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "os" - "os/exec" + "io" + "math" "path" "path/filepath" "regexp" - "runtime" "sort" "strconv" "strings" - "sync" "unicode" "github.com/leji-org/leji/packages/sdk-go/internal/assets" @@ -28,6 +27,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/layer" + "github.com/leji-org/leji/packages/sdk-go/internal/layout" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/mounts" ) @@ -78,26 +78,41 @@ var categoryEmoji = map[string]string{ "decisions": "🧭", } -// defaultThemeColor is the default accent (Leji brand blue) when no -// viewer.theme.primary is set; defaultLogo is the vendored Leji mark. +// defaultThemeColor is the default accent (Leji brand green) when no +// viewer.theme.primary is set. +const defaultThemeColor = "#009F71" + +// The base every URL the generated chrome emits is written against: "/" for the +// local server (the app root, the served flavor's unchanged contract) and "" for +// an export, whose references then resolve against the page itself so the tree +// hosts correctly under a subpath. It is a generation parameter, never a post-hoc +// rewrite of emitted HTML: one code path, two invocations. index.html is the only +// artifact that exists in two flavors — everything else under the chrome is +// flavor-neutral. const ( - defaultThemeColor = "#223F93" - defaultLogo = "/assets/leji-logo.svg" + servedBase = "/" + ExportBase = "" ) +// defaultLogo is the vendored Leji mark, as the given base addresses it. +func defaultLogo(base string) string { return base + "assets/leji-logo.svg" } + // mermaidAssets are loaded only when mermaid is enabled. var mermaidAssets = map[string]bool{ "mermaid.min.js": true, "docsify-mermaid.js": true, } -// safeCSSColor matches a CSS color safe to hand to the page: a hex color or a -// bare color keyword. The accent reaches a stylesheet as a custom-property -// value, so anything with punctuation in it is a CSS-injection sink, not a color. -var safeCSSColor = regexp.MustCompile(`^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$`) +// safeCSSColor matches the one accent format the viewer accepts: a hex color at a +// length CSS actually defines (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). The accent reaches +// a stylesheet as a custom-property value, so anything with punctuation in it is a +// CSS-injection sink, not a color; hex-only also keeps one canonical form across the +// three SDKs and the schema. `$` here is end of text — Go's default, no multiline +// flag — so a trailing newline does not slip a hex through. +var safeCSSColor = regexp.MustCompile(`^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`) // resolveThemeColor returns the viewer accent: viewer.theme.primary when it is a -// plain CSS color, else the Leji default with a warning. Never the authored value +// hex color, else the Leji default with a warning. Never the authored value // unchecked. Mirrors the Node SDK's resolveThemeColor, warning included. func resolveThemeColor(m *manifest.Manifest, fnds *[]findings.Finding) string { if m.Viewer == nil || m.Viewer.Theme == nil || m.Viewer.Theme.Primary == "" { @@ -108,10 +123,101 @@ func resolveThemeColor(m *manifest.Manifest, fnds *[]findings.Finding) string { return configured } *fnds = append(*fnds, findings.NewNoPath("viewer-theme-invalid", findings.Warning, - `viewer.theme.primary "`+configured+`" is not a plain CSS color (hex or keyword); using `+defaultThemeColor)) + `viewer.theme.primary "`+configured+`" is not a hex color (#RGB, #RGBA, #RRGGBB, or #RRGGBBAA); using `+defaultThemeColor)) return defaultThemeColor } +// hexDigits matches the bare hex an accent reduces to once the leading `#` is out +// of the way; the length check follows. +var hexDigits = regexp.MustCompile(`^[0-9a-f]+$`) + +// srgb is an opaque sRGB triple: the form an accent resolves to once any alpha it +// carried has been composited away. +type srgb struct{ r, g, b int } + +// parseAccentColor resolves the accent to opaque sRGB channels, reporting false for +// a value that names no color the generator can resolve — a keyword, `currentColor`, +// a malformed hex. Accepts 3/4/6/8-digit hex, the only form the accent can take; an +// accent carrying alpha is composited over white, the viewer's content background, +// which is the only backdrop knowable at generation time (the accent itself keeps +// its authored alpha everywhere it is used — this composite decides text color, +// nothing that renders). +func parseAccentColor(value string) (srgb, bool) { + raw := strings.ToLower(strings.TrimSpace(value)) + hex, ok := strings.CutPrefix(raw, "#") + if !ok || !hexDigits.MatchString(hex) { + return srgb{}, false + } + var full string + switch len(hex) { + case 3, 4: + var doubled strings.Builder + for _, c := range hex { + doubled.WriteRune(c) + doubled.WriteRune(c) + } + full = doubled.String() + case 6, 8: + full = hex + default: + return srgb{}, false + } + channel := func(i int) int { + v, _ := strconv.ParseUint(full[i*2:i*2+2], 16, 16) + return int(v) + } + alpha := 1.0 + if len(full) == 8 { + alpha = float64(channel(3)) / 255 + } + over := func(c int) int { + return int(math.Round(float64(c)*alpha + 255*(1-alpha))) + } + return srgb{over(channel(0)), over(channel(1)), over(channel(2))}, true +} + +// relativeLuminance is the WCAG relative luminance: linearized sRGB channels, weighted. +func relativeLuminance(c srgb) float64 { + linear := func(v int) float64 { + s := float64(v) / 255 + if s <= 0.03928 { + return s / 12.92 + } + return math.Pow((s+0.055)/1.055, 2.4) + } + return 0.2126*linear(c.r) + 0.7152*linear(c.g) + 0.0722*linear(c.b) +} + +// contrastRatio is the WCAG contrast ratio between two relative luminances. +func contrastRatio(a, b float64) float64 { + return (math.Max(a, b) + 0.05) / (math.Min(a, b) + 0.05) +} + +// mermaidTextColor is the mermaid node-text color for an accent, computed here +// rather than in the browser: the viewer's boot script sees only what the config +// block carries, while this side can resolve every color form viewer.theme.primary +// accepts. Whichever of #1a1a1a and #ffffff contrasts more with the accent, or +// #000000 when neither clears WCAG AA (4.5:1) — a mid-gray accent, where the extra +// half-stop of black is the best text color available. An accent this cannot +// resolve keeps the dark default, which is also the boot script's fallback. +// Mirrors the Node SDK's mermaidTextColor. +func mermaidTextColor(themeColor string) string { + c, ok := parseAccentColor(themeColor) + if !ok { + return "#1a1a1a" + } + accent := relativeLuminance(c) + onDark := contrastRatio(relativeLuminance(srgb{0x1a, 0x1a, 0x1a}), accent) + onLight := contrastRatio(1, accent) + if onDark < 4.5 && onLight < 4.5 { + return "#000000" + } + if onDark >= onLight { + return "#1a1a1a" + } + return "#ffffff" +} + var httpURLRe = regexp.MustCompile(`^https?://`) // placeholderRe matches the template's `{{NAME}}` substitution sites. @@ -132,7 +238,7 @@ func resolveViewerRel(root, rootPath, value string) (string, bool) { if fsx.IsFile(joined) { return clean, true } - if stripped, ok := relativeToRoot(clean, rootPath); ok && fsx.IsFile(filepath.Join(root, clean)) { + if stripped, ok := RelativeToRoot(clean, rootPath); ok && fsx.IsFile(filepath.Join(root, clean)) { return stripped, true } return "", false @@ -159,17 +265,17 @@ func effectiveHomepage(root string, m *manifest.Manifest, fnds *[]findings.Findi // resolveLogo resolves the viewer logo URL: a configured path is served from the // content mount (or used as-is when absolute); unset falls back to the vendored mark. -func resolveLogo(root, rootPath, logo string) string { +func resolveLogo(root, rootPath, logo, base string) string { if logo == "" { - return defaultLogo + return defaultLogo(base) } if strings.HasPrefix(logo, "/") || httpURLRe.MatchString(logo) { return logo } if rel, ok := resolveViewerRel(root, rootPath, logo); ok { - return "/content/" + rel + return base + "content/" + rel } - return "/content/" + fsx.StripSlash(logo) + return base + "content/" + fsx.StripSlash(logo) } // htmlEscape escapes text for HTML element/attribute content, matching the Node @@ -184,7 +290,7 @@ func htmlEscape(s string) string { return s } -func relativeToRoot(relPath, rootPath string) (string, bool) { +func RelativeToRoot(relPath, rootPath string) (string, bool) { base := fsx.StripSlash(rootPath) if base == "" || base == "." { return relPath, true @@ -209,8 +315,19 @@ func mdLinkText(s string) string { return mdLinkTextRe.ReplaceAllString(s, `\$0`) } +// Destinations are emitted app-root absolute (leading slash): with the viewer's +// relativePath routing, a bare rootPath-relative destination would re-resolve +// against whatever nested route is current and double-prefix; leading-slash links +// are exempt from relative resolution by Docsify's contract. Idempotent: leading +// slashes are trimmed first, so an already-absolute destination never becomes +// `//…`, which Docsify routes as an external protocol-relative URL. Empty input +// stays empty, never a bare `/`. func mdLinkDest(s string) string { - return mdLinkDestRe.ReplaceAllString(s, `\$0`) + escaped := mdLinkDestRe.ReplaceAllString(strings.TrimLeft(s, "/"), `\$0`) + if escaped == "" { + return "" + } + return "/" + escaped } // TreeNode is a reference doc in the browse zone: rootPath-relative path and title. @@ -361,7 +478,7 @@ func BuildSidebar(m *manifest.Manifest, groups []SidebarGroup, tree []TreeNode, // Emoji inside the link text so the label stays on one line (links render as // block elements; an emoji outside would wrap above). A pinned boot profile // replaces this default line with the team's own label and position. - if boot, ok := relativeToRoot(m.BootProfilePath, m.RootPath); ok && !bootPinned { + if boot, ok := RelativeToRoot(m.BootProfilePath, m.RootPath); ok && !bootPinned { topLines = append(topLines, "- ["+bootEmoji+" Boot profile]("+mdLinkDest(boot)+")") } for _, pin := range pins { @@ -553,7 +670,7 @@ func BuildSidebarGroups(root string, m *manifest.Manifest, entries []indexgen.In if !byPath[relPath] { continue } - rel, ok := relativeToRoot(relPath, m.RootPath) + rel, ok := RelativeToRoot(relPath, m.RootPath) if !ok { continue } @@ -570,10 +687,16 @@ func BuildSidebarGroups(root string, m *manifest.Manifest, entries []indexgen.In // viewer.agentsLabel; first in derived order, reorderable by groupOrder). var agentMembers []SidebarEntry for _, p := range layer.ScanAgentProfiles(root, m) { - rel, ok := relativeToRoot(p.RelPath, m.RootPath) + rel, ok := RelativeToRoot(p.RelPath, m.RootPath) if !ok { continue } + // A declared profiles directory can name a private role; its files are not + // servable, so neither is the label lifted out of one. The route would 404 + // anyway — this keeps the bytes out of the sidebar that links it. + if !servableSource(root, p.RelPath) { + continue + } title := "" if p.Frontmatter != nil { if n, isStr := p.Frontmatter["name"].(string); isStr && strings.TrimSpace(n) != "" { @@ -709,9 +832,9 @@ func sidebarLabel(root, relPath, rootRel string) string { // 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). The -// `.leji` viewer dir is skipped by the walk itself. Returned as rootPath-relative -// nodes. +// profiles, category index files, overview.md, the generated _sidebar.md). +// Generated artifacts live in the root `.leji/`, which the walk skips as a dot-dir +// even when rootPath is ".". Returned as rootPath-relative nodes. func referenceTree(root string, m *manifest.Manifest, governedPaths map[string]bool) []TreeNode { rootDirRel := fsx.StripSlash(m.RootPath) if rootDirRel == "" { @@ -751,7 +874,7 @@ func referenceTree(root string, m *manifest.Manifest, governedPaths map[string]b if rel == overviewRel || rel == sidebarRel || rel == manifestPageRel { continue } - r, ok := relativeToRoot(rel, m.RootPath) + r, ok := RelativeToRoot(rel, m.RootPath) if !ok { continue } @@ -942,11 +1065,11 @@ func buildManifestPage(m *manifest.Manifest, statuses []mounts.StatusResult) str title = m.Viewer.Title } lines := []string{ - "# " + esc(title) + " — Manifest", + "# " + esc(title) + ": Manifest", "", "A human-readable view of this layer's `leji.json`.", "", - "> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects — no network fetch is performed.", + "> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed.", "", "## Identity", "", @@ -970,7 +1093,7 @@ func buildManifestPage(m *manifest.Manifest, statuses []mounts.StatusResult) str claimed = m.Conformance.ClaimedLevel } if claimed != "" { - lines = append(lines, "| Conformance | claims `"+esc(claimed)+"` — run `leji conformance` to verify |") + lines = append(lines, "| Conformance | claims `"+esc(claimed)+"` (run `leji conformance` to verify) |") } else { lines = append(lines, "| Conformance | no level claimed |") } @@ -1125,7 +1248,7 @@ func buildManifestPage(m *manifest.Manifest, statuses []mounts.StatusResult) str } lines = append(lines, "| "+esc(d.Name)+" | "+availability+" | "+drift+" | "+ownerCell+" | "+pinCell+" | "+sourceCell+" |") } - lines = append(lines, "", "> `not hydrated` / `unknown` are normal degraded reads — ordinary validation never fails just because a mount is unavailable (opt-in federation enforcement is separate). Run `leji mounts hydrate`, then regenerate the viewer to refresh.") + lines = append(lines, "", "> `not hydrated` / `unknown` are normal degraded reads; ordinary validation never fails just because a mount is unavailable (opt-in federation enforcement is separate). Run `leji mounts hydrate`, then regenerate the viewer to refresh.") var roled []manifest.Mount for _, d := range mountList { if d.Role != "" { @@ -1135,7 +1258,7 @@ func buildManifestPage(m *manifest.Manifest, statuses []mounts.StatusResult) str if len(roled) > 0 { lines = append(lines, "", "**Roles**", "") for _, d := range roled { - lines = append(lines, "- **"+esc(d.Name)+"** — "+esc(d.Role)) + lines = append(lines, "- **"+esc(d.Name)+"**: "+esc(d.Role)) } } } @@ -1160,12 +1283,12 @@ func profileValue(value any) string { return codeSpan(string(encoded)) } -// unresolvedProfilePage is the page for an agent profile that declares `inherits` +// UnresolvedProfilePage is the page for an agent profile that declares `inherits` // and does not resolve: there is no effective profile to show, and presenting the // derived file as if there were would be the error the finding names. -func unresolvedProfilePage(relPath string, fnds []findings.Finding) string { +func UnresolvedProfilePage(relPath string, fnds []findings.Finding) string { lines := []string{ - "# " + esc(relPath) + " — unresolved profile", + "# " + esc(relPath) + ": unresolved profile", "", "> **This profile does not resolve.** " + codeSpan(relPath) + " declares `inherits`, and the inheritance cannot be resolved, so the layer has no effective profile for this role. The file on disk is only its own half and is not shown here: a consumer that cannot resolve an inherited profile must not apply the derived file alone.", "", @@ -1199,7 +1322,7 @@ func renderResolvedProfile(profiles []layer.ScannedProfile, derived layer.Scanne } resolved := layer.ResolveAgentProfile(derived, profiles) if resolved.Frontmatter == nil || resolved.Body == nil { - return unresolvedProfilePage(derived.RelPath, resolved.Findings) + return UnresolvedProfilePage(derived.RelPath, resolved.Findings) } baseID := "" @@ -1221,7 +1344,7 @@ func renderResolvedProfile(profiles []layer.ScannedProfile, derived layer.Scanne title = name } lines := []string{ - "# " + esc(title) + " — resolved profile", + "# " + esc(title) + ": resolved profile", "", "> **Resolved profile.** " + codeSpan(derived.RelPath) + " declares `inherits: " + esc(baseID) + "`, so this page is the effective profile: posture from " + codeSpan(baseRel) + " first, then this profile's own, with exact duplicates dropped. Every other field is this profile's own; both bodies are operative, base first. The file on disk carries only its own half.", "", @@ -1234,7 +1357,7 @@ func renderResolvedProfile(profiles []layer.ScannedProfile, derived layer.Scanne value := effective[key] entries, isArray := value.([]any) if !isArray { - lines = append(lines, "- **"+esc(key)+"** — "+profileValue(value)) + lines = append(lines, "- **"+esc(key)+"**: "+profileValue(value)) continue } // Composed posture: label every entry with the profile that supplied it. @@ -1253,7 +1376,7 @@ func renderResolvedProfile(profiles []layer.ScannedProfile, derived layer.Scanne if fromBase[stringifyEntry(entry)] { source = baseID } - lines = append(lines, " - "+profileValue(entry)+" — from `"+esc(source)+"`") + lines = append(lines, " - "+profileValue(entry)+" (from `"+esc(source)+"`)") } } lines = append(lines, "", "## Effective body", "") @@ -1279,17 +1402,17 @@ func stringifyEntry(v any) string { return string(encoded) } -// declaresInherits reports whether the file at repoRel declares `inherits`, so it +// DeclaresInherits reports whether the file at repoRel declares `inherits`, so it // is one half of a profile and must never reach a reader as the effective one. // Manifest-free and total, so the serve path can still classify when nothing else // is readable. -func declaresInherits(root, repoRel string) bool { +func DeclaresInherits(root, repoRel string) bool { rootAbs, err := filepath.Abs(root) if err != nil { return false } abs := filepath.Join(root, repoRel) - if !fsx.IsFile(abs) || !fsx.ResolvesUnder(rootAbs, abs) { + if !fsx.IsFile(abs) || !fsx.ResolvedWithinRoot(rootAbs, abs) { return false } text, err := fsx.ReadText(abs) @@ -1300,6 +1423,74 @@ func declaresInherits(root, repoRel string) bool { return ok } +// servableSource reports whether the layer file at repoRel may be read into +// something served or exported: judged by the servable-roots whitelist as requested +// AND after symlink resolution, the same pair of checks serveFrom makes on a +// response. A path that resolves into a private `.leji/` role fails, however it was +// spelled. +func servableSource(root, repoRel string) bool { + abs, err := filepath.Abs(root) + if err != nil { + return false + } + rootAbs, ok := fsx.ResolvedPath(abs) + if !ok { + return false + } + target := filepath.Join(rootAbs, filepath.FromSlash(repoRel)) + if !layout.ServablePath(rootAbs, target) { + return false + } + real, ok := fsx.ResolvedPathUnder(rootAbs, target) + return ok && layout.ServablePath(rootAbs, real) +} + +// servableProfileText is a profile source read the way check-before-act requires: the requested +// path is judged, its RESOLVED path is judged, and the bytes come from the descriptor +// opened on that resolved path and proved a regular file — so nothing swapped between +// the check and the read (a file, or any directory above it, becoming a symlink) +// changes what is composed into a served or exported page. ok is false for anything +// refused. +func servableProfileText(rootAbs, repoRel string) (string, bool) { + abs := filepath.Join(rootAbs, filepath.FromSlash(repoRel)) + if !layout.ServablePath(rootAbs, abs) { + return "", false + } + src, err := fsx.OpenVerifiedSource(abs, func(real string) bool { + return layout.ServablePath(rootAbs, real) && + (real == rootAbs || strings.HasPrefix(real, rootAbs+string(filepath.Separator))) + }) + if err != nil || src.File == nil { + return "", false + } + defer func() { _ = src.File.Close() }() + body, rerr := io.ReadAll(src.File) + if rerr != nil { + return "", false + } + return string(body), true +} + +// servableProfileSet is the profile set as the viewer may render it: every source +// read through servableProfileText, so no profile living in — or symlinked into — a +// private `.leji/` role is composed into a served page or an exported one, and the +// bytes composed are the bytes that passed the check. Dropped silently, exactly as +// the content walk drops unservable content; the scan itself stays total, so +// validation still reports on those files. +func servableProfileSet(root string, m *manifest.Manifest) []layer.ScannedProfile { + abs, err := filepath.Abs(root) + if err != nil { + return nil + } + rootAbs, ok := fsx.ResolvedPath(abs) + if !ok { + return nil + } + return layer.ScanProfileSetWith(root, m, func(relPath string) (string, bool) { + return servableProfileText(rootAbs, relPath) + }) +} + // ResolvedProfilePage is the page for repoRel when it is an agent profile that // declares `inherits`, else ok=false (every other document is served from disk as // authored). @@ -1323,41 +1514,48 @@ func ResolvedProfilePage(root string, m *manifest.Manifest, repoRel string) (str if !bound && !fsx.UnderPath(repoRel, manifest.EffectiveAgentProfilesPath(m)) { return "", false } - if !declaresInherits(root, repoRel) { + // The whitelist, judged before this file is read into a page: a profile that + // resolves into a private `.leji/` role is not the viewer's to render. Falling + // through hands the request back to the content walk, which refuses it the same + // way it refuses any unservable file — this branch never becomes the way in. + if !servableSource(root, repoRel) { return "", false } - profiles := layer.ScanProfileSet(root, m) + if !DeclaresInherits(root, repoRel) { + return "", false + } + profiles := servableProfileSet(root, m) for _, p := range profiles { if p.RelPath == repoRel { return renderResolvedProfile(profiles, p), true } } - return unresolvedProfilePage(repoRel, []findings.Finding{ + return UnresolvedProfilePage(repoRel, []findings.Finding{ findings.New("artifact-parse", findings.Error, "the profile scan did not reach this file", repoRel), }), true } -// resolvedProfilePage is one inheriting profile's rootPath-relative viewer path -// and resolved page. -type resolvedPage struct { - rel string - page string +// ResolvedPage is one inheriting profile's rootPath-relative viewer path and +// resolved page. +type ResolvedPage struct { + Rel string + Page string } -// resolvedProfilePages is every inheriting profile as its rootPath-relative viewer +// ResolvedProfilePages is every inheriting profile as its rootPath-relative viewer // path and resolved page, so a static export carries what the local server renders. -func resolvedProfilePages(root string, m *manifest.Manifest) []resolvedPage { - profiles := layer.ScanProfileSet(root, m) - var out []resolvedPage +func ResolvedProfilePages(root string, m *manifest.Manifest) []ResolvedPage { + profiles := servableProfileSet(root, m) + var out []ResolvedPage for _, p := range profiles { if _, ok := p.Frontmatter["inherits"].(string); !ok { continue } - rel, ok := relativeToRoot(p.RelPath, m.RootPath) + rel, ok := RelativeToRoot(p.RelPath, m.RootPath) if !ok { continue // outside the context root: not servable } - out = append(out, resolvedPage{rel: rel, page: renderResolvedProfile(profiles, p)}) + out = append(out, ResolvedPage{Rel: rel, Page: renderResolvedProfile(profiles, p)}) } return out } @@ -1379,7 +1577,7 @@ func scriptSafeJSON(b []byte) string { // key order (JSON.stringify of the object literal), then script-escapes it. // Appends the homepage viewer-path-missing warning to fnds when the configured // homepage does not resolve. -func docsifyConfigJSON(root string, m *manifest.Manifest, nameHTML string, fnds *[]findings.Finding) (string, error) { +func docsifyConfigJSON(root string, m *manifest.Manifest, nameHTML, base string, fnds *[]findings.Finding) (string, error) { var b bytes.Buffer writeStr := func(s string) error { enc, err := jsonenc.Marshal(s) @@ -1400,22 +1598,30 @@ func docsifyConfigJSON(root string, m *manifest.Manifest, nameHTML string, fnds if err := writeStr(nameHTML); err != nil { return "", err } + // Where the layer's markdown is mounted. Docsify's own key, so the boot script + // configures the router from it rather than hardcoding a root: '/content/' + // served, 'content/' exported (resolved against the page, so the tree hosts + // under any subpath). + b.WriteString(`,"basePath":`) + if err := writeStr(base + "content/"); err != nil { + return "", err + } // Hash navigation for the logo/title link: #/ re-routes to the homepage // inside the SPA instead of a full page reload. b.WriteString(`,"nameLink":"#/"`) // Per-page classification badge (top-right chip): the boot script resolves // the current route against the served index using these. - idxRel, idxOK := relativeToRoot(manifest.EffectiveIndexPath(m), m.RootPath) + idxRel, idxOK := RelativeToRoot(manifest.EffectiveIndexPath(m), m.RootPath) b.WriteString(`,"lejiIndexRel":`) if err := writeNullable(idxRel, idxOK); err != nil { return "", err } - bootRel, bootOK := relativeToRoot(m.BootProfilePath, m.RootPath) + bootRel, bootOK := RelativeToRoot(m.BootProfilePath, m.RootPath) b.WriteString(`,"lejiBootPath":`) if err := writeNullable(bootRel, bootOK); err != nil { return "", err } - agentsRel, agentsOK := relativeToRoot(manifest.EffectiveAgentProfilesPath(m), m.RootPath) + agentsRel, agentsOK := RelativeToRoot(manifest.EffectiveAgentProfilesPath(m), m.RootPath) b.WriteString(`,"lejiAgentsPrefix":`) if err := writeNullable(agentsRel, agentsOK); err != nil { return "", err @@ -1460,6 +1666,14 @@ func docsifyConfigJSON(root string, m *manifest.Manifest, nameHTML string, fnds if err := writeStr(theme); err != nil { return "", err } + // Mermaid node text, readable against the accent. Computed here because this + // side resolves every accepted color form; the boot script's own hex-only + // fallback covers viewer trees generated before this field. Leji's own key, + // not one Docsify reads, hence the prefix. + b.WriteString(`,"lejiMermaidTextColor":`) + if err := writeStr(mermaidTextColor(theme)); err != nil { + return "", err + } // Read by the boot script's powered-by plugin; false removes the mark. powered := !(m.Viewer != nil && m.Viewer.PoweredBy != nil && !*m.Viewer.PoweredBy) b.WriteString(`,"lejiPoweredBy":`) @@ -1468,12 +1682,12 @@ func docsifyConfigJSON(root string, m *manifest.Manifest, nameHTML string, fnds return scriptSafeJSON(b.Bytes()), nil } -// assembleSidebar assembles the current sidebar for a layer entirely in memory: +// AssembleSidebar assembles the current sidebar for a layer entirely in memory: // pins (with boot-pin replacement), pin-filtered groups, and the // homepage-excluded reference tree. Used by generation and by the serve path, // which rebuilds it per fetch so a long-running viewer never shows a deleted or // moved document. -func assembleSidebar(root string, m *manifest.Manifest, entries []indexgen.IndexEntry, fnds *[]findings.Finding) string { +func AssembleSidebar(root string, m *manifest.Manifest, entries []indexgen.IndexEntry, fnds *[]findings.Finding) string { governedPaths := map[string]bool{} for _, e := range entries { governedPaths[e.Path] = true @@ -1492,7 +1706,7 @@ func assembleSidebar(root string, m *manifest.Manifest, entries []indexgen.Index // Pins are repo-relative canonically; a rootPath-relative pin under the // context root is accepted too (same tolerance as homepage/logo/favicon). // repoRel tracks where the file actually lives for reads and comparisons. - rel, ok := relativeToRoot(pinPath, m.RootPath) + rel, ok := RelativeToRoot(pinPath, m.RootPath) repoRel := pinPath if !ok || !fsx.IsFile(filepath.Join(root, pinPath)) { base := fsx.StripSlash(m.RootPath) @@ -1560,28 +1774,16 @@ func assembleSidebar(root string, m *manifest.Manifest, entries []indexgen.Index return BuildSidebar(m, groups, tree, pins, bootPinned) } -// GenerateViewer writes index.html, _sidebar.md, and the vendored assets into the -// context root: 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) { - result := indexgen.GenerateIndex(root, m) - // 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. - for _, f := range result.Findings { - if f.Severity == findings.Error { - return Result{Written: nil, Findings: result.Findings, Entries: 0}, nil - } - } - var entries []indexgen.IndexEntry - if result.Index != nil { - entries = result.Index.Entries - } - var findingsEarly []findings.Finding - +// BuildIndexHTML is the SPA shell for one flavor of the chrome: the template with +// this layer's config baked in, every URL it emits written against base. The served +// flavor ("/") and the export flavor ("") come from this one function, so the export +// never gets its HTML rewritten after the fact. fnds collects the two resolution +// warnings (homepage, accent) in their established order; the export invocation +// discards them, having already reported the generation run's. +func BuildIndexHTML(root string, m *manifest.Manifest, base string, fnds *[]findings.Finding) (string, error) { htmlBytes, err := assets.FS.ReadFile("templates/viewer/index.html") if err != nil { - return Result{}, err + return "", err } // Display title: viewer.title override, else the context layer name. displayTitle := m.Name @@ -1598,7 +1800,7 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { logo = m.Viewer.Logo favicon = m.Viewer.Favicon } - logoURL := htmlEscape(resolveLogo(root, m.RootPath, logo)) + logoURL := htmlEscape(resolveLogo(root, m.RootPath, logo, base)) var nameHTML string if logo != "" { nameHTML = `` + htmlEscape(displayTitle) + `` @@ -1607,23 +1809,22 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { } // Favicon: a configured path is served from the content mount; unset falls back // to the vendored Leji mark. - faviconURL := htmlEscape(defaultLogo) + faviconURL := htmlEscape(defaultLogo(base)) if favicon != "" { rel, ok := resolveViewerRel(root, m.RootPath, favicon) if !ok { rel = fsx.StripSlash(favicon) } - faviconURL = htmlEscape("/content/" + rel) + faviconURL = htmlEscape(base + "content/" + rel) } - config, err := docsifyConfigJSON(root, m, nameHTML, &findingsEarly) + config, err := docsifyConfigJSON(root, m, nameHTML, base, fnds) if err != nil { - return Result{}, err + return "", err } // Mermaid is on unless explicitly disabled. When off, the scripts are omitted // and their assets not copied (~3MB smaller viewer). - mermaidEnabled := m.Viewer == nil || m.Viewer.Mermaid == nil || *m.Viewer.Mermaid mermaidScripts := "" - if mermaidEnabled { + if mermaidEnabled(m) { mermaidScripts = "\n " + "\n " } @@ -1637,24 +1838,55 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { "DOCSIFY_CONFIG": config, "MERMAID_SCRIPTS": mermaidScripts, } - page := placeholderRe.ReplaceAllStringFunc(string(htmlBytes), func(whole string) string { + return placeholderRe.ReplaceAllStringFunc(string(htmlBytes), func(whole string) string { if v, ok := substitutions[whole[2:len(whole)-2]]; ok { return v } return whole - }) - sidebar := assembleSidebar(root, m, entries, &findingsEarly) + }), nil +} + +// mermaidEnabled reports whether the layer keeps mermaid on (the default). +func mermaidEnabled(m *manifest.Manifest) bool { + return m.Viewer == nil || m.Viewer.Mermaid == nil || *m.Viewer.Mermaid +} + +// GenerateViewer writes index.html, _sidebar.md, and the vendored assets into the +// 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) { + result, err := indexgen.GenerateIndex(root, m) + if err != nil { + return Result{}, err + } + // 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. + for _, f := range result.Findings { + if f.Severity == findings.Error { + return Result{Written: nil, Findings: result.Findings, Entries: 0}, nil + } + } + var entries []indexgen.IndexEntry + if result.Index != nil { + entries = result.Index.Entries + } + var findingsEarly []findings.Finding + + // The served flavor: the chrome under `.leji/viewer/` is never export-flavored. + page, err := BuildIndexHTML(root, m, servedBase, &findingsEarly) + if err != nil { + return Result{}, err + } + sidebar := AssembleSidebar(root, m, entries, &findingsEarly) rootDir := fsx.StripSlash(m.RootPath) if rootDir == "" { rootDir = "." } - - // The viewer is contained under rootPath/.leji/viewer/ (gitignored) so it never - // collides with the user's own files in the context root. - viewerDir := ".leji/viewer" - if rootDir != "." { - viewerDir = rootDir + "/.leji/viewer" + rootAbs, err := filepath.Abs(root) + if err != nil { + return Result{}, err } var written []string @@ -1676,7 +1908,8 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { if e.IsDir() || e.Name() == "PROVENANCE.txt" || strings.HasPrefix(e.Name(), ".") { continue } - if !mermaidEnabled && mermaidAssets[e.Name()] { + // Mermaid off omits its two scripts from the page and their assets here (~3MB). + if !mermaidEnabled(m) && mermaidAssets[e.Name()] { continue } content, err := assets.FS.ReadFile("templates/viewer/assets/" + e.Name()) @@ -1690,59 +1923,124 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { } findingList := append([]findings.Finding{}, result.Findings...) findingList = append(findingList, findingsEarly...) - // Refuse to write through a symlink that escapes the layer root. ResolvesUnder - // resolves the nearest existing ancestor, so a not-yet-existing target under a - // symlinked directory is caught before mkdir/write can escape. An escaping target - // is skipped with an error finding rather than aborting, mirroring Node's writeWithin. - for _, f := range files { - rel := viewerDir + "/" + f.name - abs := filepath.Join(root, rel) - if !fsx.ResolvesUnder(root, abs) { - findingList = append(findingList, findings.New("artifact-parse", findings.Error, - "viewer path "+rel+" resolves outside the layer root", rel)) - continue + + // Check-before-act: the generation target — the `.leji/viewer/` role — is + // realpath-resolved and validated BEFORE a single byte is written. A `.leji/viewer` + // that resolves into a DIFFERENT private role (`.leji/work/`, `.leji/mounts/`, a + // future role), or out of the repository altogether, is refused here, so a symlinked + // viewer can never be written through into the trust domain or out of the tree; only + // its own directory passes. Unresolvable (permission/I/O error, not mere absence) + // fails the check rather than being rebuilt lexically. + resolvedRoot, ok := fsx.ResolvedPath(rootAbs) + if !ok { + resolvedRoot = rootAbs + } + viewerTarget, resolvable := fsx.ResolvedPathUnder(resolvedRoot, layout.Abs(resolvedRoot, layout.ViewerRel)) + verdict := layout.TargetVerdict{Unresolvable: true} + if resolvable { + verdict = layout.WritableTarget(resolvedRoot, viewerTarget, layout.ViewerRel) + } + if !verdict.OK { + message := "refusing to generate the viewer: " + layout.ViewerRel + "/ resolves into " + + layout.LejiDir + "/" + verdict.Role + " (private); remove the symlink" + switch { + case verdict.Unresolvable: + message = "refusing to generate the viewer: " + layout.ViewerRel + + "/ cannot be resolved (permission or I/O error); remove the symlink" + case verdict.OutsideRoot: + message = "refusing to generate the viewer: " + layout.ViewerRel + + "/ 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 + } + + // The chrome's role in the unified root `.leji/` (gitignored): outside the context + // root whatever rootPath is, so it never collides with the user's own files and + // never rides a content walk. Every write goes back through the chokepoint with the + // viewer's own role, so each file is judged on its RESOLVED path immediately before + // it is written and lands there: the role was validated as a whole above, and this + // keeps a symlink planted inside the tree from redirecting a single file elsewhere. + viewerDir := layout.ViewerRel + writeViewerFile := func(rel string, content []byte) error { + abs := filepath.Join(root, filepath.FromSlash(rel)) + verdict, err := fsx.WriteFileGuarded(resolvedRoot, abs, layout.ViewerRel, content, fsx.WriteOptions{}) + if err != nil { + return err } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return Result{}, err + if !verdict.OK { + findingList = append(findingList, findings.New("artifact-parse", findings.Error, + "viewer path "+rel+" resolves outside "+layout.ViewerRel+"/", rel)) + return nil } - if err := os.WriteFile(abs, f.content, 0o644); err != nil { + written = append(written, rel) + return nil + } + for _, f := range files { + if err := writeViewerFile(viewerDir+"/"+f.name, f.content); err != nil { return Result{}, err } - written = append(written, rel) } // 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. + // + // 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 + // lands via the guarded-write chokepoint on that path. overviewRel := "overview.md" if rootDir != "." { overviewRel = rootDir + "/overview.md" } overviewAbs := filepath.Join(root, overviewRel) - if !fsx.IsFile(overviewAbs) { - if !fsx.ResolvesUnder(root, overviewAbs) { - findingList = append(findingList, findings.New("artifact-parse", findings.Error, - "viewer path "+overviewRel+" resolves outside the layer root", overviewRel)) - } else { - if err := os.MkdirAll(filepath.Dir(overviewAbs), 0o755); err != nil { - return Result{}, err - } - if err := os.WriteFile(overviewAbs, []byte(buildOverviewSeed(m, entries)), 0o644); err != nil { - return Result{}, err - } - written = append(written, overviewRel) - } - } else if fsx.ResolvesUnder(root, overviewAbs) { - existing, err := fsx.ReadText(overviewAbs) + overviewResolved, overviewResolvable := fsx.ResolvedPathUnder(resolvedRoot, overviewAbs) + overviewVerdict := layout.TargetVerdict{} + contained := overviewResolvable && fsx.ResolvedWithinRoot(rootAbs, overviewAbs) + if contained { + overviewVerdict = layout.WritableTarget(resolvedRoot, overviewResolved, "") + } + overviewRead, err := fsx.VerifiedTargetRead(resolvedRoot, overviewAbs, "") + if err != nil { + return Result{}, err + } + switch { + case !contained: + findingList = append(findingList, findings.New("artifact-parse", findings.Error, + "overview.md resolves outside the layer root", overviewRel)) + case !overviewVerdict.OK: + findingList = append(findingList, findings.New("viewer-target-refused", findings.Error, + "refusing to write overview.md: it resolves into "+layout.LejiDir+"/"+overviewVerdict.Role+ + " (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. + 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{}) if err != nil { return Result{}, err } + if seeded.OK { + 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 := os.WriteFile(overviewAbs, []byte(updated), 0o644); err != nil { + if _, err := fsx.WriteFileGuarded(resolvedRoot, overviewAbs, "", + []byte(updated), fsx.WriteOptions{}); err != nil { return Result{}, err } } @@ -1757,266 +2055,14 @@ func GenerateViewer(root string, m *manifest.Manifest) (Result, error) { // user's own files) and served via a dedicated content route (never a committed // file at the context root, so no diff churn). Regenerated every run; pinned. manifestStatuses, _ := mounts.MountStatus(root, m, mounts.StatusOptions{}) - manifestRel := viewerDir + "/_manifest.md" - manifestAbs := filepath.Join(root, manifestRel) - if !fsx.ResolvesUnder(root, manifestAbs) { - findingList = append(findingList, findings.New("artifact-parse", findings.Error, - "viewer path "+manifestRel+" resolves outside the layer root", manifestRel)) - } else { - if err := os.MkdirAll(filepath.Dir(manifestAbs), 0o755); err != nil { - return Result{}, err - } - if err := os.WriteFile(manifestAbs, []byte(buildManifestPage(m, manifestStatuses)), 0o644); err != nil { - return Result{}, err - } - written = append(written, manifestRel) + if err := writeViewerFile(viewerDir+"/_manifest.md", []byte(buildManifestPage(m, manifestStatuses))); err != nil { + return Result{}, err } return Result{Written: written, Findings: findingList, Entries: len(entries)}, nil } -// ProtectWarning is the protect-your-context warning surfaced by `leji viewer -// build` (stdout and a comment in the exported index.html). -const ProtectWarning = "This is your context layer (identity, invariants, decisions, sometimes sensitive internal knowledge). Host the exported folder behind internal authentication, not a public or shared bucket where it could be indexed or leaked. Active file types (.htm, .html, .js, .mjs, .xhtml) are left out of the exported content: a static host would serve them as same-origin documents that execute with no policy." - -// exportMarker is the first bytes `viewer build` writes into an exported -// index.html. A target directory carrying this marker is a previous export and -// may be cleared; any other non-empty directory is somebody's content. -const exportMarker = "\n" + string(indexHTML) - if err := os.WriteFile(filepath.Join(outAbs, "index.html"), []byte(prepended), 0o644); err != nil { - return BuildResult{}, err - } - - return BuildResult{Out: outDisplay, Findings: gen.Findings}, nil -} - -// activeExtensions are the extensions a browser would run as an active, +// ActiveExtensions are the extensions a browser would run as an active, // same-origin document. Under `/content/` they are served as text/plain instead // of their active type, and they are left out of the static export entirely: // everything under the content mount is layer material, and layer material is @@ -2028,398 +2074,10 @@ func BuildViewer(root string, m *manifest.Manifest, outRel string) (BuildResult, // the cspContent sandbox below, so an SVG navigated to or framed lands in an // opaque origin with scripting off, and an SVG loaded as an never runs // script whatever its type. -var activeExtensions = map[string]bool{ +var ActiveExtensions = map[string]bool{ ".html": true, ".htm": true, ".js": true, ".mjs": true, ".xhtml": true, } - -const ( - // cspChrome is the SPA shell's policy, sent as a response header on every - // chrome response so it holds for documents reached outside the shell too. - // Mirrors the meta in templates/viewer/index.html; keep the two in step. - cspChrome = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'none'" - // cspContent is the policy for everything served out of the layer itself. - // `sandbox` with no tokens puts a /content/ document in an opaque origin with - // scripting off, so a governed file framed or opened directly is inert rather - // than same-origin code. - cspContent = "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; sandbox" -) - -// loopbackHosts are the host names the local preview answers to. -var loopbackHosts = map[string]bool{"localhost": true, "127.0.0.1": true, "[::1]": true} - -// loopbackHost reports whether the Host header names the loopback interface: -// hostname only, since the port a request arrives on is already fixed by the -// loopback bind. A missing Host is accepted (an HTTP/1.0 client omits it). -func loopbackHost(host string) bool { - if host == "" { - return true - } - name := host - if strings.HasPrefix(host, "[") { - name = host[:strings.Index(host, "]")+1] - } else if i := strings.Index(host, ":"); i >= 0 { - name = host[:i] - } - return loopbackHosts[strings.ToLower(name)] -} - -var contentTypes = map[string]string{ - ".html": "text/html; charset=utf-8", - ".md": "text/markdown; 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", - ".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", -} - -// serveFrom serves `sub` (clean relative path; "" -> index.html) from mountRoot. -// Lexically contains the target, follows a dir to index.html, then realpath-checks -// so a symlink can't escape. Mirrors Node: 200 ok, 403 containment violation, 404 -// on any stat/read failure. `inert` marks the layer's own content mount, whose -// files are never given an active content type however they are named. -func serveFrom(w http.ResponseWriter, mountRoot, sub string, inert bool) { - var abs string - if sub == "" { - abs = filepath.Join(mountRoot, "index.html") - } else { - abs = filepath.Join(mountRoot, sub) - } - if abs != mountRoot && !strings.HasPrefix(abs, mountRoot+string(filepath.Separator)) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte("forbidden")) - return - } - if info, err := os.Stat(abs); err == nil && info.IsDir() { - abs = filepath.Join(abs, "index.html") - } - real, err := filepath.EvalSymlinks(abs) - if err != nil { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("not found")) - return - } - if real != mountRoot && !strings.HasPrefix(real, mountRoot+string(filepath.Separator)) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte("forbidden")) - return - } - body, err := os.ReadFile(abs) - if err != nil { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("not found")) - return - } - ext := strings.ToLower(path.Ext(abs)) - ct := contentTypes[ext] - if ct == "" { - ct = "application/octet-stream" - } - if inert && activeExtensions[ext] { - ct = "text/plain; charset=utf-8" - } - w.Header().Set("content-type", ct) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) -} - -// 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. -type sidebarCache struct { - key string - body string - indexJSON string - hasIndex bool -} - -// statusWriter records the status code written so the access log can report it. -type statusWriter struct { - http.ResponseWriter - code int -} - -func (w *statusWriter) WriteHeader(code int) { - w.code = code - w.ResponseWriter.WriteHeader(code) -} - -// newHandler builds the virtual-mount handler: viewer chrome -// (rootPath/.leji/viewer/) at "/", the layer's markdown (rootPath/) under -// "/content/". The internal .leji path is reachable only through these mounts. -// 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 { - // 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 - // 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. - var mu sync.Mutex - var cache *sidebarCache - treeFingerprint := func() string { - var parts []string - add := func(rel string) { - st, err := os.Stat(filepath.Join(rootAbs, rel)) - if err != nil { - parts = append(parts, rel+"\x00gone") - return - } - parts = append(parts, fmt.Sprintf("%s\x00%d\x00%d", rel, st.ModTime().UnixNano(), st.Size())) - } - add("leji.json") - walkBase := base - if walkBase == "" { - walkBase = "." - } - for _, rel := range fsx.WalkTree(rootAbs, walkBase) { - add(rel) - } - return strings.Join(parts, "\n") - } - // refresh rebuilds the cache for key from the live tree; returns nil when the - // manifest is missing or the tree will not index cleanly (callers then fall - // back to the generated artifact). - refresh := func(key string) *sidebarCache { - load := manifest.LoadManifest(rootAbs) - if load.Manifest == nil { - return nil - } - idx := indexgen.GenerateIndex(rootAbs, load.Manifest) - for _, f := range idx.Findings { - if f.Severity == findings.Error { - return nil - } - } - var entries []indexgen.IndexEntry - if idx.Index != nil { - entries = idx.Index.Entries - } - var discard []findings.Finding - c := &sidebarCache{key: key, body: assembleSidebar(rootAbs, load.Manifest, entries, &discard)} - if idx.Index != nil { - c.indexJSON = indexgen.SerializeIndex(idx.Index) - c.hasIndex = true - } - cache = c - return c - } - serveText := func(w http.ResponseWriter, contentType, body string) { - w.Header().Set("content-type", contentType) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(body)) - } - inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Policy headers ride every response, not just the SPA shell: a document - // served straight out of /content/ is same-origin and would otherwise run - // with no policy at all. Set before any write; the content mount downgrades - // to the inert policy once the route is known. - w.Header().Set("x-content-type-options", "nosniff") - w.Header().Set("content-security-policy", cspChrome) - // Loopback binding alone does not stop DNS rebinding: a hostile page whose - // name resolves to 127.0.0.1 reaches this server with its own Host. Only the - // loopback names the viewer is actually addressed by are answered. The port is - // deliberately not part of the test: a rebound request carries the right port - // anyway, so matching it adds nothing. Don't "fix" this by checking it. - if !loopbackHost(r.Host) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte("forbidden")) - return - } - // A malformed percent-encoding leaves RawPath set but Path empty/wrong; - // detect a decode error and answer 400 rather than crash. - urlPath, err := decodePath(r.URL) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte("bad request")) - return - } - rel := urlPathToRel(urlPath) - if rel == "content" || strings.HasPrefix(rel, "content/") { - w.Header().Set("content-security-policy", cspContent) - } - // Refuse any dotfile or VCS-internal segment in the request path: the .leji - // viewer dir is reached only through the mounts below. - for _, seg := range strings.Split(rel, "/") { - if seg == ".git" || (strings.HasPrefix(seg, ".") && seg != "." && seg != "") { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("not found")) - return - } - } - // The generated sidebar lives in the viewer dir but is served as if at the - // content root, so Docsify's basePath /content/ + _sidebar alias resolve it. - // Docsify fetches it once per page load, so it is rebuilt from the live tree - // on every request: a long-running server never shows a deleted or moved - // 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 { - serveText(w, "text/markdown; charset=utf-8", c.body) - return - } - serveFrom(w, viewerAbs, "_sidebar.md", false) - return - } - // The stored context index is served live (same fingerprint cache as the - // sidebar), so per-page classification badges never disagree with the tree. - if strings.HasPrefix(rel, "content/") { - load := manifest.LoadManifest(rootAbs) - if load.Manifest != nil { - if idxRel, ok := 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 { - serveText(w, "application/json; charset=utf-8", c.indexJSON) - return - } - } - } - } - // The generated Manifest page lives in the viewer dir (gitignored chrome) but - // is linked from the sidebar and fetched under the content root, like - // _sidebar.md. Reserved underscore name; served from the last generation. - if rel == "content/_manifest.md" { - serveFrom(w, viewerAbs, "_manifest.md", false) - return - } - if rel == "content" || strings.HasPrefix(rel, "content/") { - sub := "" - if rel != "content" { - sub = rel[len("content/"):] - } - // An agent profile that declares `inherits` is served resolved: the file - // on disk is one half, and presenting it as the effective profile is the - // thing a consumer must not do. So this branch fails closed. If anything - // at all goes wrong, a file that declares `inherits` still gets a findings - // page; only a file that is not half a profile falls through to disk. - if strings.HasSuffix(sub, ".md") { - repoRel := sub - if base != "" && base != "." { - repoRel = base + "/" + sub - } - page, served := "", false - load := manifest.LoadManifest(rootAbs) - if load.Manifest != nil { - page, served = ResolvedProfilePage(rootAbs, load.Manifest, repoRel) - } else if declaresInherits(rootAbs, repoRel) { - page = unresolvedProfilePage(repoRel, []findings.Finding{ - findings.New("artifact-parse", findings.Error, "the layer manifest could not be read", "leji.json"), - }) - served = true - } - if served { - serveText(w, "text/markdown; charset=utf-8", page) - return - } - } - serveFrom(w, contentAbs, sub, true) - return - } - // Everything else (`/`, /index.html, /assets/*) is viewer chrome. - serveFrom(w, viewerAbs, rel, false) - }) - if logf == nil { - return inner - } - // Access log: one terse line per request, after the status is known. - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sw := &statusWriter{ResponseWriter: w, code: http.StatusOK} - inner.ServeHTTP(sw, r) - logf(r.Method + " " + r.URL.RequestURI() + " " + strconv.Itoa(sw.code)) - }) -} - -// decodePath returns the percent-decoded request path, or an error when the -// encoding is malformed (mirrors Node's decodeURIComponent throwing -> 400). -func decodePath(u *url.URL) (string, error) { - if u.RawPath != "" { - return url.PathUnescape(u.RawPath) - } - return u.Path, nil -} - -// resolveRoot resolves symlinks in root, falling back to its absolute path. -func resolveRoot(root string) string { - rootAbs, err := filepath.EvalSymlinks(root) - if err != nil { - rootAbs, _ = filepath.Abs(root) - } - return rootAbs -} - -// urlPathToRel turns a request URL path into a clean relative route key. -// Separators fold to "/" and the path is cleaned against a root, so one request -// has one route key on any platform — filepath.Clean follows the host and -// answered differently on Windows, missing every "content/" route test. -// Canonicalization only; serveFrom enforces containment. -func urlPathToRel(urlPath string) string { - return strings.TrimLeft(path.Clean("/"+strings.ReplaceAll(urlPath, "\\", "/")), "/") -} - -// 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) { - rootAbs := resolveRoot(root) - base := fsx.StripSlash(rootRel) - contentAbs := rootAbs - if base != "" && base != "." { - contentAbs = filepath.Join(rootAbs, base) - } - // A direct SDK caller could pass an escaping rootRel (e.g. ".."); refuse to mount - // content outside the layer root. - if !fsx.ResolvesUnder(rootAbs, contentAbs) { - return nil, nil, fmt.Errorf("viewer root %q escapes the layer root", rootRel) - } - ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) - if err != nil { - return nil, nil, err - } - viewerAbs := filepath.Join(contentAbs, ".leji", "viewer") - srv := &http.Server{Handler: newHandler(rootAbs, base, contentAbs, viewerAbs, logf)} - return ln, srv, nil -} - -// OpenBrowser best-effort opens url in the default browser. Never blocks or fails -// the caller: a missing opener is a silent no-op. -func OpenBrowser(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", "", url) - default: - cmd = exec.Command("xdg-open", url) - } - // Start (don't Wait): spawn detached and ignore any error. - _ = cmd.Start() -} 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 2eaaccc..05ab4b7 100644 --- a/packages/sdk-go/internal/commands/viewer/viewer_more_test.go +++ b/packages/sdk-go/internal/commands/viewer/viewer_more_test.go @@ -1,8 +1,11 @@ package viewer import ( + "math" "os" "path/filepath" + "regexp" + "strconv" "strings" "testing" @@ -22,6 +25,19 @@ func exampleCopy(t *testing.T) string { return dst } +// writeUnder writes rel (forward-slashed, repo-relative) under dir, creating its +// parent directories. +func writeUnder(t *testing.T, dir, rel, text string) { + t.Helper() + abs := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", rel, err) + } + if err := os.WriteFile(abs, []byte(text), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} + func TestGenerateViewerProjectsViewer(t *testing.T) { dir := exampleCopy(t) m := manifest.LoadManifest(dir).Manifest @@ -33,7 +49,7 @@ func TestGenerateViewerProjectsViewer(t *testing.T) { t.Fatal("expected indexed entries, got 0") } for _, name := range []string{"index.html", "_sidebar.md"} { - if _, err := os.Stat(filepath.Join(dir, m.RootPath, ".leji", "viewer", name)); err != nil { + if _, err := os.Stat(filepath.Join(dir, ".leji", "viewer", name)); err != nil { t.Fatalf("expected generated %s under the viewer dir: %v", name, err) } } @@ -43,7 +59,6 @@ func TestGenerateViewerProjectsViewer(t *testing.T) { "docsify-sidebar-collapse.min.css", "docsify-sidebar-collapse.min.js", "docsify.min.js", - "fonts-licenses.txt", "leji-logo.svg", "mermaid.min.js", "prism-bash.min.js", @@ -63,12 +78,14 @@ func TestGenerateViewerProjectsViewer(t *testing.T) { "source-sans-pro-600-latin-ext.woff2", "source-sans-pro-600-latin.woff2", "source-sans-pro-600-vietnamese.woff2", + "third-party-licenses.txt", "viewer-boot.js", "vue.css", "zoom-image.min.js", } rootDir := strings.TrimRight(m.RootPath, "/") - viewerRel := rootDir + "/.leji/viewer" + // The chrome lives in the unified root `.leji/`, whatever rootPath is. + viewerRel := ".leji/viewer" want := []string{ viewerRel + "/index.html", viewerRel + "/_sidebar.md", @@ -86,7 +103,7 @@ func TestGenerateViewerProjectsViewer(t *testing.T) { t.Fatalf("written[%d] = %q, want %q", i, res.Written[i], want[i]) } } - viewer := filepath.Join(dir, m.RootPath, ".leji", "viewer") + viewer := filepath.Join(dir, ".leji", "viewer") html, err := os.ReadFile(filepath.Join(viewer, "index.html")) if err != nil { t.Fatalf("read index.html: %v", err) @@ -114,7 +131,7 @@ func TestGenerateViewerMermaidDisabled(t *testing.T) { if err != nil { t.Fatalf("GenerateViewer: %v", err) } - viewer := filepath.Join(dir, m.RootPath, ".leji", "viewer") + viewer := filepath.Join(dir, ".leji", "viewer") html, err := os.ReadFile(filepath.Join(viewer, "index.html")) if err != nil { t.Fatalf("read index.html: %v", err) @@ -250,7 +267,7 @@ func TestViewerPathFormsAndMissingHomepageWarns(t *testing.T) { t.Fatalf("expected no viewer-path-missing finding, got: %v", f) } } - html, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + html, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) if err != nil { t.Fatalf("read index.html: %v", err) } @@ -260,12 +277,12 @@ func TestViewerPathFormsAndMissingHomepageWarns(t *testing.T) { if !strings.Contains(string(html), "/content/HOME.md") { t.Fatal("expected the favicon URL normalized under the content mount") } - sidebar, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "_sidebar.md")) + sidebar, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) if err != nil { t.Fatalf("read _sidebar.md: %v", err) } top := strings.SplitN(string(sidebar), "---", 2)[0] - if !strings.Contains(top, "](domain/glossary.md)") { + if !strings.Contains(top, "](/domain/glossary.md)") { t.Fatalf("expected the rootPath-relative pin resolved into the top zone, got: %q", top) } // An unresolvable homepage is kept as authored and warned about, never silent. @@ -298,14 +315,14 @@ func TestViewerBootPinReplacesDefaultLine(t *testing.T) { if _, err := GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } - sidebar, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "_sidebar.md")) + sidebar, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) if err != nil { t.Fatalf("read _sidebar.md: %v", err) } if strings.Contains(string(sidebar), "🤖 Boot profile") { t.Fatalf("expected the default boot line replaced by the pin, got: %q", sidebar) } - if !strings.Contains(string(sidebar), "- [🚀 Start here](boot-profile.md)") { + if !strings.Contains(string(sidebar), "- [🚀 Start here](/boot-profile.md)") { t.Fatalf("expected the curated boot pin label, got: %q", sidebar) } } @@ -343,167 +360,6 @@ func TestGenerateViewerOverviewWithoutMarkersWarns(t *testing.T) { } } -// Mirrors the Node test: viewer build exports a self-contained static folder -// carrying the protect warning, and refuses an escaping, root, or absolute --out. -func TestBuildViewerExportsAndRejects(t *testing.T) { - dir := exampleCopy(t) - m := manifest.LoadManifest(dir).Manifest - r, err := BuildViewer(dir, m, "out") - if err != nil { - t.Fatalf("BuildViewer: %v", err) - } - if r.Out != "out" { - t.Fatalf("out = %q, want %q", r.Out, "out") - } - out := filepath.Join(dir, "out") - // Chrome at the web root; the layer's markdown under /content/. - for _, rel := range []string{ - "index.html", - "assets/docsify.min.js", - "content/boot-profile.md", - "content/overview.md", - "content/_sidebar.md", - "content/domain/glossary.md", - } { - if _, err := os.Stat(filepath.Join(out, rel)); err != nil { - t.Fatalf("expected exported %s: %v", rel, err) - } - } - // The contained, regenerable .leji/ is never exported into the content. - if _, err := os.Stat(filepath.Join(out, "content", ".leji")); err == nil { - t.Fatal("expected .leji to be excluded from the export") - } - html, err := os.ReadFile(filepath.Join(out, "index.html")) - if err != nil { - t.Fatalf("read exported index.html: %v", err) - } - if !strings.HasPrefix(string(html), "") - if !strings.Contains(warning, "Active file types") { - t.Fatal("expected the export warning to name the exclusion") - } - if strings.Contains(warning, ".svg") { - t.Fatal("the warning must not claim SVG is excluded") - } -} - // Both values name other placeholders: with sequential substitution passes they // were expanded a second time, injecting a literal into the JSON island // and breaking out of the favicon's href attribute. @@ -518,7 +374,7 @@ func TestGenerateViewerHostileManifestCannotBreakOut(t *testing.T) { if _, err := GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } - page, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + page, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) if err != nil { t.Fatal(err) } @@ -534,6 +390,23 @@ func TestGenerateViewerHostileManifestCannotBreakOut(t *testing.T) { } } +// themeWarning is the one message a rejected accent produces, spelled out here so +// a change to the contract's wording fails the suite rather than shipping. +func themeWarning(value string) string { + return `viewer.theme.primary "` + value + `" is not a hex color (#RGB, #RGBA, #RRGGBB, or #RRGGBBAA); using #009F71` +} + +// themeWarnings collects the accent findings of one generation run. +func themeWarnings(fnds []findings.Finding) []findings.Finding { + var out []findings.Finding + for _, f := range fnds { + if f.Rule == "viewer-theme-invalid" && f.Severity == findings.Warning { + out = append(out, f) + } + } + return out +} + // An unusable accent is refused with a warning rather than interpolated into a // stylesheet; a plain color is kept as authored. func TestGenerateViewerRejectsUnsafeThemeColor(t *testing.T) { @@ -542,32 +415,31 @@ func TestGenerateViewerRejectsUnsafeThemeColor(t *testing.T) { if m.Viewer == nil { m.Viewer = &manifest.Viewer{} } - m.Viewer.Theme = &manifest.Theme{Primary: "red; } body { display: none } /*"} + injection := "red; } body { display: none } /*" + m.Viewer.Theme = &manifest.Theme{Primary: injection} res, err := GenerateViewer(dir, m) if err != nil { t.Fatalf("GenerateViewer: %v", err) } - page, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + page, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) if err != nil { t.Fatal(err) } - if !strings.Contains(string(page), `"themeColor":"#223F93"`) { + if !strings.Contains(string(page), `"themeColor":"#009F71"`) { t.Fatal("expected the accent to fall back to the default") } - warned := false - for _, f := range res.Findings { - if f.Rule == "viewer-theme-invalid" && f.Severity == findings.Warning { - warned = true - } + warnings := themeWarnings(res.Findings) + if len(warnings) != 1 { + t.Fatalf("expected the rejected accent to be surfaced once, got %d", len(warnings)) } - if !warned { - t.Fatal("expected the rejected accent to be surfaced") + if warnings[0].Message != themeWarning(injection) { + t.Errorf("message = %q, want %q", warnings[0].Message, themeWarning(injection)) } m.Viewer.Theme = &manifest.Theme{Primary: "#ff0000"} if _, err := GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } - page, err = os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + page, err = os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) if err != nil { t.Fatal(err) } @@ -576,6 +448,133 @@ func TestGenerateViewerRejectsUnsafeThemeColor(t *testing.T) { } } +// The accent is hex and nothing else: 5 and 7 digits are no CSS color at all, and +// used to reach the page as an unusable accent with no warning while the mermaid +// text color silently defaulted. Keywords are not the contract either, however real +// the name — that acceptance fell out of the injection guard, never design. +func TestGenerateViewerAccentIsHexOnly(t *testing.T) { + dir := exampleCopy(t) + m := manifest.LoadManifest(dir).Manifest + if m.Viewer == nil { + m.Viewer = &manifest.Viewer{} + } + for _, tc := range []struct { + accent string + accepted bool + }{ + // The four lengths CSS defines, alpha forms included, case-insensitive. + {"#0f7", true}, + {"#1234", true}, + {"#009F71", true}, + {"#AABBCCDD", true}, + {"#12345", false}, + {"#1234567", false}, + {"navy", false}, + {"notacolor", false}, + {"transparent", false}, + // A trailing newline does not sneak a hex past the predicate, in any SDK: + // Go's `$` is end of text without multiline mode, which is what makes it + // true here. + {"#009F71\n", false}, + } { + m.Viewer.Theme = &manifest.Theme{Primary: tc.accent} + res, err := GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer(%q): %v", tc.accent, err) + } + page, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) + if err != nil { + t.Fatal(err) + } + warnings := themeWarnings(res.Findings) + if tc.accepted { + if len(warnings) != 0 { + t.Errorf("%q: warned, want accepted silently", tc.accent) + } + if !strings.Contains(string(page), `"themeColor":"`+tc.accent+`"`) { + t.Errorf("%q: expected it kept as authored", tc.accent) + } + continue + } + if len(warnings) != 1 { + t.Errorf("%q: warnings = %d, want 1", tc.accent, len(warnings)) + continue + } + if warnings[0].Message != themeWarning(tc.accent) { + t.Errorf("%q: message = %q, want %q", tc.accent, warnings[0].Message, themeWarning(tc.accent)) + } + if !strings.Contains(string(page), `"themeColor":"#009F71"`) { + t.Errorf("%q: expected the default accent", tc.accent) + } + } +} + +// wcagContrast is the contrast ratio between two #rrggbb colors, computed here +// rather than through the code under test, so the numeric assertions below are +// derived independently of the implementation they judge. +func wcagContrast(a, b string) float64 { + luminance := func(hex string) float64 { + channel := func(i int) float64 { + v, _ := strconv.ParseUint(hex[1+i*2:3+i*2], 16, 16) + c := float64(v) / 255 + if c <= 0.03928 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) + } + return 0.2126*channel(0) + 0.7152*channel(1) + 0.0722*channel(2) + } + x, y := luminance(a), luminance(b) + return (math.Max(x, y) + 0.05) / (math.Min(x, y) + 0.05) +} + +// The mermaid node-text color is computed from the accent over every form +// viewer.theme.primary accepts: the generator resolves what the boot script's +// fallback cannot — the alpha forms, composited over the viewer's white content +// ground. Mirrors the Node SDK's vectors. +func TestMermaidTextColorOverEveryAcceptedForm(t *testing.T) { + for _, tc := range []struct{ accent, want string }{ + // The two brand accents, and the mid-gray class where neither #1a1a1a nor + // #ffffff clears 4.5:1 and black buys the last half-stop. + {"#009F71", "#1a1a1a"}, + {"#223F93", "#ffffff"}, + {"#777777", "#000000"}, + // #RGB expands like the boot script's fallback does. + {"#0f7", "#1a1a1a"}, + // Alpha composites over white, which lightens: the same accent at half alpha + // takes dark text, and a black at 47% is light enough for it too. + {"#009F7180", "#1a1a1a"}, + {"#0007", "#1a1a1a"}, + // Named resolution is gone: navy would take white text if any keyword path + // survived, so the dark default here is the proof it does not. + {"navy", "#1a1a1a"}, + // Unresolvable by nature or by typo: the dark default, never a guess. + {"currentColor", "#1a1a1a"}, + {"notacolor", "#1a1a1a"}, + {"#12345", "#1a1a1a"}, + // A dark accent takes white; the case of the authored hex does not matter. + {"#1A1A1A", "#ffffff"}, + {"#000080", "#ffffff"}, + } { + if got := mermaidTextColor(tc.accent); got != tc.want { + t.Errorf("mermaidTextColor(%q) = %q, want %q", tc.accent, got, tc.want) + } + } + // The default accent's choice is not merely dark, it is accessible: the numeric + // ratio is what the rule is about, so it is asserted as a number. + if r := wcagContrast("#009F71", "#1a1a1a"); r < 4.5 { + t.Errorf("the default accent misses WCAG AA against its text color: %.2f", r) + } + if r := wcagContrast("#223F93", "#ffffff"); r < 4.5 { + t.Errorf("a dark accent misses WCAG AA against white: %.2f", r) + } + // The #777777 class: black is chosen because both candidates miss, not because + // it wins outright over a passing option. + if wcagContrast("#777777", "#1a1a1a") >= 4.5 || wcagContrast("#777777", "#ffffff") >= 4.5 { + t.Error("expected both #1a1a1a and #ffffff to miss 4.5:1 against #777777") + } +} + // A manifest label carrying HTML reaches the generated sidebar verbatim, so the // angle brackets are escaped there. func TestGenerateViewerEscapesHTMLInSidebarLabels(t *testing.T) { @@ -588,7 +587,7 @@ func TestGenerateViewerEscapesHTMLInSidebarLabels(t *testing.T) { if _, err := GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer: %v", err) } - sidebar, err := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "_sidebar.md")) + sidebar, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) if err != nil { t.Fatal(err) } @@ -596,3 +595,86 @@ func TestGenerateViewerEscapesHTMLInSidebarLabels(t *testing.T) { t.Fatalf("expected the angle brackets escaped, got %q", string(sidebar)) } } + +// --- 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 +// two halves: Docsify's relativePath routing (so a link resolves against the +// document carrying it, exactly as the same file reads on disk) and generated +// sidebar destinations emitted app-root absolute (exempt from that resolution). +// The generation half is pinned here; the serve half lives in serve_more_test.go. + +var sidebarDestRe = regexp.MustCompile(`\]\(([^)]*)\)`) + +func TestSidebarDestinationsAreAppRootAbsolute(t *testing.T) { + dir := exampleCopy(t) + // One layer carrying every sidebar entry class at once: a pinned boot profile, + // the always-pinned Manifest chrome, a user pin, grouped index entries, and + // documents nested two directories deep in both the governed and browse zones. + writeUnder(t, dir, "docs/domain/billing/settlement/netting.md", "# Netting\n") + writeUnder(t, dir, "docs/notes/team/onboarding/day-one.md", "# Day one\n") + m := manifest.LoadManifest(dir).Manifest + m.Viewer = &manifest.Viewer{Pins: []manifest.ViewerPin{ + {Path: "docs/boot-profile.md"}, + {Path: "docs/domain/glossary.md"}, + }} + res, err := GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + for _, f := range res.Findings { + if f.Severity == findings.Error { + t.Fatalf("unexpected error finding: %v", f) + } + } + sidebar, err := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) + if err != nil { + t.Fatalf("read _sidebar.md: %v", err) + } + // Each class is present, so the sweep below is not vacuous. + for _, dest := range []string{ + "/boot-profile.md", // the pinned boot profile + "/_manifest.md", // generated Manifest chrome + "/domain/glossary.md", // a user pin in the top zone + "/system/invariants.md", // a grouped index entry + "/domain/billing/settlement/netting.md", // grouped, nested two deep + "/notes/team/onboarding/day-one.md", // browse zone, nested two deep + } { + if !strings.Contains(string(sidebar), "]("+dest+")") { + t.Fatalf("expected %s in the sidebar, got: %q", dest, sidebar) + } + } + // Every emitted destination, parsed rather than sampled: one bare rel anywhere + // in the sidebar re-resolves against whatever nested route is current. + dests := sidebarDestRe.FindAllStringSubmatch(string(sidebar), -1) + if len(dests) < 6 { + t.Fatalf("expected the matrix to produce links to sweep, got %d", len(dests)) + } + for _, d := range dests { + if !strings.HasPrefix(d[1], "/") { + t.Fatalf("sidebar destination %q is not app-root absolute", d[1]) + } + } +} + +// TestMdLinkDest pins the escape-and-prefix contract on the helper itself. The +// vectors are shared verbatim with the Node and Python SDKs (test/units.test.ts, +// tests/test_units.py): the three must agree byte for byte. +func TestMdLinkDest(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"a.md", "/a.md"}, + {"dir/b.md", "/dir/b.md"}, + // Already absolute: `//…` would be a protocol-relative external URL to Docsify. + {"/a.md", "/a.md"}, + {"//a.md", "/a.md"}, + // Degenerate input passes through rather than becoming a bare `/`. + {"", ""}, + {"a(b).md", `/a\(b\).md`}, + {"(x).md", `/\(x\).md`}, + {`a\b.md`, `/a\\b.md`}, + } { + if got := mdLinkDest(tc.in); got != tc.want { + t.Fatalf("mdLinkDest(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/packages/sdk-go/internal/conformancetest/badge_test.go b/packages/sdk-go/internal/conformancetest/badge_test.go new file mode 100644 index 0000000..bb8f66d --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/badge_test.go @@ -0,0 +1,856 @@ +package conformancetest + +// Two halves of one contract, mirroring packages/sdk/test/badge.test.ts. First the +// constants: every level rendered and byte-compared against `fixtures/badge/`, the +// sole oracle, plus the `--out` acceptance table, the existing-file rule and the +// containment matrix over committed working copies. Then the shared fixtures' +// `badge` blocks, driven through the real CLI. + +import ( + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/badge" + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +// committedFixture is a committed working copy of a shared fixture: the level a +// badge states needs a git baseline, since the `indexed` changelog item is +// `unknown` until the changelog is in HEAD (`fixtures/README.md` -> "The `badge` +// block"). +// +// The copy lives under a SHORT temp name of its own rather than t.TempDir(), whose +// test-named path is long enough to exceed the platform's `sun_path` limit — and a +// socket bound at the badge target is one of the standing entries the containment +// matrix below has to plant. Resolved, like every root the guard judges. +func committedFixture(t *testing.T, name string) string { + t.Helper() + dir, err := os.MkdirTemp("", "leji-badge-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + cp := exec.Command("cp", "-r", filepath.Join(fixturesDir(t), name)+"/.", dir) + if out, err := cp.CombinedOutput(); err != nil { + t.Fatalf("cp: %v: %s", err, out) + } + gitCommitAll(t, dir) + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func goldenBadge(t *testing.T, rel string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join(fixturesDir(t), filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + return body +} + +func mustRun(t *testing.T, root, out string) badge.Result { + t.Helper() + r, err := badge.Run(root, out) + if err != nil { + t.Fatalf("badge.Run(%s, %s): %v", root, out, err) + } + return r +} + +// --- the canonical bytes ------------------------------------------------------ + +func TestBadgeRendersEveryLevelByteForByte(t *testing.T) { + for _, level := range manifest.ConformanceLevels { + if got, want := badge.Render(level), string(goldenBadge(t, "badge/"+level+".svg")); got != want { + t.Fatalf("%s.svg differs from the golden", level) + } + if got, want := badge.Markdown(level, badge.DefaultOut), string(goldenBadge(t, "badge/"+level+".md")); got != want { + t.Fatalf("%s.md differs from the golden:\n got=%q\nwant=%q", level, got, want) + } + } +} + +// drawnText is every `` body in an SVG, in document order: what a renderer +// actually paints, as opposed to what the accessible name says. +var drawnText = regexp.MustCompile(`]*>([^<]*)`) + +func TestBadgeClaimIsStructuralNotDrawn(t *testing.T) { + for _, level := range manifest.ConformanceLevels { + claim := "Leji 1.0 · " + level + " · self-attested" + // The markdown fixture — the alt text an adopter pastes into a README — carries + // the whole claim, which is what lets the face drop it. + md := string(goldenBadge(t, "badge/"+level+".md")) + if !strings.Contains(md, "[!["+claim+"]") { + t.Errorf("%s.md must carry the full alt claim, got %q", level, md) + } + + svg := string(goldenBadge(t, "badge/"+level+".svg")) + if !strings.Contains(svg, ""+claim+"") { + t.Errorf("%s.svg must carry the claim", level) + } + if !strings.Contains(svg, `aria-label="`+claim+`"`) { + t.Errorf("%s.svg aria-label must carry the claim", level) + } + + // The visible segment is the level alone: the two `<text>` bodies are the + // wordmark and the level, and `self-attested` appears nowhere a renderer draws. + var drawn []string + for _, m := range drawnText.FindAllStringSubmatch(svg, -1) { + drawn = append(drawn, m[1]) + } + if want := []string{"Leji 1.0", level}; !reflect.DeepEqual(drawn, want) { + t.Errorf("%s.svg draws %q, want %q", level, drawn, want) + } + } +} + +func TestBadgeMarkdownCarriesTheCanonicalOutNotTheDefault(t *testing.T) { + want := "[![Leji 1.0 · governed · self-attested](docs/badge.svg)](https://leji.org/agent-ready/)\n" + if got := badge.Markdown("governed", "docs/badge.svg"); got != want { + t.Fatalf("markdown %q, want %q", got, want) + } +} + +// --- the `--out` acceptance rule ---------------------------------------------- + +func TestBadgeOutAcceptsRepositoryRelativeSvgAndRejectsEverythingElse(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + // Accepted, with the canonical POSIX form echoed back: a `.` segment is dropped, + // and a nested target has its parent directories created. + for _, c := range []struct{ given, canonical string }{ + {"leji-badge.svg", "leji-badge.svg"}, + {"./badge.svg", "badge.svg"}, + {"docs/badge.svg", "docs/badge.svg"}, + {"a/b/c-1_2.svg", "a/b/c-1_2.svg"}, + } { + r := mustRun(t, dir, c.given) + if r.UsageError != "" { + t.Fatalf("%s must be accepted: %s", c.given, r.UsageError) + } + if r.Out != c.canonical { + t.Fatalf("%s canonicalizes to %q, got %q", c.given, c.canonical, r.Out) + } + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(c.canonical))); err != nil { + t.Fatalf("%s was not written: %v", c.canonical, err) + } + } + // Rejected at argument parsing, before conformance runs: no level is reported at + // all, and nothing is written. + for _, bad := range []string{ + "/abs.svg", "../x.svg", "docs/../x.svg", `a\b.svg`, "x.png", "x.svg ", + "a//b.svg", "doc s/x.svg", "x.svg#frag", ".leji/x.svg", ".leji/dist/x.svg", ".leji/a/b/x.svg", + } { + r := mustRun(t, dir, bad) + if r.UsageError == "" { + t.Fatalf("%s must be rejected", bad) + } + if r.Out != "" || r.Level != "" || r.ClaimedLevel != "" || r.VerifiedLevel != "" { + t.Fatalf("%s must report no level at all: %+v", bad, r) + } + } + // A directory at the target is a rejection too, and the directory survives it. + if err := os.Mkdir(filepath.Join(dir, "adir.svg"), 0o755); err != nil { + t.Fatal(err) + } + if mustRun(t, dir, "adir.svg").UsageError == "" { + t.Fatal("a directory is never a badge target") + } + if info, err := os.Stat(filepath.Join(dir, "adir.svg")); err != nil || !info.IsDir() { + t.Fatal("the directory must survive the rejection") + } +} + +// --- the existing-file rule --------------------------------------------------- + +func TestBadgeTargetFileDecidesTheActionByItsBytesAndNothingElse(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + target := filepath.Join(dir, badge.DefaultOut) + + // Absent: written. + if got := mustRun(t, dir, badge.DefaultOut).Action; got != badge.Wrote { + t.Fatalf("an absent target is written, got %q", got) + } + body, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(body) != badge.Render("governed") { + t.Fatal("the written bytes are the canonical governed badge") + } + + // These exact bytes: unchanged, and not rewritten (the mtime stands). + before, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if got := mustRun(t, dir, badge.DefaultOut).Action; got != badge.Unchanged { + t.Fatalf("identical bytes are unchanged, got %q", got) + } + after, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if !after.ModTime().Equal(before.ModTime()) { + t.Fatal("an unchanged target is never rewritten") + } + + // Another canonical badge of this contract: overwritten, which is how a level + // change regenerates. All three of the others, not just the neighbouring one. + for _, level := range manifest.ConformanceLevels { + if level == "governed" { + continue + } + if err := os.WriteFile(target, []byte(badge.Render(level)), 0o644); err != nil { + t.Fatal(err) + } + if got := mustRun(t, dir, badge.DefaultOut).Action; got != badge.Overwrote { + t.Fatalf("a stale %s badge regenerates, got %q", level, got) + } + body, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(body) != badge.Render("governed") { + t.Fatalf("a stale %s badge is replaced by the verified level's bytes", level) + } + } + + // Anything else: refused, exit 2's message, the file untouched and never + // truncated. The levels are still reported, the rule running after conformance. + foreign := "<svg><!-- somebody elses file --></svg>\n" + if err := os.WriteFile(target, []byte(foreign), 0o644); err != nil { + t.Fatal(err) + } + r := mustRun(t, dir, badge.DefaultOut) + if r.Refusal != "leji-badge.svg exists and is not a leji badge; remove or rename it" { + t.Fatalf("refusal %q", r.Refusal) + } + if r.Out != "" || r.Action != "" { + t.Fatalf("a refusal writes nothing: %+v", r) + } + if r.ClaimedLevel != "governed" || r.VerifiedLevel != "governed" { + t.Fatalf("a refusal still reports both levels: %+v", r) + } + body, err = os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(body) != foreign { + t.Fatal("a refusal never edits and never truncates") + } +} + +func TestBadgeNestedOutCreatesParentsOnlyWhenTheWriteHappens(t *testing.T) { + dir := committedFixture(t, "valid-records") // claims core, verifies core + if err := os.WriteFile(filepath.Join(dir, badge.DefaultOut), []byte("not a badge\n"), 0o644); err != nil { + t.Fatal(err) + } + if mustRun(t, dir, "docs/nested/badge.svg").Out == "" { + t.Fatal("a nested target is written") + } + if _, err := os.Stat(filepath.Join(dir, "docs", "nested", "badge.svg")); err != nil { + t.Fatalf("the nested target: %v", err) + } + // The refusal path writes nothing, so it establishes no directory either. + if mustRun(t, dir, badge.DefaultOut).Refusal == "" { + t.Fatal("the foreign file is refused") + } +} + +func TestBadgeRunThatWritesNothingEstablishesNoDirectory(t *testing.T) { + // Exit 1 (a claim this run refutes): the nested target and its parent are both + // absent afterwards, so the directory is a consequence of the write and not of + // the attempt. + failing := committedFixture(t, "invalid-governed-no-profile") + r := mustRun(t, failing, "pub/x/badge.svg") + if r.Out != "" || r.Action != "" { + t.Fatalf("a refuted claim writes nothing: %+v", r) + } + if !hasErrorFinding(r) { + t.Fatal("a refuted claim reports an error finding") + } + for _, rel := range []string{"pub/x/badge.svg", "pub/x", "pub"} { + if _, err := os.Lstat(filepath.Join(failing, filepath.FromSlash(rel))); err == nil { + t.Fatalf("%s was created by a run that wrote nothing", rel) + } + } + + // Exit 2 (a foreign file at a nested target whose parent already exists): the + // parent is left exactly as it was and the target's bytes are untouched. + dir := committedFixture(t, "valid-badge-governed") + parent := filepath.Join(dir, "pub") + if err := os.Mkdir(parent, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "sibling.txt"), []byte("untouched\n"), 0o644); err != nil { + t.Fatal(err) + } + foreign := "not a badge\n" + if err := os.WriteFile(filepath.Join(parent, "badge.svg"), []byte(foreign), 0o644); err != nil { + t.Fatal(err) + } + before := snapshot(t, 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) + } + body, err := os.ReadFile(filepath.Join(parent, "badge.svg")) + if err != nil { + t.Fatal(err) + } + if string(body) != foreign { + t.Fatal("the target is byte-untouched") + } + if !equalStrings(snapshot(t, dir), before) { + t.Fatal("the tree is untouched") + } +} + +func hasErrorFinding(r badge.Result) bool { + for _, f := range r.Findings { + if f.Severity == "error" { + return true + } + } + return false +} + +// --- containment: the resolved path decides, in both directions ----------------- + +func TestBadgeRefusesAParentResolvingOutsideTheRepository(t *testing.T) { + outside := t.TempDir() + dir := committedFixture(t, "valid-badge-governed") + // A file already standing at the escaped location: the run must neither read it + // (it is not the target the check cleared) nor replace it. + planted := "somebody elses file\n" + if err := os.WriteFile(filepath.Join(outside, "x.svg"), []byte(planted), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "pub")); err != nil { + t.Fatal(err) + } + before := snapshot(t, dir) + + r := mustRun(t, dir, "pub/x.svg") + if r.UsageError == "" && r.Refusal == "" { + t.Fatalf("the escape is refused: %+v", r) + } + if r.Out != "" || r.Action != "" { + t.Fatalf("nothing is reported written: %+v", r) + } + body, err := os.ReadFile(filepath.Join(outside, "x.svg")) + if err != nil { + t.Fatal(err) + } + if string(body) != planted { + t.Fatal("the outside file is untouched") + } + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "x.svg" { + t.Fatal("nothing was created outside the repository") + } + if !equalStrings(snapshot(t, dir), before) { + t.Fatal("and nothing inside it") + } +} + +func TestBadgeRefusesAParentResolvingIntoLejiAtAnyDepth(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + dist := filepath.Join(dir, ".leji", "dist") + if err := os.MkdirAll(dist, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(dist, filepath.Join(dir, "pub")); err != nil { + t.Fatal(err) + } + r := mustRun(t, dir, "pub/x.svg") + if r.UsageError == "" && r.Refusal == "" { + t.Fatalf(".leji/ is never a badge target: %+v", r) + } + if r.Out != "" { + t.Fatalf("nothing is reported written: %+v", r) + } + entries, err := os.ReadDir(dist) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatal("the private role stays empty") + } +} + +func TestBadgeRefusesATargetSymlinkedOutOfTheRepository(t *testing.T) { + outside := t.TempDir() + dir := committedFixture(t, "valid-badge-governed") + planted := "somebody elses file\n" + escaped := filepath.Join(outside, "foreign.svg") + if err := os.WriteFile(escaped, []byte(planted), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(escaped, filepath.Join(dir, badge.DefaultOut)); err != nil { + t.Fatal(err) + } + + r := mustRun(t, dir, badge.DefaultOut) + if r.UsageError == "" && r.Refusal == "" { + t.Fatalf("a link out of the repository is refused: %+v", r) + } + if r.Out != "" || r.Action != "" { + t.Fatalf("nothing is reported written: %+v", r) + } + body, err := os.ReadFile(escaped) + if err != nil { + t.Fatal(err) + } + if string(body) != planted { + t.Fatal("the link target is byte-untouched") + } + info, err := os.Lstat(filepath.Join(dir, badge.DefaultOut)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the link itself is left alone") + } +} + +func TestBadgeRefusesADanglingTargetInsideTheRepository(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + // The link resolves to a missing file INSIDE the repository, so the resolved + // destination is absent while the entry at the target path is not. A write would + // follow the link and create the destination; a standing entry that could not be + // verified as a badge is a refusal instead. + if err := os.Symlink("missing-file.svg", filepath.Join(dir, badge.DefaultOut)); err != nil { + t.Fatal(err) + } + before := snapshot(t, dir) + + r := mustRun(t, dir, badge.DefaultOut) + assertTargetRefusal(t, r, badge.DefaultOut) + info, err := os.Lstat(filepath.Join(dir, badge.DefaultOut)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the link itself is left alone") + } + 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) { + t.Fatal("the tree is untouched") + } +} + +// listenUnix binds a unix socket, or skips: binding one is not portable, and the +// path length limit is the platform's, so a platform that cannot is skipped rather +// than failed (as the reference test is). +func listenUnix(t *testing.T, abs string) net.Listener { + t.Helper() + l, err := net.Listen("unix", abs) + if err != nil { + t.Skipf("this platform cannot bind a unix socket at %s: %v", abs, err) + } + t.Cleanup(func() { _ = l.Close() }) + return l +} + +func TestBadgeRefusesAUnixSocketTargetAsADocumentNotACrash(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + target := filepath.Join(dir, badge.DefaultOut) + // A socket is the non-regular entry that no earlier check rejects: it is not a + // directory, and opening it fails with something other than ENOENT. + listenUnix(t, target) + info, err := os.Lstat(target) + if err != nil || info.Mode()&os.ModeSocket == 0 { + t.Fatal("the target is a socket") + } + before := snapshot(t, 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) { + t.Fatal("the tree is untouched") + } + + // Through the real CLI: the refusal is the ordinary badge document at exit 2, + // which is exactly what an escaping error would deny this case. + code, out := runCLI(t, []string{"badge", "--root", dir, "--json"}) + if code != 2 { + t.Fatalf("the refusal exits 2, got %d", code) + } + assertRefusalDocument(t, out, badge.DefaultOut, "badge-target-refused") +} + +func TestBadgeRefusesASymlinkToAUnixSocketAsADocumentToo(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + sock := filepath.Join(dir, "sock") + target := filepath.Join(dir, badge.DefaultOut) + // The link passes an entry-kind check that stops at the link itself, and the + // verified open then follows it to the socket. So the kind that decides is the + // one at the END of the link. + listenUnix(t, sock) + if err := os.Symlink("sock", target); err != nil { + t.Fatal(err) + } + before := snapshot(t, dir) + + assertTargetRefusal(t, mustRun(t, dir, badge.DefaultOut), badge.DefaultOut) + info, err := os.Lstat(target) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the link itself is left alone") + } + info, err = os.Lstat(sock) + if err != nil || info.Mode()&os.ModeSocket == 0 { + t.Fatal("and so is the socket it points at") + } + if !equalStrings(snapshot(t, dir), before) { + t.Fatal("the tree is untouched") + } + + code, out := runCLI(t, []string{"badge", "--root", dir, "--json"}) + if code != 2 { + t.Fatalf("the refusal exits 2, got %d", code) + } + assertRefusalDocument(t, out, badge.DefaultOut, "badge-target-refused") +} + +// assertTargetRefusal is the one standing-entry refusal, in the same words for +// every entry kind that is not a regular file this run may act through. +func assertTargetRefusal(t *testing.T, r badge.Result, rel string) { + t.Helper() + want := rel + " does not resolve to a regular file inside the repository; nothing was written" + if r.Refusal != want { + t.Fatalf("refusal %q, want %q", r.Refusal, want) + } + if r.Out != "" || r.Action != "" { + t.Fatalf("a refusal writes nothing: %+v", r) + } + if got := findingKeys(r); len(got) != 1 || got[0] != "badge-target-refused|error|"+rel { + t.Fatalf("findings %v", got) + } +} + +func findingKeys(r badge.Result) []string { + var out []string + for _, f := range r.Findings { + out = append(out, f.Rule+"|"+f.Severity+"|"+f.Path) + } + return out +} + +// --- the `--json` document ---------------------------------------------------- + +// documentKeys is 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. +var documentKeys = []string{ + "command", "ok", "findings", "summary", "out", "level", + "claimedLevel", "verifiedLevel", "markdown", "action", +} + +type badgeDocument struct { + Command string `json:"command"` + OK bool `json:"ok"` + Findings []struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Path string `json:"path"` + } `json:"findings"` + Summary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + } `json:"summary"` + Out *string `json:"out"` + Level *string `json:"level"` + ClaimedLevel *string `json:"claimedLevel"` + VerifiedLevel *string `json:"verifiedLevel"` + Markdown *string `json:"markdown"` + Action *string `json:"action"` +} + +func parseDocument(t *testing.T, stdout, where string) badgeDocument { + t.Helper() + var keyed map[string]json.RawMessage + if err := json.Unmarshal([]byte(stdout), &keyed); err != nil { + t.Fatalf("%s: not a JSON document: %v\n%s", where, err, stdout) + } + var got []string + for k := range keyed { + got = append(got, k) + } + sort.Strings(got) + want := append([]string{}, documentKeys...) + sort.Strings(want) + if !equalStrings(got, want) { + t.Fatalf("%s: the exact JSON key set, got %v want %v", where, got, want) + } + var doc badgeDocument + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatalf("%s: %v", where, err) + } + return doc +} + +func docFindingKeys(doc badgeDocument) []string { + var out []string + for _, f := range doc.Findings { + out = append(out, f.Rule+"|"+f.Severity+"|"+f.Path) + } + return out +} + +// assertRefusalDocument is the standing-entry refusal as a `--json` consumer reads +// it: the ordinary badge document, `ok:false`, every written field null. +func assertRefusalDocument(t *testing.T, stdout, rel, rule string) { + t.Helper() + doc := parseDocument(t, stdout, "refusal") + if doc.Command != "badge" || doc.OK { + t.Fatalf("command %q ok %v", doc.Command, doc.OK) + } + if doc.Out != nil || doc.Level != nil || doc.Markdown != nil || doc.Action != nil { + t.Fatalf("a refusal reports nothing written: %+v", doc) + } + if got := docFindingKeys(doc); len(got) != 1 || got[0] != rule+"|error|"+rel { + t.Fatalf("findings %v", got) + } + if doc.Summary.Errors != 1 || doc.Summary.Warnings != 0 { + t.Fatalf("summary %+v", doc.Summary) + } +} + +func TestBadgeOutUsageErrorExitsTwoAndEmitsNoDocumentAtAll(t *testing.T) { + dir := committedFixture(t, "valid-badge-governed") + for _, bad := range []string{"x.png", "../x.svg", "/abs.svg", ".leji/x.svg"} { + code, out := runCLI(t, []string{"badge", "--root", dir, "--json", "--out", bad}) + if code != 2 { + t.Fatalf("%s is a usage error, got %d", bad, code) + } + if strings.TrimSpace(out) != "" { + t.Fatalf("%s writes nothing to stdout, so no level is reported: %q", bad, out) + } + } + if _, err := os.Lstat(filepath.Join(dir, badge.DefaultOut)); err == nil { + t.Fatal("and nothing was written") + } +} + +// --- the shared fixtures' `badge` blocks -------------------------------------- + +type badgePreseed struct { + Path string `json:"path"` + From string `json:"from"` + Bytes string `json:"bytes"` +} + +type badgeBlock struct { + Args []string `json:"args"` + Exit int `json:"exit"` + Out *string `json:"out"` + Level *string `json:"level"` + ClaimedLevel *string `json:"claimedLevel"` + VerifiedLevel *string `json:"verifiedLevel"` + Golden *string `json:"golden"` + Action *string `json:"action"` + Written *bool `json:"written"` + Preseed *badgePreseed `json:"preseed"` + Rerun *struct { + Action string `json:"action"` + ByteIdentical bool `json:"byteIdentical"` + } `json:"rerun"` +} + +func loadBadgeBlock(t *testing.T, dir string) *badgeBlock { + t.Helper() + body, err := os.ReadFile(filepath.Join(dir, "expected.json")) + if err != nil { + t.Fatal(err) + } + var wrapper struct { + Badge *badgeBlock `json:"badge"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { + t.Fatal(err) + } + return wrapper.Badge +} + +// expectedFindings are the findings and the summary a `badge` block PINS — fixed by +// the block alone, never read off the document being judged, so a different rule, an +// extra finding or a missing one fails. Three outcomes exhaust the block: a success +// reports nothing; an exit-2 refusal names the foreign file it would not overwrite; +// an exit-1 run reports the conformance error that left nothing honest to state — +// the claim gate when this run verified a level below the claim, `badge-unverified` +// when it verified no level at all. +func expectedFindings(block *badgeBlock, targetRel string) ([]string, int) { + if block.Exit == 0 { + return nil, 0 + } + if block.Exit == 2 { + return []string{"badge-target-foreign|error|" + targetRel}, 1 + } + rule := "conformance-claim" + if block.VerifiedLevel == nil { + rule = "badge-unverified" + } + return []string{rule + "|error|leji.json"}, 1 +} + +// assertBadgeDocument compares the whole `--json` document against the block: the +// exact key set, and every value the block fixes — including the findings and the +// summary, pinned rather than derived from the document, which is what makes a wrong +// rule or a stray finding fail here. +func assertBadgeDocument(t *testing.T, stdout string, block *badgeBlock, targetRel, where string) { + t.Helper() + doc := parseDocument(t, stdout, where) + if doc.Command != "badge" { + t.Fatalf("%s: command %q", where, doc.Command) + } + for _, f := range []struct { + name string + got, want *string + }{ + {"out", doc.Out, block.Out}, + {"level", doc.Level, block.Level}, + {"claimedLevel", doc.ClaimedLevel, block.ClaimedLevel}, + {"verifiedLevel", doc.VerifiedLevel, block.VerifiedLevel}, + {"action", doc.Action, block.Action}, + } { + if !sameNullable(f.got, f.want) { + t.Fatalf("%s: %s %s, want %s", where, f.name, showNullable(f.got), showNullable(f.want)) + } + } + var wantMarkdown *string + if block.Level != nil && block.Out != nil { + md := badge.Markdown(*block.Level, *block.Out) + wantMarkdown = &md + } + if !sameNullable(doc.Markdown, wantMarkdown) { + t.Fatalf("%s: markdown %s, want %s", where, showNullable(doc.Markdown), showNullable(wantMarkdown)) + } + if doc.OK != (block.Exit == 0) { + t.Fatalf("%s: ok %v tracks the exit code %d", where, doc.OK, block.Exit) + } + wantFindings, wantErrors := expectedFindings(block, targetRel) + if !equalStrings(docFindingKeys(doc), wantFindings) { + t.Fatalf("%s: findings %v, want %v", where, docFindingKeys(doc), wantFindings) + } + if doc.Summary.Errors != wantErrors || doc.Summary.Warnings != 0 { + t.Fatalf("%s: summary %+v, want {%d 0}", where, doc.Summary, wantErrors) + } +} + +func sameNullable(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func showNullable(p *string) string { + if p == nil { + return "null" + } + return `"` + *p + `"` +} + +func TestFixtureBadgeBlocks(t *testing.T) { + fd := fixturesDir(t) + for _, name := range fixtureNames(t) { + block := loadBadgeBlock(t, filepath.Join(fd, name)) + if block == nil { + continue + } + t.Run(name, func(t *testing.T) { + dir := committedFixture(t, name) + targetRel := badge.DefaultOut + switch { + case block.Preseed != nil: + targetRel = block.Preseed.Path + case block.Out != nil: + targetRel = *block.Out + } + target := filepath.Join(dir, filepath.FromSlash(targetRel)) + var planted []byte + if block.Preseed != nil { + body := []byte(block.Preseed.Bytes) + if block.Preseed.From != "" { + body = goldenBadge(t, block.Preseed.From) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, body, 0o644); err != nil { + t.Fatal(err) + } + planted = body + } + + args := block.Args + if args == nil { + args = []string{"badge"} + } + code, stdout := runCLI(t, append(append([]string{}, args...), "--root", dir, "--json")) + if code != block.Exit { + t.Fatalf("exit code: got %d want %d\n%s", code, block.Exit, stdout) + } + // The document carries every outcome, refusals included: `ok:false` and the + // rule that refused, at the target path, are pinned inside it. + assertBadgeDocument(t, stdout, block, targetRel, name+" (first run)") + + if block.Golden != nil { + got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(*block.Out))) + if err != nil { + t.Fatal(err) + } + if string(got) != string(goldenBadge(t, *block.Golden)) { + t.Fatalf("the written bytes differ from %s", *block.Golden) + } + } + // `written: false` is two claims in one: the target does not exist after the + // run, or — when `preseed` planted it — its planted bytes are still there. + if block.Written != nil && !*block.Written { + if planted == nil { + if _, err := os.Lstat(target); err == nil { + t.Fatalf("%s was never created", targetRel) + } + } else { + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != string(planted) { + t.Fatalf("%s is byte-untouched", targetRel) + } + } + } + + if block.Rerun != nil { + afterFirst := snapshot(t, 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) + } + // The whole document again, not just `action`: the steady state is the + // same run reported the same way, with the write already done. + steady := *block + steady.Action = &block.Rerun.Action + steady.Preseed = nil + assertBadgeDocument(t, stdout, &steady, targetRel, name+" (rerun)") + if block.Rerun.ByteIdentical && !equalStrings(snapshot(t, 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 new file mode 100644 index 0000000..ffbe391 --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/canary_test.go @@ -0,0 +1,1362 @@ +package conformancetest + +// 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 the request corpus (`trustCanary`) and the layout claims +// (`export.layout`), so all three SDKs answer identical requests against identical +// bytes. +// +// Scope: the four F8 layout fixtures — their layout roles, their golden export +// bytes, and their canary corpus. The general `export`-block harness (findings, +// `--strict` variants) takes every other fixture. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/commands/export" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/serve" + "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" +) + +var layoutFixtures = []string{ + "valid-unified-leji-fresh", + "valid-unified-leji-stale-tree", + "valid-trust-canary-nested-root", + "valid-trust-canary-dot-root", +} + +// token is the planted byte string. Spelled in each harness and deliberately in no +// `expected.json`: under `rootPath: "."` a fixture's own metadata is exported like +// any other file, so a token literal there would count as a leak. +const token = "LEJI-TRUST-CANARY" + +type seed struct { + From string `json:"from"` + To string `json:"to"` +} + +type expectedLayout struct { + Roles map[string]string `json:"roles"` + Present []string `json:"present"` + Absent []string `json:"absent"` + Preserved []string `json:"preserved"` +} + +type expectedExport struct { + Exit int `json:"exit"` + Out string `json:"out"` + Layout expectedLayout `json:"layout"` + Rerun struct { + ByteIdentical bool `json:"byteIdentical"` + } `json:"rerun"` + GoldenTree goldenTree `json:"goldenTree"` +} + +type canaryRequest struct { + Path string `json:"path"` + Status int `json:"status"` + Note string `json:"note"` +} + +type expectedCanary struct { + Topology string `json:"topology"` + PlantedPaths []string `json:"plantedPaths"` + Serve struct { + Requests []canaryRequest `json:"requests"` + RouteScan *struct { + AssertNoTokenIn200Bodies *bool `json:"assertNoTokenIn200Bodies"` + } `json:"routeScan"` + } `json:"serve"` + ExportScan struct { + Root string `json:"root"` + Occurrences int `json:"occurrences"` + } `json:"exportScan"` +} + +type layoutExpectation struct { + Seeds []seed `json:"seeds"` + Export *expectedExport `json:"export"` + TrustCanary *expectedCanary `json:"trustCanary"` +} + +// fixtureRel checks a fixture-declared path as the README fixes it: +// repository-root-relative POSIX, normalized, no `..` segment, never absolute. A +// violation is a harness error — the fixture is the contract, so a malformed one +// fails loudly rather than being repaired here. +func fixtureRel(t *testing.T, value, what string) string { + t.Helper() + if path.IsAbs(value) { + t.Fatalf("%s must be relative: %s", what, value) + } + trimmed := strings.TrimRight(value, "/") + if normalized := path.Clean(value); normalized != trimmed { + t.Fatalf("%s must be normalized: %s", what, value) + } + for _, seg := range strings.Split(trimmed, "/") { + if seg == ".." { + t.Fatalf("%s must not escape the fixture: %s", what, value) + } + } + return trimmed +} + +// fixtureAbs joins a fixture-declared POSIX path onto a working copy. +func fixtureAbs(dir, rel string) string { + return filepath.Join(dir, filepath.FromSlash(rel)) +} + +// copySeed copies a committed seed's CONTENTS into `to`, which the harness creates. +// Regular files and directories only: a symlink anywhere inside a seed is a harness +// error, and no seed file is ever executed, so modes stay the platform's default. +func copySeed(t *testing.T, from, to string) { + t.Helper() + if err := os.MkdirAll(to, 0o755); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(from) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + src := filepath.Join(from, e.Name()) + dest := filepath.Join(to, e.Name()) + if e.Type()&os.ModeSymlink != 0 { + t.Fatalf("seed carries a symlink: %s", src) + } + if e.IsDir() { + if e.Name() == ".leji" || e.Name() == "dist" { + t.Fatalf("seed path component %q is gitignored at any depth; spell it under the seed name", e.Name()) + } + copySeed(t, src, dest) + continue + } + if !e.Type().IsRegular() { + t.Fatalf("seed carries a non-regular file: %s", src) + } + body, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dest, body, 0o644); err != nil { + t.Fatal(err) + } + } +} + +// materialize is a pristine working copy of the fixture with every declared seed +// materialized. +func materialize(t *testing.T, name string, seeds []seed) string { + t.Helper() + dir := t.TempDir() + cpTree(t, filepath.Join(fixturesDir(t), name), dir) + var targets []string + for _, s := range seeds { + from := fixtureRel(t, s.From, "seed.from") + to := fixtureRel(t, s.To, "seed.to") + toAbs := fixtureAbs(dir, to) + // A pre-existing target means the working copy is not what the harness thinks + // it is; overlapping targets are a fixture-authoring error, not something to + // resolve by ordering. + if _, err := os.Lstat(toAbs); err == nil { + t.Fatalf("seed target already exists: %s", to) + } + for _, other := range targets { + if to == other || strings.HasPrefix(to, other+"/") { + t.Fatalf("seed targets overlap: %s and %s", to, other) + } + } + targets = append(targets, to) + copySeed(t, fixtureAbs(dir, from), toAbs) + } + return dir +} + +// cpTree copies src's contents into dst (which must exist); plain files and +// directories, the shape every fixture ships. +func cpTree(t *testing.T, src, dst string) { + t.Helper() + entries, err := os.ReadDir(src) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + from := filepath.Join(src, e.Name()) + to := filepath.Join(dst, e.Name()) + if e.IsDir() { + if err := os.MkdirAll(to, 0o755); err != nil { + t.Fatal(err) + } + cpTree(t, from, to) + continue + } + body, err := os.ReadFile(from) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(to, body, 0o644); err != nil { + t.Fatal(err) + } + } +} + +// 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) { + t.Helper() + if _, err := os.Stat(dir); err != nil { + return 0, nil + } + count := 0 + var where []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 { + childRel := e.Name() + if rel != "" { + childRel = rel + "/" + e.Name() + } + if e.IsDir() { + walk(childRel) + continue + } + if !e.Type().IsRegular() { + continue + } + body, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(childRel))) + if err != nil { + t.Fatal(err) + } + if hits := strings.Count(string(body), token); hits > 0 { + count += hits + where = append(where, childRel) + } + } + } + walk("") + return count, where +} + +// serveLayer starts the viewer over dir and returns its base URL plus a stop func. +func serveLayer(t *testing.T, dir string, m *manifest.Manifest) (string, func()) { + t.Helper() + ln, srv, err := serve.Serve(dir, 0, m.RootPath, nil) + if err != nil { + t.Fatalf("serve: %v", err) + } + go func() { _ = srv.Serve(ln) }() + return "http://" + ln.Addr().String(), func() { _ = srv.Close() } +} + +// requestRaw issues one request with the corpus's path EXACTLY as written — no URL +// parsing on this side, or the encoded and malformed variants would be +// canonicalized before the server ever saw them. +func requestRaw(t *testing.T, addr, urlPath string) (int, string) { + t.Helper() + host := strings.TrimPrefix(addr, "http://") + conn, err := net.Dial("tcp", host) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if _, err := fmt.Fprintf(conn, "GET %s HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n", urlPath); err != nil { + t.Fatalf("write request: %v", err) + } + raw, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("read response: %v", err) + } + text := string(raw) + var status int + if _, err := fmt.Sscanf(text, "HTTP/1.0 %d", &status); err != nil { + if _, err := fmt.Sscanf(text, "HTTP/1.1 %d", &status); err != nil { + t.Fatalf("unparsable status line: %q", text) + } + } + body := "" + if i := strings.Index(text, "\r\n\r\n"); i >= 0 { + body = text[i+4:] + } + return status, body +} + +func loadLayoutExpectation(t *testing.T, name string) layoutExpectation { + t.Helper() + b, err := os.ReadFile(filepath.Join(fixturesDir(t), name, "expected.json")) + if err != nil { + t.Fatal(err) + } + var exp layoutExpectation + if err := json.Unmarshal(b, &exp); err != nil { + t.Fatal(err) + } + return exp +} + +func TestLayoutFixturesCanaryAndIdempotency(t *testing.T) { + for _, name := range layoutFixtures { + t.Run(name, func(t *testing.T) { + exp := loadLayoutExpectation(t, name) + if exp.Export == nil { + t.Fatalf("%s must declare an export block", name) + } + canary := exp.TrustCanary + dir := materialize(t, name, exp.Seeds) + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the fixture manifest must load") + } + + // The planted bytes are really planted: without this the scans below could + // pass over a fixture that plants nothing. + plantedBefore := map[string]string{} + if canary != nil { + for _, rel := range canary.PlantedPaths { + body, err := os.ReadFile(fixtureAbs(dir, fixtureRel(t, rel, "plantedPaths entry"))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), token) { + t.Fatalf("%s must carry the canary token", rel) + } + plantedBefore[rel] = string(body) + } + } + // Every path the fixture says must survive the run, as it stands before it. + preservedBefore := map[string]string{} + for _, rel := range exp.Export.Layout.Preserved { + abs := fixtureAbs(dir, fixtureRel(t, rel, "preserved entry")) + info, err := os.Stat(abs) + if err != nil { + t.Fatalf("preserved path must exist before the run: %s", rel) + } + if info.Mode().IsRegular() { + body, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + preservedBefore[rel] = string(body) + } + } + + // --- the run --------------------------------------------------------- + first, err := export.BuildViewer(dir, m, "", export.Options{}) + if err != nil { + t.Fatalf("BuildViewer: %v", err) + } + exit := 0 + for _, f := range first.Findings { + if f.Severity == findings.Error { + exit = 1 + } + } + if exit != exp.Export.Exit { + t.Fatalf("exit code %d, want %d (findings: %v)", exit, exp.Export.Exit, first.Findings) + } + if got := filepath.ToSlash(first.Out); got != exp.Export.Out { + t.Fatalf("output directory %q, want %q", got, exp.Export.Out) + } + + // --- layout ---------------------------------------------------------- + for role, roleDir := range exp.Export.Layout.Roles { + abs := fixtureAbs(dir, fixtureRel(t, roleDir, "role "+role)) + if info, err := os.Stat(abs); err != nil || !info.IsDir() { + t.Fatalf("role %s must be established at %s", role, roleDir) + } + } + for _, rel := range exp.Export.Layout.Present { + if _, err := os.Stat(fixtureAbs(dir, fixtureRel(t, rel, "present entry"))); err != nil { + t.Fatalf("must be present after the run: %s", rel) + } + } + for _, rel := range exp.Export.Layout.Absent { + if _, err := os.Stat(fixtureAbs(dir, fixtureRel(t, rel, "absent entry"))); err == nil { + t.Fatalf("must never be created: %s", rel) + } + } + for rel, before := range preservedBefore { + body, err := os.ReadFile(fixtureAbs(dir, rel)) + if err != nil { + t.Fatalf("must still be present after the run: %s", rel) + } + if string(body) != before { + t.Fatalf("must be byte-identical after the run: %s", rel) + } + } + + // --- the golden tree --------------------------------------------------- + assertGoldenTree(t, filepath.Join(fixturesDir(t), name), + fixtureAbs(dir, fixtureRel(t, exp.Export.Out, "export out")), exp.Export.GoldenTree) + + // --- the export-side scan -------------------------------------------- + if canary != nil { + scanRoot := fixtureAbs(dir, fixtureRel(t, canary.ExportScan.Root, "exportScan.root")) + count, where := countToken(t, scanRoot) + if count != canary.ExportScan.Occurrences { + t.Fatalf("canary occurrences in %s: %d, want %d (%v)", + canary.ExportScan.Root, count, canary.ExportScan.Occurrences, where) + } + } + + // --- the serve corpus ------------------------------------------------- + if canary != nil { + addr, stop := serveLayer(t, dir, m) + scanBodies := canary.Serve.RouteScan == nil || canary.Serve.RouteScan.AssertNoTokenIn200Bodies == nil || + *canary.Serve.RouteScan.AssertNoTokenIn200Bodies + for _, want := range canary.Serve.Requests { + status, body := requestRaw(t, addr, want.Path) + if status != want.Status { + t.Fatalf("%s: status %d, want %d%s", want.Path, status, want.Status, noteOf(want)) + } + if status == 200 && scanBodies && strings.Contains(body, token) { + t.Fatalf("canary byte in the 200 body of %s", want.Path) + } + } + stop() + } + + // --- idempotency ------------------------------------------------------- + if exp.Export.Rerun.ByteIdentical { + afterFirst := snapshot(t, dir) + if _, err := export.BuildViewer(dir, m, "", export.Options{}); err != nil { + t.Fatalf("second BuildViewer: %v", err) + } + afterSecond := snapshot(t, 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)) + } + } + + // The planted bytes are still exactly as planted: the tool never read them + // into anything, and never rewrote them either. + for rel, before := range plantedBefore { + body, err := os.ReadFile(fixtureAbs(dir, rel)) + if err != nil || string(body) != before { + t.Fatalf("must be untouched: %s", rel) + } + } + }) + } +} + +func noteOf(r canaryRequest) string { + if r.Note == "" { + return "" + } + return " — " + r.Note +} + +// diffStrings is the entries of a not present in b (sorted slices). +func diffStrings(a, b []string) []string { + set := map[string]bool{} + for _, s := range b { + set[s] = true + } + var out []string + for _, s := range a { + if !set[s] { + out = append(out, s) + } + } + return out +} + +// canaryLayer is the dot-root canary layer with its seed materialized: the topology +// where the trust domain sits inside the content mount, so a symlink into it +// resolves inside every containment check and only the by-name whitelist refuses it. +func canaryLayer(t *testing.T) string { + t.Helper() + return materialize(t, "valid-trust-canary-dot-root", []seed{{From: ".leji-seed", To: ".leji"}}) +} + +func mustLoad(t *testing.T, dir string) *manifest.Manifest { + t.Helper() + m := manifest.LoadManifest(dir).Manifest + if m == nil { + t.Fatal("the fixture manifest must load") + } + return m +} + +// The one boundary a fixture cannot plant (a seed carries no symlinks) and the one +// the dot convention cannot hold: under `rootPath: "."` the trust domain really is +// inside the content mount, so a symlink there resolves INSIDE the mount root and +// passes every containment check. Only the by-name whitelist refuses it — remove the +// ServablePath calls in the serve path and this test serves the canary. +func TestWhitelistRefusesContentSymlinkIntoPrivateRole(t *testing.T) { + dir := canaryLayer(t) + if err := os.Symlink(filepath.Join(".leji", "work", "proposal.md"), filepath.Join(dir, "leak.md")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(".leji", "work"), filepath.Join(dir, "leakdir")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + // Generate the chrome (and an export) with the symlinks already planted, so the + // serve legs run against a complete layer and the export legs see the bait. + if _, err := export.BuildViewer(dir, m, "", export.Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + addr, stop := serveLayer(t, dir, m) + defer stop() + for _, route := range []string{"/content/leak.md", "/content/leakdir/proposal.md"} { + status, body := requestRaw(t, addr, route) + if status != http.StatusNotFound { + t.Fatalf("%s must be denied by name whatever it resolves to, got %d", route, status) + } + if strings.Contains(body, token) { + t.Fatalf("canary byte in the response to %s", route) + } + } + // The servable role still serves through its own mount: the whitelist denies the + // other roles, not the chrome. + if status, _ := requestRaw(t, addr, "/index.html"); status != http.StatusOK { + t.Fatalf("the chrome must still serve, got %d", status) + } + // And the export never followed it either (symlinks are skipped, and the target is + // outside the enumerated roots). + if count, where := countToken(t, filepath.Join(dir, ".leji", "dist")); count != 0 { + t.Fatalf("canary bytes in the export: %v", where) + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "dist", "content", "leak.md")); err == nil { + t.Fatal("the symlink must not be exported") + } +} + +// The vectors below share the reason the test above lives here rather than in a +// fixture: they need a symlink (a seed carries none by contract — copySeed refuses +// one) or a hostile manifest, which is a per-SDK hazard rather than a shared contract +// the fixtures publish. So they are constructed at runtime, over a fixture's own +// layer and its own planted bytes. + +func TestWhitelistRefusesBoundProfileInPrivateRole(t *testing.T) { + dir := canaryLayer(t) + // A profile pair the resolver really composes: an ordinary base under the layer's + // agents directory, and a derived half planted in the onboarding workspace, bound + // into the roster by a symlink at the content root. Without the whitelist on the + // profile sources, the resolved page renders the planted half verbatim — the + // overlay answers before the content mount ever judges the path. + if err := os.MkdirAll(filepath.Join(dir, "agents"), 0o755); err != nil { + t.Fatal(err) + } + base := strings.Join([]string{ + "---", "id: core", "name: Core", "role: core", + "requiredRead:", " - boot-profile.md", + "mustAskWhen:", " - anything is unclear", "---", "", "Base body.", "", + }, "\n") + if err := os.WriteFile(filepath.Join(dir, "agents", "core.md"), []byte(base), 0o644); err != nil { + t.Fatal(err) + } + derived := strings.Join([]string{ + "---", "id: leak", "name: Leak", "role: leak", "inherits: core", "---", "", "Planted: " + token, "", + }, "\n") + if err := os.WriteFile(filepath.Join(dir, ".leji", "work", "leak-profile.md"), []byte(derived), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(".leji", "work", "leak-profile.md"), filepath.Join(dir, "leak.md")); err != nil { + t.Fatal(err) + } + patchManifest(t, dir, func(declared map[string]any) { + declared["agents"] = map[string]any{"leak": "leak.md"} + }) + + m := mustLoad(t, dir) + if _, err := export.BuildViewer(dir, m, "", export.Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + addr, stop := serveLayer(t, dir, m) + defer stop() + status, body := requestRaw(t, addr, "/content/leak.md") + if status != http.StatusNotFound { + t.Fatalf("the profile overlay must refuse a source it may not read, got %d", status) + } + if strings.Contains(body, token) { + t.Fatal("canary byte in the response") + } + // The overlay still resolves the profiles it may read. + if status, _ := requestRaw(t, addr, "/content/agents/core.md"); status != http.StatusOK { + t.Fatalf("a servable profile must still resolve, got %d", status) + } + if count, where := countToken(t, filepath.Join(dir, ".leji", "dist")); count != 0 { + t.Fatalf("canary bytes in the export: %v", where) + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "dist", "content", "leak.md")); err == nil { + t.Fatal("no page must be written for it") + } +} + +func TestSidebarLiftsNoLabelOutOfAPrivateProfilesDir(t *testing.T) { + dir := canaryLayer(t) + // The same scan, reached the other way: a declared `agentProfilesPath` naming a + // private role needs no symlink at all. The page itself was always refused, but the + // sidebar built its label from the file's frontmatter — bytes of a private file, + // served in a 200 body and copied into the export. + planted := strings.Join([]string{ + "---", "id: planted", "name: " + token, "role: planted", + "requiredRead:", " - boot-profile.md", + "mustAskWhen:", " - anything is unclear", "---", "", "Body.", "", + }, "\n") + if err := os.WriteFile(filepath.Join(dir, ".leji", "work", "p.md"), []byte(planted), 0o644); err != nil { + t.Fatal(err) + } + patchManifest(t, dir, func(declared map[string]any) { + declared["machine"] = map[string]any{"agentProfilesPath": ".leji/work/"} + }) + m := mustLoad(t, dir) + if _, err := export.BuildViewer(dir, m, "", export.Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + if count, where := countToken(t, filepath.Join(dir, ".leji", "dist")); count != 0 { + t.Fatalf("canary bytes in the export: %v", where) + } + addr, stop := serveLayer(t, dir, m) + defer stop() + status, body := requestRaw(t, addr, "/content/_sidebar.md") + if status != http.StatusOK { + t.Fatalf("the live sidebar must still build, got %d", status) + } + if strings.Contains(body, token) { + t.Fatal("the sidebar must carry no byte of the planted profile") + } +} + +// --- The check-before-act invariant on WRITE/CLEAR targets ---------------------------------- +// One structural rule: every location the tool writes into or clears is realpath- +// resolved and validated against its role BEFORE the operation — never after, never +// conditionally. These pin the two write-side vectors two review rounds left open. + +func TestCheckBeforeActGenerationRefusesViewerAliasedIntoPrivateRole(t *testing.T) { + dir := canaryLayer(t) + // Point the servable role at another private role, bytes of its own already there. + // Before the check-before-act rule, generation wrote the chrome THROUGH the link + // into the trust domain and only the export's later identity check noticed — after + // the mutation. The aliased directory is snapshotted WHOLE, so any pre-refusal write + // (not just an overwrite of one planted file) is caught. + aliased := filepath.Join(dir, ".leji", "work", "chrome") + if err := os.MkdirAll(filepath.Join(aliased, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(aliased, "assets", "planted.txt"), []byte(token+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("work", "chrome"), filepath.Join(dir, ".leji", "viewer")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + before := snapshot(t, aliased) + + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + if !hasRefusal(gen.Findings) { + t.Fatalf("generation must refuse with a hard error, got %v", gen.Findings) + } + if len(gen.Written) != 0 { + t.Fatalf("generation must write nothing, got %v", gen.Written) + } + if !equalStrings(before, snapshot(t, aliased)) { + t.Fatal("the aliased private role must be byte-identical") + } + + // BuildViewer regenerates first, so it inherits the refusal and never reaches the + // destructive clean/copy: no export is produced either. + built, err := export.BuildViewer(dir, m, "", export.Options{}) + if err != nil { + t.Fatalf("BuildViewer: %v", err) + } + if !hasRefusal(built.Findings) { + t.Fatalf("the export must inherit the refusal, got %v", built.Findings) + } + if !equalStrings(before, snapshot(t, aliased)) { + t.Fatal("still untouched after BuildViewer") + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "dist")); err == nil { + t.Fatal("no export must be written") + } +} + +func TestCheckBeforeActDefaultOutputRefusesDistIntoPrivateRole(t *testing.T) { + dir := canaryLayer(t) + // The surviving default-bypass vector: the reservation used to be conditioned on a + // caller --out, so a default .leji/dist redirected into the trust domain slipped + // through. Now the default is validated identically — before any clear or write. + planted := filepath.Join(dir, ".leji", "mounts", "store", "x") + if err := os.MkdirAll(planted, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(planted, "planted"), []byte(token+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("mounts", "store", "x"), filepath.Join(dir, ".leji", "dist")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + before := snapshot(t, filepath.Join(dir, ".leji", "mounts")) + _, 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"))) { + t.Fatal("nothing must be cleared or written in the private role") + } + body, rerr := os.ReadFile(filepath.Join(planted, "planted")) + if rerr != nil || string(body) != token+"\n" { + t.Fatal("the planted bytes must be intact") + } +} + +func TestCheckBeforeActOutOfRepositoryViewerOrDistAliasIsRefused(t *testing.T) { + // Containment is absolute: every write this tool makes lands inside the repository + // it was pointed at. A `.leji/viewer` or `.leji/dist` symlinked to a real, empty + // destination outside the tree — once a supported relocate/publish alias — is a + // hard refusal now, with nothing written through it. A user who wants the export + // elsewhere copies the finished folder there. + chromeHome := t.TempDir() + relocated := canaryLayer(t) + if err := os.Symlink(chromeHome, filepath.Join(relocated, ".leji", "viewer")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, relocated) + built, err := export.BuildViewer(relocated, m, "", export.Options{}) + if err != nil { + t.Fatalf("the relocated viewer role must be a finding, not a failure: %v", err) + } + if !hasRefusal(built.Findings) { + t.Fatalf("the relocated viewer role must be refused, got %v", built.Findings) + } + if built.Wrote { + t.Fatal("the export must not run") + } + if entries, rerr := os.ReadDir(chromeHome); rerr != nil || len(entries) != 0 { + t.Fatalf("nothing may be written into the out-of-tree viewer home, got %v (%v)", entries, rerr) + } + + publish := t.TempDir() + published := canaryLayer(t) + if err := os.Symlink(publish, filepath.Join(published, ".leji", "dist")); err != nil { + t.Fatal(err) + } + pm := mustLoad(t, published) + _, berr := export.BuildViewer(published, pm, "", export.Options{}) + if berr == nil || !strings.Contains(berr.Error(), "resolves outside the repository") { + t.Fatalf("the out-of-tree publish target must be refused, got %v", berr) + } + if entries, rerr := os.ReadDir(publish); rerr != nil || len(entries) != 0 { + t.Fatalf("nothing may be written into the out-of-tree publish root, got %v (%v)", entries, rerr) + } +} + +func TestCheckBeforeActBoundarySkipWarnsOnceAndCleanBuildIsSilent(t *testing.T) { + // A servable-looking source (an .md at the content root) whose resolved path lands + // in a private role: withheld from serve and export, and — unlike an ordinary skip — + // it says why, exactly once, on stderr (never stdout, never --json). + dir := canaryLayer(t) + if err := os.Symlink(filepath.Join(".leji", "work", "proposal.md"), filepath.Join(dir, "leak.md")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + stderr := captureStderr(t, func() { + if _, err := export.BuildViewer(dir, m, "", export.Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + }) + var warnings []string + for _, line := range strings.Split(stderr, "\n") { + if strings.HasPrefix(line, "skipped leak.md:") { + warnings = append(warnings, line) + } + } + if len(warnings) != 1 { + t.Fatalf("the withheld source must be named exactly once: %q", stderr) + } + if !strings.Contains(warnings[0], "resolves into .leji/work (private); not served or exported") { + t.Fatalf("boundary-skip wording: %q", warnings[0]) + } + if count, _ := countToken(t, filepath.Join(dir, ".leji", "dist")); count != 0 { + t.Fatal("no canary byte must reach the export") + } + + // A clean layer (no cross-role source) says nothing on stderr. + clean := canaryLayer(t) + m2 := mustLoad(t, clean) + quiet := captureStderr(t, func() { + if _, err := export.BuildViewer(clean, m2, "", export.Options{}); err != nil { + t.Fatalf("BuildViewer: %v", err) + } + }) + for _, line := range strings.Split(quiet, "\n") { + if strings.HasPrefix(line, "skipped ") { + t.Fatalf("a clean build must emit no boundary-skip warning: %q", quiet) + } + } +} + +func TestExportRefusesOutThatResolvesIntoPrivateRole(t *testing.T) { + // The nested topology, deliberately: with the content root a subdirectory, an --out + // at the repository root is a legitimate destination, so the reservation is the only + // rule standing between a redirected path and the private domain. + dir := materialize(t, "valid-trust-canary-nested-root", []seed{{From: ".leji-seed", To: ".leji"}}) + m := mustLoad(t, dir) + // Proof the destination is otherwise open: an ordinary sibling path exports. + if _, err := export.BuildViewer(dir, m, "plain-out", export.Options{}); err != nil { + t.Fatalf("an ordinary --out at the root must export: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "plain-out", "index.html")); err != nil { + t.Fatalf("expected the ordinary export: %v", err) + } + // The same path, redirected: the reservation judges where the write would land, so + // the private role is refused however the destination is spelled. + if err := os.Symlink(filepath.Join(".leji", "mounts"), filepath.Join(dir, "redirect")); err != nil { + t.Fatal(err) + } + _, 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 redirected --out must be refused, got %v", err) + } + if _, serr := os.Stat(filepath.Join(dir, ".leji", "mounts", "export")); serr == nil { + t.Fatal("nothing must be written into the private role") + } + // The refusal is not destructive either: the planted bytes are as planted. + body, rerr := os.ReadFile(filepath.Join(dir, ".leji", "mounts", "store", "x", "planted")) + if rerr != nil || !strings.Contains(string(body), token) { + t.Fatal("the private role must be intact") + } +} + +// --- Check-before-act completeness: the overview.md write sites and the resolver's dangling paths. +// These pin the write sites two review rounds after the first left them: overview.md +// (seed AND refresh) is a content write that used to be guarded by containment only, +// and a nested/chained/unresolvable `--out` whose real destination the resolver used +// to rebuild lexically. Each hard-refusal case names, in its comment, the mutation +// that reddens it. + +func TestCheckBeforeActOverviewSeedRefusedIntoPrivateRole(t *testing.T) { + // rootPath ".", so overview.md is seeded at the repository root. A symlink there + // into a private role is contained (inside the repo) yet crosses the trust boundary: + // containment-only was the gap. The target dangles, so the seed WOULD create it + // inside the role. Mutation that reddens: revert the overview guard to + // ResolvedWithinRoot-only (no WritableTarget) — the seed writes through and + // .leji/work/new.md appears. + for _, role := range []string{"work", "mounts"} { + dir := canaryLayer(t) + roleDir := filepath.Join(dir, ".leji", role) + if err := os.MkdirAll(roleDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(".leji", role, "new.md"), filepath.Join(dir, "overview.md")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + before := snapshot(t, roleDir) + + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + found := false + for _, f := range gen.Findings { + if f.Rule == "viewer-target-refused" && f.Severity == findings.Error && + strings.Contains(f.Message, "overview.md") && + strings.Contains(f.Message, ".leji/"+role+" (private)") { + found = true + } + } + if !found { + t.Fatalf("generation must refuse the overview.md seed into .leji/%s, got %v", role, gen.Findings) + } + for _, w := range gen.Written { + if w == "overview.md" { + t.Fatal("overview.md must not be reported written") + } + } + 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)) { + t.Fatalf("the aliased .leji/%s must be byte-identical", role) + } + } + + // 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. + dir := canaryLayer(t) + if !foldsCase(t, dir) { + return + } + if err := os.MkdirAll(filepath.Join(dir, ".leji", "work"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(".LEJI", "work", "case.md"), filepath.Join(dir, "overview.md")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + found := false + for _, f := range gen.Findings { + if f.Rule == "viewer-target-refused" && strings.Contains(f.Message, "overview.md") { + found = true + } + } + if !found { + t.Fatalf("a case-variant overview.md alias must be refused as the role it folds to, got %v", gen.Findings) + } + if _, err := os.Stat(filepath.Join(dir, ".leji", "work", "case.md")); err == nil { + t.Fatal("nothing must be written through the case variant") + } +} + +func TestCheckBeforeActOverviewStandingAsANonRegularEntryIsRefused(t *testing.T) { + // The map is neither seeded through a standing entry this run cannot verify nor + // refreshed from bytes read by pathname: a dangling link resolves nowhere while + // still standing, and a directory is not a page. Both are the verified read's + // refusal, reported as a finding with nothing written. Mutation that reddens: + // decide the seed with a stat again — the dangling case writes the link's + // destination. + for _, c := range []struct { + name string + plant func(t *testing.T, dir string) + }{ + {"a dangling link", func(t *testing.T, dir string) { + if err := os.Symlink("never-created.md", filepath.Join(dir, "overview.md")); err != nil { + t.Fatal(err) + } + }}, + {"a directory", func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, "overview.md"), 0o755); err != nil { + t.Fatal(err) + } + }}, + } { + dir := canaryLayer(t) + c.plant(t, dir) + m := mustLoad(t, dir) + + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("%s: GenerateViewer: %v", c.name, err) + } + found := false + for _, f := range gen.Findings { + if f.Rule == "viewer-target-refused" && f.Severity == findings.Error && + strings.Contains(f.Message, "overview.md") && + strings.Contains(f.Message, "does not resolve to a regular file inside the repository") { + found = true + } + } + if !found { + t.Fatalf("%s: the overview must be refused, got %v", c.name, gen.Findings) + } + for _, w := range gen.Written { + if w == "overview.md" { + t.Fatalf("%s: overview.md must not be reported written", c.name) + } + } + if _, err := os.Lstat(filepath.Join(dir, "never-created.md")); err == nil { + t.Fatalf("%s: the dangling link's destination must never be created", c.name) + } + } +} + +func TestCheckBeforeActOverviewRefreshRefusesAliasIntoPrivateRole(t *testing.T) { + // overview.md is a symlink to an EXISTING private file carrying the generated-map + // markers: the refresh branch (isFile true) used to containment-check, read it, and + // rewrite the map block THROUGH the link. The check now runs on the resolved path + // before the read. Mutation that reddens: revert to ResolvedWithinRoot-only — the private + // file is read and its map block rewritten. + dir := canaryLayer(t) + target := filepath.Join(dir, ".leji", "mounts", "existing.md") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + original := "# private " + token + "\n<!-- leji:generated-map:start -->STALE<!-- leji:generated-map:end -->\n" + if err := os.WriteFile(target, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(".leji", "mounts", "existing.md"), filepath.Join(dir, "overview.md")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + found := false + for _, f := range gen.Findings { + if f.Rule == "viewer-target-refused" && f.Severity == findings.Error && + strings.Contains(f.Message, "overview.md") && strings.Contains(f.Message, ".leji/mounts (private)") { + found = true + } + } + if !found { + t.Fatalf("the refresh must refuse the alias with a hard error, got %v", gen.Findings) + } + body, rerr := os.ReadFile(target) + if rerr != nil || string(body) != original { + t.Fatal("the private file must be neither read-then-rewritten nor touched") + } +} + +func TestExportRefusesNestedDanglingOutIntoPrivateRole(t *testing.T) { + // `redirect/export` where `redirect` is a DANGLING symlink into a private role: a + // write would follow it, but the resolver used to climb past the dangling component + // and rebuild `redirect/export` lexically (outside .leji/), so the check passed and a + // target created afterward raced the write into the role. The resolver now follows + // the dangling intermediate link. Mutation that reddens: revert ResolvedPath's + // intermediate-symlink follow (climb-past) — outAbs reads as outside .leji/ and the + // build is not refused. + dir := materialize(t, "valid-trust-canary-nested-root", []seed{{From: ".leji-seed", To: ".leji"}}) + m := mustLoad(t, dir) + // redirect -> .leji/mounts/ghost, and ghost does NOT exist: a dangling intermediate. + 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")) + _, 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) + } + 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"))) { + t.Fatal("nothing must be cleared or written in the private role") + } + + // The created-after-validation race, closed: even once the target exists, the same + // resolved path is judged, so the build still refuses (never a one-time dangling + // fluke that a real directory would slip past). + if err := os.MkdirAll(filepath.Join(dir, ".leji", "mounts", "ghost"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := export.BuildViewer(dir, m, "redirect/export", export.Options{}); err == nil || + !strings.Contains(err.Error(), "reserved for the tool's own roles") { + t.Fatalf("must be refused again once the target is a real directory, got %v", err) + } +} + +func TestExportRefusesChainedDanglingOutIntoPrivateRole(t *testing.T) { + // redirect -> hop -> .leji/work/ghost, every hop dangling: the resolver follows the + // chain of intermediate dangling links to the real destination. Mutation that + // reddens: revert ResolvedPath's intermediate-symlink follow — the chain is rebuilt + // lexically as outside .leji/ and the build is not refused. + dir := materialize(t, "valid-trust-canary-nested-root", []seed{{From: ".leji-seed", To: ".leji"}}) + m := mustLoad(t, dir) + if err := os.Symlink("hop", filepath.Join(dir, "redirect")); err != nil { + t.Fatal(err) + } + 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")) + _, 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"))) { + t.Fatal("nothing must be cleared or written in the private role") + } +} + +func TestExportTreatsUnresolvableOutAsFailure(t *testing.T) { + // A non-ENOENT resolution failure (here an unreadable intermediate directory) must + // FAIL the check, never be rebuilt lexically as a not-yet-created target. Mutation + // that reddens: make ResolvedPath return the lexical path on a non-ENOENT error — + // the build proceeds instead of refusing. Skipped as root, which bypasses the mode. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses directory permissions; the EACCES cannot be constructed") + } + dir := materialize(t, "valid-trust-canary-nested-root", []seed{{From: ".leji-seed", To: ".leji"}}) + m := mustLoad(t, dir) + noperm := filepath.Join(dir, "noperm") + if err := os.MkdirAll(filepath.Join(noperm, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(noperm, 0o000); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(noperm, 0o755) }() + _, err := export.BuildViewer(dir, m, "noperm/sub/export", export.Options{}) + if err == nil || !strings.Contains(err.Error(), "cannot be resolved (permission or I/O error)") { + t.Fatalf("an unresolvable --out must be refused, not treated as absent, got %v", err) + } +} + +func TestExportRefusesADanglingOutputEntry(t *testing.T) { + // A dangling symlink is a standing entry under both forms — never written through, + // never read as absent. The output used to be resolved before anything judged it, + // so `.leji/dist -> site` with `site` missing BECAME its own destination: the stat + // reported absence, "clearable" followed, and the export created and filled the + // link's target. The original entry is judged first now. Mutation that reddens: + // drop the lstat on the original entry — the build writes through the link. + dir := materialize(t, "valid-trust-canary-nested-root", []seed{{From: ".leji-seed", To: ".leji"}}) + m := mustLoad(t, dir) + // Settle the internal chrome first: every build regenerates it, so the comparison + // below measures the export's destructive half and nothing else. + if _, err := viewer.GenerateViewer(dir, m); err != nil { + t.Fatal(err) + } + + if err := os.Symlink(filepath.Join("..", "site"), filepath.Join(dir, ".leji", "dist")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("elsewhere", filepath.Join(dir, "published")); err != nil { + t.Fatal(err) + } + before := snapshot(t, dir) + + if _, err := export.BuildViewer(dir, m, "", export.Options{}); err == nil || + !strings.Contains(err.Error(), "it is a dangling symlink") { + t.Fatalf("the default output must be refused before it is resolved, got %v", err) + } + if _, err := export.BuildViewer(dir, m, "published", export.Options{}); err == nil || + !strings.Contains(err.Error(), "it is a dangling symlink") { + t.Fatalf("a caller --out that dangles must be refused too, got %v", err) + } + + for _, link := range []string{filepath.Join(dir, ".leji", "dist"), filepath.Join(dir, "published")} { + st, err := os.Lstat(link) + if err != nil || st.Mode()&os.ModeSymlink == 0 { + t.Fatalf("the planted link %s must be left in place (%v)", link, err) + } + } + for _, gone := range []string{filepath.Join(dir, "site"), filepath.Join(dir, "elsewhere")} { + if _, err := os.Lstat(gone); err == nil { + t.Fatalf("the link destination %s was created", gone) + } + } + if !equalStrings(before, snapshot(t, dir)) { + t.Fatal("the tree must be byte-identical") + } +} + +func TestCheckBeforeActRefusesCaseVariantAliasThroughNonEnumerableDirectory(t *testing.T) { + // The composition the separate case-fold and unresolvable cases left open: a + // `.LEJI/` spelling of the role tree reached through a directory that is + // traversable and writable but NOT enumerable. The canonical spelling is read back + // from the directory, so denying enumeration denies case recovery — and falling + // back to the caller's spelling made the resolved target compare as outside + // `.leji/`, so the write and the clear were permitted straight into a private role. + // An enumeration failure now makes the path unresolvable, which refuses both. + // Mutation that reddens: return the given name from realName on a ReadDir error — + // generation writes the chrome into .leji/work and the export clears and writes + // into .leji/mounts. + if os.Geteuid() == 0 { + t.Skip("running as root bypasses directory permissions; the mode cannot be constructed") + } + + // Write side: `.leji/viewer` aliased to `../.LEJI/work/chrome`. + dir := canaryLayer(t) + if !foldsCase(t, dir) { + t.Skip("this volume tells .leji from .LEJI; the case-alias vector cannot be constructed") + } + aliased := filepath.Join(dir, ".leji", "work", "chrome") + if err := os.MkdirAll(filepath.Join(aliased, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(aliased, "assets", "planted.txt"), []byte(token+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", ".LEJI", "work", "chrome"), filepath.Join(dir, ".leji", "viewer")); err != nil { + t.Fatal(err) + } + m := mustLoad(t, dir) + before := snapshot(t, aliased) + // 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 { + t.Fatal(err) + } + defer func() { _ = os.Chmod(dir, 0o755) }() + + gen, err := viewer.GenerateViewer(dir, m) + if err != nil { + t.Fatalf("GenerateViewer: %v", err) + } + if !hasRefusal(gen.Findings) { + t.Fatalf("generation must refuse an unresolvable viewer target, got %v", gen.Findings) + } + if len(gen.Written) != 0 { + t.Fatalf("generation must write nothing, got %v", gen.Written) + } + if !equalStrings(before, snapshot(t, aliased)) { + t.Fatal("the aliased private role must be byte-identical") + } + _ = os.Chmod(dir, 0o755) + + // Clear side: the default output aliased to an EMPTY directory in a private role, + // so the clearable-export rule cannot be what refuses it. + other := canaryLayer(t) + empty := filepath.Join(other, ".leji", "mounts", "store", "empty") + if err := os.MkdirAll(empty, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", ".LEJI", "mounts", "store", "empty"), filepath.Join(other, ".leji", "dist")); err != nil { + t.Fatal(err) + } + om := mustLoad(t, other) + mountsBefore := snapshot(t, filepath.Join(other, ".leji", "mounts")) + if err := os.Chmod(other, 0o311); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(other, 0o755) }() + _, berr := export.BuildViewer(other, om, "", export.Options{}) + if berr == nil || !strings.Contains(berr.Error(), "cannot be resolved (permission or I/O error)") { + 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"))) { + t.Fatal("nothing must be cleared or written in the private role") + } +} + +// --- helpers ------------------------------------------------------------------ + +func hasRefusal(fs []findings.Finding) bool { + for _, f := range fs { + if f.Rule == "viewer-target-refused" && f.Severity == findings.Error { + return true + } + } + return false +} + +func patchManifest(t *testing.T, dir string, mutate func(map[string]any)) { + t.Helper() + abs := filepath.Join(dir, "leji.json") + raw, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + var declared map[string]any + if err := json.Unmarshal(raw, &declared); err != nil { + t.Fatal(err) + } + mutate(declared) + out, err := json.MarshalIndent(declared, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, append(out, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} + +// foldsCase reports whether this directory sits on a filesystem that cannot tell +// `.leji` from `.LEJI` — asked of the volume, so a case-variant assertion runs only +// where the fold is real. +func foldsCase(t *testing.T, dir string) bool { + t.Helper() + probe := filepath.Join(dir, "leji-case-probe") + if err := os.MkdirAll(probe, 0o755); err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(probe) }() + _, err := os.Stat(filepath.Join(dir, "LEJI-CASE-PROBE")) + return err == nil +} + +// captureStderr runs fn with os.Stderr redirected to a pipe and returns what it +// wrote. The boundary-skip warning is a stderr contract, so it is read from the file +// the process actually writes to. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stderr + os.Stderr = w + done := make(chan string, 1) + go func() { + body, _ := io.ReadAll(r) + done <- string(body) + }() + fn() + os.Stderr = orig + _ = w.Close() + out := <-done + _ = r.Close() + return out +} diff --git a/packages/sdk-go/internal/conformancetest/compact_test.go b/packages/sdk-go/internal/conformancetest/compact_test.go index b890ab1..4ea010e 100644 --- a/packages/sdk-go/internal/conformancetest/compact_test.go +++ b/packages/sdk-go/internal/conformancetest/compact_test.go @@ -80,7 +80,7 @@ func lastEntry(t *testing.T, abs string) map[string]any { func TestCompactKeepFoldsOldest(t *testing.T) { dir := seedWithEntries(t, 10) m := loadM(t, dir) - result := changelog.CompactChangelog(dir, m, changelog.CompactOptions{Keep: 4, HasKeep: true}) + result := compactChangelog(t, dir, m, changelog.CompactOptions{Keep: 4, HasKeep: true}) if errs := errorFindings(result.Findings); len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -130,7 +130,7 @@ func TestCompactKeepFoldsOldest(t *testing.T) { if errs := errorFindings(check.Findings); len(errs) != 0 { t.Fatalf("append-only errors after compact: %v", errs) } - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) if errs := errorFindings(v.Findings); len(errs) != 0 { t.Fatalf("layer validate errors after compact: %v", errs) } @@ -139,7 +139,7 @@ func TestCompactKeepFoldsOldest(t *testing.T) { func TestCompactBeforeCutoff(t *testing.T) { dir := seedWithEntries(t, 10) m := loadM(t, dir) - result := changelog.CompactChangelog(dir, m, changelog.CompactOptions{Before: "2026-01-06", HasBefore: true}) + result := compactChangelog(t, dir, m, changelog.CompactOptions{Before: "2026-01-06", HasBefore: true}) if errs := errorFindings(result.Findings); len(errs) != 0 { t.Fatalf("unexpected errors: %v", errs) } @@ -162,7 +162,7 @@ func TestCompactBothFlagsIntersection(t *testing.T) { m := loadM(t, dir) // --keep 3 marks e-01..e-07 foldable; --before 2026-01-04 marks e-01..e-03. // The intersection (both must accept) is e-01..e-03. - result := changelog.CompactChangelog(dir, m, changelog.CompactOptions{ + result := compactChangelog(t, dir, m, changelog.CompactOptions{ Keep: 3, HasKeep: true, Before: "2026-01-04", HasBefore: true, }) if result.Folded != 3 { @@ -180,7 +180,7 @@ func TestCompactNoOp(t *testing.T) { m := loadM(t, dir) abs := filepath.Join(dir, changelogRel) before, _ := os.ReadFile(abs) - result := changelog.CompactChangelog(dir, m, changelog.CompactOptions{Keep: 10, HasKeep: true}) + result := compactChangelog(t, dir, m, changelog.CompactOptions{Keep: 10, HasKeep: true}) if result.Folded != 0 { t.Fatalf("folded = %d, want 0", result.Folded) } @@ -203,7 +203,7 @@ func TestCompactDedupesID(t *testing.T) { first["id"] = "compaction-" + today // collide with the id the compactor will pick writeChangelog(t, abs, log) m := loadM(t, dir) - result := changelog.CompactChangelog(dir, m, changelog.CompactOptions{Keep: 2, HasKeep: true}) + result := compactChangelog(t, dir, m, changelog.CompactOptions{Keep: 2, HasKeep: true}) if result.Folded == 0 { t.Fatal("expected folding") } @@ -216,7 +216,7 @@ func TestCompactDedupesID(t *testing.T) { func TestCompactProducesValidJSON(t *testing.T) { dir := seedWithEntries(t, 4) m := loadM(t, dir) - changelog.CompactChangelog(dir, m, changelog.CompactOptions{Keep: 1, HasKeep: true}) + compactChangelog(t, dir, m, changelog.CompactOptions{Keep: 1, HasKeep: true}) b, _ := os.ReadFile(filepath.Join(dir, changelogRel)) var v any if err := json.Unmarshal(b, &v); err != nil { diff --git a/packages/sdk-go/internal/conformancetest/coverage_test.go b/packages/sdk-go/internal/conformancetest/coverage_test.go index 644f7e0..8e0f7a7 100644 --- a/packages/sdk-go/internal/conformancetest/coverage_test.go +++ b/packages/sdk-go/internal/conformancetest/coverage_test.go @@ -79,7 +79,7 @@ func TestAgentsMapBadTargetFlagged(t *testing.T) { if err := os.WriteFile(mpath, append(out, '\n'), 0o644); err != nil { t.Fatal(err) } - res := validate.ValidateLayer(dir, false) + res := validateLayer(t, dir, false) found := false for _, f := range res.Findings { if f.Rule == "profile-frontmatter" { @@ -108,7 +108,7 @@ func TestIndexEntryArraysSerialized(t *testing.T) { t.Fatalf("expected serialized tags in the index: %s", string(b)) } // CheckIndex compares the stored tagged index against the tree (array path). - if res := indexgen.CheckIndex(dir, m); res.Stale != nil && *res.Stale { + if res := checkIndex(t, dir, m); res.Stale != nil && *res.Stale { t.Fatalf("freshly written index should be current, findings: %v", res.Findings) } } diff --git a/packages/sdk-go/internal/conformancetest/fixtures_test.go b/packages/sdk-go/internal/conformancetest/fixtures_test.go index 2ce91da..cca6c3c 100644 --- a/packages/sdk-go/internal/conformancetest/fixtures_test.go +++ b/packages/sdk-go/internal/conformancetest/fixtures_test.go @@ -11,8 +11,6 @@ import ( "testing" "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" - "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" - "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" "github.com/leji-org/leji/packages/sdk-go/internal/findings" "github.com/leji-org/leji/packages/sdk-go/internal/manifest" ) @@ -97,7 +95,7 @@ func TestFixtureValidate(t *testing.T) { t.Run(name, func(t *testing.T) { dir := filepath.Join(fd, name) exp := loadExpected(t, dir) - result := validate.ValidateLayer(dir, false) + result := validateLayer(t, dir, false) var got []string for _, f := range result.Findings { @@ -199,7 +197,7 @@ func TestFixtureIndexCheck(t *testing.T) { if m == nil { t.Fatalf("manifest must load for indexCheck fixtures: %s", name) } - result := indexgen.CheckIndex(dir, m) + result := checkIndex(t, dir, m) stale := true if result.Stale != nil { stale = *result.Stale diff --git a/packages/sdk-go/internal/conformancetest/render_test.go b/packages/sdk-go/internal/conformancetest/render_test.go new file mode 100644 index 0000000..5b81eb3 --- /dev/null +++ b/packages/sdk-go/internal/conformancetest/render_test.go @@ -0,0 +1,350 @@ +package conformancetest + +// The shared render fixtures, driven through the real command: their pinned +// findings, their layout, their golden export bytes, and their idempotency. The +// detector's own families live with the detector (internal/renderlint); what this +// file asserts is the contract the three SDKs share — the findings a `--json` +// consumer reads, in the canonical order, and the exported tree byte for byte +// against the committed goldens. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/cli" +) + +// canaryDriven fixtures are asserted by canary_test.go, which takes their trust +// corpus alongside the same export block; this harness takes the rest. +var canaryDriven = map[string]bool{ + "valid-unified-leji-fresh": true, + "valid-unified-leji-stale-tree": true, + "valid-trust-canary-nested-root": true, + "valid-trust-canary-dot-root": true, +} + +type renderFinding struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Path string `json:"path"` + Line int `json:"line"` + Construct string `json:"construct"` +} + +type goldenTree struct { + Status string `json:"status"` + ContentDir string `json:"contentDir"` + Manifest string `json:"manifest"` +} + +type renderExportBlock struct { + Args []string `json:"args"` + Exit int `json:"exit"` + Findings []renderFinding `json:"findings"` + Out string `json:"out"` + Layout expectedLayout `json:"layout"` + Rerun struct { + ByteIdentical bool `json:"byteIdentical"` + } `json:"rerun"` + GoldenTree goldenTree `json:"goldenTree"` +} + +type renderExpectation struct { + Export *renderExportBlock `json:"export"` +} + +// exportDoc is the command's canonical JSON document, as a `--json` consumer reads it. +type exportDoc struct { + Command string `json:"command"` + OK bool `json:"ok"` + Out string `json:"out"` + Findings []renderFinding `json:"findings"` + Warning string `json:"warning"` +} + +// runCLI runs the CLI with stdout captured, the way a consumer invokes it. +func runCLI(t *testing.T, argv []string) (int, string) { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + var sb strings.Builder + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + sb.Write(buf[:n]) + } + if err != nil { + break + } + } + done <- sb.String() + }() + code := cli.Run(argv) + os.Stdout = orig + _ = w.Close() + out := <-done + _ = r.Close() + return code, out +} + +// filesUnder is every file under dir, as export-root-relative POSIX paths, sorted. +func filesUnder(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 { + childRel := e.Name() + if rel != "" { + childRel = rel + "/" + e.Name() + } + if e.IsDir() { + walk(childRel) + continue + } + out = append(out, childRel) + } + } + walk("") + sort.Strings(out) + return out +} + +// goldenPath is a golden artifact at its declared name, or at the dot-prefixed name +// beside it: a `rootPath: "."` fixture exports its own root, so a plainly named +// golden would be exported into the next bake of itself. The dot form is skipped by +// the content walk, which is what makes it committable there (fixtures/README.md). +func goldenPath(fixtureRoot, declared string) string { + segments := strings.SplitN(declared, "/", 2) + plain := filepath.Join(fixtureRoot, filepath.FromSlash(declared)) + if _, err := os.Stat(plain); err == nil { + return plain + } + segments[0] = "." + segments[0] + return filepath.Join(fixtureRoot, filepath.FromSlash(strings.Join(segments, "/"))) +} + +// assertGoldenTree compares a written export tree against a fixture's committed +// goldens: the content tree as bytes, everything else by digest and size. The two +// sets are disjoint by construction and exhaustive by this comparison. A `baked` +// golden is the only one with bytes to compare. +func assertGoldenTree(t *testing.T, fixtureRoot, out string, golden goldenTree) { + t.Helper() + if golden.Status != "baked" { + return + } + contentDir := goldenPath(fixtureRoot, golden.ContentDir) + manifestFile := goldenPath(fixtureRoot, golden.Manifest) + written := filesUnder(t, out) + var inContent, outside []string + for _, f := range written { + if strings.HasPrefix(f, "content/") { + inContent = append(inContent, strings.TrimPrefix(f, "content/")) + } else { + outside = append(outside, f) + } + } + // The committed bytes ARE the export's content tree: same paths, same bytes, in + // both directions, so a file that appears or disappears fails here. + goldenFiles := filesUnder(t, contentDir) + if !equalStrings(inContent, goldenFiles) { + t.Fatalf("the golden content tree lists exactly what the export wrote\nextra=%v\nmissing=%v", + diffStrings(inContent, goldenFiles), diffStrings(goldenFiles, inContent)) + } + for _, rel := range goldenFiles { + got, err := os.ReadFile(filepath.Join(out, "content", filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(filepath.Join(contentDir, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("exported bytes differ from the golden for content/%s", rel) + } + } + + // Everything else — chrome, vendored assets, fonts — by digest and size. + manRaw, err := os.ReadFile(manifestFile) + if err != nil { + t.Fatal(err) + } + var man struct { + Version int `json:"version"` + Files map[string]struct { + SHA256 string `json:"sha256"` + Size int `json:"size"` + } `json:"files"` + } + if err := json.Unmarshal(manRaw, &man); err != nil { + t.Fatal(err) + } + if man.Version != 1 { + t.Fatalf("the manifest states its version: %d", man.Version) + } + var pinned []string + for rel := range man.Files { + pinned = append(pinned, rel) + } + sort.Strings(pinned) + if !equalStrings(pinned, outside) { + t.Fatalf("the manifest pins every file outside content/\nextra=%v\nmissing=%v", + diffStrings(outside, pinned), diffStrings(pinned, outside)) + } + for _, rel := range outside { + body, err := os.ReadFile(filepath.Join(out, filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + if hex.EncodeToString(sum[:]) != man.Files[rel].SHA256 || len(body) != man.Files[rel].Size { + t.Fatalf("%s differs from the manifest's pin", rel) + } + } +} + +func TestRenderFixtureExportBlocks(t *testing.T) { + fd := fixturesDir(t) + for _, name := range fixtureNames(t) { + if canaryDriven[name] { + continue + } + raw, err := os.ReadFile(filepath.Join(fd, name, "expected.json")) + if err != nil { + t.Fatal(err) + } + var exp renderExpectation + if err := json.Unmarshal(raw, &exp); err != nil { + t.Fatal(err) + } + if exp.Export == nil { + continue + } + block := exp.Export + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + cpTree(t, filepath.Join(fd, name), dir) + + preservedBefore := map[string]string{} + for _, rel := range block.Layout.Preserved { + abs := fixtureAbs(dir, fixtureRel(t, rel, "preserved entry")) + info, err := os.Stat(abs) + if err != nil { + t.Fatalf("preserved path must exist before the run: %s", rel) + } + if info.Mode().IsRegular() { + body, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + preservedBefore[rel] = string(body) + } + } + + // The whole command, under the fixture's own argv: the exit code is the + // process's, and the findings are the ones a `--json` consumer reads. + args := block.Args + if len(args) == 0 { + args = []string{"export"} + } + argv := append(append([]string{}, args...), "--root", dir, "--json") + code, stdout := runCLI(t, argv) + if code != block.Exit { + t.Fatalf("exit code %d, want %d: %s", code, block.Exit, stdout) + } + var doc exportDoc + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + t.Fatalf("the command must emit its canonical document: %v (%s)", err, stdout) + } + if got := filepath.ToSlash(doc.Out); got != block.Out { + t.Fatalf("declared output directory %q, want %q", got, block.Out) + } + // Matched on (rule, severity, path, line, construct) IN ORDER — message text + // is never compared, and the order is the canonical one the three SDKs share. + if len(doc.Findings) != len(block.Findings) { + t.Fatalf("findings: got %d, want %d (%v)", len(doc.Findings), len(block.Findings), doc.Findings) + } + for i, want := range block.Findings { + if doc.Findings[i] != want { + t.Fatalf("finding %d: got %+v, want %+v", i, doc.Findings[i], want) + } + } + + // `roles` is the layout's role map — which directory each role NAMES — and + // present/absent say which of them a given run establishes: a `--strict` run + // names the export role and deliberately writes nothing at it. + absent := map[string]bool{} + for _, rel := range block.Layout.Absent { + absent[fixtureRel(t, rel, "absent entry")] = true + } + for role, roleDir := range block.Layout.Roles { + rel := fixtureRel(t, roleDir, "role "+role) + if absent[rel] { + continue + } + if info, err := os.Stat(fixtureAbs(dir, rel)); err != nil || !info.IsDir() { + t.Fatalf("role %s must be established at %s", role, roleDir) + } + } + for _, rel := range block.Layout.Present { + if _, err := os.Stat(fixtureAbs(dir, fixtureRel(t, rel, "present entry"))); err != nil { + t.Fatalf("must be present after the run: %s", rel) + } + } + for rel := range absent { + if _, err := os.Stat(fixtureAbs(dir, rel)); err == nil { + t.Fatalf("must never be created: %s", rel) + } + } + for rel, before := range preservedBefore { + body, err := os.ReadFile(fixtureAbs(dir, rel)) + if err != nil || string(body) != before { + t.Fatalf("must be byte-identical after the run: %s", rel) + } + } + + // --- the golden tree --------------------------------------------------- + out := fixtureAbs(dir, block.Out) + if block.GoldenTree.Status == "none" { + if _, err := os.Stat(out); err == nil { + t.Fatal("a run that writes no export tree has nothing to bake") + } + } + assertGoldenTree(t, filepath.Join(fd, name), out, block.GoldenTree) + + // --- idempotency --------------------------------------------------------- + if block.Rerun.ByteIdentical { + afterFirst := snapshot(t, dir) + if code, stdout := runCLI(t, argv); code != block.Exit { + t.Fatalf("second run exit %d, want %d: %s", code, block.Exit, stdout) + } + if afterSecond := snapshot(t, dir); !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)) + } + } + }) + } +} diff --git a/packages/sdk-go/internal/conformancetest/units_test.go b/packages/sdk-go/internal/conformancetest/units_test.go index ed97ea4..05ed38f 100644 --- a/packages/sdk-go/internal/conformancetest/units_test.go +++ b/packages/sdk-go/internal/conformancetest/units_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/leji-org/leji/packages/sdk-go/internal/commands/changelog" "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" "github.com/leji-org/leji/packages/sdk-go/internal/commands/freshness" "github.com/leji-org/leji/packages/sdk-go/internal/commands/indexgen" @@ -92,7 +93,7 @@ func mustWriteIndex(t *testing.T, dir string, m *manifest.Manifest) indexgen.Res } func TestExampleValidatesClean(t *testing.T) { - result := validate.ValidateLayer(exampleDir(t), false) + result := validateLayer(t, exampleDir(t), false) for _, f := range result.Findings { if f.Severity == findings.Error { t.Fatalf("unexpected error finding: %s %s", f.Rule, f.Message) @@ -104,7 +105,7 @@ func TestIndexRoundTripCurrent(t *testing.T) { dir := copyTree(t, exampleDir(t)) m := loadM(t, dir) mustWriteIndex(t, dir, m) - check := indexgen.CheckIndex(dir, m) + check := checkIndex(t, dir, m) if check.Stale == nil || *check.Stale { t.Fatalf("expected fresh index, stale=%v", check.Stale) } @@ -117,7 +118,7 @@ func TestIndexGoesStaleOnEdit(t *testing.T) { f := filepath.Join(dir, "docs", "domain", "glossary.md") b, _ := os.ReadFile(f) os.WriteFile(f, append(b, []byte("\n- **Refund**: a reversal.\n")...), 0o644) - check := indexgen.CheckIndex(dir, m) + check := checkIndex(t, dir, m) if check.Stale == nil || !*check.Stale { t.Fatal("expected stale index") } @@ -242,38 +243,38 @@ func TestViewerGeneratesSidebar(t *testing.T) { t.Fatalf("GenerateViewer: %v", err) } wantWritten := []string{ - "docs/.leji/viewer/index.html", - "docs/.leji/viewer/_sidebar.md", - "docs/.leji/viewer/assets/docsify-copy-code.min.js", - "docs/.leji/viewer/assets/docsify-mermaid.js", - "docs/.leji/viewer/assets/docsify-sidebar-collapse.min.css", - "docs/.leji/viewer/assets/docsify-sidebar-collapse.min.js", - "docs/.leji/viewer/assets/docsify.min.js", - "docs/.leji/viewer/assets/fonts-licenses.txt", - "docs/.leji/viewer/assets/leji-logo.svg", - "docs/.leji/viewer/assets/mermaid.min.js", - "docs/.leji/viewer/assets/prism-bash.min.js", - "docs/.leji/viewer/assets/prism-json.min.js", - "docs/.leji/viewer/assets/prism-markdown.min.js", - "docs/.leji/viewer/assets/prism-typescript.min.js", - "docs/.leji/viewer/assets/roboto-mono-400-latin-ext.woff2", - "docs/.leji/viewer/assets/roboto-mono-400-latin.woff2", - "docs/.leji/viewer/assets/roboto-mono-400-vietnamese.woff2", - "docs/.leji/viewer/assets/search.min.js", - "docs/.leji/viewer/assets/source-sans-pro-300-latin-ext.woff2", - "docs/.leji/viewer/assets/source-sans-pro-300-latin.woff2", - "docs/.leji/viewer/assets/source-sans-pro-300-vietnamese.woff2", - "docs/.leji/viewer/assets/source-sans-pro-400-latin-ext.woff2", - "docs/.leji/viewer/assets/source-sans-pro-400-latin.woff2", - "docs/.leji/viewer/assets/source-sans-pro-400-vietnamese.woff2", - "docs/.leji/viewer/assets/source-sans-pro-600-latin-ext.woff2", - "docs/.leji/viewer/assets/source-sans-pro-600-latin.woff2", - "docs/.leji/viewer/assets/source-sans-pro-600-vietnamese.woff2", - "docs/.leji/viewer/assets/viewer-boot.js", - "docs/.leji/viewer/assets/vue.css", - "docs/.leji/viewer/assets/zoom-image.min.js", + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/assets/docsify-copy-code.min.js", + ".leji/viewer/assets/docsify-mermaid.js", + ".leji/viewer/assets/docsify-sidebar-collapse.min.css", + ".leji/viewer/assets/docsify-sidebar-collapse.min.js", + ".leji/viewer/assets/docsify.min.js", + ".leji/viewer/assets/leji-logo.svg", + ".leji/viewer/assets/mermaid.min.js", + ".leji/viewer/assets/prism-bash.min.js", + ".leji/viewer/assets/prism-json.min.js", + ".leji/viewer/assets/prism-markdown.min.js", + ".leji/viewer/assets/prism-typescript.min.js", + ".leji/viewer/assets/roboto-mono-400-latin-ext.woff2", + ".leji/viewer/assets/roboto-mono-400-latin.woff2", + ".leji/viewer/assets/roboto-mono-400-vietnamese.woff2", + ".leji/viewer/assets/search.min.js", + ".leji/viewer/assets/source-sans-pro-300-latin-ext.woff2", + ".leji/viewer/assets/source-sans-pro-300-latin.woff2", + ".leji/viewer/assets/source-sans-pro-300-vietnamese.woff2", + ".leji/viewer/assets/source-sans-pro-400-latin-ext.woff2", + ".leji/viewer/assets/source-sans-pro-400-latin.woff2", + ".leji/viewer/assets/source-sans-pro-400-vietnamese.woff2", + ".leji/viewer/assets/source-sans-pro-600-latin-ext.woff2", + ".leji/viewer/assets/source-sans-pro-600-latin.woff2", + ".leji/viewer/assets/source-sans-pro-600-vietnamese.woff2", + ".leji/viewer/assets/third-party-licenses.txt", + ".leji/viewer/assets/viewer-boot.js", + ".leji/viewer/assets/vue.css", + ".leji/viewer/assets/zoom-image.min.js", "docs/overview.md", - "docs/.leji/viewer/_manifest.md", + ".leji/viewer/_manifest.md", } if len(result.Written) != len(wantWritten) { t.Fatalf("unexpected written: %v", result.Written) @@ -284,25 +285,34 @@ func TestViewerGeneratesSidebar(t *testing.T) { } } // Mermaid is on by default: the two scripts + their assets are present. - page0, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + page0, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) for _, want := range []string{"assets/mermaid.min.js", "assets/docsify-mermaid.js"} { if !strings.Contains(string(page0), want) { t.Fatalf("expected mermaid wired into index.html by default: %q", want) } } - if _, err := os.Stat(filepath.Join(dir, "docs", ".leji", "viewer", "assets", "mermaid.min.js")); err != nil { + if _, err := os.Stat(filepath.Join(dir, ".leji", "viewer", "assets", "mermaid.min.js")); err != nil { t.Fatalf("expected mermaid asset copied by default: %v", err) } - bootJS, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "assets", "viewer-boot.js")) - if !strings.Contains(string(bootJS), "basePath: '/content/'") { - t.Fatalf("expected content mount in the boot script, got: %q", bootJS) + // The content mount is the SDK's value, carried in the config block; the boot + // script routes from it instead of hardcoding a root, which is what lets the + // export flavor be relative. + bootJS, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "assets", "viewer-boot.js")) + if !strings.Contains(string(bootJS), "basePath: lejiContentBase") { + t.Fatalf("expected the boot script to route from the generated base, got: %q", bootJS) + } + if !strings.Contains(string(page0), `"basePath":"/content/"`) { + t.Fatal("expected the served flavor to mount content at the app root") } // Default theming: the Leji mark (in the name HTML, served relative to the page - // so basePath does not break it), brand blue, and the layer name/title. - page, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + // so basePath does not break it), brand green with the mermaid node-text color + // the SDK computed for it (dark, at 5.14:1 against the accent), and the layer + // name/title. + page, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) for _, want := range []string{ "/assets/leji-logo.svg", - "\"themeColor\":\"#223F93\"", + "\"themeColor\":\"#009F71\"", + "\"lejiMermaidTextColor\":\"#1a1a1a\"", "acme-billing-context", "<title>acme-billing-context", } { @@ -310,12 +320,29 @@ func TestViewerGeneratesSidebar(t *testing.T) { t.Fatalf("expected index.html to contain %q", want) } } - sidebar, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "_sidebar.md")) - want := "- [🤖 Boot profile](boot-profile.md)\n- [📄 Manifest](_manifest.md)\n\n---\n\n" + - "- **🤖 Agents**\n - [Agent Core](agents/core.md)\n - [Thought Partner (Codex)](agents/thought-partner.md)\n" + - "- **📖 Domain**\n - [Glossary](domain/glossary.md)\n" + - "- **⚙️ System**\n - [Invariants](system/invariants.md)\n" + - "- **🧭 Decisions**\n - [Adopt the Leji context layer](decisions/0001-adopt-leji.md)\n" + // A configured accent is computed over too, not just the default: a dark accent + // flips the mermaid node text to white, end to end through the generator. + darkDir := copyTree(t, exampleDir(t)) + darkM := loadM(t, darkDir) + darkM.Viewer = &manifest.Viewer{Theme: &manifest.Theme{Primary: "#164E42"}} + if _, err := viewer.GenerateViewer(darkDir, darkM); err != nil { + t.Fatalf("GenerateViewer (configured accent): %v", err) + } + darkPage, _ := os.ReadFile(filepath.Join(darkDir, ".leji", "viewer", "index.html")) + for _, want := range []string{ + "\"themeColor\":\"#164E42\"", + "\"lejiMermaidTextColor\":\"#ffffff\"", + } { + if !strings.Contains(string(darkPage), want) { + t.Fatalf("expected index.html to contain %q", want) + } + } + sidebar, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) + want := "- [🤖 Boot profile](/boot-profile.md)\n- [📄 Manifest](/_manifest.md)\n\n---\n\n" + + "- **🤖 Agents**\n - [Agent Core](/agents/core.md)\n - [Thought Partner (Codex)](/agents/thought-partner.md)\n" + + "- **📖 Domain**\n - [Glossary](/domain/glossary.md)\n" + + "- **⚙️ System**\n - [Invariants](/system/invariants.md)\n" + + "- **🧭 Decisions**\n - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md)\n" if string(sidebar) != want { t.Fatalf("sidebar mismatch:\n got=%q\nwant=%q", sidebar, want) } @@ -323,7 +350,7 @@ func TestViewerGeneratesSidebar(t *testing.T) { if _, err := viewer.GenerateViewer(dir, m); err != nil { t.Fatalf("GenerateViewer (regen): %v", err) } - again, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "_sidebar.md")) + again, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) if string(again) != want { t.Fatalf("regenerated sidebar diverged:\n got=%q\nwant=%q", again, want) } @@ -343,7 +370,7 @@ func TestViewerBrandConfig(t *testing.T) { if err != nil { t.Fatalf("GenerateViewer: %v", err) } - page, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + page, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) // A relative logo path is served from the content mount; absolute/url is used as-is. for _, want := range []string{ "/content/assets/brand.svg", @@ -355,9 +382,9 @@ func TestViewerBrandConfig(t *testing.T) { t.Fatalf("expected index.html to contain %q", want) } } - sidebar, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "_sidebar.md")) + sidebar, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "_sidebar.md")) top := strings.SplitN(string(sidebar), "---", 2)[0] - if !strings.Contains(top, "- [Glossary](domain/glossary.md)") { + if !strings.Contains(top, "- [Glossary](/domain/glossary.md)") { t.Fatalf("expected the pinned page in the top zone, got: %q", top) } pinMissing := false @@ -392,7 +419,7 @@ func TestBuildSidebarSkipsOutOfRootBootAndRendersPlainEntries(t *testing.T) { if !strings.Contains(sidebar, "- **💰 Finance**") { t.Fatalf("expected the group label to be the index-file H1, verbatim, bold, got: %q", sidebar) } - if !strings.Contains(sidebar, " - [Glossary](domain/glossary.md)") { + if !strings.Contains(sidebar, " - [Glossary](/domain/glossary.md)") { t.Fatalf("expected entries to render as plain links, got: %q", sidebar) } if strings.Contains(sidebar, "lj-rec") { @@ -412,14 +439,14 @@ func TestViewerMermaidDisabled(t *testing.T) { if err != nil { t.Fatalf("GenerateViewer: %v", err) } - page, _ := os.ReadFile(filepath.Join(dir, "docs", ".leji", "viewer", "index.html")) + page, _ := os.ReadFile(filepath.Join(dir, ".leji", "viewer", "index.html")) if strings.Contains(string(page), "mermaid.min.js") { t.Fatal("expected no mermaid script when disabled") } if strings.Contains(string(page), "docsify-mermaid.js") { t.Fatal("expected no mermaid plugin when disabled") } - if _, err := os.Stat(filepath.Join(dir, "docs", ".leji", "viewer", "assets", "mermaid.min.js")); err == nil { + if _, err := os.Stat(filepath.Join(dir, ".leji", "viewer", "assets", "mermaid.min.js")); err == nil { t.Fatal("expected mermaid asset not copied when disabled") } for _, w := range result.Written { @@ -444,7 +471,7 @@ func TestInitYesValidatesCleanCore(t *testing.T) { } // init does not `git init`, so a freshly scaffolded layer in a bare temp dir // carries exactly the not-in-git warning; its content is otherwise clean. - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) for _, f := range v.Findings { if f.Rule != "git-required" { t.Fatalf("expected only git-required, got %v", v.Findings) @@ -462,7 +489,7 @@ func TestInitIndexedVerifiesImmediately(t *testing.T) { t.Fatal(err) } gitCommitAll(t, dir) - v := validate.ValidateLayer(dir, false) + v := validateLayer(t, dir, false) for _, f := range v.Findings { if f.Severity == findings.Error { t.Fatalf("unexpected error: %s", f.Rule) @@ -656,7 +683,7 @@ func TestRecordsFullyDisplacedBroadSelectorReportedAsShadowed(t *testing.T) { os.Remove(filepath.Join(dir, "docs", "records", "2026-07-03-status.md")) os.Remove(filepath.Join(dir, "docs", "records", "ledger.md")) m := loadM(t, dir) - report := statuscmd.StatusReport(dir, m) + report := statusReport(t, dir, m) want := []statuscmd.ShadowedSelector{{IndexFile: "docs/context/domain.md", Path: "docs/records/"}} if !reflect.DeepEqual(report.Shadowed, want) { t.Fatalf("shadowed mismatch: got %+v want %+v", report.Shadowed, want) @@ -695,3 +722,44 @@ func containsStr(list []string, v string) bool { } return false } + +// --- gate helpers ------------------------------------------------------------- +// These commands now carry an error channel, because an operational read failure on +// an allowed path propagates instead of being swallowed (the reference throws it). +// A test that does not construct such a failure asserts there is none. + +func validateLayer(t *testing.T, root string, content bool) validate.Result { + t.Helper() + res, err := validate.ValidateLayer(root, content) + if err != nil { + t.Fatalf("ValidateLayer(%s): %v", root, err) + } + return res +} + +func checkIndex(t *testing.T, root string, m *manifest.Manifest) indexgen.Result { + t.Helper() + res, err := indexgen.CheckIndex(root, m) + if err != nil { + t.Fatalf("CheckIndex(%s): %v", root, err) + } + return res +} + +func statusReport(t *testing.T, root string, m *manifest.Manifest) statuscmd.Report { + t.Helper() + res, err := statuscmd.StatusReport(root, m) + if err != nil { + t.Fatalf("StatusReport(%s): %v", root, err) + } + return res +} + +func compactChangelog(t *testing.T, root string, m *manifest.Manifest, opts changelog.CompactOptions) changelog.CompactResult { + t.Helper() + res, err := changelog.CompactChangelog(root, m, opts) + if err != nil { + t.Fatalf("CompactChangelog(%s): %v", root, err) + } + return res +} diff --git a/packages/sdk-go/internal/detect/detect.go b/packages/sdk-go/internal/detect/detect.go index ce901ac..79f6ce2 100644 --- a/packages/sdk-go/internal/detect/detect.go +++ b/packages/sdk-go/internal/detect/detect.go @@ -28,6 +28,28 @@ type HostSpec struct { // McpCheck reports whether the Leji MCP server is already registered (exit 0 = // present); used to skip the install offer when it's already there. McpCheck []string + // McpAddUser registers the server for THIS USER, across every project. Nil when + // McpAdd is already the user-level form (Codex has no other scope). + McpAddUser []string + // McpSharedFile is the committed file a shared (project-scope) registration + // writes, repository root relative. Only a host whose McpAdd writes into the + // repository has one. + McpSharedFile string + // McpConfig is where a host with no registration command reads its MCP + // configuration, for a host Leji can only tell the user about, and which shape + // that file takes. + McpConfig *MCPConfigLocation +} + +// MCPConfigLocation is one host's MCP configuration file, the scope it covers, and +// the top-level key that file uses for its server map. `mcpServers` is the common +// one; VS Code (and GitHub Copilot through it) spells the same map `servers` in +// `.vscode/mcp.json`, so a client told to paste the common block there ends up with a +// file the editor ignores. +type MCPConfigLocation struct { + Path string + Scope string // "project" | "user" + Shape string // "mcpServers" | "servers" } // MCPServerName and MCPPackage are the registered server name and the npm package @@ -37,6 +59,27 @@ const ( MCPPackage = "@leji-org/mcp" ) +// McpJSONConfig is the MCP client configuration for the local Leji server, in the +// shape one client's configuration file takes. The SDK owns these bytes: the MCP +// package README and the website quote the `mcpServers` form, and a repo test asserts +// the three of them agree, so the instruction a user reads is one text. +func McpJSONConfig(shape string) string { + return `{ + "` + shape + `": { + "` + MCPServerName + `": { "command": "npx", "args": ["-y", "` + MCPPackage + `"] } + } +}` +} + +// MCPJSONConfig is the common form, the one the README and the website publish. +var MCPJSONConfig = McpJSONConfig("mcpServers") + +// McpCommand renders one host command line as a user would type it: the host +// binary, then the argv. +func McpCommand(spec *HostSpec, argv []string) string { + return spec.Bins[0] + " " + strings.Join(argv, " ") +} + // PortableAdapter is the portable discovery adapter. `AGENTS.md` is a cross-host // entrypoint convention (stewarded by the Linux Foundation's Agentic AI // Foundation, read natively by Codex, Copilot, Cursor, Gemini CLI, and others), @@ -57,6 +100,10 @@ var HostSpecs = []HostSpec{ // Project scope writes a committed `.mcp.json` so the whole team gets the server. McpAdd: []string{"mcp", "add", MCPServerName, "--scope", "project", "--", "npx", "-y", MCPPackage}, McpCheck: []string{"mcp", "get", MCPServerName}, + // User scope is the personal form: it registers for every project of this + // user without touching a file the repository commits. + McpAddUser: []string{"mcp", "add", MCPServerName, "--scope", "user", "--", "npx", "-y", MCPPackage}, + McpSharedFile: ".mcp.json", }, { ID: "codex", @@ -78,6 +125,7 @@ var HostSpecs = []HostSpec{ RepoFiles: []string{".github/copilot-instructions.md"}, UserDirs: []string{}, Adapter: ".github/copilot-instructions.md", + McpConfig: &MCPConfigLocation{Path: ".vscode/mcp.json", Scope: "project", Shape: "servers"}, }, { ID: "gemini", @@ -86,6 +134,7 @@ var HostSpecs = []HostSpec{ RepoFiles: []string{"GEMINI.md", ".gemini"}, UserDirs: []string{".gemini"}, Adapter: "GEMINI.md", + McpConfig: &MCPConfigLocation{Path: ".gemini/settings.json", Scope: "project", Shape: "mcpServers"}, }, { ID: "cursor", @@ -94,6 +143,7 @@ var HostSpecs = []HostSpec{ RepoFiles: []string{".cursor/rules", ".cursorrules"}, UserDirs: []string{}, Adapter: ".cursor/rules/leji.md", + McpConfig: &MCPConfigLocation{Path: ".cursor/mcp.json", Scope: "project", Shape: "mcpServers"}, }, { ID: "windsurf", @@ -102,6 +152,7 @@ var HostSpecs = []HostSpec{ RepoFiles: []string{".windsurf/rules", ".windsurfrules"}, UserDirs: []string{}, Adapter: ".windsurf/rules/leji.md", + McpConfig: &MCPConfigLocation{Path: "~/.codeium/windsurf/mcp_config.json", Scope: "user", Shape: "mcpServers"}, }, } diff --git a/packages/sdk-go/internal/ecosystem/ecosystem.go b/packages/sdk-go/internal/ecosystem/ecosystem.go new file mode 100644 index 0000000..b1f6f61 --- /dev/null +++ b/packages/sdk-go/internal/ecosystem/ecosystem.go @@ -0,0 +1,1337 @@ +// Package ecosystem answers which dependency ecosystem owns a repository root, +// which package manager runs it, how the Leji CLI is declared as a dev dependency +// there, and how a hook or CI job should invoke it. +// +// Pure and offline: it reads a bounded set of files directly under the root and +// writes nothing, launches nothing, and never walks up out of the root (an add in +// a parent directory would write outside the root the user targeted). Every answer +// is a total decision table over repository evidence, so the three SDKs return the +// same report for the same tree. Transcribed from the TypeScript reference +// (packages/sdk/src/lib/ecosystem.ts): tables and strings byte for byte. +package ecosystem + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/leji-org/leji/packages/sdk-go/internal/fsx" +) + +// DepName is the npm package name. Its presence in package.json's dependency maps +// is what DirectDeclared reports for a Node repository. +const DepName = "@leji-org/leji" + +// pyDist is the distribution name on PyPI and the name a Python manifest declares. +const pyDist = "leji" + +// goToolPath is the Go module path a `tool` directive names for the CLI. +const goToolPath = "github.com/leji-org/leji/packages/sdk-go/cmd/leji" + +// Candidate is one package manager the evidence could not choose between, with +// the command that would declare Leji under it (nil for a print-only manager). +type Candidate struct { + Manager string `json:"manager"` + Add []string `json:"add"` +} + +// Result is one gated ecosystem's answer. TOTAL: every field is set on every +// outcome, so a consumer never has to know which branch produced the result. +// Field order is the JSON contract. +type Result struct { + Ecosystem string `json:"ecosystem"` + Status string `json:"status"` + Manifest *string `json:"manifest"` + Manager *string `json:"manager"` + Source *string `json:"source"` + Evidence []string `json:"evidence"` + Add []string `json:"add"` + Runner []string `json:"runner"` + DirectDeclared bool `json:"directDeclared"` + LockEvidenced bool `json:"lockEvidenced"` + Candidates []Candidate `json:"candidates"` +} + +// Report is the whole answer for one root. Selected is non-nil only when exactly +// one ecosystem is gated AND it chose a manager. +type Report struct { + Selected *Result `json:"selected"` + All []Result `json:"all"` + Reason *string `json:"reason"` +} + +type commandPair struct { + add []string + runner []string + // install is the manager's own plain install — what a joiner runs on a fresh + // clone so the declared CLI resolves — and is nil for a manager whose install + // depends on which requirements file the repository uses. + install []string +} + +// managerCommands is the per-manager command table. `add` nil means a manager +// that cannot declare a dev dependency from the command line; its guidance is +// printed instead. Argv arrays, never shell strings. No version pin: the lockfile +// pins the exact version, and Go needs a selector, so it takes @latest. +var managerCommands = map[string]commandPair{ + "npm": {add: []string{"npm", "i", "-D", DepName}, runner: []string{"npx", "--no-install", DepName}, install: []string{"npm", "install"}}, + "pnpm": {add: []string{"pnpm", "add", "-D", DepName}, runner: []string{"pnpm", "exec", "leji"}, install: []string{"pnpm", "install"}}, + "yarn": {add: []string{"yarn", "add", "-D", DepName}, runner: []string{"yarn", "leji"}, install: []string{"yarn", "install"}}, + "bun": {add: []string{"bun", "add", "-d", DepName}, runner: []string{"bun", "run", "leji"}, install: []string{"bun", "install"}}, + "uv": {add: []string{"uv", "add", "--dev", pyDist}, runner: []string{"uv", "run", "leji"}, install: []string{"uv", "sync"}}, + "poetry": {add: []string{"poetry", "add", "--group", "dev", pyDist}, runner: []string{"poetry", "run", "leji"}, install: []string{"poetry", "install"}}, + "pdm": {add: []string{"pdm", "add", "-dG", "dev", pyDist}, runner: []string{"pdm", "run", "leji"}, install: []string{"pdm", "install"}}, + "pipenv": {add: []string{"pipenv", "install", "--dev", pyDist}, runner: []string{"pipenv", "run", "leji"}, install: []string{"pipenv", "install", "--dev"}}, + "pip": {add: nil, runner: []string{"leji"}, install: nil}, + // The same command F10's CI table installs a Go repository's tools with. + "go": {add: []string{"go", "get", "-tool", goToolPath + "@latest"}, runner: []string{"go", "tool", "leji"}, install: []string{"go", "mod", "download"}}, + "go-legacy": {add: nil, runner: []string{"leji"}, install: nil}, +} + +// ManagerRunnerArgv returns the runner argv for one manager name, or nil when +// leji does not know it. One runner table serves detection, the hook, and CI. +func ManagerRunnerArgv(manager string) []string { + if pair, ok := managerCommands[manager]; ok { + return append([]string(nil), pair.runner...) + } + return nil +} + +// ManagerInstallArgv returns the plain install argv for one manager name: what a +// joiner runs on a fresh clone so the CLI the repository declares actually +// resolves. Nil when leji does not know the manager, or when the manager has no +// single install command. +func ManagerInstallArgv(manager string) []string { + if pair, ok := managerCommands[manager]; ok && pair.install != nil { + return append([]string(nil), pair.install...) + } + return nil +} + +// plainRunner is the fallback: the CLI on PATH, for every repository that has not +// declared it. +var plainRunner = []string{"leji"} + +const ( + nodeManifest = "package.json" + goManifest = "go.mod" + pyproject = "pyproject.toml" + pipfile = "Pipfile" +) + +type lockEntry struct { + file string + manager string + lock bool +} + +// nodeLocks are the Node lockfile families, in the fixed order every list of them +// uses. Two names mark bun (text and binary); either is presence-only evidence. +var nodeLocks = []lockEntry{ + {file: "package-lock.json", manager: "npm"}, + {file: "pnpm-lock.yaml", manager: "pnpm"}, + {file: "yarn.lock", manager: "yarn"}, + {file: "bun.lock", manager: "bun"}, + {file: "bun.lockb", manager: "bun"}, +} + +var nodeManagers = []string{"npm", "pnpm", "yarn", "bun"} + +// pyLocks are the Python lock families, in the fixed order every list of them +// uses. Pipfile is a family member without being a lock: it selects pipenv, but +// only Pipfile.lock evidences a lock. +var pyLocks = []lockEntry{ + {file: "uv.lock", manager: "uv", lock: true}, + {file: "poetry.lock", manager: "poetry", lock: true}, + {file: "pdm.lock", manager: "pdm", lock: true}, + {file: "Pipfile.lock", manager: "pipenv", lock: true}, + {file: pipfile, manager: "pipenv", lock: false}, +} + +// pyToolTables are the [tool.] tables that name a manager when no lock family +// is present. +var pyToolTables = []struct { + table string + manager string +}{ + {table: "tool.uv", manager: "uv"}, + {table: "tool.poetry", manager: "poetry"}, + {table: "tool.pdm", manager: "pdm"}, +} + +// requirementsRe matches the root files that gate the Python ecosystem alongside +// the two manifests. +var requirementsRe = regexp.MustCompile(`^requirements[A-Za-z0-9._-]*\.txt$`) + +// --- the human block ------------------------------------------------------ +// Every string the offer prints lives here once, so the three SDKs transcribe one +// table rather than re-deriving prose. + +const offerLead = "To declare the Leji CLI as a dev dependency so a clean install brings leji, run:" +const declareWithTool = "Declare the Leji CLI as a dev dependency with the tool this repo uses." + +// Indent is the one indentation every printed command line uses. +const Indent = " " + +// TextOffer renders the offer lead for a detected manager. +func TextOffer(manager, file string) string { + return "Detected " + manager + " (" + file + "). " + offerLead +} + +// TextDeclared renders the already-declared line. +func TextDeclared(manifest string) string { + return "The Leji CLI is already declared in " + manifest + "." +} + +// TextAmbiguous renders the manager-ambiguity lead. +func TextAmbiguous(manifest string, files []string) string { + return "Detected " + manifest + " with " + joinAnd(files) + + "; leji will not guess the package manager. Declare it with the one this repo uses:" +} + +// TextMultiple renders the several-ecosystems lead; the punctuation closes the +// sentence when no gated ecosystem has a runnable add. +func TextMultiple(manifests []string, commands bool) string { + tail := "." + if commands { + tail = ":" + } + return "Detected " + joinAnd(manifests) + + "; leji will not guess which ecosystem owns this repository. Declare it with the one this repo uses" + tail +} + +// TextNone is the per-person install block for a root that gates nothing. +var TextNone = []string{ + "No package.json, pyproject.toml or go.mod here, so there is nothing for leji to declare itself in. Install the Leji CLI for yourself:", + Indent + "npm install -g " + DepName, + "Other runtimes and the full walkthrough: https://leji.org/quickstart/", +} + +// TextUnsupported renders the unrecognized-packageManager lead. +func TextUnsupported(manifest string) string { + return "Detected " + manifest + + ", whose packageManager field names a package manager leji does not know; leji will not guess. " + declareWithTool +} + +// TextUnreadable renders the unreadable-manifest lead. +func TextUnreadable(manifest string) string { + return "Could not read " + manifest + ", so leji will not guess the package manager. " + declareWithTool +} + +// TextRefused renders the refused-evidence lead. +func TextRefused(files []string) string { + return "Refusing to read " + joinAnd(files) + ": not a regular file inside this repository. " + declareWithTool +} + +// TextPipGroups renders the PEP 735 guidance for a pip repository. +func TextPipGroups(file string) []string { + return []string{ + "Detected pip (" + file + "). To declare the Leji CLI as a dev dependency so a clean install brings leji, add to " + pyproject + ":", + Indent + "[dependency-groups]", + Indent + `dev = ["` + pyDist + `"]`, + "then run it with pip 25.1 or newer:", + Indent + "pip install --group dev", + } +} + +// TextPipRequirements renders the requirements-file guidance for a pip repository. +func TextPipRequirements(file string) []string { + return []string{ + "Detected pip (" + file + "). To declare the Leji CLI as a dev dependency so a clean install brings leji, add a line `" + pyDist + "` to requirements-dev.txt, then run:", + Indent + "pip install -r requirements-dev.txt", + } +} + +// TextGoLegacy renders the per-person install for a pre-1.24 Go module. +func TextGoLegacy(file string) []string { + return []string{ + "Detected Go (" + file + ") without a go directive of 1.24 or newer, so leji cannot be declared as a module tool. Install the Leji CLI for yourself:", + Indent + "go install " + goToolPath + "@latest", + } +} + +// The one-line forms `leji detect` prints. + +func lineSelected(manager, file string, declared bool) string { + state := "not declared" + if declared { + state = "declared" + } + return "Ecosystem: " + manager + " (" + file + "); Leji CLI " + state +} + +const lineNone = "Ecosystem: none detected" + +func lineMultiple(manifests []string) string { + return "Ecosystem: " + joinAnd(manifests) + "; leji will not guess which one owns this repository" +} + +func lineAmbiguous(manifest string, files []string) string { + return "Ecosystem: " + manifest + " with " + joinAnd(files) + "; leji will not guess the package manager" +} + +func lineUnsupported(manifest string) string { + return "Ecosystem: " + manifest + "; unrecognized packageManager field" +} + +func lineUnreadable(manifest string) string { + return "Ecosystem: " + manifest + "; unreadable" +} + +func lineRefused(files []string) string { + return "Ecosystem: " + joinAnd(files) + "; not a regular file inside this repository" +} + +// The consent path (plan section 3): the prompt, and every outcome of running the +// manager's own add command. leji writes no manifest byte itself, so these are the +// only words it owns once the user says yes. + +// ConsentDisclosure is printed immediately before the prompt, interactive runs +// only. The manager runs here, as the user, with the user's environment: say so +// before asking, not after. +func ConsentDisclosure(bin string) string { + return "This runs " + bin + " here with your environment, as when you run it yourself: it will contact its registry and may run install scripts." +} + +// ConsentPrompt is the question the offer asks. +const ConsentPrompt = "Run it now?" + +// ConsentRunning announces the command about to run. +func ConsentRunning(command []string) string { + return "Running: " + strings.Join(command, " ") +} + +// declaredSubject names what each ecosystem's add command actually declares. +var declaredSubject = map[string]string{ + "node": DepName, + "python": pyDist, + "go": "the leji module tool", +} + +// ConsentDeclared reports a clean add, naming what was declared. +func ConsentDeclared(ecosystem string) string { + return "Declared " + declaredSubject[ecosystem] + "; a clean install now brings leji." +} + +// ConsentExited reports a non-zero add. +func ConsentExited(bin string, code int) string { + return bin + " exited " + strconv.Itoa(code) + "; run it yourself:" +} + +// ConsentSignaled reports an add killed by a signal. +func ConsentSignaled(bin, signal string) string { + return bin + " was terminated (" + signal + "); run it yourself:" +} + +// ConsentMissing reports a spawn that never started. +func ConsentMissing(bin string) string { + return bin + " is not on your PATH; run it yourself once it is:" +} + +// ConsentDeclined is printed when the user says no. +const ConsentDeclined = "Skipped; declare it later with:" + +// ConsentCommand renders one indented command line, so no caller re-derives the +// indentation. +func ConsentCommand(command []string) string { + return Indent + strings.Join(command, " ") +} + +// joinAnd renders `a`, `a and b`, `a, b and c` — the one list join every message +// uses. +func joinAnd(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + default: + return strings.Join(items[:len(items)-1], ", ") + " and " + items[len(items)-1] + } +} + +// --- evidence eligibility ------------------------------------------------- + +// entryKind is what stands at one probed name directly under the root. A gated +// file counts only when lstat says regular file AND its real path lies inside the +// real root: a symlink, a dangling link, a directory, a socket or a FIFO is +// refused rather than read, so no manifest or lockfile can redirect the answer out +// of the repository the user pointed at. +type entryKind int + +const ( + entryAbsent entryKind = iota + entryEligible + entryRefused +) + +func classify(rootAbs, name string) entryKind { + abs := filepath.Join(rootAbs, name) + info, err := os.Lstat(abs) + if err != nil { + return entryAbsent + } + if !info.Mode().IsRegular() { + return entryRefused + } + if fsx.ResolvedWithinRoot(rootAbs, abs) { + return entryEligible + } + return entryRefused +} + +// rootScan holds the probed names of one root, classified once. +type rootScan struct { + rootAbs string + kinds map[string]entryKind + entries []string + listed bool +} + +func newRootScan(rootAbs string) *rootScan { + return &rootScan{rootAbs: rootAbs, kinds: map[string]entryKind{}} +} + +func (s *rootScan) kind(name string) entryKind { + if k, ok := s.kinds[name]; ok { + return k + } + k := classify(s.rootAbs, name) + s.kinds[name] = k + return k +} + +func (s *rootScan) present(name string) bool { return s.kind(name) != entryAbsent } +func (s *rootScan) eligible(name string) bool { return s.kind(name) == entryEligible } + +// refused returns the refused names among names, in the order given. +func (s *rootScan) refused(names []string) []string { + out := []string{} + for _, n := range names { + if s.kind(n) == entryRefused { + out = append(out, n) + } + } + return out +} + +// read returns the bytes of one probed name, or ok=false. Structurally gated: a +// name that is not an eligible regular file inside the real root is never opened, +// so no read can bypass the eligibility rule by being spelled at a new call site. +func (s *rootScan) read(name string) (string, bool) { + if s.kind(name) != entryEligible { + return "", false + } + data, err := os.ReadFile(filepath.Join(s.rootAbs, name)) + if err != nil { + return "", false + } + return string(data), true +} + +// matching returns every root entry matching re, sorted bytewise (never by +// locale: the three SDKs must agree). +func (s *rootScan) matching(re *regexp.Regexp) []string { + if !s.listed { + s.listed = true + items, err := os.ReadDir(s.rootAbs) + if err == nil { + for _, item := range items { + s.entries = append(s.entries, item.Name()) + } + } + } + out := []string{} + for _, n := range s.entries { + if re.MatchString(n) { + out = append(out, n) + } + } + sort.Strings(out) + return out +} + +// --- result construction -------------------------------------------------- + +// decision is what a branch decided; everything it leaves out is the neutral +// value. +type decision struct { + ecosystem string + status string + manifest *string + manager *string + source *string + evidence []string + directDeclared bool + lockEvidenced bool + candidates []Candidate +} + +// newResult is the one Result constructor. Every field of the result is set here, +// in the fixed key order the JSON contract pins, so no branch can build a partial +// outcome and Add/Runner always follow the manager rather than the branch. +func newResult(d decision) Result { + status := d.status + if status == "" { + status = "ok" + } + evidence := d.evidence + if evidence == nil { + evidence = []string{} + } + candidates := d.candidates + if candidates == nil { + candidates = []Candidate{} + } + var add, runner []string + if d.manager != nil { + if pair, ok := managerCommands[*d.manager]; ok { + add = pair.add + runner = pair.runner + } + } + return Result{ + Ecosystem: d.ecosystem, + Status: status, + Manifest: d.manifest, + Manager: d.manager, + Source: d.source, + Evidence: evidence, + Add: add, + Runner: runner, + DirectDeclared: d.directDeclared, + LockEvidenced: d.lockEvidenced, + Candidates: candidates, + } +} + +func candidatesFor(managers []string) []Candidate { + out := []Candidate{} + for _, m := range managers { + var add []string + if pair, ok := managerCommands[m]; ok { + add = pair.add + } + out = append(out, Candidate{Manager: m, Add: add}) + } + return out +} + +func uniq(items []string) []string { + seen := map[string]bool{} + out := []string{} + for _, x := range items { + if !seen[x] { + seen[x] = true + out = append(out, x) + } + } + return out +} + +func strptr(s string) *string { return &s } + +// --- Node ----------------------------------------------------------------- + +// packageManagerRe is corepack's grammar, [@[+]]. A value +// that is present but does not parse is malformed — never a fall-through to a +// lockfile or the default, because explicit repository evidence is never +// overridden by a guess. +var packageManagerRe = regexp.MustCompile(`^([a-z][a-z0-9-]*)(?:@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)(?:\+([A-Za-z0-9._-]+))?)?$`) + +func nodeResult(scan *rootScan) Result { + probed := []string{nodeManifest} + for _, l := range nodeLocks { + probed = append(probed, l.file) + } + if refused := scan.refused(probed); len(refused) > 0 { + sort.Strings(refused) + return newResult(decision{ + ecosystem: "node", status: "refused-evidence", + manifest: strptr(nodeManifest), evidence: refused, + }) + } + raw, ok := scan.read(nodeManifest) + var pkg map[string]any + if ok { + pkg = parsePackageJSON(raw) + } + if pkg == nil { + return newResult(decision{ecosystem: "node", status: "unreadable-manifest", manifest: strptr(nodeManifest)}) + } + var locks []lockEntry + evidence := []string{} + for _, l := range nodeLocks { + if scan.eligible(l.file) { + locks = append(locks, l) + evidence = append(evidence, l.file) + } + } + directDeclared := declaresDepIn(pkg) + selected := func(manager, source string) Result { + lockEvidenced := false + for _, l := range locks { + if l.manager == manager { + lockEvidenced = true + } + } + return newResult(decision{ + ecosystem: "node", manifest: strptr(nodeManifest), manager: strptr(manager), + source: strptr(source), evidence: evidence, + directDeclared: directDeclared, lockEvidenced: lockEvidenced, + }) + } + + if pmRaw, has := pkg["packageManager"]; has { + name := "" + if s, isString := pmRaw.(string); isString { + if m := packageManagerRe.FindStringSubmatch(s); m != nil { + name = m[1] + } + } + known := false + for _, n := range nodeManagers { + if n == name { + known = true + } + } + if !known { + return newResult(decision{ + ecosystem: "node", status: "unsupported-manager", manifest: strptr(nodeManifest), + source: strptr("packageManager"), evidence: evidence, directDeclared: directDeclared, + }) + } + return selected(name, "packageManager") + } + + families := []string{} + for _, l := range locks { + families = append(families, l.manager) + } + families = uniq(families) + if len(families) > 1 { + return newResult(decision{ + ecosystem: "node", status: "ambiguous-manager", manifest: strptr(nodeManifest), + evidence: evidence, directDeclared: directDeclared, candidates: candidatesFor(families), + }) + } + if len(families) == 1 { + return selected(families[0], "lockfile") + } + return selected("npm", "default") +} + +// parsePackageJSON parses strict JSON after one BOM strip; anything else — +// unparseable, or parsed to something that is not a JSON object — leaves the +// manifest unreadable, and locks and defaults are not consulted from incomplete +// evidence. +func parsePackageJSON(raw string) map[string]any { + text := strings.TrimPrefix(raw, "\ufeff") + var parsed any + dec := json.NewDecoder(strings.NewReader(text)) + dec.UseNumber() + if err := dec.Decode(&parsed); err != nil { + return nil + } + // Anything after the first value is not strict JSON: only a clean end of input + // is acceptable. A second value parses, and trailing garbage errors — both are + // input JSON.parse and Python's json.loads refuse, so both leave the manifest + // unreadable rather than being read as the value that happened to come first. + var extra any + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + return nil + } + obj, ok := parsed.(map[string]any) + if !ok { + return nil + } + return obj +} + +func declaresDepIn(pkg map[string]any) bool { + for _, field := range []string{"dependencies", "devDependencies"} { + deps, ok := pkg[field].(map[string]any) + if !ok { + continue + } + if _, has := deps[DepName]; has { + return true + } + } + return false +} + +// --- Python --------------------------------------------------------------- + +func pythonResult(scan *rootScan) Result { + requirements := scan.matching(requirementsRe) + manifest := pythonManifest(scan, requirements) + probed := []string{pyproject} + for _, l := range pyLocks { + probed = append(probed, l.file) + } + probed = append(probed, requirements...) + if refused := scan.refused(uniq(probed)); len(refused) > 0 { + sort.Strings(refused) + return newResult(decision{ + ecosystem: "python", status: "refused-evidence", manifest: manifest, evidence: refused, + }) + } + pyprojectText, pyprojectOK := "", false + if scan.eligible(pyproject) { + pyprojectText, pyprojectOK = scan.read(pyproject) + if !pyprojectOK { + return newResult(decision{ecosystem: "python", status: "unreadable-manifest", manifest: manifest}) + } + } + pipfileText, pipfileOK := "", false + if scan.eligible(pipfile) { + pipfileText, pipfileOK = scan.read(pipfile) + if !pipfileOK { + return newResult(decision{ecosystem: "python", status: "unreadable-manifest", manifest: manifest}) + } + } + + var present []lockEntry + evidence := []string{} + for _, l := range pyLocks { + if scan.eligible(l.file) { + present = append(present, l) + evidence = append(evidence, l.file) + } + } + evidence = append(evidence, requirements...) + directDeclared := pythonDeclared(scan, pyprojectText, pyprojectOK, pipfileText, pipfileOK, requirements) + + families := []string{} + for _, l := range present { + families = append(families, l.manager) + } + families = uniq(families) + if len(families) > 1 { + return newResult(decision{ + ecosystem: "python", status: "ambiguous-manager", manifest: manifest, + evidence: evidence, directDeclared: directDeclared, candidates: candidatesFor(families), + }) + } + if len(families) == 1 { + // Pipfile alone selects pipenv from the manifest itself; only Pipfile.lock is + // lock evidence, which is what CI reads to choose a locked install. + lock := false + for _, l := range present { + if l.manager == families[0] && l.lock { + lock = true + } + } + source := "manifest" + if lock { + source = "lockfile" + } + return newResult(decision{ + ecosystem: "python", manifest: manifest, manager: strptr(families[0]), source: strptr(source), + evidence: evidence, directDeclared: directDeclared, lockEvidenced: lock, + }) + } + + tables := []string{} + if pyprojectOK { + for _, t := range pyToolTables { + if tomlHasTable(pyprojectText, t.table) { + tables = append(tables, t.manager) + } + } + } + if len(tables) > 1 { + return newResult(decision{ + ecosystem: "python", status: "ambiguous-manager", manifest: manifest, + evidence: evidence, directDeclared: directDeclared, candidates: candidatesFor(tables), + }) + } + if len(tables) == 1 { + return newResult(decision{ + ecosystem: "python", manifest: manifest, manager: strptr(tables[0]), source: strptr("tool-table"), + evidence: evidence, directDeclared: directDeclared, + }) + } + // Nothing named a manager: pip is the ecosystem's default, and it is print-only. + return newResult(decision{ + ecosystem: "python", manifest: manifest, manager: strptr("pip"), source: strptr("default"), + evidence: evidence, directDeclared: directDeclared, + }) +} + +// pythonManifest applies the manifest precedence: pyproject, then Pipfile, then +// the conventional requirements files. Decided on presence, so a refused entry +// still names what was refused. +func pythonManifest(scan *rootScan, requirements []string) *string { + if scan.present(pyproject) { + return strptr(pyproject) + } + if scan.present(pipfile) { + return strptr(pipfile) + } + for _, name := range []string{"requirements-dev.txt", "requirements.txt"} { + for _, r := range requirements { + if r == name { + return strptr(name) + } + } + } + if len(requirements) > 0 { + return strptr(requirements[0]) + } + return nil +} + +func pythonDeclared(scan *rootScan, pyprojectText string, pyprojectOK bool, pipfileText string, pipfileOK bool, requirements []string) bool { + if pyprojectOK && tomlDeclaresLeji(pyprojectText, pyprojectFields) { + return true + } + if pipfileOK && tomlDeclaresLeji(pipfileText, pipfileFields) { + return true + } + for _, name := range requirements { + if !scan.eligible(name) { + continue + } + if text, ok := scan.read(name); ok && requirementsDeclareLeji(text) { + return true + } + } + return false +} + +// --- Go ------------------------------------------------------------------- + +var goDirectiveRe = regexp.MustCompile(`^go\s+(\d+)\.(\d+)`) + +func goResult(scan *rootScan) Result { + if refused := scan.refused([]string{goManifest}); len(refused) > 0 { + return newResult(decision{ + ecosystem: "go", status: "refused-evidence", manifest: strptr(goManifest), evidence: refused, + }) + } + text, ok := scan.read(goManifest) + if !ok { + return newResult(decision{ecosystem: "go", status: "unreadable-manifest", manifest: strptr(goManifest)}) + } + // Tool dependencies are a Go 1.24 feature; an older or missing directive gets + // the per-person install instead. go.sum is the manager's business, so a + // declared tool is its own lock evidence. + directDeclared := goDeclaresTool(text) + modern := goDirectiveAtLeast(text, 1, 24) + manager := "go-legacy" + if modern { + manager = "go" + } + return newResult(decision{ + ecosystem: "go", manifest: strptr(goManifest), manager: strptr(manager), source: strptr("manifest"), + directDeclared: directDeclared, lockEvidenced: modern && directDeclared, + }) +} + +func goDirectiveAtLeast(text string, major, minor int) bool { + for _, line := range splitLines(text) { + m := goDirectiveRe.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + continue + } + foundMajor, _ := strconv.Atoi(m[1]) + foundMinor, _ := strconv.Atoi(m[2]) + return foundMajor > major || (foundMajor == major && foundMinor >= minor) + } + return false +} + +var goToolBlockRe = regexp.MustCompile(`^tool\s*\($`) + +// goDeclaresTool reports a `tool ` line, or that path inside a `tool (` block. +func goDeclaresTool(text string) bool { + inBlock := false + for _, raw := range splitLines(text) { + line := raw + if cut := strings.Index(line, "//"); cut >= 0 { + line = line[:cut] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if inBlock { + if line == ")" { + inBlock = false + } else if line == goToolPath { + return true + } + continue + } + if goToolBlockRe.MatchString(line) { + inBlock = true + continue + } + if line == "tool "+goToolPath { + return true + } + } + return false +} + +// --- the TOML dependency scan --------------------------------------------- + +// tomlFields says which fields of a TOML document declare a dependency. +// Deliberately not a TOML parser: a field-specific, stateful line scan that tracks +// the current table, triple-quoted string state, and the bracket depth of the one +// array it is inspecting. Only the listed fields are inspected, so a description, a +// comment, or an unrelated table cannot produce a false positive — and a false +// positive is the expensive error here, because it suppresses the only offer the +// user gets. +type tomlFields struct { + keyTable func(table string) bool + arrayField func(table, key string) bool +} + +var poetryGroupRe = regexp.MustCompile(`^tool\.poetry\.group\.[^.]+\.dependencies$`) + +var pyprojectFields = tomlFields{ + keyTable: func(t string) bool { + return t == "tool.poetry.dependencies" || + t == "tool.poetry.dev-dependencies" || + t == "tool.pdm.dev-dependencies" || + poetryGroupRe.MatchString(t) + }, + arrayField: func(t, k string) bool { + return (t == "project" && k == "dependencies") || + t == "project.optional-dependencies" || + t == "dependency-groups" || + (t == "tool.uv" && k == "dev-dependencies") || + t == "tool.pdm.dev-dependencies" + }, +} + +var pipfileFields = tomlFields{ + keyTable: func(t string) bool { return t == "packages" || t == "dev-packages" }, + arrayField: func(string, string) bool { return false }, +} + +// lejiRequirementRe matches a requirement whose distribution name is exactly +// leji: the name, then the end of the token or one of the characters that can +// follow a name in PEP 508 / requirements syntax. +var lejiRequirementRe = regexp.MustCompile(`^leji($|[\[=<>~!;,\s])`) + +func tomlDeclaresLeji(text string, fields tomlFields) bool { + table := "" + triple := "" + depth := 0 + inspecting := false + for _, line := range splitLines(text) { + i := 0 + if triple != "" { + close := strings.Index(line, triple) + if close < 0 { + continue + } + i = close + 3 + triple = "" + } else if depth == 0 { + if header, ok := tomlTableHeader(line); ok { + table = header + continue + } + key, valueAt, ok := tomlKeyAt(line) + if !ok { + continue + } + if key == pyDist && fields.keyTable(table) { + return true + } + inspecting = fields.arrayField(table, key) + i = valueAt + } + // One character scan carries the rest: strings (whose contents are the only + // things that can match), bracket depth (which says whether we are inside the + // inspected array), comments, and a triple quote that runs past this line. + for i < len(line) { + c := line[i] + if c == '#' { + break + } + if c == '"' || c == '\'' { + fence := strings.Repeat(string(c), 3) + if strings.HasPrefix(line[i:], fence) { + close := strings.Index(line[i+3:], fence) + if close < 0 { + triple = fence + break + } + // A triple-quoted string is skipped ENTIRELY, on one line as across + // several: the scanner has no TOML parser to tell a multi-line + // dependency from prose that merely starts with the name, so the + // conservative answer is the only safe one. + i = i + 3 + close + 3 + continue + } + text, end := tomlReadString(line, i, c) + if depth > 0 && inspecting && lejiRequirementRe.MatchString(text) { + return true + } + i = end + continue + } + if c == '[' { + depth++ + } else if c == ']' && depth > 0 { + depth-- + if depth == 0 { + inspecting = false + } + } + i++ + } + } + return false +} + +var tomlArrayHeaderRe = regexp.MustCompile(`^\s*\[\[\s*([^\]]+?)\s*\]\]\s*(?:#.*)?$`) +var tomlHeaderRe = regexp.MustCompile(`^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$`) +var whitespaceRe = regexp.MustCompile(`\s+`) + +// tomlTableHeader matches `[table]` or `[[array-of-tables]]`, with inner +// whitespace removed. +func tomlTableHeader(line string) (string, bool) { + if m := tomlArrayHeaderRe.FindStringSubmatch(line); m != nil { + return whitespaceRe.ReplaceAllString(m[1], ""), true + } + if m := tomlHeaderRe.FindStringSubmatch(line); m != nil { + return whitespaceRe.ReplaceAllString(m[1], ""), true + } + return "", false +} + +var tomlKeyRe = regexp.MustCompile(`^\s*(?:"([^"]*)"|'([^']*)'|([A-Za-z0-9_.-]+))\s*=\s*`) + +// tomlKeyAt returns the key a line assigns to, bare or quoted, and where its +// value starts. +func tomlKeyAt(line string) (string, int, bool) { + m := tomlKeyRe.FindStringSubmatchIndex(line) + if m == nil { + return "", 0, false + } + for g := 1; g <= 3; g++ { + if m[2*g] >= 0 { + return line[m[2*g]:m[2*g+1]], m[1], true + } + } + return "", m[1], true +} + +// tomlReadString reads one single-line basic or literal string, from its opening +// quote. Escapes are consumed, not decoded: only a `leji` prefix is ever tested +// against the result. +func tomlReadString(line string, start int, quote byte) (string, int) { + var b strings.Builder + i := start + 1 + for i < len(line) { + c := line[i] + if quote == '"' && c == '\\' { + if i+1 < len(line) { + b.WriteByte(line[i+1]) + } + i += 2 + continue + } + if c == quote { + return b.String(), i + 1 + } + b.WriteByte(c) + i++ + } + return b.String(), len(line) +} + +// tomlHasTable reports whether the document opens the given table, or any table +// under it: TOML defines tool.poetry implicitly when a document writes only +// [tool.poetry.dependencies], and a manager's table is present either way. The dot +// is what keeps [tool.uvicorn] from answering for tool.uv. Same header rules as +// the dependency scan, including the multi-line-string state that keeps a table +// name inside a description from counting. +func tomlHasTable(text, table string) bool { + triple := "" + for _, line := range splitLines(text) { + if triple != "" { + if strings.Contains(line, triple) { + triple = "" + } + continue + } + if header, ok := tomlTableHeader(line); ok { + if header == table || strings.HasPrefix(header, table+".") { + return true + } + continue + } + if opened := tomlOpensTriple(line); opened != "" { + triple = opened + } + } + return false +} + +// tomlOpensTriple returns the triple quote a line leaves open, or "". +func tomlOpensTriple(line string) string { + i := 0 + open := "" + for i < len(line) { + c := line[i] + if c == '#' { + break + } + if c == '"' || c == '\'' { + fence := strings.Repeat(string(c), 3) + if strings.HasPrefix(line[i:], fence) { + close := strings.Index(line[i+3:], fence) + if close < 0 { + open = fence + break + } + i = i + 3 + close + 3 + continue + } + _, end := tomlReadString(line, i, c) + i = end + continue + } + i++ + } + return open +} + +var requirementLineRe = regexp.MustCompile(`^leji($|[\s\[=<>~!;,#])`) + +// requirementsDeclareLeji reports a `leji` requirement line in a requirements +// file: the name at the start of the line, then end-of-line or a character that +// can follow a name. +func requirementsDeclareLeji(text string) bool { + for _, line := range splitLines(text) { + if requirementLineRe.MatchString(line) { + return true + } + } + return false +} + +func splitLines(text string) []string { + lines := strings.Split(text, "\n") + for i, l := range lines { + lines[i] = strings.TrimSuffix(l, "\r") + } + return lines +} + +// --- the report ----------------------------------------------------------- + +// Detect detects the dependency ecosystems gated by files directly under rootAbs. +// Reads; never writes, never runs anything, never walks up. +func Detect(rootAbs string) Report { + abs, err := filepath.Abs(rootAbs) + if err != nil { + abs = rootAbs + } + scan := newRootScan(abs) + all := []Result{} + // Fixed order, so `all` reads the same in every report and in every SDK. + if scan.present(nodeManifest) { + all = append(all, nodeResult(scan)) + } + pythonGated := scan.present(pyproject) || scan.present(pipfile) || len(scan.matching(requirementsRe)) > 0 + if pythonGated { + all = append(all, pythonResult(scan)) + } + if scan.present(goManifest) { + all = append(all, goResult(scan)) + } + + if len(all) == 0 { + return Report{Selected: nil, All: all, Reason: strptr("none")} + } + if len(all) > 1 { + return Report{Selected: nil, All: all, Reason: strptr("multiple-ecosystems")} + } + only := all[0] + // A manager-less single ecosystem carries its own reason up: the report's + // reason is never a second, independently derived verdict. + if only.Status != "ok" { + return Report{Selected: nil, All: all, Reason: strptr(only.Status)} + } + selected := only + return Report{Selected: &selected, All: all, Reason: nil} +} + +// RunnerArgv returns the argv a hook or CI job runs leji with: the detected +// manager's runner when the repository actually declares the CLI, else the plain +// fallback on PATH. +func RunnerArgv(report Report) []string { + if s := report.Selected; s != nil && s.DirectDeclared && s.Runner != nil { + return append([]string(nil), s.Runner...) + } + return append([]string(nil), plainRunner...) +} + +// RenderBlock renders the always-printed human block: what was detected, and what +// to run to declare the Leji CLI. Never a prompt, never a command run — the caller +// owns both. +func RenderBlock(report Report) string { + return strings.Join(blockLines(report), "\n") +} + +func blockLines(report Report) []string { + if report.Reason != nil && *report.Reason == "none" { + return TextNone + } + if report.Reason != nil && *report.Reason == "multiple-ecosystems" { + // A print-only ecosystem (pip, pre-1.24 Go) contributes no command here; the + // lead sentence closes with a period rather than dangling a colon. + commands := []string{} + manifests := []string{} + for _, r := range report.All { + commands = append(commands, commandLines(r)...) + manifests = append(manifests, manifestLabel(r)) + } + return append([]string{TextMultiple(manifests, len(commands) > 0)}, commands...) + } + only := report.All[0] + if only.DirectDeclared && only.Manifest != nil { + return []string{TextDeclared(*only.Manifest)} + } + switch only.Status { + case "refused-evidence": + return []string{TextRefused(only.Evidence)} + case "unreadable-manifest": + return []string{TextUnreadable(deref(only.Manifest))} + case "unsupported-manager": + return []string{TextUnsupported(deref(only.Manifest))} + case "ambiguous-manager": + return append([]string{TextAmbiguous(deref(only.Manifest), only.Evidence)}, commandLines(only)...) + default: + return okLines(only) + } +} + +// okLines renders the offer for one ecosystem that chose a manager. A manager with +// no add command prints its own guidance instead. +func okLines(r Result) []string { + if r.Manager != nil && *r.Manager == "pip" { + if r.Manifest != nil && *r.Manifest == pyproject { + return TextPipGroups(deciderFile(r)) + } + return TextPipRequirements(deciderFile(r)) + } + if r.Manager != nil && *r.Manager == "go-legacy" { + return TextGoLegacy(deciderFile(r)) + } + return append([]string{TextOffer(deref(r.Manager), deciderFile(r))}, commandLines(r)...) +} + +// commandLines renders the indented command line(s) for one result: its own add +// command, or one per candidate when the evidence could not choose. +func commandLines(r Result) []string { + if r.Add != nil { + return []string{ConsentCommand(r.Add)} + } + out := []string{} + for _, c := range r.Candidates { + if c.Add != nil { + out = append(out, ConsentCommand(c.Add)) + } + } + return out +} + +// deciderFile names the one file a message cites as the evidence for the manager: +// the lockfile that selected it, the pyproject that carried its tool table, or the +// manifest. +func deciderFile(r Result) string { + if r.Source != nil && *r.Source == "lockfile" { + for _, f := range r.Evidence { + for _, l := range nodeLocks { + if l.file == f && r.Manager != nil && l.manager == *r.Manager { + return f + } + } + for _, l := range pyLocks { + if l.file == f && r.Manager != nil && l.manager == *r.Manager && l.lock { + return f + } + } + } + } + if r.Source != nil && *r.Source == "tool-table" { + return pyproject + } + if r.Ecosystem == "python" && r.Source != nil && *r.Source == "manifest" { + return pipfile + } + return deref(r.Manifest) +} + +func manifestLabel(r Result) string { + if r.Manifest != nil { + return *r.Manifest + } + return r.Ecosystem +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// RenderLine renders the one line `leji detect` prints about the ecosystem. +func RenderLine(report Report) string { + if report.Reason != nil && *report.Reason == "none" { + return lineNone + } + if report.Reason != nil && *report.Reason == "multiple-ecosystems" { + manifests := []string{} + for _, r := range report.All { + manifests = append(manifests, manifestLabel(r)) + } + return lineMultiple(manifests) + } + only := report.All[0] + switch only.Status { + case "refused-evidence": + return lineRefused(only.Evidence) + case "unreadable-manifest": + return lineUnreadable(deref(only.Manifest)) + case "unsupported-manager": + return lineUnsupported(deref(only.Manifest)) + case "ambiguous-manager": + return lineAmbiguous(deref(only.Manifest), only.Evidence) + default: + return lineSelected(deref(only.Manager), deciderFile(only), only.DirectDeclared) + } +} + +// MarshalJSONIndent encodes the report exactly as the reference does with +// JSON.stringify(value, null, 2): struct field order is the key order, empty +// arrays stay arrays, absent argv is null, and nothing is HTML-escaped. +func MarshalJSONIndent(v any, prefix, indent string) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent(prefix, indent) + if err := enc.Encode(v); err != nil { + return nil, err + } + // Encoder.Encode appends a newline; the caller owns trailing bytes. + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} diff --git a/packages/sdk-go/internal/ecosystem/ecosystem_test.go b/packages/sdk-go/internal/ecosystem/ecosystem_test.go new file mode 100644 index 0000000..e17cc69 --- /dev/null +++ b/packages/sdk-go/internal/ecosystem/ecosystem_test.go @@ -0,0 +1,404 @@ +package ecosystem_test + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/ecosystem" +) + +func repoRoot(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + return filepath.Join(wd, "..", "..", "..", "..") +} + +func casesDir(t *testing.T) string { + t.Helper() + return filepath.Join(repoRoot(t), "fixtures", "ecosystem") +} + +// serialize is the one committed formatting of an ecosystem expected.json: the +// report under an `ecosystem` key, two-space indent, one trailing newline. +// Comparing the BYTES is what pins key order, which a deep comparison cannot see +// and which the --json surface makes a public contract. +func serialize(t *testing.T, report ecosystem.Report) string { + t.Helper() + wrapper := struct { + Ecosystem ecosystem.Report `json:"ecosystem"` + }{Ecosystem: report} + out, err := ecosystem.MarshalJSONIndent(wrapper, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(out) + "\n" +} + +func caseNames(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(casesDir(t)) + if err != nil { + t.Fatalf("fixtures/ecosystem: %v", err) + } + names := []string{} + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names +} + +func TestEcosystemFixtures(t *testing.T) { + names := caseNames(t) + if len(names) < 111 { + t.Fatalf("expected the full ecosystem fixture family, found %d", len(names)) + } + for _, name := range names { + dir := filepath.Join(casesDir(t), name) + expected, err := os.ReadFile(filepath.Join(dir, "expected.json")) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + report := ecosystem.Detect(dir) + got := serialize(t, report) + if got != string(expected) { + t.Errorf("%s: bytes differ\n--- want ---\n%s\n--- got ---\n%s", name, expected, got) + continue + } + // Deep equality too, so a divergence names the field rather than a byte offset. + var wantAny, gotAny any + if err := json.Unmarshal(expected, &wantAny); err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := json.Unmarshal([]byte(got), &gotAny); err != nil { + t.Fatalf("%s: %v", name, err) + } + if !reflect.DeepEqual(wantAny, gotAny) { + t.Errorf("%s: report differs", name) + } + } +} + +// The scanner families, transcribed from the TypeScript contract test: every +// inspected field carries both verdicts as shared cases, and this port reads the +// same verdicts off the same fixtures. +func TestScannerFamiliesBothVerdicts(t *testing.T) { + declared := func(name string) bool { + report := ecosystem.Detect(filepath.Join(casesDir(t), name)) + if report.Selected == nil { + t.Fatalf("%s: a scanner case always selects one manager", name) + } + return report.Selected.DirectDeclared + } + positives := []string{ + "scan-project-deps-inline", "scan-project-deps-multiline", "scan-project-deps-specifier", + "scan-project-deps-spaced-header", "scan-project-deps-single-quoted", + "scan-optional-deps-declared", "scan-dependency-groups-declared", + "scan-tool-uv-dev-declared", "scan-tool-uv-dev-marker", + "scan-poetry-deps-key", "scan-poetry-deps-quoted-key", + "scan-poetry-dev-deps-key", "scan-poetry-dev-deps-quoted-key", + "scan-poetry-group-key", "scan-poetry-group-inline-table", + "scan-pdm-dev-array", "scan-pdm-dev-key", + "scan-pipfile-packages", "scan-pipfile-packages-quoted-key", + "scan-pipfile-dev-packages", "scan-pipfile-dev-packages-bare-key", + "scan-requirements-declared", "scan-requirements-bare", "scan-requirements-extras", + "scan-plain-quoted-element", "scan-go-2.0", + } + negatives := []string{ + "scan-project-deps-absent", "scan-project-deps-prefix-only", "scan-project-deps-comment", + "scan-optional-deps-absent", "scan-optional-deps-comment", + "scan-dependency-groups-absent", "scan-dependency-groups-comment", "scan-dependency-groups-triple-quoted", + "scan-tool-uv-dev-absent", "scan-tool-uv-dev-comment", "scan-tool-uv-other-field", + "scan-poetry-deps-absent", "scan-poetry-deps-comment", + "scan-poetry-dev-deps-absent", "scan-poetry-dev-deps-comment", + "scan-poetry-group-absent", "scan-poetry-group-comment", + "scan-pdm-dev-absent", "scan-pdm-dev-comment", + "scan-pipfile-packages-absent", "scan-pipfile-packages-comment", + "scan-pipfile-dev-packages-absent", "scan-pipfile-dev-packages-comment", + "scan-requirements-indented", "scan-requirements-comment", + "scan-requirements-prefix-only", "scan-requirements-include-line", + "scan-triple-quoted-element", "scan-triple-quoted-element-literal", + "scan-multiline-basic-string", "scan-multiline-literal-string", "scan-multiline-string-hides-table", + "scan-project-description", "scan-project-keywords", "scan-project-classifiers", + "scan-project-nested-array", "scan-unrelated-table-key", + "scan-poetry-scripts-key", "scan-pipfile-scripts", + "scan-go-closed-block", "scan-go-comment", "scan-go-1.9", "scan-go-1.25", + } + for _, name := range positives { + if !declared(name) { + t.Errorf("%s must declare", name) + } + } + for _, name := range negatives { + if declared(name) { + t.Errorf("%s must not declare", name) + } + } + // The families must cover every shared scan case on disk, exactly as the + // TypeScript partition test asserts: a case this port does not read is a case + // it can diverge on. + claimed := map[string]bool{} + for _, n := range append(append([]string{}, positives...), negatives...) { + claimed[n] = true + } + for _, name := range caseNames(t) { + if strings.HasPrefix(name, "scan-") && !claimed[name] { + t.Errorf("shared scanner case %s is not read by this port", name) + } + } +} + +// The go directive threshold, pinned by shared cases on both sides of 1.24. +func TestGoDirectiveThreshold(t *testing.T) { + manager := func(name string) string { + report := ecosystem.Detect(filepath.Join(casesDir(t), name)) + if report.Selected == nil || report.Selected.Manager == nil { + t.Fatalf("%s: expected a selected manager", name) + } + return *report.Selected.Manager + } + for name, want := range map[string]string{ + "scan-go-1.9": "go-legacy", "go-1.23-legacy": "go-legacy", "go-no-directive": "go-legacy", + "go-1.24": "go", "scan-go-1.25": "go", "scan-go-2.0": "go", + } { + if got := manager(name); got != want { + t.Errorf("%s: manager %q, want %q", name, got, want) + } + } +} + +func plant(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for rel, body := range files { + if err := os.WriteFile(filepath.Join(dir, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +// The packageManager grammar, mirrored from the TypeScript contract test: a +// recognized name wins whatever the lockfiles say, and a value that does not parse +// never falls through to one. +func TestPackageManagerGrammar(t *testing.T) { + pm := func(value string, extra map[string]string) ecosystem.Report { + files := map[string]string{"package.json": `{"packageManager":` + quote(value) + `}`} + for k, v := range extra { + files[k] = v + } + return ecosystem.Detect(plant(t, files)) + } + for value, want := range map[string]string{ + "pnpm@9.12.0": "pnpm", "bun@1.1.30+e1f2a3b4c5": "bun", "yarn@4.1.0-rc.1": "yarn", "npm": "npm", + } { + r := pm(value, nil) + if r.Selected == nil || r.Selected.Manager == nil || *r.Selected.Manager != want { + t.Errorf("packageManager %q: want %q", value, want) + } + } + if r := pm("pnpm@9.12.0", map[string]string{"yarn.lock": ""}); r.Selected == nil || *r.Selected.Manager != "pnpm" { + t.Error("packageManager wins over a lockfile") + } + for _, bad := range []string{"pnpm@@9", "pnpm@", "@9.12.0", "Pnpm@9.12.0", "pnpm 9.12.0", "", "hermit@1.0.0"} { + r := pm(bad, map[string]string{"package-lock.json": ""}) + if r.Reason == nil || *r.Reason != "unsupported-manager" { + t.Errorf("packageManager %q: want unsupported-manager", bad) + } + if r.All[0].Source == nil || *r.All[0].Source != "packageManager" || len(r.All[0].Candidates) != 0 || r.All[0].Add != nil { + t.Errorf("packageManager %q: no fall-through, no candidates, no add", bad) + } + } + // A non-string value is a present value that does not parse. + numeric := ecosystem.Detect(plant(t, map[string]string{"package.json": `{"packageManager":9}`})) + if numeric.Reason == nil || *numeric.Reason != "unsupported-manager" { + t.Error("a non-string packageManager is unsupported") + } +} + +func quote(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// The eligibility gate: a symlinked, dangling or non-regular manifest is refused, +// never read, and an unreadable one consults neither locks nor defaults. +func TestEvidenceEligibility(t *testing.T) { + outside := plant(t, map[string]string{"package.json": `{"dependencies":{"@leji-org/leji":"1"}}`}) + + linked := t.TempDir() + if err := os.Symlink(filepath.Join(outside, "package.json"), filepath.Join(linked, "package.json")); err != nil { + t.Fatal(err) + } + r := ecosystem.Detect(linked) + if r.Reason == nil || *r.Reason != "refused-evidence" { + t.Fatal("a symlinked manifest is refused") + } + if !reflect.DeepEqual(r.All[0].Evidence, []string{"package.json"}) || r.All[0].DirectDeclared { + t.Error("a refused manifest is never read for a declaration") + } + + dangling := t.TempDir() + if err := os.Symlink(filepath.Join(dangling, "gone.json"), filepath.Join(dangling, "package.json")); err != nil { + t.Fatal(err) + } + if rr := ecosystem.Detect(dangling); rr.Reason == nil || *rr.Reason != "refused-evidence" { + t.Error("a dangling manifest is refused") + } + + dirLock := plant(t, map[string]string{"package.json": "{}"}) + if err := os.Mkdir(filepath.Join(dirLock, "pnpm-lock.yaml"), 0o755); err != nil { + t.Fatal(err) + } + asDir := ecosystem.Detect(dirLock) + if asDir.Reason == nil || *asDir.Reason != "refused-evidence" || + !reflect.DeepEqual(asDir.All[0].Evidence, []string{"pnpm-lock.yaml"}) { + t.Error("a directory standing where a lockfile belongs is refused") + } + + inside := plant(t, map[string]string{"package.json": "{}", "other.json": "{}"}) + if err := os.Symlink("./other.json", filepath.Join(inside, "pnpm-lock.yaml")); err != nil { + t.Fatal(err) + } + if ir := ecosystem.Detect(inside); ir.Reason == nil || *ir.Reason != "refused-evidence" { + t.Error("a symlink that stays inside the root is still not a regular file") + } + + broken := plant(t, map[string]string{"package.json": `{ "name": `, "package-lock.json": ""}) + br := ecosystem.Detect(broken) + if br.Reason == nil || *br.Reason != "unreadable-manifest" || + br.All[0].Manager != nil || len(br.All[0].Evidence) != 0 || br.All[0].Add != nil { + t.Error("an unreadable manifest consults neither locks nor defaults") + } + if ar := ecosystem.Detect(plant(t, map[string]string{"package.json": "[]"})); ar.Reason == nil || *ar.Reason != "unreadable-manifest" { + t.Error("valid JSON that is not an object cannot carry a field") + } + bom := plant(t, map[string]string{"package.json": "\ufeff{ \"packageManager\": \"yarn@4.1.0\" }"}) + if bm := ecosystem.Detect(bom); bm.Selected == nil || *bm.Selected.Manager != "yarn" { + t.Error("one BOM is stripped before the strict parse") + } + + // No walk-up: a parent manifest never answers for the root. + parent := plant(t, map[string]string{"package.json": "{}", "package-lock.json": ""}) + child := filepath.Join(parent, "child") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatal(err) + } + cr := ecosystem.Detect(child) + if cr.Selected != nil || len(cr.All) != 0 || cr.Reason == nil || *cr.Reason != "none" { + t.Error("detection never walks up out of the root") + } +} + +// The runner a hook or CI job takes, and the printed block and line, against the +// shared cases. +func TestRunnerBlockAndLine(t *testing.T) { + report := func(name string) ecosystem.Report { return ecosystem.Detect(filepath.Join(casesDir(t), name)) } + for name, want := range map[string][]string{ + "node-declared": {"npx", "--no-install", "@leji-org/leji"}, + "node-pnpm-lock": {"leji"}, + "go-declared-block": {"go", "tool", "leji"}, + "python-declared-pyproject-groups": {"uv", "run", "leji"}, + "none": {"leji"}, + "node-two-lockfiles": {"leji"}, + } { + if got := ecosystem.RunnerArgv(report(name)); !reflect.DeepEqual(got, want) { + t.Errorf("%s: runner %v, want %v", name, got, want) + } + } + + if got := ecosystem.RenderBlock(report("node-pnpm-lock")); got != + "Detected pnpm (pnpm-lock.yaml). To declare the Leji CLI as a dev dependency so a clean install brings leji, run:\n pnpm add -D @leji-org/leji" { + t.Errorf("offer block:\n%s", got) + } + if got := ecosystem.RenderBlock(report("node-declared")); got != "The Leji CLI is already declared in package.json." { + t.Errorf("declared block: %s", got) + } + if got := ecosystem.RenderBlock(report("node-two-lockfiles")); got != + "Detected package.json with package-lock.json and yarn.lock; leji will not guess the package manager. Declare it with the one this repo uses:\n npm i -D @leji-org/leji\n yarn add -D @leji-org/leji" { + t.Errorf("ambiguous block:\n%s", got) + } + for _, name := range []string{"node-packagemanager-unknown", "node-unreadable-manifest", "node-refused-evidence"} { + block := ecosystem.RenderBlock(report(name)) + if block == "" || strings.Contains(block, "\n npm") { + t.Errorf("%s: a block, and never a guessed command", name) + } + } + if got := ecosystem.RenderBlock(report("python-bare-pyproject")); got != strings.Join(ecosystem.TextPipGroups("pyproject.toml"), "\n") { + t.Errorf("pip block:\n%s", got) + } + if got := ecosystem.RenderBlock(report("python-requirements-only")); !strings.Contains(got, "pip install -r requirements-dev.txt") { + t.Errorf("pip requirements block:\n%s", got) + } + if got := ecosystem.RenderBlock(report("go-1.23-legacy")); !strings.Contains(got, "go install "+"github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest") { + t.Errorf("go-legacy block:\n%s", got) + } + if got := ecosystem.RenderBlock(report("none")); !strings.Contains(got, "https://leji.org/quickstart/") { + t.Errorf("none block:\n%s", got) + } + + for name, want := range map[string]string{ + "node-pnpm-lock": "Ecosystem: pnpm (pnpm-lock.yaml); Leji CLI not declared", + "node-declared": "Ecosystem: npm (package-lock.json); Leji CLI declared", + "python-pipfile-only": "Ecosystem: pipenv (Pipfile); Leji CLI declared", + "python-tool-uv-no-lock": "Ecosystem: uv (pyproject.toml); Leji CLI not declared", + "none": "Ecosystem: none detected", + } { + if got := ecosystem.RenderLine(report(name)); got != want { + t.Errorf("%s: line %q, want %q", name, got, want) + } + } + for _, name := range caseNames(t) { + if strings.Contains(ecosystem.RenderLine(report(name)), "\n") { + t.Errorf("%s: the detect line is one line", name) + } + } +} + +// A non-finite JSON constant is not strict JSON: the three SDKs must call the same +// bytes unreadable (TS JSON.parse and Go's decoder both refuse; Python needs its +// parse_constant hook to agree). +func TestNonFiniteConstantIsUnreadable(t *testing.T) { + for _, bad := range []string{ + `{"dependencies":{"@leji-org/leji":NaN}}`, + `{"packageManager":Infinity}`, + } { + dir := plant(t, map[string]string{"package.json": bad, "package-lock.json": ""}) + report := ecosystem.Detect(dir) + if report.Reason == nil || *report.Reason != "unreadable-manifest" { + t.Errorf("%s should be unreadable-manifest, got %v", bad, report.Reason) + } + } +} + +// Trailing content after the first value is not strict JSON: JSON.parse and +// Python's json.loads both refuse it, so the manifest is unreadable rather than +// read as whatever came first. +func TestTrailingContentIsUnreadable(t *testing.T) { + for _, bad := range []string{ + `{"devDependencies":{"@leji-org/leji":"^1"}} trailing garbage`, + `{"name":"demo"} {"name":"second"}`, + `{"name":"demo"}]`, + `{"name":"demo"} null`, + } { + dir := plant(t, map[string]string{"package.json": bad, "package-lock.json": ""}) + report := ecosystem.Detect(dir) + if report.Reason == nil || *report.Reason != "unreadable-manifest" { + t.Errorf("%q should be unreadable-manifest, got %v", bad, report.Reason) + } + } + // Trailing whitespace and a trailing newline are not content. + for _, fine := range []string{"{\"name\":\"demo\"}\n", " {\"name\":\"demo\"} \n\n"} { + dir := plant(t, map[string]string{"package.json": fine, "package-lock.json": ""}) + if report := ecosystem.Detect(dir); report.Reason != nil { + t.Errorf("%q should parse, got %v", fine, *report.Reason) + } + } +} diff --git a/packages/sdk-go/internal/findings/findings.go b/packages/sdk-go/internal/findings/findings.go index 167c14e..2d71d1e 100644 --- a/packages/sdk-go/internal/findings/findings.go +++ b/packages/sdk-go/internal/findings/findings.go @@ -17,7 +17,14 @@ type Finding struct { Rule string Severity Severity Path string - Message string + // Line is the 1-based line within Path when the rule locates one (the + // rendering lint); 0 when it does not, and then omitted from the JSON. + Line int + // Construct is the closed-token construct a rule names, when it carries one: + // what the three SDKs compare on for `render-unsupported`, message text being + // outside the contract. Empty when the rule names none, and then omitted. + Construct string + Message string // HasPath distinguishes "no path" from "empty-string path" so the emitted // JSON can omit the field, matching Node/Python. HasPath bool @@ -36,7 +43,10 @@ type Summary struct { Warnings int `json:"warnings"` } -// Sort orders findings by (path, rule, message); stable to mirror JS sort. +// Sort orders findings by (path, line, rule, construct), message last as the final +// tie-break; stable to mirror JS sort. The line and construct keys carry the +// rendering lint's ordering — two constructs reported on one line stay in the same +// order in all three SDKs — and change nothing for a rule that locates neither. func Sort(in []Finding) []Finding { out := make([]Finding, len(in)) copy(out, in) @@ -45,9 +55,15 @@ func Sort(in []Finding) []Finding { if a.Path != b.Path { return a.Path < b.Path } + if a.Line != b.Line { + return a.Line < b.Line + } if a.Rule != b.Rule { return a.Rule < b.Rule } + if a.Construct != b.Construct { + return a.Construct < b.Construct + } return a.Message < b.Message }) return out diff --git a/packages/sdk-go/internal/fsx/fsx.go b/packages/sdk-go/internal/fsx/fsx.go index a3ff70b..476354c 100644 --- a/packages/sdk-go/internal/fsx/fsx.go +++ b/packages/sdk-go/internal/fsx/fsx.go @@ -3,54 +3,741 @@ package fsx import ( + "errors" + "io" + "io/fs" "os" "path/filepath" "sort" "strings" + "syscall" + + "github.com/leji-org/leji/packages/sdk-go/internal/layout" ) func ToPosix(p string) string { return filepath.ToSlash(p) } -// ResolvesUnder reports whether abs, after resolving symlinks, stays under root -// (also resolved). A target that does not yet exist is judged by its nearest -// existing ancestor, so a brand-new file under a real root is allowed while a -// path reached through a symlink that escapes root is rejected. -func ResolvesUnder(root, abs string) bool { - // Resolve both operands to absolute first (Node's fs.realpathSync always - // yields absolute paths). EvalSymlinks of a relative path stays relative, so - // without this an absolute realRoot would never prefix-match and every file - // would be excluded. - if a, err := filepath.Abs(root); err == nil { - root = a +// GuardRoot is the repository root as every guard judges it: absolute and +// realpath-resolved, falling back to the absolute spelling when it cannot be +// resolved at all. Both sides of the containment rule must come through the same +// resolver, or a root reached through a symlinked ancestor (/tmp -> /private/tmp) +// compares unequal to its own children and every write under it reads as an escape. +func GuardRoot(root string) string { + abs, err := filepath.Abs(root) + if err != nil { + abs = root + } + if resolved, ok := ResolvedPath(abs); ok { + return resolved + } + return abs +} + +// isNoEntry reports the errors a FOLLOWING stat treats as "nothing is there", which +// is what the reference's `statSync(path, {throwIfNoEntry: false})` returns undefined +// for: the entry is missing (ENOENT), or the path runs through something that is not +// a directory (ENOTDIR), so there is no entry to have a kind. Anything else — a +// permission denial, a symlink loop, an I/O error — is the filesystem failing and +// travels out. The reference's lstat of the ORIGINAL entry is deliberately NOT this +// lenient (it throws ENOTDIR), and neither is this port: a target path that runs +// through a file is a failure, while a LINK that points through one is a standing +// entry this run cannot verify. +func isNoEntry(err error) bool { + return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) +} + +// under reports whether abs is dir or sits underneath it. +func under(dir, abs string) bool { + return abs == dir || strings.HasPrefix(abs, dir+string(filepath.Separator)) +} + +// ResolvedWithinRoot reports whether abs resolves (following symlinks) within +// rootAbs, even when abs does not yet exist: a non-existent target is checked via +// its nearest existing ancestor, so a symlinked ancestor that escapes root is caught +// before a write creates the file under it. It is the ONE within-root primitive, and +// it fails CLOSED — an unresolvable root or target (permission or I/O error, not +// mere absence) is false, never rebuilt from its spelling and allowed. +// +// Both sides come through the same resolver, or a root resolved one way and a child +// the other would differ in spelling alone and read as an escape. Containment is a +// prefix question and re-spelling components cannot move a path out from under its +// prefix, so the first pass compares the paths as the filesystem's symlinks leave +// them; only when that says "outside" — where a case-variant of the ROOT could still +// be hiding a contained path — are both sides read back component by component. The +// content walks run this check per entry, and the reference's native resolver answers +// it without reading a single directory. +func ResolvedWithinRoot(rootAbs, abs string) bool { + root, err := filepath.Abs(rootAbs) + if err != nil { + return false } - if a, err := filepath.Abs(abs); err == nil { + linked, err := filepath.EvalSymlinks(root) + if err != nil { + return false + } + real, ok := resolvedPath(asGiven, abs) + if !ok { + return false + } + if under(linked, real) { + return true + } + // Not under the root as the caller spelled it. That is where a spelling can still + // decide the answer — a link into the tree through a case-variant of the root + // itself — so both sides are canonicalized before the check refuses. + canonical, err := canonicalCase("", linked) + if err != nil { + return false + } + real, ok = ResolvedPathUnder(canonical, abs) + return ok && under(canonical, real) +} + +// realName returns the filesystem's own spelling of name inside dir: name itself +// when the directory holds it verbatim, otherwise the entry that differs from it +// only in case. This is what closes case-variant role aliasing, which Node closes +// with `realpathSync.native`: on a case-insensitive volume `.LEJI/mounts` opens the +// very directory `.leji/mounts` names, yet compares unequal to it as a string, so a +// decision made on the spelling is not a decision about the file. filepath.EvalSymlinks +// hands back the spelling it was given, so the canonical name is read from the +// directory itself. A directory that denies enumeration (permission or I/O) makes the +// canonical spelling unknowable, so the error propagates and the whole path is +// unresolvable: a directory can refuse to be listed while still allowing traversal +// and writes through it, and falling back to the caller's spelling would let a +// `.LEJI/` alias be judged as a location outside the roles it actually opens. Mere +// nonexistence is not a failure — that is the not-yet-created target the caller +// rebuilds lexically. +func realName(dir, name string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return name, nil + } + return "", err + } + folded := "" + for _, e := range entries { + if e.Name() == name { + return name, nil // the volume holds this exact spelling + } + if folded == "" && strings.EqualFold(e.Name(), name) { + folded = e.Name() + } + } + if folded != "" { + return folded, nil + } + return name, nil +} + +// canonicalCase rewrites an existing, symlink-free absolute path with each +// component below `base` spelled as the filesystem holds it. `base` is a prefix +// already known to be canonical — the resolved repository root at every check-before-act call +// site — so the walk stays inside the tree the decision is about instead of reading +// every directory from the filesystem root down. An empty base, or a path outside +// it, canonicalizes from the volume root. The error a component's directory raises +// travels out: a spelling that cannot be read back is not a spelling to decide on. +func canonicalCase(base, abs string) (string, error) { + cur := base + rest := "" + switch { + case base != "" && abs == base: + return base, nil + case base != "" && strings.HasPrefix(abs, base+string(filepath.Separator)): + rest = abs[len(base)+1:] + default: + vol := filepath.VolumeName(abs) + cur = vol + string(filepath.Separator) + rest = strings.TrimPrefix(abs[len(vol):], string(filepath.Separator)) + } + if rest == "" { + return cur, nil + } + for _, seg := range strings.Split(rest, string(filepath.Separator)) { + if seg == "" { + continue + } + real, err := realName(cur, seg) + if err != nil { + return "", err + } + cur = filepath.Join(cur, real) + } + return cur, nil +} + +// spelling is how a resolver spells an existing, symlink-free absolute path: the +// filesystem's own casing (Node's `realpathSync.native`, which every decision about +// a `.leji/` ROLE needs), or the path as given, for the one question whose answer +// cannot depend on a component's spelling. +type spelling func(abs string) (string, error) + +// canonicalUnder is the case-canonical spelling below a prefix already known to be +// canonical. +func canonicalUnder(base string) spelling { + return func(abs string) (string, error) { return canonicalCase(base, abs) } +} + +// asGiven is the spelling the caller supplied. Containment is a prefix question, and +// re-spelling components below the prefix cannot move a path out from under it, so +// the containment primitive asks for this one — which also means a directory that +// refuses enumeration does not make every path beneath it unresolvable, matching the +// reference, whose native resolver reads no directory to answer. +func asGiven(abs string) (string, error) { return abs, nil } + +// nativeRealpath resolves every symlink in abs AND applies spell to the result, the +// two halves of Node's `realpathSync.native`. Either half failing fails the whole +// resolution. +func nativeRealpath(spell spelling, abs string) (string, error) { + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + return spell(resolved) +} + +// ResolvedPath is abs with every symlink in it resolved, and with the filesystem's +// own spelling of each existing component — so a case-variant path on a +// case-insensitive filesystem comes back canonical. A path that does not exist yet +// resolves through its nearest existing ancestor, with the remainder re-appended, +// so a caller can judge a write target before anything is created under it. ok is +// false when even the ancestor cannot be resolved. +// +// Judge with this whenever a decision and the write it guards must be about the +// same path: a lexical comparison answers for the spelling, not for the file. +func ResolvedPath(abs string) (string, bool) { + return ResolvedPathUnder("", abs) +} + +// ResolvedPathUnder is ResolvedPath with a prefix already known to be canonical — +// the resolved repository root, which every check-before-act call site holds — so only the +// components below it are read back from the filesystem. Semantics are identical; +// a path that resolves outside base is canonicalized in full. +func ResolvedPathUnder(base, abs string) (string, bool) { + return resolvedPath(canonicalUnder(base), abs) +} + +// resolvedPath is ResolvedPathUnder over one spelling of the resolved components. +func resolvedPath(spell spelling, abs string) (string, bool) { + // Node's `realpathSync.native` returns an absolute path whatever it is handed, so + // the TS resolver absolutizes inherently; filepath.EvalSymlinks hands a relative + // path back relative, and the canonical-case walk would then re-root it at the + // volume root (`docs/overview.md` → `/docs/overview.md`) — a decision, and the + // write it guards, about a location nobody named. Absolutize against the working + // directory on entry so every caller gets the same contract as TS. An already + // absolute path is left exactly as given (filepath.Abs would also Clean it); when + // there is no working directory to resolve against, the path names no location + // that can be judged, so the check fails rather than guessing. + if !filepath.IsAbs(abs) { + a, err := filepath.Abs(abs) + if err != nil { + return "", false + } abs = a } - realRoot, err := filepath.EvalSymlinks(root) + real, err := nativeRealpath(spell, abs) + if err == nil { + return real, true // path exists (e.g. overwrite target / vendor file) + } + // Only genuine nonexistence is rebuilt lexically from the nearest existing + // ancestor. A permission or I/O error (EACCES, EIO, ELOOP, ENOTDIR, …) means the + // path exists but cannot be resolved: it FAILS the check rather than being + // reconstructed as if it were an absent write target — a resolved decision and + // the write it guards must be about the same real path. + if !os.IsNotExist(err) { + return "", false + } + // A dangling symlink at the final component: EvalSymlinks cannot follow it to a + // missing target, but a write WOULD follow it there, so resolve the link's target + // rather than treating the link's own name as the location — otherwise a symlink + // into a private role reads as its own path and slips the boundary. (EvalSymlinks + // already proved the chain has no loop; a loop is refused as unresolvable above.) + // A missing final component that is not a symlink falls through to the ancestor + // walk, the normal not-yet-created write target. + if st, lerr := os.Lstat(abs); lerr == nil && st.Mode()&os.ModeSymlink != 0 { + return resolvedPath(spell, resolveLink(abs)) + } + // Walk to the nearest existing ancestor. A dangling symlink in an INTERMEDIATE + // component is not "absent": a write would follow it, so follow it here too — + // resolve the link and re-root the remainder onto its target, rather than climbing + // past it and rebuilding the link's own name lexically. Otherwise a nested + // `redirect/export` whose `redirect` dangles into a private role reads as + // `.../redirect/export` (outside `.leji/`) and a target created after the check + // lands the write inside the role — the check/use race this closes. + p := filepath.Dir(abs) + for !Exists(p) && filepath.Dir(p) != p { + st, lerr := os.Lstat(p) + if lerr != nil && !os.IsNotExist(lerr) { + return "", false // p is present but cannot be lstat'd (permission/I/O) + } + if lerr == nil && st.Mode()&os.ModeSymlink != 0 { + rest, rerr := filepath.Rel(p, abs) + if rerr != nil { + return "", false + } + return resolvedPath(spell, filepath.Join(resolveLink(p), rest)) + } + p = filepath.Dir(p) + } + ancestor, err := nativeRealpath(spell, p) if err != nil { - realRoot, _ = filepath.Abs(root) + return "", false + } + rest, err := filepath.Rel(p, abs) + if err != nil { + return "", false + } + return filepath.Join(ancestor, rest), true +} + +// resolveLink reads the symlink at abs and returns its target as an absolute path +// (a relative target resolves against the link's own directory). +func resolveLink(abs string) string { + target, err := os.Readlink(abs) + if err != nil { + return abs + } + if filepath.IsAbs(target) { + return filepath.Clean(target) + } + 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. +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 + } + return layout.WritableTarget(rootAbs, real, ownRoleRel), real, true +} + +// GuardedWrite is the single guarded-write chokepoint (check-before-act). +// Realpath-resolve targetAbs, run layout.WritableTarget on the resolved path, and +// perform the write or clear — through op, on that resolved path — ONLY when the +// target is allowed to land there, which means all of: it resolves at all; it +// resolves INSIDE the repository root, with no exceptions; and it lands outside root +// `.leji/` or inside the one role ownRoleRel names. On refusal nothing is touched: +// the verdict is returned (unresolvable, outside the repository, or the private +// `.leji/` role the target crossed into) so the caller renders the mandated hard +// refusal in its own channel — a generation finding, or a build error — before any +// byte is written. +// +// rootAbs must already be realpath-resolved (GuardRoot). ownRoleRel names the one +// `.leji/` role this write may legitimately land in, or "" when the target has no +// `.leji/` role at all (user content such as overview.md). One home for every write +// whose target derives from user-influenceable input, so a new write site is guarded +// by construction rather than by remembering to guard it — and the guarded +// conveniences below are how command packages reach it, so no command spells a raw +// write primitive of its own. +func GuardedWrite(rootAbs, targetAbs, ownRoleRel string, op func(resolved string) error) (layout.TargetVerdict, error) { + verdict, resolved, ok := judgeTarget(rootAbs, targetAbs, ownRoleRel) + if !verdict.OK || !ok { + return verdict, nil + } + return verdict, op(resolved) +} + +// WriteOptions tunes WriteFileGuarded. Mode is the mode set at creation (0 means +// the ordinary 0o644 every written file carries); Exclusive creates with O_EXCL, so +// a target that already exists comes back as the Exists verdict rather than being +// overwritten or followed through a planted symlink. +type WriteOptions struct { + Mode fs.FileMode + Exclusive bool +} + +// WriteFileGuarded writes bytes to a guarded target, creating its parent directories +// only when the write itself happens (a refused run establishes nothing). +// +// An exclusive create is decided on the ORIGINAL directory entry before anything is +// resolved: ANY standing entry — a regular file, a directory, a symlink whether it +// dangles or not — is Exists. Resolving first would defeat the point, because a +// dangling symlink resolves to its missing destination, and O_EXCL on that +// destination would happily create the file the link points at. Nothing stands there +// ⇒ the resolved path is judged (its parents included) and O_EXCL still closes the +// race between that judgement and the create. +func WriteFileGuarded(rootAbs, targetAbs, ownRoleRel string, bytes []byte, opts WriteOptions) (layout.TargetVerdict, error) { + if opts.Exclusive { + if _, err := os.Lstat(targetAbs); err == nil { + return layout.TargetVerdict{Exists: true}, nil + } else if !os.IsNotExist(err) { + return layout.TargetVerdict{}, err + } + } + mode := opts.Mode + if mode == 0 { + mode = 0o644 } - target := abs - for { - resolved, err := filepath.EvalSymlinks(target) - if err == nil { - target = resolved - break + exists := false + verdict, err := GuardedWrite(rootAbs, targetAbs, ownRoleRel, func(resolved string) error { + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return err } - parent := filepath.Dir(target) - if parent == target { - // Reached the filesystem root without resolving; fall back to abs. - target, _ = filepath.Abs(abs) - break + if !opts.Exclusive { + return os.WriteFile(resolved, bytes, mode) } - target = parent + f, err := os.OpenFile(resolved, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + if os.IsExist(err) { + exists = true + return nil + } + return err + } + if _, werr := f.Write(bytes); werr != nil { + _ = f.Close() + return werr + } + return f.Close() + }) + if exists { + return layout.TargetVerdict{Exists: true}, nil } - if target == realRoot { - return true + return verdict, err +} + +// MkdirpGuarded creates a guarded directory and every missing parent, and hands back +// the RESOLVED path it was created at, so every act that follows works from the path +// the rule judged rather than re-joining its own. real is "" when the verdict refuses. +func MkdirpGuarded(rootAbs, targetAbs, ownRoleRel string) (verdict layout.TargetVerdict, real string, err error) { + verdict, resolved, ok := judgeTarget(rootAbs, targetAbs, ownRoleRel) + if !verdict.OK || !ok { + return verdict, "", nil + } + if err := os.MkdirAll(resolved, 0o755); err != nil { + return verdict, "", err + } + return verdict, resolved, nil +} + +// RmGuarded clears a guarded target: recursive, and absent is success (the +// clean-rebuild form every generator uses). +func RmGuarded(rootAbs, targetAbs, ownRoleRel string) (layout.TargetVerdict, error) { + return GuardedWrite(rootAbs, targetAbs, ownRoleRel, func(resolved string) error { + return os.RemoveAll(resolved) + }) +} + +// RenameGuarded renames with BOTH ends judged before either is touched, so neither +// the source nor the destination can be redirected out of the rule by a planted +// symlink. +func RenameGuarded(rootAbs, fromAbs, toAbs, ownRoleRel string) (layout.TargetVerdict, error) { + fromVerdict, from, ok := judgeTarget(rootAbs, fromAbs, ownRoleRel) + if !fromVerdict.OK || !ok { + return fromVerdict, nil + } + toVerdict, to, ok := judgeTarget(rootAbs, toAbs, ownRoleRel) + if !toVerdict.OK || !ok { + return toVerdict, nil + } + return toVerdict, os.Rename(from, to) +} + +// ChmodGuarded sets the mode of a guarded target. +func ChmodGuarded(rootAbs, targetAbs, ownRoleRel string, mode fs.FileMode) (layout.TargetVerdict, error) { + return GuardedWrite(rootAbs, targetAbs, ownRoleRel, func(resolved string) error { + return os.Chmod(resolved, mode) + }) +} + +// GuardedOpen is a guarded destination opened for writing: the file and the resolved +// path it is bound to, or the refusal verdict. The caller writes into File and closes +// it; the bytes then land in the file the rule judged, never in a path reopened +// afterwards. File is nil exactly when Verdict refuses. +type GuardedOpen struct { + File *os.File + Real string + Verdict layout.TargetVerdict +} + +// OpenWriteGuarded opens a guarded destination for writing (truncating), creating its +// parent directories only when the open actually happens. mode is the creation mode +// (0 means 0o644). +func OpenWriteGuarded(rootAbs, targetAbs, ownRoleRel string, mode fs.FileMode) (GuardedOpen, error) { + verdict, resolved, ok := judgeTarget(rootAbs, targetAbs, ownRoleRel) + if !verdict.OK || !ok { + return GuardedOpen{Verdict: verdict}, nil + } + if mode == 0 { + mode = 0o644 + } + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return GuardedOpen{Verdict: verdict}, err + } + f, err := os.OpenFile(resolved, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return GuardedOpen{Verdict: verdict}, err + } + return GuardedOpen{File: f, Real: resolved, Verdict: verdict}, nil +} + +// WriteFileAtomicGuarded writes a guarded target atomically: a temp sibling in the +// same directory, then a rename onto the destination, so an interrupted write never +// leaves a partial file. Both paths are judged before either is touched — a planted +// `.leji-tmp` symlink would otherwise be written through before the rename — +// and the temp is removed when anything fails, so the whole compound operation lives +// here rather than being re-composed at each call site. +func WriteFileAtomicGuarded(rootAbs, targetAbs, ownRoleRel string, bytes []byte) (layout.TargetVerdict, error) { + tmpVerdict, tmp, ok := judgeTarget(rootAbs, targetAbs+".leji-tmp", ownRoleRel) + if !tmpVerdict.OK || !ok { + return tmpVerdict, nil + } + destVerdict, dest, ok := judgeTarget(rootAbs, targetAbs, ownRoleRel) + if !destVerdict.OK || !ok { + return destVerdict, nil + } + write := func() error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(tmp, bytes, 0o644); err != nil { + return err + } + if err := maybeInjectWriteFailure(); err != nil { + return err + } + return os.Rename(tmp, dest) + } + if err := write(); err != nil { + // Best-effort cleanup; the caller reports the original failure. + _ = os.RemoveAll(tmp) + return destVerdict, err + } + return destVerdict, nil +} + +// maybeInjectWriteFailure is test-only fault injection for WriteFileAtomicGuarded: +// with LEJI_TEST_FAIL_RENAME set, fail after the temp file exists but before the +// rename, to exercise the cleanup and the caller's normalized-error path. +func maybeInjectWriteFailure() error { + if os.Getenv("LEJI_TEST_FAIL_RENAME") != "" { + return errors.New("injected write failure") + } + return nil +} + +// VerifiedSource is an opened source: the file when it passed every check (the +// caller closes it), else nil — with the resolved path, when it could be resolved +// at all, so a refusal can name where the source actually landed. +type VerifiedSource struct { + File *os.File + Real string + // Resolved is false when the path could not be resolved at all. + Resolved bool +} + +// OpenVerifiedSource is the guarded-READ counterpart of GuardedWrite +// (check-before-act), for every source whose bytes are about to be served, linted, or +// exported. Resolve abs, judge the RESOLVED path with allow, then open that path and +// prove the DESCRIPTOR is a regular file with fstat — so the file the check judged is +// the file the read gets. A path-based check leaves two windows open: an ancestor +// directory swapped to a symlink after enumeration (an lstat of the final component +// follows it and reports an ordinary file), and the gap between any check and a later +// read or copy by path. Reading from the descriptor closes both: the inode is pinned +// by the open. +// +// The open itself is by path, so one window survives that: a swap landing between the +// resolve above and the open makes the open follow the new link, and fstat sees only an +// ordinary regular file. So the source is resolved ONCE MORE after the open and the +// descriptor is required to be that same location and that same file identity (os.SameFile, +// the portable (dev, ino) comparison) — the bytes about to be read are then provably the +// ones allow judged. What remains is the recorded check-before-act limit +// (docs/practice/trust-boundary.md): an attacker must swap AND revert within the +// open→recheck span to pass both resolutions. The reference +// implementation states the same limit for the same reason, so this port keeps the +// resolve→open→fstat→recheck order rather than reaching for a platform openat: the +// observable behavior is the contract, and it must be identical in all three SDKs. +// +// The caller closes File when it is non-nil, and owns the refusal semantics — a silent +// drop, a boundary warning, or an error — since only it knows which the source deserves. +// A source that vanished between the check and the open is one such refusal; any other +// I/O error on an allowed path is the filesystem failing rather than the boundary +// refusing, so it is returned as an error, as a read by path always has been. +func OpenVerifiedSource(abs string, allow func(resolved string) bool) (VerifiedSource, error) { + return openVerifiedSourceUnder("", abs, allow) +} + +// openVerifiedSourceUnder is OpenVerifiedSource with a prefix already known to be +// canonical — the resolved repository root the write rule is about — so only the +// components below it are read back from the filesystem. Semantics are identical. +func openVerifiedSourceUnder(base, abs string, allow func(resolved string) bool) (VerifiedSource, error) { + real, ok := ResolvedPathUnder(base, abs) + if !ok { + return VerifiedSource{}, nil + } + if !allow(real) { + return VerifiedSource{Real: real, Resolved: true}, nil + } + f, err := os.Open(real) + if err != nil { + if os.IsNotExist(err) { + return VerifiedSource{Real: real, Resolved: true}, nil // gone between the check and the open + } + return VerifiedSource{Real: real, Resolved: true}, err + } + opened, err := f.Stat() + if err != nil { + _ = f.Close() + return VerifiedSource{Real: real, Resolved: true}, nil + } + if !opened.Mode().IsRegular() { + _ = f.Close() + return VerifiedSource{Real: real, Resolved: true}, nil + } + // The recheck. A refusal names where the source resolves NOW, not where it resolved + // before the swap, so the caller's boundary message points at the role the bytes + // would actually have come from — but only where that is a place at all. The + // recheck path is stat'd FIRST, exactly as the reference implementation does: a + // path that cannot be resolved, or that resolves to a dangling target no stat can + // reach, names no location for a refusal to carry, so the originally allowed path + // comes back and the caller drops the source as silently as it drops a redirect. + // Only a stat that succeeded is compared, on location and on file identity alike. + recheck, ok := ResolvedPathUnder(base, abs) + if !ok { + _ = f.Close() + return VerifiedSource{Real: real, Resolved: true}, nil + } + landed, lerr := os.Stat(recheck) + if lerr != nil { + _ = f.Close() + return VerifiedSource{Real: real, Resolved: true}, nil + } + if recheck != real || !os.SameFile(opened, landed) { + _ = f.Close() + return VerifiedSource{Real: recheck, Resolved: true}, nil + } + return VerifiedSource{File: f, Real: real, Resolved: true}, nil +} + +// ReadStatus is which of the three outcomes a TargetRead carries. +type ReadStatus string + +const ( + // ReadAbsent is nothing standing at the target: the create path. + ReadAbsent ReadStatus = "absent" + // ReadRegular is a verified regular file, its bytes carried along. + ReadRegular ReadStatus = "regular" + // ReadRefused is a standing entry this run refuses to act through. + ReadRefused ReadStatus = "refused" +) + +// RefusalReason is why a TargetRead refused: the resolved target left the +// repository, crossed into another `.leji/` role, is not a regular file, or is a +// standing entry this run could not verify. +type RefusalReason string + +const ( + // RefusedOutsideRoot is a target resolving outside the repository root. + RefusedOutsideRoot RefusalReason = "outside-root" + // RefusedOtherRole is a target resolving into a `.leji/` role this act does not own. + RefusedOtherRole RefusalReason = "other-role" + // RefusedNotRegular is a directory, socket, FIFO, device node, or a link to one. + RefusedNotRegular RefusalReason = "not-regular" + // RefusedUnverifiable is a standing entry no verified descriptor could be proved for. + RefusedUnverifiable RefusalReason = "unverifiable" +) + +// TargetRead is what stood at a read-then-act target, judged by the same rule the +// write will be: nothing (ReadAbsent), a regular file whose verified bytes are +// carried along (ReadRegular), or a standing entry this run refuses to act through +// (ReadRefused, with the reason and — when it resolved at all, Resolved — where it +// resolved). +type TargetRead struct { + Status ReadStatus + Real string + Resolved bool + Bytes []byte + Reason RefusalReason +} + +// VerifiedTargetRead reads a target that is about to be written, under the write rule +// itself: the shape every "look at what is there, then act on it" command needs, so +// none of them re-composes it. +// +// The ORIGINAL directory entry decides the kind first — a socket, a FIFO, a device +// node or a directory standing at the target is refused rather than opened, and a +// symlink is settled on what it resolves TO, because the open would follow it. Then +// OpenVerifiedSource judges the RESOLVED path against layout.WritableTarget for this +// role and proves the descriptor is that same regular file, so the bytes come back +// from the inode the rule cleared. +// +// ReadAbsent is decided on the original entry, never on where it resolves: a dangling +// symlink resolves to a missing destination while the link itself is still standing, +// and a standing entry this run could not verify is ReadRefused/RefusedUnverifiable, +// never a write through it. Operational I/O failures on an allowed path PROPAGATE as +// errors, as a read by path always has; only containment, entry kind, and +// verification become refusals. +func VerifiedTargetRead(rootAbs, targetAbs, ownRoleRel string) (TargetRead, error) { + refused := func(reason RefusalReason) (TargetRead, error) { + real, ok := ResolvedPathUnder(rootAbs, targetAbs) + return TargetRead{Status: ReadRefused, Real: real, Resolved: ok, Reason: reason}, nil + } + entry, lerr := os.Lstat(targetAbs) + if lerr != nil && !os.IsNotExist(lerr) { + return TargetRead{}, lerr + } + if lerr == nil && !entry.Mode().IsRegular() && entry.Mode()&os.ModeSymlink == 0 { + return refused(RefusedNotRegular) + } + if lerr == nil && entry.Mode()&os.ModeSymlink != 0 { + // A symlink is settled on what it resolves TO, because the open follows it: a + // link to a socket would raise exactly the escaping error this check prevents. + // A link that resolves to NO entry — dangling, or through a component that is + // not a directory — has no kind to settle, so it continues and the resolver + // below refuses it as unverifiable. Only a genuine operational failure + // (permission, an I/O error, a symlink loop) travels out. + followed, serr := os.Stat(targetAbs) + if serr != nil && !isNoEntry(serr) { + return TargetRead{}, serr + } + if serr == nil && !followed.Mode().IsRegular() { + return refused(RefusedNotRegular) + } + } + var refusal RefusalReason + src, err := openVerifiedSourceUnder(rootAbs, targetAbs, func(resolved string) bool { + verdict := layout.WritableTarget(rootAbs, resolved, ownRoleRel) + if verdict.OK { + return true + } + if verdict.OutsideRoot { + refusal = RefusedOutsideRoot + } else { + refusal = RefusedOtherRole + } + return false + }) + if err != nil { + return TargetRead{}, err + } + if src.File != nil { + bytes, rerr := io.ReadAll(src.File) + _ = src.File.Close() + if rerr != nil { + return TargetRead{}, rerr + } + return TargetRead{Status: ReadRegular, Real: src.Real, Resolved: true, Bytes: bytes}, nil + } + if refusal != "" { + return TargetRead{Status: ReadRefused, Real: src.Real, Resolved: src.Resolved, Reason: refusal}, nil + } + if !src.Resolved { + return TargetRead{Status: ReadRefused, Reason: RefusedUnverifiable}, nil + } + // Nothing verified was opened, and only ONE thing may follow from that: the target + // is absent. Anything still standing there is a refusal. + if _, err := os.Lstat(targetAbs); err != nil { + if !os.IsNotExist(err) { + return TargetRead{}, err + } + return TargetRead{Status: ReadAbsent, Real: src.Real, Resolved: true}, nil } - return strings.HasPrefix(target, realRoot+string(filepath.Separator)) + return TargetRead{Status: ReadRefused, Real: src.Real, Resolved: true, Reason: RefusedUnverifiable}, nil } func Exists(abs string) bool { @@ -83,7 +770,7 @@ func WalkMd(root, relPath string) []string { if IsFile(abs) { // A declared path that is itself a symlinked file must not escape the // tree, mirroring the per-entry guard in the directory walk below. - if strings.HasSuffix(relPath, ".md") && ResolvesUnder(root, abs) { + if strings.HasSuffix(relPath, ".md") && ResolvedWithinRoot(root, abs) { return []string{ToPosix(relPath)} } return []string{} @@ -108,7 +795,7 @@ func WalkMd(root, relPath string) []string { full := filepath.Join(dir, name) // Exclude entries that resolve outside the repository root via a // symlink, so a walk cannot follow a link out of the tree. - if !ResolvesUnder(root, full) { + if !ResolvedWithinRoot(root, full) { continue } if entry.IsDir() { diff --git a/packages/sdk-go/internal/fsx/guarded_test.go b/packages/sdk-go/internal/fsx/guarded_test.go new file mode 100644 index 0000000..201ad8b --- /dev/null +++ b/packages/sdk-go/internal/fsx/guarded_test.go @@ -0,0 +1,536 @@ +package fsx + +import ( + "net" + "os" + "path/filepath" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/layout" +) + +// The write boundary at its own level: the strict within-root primitive, the rule +// GuardedWrite applies through every convenience, and the verified read that decides +// what is standing at a target before anything acts on it. The canary suite pins the +// same rule through the commands; these pin the mechanism, so a port has a per-case +// oracle rather than an end-to-end one. Mirrors the reference's test/fsx.test.ts. + +// repo is a temp repository root, resolved (macOS hands out /var -> /private/var). +func repo(t *testing.T) string { + t.Helper() + real, ok := ResolvedPath(t.TempDir()) + if !ok { + t.Fatal("the scratch directory must resolve") + } + return real +} + +// outside is a destination outside any repository, for the escape cases. +func outside(t *testing.T) string { + t.Helper() + return repo(t) +} + +func mustWrite(t *testing.T, abs, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } +} + +func mustBeEmpty(t *testing.T, dir, why string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("%s: %v", why, entries) + } +} + +// writeGuarded is WriteFileGuarded with the error channel asserted away, since these +// cases are about verdicts. +func writeGuarded(t *testing.T, root, target, role, body string, opts WriteOptions) layout.TargetVerdict { + t.Helper() + verdict, err := WriteFileGuarded(root, target, role, []byte(body), opts) + if err != nil { + t.Fatalf("WriteFileGuarded(%s): %v", target, err) + } + return verdict +} + +// --- the strict within-root primitive ---------------------------------------- + +func TestResolvedWithinRootExistingAbsentDanglingEscapingCaseVariant(t *testing.T) { + root := repo(t) + mustWrite(t, filepath.Join(root, "file.md"), "x\n") + if !ResolvedWithinRoot(root, filepath.Join(root, "file.md")) { + t.Fatal("an existing file inside root is contained") + } + if !ResolvedWithinRoot(root, filepath.Join(root, "not-yet", "file.md")) { + t.Fatal("a not-yet-created target is contained") + } + + away := outside(t) + mustSymlink(t, filepath.Join(away, "gone.md"), filepath.Join(root, "dangling.md")) + if ResolvedWithinRoot(root, filepath.Join(root, "dangling.md")) { + t.Fatal("a dangling link out of root is not contained") + } + + mustWrite(t, filepath.Join(away, "real.md"), "x\n") + mustSymlink(t, filepath.Join(away, "real.md"), filepath.Join(root, "escape.md")) + if ResolvedWithinRoot(root, filepath.Join(root, "escape.md")) { + t.Fatal("a link resolving out of root is not contained") + } + + if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil { + t.Fatal(err) + } + mustSymlink(t, away, filepath.Join(root, "dir", "up")) + if ResolvedWithinRoot(root, filepath.Join(root, "dir", "up", "new.md")) { + t.Fatal("a symlinked ancestor is not contained") + } + + // A `.LEJI/` spelling on a case-insensitive filesystem resolves to the directory + // the filesystem actually holds, which is what the `.leji/` rule then judges. + if err := os.MkdirAll(layout.Abs(root, layout.DistRel), 0o755); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, ".LEJI")); err == nil { + verdict := writeGuarded(t, root, filepath.Join(root, ".LEJI", "dist", "x.html"), "", "x", WriteOptions{}) + if verdict.OK || verdict.Role != "dist" { + t.Fatalf("a .LEJI/ spelling is judged as the .leji/ role it opens, got %+v", verdict) + } + } +} + +func TestResolvedWithinRootFailsClosedOnAnUnresolvablePath(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: a 0o000 directory is still traversable") + } + root := repo(t) + closed := filepath.Join(root, "closed") + mustWrite(t, filepath.Join(closed, "target.md"), "x\n") + if err := os.Chmod(closed, 0o000); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(closed, 0o700) }() + if _, err := os.Stat(filepath.Join(closed, "target.md")); err == nil { + t.Skip("this platform allows traversal of a 0o000 directory") + } + if ResolvedWithinRoot(root, filepath.Join(closed, "target.md")) { + t.Fatal("unresolvable fails closed") + } + verdict := writeGuarded(t, root, filepath.Join(closed, "target.md"), "", "x", WriteOptions{}) + if verdict.OK || !verdict.Unresolvable { + t.Fatalf("the chokepoint refuses it as unresolvable, got %+v", verdict) + } +} + +// --- the rule, through the conveniences --------------------------------------- + +func TestTheWriteRuleRefusesOutsideTheRepositoryWhateverTheRole(t *testing.T) { + root := repo(t) + away := outside(t) + if err := os.MkdirAll(layout.Abs(root, layout.LejiDir), 0o755); err != nil { + t.Fatal(err) + } + mustSymlink(t, away, layout.Abs(root, layout.DistRel)) + + verdict := writeGuarded(t, root, filepath.Join(layout.Abs(root, layout.DistRel), "index.html"), + layout.DistRel, "x", WriteOptions{}) + if verdict.OK || !verdict.OutsideRoot { + t.Fatalf("an own-role target relocated out of the repository is refused, got %+v", verdict) + } + mustBeEmpty(t, away, "nothing may be written outside") + + cleared, err := RmGuarded(root, layout.Abs(root, layout.DistRel), layout.DistRel) + if err != nil { + t.Fatal(err) + } + if !cleared.OutsideRoot { + t.Fatalf("the clear is refused the same way, got %+v", cleared) + } + if _, err := os.Stat(away); err != nil { + t.Fatalf("the out-of-tree directory still stands: %v", err) + } +} + +func TestTheWriteRuleRefusesAnotherRoleAndPassesItsOwn(t *testing.T) { + root := repo(t) + if err := os.MkdirAll(layout.Abs(root, layout.WorkRel), 0o755); err != nil { + t.Fatal(err) + } + + crossed := writeGuarded(t, root, filepath.Join(layout.Abs(root, layout.WorkRel), "stolen.md"), + layout.DistRel, "x", WriteOptions{}) + if crossed.OK || crossed.Role != "work" { + t.Fatalf("the export role may not write into the work role, got %+v", crossed) + } + if _, err := os.Lstat(filepath.Join(layout.Abs(root, layout.WorkRel), "stolen.md")); err == nil { + t.Fatal("nothing may be written") + } + + own := writeGuarded(t, root, filepath.Join(layout.Abs(root, layout.DistRel), "index.html"), + layout.DistRel, "x", WriteOptions{}) + if !own.OK { + t.Fatalf("its own role passes, got %+v", own) + } + if content := writeGuarded(t, root, filepath.Join(root, "overview.md"), "", "x", WriteOptions{}); !content.OK { + t.Fatalf("ordinary content passes, got %+v", content) + } + + roleless := writeGuarded(t, root, filepath.Join(layout.Abs(root, layout.DistRel), "other.html"), "", "x", WriteOptions{}) + if roleless.OK || roleless.Role != "dist" { + t.Fatalf("content has no legitimate .leji/ landing, got %+v", roleless) + } + + bare := writeGuarded(t, root, filepath.Join(layout.Abs(root, layout.LejiDir), "loose.md"), + layout.DistRel, "x", WriteOptions{}) + if bare.OK || bare.Role != "loose.md" { + t.Fatalf("the role is the first segment under .leji/, got %+v", bare) + } + lejiItself, err := RmGuarded(root, layout.Abs(root, layout.LejiDir), layout.DistRel) + if err != nil { + t.Fatal(err) + } + if lejiItself.OK || lejiItself.Role != "" { + t.Fatalf(".leji/ itself is never the export role, got %+v", lejiItself) + } + if _, err := os.Stat(layout.Abs(root, layout.WorkRel)); err != nil { + t.Fatalf("the trust domain still stands: %v", err) + } +} + +func TestTheWriteRuleCatchesAParentSymlinkedOutOfRoot(t *testing.T) { + root := repo(t) + away := outside(t) + mustSymlink(t, away, filepath.Join(root, "redirect")) + verdict := writeGuarded(t, root, filepath.Join(root, "redirect", "planted.md"), "", "x", WriteOptions{}) + if verdict.OK || !verdict.OutsideRoot { + t.Fatalf("a parent symlinked out of root is refused, got %+v", verdict) + } + mustBeEmpty(t, away, "the parent was not written through") +} + +func TestTheConveniencesExclusiveMkdirpRenameAtomicAndGuardedOpen(t *testing.T) { + root := repo(t) + away := outside(t) + + created := writeGuarded(t, root, filepath.Join(root, "leji.json"), "", "{}\n", WriteOptions{Exclusive: true}) + if !created.OK { + t.Fatalf("an exclusive create of a free name succeeds, got %+v", created) + } + again := writeGuarded(t, root, filepath.Join(root, "leji.json"), "", `{"other":1}`+"\n", WriteOptions{Exclusive: true}) + if again.OK || !again.Exists { + t.Fatalf("an existing target is its own verdict, never an overwrite, got %+v", again) + } + if body, err := os.ReadFile(filepath.Join(root, "leji.json")); err != nil || string(body) != "{}\n" { + t.Fatalf("the bytes are untouched, got %q (%v)", body, err) + } + + madeVerdict, real, err := MkdirpGuarded(root, filepath.Join(layout.Abs(root, layout.DistRel), "content"), layout.DistRel) + if err != nil || !madeVerdict.OK { + t.Fatalf("mkdirp of its own role succeeds, got %+v (%v)", madeVerdict, err) + } + if want := filepath.Join(layout.Abs(root, layout.DistRel), "content"); real != want { + t.Fatalf("the checked resolved path comes back: %q want %q", real, want) + } + + mustSymlink(t, away, filepath.Join(root, "out")) + escaped, _, err := MkdirpGuarded(root, filepath.Join(root, "out", "deep"), "") + if err != nil || escaped.OK { + t.Fatalf("mkdirp is guarded too, got %+v (%v)", escaped, err) + } + mustBeEmpty(t, away, "mkdirp wrote outside the repository") + + renamed, err := RenameGuarded(root, filepath.Join(root, "leji.json"), filepath.Join(root, "out", "leji.json"), "") + if err != nil || renamed.OK { + t.Fatalf("a rename with an escaping destination is refused, got %+v (%v)", renamed, err) + } + if _, err := os.Stat(filepath.Join(root, "leji.json")); err != nil { + t.Fatalf("and the source is still there: %v", err) + } + moved, err := RenameGuarded(root, filepath.Join(root, "leji.json"), filepath.Join(root, "moved.json"), "") + if err != nil || !moved.OK { + t.Fatalf("a contained rename succeeds, got %+v (%v)", moved, err) + } + + atomic, err := WriteFileAtomicGuarded(root, filepath.Join(root, "ci.yml"), "", []byte("jobs:\n")) + if err != nil || !atomic.OK { + t.Fatalf("the atomic write succeeds, got %+v (%v)", atomic, err) + } + if body, err := os.ReadFile(filepath.Join(root, "ci.yml")); err != nil || string(body) != "jobs:\n" { + t.Fatalf("the atomic write landed: %q (%v)", body, err) + } + if _, err := os.Lstat(filepath.Join(root, "ci.yml.leji-tmp")); err == nil { + t.Fatal("the temp sibling is gone") + } + escapedAtomic, err := WriteFileAtomicGuarded(root, filepath.Join(root, "out", "ci.yml"), "", []byte("x")) + if err != nil || escapedAtomic.OK { + t.Fatalf("an escaping atomic destination is refused, got %+v (%v)", escapedAtomic, err) + } + + opened, err := OpenWriteGuarded(root, filepath.Join(layout.Abs(root, layout.DistRel), "assets", "app.css"), + layout.DistRel, 0o644) + if err != nil || opened.File == nil { + t.Fatalf("a guarded open of its own role succeeds, got %+v (%v)", opened.Verdict, err) + } + if _, err := opened.File.WriteString("body{}\n"); err != nil { + t.Fatal(err) + } + if err := opened.File.Close(); err != nil { + t.Fatal(err) + } + if body, err := os.ReadFile(opened.Real); err != nil || string(body) != "body{}\n" { + t.Fatalf("the bytes land in the judged file: %q (%v)", body, err) + } + refusedOpen, err := OpenWriteGuarded(root, filepath.Join(root, "out", "app.css"), "", 0) + if err != nil || refusedOpen.File != nil { + t.Fatalf("an escaping open is refused, got %+v (%v)", refusedOpen.Verdict, err) + } + mustBeEmpty(t, away, "nothing landed outside the repository") +} + +func TestAnExclusiveCreateIsDecidedOnTheStandingEntry(t *testing.T) { + // O_EXCL on the RESOLVED path is not enough: a dangling symlink resolves to its + // missing destination, so resolving first would let `leji.json -> nowhere` create + // the file the link points at. ANY standing entry is Exists, and nothing anywhere + // is created. Mutation that reddens: resolve before the lstat — the dangling cases + // create the link's destination. + root := repo(t) + away := outside(t) + work := layout.Abs(root, layout.WorkRel) + mustWrite(t, filepath.Join(work, "private.json"), "private\n") + target := filepath.Join(root, "leji.json") + const bytes = `{"schemaVersion":"1.0"}` + "\n" + + cases := []struct { + name string + plant func() + landing string + }{ + {"a dangling link to a contained path", func() { + mustSymlink(t, filepath.Join(root, "missing.json"), target) + }, filepath.Join(root, "missing.json")}, + {"a dangling link out of the repository", func() { + mustSymlink(t, filepath.Join(away, "missing.json"), target) + }, filepath.Join(away, "missing.json")}, + {"a link into another role", func() { + mustSymlink(t, filepath.Join(work, "planted.json"), target) + }, filepath.Join(work, "planted.json")}, + {"a link to a standing file in another role", func() { + mustSymlink(t, filepath.Join(work, "private.json"), target) + }, target}, + {"a directory", func() { + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + }, target}, + } + for _, c := range cases { + c.plant() + verdict := writeGuarded(t, root, target, "", bytes, WriteOptions{Exclusive: true}) + if verdict.OK || !verdict.Exists { + t.Fatalf("%s: must be reported as an existing target, got %+v", c.name, verdict) + } + if c.landing != target { + if _, err := os.Lstat(c.landing); err == nil { + t.Fatalf("%s: the link's destination was created", c.name) + } + } + if err := os.RemoveAll(target); err != nil { + t.Fatal(err) + } + } + if body, err := os.ReadFile(filepath.Join(work, "private.json")); err != nil || string(body) != "private\n" { + t.Fatalf("the other role's file was never written through: %q (%v)", body, err) + } + + // A standing regular file is the ordinary case, and its bytes stay as they were. + mustWrite(t, target, "original\n") + overExisting := writeGuarded(t, root, target, "", bytes, WriteOptions{Exclusive: true}) + if !overExisting.Exists { + t.Fatalf("an existing regular file is never overwritten, got %+v", overExisting) + } + if body, err := os.ReadFile(target); err != nil || string(body) != "original\n" { + t.Fatalf("its bytes stand: %q (%v)", body, err) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + + // Nothing standing: the resolved path is judged, its parents included, and created. + if free := writeGuarded(t, root, target, "", bytes, WriteOptions{Exclusive: true}); !free.OK { + t.Fatalf("a free name is created, got %+v", free) + } + if body, err := os.ReadFile(target); err != nil || string(body) != bytes { + t.Fatalf("the bytes landed: %q (%v)", body, err) + } + mustBeEmpty(t, away, "nothing was created outside the repository at any point") + entries, err := os.ReadDir(work) + if err != nil || len(entries) != 1 || entries[0].Name() != "private.json" { + t.Fatalf("nor in another role: %v (%v)", entries, err) + } +} + +func TestARefusedWriteEstablishesNoDirectory(t *testing.T) { + root := repo(t) + if err := os.MkdirAll(layout.Abs(root, layout.WorkRel), 0o755); err != nil { + t.Fatal(err) + } + verdict := writeGuarded(t, root, filepath.Join(layout.Abs(root, layout.WorkRel), "deep", "nested", "x.md"), + layout.DistRel, "x", WriteOptions{}) + if verdict.OK { + t.Fatalf("the crossing write is refused, got %+v", verdict) + } + if _, err := os.Stat(filepath.Join(layout.Abs(root, layout.WorkRel), "deep")); err == nil { + t.Fatal("no parent may be created for a refused write") + } +} + +// --- the verified read --------------------------------------------------------- + +func TestVerifiedTargetReadAbsentRegularDanglingSocketDirectory(t *testing.T) { + root := repo(t) + target := filepath.Join(root, "leji-badge.svg") + + read, err := VerifiedTargetRead(root, target, "") + if err != nil || read.Status != ReadAbsent { + t.Fatalf("nothing standing there is absent, got %+v (%v)", read, err) + } + + mustWrite(t, target, "svg\n") + read, err = VerifiedTargetRead(root, target, "") + if err != nil || read.Status != ReadRegular || string(read.Bytes) != "svg\n" { + t.Fatalf("a regular file comes back with its bytes, got %+v (%v)", read, err) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + + mustSymlink(t, filepath.Join(root, "missing.svg"), target) + read, err = VerifiedTargetRead(root, target, "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedUnverifiable { + t.Fatalf("a standing dangling link is never read as absent, got %+v (%v)", read, err) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + + sock := filepath.Join(root, "sock") + listener, err := net.Listen("unix", sock) + if err != nil { + t.Skipf("unix sockets unavailable here: %v", err) + } + read, err = VerifiedTargetRead(root, sock, "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedNotRegular { + t.Fatalf("a socket is refused on its own kind, got %+v (%v)", read, err) + } + mustSymlink(t, sock, target) + read, err = VerifiedTargetRead(root, target, "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedNotRegular { + t.Fatalf("a link to a socket is settled on what it resolves to, got %+v (%v)", read, err) + } + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + _ = listener.Close() + _ = os.Remove(sock) + + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + read, err = VerifiedTargetRead(root, target, "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedNotRegular { + t.Fatalf("a directory is refused, got %+v (%v)", read, err) + } +} + +func TestVerifiedTargetReadOutsideRootAnotherRoleAndASymlinkedParent(t *testing.T) { + root := repo(t) + away := outside(t) + mustWrite(t, filepath.Join(away, "real.svg"), "svg\n") + + escaping := filepath.Join(root, "escape.svg") + mustSymlink(t, filepath.Join(away, "real.svg"), escaping) + read, err := VerifiedTargetRead(root, escaping, "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedOutsideRoot { + t.Fatalf("a source resolving out of the repository is refused, got %+v (%v)", read, err) + } + + work := layout.Abs(root, layout.WorkRel) + mustWrite(t, filepath.Join(work, "private.svg"), "svg\n") + crossing := filepath.Join(root, "crossing.svg") + mustSymlink(t, filepath.Join(work, "private.svg"), crossing) + read, err = VerifiedTargetRead(root, crossing, "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedOtherRole { + t.Fatalf("a source resolving into another role is refused, got %+v (%v)", read, err) + } + read, err = VerifiedTargetRead(root, crossing, layout.WorkRel) + if err != nil || read.Status != ReadRegular { + t.Fatalf("its own role reads through, got %+v (%v)", read, err) + } + + mustSymlink(t, away, filepath.Join(root, "redirect")) + read, err = VerifiedTargetRead(root, filepath.Join(root, "redirect", "real.svg"), "") + if err != nil || read.Status != ReadRefused || read.Reason != RefusedOutsideRoot { + t.Fatalf("a parent symlinked out of root is refused, got %+v (%v)", read, err) + } +} + +func TestGuardRootResolvesARootReachedThroughASymlinkedAncestor(t *testing.T) { + root := repo(t) + parent := repo(t) + link := filepath.Join(parent, "repo") + mustSymlink(t, root, link) + if got := GuardRoot(link); got != root { + t.Fatalf("both sides of the rule come through one resolver: %q want %q", got, root) + } + if verdict := writeGuarded(t, GuardRoot(link), filepath.Join(link, "x.md"), "", "x", WriteOptions{}); !verdict.OK { + t.Fatalf("a write under the linked root is allowed, got %+v", verdict) + } + if body, err := os.ReadFile(filepath.Join(root, "x.md")); err != nil || string(body) != "x" { + t.Fatalf("and it lands in the resolved root: %q (%v)", body, err) + } +} + +func TestVerifiedTargetReadOnALinkThroughSomethingThatIsNotADirectory(t *testing.T) { + // `link -> somefile/child`, where `somefile` is a regular file: the link stands, + // but following it hits ENOTDIR, so there is no entry to have a kind. The + // reference's following stat returns undefined for that exactly as it does for a + // missing entry, so the read continues and the resolver — which refuses any + // non-ENOENT failure — makes it a standing entry this run could not verify. + // Mutation that reddens: propagate the follow-stat's ENOTDIR — the command reports + // an OS error instead of its own refusal. + root := repo(t) + mustWrite(t, filepath.Join(root, "somefile"), "x\n") + target := filepath.Join(root, "link") + mustSymlink(t, filepath.Join("somefile", "child"), target) + + read, err := VerifiedTargetRead(root, target, "") + if err != nil { + t.Fatalf("a link through a non-directory must not fail the run: %v", err) + } + if read.Status != ReadRefused || read.Reason != RefusedUnverifiable { + t.Fatalf("want refused/unverifiable, got %+v", read) + } + + // The ORIGINAL entry is judged less leniently, exactly as the reference's lstat + // is: a TARGET PATH that itself runs through a file is an operational failure, not + // a standing entry, and it travels out. + if _, derr := VerifiedTargetRead(root, filepath.Join(root, "somefile", "child"), ""); derr == nil { + t.Fatal("a target path running through a file must fail the run") + } +} diff --git a/packages/sdk-go/internal/fsx/openverified_test.go b/packages/sdk-go/internal/fsx/openverified_test.go new file mode 100644 index 0000000..967ea41 --- /dev/null +++ b/packages/sdk-go/internal/fsx/openverified_test.go @@ -0,0 +1,184 @@ +package fsx + +import ( + "io" + "os" + "path/filepath" + "testing" +) + +// The guarded read: what it hands back, and the window it closes. `allow` is called +// after the resolve and before the open, which is exactly the window the post-open +// recheck exists for — so the swap below is performed from inside it, deterministically, +// rather than raced. + +func TestOpenVerifiedSourceReadsAnAllowedSource(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "doc.md") + if err := os.WriteFile(abs, []byte("authorized\n"), 0o644); err != nil { + t.Fatal(err) + } + src, err := OpenVerifiedSource(abs, func(string) bool { return true }) + if err != nil { + t.Fatalf("OpenVerifiedSource: %v", err) + } + if src.File == nil { + t.Fatal("an allowed regular file must open") + } + defer func() { _ = src.File.Close() }() + body, err := io.ReadAll(src.File) + if err != nil || string(body) != "authorized\n" { + t.Fatalf("bytes = %q (%v)", body, err) + } +} + +func TestOpenVerifiedSourceRefusesWhatAllowRejectsAndNamesWhereItLands(t *testing.T) { + // The resolved spelling of the scratch dir, since the assertions below compare + // against what the resolver hands back (on macOS /var is itself a symlink). + dir, ok := ResolvedPath(t.TempDir()) + if !ok { + t.Fatal("the scratch directory must resolve") + } + target := filepath.Join(dir, "private.md") + if err := os.WriteFile(target, []byte("planted\n"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "doc.md") + if err := os.Symlink("private.md", link); err != nil { + t.Fatal(err) + } + src, err := OpenVerifiedSource(link, func(resolved string) bool { return resolved != target }) + if err != nil { + t.Fatalf("OpenVerifiedSource: %v", err) + } + if src.File != nil { + _ = src.File.Close() + t.Fatal("a refused source must not open") + } + // The refusal names where the source actually resolves, so a caller's boundary + // message points at the role the bytes would have come from. + if !src.Resolved || src.Real != target { + t.Fatalf("real = %q (resolved=%v), want %q", src.Real, src.Resolved, target) + } +} + +func TestOpenVerifiedSourceRefusesANonRegularSource(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "adir") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + src, err := OpenVerifiedSource(sub, func(string) bool { return true }) + if err != nil { + t.Fatalf("OpenVerifiedSource: %v", err) + } + if src.File != nil { + _ = src.File.Close() + t.Fatal("only a regular file may be handed back") + } +} + +func TestOpenVerifiedSourceRefusesASwapBetweenTheCheckAndTheOpen(t *testing.T) { + // The residual the descriptor pinning alone leaves: the swap lands AFTER the + // realpath that authorized the source and BEFORE the open on it, so the open + // follows the new link and the descriptor holds planted bytes while every check has + // already passed on the authorized path. fstat cannot see it — the decoy is a + // perfectly ordinary regular file. The recheck after the open resolves the source + // once more and requires the same location AND the same file identity, so the bytes + // about to be read are proved to be the ones `allow` judged. Mutation that reddens: + // drop the recheck and trust fstat alone — the planted bytes are handed back. + dir := t.TempDir() + tree := filepath.Join(dir, "tree") + decoy := filepath.Join(dir, "decoy") + for _, d := range []string{tree, decoy} { + if err := os.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(tree, "doc.md"), []byte("authorized\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(decoy, "doc.md"), []byte("planted\n"), 0o644); err != nil { + t.Fatal(err) + } + + swapped := false + src, err := OpenVerifiedSource(filepath.Join(tree, "doc.md"), func(resolved string) bool { + if !swapped { + swapped = true + if err := os.Rename(tree, filepath.Join(dir, "tree-real")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("decoy", tree); err != nil { + t.Fatal(err) + } + } + return true // authorized on the path as it resolved a moment ago + }) + if err != nil { + t.Fatalf("OpenVerifiedSource: %v", err) + } + if !swapped { + t.Fatal("the ancestor must have been swapped between the check and the open") + } + if src.File != nil { + body, _ := io.ReadAll(src.File) + _ = src.File.Close() + t.Fatalf("a source whose path and descriptor diverged must never be read: got %q", body) + } +} + +func TestOpenVerifiedSourceRefusingADanglingSwapNamesTheAllowedPath(t *testing.T) { + // The other branch of the same window: the swap points the source at a target that + // does not exist. The source is still refused — path and descriptor diverged — but + // there is no location to name, so the refusal carries the ORIGINAL allowed path. + // The reference implementation gets there by stat'ing the recheck path BEFORE + // comparing it, so the stat failure lands in its catch and returns `real`; Python + // does the same. The caller reads that path to decide its refusal semantics, so + // naming the dangling target instead would turn a silent drop into a private-role + // boundary warning naming a role the bytes never came from. Mutation that reddens: + // compare the paths before stat'ing, and the dangling target comes back. + dir, ok := ResolvedPath(t.TempDir()) + if !ok { + t.Fatal("the scratch directory must resolve") + } + target := filepath.Join(dir, "authorized.md") + if err := os.WriteFile(target, []byte("authorized\n"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "doc.md") + if err := os.Symlink("authorized.md", link); err != nil { + t.Fatal(err) + } + + swapped := false + src, err := OpenVerifiedSource(link, func(string) bool { + if !swapped { + swapped = true + // Re-pointed after the resolve authorized it and before the open: the open + // is on the resolved path, so it still succeeds and fstat still sees the + // authorized regular file — only the recheck resolves elsewhere, to a + // target that was never created. + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink("ghost.md", link); err != nil { + t.Fatal(err) + } + } + return true + }) + if err != nil { + t.Fatalf("OpenVerifiedSource: %v", err) + } + if !swapped { + t.Fatal("the source must have been re-pointed between the check and the open") + } + if src.File != nil { + _ = src.File.Close() + t.Fatal("a source whose path and descriptor diverged must never be read") + } + if !src.Resolved || src.Real != target { + t.Fatalf("real = %q (resolved=%v), want the allowed path %q", src.Real, src.Resolved, target) + } +} diff --git a/packages/sdk-go/internal/layer/layer.go b/packages/sdk-go/internal/layer/layer.go index ebb3b9e..682baf2 100644 --- a/packages/sdk-go/internal/layer/layer.go +++ b/packages/sdk-go/internal/layer/layer.go @@ -159,7 +159,7 @@ func collectSelectors(root string, m *manifest.Manifest) ([]*selector, []finding for _, indexRel := range mapping.Indexes { abs := filepath.Join(root, indexRel) text := "" - readable := fsx.IsFile(abs) && fsx.ResolvesUnder(rootAbs, abs) + readable := fsx.IsFile(abs) && fsx.ResolvedWithinRoot(rootAbs, abs) if readable { var err error text, err = fsx.ReadText(abs) @@ -425,13 +425,30 @@ func scanFrontmatterArtifact(text, relPath, schemaName, rule string) ScannedProf return ScannedProfile{RelPath: relPath, Frontmatter: fm.Data, Keys: fm.Keys, Body: fm.Body, Findings: fs} } -func scanFrontmatterArtifacts(root, dir, schemaName, rule string) []ScannedProfile { +// ArtifactReader is how a scan gets one artifact's bytes, and whether it may have +// them at all. The default reads by path; a caller composing something it will serve +// or export passes a reader that binds the check to the read (check-before-act), and returns false +// for a source it refuses — missing, not a regular file, or resolving somewhere it +// may not be read from. A refused artifact is dropped from the scan, exactly as the +// whitelist filter it replaces dropped it, so validation (which passes no reader) is +// unaffected. +type ArtifactReader func(relPath string) (string, bool) + +func scanFrontmatterArtifacts(root, dir, schemaName, rule string, read ArtifactReader) []ScannedProfile { var out []ScannedProfile for _, relPath := range fsx.WalkMd(root, dir) { if strings.ToLower(path.Base(relPath)) == "readme.md" { continue } - text, _ := fsx.ReadText(filepath.Join(root, relPath)) + var text string + if read == nil { + text, _ = fsx.ReadText(filepath.Join(root, relPath)) + } else { + var ok bool + if text, ok = read(relPath); !ok { + continue + } + } out = append(out, scanFrontmatterArtifact(text, relPath, schemaName, rule)) } return out @@ -439,7 +456,7 @@ func scanFrontmatterArtifacts(root, dir, schemaName, rule string) []ScannedProfi func ScanAgentProfiles(root string, m *manifest.Manifest) []ScannedProfile { dir := manifest.EffectiveAgentProfilesPath(m) - return scanFrontmatterArtifacts(root, dir, "agent-profile", "profile-frontmatter") + return scanFrontmatterArtifacts(root, dir, "agent-profile", "profile-frontmatter", nil) } // ScanProfileSet is the profile set inheritance resolves against: the @@ -456,8 +473,15 @@ func ScanAgentProfiles(root string, m *manifest.Manifest) []ScannedProfile { // findings, so ProfileInheritanceFindings collapses the pair rather than // reporting either twice. func ScanProfileSet(root string, m *manifest.Manifest) []ScannedProfile { - profiles := ScanAgentProfiles(root, m) + return ScanProfileSetWith(root, m, nil) +} + +// ScanProfileSetWith is the same scan through a caller's reader — the seam a viewer +// or export needs and nobody else does. A nil reader is ScanProfileSet's own +// read-by-path behavior. +func ScanProfileSetWith(root string, m *manifest.Manifest, read ArtifactReader) []ScannedProfile { dir := manifest.EffectiveAgentProfilesPath(m) + profiles := scanFrontmatterArtifacts(root, dir, "agent-profile", "profile-frontmatter", read) seen := map[string]bool{} for _, p := range profiles { seen[p.RelPath] = true @@ -467,13 +491,21 @@ func ScanProfileSet(root string, m *manifest.Manifest) []ScannedProfile { if seen[rel] || fsx.UnderPath(rel, dir) { continue } - abs := filepath.Join(root, rel) - if !fsx.IsFile(abs) || !fsx.ResolvesUnder(rootAbs, abs) { - continue // missing or escaping: the agents-map check owns that - } - text, err := fsx.ReadText(abs) - if err != nil { - continue + var text string + if read == nil { + abs := filepath.Join(root, rel) + if !fsx.IsFile(abs) || !fsx.ResolvedWithinRoot(rootAbs, abs) { + continue // missing or escaping: the agents-map check owns that + } + var err error + if text, err = fsx.ReadText(abs); err != nil { + continue + } + } else { + var ok bool + if text, ok = read(rel); !ok { + continue + } } seen[rel] = true profiles = append(profiles, scanFrontmatterArtifact(text, rel, "agent-profile", "profile-frontmatter")) @@ -843,7 +875,7 @@ func ReadJSONArtifact(root, relPath string) (any, *findings.Finding) { if !fsx.IsFile(abs) { return nil, nil } - if !fsx.ResolvesUnder(root, abs) { + if !fsx.ResolvedWithinRoot(root, abs) { f := findings.New("artifact-parse", findings.Error, fmt.Sprintf("artifact %s resolves outside the layer root", relPath), relPath) return nil, &f diff --git a/packages/sdk-go/internal/layout/layout.go b/packages/sdk-go/internal/layout/layout.go new file mode 100644 index 0000000..1ccd460 --- /dev/null +++ b/packages/sdk-go/internal/layout/layout.go @@ -0,0 +1,122 @@ +// Package layout holds the unified `.leji/` layout: one tree at the repository +// root holding every role the tool owns, whatever `rootPath` the layer declares. +// Roles are repository-root-relative by construction — a generated artifact never +// lives inside the context root, so the content walk and the served content mount +// carry nothing of the tool's own. +// +// - `mounts/` + `mounts.local.json` — the private federation domain (owned by +// internal/mounts, which spells the paths inside it; never servable, never +// exportable). +// - `viewer/` — generated chrome, the ONE servable role. +// - `dist/` — the default export output. +// - `work/` — the transient onboarding workspace. +package layout + +import ( + "path/filepath" + "strings" +) + +// LejiDir is the unified tree at the repository root. +const LejiDir = ".leji" + +// ViewerRel is the generated viewer chrome (index.html, _sidebar.md, +// _manifest.md, assets/). +const ViewerRel = LejiDir + "/viewer" + +// DistRel is the default export output; the only role a caller-supplied `--out` +// may name. +const DistRel = LejiDir + "/dist" + +// WorkRel is the transient onboarding workspace (brief, proposal, hooks). +const WorkRel = LejiDir + "/work" + +// MountsRel is the private federation domain: managed object stores, projection +// cache, staging. +const MountsRel = LejiDir + "/mounts" + +// 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 { + return filepath.Join(rootAbs, filepath.FromSlash(rel)) +} + +// under reports whether abs is dir or sits underneath it. +func under(dir, abs string) bool { + return abs == dir || strings.HasPrefix(abs, dir+string(filepath.Separator)) +} + +// ServablePath is the servable-roots whitelist: a path may be served or exported +// only when it lies outside root `.leji/` entirely, or inside `.leji/viewer/`. +// Every other role under `.leji/` — the private mounts domain, the export output, +// the onboarding workspace, and any role added later — is denied by name, so a +// new role is born unservable and no relaxation of the dot-segment refusal (kept +// as defense in depth) can open the trust domain as a side effect. +// +// rootAbs must be a resolved (realpath'd) repository root, and abs is judged both +// as requested and after symlink resolution: the name is what decides, not how the +// caller spelled it. +func ServablePath(rootAbs, abs string) bool { + leji := Abs(rootAbs, LejiDir) + if !under(leji, abs) { + return true + } + return under(Abs(rootAbs, ViewerRel), abs) +} + +// LejiRole is the private `.leji/` role a resolved path falls into: the first path +// segment under `.leji/` (`mounts`, `work`, `dist`, `viewer`, or any future role +// name), or "" when the path is `.leji/` itself. Callers establish that abs is +// under `.leji/` before asking; used to name the role in a boundary message. +func LejiRole(rootAbs, abs string) string { + rest, err := filepath.Rel(Abs(rootAbs, LejiDir), abs) + if err != nil || rest == "." { + return "" + } + return strings.Split(rest, string(filepath.Separator))[0] +} + +// TargetVerdict is the verdict of WritableTarget: 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. +type TargetVerdict struct { + OK bool + Role string + Unresolvable bool + OutsideRoot bool + Exists bool +} + +// WritableTarget is the check-before-act rule for a WRITE or CLEAR target, +// judged on the RESOLVED path immediately before the act, in this order: +// +// 1. The target must resolve INSIDE the repository root. Every write this tool +// makes lands in the repository it was pointed at, with no exceptions: a +// `.leji/` role symlinked out of the tree is refused rather than followed. A +// user who wants the export somewhere else copies the finished folder there. +// 2. A target under root `.leji/` is refused — that tree is the tool's own trust +// domain — UNLESS ownRoleRel is given and the target lies under that one role. +// 3. Anything else inside the repository is ordinary content and is allowed. +// +// Both rootAbs and resolvedAbs must be realpath-resolved, so a redirecting symlink +// or a case-variant spelling is judged by where it lands, not by how it was +// written. One home for the rule, called before every write and clear. +// +// ownRoleRel names the ONE `.leji/` role the target may land in, as a lexical path +// under the resolved root; pass "" when the target has no legitimate `.leji/` role +// at all (user content such as overview.md, which lives under the content root, +// never inside `.leji/`) — then any `.leji/` landing is refused. +func WritableTarget(rootAbs, resolvedAbs, ownRoleRel string) TargetVerdict { + if !under(rootAbs, resolvedAbs) { + return TargetVerdict{OutsideRoot: true} + } + if !under(Abs(rootAbs, LejiDir), resolvedAbs) { + return TargetVerdict{OK: true} // inside the repository, outside .leji/ + } + if ownRoleRel != "" && under(Abs(rootAbs, ownRoleRel), resolvedAbs) { + return TargetVerdict{OK: true} // its own role + } + return TargetVerdict{Role: LejiRole(rootAbs, resolvedAbs)} +} diff --git a/packages/sdk-go/internal/manifest/manifest.go b/packages/sdk-go/internal/manifest/manifest.go index 1c8bb2d..0047022 100644 --- a/packages/sdk-go/internal/manifest/manifest.go +++ b/packages/sdk-go/internal/manifest/manifest.go @@ -181,7 +181,7 @@ func LoadManifest(root string) Load { // Confine the read: a symlinked leji.json that resolves outside the layer root // must not be read (an MCP exposes this read to an agent). Mirrors Node's // readTextWithin. - if !fsx.ResolvesUnder(root, abs) { + if !fsx.ResolvedWithinRoot(root, abs) { return Load{Manifest: nil, Findings: []findings.Finding{ findings.New("manifest-parse", findings.Error, Filename+" resolves outside the layer root", Filename), }} diff --git a/packages/sdk-go/internal/manifest/pinspan.go b/packages/sdk-go/internal/manifest/pinspan.go new file mode 100644 index 0000000..239b910 --- /dev/null +++ b/packages/sdk-go/internal/manifest/pinspan.go @@ -0,0 +1,398 @@ +package manifest + +// The mount pin span. Mirrors lib/manifest.ts. +// +// `leji mounts update-pin` moves one declared pin. The agent edits in manifest.go +// anchor on the canonical two-space layout, which the manifest schema does not +// require, so a pin move gets a lexical scanner instead: it walks the document as +// JSON tokens, finds `federation.mounts[i]` whose `name` equals the addressed +// mount, and returns the byte span of THAT object's `pin` string value. Only that +// span is replaced. Nothing is reserialized or normalized, so field order, +// indentation, line endings, escapes, unmodeled keys, and every other byte of the +// file survive untouched. + +import ( + "errors" + "fmt" + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/leji-org/leji/packages/sdk-go/internal/jsonenc" +) + +// pinScanError is a lexical failure: the document is not shaped the way a manifest +// is. Callers turn it into the same "cannot locate" refusal as a missing mount, +// because both mean the same thing operationally — this text has no such pin to +// move. +type pinScanError struct{ msg string } + +func (e pinScanError) Error() string { return e.msg } + +// pinAmbiguityError is a duplicate key on the path to the pin. JSON does not forbid +// one, and the two readers of this document disagree about which wins: a lexical +// scan takes the FIRST member, a parser keeps the LAST. So a manifest carrying two +// `pin` keys on the addressed mount could have its first span rewritten while the +// pin every parser reads stays exactly as it was — a reported change that changed +// nothing. The scanner refuses that document instead of picking a winner, and this +// error carries its own message out rather than collapsing into "cannot locate". +type pinAmbiguityError struct{ msg string } + +func (e pinAmbiguityError) Error() string { return e.msg } + +// quoteJSONString renders s the way Node's JSON.stringify(s) does, which is how +// every message below spells a key or a name. +func quoteJSONString(s string) string { + b, err := jsonenc.Marshal(s) + if err != nil { + return `"` + s + `"` + } + return string(b) +} + +// jsonMember is one object member: its decoded key and the index of its value. +type jsonMember struct { + key string + valueAt int +} + +// uniqueMember returns the one member named key, or nil when there is none. Two or +// more is refused: every key this scanner reads sits on the path to the pin, so an +// ambiguous one makes the whole edit ambiguous. +func uniqueMember(members []jsonMember, key, where string) (*jsonMember, error) { + var found *jsonMember + for i := range members { + if members[i].key != key { + continue + } + if found != nil { + return nil, pinAmbiguityError{fmt.Sprintf("duplicate key %s %s", quoteJSONString(key), where)} + } + found = &members[i] + } + return found, nil +} + +// skipJSONWs returns the index of the first character at or after i that is not +// JSON whitespace. +func skipJSONWs(text string, i int) int { + for i < len(text) && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r') { + i++ + } + return i +} + +// jsonString is one scanned JSON string: its decoded value (escapes resolved, for +// comparison only) and the span of its RAW contents between the quotes, which is +// the only thing an edit ever replaces. +type jsonString struct { + value string + contentStart int + end int +} + +// hexUnit reads the four hex digits of a \uXXXX escape at off. +func hexUnit(text string, off int) (rune, bool) { + if off+4 > len(text) { + return 0, false + } + v := 0 + for i := off; i < off+4; i++ { + c := text[i] + switch { + case c >= '0' && c <= '9': + v = v*16 + int(c-'0') + case c >= 'a' && c <= 'f': + v = v*16 + int(c-'a') + 10 + case c >= 'A' && c <= 'F': + v = v*16 + int(c-'A') + 10 + default: + return 0, false + } + } + return rune(v), true +} + +// scanJSONString scans one JSON string starting at the opening quote. +func scanJSONString(text string, i int) (jsonString, error) { + if i >= len(text) || text[i] != '"' { + return jsonString{}, pinScanError{"expected a string"} + } + contentStart := i + 1 + var out strings.Builder + j := contentStart + for j < len(text) { + c := text[j] + if c == '"' { + return jsonString{value: out.String(), contentStart: contentStart, end: j + 1}, nil + } + if c != '\\' { + out.WriteByte(c) + j++ + continue + } + if j+1 >= len(text) { + break + } + esc := text[j+1] + j += 2 + switch esc { + case '"', '\\', '/': + out.WriteByte(esc) + case 'b': + out.WriteByte('\b') + case 'f': + out.WriteByte('\f') + case 'n': + out.WriteByte('\n') + case 'r': + out.WriteByte('\r') + case 't': + out.WriteByte('\t') + case 'u': + unit, ok := hexUnit(text, j) + if !ok { + return jsonString{}, pinScanError{`malformed \u escape`} + } + j += 4 + // A surrogate PAIR spelled as two escapes reassembles into its astral + // character by the same rule the parser uses, so an escaped name compares + // equal to a raw one. An unpaired surrogate cannot be carried in a Go + // string, and decodes to U+FFFD exactly as every parser of these bytes + // would give the caller. + if utf16.IsSurrogate(unit) { + if lo, ok := hexUnit(text, j+2); ok && j+1 < len(text) && text[j] == '\\' && text[j+1] == 'u' { + if r := utf16.DecodeRune(unit, lo); r != utf8.RuneError { + out.WriteRune(r) + j += 6 + break + } + } + out.WriteRune(utf8.RuneError) + break + } + out.WriteRune(unit) + default: + return jsonString{}, pinScanError{"unknown escape"} + } + } + return jsonString{}, pinScanError{"unterminated string"} +} + +// skipJSONValue returns the index just past the value beginning at i, whatever it +// is. Objects and arrays are skipped STRUCTURALLY (nesting counted through their +// own members), so a `pin` key inside some unrelated nested object is never +// mistaken for a mount's. +func skipJSONValue(text string, i int) (int, error) { + i = skipJSONWs(text, i) + if i >= len(text) { + return 0, pinScanError{"expected a value"} + } + c := text[i] + if c == '"' { + s, err := scanJSONString(text, i) + if err != nil { + return 0, err + } + return s.end, nil + } + if c == '{' || c == '[' { + closer := byte('}') + if c == '[' { + closer = ']' + } + j := i + 1 + for { + j = skipJSONWs(text, j) + if j >= len(text) { + return 0, pinScanError{"unterminated container"} + } + if text[j] == closer { + return j + 1, nil + } + if text[j] == ',' || text[j] == ':' { + j++ + continue + } + next, err := skipJSONValue(text, j) + if err != nil { + return 0, err + } + j = next + } + } + // A literal or a number: everything up to the next structural character. + j := i + for j < len(text) && !strings.ContainsRune(" \t\n\r,}]", rune(text[j])) { + j++ + } + if j == i { + return 0, pinScanError{"expected a value"} + } + return j, nil +} + +// jsonMembers returns each member of the object beginning at i, as (decoded key, +// index of its value), plus the index just past the object. +func jsonMembers(text string, i int) ([]jsonMember, int, error) { + i = skipJSONWs(text, i) + if i >= len(text) || text[i] != '{' { + return nil, 0, pinScanError{"expected an object"} + } + var members []jsonMember + j := i + 1 + for { + j = skipJSONWs(text, j) + if j >= len(text) { + return nil, 0, pinScanError{"unterminated object"} + } + if text[j] == '}' { + return members, j + 1, nil + } + if text[j] == ',' { + j++ + continue + } + key, err := scanJSONString(text, j) + if err != nil { + return nil, 0, err + } + j = skipJSONWs(text, key.end) + if j >= len(text) || text[j] != ':' { + return nil, 0, pinScanError{`expected ":"`} + } + valueAt := skipJSONWs(text, j+1) + members = append(members, jsonMember{key: key.value, valueAt: valueAt}) + next, err := skipJSONValue(text, valueAt) + if err != nil { + return nil, 0, err + } + j = next + } +} + +// pinSpan is the raw span of a mount's `pin` value and the value it holds. +type pinSpan struct { + value string + contentStart int + contentEnd int +} + +// findMountPinSpan returns the raw span of `federation.mounts[i].pin` for the mount +// named name, with the value the span currently holds. found is false when there is +// no such mount, or no `pin` on it. +func findMountPinSpan(text, name string) (span pinSpan, found bool, err error) { + rootMembers, _, err := jsonMembers(text, 0) + if err != nil { + return pinSpan{}, false, err + } + federation, err := uniqueMember(rootMembers, "federation", "in the manifest root") + if err != nil || federation == nil { + return pinSpan{}, false, err + } + federationMembers, _, err := jsonMembers(text, federation.valueAt) + if err != nil { + return pinSpan{}, false, err + } + mountsKey, err := uniqueMember(federationMembers, "mounts", `in "federation"`) + if err != nil || mountsKey == nil { + return pinSpan{}, false, err + } + i := skipJSONWs(text, mountsKey.valueAt) + if i >= len(text) || text[i] != '[' { + return pinSpan{}, false, pinScanError{"expected an array"} + } + i++ + for { + i = skipJSONWs(text, i) + if i >= len(text) { + return pinSpan{}, false, pinScanError{"unterminated array"} + } + if text[i] == ']' { + return pinSpan{}, false, nil + } + if text[i] == ',' { + i++ + continue + } + if text[i] != '{' { + next, err := skipJSONValue(text, i) + if err != nil { + return pinSpan{}, false, err + } + i = next + continue + } + entryMembers, entryEnd, err := jsonMembers(text, i) + if err != nil { + return pinSpan{}, false, err + } + // A mount whose own name is ambiguous cannot be told apart from the addressed + // one, so the document is refused before any element is matched. + nameMember, err := uniqueMember(entryMembers, "name", "in a federation mount") + if err != nil { + return pinSpan{}, false, err + } + matched := false + if nameMember != nil && nameMember.valueAt < len(text) && text[nameMember.valueAt] == '"' { + s, err := scanJSONString(text, nameMember.valueAt) + if err != nil { + return pinSpan{}, false, err + } + matched = s.value == name + } + if matched { + pinMember, err := uniqueMember(entryMembers, "pin", "in mount "+quoteJSONString(name)) + if err != nil { + return pinSpan{}, false, err + } + if pinMember == nil { + return pinSpan{}, false, nil + } + if pinMember.valueAt >= len(text) || text[pinMember.valueAt] != '"' { + return pinSpan{}, false, pinScanError{"pin is not a string"} + } + pin, err := scanJSONString(text, pinMember.valueAt) + if err != nil { + return pinSpan{}, false, err + } + return pinSpan{value: pin.value, contentStart: pin.contentStart, contentEnd: pin.end - 1}, true, nil + } + i = entryEnd + } +} + +// ReplaceMountPinInManifestText moves one declared mount's pin, in place. `from` is +// what the span must currently hold — the value the comparison was computed against +// — so a manifest that moved underneath the run is refused rather than overwritten. +// Everything outside the pin value's own bytes is returned exactly as it came in. +// +// The error is returned when the pin cannot be located, or holds something other +// than `from`. Both are internal refusals after the manifest has already parsed and +// validated. +func ReplaceMountPinInManifestText(text, name, from, to string) (out string, changed bool, err error) { + span, found, err := findMountPinSpan(text, name) + if err != nil { + // An ambiguous document is refused on its own terms; a merely malformed one + // is the same answer as a mount that is not there. + var ambiguity pinAmbiguityError + if errors.As(err, &ambiguity) { + return "", false, fmt.Errorf("%s: %s", Filename, ambiguity.msg) + } + var scan pinScanError + if !errors.As(err, &scan) { + return "", false, err + } + found = false + } + if !found { + return "", false, fmt.Errorf("%s: cannot locate the pin of mount %s", Filename, quoteJSONString(name)) + } + if span.value != from { + return "", false, fmt.Errorf("%s: pin of mount %s is not %s", Filename, quoteJSONString(name), quoteJSONString(from)) + } + if from == to { + return text, false, nil + } + return text[:span.contentStart] + to + text[span.contentEnd:], true, nil +} diff --git a/packages/sdk-go/internal/manifest/pinspan_test.go b/packages/sdk-go/internal/manifest/pinspan_test.go new file mode 100644 index 0000000..14a57a5 --- /dev/null +++ b/packages/sdk-go/internal/manifest/pinspan_test.go @@ -0,0 +1,189 @@ +// The pin-span scanner over its own byte fixtures (`fixtures/manifest-pin-span/`, +// documented in fixtures/README.md). Mirrors the first section of +// packages/sdk/test/update-pin.test.ts; the fixtures are the byte oracle all three +// SDKs answer to. +package manifest_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/leji-org/leji/packages/sdk-go/internal/manifest" +) + +type pinSpanCase struct { + Note string `json:"note"` + Mount string `json:"mount"` + From string `json:"from"` + To string `json:"to"` + Outcome string `json:"outcome"` + Error string `json:"error"` +} + +func pinSpanDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Join(wd, "..", "..", "..", "..", "fixtures", "manifest-pin-span") +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestManifestPinSpanFixtures(t *testing.T) { + dir := pinSpanDir(t) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + ran := 0 + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + caseDir := filepath.Join(dir, name) + var spec pinSpanCase + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(caseDir, "case.json"))), &spec); err != nil { + t.Fatalf("%s: case.json: %v", name, err) + } + ran++ + t.Run(name, func(t *testing.T) { + input := readFile(t, filepath.Join(caseDir, "input.json")) + if spec.Outcome == "error" { + _, _, err := manifest.ReplaceMountPinInManifestText(input, spec.Mount, spec.From, spec.To) + if err == nil { + t.Fatalf("%s: expected the %s refusal", name, spec.Error) + } + want := map[string]string{ + "not-located": "cannot locate the pin of mount", + "not-from": "is not", + "duplicate-key": "duplicate key", + }[spec.Error] + if want == "" || !strings.Contains(err.Error(), want) { + t.Fatalf("%s: %s refusal, got %q", name, spec.Error, err.Error()) + } + return + } + expected := readFile(t, filepath.Join(caseDir, "expected.json")) + got, changed, err := manifest.ReplaceMountPinInManifestText(input, spec.Mount, spec.From, spec.To) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !changed { + t.Fatalf("%s: the span moved", name) + } + if got != expected { + t.Fatalf("%s: byte-exact output\n got=%q\nwant=%q", name, got, expected) + } + // Every case is a real manifest before and after: the edit never produces + // something a parser would reject. + var parsed any + if err := json.Unmarshal([]byte(got), &parsed); err != nil { + t.Fatalf("%s: the result must still parse: %v", name, err) + } + // And the edit is confined: exactly the pin's own characters differ. + if len(got) != len(input)+len(spec.To)-len(spec.From) { + t.Fatalf("%s: the edit is confined to the pin span", name) + } + }) + } + if ran == 0 { + t.Fatal("no manifest-pin-span fixtures found") + } +} + +func TestManifestPinSpanDuplicateKeyIsRefusedNeverResolved(t *testing.T) { + // The two readers of this document disagree: a lexical scan takes the FIRST + // member, a parser keeps the LAST. Rewriting the first span would report a + // change that every parser of the result still reads as the old pin. + dir := pinSpanDir(t) + input := readFile(t, filepath.Join(dir, "error-duplicate-pin", "input.json")) + var spec pinSpanCase + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "error-duplicate-pin", "case.json"))), &spec); err != nil { + t.Fatal(err) + } + var parsed struct { + Federation struct { + Mounts []struct { + Pin string `json:"pin"` + } `json:"mounts"` + } `json:"federation"` + } + if err := json.Unmarshal([]byte(input), &parsed); err != nil { + t.Fatal(err) + } + if parsed.Federation.Mounts[0].Pin == spec.From { + t.Fatal("the parser reads the LAST pin, which is not the span a scan finds first") + } + _, _, err := manifest.ReplaceMountPinInManifestText(input, spec.Mount, spec.From, spec.To) + if err == nil || !strings.Contains(err.Error(), `duplicate key "pin" in mount "product-context"`) { + t.Fatalf("duplicate pin refusal, got %v", err) + } + // Every key the scanner reads on its way to the pin carries the same rule. + for _, c := range []struct{ fixture, message string }{ + {"error-duplicate-federation", `duplicate key "federation" in the manifest root`}, + {"error-duplicate-mounts", `duplicate key "mounts" in "federation"`}, + {"error-duplicate-name", `duplicate key "name" in a federation mount`}, + } { + text := readFile(t, filepath.Join(dir, c.fixture, "input.json")) + _, _, err := manifest.ReplaceMountPinInManifestText(text, "product-context", spec.From, spec.To) + if err == nil || !strings.Contains(err.Error(), c.message) { + t.Fatalf("%s: want %q, got %v", c.fixture, c.message, err) + } + } +} + +func TestManifestPinSpanMovesOnlyTheAddressedMount(t *testing.T) { + dir := filepath.Join(pinSpanDir(t), "shared-prefix") + input := readFile(t, filepath.Join(dir, "input.json")) + var spec pinSpanCase + if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "case.json"))), &spec); err != nil { + t.Fatal(err) + } + type doc struct { + Federation struct { + Mounts []struct { + Name string `json:"name"` + Pin string `json:"pin"` + } `json:"mounts"` + } `json:"federation"` + } + moved, _, err := manifest.ReplaceMountPinInManifestText(input, spec.Mount, spec.From, spec.To) + if err != nil { + t.Fatal(err) + } + var before, after doc + if err := json.Unmarshal([]byte(input), &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(moved), &after); err != nil { + t.Fatal(err) + } + // The neighbouring mount's pin is untouched by the move above it. + if after.Federation.Mounts[0].Pin != before.Federation.Mounts[0].Pin { + t.Fatal("the neighbouring mount's pin moved") + } + if after.Federation.Mounts[1].Pin == before.Federation.Mounts[1].Pin { + t.Fatal("the addressed mount's pin did not move") + } + // `from == to` is a no-op the caller can rely on, not a rewrite of equal bytes. + same, changed, err := manifest.ReplaceMountPinInManifestText(input, spec.Mount, spec.From, spec.From) + if err != nil { + t.Fatal(err) + } + if changed || same != input { + t.Fatal("a no-op move must report changed=false and return the input") + } +} diff --git a/packages/sdk-go/internal/mounts/mounts.go b/packages/sdk-go/internal/mounts/mounts.go index 3beeb63..7ad96bf 100644 --- a/packages/sdk-go/internal/mounts/mounts.go +++ b/packages/sdk-go/internal/mounts/mounts.go @@ -38,6 +38,7 @@ import ( "unicode/utf8" "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/manifest" "github.com/leji-org/leji/packages/sdk-go/internal/schemas" ) @@ -235,13 +236,35 @@ func CacheKeyFor(sourceIdentity, pin string) string { // MountsDir is the resolver-owned cache root under the host layer. func MountsDir(root string) string { - return filepath.Join(root, ".leji", "mounts") + return layout.Abs(root, layout.MountsRel) +} + +// establishMountsDir establishes one mounts DESTINATION — a managed store, a cache +// entry, a staging directory — through the write chokepoint, and hands back the +// RESOLVED directory it was created at. ok is false when the rule refuses it: a +// planted `.leji/mounts` symlink into another role or out of the repository is caught +// here, once, instead of being followed by every per-entry write underneath. +// +// The per-entry protocol elsewhere in this package (hashed identities, contained +// relative paths, the symlink-escape rules, publish-by-rename) is the declared +// 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) { + verdict, real, err := fsx.MkdirpGuarded(fsx.GuardRoot(root), dirAbs, layout.MountsRel) + if err != nil { + return "", false, err + } + if !verdict.OK { + return "", false, nil + } + return real, true, nil } // readTextWithin mirrors Node's readTextWithin: nil (ok=false) unless abs is a // regular file that resolves inside root. func readTextWithin(root, abs string) (string, bool) { - if !fsx.IsFile(abs) || !fsx.ResolvesUnder(root, abs) { + if !fsx.IsFile(abs) || !fsx.ResolvedWithinRoot(root, abs) { return "", false } text, err := fsx.ReadText(abs) @@ -536,74 +559,114 @@ func FindObjectSource(root string, mount MountDecl, sourceIdentity string) Objec return ObjectSource{Ambiguous: ambiguous} } -// FetchIntoStore fetches the pin and refreshes 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. 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) { - failed := func(msg string) (string, bool, string, error) { - return "", false, msg, nil +// retentionInjectedFailure is test-only fault injection for RetainPinInStore: with +// LEJI_TEST_FAIL_PIN_REF set to a commit id, retaining exactly that commit fails at +// the ref. It exists because the TARGET-retention refusal has no other reachable +// path — by the time the target is retained, the comparison repository IS the +// managed store and already holds the commit, so the fetch never runs and only the +// ref update can fail. +func retentionInjectedFailure(oid string) bool { + return os.Getenv("LEJI_TEST_FAIL_PIN_REF") == oid +} + +// RetainPinInStore establishes the managed store and retains 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 caller that needs more than one commit retained pays exactly one +// round trip per commit and no extra observation of a moving ref. +// +// The declared pin and an explicitly named target are both retained through this, +// 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) { + failed := func(msg string) (string, string, error) { + return "", msg, nil } // The locator becomes argv here: anything option-shaped is refused, never passed. if strings.HasPrefix(mount.Source, "-") { return failed(`the source locator may not begin with "-"`) } - store := storeDir(root, sourceIdentity) + store, ok, err := establishMountsDir(root, storeDir(root, sourceIdentity)) + if err != nil { + return "", "", err + } + if !ok { + return failed("the managed store could not be initialized") + } if !isGitRepo(store) { - if err := os.MkdirAll(store, 0o755); err != nil { - return "", false, "", err - } if !RunGit([]string{"init", "--bare", "-q", store}, "").OK { return failed("the managed store could not be initialized") } } - // The pin is immutable: a store that already holds it needs no round trip. The - // declared pin is resolved directly, never read back out of FETCH_HEAD, so the - // fetch has no reason to write one and races with a concurrent fetch. - if !hasCommit(store, mount.Pin) { + // A commit id is immutable: a store that already holds it needs no round trip. + // The id is resolved directly, never read back out of FETCH_HEAD, so the fetch + // has no reason to write one and races with a concurrent fetch. + if !hasCommit(store, oid) { fetch := RunGit([]string{ "-C", store, "-c", "fetch.recurseSubmodules=no", "fetch", "-q", "--no-write-fetch-head", - mount.Source, mount.Pin, + mount.Source, oid, }, "") if !fetch.OK { return failed("the pin could not be fetched from the source") } } - // Retain the pin by a ref of our own: without it, git maintenance may prune the + // Retain it by a ref of our own: without it, git maintenance may prune the // version of record. - pinOid := revOid(store, mount.Pin) + pinOid := revOid(store, oid) if pinOid == "" { return failed("fetched, but the pin is not reachable") } - if !RunGit([]string{"-C", store, "update-ref", PinRefFor(sourceIdentity, pinOid), pinOid}, "").OK { + if retentionInjectedFailure(pinOid) || + !RunGit([]string{"-C", store, "update-ref", PinRefFor(sourceIdentity, pinOid), pinOid}, "").OK { return failed("the pin could not be retained by a ref in the managed store") } + return store, "", nil +} + +// FetchIntoStore fetches the pin and refreshes 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. 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) + if err != nil || store == "" { + 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) { + if !RefreshWitness(store, mount, sourceIdentity) { return store, true, "", nil } } return store, false, "", nil } -// refreshWitness refreshes the managed witness ref: fetch the tracking ref to a +// RefreshWitness refreshes 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. -func refreshWitness(store string, mount MountDecl, sourceIdentity string) bool { +func RefreshWitness(store string, mount MountDecl, sourceIdentity string) bool { witnessRef := WitnessRefFor(sourceIdentity, mount.TrackingRef) tempRef := fmt.Sprintf("%s/tmp/%d-%s", WitnessRefNamespace, os.Getpid(), randomHex(8)) spec := "+" + mount.TrackingRef + ":" + tempRef - fetch := RunGit([]string{"-C", store, "-c", "fetch.recurseSubmodules=no", "fetch", "-q", mount.Source, spec}, "") + // `--no-write-fetch-head` for the same reason retention passes it: the ref this + // fetch cares about is the temporary one in the refspec, and a FETCH_HEAD left + // behind is a per-run path recorded inside the managed store. + fetch := RunGit([]string{ + "-C", store, + "-c", "fetch.recurseSubmodules=no", + "fetch", "-q", "--no-write-fetch-head", + mount.Source, spec, + }, "") tip := "" if fetch.OK { tip = refOid(store, tempRef) @@ -1589,7 +1652,9 @@ func TrackedCacheFiles(root string) []string { return out } -func nowISO() string { +// NowISO is the observation clock every mount surface stamps with: UTC, millisecond +// precision, the same spelling Node's toISOString() writes. +func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") } @@ -1679,11 +1744,22 @@ func HydrateMounts(root string, m *manifest.Manifest, opts HydrateOptions) (Hydr continue } // Staged inside the entry's own directory, so publication is a rename on one - // filesystem, and under a per-process name, so no two producers collide. - staging := filepath.Join(cacheDir, ".staging-"+stagingToken()) - if err := os.MkdirAll(staging, 0o755); err != nil { + // 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())) + if err != nil { return HydrateResult{}, err } + if !staged { + outcomes = append(outcomes, outcome(HydrateOutcome{ + Name: mount.Name, + Status: "error", + Detail: "the cache entry destination could not be established", + })) + continue + } + cacheEntryDir := filepath.Dir(staging) projected, err := ExtractProjection(src.Repo, mount.Pin, staging) if err != nil { return HydrateResult{}, err @@ -1734,12 +1810,12 @@ func HydrateMounts(root string, m *manifest.Manifest, opts HydrateOptions) (Hydr metadata.set("siblingName", nil) } metadata.set("completionState", "complete") - metadata.set("hydratedAt", nowISO()) + metadata.set("hydratedAt", NowISO()) var buf bytes.Buffer metadata.encodeIndent(&buf, "", " ") buf.WriteByte('\n') // The whole tree is extracted and validated before it is publishable. - status, detail, perr := publishCacheEntry(cacheDir, staging, buf.Bytes()) + status, detail, perr := publishCacheEntry(cacheEntryDir, staging, buf.Bytes()) if perr != nil { return HydrateResult{}, perr } @@ -1770,8 +1846,10 @@ func contains(list []string, s string) bool { // VerifyProjection verifies a cached projection against a reachable object // store: every projected file's bytes and mode against the pinned tree. Returns -// nil when no object store is reachable (unverifiable), true/false otherwise. -// The error return carries filesystem failures (TS exceptions). +// nil when a prerequisite for verifying is unavailable — no reachable object +// store, an unresolvable pin, no writable temp dir — leaving the projection +// unverified rather than judged; true/false otherwise. The error return carries +// filesystem failures (TS exceptions). func VerifyProjection(root string, mount MountDecl) (*bool, error) { f := false identity, idOK := NormalizeSource(mount.Source) @@ -1792,12 +1870,16 @@ func VerifyProjection(root string, mount MountDecl) (*bool, error) { return nil, nil } commit := strings.TrimSpace(string(commitR.Stdout)) - staging := filepath.Join(MountsDir(root), fmt.Sprintf("verify-%d", os.Getpid())) - if err := os.RemoveAll(staging); err != nil { - return nil, err - } - if err := os.MkdirAll(staging, 0o755); err != nil { - return nil, err + // Staging happens outside the host: verifying is a read-only question, so asking + // it must not write into the tree being asked about (a read-only or shared + // checkout could not answer otherwise). The name is allocated, never constructed + // and pre-deleted: a guessed path is a path a concurrent verification is already + // using, and deleting it is how one run made another fail. Cleanup is installed + // the moment allocation succeeds. A failed allocation is one more unavailable + // prerequisite — unverifiable, never an error and never an in-tree fallback. + staging, err := os.MkdirTemp("", "leji-verify-*") + if err != nil { + return nil, nil } defer os.RemoveAll(staging) projected, err := ExtractProjection(src.Repo, commit, staging) @@ -1982,11 +2064,169 @@ func LocateMount(root string, m *manifest.Manifest, name string) (LocateResult, result.Path = &projDir } if present && !verified { - result.Detail = "projection present but not verified against a reachable object store" + result.Detail = "projection present but not verified: it does not match its pin, or verification prerequisites are unavailable" } return result, nil } +// ComparisonSelection is which repository answers a pin comparison, and the ONE +// witness snapshot it answered with. Reason is the degraded alternative: a stable +// status code and nothing selected. +type ComparisonSelection struct { + Repo string + ComparisonRepository string + WitnessProvenance string + ComparedRef string + TipOid string + // Reason is non-empty exactly when nothing was selected. + Reason string +} + +// SelectComparison resolves the store-first availability matrix once: the +// resolver's own witness in the managed store first, then the first object source +// that holds BOTH the pin and the compared ref. The pin and the witness always come +// from the same repository, and nothing here fetches. +// +// TipOid is the single witness snapshot for the whole operation. `status` reports +// from it and `update-pin` targets, counts and gates from it, so no caller can end +// up describing two different commits by re-reading a ref that moved in between. +func SelectComparison(root string, mount MountDecl, effectiveRef string) ComparisonSelection { + identity, idOK := NormalizeSource(mount.Source) + if !idOK { + return ComparisonSelection{Reason: "mount-source-unnormalizable"} + } + if !ValidTrackingRef(effectiveRef) { + return ComparisonSelection{Reason: "mount-tracking-ref-invalid"} + } + // Row 1: the managed store holds the pin and the resolver's own witness. + store := storeDir(root, identity) + managedTip := "" + if isGitRepo(store) && hasCommit(store, mount.Pin) { + managedTip = revOid(store, WitnessRefFor(identity, effectiveRef)) + } + // Row 2: the first pin-holding source that also resolves the ref itself. A + // candidate holding only the pin is passed over, never allowed to mask a + // later one holding both. + var candidates []ObjectSource + ambiguous := false + if managedTip == "" { + candidates, ambiguous = ObjectSourceCandidates(root, mount, identity) + } + selectedRepo, selectedKind, tipOid := "", "", "" + if managedTip != "" { + selectedRepo, selectedKind, tipOid = store, "store", managedTip + } + for _, candidate := range candidates { + if tip := revOid(candidate.Repo, effectiveRef); tip != "" { + selectedRepo, selectedKind, tipOid = candidate.Repo, candidate.Kind, tip + break + } + } + if selectedRepo == "" { + // Ambiguity is its own answer: those repositories were never consulted, + // so reporting the pin or the witness unavailable would claim more than + // was checked. + reason := "mount-witness-unavailable" + if ambiguous { + reason = "mount-source-ambiguous" + } else if len(candidates) == 0 { + reason = "mount-pin-unavailable" + } + return ComparisonSelection{Reason: reason} + } + comparisonRepository := selectedKind + if selectedKind == "store" { + comparisonRepository = "managed-store" + } + witnessProvenance := "unmanaged" + if managedTip != "" { + witnessProvenance = "managed" + } + return ComparisonSelection{ + Repo: selectedRepo, + ComparisonRepository: comparisonRepository, + WitnessProvenance: witnessProvenance, + ComparedRef: effectiveRef, + TipOid: tipOid, + } +} + +// PinComparison is a settled pin comparison: State is never "unknown", because a +// repository that cannot answer the range returns Reason instead. +type PinComparison struct { + State string + Behind int + Ahead int + AncestryComplete bool + // Reason is "mount-ancestry-incomplete" exactly when nothing was settled. + Reason string +} + +// ComparePins says where the pin stands against ONE witness snapshot, in ONE +// repository. Shared by `status`, which reports it, and `update-pin`, which +// additionally gates on it — so the two can never describe the same pair of commits +// differently. +func ComparePins(repo, pin, tipOid string) PinComparison { + incomplete := PinComparison{Reason: "mount-ancestry-incomplete"} + behind, behindOK := countRange(repo, pin, tipOid) + ahead, aheadOK := countRange(repo, tipOid, pin) + if !behindOK || !aheadOK { + return incomplete + } + shallow := RunGit([]string{"-C", repo, "rev-parse", "--is-shallow-repository"}, "") + ancestryComplete := shallow.OK && strings.TrimSpace(string(shallow.Stdout)) == "false" + // Both counts positive is either divergence or two unrelated histories, and + // only a merge base tells them apart. Exit 1 is the answer "no merge base"; + // any other failure is the repository unable to answer, never an answer. + // Truncated history can also lose a merge base that exists, so `unrelated` + // is a claim only complete ancestry makes. + disjoint := false + if behind > 0 && ahead > 0 { + mergeBase := RunGit([]string{"-C", repo, "merge-base", pin, tipOid}, "") + if !mergeBase.OK && mergeBase.Code != 1 { + return incomplete + } + disjoint = !mergeBase.OK + if disjoint && !ancestryComplete { + return incomplete + } + } + state := "ahead" + switch { + case behind == 0 && ahead == 0: + state = "up-to-date" + case behind > 0 && ahead > 0: + state = "diverged" + if disjoint { + state = "unrelated" + } + case behind > 0: + state = "behind" + } + return PinComparison{State: state, Behind: behind, Ahead: ahead, AncestryComplete: ancestryComplete} +} + +// ResolveDefaultRef returns the ref a source advertises as its default branch: +// HEAD's symref target, read with `ls-remote --symref`. The one lookup in this +// package that reaches the network without the caller having named a ref, so both +// failures stay distinguishable — errKind is "unreachable" when the source could +// not be reached at all, "no-symref" when it advertises no symref to follow. +func ResolveDefaultRef(source string) (ref string, errKind string) { + // The locator becomes argv here: anything option-shaped is refused, never passed. + if strings.HasPrefix(source, "-") { + return "", "unreachable" + } + head := RunGit([]string{"ls-remote", "--symref", source, "HEAD"}, "") + if !head.OK { + return "", "unreachable" + } + m := headSymrefRe.FindStringSubmatch(string(head.Stdout)) + if m == nil { + return "", "no-symref" + } + return m[1], "" +} + // MountStatus reports every declared mount's pin against its witness, offline. // Comparison is the availability matrix: the resolver's own witness in the // managed store first, then any object source that holds both the pin and the @@ -1995,7 +2235,7 @@ func LocateMount(root string, m *manifest.Manifest, name string) (LocateResult, // exceptions). func MountStatus(root string, m *manifest.Manifest, opts StatusOptions) ([]StatusResult, error) { // One observation time for the whole execution, injectable so tests are stable. - observedAt := nowISO() + observedAt := NowISO() if !opts.Now.IsZero() { observedAt = opts.Now.UTC().Format("2006-01-02T15:04:05.000Z") } @@ -2060,99 +2300,27 @@ func MountStatus(root string, m *manifest.Manifest, opts StatusOptions) ([]Statu out = append(out, unknown("mount-tracking-ref-invalid", nil, nil)) continue } - // Row 1: the managed store holds the pin and the resolver's own witness. - store := storeDir(root, identity) - managedTip := "" - if isGitRepo(store) && hasCommit(store, mount.Pin) { - managedTip = revOid(store, WitnessRefFor(identity, mount.TrackingRef)) - } - // Row 2: the first pin-holding source that also resolves the ref itself. A - // candidate holding only the pin is passed over, never allowed to mask a - // later one holding both. - var candidates []ObjectSource - ambiguous := false - if managedTip == "" { - candidates, ambiguous = ObjectSourceCandidates(root, mount, identity) - } - selectedRepo, selectedKind, tipOid := "", "", "" - if managedTip != "" { - selectedRepo, selectedKind, tipOid = store, "store", managedTip - } - for _, candidate := range candidates { - if tip := revOid(candidate.Repo, mount.TrackingRef); tip != "" { - selectedRepo, selectedKind, tipOid = candidate.Repo, candidate.Kind, tip - break - } - } - if selectedRepo == "" { - // Ambiguity is its own answer: those repositories were never consulted, - // so reporting the pin unavailable would claim more than was checked. - reason := "mount-witness-unavailable" - if ambiguous { - reason = "mount-source-ambiguous" - } else if len(candidates) == 0 { - reason = "mount-pin-unavailable" - } - out = append(out, unknown(reason, nil, nil)) + selection := SelectComparison(root, mount, mount.TrackingRef) + if selection.Reason != "" { + out = append(out, unknown(selection.Reason, nil, nil)) continue } - comparisonRepository := selectedKind - if selectedKind == "store" { - comparisonRepository = "managed-store" - } - witnessProvenance := "unmanaged" - if managedTip != "" { - witnessProvenance = "managed" - } - cr, wp := comparisonRepository, witnessProvenance - behind, behindOK := countRange(selectedRepo, mount.Pin, tipOid) - ahead, aheadOK := countRange(selectedRepo, tipOid, mount.Pin) - if !behindOK || !aheadOK { - out = append(out, unknown("mount-ancestry-incomplete", &cr, &wp)) + cr, wp := selection.ComparisonRepository, selection.WitnessProvenance + comparison := ComparePins(selection.Repo, mount.Pin, selection.TipOid) + if comparison.Reason != "" { + out = append(out, unknown(comparison.Reason, &cr, &wp)) continue } - shallow := RunGit([]string{"-C", selectedRepo, "rev-parse", "--is-shallow-repository"}, "") - ancestryComplete := shallow.OK && strings.TrimSpace(string(shallow.Stdout)) == "false" - // Both counts positive is either divergence or two unrelated histories, and - // only a merge base tells them apart. Exit 1 is the answer "no merge base"; - // any other failure is the repository unable to answer, never an answer. - // Truncated history can also lose a merge base that exists, so `unrelated` - // is a claim only complete ancestry makes. - disjoint := false - if behind > 0 && ahead > 0 { - mergeBase := RunGit([]string{"-C", selectedRepo, "merge-base", mount.Pin, tipOid}, "") - if !mergeBase.OK && mergeBase.Code != 1 { - out = append(out, unknown("mount-ancestry-incomplete", &cr, &wp)) - continue - } - disjoint = !mergeBase.OK - if disjoint && !ancestryComplete { - out = append(out, unknown("mount-ancestry-incomplete", &cr, &wp)) - continue - } - } - state := "ahead" - switch { - case behind == 0 && ahead == 0: - state = "up-to-date" - case behind > 0 && ahead > 0: - state = "diverged" - if disjoint { - state = "unrelated" - } - case behind > 0: - state = "behind" - } row := base - b, a := behind, ahead + b, a := comparison.Behind, comparison.Ahead row.PinReport = PinReport{ - State: state, + State: comparison.State, Behind: &b, Ahead: &a, ComparedRef: trackingRefPtr, ComparisonRepository: &cr, WitnessProvenance: &wp, - AncestryComplete: ancestryComplete, + AncestryComplete: comparison.AncestryComplete, ObservedAt: observedAt, } out = append(out, row) @@ -2200,15 +2368,15 @@ func CheckPinReachability(root string, mount MountDecl) (ReachabilityResult, err // Resolve the witness ref: declared, or the source's advertised default branch. witnessRef := mount.TrackingRef if witnessRef == "" { - head := RunGit([]string{"ls-remote", "--symref", mount.Source, "HEAD"}, "") - if !head.OK { - return ReachabilityResult{State: "unknown", Detail: "the source could not be reached"}, nil - } - m := headSymrefRe.FindStringSubmatch(string(head.Stdout)) - if m == nil { - return ReachabilityResult{State: "unknown", Detail: "source advertises no HEAD symref"}, nil + resolved, errKind := ResolveDefaultRef(mount.Source) + if errKind != "" { + detail := "the source could not be reached" + if errKind == "no-symref" { + detail = "source advertises no HEAD symref" + } + return ReachabilityResult{State: "unknown", Detail: detail}, nil } - witnessRef = m[1] + witnessRef = resolved } adv := RunGit([]string{"ls-remote", mount.Source, witnessRef}, "") if !adv.OK { @@ -2221,11 +2389,14 @@ 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 := filepath.Join(MountsDir(root), "store", Sha256Hex(identity)) + store, established, err := establishMountsDir(root, filepath.Join(MountsDir(root), "store", Sha256Hex(identity))) + if err != nil { + return ReachabilityResult{}, err + } + if !established { + return ReachabilityResult{State: "unknown", WitnessRef: &witnessRef, Detail: "the managed store could not be initialized"}, nil + } if !isGitRepo(store) { - if err := os.MkdirAll(store, 0o755); err != nil { - return ReachabilityResult{}, err - } init := RunGit([]string{"init", "--bare", "-q", store}, "") if !init.OK { return ReachabilityResult{State: "unknown", WitnessRef: &witnessRef, Detail: "the managed store could not be initialized"}, nil @@ -2290,7 +2461,7 @@ func FederationEnforcement(root string, m *manifest.Manifest, mode string, taskM return nil, err } if verified == nil || !*verified { - message := "mount \"" + mount.Name + "\" projection cannot be verified (no reachable object store); an unverified cache is not evidence" + message := "mount \"" + mount.Name + "\" projection cannot be verified (verification prerequisites unavailable: no reachable object store, unresolvable pin, or no writable temp dir); an unverified cache is not evidence" if verified != nil { message = "mount \"" + mount.Name + "\" projection does not match its pin; re-run `leji mounts hydrate`" } diff --git a/packages/sdk-go/internal/mounts/mounts_test.go b/packages/sdk-go/internal/mounts/mounts_test.go index 20534eb..6321073 100644 --- a/packages/sdk-go/internal/mounts/mounts_test.go +++ b/packages/sdk-go/internal/mounts/mounts_test.go @@ -5,12 +5,17 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" + "io/fs" "os" "os/exec" "path/filepath" + "slices" + "sort" "strings" "sync" "testing" + "time" "github.com/leji-org/leji/packages/sdk-go/internal/commands/conformance" "github.com/leji-org/leji/packages/sdk-go/internal/commands/validate" @@ -189,7 +194,7 @@ func TestMountsHydrateViaHintMaterializesVerifiedProjectionAndClearsWarning(t *t t.Fatalf("projection missing boot-profile.md: %v", err) } // validate no longer reports mount-unavailable. - v := validate.ValidateLayer(host, false) + v := validateLayer(t, host, false) for _, f := range v.Findings { if f.Rule == "mount-unavailable" { t.Fatalf("validate still reports mount-unavailable: %+v", f) @@ -409,6 +414,59 @@ func TestMountsHydrateFetchPullsPinIntoManagedStore(t *testing.T) { } } +// TestMountsFetchRetainsThePinAndWritesNoFetchHead mirrors the TS reference's +// `mounts: --fetch retains the pin by a resolver-owned ref, and writes no +// FETCH_HEAD at all`. +func TestMountsFetchRetainsThePinAndWritesNoFetchHead(t *testing.T) { + host, sibling, pin := mountedPair(t) + // Fetching a commit by id is how the resolver retains a pin, so the sibling must + // serve one the way a real host does. + git(t, sibling, "config", "uploadpack.allowAnySHA1InWant", "true") + identity, ok := mounts.NormalizeSource(acmeSource) + if !ok { + t.Fatal("identity failed to normalize") + } + m := loadHost(t, host) + // main moves past the pin, so neither fetch may leave the version of record to + // FETCH_HEAD: only a ref of our own retains it. + if err := os.WriteFile(filepath.Join(sibling, "b.md"), []byte("# b\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, sibling, "add", "-A") + git(t, sibling, "-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-q", "-m", "later") + hydrate := func() { + t.Helper() + withSourceRewrite(t, sibling, func() mounts.ReachabilityResult { + if _, err := mounts.HydrateMounts(host, m, mounts.HydrateOptions{Fetch: true}); err != nil { + t.Fatalf("hydrate: %v", err) + } + return mounts.ReachabilityResult{} + }) + } + hydrate() + sum := sha256.Sum256([]byte(identity)) + store := filepath.Join(host, ".leji", "mounts", "store", hex.EncodeToString(sum[:])) + if got := git(t, store, "rev-parse", mounts.PinRefFor(identity, pin)); got != pin { + t.Fatalf("the pin ref retains the version of record: got %s want %s", got, pin) + } + // Both fetches pass --no-write-fetch-head, so the managed store carries no + // per-run record of where the objects came from. + if _, err := os.Stat(filepath.Join(store, "FETCH_HEAD")); !os.IsNotExist(err) { + t.Fatal("no FETCH_HEAD in the managed store") + } + // And a second --fetch, which refreshes the witness over an existing store, does + // not create one either. + if err := os.WriteFile(filepath.Join(sibling, "c.md"), []byte("# c\n"), 0o644); err != nil { + t.Fatal(err) + } + git(t, sibling, "add", "-A") + git(t, sibling, "-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-q", "-m", "later2") + hydrate() + if _, err := os.Stat(filepath.Join(store, "FETCH_HEAD")); !os.IsNotExist(err) { + t.Fatal("still none after a witness refresh") + } +} + // withSourceRewrite routes git's network protocols at the declared source URL to // a local repo, so the "networked" reachability probe runs hermetically (env // flows into RunGit). @@ -884,7 +942,7 @@ func patchMount(t *testing.T, key, value string) []findings.Finding { if err := os.WriteFile(mp, append(out, '\n'), 0o644); err != nil { t.Fatalf("write host manifest: %v", err) } - return validate.ValidateLayer(host, false).Findings + return validateLayer(t, host, false).Findings } func hasRule(fs []findings.Finding, rule, severity string) bool { @@ -975,3 +1033,465 @@ func TestConformanceReportsAllFourMountItemsWhenNoneAreDeclared(t *testing.T) { t.Fatalf("federated items = %v", got) } } + +// --- verification is read-only: it may not stage inside the tree it verifies --- + +// denyWrites strips write permission from every directory in the tree; returns +// the undo. On POSIX this is a real denial for a non-root user. The Windows +// equivalent is a DENY ACE rather than a mode, which is a named verify-at-build +// obligation for the cross-platform runner, not something these mode bits stand +// in for. +func denyWrites(t *testing.T, dir string) func() { + t.Helper() + type saved struct { + path string + mode os.FileMode + } + var dirs []saved + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + dirs = append(dirs, saved{p, info.Mode().Perm()}) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } + for _, s := range dirs { + if err := os.Chmod(s.path, 0o555); err != nil { + t.Fatalf("chmod %s: %v", s.path, err) + } + } + return func() { + for _, s := range dirs { + os.Chmod(s.path, s.mode) + } + } +} + +// writeDenied asserts the denial rather than assuming it: a mode that a +// root-owned or ACL-governed run ignores would make every "did not write" +// assertion below vacuous. +func writeDenied(dir string) bool { + probe := filepath.Join(dir, ".write-probe") + if err := os.WriteFile(probe, []byte("x"), 0o644); err != nil { + return true + } + os.Remove(probe) + return false +} + +// treeSnapshot records paths, types, modes, symlink targets, content and +// directory mtimes: the whole of what "the tree is byte-for-byte what it was" +// has to mean here. Content alone would miss a staging directory created and +// removed between the two reads — its parent's mtime is the only trace that +// survives. +func treeSnapshot(t *testing.T, dir, prefix string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + var out []string + for _, name := range names { + abs := filepath.Join(dir, name) + rel := name + if prefix != "" { + rel = prefix + "/" + name + } + st, err := os.Lstat(abs) + if err != nil { + t.Fatalf("lstat %s: %v", abs, err) + } + mode := fmt.Sprintf("%o", st.Mode().Perm()) + switch { + case st.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(abs) + if err != nil { + t.Fatalf("readlink %s: %v", abs, err) + } + out = append(out, fmt.Sprintf("L %s %s %s", rel, mode, target)) + case st.IsDir(): + out = append(out, fmt.Sprintf("D %s %s %d", rel, mode, st.ModTime().UnixNano())) + out = append(out, treeSnapshot(t, abs, rel)...) + default: + data, err := os.ReadFile(abs) + if err != nil { + t.Fatalf("read %s: %v", abs, err) + } + sum := sha256.Sum256(data) + out = append(out, fmt.Sprintf("F %s %s %d %s", rel, mode, st.Size(), hex.EncodeToString(sum[:]))) + } + } + return out +} + +// verifyResidue lists staging directories left behind in the OS temp dir. +// Compared as a delta, since the suite's other tests run against the same temp +// dir. +func verifyResidue(t *testing.T) map[string]bool { + t.Helper() + entries, err := os.ReadDir(os.TempDir()) + if err != nil { + t.Fatalf("read temp dir: %v", err) + } + out := map[string]bool{} + for _, e := range entries { + if strings.HasPrefix(e.Name(), "leji-verify-") { + out[e.Name()] = true + } + } + return out +} + +func newVerifyResidue(t *testing.T, before map[string]bool) []string { + t.Helper() + var out []string + for name := range verifyResidue(t) { + if !before[name] { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +func TestMountsCheckIntegrityVerifiesAWriteDeniedHostTreeTwiceWithoutTouchingIt(t *testing.T) { + // `mounts status --check-integrity` staged its comparison tree inside the + // host's own .leji/mounts/, so the read-only diagnostic wrote into the tree it + // was diagnosing — and could not run at all where that tree is not writable. + host, _, _ := mountedPair(t) + m := loadHost(t, host) + if _, err := mounts.HydrateMounts(host, m, mounts.HydrateOptions{}); err != nil { + t.Fatalf("hydrate: %v", err) + } + restore := denyWrites(t, host) + defer restore() + if !writeDenied(filepath.Join(host, ".leji", "mounts")) { + t.Fatalf("the mounts dir must really be write-denied") + } + if !writeDenied(host) { + t.Fatalf("the host root must really be write-denied") + } + before := treeSnapshot(t, host, "") + residueBefore := verifyResidue(t) + // Twice: once proves it runs, twice proves the second run is not consuming + // residue the first left behind. + for i := range 2 { + rows, err := mounts.MountStatus(host, m, mounts.StatusOptions{CheckIntegrity: true}) + if err != nil { + t.Fatalf("status %d: %v", i, err) + } + if rows[0].Verified == nil || !*rows[0].Verified { + t.Fatalf("status %d verified = %v", i, rows[0].Verified) + } + } + // The other two callers of the same verification, on the same denied tree. + loc, err := mounts.LocateMount(host, m, "acme-product-context") + if err != nil { + t.Fatalf("locate: %v", err) + } + if !loc.Present || !loc.Verified { + t.Fatalf("locate = %+v", loc) + } + findings, err := mounts.FederationEnforcement(host, m, "available", nil) + if err != nil { + t.Fatalf("enforcement: %v", err) + } + if len(findings) != 0 { + t.Fatalf("findings = %+v", findings) + } + if !slices.Equal(treeSnapshot(t, host, ""), before) { + t.Fatalf("verification wrote into the host tree") + } + if extra := newVerifyResidue(t, residueBefore); len(extra) != 0 { + t.Fatalf("staging outlived the verification that allocated it: %v", extra) + } +} + +// rendezvous returns a two-party barrier: each call blocks until both parties +// have called it, and it is reusable round after round. +func rendezvous() func() { + gate := make(chan struct{}) + return func() { + select { + case gate <- struct{}{}: + case <-gate: + } + } +} + +func TestMountsTwoVerificationsAtOnceInOneProcessDoNotCollide(t *testing.T) { + // Two goroutines running `rounds` verifications of the host's only mount, every + // round entered through a two-goroutine rendezvous, with the lagging goroutine + // then held back to about half of its last round. + // + // Both halves earn their place. Without the rendezvous the goroutines drift into + // taking turns and never overlap; with the rendezvous alone they run identical + // work in lockstep, and two of them staging the same content into one shared + // directory at the same instant still agree — the interleaving that a shared + // staging directory cannot survive is one goroutine starting while the other is + // mid-verification. One process, so a staging name derived from the pid is one + // name for both of them. + host, _, _ := mountedPair(t) + m := loadHost(t, host) + if _, err := mounts.HydrateMounts(host, m, mounts.HydrateOptions{}); err != nil { + t.Fatalf("hydrate: %v", err) + } + mount := firstMount(t, m) + residueBefore := verifyResidue(t) + const rounds = 8 + meet := rendezvous() + results := make([][]string, 2) + var wg sync.WaitGroup + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + lag := i == 1 + lastRound := 40 * time.Millisecond + out := make([]string, 0, rounds) + for range rounds { + meet() + if lag { + time.Sleep(max(lastRound/2, 5*time.Millisecond)) + } + startedAt := time.Now() + v, err := mounts.VerifyProjection(host, mount) + lastRound = time.Since(startedAt) + switch { + case err != nil: + out = append(out, "error "+err.Error()) + case v == nil: + out = append(out, "unverifiable") + default: + out = append(out, fmt.Sprintf("%t", *v)) + } + } + results[i] = out + }(i) + } + wg.Wait() + // Every one of them verified: a shared staging path has one goroutine deleting + // or half-writing the tree the other is comparing, which surfaces as ENOENT, + // ENOTEMPTY, or a false verdict on content nobody tampered with. + want := make([]string, rounds) + for i := range want { + want[i] = "true" + } + for i, got := range results { + if !slices.Equal(got, want) { + t.Fatalf("goroutine %d = %v", i, got) + } + } + if extra := newVerifyResidue(t, residueBefore); len(extra) != 0 { + t.Fatalf("staging outlived the verifications that allocated it: %v", extra) + } +} + +func TestMountsAnUnusableTempDirMakesVerificationUnverifiableNeverInTree(t *testing.T) { + host, _, _ := mountedPair(t) + m := loadHost(t, host) + if _, err := mounts.HydrateMounts(host, m, mounts.HydrateOptions{}); err != nil { + t.Fatalf("hydrate: %v", err) + } + noTmp := filepath.Join(t.TempDir(), "notmp") + if err := os.Mkdir(noTmp, 0o755); err != nil { + t.Fatalf("mkdir notmp: %v", err) + } + if err := os.Chmod(noTmp, 0o555); err != nil { + t.Fatalf("chmod notmp: %v", err) + } + defer os.Chmod(noTmp, 0o755) + t.Setenv("TMPDIR", noTmp) + if os.TempDir() != noTmp { + t.Fatalf("the runtime must honor TMPDIR for this to force the failure") + } + if !writeDenied(noTmp) { + t.Fatalf("the temp dir must really be write-denied") + } + before := treeSnapshot(t, host, "") + // Unknown: no staging area is a missing prerequisite, exactly like no reachable + // object store. It is never a pass, never a failure, and never a reason to fall + // back into the host tree. + rows, err := mounts.MountStatus(host, m, mounts.StatusOptions{CheckIntegrity: true}) + if err != nil { + t.Fatalf("status: %v", err) + } + if !rows[0].Present || rows[0].Verified != nil { + t.Fatalf("status row = %+v", rows[0]) + } + loc, err := mounts.LocateMount(host, m, "acme-product-context") + if err != nil { + t.Fatalf("locate: %v", err) + } + if !loc.Present || loc.Verified { + t.Fatalf("locate = %+v", loc) + } + if !strings.Contains(loc.Detail, "present but not verified") || + !strings.Contains(loc.Detail, "verification prerequisites are unavailable") { + t.Fatalf("locate detail = %q", loc.Detail) + } + // The diagnostic names the prerequisite that was actually missing rather than + // blaming the object store, which is reachable here: a reader told to check + // their hint would be reading the wrong end of the failure. + findings, err := mounts.FederationEnforcement(host, m, "available", nil) + if err != nil { + t.Fatalf("enforcement: %v", err) + } + if len(findings) != 1 || + !strings.Contains(findings[0].Message, "cannot be verified") || + !strings.Contains(findings[0].Message, "verification prerequisites unavailable") || + !strings.Contains(findings[0].Message, "no writable temp dir") { + t.Fatalf("findings = %+v", findings) + } + if !slices.Equal(treeSnapshot(t, host, ""), before) { + t.Fatalf("verification fell back into the host tree") + } + staged, err := os.ReadDir(noTmp) + if err != nil { + t.Fatalf("read notmp: %v", err) + } + if len(staged) != 0 { + t.Fatalf("nothing was staged in the unusable temp dir: %v", staged) + } +} + +func TestMountsAReachableStoreWithoutThePinIsUnverifiableAndNamesThePrerequisite(t *testing.T) { + host, _, _ := mountedPair(t) + m := loadHost(t, host) + if _, err := mounts.HydrateMounts(host, m, mounts.HydrateOptions{}); err != nil { + t.Fatalf("hydrate: %v", err) + } + // A real repository, reachable, that simply does not contain this pin. The + // published projection stays published — its cache key comes from the + // declaration, not from whichever store happens to be reachable — so the only + // missing prerequisite is the commit the comparison would be made against. + other := filepath.Join(filepath.Dir(host), "other") + if err := os.MkdirAll(other, 0o755); err != nil { + t.Fatalf("mkdir other: %v", err) + } + git(t, other, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(other, "unrelated.md"), []byte("# unrelated\n"), 0o644); err != nil { + t.Fatalf("write unrelated: %v", err) + } + git(t, other, "add", "-A") + git(t, other, "-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-q", "-m", "unrelated") + hint := `{"mounts":{"acme-product-context":{"repo":"../other"}}}` + "\n" + if err := os.WriteFile(filepath.Join(host, ".leji", "mounts.local.json"), []byte(hint), 0o644); err != nil { + t.Fatalf("write hint: %v", err) + } + v, err := mounts.VerifyProjection(host, firstMount(t, m)) + if err != nil { + t.Fatalf("verify: %v", err) + } + if v != nil { + t.Fatalf("verify = %v, want nil", *v) + } + rows, err := mounts.MountStatus(host, m, mounts.StatusOptions{CheckIntegrity: true}) + if err != nil { + t.Fatalf("status: %v", err) + } + if !rows[0].Present || rows[0].Verified != nil { + t.Fatalf("status row = %+v", rows[0]) + } + loc, err := mounts.LocateMount(host, m, "acme-product-context") + if err != nil { + t.Fatalf("locate: %v", err) + } + if !loc.Present || loc.Verified { + t.Fatalf("locate = %+v", loc) + } + // The parenthetical is the whole of what makes a projection unverifiable. An + // exhaustive-looking list that omits this branch tells the reader their object + // store is unreachable when it is reachable and their pin is what is missing. + findings, err := mounts.FederationEnforcement(host, m, "available", nil) + if err != nil { + t.Fatalf("enforcement: %v", err) + } + want := `mount "acme-product-context" projection cannot be verified (verification prerequisites unavailable: no reachable object store, unresolvable pin, or no writable temp dir); an unverified cache is not evidence` + if len(findings) != 1 || findings[0].Message != want { + t.Fatalf("findings = %+v", findings) + } +} + +func TestMountsHydrateRefusesAStoreDestinationPlantedOutOfTheRepository(t *testing.T) { + // `.leji/mounts` was a lexical join, so a planted symlink redirected every + // per-entry write of the federation protocol — into another private role, or clean + // out of the repository. The store, cache entry and staging destinations are + // established through the write chokepoint now, and every inner act works from the + // RESOLVED root it returned. Mutation that reddens: mkdir the destination directly + // again — the projection materializes through the link. + for _, c := range []struct { + name string + plant func(t *testing.T, host string) string + detail string + }{ + {"out of the repository", func(t *testing.T, host string) string { + away := t.TempDir() + if err := os.Symlink(away, filepath.Join(host, ".leji", "mounts")); err != nil { + t.Fatal(err) + } + return away + }, "the cache entry destination could not be established"}, + {"into another private role", func(t *testing.T, host string) string { + target := filepath.Join(host, ".leji", "work", "planted") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("work", "planted"), filepath.Join(host, ".leji", "mounts")); err != nil { + t.Fatal(err) + } + return target + }, "the cache entry destination could not be established"}, + } { + host, _, _ := mountedPair(t) + if err := os.RemoveAll(filepath.Join(host, ".leji", "mounts")); err != nil { + t.Fatal(err) + } + landing := c.plant(t, host) + m := loadHost(t, host) + + r, err := mounts.HydrateMounts(host, m, mounts.HydrateOptions{}) + if err != nil { + t.Fatalf("%s: hydrate: %v", c.name, err) + } + if len(r.Outcomes) != 1 || r.Outcomes[0].Status != "error" || r.Outcomes[0].Detail != c.detail { + t.Fatalf("%s: outcomes = %+v", c.name, r.Outcomes) + } + entries, rerr := os.ReadDir(landing) + if rerr != nil { + t.Fatal(rerr) + } + if len(entries) != 0 { + t.Fatalf("%s: nothing may be materialized through the planted link, got %v", c.name, entries) + } + } +} + +// --- gate helpers ------------------------------------------------------------- +// These commands now carry an error channel, because an operational read failure on +// an allowed path propagates instead of being swallowed (the reference throws it). +// A test that does not construct such a failure asserts there is none. + +func validateLayer(t *testing.T, root string, content bool) validate.Result { + t.Helper() + res, err := validate.ValidateLayer(root, content) + if err != nil { + t.Fatalf("ValidateLayer(%s): %v", root, err) + } + return res +} diff --git a/packages/sdk-go/internal/renderlint/renderlint.go b/packages/sdk-go/internal/renderlint/renderlint.go new file mode 100644 index 0000000..0f3ddbb --- /dev/null +++ b/packages/sdk-go/internal/renderlint/renderlint.go @@ -0,0 +1,529 @@ +// Package renderlint is the rendering-subset scan: given one markdown document, +// the constructs in it that render differently across renderers. The Node SDK's +// lib/renderlint.ts is the executable contract this port follows rule for rule — +// the rules are stated there and here in the order they are applied, because a +// second statement of them (a grammar, a spec paragraph) would be a source that +// drifts. +// +// The rules, in application order: +// +// 1. Excluded regions are found first. YAML frontmatter (a leading block only, by +// the SDK's own boundary), fenced code blocks, and HTML comments are scanned +// before anything else, and nothing inside one is ever reported — text that +// merely names a construct is not that construct. Code spans are excluded the +// same way, inline, as the scan reaches them. +// 2. Three constructs are reported, and only these three: `raw-html` (CommonMark +// HTML blocks and inline raw HTML; comments excepted, since Leji's own +// generated-block markers are comments), `footnote` (the definition and +// reference forms alike), and `math-block` (a PAIRED `$$` delimiter — a lone +// one is prose). +// 3. Backslash escapes are honored for all three, per CommonMark: an escaped ASCII +// punctuation character is a literal, so `\
` is prose. +// 4. Overlapping constructs resolve to the earliest-starting match, which the +// single left-to-right scan below produces by construction, and each match is +// attributed to the line it OPENS on — a multi-line HTML block or `$$` block +// reports once, at its opening line. +// 5. One hit per (line, construct): the line is the unit, so a line carrying two +// inline tags reports `raw-html` once. +// +// What is deliberately NOT reported: inline `$` (a currency amount spells it), +// unknown fence info strings (the unhighlighted fallback is conforming), and loose +// prose shapes. `adoption/rendering.md` is the profile these rules serve. +package renderlint + +import ( + "regexp" + "sort" + "strconv" + "strings" + + "github.com/leji-org/leji/packages/sdk-go/internal/findings" + "github.com/leji-org/leji/packages/sdk-go/internal/frontmatter" +) + +// The closed token set. Findings compare on it across the three SDKs; the message +// text does not. +const ( + RawHTML = "raw-html" + Footnote = "footnote" + MathBlock = "math-block" +) + +// RenderUnsupportedRule is the one rule this scan produces. `--strict` promotes it +// (see the export command). +const RenderUnsupportedRule = "render-unsupported" + +// Message is the shared message template. Identical bytes in all three SDKs by +// convention, outside the fixture contract by design. +func Message(construct string) string { + return "`" + construct + "` is outside the supported rendering subset; see adoption/rendering.md" +} + +// Hit is one reported construct: the token, and the 1-based line it opens on. +type Hit struct { + Line int + Construct string +} + +// blockTags is CommonMark HTML block type 6: a line opening with one of these tags +// starts a block that runs to the next blank line, whatever else the line carries. +// The list is CommonMark's, verbatim, so a `
` closing a block on a later line +// is block content rather than a second construct. +var blockTags = func() map[string]bool { + names := "address article aside base basefont blockquote body caption center col colgroup dd details dialog dir div dl " + + "dt fieldset figcaption figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend " + + "li link main menu menuitem nav noframes ol optgroup option p param search section summary table tbody td " + + "tfoot th thead title tr track ul" + set := map[string]bool{} + for _, n := range strings.Split(names, " ") { + set[n] = true + } + return set +}() + +var ( + // CommonMark HTML block type 1: these run to a line carrying a closing tag + // rather than to a blank line, because their content is raw text. + rawTextOpen = regexp.MustCompile(`(?i)^<(script|pre|style|textarea)([ \t>]|$)`) + rawTextClose = regexp.MustCompile(`(?i)`) + + // Inline raw HTML, as CommonMark defines it: an open tag, a closing tag, a + // processing instruction, a declaration, or a CDATA section. (A comment is the + // sixth form and the excepted one, handled as an excluded region.) Each is + // anchored, so it is tried at exactly the scan position. A declaration takes an + // ASCII letter of either case after `` and `` + // alike disappear into the renderer, which is precisely what the lint exists to + // warn about. + openTag = regexp.MustCompile("^<[A-Za-z][A-Za-z0-9-]*(?:[ \t\r\n]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t\r\n]*=[ \t\r\n]*(?:[^ \t\r\n\"'=<>`]+|'[^']*'|\"[^\"]*\"))?)*[ \t\r\n]*/?>") + closeTag = regexp.MustCompile("^") + cdata = regexp.MustCompile(`(?s)^`) + declaration = regexp.MustCompile(`(?s)^`) + processing = regexp.MustCompile(`(?s)^<\?.*?\?>`) + // Both footnote forms: the reference `[^id]`, and the definition `[^id]:`, whose + // opening bracket the same match covers. An unclosed `[^` is prose. + footnote = regexp.MustCompile(`^\[\^[^\][\n]+\]`) + + // A fence opener: three or more backticks or tildes. A backtick fence's info + // string may carry no backtick, which is what keeps a code span off this path. + fenceOpen = regexp.MustCompile("^(`{3,}|~{3,})(.*)$") + // A fence closer: the same character, at least as long, alone on its line. + fenceClose = regexp.MustCompile("^(`{3,}|~{3,})[ \t]*$") + // A line opening with a tag name, for the type-6/type-7 block test. + lineTag = regexp.MustCompile(`^|$)`) +) + +// escapable is CommonMark's escapable set: ASCII punctuation, and nothing else. +func escapable(b byte) bool { + return (b >= '!' && b <= '/') || (b >= ':' && b <= '@') || (b >= '[' && b <= '`') || (b >= '{' && b <= '~') +} + +func asciiLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// region is a span the scan treats as one unit: an excluded region (empty +// construct), or a block-level construct reported at its opening line. Regions are +// produced in document order and never overlap. +type region struct { + start int + end int + construct string +} + +// lineStartsOf is the offsets at which each line begins, so an offset resolves to a +// line number. +func lineStartsOf(text string) []int { + starts := []int{0} + for i := 0; i < len(text); i++ { + if text[i] == '\n' { + starts = append(starts, i+1) + } + } + return starts +} + +// lineOf is the 0-based line an offset falls on. +func lineOf(starts []int, offset int) int { + lo, hi := 0, len(starts)-1 + for lo < hi { + mid := (lo + hi + 1) / 2 + if starts[mid] <= offset { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +// lineTextAt is one line's text, without its line terminator (CRLF included). +func lineTextAt(text string, starts []int, li int) string { + end := len(text) + if li+1 < len(starts) { + end = starts[li+1] + } + line := text[starts[li]:end] + line = strings.TrimSuffix(line, "\n") + return strings.TrimSuffix(line, "\r") +} + +// lineEndOf is the offset just past a line's terminator. +func lineEndOf(text string, starts []int, li int) int { + if li+1 < len(starts) { + return starts[li+1] + } + return len(text) +} + +// indentOf is the leading spaces, capped at the four that would make the line +// indented code. +func indentOf(line string) int { + n := 0 + for n < 4 && n < len(line) && (line[n] == ' ' || line[n] == '\t') { + n++ + } + return n +} + +// indexFrom is strings.Index over text[from:], as an absolute offset or -1. +func indexFrom(text, sub string, from int) int { + if from > len(text) { + return -1 + } + i := strings.Index(text[from:], sub) + if i < 0 { + return -1 + } + return from + i +} + +// blockRegions is the block pass: frontmatter, fenced code, HTML comments (all +// excluded), and the HTML blocks that report as `raw-html` at their opening line. +// Line-based and in document order, so a fence inside a comment is comment text and +// a comment inside a fence is code — whichever opens first wins. +func blockRegions(text string, starts []int) []region { + var regions []region + n := len(text) + li := 0 + + // Frontmatter, by the SDK's own boundary (a LEADING block only; a `---` later in + // the document is a thematic break, and an unterminated block is prose). + fm := frontmatter.Parse(text) + if len(fm.Body) != n { + end := n - len(fm.Body) + regions = append(regions, region{start: 0, end: end}) + if end >= n { + li = len(starts) + } else { + li = lineOf(starts, end) + } + } + + for li < len(starts) { + line := lineTextAt(text, starts, li) + indent := indentOf(line) + if indent >= 4 { + li++ + continue + } + rest := line[indent:] + at := starts[li] + indent + + if fence := fenceOpen.FindStringSubmatch(rest); fence != nil && (fence[1][0] == '~' || !strings.Contains(fence[2], "`")) { + closeLi := li + 1 + for ; closeLi < len(starts); closeLi++ { + candidate := lineTextAt(text, starts, closeLi) + m := fenceClose.FindStringSubmatch(candidate[indentOf(candidate):]) + if m != nil && m[1][0] == fence[1][0] && len(m[1]) >= len(fence[1]) { + break + } + } + last := closeLi + if last > len(starts)-1 { + last = len(starts) - 1 + } + regions = append(regions, region{start: starts[li], end: lineEndOf(text, starts, last)}) + li = last + 1 + continue + } + + // A comment opening a line is CommonMark HTML block type 2: it runs to the + // line carrying `-->`, and the whole of that line belongs to it. Comments are + // the one HTML form the profile excepts, so the region reports nothing. + if strings.HasPrefix(rest, "", at+4) + last := len(starts) - 1 + if closeAt >= 0 { + last = lineOf(starts, closeAt+3) + } + regions = append(regions, region{start: starts[li], end: lineEndOf(text, starts, last)}) + li = last + 1 + continue + } + + // CommonMark HTML blocks 3, 4 and 5: a processing instruction, a declaration, + // or a CDATA section opening a line is a BLOCK, running to the line carrying + // its terminator (`?>`, `>`, `]]>`) and ending with that whole line — so what + // follows the terminator on it is block content, never a second construct. An + // unterminated one runs to the end of the document, as the comment form does. + // Type 4 takes an ASCII letter of either case, so `` disappears from the page. + terminator := "" + switch { + case strings.HasPrefix(rest, "" + case strings.HasPrefix(rest, "" + case len(rest) > 2 && rest[0] == '<' && rest[1] == '!' && asciiLetter(rest[2]): + terminator = ">" + } + if terminator != "" { + closeAt := indexFrom(text, terminator, at) + last := len(starts) - 1 + if closeAt >= 0 { + last = lineOf(starts, closeAt) + } + regions = append(regions, region{start: starts[li], end: lineEndOf(text, starts, last), construct: RawHTML}) + li = last + 1 + continue + } + + if rawTextOpen.MatchString(rest) { + last := len(starts) - 1 + if loc := rawTextClose.FindStringIndex(text[at:]); loc != nil { + last = lineOf(starts, at+loc[0]) + } + regions = append(regions, region{start: starts[li], end: lineEndOf(text, starts, last), construct: RawHTML}) + li = last + 1 + continue + } + + // Type 6 (a known block tag opens the line) and type 7 (any complete tag alone + // on a line, which cannot interrupt a paragraph). Both run to the next blank + // line, so the tags closing them are block content. + tag := lineTag.FindStringSubmatch(rest) + previousBlank := li == 0 || strings.TrimSpace(lineTextAt(text, starts, li-1)) == "" + isBlock := (tag != nil && blockTags[strings.ToLower(tag[1])]) || (previousBlank && wholeLineIsTag(rest)) + if isBlock { + closeLi := li + 1 + for closeLi < len(starts) && strings.TrimSpace(lineTextAt(text, starts, closeLi)) != "" { + closeLi++ + } + regions = append(regions, region{start: starts[li], end: lineEndOf(text, starts, closeLi-1), construct: RawHTML}) + li = closeLi + continue + } + li++ + } + return regions +} + +// wholeLineIsTag reports whether the line is one complete open or closing tag and +// nothing else. +func wholeLineIsTag(rest string) bool { + for _, re := range []*regexp.Regexp{openTag, closeTag} { + if m := re.FindString(rest); m != "" && strings.TrimSpace(rest[len(m):]) == "" { + return true + } + } + return false +} + +// skipRegion is the end of the region containing i, or i when it is outside every one. +func skipRegion(regions []region, i int) int { + for _, r := range regions { + if i >= r.start && i < r.end { + return r.end + } + } + return i +} + +// runLength is the length of the run of ch starting at i. +func runLength(text string, i int, ch byte) int { + n := 0 + for i+n < len(text) && text[i+n] == ch { + n++ + } + return n +} + +// afterCodeSpan skips a code span: a backtick run closed by a run of exactly the +// same length. An unclosed run is literal text, so the scan resumes just past it. +// Inline state never crosses a block boundary: a candidate whose closer would lie +// beyond an excluded or block region is unclosed AT that boundary, because the +// region ends the paragraph the run opened in — so constructs after the region still +// report. +func afterCodeSpan(text string, regions []region, i int) int { + open := runLength(text, i, '`') + j := i + open + for j < len(text) { + if skipRegion(regions, j) != j { + break + } + if text[j] == '`' { + run := runLength(text, j, '`') + if run == open { + return j + run + } + j += run + continue + } + j++ + } + return i + open +} + +// nextMathDelimiter is the next unescaped `$$` at or after from, or -1. A delimiter +// is a closer only where a delimiter can be read: not inside a code span, not inside +// a comment, and not on the far side of a block boundary — a pair no more bridges a +// region than a code span does, so an open whose apparent mate sits in one of them is +// unpaired, which is prose. +func nextMathDelimiter(text string, regions []region, from int) int { + j := from + for j < len(text)-1 { + if skipRegion(regions, j) != j { + return -1 + } + if text[j] == '\\' && j+1 < len(text) && escapable(text[j+1]) { + j += 2 + continue + } + if text[j] == '`' { + j = afterCodeSpan(text, regions, j) + continue + } + if strings.HasPrefix(text[j:], "", j+4) + if closeAt < 0 { + j = len(text) + } else { + j = closeAt + 3 + } + continue + } + if text[j] == '$' && text[j+1] == '$' { + return j + } + j++ + } + return -1 +} + +// inlineHTMLEnd is an inline raw-HTML form at i, as its end offset, or -1. +func inlineHTMLEnd(text string, i int) int { + for _, re := range []*regexp.Regexp{cdata, processing, declaration, closeTag, openTag} { + if m := re.FindString(text[i:]); m != "" { + return i + len(m) + } + } + return -1 +} + +// ScanRenderConstructs is every reported construct in one markdown document, ordered +// by (line, construct) — the order the export's findings carry, and the tie-breaker +// that keeps two constructs on one line deterministic across the three SDKs. +func ScanRenderConstructs(text string) []Hit { + starts := lineStartsOf(text) + regions := blockRegions(text, starts) + seen := map[string]bool{} + var hits []Hit + record := func(offset int, construct string) { + line := lineOf(starts, offset) + 1 + key := strconv.Itoa(line) + " " + construct + if seen[key] { + return + } + seen[key] = true + hits = append(hits, Hit{Line: line, Construct: construct}) + } + + for _, r := range regions { + if r.construct != "" { + record(r.start, r.construct) + } + } + + // The inline pass: one left-to-right walk, so the earliest-starting match wins + // every overlap and each match is consumed whole. + i := 0 + for i < len(text) { + if skip := skipRegion(regions, i); skip != i { + i = skip + continue + } + c := text[i] + if c == '\\' && i+1 < len(text) && escapable(text[i+1]) { + i += 2 + continue + } + if c == '`' { + i = afterCodeSpan(text, regions, i) + continue + } + if c == '<' { + if strings.HasPrefix(text[i:], "", i+4) + if closeAt < 0 { + i = len(text) + } else { + i = closeAt + 3 + } + continue + } + if end := inlineHTMLEnd(text, i); end != -1 { + record(i, RawHTML) + i = end + continue + } + i++ + continue + } + if c == '[' && i+1 < len(text) && text[i+1] == '^' { + if m := footnote.FindString(text[i:]); m != "" { + record(i, Footnote) + i += len(m) + continue + } + i++ + continue + } + if c == '$' && i+1 < len(text) && text[i+1] == '$' { + if closeAt := nextMathDelimiter(text, regions, i+2); closeAt != -1 { + record(i, MathBlock) + i = closeAt + 2 + continue + } + // Unpaired: prose, and the scan carries on past it. + i += 2 + continue + } + i++ + } + + sort.SliceStable(hits, func(a, b int) bool { + if hits[a].Line != hits[b].Line { + return hits[a].Line < hits[b].Line + } + return hits[a].Construct < hits[b].Construct + }) + return hits +} + +// Findings is the scan as findings for one document: `warning` severity, the +// repository-relative path the export carries it at, the opening line, and the token. +func Findings(relPath, text string) []findings.Finding { + var out []findings.Finding + for _, hit := range ScanRenderConstructs(text) { + out = append(out, findings.Finding{ + Rule: RenderUnsupportedRule, + Severity: findings.Warning, + Path: relPath, + HasPath: true, + Line: hit.Line, + Construct: hit.Construct, + Message: Message(hit.Construct), + }) + } + return out +} diff --git a/packages/sdk-go/internal/renderlint/renderlint_test.go b/packages/sdk-go/internal/renderlint/renderlint_test.go new file mode 100644 index 0000000..c1cb150 --- /dev/null +++ b/packages/sdk-go/internal/renderlint/renderlint_test.go @@ -0,0 +1,247 @@ +package renderlint + +import ( + "strconv" + "strings" + "testing" +) + +// The scan itself, family by family over the edges the fixtures state in prose: +// what it reports, and — the half a lint lives or dies on — what it stays quiet +// about. Ported from the reference suite's families, case for case; the shared +// render fixtures drive the same detector through the real command. + +// hits is the scan as `line:construct` strings, which is what a family assertion reads. +func hits(text string) []string { + var out []string + for _, h := range ScanRenderConstructs(text) { + out = append(out, strconv.Itoa(h.Line)+":"+h.Construct) + } + return out +} + +func eq(t *testing.T, text string, want ...string) { + t.Helper() + got := hits(text) + if len(got) != len(want) { + t.Fatalf("hits(%q) = %v, want %v", text, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("hits(%q) = %v, want %v", text, got, want) + } + } +} + +func lines(l ...string) string { return strings.Join(l, "\n") } + +// --- family: multi-line HTML blocks ------------------------------------------- + +func TestFamilyHTMLBlocksReportOnceAtTheOpeningLine(t *testing.T) { + // A block runs to the next blank line, so the tags inside it — the closing one + // included — are block content and not a second construct. + eq(t, lines("# Doc", "", `
`, " inner text", "
", "", "after"), "3:raw-html") + // Two blocks separated by a blank line are two constructs. + eq(t, lines("", "", "
a
", "", "
", "
"), "1:raw-html", "5:raw-html") + // A raw-text block (type 1) ends at its closing tag rather than at a blank line, + // so the blank line inside it does not split it into two. + eq(t, lines("", "", "prose"), "1:raw-html") + // Inline raw HTML mid-paragraph is the other form, reported on its own line, and + // a line carrying two tags is still one finding: the line is the unit. + eq(t, "A paragraph with bold and italic in it.\n", "1:raw-html") + // Negative: a document with no HTML at all reports nothing. + eq(t, "# Title\n\nProse with a < less-than and an a > b comparison.\n") +} + +// --- family: excluded regions ------------------------------------------------- + +func TestFamilyExcludedRegions(t *testing.T) { + // Code spans, including the multiple-backtick form. + eq(t, "The tag `
` and `[^ref]` and `$$x$$` are text.\n") + eq(t, "A span with a backtick in it: ``a `` span``.\n") + // Fenced blocks, whatever the info string, and a longer fence carrying a shorter + // one: everything between the delimiters is code. + eq(t, lines("```html", "
", "
", "```")) + eq(t, lines("````markdown", "```html", "x", "```", "````")) + eq(t, lines("~~~", "[^one]: definition", "$$", "x", "$$", "~~~")) + // Comments are the excepted HTML form: nothing inside one is reported, on one + // line or many, at the start of a line or inside prose. + eq(t, lines("", "", "prose")) + eq(t, "Prose with and more prose.\n") + // Positive controls: the same constructs outside a region are reported, so the + // assertions above are the exclusion working rather than a scan that sees nothing. + eq(t, "The tag
and [^ref] and $$x$$ are markup.\n", "1:footnote", "1:math-block", "1:raw-html") + // An unclosed fence excludes the rest of the document, as a renderer reads it. + eq(t, lines("```", "
", "[^one]")) +} + +// --- family: malformed and unpaired forms ------------------------------------- + +func TestFamilyUnpairedOrMalformedIsProse(t *testing.T) { + // `$$` needs an open and a close; a lone delimiter is prose. + eq(t, "A lone delimiter:\n\n$$\n") + eq(t, "$$\na^2 + b^2 = c^2\n$$\n", "1:math-block") + eq(t, "An inline pair: $$e = mc^2$$ mid-sentence.\n", "1:math-block") + // A single `$` is deliberately outside the closed token set. + eq(t, "An amount of $5 and a variable named $path.\n") + // A footnote needs its closing bracket. + eq(t, "An open bracket [^ and nothing closing it.\n") + eq(t, "An empty label [^] is not a footnote either.\n") + eq(t, "A reference[^one] and its definition.\n\n[^one]: The text.\n", "1:footnote", "3:footnote") + // A `<` that opens no valid tag is prose, and a bare tag name is not markup. + eq(t, "Compare a < b, and 3<4, and <-- an arrow.\n") +} + +// --- family: the YAML frontmatter boundary ------------------------------------ + +func TestFamilyFrontmatterBoundary(t *testing.T) { + eq(t, lines("---", "title: A value with
and [^ref] and $$x$$", "---", "", "# Doc", "")) + // A `---` later in a document is a thematic break, so the text after it is + // scanned like any other prose. + eq(t, lines("# Doc", "", "---", "", "Prose with
in it.", ""), "5:raw-html") + // A block that never closes is not frontmatter, so its content is prose — and + // reported, which is the honest read of a document nothing will strip. + eq(t, lines("---", "title:
", "", "# Doc", ""), "2:raw-html") + // Frontmatter opens the FILE or it is not frontmatter: a block one line down is a + // thematic break followed by prose. + eq(t, lines("", "---", "title:
", "---", ""), "3:raw-html") +} + +// --- family: overlaps and same-line ordering ---------------------------------- + +func TestFamilyOverlapsAndSameLineOrdering(t *testing.T) { + // Three constructs on one line, reported in the closed set's alphabetical order — + // the tie-breaker that keeps a same-line group deterministic across the SDKs. + eq(t, "All three: [^b], italic, and $$x + y$$ in one sentence.\n", + "1:footnote", "1:math-block", "1:raw-html") + // A footnote-looking label inside a tag's attribute belongs to the tag: the + // earliest-starting match consumes it, so the line reports raw HTML only. + eq(t, "text\n", "1:raw-html") + // And the other way round: a tag inside a math pair belongs to the pair. + eq(t, "$$ a c $$\n", "1:math-block") + // A math pair spanning lines is attributed to its opening line, and the constructs + // between the delimiters are inside it. + eq(t, lines("$$", "a c [^ref]", "$$", "", "x"), "1:math-block", "5:raw-html") + // Block structure outranks the inline pair, as a renderer reads it: a line OPENING + // with a block tag is an HTML block running to the blank line, so the second + // delimiter is inside it and the first never pairs. + eq(t, lines("$$", "
[^ref]", "$$", "", "x"), "2:raw-html", "5:raw-html") + // Repeats on one line collapse; the same construct on the next line does not. + eq(t, "[^a] and [^b] together.\n[^c] alone.\n", "1:footnote", "2:footnote") +} + +// --- family: backslash escapes ------------------------------------------------ + +func TestFamilyBackslashEscapes(t *testing.T) { + eq(t, "Escaped: \\
and \\bold\\ are prose.\n") + eq(t, "Escaped: \\[^one] in a sentence.\n\n\\[^one]: not a definition.\n") + eq(t, "Escaped math: \\$\\$ a^2 \\$\\$ is prose about the notation.\n") + // An HTML entity spells a character, not an element. + eq(t, "Entities: <div> and &lt; are text.\n") + // Positive controls for each escape above. + eq(t, "Unescaped:
here.\n", "1:raw-html") + eq(t, "Unescaped: [^one] here.\n", "1:footnote") + eq(t, "Unescaped: $$ a^2 $$ here.\n", "1:math-block") + // A backslash before a non-punctuation character is a literal backslash, so the + // construct after it still reports. + eq(t, "A backslash \\n then
.\n", "1:raw-html") +} + +// --- family: the HTML block forms that end mid-line --------------------------- + +func TestFamilyBlockFormsEndingMidLine(t *testing.T) { + // CommonMark type 3: the block ends on the line carrying `?>`, and the WHOLE of + // that line belongs to it — so what follows the terminator there is block content + // rather than a second construct, and the block reports once, at its opening line. + eq(t, lines(" [^after]"), "1:raw-html") + // Type 4 (a declaration) ends at the first `>`, type 5 (CDATA) at `]]>`; what + // follows the block, on a later line, is scanned normally. + eq(t, lines("", "", "[^after]"), "1:raw-html", "3:footnote") + eq(t, lines(" [^after]", "", "prose [^real]"), "1:raw-html", "5:footnote") + // A block whose terminator never arrives runs to the end of the document, exactly + // as the comment form does. + eq(t, lines(" and [^ref].\n", "1:footnote", "1:raw-html") + eq(t, "Escaped \\ here.\n") + eq(t, lines("```", "", "```", "[^after]"), "4:footnote") +} + +// --- family: inline state never crosses a block boundary ---------------------- + +func TestFamilyInlineStateNeverBridgesABlockRegion(t *testing.T) { + // The candidate closer lies beyond a block region, which ended the paragraph the + // run opened in: the backticks are literal at that boundary, so the footnote after + // the region is reported rather than swallowed. + eq(t, lines("Text `open", "", "[^after] and a closer `here"), "3:footnote") + // The same for a `$$` whose apparent mate sits on the far side of the region: an + // unpaired delimiter is prose, and what follows it still reports. + eq(t, lines("$$ open", "", "$$ and [^after]"), "3:footnote") + // Positive controls: inside ONE block, both forms still span lines. + eq(t, lines("A span `over", "two lines` and [^after]"), "2:footnote") + eq(t, lines("$$", "a^2 + b^2", "$$"), "1:math-block") +} + +// --- family: a mate inside an excluded span, and straddling delimiters --------- + +func TestFamilyMateInsideAnExcludedSpan(t *testing.T) { + // The apparent closer is inside an excluded region, so the open never pairs and + // the line is prose about the notation. + eq(t, "$$ open `$$` tail\n") + eq(t, "$$ open tail\n") + // Positive controls: a readable mate pairs, and a real pair after an excluded one + // is still found. + eq(t, "$$ open $$ tail\n", "1:math-block") + eq(t, "`$$` and then a real pair $$x$$\n", "1:math-block") + // Straddling a span's edge, both ways: a footnote whose closing bracket is inside + // a code span still reports — the earliest start wins the overlap — while one that + // OPENS inside the span is span content. + eq(t, "[^one `] and text`\n", "1:footnote") + eq(t, "`[^one` ] tail\n") +} + +// --- family: declaration case, split terminators, and indented openers -------- + +func TestFamilyDeclarationCaseAndTerminators(t *testing.T) { + // ``, so what sits + // inside the consumed span and what trails the terminator on its line are block + // content rather than constructs of their own. + eq(t, lines(" [^tail]", "", "[^after]"), "1:raw-html", "5:footnote") + // Unterminated, the block runs to the end of the document, as the comment form does. + eq(t, lines(" and [^ref].\n", "1:footnote", "1:raw-html") + // The uppercase spellings, block form and inline form: identical treatment, so the + // assertions above are the grammar and not a case accident. + eq(t, lines("", "", "[^after]"), "1:raw-html", "3:footnote") + eq(t, "Prose and [^ref].\n", "1:footnote", "1:raw-html") + // A terminator split across two lines is not a terminator: the CDATA block runs on + // to the contiguous `]]>`, and that whole line is block content. + eq(t, lines(" still inside [^no]", "]]> [^after]", "", "[^real]"), + "1:raw-html", "6:footnote") + // Indentation decides whether a line opens a block at all: a tab is one indent + // character, so a tab-indented opener still opens one, terminator line included. + eq(t, lines("\t [^after]", "", "[^real]"), "1:raw-html", "5:footnote") + // Four leading spaces are indented code, which opens no block: an unterminated + // opener there swallows nothing, and the line after it still reports. + eq(t, lines(" in it.\n") + if len(fs) != 1 { + t.Fatalf("findings = %v", fs) + } + f := fs[0] + if f.Rule != RenderUnsupportedRule || f.Severity != "warning" || f.Path != "docs/render/raw-html.md" || + f.Line != 3 || f.Construct != RawHTML { + t.Fatalf("finding = %+v", f) + } + if f.Message != "`raw-html` is outside the supported rendering subset; see adoption/rendering.md" { + t.Fatalf("message = %q", f.Message) + } +} diff --git a/packages/sdk-go/internal/schemas/schemas.go b/packages/sdk-go/internal/schemas/schemas.go index d8809c4..d1d3681 100644 --- a/packages/sdk-go/internal/schemas/schemas.go +++ b/packages/sdk-go/internal/schemas/schemas.go @@ -25,15 +25,34 @@ import ( var SupportedLines = []string{"1.0"} // SDKVersion is overridable via ldflags; defaults to match Node/Python. -var SDKVersion = "1.3.1" +var SDKVersion = "1.4.0" type CliOption struct { Flags string `json:"flags"` Summary string `json:"summary"` } +// CliGroup is one display section of the command list, in the order help and the +// site show them. +type CliGroup struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// CliExitCode carries the code as a json.Number so help prints the literal the +// asset holds (`0`), never a float rendering of it. +type CliExitCode struct { + Code json.Number `json:"code"` + Meaning string `json:"meaning"` +} + type CliCommand struct { - Name string `json:"name"` + Name string `json:"name"` + // Group is the CliGroup id this command is listed under; exactly one, and + // always a declared id. AliasOf, when set, names the primary command this one + // stands for, itself never an alias. + Group string `json:"group"` + AliasOf string `json:"aliasOf,omitempty"` Summary string `json:"summary"` Usage string `json:"usage"` Description string `json:"description"` @@ -43,12 +62,13 @@ type CliCommand struct { } type CliSpec struct { - Name string `json:"name"` - Summary string `json:"summary"` - Usage string `json:"usage"` - GlobalOptions []CliOption `json:"globalOptions"` - ExitCodes []map[string]any `json:"exitCodes"` - Commands []CliCommand `json:"commands"` + Name string `json:"name"` + Summary string `json:"summary"` + Usage string `json:"usage"` + GlobalOptions []CliOption `json:"globalOptions"` + ExitCodes []CliExitCode `json:"exitCodes"` + Groups []CliGroup `json:"groups"` + Commands []CliCommand `json:"commands"` } func LoadCliSpec() (CliSpec, error) { diff --git a/packages/sdk-go/internal/sourceaudit/audit_test.go b/packages/sdk-go/internal/sourceaudit/audit_test.go new file mode 100644 index 0000000..b8e03b2 --- /dev/null +++ b/packages/sdk-go/internal/sourceaudit/audit_test.go @@ -0,0 +1,535 @@ +// Package sourceaudit is the acceptance check for the write boundary: no production +// source file of this SDK reaches a raw filesystem mutation, or a subprocess that +// could perform one, except at a symbol named below. Every other write goes through +// internal/fsx — the chokepoint and its guarded conveniences — so a new write site is +// contained by construction rather than by remembering to contain it, and a reviewer +// can read the exceptions instead of re-deriving them. docs/practice/trust-boundary.md +// mirrors both lists. +// +// The scan is TYPE-RESOLVED: every package is parsed and type-checked, and the +// QUALIFIER of each selector is resolved to the object it actually names, so a +// mutator is recognized by the package it belongs to rather than by how the call was +// spelled. `import stdos "os"` and `w := os.Rename` are caught; a local variable or +// field named `os`, or a method named `Rename` on this SDK's own types, is not. A dot +// import of a watched package is banned outright — it would put a mutator's bare name +// in scope, which no type check can then attribute. The raw syscall gate is watched +// the same way, so an assembled call cannot slip past the named surface. +// +// The recorded residual, for a regression audit rather than a sandbox: a mutator +// handed across a package boundary as a func VALUE (this SDK passes none), reached +// through `go:linkname` or cgo, or executed by a program a subprocess allowance +// starts. The first is not data flow this scan follows; the last two are the reason +// subprocesses carry their own named list. +package sourceaudit + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "path" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "testing" +) + +// The filesystem mutation surface, by the package that declares it. These are +// package-level functions: a method on an *os.File the caller already holds is not +// listed, because obtaining that file is itself an allowance decision (os.OpenFile, +// os.Create) — copying into a descriptor OpenWriteGuarded returned is the whole point +// of the guarded open. +var mutators = map[string]map[string]bool{ + "os": setOf( + "Chmod", "Chown", "Chtimes", "Create", "CreateTemp", "Lchown", "Link", "Mkdir", + "MkdirAll", "MkdirTemp", "NewFile", "OpenFile", "Remove", "RemoveAll", "Rename", + "Symlink", "Truncate", "WriteFile", + ), + "io/ioutil": setOf("TempDir", "TempFile", "WriteFile"), + "syscall": setOf( + "Chmod", "Chown", "Ftruncate", "Link", "Mkdir", "Mkdirat", "Openat", "Rename", + "Renameat", "Rmdir", "Symlink", "Truncate", "Unlink", "Unlinkat", "Write", + // The raw gate: a syscall assembled by number can be any of the above, and + // nothing below this line can tell which. Each caller is named and reasoned. + "Syscall", "Syscall6", "SyscallN", "RawSyscall", "RawSyscall6", + ), +} + +// Anything that hands work to another program, which can then write whatever it +// likes. +var subprocess = map[string]map[string]bool{ + "os/exec": setOf("Command", "CommandContext"), + "syscall": setOf("Exec", "ForkExec", "StartProcess"), + "os": setOf("StartProcess"), +} + +// Dot-importing any of these would put a bare mutator name in scope, which no type +// check can then attribute to its package. Not a style this SDK uses, so it is +// refused rather than analyzed. +var bannedImports = setOf("os", "os/exec", "io/ioutil", "syscall") + +// The write allow-list, by `file#symbol` — never by whole file, so a future raw +// mutation elsewhere in an allowed file still fails. An entry that matches nothing +// fails too: a stale exception is an exception nobody is checking. +var allowedWrites = map[string]string{ + "internal/fsx/fsx.go#WriteFileGuarded": "the chokepoint itself: the guarded write, judged before it acts", + "internal/fsx/fsx.go#MkdirpGuarded": "the chokepoint itself: the guarded directory establishment", + "internal/fsx/fsx.go#RmGuarded": "the chokepoint itself: the guarded clear", + "internal/fsx/fsx.go#RenameGuarded": "the chokepoint itself: the guarded rename, both ends judged", + "internal/fsx/fsx.go#ChmodGuarded": "the chokepoint itself: the guarded mode change", + "internal/fsx/fsx.go#OpenWriteGuarded": "the chokepoint itself: the guarded destination descriptor", + "internal/fsx/fsx.go#WriteFileAtomicGuarded": "the chokepoint itself: temp sibling plus rename, both ends judged", + "internal/mounts/mounts.go#ExtractProjection": "mounts per-entry protocol, under a store root the chokepoint established", + "internal/mounts/mounts.go#publishCacheEntry": "mounts per-entry protocol: sidecar, marker, publish-by-rename, staging clear", + "internal/mounts/mounts.go#HydrateMounts": "mounts per-entry protocol: clears its own established staging directory", + "internal/mounts/mounts.go#VerifyProjection": "verification staging under the OS temp directory, outside the repository", + "internal/commands/init/init.go#InitLayer": "root bootstrap: creates the selected root before any repository root exists", + "internal/commands/init/init.go#AdoptLayer": "root bootstrap: creates the selected root before any repository root exists", + "internal/cli/tty_darwin.go#fdIsTTY": "the raw syscall gate, for one read-only terminal ioctl (TIOCGETA); writes nothing", + "internal/cli/tty_linux.go#fdIsTTY": "the raw syscall gate, for one read-only terminal ioctl (TCGETS); writes nothing", +} + +// The subprocess allow-list. A child process is outside every guard this SDK can +// enforce, so each caller is named with what it runs and what it may write. +var allowedSubprocesses = map[string]string{ + "internal/git/git.go#run": "read-only git queries (log, ls-files, status) in the host repository", + "internal/mounts/mounts.go#runGitLimited": "the federation resolver: git init/fetch write ONLY into a store or cache root the chokepoint established, plus read-only queries", + "internal/commands/init/init.go#gitConfig": "read-only `git config --get`", + "internal/commands/init/init.go#hooksPathConfig": "read-only `git -C root config core.hooksPath`", + "internal/commands/init/init.go#gitHooksDir": "read-only `git rev-parse --git-path hooks`", + "internal/commands/init/init.go#gitDirs": "read-only `git rev-parse --git-dir --git-common-dir`", + "internal/commands/init/init.go#DefaultHandoffIO": "the handoff IO: launches the agent host the user chose, or runs the command it declares; its writes are that program's, not this SDK's", + "internal/commands/init/init.go#captureRun": "the handoff IO's bounded probe: asks for a version with argv only, cwd-pinned to the repository root, stdin closed, stderr discarded, output and wall time capped while the child runs (passing the cap cancels the run, which terminates the child promptly), and an environment built from empty that REPLACES this process's rather than extending it; it never invokes a package manager's script runner, and it writes nothing", + "internal/commands/init/dependency.go#DefaultDependencyIO": "the declaration offer: runs the repository's OWN package manager add command, argv only and never a shell, and only after the user says yes at init/adopt; its writes are that manager's (manifest and lockfile), not this SDK's", + "internal/commands/serve/serve.go#OpenBrowser": "opens the preview URL in the desktop browser; writes nothing", +} + +func setOf(names ...string) map[string]bool { + out := make(map[string]bool, len(names)) + for _, n := range names { + out[n] = true + } + return out +} + +// hit is one flagged call: where it is (`file#symbol`), on what line, and the name it +// resolved to. +type hit struct { + key string + line int + name string +} + +type result struct { + writes []hit + subprocesses []hit +} + +// stubImporter satisfies the type checker without reading a single dependency from +// disk: every import becomes an empty package under its own path, which is all the +// audit needs, since it asks what a QUALIFIER names, never what the member is. It +// also makes the audit hermetic — no GOROOT parse, no build cache, no network. +type stubImporter struct{} + +func (stubImporter) Import(importPath string) (*types.Package, error) { + pkg := types.NewPackage(importPath, path.Base(importPath)) + pkg.MarkComplete() + return pkg, nil +} + +// scan type-checks one directory's production files and returns every flagged call in +// them, keyed by the module-relative file and the nearest enclosing named function. +func scan(t *testing.T, moduleRoot, dir string, files map[string]string) result { + t.Helper() + fset := token.NewFileSet() + var parsed []*ast.File + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + file, err := parser.ParseFile(fset, name, files[name], parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + parsed = append(parsed, file) + } + conf := types.Config{Importer: stubImporter{}, Error: func(error) {}} + info := &types.Info{Uses: map[*ast.Ident]types.Object{}} + // The check reports errors for every member of a stubbed package; they are + // expected and ignored, and the qualifier resolution the audit reads is recorded + // regardless. + _, _ = conf.Check(dir, fset, parsed, info) + + var out result + for _, file := range parsed { + rel, err := filepath.Rel(moduleRoot, fset.Position(file.Package).Filename) + if err != nil { + t.Fatal(err) + } + rel = filepath.ToSlash(rel) + record := func(into *[]hit, node ast.Node, name string) { + *into = append(*into, hit{key: rel + "#" + enclosingSymbol(file, node), line: fset.Position(node.Pos()).Line, name: name}) + } + // A dot import, or `unsafe`, is refused by its import statement: neither can be + // attributed by the type check that follows. + for _, imp := range file.Imports { + importPath, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + dotted := imp.Name != nil && imp.Name.Name == "." + if bannedImports[importPath] && (dotted || importPath == "unsafe") { + record(&out.writes, imp, "import of "+importPath) + } + } + // The one mutator that is also the ordinary read — os.OpenFile with a literally + // read-only flag — is settled first, on the call, since the exemption is in the + // arguments. Every other appearance of the name, call or function value alike, + // is a hit. + exempt := map[*ast.SelectorExpr]bool{} + ast.Inspect(file, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if sel := readOnlyOpen(call, info); sel != nil { + exempt[sel] = true + } + } + return true + }) + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + pkgName, ok := info.Uses[qualifier].(*types.PkgName) + if !ok { + return true // a value, a field, a shadowing local: not the package + } + importPath := pkgName.Imported().Path() + if mutators[importPath][sel.Sel.Name] && !exempt[sel] { + record(&out.writes, sel, importPath+"."+sel.Sel.Name) + } + if subprocess[importPath][sel.Sel.Name] { + record(&out.subprocesses, sel, importPath+"."+sel.Sel.Name) + } + return true + }) + } + return out +} + +// readOnlyOpen is the callee of an `os.OpenFile(path, os.O_RDONLY, …)` call, else +// nil: the one mutator that is also the ordinary read. Anything else creates or +// truncates, and a flag assembled elsewhere cannot be proven read-only. The flag is +// resolved the same type-resolved way the callee is — a constant merely NAMED +// O_RDONLY, on any package or value of the author's making, exempts nothing. +func readOnlyOpen(call *ast.CallExpr, info *types.Info) *ast.SelectorExpr { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "OpenFile" || len(call.Args) < 2 { + return nil + } + flag, ok := call.Args[1].(*ast.SelectorExpr) + if !ok || flag.Sel.Name != "O_RDONLY" { + return nil + } + qualifier, ok := flag.X.(*ast.Ident) + if !ok { + return nil + } + pkgName, ok := info.Uses[qualifier].(*types.PkgName) + if !ok || pkgName.Imported().Path() != "os" { + return nil + } + return sel +} + +// enclosingSymbol is the nearest named FUNCTION containing node: the declaration or +// method a reader would cite when arguing the exception. A function literal is +// transparent — a raw primitive inside a closure belongs to the function that owns +// it, so naming a closure cannot launder one past the allow-list. +func enclosingSymbol(file *ast.File, node ast.Node) string { + name := "(top level)" + ast.Inspect(file, func(n ast.Node) bool { + decl, ok := n.(*ast.FuncDecl) + if !ok { + return true + } + if decl.Pos() <= node.Pos() && node.End() <= decl.End() { + name = decl.Name.Name + } + return true + }) + return name +} + +// productionPackages is every directory of this module holding production Go files, +// as `dir -> {file: source}`; tests and testdata are excluded explicitly. +func productionPackages(t *testing.T, moduleRoot string) map[string]map[string]string { + t.Helper() + pkgs := map[string]map[string]string{} + err := filepath.WalkDir(moduleRoot, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "testdata" || d.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + body, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + dir := filepath.Dir(p) + if pkgs[dir] == nil { + pkgs[dir] = map[string]string{} + } + pkgs[dir][p] = string(body) + return nil + }) + if err != nil { + t.Fatal(err) + } + return pkgs +} + +func moduleRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate the audit's own source file") + } + return filepath.Dir(filepath.Dir(filepath.Dir(thisFile))) +} + +func audit(t *testing.T) result { + t.Helper() + root := moduleRoot(t) + pkgs := productionPackages(t, root) + if len(pkgs) == 0 { + t.Fatal("the audit loaded no source files") + } + dirs := make([]string, 0, len(pkgs)) + for dir := range pkgs { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + var all result + for _, dir := range dirs { + found := scan(t, root, dir, pkgs[dir]) + all.writes = append(all.writes, found.writes...) + all.subprocesses = append(all.subprocesses, found.subprocesses...) + } + return all +} + +func unexpected(hits []hit, allowed map[string]string) []string { + var out []string + for _, h := range hits { + if _, ok := allowed[h.key]; !ok { + out = append(out, fmt.Sprintf("%s (%s) at line %d", h.key, h.name, h.line)) + } + } + sort.Strings(out) + return out +} + +func stale(hits []hit, allowed map[string]string) []string { + matched := map[string]bool{} + for _, h := range hits { + matched[h.key] = true + } + var out []string + for key := range allowed { + if !matched[key] { + out = append(out, key) + } + } + sort.Strings(out) + return out +} + +func TestNoProductionSourceReachesARawFilesystemMutation(t *testing.T) { + found := audit(t) + if outside := unexpected(found.writes, allowedWrites); len(outside) > 0 { + t.Fatalf("raw filesystem mutations outside the chokepoint:\n %s\n"+ + "Route the write through internal/fsx (WriteFileGuarded, MkdirpGuarded, RmGuarded, "+ + "RenameGuarded, ChmodGuarded, OpenWriteGuarded, WriteFileAtomicGuarded), or argue the "+ + "exception into the allow-list.", strings.Join(outside, "\n ")) + } + if dead := stale(found.writes, allowedWrites); len(dead) > 0 { + t.Fatalf("write allow-list entries matching no symbol (delete them): %s", strings.Join(dead, ", ")) + } +} + +func TestEverySubprocessCallIsANamedReasonedException(t *testing.T) { + found := audit(t) + if outside := unexpected(found.subprocesses, allowedSubprocesses); len(outside) > 0 { + t.Fatalf("subprocess calls outside the allow-list:\n %s\n"+ + "A child process writes wherever it likes; name the caller and say what it runs and "+ + "what it may write.", strings.Join(outside, "\n ")) + } + if dead := stale(found.subprocesses, allowedSubprocesses); len(dead) > 0 { + t.Fatalf("subprocess allow-list entries matching no symbol (delete them): %s", strings.Join(dead, ", ")) + } +} + +// The laundering corpus, permanent. Each probe below is a way of reaching a mutator +// that a name-matching scanner misses; they are analyzed by the same code the +// production scan runs, so the audit's reach is asserted rather than assumed. The +// last two are the controls: ordinary calls that share their names with the watched +// surface and must never be flagged. +var probes = []struct { + name string + source string + writes bool + spawns bool + symbol string // when set, the symbol the hit must be attributed to + comment string +}{ + { + name: "closure.go", + source: `package probe +import "os" +func launderClosure(p string) error { + clear := func() error { return os.RemoveAll(p) } + return clear() +} +`, + writes: true, + symbol: "launderClosure", + comment: "naming a closure does not move the primitive out of the function that owns it", + }, + { + name: "aliased_import.go", + source: `package probe +import stdos "os" +func launderAlias(p string) error { return stdos.Rename(p, p+".bak") } +`, + writes: true, + comment: "the import name is not the package name", + }, + { + name: "function_value.go", + source: `package probe +import "os" +func launderValue(p string) error { + mv := os.Rename + return mv(p, p+".bak") +} +`, + writes: true, + comment: "the mutator is bound to a variable and called through it", + }, + { + name: "dot_import.go", + source: `package probe +import . "os" +func launderDotImport(p string) error { return Rename(p, p+".bak") } +`, + writes: true, + comment: "a dot import puts the bare mutator name in scope", + }, + { + name: "struct_field.go", + source: `package probe +import "os" +type writer struct{ mv func(string, string) error } +func launderField(p string) error { + w := writer{mv: os.Rename} + return w.mv(p, p+".bak") +} +`, + writes: true, + comment: "the mutator is stashed in a struct field", + }, + { + name: "subprocess.go", + source: `package probe +import "os/exec" +func launderSpawn() error { return exec.Command("sh", "-c", "echo hi > /tmp/x").Run() } +`, + spawns: true, + comment: "a child process writes whatever it likes", + }, + { + name: "fake_readonly_flag.go", + source: `package probe +import "os" +type flags struct{ O_RDONLY int } +func launderFlag(p string) (*os.File, error) { + fake := flags{O_RDONLY: os.O_WRONLY | os.O_CREATE} + return os.OpenFile(p, fake.O_RDONLY, 0o644) +} +`, + writes: true, + symbol: "launderFlag", + comment: "a constant merely named O_RDONLY does not make an open read-only", + }, + { + name: "negative_shadow.go", + source: `package probe +type fakeOS struct{} +func (fakeOS) Rename(a, b string) error { return nil } +func (fakeOS) Command(a string) string { return a } +func notAMutation(a, b string) error { + os := fakeOS{} + exec := fakeOS{} + _ = exec.Command(a) + return os.Rename(a, b) +} +`, + comment: "a local value named os or exec is not the package", + }, + { + name: "negative_read.go", + source: `package probe +import ( + "io" + "os" +) +func readOnly(p string) ([]byte, error) { + f, err := os.OpenFile(p, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + return io.ReadAll(f) +} +`, + comment: "a read-only open, and a read helper, are not mutations", + }, +} + +func TestTheAnalyzerSeesThroughEveryKnownLaundering(t *testing.T) { + root := moduleRoot(t) + dir := filepath.Join(root, "internal", "sourceaudit", "__probe") + for _, probe := range probes { + files := map[string]string{filepath.Join(dir, probe.name): probe.source} + found := scan(t, root, dir, files) + gotWrites := len(found.writes) > 0 + gotSpawns := len(found.subprocesses) > 0 + if gotWrites != probe.writes { + t.Fatalf("%s (%s): writes flagged = %v, want %v (%v)", probe.name, probe.comment, gotWrites, probe.writes, found.writes) + } + if gotSpawns != probe.spawns { + t.Fatalf("%s (%s): subprocess flagged = %v, want %v (%v)", probe.name, probe.comment, gotSpawns, probe.spawns, found.subprocesses) + } + if probe.symbol != "" && !strings.HasSuffix(found.writes[0].key, "#"+probe.symbol) { + t.Fatalf("%s (%s): attributed to %q, want the enclosing %q", probe.name, probe.comment, found.writes[0].key, probe.symbol) + } + } +} diff --git a/packages/sdk-go/internal/writeplan/writeplan.go b/packages/sdk-go/internal/writeplan/writeplan.go index 8c4cab4..33cde8d 100644 --- a/packages/sdk-go/internal/writeplan/writeplan.go +++ b/packages/sdk-go/internal/writeplan/writeplan.go @@ -68,7 +68,7 @@ func Build(rootAbs string, writes []PlannedWrite, wontModify, overwrite []string entries = append(entries, PlanEntry{ Rel: rel, Status: WontModify, - Note: "existing file, read-only input — Leji will not modify it", + Note: "existing file, read-only input; Leji will not modify it", }) } return entries diff --git a/packages/sdk-go/package.json b/packages/sdk-go/package.json index 23b5f31..0c949fc 100644 --- a/packages/sdk-go/package.json +++ b/packages/sdk-go/package.json @@ -1,6 +1,6 @@ { "name": "@leji-internal/sdk-go", - "version": "1.3.1", + "version": "1.4.0", "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-py/README.md b/packages/sdk-py/README.md index 06cc180..e0cd75c 100644 --- a/packages/sdk-py/README.md +++ b/packages/sdk-py/README.md @@ -12,6 +12,7 @@ leji index --check # fail when the index is stale leji changelog check # append-only discipline leji freshness # review-horizon report leji conformance # score the layer against its claimed level +leji badge # write the self-attested conformance badge and its markdown leji status # unindexed, dangling, and stale documents leji route # the governed context a task's scope routes to leji viewer # generate the static viewer for the context layer @@ -26,15 +27,23 @@ leji agent --name # bind an additional named agent into the layer leji mounts hydrate # materialize declared federation mounts into the resolver cache leji mounts status # each mount's availability, integrity, and pin ancestry leji mounts locate # resolver state for one mount: projection path, pin, verification +leji mounts update-pin # move one mount's declared pin, verified against the source leji changelog compact # fold the oldest changelog entries into one compaction entry ``` See the full command reference (flags, exit codes, examples) at https://leji.org/cli/. +Inside a repository that declares `leji`, has a project environment inside the +repository (`.venv`, or uv's `UV_PROJECT_ENVIRONMENT`) with a copy meeting the +layer's minimum, `leji` runs that copy; set `LEJI_NO_LOCAL` to any value to run +this one. + Behaviorally identical to the `@leji-org/leji` npm package and the Go SDK: same commands, same flags, same findings, same exit codes (0 clean, 1 findings, 2 -usage error). All three implementations are tested against one shared fixture +usage error); the one runtime-specific behavior is the hand-off above, which the +Node and Python CLIs perform and the Go CLI does not (there, run the pinned copy +with `go tool leji`). All three implementations are tested against one shared fixture suite. Install whichever matches your toolchain; agents and CI see the same tool either way. diff --git a/packages/sdk-py/package.json b/packages/sdk-py/package.json index 69942ee..04bf63f 100644 --- a/packages/sdk-py/package.json +++ b/packages/sdk-py/package.json @@ -1,6 +1,6 @@ { "name": "@leji-internal/sdk-py", - "version": "1.3.1", + "version": "1.4.0", "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 200b7fd..4bcf77f 100644 --- a/packages/sdk-py/pyproject.toml +++ b/packages/sdk-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "leji" -version = "1.3.1" +version = "1.4.0" 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" @@ -47,7 +47,7 @@ Issues = "https://github.com/leji-org/leji/issues" Changelog = "https://github.com/leji-org/leji/blob/main/CHANGELOG.md" [project.scripts] -leji = "leji.cli:main" +leji = "leji.cli:entry" [tool.hatch.build.targets.wheel] packages = ["src/leji"] diff --git a/packages/sdk-py/src/leji/__init__.py b/packages/sdk-py/src/leji/__init__.py index b57d653..cd84b67 100644 --- a/packages/sdk-py/src/leji/__init__.py +++ b/packages/sdk-py/src/leji/__init__.py @@ -4,8 +4,24 @@ implementations are tested against one shared fixture suite. """ +from .badge import ( + DEFAULT_BADGE_OUT, + OUT_RULE, + BadgeResult, + badge_label, + badge_markdown, + badge_run, + render_badge, +) from .changelog import CompactResult, compact_changelog, serialize_changelog from .conformance import ConformanceResult, conformance_report, render_explain +from .dependency import dependency_add_failed, offer_dependency +from .ecosystem import ( + detect_ecosystem, + render_ecosystem_block, + render_ecosystem_line, + runner_argv, +) from .detect import ( HOST_SPECS, DetectedHost, @@ -17,15 +33,13 @@ render_detect, resolve_host_id, ) +from .export_cmd import BuildResult, build_viewer +from .serve_cmd import open_browser, serve_viewer from .viewer_cmd import ( - BuildResult, ViewerResult, build_sidebar, - build_viewer, generate_viewer, - open_browser, resolve_viewer_port, - serve_viewer, ) from .findings import Finding, Severity, sort_findings, summarize from .freshness import FreshnessReport, freshness_report @@ -63,9 +77,11 @@ __all__ = [ "AdoptResult", "AgentResult", + "BadgeResult", "BuildResult", "CompactResult", "ConformanceResult", + "DEFAULT_BADGE_OUT", "DanglingEntry", "DetectResult", "DetectedHost", @@ -78,6 +94,7 @@ "InitResult", "LIVE_STATUSES", "Manifest", + "OUT_RULE", "PlanEntry", "PlannedWrite", "RouteInput", @@ -92,6 +109,9 @@ "adapter_content", "add_agent", "adopt_layer", + "badge_label", + "badge_markdown", + "badge_run", "build_sidebar", "build_viewer", "build_write_plan", @@ -102,6 +122,8 @@ "conformance_report", "content_findings", "detect_hosts", + "dependency_add_failed", + "detect_ecosystem", "detect_layer", "ensure_ci_workflow", "enter_layer", @@ -109,7 +131,12 @@ "freshness_report", "generate_viewer", "handoff_offer", + "render_badge", + "offer_dependency", "render_detect", + "render_ecosystem_block", + "render_ecosystem_line", + "runner_argv", "render_explain", "resolve_host_id", "route", diff --git a/packages/sdk-py/src/leji/_assets/assets-manifest.json b/packages/sdk-py/src/leji/_assets/assets-manifest.json index fc4933a..8f441ca 100644 --- a/packages/sdk-py/src/leji/_assets/assets-manifest.json +++ b/packages/sdk-py/src/leji/_assets/assets-manifest.json @@ -5,24 +5,23 @@ "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:94d2f8503120a19c77e5a742158a790cdce0223a211376fbc34d92b0b3b95c56", + "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07", "templates/README.md": "sha256:3fa28c144a26076cc75dc2a6d23014d61370abcb2073afa7d5bd3f26af4884c7", "templates/agent-profile.md": "sha256:fb1cf77aeaffc10718795231b936a7eab9b54c9221f4de072677e3afd3656545", - "templates/agents/core.md": "sha256:6118a42d72712be08e10ba21843894b6afb895b350af6f5a308375f7d7281c5a", - "templates/boot-profile.md": "sha256:07f4b6a7ca03ffdbb5a2bebfc668560fbbffc6bb81dfc5bde2d775a21f647490", + "templates/agents/core.md": "sha256:496814fd60776312c3d4e96547defa17bcd26c55af6f6c688c22d19c842c0295", + "templates/boot-profile.md": "sha256:e3cfe63f45bc0ce0f761f90542bc8eb2afeca995072d143f2cb9b1efb409d365", "templates/decision-record.md": "sha256:ea122e7ceb5984b1610c66df2503128d619b312b5b5fc57c29f4e017a300c91c", "templates/identity.md": "sha256:dc9b30ab64ba13f27db998c1bb070e5f560b8c442881566f3426fcf637fbfc62", "templates/leji.json": "sha256:4b265b83901e8995a4aa0f81984bda6a4b26373cec15baa7b825e832942d34cc", - "templates/onboarding-brief.md": "sha256:adc7d29025dc020f4a8c8c6d709560169df48b3bc15cee2bd3eedb5f09c4962a", - "templates/viewer/assets/PROVENANCE.txt": "sha256:d07eae4c00e37c93fde3b2978d54ba67a70cd4cd84541c74da3a7eaa85e56e42", + "templates/onboarding-brief.md": "sha256:3a4296e23829ca2cb64a87a3b009ac0e4d9a9539af79e22e3046caf74c45adbf", + "templates/viewer/assets/PROVENANCE.txt": "sha256:75425f589e57e4a3198eafc74b37030a5cfbb434abcb4990bf5d5e28bd808cb0", "templates/viewer/assets/docsify-copy-code.min.js": "sha256:942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", "templates/viewer/assets/docsify-mermaid.js": "sha256:4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", "templates/viewer/assets/docsify-sidebar-collapse.min.css": "sha256:ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", "templates/viewer/assets/docsify-sidebar-collapse.min.js": "sha256:78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", "templates/viewer/assets/docsify.min.js": "sha256:9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", - "templates/viewer/assets/fonts-licenses.txt": "sha256:0a7d0278d1bbf74d54d6b844832e19a4f5ccb817ff24770a6059130dd48758b5", - "templates/viewer/assets/leji-logo.svg": "sha256:85cf45fdc760047cf44fa8d001a247634095a83886d55f866ac699dc44257ca0", + "templates/viewer/assets/leji-logo.svg": "sha256:3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", "templates/viewer/assets/mermaid.min.js": "sha256:217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", "templates/viewer/assets/prism-bash.min.js": "sha256:89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", "templates/viewer/assets/prism-json.min.js": "sha256:41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", @@ -41,10 +40,11 @@ "templates/viewer/assets/source-sans-pro-600-latin-ext.woff2": "sha256:9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", "templates/viewer/assets/source-sans-pro-600-latin.woff2": "sha256:156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", "templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", - "templates/viewer/assets/viewer-boot.js": "sha256:89be9a6cd3c3902b358e7dadcb7ad2ecaf34773c30d1b993af5a8e086db1b611", - "templates/viewer/assets/vue.css": "sha256:9c87099992a5da838a432ffacbdf86705603b916a726c382521d050fbda4564a", + "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/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", - "templates/viewer/index.html": "sha256:d5dd1eca373320b26918ef57836100531ebeb53281fc7198b14e9e8b865a13b6", + "templates/viewer/index.html": "sha256:127dadfdca91e9e93739b4e2898ab1ec33fe0b5658354288d960e3e2989f6998", "templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c" } } diff --git a/packages/sdk-py/src/leji/_assets/cli.json b/packages/sdk-py/src/leji/_assets/cli.json index 72496e2..4f21624 100644 --- a/packages/sdk-py/src/leji/_assets/cli.json +++ b/packages/sdk-py/src/leji/_assets/cli.json @@ -1,11 +1,11 @@ { "name": "leji", - "summary": "Reference CLI for the Leji specification: validate, index, changelog, freshness, conformance, status, route, viewer/view, detect, adopt, init, start, ci, and agent for a shared context layer.", + "summary": "Reference CLI for the Leji specification: validate, index, changelog, freshness, conformance, badge, status, route, mounts, export, viewer/view, detect, adopt, init, start, ci, and agent for a shared context layer.", "usage": "leji [options]", "globalOptions": [ { "flags": "--root ", - "summary": "Repository root to operate on (default: the current directory)." + "summary": "Repository root to operate on (default: the current directory). With the Node and Python CLIs, a root that declares and installs the Leji CLI for that runtime, meeting the layer's minimum, runs that copy." }, { "flags": "--json", @@ -34,9 +34,242 @@ "meaning": "Usage error, or an internal failure (e.g. init refusing to overwrite)." } ], + "groups": [ + { + "id": "start", + "title": "Get started" + }, + { + "id": "everyday", + "title": "Every day" + }, + { + "id": "federation", + "title": "Federation" + }, + { + "id": "viewer", + "title": "Viewer and export" + } + ], "commands": [ + { + "name": "init", + "group": "start", + "summary": "Bootstrap a new context layer from the templates.", + "usage": "leji init [--dir ] [--yes] [--mode ] [--level ] [--name ] [--agent ] [--no-agents] [--dry-run] [--json]", + "description": "Scaffolds a new context layer from the templates.", + "details": [ + "Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the portable entrypoint many agent hosts read, redirecting to the boot profile; `--no-agents` skips it), seeded category documents, a first decision record, an agent onboarding brief, and a generated index, so the scaffold is ready for the CI job `leji ci` writes. At the indexed level it also writes the machine changelog. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", + "`--mode solo` (a team of one) also seeds identity and writing-style starters, maps the practice category, routes identity and writing work in the boot profile, and points the onboarding brief at the owner interview (answer in text or with dropped files).", + "Refuses to overwrite an existing `leji.json`, and refuses when the git tree has uncommitted changes; never overwrites individual files.", + "Reports the repository's dependency ecosystem (its package manager, from the manifest and lockfiles present) and how to declare the Leji CLI as a dev dependency there, so a clean install brings `leji`; on a real terminal it offers to run that manager's own add command, and only on your explicit yes. `--yes`, a non-TTY and `--json` print the command instead of running it, and leji never edits a manifest or lockfile itself.", + "`--dry-run` prints the write plan without writing.", + "Also backs `npm create leji`." + ], + "options": [ + { + "flags": "--dir ", + "summary": "Target directory (default: the current directory)." + }, + { + "flags": "--yes, -y", + "summary": "Accept all defaults; run non-interactively." + }, + { + "flags": "--mode ", + "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." + }, + { + "flags": "--level ", + "summary": "Conformance level to claim: core or indexed (default: core)." + }, + { + "flags": "--name ", + "summary": "Context layer name (default: derived from the directory)." + }, + { + "flags": "--agent ", + "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." + }, + { + "flags": "--no-agents", + "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." + }, + { + "flags": "--dry-run", + "summary": "Print the write plan and exit without creating any files." + } + ], + "examples": [ + "leji init", + "leji init --dry-run", + "leji init --mode solo", + "leji init --agent claude-code" + ] + }, + { + "name": "adopt", + "group": "start", + "summary": "Adopt Leji into an existing repository.", + "usage": "leji adopt [--dir ] [--yes] [--mode ] [--agent ] [--wire-adapters] [--no-agents] [--dry-run] [--json]", + "description": "Brings Leji into a repository that already has docs and agent config.", + "details": [ + "Reuses an existing `docs/` root, migrates any vendor entrypoints (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without modifying the originals, and seeds the scaffold.", + "Writes a generated index, so the adopted context layer is ready for the CI job `leji ci` writes. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", + "`--wire-adapters` converts those entrypoints to one-line redirects, after migrating their content.", + "When no `AGENTS.md` exists, writes a pointer-only one (the portable entrypoint many agent hosts read) redirecting to the boot profile; `--no-agents` skips it, and an existing file is never touched.", + "`--mode solo` (a team of one) also seeds identity and writing-style starters and points the onboarding brief at the owner interview; existing files are never overwritten.", + "Refuses when a `leji.json` exists, `--dry-run` included: a repository that already has a context layer has nothing to adopt. Also refuses when the git tree has uncommitted changes, which `--dry-run` is exempt from because it writes nothing.", + "Reports the repository's dependency ecosystem (its package manager, from the manifest and lockfiles present) and how to declare the Leji CLI as a dev dependency there, so a clean install brings `leji`; on a real terminal it offers to run that manager's own add command, and only on your explicit yes. `--yes`, a non-TTY and `--json` print the command instead of running it, and leji never edits a manifest or lockfile itself.", + "Also backs `npm create leji` on a repository that already carries docs or an agent entrypoint." + ], + "options": [ + { + "flags": "--dir ", + "summary": "Target directory (default: the current directory)." + }, + { + "flags": "--yes, -y", + "summary": "Accept all defaults; run non-interactively." + }, + { + "flags": "--mode ", + "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." + }, + { + "flags": "--agent ", + "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." + }, + { + "flags": "--wire-adapters", + "summary": "Convert present vendor entrypoints to redirects (consented; content migrated first)." + }, + { + "flags": "--no-agents", + "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." + }, + { + "flags": "--dry-run", + "summary": "Print the write plan and exit without changing anything." + } + ], + "examples": [ + "leji adopt", + "leji adopt --dry-run", + "leji adopt --mode solo", + "leji adopt --wire-adapters" + ] + }, + { + "name": "start", + "group": "start", + "summary": "Open a coding agent in this context layer, booted from the boot profile.", + "usage": "leji start [--agent ] [--root ] [--json] [-- ]", + "description": "Detects an installed agent (or use --agent), launches it from the context root, and points it at the boot profile so it loads the team's context first. The agent-facing counterpart to `leji view`. Several detected agents prompt for which; with none detected or in a non-interactive shell, it prints the command to run. Everything after a literal -- passes verbatim to the launched host binary, before the boot prompt. Host-specific flags ride with a pinned host: `leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which could hand the flag to whichever host gets picked.", + "details": [ + "Before the agent starts, prints a Setup block for this clone: whether the Leji CLI this repository declares resolves here and meets the minimum version for the layer's spec line, whether the MCP server is registered for the selected host, whether the shared `.mcp.json` is committed, and whether the pre-commit hook is installed.", + "Each row is personal or shared. Personal state (your host's MCP registration, this clone's `.git` hook) is offered on a real terminal and printed as an exact command otherwise; shared state (the dependency declaration, a committed `.mcp.json`, a hooks directory inside the working tree) is only ever reported, with the command a maintainer runs and commits. A gap never blocks entry: the agent still boots.", + "`--json` makes it report-only: one document with `ready` and the same checks, no prompts and no launch, exit 0 even when `ready` is false. The launch-selection arguments are accepted and have no effect there; an `--agent` naming no launchable host is still a usage error." + ], + "options": [ + { + "flags": "--agent ", + "summary": "Launch a specific host (claude-code or codex) instead of auto-detecting." + }, + { + "flags": "-- ", + "summary": "Pass the remaining arguments verbatim to the launched host binary; pin --agent when they are host-specific." + } + ], + "examples": [ + "leji start", + "leji start --agent codex", + "leji start --agent claude-code -- --chrome" + ] + }, + { + "name": "agent", + "group": "start", + "summary": "Bind an additional named agent into an existing context layer.", + "usage": "leji agent --name [--host ] [--role ] [--root ] [--json]", + "description": "Adds a second (or third) agent to a context layer that already has a `leji.json`.", + "details": [ + "Writes a starter agent profile under the agent-profiles path and binds it in the manifest's agents map via an in-place edit that preserves the rest of the file.", + "Never writes an agent-host entrypoint file. The portable `AGENTS.md` pointer is written by `init` and `adopt` (unless `--no-agents`); single-vendor files like `CLAUDE.md` are only ever converted from an existing one by `adopt --wire-adapters`.", + "`--host` is optional: a host pins the profile to a specific external CLI; with none, it's a host-agnostic resident agent any host can run.", + "The role defaults to reviewer; pass `--role` for a different one.", + "Binding the `default` key prints a note: `agents.default` selects a role profile, it does not load it, so instructions that must apply before every task belong in the boot profile rather than that profile. This note prints once, when the binding is written.", + "Idempotent: an existing profile or binding is left untouched. Requires an existing context layer." + ], + "options": [ + { + "flags": "--name ", + "summary": "Name for the agent; also its profile id and agents-map key (kebab-case)." + }, + { + "flags": "--host ", + "summary": "Optional. Pin the profile to a host (claude-code, codex, copilot, gemini, cursor, windsurf; aliases ok); omit for a host-agnostic resident agent." + }, + { + "flags": "--role ", + "summary": "Role the agent fills (default: reviewer)." + } + ], + "examples": [ + "leji agent --name porter --role porter", + "leji agent --host codex --name reviewer", + "leji agent --host claude-code --name thought-partner --role advisor" + ] + }, + { + "name": "detect", + "group": "start", + "summary": "Detect the coding-agent hosts available on this machine.", + "usage": "leji detect [--root ] [--json]", + "description": "Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable binary, a config file in the repository, or a user-level config directory. Writes nothing; use it to decide which host to open with `leji start --agent ` (launchable hosts today: claude-code and codex; other detected hosts enter the context layer through their vendor-file redirect). Also reports the repository's own dependency ecosystem: the package manager its manifest and lockfiles name, whether the Leji CLI is already declared as a dev dependency there, and the command that would declare it.", + "options": [], + "examples": [ + "leji detect", + "leji detect --json" + ] + }, + { + "name": "ci", + "group": "start", + "summary": "Add a CI workflow that runs leji validate and index --check on every change.", + "usage": "leji ci [--provider ] [--hooks] [--root ] [--json]", + "description": "Adds a CI job that runs `leji validate` and `leji index --check` on every change, so your context layer stays honest in CI (the same two gates the `--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place is left untouched.", + "details": [ + "The provider is inferred from the `origin` remote (a github.com host selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure Pipelines) and falls back to GitHub when the remote names none; `--provider` overrides the inference, and CircleCI is never inferred.", + "GitHub: writes its own workflow at `.github/workflows/leji.yml`.", + "GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if it's absent.", + "CircleCI: writes `.circleci/config.yml` if absent; when a config already exists that leji did not generate, prints a snippet to add by hand instead of editing it.", + "Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not auto-discover it, so activation is manual: create a pipeline that points at the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), then add a build-validation branch policy on `main` for pull-request checks. This activation note prints once, on first creation.", + "Local hook (`--hooks`): writes a managed pre-commit running `leji validate` and `leji index --check` through the same detected runner the CI job uses (`'pnpm' 'exec' 'leji' validate`, and so on), each argument single-quoted for the shell; a repository that does not declare the CLI runs the `leji` on PATH. `core.hooksPath` is detected, so a husky repo gets a managed block merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an existing unmanaged hook is never touched, its snippet printed to add by hand.", + "Local-first, through the package manager this repository actually uses: `leji ci` detects it from the manifest and lockfiles present, and when the repository DECLARES the Leji CLI and carries that manager's lock evidence the generated job installs its locked dependencies and runs the local binary (`corepack enable && pnpm install --frozen-lockfile` then `pnpm exec leji`, `uv sync --locked` then `uv run leji`, `go mod download` then `go tool leji`, and so on). Everything else takes a fallback that needs no manifest: `npx @leji-org/leji@1` for Node, several ecosystems and none; `pip install 'leji>=1,<2'` for Python; `go install .../cmd/leji@latest` for Go. A bootstrap tool the job installs unpinned (poetry, pdm, pipenv, uv outside GitHub) is disclosed in one comment line.", + "Generated files carry the marker `# generated by leji ci (managed) v2`. A re-run replaces a whole file (GitHub, CircleCI, Azure) only when its bytes are ones leji generated, in this release or an earlier one, so a manager change or an upgrade refreshes the job; a generated file you edited, and any file you wrote yourself, are left untouched with a snippet to add by hand. Editing the file, or deleting the marker, is the opt-out. GitLab owns only its marker-delimited block inside `.gitlab-ci.yml`." + ], + "options": [ + { + "flags": "--hooks", + "summary": "Write a managed local pre-commit running validate + index --check; core.hooksPath is detected, so a husky repo gets a managed block in .husky/pre-commit, and an existing unmanaged hook is left untouched with the snippet printed." + }, + { + "flags": "--provider ", + "summary": "CI provider: github (default when no remote is recognizable), gitlab, circleci, or azure. Without this flag the provider is inferred from the origin remote." + } + ], + "examples": [ + "leji ci", + "leji ci --provider gitlab", + "leji ci --provider azure", + "leji ci --hooks" + ] + }, { "name": "validate", + "group": "everyday", "summary": "Validate the context layer: manifest, artifacts, frontmatter, and lint rules.", "usage": "leji validate [--content] [--federation [--paths ]] [--root ] [--json]", "description": "Loads leji.json and checks it against the schemas and the lint rules: declared files exist, categories are populated, vendor entrypoints redirect to the boot profile, frontmatter is valid, and (per the claimed conformance level) the index is current and the changelog is append-only. One of the two gates the generated CI runs, beside `leji index --check`. With --content it also runs a warning-only content lint (placeholder text, generic boot identity, thin categories) that never errors and never affects a conformance level.", @@ -62,6 +295,7 @@ }, { "name": "index", + "group": "everyday", "summary": "Generate the context index at the declared path, or verify it is current.", "usage": "leji index [--check] [--root ] [--json]", "description": "Resolves the category index files to the documents they list and writes the context index to machine.indexPath. Ids are carried across a move when the move is unambiguous: a document whose path changes keeps its id if its content is unchanged and that content is unique in the context layer. A move that also edits the content, or that moves one of several byte-identical documents, cannot be carried and mints a fresh id. Declare a frontmatter id to make a document's id survive any move; that is the only unconditional guarantee. With --check it writes nothing and instead fails when the stored index no longer matches what the index files resolve to (a stale index is a hard failure).", @@ -77,81 +311,82 @@ ] }, { - "name": "changelog check", - "summary": "Verify the machine changelog: schema and append-only discipline.", - "usage": "leji changelog check [--strict] [--root ] [--json]", - "description": "Validates the declared changelog against its schema and checks append-only discipline against the committed state of the file at HEAD: surviving entries are immutable, and entries may be removed only from the oldest end and only alongside a compaction entry. The comparison is against HEAD, so it catches an uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI checkout the working tree is HEAD, so it does not by itself detect a rewrite that arrives already committed. Reviewing the diff covers that. Without git the discipline is unverifiable and reported as a warning.", + "name": "status", + "group": "everyday", + "summary": "Report unindexed, dangling, and stale documents in the context layer.", + "usage": "leji status [--strict] [--root ] [--json]", + "description": "Informational health report: markdown under the context root that no category index lists (reference content), index entries whose listed path does not resolve (dangling), and stored-index paths the index files no longer resolve to (stale). It also reports shadowed entries and skipped READMEs, which are informational only, and whether the context layer at HEAD would project completely if a host mounted it (the closure enumerated, the failure detail, or no commit to judge). Report-only by default; exit 0. With --strict, exits nonzero when an unindexed, dangling, stale, or pending document is flagged (shadowed and skipped-README entries never fail the run), for CI use.", "options": [ { "flags": "--strict", - "summary": "Treat an unverifiable append-only check (no git baseline) as an error." + "summary": "Exit nonzero when an unindexed, dangling, stale, or pending document is flagged, for CI." } ], "examples": [ - "leji changelog check", - "leji changelog check --strict" + "leji status", + "leji status --strict --json" ] }, { - "name": "changelog compact", - "summary": "Fold the oldest changelog entries into a single compaction entry.", - "usage": "leji changelog compact [--keep ] [--before ] [--root ] [--json]", - "description": "Compacts the oldest end of the machine changelog, folding entries into a single compaction entry that records how many were folded and the id range removed.", - "details": [ - "Selection: `--keep ` folds every entry except the newest n; `--before ` folds entries dated before the given day. With both, an entry folds only if it satisfies both (the intersection).", - "At least one of `--keep` or `--before` is required.", - "The folded set is always a contiguous run from the oldest end, so the result still satisfies the append-only discipline that `leji changelog check` enforces." - ], + "name": "conformance", + "group": "everyday", + "summary": "Score the context layer against its claimed conformance level.", + "usage": "leji conformance [--explain] [--federation verify] [--root ] [--json]", + "description": "Runs the `core`, `indexed`, `governed`, and `federated` checklists. Machine-checkable items pass or fail; process items (review gate, CI, external consumers) are reported as manual. A machine failure at or below the claimed level is an error; evidence this run could not obtain reports `unknown`, which caps the verified level without refuting the claim. With --explain it also prints what it would take to reach the next level.", "options": [ { - "flags": "--keep ", - "summary": "Keep the newest n entries; fold everything older. Must be a positive integer." + "flags": "--explain", + "summary": "Print actionable guidance for reaching the next conformance level." }, { - "flags": "--before ", - "summary": "Fold entries dated strictly before this YYYY-MM-DD day." + "flags": "--federation verify", + "summary": "Run the networked pin-reachability probe against each mount's source (git ls-remote + witness-ref ancestry). Without it the pin-reachable item reports unknown, which never awards the federated level." } ], "examples": [ - "leji changelog compact --keep 50", - "leji changelog compact --before 2026-01-01", - "leji changelog compact --keep 50 --before 2026-01-01" + "leji conformance", + "leji conformance --explain", + "leji conformance --json" ] }, { - "name": "freshness", - "summary": "Report review horizons across category documents and agent profiles.", - "usage": "leji freshness [--strict] [--root ] [--json]", - "description": "Lists documents whose freshness.reviewAfter horizon has passed (expired) or falls within the next 30 days (upcoming). Report-only by default; expired horizons are warnings.", + "name": "badge", + "group": "everyday", + "summary": "Write the self-attested conformance badge for this repository.", + "usage": "leji badge [--root ] [--out ] [--json]", + "description": "Writes one SVG (default: leji-badge.svg at the repository root) and prints the markdown line that embeds it. Self-attested: the badge states the level `leji conformance` verified in this offline run, never more than the layer claims and possibly less, and a claim this run could not confirm is named beside it. Nothing is sent anywhere and no service or registry is involved; the bytes are constants, and the file is yours to commit. A run with an error finding, or one that verified no level at all, writes nothing and exits 1. An existing target is replaced only when its bytes are a badge this command wrote, which is how a level change regenerates; any other file is left untouched and the run refuses.", "options": [ { - "flags": "--strict", - "summary": "Treat expired horizons as errors instead of warnings." + "flags": "--out ", + "summary": "Where to write the badge (default: leji-badge.svg). A repository-relative POSIX path over [A-Za-z0-9._/-] with no \"..\" segment, ending .svg, resolving inside the repository and never inside .leji/." } ], "examples": [ - "leji freshness", - "leji freshness --strict --json" + "leji badge", + "leji badge --out docs/badge.svg", + "leji badge --json" ] }, { - "name": "status", - "summary": "Report unindexed, dangling, and stale documents in the context layer.", - "usage": "leji status [--strict] [--root ] [--json]", - "description": "Informational health report: markdown under the context root that no category index lists (reference content), index entries whose listed path does not resolve (dangling), and stored-index paths the index files no longer resolve to (stale). It also reports shadowed entries and skipped READMEs, which are informational only, and whether the context layer at HEAD would project completely if a host mounted it (the closure enumerated, the failure detail, or no commit to judge). Report-only by default; exit 0. With --strict, exits nonzero when an unindexed, dangling, stale, or pending document is flagged (shadowed and skipped-README entries never fail the run), for CI use.", + "name": "freshness", + "group": "everyday", + "summary": "Report review horizons across category documents and agent profiles.", + "usage": "leji freshness [--strict] [--root ] [--json]", + "description": "Lists documents whose freshness.reviewAfter horizon has passed (expired) or falls within the next 30 days (upcoming). Report-only by default; expired horizons are warnings.", "options": [ { "flags": "--strict", - "summary": "Exit nonzero when an unindexed, dangling, stale, or pending document is flagged, for CI." + "summary": "Treat expired horizons as errors instead of warnings." } ], "examples": [ - "leji status", - "leji status --strict --json" + "leji freshness", + "leji freshness --strict --json" ] }, { "name": "route", + "group": "everyday", "summary": "Show the governed context a task's scope routes to.", "usage": "leji route [--paths ] [--categories ] [--topics ]... [--as-of ] [--root ] [--json]", "description": "Read-only: given a task's scope (repository-relative paths it reads or changes, plus any categories and topics it names), print the slice of governed context that scope selects per the Task routing algorithm. Paths select the governed entries that contain them or are contained by them, and a path that is itself a governed document signals that document's category for decision and mount matching without expanding it; only a category the task explicitly names expands that category's intent documents and record candidates. Topics select sibling mounts and nothing else. Prints the expanded categories and the signalled ones, the governed documents (with each document's review horizon and whether it has expired), the record candidates a reader loads by judgment, the live decision records routed to the task, and the sibling mounts the supplied category and topic signals match. It computes the scope-dependent portion only: the boot profile's unconditional load set and the active agent profile's requiredRead are the caller's baseline and are never emitted here. Reads and reports context; it never executes a task.", @@ -188,28 +423,52 @@ ] }, { - "name": "conformance", - "summary": "Score the context layer against its claimed conformance level.", - "usage": "leji conformance [--explain] [--federation verify] [--root ] [--json]", - "description": "Runs the `core`, `indexed`, `governed`, and `federated` checklists. Machine-checkable items pass or fail; process items (review gate, CI, external consumers) are reported as manual. A machine failure at or below the claimed level is an error; evidence this run could not obtain reports `unknown`, which caps the verified level without refuting the claim. With --explain it also prints what it would take to reach the next level.", + "name": "changelog check", + "group": "everyday", + "summary": "Verify the machine changelog: schema and append-only discipline.", + "usage": "leji changelog check [--strict] [--root ] [--json]", + "description": "Validates the declared changelog against its schema and checks append-only discipline against the committed state of the file at HEAD: surviving entries are immutable, and entries may be removed only from the oldest end and only alongside a compaction entry. The comparison is against HEAD, so it catches an uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI checkout the working tree is HEAD, so it does not by itself detect a rewrite that arrives already committed. Reviewing the diff covers that. Without git the discipline is unverifiable and reported as a warning.", "options": [ { - "flags": "--explain", - "summary": "Print actionable guidance for reaching the next conformance level." + "flags": "--strict", + "summary": "Treat an unverifiable append-only check (no git baseline) as an error." + } + ], + "examples": [ + "leji changelog check", + "leji changelog check --strict" + ] + }, + { + "name": "changelog compact", + "group": "everyday", + "summary": "Fold the oldest changelog entries into a single compaction entry.", + "usage": "leji changelog compact [--keep ] [--before ] [--root ] [--json]", + "description": "Compacts the oldest end of the machine changelog, folding entries into a single compaction entry that records how many were folded and the id range removed.", + "details": [ + "Selection: `--keep ` folds every entry except the newest n; `--before ` folds entries dated before the given day. With both, an entry folds only if it satisfies both (the intersection).", + "At least one of `--keep` or `--before` is required.", + "The folded set is always a contiguous run from the oldest end, so the result still satisfies the append-only discipline that `leji changelog check` enforces." + ], + "options": [ + { + "flags": "--keep ", + "summary": "Keep the newest n entries; fold everything older. Must be a positive integer." }, { - "flags": "--federation verify", - "summary": "Run the networked pin-reachability probe against each mount's source (git ls-remote + witness-ref ancestry). Without it the pin-reachable item reports unknown, which never awards the federated level." + "flags": "--before ", + "summary": "Fold entries dated strictly before this YYYY-MM-DD day." } ], "examples": [ - "leji conformance", - "leji conformance --explain", - "leji conformance --json" + "leji changelog compact --keep 50", + "leji changelog compact --before 2026-01-01", + "leji changelog compact --keep 50 --before 2026-01-01" ] }, { "name": "mounts hydrate", + "group": "federation", "summary": "Materialize declared federation mounts into the resolver cache.", "usage": "leji mounts hydrate [--fetch] [--root ] [--json]", "description": "For each declared federation mount, resolves the pinned commit from a local object store (an explicit hint in .leji/mounts.local.json, the resolver-managed store, or a unique matching submodule's object database) and extracts the sibling's layer projection into the gitignored cache under .leji/mounts/. The projection is the deduplicated union of everything the sibling's own manifest makes readable at the pin: the root leji.json, the tree under its declared context root, its boot profile, its machine index and changelog files when present, its agent-profiles and decision-records trees when present, every agent profile its agents map binds, every category index file, and every governed path its pinned generated index lists, wherever those live. The failure boundary follows the same line: a referenced or schema-required file absent at the pin (the boot profile, a category index, a bound agent profile, an indexed governed path) fails the projection naming the declaring artifact and the missing path, while an absent directory or an absent machine artifact contributes nothing and fails nothing. The only mutating mounts command, and offline by default: --fetch establishes the resolver-managed store for every declared mount, including one a hint already resolves, fetching the pin from the declared source, retaining it under refs/leji-pin/v1/, and refreshing the managed witness under refs/leji-witness/v1/ (the only writer of that namespace, since `mounts status` never fetches). Best-effort: an unavailable mount is reported and skipped (degraded knowledge, never a failed run); the exit code reflects declaration, safety, or projection errors only.", @@ -226,6 +485,7 @@ }, { "name": "mounts status", + "group": "federation", "summary": "Report each mount's availability, integrity, and pin ancestry.", "usage": "leji mounts status [--check-integrity] [--root ] [--json]", "description": "Read-only diagnostics for the declared federation mounts: whether the pinned projection is present in the cache, and an ancestry-aware pin report against the declared witness ref (trackingRef) computed from a reachable local object store: up-to-date, behind N, ahead, diverged, unrelated, or unknown, always naming the compared ref, the category of repository the comparison ran in (comparisonRepository: managed-store, hint, or submodule), whether the witness was the resolver's own ref or one it does not own (witnessProvenance), the observation time, and ancestry completeness. --check-integrity additionally re-derives the projection from the object store and compares it byte-for-byte (paths, modes, symlinks) against the cache. Never mutates and never touches the network.", @@ -242,20 +502,52 @@ }, { "name": "mounts locate", + "group": "federation", "summary": "Print resolver state for one mount: projection path, pin, verification.", "usage": "leji mounts locate [--root ] [--json]", - "description": "Resolves a declared mount's hydrated projection through resolver state (never by inferring cache paths): the projection directory, the pin, whether the bytes are present, and whether they verified against a reachable object store this run. Readers obtain the mounted content's location from this command; a projection that cannot be verified is reported as present but unverified. Exits 0 when the projection is present, 1 otherwise.", + "description": "Resolves a declared mount's hydrated projection through resolver state (never by inferring cache paths): the projection directory, the pin, whether the bytes are present, and whether they verified this run. Readers obtain the mounted content's location from this command; a projection that cannot be verified is reported as present but unverified, which includes the case where a verification prerequisite (a reachable object store, a resolvable pin, a writable temp dir) is unavailable. Exits 0 when the projection is present, 1 otherwise.", "options": [], "examples": [ "leji mounts locate product-context", "leji mounts locate product-context --json" ] }, + { + "name": "mounts update-pin", + "group": "federation", + "summary": "Move a declared mount's pin forward to a witnessed commit, showing the comparison first.", + "usage": "leji mounts update-pin [--to ] [--allow-non-fast-forward] [--fetch] [--dry-run] [--root ] [--json]", + "description": "Rewrites one declared federation mount's pin in leji.json, after printing where that pin stands against its tracking ref. Offline by default: the target is the last successfully observed witness in a reachable object store (the resolver-managed store first, then a hint or a unique matching submodule holding both the pin and the ref), never a claim that the source was looked at during this run. --fetch observes the declared source and nothing else, in three acts: retain the current pin in the resolver-managed store, refresh the managed witness ref once, and retain the target once the comparison has passed; any of them failing refuses the move with a stable reason and leaves leji.json untouched, though objects and refs already fetched stay in the managed store. With no trackingRef declared the run refuses offline, and under --fetch resolves the source's advertised default branch for this run and reports it as the compared ref. The pin moves forward only: a target that is not a descendant of the current pin is refused unless BOTH --to and --allow-non-fast-forward are given, which is recorded as a warning and as override in --json; neither flag bypasses a repository whose ancestry is incomplete. --to takes a full 40- or 64-character lowercase hex commit id the comparison repository already holds. --dry-run computes and prints everything and writes no manifest byte; combined with --fetch it still performs that flag's store and network acts, so fetched objects and refs land in the managed store. Only the pin's own bytes are replaced, so field order, formatting and unmodeled keys survive. Hydration is a separate step: the run prints the leji mounts hydrate command that materializes the new pin, and the cache entry for the old pin is left in place for you to remove by hand. Exit 0 when the pin was updated, was already the target, or the run was a dry run; 1 when the move was refused with a stable reason; 2 for a usage error, or when the addressed pin cannot be located in leji.json.", + "options": [ + { + "flags": "--to ", + "summary": "Move to this exact commit instead of the witness tip; it must already be held by the comparison repository." + }, + { + "flags": "--allow-non-fast-forward", + "summary": "Permit a target that is not a descendant of the current pin. Valid only with --to, and always warned." + }, + { + "flags": "--fetch", + "summary": "Observe the declared source: retain the current pin, refresh the managed witness ref, and retain the target." + }, + { + "flags": "--dry-run", + "summary": "Show the comparison and what would change; write no manifest byte. With --fetch, the store and network acts still happen." + } + ], + "examples": [ + "leji mounts update-pin product-context", + "leji mounts update-pin product-context --fetch --dry-run", + "leji mounts update-pin product-context --to 7d3f2a19c4e8b6a0d5f1c2e9b8a7f6d5c4b3a2e1" + ] + }, { "name": "viewer", + "group": "viewer", "summary": "Generate the static viewer for the context layer.", "usage": "leji viewer [--root ] [--json]", - "description": "Projects the context index into a browsable Docsify viewer: writes a frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored viewer assets into the context layer's contained viewer directory. Presentation is non-normative; this is the reference projection. Generates only; use `leji viewer serve` (or `leji view`) to preview it locally, and `leji viewer build` to export a self-contained copy.", + "description": "Projects the context index into a browsable Docsify viewer: writes a frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored viewer assets into the context layer's contained viewer directory. Presentation is non-normative; this is the reference projection. Generates only; use `leji viewer serve` (or `leji view`) to preview it locally, and `leji export` (spelled `leji viewer build` inside the viewer subsystem) to write a self-contained static site.", "options": [], "examples": [ "leji viewer", @@ -264,6 +556,7 @@ }, { "name": "viewer serve", + "group": "viewer", "summary": "Generate the viewer and serve it locally.", "usage": "leji viewer serve [--port ] [--open] [--root ] [--json]", "description": "Generates the viewer, then serves it on localhost (a local preview, never hosting) at the web root. With --open it also opens your default browser at the viewer.", @@ -283,24 +576,10 @@ "leji viewer serve --port 0" ] }, - { - "name": "viewer build", - "summary": "Export a self-contained static viewer folder for internal hosting.", - "usage": "leji viewer build [--out ] [--root ] [--json]", - "description": "Regenerates the viewer and materializes it into a standalone static folder (default: .leji/viewer-dist/, kept out of git) that any host serves as-is. A custom --out must resolve inside the repository. The exported index.html warns that a context layer is sensitive and should be hosted behind internal authentication, not a public bucket.", - "options": [ - { - "flags": "--out ", - "summary": "Output directory for the export (default: .leji/viewer-dist inside the context root; must resolve inside the repository)." - } - ], - "examples": [ - "leji viewer build", - "leji viewer build --out dist/site" - ] - }, { "name": "view", + "group": "viewer", + "aliasOf": "viewer serve", "summary": "Alias for `leji viewer serve` (and opens the browser).", "usage": "leji view [--port ] [--root ]", "description": "One-word shortcut to browse the context layer: generates the viewer, serves it on localhost, and opens your default browser. Equivalent to `leji viewer serve --open`.", @@ -316,201 +595,47 @@ ] }, { - "name": "start", - "summary": "Open a coding agent in this context layer, booted from the boot profile.", - "usage": "leji start [--agent ] [--root ] [-- ]", - "description": "Detects an installed agent (or use --agent), launches it from the context root, and points it at the boot profile so it loads the team's context first. The agent-facing counterpart to `leji view`. Several detected agents prompt for which; with none detected or in a non-interactive shell, it prints the command to run. Everything after a literal -- passes verbatim to the launched host binary, before the boot prompt. Host-specific flags ride with a pinned host: `leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which could hand the flag to whichever host gets picked.", - "options": [ - { - "flags": "--agent ", - "summary": "Launch a specific host (claude-code or codex) instead of auto-detecting." - }, - { - "flags": "-- ", - "summary": "Pass the remaining arguments verbatim to the launched host binary; pin --agent when they are host-specific." - } - ], - "examples": [ - "leji start", - "leji start --agent codex", - "leji start --agent claude-code -- --chrome" - ] - }, - { - "name": "detect", - "summary": "Detect the coding-agent hosts available on this machine.", - "usage": "leji detect [--root ] [--json]", - "description": "Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable binary, a config file in the repository, or a user-level config directory. Writes nothing; use it to decide which host to open with `leji start --agent ` (launchable hosts today: claude-code and codex; other detected hosts enter the context layer through their vendor-file redirect).", - "options": [], - "examples": [ - "leji detect", - "leji detect --json" - ] - }, - { - "name": "adopt", - "summary": "Adopt Leji into an existing repository.", - "usage": "leji adopt [--dir ] [--yes] [--mode ] [--agent ] [--wire-adapters] [--no-agents] [--dry-run]", - "description": "Brings Leji into a repository that already has docs and agent config.", - "details": [ - "Reuses an existing `docs/` root, migrates any vendor entrypoints (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without modifying the originals, and seeds the scaffold.", - "Writes a generated index, so the adopted context layer is ready for the CI job `leji ci` writes. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", - "`--wire-adapters` converts those entrypoints to one-line redirects, after migrating their content.", - "When no `AGENTS.md` exists, writes a pointer-only one (the portable entrypoint many agent hosts read) redirecting to the boot profile; `--no-agents` skips it, and an existing file is never touched.", - "`--mode solo` (a team of one) also seeds identity and writing-style starters and points the onboarding brief at the owner interview; existing files are never overwritten.", - "Refuses when a `leji.json` exists, `--dry-run` included: a repository that already has a context layer has nothing to adopt. Also refuses when the git tree has uncommitted changes, which `--dry-run` is exempt from because it writes nothing." - ], + "name": "export", + "group": "viewer", + "summary": "Export the context layer as a self-contained static site.", + "usage": "leji export [--out ] [--strict] [--root ] [--json]", + "description": "Regenerates the viewer chrome, then writes the static site from the layer on disk (default: .leji/dist/, kept out of git), complete on its own and servable as-is, including under a subpath. Everything the site needs travels with it: nothing is read from anywhere but the layer when it is written, and nothing is read from anywhere but the site's own files when it is opened. `leji viewer build` is the viewer subsystem's name for this same operation, beside `leji viewer serve`; both names are permanently supported and behave identically. A custom --out must resolve inside the repository, and never inside .leji/ except exactly .leji/dist. The exported index.html warns that a context layer is sensitive and belongs behind internal authentication, not in a public bucket.", "options": [ { - "flags": "--dir ", - "summary": "Target directory (default: the current directory)." - }, - { - "flags": "--yes, -y", - "summary": "Accept all defaults; run non-interactively." - }, - { - "flags": "--mode ", - "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." - }, - { - "flags": "--agent ", - "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." - }, - { - "flags": "--wire-adapters", - "summary": "Convert present vendor entrypoints to redirects (consented; content migrated first)." - }, - { - "flags": "--no-agents", - "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." - }, - { - "flags": "--dry-run", - "summary": "Print the write plan and exit without changing anything." - } - ], - "examples": [ - "leji adopt", - "leji adopt --dry-run", - "leji adopt --mode solo", - "leji adopt --wire-adapters" - ] - }, - { - "name": "init", - "summary": "Bootstrap a new context layer from the templates.", - "usage": "leji init [--dir ] [--yes] [--mode ] [--level ] [--name ] [--agent ] [--no-agents] [--dry-run]", - "description": "Scaffolds a new context layer from the templates.", - "details": [ - "Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the portable entrypoint many agent hosts read, redirecting to the boot profile; `--no-agents` skips it), seeded category documents, a first decision record, an agent onboarding brief, and a generated index, so the scaffold is ready for the CI job `leji ci` writes. At the indexed level it also writes the machine changelog. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", - "`--mode solo` (a team of one) also seeds identity and writing-style starters, maps the practice category, routes identity and writing work in the boot profile, and points the onboarding brief at the owner interview (answer in text or with dropped files).", - "Refuses to overwrite an existing `leji.json`, and refuses when the git tree has uncommitted changes; never overwrites individual files.", - "`--dry-run` prints the write plan without writing.", - "Also backs `npm create leji`." - ], - "options": [ - { - "flags": "--dir ", - "summary": "Target directory (default: the current directory)." - }, - { - "flags": "--yes, -y", - "summary": "Accept all defaults; run non-interactively." - }, - { - "flags": "--mode ", - "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." - }, - { - "flags": "--level ", - "summary": "Conformance level to claim: core or indexed (default: core)." - }, - { - "flags": "--name ", - "summary": "Context layer name (default: derived from the directory)." - }, - { - "flags": "--agent ", - "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." - }, - { - "flags": "--no-agents", - "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." - }, - { - "flags": "--dry-run", - "summary": "Print the write plan and exit without creating any files." - } - ], - "examples": [ - "leji init", - "leji init --dry-run", - "leji init --mode solo", - "leji init --agent claude-code" - ] - }, - { - "name": "ci", - "summary": "Add a CI workflow that runs leji validate and index --check on every change.", - "usage": "leji ci [--provider ] [--hooks] [--root ] [--json]", - "description": "Adds a CI job that runs `leji validate` and `leji index --check` on every change, so your context layer stays honest in CI (the same two gates the `--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place is left untouched.", - "details": [ - "The provider is inferred from the `origin` remote (a github.com host selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure Pipelines) and falls back to GitHub when the remote names none; `--provider` overrides the inference, and CircleCI is never inferred.", - "GitHub: writes its own workflow at `.github/workflows/leji.yml`.", - "GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if it's absent.", - "CircleCI: writes `.circleci/config.yml` if absent; when a config already exists, prints a snippet to add by hand instead of editing it.", - "Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not auto-discover it, so activation is manual: create a pipeline that points at the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), then add a build-validation branch policy on `main` for pull-request checks. This activation note prints once, on first creation.", - "Local hook (`--hooks`): writes a managed pre-commit running `leji validate` and `leji index --check`. `core.hooksPath` is detected, so a husky repo gets a managed block merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an existing unmanaged hook is never touched, its snippet printed to add by hand.", - "Local-first: when the repository declares `@leji-org/leji` in its package.json, the generated CI job runs that lockfile-pinned install (`npm ci`, then `npx --no-install @leji-org/leji` for `validate` and `index --check`); a repository without it falls back to `npx @leji-org/leji@1`. The generated hook independently prefers a repo-local `node_modules/.bin/leji` when present, else the `leji` on PATH (it does not run `npm ci` or read the dependency declaration)." - ], - "options": [ - { - "flags": "--hooks", - "summary": "Write a managed local pre-commit running validate + index --check; core.hooksPath is detected, so a husky repo gets a managed block in .husky/pre-commit, and an existing unmanaged hook is left untouched with the snippet printed." + "flags": "--out ", + "summary": "Output directory for the export (default: .leji/dist; must resolve inside the repository, and never inside .leji/ except exactly .leji/dist)." }, { - "flags": "--provider ", - "summary": "CI provider: github (default when no remote is recognizable), gitlab, circleci, or azure. Without this flag the provider is inferred from the origin remote." + "flags": "--strict", + "summary": "Fail the export on any lint finding and write nothing, leaving an existing export untouched. Without it, lint findings are reported as warnings and the export is still written." } ], "examples": [ - "leji ci", - "leji ci --provider gitlab", - "leji ci --provider azure", - "leji ci --hooks" + "leji export", + "leji export --out site", + "leji export --strict --json" ] }, { - "name": "agent", - "summary": "Bind an additional named agent into an existing context layer.", - "usage": "leji agent --name [--host ] [--role ] [--root ] [--json]", - "description": "Adds a second (or third) agent to a context layer that already has a `leji.json`.", - "details": [ - "Writes a starter agent profile under the agent-profiles path and binds it in the manifest's agents map via an in-place edit that preserves the rest of the file.", - "Never writes an agent-host entrypoint file. The portable `AGENTS.md` pointer is written by `init` and `adopt` (unless `--no-agents`); single-vendor files like `CLAUDE.md` are only ever converted from an existing one by `adopt --wire-adapters`.", - "`--host` is optional: a host pins the profile to a specific external CLI; with none, it's a host-agnostic resident agent any host can run.", - "The role defaults to reviewer; pass `--role` for a different one.", - "Idempotent: an existing profile or binding is left untouched. Requires an existing context layer." - ], + "name": "viewer build", + "group": "viewer", + "aliasOf": "export", + "summary": "The viewer subsystem's name for `leji export`: write the static site.", + "usage": "leji viewer build [--out ] [--strict] [--root ] [--json]", + "description": "The same operation as `leji export`, under the viewer subsystem's own name beside `leji viewer serve`: one code path, identical output, identical exits. Both names are permanently supported; `leji export` is the name the documentation leads with. Run `leji export --help` for the full description.", "options": [ { - "flags": "--name ", - "summary": "Name for the agent; also its profile id and agents-map key (kebab-case)." - }, - { - "flags": "--host ", - "summary": "Optional. Pin the profile to a host (claude-code, codex, copilot, gemini, cursor, windsurf; aliases ok); omit for a host-agnostic resident agent." + "flags": "--out ", + "summary": "Output directory for the export (default: .leji/dist; must resolve inside the repository, and never inside .leji/ except exactly .leji/dist)." }, { - "flags": "--role ", - "summary": "Role the agent fills (default: reviewer)." + "flags": "--strict", + "summary": "Fail the export on any lint finding and write nothing, leaving an existing export untouched. Without it, lint findings are reported as warnings and the export is still written." } ], "examples": [ - "leji agent --name porter --role porter", - "leji agent --host codex --name reviewer", - "leji agent --host claude-code --name thought-partner --role advisor" + "leji viewer build", + "leji viewer build --out site" ] } ] 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 abaf3a2..585c59e 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 CSS color (e.g. \"#223F93\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." } } }, diff --git a/packages/sdk-py/src/leji/_assets/templates/agents/core.md b/packages/sdk-py/src/leji/_assets/templates/agents/core.md index 938600d..f60446f 100644 --- a/packages/sdk-py/src/leji/_assets/templates/agents/core.md +++ b/packages/sdk-py/src/leji/_assets/templates/agents/core.md @@ -24,4 +24,4 @@ The shared posture for all agents working in this repository. Role profiles inhe ## Escalation - +Ask the primary owner () whenever mustAskWhen applies; record durable rulings in decisions. diff --git a/packages/sdk-py/src/leji/_assets/templates/boot-profile.md b/packages/sdk-py/src/leji/_assets/templates/boot-profile.md index f790ceb..132d1b3 100644 --- a/packages/sdk-py/src/leji/_assets/templates/boot-profile.md +++ b/packages/sdk-py/src/leji/_assets/templates/boot-profile.md @@ -7,6 +7,10 @@ +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`: it checks that the Leji CLI resolves here, offers your agent's MCP registration and the pre-commit hook, and boots your agent from this profile. An agent already running from this profile has nothing to do here. + ## Loading Read before any task (keep this set small; it is paid on every task): @@ -57,3 +61,4 @@ When you change anything in this context layer: - Append an entry to `docs/context-changelog.json`: id, date, type, one-line summary, affected paths. - Decisions get a record in `docs/decisions/`; copy the shape of an existing one. - Regenerate `docs/context-index.json` when files are added, moved, or retitled. +- **A new file is categorized the moment it is created**: add it to the right category index, or deliberately leave it as an ungoverned reference and say so, in the same change set; the unindexed count (`leji status`) must be a choice, never a surprise. When the right category isn't obvious, ask the layer's owners instead of guessing. diff --git a/packages/sdk-py/src/leji/_assets/templates/onboarding-brief.md b/packages/sdk-py/src/leji/_assets/templates/onboarding-brief.md index 4bd34e6..e012a19 100644 --- a/packages/sdk-py/src/leji/_assets/templates/onboarding-brief.md +++ b/packages/sdk-py/src/leji/_assets/templates/onboarding-brief.md @@ -1,9 +1,9 @@ + It lives in the gitignored onboarding workspace (`.leji/work/`) at the repository root, + beside the generated viewer, so it is excluded from the index, the viewer, and the + changelog. --> # Onboarding brief for the agent @@ -99,7 +99,7 @@ The three file paths: 2. **Local path**: the owner drags a file into the terminal (which pastes its path) or types a path. Read the file in place; do not copy or move it. 3. **Drop folder**: if the owner says "open the drop folder," create - `/.leji/onboarding-inputs/`, run the safety checks in the next section, print its + `.leji/work/onboarding-inputs/`, run the safety checks in the next section, print its absolute path, and open it in the system file browser where supported. The owner copies files in and tells you when they are ready. @@ -114,18 +114,18 @@ Raw artifacts (emails, PDFs, bios, brand documents, writing samples) are **priva never content**. They must never enter the governed tree, the index, the changelog, or any commit. -**The transient workspace.** Everything artifact-related lives only under `/.leji/`: +**The transient workspace.** Everything artifact-related lives only under `.leji/work/`: -- `/.leji/onboarding-inputs/` for dropped or copied raw artifacts, -- `/.leji/onboarding-work/` for temporary extraction scratch, if needed, -- `/.leji/onboarding-sources.json`, a private source ledger you maintain: for each +- `.leji/work/onboarding-inputs/` for dropped or copied raw artifacts, +- `.leji/work/onboarding-work/` for temporary extraction scratch, if needed, +- `.leji/work/onboarding-sources.json`, a private source ledger you maintain: for each artifact record a short display name, kind, where it came from (attachment, external file, drop folder), and which sections it informed. No file contents in the ledger. **Before accepting any artifact**, verify the boundary is intact: -- Confirm `.leji/` is ignored (`git check-ignore /.leji` succeeds). -- Confirm nothing under `/.leji/` is tracked (`git ls-files /.leji` is empty). If +- Confirm `.leji/` is ignored (`git check-ignore .leji` succeeds). +- Confirm nothing under `.leji/` is tracked (`git ls-files .leji` is empty). If anything is tracked, stop artifact intake and tell the owner exactly what is tracked; do not run `git rm --cached` yourself. @@ -239,7 +239,7 @@ human-readable terms (for example "owner-provided 2025 brand guide"), nothing mo markers and `status: proposed` decisions as owner confirmations pending, and `leji status` reports what is still unindexed, dangling, or stale. 7. **Write the proposal, print it, then ask.** Phase 1 ends with the STOP section's two - steps, in order: the whole proposal written to `/.leji/proposal.md` and printed as + steps, in order: the whole proposal written to `.leji/work/proposal.md` and printed as plain text in your reply, then the approval prompt directly after it. Going from tool calls straight into the question tool without the printed summary is a protocol violation, not a shortcut: the owner must be able to read the full proposal without stepping through the @@ -285,7 +285,7 @@ samples is a proposal until confirmed. Do not relabel aspiration as voice to byp Two steps, strictly ordered, after every draft is written and sanity-checked: 1. **Write and print the confirmation summary.** Write the whole proposal to - `/.leji/proposal.md`, first line exactly `# Proposal for approval`, covering the + `.leji/work/proposal.md`, first line exactly `# Proposal for approval`, covering the load-bearing claims below, then print that same content as plain, readable text in your reply. The printed message comes IMMEDIATELY before the approval prompt: no tool calls, file edits, or checks in between. Never point at earlier tool output or file diffs as the @@ -314,14 +314,14 @@ Ask only what you could not verify. A few sharp questions beat a long interview. ## Phase 2: finalize (only after the owner confirms) - Adjust the index files to the owner's calls: promote, downgrade to reference, or recategorize. -- Remove the onboarding guard if installed: delete `/.leji/hooks/`, the +- Remove the onboarding guard if installed: delete `.leji/work/hooks/`, the `AskUserQuestion` PreToolUse entry it added to `.claude/settings.json`, and the - `/.leji/proposal.md` artifact (all transient onboarding machinery, never part of + `.leji/work/proposal.md` artifact (all transient onboarding machinery, never part of the layer). - Replace each `TODO(confirm-…)` with the confirmed wording (or correct it to what the owner said). - Flip each confirmed `status: proposed` decision to `status: accepted`. - Leave any genuinely-unknown plain `TODO:` in place and call it out. -- **Leak check** before anything else: nothing under `/.leji/` is tracked; no raw input +- **Leak check** before anything else: nothing under `.leji/` is tracked; no raw input filenames, hashes, or absolute private paths appear in governed documents; no email headers or raw excerpts survive in the proposed content. - Run `leji index` to regenerate the index, `leji status` to confirm nothing governed is left @@ -332,11 +332,11 @@ Ask only what you could not verify. A few sharp questions beat a long interview. locally on 127.0.0.1, and opens it in the browser. Offer to run it (or hand them the command); seeing the layer is what closes the loop for the humans who will rely on it. - As your last step, once everything above passes, delete the transient onboarding files: - this brief (`/.leji/onboarding-brief.md`), `/.leji/onboarding-inputs/`, - `/.leji/onboarding-work/`, and `/.leji/onboarding-sources.json`. They are + this brief (`.leji/work/onboarding-brief.md`), `.leji/work/onboarding-inputs/`, + `.leji/work/onboarding-work/`, and `.leji/work/onboarding-sources.json`. They are scaffolding and private evidence, not context. Never delete or modify the owner's external - originals. Leave the rest of `/.leji/` in place (it holds the generated viewer and is - gitignored). + originals. Leave the rest of `.leji/` in place (it holds the generated viewer and the + federation cache, and is gitignored). In your final report, **quote the owner's confirmation** of the classification, invariants, gates, and (in solo mode) the identity and writing-style synthesis. The tool cannot prove a @@ -345,7 +345,7 @@ conversation happened; your report and the repository's review gate are the reco ## Boundaries Only create or edit files Leji owns under the context root, plus the transient workspace named -above (`/.leji/onboarding-inputs/`, `onboarding-work/`, `onboarding-sources.json`), +above (`.leji/work/onboarding-inputs/`, `onboarding-work/`, `onboarding-sources.json`), which you create and delete as described. Treat existing `CLAUDE.md`, `AGENTS.md`, `.cursor/rules`, `.github/copilot-instructions.md` and similar as **read-only inputs to learn from**; never rewrite them, and never wire a vendor redirect without showing the owner the diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/PROVENANCE.txt b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/PROVENANCE.txt index 0852c68..eb98b5d 100644 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/PROVENANCE.txt +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/PROVENANCE.txt @@ -33,4 +33,11 @@ mermaid.min.js, docsify-mermaid.js leji-logo.svg The default viewer logo (the Leji mark). Overridden per-layer by viewer.logo. +third-party-licenses.txt + The consolidated notice for everything vendored here: a component list (name, + version, copyright, license) followed by each license text once. Unlike this + note it DOES ship, into every generated viewer/ and every exported dist/, so + the redistributed components carry their notices. Update it whenever an asset + is added, removed, or bumped. + This note is documentation only; the SDKs never copy it into a user's output. diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/fonts-licenses.txt b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/fonts-licenses.txt deleted file mode 100644 index a46662c..0000000 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/fonts-licenses.txt +++ /dev/null @@ -1,15 +0,0 @@ -Vendored webfont licenses - -Source Sans Pro (source-sans-pro-*.woff2) - Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), - with Reserved Font Name 'Source'. All Rights Reserved. Source is a - trademark of Adobe Systems Incorporated in the United States and/or - other countries. - Licensed under the SIL Open Font License, Version 1.1. - https://openfontlicense.org - -Roboto Mono (roboto-mono-*.woff2) - Copyright 2015 The Roboto Mono Project Authors - (https://github.com/googlefonts/robotomono) - Licensed under the Apache License, Version 2.0. - http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/leji-logo.svg b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/leji-logo.svg index 490b6b6..33944b0 100644 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/leji-logo.svg +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/leji-logo.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/third-party-licenses.txt b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/third-party-licenses.txt new file mode 100644 index 0000000..7bd07c2 --- /dev/null +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/third-party-licenses.txt @@ -0,0 +1,408 @@ +Third-party notices for the Leji viewer +====================================== + +This file travels with the generated viewer chrome and with every static site +`leji export` writes: it names each third-party component bundled beside it and +carries the full text of every license those components are used under, once +each, after the component list. Components are redistributed unmodified except +where a note says otherwise. The Leji mark (leji-logo.svg) is not third-party +material; Leji's own license is LICENSE.md in the Leji repository. + +Where a component's upstream version is not recorded in the Leji repository, +this file says so rather than guessing. + + +Components +---------- + +docsify + Version: 4.13.1 + License: MIT + Copyright (c) 2016 - present Docsify Contributors + Files: docsify.min.js; vue.css (the vendored theme, with a Leji brand block + appended); search.min.js (full-text search plugin); zoom-image.min.js + (image-zoom plugin). + +docsify-sidebar-collapse + Version: not recorded + License: MIT + Copyright (c) 2018 iPeng6 + Files: docsify-sidebar-collapse.min.js, docsify-sidebar-collapse.min.css. + +docsify-copy-code + Version: 2.1.1 + License: MIT + Copyright (c) 2017-2020 JP Erasmus + Files: docsify-copy-code.min.js. + +docsify-mermaid + Version: 2.0.1 + License: ISC + Copyright (c) Paul-Julien Vauthier + Files: docsify-mermaid.js. + +Mermaid + Version: 11.14.0 + License: MIT + Copyright (c) 2014 - 2022 Knut Sveidqvist + Files: mermaid.min.js. Bundled only while the layer leaves viewer.mermaid + enabled; disabling it ships neither this file nor the plugin above. + +Prism + Version: not recorded + License: MIT + Copyright (c) 2012 Lea Verou + Files: prism-bash.min.js, prism-json.min.js, prism-markdown.min.js, + prism-typescript.min.js (language components extending the Prism core + that docsify.min.js bundles). + +Source Sans Pro + Version: not recorded + License: SIL Open Font License 1.1 + Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), + with Reserved Font Name 'Source'. All Rights Reserved. Source is a + trademark of Adobe Systems Incorporated in the United States and/or + other countries. + Files: source-sans-pro-*.woff2. + +Roboto Mono + Version: not recorded + License: Apache License 2.0 + Copyright 2015 The Roboto Mono Project Authors + (https://github.com/googlefonts/robotomono) + Files: roboto-mono-*.woff2. + + +MIT License (docsify, docsify-sidebar-collapse, docsify-copy-code, Mermaid, Prism) +================================================================================== + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +ISC License (docsify-mermaid) +============================= + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + +SIL Open Font License 1.1 (Source Sans Pro) +=========================================== + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +Apache License 2.0 (Roboto Mono) +================================ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js index adada39..aecd89a 100644 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/assets/viewer-boot.js @@ -3,30 +3,117 @@ // Kept as a vendored file (not inline) so the page can run under a strict // Content-Security-Policy (script-src 'self'), which blocks any script injected // through served Markdown content. Written alongside the page by `leji viewer`. -// Pick a readable mermaid node-text color for the layer's accent: dark text on a -// light accent, white on a dark one. Parses #rgb or #rrggbb (case-insensitive); -// an unparseable value keeps the dark default. +// Fallback mermaid node-text color for the layer's accent. The SDK computes this +// server-side and ships it in the config block (lejiMermaidTextColor), over every +// color form the manifest accepts; this covers only a viewer tree generated before +// that field existed, so it parses #rgb and #rrggbb and nothing else. WCAG relative +// luminance over linearized sRGB: whichever of #1a1a1a and #ffffff contrasts more +// with the accent, or #000000 when neither clears 4.5:1 (a mid-gray accent, where +// the extra half-stop of black is the best text color available). An unparseable +// value keeps the dark default. function lejiMermaidTextColor(accent) { var hex = String(accent || '').replace(/^#/, ''); if (hex.length === 3) { hex = hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2); } if (!/^[0-9a-fA-F]{6}$/.test(hex)) return '#1a1a1a'; - var r = parseInt(hex.slice(0, 2), 16); - var g = parseInt(hex.slice(2, 4), 16); - var b = parseInt(hex.slice(4, 6), 16); - var brightness = (299 * r + 587 * g + 114 * b) / 1000; - return brightness >= 150 ? '#1a1a1a' : '#ffffff'; + var luminance = function (h) { + var channel = function (i) { + var c = parseInt(h.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4); + }; + var ratio = function (a, b) { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }; + var accentLuminance = luminance(hex); + var onDark = ratio(luminance('1a1a1a'), accentLuminance); + var onLight = ratio(luminance('ffffff'), accentLuminance); + if (onDark < 4.5 && onLight < 4.5) return '#000000'; + return onDark >= onLight ? '#1a1a1a' : '#ffffff'; } -window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify-config').textContent), { +// Resolve a raw-HTML `` against the document that carries it, exactly as +// Docsify's relativePath routing already resolves the markdown image form. Returns +// the path under `contentBase` (query and fragment preserved) or null for a src that must be +// left as authored: empty, fragment- or query-only, root-relative, backslash-led, +// protocol-relative, any scheme reference, and any traversal escaping /content/ — +// traversal is rejected rather than clamped, because the server canonicalizes and a +// clamped path would quietly address the viewer chrome instead of the layer. +// Containment is judged on the decoded, normalized path, not the literal one, +// because the server canonicalizes percent-encoding and separators before it +// routes — an encoded `..` reads as traversal there even though URL keeps it. +// The value is first put through URL parsing's own input preprocessing — leading +// and trailing C0-control-and-space characters trimmed, then ASCII tab, LF, and +// CR removed anywhere in the value — so classification sees exactly what the +// parser sees; otherwise a padded or tab-split scheme reference slips past the +// first-character and scheme checks and gets rewritten. +function lejiResolveImgSrc(src, docDir, contentBase) { + var origin = 'http://leji.invalid'; + var raw = String(src || '') + .replace(/^[\x00-\x20]+/, '') + .replace(/[\x00-\x20]+$/, '') + .replace(/[\t\n\r]/g, ''); + if (raw === '') return null; + var first = raw.charAt(0); + if (first === '#' || first === '?' || first === '/' || first === '\\') return null; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw)) return null; + var url; + try { + url = new URL(raw, origin + '/content/' + (docDir ? docDir + '/' : '')); + } catch (e) { + return null; + } + if (url.origin !== origin) return null; + if (url.pathname.indexOf('/content/') !== 0) return null; + var decoded; + try { + decoded = decodeURIComponent(url.pathname); + } catch (e) { + return null; + } + var parts = decoded.replace(/\\/g, '/').split('/'); + var kept = []; + for (var i = 0; i < parts.length; i++) { + if (parts[i] === '' || parts[i] === '.') continue; + if (parts[i] === '..') kept.pop(); + else kept.push(parts[i]); + } + if (('/' + kept.join('/')).indexOf('/content/') !== 0) return null; + // Re-based onto the content mount as this page addresses it: '/content/…' when + // served locally, 'content/…' in an export, which the browser then resolves + // against the page so a subpath-hosted tree still finds the file. + return contentBase + url.pathname.slice('/content/'.length) + url.search + url.hash; +} + +var lejiConfig = JSON.parse(document.getElementById('leji-docsify-config').textContent); +// Where this page addresses the layer's markdown, from the SDK's config block: +// '/content/' for the local server, 'content/' for an export. Everything the page +// fetches for itself is derived from it, so one generated value moves the whole +// chrome between the app root and a relative base. Older viewer trees carry no +// basePath in their config; they were server-flavored, so the app root is the +// correct fallback. +var lejiContentBase = typeof lejiConfig.basePath === 'string' ? lejiConfig.basePath : '/content/'; + +window.$docsify = Object.assign(lejiConfig, { // The viewer chrome lives at the web root; the layer's markdown is mounted under - // /content/. basePath points Docsify at the content mount; the alias maps every - // nested `_sidebar.md` lookup to the single generated sidebar (so nested routes do - // not 404), which basePath then resolves to /content/_sidebar.md. - basePath: '/content/', + // the content base above. basePath points Docsify at the content mount; the alias + // maps every nested `_sidebar.md` lookup to the single generated sidebar (so + // nested routes do not 404), which basePath then resolves to _sidebar.md. + basePath: lejiContentBase, loadSidebar: '_sidebar.md', alias: { '/.*/_sidebar.md': '_sidebar.md' }, + // Markdown links resolve against the document that carries them, matching how + // the same files read on disk and on any git host. Generated sidebar links are + // emitted app-root absolute (leading slash) so they are unaffected. Without + // this, a `../`-style link on a nested page escapes the router entirely. + relativePath: true, + // A missing document renders Docsify's in-app not-found message; the vendored + // runtime's default (true) would issue a second, always-failing fetch for a + // `_404.md` no layer ships. The primary missing-document 404 is inherent to + // static serving. + notFoundPage: false, subMaxLevel: 3, auto2top: true, // Docsify's script execution runs a `new Function(...)` over a rendered page's @@ -50,6 +137,26 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); }); }, + function resolveImageSrc(hook, vm) { + // relativePath resolves markdown images; a raw-HTML + // passes through untouched and the browser resolves it against the page URL, + // so on a nested page it 404s. Rewrite at render rather than on the way out: + // a document's served bytes are the file's, verbatim. afterEach runs before + // the compiled HTML is inserted, so the unresolved URL is never requested. + hook.afterEach(function (html, next) { + var rel = vm.route && vm.route.file ? vm.route.file : ''; + var cut = rel.lastIndexOf('/'); + var docDir = cut === -1 ? '' : rel.slice(0, cut); + // Parsed in a detached container, never regexed over the HTML string. + var container = document.createElement('div'); + container.innerHTML = html; + container.querySelectorAll('img[src]').forEach(function (img) { + var resolved = lejiResolveImgSrc(img.getAttribute('src'), docDir, lejiContentBase); + if (resolved !== null) img.setAttribute('src', resolved); + }); + next(container.innerHTML); + }); + }, function categoryBadge(hook, vm) { // Top-right classification chip: the category (emoji + label) every // governed page carries for agents, made visible to people. Records @@ -60,8 +167,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify if (!cfg.lejiIndexRel || !cfg.lejiCategories) return; hook.doneEach(function () { var rel = vm.route && vm.route.file ? vm.route.file : ''; - fetch('/content/' + cfg.lejiIndexRel, { cache: 'no-store' }) - .then(function (r) { return r.ok ? r.json() : null; }) + fetch(lejiContentBase + cfg.lejiIndexRel, { cache: 'no-store' }) + .then(function (r) { + return r.ok ? r.json() : null; + }) .then(function (idx) { var label = null; if (rel === cfg.lejiBootPath) { @@ -76,11 +185,17 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify prefix = path.slice(0, path.length - rel.length); break; } - if (path === rel) { prefix = ''; break; } + if (path === rel) { + prefix = ''; + break; + } } var entry = null; for (var j = 0; j < idx.entries.length; j++) { - if (idx.entries[j].path === (prefix === null ? rel : prefix + rel)) { entry = idx.entries[j]; break; } + if (idx.entries[j].path === (prefix === null ? rel : prefix + rel)) { + entry = idx.entries[j]; + break; + } } if (entry) { label = cfg.lejiCategories[entry.category] || entry.category; @@ -90,7 +205,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify } } var el = document.querySelector('.lj-cat'); - if (!label) { if (el) el.remove(); return; } + if (!label) { + if (el) el.remove(); + return; + } if (!el) { el = document.createElement('div'); el.className = 'lj-cat'; @@ -98,7 +216,9 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify } el.textContent = label; }) - .catch(function () { /* badge is best-effort chrome */ }); + .catch(function () { + /* badge is best-effort chrome */ + }); }); }, function sidebarLoadingState(hook) { @@ -131,7 +251,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify }, function brandMermaid(hook) { // Theme mermaid diagrams from the layer's accent color; runs at init so - // it lands after mermaid.min.js (loaded last) is present. + // it lands after mermaid.min.js (loaded last) is present. The node-text + // color is the SDK's, computed at generation time over every color form + // the manifest accepts; the local fallback covers only a viewer tree + // generated before that field shipped. hook.init(function () { if (!window.mermaid || !window.$docsify.themeColor) return; window.mermaid.initialize({ @@ -139,9 +262,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify theme: 'base', themeVariables: { primaryColor: window.$docsify.themeColor, - primaryTextColor: lejiMermaidTextColor(window.$docsify.themeColor), + primaryTextColor: + window.$docsify.lejiMermaidTextColor || lejiMermaidTextColor(window.$docsify.themeColor), lineColor: '#666', - tertiaryColor: '#f8f9fa', + tertiaryColor: '#f7f8f5', }, }); }); 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 6a86ff6..1fc6850 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 @@ -1,5 +1,5 @@ /* Vendored webfonts (self-contained; no CDN). Source Sans Pro: SIL OFL 1.1; - Roboto Mono: Apache-2.0. See fonts-licenses.txt. */ + Roboto Mono: Apache-2.0. See third-party-licenses.txt. */ @font-face { font-family: 'Roboto Mono'; font-style: normal; @@ -973,14 +973,16 @@ code .token { (see the @font-face rules at the top of this file). ========================================================================== */ :root { - --theme-color: #223f93; /* recolors every var(--theme-color) rule above */ - --leji-blue-deep: #162960; - --leji-blue: #223f93; - --leji-gold: #ffbd6e; - --leji-paper: #f8f9fa; + --theme-color: #009f71; /* recolors every var(--theme-color) rule above */ + --leji-brand: #009f71; /* the Leji mark green: brand moments, never small text */ + --leji-link: #007d59; /* the accessible green for links and small text */ + --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-line: #e0e0e0; + --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 */ color-scheme: light; } @@ -1019,7 +1021,7 @@ body { color: var(--theme-color); text-decoration: none; } -/* Active document: a plain blue text change, nothing else. */ +/* Active document: a plain accent-colored text change, nothing else. */ .sidebar ul li.active > a { color: var(--theme-color) !important; border-right: none; @@ -1098,7 +1100,7 @@ body { .search input:focus { outline: none; border-color: var(--theme-color); - box-shadow: 0 0 0 2px rgba(34, 63, 147, 0.12); + box-shadow: 0 0 0 2px rgba(0, 159, 113, 0.12); } .search .results-panel { background: var(--leji-paper); @@ -1137,14 +1139,14 @@ body { background-color: var(--leji-paper) !important; } .leji-powered a { - color: var(--leji-blue) !important; + color: var(--leji-link) !important; text-decoration: none; } .leji-powered a:hover { - color: #162960 !important; + color: var(--leji-deep) !important; } .leji-powered .spark { - color: var(--leji-gold); + color: var(--leji-brand); } .leji-powered strong { font-weight: 600; @@ -1154,18 +1156,22 @@ body { color: var(--theme-color); } /* brand-tinted inline code, replacing the stock orange. Scoped away from - pre > code so fenced blocks keep the stock panel and token colors. */ + 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); - background: rgba(34, 63, 147, 0.07); + background: var(--leji-code-bg); +} +/* The fenced-code panel takes the same ground, replacing the stock neutral gray. */ +.markdown-section pre { + background-color: var(--leji-code-bg); } .markdown-section pre > code { color: #525252; background: none; } .markdown-section blockquote { - border-left: 3px solid var(--leji-gold); + border-left: 3px solid var(--leji-accent); color: var(--leji-ink-soft); } 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 bdf7c6d..924ce01 100644 --- a/packages/sdk-py/src/leji/_assets/templates/viewer/index.html +++ b/packages/sdk-py/src/leji/_assets/templates/viewer/index.html @@ -31,9 +31,10 @@ diff --git a/packages/sdk-py/src/leji/badge.py b/packages/sdk-py/src/leji/badge.py new file mode 100644 index 0000000..e74ee67 --- /dev/null +++ b/packages/sdk-py/src/leji/badge.py @@ -0,0 +1,304 @@ +"""`leji badge`: the local, self-attested conformance badge. The command scores the +layer with :func:`~leji.conformance.conformance_report` (federation never verified, so +the run is offline by construction), renders the canonical SVG for the level THIS run +verified, and prints the markdown that embeds it. There is no endpoint, no registry, +and no hosted service anywhere in this module or the ones it imports: the bytes are +constants, and the only thing that varies is which level's constants are used. + +The four files under ``fixtures/badge/`` are the byte oracle for everything here, and +``fixtures/README.md`` -> "The `badge` block" is the normative contract the three SDKs +implement. Mirrors packages/sdk/src/commands/badge.ts. +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal, Optional + +from .conformance import conformance_report +from .findings import Finding, sort_findings +from .fsx import ( + guard_root, + resolved_path, + resolved_within_root, + verified_target_read, + write_file_guarded, +) +from .layout import writable_target +from .manifest import CONFORMANCE_LEVELS + +#: The badge target when ``--out`` is not given: a repository-root file, one +#: copy-paste from a root README. +DEFAULT_BADGE_OUT = "leji-badge.svg" + +#: The page the markdown wrapper links. One constant, never configurable. +_AGENT_READY_URL = "https://leji.org/agent-ready/" + +#: The ``--out`` acceptance rule, quoted verbatim by the usage error that rejects a +#: path (``fixtures/README.md`` -> "The `--out` acceptance rule"). +OUT_RULE = ( + "--out takes a repository-relative POSIX path over [A-Za-z0-9._/-], with no leading /, " + 'no backslash, no ".." segment, no empty segment, and ending .svg' +) + +#: The mark: the single ```` of ``packages/site/src/assets/leji-icon.svg`` +#: (viewBox ``0 0 370 391``), inlined as a constant rather than read at runtime. The +#: badge is a frozen byte contract, so it can never depend on a file a caller could +#: replace or a package could ship differently. +_MARK_PATH = "M185.038 77.918C162.621 77.942 144.384 96.185 144.372 118.607C144.382 136.031 155.422 150.887 170.856 156.67V305.245H199.225V156.671C214.663 150.888 225.707 136.031 225.724 118.608C225.703 96.184 207.46 77.942 185.038 77.918ZM185.043 130.9C178.268 130.896 172.747 125.372 172.747 118.607C172.747 111.833 178.262 106.318 185.037 106.303C191.816 106.318 197.337 111.832 197.337 118.607C197.337 125.372 191.811 130.896 185.043 130.9ZM349.766 22.16C336.469 8.72897 317.174 0.943 295.071 0H74.715C52.613 0.943 33.319 8.72897 20.021 22.16C7.09602 35.134 -0.0149763 52.521 2.36824e-05 71.128C2.36824e-05 87.589 5.66601 103.074 15.951 114.726C26.651 126.955 42.597 134.172 59.95 134.713C63.415 134.798 81.128 134.812 97.081 127.028V311.413C81.642 317.196 70.595 332.056 70.585 349.48C70.597 371.898 88.841 390.139 111.255 390.156C133.679 390.139 151.919 371.898 151.935 349.48C151.923 332.055 140.88 317.2 125.443 311.417V58.449H244.589V211.559C229.155 217.342 218.114 232.193 218.105 249.622C218.118 272.053 236.357 290.295 258.779 290.295C281.193 290.295 299.431 272.053 299.453 249.622C299.437 232.193 288.39 217.338 272.957 211.559V127.147C288.846 134.809 306.393 134.797 309.84 134.712C327.192 134.171 343.138 126.953 353.838 114.725C364.123 103.074 369.789 87.588 369.789 71.127C369.801 52.521 362.692 35.134 349.766 22.16ZM111.261 361.782C104.487 361.763 98.965 356.247 98.959 349.491C98.965 342.705 104.486 337.187 111.254 337.187C118.024 337.187 123.543 342.709 123.559 349.476C123.543 356.247 118.016 361.764 111.261 361.782ZM258.786 261.931C252.005 261.917 246.483 256.392 246.483 249.622C246.483 242.843 252.004 237.334 258.778 237.334C265.542 237.334 271.063 242.847 271.079 249.612C271.063 256.386 265.542 261.917 258.786 261.931ZM332.597 95.914C326.347 102.87 318.107 106.295 307.41 106.378C288.412 106.165 281.539 100.531 277.324 95.052C275.131 92.123 273.783 88.628 272.955 85.317V58.45H300.57C303.603 58.45 306.808 59.782 307.015 59.87C308.813 60.684 310.429 61.838 311.277 63.098C311.993 64.174 312.539 65.34 312.574 67.815C312.523 70.481 311.668 72.002 310.269 73.352C308.873 74.643 306.746 75.487 304.64 75.471C301.994 75.386 299.636 74.504 297.46 71.373C294.728 67.264 289.187 66.153 285.077 68.885C282.002 70.933 280.603 74.56 281.247 77.978C287.59 93.654 305.199 93.329 305.415 93.324C311.64 93.133 317.642 90.795 322.325 86.535C327.213 82.137 330.485 75.362 330.436 67.815C330.47 61.991 328.678 56.727 325.883 52.813C323.102 48.875 319.584 46.292 316.354 44.568C310.887 41.694 306.067 40.887 304.242 40.68H66.514C64.69 40.887 59.868 41.694 54.402 44.568C51.173 46.293 47.654 48.875 44.873 52.813C42.079 56.727 40.286 61.991 40.321 67.815C40.272 75.362 43.544 82.136 48.431 86.535C53.116 90.795 59.116 93.133 65.342 93.324C65.557 93.329 83.167 93.654 89.51 77.978C90.153 74.56 88.754 70.933 85.68 68.885C81.57 66.154 76.028 67.264 73.296 71.373C71.119 74.504 68.762 75.386 66.117 75.471C64.011 75.487 61.884 74.643 60.488 73.352C59.09 72.001 58.232 70.481 58.182 67.815C58.216 65.34 58.763 64.174 59.479 63.098C60.327 61.838 61.943 60.685 63.741 59.87C63.949 59.782 67.152 58.45 70.185 58.45H97.08V84.257C96.283 87.871 94.891 91.81 92.463 95.053C88.248 100.532 81.375 106.166 62.375 106.379C51.68 106.296 43.439 102.871 37.19 95.915C31.563 89.595 28.351 80.556 28.371 71.129C28.379 60.046 32.544 49.776 40.101 42.199C49.323 33.015 62.602 28.34 79.568 28.291H290.218C307.183 28.34 320.462 33.016 329.684 42.199C337.242 49.776 341.407 60.046 341.415 71.129C341.435 80.555 338.223 89.594 332.597 95.914Z" + +#: The one per-level constant: the wordmark's ``textLength`` is fixed at 41 and every +#: other width derives from the status text's (``fixtures/README.md`` -> "The canonical +#: badge bytes"). Horizontal padding is 5 either side, the mark occupies a 14-wide slot +#: with a 3-wide gap, so the identity segment is 5+14+3+41+5 = 68 and the status segment +#: is ``textLength + 10``. +_STATUS_TEXT_LENGTH: dict[str, int] = { + "core": 24, + "indexed": 43, + "governed": 52, + "federated": 53, +} + +#: The identity segment's fixed width, and the wordmark it carries. +_IDENTITY_WIDTH = 68 +_WORDMARK = "Leji 1.0" + + +def badge_label(level: str) -> str: + """The accessible name and the markdown alt text: one string, three places. It carries + the full self-attestation claim, which the badge FACE does not: the visible status + segment is the level alone, and the claim stays structural — in the ````, the + ``aria-label``, and the markdown alt — with the linked agent-ready page carrying the + story.""" + return f"{_WORDMARK} · {level} · self-attested" + + +def render_badge(level: str) -> str: + """The canonical badge for one level, byte for byte: shields-flat shape, height 20, + rounded by a clipPath, the mark and wordmark on the ``#183D3B`` identity segment and + ``<level>`` alone on the ``#009F71`` status segment. No XML declaration, no BOM, no + comment, no timestamp, no version string; UTF-8, LF, one trailing newline. Compared + against ``fixtures/badge/<level>.svg`` by unit test.""" + status = _STATUS_TEXT_LENGTH[level] + status_width = status + 10 + width = _IDENTITY_WIDTH + status_width + label = badge_label(level) + return ( + f'<svg xmlns="http://www.w3.org/2000/svg" role="img" width="{width}" height="20" aria-label="{label}">\n' + f"<title>{label}\n" + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f"\n" + f'\n' + f'{_WORDMARK}\n' + f'{level}\n' + f"\n" + f"\n" + ) + + +def badge_markdown(level: str, out: str) -> str: + """The one markdown line the command prints: the badge image, wrapped in a link to + the agent-ready page. ``out`` is the canonical POSIX path, relative to the repository + root, so a root README embeds it as written.""" + return f"[![{badge_label(level)}]({out})]({_AGENT_READY_URL})\n" + + +#: Every canonical badge of this contract, which is exactly what an existing file is +#: recognized against: its own bytes, and no marker, sidecar, or state. Compared as +#: BYTES — the target's bytes are whatever somebody left there, and a file that is not +#: valid UTF-8 is a foreign file to refuse rather than a decoding failure to raise. +_CANONICAL_BADGES: list[bytes] = [ + render_badge(level).encode("utf-8") for level in CONFORMANCE_LEVELS +] + +#: What the run did to the target file: ``wrote`` it (absent), left it ``unchanged`` (it +#: already held these exact bytes), or ``overwrote`` another canonical badge of this +#: contract, which is how a level change regenerates. +BadgeAction = Literal["wrote", "unchanged", "overwrote"] + + +@dataclass +class BadgeResult: + """One ``leji badge`` run, in the shape the caller renders in either channel. A + failed run carries ``out``, ``level``, ``markdown``, and ``action`` as None and says + why in ``findings``; ``claimed_level`` and ``verified_level`` are reported whatever + the outcome, so a refusal is still honest about what the layer claims.""" + + out: Optional[str] = None + level: Optional[str] = None + claimed_level: Optional[str] = None + verified_level: Optional[str] = None + markdown: Optional[str] = None + action: Optional[BadgeAction] = None + findings: list[Finding] = field(default_factory=list) + #: ``--out`` was rejected at argument parsing, before conformance ran: the caller + #: prints this in the CLI's usage-error form and exits 2, reporting no level. + usage_error: Optional[str] = None + #: The target exists and is not a badge of this contract: exit 2, the file untouched. + #: Reported after conformance, so the levels above are populated. + refusal: Optional[str] = None + + +#: The accepted ``--out`` charset. Matched with ``fullmatch``: Python's ``$`` also +#: matches before a trailing newline, where the reference SDK's ``$`` is end-of-input. +_OUT_CHARSET = re.compile(r"[A-Za-z0-9._/-]+") + + +def _accepted_out_syntax(out: str) -> bool: + """The syntax half of the ``--out`` rule, on the spelling alone.""" + if _OUT_CHARSET.fullmatch(out) is None: + return False + if out.startswith("/") or not out.endswith(".svg"): + return False + return all(seg != "" and seg != ".." for seg in out.split("/")) + + +def _canonical_out(out: str) -> str: + """The canonical POSIX form of an accepted ``--out``: the spelling with its ``.`` + segments dropped, which is what stdout, ``--json``, and the markdown carry.""" + return "/".join(seg for seg in out.split("/") if seg != ".") + + +@dataclass(frozen=True) +class _CheckedOut: + """The ``--out`` verdict: the usage-error text, or the canonical relative path and + the resolved absolute one.""" + + error: Optional[str] = None + rel: str = "" + abs_path: str = "" + + +def _check_out(root_abs: str, out: str) -> _CheckedOut: + """The ``--out`` check, run at argument parsing and BEFORE conformance: the syntax + rule above, then containment of the RESOLVED path — inside the repository, never + under ``.leji/`` at any depth (that tree is the tool's own domain and the badge is + user content), and not a directory. Returns the usage-error text on a rejection, else + the canonical relative path and the resolved absolute one.""" + if not _accepted_out_syntax(out): + return _CheckedOut(error=f'{OUT_RULE} (got "{out}")') + rel = _canonical_out(out) + abs_path = os.path.abspath(os.path.join(root_abs, *rel.split("/"))) + resolved = resolved_path(abs_path) + if resolved is None: + return _CheckedOut(error=f'--out "{rel}" cannot be resolved (permission or I/O error)') + if not resolved_within_root(root_abs, Path(abs_path)): + return _CheckedOut(error=f'--out "{rel}" must resolve inside the repository') + # No own role: the badge has no legitimate `.leji/` landing at any depth. + if not writable_target(root_abs, resolved, None).ok: + return _CheckedOut( + error=f'--out "{rel}" resolves inside .leji/, the tool\'s own domain; ' + "the badge is user content" + ) + if os.path.isdir(resolved): + return _CheckedOut(error=f'--out "{rel}" is a directory') + return _CheckedOut(rel=rel, abs_path=abs_path) + + +def badge_run(root: str, out: str = DEFAULT_BADGE_OUT) -> BadgeResult: + """Run ``leji badge`` over ``root``, writing the badge for the level this offline run + verified. The order is fixed and is part of the contract: ``--out`` is judged first (a + usage error reports no level at all), then conformance decides whether there is + anything honest to state, and only then does the existing target decide the action.""" + root_abs = guard_root(root) + checked = _check_out(root_abs, out) + if checked.error is not None: + return BadgeResult(usage_error=checked.error) + + # Federation is never verified: the badge states what an offline run established, + # which is why it can sit below the claim and never above it. + report = conformance_report(root) + + def reported(extra: Optional[Finding] = None, refusal: Optional[str] = None) -> BadgeResult: + """A run with nothing to write: the levels this run established, the conformance + findings, and — when the target itself is what stopped it — one more finding and + the refusal line.""" + return BadgeResult( + claimed_level=report.claimed_level, + verified_level=report.verified_level, + findings=sort_findings(report.findings if extra is None else [*report.findings, extra]), + refusal=refusal, + ) + + if any(f.severity == "error" for f in report.findings): + return reported() + if report.verified_level is None: + return reported( + Finding( + "badge-unverified", + "error", + "no level verified in this run; the badge states only what was verified", + "leji.json", + ) + ) + + level = report.verified_level + svg = render_badge(level) + + # Check-before-act, badge-side. `_check_out` judged the target as it was spelled + # at argument parsing; the read below and the write after it are separate acts, and a + # component of the path can become a symlink in between. So the boundary is + # re-established immediately before each act, on the RESOLVED path, by the shared rule + # itself: `verified_target_read` for the read, the guarded write for the write. + def refuse_target() -> BadgeResult: + return reported( + Finding( + "badge-target-refused", + "error", + f"{checked.rel} does not resolve to a regular file inside the repository", + checked.rel, + ), + refusal=( + f"{checked.rel} does not resolve to a regular file inside the repository; " + "nothing was written" + ), + ) + + # The existing target is read through the shared verified read: the standing entry + # decides its own kind (a directory, a socket, a link to one: refused, never written + # through), the resolved location is judged by the same rule the write below is judged + # by, and the bytes come from the descriptor proved to be that file. Absence is decided + # on the ORIGINAL entry, so a dangling link — standing, resolving nowhere — is a + # refusal rather than an absent target written through. + read = verified_target_read(root_abs, checked.abs_path, None) + if read.status == "refused": + return refuse_target() + existing = read.data if read.status == "regular" else None + if existing is not None and existing not in _CANONICAL_BADGES: + return reported( + Finding( + "badge-target-foreign", "error", f"{checked.rel} is not a leji badge", checked.rel + ), + refusal=f"{checked.rel} exists and is not a leji badge; remove or rename it", + ) + action: BadgeAction = ( + "wrote" + if existing is None + else "unchanged" + if existing == svg.encode("utf-8") + else "overwrote" + ) + if action != "unchanged": + # The guarded-write chokepoint re-resolves the target immediately before the write + # and answers the whole boundary — inside the repository, outside `.leji/` — so + # nothing here is inherited from the parse-time verdict. Parent directories are + # created only inside a write that happens: a run that writes nothing (a refusal, + # an unchanged target) establishes no directory either. + if not write_file_guarded(root_abs, checked.abs_path, None, svg).ok: + return refuse_target() + return BadgeResult( + out=checked.rel, + level=level, + claimed_level=report.claimed_level, + verified_level=level, + markdown=badge_markdown(level, checked.rel), + action=action, + findings=sort_findings(report.findings), + ) diff --git a/packages/sdk-py/src/leji/changelog.py b/packages/sdk-py/src/leji/changelog.py index ea273c0..11078d1 100644 --- a/packages/sdk-py/src/leji/changelog.py +++ b/packages/sdk-py/src/leji/changelog.py @@ -10,8 +10,7 @@ from typing import Optional from .findings import Finding -from .fsx import resolved_within_root -from .layer import read_json_artifact +from .fsx import guard_root, verified_target_read, write_file_guarded from .manifest import Manifest, claimed_level, effective_changelog_path, level_at_least @@ -80,13 +79,17 @@ def seed_changelog_if_missing(root: str, manifest: Manifest) -> Optional[str]: """Seed the machine changelog when the layer claims ``indexed``+ and the file is missing (lets ``leji index`` complete the indexed surface for a layer upgraded from core). Returns the seeded path, or ``None`` when nothing was written (not - indexed, already present, or a symlink escapes the root). Never overwrites.""" + indexed, already present, or a symlink escapes the root). Never overwrites. + + "Missing" is decided by the exclusive create itself rather than by a pathname + check, because ``Path.is_file()`` follows symlinks: a dangling link at the + changelog name reads as absent and the seed would be created at the link's + destination. The exclusive create judges the ORIGINAL entry, so any standing entry + is the same already-present no-op an existing changelog is.""" if not level_at_least(claimed_level(manifest), "indexed"): return None rel = effective_changelog_path(manifest) abs_path = Path(root) / rel - if abs_path.is_file() or not resolved_within_root(root, abs_path): - return None log = { "$schema": "https://leji.org/schemas/v1.0/context-changelog.schema.json", "schemaVersion": "1.0", @@ -102,8 +105,10 @@ def seed_changelog_if_missing(root: str, manifest: Manifest) -> Optional[str]: } ], } - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(serialize_changelog(log), encoding="utf-8") + if not write_file_guarded( + guard_root(root), str(abs_path), None, serialize_changelog(log), exclusive=True + ).ok: + return None return rel @@ -143,10 +148,27 @@ def compact_changelog( kept=0, path=rel, ) - data, parse_finding = read_json_artifact(root, rel) - if parse_finding: - return CompactResult(findings=[parse_finding], folded=0, kept=0, path=rel) - if data is None: + # Compaction rewrites the file it just read, so the bytes it folds come from the + # verified read rather than from a pathname read once and written again: a refusal + # (outside the layer root, a private role, an entry that is not a regular file) is + # reported exactly as an unreadable artifact, and nothing is written. + root_real = guard_root(root) + read = verified_target_read(root_real, str(Path(root) / rel), None) + if read.status == "refused": + return CompactResult( + findings=[ + Finding( + "artifact-parse", + "error", + f"artifact {rel} resolves outside the layer root", + rel, + ) + ], + folded=0, + kept=0, + path=rel, + ) + if read.status == "absent": return CompactResult( findings=[ Finding("changelog-required", "error", f"changelog {rel} does not exist", rel) @@ -155,6 +177,15 @@ def compact_changelog( kept=0, path=rel, ) + try: + data = json.loads(read.text()) + except (ValueError, UnicodeDecodeError) as e: + return CompactResult( + findings=[Finding("artifact-parse", "error", f"invalid JSON: {e}", rel)], + folded=0, + kept=0, + path=rel, + ) log = data if isinstance(data, dict) else {} raw_entries = log.get("entries") original: list[dict] = ( @@ -212,7 +243,7 @@ def fold_by_before(e: dict) -> bool: nxt = {**log, "entries": [*survivors, compaction]} abs_path = Path(root) / rel - if not resolved_within_root(root, abs_path): + if not write_file_guarded(root_real, str(abs_path), None, serialize_changelog(nxt)).ok: return CompactResult( findings=[ Finding( @@ -226,7 +257,5 @@ def fold_by_before(e: dict) -> bool: kept=len(original), path=rel, ) - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(serialize_changelog(nxt), encoding="utf-8") return CompactResult(findings=[], folded=n_folded, kept=len(nxt["entries"]), path=rel) diff --git a/packages/sdk-py/src/leji/cigen.py b/packages/sdk-py/src/leji/cigen.py new file mode 100644 index 0000000..3843644 --- /dev/null +++ b/packages/sdk-py/src/leji/cigen.py @@ -0,0 +1,469 @@ +"""The generated CI job and the pre-commit hook body. + +One table, one job resolution, four renderers. Every cell an adopter's pipeline +runs is stated here rather than assembled at the call site, so the three SDKs +transcribe data instead of re-deriving prose, and a reviewer reads the matrix. +Transcribed from the TypeScript reference (``packages/sdk/src/commands/init.ts``). +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass + +from .ecosystem import DEP_NAME, EcosystemReport, manager_runner_argv + +# Every provider `leji ci` generates for, in a fixed order. +CI_PROVIDERS = ["github", "gitlab", "circleci", "azure"] + + +@dataclass(frozen=True) +class CiManagerCell: + """One package manager's CI facts. ``pip_bootstrap`` names a tool that has to be + installed with pip wherever the provider offers no dedicated setup action; + ``unpinned`` names a bootstrap tool the job installs unpinned, disclosed in one + comment line.""" + + runtime: str + install: str + pip_bootstrap: str = "" + unpinned: str = "" + + +# Manager -> install command and runtime. The runner argv is NOT duplicated here: +# it comes from the detection report, which owns the one runner table. Insertion +# order is the enumeration order the goldens depend on. +CI_MANAGERS: dict[str, CiManagerCell] = { + "npm": CiManagerCell(runtime="node", install="npm ci"), + "pnpm": CiManagerCell( + runtime="node", install="corepack enable && pnpm install --frozen-lockfile" + ), + "yarn": CiManagerCell( + runtime="node", install="corepack enable && yarn install --frozen-lockfile" + ), + "bun": CiManagerCell(runtime="bun", install="bun install --frozen-lockfile"), + "uv": CiManagerCell(runtime="python", install="uv sync --locked", pip_bootstrap="uv"), + "poetry": CiManagerCell( + runtime="python", install="pip install poetry && poetry install", unpinned="poetry" + ), + "pdm": CiManagerCell( + runtime="python", install="pip install pdm && pdm install", unpinned="pdm" + ), + "pipenv": CiManagerCell( + runtime="python", install="pip install pipenv && pipenv install --dev", unpinned="pipenv" + ), + "go": CiManagerCell(runtime="go", install="go mod download"), +} + + +@dataclass +class CiJob: + """The one job a provider renders: what to set up, what to install, what to run.""" + + runtime: str + install: list[str] + runner: list[str] + unpinned: str = "" + uv_action: bool = False + local: bool = False + + +# The CLI as CI reaches it when the repository does not declare it: version-pinned +# to the current major, which is additive-only, so a valid layer stays valid and a +# breaking major never reaches adopter CI without a bump. +CI_FALLBACK_NODE = ["npx", "-y", f"{DEP_NAME}@1"] +CI_FALLBACK_PY_INSTALL = "pip install 'leji>=1,<2'" +CI_FALLBACK_GO_INSTALL = "go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" + + +def resolve_ci_job(report: EcosystemReport, provider: str) -> CiJob: + """Which job this repository gets. Local-first: a repository that DECLARES the CLI + and has the manager's lock evidence installs its own locked dependencies and runs + the local binary. Everything else — undeclared, unlocked, ambiguous, unsupported, + unreadable, refused evidence, several ecosystems, none — takes the fallback for its + ecosystem, which needs no manifest and no lockfile.""" + selected = report.selected + cell = CI_MANAGERS.get(selected.manager) if selected is not None and selected.manager else None + if ( + selected is not None + and cell is not None + and selected.direct_declared + and selected.lock_evidenced + and selected.runner + ): + # uv is the one manager with a first-party setup action; everywhere else it is + # pip-installed like poetry/pdm/pipenv, and disclosed the same way. + uv_action = provider == "github" and cell.pip_bootstrap == "uv" + bootstrap = cell.pip_bootstrap if cell.pip_bootstrap and not uv_action else "" + install = f"pip install {bootstrap} && {cell.install}" if bootstrap else cell.install + return CiJob( + runtime=cell.runtime, + install=[install], + runner=list(selected.runner), + unpinned=cell.unpinned or bootstrap, + uv_action=uv_action, + local=True, + ) + ecosystem = report.all[0].ecosystem if len(report.all) == 1 else None + if ecosystem == "python": + return CiJob(runtime="python", install=[CI_FALLBACK_PY_INSTALL], runner=["leji"]) + if ecosystem == "go": + return CiJob(runtime="go", install=[CI_FALLBACK_GO_INSTALL], runner=["leji"]) + # Node, several ecosystems, and none alike: the job that needs no package manager. + return CiJob(runtime="node", install=[], runner=list(CI_FALLBACK_NODE)) + + +# The generator schema version. Bumped when the generated shape changes, so the +# marker says which generation wrote a file; pre-1.4 output is implicitly v1. +CI_GENERATOR_VERSION = "2" +CI_MARKER = f"# generated by leji ci (managed) v{CI_GENERATOR_VERSION}" + +GITLAB_MARKER_START = "# >>> leji ci (managed) >>>" +GITLAB_MARKER_END = "# <<< leji ci (managed) <<<" + + +def _unpinned_note(job: CiJob) -> str: + """The one disclosure line for a job that installs a bootstrap tool unpinned.""" + if not job.unpinned: + return "" + return f"# {job.unpinned} is installed unpinned here; pin it if your project pins it." + + +def _github_setup(job: CiJob) -> list[str]: + """GitHub Actions setup steps for a runtime, already at the steps' indentation.""" + if job.runtime == "node": + return [ + " - uses: actions/setup-node@v4", + " with:", + " node-version: '22'", + ] + if job.runtime == "bun": + return [" - uses: oven-sh/setup-bun@v2"] + if job.runtime == "python": + lines = [ + " - uses: actions/setup-python@v5", + " with:", + " python-version: '3.12'", + ] + if job.uv_action: + lines.append(" - uses: astral-sh/setup-uv@v5") + return lines + return [" - uses: actions/setup-go@v5", " with:", " go-version: '1.24'"] + + +def build_github_workflow(job: CiJob) -> str: + note = _unpinned_note(job) + runner = " ".join(job.runner) + lines = [ + CI_MARKER, + "name: leji", + "on: [push, pull_request]", + "jobs:", + " validate:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v4", + *_github_setup(job), + *([f" {note}"] if note else []), + *[f" - run: {cmd}" for cmd in job.install], + f" - run: {runner} validate", + f" - run: {runner} index --check", + ] + return "\n".join(lines) + "\n" + + +def _ci_image(runtime: str) -> str: + """The container image a job runs in on the image-based providers.""" + return {"node": "node:22", "bun": "oven/bun:1", "python": "python:3.12"}.get( + runtime, "golang:1.24" + ) + + +def build_gitlab_block(job: CiJob) -> str: + note = _unpinned_note(job) + runner = " ".join(job.runner) + # `.pre` is always available. Without an explicit stage GitLab assigns `test`, and + # a pipeline whose own `stages:` list omits `test` rejects the whole configuration, + # so the generated job would break an existing pipeline it was merged into. + lines = [ + GITLAB_MARKER_START, + "leji-validate:", + " stage: .pre", + f" image: {_ci_image(job.runtime)}", + " script:", + *([f" {note}"] if note else []), + *[f" - {cmd}" for cmd in job.install], + f" - {runner} validate", + f" - {runner} index --check", + GITLAB_MARKER_END, + ] + return "\n".join(lines) + "\n" + + +def _circleci_job(job: CiJob) -> list[str]: + """CircleCI job steps, shared by the full config and the hand-add snippet.""" + note = _unpinned_note(job) + runner = " ".join(job.runner) + return [ + "jobs:", + " leji-validate:", + " docker:", + f" - image: {_ci_image(job.runtime)}", + " steps:", + " - checkout", + *([f" {note}"] if note else []), + *[f" - run: {cmd}" for cmd in job.install], + f" - run: {runner} validate", + f" - run: {runner} index --check", + "workflows:", + " leji:", + " jobs:", + " - leji-validate", + ] + + +def build_circleci_config(job: CiJob) -> str: + return "\n".join([CI_MARKER, "version: 2.1", *_circleci_job(job)]) + "\n" + + +def build_circleci_snippet(job: CiJob) -> str: + """The jobs + workflows fragment to add by hand to an existing CircleCI config. + No marker: it is pasted into a file leji does not own.""" + return "\n".join(_circleci_job(job)) + "\n" + + +def _azure_setup(job: CiJob) -> list[str]: + """Azure Pipelines setup tasks for a runtime, at the steps' indentation.""" + if job.runtime == "node": + return [" - task: NodeTool@0", " inputs:", " versionSpec: '22.x'"] + if job.runtime == "bun": + return [ + " - task: NodeTool@0", + " inputs:", + " versionSpec: '22.x'", + " - script: npm install -g bun", + " displayName: install bun", + ] + if job.runtime == "python": + return [" - task: UsePythonVersion@0", " inputs:", " versionSpec: '3.12'"] + return [" - task: GoTool@0", " inputs:", " version: '1.24'"] + + +def build_azure_pipeline(job: CiJob) -> str: + note = _unpinned_note(job) + runner = " ".join(job.runner) + install_lines: list[str] = [] + for cmd in job.install: + install_lines.extend([f" - script: {cmd}", " displayName: install"]) + lines = [ + CI_MARKER, + "trigger:", + " - main", + "pool:", + " vmImage: ubuntu-latest", + "steps:", + *_azure_setup(job), + *([f" {note}"] if note else []), + *install_lines, + f" - script: {runner} validate", + " displayName: leji validate", + f" - script: {runner} index --check", + " displayName: leji index --check", + ] + return "\n".join(lines) + "\n" + + +def build_ci_file(provider: str, job: CiJob) -> str: + """The whole file a provider writes for a job; GitLab's is the block it owns + inside a shared file.""" + if provider == "github": + return build_github_workflow(job) + if provider == "gitlab": + return build_gitlab_block(job) + if provider == "circleci": + return build_circleci_config(job) + return build_azure_pipeline(job) + + +def _ci_job_variants(provider: str) -> list[tuple[str, CiJob]]: + """Every job this generator can produce, in a fixed order: the nine local manager + cells, then the three ecosystem fallbacks.""" + out: list[tuple[str, CiJob]] = [] + for manager, cell in CI_MANAGERS.items(): + uv_action = provider == "github" and cell.pip_bootstrap == "uv" + bootstrap = cell.pip_bootstrap if cell.pip_bootstrap and not uv_action else "" + install = f"pip install {bootstrap} && {cell.install}" if bootstrap else cell.install + out.append( + ( + f"{manager}-local", + CiJob( + runtime=cell.runtime, + install=[install], + runner=manager_runner_argv(manager) or ["leji"], + unpinned=cell.unpinned or bootstrap, + uv_action=uv_action, + local=True, + ), + ) + ) + out.append(("node-fallback", CiJob(runtime="node", install=[], runner=list(CI_FALLBACK_NODE)))) + out.append( + ( + "python-fallback", + CiJob(runtime="python", install=[CI_FALLBACK_PY_INSTALL], runner=["leji"]), + ) + ) + out.append( + ("go-fallback", CiJob(runtime="go", install=[CI_FALLBACK_GO_INSTALL], runner=["leji"])) + ) + return out + + +def ci_variants() -> list[tuple[str, str, str]]: + """Every generated artifact of the CURRENT generator: (provider, key, bytes).""" + out: list[tuple[str, str, str]] = [] + for provider in CI_PROVIDERS: + for key, job in _ci_job_variants(provider): + out.append((provider, key, build_ci_file(provider, job))) + return out + + +# Digests of every whole file this generator has ever written, so a file leji created +# in an EARLIER release is still recognized as its own and upgraded rather than +# abandoned. Appended at each release; the marker line carries the generator version +# that wrote a file, and these digests carry the ones that predate it. +# +# Keyed by provider, and consulted only for the provider whose path is being written: +# the same bytes are leji's workflow at .github/workflows/leji.yml and somebody +# else's file at .azure-pipelines/leji.yml. +# +# Seeded with the pre-1.4 (1.3.x) variants, which carry no marker at all: two per +# whole-file provider, the local-install job and the `npx @leji-org/leji@1` fallback. +# Each release since appends its own twelve at pre-flight, enumerated by ci_variants() +# and printed by a test, so the next release still recognizes them; while a release is +# current its variants are also compared by bytes, which is strictly stronger. +KNOWN_GENERATED: dict[str, list[str]] = { + # 1.3.x GitHub Actions: local install, then the npx fallback. + "github": [ + "ef38ea0bc0daa13b9856ca9abeb5f2229ae2465aeed2f61bd94f557a1806f13d", + "1c2afeb4d3043f94823ac0c1a254a8735c0fe87844cd454cf8ae07fbfa6588d4", + # 1.4.0: the twelve job variants, in ciVariants() order. + "616638c5c1594e8faeb38cd476b9c5e0a4d12889a01b54076a0f8ffceba8a1a8", # npm-local + "1b9afce2109d75c86ba3e3b33abd2466d55509d7e46b5ef79a9407d3df566154", # pnpm-local + "e16f877d33a5be6e2c720112692167e9442fb5c63a2335f95401b7a891d46e18", # yarn-local + "e0819c85b4b3e540472fa5d2a3820ae0c4837a41b66cc97de84581c1b19ede96", # bun-local + "7b3d400ea23799ebf26541bffe059db4e9f60021fdf0f783c1f1cfa7a532816d", # uv-local + "87089be204a85a61dfcfbcb9f4a9f2ad2f5afc8b00f384d52dfbd81c63e0296f", # poetry-local + "48b3c7a1751b65bbea29421e7e952ac8c2720169ff332ea0e277573a2fb582c0", # pdm-local + "809d7ee991b8c1182442d93e326d4dc3ad9e0993f91f4da83aac8187c98e90bb", # pipenv-local + "e952a6109a05d97adc2791f641f807245c75ba401ced279964b06fcc2987b92e", # go-local + "ae3385d9deac83936000100621078011b1918a66237d4ae1ef72770dc677914a", # node-fallback + "b0be130068ad150eb7f59a2166a4fc22601e10ec961d2144d4c158602fde1f9c", # python-fallback + "91b37a1c14fcc6f237d9600b8f49bb936eb2091f418fe8932fc94cdbfe91454f", # go-fallback + ], + # GitLab owns a marked block inside a shared file, never a whole file, so it + # recognizes its own output by the markers and registers no digests. + "gitlab": [], + "circleci": [ + "99a942be4f0ac62672af68a9d33e17328441e64f3b28b52ff8dafede0a5ce9f0", + "cf813aa8c65a5efa64500628bc51c73d3ae3a5f56ec47386f5525de0828818d3", + # 1.4.0: the twelve job variants, in ciVariants() order. + "e72c78146170d54a4b79326b3a8933ba0ca54bf76be665b45f47009729a864b1", # npm-local + "039559039ccda2dca14da3366cb1ca56895f4eaf48a395a7dd75f4a3614dabc1", # pnpm-local + "ba5303502b7fc3e70174b163666fe27af855663b2b66900a0e1affcd3ee3290b", # yarn-local + "e46e865183f764c1d6bf594e9d074744911221232db2805ea3de3896fa920ce4", # bun-local + "4b974432dc0d1939a89e8ca9c130ec16c70e1aeb1252fd5895785018e9e84f98", # uv-local + "3cc62af0609c563e0268e632285d28d7365c4ce81652e1c0d9f3f62496f01665", # poetry-local + "b519fa8e216b62a8da7ce0f98b53de81dde62f4c3bed924b7fbc59b7b5f8af1f", # pdm-local + "6786b3df303d00239170bf0365e7ff66662fcfc0912ea6cd992547e1422eed9a", # pipenv-local + "43ca7c5fce284555d5b72a6cd69c153e295ae01f921dd5e2b755e11d4e3e9e8f", # go-local + "b82f65f616ec46445e43b8c1680618428cce89ee17e69f0a1e2b94b4ace2fc9b", # node-fallback + "0d2135d41e50be5fa6811bdc9aa85fc0ce4110ec918e17c139cd969087834e4b", # python-fallback + "704a4b3c3f8880c505e181eed154234e4640cdad5d13a3c9dfe5b4cdcdfbd3d9", # go-fallback + ], + "azure": [ + "71fb19e18660e84ec4a2b9364ea6a9dea0ca7aff8bb52ede8d5c3f4d77c68669", + "7a086e5cd0f2e8a2e67b925ec54b8e8febb1bca016e1893c95fd00815d86c63a", + # 1.4.0: the twelve job variants, in ciVariants() order. + "9c8127bfb670731eb08089b02a1adecc135bc32524699793e83e23eb4143e4f1", # npm-local + "099befce80e7420297583ee3a88bed97f01c073dbb756bbf64ad37b321eb506a", # pnpm-local + "5d1f2642c97954b6fca52fa8239534c5633cc3bc512c7946f2515473bde58bb7", # yarn-local + "9e60a214631033172e3021d773581f15db14c3e1e05d1673f05d7752ff054b03", # bun-local + "c4d041736cedbd2092667480bbaf91711f3696997256456fa276a59660c66555", # uv-local + "2913197fc21a1fbf3587695af2b2d2215b6f9966507b542ecc08e44727b5bf2b", # poetry-local + "f756c8ea1ff653d4bed01ac60aa9ba26b426936cf19a5be54508a166e66ca6f6", # pdm-local + "7a452ce135e706fdada5f953a18bdaafb0fd453650d061b0e07f62e5309d6d58", # pipenv-local + "ce1b7546d140ba09838e9af8dab92ecebf75f20c7e7714c2eeb210ab1f1422d3", # go-local + "1a4167a0b4b3a5b7528d7a6d0bcefeabd37f617b02f82772fd6c74c148d9e17e", # node-fallback + "c9cd3d115cb4f4d9db1b3f523cfbbf1397923df6142c58e5e6c8562ff57fa908", # python-fallback + "23679c491cbd53e39ffc5d940de7b97865f1b944bf55ad1bd7e73b8c57520606", # go-fallback + ], +} + + +def is_leji_generated(provider: str, text: str) -> bool: + """Is this file leji's to replace? Yes when its bytes are one this generator can + write right now, or when its digest is one an earlier release wrote. A file the + user edited matches neither, and is left alone with a snippet — editing a generated + file, or deleting its marker, is the explicit opt-out, and it is honored.""" + for prov, _key, body in ci_variants(): + if prov == provider and body == text: + return True + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + # Scoped to THIS provider: a file that is leji's at one provider's path is a + # foreign file at another's, and a foreign file is never replaced. + return digest in KNOWN_GENERATED.get(provider, []) + + +# --- the hook ------------------------------------------------------------- + +HOOK_MARKER = "# leji pre-commit (managed)" +HUSKY_MARKER_START = "# >>> leji hooks (managed) >>>" +HUSKY_MARKER_END = "# <<< leji hooks (managed) <<<" + + +def sh_quote(word: str) -> str: + """One argv element, quoted for ``sh``. Single quotes take everything literally, + and an embedded quote is closed, escaped, and reopened (``'\\''``) — the one escape + a POSIX shell accepts inside them. The runner comes from the repository's own + package manager, so it is never interpolated raw into generated shell.""" + return "'" + word.replace("'", "'\\''") + "'" + + +def _sh_command(runner: list[str]) -> str: + """The runner argv as one quoted command prefix.""" + return " ".join(sh_quote(word) for word in runner) + + +def _hook_gates(runner: list[str]) -> str: + # The failure message is single-quoted for the SHELL: the backticks around + # `leji index` are literal text, and inside a double-quoted echo sh would run them + # as a command substitution (regenerating the index the hook just refused a commit + # over). Never emit an unquoted backtick, "$(", or "$VAR" into generated shell + # unless expansion is the intent. + leji = _sh_command(runner) + return ( + f"{leji} validate || exit 1\n" + f"{leji} index --check || {{\n" + " echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2\n" + " exit 1\n" + "}\n" + ) + + +def hook_body(runner: list[str]) -> str: + """The standalone managed pre-commit hook, running the repository's own runner.""" + return ( + "#!/bin/sh\n" + f"{HOOK_MARKER}\n" + "# Validate the context layer and refuse a commit that would leave the stored\n" + "# index stale. Local mirror of the CI gate; delete this file to opt out.\n" + f"{_hook_gates(runner)}" + ) + + +def husky_block(runner: list[str]) -> str: + """The same two gates ``hook_body`` runs, wrapped in markers so the block can be + merged into a husky repo's hand-authored ``.husky/pre-commit`` without touching its + rest.""" + return f"{HUSKY_MARKER_START}\n{_hook_gates(runner)}{HUSKY_MARKER_END}\n" diff --git a/packages/sdk-py/src/leji/cli.py b/packages/sdk-py/src/leji/cli.py index 5f3ec54..cd0cae6 100644 --- a/packages/sdk-py/src/leji/cli.py +++ b/packages/sdk-py/src/leji/cli.py @@ -8,28 +8,34 @@ import argparse import datetime as _dt import json +import os import re import sys from typing import cast +from .badge import DEFAULT_BADGE_OUT, BadgeResult, badge_label, badge_run from .changelog import compact_changelog, seed_changelog_if_missing from .conformance import conformance_report, render_explain from .detect import detect_hosts, detect_layer, render_detect -from .viewer_cmd import ( - PROTECT_WARNING, - build_viewer, - generate_viewer, - open_browser, - resolve_viewer_port, - serve_viewer, -) +from .export_cmd import PROTECT_WARNING, BuildResult, build_viewer +from .serve_cmd import open_browser, serve_viewer +from .viewer_cmd import generate_viewer, resolve_viewer_port from .findings import Finding, has_errors, sort_findings, summarize from .freshness import freshness_report -from .fsx import strip_slash from .indexgen import check_index, write_index +from .layout import DIST_REL, VIEWER_REL from .gitutil import git_origin_url +from .dependency import dependency_add_failed, offer_dependency +from .ecosystem import ( + EcosystemReport, + detect_ecosystem, + render_ecosystem_block, + render_ecosystem_line, + runner_argv, +) from .init_cmd import ( StartOptions, + _default_handoff_io, add_agent, adopt_layer, ci_provider_from_remote, @@ -45,6 +51,15 @@ McpOfferOptions, offer_approval_guard, offer_mcp_install, + boot_profile_ready, + resolve_start_host, +) +from .preflight import ( + check_document, + color_decision, + offer_preflight_fixes, + render_preflight, + run_preflight, ) from .manifest import CATEGORY_IDS, effective_changelog_path, effective_index_path, load_manifest from .mounts import ( @@ -55,8 +70,14 @@ mount_status, ) from .route import RouteInput, route -from .status import status_report +from .status import status_report, unindexed_paths from .schemas import SDK_VERSION, SUPPORTED_LINES, load_cli_spec +from .update_pin import ( + MOUNT_UPDATE_PIN_REASONS, + UpdatePinResult, + short_oid, + update_pin_run, +) from .validate import check_changelog_append_only, validate_layer from .writeplan import render_write_plan @@ -83,29 +104,134 @@ def _projection_json(proj: SelfProjection) -> dict[str, object]: return {"state": "fail", "commit": proj.commit, "detail": proj.detail} -def _build_usage() -> str: - """Top-level help, generated from cli.json so it can't drift from the docs. - Commands + global options only; per-command options live in - `leji --help`. Byte-for-byte parity with renderUsage() in index.ts.""" - spec = load_cli_spec() +#: Terminal help wraps at a fixed width, never the actual terminal's: help bytes are a +#: shared contract across the three SDKs, so they may not depend on the environment. +HELP_WIDTH = 80 + +_PARAGRAPH_BREAK = re.compile(r"\n[ \t]*\n") + + +def _wrap(text: str, width: int, indent_first: int, indent_rest: int) -> list[str]: + """The one line-wrapper behind every terminal help surface, so the three SDKs emit + the same bytes: whitespace runs collapse to one space, the first line is indented by + ``indent_first`` and every continuation by ``indent_rest``, and width is counted in + code points. A token that cannot fit the remaining width takes a line of its own, + unbroken (URLs and flag spellings stay copyable). Empty text yields no lines. + Mirrors wrap() in lib/text.ts.""" + words = text.split() + if not words: + return [] + lines: list[str] = [] + indent = indent_first + current = "" + for word in words: + room = width - indent - len(current) + if current == "": + current = word + elif len(word) + 1 <= room: + current += " " + word + else: + lines.append(" " * indent + current) + indent = indent_rest + current = word + lines.append(" " * indent + current) + return lines + + +def _help_row(label: str, col: int, text: str) -> list[str]: + """One row of a two-column help block: a label on the left, its prose on the right, + the prose hanging under itself at ``col``. A label that would leave no gap before its + summary -- one at least as wide as the column, which the option column's clamp makes + reachable -- takes the line alone and its summary starts on the next line at the same + column, so a long flag never concatenates into the text describing it. Mirrors + helpRow() in lib/text.ts.""" + lines = _wrap(text, HELP_WIDTH, col, col) + if len(label) >= col - 3: + head = f" {label}" + return [head] if not lines else [head, *lines] + head = f" {label.ljust(col - 3)}" + if not lines: + return [head.rstrip()] + return [head + lines[0][col:], *lines[1:]] + + +def _bounded_column(labels: list[str], gap: int, low: int, high: int) -> int: + """Where a two-column block's right column starts: the longest label plus a gap, kept + inside a band so one long label cannot push every summary to the right edge, and + measured in code points. Past the band's top the label outgrows the column and + ``_help_row`` gives it its own line. Every dynamic label class in terminal help + resolves its column here. Mirrors boundedColumn() in lib/text.ts.""" + longest = max((len(label) for label in labels), default=0) + return 3 + min(high, max(low, longest + gap)) + + +def _option_column(options: list[dict[str, str]]) -> int: + """Option rows, top-level and per-command: flags plus 3, bounded to [20, 30].""" + return _bounded_column([o["flags"] for o in options], 3, 20, 30) + + +def _name_column(commands: list[dict[str, object]]) -> int: + """Command and alias rows: the name plus 3, bounded to [12, 30].""" + return _bounded_column([str(c["name"]) for c in commands], 3, 12, 30) + + +def _exit_code_column(exit_codes: list[dict[str, object]]) -> int: + """Exit-code rows: the code plus 2 (digits, not words), bounded to [3, 8].""" + return _bounded_column([str(e["code"]) for e in exit_codes], 2, 3, 8) + + +def _build_usage(spec: dict[str, object] | None = None) -> str: + """Top-level help, generated from cli.json so it can't drift from the docs: the + commands by group, the global options, and the exit codes. Per-command options live + in `leji --help`. Byte-for-byte parity with renderUsage() in index.ts. A + caller may pass a spec, so the bounds can be exercised against a synthetic one.""" + spec = load_cli_spec() if spec is None else spec commands = cast("list[dict[str, object]]", spec["commands"]) + groups = cast("list[dict[str, str]]", spec["groups"]) global_options = cast("list[dict[str, str]]", spec["globalOptions"]) + exit_codes = cast("list[dict[str, object]]", spec["exitCodes"]) + # Every emitted field goes through the wrapper, including the ones no current value + # is long enough to overflow: a longer version string or group title must not be what + # discovers that a line was never wrapped. out: list[str] = [ - f"leji {SDK_VERSION}: reference CLI for the Leji specification " - f"(spec line {', '.join(SUPPORTED_LINES)})", - "", - f"Usage: {spec['usage']}", + *_wrap( + f"leji {SDK_VERSION}: reference CLI for the Leji specification " + f"(spec line {', '.join(SUPPORTED_LINES)})", + HELP_WIDTH, + 0, + 3, + ), "", - "Commands:", + *_wrap(f"Usage: {spec['usage']}", HELP_WIDTH, 0, 7), ] - cmd_width = max(len(str(c["name"])) for c in commands) + 3 - for c in commands: - out.append(f" {str(c['name']).ljust(cmd_width)}{c['summary']}") - opt_width = max(len(o["flags"]) for o in global_options) + 3 + # One name column across every group, so the summaries line up down the whole list + # rather than jumping per section. + cmd_col = _name_column(commands) + for g in groups: + out.extend(["", *_wrap(f"{g['title']}:", HELP_WIDTH, 0, 0)]) + for c in commands: + if c["group"] != g["id"] or c.get("aliasOf"): + continue + out.extend(_help_row(str(c["name"]), cmd_col, str(c["summary"]))) + # An alias earns a line under its primary, not a row of its own: it is the + # same command, and repeating the summary reads as a second one. It keeps + # the name column, so the right-hand column stays straight down the list. + for a in commands: + if a.get("aliasOf") == c["name"]: + out.extend(_help_row(str(a["name"]), cmd_col, f"(alias of {c['name']})")) + + opt_col = _option_column(global_options) out.extend(["", "Options:"]) for o in global_options: - out.append(f" {o['flags'].ljust(opt_width)}{o['summary']}") + out.extend(_help_row(o["flags"], opt_col, o["summary"])) + + # The meaning hangs under itself, like every other two-column block here, so a + # continuation line is never mistaken for another code. + code_col = _exit_code_column(exit_codes) + out.extend(["", "Exit codes:"]) + for e in exit_codes: + out.extend(_help_row(str(e["code"]), code_col, str(e["meaning"]))) out.extend( [ @@ -117,35 +243,35 @@ def _build_usage() -> str: return "\n".join(out) -def _build_command_help(name: str) -> str | None: - """Per-command help from cli.json. Returns None for an undocumented command - (caller falls back to top-level usage). Parity with renderCommandHelp() in index.ts.""" - spec = load_cli_spec() +def _build_command_help(name: str, spec: dict[str, object] | None = None) -> str | None: + """Per-command help from cli.json: this command's own options only, with the globals + one pointer away. Returns None for an undocumented command (caller falls back to + top-level usage). Parity with renderCommandHelp() in index.ts. A caller may pass a + spec, so the bounds can be exercised against a synthetic one.""" + spec = load_cli_spec() if spec is None else spec commands = cast("list[dict[str, object]]", spec["commands"]) - global_options = cast("list[dict[str, str]]", spec["globalOptions"]) cmd = next((c for c in commands if c["name"] == name), None) if cmd is None: return None out: list[str] = [ - f"leji {cmd['name']}: {cmd['summary']}", - "", - f"Usage: {cmd['usage']}", + *_wrap(f"leji {cmd['name']}: {cmd['summary']}", HELP_WIDTH, 0, 3), "", - str(cmd["description"]), + *_wrap(f"Usage: {cmd['usage']}", HELP_WIDTH, 0, 7), ] + for para in _PARAGRAPH_BREAK.split(str(cmd["description"])): + out.extend(["", *_wrap(para, HELP_WIDTH, 0, 0)]) details = cast("list[str]", cmd.get("details") or []) if details: out.extend(["", "Details:"]) for d in details: - out.append(f" - {d}") + out.extend(_wrap(f"- {d}", HELP_WIDTH, 3, 5)) cmd_opts = cast("list[dict[str, str]]", cmd["options"]) - opts = [*global_options, *cmd_opts] - opt_width = max(len(o["flags"]) for o in opts) + 3 - out.extend(["", "Options:"]) - for o in opts: - # Byte parity with Node: an option missing "summary" in cli.json renders - # the literal "undefined" there (template-string of an absent field). - out.append(f" {o['flags'].ljust(opt_width)}{o.get('summary', 'undefined')}") + if cmd_opts: + opt_col = _option_column(cmd_opts) + out.extend(["", "Options:"]) + for o in cmd_opts: + out.extend(_help_row(o["flags"], opt_col, o["summary"])) + out.extend(["", "Global options: see leji --help."]) examples = cast("list[str]", cmd.get("examples") or []) if examples: out.extend(["", "Examples:"]) @@ -167,6 +293,34 @@ def _is_calendar_date(v: str) -> bool: return False +def _emit_scaffold( + command: str, + findings: list[Finding], + written: list[str], + ecosystem: "EcosystemReport", + dry_run: bool, +) -> int: + """The one ``--json`` document ``init`` and ``adopt`` emit: a single object, like + every other command's, carrying what the run wrote and the repository's dependency + ecosystem. ``--json`` is non-interactive by construction, so nothing here can have + prompted or run a package manager; the report is what a consumer acts on.""" + ordered = sort_findings(findings) + summary = summarize(ordered) + ok = summary["errors"] == 0 + document: dict[str, object] = { + "command": command, + "ok": ok, + "findings": [f.to_dict() for f in ordered], + "summary": summary, + } + if dry_run: + document["dryRun"] = True + document["written"] = written + document["ecosystem"] = ecosystem.to_json() + print(json.dumps(document, indent=2, ensure_ascii=False)) + return 0 if ok else 1 + + def _report_scaffold_index(findings: list[Finding]) -> int: """Report index-generation findings from ``init`` / ``adopt``. The scaffold is already on disk, so this never unwinds it; it says what could not be indexed and @@ -186,7 +340,8 @@ def _report_scaffold_index(findings: list[Finding]) -> int: def _print_findings(findings: list[Finding]) -> None: for f in sort_findings(findings): - where = f" {f.path}" if f.path else "" + # 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}") @@ -260,6 +415,198 @@ def _mount_findings(rows: list[dict[str, object]]) -> list[Finding]: } +def _print_unindexed_nudge(count: int) -> None: + """The `index` generate run's closing nudge. Byte-identical in all three SDKs + and quiet at zero: a layer with nothing unindexed says nothing.""" + if count <= 0: + return + print(f"{count} file(s) unindexed: add to a category index or leave as reference deliberately") + + +def _run_export(args: argparse.Namespace) -> 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 + by construction — same default output, same JSON document, same exits. + + 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 + (raised, and rendered by the caller's catch).""" + load = load_manifest(args.root) + out = getattr(args, "out", None) + # 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 load.manifest is None: + 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)) + + +def _report_export(args: argparse.Namespace, r: BuildResult) -> int: + """The one export report, for every outcome the pipeline can reach.""" + ordered = sort_findings(r.findings) + if args.json: + # The canonical JSON document for this command, under either name. + print( + json.dumps( + { + "command": "export", + "ok": r.wrote, + "out": r.out, + "findings": [f.to_dict() for f in ordered], + "warning": PROTECT_WARNING, + }, + indent=2, + ensure_ascii=False, + ) + ) + return 0 if r.wrote else 1 + if not r.wrote: + summary = summarize(ordered) + _print_findings(ordered) + plural_e = "" if summary["errors"] == 1 else "s" + plural_w = "" if summary["warnings"] == 1 else "s" + strict_note = "; strict, nothing written" if args.strict else "" + print( + f"failed ({summary['errors']} error{plural_e}, " + f"{summary['warnings']} warning{plural_w}{strict_note})" + ) + return 1 + # Human mode says where the export went and repeats the protect-your-context + # warning, which is the part a person must act on before hosting it. + print(f"Exported the static viewer to {r.out}/") + print(f"\n{PROTECT_WARNING}") + return 0 + + +def _report_update_pin(args: argparse.Namespace, r: UpdatePinResult) -> int: + """Render one `mounts update-pin` run. The comparison is shown first, then what + the run did with it, then the follow-up act this command deliberately does not + perform. Every string is Leji-authored: git's stderr never reaches output.""" + # An internal refusal after validation carries no document at all: there is no + # outcome to report, only the act this run would not perform. + if r.write_error is not None: + print(f"leji: {r.write_error}", file=sys.stderr) + return 2 + findings = sort_findings(r.findings) + summary = summarize(findings) + ok = summary["errors"] == 0 + if args.json: + payload: dict[str, object] = { + "command": "mounts update-pin", + "ok": ok, + "findings": [f.to_dict() for f in findings], + "summary": summary, + "mount": r.mount, + "pinReport": r.pin_report, + "action": r.action, + "override": r.override, + } + if r.reason is not None: + payload["reason"] = r.reason + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 if ok else 1 + rep = r.pin_report + to_oid = r.mount["to"] + from_oid = r.mount["from"] + if ( + rep is not None + and rep["state"] != "unknown" + and to_oid is not None + and from_oid is not None + ): + # Offline, the witness is the last one successfully observed — never a claim + # that the source was looked at during this run. + observed = ( + "" if args.fetch else " (last observed witness; run with --fetch to observe the source)" + ) + print( + f"{r.mount['name']} @ {short_oid(cast('str', from_oid))} → " + f"{short_oid(cast('str', to_oid))} · pin: {rep['state']} " + f"(behind {rep['behind']}, ahead {rep['ahead']}) · " + f"via {rep['comparisonRepository']}{observed}" + ) + overridden = " (non-fast-forward, overridden)" if r.override else "" + from12 = "" if from_oid is None else short_oid(cast("str", from_oid)) + to12 = "" if to_oid is None else short_oid(cast("str", to_oid)) + if r.action == "updated": + print(f"Updated leji.json: {r.mount['name']} pin {from12} → {to12}{overridden}") + # Moving the pin is one act; materializing the new projection is another. + print(f"Run leji mounts hydrate{'' if args.fetch else ' --fetch'} to hydrate the new pin.") + elif r.action == "unchanged": + print(f"Unchanged: {r.mount['name']} pin {from12} is already the target") + elif r.action == "dry-run": + print( + f"Would update leji.json: {r.mount['name']} pin {from12} → {to12} (dry run){overridden}" + ) + elif r.action == "refused": + reason = r.reason or "" + print(f"Refused: {MOUNT_UPDATE_PIN_REASONS.get(reason, reason)}") + return 0 if ok else 1 + + +def _report_badge(args: argparse.Namespace, r: BadgeResult) -> int: + """The one `leji badge` report, for every outcome the command can reach. The JSON + document is the shared `_emit()` shape plus the badge's own fields, emitted under + success and refusal alike so a consumer parses one document; the human channel says + what was written and hands over the markdown line to paste. + + Exits: `0` the badge is written or already current, `1` a conformance error finding or + nothing machine-verified in this run, `2` a `--out` usage error (rendered by the + caller, with no level reported) or a refusal to overwrite a file that is not a badge + of this contract.""" + findings = sort_findings(r.findings) + summary = summarize(findings) + ok = summary["errors"] == 0 + code = 2 if r.refusal is not None else 0 if ok else 1 + if args.json: + print( + json.dumps( + { + "command": "badge", + "ok": ok, + "findings": [f.to_dict() for f in findings], + "summary": summary, + "out": r.out, + "level": r.level, + "claimedLevel": r.claimed_level, + "verifiedLevel": r.verified_level, + "markdown": r.markdown, + "action": r.action, + }, + indent=2, + ensure_ascii=False, + ) + ) + if r.refusal is not None: + print(f"leji: {r.refusal}", file=sys.stderr) + return code + if r.refusal is not None: + print(f"leji: {r.refusal}", file=sys.stderr) + return 2 + if not ok: + _print_findings(findings) + print("Run leji conformance --explain.") + return 1 + verb = ( + "Wrote" if r.action == "wrote" else "Overwrote" if r.action == "overwrote" else "Unchanged" + ) + assert r.level is not None and r.markdown is not None # every ok run carries both + print(f"{verb} {r.out}: {badge_label(r.level)}") + # The badge states what this run verified, so a claim it did not reach is said out + # loud rather than quietly dropped. + if r.claimed_level is not None and r.claimed_level != r.verified_level: + print( + f"Claimed {r.claimed_level}; this offline run verified {r.verified_level} " + "(leji conformance --federation=verify checks the claim)." + ) + print("\nAdd it to your README (paths are relative to the repository root):\n") + print(r.markdown.rstrip()) + return 0 + + def _emit(command: str, findings: list[Finding], as_json: bool, **extra: object) -> int: ordered = sort_findings(findings) summary = summarize(ordered) @@ -298,10 +645,12 @@ def _emit(command: str, findings: list[Finding], as_json: bool, **extra: object) "status", "route", "conformance", + "badge", "mounts", "detect", "init", "adopt", + "export", "viewer", "view", "start", @@ -333,6 +682,7 @@ def _emit(command: str, findings: list[Finding], as_json: bool, **extra: object) "--topics", "--as-of", "--federation", + "--to", } ) @@ -373,6 +723,17 @@ def _int_flag(raw: str, low: int, high: int) -> int | None: "--mode": (("solo", "team"), "--mode must be solo or team"), } +# Flags whose value must match a shape, checked in the same place and for the same +# reason. `--to` takes the schema's own pin shape: a full commit id, never an +# abbreviation and never a revision expression, so all three SDKs accept one +# spelling. +_PATTERN_FLAGS = { + "--to": ( + re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$"), + "--to must be a full 40- or 64-character lowercase hex commit id", + ), +} + def _expand_equals_flags(argv: list[str]) -> list[str]: """Expand `--flag=value` into `--flag value` for declared value flags, so both @@ -391,6 +752,41 @@ def _expand_equals_flags(argv: list[str]) -> list[str]: return out +def effective_root(argv: list[str]) -> str | None: + """The repository root this argv lands on, decided ONCE and used by everything that + has to agree about it: the parse below, and the installed console script's hand-off + to a repository's own pinned CLI, which must select the same repository the command + would then operate on. Same scan as the parse (`--flag=value` expanded, every + declared value flag consuming its own value, the literal `--` ending our flags), so + `--root` is read from the same token stream rather than from a second reading of it. + Last `--root` wins; the default is the current directory. + + None when the scan cannot tell: a value flag with no value, or one whose value is + itself a flag, is the usage error the parse reports, and a root guessed out of a + malformed command line is exactly the wrong thing to hand an invocation to. + Mirrors effectiveRoot in packages/sdk/src/index.ts.""" + expanded = _expand_equals_flags(argv) + root = "." + i = 0 + while i < len(expanded): + arg = expanded[i] + if arg == "--": # host pass-through: never our flags + break + if arg not in _VALUE_FLAGS: + i += 1 + continue + i += 1 + value = expanded[i] if i < len(expanded) else None + if value is None or _is_flag_token(value): + return None + if arg == "--root": + if value == "": # `--root ""` is the usage error, not a root + return None + root = value + i += 1 + return root + + def _first_command(argv: list[str]) -> str | None: """First positional token (the command), skipping flags and their values. Meta-flags (-h/--help/-v/--version) count as the command. None if absent.""" @@ -482,7 +878,8 @@ def _parse_error(argv: list[str]) -> str | None: declared value flag whose value is absent, empty (`--flag=` expands to an empty token; Node's `!v` rejects it), or itself a flag token is ` requires a value`. A numeric flag whose value is not a decimal integer - in range, or an enum flag whose value is not one of its words, gets that flag's + in range, an enum flag whose value is not one of its words, or a shaped flag + (`--to`) whose value does not match, gets that flag's own message, here rather than after argparse, so the three agree on which error a command that does not even accept `--port` reports. Whichever comes first in argv wins, so all three SDKs report the same token with the same text. None @@ -509,6 +906,11 @@ def _parse_error(argv: list[str]) -> str | None: words, message = enum if nxt not in words: return message + pattern = _PATTERN_FLAGS.get(arg) + if pattern is not None: + shape, message = pattern + if shape.match(cast("str", nxt)) is None: + return message i += 2 continue if arg.startswith("-") and arg not in known: @@ -678,7 +1080,15 @@ def common(p: argparse.ArgumentParser) -> None: help="run the networked pin-reachability probe (takes only verify)", ) - # `leji mounts `: the federation resolver commands. + badge = sub.add_parser("badge", help="write the self-attested conformance badge") + common(badge) + badge.add_argument( + "--out", + default=None, + help="where to write the badge (default: leji-badge.svg at the repository root)", + ) + + # `leji mounts `: the federation resolver commands. mounts = sub.add_parser("mounts", help="resolve and inspect declared federation mounts") mounts_sub = mounts.add_subparsers(dest="subcommand") mounts_hydrate = mounts_sub.add_parser( @@ -707,6 +1117,34 @@ def common(p: argparse.ArgumentParser) -> None: # Optional at parse time: the missing-name usage error is issued in dispatch # (after the manifest load), mirroring the Node ordering exactly. mounts_locate.add_argument("name", nargs="?", default=None) + mounts_update_pin = mounts_sub.add_parser( + "update-pin", help="move a declared mount's pin forward to a witnessed commit" + ) + common(mounts_update_pin) + # Optional at parse time; the missing-name usage error is issued in dispatch, + # ahead of the manifest load, exactly where Node issues it. + mounts_update_pin.add_argument("name", nargs="?", default=None) + mounts_update_pin.add_argument( + "--to", + default=None, + help="move to this exact commit instead of the witness tip", + ) + mounts_update_pin.add_argument( + "--allow-non-fast-forward", + action="store_true", + dest="allow_non_fast_forward", + help="permit a target that is not a descendant of the current pin (needs --to)", + ) + mounts_update_pin.add_argument( + "--fetch", + action="store_true", + help="observe the declared source: retain the pin, refresh the witness, retain the target", + ) + mounts_update_pin.add_argument( + "--dry-run", + action="store_true", + help="show the comparison and what would change; write no manifest byte", + ) common(sub.add_parser("detect", help="detect the coding-agent hosts available on this machine")) @@ -775,6 +1213,24 @@ def common(p: argparse.ArgumentParser) -> None: "--dry-run", action="store_true", help="compute the write plan without writing" ) + # `leji export`: the front-door name for the static export. `leji viewer build` + # below is the viewer subsystem's name for the same operation, so both parsers + # declare the same options and both dispatch to _run_export. + export = sub.add_parser( + "export", help="export the context layer as a self-contained static site" + ) + common(export) + export.add_argument( + "--out", + default=None, + help="output directory for the export (default: .leji/dist)", + ) + export.add_argument( + "--strict", + action="store_true", + help="fail the export on any lint finding and write nothing", + ) + # `leji viewer` generates only; `leji viewer serve` generates then serves. viewer = sub.add_parser( "viewer", help="generate the static viewer (Docsify index.html + _sidebar.md)" @@ -802,7 +1258,12 @@ def common(p: argparse.ArgumentParser) -> None: viewer_build.add_argument( "--out", default=None, - help="output directory for the export (default: .leji/viewer-dist inside the context root)", + help="output directory for the export (default: .leji/dist)", + ) + viewer_build.add_argument( + "--strict", + action="store_true", + help="fail the export on any lint finding and write nothing", ) # `leji view` is an alias for `leji viewer serve` that also opens the browser. @@ -960,18 +1421,13 @@ def main(argv: list[str] | None = None) -> int: print(USAGE, file=sys.stderr) return 2 - # `leji mounts` requires one of the three subcommands; anything else is a - # usage error before argparse (byte parity with the Node dispatch). - if command == "mounts" and sub not in ("hydrate", "status", "locate"): - print("leji: usage: leji mounts \n", file=sys.stderr) - print(USAGE, file=sys.stderr) - return 2 - # Reject surplus positional arguments with the same message the other two # implementations use. argparse would reject them too, but in its own wording and # format, so the three CLIs disagreed on an error a typo produces every day. + # Ahead of the `mounts` sub-guard below, where Node and Go check it: a misspelled + # subcommand carrying a stray positional reports the positional in all three. expected = 2 if command in _TWO_WORD_COMMANDS and sub else 1 - if command == "mounts" and sub == "locate": + if command == "mounts" and sub in ("locate", "update-pin"): expected += 1 # `view` has its own usage message for a stray subcommand, and it is the more # useful one; let that case fall through to it. @@ -984,6 +1440,13 @@ def main(argv: list[str] | None = None) -> int: print(USAGE, file=sys.stderr) return 2 + # `leji mounts` requires one of the four subcommands; anything else is a + # usage error before argparse (byte parity with the Node dispatch). + if command == "mounts" and sub not in ("hydrate", "status", "locate", "update-pin"): + print("leji: usage: leji mounts \n", file=sys.stderr) + print(USAGE, file=sys.stderr) + return 2 + parser = _build_parser() # --port, --keep, --mode, and --level are already checked in _parse_error, # mirroring where Node and Go check them. @@ -1061,12 +1524,19 @@ def main(argv: list[str] | None = None) -> int: ) if seeded_changelog is not None: index_extra["changelog"] = seeded_changelog - return _emit( + code = _emit( "index", [*load.findings, *index_result.findings], args.json, **index_extra, ) + # A generate run ends by naming what the layer governs but does not + # index. A nudge, never a gate: the exit code is _emit's alone, and + # nothing is printed when the count is zero. Text output only; --json + # carries one document and nothing after it. + if not args.json: + _print_unindexed_nudge(len(unindexed_paths(args.root, load.manifest))) + return code if args.command == "changelog": subcommand = getattr(args, "subcommand", None) @@ -1379,7 +1849,7 @@ def split_list(s: object) -> list[str]: "unknown": "unknown", "not-applicable": "n/a ", }.get(check_item.status, "manual ") - detail = f" — {check_item.detail}" if check_item.detail else "" + detail = f": {check_item.detail}" if check_item.detail else "" print(f"{mark} [{check_item.level}] {check_item.description}{detail}") print() if args.explain: @@ -1393,11 +1863,54 @@ def split_list(s: object) -> list[str]: extra["items"] = [i.to_dict() for i in conformance.items] return _emit("conformance", conformance.findings, args.json, **extra) + if args.command == "badge": + badge_result = badge_run( + args.root, args.out if args.out is not None else DEFAULT_BADGE_OUT + ) + # A rejected `--out` is a usage error, in the CLI's usage-error form and + # ahead of every level the command could have reported. + if badge_result.usage_error is not None: + print(f"leji: {badge_result.usage_error}\n", file=sys.stderr) + print(USAGE, file=sys.stderr) + return 2 + return _report_badge(args, badge_result) + if args.command == "mounts": subcommand = getattr(args, "subcommand", None) + # Argument shape is settled before anything on disk is read: a usage + # error is never contingent on a manifest loading. + if subcommand == "update-pin": + if not getattr(args, "name", None): + print( + "leji: usage: leji mounts update-pin [--to ]\n", + file=sys.stderr, + ) + print(USAGE, file=sys.stderr) + return 2 + if args.allow_non_fast_forward and args.to is None: + print( + "leji: --allow-non-fast-forward is valid only with an explicit " + "--to \n", + file=sys.stderr, + ) + print(USAGE, file=sys.stderr) + return 2 load = load_manifest(args.root) if load.manifest is None: return _emit(f"mounts {subcommand}", load.findings, args.json) + if subcommand == "update-pin": + return _report_update_pin( + args, + update_pin_run( + args.root, + load.manifest, + args.name, + to=args.to, + allow_non_fast_forward=args.allow_non_fast_forward, + fetch=args.fetch, + dry_run=args.dry_run, + ), + ) if subcommand == "hydrate": hydrate = hydrate_mounts(args.root, load.manifest, fetch=args.fetch) if hydrate.fatal is not None: @@ -1506,30 +2019,10 @@ def split_list(s: object) -> list[str]: _print_findings(issues) return 0 - if args.command == "viewer" and getattr(args, "subcommand", None) == "build": - load = load_manifest(args.root) - if load.manifest is None: - return _emit("viewer build", load.findings, args.json) - r = build_viewer(args.root, load.manifest, args.out) - if any(f.severity == "error" for f in r.findings): - return _emit("viewer build", r.findings, args.json) - if args.json: - print( - json.dumps( - { - "command": "viewer build", - "ok": True, - "out": r.out, - "warning": PROTECT_WARNING, - }, - indent=2, - ensure_ascii=False, - ) - ) - else: - print(f"Exported the static viewer to {r.out}/") - print(f"\n{PROTECT_WARNING}") - return 0 + if args.command == "export" or ( + args.command == "viewer" and getattr(args, "subcommand", None) == "build" + ): + return _run_export(args) if args.command in ("viewer", "view"): # `view` aliases `viewer serve` and also opens the browser. @@ -1557,7 +2050,7 @@ def split_list(s: object) -> list[str]: code = 0 if not want_serve or code != 0: if not args.json and code == 0: - viewer_dir = f"{strip_slash(load.manifest['rootPath']) or '.'}/.leji/viewer/" + viewer_dir = f"{VIEWER_REL}/" print( f"viewer ready ({viewer_result.entries} entries) → {viewer_dir}" " serve: leji view" @@ -1592,13 +2085,14 @@ def split_list(s: object) -> list[str]: "command": "detect", "ok": True, "hosts": [h.to_dict() for h in detect_result.hosts], + "ecosystem": detect_result.ecosystem.to_json(), }, indent=2, ensure_ascii=False, ) ) else: - print(render_detect(detect_result.hosts)) + print(render_detect(detect_result.hosts, detect_result.ecosystem)) return 0 if args.command == "adopt": @@ -1612,7 +2106,12 @@ def split_list(s: object) -> list[str]: agent=args.agent, mode=args.mode, ) + # The repository's own dependency ecosystem, read once and reported by + # every output mode: the human block, the JSON document, and the offer. + adopt_eco = detect_ecosystem(adopt_result.root) if adopt_result.dry_run: + if args.json: + return _emit_scaffold("adopt", adopt_result.findings, [], adopt_eco, True) # A wire-only run scaffolds nothing, so "Adopting the existing # repository" misnames it: the layer is already there and the plan # beneath is entrypoint conversions. @@ -1627,14 +2126,26 @@ def split_list(s: object) -> list[str]: ) print("\n" + render_write_plan(adopt_result.plan)) print("\nNo files written (--dry-run). Re-run without --dry-run to apply.") + print("\n" + render_ecosystem_block(adopt_eco)) return 0 + if args.json: + return _emit_scaffold( + "adopt", adopt_result.findings, adopt_result.written, adopt_eco, False + ) print( f"\nWrote {len(adopt_result.written)} files (context root: {adopt_result.detected_root}):" ) for rel in adopt_result.written: print(f" {rel}") index_failed = _report_scaffold_index(adopt_result.findings) - interactive = not args.yes and _stdin_is_tty() + # --json is a single-document mode, so it is never interactive: nothing + # prompts, and no package manager can run under it. + interactive = not args.yes and not args.json and _stdin_is_tty() + # A wire-only run scaffolds no layer, so it makes no declaration offer. + dependency_failed = False + if not adopt_result.wired_only: + offer = offer_dependency(adopt_result.root, adopt_eco, interactive) + dependency_failed = dependency_add_failed(offer) # Register the MCP server before the handoff, so the launched agent picks it # up at startup; the launch is anchored at the layer root (cwd) too. mcp = offer_mcp_install( @@ -1663,7 +2174,9 @@ def split_list(s: object) -> list[str]: mcp=mcp, ): print(entering_adopted(adopt_result)) - return index_failed + # The layer is written either way; a consented add that failed means the + # durable setup this run promised was not reached, and the exit says so. + return 1 if (index_failed or dependency_failed) else 0 if args.command == "init": target = args.dir if args.dir != "." else args.root @@ -1677,15 +2190,24 @@ def split_list(s: object) -> list[str]: agent=args.agent, mode=args.mode, ) + init_eco = detect_ecosystem(init_result.root) if init_result.dry_run: + if args.json: + return _emit_scaffold("init", init_result.findings, [], init_eco, True) print("\n" + render_write_plan(init_result.plan)) print("\nNo files written (--dry-run). Re-run without --dry-run to create them.") + print("\n" + render_ecosystem_block(init_eco)) return 0 + if args.json: + return _emit_scaffold( + "init", init_result.findings, init_result.written, init_eco, False + ) print(f"\nWrote {len(init_result.written)} files:") for rel in init_result.written: print(f" {rel}") index_failed = _report_scaffold_index(init_result.findings) - interactive = not args.yes and _stdin_is_tty() + interactive = not args.yes and not args.json and _stdin_is_tty() + offer = offer_dependency(init_result.root, init_eco, interactive) # Register the MCP server before the handoff, so the launched agent picks it # up at startup; the launch is anchored at the layer root (cwd) too. mcp = offer_mcp_install( @@ -1714,16 +2236,84 @@ def split_list(s: object) -> list[str]: mcp=mcp, ): print(entering_the_layer(init_result.manifest, init_result.mode)) - return index_failed + return 1 if (index_failed or dependency_add_failed(offer)) else 0 if args.command == "start": load = load_manifest(args.root) if load.manifest is None: return _emit("start", load.findings, args.json) detected = detect_hosts(args.root) - # `start` doesn't document --yes, so it's never set; getattr keeps - # parity with the Node `!flags.yes` default. - interactive = not getattr(args, "yes", False) and _stdin_is_tty() + # The repository's own ecosystem, read once: the preflight probes the + # runner it names, and the JSON document reports it. + start_eco = detect_ecosystem(args.root) + # --json is a single-document mode, so it is never interactive: nothing + # prompts, nothing launches, and no repair can run under it. `start` + # doesn't document --yes, so it's never set; getattr keeps parity with + # the Node `!flags.yes` default. + interactive = not getattr(args, "yes", False) and not args.json and _stdin_is_tty() + # The boot profile is checked first, before any report or prompt: a layer + # whose entrypoint is missing has nothing to enter. + if not boot_profile_ready(args.root, load.manifest): + if args.json: + print( + json.dumps( + { + "command": "start", + "ok": False, + "ready": False, + "error": "boot-missing", + "checks": [], + "ecosystem": start_eco.to_json(), + }, + indent=2, + ) + ) + else: + print( + f"leji: boot profile {load.manifest['bootProfilePath']} " + "is missing or invalid; run leji validate", + file=sys.stderr, + ) + return 1 + start_io = _default_handoff_io() + # The host is resolved BEFORE the report, so the MCP rows answer for the + # host this run actually targets. An --agent naming no launchable host + # raises here, exactly as it did inside enter_layer: a usage error. + start_host = resolve_start_host(detected, args.agent, interactive, start_io) + preflight = run_preflight( + args.root, load.manifest, start_host, detected, start_eco, start_io + ) + if args.json: + # Report only: the launch-selection arguments are accepted and have no + # effect, and a gap is reported rather than blocking (`ready` is the + # scriptable signal). + print( + json.dumps( + { + "command": "start", + "ok": True, + "ready": preflight.ready, + # Projected, never the raw checks: the document publishes + # four keys, and a field the renderer needs is not one. + "checks": [check_document(c) for c in preflight.checks], + "ecosystem": start_eco.to_json(), + }, + indent=2, + ) + ) + return 0 + # The one place color is decided: a terminal question, asked at the + # boundary and injected, so the block itself never consults the process. + color = color_decision(sys.stdout.isatty(), os.environ) + print("\n" + render_preflight(preflight.checks, color)) + offer_preflight_fixes( + args.root, + start_host, + preflight, + runner_argv(start_eco), + interactive, + start_io, + ) outcome = enter_layer( StartOptions( root=args.root, @@ -1732,25 +2322,24 @@ def split_list(s: object) -> list[str]: agent=args.agent, interactive=interactive, host_args=host_args, + io=start_io, + host=start_host, + host_resolved=True, ) ) - if outcome == "boot-missing": - print( - f"leji: boot profile {load.manifest['bootProfilePath']} " - "is missing or invalid; run leji validate", - file=sys.stderr, - ) - return 1 if outcome == "fallback": print(entering_via_boot(load.manifest, host_args)) return 0 if args.command == "ci": + # One detection for the whole command: the hook and the CI job both run + # what a clean install of this repository provides. + ci_eco = detect_ecosystem(args.root) if args.hooks: load = load_manifest(args.root) if load.manifest is None: return _emit("ci", load.findings, args.json) - hook = ensure_local_hook(args.root) + hook = ensure_local_hook(args.root, runner_argv(ci_eco)) if args.json: hook_out: dict[str, object] = { "command": "ci", @@ -1761,6 +2350,7 @@ def split_list(s: object) -> list[str]: if hook.action == "manual": hook_out["reason"] = hook.reason hook_out["snippet"] = hook.snippet + hook_out["ecosystem"] = ci_eco.to_json() print(json.dumps(hook_out, indent=2, ensure_ascii=False)) elif hook.action == "manual": if hook.reason == "outside-root": @@ -1790,6 +2380,8 @@ def split_list(s: object) -> list[str]: f"{verb} {hook.path} (validate + index --check before every " "commit; per-clone, delete to opt out)." ) + if not args.json: + print(render_ecosystem_line(ci_eco)) return 0 # No --provider: infer from the origin remote (a GitLab repo must # never silently receive a GitHub workflow); say which and why. @@ -1814,7 +2406,7 @@ def split_list(s: object) -> list[str]: load = load_manifest(args.root) if load.manifest is None: return _emit("ci", load.findings, args.json) - ci_result = ensure_ci_workflow(args.root, provider) + ci_result = ensure_ci_workflow(args.root, provider, ci_eco) if args.json: out: dict[str, object] = { "command": "ci", @@ -1828,6 +2420,7 @@ def split_list(s: object) -> list[str]: out["snippet"] = ci_result.snippet if ci_result.note: out["note"] = ci_result.note + out["ecosystem"] = ci_eco.to_json() print(json.dumps(out, indent=2, ensure_ascii=False)) else: if ci_result.action == "created": @@ -1837,12 +2430,15 @@ def split_list(s: object) -> list[str]: elif ci_result.action == "unchanged": print(f"{ci_result.path} already present; nothing to do.") else: # manual + # Not leji's file: it was written by hand, or a generated one was + # edited. Either way the edit is the opt-out, and it is honored. print( - f"{ci_result.path} already exists; not modifying it. " - f"Add this to your CircleCI config:\n\n{ci_result.snippet}" + f"{ci_result.path} already exists and was not generated by leji; " + f"not modifying it. Add this yourself:\n\n{ci_result.snippet}" ) if ci_result.note: print(ci_result.note) + print(render_ecosystem_line(ci_eco)) return 0 if args.command == "agent": @@ -1857,24 +2453,21 @@ def split_list(s: object) -> list[str]: args.root, load.manifest, host=args.host, name=args.name, role=args.role ) if args.json: - print( - json.dumps( - { - "command": "agent", - "ok": True, - "name": agent_result.name, - "role": agent_result.role, - "host": agent_result.host_id, - "profile": agent_result.profile_path, - "created": { - "profile": agent_result.profile_created, - "manifest": agent_result.manifest_changed, - }, - }, - indent=2, - ensure_ascii=False, - ) - ) + agent_out: dict[str, object] = { + "command": "agent", + "ok": True, + "name": agent_result.name, + "role": agent_result.role, + "host": agent_result.host_id, + "profile": agent_result.profile_path, + "created": { + "profile": agent_result.profile_created, + "manifest": agent_result.manifest_changed, + }, + } + if agent_result.note: + agent_out["note"] = agent_result.note + print(json.dumps(agent_out, indent=2, ensure_ascii=False)) else: lines = [ f"Wrote {agent_result.profile_path}" @@ -1891,6 +2484,8 @@ def split_list(s: object) -> list[str]: if agent_result.manifest_changed else f'agent "{agent_result.name}" already bound in leji.json; nothing to do.' ) + if agent_result.note: + lines.append(agent_result.note) print("\n".join(lines)) return 0 @@ -1904,5 +2499,35 @@ def split_list(s: object) -> list[str]: return 2 +def _self_entry() -> str | None: + """This console script, with every symlink resolved: the one path the hand-off must + never select, or a repository whose install points back here would run us forever. + An entry that cannot be resolved refuses the hand-off rather than risking that.""" + script = sys.argv[0] if sys.argv else "" + if script == "": + return None + try: + return os.path.realpath(script) + except OSError: + return None + + +def entry() -> int: + """The INSTALLED console script (`leji`), which is the only place the hand-off + lives. Before anything is parsed: inside a repository that declares the Leji CLI and + has it installed, this invocation belongs to that pinned copy rather than to + whichever global the PATH found. `main()` is a library call and never hands work to + another program. Mirrors packages/sdk/src/cli.ts.""" + # Imported here, not at module scope: the wrapper imports this module for the + # shared root scan, and a library user of `main` never loads the launcher at all. + from .localcli import launch_local_cli, resolve_local_cli + + argv = sys.argv[1:] + local = resolve_local_cli(argv, os.environ, sys.platform, _self_entry()) + if local is not None: + launch_local_cli(local) + return main(argv) + + if __name__ == "__main__": - sys.exit(main()) + sys.exit(entry()) diff --git a/packages/sdk-py/src/leji/conformance.py b/packages/sdk-py/src/leji/conformance.py index 94f5135..e80fac9 100644 --- a/packages/sdk-py/src/leji/conformance.py +++ b/packages/sdk-py/src/leji/conformance.py @@ -485,7 +485,7 @@ def render_explain(result: ConformanceResult) -> str: how = " (evidence unobtainable in this run; unknown never awards the level)" else: how = "" - detail = f" — {b.detail}" if b.detail else "" + detail = f": {b.detail}" if b.detail else "" lines.append(f" - {b.description}{detail}{how}") lines.extend( [ diff --git a/packages/sdk-py/src/leji/dependency.py b/packages/sdk-py/src/leji/dependency.py new file mode 100644 index 0000000..49d2a8f --- /dev/null +++ b/packages/sdk-py/src/leji/dependency.py @@ -0,0 +1,160 @@ +"""The declaration offer: after the scaffold is written, tell the user how a clean +install of this repository will bring ``leji``, and offer to run their own package +manager's add command. + +leji writes no manifest or lockfile byte itself: the manager owns both formats, so +the only thing that changes the repository here is a command the user explicitly +accepted. Transcribed from the TypeScript reference (offerDependency in +``packages/sdk/src/commands/init.ts``). +""" + +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass +from typing import Callable, Optional, TextIO + +from .ecosystem import ( + CONSENT_DECLINED, + CONSENT_PROMPT, + EcosystemReport, + consent_command, + consent_declared, + consent_disclosure, + consent_exited, + consent_missing, + consent_running, + consent_signaled, + render_ecosystem_block, +) + + +@dataclass +class AddResult: + """What running one manager add command did: the runner's own result type, where + the start state lives. A spawn that never started reports ``started`` False; a + started run reports its exit code, and ``signal`` when the runtime killed it.""" + + started: bool + exit_code: int = 0 + signal: str = "" + + +@dataclass +class DependencyIO: + """Injectable I/O for the declaration offer, so the interactive flow is + deterministically testable and no test can reach a real package manager.""" + + read_line: Callable[[str, str], str] + run: Callable[[str, list[str], str], AddResult] + + +def default_dependency_io() -> DependencyIO: + """Real I/O: a stdin prompt and an argv spawn, never a shell.""" + + def read_line(question: str, fallback: str) -> str: + try: + return input(f"{question} [{fallback}]: ").strip() + except EOFError: + return "" + + def run(bin_name: str, args: list[str], cwd: str) -> AddResult: + try: + proc = subprocess.run([bin_name, *args], cwd=cwd) # noqa: S603 (no shell) + except OSError: + # Never started (ENOENT and friends): a missing binary, not a failed add. + return AddResult(started=False) + # POSIX reports a signalled child as a negative return code; the signal is + # what the outcome reports, since such a child has no exit code of its own. + if proc.returncode < 0: + signal_number = -proc.returncode + try: + import signal as signal_mod + + name = signal_mod.Signals(signal_number).name + except (ValueError, ImportError): + name = str(signal_number) + return AddResult(started=True, exit_code=-1, signal=name) + return AddResult(started=True, exit_code=proc.returncode) + + return DependencyIO(read_line=read_line, run=run) + + +@dataclass +class DependencyOffer: + """What the declaration step did, in the reference's shape: ``ran`` means the add + was consented to and attempted, and ``exit_code``/``signal`` are nullable exactly + as they are in the ``--json`` contract. A signalled manager has no exit code of + its own (``exit_code`` None, ``signal`` set), and a spawn that never started has + neither (both None with ``ran`` True) — which counts as a failure just like a + non-zero exit. The runner's start state stays in :class:`AddResult`.""" + + offered: bool = False + ran: bool = False + command: Optional[list[str]] = None + exit_code: Optional[int] = None + signal: Optional[str] = None + + +def dependency_add_failed(offer: DependencyOffer) -> bool: + """A consented add that did not succeed, so the command must not exit 0: the + layer is written but the durable setup the run promised was not reached.""" + return offer.ran and ( + offer.exit_code is None or offer.exit_code != 0 or offer.signal is not None + ) + + +def offer_dependency( + root: str, + report: EcosystemReport, + interactive: bool, + io: Optional[DependencyIO] = None, + out: Optional[TextIO] = None, +) -> DependencyOffer: + """The block is ALWAYS printed (this function is simply not called under + ``--json``, which is a single-document mode). The prompt fires only when the run + is interactive, an add command exists for the detected manager, and the CLI is + not already declared.""" + stream = out if out is not None else sys.stdout + print("\n" + render_ecosystem_block(report), file=stream) + selected = report.selected + command = selected.add if selected is not None and selected.add else None + offered = command is not None and selected is not None and not selected.direct_declared + skipped = DependencyOffer(offered=offered, command=command) + if not offered or not interactive: + return skipped + + handle = io if io is not None else default_dependency_io() + # Consent is only consent if it is informed: the manager runs here, as this + # user, with this environment, and does whatever it normally does. + assert command is not None + print(consent_disclosure(command[0]), file=stream) + answer = handle.read_line(CONSENT_PROMPT, "Y/n").lower() + if answer not in ("", "y", "yes"): + print(CONSENT_DECLINED, file=stream) + print(consent_command(command), file=stream) + return skipped + print(consent_running(command), file=stream) + result = handle.run(command[0], command[1:], root) + attempted = DependencyOffer(offered=offered, ran=True, command=command) + # A spawn that never started surfaces as a start failure, never as an exit code, + # so it is reported as a missing binary rather than as a failed add, and it + # carries neither an exit code nor a signal. + if not result.started: + print(consent_missing(command[0]), file=stream) + print(consent_command(command), file=stream) + return attempted + if result.signal: + print(consent_signaled(command[0], result.signal), file=stream) + print(consent_command(command), file=stream) + attempted.signal = result.signal + return attempted + attempted.exit_code = result.exit_code + if result.exit_code != 0: + print(consent_exited(command[0], result.exit_code), file=stream) + print(consent_command(command), file=stream) + return attempted + assert selected is not None + print(consent_declared(selected.ecosystem), file=stream) + return attempted diff --git a/packages/sdk-py/src/leji/detect.py b/packages/sdk-py/src/leji/detect.py index 1edc897..98cf930 100644 --- a/packages/sdk-py/src/leji/detect.py +++ b/packages/sdk-py/src/leji/detect.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Callable, Optional +from .ecosystem import EcosystemReport, detect_ecosystem, render_ecosystem_line + @dataclass(frozen=True) class HostSpec: @@ -36,6 +38,28 @@ class HostSpec: # Argv that reports whether the Leji MCP server is already registered (exit 0 = # present); used to skip the install offer when it's already there. mcp_check: Optional[list[str]] = None + # Argv that registers the server for THIS USER, across every project. None when + # ``mcp_add`` is already the user-level form (Codex has no other scope). + mcp_add_user: Optional[list[str]] = None + # The committed file a shared (project-scope) registration writes, repository + # root relative. Only a host whose ``mcp_add`` writes into the repository has one. + mcp_shared_file: Optional[str] = None + # Where a host with no registration command reads its MCP configuration, for a + # host Leji can only tell the user about, and which shape that file takes. + mcp_config: Optional[McpConfigLocation] = None + + +@dataclass(frozen=True) +class McpConfigLocation: + """One host's MCP configuration file, the scope it covers, and the top-level key + that file uses for its server map. ``mcpServers`` is the common one; VS Code (and + GitHub Copilot through it) spells the same map ``servers`` in ``.vscode/mcp.json``, + so a client told to paste the common block there ends up with a file the editor + ignores.""" + + path: str + scope: str # "project" | "user" + shape: str # "mcpServers" | "servers" # The registered server name and the npm package behind the local Leji MCP server. @@ -43,6 +67,34 @@ class HostSpec: MCP_PACKAGE = "@leji-org/mcp" +def mcp_json_config(shape: str) -> str: + """The MCP client configuration for the local Leji server, in the shape one + client's configuration file takes. The SDK owns these bytes: the MCP package README + and the website quote the ``mcpServers`` form, and a repo test asserts the three of + them agree, so the instruction a user reads is one text.""" + return ( + "{\n" + f' "{shape}": {{\n' + f' "{MCP_SERVER_NAME}": {{ "command": "npx", "args": ["-y", "{MCP_PACKAGE}"] }}\n' + " }\n" + "}" + ) + + +# The common form, the one the README and the website publish. +MCP_JSON_CONFIG = mcp_json_config("mcpServers") + + +def mcp_command(spec: "HostSpec", argv: list[str]) -> str: + """One host command line as a user would type it: the host binary, then the argv.""" + return f"{spec.bins[0]} {' '.join(argv)}" + + +def spec_by_id(host_id: str) -> "Optional[HostSpec]": + """The host spec with this id, or None.""" + return next((s for s in HOST_SPECS if s.id == host_id), None) + + # The portable discovery adapter. `AGENTS.md` is a cross-host entrypoint # convention (stewarded by the Linux Foundation's Agentic AI Foundation, read # natively by Codex, Copilot, Cursor, Gemini CLI, and others), not any one @@ -73,6 +125,20 @@ class HostSpec: MCP_PACKAGE, ], mcp_check=["mcp", "get", MCP_SERVER_NAME], + # User scope is the personal form: it registers for every project of this + # user without touching a file the repository commits. + mcp_add_user=[ + "mcp", + "add", + MCP_SERVER_NAME, + "--scope", + "user", + "--", + "npx", + "-y", + MCP_PACKAGE, + ], + mcp_shared_file=".mcp.json", ), HostSpec( id="codex", @@ -94,6 +160,7 @@ class HostSpec: repo_files=[".github/copilot-instructions.md"], user_dirs=[], adapter=".github/copilot-instructions.md", + mcp_config=McpConfigLocation(path=".vscode/mcp.json", scope="project", shape="servers"), ), HostSpec( id="gemini", @@ -102,6 +169,9 @@ class HostSpec: repo_files=["GEMINI.md", ".gemini"], user_dirs=[".gemini"], adapter="GEMINI.md", + mcp_config=McpConfigLocation( + path=".gemini/settings.json", scope="project", shape="mcpServers" + ), ), HostSpec( id="cursor", @@ -110,6 +180,7 @@ class HostSpec: repo_files=[".cursor/rules", ".cursorrules"], user_dirs=[], adapter=".cursor/rules/leji.md", + mcp_config=McpConfigLocation(path=".cursor/mcp.json", scope="project", shape="mcpServers"), ), HostSpec( id="windsurf", @@ -118,6 +189,9 @@ class HostSpec: repo_files=[".windsurf/rules", ".windsurfrules"], user_dirs=[], adapter=".windsurf/rules/leji.md", + mcp_config=McpConfigLocation( + path="~/.codeium/windsurf/mcp_config.json", scope="user", shape="mcpServers" + ), ), ] @@ -238,20 +312,24 @@ def adapter_content(boot_profile_path: str) -> str: @dataclass class DetectResult: + """The agent hosts available to this user, ranked, and the dependency ecosystem + of the repository itself.""" + hosts: list[DetectedHost] + ecosystem: EcosystemReport def detect_layer(root: str) -> DetectResult: - """Result of ``detect``: the agent hosts available to this user, ranked.""" - return DetectResult(hosts=detect_hosts(root)) + return DetectResult(hosts=detect_hosts(root), ecosystem=detect_ecosystem(root)) -def render_detect(hosts: list[DetectedHost]) -> str: +def render_detect(hosts: list[DetectedHost], ecosystem: EcosystemReport) -> str: """Human-readable detection report.""" + eco_line = render_ecosystem_line(ecosystem) if not hosts: return ( "No coding-agent hosts detected. Leji works without one; the onboarding " - "brief still guides any agent you point at it." + "brief still guides any agent you point at it." + "\n\n" + eco_line ) lines = ["Detected agent hosts (strongest signal first):"] for h in hosts: @@ -267,7 +345,10 @@ def render_detect(hosts: list[DetectedHost]) -> str: adapter = ( f"adapter {h.adapter}" if h.adapter else "directory-style adapter (wiring deferred)" ) - lines.append(f" {h.strength.ljust(16)} {h.name} — {signals}; {adapter}") + lines.append(f" {h.strength.ljust(16)} {h.name}: {signals}; {adapter}") + # One line about the repository's own ecosystem: what would declare and run the + # CLI here. The full offer block belongs to init/adopt, which can act on it. + lines.extend(["", eco_line]) # --agent names the host Leji launches, and only claude-code and codex accept # an inline prompt; suggesting `--agent ` for every detected host offered # a command the flag rejects. diff --git a/packages/sdk-py/src/leji/ecosystem.py b/packages/sdk-py/src/leji/ecosystem.py new file mode 100644 index 0000000..f7c2c2c --- /dev/null +++ b/packages/sdk-py/src/leji/ecosystem.py @@ -0,0 +1,1107 @@ +"""Which dependency ecosystem owns a repository root, which package manager runs +it, how the Leji CLI is declared as a dev dependency there, and how a hook or CI +job should invoke it. + +Pure and offline: it reads a bounded set of files directly under the root and +writes nothing, launches nothing, and never walks up out of the root (an add in a +parent directory would write outside the root the user targeted). Every answer is +a total decision table over repository evidence, so the three SDKs return the same +report for the same tree. Transcribed from the TypeScript reference +(``packages/sdk/src/lib/ecosystem.ts``): tables and strings byte for byte. +""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional + +from .fsx import resolved_within_root + +# The npm package name. Its presence in package.json's dependency maps is what +# ``directDeclared`` reports for a Node repository. +DEP_NAME = "@leji-org/leji" +# The distribution name on PyPI and the name a Python manifest declares. +PY_DIST = "leji" +# The Go module path a ``tool`` directive names for the CLI. +GO_TOOL_PATH = "github.com/leji-org/leji/packages/sdk-go/cmd/leji" + +# Per-manager commands. ``add`` is None for a manager that cannot declare a dev +# dependency from the command line; its guidance is printed instead. Argv lists, +# never shell strings. No version pin: the lockfile pins the exact version, and Go +# needs a selector, so it takes @latest. +MANAGER_COMMANDS: dict[str, dict[str, Optional[list[str]]]] = { + "npm": { + "add": ["npm", "i", "-D", DEP_NAME], + "runner": ["npx", "--no-install", DEP_NAME], + "install": ["npm", "install"], + }, + "pnpm": { + "add": ["pnpm", "add", "-D", DEP_NAME], + "runner": ["pnpm", "exec", "leji"], + "install": ["pnpm", "install"], + }, + "yarn": { + "add": ["yarn", "add", "-D", DEP_NAME], + "runner": ["yarn", "leji"], + "install": ["yarn", "install"], + }, + "bun": { + "add": ["bun", "add", "-d", DEP_NAME], + "runner": ["bun", "run", "leji"], + "install": ["bun", "install"], + }, + "uv": { + "add": ["uv", "add", "--dev", PY_DIST], + "runner": ["uv", "run", "leji"], + "install": ["uv", "sync"], + }, + "poetry": { + "add": ["poetry", "add", "--group", "dev", PY_DIST], + "runner": ["poetry", "run", "leji"], + "install": ["poetry", "install"], + }, + "pdm": { + "add": ["pdm", "add", "-dG", "dev", PY_DIST], + "runner": ["pdm", "run", "leji"], + "install": ["pdm", "install"], + }, + "pipenv": { + "add": ["pipenv", "install", "--dev", PY_DIST], + "runner": ["pipenv", "run", "leji"], + "install": ["pipenv", "install", "--dev"], + }, + "pip": {"add": None, "runner": ["leji"], "install": None}, + "go": { + "add": ["go", "get", "-tool", f"{GO_TOOL_PATH}@latest"], + "runner": ["go", "tool", "leji"], + # The same command F10's CI table installs a Go repository's tools with. + "install": ["go", "mod", "download"], + }, + "go-legacy": {"add": None, "runner": ["leji"], "install": None}, +} + + +def manager_runner_argv(manager: str) -> Optional[list[str]]: + """The runner argv for one manager name, or None when leji does not know it. + One runner table serves detection, the hook, and CI.""" + cell = MANAGER_COMMANDS.get(manager) + return list(cell["runner"]) if cell and cell["runner"] else None + + +def manager_install_argv(manager: str) -> Optional[list[str]]: + """The plain install argv for one manager name: what a joiner runs on a fresh clone + so the CLI the repository declares actually resolves. ``None`` when leji does not + know the manager, or when the manager has no single install command.""" + cell = MANAGER_COMMANDS.get(manager) + install = cell.get("install") if cell else None + return list(install) if install else None + + +# The fallback runner: the CLI on PATH, for every repository that has not declared it. +PLAIN_RUNNER = ["leji"] + +NODE_MANIFEST = "package.json" +GO_MANIFEST = "go.mod" +PYPROJECT = "pyproject.toml" +PIPFILE = "Pipfile" + +# Node lockfile families, in the fixed order every list of them uses. Two names +# mark bun (text and binary); either one is presence-only evidence. +NODE_LOCKS: list[tuple[str, str]] = [ + ("package-lock.json", "npm"), + ("pnpm-lock.yaml", "pnpm"), + ("yarn.lock", "yarn"), + ("bun.lock", "bun"), + ("bun.lockb", "bun"), +] +NODE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] + +# Python lock families, in the fixed order every list of them uses. ``Pipfile`` is +# a family member without being a lock: it selects pipenv, but only Pipfile.lock +# evidences a lock. +PY_LOCKS: list[tuple[str, str, bool]] = [ + ("uv.lock", "uv", True), + ("poetry.lock", "poetry", True), + ("pdm.lock", "pdm", True), + ("Pipfile.lock", "pipenv", True), + (PIPFILE, "pipenv", False), +] +# ``[tool.]`` tables that name a manager when no lock family is present. +PY_TOOL_TABLES: list[tuple[str, str]] = [ + ("tool.uv", "uv"), + ("tool.poetry", "poetry"), + ("tool.pdm", "pdm"), +] +# Root files that gate the Python ecosystem alongside the two manifests. +REQUIREMENTS_RE = re.compile(r"^requirements[A-Za-z0-9._-]*\.txt$") + +# --- the human block ------------------------------------------------------ +# Every string the offer prints lives here once, so the three SDKs transcribe one +# table rather than re-deriving prose. + +_OFFER_LEAD = "To declare the Leji CLI as a dev dependency so a clean install brings leji, run:" +_DECLARE_WITH_TOOL = "Declare the Leji CLI as a dev dependency with the tool this repo uses." +INDENT = " " + + +def _join_and(items: list[str]) -> str: + """``a``, ``a and b``, ``a, b and c`` — the one list join every message uses.""" + if not items: + return "" + if len(items) == 1: + return items[0] + return ", ".join(items[:-1]) + " and " + items[-1] + + +def text_offer(manager: str, file: str) -> str: + return f"Detected {manager} ({file}). {_OFFER_LEAD}" + + +def text_declared(manifest: str) -> str: + return f"The Leji CLI is already declared in {manifest}." + + +def text_ambiguous(manifest: str, files: list[str]) -> str: + return ( + f"Detected {manifest} with {_join_and(files)}; leji will not guess the package manager. " + "Declare it with the one this repo uses:" + ) + + +def text_multiple(manifests: list[str], commands: bool) -> str: + tail = ":" if commands else "." + return ( + f"Detected {_join_and(manifests)}; leji will not guess which ecosystem owns this repository. " + f"Declare it with the one this repo uses{tail}" + ) + + +TEXT_NONE = [ + "No package.json, pyproject.toml or go.mod here, so there is nothing for leji to declare itself in. " + "Install the Leji CLI for yourself:", + f"{INDENT}npm install -g {DEP_NAME}", + "Other runtimes and the full walkthrough: https://leji.org/quickstart/", +] + + +def text_unsupported(manifest: str) -> str: + return ( + f"Detected {manifest}, whose packageManager field names a package manager leji does not know; " + f"leji will not guess. {_DECLARE_WITH_TOOL}" + ) + + +def text_unreadable(manifest: str) -> str: + return f"Could not read {manifest}, so leji will not guess the package manager. {_DECLARE_WITH_TOOL}" + + +def text_refused(files: list[str]) -> str: + return f"Refusing to read {_join_and(files)}: not a regular file inside this repository. {_DECLARE_WITH_TOOL}" + + +def text_pip_groups(file: str) -> list[str]: + return [ + f"Detected pip ({file}). To declare the Leji CLI as a dev dependency so a clean install brings leji, " + f"add to {PYPROJECT}:", + f"{INDENT}[dependency-groups]", + f'{INDENT}dev = ["{PY_DIST}"]', + "then run it with pip 25.1 or newer:", + f"{INDENT}pip install --group dev", + ] + + +def text_pip_requirements(file: str) -> list[str]: + return [ + f"Detected pip ({file}). To declare the Leji CLI as a dev dependency so a clean install brings leji, " + f"add a line `{PY_DIST}` to requirements-dev.txt, then run:", + f"{INDENT}pip install -r requirements-dev.txt", + ] + + +def text_go_legacy(file: str) -> list[str]: + return [ + f"Detected Go ({file}) without a go directive of 1.24 or newer, so leji cannot be declared as a " + "module tool. Install the Leji CLI for yourself:", + f"{INDENT}go install {GO_TOOL_PATH}@latest", + ] + + +def _line_selected(manager: str, file: str, declared: bool) -> str: + state = "declared" if declared else "not declared" + return f"Ecosystem: {manager} ({file}); Leji CLI {state}" + + +LINE_NONE = "Ecosystem: none detected" + + +def _line_multiple(manifests: list[str]) -> str: + return f"Ecosystem: {_join_and(manifests)}; leji will not guess which one owns this repository" + + +def _line_ambiguous(manifest: str, files: list[str]) -> str: + return f"Ecosystem: {manifest} with {_join_and(files)}; leji will not guess the package manager" + + +def _line_unsupported(manifest: str) -> str: + return f"Ecosystem: {manifest}; unrecognized packageManager field" + + +def _line_unreadable(manifest: str) -> str: + return f"Ecosystem: {manifest}; unreadable" + + +def _line_refused(files: list[str]) -> str: + return f"Ecosystem: {_join_and(files)}; not a regular file inside this repository" + + +# The consent path (plan section 3): the prompt, and every outcome of running the +# manager's own add command. leji writes no manifest byte itself, so these are the +# only words it owns once the user says yes. + + +def consent_disclosure(binary: str) -> str: + """Printed immediately before the prompt, interactive runs only. The manager + runs here, as the user, with the user's environment: say so before asking.""" + return ( + f"This runs {binary} here with your environment, as when you run it yourself: " + "it will contact its registry and may run install scripts." + ) + + +CONSENT_PROMPT = "Run it now?" + + +def consent_running(command: list[str]) -> str: + return "Running: " + " ".join(command) + + +# What each ecosystem's add command actually declares. +DECLARED_SUBJECT = {"node": DEP_NAME, "python": PY_DIST, "go": "the leji module tool"} + + +def consent_declared(ecosystem: str) -> str: + return f"Declared {DECLARED_SUBJECT[ecosystem]}; a clean install now brings leji." + + +def consent_exited(binary: str, code: int) -> str: + return f"{binary} exited {code}; run it yourself:" + + +def consent_signaled(binary: str, signal: str) -> str: + return f"{binary} was terminated ({signal}); run it yourself:" + + +def consent_missing(binary: str) -> str: + return f"{binary} is not on your PATH; run it yourself once it is:" + + +CONSENT_DECLINED = "Skipped; declare it later with:" + + +def consent_command(command: list[str]) -> str: + """One indented command line, so no caller re-derives the indentation.""" + return INDENT + " ".join(command) + + +# --- evidence eligibility ------------------------------------------------- + +ENTRY_ABSENT = "absent" +ENTRY_ELIGIBLE = "eligible" +ENTRY_REFUSED = "refused" + + +def classify_entry(root_abs: str, name: str) -> str: + """What stands at one probed name directly under the root. A gated file counts + only when lstat says regular file AND its real path lies inside the real root: + a symlink, a dangling link, a directory, a socket or a FIFO is refused rather + than read, so no manifest or lockfile can redirect the answer out of the + repository the user pointed at.""" + abs_path = os.path.join(root_abs, name) + try: + st = os.lstat(abs_path) + except OSError: + return ENTRY_ABSENT + import stat as stat_mod + + if not stat_mod.S_ISREG(st.st_mode): + return ENTRY_REFUSED + return ENTRY_ELIGIBLE if resolved_within_root(root_abs, Path(abs_path)) else ENTRY_REFUSED + + +class _RootScan: + """The probed names of one root, classified once.""" + + def __init__(self, root_abs: str) -> None: + self.root_abs = root_abs + self._kinds: dict[str, str] = {} + self._entries: Optional[list[str]] = None + + def kind(self, name: str) -> str: + if name not in self._kinds: + self._kinds[name] = classify_entry(self.root_abs, name) + return self._kinds[name] + + def present(self, name: str) -> bool: + return self.kind(name) != ENTRY_ABSENT + + def eligible(self, name: str) -> bool: + return self.kind(name) == ENTRY_ELIGIBLE + + def refused(self, names: list[str]) -> list[str]: + """The refused names among ``names``, in the order given.""" + return [n for n in names if self.kind(n) == ENTRY_REFUSED] + + def read(self, name: str) -> Optional[str]: + """The bytes of one probed name, or None. Structurally gated: a name that is + not an eligible regular file inside the real root is never opened, so no read + can bypass the eligibility rule by being spelled at a new call site.""" + if self.kind(name) != ENTRY_ELIGIBLE: + return None + try: + with open(os.path.join(self.root_abs, name), "r", encoding="utf-8") as handle: + return handle.read() + except (OSError, UnicodeDecodeError): + return None + + def matching(self, pattern: re.Pattern[str]) -> list[str]: + """Every root entry matching ``pattern``, sorted BYTEWISE (never by locale: + the three SDKs must agree).""" + if self._entries is None: + try: + self._entries = os.listdir(self.root_abs) + except OSError: + self._entries = [] + return sorted(n for n in self._entries if pattern.match(n)) + + +# --- result construction -------------------------------------------------- + + +@dataclass +class EcoResult: + """One gated ecosystem's answer. TOTAL: every field is set on every outcome.""" + + ecosystem: str + status: str + manifest: Optional[str] + manager: Optional[str] + source: Optional[str] + evidence: list[str] + add: Optional[list[str]] + runner: Optional[list[str]] + direct_declared: bool + lock_evidenced: bool + candidates: list[dict[str, Any]] + + def to_json(self) -> dict[str, Any]: + """The fixed key order the JSON contract pins.""" + return { + "ecosystem": self.ecosystem, + "status": self.status, + "manifest": self.manifest, + "manager": self.manager, + "source": self.source, + "evidence": self.evidence, + "add": self.add, + "runner": self.runner, + "directDeclared": self.direct_declared, + "lockEvidenced": self.lock_evidenced, + "candidates": self.candidates, + } + + +@dataclass +class EcosystemReport: + """The whole answer for one root. ``selected`` is non-None only when exactly one + ecosystem is gated AND it chose a manager.""" + + selected: Optional[EcoResult] + all: list[EcoResult] = field(default_factory=list) + reason: Optional[str] = None + + def to_json(self) -> dict[str, Any]: + return { + "selected": self.selected.to_json() if self.selected else None, + "all": [r.to_json() for r in self.all], + "reason": self.reason, + } + + +def _result( + ecosystem: str, + *, + status: str = "ok", + manifest: Optional[str] = None, + manager: Optional[str] = None, + source: Optional[str] = None, + evidence: Optional[list[str]] = None, + direct_declared: bool = False, + lock_evidenced: bool = False, + candidates: Optional[list[dict[str, Any]]] = None, +) -> EcoResult: + """The one EcoResult constructor. Every field of the result is set here, in the + fixed key order the JSON contract pins, so no branch can build a partial outcome + and add/runner always follow the manager rather than the branch.""" + cell = MANAGER_COMMANDS.get(manager) if manager is not None else None + return EcoResult( + ecosystem=ecosystem, + status=status, + manifest=manifest, + manager=manager, + source=source, + evidence=evidence if evidence is not None else [], + add=list(cell["add"]) if cell and cell["add"] else None, + runner=list(cell["runner"]) if cell and cell["runner"] else None, + direct_declared=direct_declared, + lock_evidenced=lock_evidenced, + candidates=candidates if candidates is not None else [], + ) + + +def _candidates_for(managers: list[str]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for manager in managers: + cell = MANAGER_COMMANDS.get(manager) + add = list(cell["add"]) if cell and cell["add"] else None + out.append({"manager": manager, "add": add}) + return out + + +def _uniq(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item not in seen: + seen.add(item) + out.append(item) + return out + + +# --- Node ----------------------------------------------------------------- + +# corepack's grammar, [@[+]]. A value that is present but does +# not parse is malformed — never a fall-through to a lockfile or the default, +# because explicit repository evidence is never overridden by a guess. +PACKAGE_MANAGER_RE = re.compile( + r"^([a-z][a-z0-9-]*)(?:@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)(?:\+([A-Za-z0-9._-]+))?)?$" +) + + +def _node_result(scan: _RootScan) -> EcoResult: + probed = [NODE_MANIFEST] + [f for f, _ in NODE_LOCKS] + refused = scan.refused(probed) + if refused: + return _result( + "node", status="refused-evidence", manifest=NODE_MANIFEST, evidence=sorted(refused) + ) + raw = scan.read(NODE_MANIFEST) + pkg = _parse_package_json(raw) if raw is not None else None + if pkg is None: + return _result("node", status="unreadable-manifest", manifest=NODE_MANIFEST) + locks = [(f, m) for f, m in NODE_LOCKS if scan.eligible(f)] + evidence = [f for f, _ in locks] + direct_declared = _declares_dep_in(pkg) + + def selected(manager: str, source: str) -> EcoResult: + return _result( + "node", + manifest=NODE_MANIFEST, + manager=manager, + source=source, + evidence=evidence, + direct_declared=direct_declared, + lock_evidenced=any(m == manager for _, m in locks), + ) + + if "packageManager" in pkg: + value = pkg["packageManager"] + name = None + if isinstance(value, str): + match = PACKAGE_MANAGER_RE.match(value) + if match: + name = match.group(1) + if name is None or name not in NODE_MANAGERS: + return _result( + "node", + status="unsupported-manager", + manifest=NODE_MANIFEST, + source="packageManager", + evidence=evidence, + direct_declared=direct_declared, + ) + return selected(name, "packageManager") + + families = _uniq([m for _, m in locks]) + if len(families) > 1: + return _result( + "node", + status="ambiguous-manager", + manifest=NODE_MANIFEST, + evidence=evidence, + direct_declared=direct_declared, + candidates=_candidates_for(families), + ) + if len(families) == 1: + return selected(families[0], "lockfile") + return selected("npm", "default") + + +def _reject_non_finite(_: str) -> object: + """parse_constant hook: a non-finite JSON constant is not strict JSON.""" + raise ValueError("non-finite JSON constant") + + +def _parse_package_json(raw: str) -> Optional[dict[str, Any]]: + """Strict JSON after one BOM strip; anything else — unparseable, or parsed to + something that is not a JSON object — leaves the manifest unreadable, and locks + and defaults are not consulted from incomplete evidence.""" + text = raw[1:] if raw.startswith("\ufeff") else raw + try: + # parse_constant rejects NaN/Infinity/-Infinity, which JSON.parse and Go's + # encoding/json both forbid and Python's json.loads would otherwise accept: + # the three SDKs must call the same bytes unreadable. + parsed = json.loads(text, parse_constant=_reject_non_finite) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _declares_dep_in(pkg: dict[str, Any]) -> bool: + for field_name in ("dependencies", "devDependencies"): + deps = pkg.get(field_name) + if isinstance(deps, dict) and DEP_NAME in deps: + return True + return False + + +# --- Python --------------------------------------------------------------- + + +@dataclass(frozen=True) +class PythonChoice: + """Which manager one Python root's evidence names. ``manager`` is None exactly + when the evidence is ambiguous, and ``candidates`` then names what it could not + choose between.""" + + manager: Optional[str] + source: Optional[str] + lock_evidenced: bool + candidates: list[str] + + +def python_manager_choice(lock_names: list[str], pyproject_text: Optional[str]) -> PythonChoice: + """The manager decision table for a Python root, over NAMES and TEXT alone. + + One home for the table, because two callers ask it: the ecosystem report below, + and the hand-off resolver, which must derive the manager from bytes it verified + itself rather than from a scan it did not perform. Pure by construction, so + neither caller can drift from the other about what the same evidence means. + """ + present = [(f, m, lock) for f, m, lock in PY_LOCKS if f in lock_names] + families = _uniq([m for _, m, _ in present]) + if len(families) > 1: + return PythonChoice(None, None, False, families) + if len(families) == 1: + # Pipfile alone selects pipenv from the manifest itself; only Pipfile.lock is + # lock evidence, which is what CI reads to choose a locked install. + lock = any(m == families[0] and is_lock for _, m, is_lock in present) + return PythonChoice(families[0], "lockfile" if lock else "manifest", lock, []) + tables = [ + m + for t, m in PY_TOOL_TABLES + if pyproject_text is not None and _toml_has_table(pyproject_text, t) + ] + if len(tables) > 1: + return PythonChoice(None, None, False, tables) + if len(tables) == 1: + return PythonChoice(tables[0], "tool-table", False, []) + # Nothing named a manager: pip is the ecosystem's default, and it is print-only. + return PythonChoice("pip", "default", False, []) + + +def python_declares( + pyproject_text: Optional[str], + pipfile_text: Optional[str], + requirements_texts: Iterable[Optional[str]], +) -> bool: + """Whether a Python root declares the Leji CLI, over TEXT alone. The same two + callers as :func:`python_manager_choice`, for the same reason. The requirement + texts are taken lazily, so a caller reads only as far as the first declaration.""" + if pyproject_text is not None and _toml_declares_leji(pyproject_text, _PYPROJECT_FIELDS): + return True + if pipfile_text is not None and _toml_declares_leji(pipfile_text, _PIPFILE_FIELDS): + return True + return any(text is not None and _requirements_declare_leji(text) for text in requirements_texts) + + +def _python_result(scan: _RootScan) -> EcoResult: + requirements = scan.matching(REQUIREMENTS_RE) + manifest = _python_manifest(scan, requirements) + probed = _uniq([PYPROJECT] + [f for f, _, _ in PY_LOCKS] + requirements) + refused = scan.refused(probed) + if refused: + return _result( + "python", status="refused-evidence", manifest=manifest, evidence=sorted(refused) + ) + pyproject_text = scan.read(PYPROJECT) if scan.eligible(PYPROJECT) else None + pipfile_text = scan.read(PIPFILE) if scan.eligible(PIPFILE) else None + if (scan.eligible(PYPROJECT) and pyproject_text is None) or ( + scan.eligible(PIPFILE) and pipfile_text is None + ): + return _result("python", status="unreadable-manifest", manifest=manifest) + + lock_names = [f for f, _, _ in PY_LOCKS if scan.eligible(f)] + evidence = lock_names + requirements + direct_declared = python_declares( + pyproject_text, + pipfile_text, + (scan.read(name) for name in requirements if scan.eligible(name)), + ) + choice = python_manager_choice(lock_names, pyproject_text) + + if choice.manager is None: + return _result( + "python", + status="ambiguous-manager", + manifest=manifest, + evidence=evidence, + direct_declared=direct_declared, + candidates=_candidates_for(choice.candidates), + ) + return _result( + "python", + manifest=manifest, + manager=choice.manager, + source=choice.source, + evidence=evidence, + direct_declared=direct_declared, + lock_evidenced=choice.lock_evidenced, + ) + + +def _python_manifest(scan: _RootScan, requirements: list[str]) -> Optional[str]: + """Manifest precedence: pyproject, then Pipfile, then the conventional + requirements files. Decided on presence, so a refused entry still names what was + refused.""" + if scan.present(PYPROJECT): + return PYPROJECT + if scan.present(PIPFILE): + return PIPFILE + for name in ("requirements-dev.txt", "requirements.txt"): + if name in requirements: + return name + return requirements[0] if requirements else None + + +# --- Go ------------------------------------------------------------------- + +GO_DIRECTIVE_RE = re.compile(r"^go\s+(\d+)\.(\d+)") +_GO_TOOL_BLOCK_RE = re.compile(r"^tool\s*\($") + + +def _go_result(scan: _RootScan) -> EcoResult: + refused = scan.refused([GO_MANIFEST]) + if refused: + return _result("go", status="refused-evidence", manifest=GO_MANIFEST, evidence=refused) + text = scan.read(GO_MANIFEST) + if text is None: + return _result("go", status="unreadable-manifest", manifest=GO_MANIFEST) + # Tool dependencies are a Go 1.24 feature; an older or missing directive gets the + # per-person install instead. go.sum is the manager's business, so a declared + # tool is its own lock evidence. + direct_declared = _go_declares_tool(text) + modern = _go_directive_at_least(text, 1, 24) + return _result( + "go", + manifest=GO_MANIFEST, + manager="go" if modern else "go-legacy", + source="manifest", + direct_declared=direct_declared, + lock_evidenced=modern and direct_declared, + ) + + +def _go_directive_at_least(text: str, major: int, minor: int) -> bool: + for line in _split_lines(text): + match = GO_DIRECTIVE_RE.match(line.strip()) + if not match: + continue + found_major, found_minor = int(match.group(1)), int(match.group(2)) + return found_major > major or (found_major == major and found_minor >= minor) + return False + + +def _go_declares_tool(text: str) -> bool: + """A ``tool `` line, or that path inside a ``tool (`` block.""" + in_block = False + for raw in _split_lines(text): + line = raw + cut = line.find("//") + if cut >= 0: + line = line[:cut] + line = line.strip() + if line == "": + continue + if in_block: + if line == ")": + in_block = False + elif line == GO_TOOL_PATH: + return True + continue + if _GO_TOOL_BLOCK_RE.match(line): + in_block = True + continue + if line == "tool " + GO_TOOL_PATH: + return True + return False + + +# --- the TOML dependency scan --------------------------------------------- + + +@dataclass +class _TomlFields: + """Which fields of a TOML document declare a dependency. Deliberately not a TOML + parser: a field-specific, stateful line scan that tracks the current table, + triple-quoted string state, and the bracket depth of the one array it is + inspecting. Only the listed fields are inspected, so a description, a comment, or + an unrelated table cannot produce a false positive — and a false positive is the + expensive error here, because it suppresses the only offer the user gets.""" + + key_table: Callable[[str], bool] + array_field: Callable[[str, str], bool] + + +_POETRY_GROUP_RE = re.compile(r"^tool\.poetry\.group\.[^.]+\.dependencies$") + +_PYPROJECT_FIELDS = _TomlFields( + key_table=lambda t: ( + t == "tool.poetry.dependencies" + or t == "tool.poetry.dev-dependencies" + or t == "tool.pdm.dev-dependencies" + or bool(_POETRY_GROUP_RE.match(t)) + ), + array_field=lambda t, k: ( + (t == "project" and k == "dependencies") + or t == "project.optional-dependencies" + or t == "dependency-groups" + or (t == "tool.uv" and k == "dev-dependencies") + or t == "tool.pdm.dev-dependencies" + ), +) + +_PIPFILE_FIELDS = _TomlFields( + key_table=lambda t: t in ("packages", "dev-packages"), + array_field=lambda t, k: False, +) + +# A requirement whose distribution name is exactly leji: the name, then the end of +# the token or one of the characters that can follow a name in PEP 508 / +# requirements syntax. +_LEJI_REQUIREMENT_RE = re.compile(r"^leji($|[\[=<>~!;,\s])") + + +def _toml_declares_leji(text: str, fields: _TomlFields) -> bool: + table = "" + triple = "" + depth = 0 + inspecting = False + for line in _split_lines(text): + i = 0 + if triple: + close = line.find(triple) + if close < 0: + continue + i = close + 3 + triple = "" + elif depth == 0: + header = _toml_table_header(line) + if header is not None: + table = header + continue + key = _toml_key_at(line) + if key is None: + continue + name, value_at = key + if name == PY_DIST and fields.key_table(table): + return True + inspecting = fields.array_field(table, name) + i = value_at + # One character scan carries the rest: strings (whose contents are the only + # things that can match), bracket depth (which says whether we are inside the + # inspected array), comments, and a triple quote that runs past this line. + while i < len(line): + char = line[i] + if char == "#": + break + if char in ('"', "'"): + fence = char * 3 + if line.startswith(fence, i): + close = line.find(fence, i + 3) + if close < 0: + triple = fence + break + # A triple-quoted string is skipped ENTIRELY, on one line as across + # several: the scanner has no TOML parser to tell a multi-line + # dependency from prose that merely starts with the name, so the + # conservative answer is the only safe one. + i = close + 3 + continue + content, end = _toml_read_string(line, i, char) + if depth > 0 and inspecting and _LEJI_REQUIREMENT_RE.match(content): + return True + i = end + continue + if char == "[": + depth += 1 + elif char == "]" and depth > 0: + depth -= 1 + if depth == 0: + inspecting = False + i += 1 + return False + + +_TOML_ARRAY_HEADER_RE = re.compile(r"^\s*\[\[\s*([^\]]+?)\s*\]\]\s*(?:#.*)?$") +_TOML_HEADER_RE = re.compile(r"^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$") +_WHITESPACE_RE = re.compile(r"\s+") + + +def _toml_table_header(line: str) -> Optional[str]: + """``[table]`` or ``[[array-of-tables]]``, with inner whitespace removed.""" + match = _TOML_ARRAY_HEADER_RE.match(line) + if match: + return _WHITESPACE_RE.sub("", match.group(1)) + match = _TOML_HEADER_RE.match(line) + if match: + return _WHITESPACE_RE.sub("", match.group(1)) + return None + + +_TOML_KEY_RE = re.compile(r"""^\s*(?:"([^"]*)"|'([^']*)'|([A-Za-z0-9_.-]+))\s*=\s*""") + + +def _toml_key_at(line: str) -> Optional[tuple[str, int]]: + """The key a line assigns to, bare or quoted, and where its value starts.""" + match = _TOML_KEY_RE.match(line) + if not match: + return None + name = match.group(1) + if name is None: + name = match.group(2) + if name is None: + name = match.group(3) + return (name or "", match.end()) + + +def _toml_read_string(line: str, start: int, quote: str) -> tuple[str, int]: + """One single-line basic or literal string, from its opening quote. Escapes are + consumed, not decoded: only a ``leji`` prefix is ever tested against the result.""" + out: list[str] = [] + i = start + 1 + while i < len(line): + char = line[i] + if quote == '"' and char == "\\": + out.append(line[i + 1] if i + 1 < len(line) else "") + i += 2 + continue + if char == quote: + return ("".join(out), i + 1) + out.append(char) + i += 1 + return ("".join(out), len(line)) + + +def _toml_has_table(text: str, table: str) -> bool: + """True when the document opens the given table, or any table under it: TOML + defines ``tool.poetry`` implicitly when a document writes only + ``[tool.poetry.dependencies]``, and a manager's table is present either way. The + dot is what keeps ``[tool.uvicorn]`` from answering for ``tool.uv``.""" + triple = "" + for line in _split_lines(text): + if triple: + if triple in line: + triple = "" + continue + header = _toml_table_header(line) + if header is not None: + if header == table or header.startswith(table + "."): + return True + continue + opened = _toml_opens_triple(line) + if opened: + triple = opened + return False + + +def _toml_opens_triple(line: str) -> str: + """The triple quote a line leaves open, or "".""" + i = 0 + open_fence = "" + while i < len(line): + char = line[i] + if char == "#": + break + if char in ('"', "'"): + fence = char * 3 + if line.startswith(fence, i): + close = line.find(fence, i + 3) + if close < 0: + open_fence = fence + break + i = close + 3 + continue + _, end = _toml_read_string(line, i, char) + i = end + continue + i += 1 + return open_fence + + +_REQUIREMENT_LINE_RE = re.compile(r"^leji($|[\s\[=<>~!;,#])") + + +def _requirements_declare_leji(text: str) -> bool: + """A ``leji`` requirement line: the name at the start of the line, then + end-of-line or a character that can follow a name.""" + return any(_REQUIREMENT_LINE_RE.match(line) for line in _split_lines(text)) + + +def _split_lines(text: str) -> list[str]: + return [line[:-1] if line.endswith("\r") else line for line in text.split("\n")] + + +# --- the report ----------------------------------------------------------- + + +def detect_ecosystem(root_abs: str) -> EcosystemReport: + """Detect the dependency ecosystems gated by files directly under ``root_abs``. + Reads; never writes, never runs anything, never walks up.""" + scan = _RootScan(os.path.abspath(root_abs)) + all_results: list[EcoResult] = [] + # Fixed order, so `all` reads the same in every report and in every SDK. + if scan.present(NODE_MANIFEST): + all_results.append(_node_result(scan)) + python_gated = ( + scan.present(PYPROJECT) or scan.present(PIPFILE) or bool(scan.matching(REQUIREMENTS_RE)) + ) + if python_gated: + all_results.append(_python_result(scan)) + if scan.present(GO_MANIFEST): + all_results.append(_go_result(scan)) + + if not all_results: + return EcosystemReport(selected=None, all=all_results, reason="none") + if len(all_results) > 1: + return EcosystemReport(selected=None, all=all_results, reason="multiple-ecosystems") + only = all_results[0] + # A manager-less single ecosystem carries its own reason up: the report's reason + # is never a second, independently derived verdict. + if only.status != "ok": + return EcosystemReport(selected=None, all=all_results, reason=only.status) + return EcosystemReport(selected=only, all=all_results, reason=None) + + +def runner_argv(report: EcosystemReport) -> list[str]: + """The argv a hook or CI job runs leji with: the detected manager's runner when + the repository actually declares the CLI, else the plain fallback on PATH.""" + selected = report.selected + if selected is not None and selected.direct_declared and selected.runner: + return list(selected.runner) + return list(PLAIN_RUNNER) + + +def render_ecosystem_block(report: EcosystemReport) -> str: + """The always-printed human block: what was detected, and what to run to declare + the Leji CLI. Never a prompt, never a command run — the caller owns both.""" + return "\n".join(_block_lines(report)) + + +def _block_lines(report: EcosystemReport) -> list[str]: + if report.reason == "none": + return TEXT_NONE + if report.reason == "multiple-ecosystems": + # A print-only ecosystem (pip, pre-1.24 Go) contributes no command here; the + # lead sentence closes with a period rather than dangling a colon. + commands: list[str] = [] + for result in report.all: + commands.extend(_command_lines(result)) + manifests = [_manifest_label(r) for r in report.all] + return [text_multiple(manifests, bool(commands))] + commands + only = report.all[0] + if only.direct_declared and only.manifest is not None: + return [text_declared(only.manifest)] + if only.status == "refused-evidence": + return [text_refused(only.evidence)] + if only.status == "unreadable-manifest": + return [text_unreadable(only.manifest or "")] + if only.status == "unsupported-manager": + return [text_unsupported(only.manifest or "")] + if only.status == "ambiguous-manager": + return [text_ambiguous(only.manifest or "", only.evidence)] + _command_lines(only) + return _ok_lines(only) + + +def _ok_lines(result: EcoResult) -> list[str]: + """The offer for one ecosystem that chose a manager. A manager with no add + command prints its own guidance instead.""" + if result.manager == "pip": + if result.manifest == PYPROJECT: + return text_pip_groups(_decider_file(result)) + return text_pip_requirements(_decider_file(result)) + if result.manager == "go-legacy": + return text_go_legacy(_decider_file(result)) + return [text_offer(result.manager or "", _decider_file(result))] + _command_lines(result) + + +def _command_lines(result: EcoResult) -> list[str]: + """The indented command line(s) for one result: its own add command, or one per + candidate when the evidence could not choose.""" + if result.add is not None: + return [consent_command(result.add)] + return [consent_command(c["add"]) for c in result.candidates if c["add"] is not None] + + +def _decider_file(result: EcoResult) -> str: + """The one file a message names as the evidence for the manager: the lockfile + that selected it, the pyproject that carried its tool table, or the manifest.""" + if result.source == "lockfile": + for name in result.evidence: + for lock_file, manager in NODE_LOCKS: + if lock_file == name and manager == result.manager: + return name + for lock_file, manager, is_lock in PY_LOCKS: + if lock_file == name and manager == result.manager and is_lock: + return name + if result.source == "tool-table": + return PYPROJECT + if result.ecosystem == "python" and result.source == "manifest": + return PIPFILE + return result.manifest or "" + + +def _manifest_label(result: EcoResult) -> str: + return result.manifest if result.manifest is not None else result.ecosystem + + +def render_ecosystem_line(report: EcosystemReport) -> str: + """The one line ``leji detect`` prints about the ecosystem.""" + if report.reason == "none": + return LINE_NONE + if report.reason == "multiple-ecosystems": + return _line_multiple([_manifest_label(r) for r in report.all]) + only = report.all[0] + if only.status == "refused-evidence": + return _line_refused(only.evidence) + if only.status == "unreadable-manifest": + return _line_unreadable(only.manifest or "") + if only.status == "unsupported-manager": + return _line_unsupported(only.manifest or "") + if only.status == "ambiguous-manager": + return _line_ambiguous(only.manifest or "", only.evidence) + return _line_selected(only.manager or "", _decider_file(only), only.direct_declared) diff --git a/packages/sdk-py/src/leji/export_cmd.py b/packages/sdk-py/src/leji/export_cmd.py new file mode 100644 index 0000000..300fa71 --- /dev/null +++ b/packages/sdk-py/src/leji/export_cmd.py @@ -0,0 +1,539 @@ +"""``leji export`` (and ``leji viewer build``, its co-equal name for the same +operation): the static export pipeline, in its own module so its transitive import +set can be checked. Nothing here — and nothing it imports — pulls in +``http.server``, ``socket``, ``socketserver`` or ``urllib.request``; the local +preview server keeps all of that in ``serve_cmd``. The only subprocess the pipeline +reaches is ``git``, through the mount status the manifest page renders, with lazy +fetch disabled. An import-inspection test pins the first claim. + +Mirrors the Node SDK's `commands/export.ts`. +""" + +from __future__ import annotations + +import os +import stat +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from .findings import Finding, sort_findings +from .fsx import ( + mkdirp_guarded, + open_verified_source, + open_write_guarded, + read_all, + resolved_path, + resolved_path_under, + resolved_within_root, + rm_guarded, + strip_slash, + to_posix, + verified_target_read, + write_file_guarded, +) +from .layout import ( + DIST_REL, + LEJI_DIR, + VIEWER_REL, + TargetVerdict, + leji_role, + role_abs, + servable_path, + writable_target, +) +from .manifest import Manifest +from .renderlint import RENDER_UNSUPPORTED_RULE, render_lint_findings +from .viewer_cmd import ( + ACTIVE_EXTENSIONS, + EXPORT_BASE, + _build_index_html, + _resolved_profile_pages, + generate_viewer, +) + +# The protect-your-context warning shown by `leji export` and embedded in the +# exported index.html: a context layer is sensitive and the static export should not +# be hosted somewhere public. +PROTECT_WARNING = ( + "This is your context layer (identity, invariants, decisions, sometimes sensitive " + "internal knowledge). Host the exported folder behind internal authentication, not a " + "public or shared bucket where it could be indexed or leaked. Active file types " + "(.htm, .html, .js, .mjs, .xhtml) are left out of the exported content: a static " + "host would serve them as same-origin documents that execute with no policy." +) + +# The first bytes an export writes into its index.html, under either of the +# command's names. A target directory carrying this marker is a previous export and +# may be cleared; any other non-empty directory is somebody's content and is never +# removed. The marker is a byte contract shared with the Node and Go SDKs, so it +# reads as it has always read: an export written by any of the three, under either +# name, is clearable by any of the three. +EXPORT_MARKER = "\n{index_html}", + ) + + return BuildResult(out=out_display, findings=findings, wrote=True) diff --git a/packages/sdk-py/src/leji/findings.py b/packages/sdk-py/src/leji/findings.py index 7e636f6..3c41edf 100644 --- a/packages/sdk-py/src/leji/findings.py +++ b/packages/sdk-py/src/leji/findings.py @@ -14,17 +14,35 @@ class Finding: severity: Severity message: str path: Optional[str] = None + #: 1-based line within ``path`` when the rule locates one (the rendering + #: lint); None when it does not, and then omitted from the JSON. + line: Optional[int] = None + #: The closed-token construct a rule names, when it carries one: what the + #: 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 def to_dict(self) -> dict: out: dict = {"rule": self.rule, "severity": self.severity} if self.path is not None: out["path"] = self.path + if self.line is not None: + out["line"] = self.line + if self.construct is not None: + out["construct"] = self.construct out["message"] = self.message return out def sort_findings(findings: list[Finding]) -> list[Finding]: - return sorted(findings, key=lambda f: (f.path or "", f.rule, f.message)) + """Findings in canonical order: (path, line, rule, construct), message last as + the final tie-break. The line and construct keys carry the rendering lint's + ordering — two constructs reported on one line stay in the same order in all + three SDKs — and change nothing for a rule that locates neither.""" + return sorted( + findings, + key=lambda f: (f.path or "", f.line or 0, f.rule, f.construct or "", f.message), + ) def summarize(findings: list[Finding]) -> dict: diff --git a/packages/sdk-py/src/leji/fsx.py b/packages/sdk-py/src/leji/fsx.py index 92e1676..071d9cf 100644 --- a/packages/sdk-py/src/leji/fsx.py +++ b/packages/sdk-py/src/leji/fsx.py @@ -3,7 +3,13 @@ from __future__ import annotations import os +import shutil +import stat +from dataclasses import dataclass from pathlib import Path +from typing import Callable, Literal, Optional, Union + +from .layout import TargetVerdict, writable_target def to_posix(p: str) -> str: @@ -13,8 +19,16 @@ def to_posix(p: str) -> str: def is_contained(root: str, candidate: Path) -> bool: """True when ``candidate``'s real path stays under ``root``'s real path. - Resolves symlinks on both sides so a symlinked entry pointing outside the - repository (or at /etc, the git store, etc.) is treated as escaping.""" + The LENIENT containment form, and READ-side only: it never guards a write, a + clear, or any decision a write depends on — those go through + :func:`resolved_within_root` and the chokepoint, which fail closed. It survives + where the reference SDK deleted its counterpart because the two resolvers are not + equivalent here: Node's ``realpath.native`` reads a component's canonical spelling + from the kernel, while this port must list the parent directory to recover it (see + :func:`_real_name`). A directory that is traversable but not enumerable therefore + makes the strict form unresolvable in Python where the reference resolves it + happily — and a layer's index files, read behind an existence check, must stay + readable there rather than reporting a layer that has no index at all.""" try: real_root = Path(os.path.realpath(root)) real = Path(os.path.realpath(candidate)) @@ -23,34 +37,589 @@ def is_contained(root: str, candidate: Path) -> bool: return real == real_root or real.is_relative_to(real_root) -def resolved_within_root(root: str, candidate: Path) -> bool: - """True when ``candidate`` resolves (following symlinks) to a path within - ``root``, even when ``candidate`` does not yet exist. - - Unlike :func:`is_contained`, a not-yet-existing target is checked by - resolving its nearest existing ancestor and re-appending the remainder, so a - symlinked ancestor that escapes root is caught before a write creates the - final file under it.""" - candidate = Path(candidate) - if candidate.exists() or candidate.is_symlink(): +def _real_name(directory: str, name: str) -> str: + """The filesystem's own spelling of ``name`` inside ``directory``: ``name`` + itself when the directory holds it verbatim, otherwise the entry that differs + from it only in case. + + This is what closes case-variant role aliasing, which Node closes with + ``realpathSync.native``: on a case-insensitive volume ``.LEJI/mounts`` opens the + very directory ``.leji/mounts`` names, yet compares unequal to it as a string, + so a decision made on the spelling is not a decision about the file. + ``os.path.realpath`` hands back the spelling it was given, so the canonical name + is read from the directory itself. A directory that denies enumeration (permission + or I/O) makes the canonical spelling unknowable, so the error propagates and the + whole path is unresolvable: a directory can refuse to be listed while still + allowing traversal and writes through it, and falling back to the caller's + spelling would let a ``.LEJI/`` alias be judged as a location outside the roles it + actually opens. Mere nonexistence is not a failure — that is the not-yet-created + target the caller rebuilds lexically.""" + try: + entries = os.listdir(directory) + except FileNotFoundError: + return name + folded = "" + for entry in entries: + if entry == name: + return name # the volume holds this exact spelling + if folded == "" and entry.casefold() == name.casefold(): + folded = entry + return folded or name + + +def _canonical_case(base: str, abs_path: str) -> str: + """``abs_path`` (existing and symlink-free) with each component below ``base`` + spelled as the filesystem holds it. + + ``base`` is a prefix already known to be canonical — the resolved repository + root at every check-before-act call site — so the walk stays inside the tree the decision is + about instead of reading every directory from the filesystem root down. An + empty base, or a path outside it, canonicalizes from the volume root. The + ``OSError`` a component's directory raises travels out: a spelling that cannot be + read back is not a spelling to decide on.""" + if base and abs_path == base: + return base + if base and abs_path.startswith(base + os.sep): + current, rest = base, abs_path[len(base) + 1 :] + else: + drive, tail = os.path.splitdrive(abs_path) + current, rest = drive + os.sep, tail.lstrip(os.sep) + if rest == "": + return current + for segment in rest.split(os.sep): + if segment: + current = os.path.join(current, _real_name(current, segment)) + return current + + +def _native_realpath(base: str, abs_path: str) -> str: + """Resolve every symlink in ``abs_path`` AND canonicalize the case of each + component below ``base``, the two halves of Node's ``realpathSync.native``. + Raises ``OSError`` exactly as a strict resolution does — from either half, since + either failing fails the whole resolution.""" + return _canonical_case(base, os.path.realpath(abs_path, strict=True)) + + +def _resolve_link(abs_path: str) -> str: + """The symlink at ``abs_path`` read as an absolute path (a relative target + resolves against the link's own directory).""" + try: + target = os.readlink(abs_path) + except OSError: + return abs_path + if os.path.isabs(target): + return os.path.normpath(target) + return os.path.normpath(os.path.join(os.path.dirname(abs_path), target)) + + +def resolved_path(abs_path: str) -> Optional[str]: + """``abs_path`` with every symlink in it resolved, and with the filesystem's own + spelling of each existing component — so a case-variant path on a + case-insensitive filesystem comes back canonical. A path that does not exist yet + resolves through its nearest existing ancestor, with the remainder re-appended, + so a caller can judge a write target before anything is created under it. None + when even the ancestor cannot be resolved. + + Judge with this whenever a decision and the write it guards must be about the + same path: a lexical comparison answers for the spelling, not for the file.""" + return resolved_path_under("", abs_path) + + +def resolved_path_under(base: str, abs_path: str) -> Optional[str]: + """:func:`resolved_path` with a prefix already known to be canonical — the + resolved repository root, which every check-before-act call site holds — so only the + components below it are read back from the filesystem. Semantics are identical; + a path that resolves outside ``base`` is canonicalized in full.""" + try: + return _native_realpath(base, abs_path) # path exists (overwrite target, vendor file) + except FileNotFoundError: + pass + except OSError: + # Only genuine nonexistence is rebuilt lexically from the nearest existing + # ancestor. A permission or I/O error (EACCES, EIO, ELOOP, ENOTDIR, …) means + # the path exists but cannot be resolved: it FAILS the check rather than + # being reconstructed as if it were an absent write target — a resolved + # decision and the write it guards must be about the same real path. + return None + # A dangling symlink at the final component: realpath cannot follow it to a + # missing target, but a write WOULD follow it there, so resolve the link's target + # rather than treating the link's own name as the location — otherwise a symlink + # into a private role reads as its own path and slips the boundary. (realpath + # already proved the chain has no loop; a loop raises and is refused above.) A + # missing final component that is not a symlink falls through to the ancestor + # walk, the normal not-yet-created write target. + if os.path.islink(abs_path): + return resolved_path_under(base, _resolve_link(abs_path)) + # Walk to the nearest existing ancestor. A dangling symlink in an INTERMEDIATE + # component is not "absent": a write would follow it, so follow it here too — + # resolve the link and re-root the remainder onto its target, rather than + # climbing past it and rebuilding the link's own name lexically. Otherwise a + # nested `redirect/export` whose `redirect` dangles into a private role reads as + # `.../redirect/export` (outside `.leji/`) and a target created after the check + # lands the write inside the role — the check/use race this closes. + p = os.path.dirname(abs_path) + while not os.path.exists(p) and os.path.dirname(p) != p: try: - real = Path(os.path.realpath(candidate)) + mode = os.lstat(p).st_mode + except FileNotFoundError: + mode = 0 except OSError: - return False - else: - # Does not exist yet: resolve the nearest existing ancestor, then re-append. - p = candidate.parent - while not p.exists() and p.parent != p: - p = p.parent + return None # p is present but cannot be lstat'd (permission/I/O) + if stat.S_ISLNK(mode): + return resolved_path_under( + base, os.path.join(_resolve_link(p), os.path.relpath(abs_path, p)) + ) + p = os.path.dirname(p) + try: + ancestor = _native_realpath(base, p) + except OSError: + return None + return os.path.join(ancestor, os.path.relpath(abs_path, p)) + + +#: The creation mode a guarded write hands the OS when the caller names none. +#: Spelled out because ``os.open`` defaults to ``0o777`` while the reference SDK's +#: write defaults to ``0o666``: taking Python's default would leave every generated +#: file executable, and the three SDKs would disagree on the modes they leave behind. +#: The process umask narrows it exactly as it does there. +_DEFAULT_CREATE_MODE = 0o666 + + +def guard_root(root: str) -> str: + """The repository root as every guard judges it: absolute and realpath-resolved, + falling back to the absolute spelling when it cannot be resolved at all. Both + sides of the containment rule must come through the same resolver, or a root + reached through a symlinked ancestor (``/tmp`` -> ``/private/tmp``) compares + unequal to its own children and every write under it reads as an escape.""" + abs_path = os.path.abspath(root) + return resolved_path(abs_path) or abs_path + + +def resolved_within_root(root: str, candidate: Path) -> bool: + """True when ``candidate`` resolves (following symlinks) within ``root``, even + when ``candidate`` does not yet exist: a non-existent target is checked via its + nearest existing ancestor, so a symlinked ancestor that escapes root is caught + before a write creates the file under it. + + Fails CLOSED: a path that cannot be resolved at all (permission or I/O error, a + symlink loop, a dangling link out of the tree) is not within root. A containment + check under a security contract answers "provably inside" or nothing.""" + real = resolved_path(str(candidate)) + if real is None: + return False + try: + # Both sides through the same resolver: a root resolved one way and a child + # the other would differ in spelling alone and read as an escape. + real_root = _native_realpath("", str(root)) + except OSError: + return False + return real == real_root or real.startswith(real_root + os.sep) + + +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.""" + resolved = resolved_path_under(root_abs, target_abs) + if resolved is None: + return TargetVerdict(unresolvable=True), None + return writable_target(root_abs, resolved, own_role_rel), resolved + + +def guarded_write( + root_abs: str, + target_abs: str, + own_role_rel: Optional[str], + op: Callable[[str], None], +) -> TargetVerdict: + """The single guarded-write chokepoint (check-before-act). Realpath-resolve + ``target_abs``, run :func:`~leji.layout.writable_target` on the resolved path, + and perform the write or clear — through ``op``, on that resolved path — ONLY + when the target is allowed to land there, which means all of: it resolves at all; + it resolves INSIDE the repository root, with no exceptions; and it lands outside + root ``.leji/`` or inside the one role ``own_role_rel`` names. On refusal nothing + is touched: the verdict is returned (unresolvable, outside the repository, or the + private ``.leji/`` role the target crossed into) so the caller renders the + mandated hard refusal in its own channel — a generation ``Finding``, or a raised + build error — before any byte is written. + + ``root_abs`` must already be realpath-resolved (:func:`guard_root`). + ``own_role_rel`` names the one ``.leji/`` role this write may legitimately land + in, or None when the target has no ``.leji/`` role at all (user content such as + overview.md). One home for every write whose target derives from + user-influenceable input, so a new write site is guarded by construction rather + than by remembering to guard it — and the guarded conveniences below are how + command modules reach it, so no command spells a raw write primitive of its own. + + The recorded check-before-act limit (``docs/practice/trust-boundary.md``) stands: + the act is by pathname, immediately after the resolved decision, because no + portable descriptor-bound directory walk exists here. An attacker must win the + race between the two.""" + verdict, resolved = _judge_target(root_abs, target_abs, own_role_rel) + if verdict.ok and resolved is not None: + op(resolved) + return verdict + + +def write_file_guarded( + root_abs: str, + target_abs: str, + own_role_rel: Optional[str], + content: Union[str, bytes], + mode: Optional[int] = None, + exclusive: bool = False, +) -> TargetVerdict: + """Write ``content`` to a guarded target, creating its parent directories only + when the write itself happens (a refused run establishes nothing). ``mode`` sets + the mode at creation; ``exclusive`` creates with ``O_EXCL``, so a target that + already exists comes back as the ``exists`` verdict rather than being overwritten + or followed through a planted symlink. + + An exclusive create is decided on the ORIGINAL directory entry before anything is + resolved: ANY standing entry — a regular file, a directory, a symlink whether it + dangles or not — is ``exists``. Resolving first would defeat the point, because a + dangling symlink resolves to its missing destination, and ``O_EXCL`` on that + destination would happily create the file the link points at. Nothing stands + there ⇒ the resolved path is judged (its parents included) and ``O_EXCL`` still + closes the race between that judgement and the create.""" + if exclusive and not nothing_stands_at(target_abs): + return TargetVerdict(exists=True) + data = content.encode("utf-8") if isinstance(content, str) else content + already_there = False + + def op(resolved: str) -> None: + nonlocal already_there + os.makedirs(os.path.dirname(resolved), exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | (os.O_EXCL if exclusive else os.O_TRUNC) try: - real = Path(os.path.realpath(p)) / candidate.relative_to(p) - except (OSError, ValueError): - return False + fd = os.open(resolved, flags, _DEFAULT_CREATE_MODE if mode is None else mode) + except FileExistsError: + already_there = True + return + with os.fdopen(fd, "wb") as f: + f.write(data) + + verdict = guarded_write(root_abs, target_abs, own_role_rel, op) + return TargetVerdict(exists=True) if already_there else verdict + + +@dataclass +class GuardedDir: + """A guarded directory: the RESOLVED directory the rule judged, so every act that + follows works from the path that was checked rather than re-joining its own. + ``real`` is set only when ``verdict.ok``.""" + + verdict: TargetVerdict + real: Optional[str] = None + + @property + def ok(self) -> bool: + return self.verdict.ok and self.real is not None + + +def mkdirp_guarded(root_abs: str, target_abs: str, own_role_rel: Optional[str]) -> GuardedDir: + """Create a guarded directory and every missing parent, and hand back the + resolved path it was created at.""" + verdict, resolved = _judge_target(root_abs, target_abs, own_role_rel) + if not verdict.ok or resolved is None: + return GuardedDir(verdict=verdict) + os.makedirs(resolved, exist_ok=True) + return GuardedDir(verdict=verdict, real=resolved) + + +def rm_guarded(root_abs: str, target_abs: str, own_role_rel: Optional[str]) -> TargetVerdict: + """Clear a guarded target: recursive, and absent is success (the clean-rebuild + form every generator uses).""" + + def op(resolved: str) -> None: + try: + entry = os.lstat(resolved) + except FileNotFoundError: + return # absent is success + if stat.S_ISDIR(entry.st_mode): + shutil.rmtree(resolved, ignore_errors=True) + else: + try: + os.remove(resolved) + except FileNotFoundError: + pass + + return guarded_write(root_abs, target_abs, own_role_rel, op) + + +def rename_guarded( + root_abs: str, from_abs: str, to_abs: str, own_role_rel: Optional[str] +) -> TargetVerdict: + """Rename with BOTH ends judged before either is touched, so neither the source + nor the destination can be redirected out of the rule by a planted symlink.""" + from_verdict, from_real = _judge_target(root_abs, from_abs, own_role_rel) + if not from_verdict.ok or from_real is None: + return from_verdict + to_verdict, to_real = _judge_target(root_abs, to_abs, own_role_rel) + if not to_verdict.ok or to_real is None: + return to_verdict + os.replace(from_real, to_real) + return TargetVerdict(ok=True) + + +def chmod_guarded( + root_abs: str, target_abs: str, own_role_rel: Optional[str], mode: int +) -> TargetVerdict: + """Set the mode of a guarded target.""" + return guarded_write( + root_abs, target_abs, own_role_rel, lambda resolved: os.chmod(resolved, mode) + ) + + +@dataclass +class GuardedOpen: + """A guarded destination opened for writing: the descriptor and the resolved path + it is bound to, or the refusal verdict. The caller writes into ``fd`` and closes + it; the bytes then land in the file the rule judged, never in a path reopened + afterwards.""" + + verdict: TargetVerdict + fd: Optional[int] = None + real: Optional[str] = None + + @property + def ok(self) -> bool: + return self.verdict.ok and self.fd is not None + + +def open_write_guarded( + root_abs: str, target_abs: str, own_role_rel: Optional[str], mode: Optional[int] = None +) -> GuardedOpen: + """Open a guarded destination for writing (truncating), creating its parent + directories only when the open actually happens.""" + verdict, resolved = _judge_target(root_abs, target_abs, own_role_rel) + if not verdict.ok or resolved is None: + return GuardedOpen(verdict=verdict) + os.makedirs(os.path.dirname(resolved), exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(resolved, flags, _DEFAULT_CREATE_MODE if mode is None else mode) + return GuardedOpen(verdict=verdict, fd=fd, real=resolved) + + +def write_file_atomic_guarded( + root_abs: str, target_abs: str, own_role_rel: Optional[str], content: str +) -> TargetVerdict: + """Write a guarded target atomically: a temp sibling in the same directory, then a + rename onto the destination, so an interrupted write never leaves a partial file. + Both paths are judged before either is touched — a planted ``.leji-tmp`` + symlink would otherwise be written through before the rename — and the temp is + removed when anything fails, so the whole compound operation lives here rather + than being re-composed at each call site.""" + tmp_verdict, tmp_real = _judge_target(root_abs, target_abs + ".leji-tmp", own_role_rel) + if not tmp_verdict.ok or tmp_real is None: + return tmp_verdict + dest_verdict, dest_real = _judge_target(root_abs, target_abs, own_role_rel) + if not dest_verdict.ok or dest_real is None: + return dest_verdict try: - real_root = Path(os.path.realpath(root)) + os.makedirs(os.path.dirname(dest_real), exist_ok=True) + with open(tmp_real, "w", encoding="utf-8") as f: + f.write(content) + _maybe_inject_write_failure() + os.replace(tmp_real, dest_real) + except OSError: + try: + os.remove(tmp_real) + except OSError: + pass # best-effort cleanup; the caller reports the original failure + raise + return TargetVerdict(ok=True) + + +def _maybe_inject_write_failure() -> None: + """Test-only fault injection for :func:`write_file_atomic_guarded`: with + LEJI_TEST_FAIL_RENAME set, fail after the temp file exists but before the rename, + to exercise the cleanup and the caller's normalized-error path.""" + if os.environ.get("LEJI_TEST_FAIL_RENAME"): + raise OSError("injected write failure") + + +def nothing_stands_at(abs_path: str) -> bool: + """True when NOTHING stands at ``abs_path``, which is what makes a name free. + + The ORIGINAL directory entry decides it, exactly as an exclusive create does: + ``Path.exists()`` follows symlinks, so a dangling link reads as a free name and + the write that follows lands at the link's missing destination. Any standing + entry, a dangling link included, is occupied.""" + try: + os.lstat(abs_path) + except FileNotFoundError: + return True + return False + + +@dataclass +class VerifiedSource: + """An opened source: the descriptor when the source passed every check (the + caller closes it), else None — with the resolved path, when it could be resolved + at all, so a refusal can name where the source actually landed.""" + + fd: Optional[int] = None + real: Optional[str] = None + + +def open_verified_source( + abs_path: str, allow: Callable[[str], bool], base: str = "" +) -> VerifiedSource: + """The guarded-READ counterpart of :func:`guarded_write` (check-before-act), + for every source whose bytes are about to be served, linted, or exported. + Resolve ``abs_path``, judge the RESOLVED path with ``allow``, then open that path + and prove the DESCRIPTOR is a regular file with ``fstat`` — so the file the check + judged is the file the read gets. A path-based check leaves two windows open: an + ancestor directory swapped to a symlink after enumeration (an ``lstat`` of the + final component follows it and reports an ordinary file), and the gap between any + check and a later read or copy by path. Reading from the descriptor closes both: + the inode is pinned by the open. + + The open itself is by path, so one window survives that: a swap landing between + the resolve above and the open makes the open follow the new link, and ``fstat`` + sees only an ordinary regular file. So the source is resolved ONCE MORE after the + open and the descriptor is required to be that same location and that same file + identity (``os.path.samestat``, the portable (st_dev, st_ino) comparison) — the + bytes about to be read are then provably the ones ``allow`` judged. What remains + is the recorded check-before-act limit: an attacker must swap AND revert within the + open→recheck span to pass both resolutions. The reference implementation states + the same limit for the same reason, so this port keeps the + resolve→open→fstat→recheck order rather than reaching for a platform ``openat``: + the observable behavior is the contract, and it must be identical in all three + SDKs. + + The caller closes ``fd`` when it is not None, and owns the refusal semantics — a + silent drop, a boundary warning, or an error — since only it knows which the + source deserves. A source that vanished between the check and the open is one + such refusal; any other I/O error on an allowed path is the filesystem failing + rather than the boundary refusing, so it raises as a read by path always has. + + ``base`` is the canonical-prefix optimization :func:`resolved_path_under` takes: + a resolved repository root the caller already holds.""" + real = resolved_path_under(base, abs_path) + if real is None or not allow(real): + return VerifiedSource(fd=None, real=real) + try: + fd = os.open(real, os.O_RDONLY) + except FileNotFoundError: + return VerifiedSource(fd=None, real=real) # gone between the check and the open + try: + opened = os.fstat(fd) + if not stat.S_ISREG(opened.st_mode): + os.close(fd) + return VerifiedSource(fd=None, real=real) + # The recheck. A refusal names where the source resolves NOW, not where it + # resolved before the swap, so the caller's boundary message points at the + # role the bytes would actually have come from. + recheck = resolved_path_under(base, abs_path) + landed = None if recheck is None else os.stat(recheck) + if recheck != real or landed is None or not os.path.samestat(opened, landed): + os.close(fd) + return VerifiedSource(fd=None, real=recheck if recheck is not None else real) except OSError: + os.close(fd) + return VerifiedSource(fd=None, real=real) + return VerifiedSource(fd=fd, real=real) + + +def read_all(fd: int) -> bytes: + """Every byte of an open descriptor, read from the descriptor itself rather than + reopened by path: the bytes a check judged are the bytes a caller gets.""" + chunks: list[bytes] = [] + while True: + chunk = os.read(fd, 64 * 1024) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + +#: Why a standing entry was refused, byte-identical to the reference SDK's union. +RefusalReason = Literal["outside-root", "other-role", "not-regular", "unverifiable"] + + +@dataclass +class VerifiedTargetRead: + """What stood at a read-then-act target, judged by the same rule the write will + be: nothing (``absent``), a regular file whose verified bytes are carried along + (``regular``), or a standing entry this run refuses to act through (``refused``, + with the reason and where it resolved, when it resolved at all).""" + + status: Literal["absent", "regular", "refused"] + real: Optional[str] = None + data: bytes = b"" + reason: Optional[RefusalReason] = None + + def text(self) -> str: + """The verified bytes as UTF-8 text (``regular`` only).""" + return self.data.decode("utf-8") + + +def verified_target_read( + root_abs: str, target_abs: str, own_role_rel: Optional[str] +) -> VerifiedTargetRead: + """Read a target that is about to be written, under the write rule itself: the + shape every "look at what is there, then act on it" command needs, so none of + them re-composes it. + + The ORIGINAL directory entry decides the kind first — a socket, a FIFO, a device + node or a directory standing at the target is refused rather than opened, and a + symlink is settled on what it resolves TO, because the open would follow it. + Then :func:`open_verified_source` judges the RESOLVED path against + :func:`~leji.layout.writable_target` for this role and proves the descriptor is + that same regular file, so the bytes come back from the inode the rule cleared. + + ``absent`` is decided on the original entry, never on where it resolves: a + dangling symlink resolves to a missing destination while the link itself is still + standing, and a standing entry this run could not verify is + ``refused/unverifiable``, never a write through it. Operational I/O failures on + an allowed path PROPAGATE, as a read by path always has; only containment, entry + kind, and verification become refusals.""" + try: + entry: Optional[os.stat_result] = os.lstat(target_abs) + except FileNotFoundError: + entry = None + if entry is not None and not stat.S_ISREG(entry.st_mode) and not stat.S_ISLNK(entry.st_mode): + return VerifiedTargetRead( + status="refused", real=resolved_path_under(root_abs, target_abs), reason="not-regular" + ) + if entry is not None and stat.S_ISLNK(entry.st_mode): + try: + followed: Optional[os.stat_result] = os.stat(target_abs) + except (FileNotFoundError, NotADirectoryError): + followed = None + if followed is not None and not stat.S_ISREG(followed.st_mode): + return VerifiedTargetRead( + status="refused", + real=resolved_path_under(root_abs, target_abs), + reason="not-regular", + ) + refusal: Optional[RefusalReason] = None + + def allow(resolved: str) -> bool: + nonlocal refusal + verdict = writable_target(root_abs, resolved, own_role_rel) + if verdict.ok: + return True + refusal = "outside-root" if verdict.outside_root else "other-role" return False - return real == real_root or real.is_relative_to(real_root) + + src = open_verified_source(target_abs, allow, root_abs) + if src.fd is not None and src.real is not None: + try: + return VerifiedTargetRead(status="regular", real=src.real, data=read_all(src.fd)) + finally: + os.close(src.fd) + if refusal is not None: + return VerifiedTargetRead(status="refused", real=src.real, reason=refusal) + if src.real is None: + return VerifiedTargetRead(status="refused", real=None, reason="unverifiable") + # Nothing verified was opened, and only ONE thing may follow from that: the + # target is absent. Anything still standing there is a refusal. + if nothing_stands_at(target_abs): + return VerifiedTargetRead(status="absent", real=src.real) + return VerifiedTargetRead(status="refused", real=src.real, reason="unverifiable") def walk_md(root: str, rel_path: str) -> list[str]: @@ -60,7 +629,7 @@ def walk_md(root: str, rel_path: str) -> list[str]: a local preview/index never reaches outside the repo via a symlink.""" abs_path = Path(root) / rel_path if abs_path.is_file(): - if not rel_path.endswith(".md") or not is_contained(root, abs_path): + if not rel_path.endswith(".md") or not resolved_within_root(root, abs_path): return [] return [to_posix(rel_path)] if not abs_path.is_dir(): @@ -72,7 +641,7 @@ def walk_md(root: str, rel_path: str) -> list[str]: for d in dirnames if not d.startswith(".") and d != "node_modules" - and is_contained(root, Path(dirpath) / d) + and resolved_within_root(root, Path(dirpath) / d) ] for name in filenames: if name.startswith(".") or not name.endswith(".md"): @@ -83,7 +652,7 @@ def walk_md(root: str, rel_path: str) -> list[str]: # directly-declared symlinked .md still resolves via the is_file branch above. if full.is_symlink(): continue - if not is_contained(root, full): + if not resolved_within_root(root, full): continue out.append(to_posix(str(full.relative_to(root)))) return sorted(out) diff --git a/packages/sdk-py/src/leji/indexgen.py b/packages/sdk-py/src/leji/indexgen.py index 9c9b301..1957d32 100644 --- a/packages/sdk-py/src/leji/indexgen.py +++ b/packages/sdk-py/src/leji/indexgen.py @@ -12,9 +12,9 @@ from typing import Any, Optional from .findings import Finding -from .fsx import is_contained, resolved_within_root +from .fsx import guard_root, resolved_within_root, verified_target_read, write_file_guarded from .gitutil import git_last_modified, git_toplevel -from .layer import duplicate_id_findings, read_json_artifact, scan_categories +from .layer import duplicate_id_findings, scan_categories from .manifest import Manifest, effective_index_path from .schemas import SDK_VERSION, SUPPORTED_LINES, schema_errors @@ -74,13 +74,20 @@ def _str_array(v: Any) -> Optional[list[str]]: def load_stored_index(root: str, manifest: Manifest) -> Optional[dict]: + """The stored index, or None when there is none this run can act on. Read through + the verified read, not by pathname: generation carries ids out of these bytes into + the index it writes back to this same path, so the file that was judged must be + the file that is read. Absent, unparsable, or a standing entry that cannot be + verified all mean "no stored index" — nothing is carried, and the write chokepoint + judges the destination again on its own.""" rel = effective_index_path(manifest) - abs_path = Path(root) / rel - # Refuse an artifact whose real path escapes the repo root (e.g. a symlinked - # index pointing outside the tree); a layer's index lives inside the layer. - if not abs_path.is_file() or not is_contained(root, abs_path): + read = verified_target_read(guard_root(root), str(Path(root) / rel), None) + if read.status != "regular": + return None + try: + data = json.loads(read.text()) + except (ValueError, UnicodeDecodeError): return None - data, _ = read_json_artifact(root, rel) return data if isinstance(data, dict) else None @@ -264,7 +271,7 @@ def check_index(root: str, manifest: Manifest) -> IndexResult: ) ) return IndexResult(index=None, findings=findings, stale=True) - if not is_contained(root, Path(root) / rel): + if not resolved_within_root(root, Path(root) / rel): findings.append( Finding( "artifact-parse", "error", f"artifact {rel} resolves outside the layer root", rel @@ -387,7 +394,11 @@ def write_index(root: str, manifest: Manifest) -> IndexResult: # Contain before creating any directory: resolved_within_root resolves the # nearest existing ancestor, so a symlinked ancestor of this not-yet-existing # target is caught before mkdir/write can escape the layer root. - if not resolved_within_root(root, abs_path): + # The write chokepoint judges the RESOLVED destination immediately before the + # write, catching a symlinked ancestor before anything is created under it. + if not write_file_guarded( + guard_root(root), str(abs_path), None, serialize_index(result.index) + ).ok: return IndexResult( index=result.index, findings=[ @@ -400,6 +411,4 @@ def write_index(root: str, manifest: Manifest) -> IndexResult: ), ], ) - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(serialize_index(result.index), encoding="utf-8") return result diff --git a/packages/sdk-py/src/leji/init_cmd.py b/packages/sdk-py/src/leji/init_cmd.py index 51ac46c..f33d709 100644 --- a/packages/sdk-py/src/leji/init_cmd.py +++ b/packages/sdk-py/src/leji/init_cmd.py @@ -6,7 +6,9 @@ import json import os import re +import signal import subprocess +import threading import sys from dataclasses import dataclass, field from pathlib import Path, PurePosixPath @@ -20,10 +22,39 @@ detect_hosts, resolve_host_id, ) -from .fsx import join_under_root, resolved_within_root, strip_slash, to_posix +from .fsx import ( + chmod_guarded, + guard_root, + join_under_root, + nothing_stands_at, + resolved_within_root, + strip_slash, + to_posix, + verified_target_read, + write_file_atomic_guarded, + write_file_guarded, +) +from .cigen import ( + GITLAB_MARKER_END, + GITLAB_MARKER_START, + HOOK_MARKER, + HUSKY_MARKER_END, + HUSKY_MARKER_START, + build_azure_pipeline, + build_ci_file, + build_circleci_snippet, + build_github_workflow, + build_gitlab_block, + hook_body, + husky_block, + is_leji_generated, + resolve_ci_job, +) +from .ecosystem import EcosystemReport, detect_ecosystem, runner_argv 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 .manifest import ( Manifest, bind_agent_in_manifest_text, @@ -151,17 +182,24 @@ def default_layout(root_path: str) -> ScaffoldLayout: def _resolve_scaffold_path( root: str, root_path: str, name: str, alternates: list[str], is_dir: bool ) -> str: - """Pick the first candidate name (under root_path) that does not already exist - on disk, so ``adopt`` never writes its scaffold over a repo's existing content.""" + """Pick the first candidate name (under root_path) that is free, so ``adopt`` + never writes its scaffold over a repo's existing content. Occupancy is decided on + the standing entry rather than by ``Path.exists()``, so a dangling candidate link + is occupied and the next name is tried, exactly as an existing file has always + been.""" + + def free(rel: str) -> bool: + return nothing_stands_at(str(Path(root) / strip_slash(rel))) + suffix = "/" if is_dir else "" for candidate in [name, *alternates]: rel = join_under_root(root_path, candidate + suffix) - if not (Path(root) / strip_slash(rel)).exists(): + if free(rel): return rel n = 2 while True: rel = join_under_root(root_path, f"{name}-{n}{suffix}") - if not (Path(root) / strip_slash(rel)).exists(): + if free(rel): return rel n += 1 @@ -341,62 +379,114 @@ 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 _write_manifest_exclusive(abs_path: Path, content: str, mode: str) -> None: - """Create leji.json with O_EXCL ("x") so the existence check and write are atomic: - a concurrent run, or a symlink planted between check and write, cannot be overwritten - or followed. FileExistsError surfaces as the same "already exists" error as the guard.""" - try: - with open(abs_path, "x", encoding="utf-8") as f: - f.write(content) - except FileExistsError as e: - if mode == "adopt": - raise RuntimeError( - "leji.json already exists here; this repository already has a Leji layer" - ) from e +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 + commands write is user content with no ``.leji/`` role at all.""" + return WORK_REL if rel == WORK_REL or rel.startswith(f"{WORK_REL}/") else None + + +def _guarded_or_refuse(rel: str, verdict: TargetVerdict) -> None: + """Every init/adopt write goes through the chokepoint, and a refused verdict is the + one error this command has always raised for an escaping target: the layer is + scaffolded inside the repository it was pointed at, or not at all.""" + if not verdict.ok: + raise InitPathError(f'refusing to write through a symlink that escapes the target: "{rel}"') + + +def _verified_vendor_files(root: Path) -> dict[str, str]: + """The present vendor entrypoints and their VERIFIED bytes, read once. The same + bytes decide whether an entrypoint is converted, are archived under + ``governance/``, and are compared for the draft report, so no act rests on a second + read by pathname of a file this command then rewrites. An entry that cannot be + verified as a regular file inside the repository is treated as absent, exactly as + an escaping symlink already was.""" + root_real = guard_root(str(root)) + present: dict[str, str] = {} + for rel in KNOWN_VENDOR_FILES: + read = verified_target_read(root_real, str(root / rel), None) + if read.status == "regular": + present[rel] = read.text() + return present + + +def _read_merge_source(root_real: str, abs_path: Path, rel: str) -> Optional[str]: + """Read a file this command is about to merge and rewrite, through the verified + read rather than by pathname: the bytes that decide the merge come from the + descriptor the rule cleared, so the file that was judged is the file that is read + and then written. None when nothing stands there (the create path); a standing + entry that cannot be verified as a regular file inside the repository is the same + refusal a write to it would be.""" + read = verified_target_read(root_real, str(abs_path), _init_role(rel)) + if read.status == "refused": + raise InitPathError(f'refusing to write through a symlink that escapes the target: "{rel}"') + return read.text() if read.status == "regular" else None + + +def _write_manifest_exclusive(root_abs: Path, abs_path: Path, content: str, mode: str) -> None: + """Create leji.json with O_EXCL so the existence check and write are atomic: a + concurrent run, or a symlink planted between check and write, cannot be overwritten + or followed. The ``exists`` verdict surfaces as the same "already exists" error the + entry point's initial guard raises.""" + verdict = write_file_guarded( + guard_root(str(root_abs)), str(abs_path), None, content, exclusive=True + ) + if verdict.exists: raise RuntimeError( - "leji.json already exists here; init refuses to overwrite an existing layer" - ) from e + "leji.json already exists here; this repository already has a Leji layer" + if mode == "adopt" + else "leji.json already exists here; init refuses to overwrite an existing layer" + ) + _guarded_or_refuse("leji.json", verdict) def _ensure_leji_gitignored(root_abs: Path) -> None: - """Ensure the repository-root .gitignore ignores `.leji/` (generated viewer and - transient onboarding brief; neither belongs in version control). Idempotent: creates - the file if absent, appends the line only when not already present. Matches the line - exactly, so a comment or `docs/.leji/` is not treated as equivalent.""" + """Ensure the repository-root .gitignore ignores `.leji/` — the one line that + covers every role of the unified tree (chrome, export output, onboarding + workspace, mounts) and any role added later. Idempotent: creates the file if + absent, appends the line only when not already present. Matches the line exactly, + so a comment or `docs/.leji/` is not treated as equivalent.""" abs_path = root_abs / ".gitignore" - entry = ".leji/" - text = abs_path.read_text(encoding="utf-8") if abs_path.is_file() else "" + entry = f"{LEJI_DIR}/" + root_real = guard_root(str(root_abs)) + text = _read_merge_source(root_real, abs_path, ".gitignore") or "" if entry in text.split("\n"): return - if text == "": - abs_path.write_text(entry + "\n", encoding="utf-8") - else: - abs_path.write_text( - text + ("" if text.endswith("\n") else "\n") + entry + "\n", encoding="utf-8" - ) + nxt = ( + entry + "\n" if text == "" else text + ("" if text.endswith("\n") else "\n") + entry + "\n" + ) + _guarded_or_refuse(".gitignore", write_file_guarded(root_real, str(abs_path), None, nxt)) -def _assert_leji_workspace_private(root: str, root_path: str) -> None: - """Refuse to write the transient onboarding workspace while any file under - ``/.leji/`` is tracked by git: tracked means the ignore boundary is - not intact, and private artifacts could land in history. The fix is the - owner's call (git rm --cached), never run silently.""" - leji_dir = join_under_root(root_path, ".leji/") - tracked = tracked_under(root, strip_slash(leji_dir)) +def _assert_leji_workspace_private(root: str) -> None: + """Refuse to write the transient onboarding workspace while any file under the + root ``.leji/`` is tracked by git: tracked means the ignore boundary is not + intact, and private artifacts could land in history. The fix is the owner's call + (git rm --cached), never run silently.""" + tracked = tracked_under(root, LEJI_DIR) if tracked: raise RuntimeError( - f"{len(tracked)} file(s) under {leji_dir} are tracked by git; " + f"{len(tracked)} file(s) under {LEJI_DIR}/ are tracked by git; " "untrack them (git rm --cached) so onboarding artifacts stay private" ) def _write_file_once(root: Path, rel: str, content: str, written: list[str]) -> None: + """Write a file this command owns, once: never over an existing one, and never + through a standing entry it cannot verify. The skip is decided by the verified read + rather than a pathname check, because ``Path.exists()`` follows symlinks — a + dangling link at the target reads as absent and the guarded write then lands at the + link's destination, a name this command never planned. Only ``absent`` is free; a + regular file is the never-overwrite skip; anything else standing there is the + escape refusal, with nothing written.""" abs_path = _safe_target(root, rel, "write path") - _assert_no_symlink_escape(root, abs_path, rel) - if abs_path.exists(): + root_real = guard_root(str(root)) + standing = verified_target_read(root_real, str(abs_path), _init_role(rel)) + if standing.status == "regular": return - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding="utf-8") + if standing.status == "refused": + raise InitPathError(f'refusing to write through a symlink that escapes the target: "{rel}"') + _guarded_or_refuse(rel, write_file_guarded(root_real, str(abs_path), _init_role(rel), content)) written.append(rel) @@ -515,6 +605,9 @@ def _build_boot_profile(answers: InitAnswers) -> str: def _build_core_profile(answers: InitAnswers) -> str: text = _read_template("agents/core.md") text = text.replace("docs/", join_under_root(answers.root_path, "")) + # The escalation line names a person, so the scaffold fills it: a profile that + # shipped `` would be the placeholder the lint exists to catch. + text = text.replace("", answers.owner_name) if "governance" not in answers.categories: text = re.sub( r"^ {2}- .*governance/\n", @@ -545,7 +638,7 @@ def _build_first_decision(answers: InitAnswers) -> str: ## Context -Engineering knowledge lived in heads, chat threads, and per-tool config files. People and agents had no single place to read how this team thinks. +This repository takes a shared, versioned context layer: one record of how it works, kept in the repository and read by people and agents alike. ## Decision @@ -553,7 +646,7 @@ def _build_first_decision(answers: InitAnswers) -> str: ## Consequences -Vendor config files become one-line redirects. Context fixes ride the same review gate as the work that surfaces them. {answers.owner_name} owns the layer. +Context changes ride the same review gate as the work that surfaces them, and {answers.owner_name} owns the layer. Agent entrypoints point at the context layer rather than carrying their own copy: the portable `AGENTS.md` pointer where the scaffold writes one, and vendor entrypoints only where `leji adopt --wire-adapters` converts them with your consent. """ @@ -578,9 +671,10 @@ def _build_changelog(answers: InitAnswers, written: list[str]) -> str: def _build_brief(answers: InitAnswers) -> str: """The transient onboarding brief, rewritten for the chosen root - (join_under_root('.', '') is '', so a "." root yields `.leji/...` and - `context/...`, never `..leji/` or `.context/`) and stamped with the - working mode so the agent runs the right interview without re-asking.""" + (join_under_root('.', '') is '', so a "." root yields `context/...`, never + `.context/`) and stamped with the working mode so the agent runs the right + interview without re-asking. The workspace paths it names are root-relative + already and need no rewriting.""" return ( _read_template("onboarding-brief.md") .replace("/", join_under_root(answers.root_path, "")) @@ -588,10 +682,10 @@ def _build_brief(answers: InitAnswers) -> str: ) -def brief_path(root_path: str) -> str: - """Path of the transient onboarding brief, under a dot-directory so it is - excluded from the index, the viewer, and the changelog.""" - return join_under_root(root_path, ".leji/onboarding-brief.md") +#: Path of the transient onboarding brief: the workspace role of the unified root +#: ``.leji/``, under a dot-directory so it is excluded from the index, the viewer, +#: and the changelog. Root-relative whatever rootPath is. +BRIEF_PATH = f"{WORK_REL}/onboarding-brief.md" #: The CI workflow paths, relative to the repository root. @@ -632,43 +726,11 @@ class HookResult: reason: Optional[str] = None -_HOOK_MARKER = "# leji pre-commit (managed)" -# The failure message is single-quoted for the SHELL: the backticks around -# `leji index` are literal text, and inside a double-quoted echo sh would run them -# as a command substitution (regenerating the index the hook just refused a commit -# over). Never emit an unquoted backtick, "$(", or "$VAR" into generated shell -# unless expansion is the intent. -_HOOK_BODY = ( - "#!/bin/sh\n" - f"{_HOOK_MARKER}\n" - "# Validate the context layer and refuse a commit that would leave the stored\n" - "# index stale. Local mirror of the CI gate, preferring a repo-local install;\n" - "# delete this file to opt out.\n" - 'LEJI="leji"\n' - '[ -x "node_modules/.bin/leji" ] && LEJI="node_modules/.bin/leji"\n' - '"$LEJI" validate || exit 1\n' - '"$LEJI" index --check || {\n' - " echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2\n" - " exit 1\n" - "}\n" -) - -_HUSKY_MARKER_START = "# >>> leji hooks (managed) >>>" -_HUSKY_MARKER_END = "# <<< leji hooks (managed) <<<" -# The same two gates _HOOK_BODY runs (preferring a repo-local install), wrapped in -# markers so the block can be merged into a husky repo's hand-authored -# .husky/pre-commit without touching its rest. -_HUSKY_BLOCK = ( - f"{_HUSKY_MARKER_START}\n" - 'LEJI="leji"\n' - '[ -x "node_modules/.bin/leji" ] && LEJI="node_modules/.bin/leji"\n' - '"$LEJI" validate || exit 1\n' - '"$LEJI" index --check || {\n' - " echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2\n" - " exit 1\n" - "}\n" - f"{_HUSKY_MARKER_END}\n" -) +# The hook body, the husky block and their markers live in the shared generator +# module, so the CI job and the hook transcribe one table. +_HOOK_MARKER = HOOK_MARKER +_HUSKY_MARKER_START = HUSKY_MARKER_START +_HUSKY_MARKER_END = HUSKY_MARKER_END def _hooks_path_config(root: str) -> Optional[str]: @@ -731,7 +793,123 @@ def _husky_shape(root_abs: Path, hooks_path: Optional[str]) -> Optional[str]: return None -def ensure_local_hook(root: str) -> HookResult: +def _git_dirs(root_abs: Path) -> Optional[tuple[Path, Path]]: + """Git's own directories for the repo at ``root_abs``, resolved absolute: this + working tree's git dir and the common dir it shares with every linked worktree. + Both are read-only queries, and together they are what decides whether a hook + target is clone-local (personal) rather than committed (shared). ``None`` when + this is not a git repository.""" + try: + out = subprocess.run( + ["git", "-C", str(root_abs), "rev-parse", "--git-dir", "--git-common-dir"], + capture_output=True, + text=True, + check=True, + ).stdout + except (subprocess.CalledProcessError, OSError): + return None + lines = [line.strip() for line in out.split("\n") if line.strip()] + if len(lines) < 2: + return None + + def absolute(value: str) -> Path: + p = Path(value) + if not p.is_absolute(): + p = root_abs / p + return Path(os.path.normpath(str(p))) + + return absolute(lines[0]), absolute(lines[1]) + + +@dataclass +class HookReport: + """The read-only answer `leji start` reports and :func:`ensure_local_hook` would + act on. + + ``ownership`` says who owns the pre-commit hook this repository would get, decided + by where the write would actually land rather than by the mechanism that would + perform it: "personal" under git's own directories AND inside this working tree + (``.git/hooks``, a ``core.hooksPath`` resolving inside them) — per clone, never + committed, and safe to write; "shared" inside the working tree but not under git's + directories (husky, a ``githooks/`` hooks path) — committed, so a maintainer's call; + "outside-root" under git's directories but OUTSIDE this working tree (a linked + worktree, whose hooks live in the common git directory) — per clone, but the writer + refuses to write outside the repository root, so it is reported; "external" anywhere + else (a global or ``$HOME`` hooks path, a symlink escaping the repository) — + reported, never written; "no-git" when there is no repository to hang a hook on. ``state`` is what stands at that + target: leji's own managed hook or block ("current"), nothing ("absent"), or a + hook this tool did not write ("foreign").""" + + ownership: str + state: str + # The target, repository-relative when it lies inside the repository, else the + # absolute path git resolved; empty when there is no repository. + path: str + managed: str # "file" | "block" + # What a person adds by hand where leji must not write. + snippet: str + + +def _hook_text(abs_path: Path) -> Optional[str]: + """The hook file's text, or None when nothing readable stands there. Read-only: + this answers a question, and every write still goes through + :func:`ensure_local_hook`.""" + try: + if not abs_path.is_file(): + return None + return abs_path.read_text(encoding="utf-8") + except OSError: + return None + + +def hook_status(root: str, runner: Optional[list[str]] = None) -> HookReport: + """:func:`ensure_local_hook`'s resolve step, without the write: where the managed + pre-commit hook would go for this repository, who owns that location, and what + stands there now. The whole point is that a report can be produced without touching + anything — `leji start` prints it, and only a consented repair goes on to + :func:`ensure_local_hook`.""" + root_abs = Path(root).resolve() + argv = runner if runner is not None else runner_argv(detect_ecosystem(str(root_abs))) + hooks_dir = _git_hooks_dir(root_abs) + dirs = _git_dirs(root_abs) + if hooks_dir is None or dirs is None: + return HookReport( + ownership="no-git", state="absent", path="", managed="file", snippet=hook_body(argv) + ) + git_dir, common_dir = dirs + shape = _husky_shape(root_abs, _hooks_path_config(str(root_abs))) + target = hooks_dir.parent / "pre-commit" if shape == "underscore" else hooks_dir / "pre-commit" + managed = "block" if shape else "file" + snippet = husky_block(argv) if shape else hook_body(argv) + marker = HUSKY_MARKER_START if shape else HOOK_MARKER + # Git's directories are tested FIRST: an ordinary .git/hooks also lies inside the + # working tree, and it is per-clone state, not something a commit can carry. A + # clone-local target that nonetheless falls outside this working tree (a linked + # worktree's shared hooks directory) is reported rather than offered: the writer + # refuses everything outside the repository root, so offering it would promise a + # write that cannot happen. + in_repo = resolved_within_root(str(root_abs), target) + clone_local = resolved_within_root(str(git_dir), target) or resolved_within_root( + str(common_dir), target + ) + if clone_local: + ownership = "personal" if in_repo else "outside-root" + elif in_repo: + ownership = "shared" + else: + ownership = "external" + existing = _hook_text(target) + if existing is None: + state = "absent" + else: + state = "current" if marker in existing else "foreign" + shown = str(PurePosixPath(target.relative_to(root_abs))) if in_repo else to_posix(str(target)) + return HookReport( + ownership=ownership, state=state, path=shown, managed=managed, snippet=snippet + ) + + +def ensure_local_hook(root: str, runner: Optional[list[str]] = None) -> HookResult: """Write a managed pre-commit hook running the same checks CI runs, so drift is caught before a commit instead of at the pipeline. The write location is git's effective hooks dir (``rev-parse --git-path hooks``); core.hooksPath decides @@ -743,6 +921,10 @@ def ensure_local_hook(root: str) -> HookResult: hooks_dir = _git_hooks_dir(root_abs) if hooks_dir is None: raise RuntimeError("not a git repository (no .git directory); hooks need one") + # The hook runs what a clean install of THIS repository provides: the detected + # manager's runner when the CLI is actually declared, else the plain binary on + # PATH. Injectable so a test pins a runner without planting a manifest. + argv = runner if runner is not None else runner_argv(detect_ecosystem(str(root_abs))) shape = _husky_shape(root_abs, _hooks_path_config(str(root_abs))) # Husky's user-editable hook is .husky/pre-commit: the hooks dir itself for v8 # (.husky), its parent for v9 (.husky/_). Only a direct v8 hook is run by git @@ -756,35 +938,63 @@ def ensure_local_hook(root: str) -> HookResult: return HookResult( path=to_posix(str(target)), action="manual", - snippet=_HUSKY_BLOCK if shape else _HOOK_BODY, + snippet=husky_block(argv) if shape else hook_body(argv), managed="block" if shape else "file", reason="outside-root", ) rel = str(PurePosixPath(target.relative_to(root_abs))) + + # The hook is written through the chokepoint, judged on the resolved path at the + # act; a target that stopped resolving inside the repository between the check + # above and the write comes back as the same hand-add result that check returns. + def manual(managed: str) -> HookResult: + return HookResult( + path=to_posix(str(target)), + action="manual", + snippet=husky_block(argv) if managed == "block" else hook_body(argv), + managed=managed, + reason="outside-root", + ) + + guard_root_abs = guard_root(str(root_abs)) if shape: - return _ensure_husky_block(target, rel, shape == "direct") - return _ensure_hook_file(target, rel) + return _ensure_husky_block(guard_root_abs, target, rel, shape == "direct", manual, argv) + return _ensure_hook_file(guard_root_abs, target, rel, manual, argv) -def _ensure_hook_file(hook_abs: Path, rel: str) -> HookResult: +def _ensure_hook_file( + root_abs: str, + hook_abs: Path, + rel: str, + manual: Callable[[str], HookResult], + runner: list[str], +) -> HookResult: """Write/refresh the standalone managed pre-commit hook at ``hook_abs``. Ours (marker present) is created/updated; an existing unmanaged hook is never touched and its replacement snippet comes back for a manual merge. A standalone hook is run by git itself, so a byte-current but non-executable managed hook is a mode-only - correction reported ``updated``.""" - existing = hook_abs.read_text(encoding="utf-8") if hook_abs.is_file() else None + correction reported ``updated``. + + The hook's own bytes decide whether it is ours to rewrite, so they come from the + verified read: an entry standing at the hook path that cannot be verified as a + regular file inside the repository is reported for a hand-add, never merged.""" + body = hook_body(runner) + hook_read = verified_target_read(root_abs, str(hook_abs), None) + if hook_read.status == "refused": + return manual("file") + existing = hook_read.text() if hook_read.status == "regular" else None if existing is not None and _HOOK_MARKER not in existing: return HookResult( - path=rel, action="manual", snippet=_HOOK_BODY, managed="file", reason="foreign-hook" + path=rel, action="manual", snippet=body, managed="file", reason="foreign-hook" ) - if existing == _HOOK_BODY: + if existing == body: if not _is_executable(hook_abs): - os.chmod(hook_abs, 0o755) + if not chmod_guarded(root_abs, str(hook_abs), None, 0o755).ok: + return manual("file") return HookResult(path=rel, action="updated", managed="file") return HookResult(path=rel, action="unchanged", managed="file") - hook_abs.parent.mkdir(parents=True, exist_ok=True) - hook_abs.write_text(_HOOK_BODY, encoding="utf-8") - os.chmod(hook_abs, 0o755) + if not write_file_guarded(root_abs, str(hook_abs), None, body, mode=0o755).ok: + return manual("file") return HookResult(path=rel, action="created" if existing is None else "updated", managed="file") @@ -796,28 +1006,45 @@ def _is_executable(abs_path: Path) -> bool: return False -def _ensure_husky_block(hook_abs: Path, rel: str, require_exec: bool) -> HookResult: +def _ensure_husky_block( + root_abs: str, + hook_abs: Path, + rel: str, + require_exec: bool, + manual: Callable[[str], HookResult], + runner: list[str], +) -> HookResult: """Merge the managed block into a husky hook file at ``hook_abs``, following the GitLab managed-block rules: replace an existing block in place (unchanged if byte-identical), append it after one blank line to a file without it, or create the file as ``#!/bin/sh`` + block (mode 0755) when absent. The rest of a user-authored husky hook is left untouched. ``require_exec`` (a direct ``.husky`` hook git runs itself) forces mode 0755: a byte-current but non-executable file is - a mode-only correction reported ``updated``.""" - if not hook_abs.is_file(): - hook_abs.parent.mkdir(parents=True, exist_ok=True) - hook_abs.write_text("#!/bin/sh\n" + _HUSKY_BLOCK, encoding="utf-8") - os.chmod(hook_abs, 0o755) + a mode-only correction reported ``updated``. + + The user's own hook is merged, so its bytes come from the verified read: what the + merge judged is what the rewrite is based on.""" + block = husky_block(runner) + hook_read = verified_target_read(root_abs, str(hook_abs), None) + if hook_read.status == "refused": + return manual("block") + existing = hook_read.text() if hook_read.status == "regular" else None + if existing is None: + if not write_file_guarded( + root_abs, str(hook_abs), None, "#!/bin/sh\n" + block, mode=0o755 + ).ok: + return manual("block") return HookResult(path=rel, action="created", managed="block") - existing = hook_abs.read_text(encoding="utf-8") - merged = _merge_managed_block(existing, _HUSKY_BLOCK, _HUSKY_MARKER_START, _HUSKY_MARKER_END) + merged = _merge_managed_block(existing, block, _HUSKY_MARKER_START, _HUSKY_MARKER_END) if merged != existing: - hook_abs.write_text(merged, encoding="utf-8") - if require_exec: - os.chmod(hook_abs, 0o755) + if not write_file_guarded(root_abs, str(hook_abs), None, merged).ok: + return manual("block") + if require_exec and not chmod_guarded(root_abs, str(hook_abs), None, 0o755).ok: + return manual("block") return HookResult(path=rel, action="updated", managed="block") if require_exec and not _is_executable(hook_abs): - os.chmod(hook_abs, 0o755) + if not chmod_guarded(root_abs, str(hook_abs), None, 0o755).ok: + return manual("block") return HookResult(path=rel, action="updated", managed="block") return HookResult(path=rel, action="unchanged", managed="block") @@ -865,134 +1092,6 @@ class CiResult: # layer stays valid, and a breaking major never reaches adopter CI without a bump. -def build_github_workflow(local: bool = False) -> str: - """The GitHub Actions workflow: a standalone file under .github/workflows/.""" - run = ( - " - run: npm ci\n" - " - run: npx --no-install @leji-org/leji validate\n" - " - run: npx --no-install @leji-org/leji index --check\n" - if local - else " - run: npx -y @leji-org/leji@1 validate\n" - " - run: npx -y @leji-org/leji@1 index --check\n" - ) - return ( - "name: leji\n" - "on: [push, pull_request]\n" - "jobs:\n" - " validate:\n" - " runs-on: ubuntu-latest\n" - " steps:\n" - " - uses: actions/checkout@v4\n" - " - uses: actions/setup-node@v4\n" - " with:\n" - " node-version: '22'\n" - f"{run}" - ) - - -def build_gitlab_block(local: bool = False) -> str: - """The GitLab CI marker-delimited job merged into the shared .gitlab-ci.yml.""" - script = ( - " - npm ci\n" - " - npx --no-install @leji-org/leji validate\n" - " - npx --no-install @leji-org/leji index --check\n" - if local - else " - npx -y @leji-org/leji@1 validate\n - npx -y @leji-org/leji@1 index --check\n" - ) - return ( - f"{_GITLAB_MARKER_START}\n" - "leji-validate:\n" - # `.pre` is always available. Without an explicit stage GitLab assigns - # `test`, and a pipeline whose own `stages:` omits it rejects the config. - " stage: .pre\n" - " image: node:22\n" - " script:\n" - f"{script}" - f"{_GITLAB_MARKER_END}\n" - ) - - -def _circleci_steps(local: bool) -> str: - """The CircleCI job steps shared by the config and the hand-add snippet.""" - if local: - return ( - " - checkout\n" - " - run: npm ci\n" - " - run: npx --no-install @leji-org/leji validate\n" - " - run: npx --no-install @leji-org/leji index --check\n" - ) - return ( - " - checkout\n" - " - run: npx -y @leji-org/leji@1 validate\n" - " - run: npx -y @leji-org/leji@1 index --check\n" - ) - - -def build_circleci_config(local: bool = False) -> str: - """The CircleCI config written when .circleci/config.yml is absent.""" - return ( - "version: 2.1\n" - "jobs:\n" - " leji-validate:\n" - " docker:\n" - " - image: node:22\n" - " steps:\n" - f"{_circleci_steps(local)}" - "workflows:\n" - " leji:\n" - " jobs:\n" - " - leji-validate\n" - ) - - -def build_circleci_snippet(local: bool = False) -> str: - """The jobs + workflows fragment to add by hand to an existing CircleCI config.""" - return ( - "jobs:\n" - " leji-validate:\n" - " docker:\n" - " - image: node:22\n" - " steps:\n" - f"{_circleci_steps(local)}" - "workflows:\n" - " leji:\n" - " jobs:\n" - " - leji-validate\n" - ) - - -def build_azure_pipeline(local: bool = False) -> str: - """The Azure Pipelines config: a dedicated .azure-pipelines/leji.yml the user wires to a pipeline.""" - steps = ( - ( - " - script: npm ci\n" - " displayName: install\n" - " - script: npx --no-install @leji-org/leji validate\n" - " displayName: leji validate\n" - " - script: npx --no-install @leji-org/leji index --check\n" - " displayName: leji index --check\n" - ) - if local - else ( - " - script: npx -y @leji-org/leji@1 validate\n" - " displayName: leji validate\n" - " - script: npx -y @leji-org/leji@1 index --check\n" - " displayName: leji index --check\n" - ) - ) - return ( - "trigger:\n" - " - main\n" - "pool:\n" - " vmImage: ubuntu-latest\n" - "steps:\n" - " - task: NodeTool@0\n" - " inputs:\n" - " versionSpec: '22.x'\n" - f"{steps}" - ) - - def _managed_block_span(text: str, start_marker: str, end_marker: str) -> tuple[int, int] | None: """The ``[start, end)`` span of the first managed block in ``text``, or ``None`` if none.""" start = text.find(start_marker) @@ -1036,7 +1135,7 @@ def _merge_managed_block(text: str, block: str, start_marker: str, end_marker: s def _merge_gitlab_block(text: str, block: str) -> str: """Insert/replace the managed block in an existing ``.gitlab-ci.yml``, byte-exactly.""" - return _merge_managed_block(text, block, _GITLAB_MARKER_START, _GITLAB_MARKER_END) + return _merge_managed_block(text, block, GITLAB_MARKER_START, GITLAB_MARKER_END) def _write_failure_message(rel: str, e: OSError) -> str: @@ -1048,93 +1147,89 @@ def _write_failure_message(rel: str, e: OSError) -> str: def _write_file_atomic(root_abs: Path, abs_path: Path, rel: str, contents: str) -> None: - """Write ``contents`` to ``abs_path`` atomically (sibling temp file then rename), so a - failed write never leaves a partial file. On failure the temp file is removed and a - deterministic, OS-text-free InitPathError is raised (byte-identical across SDKs).""" - tmp = abs_path.with_name(abs_path.name + ".leji-tmp") - # The sibling temp path must not escape the root either (a planted - # ``.leji-tmp`` symlink would otherwise be written through before the rename). - _assert_no_symlink_escape(root_abs, tmp, rel) + """Write ``contents`` to ``abs_path`` atomically (sibling temp file then rename, + both ends judged by the write chokepoint), so a failed write never leaves a partial + file. On failure the temp file is removed and a deterministic, OS-text-free + InitPathError is raised (byte-identical across SDKs).""" try: - abs_path.parent.mkdir(parents=True, exist_ok=True) - tmp.write_text(contents, encoding="utf-8") - _maybe_inject_write_failure() - tmp.replace(abs_path) + verdict = write_file_atomic_guarded( + guard_root(str(root_abs)), str(abs_path), _init_role(rel), contents + ) except OSError as e: - tmp.unlink(missing_ok=True) raise InitPathError(_write_failure_message(rel, e)) from e - - -def _maybe_inject_write_failure() -> None: - """Test-only fault injection: when LEJI_TEST_FAIL_RENAME is set, fail after the temp - file exists but before the rename commits, to exercise the cleanup/error path.""" - if os.environ.get("LEJI_TEST_FAIL_RENAME"): - raise OSError("injected write failure") + _guarded_or_refuse(rel, verdict) # Legacy aliases retained for any external callers of the original single-provider API. build_ci_workflow = build_github_workflow -def ensure_ci_workflow(root: str, provider: str) -> CiResult: - """Add a CI workflow that runs ``leji validate`` (the ``leji ci`` command). GitHub - gets its own workflow file; GitLab is create-or-merge into ``.gitlab-ci.yml`` via a - marker-delimited managed block; CircleCI is created if absent, else a manual snippet - is returned. Deterministic text (byte-identical across SDKs). Refuses a symlink that - escapes root.""" +def ensure_ci_workflow( + root: str, provider: str, report: Optional[EcosystemReport] = None +) -> CiResult: + """Add a CI workflow running ``leji validate`` (the ``leji ci`` command), with the + job the repository's own package manager needs. GitHub, CircleCI and Azure own + whole files: created when absent, REPLACED when the file standing there is one leji + generated (this release or an earlier one), and left untouched with a hand-add + snippet when it is foreign or was edited. GitLab owns a marker-delimited block + inside the shared ``.gitlab-ci.yml`` and merges it. Deterministic text + (byte-identical across SDKs). Refuses a symlink that escapes root.""" root_abs = Path(root).resolve() - # Local-first: a repo that declares @leji-org/leji runs its lockfile-pinned - # install; a repo without one falls back to `npx @leji-org/leji@1`. - local = _declares_leji_dep(root_abs) and _has_npm_lockfile(root_abs) + # Local-first: a repository that DECLARES the CLI and carries its manager's lock + # evidence installs its own locked dependencies and runs the local binary; every + # other state takes the fallback that needs no manifest. + detected = report if report is not None else detect_ecosystem(str(root_abs)) + job = resolve_ci_job(detected, provider) + # Every arm decides what stands at its target through the verified read, never + # through a pathname check: ``Path.exists()`` follows symlinks, so a dangling link + # at the workflow path reads as absent and the create lands at the link's + # destination. None is the create path; a verified regular file is judged by its + # bytes; a standing entry that cannot be verified is the same refusal a write to it + # would be. + root_real = guard_root(str(root_abs)) + + def whole_file(rel: str, snippet: str, note: Optional[str] = None) -> CiResult: + """Create, replace what we own, or hand back a snippet.""" + abs_path = root_abs / rel + _assert_no_symlink_escape(root_abs, abs_path, rel) + content = build_ci_file(provider, job) + existing = _read_merge_source(root_real, abs_path, rel) + if existing is None: + _write_file_atomic(root_abs, abs_path, rel, content) + return CiResult(provider=provider, path=rel, action="created", note=note) + if existing == content: + return CiResult(provider=provider, path=rel, action="unchanged") + if not is_leji_generated(provider, existing): + return CiResult(provider=provider, path=rel, action="manual", snippet=snippet) + _write_file_atomic(root_abs, abs_path, rel, content) + return CiResult(provider=provider, path=rel, action="updated") + if provider == "github": - abs_path = root_abs / CI_WORKFLOW_PATH - _assert_no_symlink_escape(root_abs, abs_path, CI_WORKFLOW_PATH) - if abs_path.exists(): - return CiResult(provider=provider, path=CI_WORKFLOW_PATH, action="unchanged") - _write_file_atomic(root_abs, abs_path, CI_WORKFLOW_PATH, build_github_workflow(local)) - return CiResult(provider=provider, path=CI_WORKFLOW_PATH, action="created") + return whole_file(CI_WORKFLOW_PATH, build_github_workflow(job)) if provider == "gitlab": abs_path = root_abs / GITLAB_CI_PATH _assert_no_symlink_escape(root_abs, abs_path, GITLAB_CI_PATH) - block = build_gitlab_block(local) - if not abs_path.exists(): + block = build_gitlab_block(job) + # The merge is a read-then-write of one target, so the bytes come from the + # verified read: the file the rule judged is the file that is read and then + # rewritten. + text = _read_merge_source(root_real, abs_path, GITLAB_CI_PATH) + if text is None: _write_file_atomic(root_abs, abs_path, GITLAB_CI_PATH, block) return CiResult(provider=provider, path=GITLAB_CI_PATH, action="created") - text = abs_path.read_text(encoding="utf-8") merged = _merge_gitlab_block(text, block) if merged == text: return CiResult(provider=provider, path=GITLAB_CI_PATH, action="unchanged") _write_file_atomic(root_abs, abs_path, GITLAB_CI_PATH, merged) return CiResult(provider=provider, path=GITLAB_CI_PATH, action="updated") if provider == "circleci": - abs_path = root_abs / CIRCLECI_CONFIG_PATH - _assert_no_symlink_escape(root_abs, abs_path, CIRCLECI_CONFIG_PATH) - if abs_path.exists(): - return CiResult( - provider=provider, - path=CIRCLECI_CONFIG_PATH, - action="manual", - snippet=build_circleci_snippet(local), - ) - _write_file_atomic(root_abs, abs_path, CIRCLECI_CONFIG_PATH, build_circleci_config(local)) - return CiResult(provider=provider, path=CIRCLECI_CONFIG_PATH, action="created") + return whole_file(CIRCLECI_CONFIG_PATH, build_circleci_snippet(job)) if provider != "azure": # Unreachable from the CLI (it validates first); guards direct helper callers so # an unknown provider errors consistently across the three SDKs. raise InitPathError(f'unknown provider "{provider}"') - abs_path = root_abs / AZURE_PIPELINE_PATH - _assert_no_symlink_escape(root_abs, abs_path, AZURE_PIPELINE_PATH) - # The activation note is intentionally created-only: a re-run on an existing - # pipeline file stays quiet (no note) rather than repeating the setup guidance. - if abs_path.exists(): - return CiResult(provider=provider, path=AZURE_PIPELINE_PATH, action="unchanged") - _write_file_atomic(root_abs, abs_path, AZURE_PIPELINE_PATH, build_azure_pipeline(local)) - return CiResult( - provider=provider, - path=AZURE_PIPELINE_PATH, - action="created", - note=AZURE_ACTIVATION_NOTE, - ) + # The activation note is created-only: a re-run on an existing file stays quiet. + return whole_file(AZURE_PIPELINE_PATH, build_azure_pipeline(job), AZURE_ACTIVATION_NOTE) def _reject_non_finite(_: str) -> object: @@ -1144,38 +1239,6 @@ def _reject_non_finite(_: str) -> object: raise ValueError("non-finite JSON constant") -def _has_npm_lockfile(root_abs: Path) -> bool: - """The generated local-install job runs ``npm ci``, which requires an npm - lockfile. A pnpm, Yarn or Bun repository can declare the dependency and still have - none, and the job would fail before Leji ran.""" - return (root_abs / "package-lock.json").exists() - - -def _declares_leji_dep(root_abs: Path) -> bool: - """Whether the repo's root package.json declares ``@leji-org/leji`` under - ``dependencies`` or ``devDependencies``. Deterministic and identical across SDKs: - read bytes, strip a single leading UTF-8 BOM, strict JSON parse rejecting non-finite - constants (any error -> not declared), and count dependencies/devDependencies only - when they are JSON objects holding the exact key (any other type -> absent).""" - try: - data = (root_abs / "package.json").read_bytes() - except OSError: - return False - if data.startswith(b"\xef\xbb\xbf"): - data = data[3:] - try: - pkg = json.loads(data, parse_constant=_reject_non_finite) - except ValueError: - return False - if not isinstance(pkg, dict): - return False - for key in ("dependencies", "devDependencies"): - deps = pkg.get(key) - if isinstance(deps, dict) and _DEP_NAME in deps: - return True - return False - - # A name (also the agent-profile `id` and the agents-map key) and a role must be # kebab identifiers: matches the agent-profile schema's id pattern, is safe as a # path segment, and is safe to interpolate into YAML frontmatter and JSON. @@ -1250,11 +1313,22 @@ def build_agent_profile(name: str, role: str, host_id: Optional[str], root_path: ) +# Guidance for the `default` binding: selecting a role profile there is not the same +# as loading it, a distinction the key's name invites readers to miss. Written-only, +# like the CI activation note: a re-run that binds nothing stays terse. +AGENTS_DEFAULT_NOTE = ( + "agents.default selects a role profile; it does not load it. If its instructions " + "must apply before every task, fold them into the boot profile; otherwise keep the " + "profile role-scoped and engage it through the relevant protocol." +) + + @dataclass class AgentResult: """What :func:`add_agent` did, for the command to report. Each artifact is independently idempotent: a ``*_created``/``manifest_changed`` of False means - it was already there.""" + it was already there. ``note`` is advisory text the caller surfaces verbatim + (set when the ``default`` binding is written).""" name: str role: str @@ -1262,6 +1336,7 @@ class AgentResult: profile_path: str profile_created: bool manifest_changed: bool + note: Optional[str] = None def add_agent( @@ -1296,21 +1371,45 @@ def add_agent( base = effective_agent_profiles_path(manifest) profile_rel = (base if base.endswith("/") else f"{base}/") + f"{name}.md" profile_abs = root_abs / profile_rel - profile_created = False - if not profile_abs.is_file(): - _assert_no_symlink_escape(root_abs, profile_abs, profile_rel) - profile_abs.parent.mkdir(parents=True, exist_ok=True) - profile_abs.write_text( - build_agent_profile(name, role, host_id, manifest["rootPath"]), encoding="utf-8" - ) - profile_created = True - + root_real = guard_root(str(root_abs)) + + # Both halves of this command are judged BEFORE either is written: binding an agent + # means a profile file and a manifest edit, and a run that can only do one of them + # must do neither. The manifest is read through the verified read (its bytes are + # spliced and written straight back), so a target that cannot be verified as a + # regular file inside the repository refuses the whole command with nothing written. + # ``absent`` refuses too: this command edits a manifest, it never creates one. manifest_abs = root_abs / "leji.json" - original = manifest_abs.read_text(encoding="utf-8") + manifest_read = verified_target_read(root_real, str(manifest_abs), None) + if manifest_read.status != "regular": + raise InitPathError( + 'refusing to write through a symlink that escapes the target: "leji.json"' + ) + original = manifest_read.text() text, _ = bind_agent_in_manifest_text(original, name, profile_rel) manifest_changed = text != original + + # The profile half is judged next, still before either write: a pathname check + # follows symlinks, so a dangling link at the profile name reads as absent and the + # write lands at the link's destination. Only ``absent`` is written; a verified + # regular file is the never-overwrite skip this command has always made; anything + # else standing there refuses the whole command with nothing written. + profile_read = verified_target_read(root_real, str(profile_abs), None) + if profile_read.status == "refused": + raise InitPathError( + f'refusing to write through a symlink that escapes the target: "{profile_rel}"' + ) + profile_created = profile_read.status == "absent" + + if profile_created: + profile = build_agent_profile(name, role, host_id, manifest["rootPath"]) + _guarded_or_refuse( + profile_rel, write_file_guarded(root_real, str(profile_abs), None, profile) + ) if manifest_changed: - manifest_abs.write_text(text, encoding="utf-8") + _guarded_or_refuse( + "leji.json", write_file_guarded(root_real, str(manifest_abs), None, text) + ) return AgentResult( name=name, @@ -1319,6 +1418,7 @@ def add_agent( profile_path=profile_rel, profile_created=profile_created, manifest_changed=manifest_changed, + note=AGENTS_DEFAULT_NOTE if name == "default" and manifest_changed else None, ) @@ -1441,7 +1541,7 @@ def init_layer( PlannedWrite(f"{layout.context_dir}{category}.md", _category_index_file(r, category)) ) writes.append(PlannedWrite(f"{layout.agents_dir}core.md", _build_core_profile(answers))) - writes.append(PlannedWrite(brief_path(r), _build_brief(answers))) + writes.append(PlannedWrite(BRIEF_PATH, _build_brief(answers))) if answers.level == "indexed": # The changelog records the paths seeded; compute from the planned set # (everything except the changelog and the generated index). Dot-paths @@ -1482,13 +1582,12 @@ def init_layer( # The tracked-file preflight and the `.leji/` ignore run BEFORE any write at # all, so the private onboarding workspace can never land in git and a failed # preflight leaves the tree untouched. - _assert_leji_workspace_private(str(root), r) + _assert_leji_workspace_private(str(root)) _ensure_leji_gitignored(root) # leji.json is created exclusively ("x" / O_EXCL): it closes the check-then-write # race and refuses to follow a symlink at the final component, so a concurrent # init or a planted symlink cannot be overwritten or escaped. - _assert_no_symlink_escape(root, root / "leji.json", "leji.json") - _write_manifest_exclusive(root / "leji.json", writes[0].content, "init") + _write_manifest_exclusive(root, root / "leji.json", writes[0].content, "init") written.append("leji.json") # The changelog is held back until the index generates cleanly. Seeding it off a # tree that cannot be indexed would leave a layer claiming `indexed` with a @@ -1571,14 +1670,6 @@ def _is_dir(p: Path) -> bool: return pick_docs_root([e.name for e in entries if _is_dir(e)]) -def _read_text(path: Path) -> str: - """Vendor-file contents, or empty string when the file cannot be read.""" - try: - return path.read_text(encoding="utf-8") - except OSError: - return "" - - @dataclass class AdoptResult(InitResult): detected_root: str = "" @@ -1685,20 +1776,15 @@ def adopt_layer( boot_rel = f"{detected_root}boot-profile.md" canonical_redirect = adapter_content(boot_rel).strip() - # A vendor file that is a symlink resolving outside root is neither read, - # migrated, nor converted: it is treated as absent. - vendor_present = [ - rel - for rel in KNOWN_VENDOR_FILES - if (root / rel).is_file() and resolved_within_root(str(root), root / rel) - ] + # A vendor file that cannot be verified as a regular file inside the repository is + # neither read, migrated, nor converted: it is treated as absent. + vendor = _verified_vendor_files(root) + vendor_present = list(vendor.keys()) # Migrate any vendor file not already exactly Leji's redirect, so its content is # archived before --wire-adapters overwrites it. An already-canonical or empty file # has nothing to preserve. to_migrate = [ - rel - for rel in vendor_present - if _read_text(root / rel).strip() not in ("", canonical_redirect) + rel for rel in vendor_present if vendor[rel].strip() not in ("", canonical_redirect) ] base = re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", root.name.lower())) @@ -1731,7 +1817,7 @@ def adopt_layer( # Convert only EXISTING vendor entrypoints (never create new) that aren't already the # canonical redirect; each was captured in to_migrate above, so no content is lost. to_convert = ( - [rel for rel in vendor_present if _read_text(root / rel).strip() != canonical_redirect] + [rel for rel in vendor_present if vendor[rel].strip() != canonical_redirect] if wire_adapters else [] ) @@ -1779,7 +1865,7 @@ def adopt_layer( PlannedWrite(f"{layout.context_dir}{category}.md", _category_index_file(r, category)) ) writes.append(PlannedWrite(f"{layout.agents_dir}core.md", _build_core_profile(answers))) - writes.append(PlannedWrite(brief_path(r), _build_brief(answers))) + writes.append(PlannedWrite(BRIEF_PATH, _build_brief(answers))) migrated: list[str] = [] migration_doc_by_vendor: dict[str, str] = {} @@ -1796,16 +1882,20 @@ def adopt_layer( ) # Disambiguate against both the planned write set and disk, so the migrated copy # is never skipped by _write_file_once (a skip then --wire-adapters overwrite would - # lose the original). + # lose the original). The on-disk half is decided on the standing entry, never by + # ``Path.exists()``, which follows symlinks: a dangling candidate would read as a + # free name and the archive would be written at the link's missing destination. + # Any standing entry is occupied and the next name is tried (the rule + # ``_archive_path`` mirrors). slug = base_slug doc_rel = f"{join_under_root(r, 'governance/')}imported-{slug}.md" n = 2 - while doc_rel in planned_rels or (root / strip_slash(doc_rel)).exists(): + while doc_rel in planned_rels or not nothing_stands_at(str(root / strip_slash(doc_rel))): slug = f"{base_slug}-{n}" doc_rel = f"{join_under_root(r, 'governance/')}imported-{slug}.md" n += 1 planned_rels.add(doc_rel) - writes.append(PlannedWrite(doc_rel, _migration_doc(rel, _read_text(root / rel)))) + writes.append(PlannedWrite(doc_rel, _migration_doc(rel, vendor[rel]))) migration_doc_by_vendor[rel] = doc_rel migrated.append(rel) if migrated: @@ -1829,7 +1919,7 @@ def adopt_layer( ), index_rel, ) - draft = any(boot_rel not in _read_text(root / rel) for rel in wont_modify) + draft = any(boot_rel not in vendor[rel] for rel in wont_modify) if dry_run: return AdoptResult( @@ -1851,10 +1941,9 @@ def adopt_layer( # The tracked-file preflight and the `.leji/` ignore run BEFORE any write at # all, so the private onboarding workspace can never land in git and a failed # preflight leaves the tree untouched. - _assert_leji_workspace_private(str(root), r) + _assert_leji_workspace_private(str(root)) _ensure_leji_gitignored(root) - _assert_no_symlink_escape(root, root / "leji.json", "leji.json") - _write_manifest_exclusive(root / "leji.json", writes[0].content, "adopt") + _write_manifest_exclusive(root, root / "leji.json", writes[0].content, "adopt") written.append("leji.json") convert = set(to_convert) for w in writes[1:]: @@ -1866,8 +1955,12 @@ def adopt_layer( if vendor_doc_rel is not None and vendor_doc_rel not in written: continue abs_path = _safe_target(root, w.rel, "write path") - _assert_no_symlink_escape(root, abs_path, w.rel) - abs_path.write_text(w.content, encoding="utf-8") + _guarded_or_refuse( + w.rel, + write_file_guarded( + guard_root(str(root)), str(abs_path), _init_role(w.rel), w.content + ), + ) written.append(w.rel) else: _write_file_once(root, w.rel, w.content, written) @@ -1908,14 +2001,22 @@ def _archive_path(root: Path, root_path: str, vendor_rel: str, doc: str) -> Opti re.sub(r"\.md$", "", Path(vendor_rel).name, flags=re.IGNORECASE).lower(), ), ) + root_real = guard_root(str(root)) n = 1 while True: slug = base_slug if n == 1 else f"{base_slug}-{n}" rel = f"{join_under_root(root_path, 'governance/')}imported-{slug}.md" abs_path = root / strip_slash(rel) - if not abs_path.exists(): + # The candidate is judged on the standing entry and, when one stands, on its + # verified bytes: a pathname existence check follows symlinks, so a dangling + # candidate link would read as free and the write would follow it to its missing + # destination. Nothing standing is free; the identical archive is already on + # disk; anything else — different bytes, or a standing entry this run cannot + # verify — is occupied, and the next name is tried. + if nothing_stands_at(str(abs_path)): return rel - if abs_path.is_file() and _read_text(abs_path) == doc: + standing = verified_target_read(root_real, str(abs_path), None) + if standing.status == "regular" and standing.text() == doc: return None n += 1 @@ -1940,22 +2041,17 @@ def _wire_adapters_into_layer(root: Path, dry_run: bool) -> AdoptResult: r = manifest["rootPath"] boot_rel = manifest["bootProfilePath"] redirect = adapter_content(boot_rel) - # A vendor file that symlinks outside root is treated as absent, as in `adopt`. - vendor_present = [ - rel - for rel in KNOWN_VENDOR_FILES - if (root / rel).is_file() and resolved_within_root(str(root), root / rel) - ] - to_convert = [ - rel for rel in vendor_present if _read_text(root / rel).strip() != redirect.strip() - ] + # A vendor entrypoint that cannot be verified is treated as absent, as in `adopt`. + vendor = _verified_vendor_files(root) + vendor_present = list(vendor.keys()) + to_convert = [rel for rel in vendor_present if vendor[rel].strip() != redirect.strip()] # Archives first, so a vendor entrypoint is never overwritten before its content # is on disk; an empty file has nothing to preserve. writes: list[PlannedWrite] = [] archived: list[str] = [] for rel in to_convert: - content = _read_text(root / rel) + content = vendor[rel] if not content.strip(): continue doc = _migration_doc(rel, content) @@ -1988,11 +2084,12 @@ def _wire_adapters_into_layer(root: Path, dry_run: bool) -> AdoptResult: ) written: list[str] = [] + root_real = guard_root(str(root)) for w in writes: abs_path = _safe_target(root, w.rel, "write path") - _assert_no_symlink_escape(root, abs_path, w.rel) - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(w.content, encoding="utf-8") + _guarded_or_refuse( + w.rel, write_file_guarded(root_real, str(abs_path), _init_role(w.rel), w.content) + ) written.append(w.rel) # Only an archive lands inside the layer, so only an archive can stale the stored # index; a plain wiring run leaves the generated index (and its timestamp) alone. @@ -2085,6 +2182,24 @@ class LaunchResult: started: bool error: Optional[str] = None + # The child's captured standard output, set only for a captured run + # (``RunOptions.capture``); empty otherwise. + stdout: str = "" + + +@dataclass(frozen=True) +class RunOptions: + """Bounds for one child run. ``quiet`` suppresses child output (the MCP presence + check); ``capture`` reads stdout back instead — bounded by ``timeout_ms`` and + ``max_bytes``, with stdin closed and stderr discarded — which is what the preflight + version probe needs; ``env`` adds the read-only, offline variables a probe runs + under.""" + + quiet: bool = False + capture: bool = False + timeout_ms: int = 0 + max_bytes: int = 0 + env: Optional[dict[str, str]] = None @dataclass @@ -2098,12 +2213,11 @@ class HandoffIO: # `); a None cwd uses the current directory. Host flags (from # `leji start -- `) go before the prompt argument. launch: Callable[[str, str, Optional[str], Optional[list[str]]], LaunchResult] - # run(bin, args, cwd, quiet): run a host subcommand (the MCP presence check / - # register) from cwd. When quiet, child output is suppressed (the check); - # otherwise it inherits the terminal so the user sees the host's own output. - # Defaulted so the handoff-only flow (and its test fakes) need not supply it; - # production wiring sets it in _default_handoff_io. - run: Optional[Callable[[str, list[str], Optional[str], bool], LaunchResult]] = None + # run(bin, args, cwd, opts): run a host subcommand (the MCP presence check / + # register) or a bounded probe from cwd, per RunOptions. Defaulted so the + # handoff-only flow (and its test fakes) need not supply it; production wiring + # sets it in _default_handoff_io. + run: Optional[Callable[[str, list[str], Optional[str], RunOptions], LaunchResult]] = None @dataclass @@ -2113,6 +2227,147 @@ class _PromptHost: name: str +@dataclass(frozen=True) +class StartHost: + """The host `leji start` targets, resolved before the preflight runs so the report + can name it before the launch takes the terminal.""" + + id: str + bin: str + name: str + + +def _exported(host: Optional[_PromptHost]) -> Optional[StartHost]: + return None if host is None else StartHost(id=host.id, bin=host.bin, name=host.name) + + +def _internal(host: Optional[StartHost]) -> Optional[_PromptHost]: + return None if host is None else _PromptHost(id=host.id, bin=host.bin, name=host.name) + + +# How much the probe reads at a time when the cap still allows it. +_PROBE_READ_CHUNK = 4096 + + +def _end_probe(proc: "subprocess.Popen[bytes]") -> None: + """Stop the probe and everything it started, then reap it. The whole session goes, + not just the direct child: the cap and the timeout are only real if nothing the probe + spawned survives them holding the pipe.""" + if proc.poll() is not None: + return + try: + if os.name != "nt": + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + else: + proc.kill() + except (OSError, ProcessLookupError): + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: # pragma: no cover - the kill above is unconditional + pass + + +def _capture_run( + bin_name: str, args: list[str], cwd: Optional[str], opts: RunOptions +) -> LaunchResult: + """The bounded probe: stdin closed so nothing can prompt, stderr discarded, output + and wall time bounded. Both bounds are enforced WHILE the child runs, not after it + finishes: the reader stops at the cap and the child is killed on the spot, so a + program that streams forever can neither fill this process's memory nor sit on the + pipe until the deadline. Exceeding either bound comes back as a failed run, which + every caller treats as a failed probe.""" + # ``env`` REPLACES the environment; nothing of this process's is inherited. + env = dict(opts.env) if opts.env is not None else None + try: + proc = subprocess.Popen( # noqa: S603 (no shell) + [bin_name, *args], + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env, + # Its own session, so ending the probe ends everything it started: a shim + # that backgrounds a child would otherwise hold the pipe open (and keep + # running) after the run is cut off. + start_new_session=os.name != "nt", + ) + except OSError as e: + return LaunchResult(started=False, error=str(e)) + + captured = bytearray() + capped = False + + def reader() -> None: + """Read UNBUFFERED, never more than one byte past what the cap still allows. + + A buffered read waits for a full chunk or for EOF, so a child that prints one + byte past the cap and then holds stdout open would not be seen as overflowing + until the deadline. Reading the remaining allowance plus one byte means the + first byte past the cap arrives on its own, the cap is decided the moment it is + passed, and the main loop ends the child immediately.""" + nonlocal capped + stream = proc.stdout + if stream is None: + return + fd = stream.fileno() + while True: + allowance = _PROBE_READ_CHUNK + if opts.max_bytes: + allowance = min(allowance, opts.max_bytes - len(captured) + 1) + try: + chunk = os.read(fd, allowance) + except (OSError, ValueError): + return + if not chunk: + return + # Exactly ``max_bytes`` is not overflow: the cap is the most that may be + # held, and the whole read that would pass it is refused, as in the other + # two SDKs. + if opts.max_bytes and len(captured) + len(chunk) > opts.max_bytes: + capped = True + return + captured.extend(chunk) + + pump = threading.Thread(target=reader, daemon=True) + pump.start() + timed_out = False + deadline = (opts.timeout_ms / 1000) if opts.timeout_ms else None + try: + # The reader owns the pipe; this only waits for the process itself, so the cap + # can end the run before the deadline does. + while True: + try: + # Always polled, deadline or not: the cap has to be able to end the run + # on its own, as it does in the other two SDKs. + proc.wait(timeout=0.05) + break + except subprocess.TimeoutExpired: + if capped: + break + if deadline is not None: + deadline -= 0.05 + if deadline <= 0: + timed_out = True + break + finally: + _end_probe(proc) + pump.join(timeout=1) + if proc.stdout is not None and not pump.is_alive(): + proc.stdout.close() + + if capped: + return LaunchResult(started=True, error="probe output exceeded the cap") + if timed_out: + return LaunchResult(started=True, error="timed out") + code = proc.returncode + return LaunchResult( + started=True, + error=None if code == 0 else f"exit {code}", + stdout=captured.decode("utf-8", errors="replace"), + ) + + def _default_handoff_io() -> HandoffIO: """Real handoff I/O: a stdin prompt and a stdio-inherit subprocess.""" @@ -2142,9 +2397,14 @@ def launch( ) def run( - bin_name: str, args: list[str], cwd: Optional[str] = None, quiet: bool = False + bin_name: str, + args: list[str], + cwd: Optional[str] = None, + opts: RunOptions = RunOptions(), ) -> LaunchResult: - stdio = subprocess.DEVNULL if quiet else None + if opts.capture: + return _capture_run(bin_name, args, cwd, opts) + stdio = subprocess.DEVNULL if opts.quiet else None try: proc = subprocess.run( # noqa: S603 (no shell) [bin_name, *args], cwd=cwd, stdin=stdio, stdout=stdio, stderr=stdio @@ -2276,7 +2536,7 @@ def handoff_offer( return False io = io or _default_handoff_io() mcp = mcp or McpOfferOutcome() - prompt_arg = f"Read ./{brief_path(manifest['rootPath'])} and follow it." + prompt_arg = f"Read ./{BRIEF_PATH} and follow it." if agent: chosen: Optional[_PromptHost] = _assert_agent_host(agent) elif mcp.next == "skip": @@ -2355,7 +2615,7 @@ def offer_mcp_install(opts: McpOfferOptions) -> McpOfferOutcome: # re-nags — but say so: a silent skip is indistinguishable from the offer being # broken. A failed check (e.g. an older host CLI) falls through to the offer. if spec.mcp_check: - chk = io.run(target.bin, spec.mcp_check, opts.root, True) + chk = io.run(target.bin, spec.mcp_check, opts.root, RunOptions(quiet=True)) if chk.started and chk.error is None: print( f"Leji MCP server already registered for {target.name}; skipping the install offer." @@ -2373,7 +2633,7 @@ def offer_mcp_install(opts: McpOfferOptions) -> McpOfferOutcome: ).lower() if answer not in ("", "y", "yes"): return outcome - res = io.run(target.bin, spec.mcp_add, opts.root, False) + res = io.run(target.bin, spec.mcp_add, opts.root, RunOptions()) argv = f"{target.bin} {' '.join(spec.mcp_add)}" if not res.started: print( @@ -2394,7 +2654,7 @@ def offer_mcp_install(opts: McpOfferOptions) -> McpOfferOutcome: def entering_the_layer(manifest: Manifest, mode: str = "team") -> str: """Post-init guidance, printed by the CLI. The team copy is unchanged from pre-mode releases; solo swaps one sentence to name the interview.""" - brief = brief_path(manifest["rootPath"]) + brief = BRIEF_PATH how = ( [ "The brief teaches the agent the Leji spec and points it at this repo: it reads your", @@ -2429,7 +2689,7 @@ def entering_the_layer(manifest: Manifest, mode: str = "team") -> str: # The onboarding approval guard: a transient Claude Code PreToolUse hook that # counters the ask-prompt pattern. AskUserQuestion stays blocked until the -# proposal is written to /.leji/proposal.md AND printed as message +# proposal is written to .leji/work/proposal.md AND printed as message # text; the corrective message lands at the action boundary, where instruction # reliably reaches the model. Self-disabling once the onboarding brief is gone; # the finalize step removes it entirely. @@ -2500,11 +2760,14 @@ def _approval_guard_script(leji_rel: str) -> str: def ensure_approval_guard(root: str, root_path: str) -> GuardAction: - """Write the guard script under /.leji/hooks/ and merge its PreToolUse - entry into .claude/settings.json (created if absent, other settings preserved). - Idempotent: an existing guard entry is left untouched.""" + """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.""" + del root_path root_abs = Path(root).resolve() - leji_rel = join_under_root(root_path, ".leji") + leji_rel = WORK_REL script_rel = f"{leji_rel}/hooks/approval-guard.mjs" script_abs = root_abs / script_rel _assert_no_symlink_escape(root_abs, script_abs, script_rel) @@ -2513,7 +2776,9 @@ def ensure_approval_guard(root: str, root_path: str) -> GuardAction: settings_abs = root_abs / settings_rel _assert_no_symlink_escape(root_abs, settings_abs, settings_rel) settings: dict[str, object] = {} - existing = settings_abs.read_text(encoding="utf-8") if settings_abs.is_file() else None + # The settings file is parsed, merged, and written back, so its bytes come from the + # verified read rather than from the pathname the merge later writes to. + existing = _read_merge_source(guard_root(str(root_abs)), settings_abs, settings_rel) if existing is not None and existing.strip() != "": try: parsed = json.loads(existing) @@ -2593,7 +2858,7 @@ def offer_approval_guard(opts: GuardOfferOptions) -> None: "Add the temporary onboarding guard for Claude Code, in this repository only? " "It has the agent print its proposal before asking for approval. Writes two " "project-local files (a hook entry in this repo’s .claude/settings.json, " - "a script in the gitignored .leji/ workspace); nothing outside this repository " + "a script in the gitignored .leji/work/ workspace); nothing outside this repository " "is touched, and the finalize step removes both", "Y/n", ).lower() @@ -2602,7 +2867,7 @@ def offer_approval_guard(opts: GuardOfferOptions) -> None: action = ensure_approval_guard(opts.root, opts.root_path) print( "Onboarding guard added (this repository only: .claude/settings.json hook + " - ".leji/hooks/approval-guard.mjs; removed at finalize)." + ".leji/work/hooks/approval-guard.mjs; removed at finalize)." if action == "installed" else "Onboarding guard already present in this repository; refreshed the script." ) @@ -2630,6 +2895,49 @@ class StartOptions: # prompt (from `leji start -- `, e.g. Claude Code's --chrome). host_args: Optional[list[str]] = None io: Optional[HandoffIO] = None + # The host the caller already resolved, so the preflight report can name it before + # the launch takes the terminal. Used only when ``host_resolved`` is True; + # otherwise :func:`enter_layer` resolves one itself, as before. A None ``host`` + # with ``host_resolved`` True is an explicit "no host", which falls back to the + # printed commands. + host: Optional[StartHost] = None + host_resolved: bool = False + + +def boot_profile_ready(root: str, manifest: Manifest) -> bool: + """Whether the manifest's boot profile is a safe relative path that actually + exists: the one condition `leji start` refuses to run under, checked before + anything is reported or launched.""" + boot_rel = manifest["bootProfilePath"] + return bool(_REL_PATH_RE.match(boot_rel)) and os.path.isfile( + os.path.join(os.path.abspath(root), boot_rel) + ) + + +def resolve_start_host( + detected: list[DetectedHost], + agent: Optional[str] = None, + interactive: bool = False, + io: Optional[HandoffIO] = None, +) -> Optional[StartHost]: + """Which host `leji start` targets: ``--agent`` forces one, a single detected + prompt-capable host is it, and several ask (interactive only). Split out of + :func:`enter_layer` so the preflight can report on the host this run has actually + selected. Raises on an unknown or non-launchable ``--agent``, as before.""" + if agent: + return _exported(_assert_agent_host(agent)) + hosts = _prompt_capable_hosts(detected) + if len(hosts) == 1: + return _exported(hosts[0]) + if len(hosts) > 1 and interactive: + return _exported(_pick_from_multiple(hosts, io or _default_handoff_io())) + return None + + +def start_hosts(detected: list[DetectedHost]) -> list[StartHost]: + """The detected hosts `leji start` could launch, ranked — what the preflight names + when several are present and none was picked.""" + return [h for h in (_exported(p) for p in _prompt_capable_hosts(detected)) if h is not None] def _boot_prompt(boot_rel: str) -> str: @@ -2645,21 +2953,17 @@ def enter_layer(opts: StartOptions) -> StartOutcome: or launch failed), or 'boot-missing' when the boot path is unsafe or absent. Raises on an unknown/non-launchable --agent (usage error → exit 2).""" root = os.path.abspath(opts.root) - boot_rel = opts.manifest["bootProfilePath"] - if not _REL_PATH_RE.match(boot_rel) or not os.path.isfile(os.path.join(root, boot_rel)): + if not boot_profile_ready(root, opts.manifest): return "boot-missing" io = opts.io or _default_handoff_io() - prompt_arg = _boot_prompt(boot_rel) + prompt_arg = _boot_prompt(opts.manifest["bootProfilePath"]) - host: Optional[_PromptHost] = None - if opts.agent: - host = _assert_agent_host(opts.agent) + # A caller that already resolved the host (the preflight names it before the + # launch) passes it in; otherwise it is resolved here, as before. + if opts.host_resolved: + host = _internal(opts.host) else: - hosts = _prompt_capable_hosts(opts.detected) - if len(hosts) == 1: - host = hosts[0] - elif len(hosts) > 1 and opts.interactive: - host = _pick_from_multiple(hosts, io) + host = _internal(resolve_start_host(opts.detected, opts.agent, opts.interactive, io)) if host is None or not opts.interactive: return "fallback" diff --git a/packages/sdk-py/src/leji/layer.py b/packages/sdk-py/src/leji/layer.py index e34494c..aa9f88a 100644 --- a/packages/sdk-py/src/leji/layer.py +++ b/packages/sdk-py/src/leji/layer.py @@ -67,7 +67,11 @@ def excluded(rel_path: str) -> bool: def _read_text_within(root: str, abs_path: Path) -> Optional[str]: """Read a file only when it resolves (following symlinks) within the layer - root; mirrors Node's readTextWithin (returns None on escape or missing).""" + root; mirrors Node's readTextWithin (returns None on escape or missing). + + Read-side, behind an existence check, so it takes the lenient containment form: + see :func:`~leji.fsx.is_contained` for why this port cannot use the fail-closed + one here without refusing layers the reference SDK reads.""" if not abs_path.is_file(): return None if not is_contained(root, abs_path): @@ -413,21 +417,43 @@ def _scan_frontmatter_artifact( return ScannedProfile(rel_path=rel_path, frontmatter=fm.data, body=fm.body, findings=findings) +#: How a scan gets one artifact's bytes, and whether it may have them at all. The +#: default reads by path; a caller composing something it will serve or export +#: passes a reader that binds the check to the read (check-before-act), and returns None for a +#: source it refuses — missing, not a regular file, or resolving somewhere it may +#: not be read from. A refused artifact is dropped from the scan, exactly as the +#: whitelist filter it replaces dropped it, so validation (which passes no reader) +#: is unaffected. +ArtifactReader = Callable[[str], Optional[str]] + + def _scan_frontmatter_artifacts( - root: str, directory: str, schema_name: str, rule: str + root: str, + directory: str, + schema_name: str, + rule: str, + read: Optional[ArtifactReader] = None, ) -> list[ScannedProfile]: out: list[ScannedProfile] = [] for rel_path in walk_md(root, directory): if posixpath.basename(rel_path).lower() == "readme.md": continue - text = (Path(root) / rel_path).read_text(encoding="utf-8") + text = ( + (Path(root) / rel_path).read_text(encoding="utf-8") if read is None else read(rel_path) + ) + if text is None: + continue out.append(_scan_frontmatter_artifact(text, rel_path, schema_name, rule)) return out -def scan_agent_profiles(root: str, manifest: Manifest) -> list[ScannedProfile]: +def scan_agent_profiles( + root: str, manifest: Manifest, read: Optional[ArtifactReader] = None +) -> list[ScannedProfile]: directory = effective_agent_profiles_path(manifest) - return _scan_frontmatter_artifacts(root, directory, "agent-profile", "profile-frontmatter") + return _scan_frontmatter_artifacts( + root, directory, "agent-profile", "profile-frontmatter", read + ) def scan_profile_set(root: str, manifest: Manifest) -> list[ScannedProfile]: @@ -444,13 +470,22 @@ def scan_profile_set(root: str, manifest: Manifest) -> list[ScannedProfile]: posture. The agents-map check validates these files too and emits byte-identical findings, so ``profile_inheritance_findings`` collapses the pair rather than reporting either twice.""" - profiles = scan_agent_profiles(root, manifest) + return scan_profile_set_with(root, manifest) + + +def scan_profile_set_with( + root: str, manifest: Manifest, read: Optional[ArtifactReader] = None +) -> list[ScannedProfile]: + """The same scan through a caller's reader — the seam a viewer or export needs + and nobody else does. A None reader is :func:`scan_profile_set`'s own + read-by-path behavior.""" + profiles = scan_agent_profiles(root, manifest, read) directory = effective_agent_profiles_path(manifest) seen = {p.rel_path for p in profiles} for rel in (manifest.get("agents") or {}).values(): if rel in seen or under_path(rel, directory): continue - text = _read_text_within(root, Path(root) / rel) + text = _read_text_within(root, Path(root) / rel) if read is None else read(rel) if text is None: continue # missing or escaping: the agents-map check owns that seen.add(rel) diff --git a/packages/sdk-py/src/leji/layout.py b/packages/sdk-py/src/leji/layout.py new file mode 100644 index 0000000..de5099b --- /dev/null +++ b/packages/sdk-py/src/leji/layout.py @@ -0,0 +1,122 @@ +"""The unified ``.leji/`` layout: one tree at the repository root holding every +role the tool owns, whatever ``rootPath`` the layer declares. + +Roles are repository-root-relative by construction — a generated artifact never +lives inside the context root, so the content walk and the served content mount +carry nothing of the tool's own. + +- ``mounts/`` + ``mounts.local.json`` — the private federation domain (owned by + mounts.py, which spells the paths inside it; never servable, never exportable). +- ``viewer/`` — generated chrome, the ONE servable role. +- ``dist/`` — the default export output. +- ``work/`` — the transient onboarding workspace. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +#: The unified tree at the repository root. +LEJI_DIR = ".leji" + +#: Generated viewer chrome (index.html, _sidebar.md, _manifest.md, assets/). +VIEWER_REL = f"{LEJI_DIR}/viewer" + +#: Default export output; the only role a caller-supplied ``--out`` may name. +DIST_REL = f"{LEJI_DIR}/dist" + +#: Transient onboarding workspace (brief, proposal, hooks). +WORK_REL = f"{LEJI_DIR}/work" + +#: The private federation domain: managed object stores, projection cache, staging. +MOUNTS_REL = f"{LEJI_DIR}/mounts" + + +def role_abs(root_abs: str, rel: str) -> str: + """Join a repository-root-relative role path (POSIX, as the constants above + spell it) onto an absolute root, in the host's own separator.""" + return os.path.join(root_abs, *rel.split("/")) + + +def _under(directory: str, abs_path: str) -> bool: + """True when ``abs_path`` is ``directory`` or sits underneath it.""" + return abs_path == directory or abs_path.startswith(directory + os.sep) + + +def servable_path(root_abs: str, abs_path: str) -> bool: + """The servable-roots whitelist: a path may be served or exported only when it + lies outside root ``.leji/`` entirely, or inside ``.leji/viewer/``. + + Every other role under ``.leji/`` — the private mounts domain, the export + output, the onboarding workspace, and any role added later — is denied **by + name**, so a new role is born unservable and no relaxation of the dot-segment + refusal (kept as defense in depth) can open the trust domain as a side effect. + + ``root_abs`` must be a resolved (realpath'd) repository root, and ``abs_path`` + is judged both as requested and after symlink resolution: the name is what + decides, not how the caller spelled it.""" + if not _under(role_abs(root_abs, LEJI_DIR), abs_path): + return True + return _under(role_abs(root_abs, VIEWER_REL), abs_path) + + +def leji_role(root_abs: str, abs_path: str) -> str: + """The private ``.leji/`` role a resolved path falls into: the first path + segment under ``.leji/`` (``mounts``, ``work``, ``dist``, ``viewer``, or any + future role name), or ``""`` when the path is ``.leji/`` itself. Callers + establish that ``abs_path`` is under ``.leji/`` before asking; used to name the + role in a boundary message.""" + try: + rest = os.path.relpath(abs_path, role_abs(root_abs, LEJI_DIR)) + except ValueError: # different drives on Windows: no role to name + return "" + return "" if rest == "." else rest.split(os.sep)[0] + + +@dataclass +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.""" + + ok: bool = False + role: str = "" + unresolvable: bool = False + outside_root: bool = False + exists: bool = False + + +def writable_target(root_abs: str, resolved_abs: str, own_role_rel: Optional[str]) -> TargetVerdict: + """The check-before-act rule for a WRITE or CLEAR target, judged on the + RESOLVED path immediately before the act, in this order: + + 1. The target must resolve INSIDE the repository root. Every write this tool + makes lands in the repository it was pointed at, with no exceptions: a + ``.leji/`` role symlinked out of the tree is refused rather than followed. A + user who wants the export somewhere else copies the finished folder there. + 2. A target under root ``.leji/`` is refused — that tree is the tool's own trust + domain — UNLESS ``own_role_rel`` is given and the target lies under that one + role. + 3. Anything else inside the repository is ordinary content and is allowed. + + Both ``root_abs`` and ``resolved_abs`` must be realpath-resolved, so a + redirecting symlink or a case-variant spelling is judged by where it lands, not + by how it was written. One home for the rule, called before every write and + clear. + + ``own_role_rel`` names the ONE ``.leji/`` role the target may land in, as a + lexical path under the resolved root; pass ``None`` when the target has no + legitimate ``.leji/`` role at all (user content such as overview.md, which lives + under the content root, never inside ``.leji/``) — then any ``.leji/`` landing is + refused.""" + if not _under(root_abs, resolved_abs): + return TargetVerdict(outside_root=True) + if not _under(role_abs(root_abs, LEJI_DIR), resolved_abs): + return TargetVerdict(ok=True) # inside the repository, outside .leji/ + if own_role_rel is not None and _under(role_abs(root_abs, own_role_rel), resolved_abs): + return TargetVerdict(ok=True) # its own role + return TargetVerdict(role=leji_role(root_abs, resolved_abs)) diff --git a/packages/sdk-py/src/leji/localcli.py b/packages/sdk-py/src/leji/localcli.py new file mode 100644 index 0000000..c5885ef --- /dev/null +++ b/packages/sdk-py/src/leji/localcli.py @@ -0,0 +1,590 @@ +"""The hand-off the INSTALLED CONSOLE SCRIPT performs before it parses anything: +inside a repository that declares the Leji CLI and has it installed, an invocation of +the global ``leji`` belongs to the repository's own pinned copy, so a teammate, a hook, +CI, and a person typing ``leji`` all run one version of the tool. Transcribes +packages/sdk/src/lib/localcli.ts, with this runtime's own target (the project +environment's console script rather than a Node bin shim). + +Nothing here is reachable from the library. ``main()`` is imported in-process by tests +and by other tools, and a library call must never turn into another program. + +Every negative outcome is SILENT and launches nothing: the global runs exactly as it +did before, so a repository that does not qualify pays a few bounded reads and notices +no difference. The reads that decide the execution are the verified form +(:func:`open_verified_source`), because the bytes that decide what runs must come from +the file the containment check cleared. The residual is the recorded check-before-act limit, stated +in ``docs/practice/trust-boundary.md``: a target swapped between the check and the exec +cannot be closed portably, and it is named in the allowance rather than claimed away. +""" + +from __future__ import annotations + +import errno +import json +import os +import re +import signal +import stat +import subprocess +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn, Optional + +from .cli import effective_root +from .ecosystem import ( + ENTRY_ABSENT, + ENTRY_ELIGIBLE, + ENTRY_REFUSED, + PIPFILE, + PY_LOCKS, + PYPROJECT, + REQUIREMENTS_RE, + classify_entry, + python_declares, + python_manager_choice, +) +from .fsx import ( + guard_root, + open_verified_source, + read_all, + resolved_path, + resolved_within_root, + to_posix, +) +from .manifest import MANIFEST_FILENAME +from .preflight import MIN_SDK_FOR_SPEC_LINE, VERSION_RE + +# The one variable that turns the hand-off off, set to ANY value including empty. +# Nothing of ours is ever ADDED to the environment: the agent host `leji start` +# launches inherits the user's environment untouched, so no sentinel of ours can leak +# into it and silently disable the hand-off for everything it runs. +OPT_OUT = "LEJI_NO_LOCAL" + +# The installed distribution's own metadata, read whole and bounded. A METADATA file is +# a few kilobytes; anything past this is not one, and reading it is not this wrapper's +# job. The other three bounds keep `--help` and `--version` away from an unbounded +# number of repository-controlled candidates: exceeding ANY of them is no hand-off, +# never a longer search. +MAX_METADATA_BYTES = 64 * 1024 +MAX_LIB_DIRS = 4 +MAX_SITE_ENTRIES = 512 +MAX_DIST_INFO = 8 +#: The same bound on the repository root's own listing, which the evidence names come +#: from. A root with more entries than this is not searched for evidence at all. +MAX_ROOT_ENTRIES = 512 + +# The interpreter directories a POSIX virtual environment keeps its packages under. +_PY_LIB_PREFIX = "python3." + +# PEP 503 name normalization: the one spelling two distributions are compared by. +_NAME_SEPARATORS = re.compile(r"[-_.]+") + +# The distribution name this SDK is published under. +_DIST_NAME = "leji" + + +@dataclass(frozen=True) +class LocalCliHandoff: + """The repository's own CLI, and exactly how to run it. ``args`` is the argv this + process received, verbatim; ``display`` is the target as a failure would name it, + repository-relative and POSIX-spelled.""" + + bin: str + args: list[str] + display: str + + +def resolve_local_cli( + argv: list[str], + env: Mapping[str, str], + platform: str, + self_entry_realpath: Optional[str], +) -> Optional[LocalCliHandoff]: + """Decide whether this invocation belongs to a repository's own pinned CLI, or + None when the global one continues. + + ``self_entry_realpath`` is the resolved path of the running console script, or None + when it could not be resolved; the target is refused when it IS that file, which is + what keeps a repository whose install points back at this very script from handing + off to itself forever. An unknown self is refused for the same reason. + + The identity of the copy is the console script itself here, which the reference + SDK's structure reaches through the package entry instead. The two agree because + of ``execv``: this process is REPLACED by the target, so the copy that runs next + sees exactly this path as its own ``sys.argv[0]``, and the comparison closes the + loop on the first re-entry. Node cannot rely on that, because a manager's shim may + be a script whose realpath is itself rather than the entry it runs, so its guard + resolves the package's declared entry; there is no shim layer between an + environment's console script and this module. + + All of the following must hold, and each one is checked on the resolved path rather + than on a spelling: + + ========================================================== ========================================== + condition why + ========================================================== ========================================== + ``LEJI_NO_LOCAL`` absent from the environment the single opt-out + the argv names a root at all a malformed command line selects no repo + the declaring manifest declares the CLI, verified the repository committed the intent + that same read names one manager the manager names the environment rule + the layer's spec line reads back and has a minimum the bar to meet + the manager-owned environment resolves inside the root never an environment kept elsewhere + exactly one installed distribution normalizes to ``leji`` a directory spelling is not an identity + its version parses and its major meets the minimum an older copy cannot serve this layer + the console script resolves inside the real root never a linked copy elsewhere + the target is not this running script no recursion + ========================================================== ========================================== + + Every read here is TOTAL: an unreadable manifest, a permission error, a directory + where a file was expected, or any other I/O failure is no hand-off, never an + exception. This runs before ``main()`` and outside its error handling, so a raise + would be a traceback where the global CLI was supposed to run. + """ + try: + return _resolve(argv, env, platform, self_entry_realpath) + except Exception: + return None + + +def _resolve( + argv: list[str], + env: Mapping[str, str], + platform: str, + self_entry_realpath: Optional[str], +) -> Optional[LocalCliHandoff]: + if OPT_OUT in env: + return None + if self_entry_realpath is None: + return None + root_arg = effective_root(argv) + if root_arg is None: + return None + root_abs = os.path.abspath(root_arg) + root_real = guard_root(root_abs) + + manager = _declared_manager(root_abs, root_real) + if manager is None: + return None + + spec_line = _read_spec_line(root_real, os.path.join(root_abs, MANIFEST_FILENAME)) + if spec_line is None: + return None + minimum = MIN_SDK_FOR_SPEC_LINE.get(spec_line) + if minimum is None: + return None + + env_dir = _project_environment(root_abs, manager, env) + if env_dir is None: + return None + version = _installed_version(root_real, _site_packages_dirs(env_dir, platform)) + if version is None: + return None + parsed = VERSION_RE.match(version) + if parsed is None or int(parsed.group(1)) < int(minimum.split(".")[0]): + return None + + target = _installed_console_script(root_abs, env_dir, platform) + if target is None: + return None + if resolved_path(target) == self_entry_realpath: + return None + return LocalCliHandoff( + bin=target, + args=list(argv), + display=to_posix(os.path.relpath(target, root_abs)), + ) + + +def _declared_manager(root_abs: str, root_real: str) -> Optional[str]: + """The manager this root's committed evidence names, or None when this root does + not ask for a hand-off at all. + + Both answers this needs, WHETHER the repository declares the CLI and WHICH manager + owns its environment, come from one verified read of each declaring manifest. The + ecosystem report is deliberately not consulted: it reads the same files by path, + and this runtime's manager selects the environment and therefore which console + script would run, so the bytes that decide it have to be bytes this resolver + verified rather than bytes something else read a moment earlier. + + The names the listing yields decide only which files this resolver then VERIFIES + OPEN. A lockfile's evidence is its name, so nothing of it is read, but the name + counts only when a verified open of it succeeds at decision time: a lockfile that + has vanished, become a link, or turned into a directory since it was listed + selects nothing, because a name alone must never steer which environment, and so + which executable, this hand-off reaches. + + The decision table is the ecosystem module's own, called on the text: a single + lock family, else a single `[tool.*]` table, else pip; ambiguity, refused + evidence, and an unreadable manifest are each no hand-off. + """ + try: + entries = sorted(os.listdir(root_abs)) + except OSError: + return None + if len(entries) > MAX_ROOT_ENTRIES: + return None + requirements = [name for name in entries if REQUIREMENTS_RE.match(name)] + + kinds = {name: classify_entry(root_abs, name) for name in {PYPROJECT, PIPFILE, *requirements}} + if any(kind == ENTRY_REFUSED for kind in kinds.values()): + return None # refused evidence: the scan's own refusal, and never read through + # The ecosystem is gated by the same three things the report gates it by; a root + # that gates no Python ecosystem names no manager. + if kinds[PYPROJECT] == ENTRY_ABSENT and kinds[PIPFILE] == ENTRY_ABSENT and not requirements: + return None + + manifests: dict[str, Optional[str]] = {} + for name in (PYPROJECT, PIPFILE): + if kinds[name] != ENTRY_ELIGIBLE: + manifests[name] = None + continue + text = _verified_text(root_real, os.path.join(root_abs, name)) + if text is None: + return None # standing but unreadable: the report's unreadable-manifest + manifests[name] = text + + declared = python_declares( + manifests[PYPROJECT], + manifests[PIPFILE], + ( + _verified_text(root_real, os.path.join(root_abs, name)) + for name in requirements + if kinds[name] == ENTRY_ELIGIBLE + ), + ) + if not declared: + return None + present = set(entries) + lock_evidence = [ + name + for name, _, _ in PY_LOCKS + if name in present and _verified_present(root_real, os.path.join(root_abs, name)) + ] + choice = python_manager_choice(lock_evidence, manifests[PYPROJECT]) + return choice.manager + + +def _verified_present(root_real: str, abs_path: str) -> bool: + """Whether a regular file provably stands at ``abs_path``, inside the real + repository root, at the moment the decision is made. + + A lockfile's whole evidence is its NAME, so nothing of it is read; what has to be + proved is that the thing bearing that name is a real file of this repository, and + only a descriptor proves it. The descriptor is closed immediately: this asks a + question, it does not open a source. + + The ENTRY itself is judged, with ``lstat``, and a symlink is refused rather than + followed. That is the scan's own convention: evidence reached through a link is + not this repository's evidence, whatever it resolves to. Judging only the opened + descriptor would accept an in-root link, because the open follows it and proves + the target instead of the name. + + The entry is judged TWICE for that, and the second time decides. An up-front + ``lstat`` is the cheap refusal, but on its own it leaves the entry free to become + a link before the open, which would then resolve and verify the link's target + perfectly well: no swap-back needed, and the link would have selected a manager. + So after the open, the descriptor's own ``fstat`` and a fresh ``lstat`` of the + NAME must report the same ``(st_dev, st_ino)``. A symlink's inode is never the + inode of the file it points at, so an entry that is a link at that instant cannot + pass, and neither can one that has become a different file. + + Any failure at all, including an operational one such as a permission error, is + False. A candidate that fails is simply not evidence, which is the same answer as + never having been there, so the table falls through to the next rule rather than + refusing the whole root on it, and one unreadable candidate cannot suppress a + family that verified cleanly. + + What remains after this is the recorded check-before-act window between the check and the exec, + which every other verified fact on this path shares.""" + try: + entry = os.lstat(abs_path) + if not stat.S_ISREG(entry.st_mode): + return False + source = open_verified_source(abs_path, lambda real: _within(root_real, real)) + if source.fd is None: + return False + try: + opened = os.fstat(source.fd) + standing = os.lstat(abs_path) + finally: + os.close(source.fd) + except OSError: + return False + if (opened.st_dev, opened.st_ino) != (standing.st_dev, standing.st_ino): + return False + return True + + +def _project_environment( + root_abs: str, manager: Optional[str], env: Mapping[str, str] +) -> Optional[str]: + """The environment the repository's MANAGER owns, computed first and alone. + + uv reads ``UV_PROJECT_ENVIRONMENT`` (a relative value resolved against the root), + and every other manager this ecosystem selects keeps the project environment at + ``.venv`` under the root. ``VIRTUAL_ENV`` is deliberately not consulted: an active + environment is a property of the shell, not of this repository, and a nested or + unrelated one in a monorepo is not this root's. An environment the manager keeps + OUTSIDE the repository (a Poetry cache venv, a pipenv ``WORKON_HOME``) is + ineligible by design, which is what the public wording says. + """ + declared = env.get("UV_PROJECT_ENVIRONMENT") if manager == "uv" else None + if declared: + candidate = declared if os.path.isabs(declared) else os.path.join(root_abs, declared) + else: + candidate = os.path.join(root_abs, ".venv") + candidate = os.path.abspath(candidate) + if not resolved_within_root(root_abs, Path(candidate)): + return None + return candidate if os.path.isdir(candidate) else None + + +def _site_packages_dirs(env_dir: str, platform: str) -> Optional[list[str]]: + """Where an environment keeps installed distributions: one fixed directory on + Windows, and the interpreter directories under ``lib/`` on POSIX, sorted so the + search order is the same everywhere. More than the bound is not an environment + this wrapper searches.""" + if platform == "win32": + return [os.path.join(env_dir, "Lib", "site-packages")] + lib = os.path.join(env_dir, "lib") + try: + names = sorted(n for n in os.listdir(lib) if n.startswith(_PY_LIB_PREFIX)) + except OSError: + return [] + if len(names) > MAX_LIB_DIRS: + return None + return [os.path.join(lib, n, "site-packages") for n in names] + + +def _names_this_distribution(entry: str) -> bool: + """Whether one ``.dist-info`` directory could belong to THIS distribution, by the + name every installer writes into it (PEP 376: ``-.dist-info``). + + This only NARROWS which metadata is opened; it never decides identity. A real + environment holds a dist-info per installed distribution, so opening all of them on + every `--version` is both wasteful and the unbounded exposure the bound exists to + close. What decides is the ``Name`` field inside the file this selects, so a + directory spelled like this package but declaring another one is still refused.""" + stem = entry[: -len(".dist-info")] + return _normalized(stem.rsplit("-", 1)[0]) == _DIST_NAME + + +def _installed_version(root_real: str, site_dirs: Optional[list[str]]) -> Optional[str]: + """The installed Leji distribution's version, or None. Identity is the distribution + NAME as PEP 503 normalizes it, never the spelling of a ``.dist-info`` directory, and + EXACTLY ONE match must exist: zero says nothing is installed here, and more than one + says this environment cannot answer which copy would run. Every bound refuses rather + than searching further.""" + if site_dirs is None: + return None + candidates: list[str] = [] + for site in site_dirs: + try: + entries = sorted(os.listdir(site)) + except OSError: + continue + if len(entries) > MAX_SITE_ENTRIES: + return None + candidates.extend( + os.path.join(site, name) + for name in entries + if name.endswith(".dist-info") and _names_this_distribution(name) + ) + if len(candidates) > MAX_DIST_INFO: + return None + found: list[str] = [] + for candidate in candidates: + metadata = _read_metadata(root_real, os.path.join(candidate, "METADATA")) + if metadata is None: + continue + name, version = metadata + if _normalized(name) == _DIST_NAME and version != "": + found.append(version) + return found[0] if len(found) == 1 else None + + +def _normalized(name: str) -> str: + """PEP 503: the one spelling two distribution names are compared by.""" + return _NAME_SEPARATORS.sub("-", name).lower() + + +def _verified_text(root_real: str, abs_path: str) -> Optional[str]: + """One file's text, read the way every byte that decides an execution has to be: + the path is resolved, the resolved path is required to stay inside the real + repository root, and the bytes come from the descriptor ``fstat`` proved a regular + file, so what decides is what the containment check judged. Bounded and total: a + size past the cap, undecodable bytes, or any I/O failure is None, which every + caller turns into no hand-off. + + One reader for all four of them (the declaring manifests, the layer's spec line, + and the installed distribution's metadata), so no decision on this path can reach + a file by any other route.""" + source = open_verified_source(abs_path, lambda real: _within(root_real, real)) + if source.fd is None: + return None + try: + if os.fstat(source.fd).st_size > MAX_METADATA_BYTES: + return None + try: + return read_all(source.fd).decode("utf-8") + except UnicodeDecodeError: + return None + finally: + os.close(source.fd) + + +def _read_metadata(root_real: str, abs_path: str) -> Optional[tuple[str, str]]: + """One distribution's ``Name`` and ``Version``, or None.""" + text = _verified_text(root_real, abs_path) + if text is None: + return None + name = "" + version = "" + for line in text.split("\n"): + # RFC 822 headers: the first blank line ends them, and the body is not metadata. + if line.strip() == "": + break + if name == "" and line.startswith("Name:"): + name = line[len("Name:") :].strip() + elif version == "" and line.startswith("Version:"): + version = line[len("Version:") :].strip() + return None if name == "" else (name, version) + + +def _read_spec_line(root_real: str, abs_path: str) -> Optional[str]: + """The layer's declared spec line, read the way the bytes that decide an execution + have to be: through the verified helper, inside the real root, bounded, and total. + Only this one field is the wrapper's business. Whether the rest of the manifest is + a valid layer is ``main()``'s question, asked after the hand-off decision and by + whichever CLI ends up answering it.""" + text = _verified_text(root_real, abs_path) + if text is None: + return None + try: + data = json.loads(text) + except ValueError: + return None + if not isinstance(data, dict): + return None + line = data.get("leji") + return line if isinstance(line, str) else None + + +def _installed_console_script(root_abs: str, env_dir: str, platform: str) -> Optional[str]: + """The environment's own ``leji`` console script, or None. Every condition is + checked before the path is ever executed: a regular file after symlinks are + followed, resolving inside the real repository root, and executable where the + platform records that.""" + parts = ("Scripts", "leji.exe") if platform == "win32" else ("bin", "leji") + abs_path = Path(env_dir) / parts[0] / parts[1] + if not resolved_within_root(root_abs, abs_path): + return None + try: + if not abs_path.is_file(): + return None + mode = abs_path.stat().st_mode + except OSError: + return None + if platform != "win32" and not mode & 0o111: + return None + return str(abs_path) + + +def _within(root_real: str, real: str) -> bool: + return real == root_real or real.startswith(root_real + os.sep) + + +@dataclass +class LaunchIo: + """Everything the launcher does to the outside world, injectable so every row of + the result table is provable without ending the test runner.""" + + platform: str + #: POSIX: REPLACE this process with the target, so signals, exit status and stdio + #: are the child's by construction. Never returns, except by failing. + exec_replace: Callable[[str, list[str]], None] + #: Windows, where a process cannot replace itself: run the target and report its + #: return code. + run_child: Callable[[str, list[str]], int] + stderr: Callable[[str], None] + exit: Callable[[int], NoReturn] + + +def _exec_replace(bin_path: str, args: list[str]) -> None: + os.execv(bin_path, [bin_path, *args]) + + +def _run_child(bin_path: str, args: list[str]) -> int: + return subprocess.run([bin_path, *args]).returncode + + +def _default_launch_io() -> LaunchIo: + def _exit(code: int) -> NoReturn: + sys.exit(code) + + return LaunchIo( + platform=sys.platform, + exec_replace=_exec_replace, + run_child=_run_child, + stderr=lambda line: print(line, file=sys.stderr), + exit=_exit, + ) + + +def launch_local_cli(handoff: LocalCliHandoff, io: Optional[LaunchIo] = None) -> NoReturn: + """Run the repository's CLI and become its result. Never returns. + + ================================== ====================================================== + outcome what this process does + ================================== ====================================================== + POSIX replaces itself with the target, so the exit status, + the signal that ends it, and its stdio are the + child's by construction + Windows, a return code exits with it: the child's 0/1/2 contract surfaces + Windows, a negative return code names the signal on stderr and exits 1, the + documented limitation + the target could not be run names it on stderr and exits 2 + ================================== ====================================================== + + Failure is CLOSED, never a quiet fall-through to the global CLI: an eligible pinned + copy was already selected, so running a different version instead would recreate the + exact drift this hand-off exists to remove, possibly under a command that writes. + """ + launch = _default_launch_io() if io is None else io + if launch.platform == "win32": + try: + code = launch.run_child(handoff.bin, handoff.args) + except OSError as exc: + _failed(launch, handoff, _error_code(exc)) + if code < 0: + launch.stderr(f"leji: the repository's Leji CLI ended by {_signal_name(-code)}") + launch.exit(1) + launch.exit(code) + try: + launch.exec_replace(handoff.bin, handoff.args) + except OSError as exc: + _failed(launch, handoff, _error_code(exc)) + # A replacement that returns did not happen: this process is still here, and there + # is no status to be. Never accidental success. + _failed(launch, handoff, "no exit status") + + +def _failed(io: LaunchIo, handoff: LocalCliHandoff, code: str) -> NoReturn: + io.stderr(f"leji: cannot run the repository's Leji CLI at {handoff.display}: {code}") + io.exit(2) + + +def _error_code(exc: OSError) -> str: + """The errno name the reference SDK prints (``ENOENT``, ``EACCES``), falling back to + the message when the failure carries no errno.""" + name = errno.errorcode.get(exc.errno) if exc.errno is not None else None + return name if name is not None else str(exc) + + +def _signal_name(number: int) -> str: + try: + return signal.Signals(number).name + except ValueError: + return str(number) diff --git a/packages/sdk-py/src/leji/manifest.py b/packages/sdk-py/src/leji/manifest.py index f901b71..45932ad 100644 --- a/packages/sdk-py/src/leji/manifest.py +++ b/packages/sdk-py/src/leji/manifest.py @@ -247,3 +247,265 @@ def bind_agent_in_manifest_text(text: str, name: str, profile_rel: str) -> tuple if not agents: return _insert_before_owners(text, [' "agents": {', f" {entry}", " },"]), True return _insert_after_marker_line(text, '"agents": {', f" {entry},"), True + + +# --- The mount pin span ------------------------------------------------------- +# +# `leji mounts update-pin` moves one declared pin. The agent edits above anchor on +# the canonical two-space layout, which the manifest schema does not require, so a +# pin move gets a lexical scanner instead: it walks the document as JSON tokens, +# finds ``federation.mounts[i]`` whose ``name`` equals the addressed mount, and +# returns the byte span of THAT object's ``pin`` string value. Only that span is +# replaced. Nothing is reserialized or normalized, so field order, indentation, +# line endings, escapes, unmodeled keys, and every other byte of the file survive +# untouched. + + +class PinScanError(Exception): + """A lexical failure: the document is not shaped the way a manifest is. Callers + turn it into the same "cannot locate" refusal as a missing mount, because both + mean the same thing operationally -- this text has no such pin to move.""" + + +class PinAmbiguityError(Exception): + """A duplicate key on the path to the pin. JSON does not forbid one, and the two + readers of this document disagree about which wins: a lexical scan takes the + FIRST member, ``json.loads`` keeps the LAST. So a manifest carrying two ``pin`` + keys on the addressed mount could have its first span rewritten while the pin + every parser reads stays exactly as it was -- a reported change that changed + nothing. The scanner refuses that document instead of picking a winner, and this + error carries its own message out rather than collapsing into "cannot locate".""" + + +@dataclass +class _Member: + """One object member: its decoded key, and the index its value begins at.""" + + key: str + value_at: int + + +@dataclass +class _ScannedString: + """One JSON string: its decoded value (escapes resolved, for comparison only) + and the span of its RAW contents between the quotes, which is the only thing an + edit ever replaces.""" + + value: str + content_start: int + end: int + + +_HEX4_RE = re.compile(r"[0-9a-fA-F]{4}") +_JSON_WS = " \t\n\r" +_JSON_VALUE_STOP = " \t\n\r,}]" + + +def _quoted(s: str) -> str: + """A JSON string literal, the way Node's ``JSON.stringify`` spells one: the + non-ASCII characters a mount name may carry stay themselves.""" + return json.dumps(s, ensure_ascii=False) + + +def _unique_member(members: list[_Member], key: str, where: str) -> Optional[_Member]: + """The one member named ``key``, or None when there is none. Two or more is + refused: every key this scanner reads sits on the path to the pin, so an + ambiguous one makes the whole edit ambiguous.""" + matches = [m for m in members if m.key == key] + if len(matches) > 1: + raise PinAmbiguityError(f"duplicate key {_quoted(key)} {where}") + return matches[0] if matches else None + + +def _skip_json_ws(text: str, i: int) -> int: + """Index of the first character at or after ``i`` that is not JSON whitespace.""" + while i < len(text) and text[i] in _JSON_WS: + i += 1 + return i + + +def _scan_json_string(text: str, i: int) -> _ScannedString: + """One JSON string starting at the opening quote.""" + if i >= len(text) or text[i] != '"': + raise PinScanError("expected a string") + content_start = i + 1 + out: list[str] = [] + saw_surrogate = False + j = content_start + n = len(text) + while j < n: + c = text[j] + if c == '"': + value = "".join(out) + if saw_surrogate: + # Code units are appended as they come: a surrogate PAIR spelled as + # two escapes reassembles into its astral character by the same rule + # the parser uses, so an escaped name compares equal to a raw one. + value = value.encode("utf-16-le", "surrogatepass").decode( + "utf-16-le", "surrogatepass" + ) + return _ScannedString(value=value, content_start=content_start, end=j + 1) + if c != "\\": + out.append(c) + j += 1 + continue + esc = text[j + 1] if j + 1 < n else "" + j += 2 + if esc in ('"', "\\", "/"): + out.append(esc) + elif esc == "b": + out.append("\b") + elif esc == "f": + out.append("\f") + elif esc == "n": + out.append("\n") + elif esc == "r": + out.append("\r") + elif esc == "t": + out.append("\t") + elif esc == "u": + hex_digits = text[j : j + 4] + if _HEX4_RE.fullmatch(hex_digits) is None: + raise PinScanError("malformed \\u escape") + code = int(hex_digits, 16) + saw_surrogate = saw_surrogate or 0xD800 <= code <= 0xDFFF + out.append(chr(code)) + j += 4 + else: + raise PinScanError("unknown escape") + raise PinScanError("unterminated string") + + +def _skip_json_value(text: str, i: int) -> int: + """Index just past the value beginning at ``i``, whatever it is. Objects and + arrays are skipped STRUCTURALLY (nesting counted through their own members), so + a ``pin`` key inside some unrelated nested object is never mistaken for a + mount's.""" + i = _skip_json_ws(text, i) + c = text[i] if i < len(text) else "" + if c == '"': + return _scan_json_string(text, i).end + if c in ("{", "["): + close = "}" if c == "{" else "]" + j = i + 1 + while True: + j = _skip_json_ws(text, j) + if j >= len(text): + raise PinScanError("unterminated container") + if text[j] == close: + return j + 1 + if text[j] in (",", ":"): + j += 1 + continue + j = _skip_json_value(text, j) + # A literal or a number: everything up to the next structural character. + j = i + while j < len(text) and text[j] not in _JSON_VALUE_STOP: + j += 1 + if j == i: + raise PinScanError("expected a value") + return j + + +def _json_members(text: str, i: int) -> tuple[list[_Member], int]: + """Each member of the object beginning at ``i``, as (decoded key, index of its + value); plus the index just past the object.""" + i = _skip_json_ws(text, i) + if i >= len(text) or text[i] != "{": + raise PinScanError("expected an object") + members: list[_Member] = [] + j = i + 1 + while True: + j = _skip_json_ws(text, j) + if j >= len(text): + raise PinScanError("unterminated object") + if text[j] == "}": + return members, j + 1 + if text[j] == ",": + j += 1 + continue + key = _scan_json_string(text, j) + j = _skip_json_ws(text, key.end) + if j >= len(text) or text[j] != ":": + raise PinScanError('expected ":"') + value_at = _skip_json_ws(text, j + 1) + members.append(_Member(key=key.value, value_at=value_at)) + j = _skip_json_value(text, value_at) + + +def _find_mount_pin_span(text: str, name: str) -> Optional[tuple[str, int, int]]: + """The raw span of ``federation.mounts[i].pin`` for the mount named ``name``, as + (current value, content start, content end). None when no such mount, or no + ``pin`` on it.""" + root_members, _ = _json_members(text, 0) + federation = _unique_member(root_members, "federation", "in the manifest root") + if federation is None: + return None + federation_members, _ = _json_members(text, federation.value_at) + mounts_key = _unique_member(federation_members, "mounts", 'in "federation"') + if mounts_key is None: + return None + i = _skip_json_ws(text, mounts_key.value_at) + if i >= len(text) or text[i] != "[": + raise PinScanError("expected an array") + i += 1 + while True: + i = _skip_json_ws(text, i) + if i >= len(text): + raise PinScanError("unterminated array") + if text[i] == "]": + return None + if text[i] == ",": + i += 1 + continue + if text[i] != "{": + i = _skip_json_value(text, i) + continue + entry_members, entry_end = _json_members(text, i) + # A mount whose own name is ambiguous cannot be told apart from the addressed + # one, so the document is refused before any element is matched. + name_member = _unique_member(entry_members, "name", "in a federation mount") + if ( + name_member is not None + and text[name_member.value_at] == '"' + and _scan_json_string(text, name_member.value_at).value == name + ): + pin_member = _unique_member(entry_members, "pin", f"in mount {_quoted(name)}") + if pin_member is None: + return None + if text[pin_member.value_at] != '"': + raise PinScanError("pin is not a string") + pin = _scan_json_string(text, pin_member.value_at) + return pin.value, pin.content_start, pin.end - 1 + i = entry_end + + +def replace_mount_pin_in_manifest_text( + text: str, name: str, from_pin: str, to_pin: str +) -> tuple[str, bool]: + """Move one declared mount's pin, in place. ``from_pin`` is what the span must + currently hold -- the value the comparison was computed against -- so a manifest + that moved underneath the run is refused rather than overwritten. Everything + outside the pin value's own bytes is returned exactly as it came in. + + Raises when the pin cannot be located, or holds something other than + ``from_pin``. Both are internal refusals after the manifest has already parsed + and validated.""" + try: + span = _find_mount_pin_span(text, name) + except PinAmbiguityError as e: + # An ambiguous document is refused on its own terms; a merely malformed one + # is the same answer as a mount that is not there. + raise RuntimeError(f"{MANIFEST_FILENAME}: {e}") from e + except PinScanError: + span = None + if span is None: + raise RuntimeError(f"{MANIFEST_FILENAME}: cannot locate the pin of mount {_quoted(name)}") + value, content_start, content_end = span + if value != from_pin: + raise RuntimeError( + f"{MANIFEST_FILENAME}: pin of mount {_quoted(name)} is not {_quoted(from_pin)}" + ) + if from_pin == to_pin: + return text, False + return text[:content_start] + to_pin + text[content_end:], True diff --git a/packages/sdk-py/src/leji/mounts.py b/packages/sdk-py/src/leji/mounts.py index 4be5650..ad2cd7f 100644 --- a/packages/sdk-py/src/leji/mounts.py +++ b/packages/sdk-py/src/leji/mounts.py @@ -31,11 +31,13 @@ import shutil import stat as statmod import subprocess +import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Literal, cast -from .fsx import resolved_within_root +from .fsx import guard_root, mkdirp_guarded, resolved_within_root +from .layout import MOUNTS_REL from .manifest import Manifest, all_strings_scalar from .schemas import schema_errors @@ -202,7 +204,22 @@ def _join(*parts: str) -> str: def mounts_dir(root: str) -> str: - return _join(root, ".leji", "mounts") + return _join(root, *MOUNTS_REL.split("/")) + + +def _establish_mounts_dir(root: str, dir_abs: str) -> 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 + into another role or out of the repository is caught here, once, instead of being + followed by every per-entry write underneath. + + The per-entry protocol below (hashed identities, contained relative paths, the + symlink-escape rules, publish-by-rename) is the declared 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``.""" + established = mkdirp_guarded(guard_root(root), dir_abs, MOUNTS_REL) + return established.real if established.ok else None def read_hints(root: str) -> dict[str, str]: @@ -393,10 +410,26 @@ def find_object_source(root: str, mount: MountDecl, source_identity: str) -> Obj return ObjectSource(repo=None, kind=None, ambiguous=ambiguous) -def fetch_into_store(root: str, mount: MountDecl, source_identity: str) -> 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.""" +def _retention_injected_failure(oid: str) -> bool: + """Test-only fault injection for :func:`retain_pin_in_store`: with + LEJI_TEST_FAIL_PIN_REF set to a commit id, retaining exactly that commit fails at + the ref. It exists because the TARGET-retention refusal has no other reachable + path — by the time the target is retained, the comparison repository IS the + managed store and already holds the commit, so the fetch never runs and only the + ref update can fail.""" + 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: + """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 + caller that needs more than one commit retained pays exactly one round trip per + commit and no extra observation of a moving ref. + + The declared pin and an explicitly named target are both retained through this, + so the version of record and the version being moved to are equally safe from + git maintenance.""" def failed(error: str) -> FetchResult: # Details are stable, Leji-authored text: git stderr never reaches output. @@ -405,15 +438,16 @@ 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 = _store_dir(root, source_identity) + store = _establish_mounts_dir(root, _store_dir(root, source_identity)) + if store is None: + return failed("the managed store could not be initialized") if not _is_git_repo(store): - Path(store).mkdir(parents=True, exist_ok=True) if not run_git(["init", "--bare", "-q", store]).ok: return failed("the managed store could not be initialized") - # The pin is immutable: a store that already holds it needs no round trip. The - # declared pin is resolved directly, never read back out of FETCH_HEAD, so the - # fetch has no reason to write one and races with a concurrent fetch. - if not _has_commit(store, mount.pin): + # A commit id is immutable: a store that already holds it needs no round trip. + # The id is resolved directly, never read back out of FETCH_HEAD, so the fetch + # has no reason to write one and races with a concurrent fetch. + if not _has_commit(store, oid): fetch = run_git( [ "-C", @@ -424,29 +458,45 @@ def failed(error: str) -> FetchResult: "-q", "--no-write-fetch-head", mount.source, - mount.pin, + oid, ] ) if not fetch.ok: return failed("the pin could not be fetched from the source") - # Retain the pin by a ref of our own: without it, git maintenance may prune the + # Retain it by a ref of our own: without it, git maintenance may prune the # version of record. - pin_oid = _rev_oid(store, mount.pin) + pin_oid = _rev_oid(store, oid) if pin_oid is None: return failed("fetched, but the pin is not reachable") - if not run_git(["-C", store, "update-ref", pin_ref_for(source_identity, pin_oid), pin_oid]).ok: + if ( + _retention_injected_failure(pin_oid) + or not run_git( + ["-C", store, "update-ref", pin_ref_for(source_identity, pin_oid), pin_oid] + ).ok + ): return failed("the pin could not be retained by a ref in the managed store") + return FetchResult(repo=store) + + +def fetch_into_store(root: str, mount: MountDecl, source_identity: str) -> 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) + if retained.repo is None: + return retained + store = retained.repo # 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.tracking_ref is not None and valid_tracking_ref(mount.tracking_ref): - if not _refresh_witness(store, mount, source_identity): + if not refresh_witness(store, mount, source_identity): return FetchResult(repo=store, witness_refresh_failed=True) 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) -> bool: """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 @@ -457,8 +507,21 @@ def _refresh_witness(store: str, mount: MountDecl, source_identity: str) -> bool witness_ref = witness_ref_for(source_identity, tracking_ref) temp_ref = f"{WITNESS_REF_NAMESPACE}/tmp/{os.getpid()}-{secrets.token_hex(8)}" spec = f"+{tracking_ref}:{temp_ref}" + # ``--no-write-fetch-head`` for the same reason retention passes it: the ref this + # fetch cares about is the temporary one in the refspec, and a FETCH_HEAD left + # behind is a per-run path recorded inside the managed store. fetch = run_git( - ["-C", store, "-c", "fetch.recurseSubmodules=no", "fetch", "-q", mount.source, spec] + [ + "-C", + store, + "-c", + "fetch.recurseSubmodules=no", + "fetch", + "-q", + "--no-write-fetch-head", + mount.source, + spec, + ] ) tip = _ref_oid(store, temp_ref) if fetch.ok else None # An empty is git's "must not exist yet". @@ -1243,9 +1306,22 @@ def outcome( ) continue # Staged inside the entry's own directory, so publication is a rename on one - # filesystem, and under a per-process name, so no two producers collide. - staging = _join(cache_dir, f".staging-{_staging_token()}") - Path(staging).mkdir(parents=True, exist_ok=True) + # 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()}")) + if staging is None: + outcomes.append( + outcome( + { + "name": mount.name, + "status": "error", + "detail": "the cache entry destination could not be established", + } + ) + ) + continue + cache_entry_dir = os.path.dirname(staging) projected = extract_projection(src.repo, mount.pin, staging) if not projected.ok: shutil.rmtree(staging, ignore_errors=True) @@ -1283,7 +1359,7 @@ def outcome( } # The whole tree is extracted and validated before it is publishable. status, detail = _publish_cache_entry( - cache_dir, staging, json.dumps(metadata, indent=2, ensure_ascii=False) + "\n" + cache_entry_dir, staging, json.dumps(metadata, indent=2, ensure_ascii=False) + "\n" ) if status == "error": outcomes.append(outcome({"name": mount.name, "status": "error", "detail": detail})) @@ -1301,8 +1377,10 @@ def outcome( def verify_projection(root: str, mount: MountDecl) -> bool | None: """Verify a cached projection against a reachable object store: every projected - file's bytes and mode against the pinned tree. Returns None when no object - store is reachable (unverifiable), True/False otherwise.""" + file's bytes and mode against the pinned tree. Returns None when a prerequisite + for verifying is unavailable — no reachable object store, an unresolvable pin, + no writable temp dir — leaving the projection unverified rather than judged; + True/False otherwise.""" identity = normalize_source(mount.source) if identity is None: return False @@ -1317,9 +1395,17 @@ def verify_projection(root: str, mount: MountDecl) -> bool | None: if not commit_r.ok: return None commit = commit_r.stdout.decode("utf-8").strip() - staging = _join(mounts_dir(root), f"verify-{os.getpid()}") - shutil.rmtree(staging, ignore_errors=True) - Path(staging).mkdir(parents=True, exist_ok=True) + # Staging happens outside the host: verifying is a read-only question, so asking + # it must not write into the tree being asked about (a read-only or shared + # checkout could not answer otherwise). The name is allocated, never constructed + # and pre-deleted: a guessed path is a path a concurrent verification is already + # using, and deleting it is how one run made another fail. Cleanup is installed + # the moment allocation succeeds. A failed allocation is one more unavailable + # prerequisite — unverifiable, never an error and never an in-tree fallback. + try: + staging = tempfile.mkdtemp(prefix="leji-verify-") + except OSError: + return None try: projected = extract_projection(src.repo, commit, staging) if not projected.ok: @@ -1414,10 +1500,132 @@ def locate_mount(root: str, manifest: Manifest, name: str) -> dict[str, object]: "path": proj_dir if present else None, } if present and not verified: - out["detail"] = "projection present but not verified against a reachable object store" + out["detail"] = ( + "projection present but not verified: it does not match its pin, " + "or verification prerequisites are unavailable" + ) return out +@dataclass +class ComparisonSelection: + """Which repository answers a pin comparison, and the ONE witness snapshot it + answered with. ``reason`` is the degraded alternative: a stable status code and + nothing selected.""" + + repo: str | None = None + comparison_repository: str | None = None + witness_provenance: str | None = None + compared_ref: str | None = None + tip_oid: str | None = None + reason: str | None = None + + +def select_comparison(root: str, mount: MountDecl, effective_ref: str) -> ComparisonSelection: + """The store-first availability matrix, resolved once: the resolver's own witness + in the managed store first, then the first object source that holds BOTH the pin + and the compared ref. The pin and the witness always come from the same + repository, and nothing here fetches. + + ``tip_oid`` is the single witness snapshot for the whole operation. ``status`` + reports from it and ``update-pin`` targets, counts and gates from it, so no + caller can end up describing two different commits by re-reading a ref that moved + in between.""" + identity = normalize_source(mount.source) + if identity is None: + return ComparisonSelection(reason="mount-source-unnormalizable") + if not valid_tracking_ref(effective_ref): + return ComparisonSelection(reason="mount-tracking-ref-invalid") + # Row 1: the managed store holds the pin and the resolver's own witness. + store = _store_dir(root, identity) + managed_tip = ( + _rev_oid(store, witness_ref_for(identity, effective_ref)) + if _is_git_repo(store) and _has_commit(store, mount.pin) + else None + ) + # Row 2: the first pin-holding source that also resolves the ref itself. A + # candidate holding only the pin is passed over, never allowed to mask a + # later one holding both. + candidates: list[ObjectSource] = [] + ambiguous = False + if managed_tip is None: + candidates, ambiguous = object_source_candidates(root, mount, identity) + selected: tuple[str, str, str] | None = ( + None if managed_tip is None else (store, "store", managed_tip) + ) + for candidate in candidates: + tip = _rev_oid(cast("str", candidate.repo), effective_ref) + if tip is not None: + selected = (cast("str", candidate.repo), cast("str", candidate.kind), tip) + break + if selected is None: + # Ambiguity is its own answer: those repositories were never consulted, + # so reporting the pin or the witness unavailable would claim more than + # was checked. + if ambiguous: + return ComparisonSelection(reason="mount-source-ambiguous") + return ComparisonSelection( + reason="mount-pin-unavailable" if not candidates else "mount-witness-unavailable" + ) + repo, kind, tip_oid = selected + return ComparisonSelection( + repo=repo, + comparison_repository="managed-store" if kind == "store" else kind, + witness_provenance="managed" if managed_tip is not None else "unmanaged", + compared_ref=effective_ref, + tip_oid=tip_oid, + ) + + +@dataclass +class PinComparison: + """A settled pin comparison: never ``unknown``, because a repository that cannot + answer the range returns the reason instead.""" + + state: str = "" + behind: int = 0 + ahead: int = 0 + ancestry_complete: bool = False + reason: str | None = None + + +def compare_pins(repo: str, pin: str, tip_oid: str) -> PinComparison: + """Where the pin stands against ONE witness snapshot, in ONE repository. Shared + by ``status``, which reports it, and ``update-pin``, which additionally gates on + it — so the two can never describe the same pair of commits differently.""" + incomplete = PinComparison(reason="mount-ancestry-incomplete") + behind = _count_range(repo, pin, tip_oid) + ahead = _count_range(repo, tip_oid, pin) + if behind is None or ahead is None: + return incomplete + shallow = run_git(["-C", repo, "rev-parse", "--is-shallow-repository"]) + ancestry_complete = shallow.ok and shallow.stdout.decode("utf-8").strip() == "false" + # Both counts positive is either divergence or two unrelated histories, and + # only a merge base tells them apart. Exit 1 is the answer "no merge base"; + # any other failure is the repository unable to answer, never an answer. + # Truncated history can also lose a merge base that exists, so `unrelated` + # is a claim only complete ancestry makes. + disjoint = False + if behind > 0 and ahead > 0: + merge_base = run_git(["-C", repo, "merge-base", pin, tip_oid]) + if not merge_base.ok and merge_base.code != 1: + return incomplete + disjoint = not merge_base.ok + if disjoint and not ancestry_complete: + return incomplete + if behind == 0 and ahead == 0: + state = "up-to-date" + elif behind > 0 and ahead > 0: + state = "unrelated" if disjoint else "diverged" + elif behind > 0: + state = "behind" + else: + state = "ahead" + return PinComparison( + state=state, behind=behind, ahead=ahead, ancestry_complete=ancestry_complete + ) + + def mount_status( root: str, manifest: Manifest, @@ -1483,89 +1691,30 @@ def unknown( rows.append(unknown("mount-tracking-ref-invalid")) continue - # Row 1: the managed store holds the pin and the resolver's own witness. - store = _store_dir(root, identity) - managed_tip = ( - _rev_oid(store, witness_ref_for(identity, mount.tracking_ref)) - if _is_git_repo(store) and _has_commit(store, mount.pin) - else None - ) - # Row 2: the first pin-holding source that also resolves the ref itself. A - # candidate holding only the pin is passed over, never allowed to mask a - # later one holding both. - candidates: list[ObjectSource] = [] - ambiguous = False - if managed_tip is None: - candidates, ambiguous = object_source_candidates(root, mount, identity) - selected: tuple[str, str, str] | None = ( - None if managed_tip is None else (store, "store", managed_tip) - ) - for candidate in candidates: - tip = _rev_oid(cast("str", candidate.repo), mount.tracking_ref) - if tip is not None: - selected = (cast("str", candidate.repo), cast("str", candidate.kind), tip) - break - if selected is None: - # Ambiguity is its own answer: those repositories were never consulted, - # so reporting the pin unavailable would claim more than was checked. - if ambiguous: - rows.append(unknown("mount-source-ambiguous")) - elif not candidates: - rows.append(unknown("mount-pin-unavailable")) - else: - rows.append(unknown("mount-witness-unavailable")) + selection = select_comparison(root, mount, mount.tracking_ref) + if selection.reason is not None: + rows.append(unknown(selection.reason)) continue - repo, kind, tip_oid = selected - comparison_repository = "managed-store" if kind == "store" else kind - witness_provenance = "managed" if managed_tip is not None else "unmanaged" - - behind = _count_range(repo, mount.pin, tip_oid) - ahead = _count_range(repo, tip_oid, mount.pin) - if behind is None or ahead is None: - rows.append( - unknown("mount-ancestry-incomplete", comparison_repository, witness_provenance) - ) + repo = cast("str", selection.repo) + tip_oid = cast("str", selection.tip_oid) + comparison_repository = selection.comparison_repository + witness_provenance = selection.witness_provenance + + comparison = compare_pins(repo, mount.pin, tip_oid) + if comparison.reason is not None: + rows.append(unknown(comparison.reason, comparison_repository, witness_provenance)) continue - shallow = run_git(["-C", repo, "rev-parse", "--is-shallow-repository"]) - ancestry_complete = shallow.ok and shallow.stdout.decode("utf-8").strip() == "false" - # Both counts positive is either divergence or two unrelated histories, and - # only a merge base tells them apart. Exit 1 is the answer "no merge base"; - # any other failure is the repository unable to answer, never an answer. - # Truncated history can also lose a merge base that exists, so `unrelated` - # is a claim only complete ancestry makes. - disjoint = False - if behind > 0 and ahead > 0: - merge_base = run_git(["-C", repo, "merge-base", mount.pin, tip_oid]) - if not merge_base.ok and merge_base.code != 1: - rows.append( - unknown("mount-ancestry-incomplete", comparison_repository, witness_provenance) - ) - continue - disjoint = not merge_base.ok - if disjoint and not ancestry_complete: - rows.append( - unknown("mount-ancestry-incomplete", comparison_repository, witness_provenance) - ) - continue - if behind == 0 and ahead == 0: - state = "up-to-date" - elif behind > 0 and ahead > 0: - state = "unrelated" if disjoint else "diverged" - elif behind > 0: - state = "behind" - else: - state = "ahead" rows.append( { **base, "pinReport": { - "state": state, - "behind": behind, - "ahead": ahead, + "state": comparison.state, + "behind": comparison.behind, + "ahead": comparison.ahead, "comparedRef": mount.tracking_ref, "comparisonRepository": comparison_repository, "witnessProvenance": witness_provenance, - "ancestryComplete": ancestry_complete, + "ancestryComplete": comparison.ancestry_complete, "observedAt": observed_at, }, } @@ -1585,6 +1734,35 @@ def _count_range(repo: str, from_ref: str, to_ref: str) -> int | None: return None +_HEAD_SYMREF_RE = re.compile(r"^ref:\s+(\S+)\s+HEAD", re.MULTILINE) + + +@dataclass +class DefaultRef: + """The ref a source advertises as its default branch, or why it could not be + read: ``unreachable`` (the source answered nothing) or ``no-symref`` (it + answered, advertising no symref to follow).""" + + ref: str | None = None + error: str | None = None + + +def resolve_default_ref(source: str) -> DefaultRef: + """The ref a source advertises as its default branch: ``HEAD``'s symref target, + read with ``ls-remote --symref``. The one lookup in this module that reaches the + network without the caller having named a ref, so both failures stay + distinguishable — the source could not be reached at all, or it advertises no + symref to follow.""" + # The locator becomes argv here: anything option-shaped is refused, never passed. + if source.startswith("-"): + return DefaultRef(error="unreachable") + head = run_git(["ls-remote", "--symref", source, "HEAD"]) + if not head.ok: + return DefaultRef(error="unreachable") + m = _HEAD_SYMREF_RE.search(head.stdout.decode("utf-8")) + return DefaultRef(ref=m.group(1)) if m else DefaultRef(error="no-symref") + + @dataclass class ReachabilityResult: state: str # 'reachable' | 'unreachable' | 'unknown' @@ -1592,9 +1770,6 @@ class ReachabilityResult: detail: str | None = None -_HEAD_SYMREF_RE = re.compile(r"^ref:\s+(\S+)\s+HEAD", re.MULTILINE) - - def check_pin_reachability(root: str, mount: MountDecl) -> ReachabilityResult: """The networked conformance probe: is the pin reachable from an advertised ref of ``source``? Advertisement comes from ``git ls-remote`` against the @@ -1611,17 +1786,18 @@ def check_pin_reachability(root: str, mount: MountDecl) -> ReachabilityResult: # Resolve the witness ref: declared, or the source's advertised default branch. witness_ref = mount.tracking_ref if witness_ref is None: - head = run_git(["ls-remote", "--symref", mount.source, "HEAD"]) - if not head.ok: + resolved = resolve_default_ref(mount.source) + if resolved.ref is None: return ReachabilityResult( - state="unknown", witness_ref=None, detail="the source could not be reached" - ) - m = _HEAD_SYMREF_RE.search(head.stdout.decode("utf-8")) - if not m: - return ReachabilityResult( - state="unknown", witness_ref=None, detail="source advertises no HEAD symref" + state="unknown", + witness_ref=None, + detail=( + "the source could not be reached" + if resolved.error == "unreachable" + else "source advertises no HEAD symref" + ), ) - witness_ref = m.group(1) + witness_ref = resolved.ref adv = run_git(["ls-remote", mount.source, witness_ref]) if not adv.ok: return ReachabilityResult( @@ -1637,9 +1813,14 @@ 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 = _join(mounts_dir(root), "store", sha256_hex(identity)) + store = _establish_mounts_dir(root, _join(mounts_dir(root), "store", sha256_hex(identity))) + if store is None: + return ReachabilityResult( + state="unknown", + witness_ref=witness_ref, + detail="the managed store could not be initialized", + ) if not _is_git_repo(store): - Path(store).mkdir(parents=True, exist_ok=True) init = run_git(["init", "--bare", "-q", store]) if not init.ok: return ReachabilityResult( @@ -1723,7 +1904,9 @@ def federation_enforcement( "re-run `leji mounts hydrate`" if verified is False else f'mount "{mount.name}" projection cannot be verified ' - "(no reachable object store); an unverified cache is not evidence" + "(verification prerequisites unavailable: no reachable object store, " + "unresolvable pin, or no writable temp dir); an unverified cache is " + "not evidence" ) out.append( EnforcementFinding( diff --git a/packages/sdk-py/src/leji/preflight.py b/packages/sdk-py/src/leji/preflight.py new file mode 100644 index 0000000..9feb569 --- /dev/null +++ b/packages/sdk-py/src/leji/preflight.py @@ -0,0 +1,699 @@ +"""`leji start`'s preflight: what a person who just cloned an adopted repository has +to fix before the layer's tooling actually works here, computed READ-ONLY and reported +as a fixed list of rows. Transcribes packages/sdk/src/commands/preflight.ts. + +The distinction the whole report turns on is who owns each gap. A gap in state that +lives in this clone or in this user's own configuration is PERSONAL: it is offered, on +a real terminal, and otherwise printed as an exact command. A gap in state the +repository commits is SHARED: it is reported with the maintainer's command and never +repaired here, because that write would land in files the whole team owns. Nothing here +blocks entry either way: the agent still boots, and ``--json``'s ``ready`` is the +scriptable signal. +""" + +from __future__ import annotations + +import os +import re +import shutil +import sys +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from .detect import DetectedHost, mcp_command, mcp_json_config, spec_by_id +from .ecosystem import EcosystemReport, manager_install_argv, runner_argv +from .fsx import resolved_within_root +from .init_cmd import ( + HandoffIO, + HookReport, + RunOptions, + StartHost, + ensure_local_hook, + hook_status, + start_hosts, +) +from .manifest import Manifest + +# Check ids, in the fixed order every report and every SDK prints them. +CHECK_CLI = "cli" +CHECK_MCP = "mcp" +CHECK_MCP_SHARED = "mcp-shared" +CHECK_HOOK = "hook" + + +@dataclass(frozen=True) +class Check: + """What one check found. "ok" needs nothing; "missing" is personal (offered here, + or printed); "shared-gap" is the repository's own state, for a maintainer; + "skipped" means the check does not apply to this machine; "n/a" means it does not + apply to this host; "unresolved" means the run could not tell which host to answer + for.""" + + id: str + status: str + detail: str + # The exact commands that close this gap, or None when there is nothing to run. + fix: Optional[list[str]] + # How the fix prints: a "command" line takes the "$ " prompt, a "snippet" is pasted + # as it stands (a config block, a hook body). Render-only, and never a key of the + # --json projection below. + fix_kind: str = "command" + + +@dataclass(frozen=True) +class PreflightResult: + """The whole read-only answer.""" + + # Every check whose id is cli, mcp or hook is ok, skipped, or not applicable. The + # shared MCP row is project hygiene and never counts against it. + ready: bool + checks: list[Check] + # The resolved hook target, so the consent step acts on what the report saw. + hook: HookReport + + +# --- the text table ------------------------------------------------------- +# Every string the Setup block prints lives here once, so the three SDKs transcribe +# one table rather than re-deriving prose. + +# The block's fixed geometry: , so +# every detail starts at the same column and the status word is the first thing read. A +# fix line is indented under the SUBJECT column, a half indent that reads as "belongs to +# the row above" and keeps long commands inside 80 columns. +_MARGIN = " " +_GUTTER = " " +_STATUS_WIDTH = 4 +_SUBJECT_WIDTH = 10 +_FIX_INDENT = " " * 8 + +# The lowest SDK version that shipped support for a spec line. A layer declares exactly +# one version expectation, its spec line; this is what that expectation means for the +# CLI resolved here. The comparison is on the major, which is where a line's support is +# added or dropped. +MIN_SDK_FOR_SPEC_LINE: dict[str, str] = {"1.0": "1.0.0"} + +# The word that names WHO owns the row. The status values stay the contract; these +# labels are what a person reads, and several statuses share one. +_STATUS_LABEL = { + "ok": "ok", + "missing": "you", + "shared-gap": "team", + "skipped": "n/a", + "n/a": "n/a", + "unresolved": "you", +} + +_SUBJECT = { + CHECK_CLI: "Leji CLI", + CHECK_MCP: "MCP server", + CHECK_MCP_SHARED: "Team MCP", + CHECK_HOOK: "Git hook", +} + +# Every detail is one short clause. Where a template carries a path, the path is its +# LAST token: a row that overflows overflows into the path, never through the prose, and +# nothing here is ever clipped (a truncated path misleads). +_HEADING = "Setup for this clone" +_TEXT_CLI_UNDECLARED = "not declared in this repository" +_TEXT_MCP_NONE = "no coding agent detected" +_TEXT_MCP_SHARED_OTHER = "none for this host" +_TEXT_MCP_SHARED_NO_HOST = "no host selected" +_TEXT_HOOK_NO_GIT = "not a git repository" +_TEXT_HOOK_ABSENT_PERSONAL = "none yet (per clone)" +_TEXT_SUMMARY_COMPLETE = "Setup complete." +_TEXT_OFFER_HOOK = "Install the pre-commit hook for this clone (validate + index --check)?" +_TEXT_OFFER_HOOK_FAILED = "The hook could not be written here; add it yourself:" +_OFFER_PROMPT = "Y/n" +_AGENT_FIX_LINE = "leji start --agent " + + +def _text_cli_ok(version: str, runner: str) -> str: + return f"{version} ({runner})" + + +def _text_cli_below_minimum(version: str, runner: str, minimum: str, line: str) -> str: + return f"{version} ({runner}) is below {minimum} for spec {line}" + + +def _text_cli_unresolvable(runner: str) -> str: + return f"{runner} reported no version here" + + +def _text_cli_unresolvable_no_install(runner: str) -> str: + return f"{runner} reported no version; run this repo's install" + + +def _text_cli_not_installed(bin_rel: str) -> str: + return f"not installed yet ({bin_rel})" + + +def _text_cli_verify(runner: str) -> str: + return f"{runner} --version" + + +def _text_cli_undeclared_ambient(version: str) -> str: + return f"not declared here (PATH has your own {version})" + + +def _text_mcp_manual(host: str) -> str: + return f"not registered for {host}; add it yourself:" + + +def _text_mcp_manual_path(config: str, scope: str) -> str: + """The first line of that snippet: where the block goes, and at which scope.""" + return f"{config} ({scope} scope)" + + +def _text_mcp_unresolved(hosts: list[str]) -> str: + return f"pick one: {', '.join(hosts)}" + + +def _text_hook_outside_root(target: str) -> str: + return f"hooks dir is outside this worktree: {target}" + + +def _text_hook_external(target: str) -> str: + return f"add it yourself; hooks run from {target}" + + +# The closing line: who owes how many fixes, and that neither answer blocks entry. +def _text_summary_fixes(n: int) -> str: + return f"{n} fix" if n == 1 else f"{n} fixes" + + +def _text_summary_you(fixes: str) -> str: + return f"{fixes} for you. The agent starts either way." + + +def _text_summary_team(fixes: str) -> str: + return f"{fixes} for a maintainer. The agent starts either way." + + +def _text_summary_both(fixes: str, team: int) -> str: + return f"{fixes} for you, {team} for a maintainer. The agent starts either way." + + +# --- the version probe ---------------------------------------------------- + +# How long a probe may take, and how much of its output is read. A probe that exceeds +# either bound fails closed, exactly like one that never started. +PROBE_TIMEOUT_MS = 10000 +PROBE_MAX_BYTES = 4096 + +# The one path a probe may execute directly: the bin shim a Node package manager +# installs for the declared dependency. It is a file this repository's own install put +# there, not a script the repository authors, which is the whole reason it is safe to +# run when npm/pnpm/yarn/bun are not. +NODE_BIN_REL = "node_modules/.bin/leji" + +# The Node managers whose declared CLI arrives as that shim. +_NODE_MANAGERS = frozenset({"npm", "pnpm", "yarn", "bun"}) + +# What the probe runs for a manager whose CLI is a console-script entry of the declared +# dependency rather than a file in the repository. Each carries the flag that keeps the +# manager from installing, syncing, or fetching anything, and none of them runs a script +# the repository declares. +_MANAGER_PROBE_ARGV: dict[str, list[str]] = { + "uv": ["uv", "run", "--no-sync", "leji"], + "poetry": ["poetry", "run", "leji"], + "pdm": ["pdm", "run", "leji"], + "pipenv": ["pipenv", "run", "leji"], + "go": ["go", "tool", "leji"], +} + +# The environment the Go probe forces: a read-only module graph, no toolchain download, +# no module proxy, and no workspace file redirecting the build. +_GO_PROBE_ENV = { + "GOFLAGS": "-mod=readonly", + "GOTOOLCHAIN": "local", + "GOPROXY": "off", + "GOWORK": "off", +} + +# The variables the probe passes through whatever it runs. Everything else in the +# caller's environment is dropped: a probe is not the user's shell, and an inherited +# NODE_OPTIONS, npm_config_*, or LD_PRELOAD is exactly the kind of thing that turns +# "ask for a version" into "run something else". +_PROBE_PLATFORM_ENV = ["SystemRoot", "SYSTEMROOT", "COMSPEC", "PATHEXT", "TEMP", "TMP", "WINDIR"] + +# Per-manager configuration the probe keeps, because without it the manager cannot find +# the environment it is being asked about. Nothing beyond this is inherited. +_PROBE_MANAGER_ENV: dict[str, list[str]] = { + "uv": ["UV_CACHE_DIR", "UV_PROJECT_ENVIRONMENT", "VIRTUAL_ENV"], + "poetry": ["POETRY_HOME", "POETRY_VIRTUALENVS_PATH", "POETRY_CACHE_DIR", "VIRTUAL_ENV"], + "pdm": ["PDM_HOME", "PDM_CACHE_DIR", "VIRTUAL_ENV"], + "pipenv": ["PIPENV_VENV_IN_PROJECT", "WORKON_HOME", "VIRTUAL_ENV"], + "go": ["GOPATH", "GOMODCACHE", "GOCACHE", "GOBIN"], +} + + +def _pass_through(names: list[str], into: dict[str, str]) -> None: + for name in names: + value = os.environ.get(name) + if value is not None: + into[name] = value + + +def _spawned_probe_env(manager: Optional[str]) -> dict[str, str]: + """The environment for a probe that has to find a program on the caller's PATH (a + package manager, or the ambient ``leji``): PATH and HOME survive because the manager + cannot answer without them, plus the manager's own named configuration. Nothing else + does.""" + env: dict[str, str] = {} + _pass_through(["PATH", "Path", "HOME"], env) + _pass_through(_PROBE_PLATFORM_ENV, env) + if manager: + _pass_through(_PROBE_MANAGER_ENV.get(manager, []), env) + if manager == "go": + env.update(_GO_PROBE_ENV) + return env + + +def _node_bin_dir() -> str: + """Where a ``node`` the shim can use lives, resolved on the caller's PATH. Empty when + there is none: the shim then fails to start, the probe fails closed, and the row says + the CLI could not be run here.""" + found = shutil.which("node") + return str(Path(found).parent) if found else "" + + +def _direct_probe_env() -> dict[str, str]: + """The environment for the direct execution of the repository's own bin shim: nothing + of the caller's is inherited at all. PATH holds only the directory of the node binary + the shim's interpreter line resolves, and HOME points at a temporary directory so no + user configuration is read.""" + env = {"PATH": _node_bin_dir(), "HOME": tempfile.gettempdir()} + _pass_through(_PROBE_PLATFORM_ENV, env) + return env + + +@dataclass(frozen=True) +class _ProbePlan: + """How this repository's declared CLI would be asked for its version. ``direct`` is + the installed Node shim, executed as a file; ``spawned`` is a manager or the ambient + binary, found on the caller's PATH; ``absent`` is a Node repository whose install has + not produced the shim (not installed, or a Yarn PnP tree that has no bin directory) — + reported, never worked around by asking a package manager to run a script.""" + + kind: str # "direct" | "spawned" | "absent" + bin: str = "" + args: tuple[str, ...] = () + env: Optional[dict[str, str]] = None + + +def _bin_candidates() -> list[str]: + """The names a Node bin shim can take, strongest first. Windows installs a ``.cmd`` + wrapper beside (or instead of) the extensionless shim.""" + return ["leji.cmd", "leji.exe", "leji"] if sys.platform == "win32" else ["leji"] + + +def _installed_node_bin(root: str) -> Optional[str]: + """The installed shim's absolute path, or None. Every condition is checked before the + path is ever executed: a regular file after symlinks are followed (npm installs the + shim AS a symlink, so links are expected), resolving inside the real repository root, + and executable where the platform records that.""" + for name in _bin_candidates(): + abs_path = Path(root) / "node_modules" / ".bin" / name + if not resolved_within_root(root, abs_path): + continue + try: + if not abs_path.is_file(): + continue + mode = abs_path.stat().st_mode + except OSError: + continue + if sys.platform != "win32" and not mode & 0o111: + continue + return str(abs_path) + return None + + +def _plan_probe(root: str, report: EcosystemReport) -> _ProbePlan: + selected = report.selected + manager = selected.manager if selected is not None and selected.direct_declared else None + if manager in _NODE_MANAGERS: + found = _installed_node_bin(root) + if found is None: + return _ProbePlan(kind="absent") + return _ProbePlan(kind="direct", bin=found, env=_direct_probe_env()) + argv = _MANAGER_PROBE_ARGV.get(manager or "") + if argv is not None: + return _ProbePlan( + kind="spawned", bin=argv[0], args=tuple(argv[1:]), env=_spawned_probe_env(manager) + ) + # Undeclared, pip, and pre-1.24 Go all reach the CLI the same way a person does: + # whatever `leji` the PATH resolves, run with the same sanitized environment. + return _ProbePlan(kind="spawned", bin="leji", env=_spawned_probe_env(None)) + + +# A bare .. with an optional prerelease or build tail, which is what +# every `leji --version` prints. Anything else is not a version this probe will believe. +VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$") + + +def _parse_version(stdout: str) -> Optional[tuple[str, int]]: + for raw in stdout.split("\n"): + line = raw.strip() + if line == "": + continue + m = VERSION_RE.match(line) + return (line, int(m.group(1))) if m else None + return None + + +def _probe_version(root: str, plan: _ProbePlan, io: HandoffIO) -> Optional[tuple[str, int]]: + """Ask the CLI this repository would run for its version. Argv, never a shell; cwd + pinned to the root; stdin closed; output and time bounded; a sanitized environment; + and never a package manager's script runner. Every failure mode — a missing + executable, a non-zero exit, a timeout, output that is not a version — comes back as + None, because a probe that cannot answer is not evidence that the CLI is there.""" + if plan.kind == "absent" or io.run is None: + return None + res = io.run( + plan.bin, + [*plan.args, "--version"], + root, + RunOptions( + quiet=True, + capture=True, + timeout_ms=PROBE_TIMEOUT_MS, + max_bytes=PROBE_MAX_BYTES, + env=plan.env, + ), + ) + if not res.started or res.error is not None: + return None + return _parse_version(res.stdout) + + +# --- the checks ----------------------------------------------------------- + + +def _argv_line(argv: list[str]) -> str: + return " ".join(argv) + + +def _cli_check(root: str, manifest: Manifest, report: EcosystemReport, io: HandoffIO) -> Check: + selected = report.selected + runner = runner_argv(report) + spec_line = manifest["leji"] + minimum = MIN_SDK_FOR_SPEC_LINE.get(spec_line) + plan = _plan_probe(root, report) + + if selected is None or not selected.direct_declared: + # The gap is the repository's declaration, which is a committed file: report it + # with the maintainer's command whatever this machine happens to have. The plain + # `leji` is still probed, so an ambient install is named as what it is. + found = _probe_version(root, plan, io) + add = selected.add if selected is not None else None + detail = _TEXT_CLI_UNDECLARED if found is None else _text_cli_undeclared_ambient(found[0]) + return Check(CHECK_CLI, "shared-gap", detail, [_argv_line(add)] if add else None) + + found = _probe_version(root, plan, io) + # The row names what actually answered: the installed shim for a Node repository, + # and the manager's own runner everywhere else. + node_shim = plan.kind in ("direct", "absent") + shown = NODE_BIN_REL if node_shim else _argv_line(runner) + install_argv = manager_install_argv(selected.manager) if selected.manager else None + fix: Optional[list[str]] = None + if install_argv: + fix = [_argv_line(install_argv)] + # A Node repository whose shim is absent gets the install command AND the way to + # confirm it worked, because leji will not run a package manager to find out. + if node_shim: + fix.append(_text_cli_verify(_argv_line(runner))) + if plan.kind == "absent": + return Check(CHECK_CLI, "missing", _text_cli_not_installed(NODE_BIN_REL), fix) + if found is None: + # A manager with no single install command (pip, pre-1.24 Go) has no argv to + # print, so the row itself has to carry the instruction. + detail = _text_cli_unresolvable(shown) if fix else _text_cli_unresolvable_no_install(shown) + return Check(CHECK_CLI, "missing", detail, fix) + if minimum is not None and found[1] < int(minimum.split(".")[0]): + return Check( + CHECK_CLI, + "missing", + _text_cli_below_minimum(found[0], shown, minimum, spec_line), + fix, + ) + return Check(CHECK_CLI, "ok", _text_cli_ok(found[0], shown), None) + + +def _personal_mcp_add(host_id: str) -> Optional[tuple[str, list[str]]]: + """The argv that registers the server for THIS USER on a host, or None when the host + has no registration command at all.""" + spec = spec_by_id(host_id) + if spec is None: + return None + argv = spec.mcp_add_user or spec.mcp_add + return (spec.bins[0], argv) if argv else None + + +def _mcp_check( + root: str, host: Optional[StartHost], detected: list[DetectedHost], io: HandoffIO +) -> Check: + if host is None: + launchable = start_hosts(detected) + if len(launchable) > 1: + return Check( + CHECK_MCP, + "unresolved", + _text_mcp_unresolved([h.name for h in launchable]), + [_AGENT_FIX_LINE], + ) + # A host Leji cannot register for is still worth a row: the person can add the + # standard configuration by hand, which is the only fix that exists for it. + for h in detected: + spec = spec_by_id(h.id) + if spec is None or spec.mcp_config is None: + continue + return Check( + CHECK_MCP, + "missing", + _text_mcp_manual(h.name), + [ + _text_mcp_manual_path(spec.mcp_config.path, spec.mcp_config.scope), + *mcp_json_config(spec.mcp_config.shape).split("\n"), + ], + "snippet", + ) + return Check(CHECK_MCP, "skipped", _TEXT_MCP_NONE, None) + + spec = spec_by_id(host.id) + if spec is not None and spec.mcp_check and io.run is not None: + res = io.run(host.bin, spec.mcp_check, root, RunOptions(quiet=True)) + if res.started and res.error is None: + return Check(CHECK_MCP, "ok", f"registered for {host.name}", None) + personal = _personal_mcp_add(host.id) + fix = [f"{personal[0]} {_argv_line(personal[1])}"] if personal else None + return Check(CHECK_MCP, "missing", f"not registered for {host.name}", fix) + + +def _committed_file(root: str, rel: str) -> bool: + """True when a regular file stands at ``rel`` directly inside the repository root.""" + abs_path = Path(root) / rel + try: + if not abs_path.is_file(): + return False + except OSError: + return False + return resolved_within_root(root, abs_path) + + +def _mcp_shared_check(root: str, host: Optional[StartHost]) -> Check: + if host is None: + return Check(CHECK_MCP_SHARED, "n/a", _TEXT_MCP_SHARED_NO_HOST, None) + spec = spec_by_id(host.id) + if spec is None or not spec.mcp_shared_file or not spec.mcp_add: + return Check(CHECK_MCP_SHARED, "n/a", _TEXT_MCP_SHARED_OTHER, None) + file = spec.mcp_shared_file + if _committed_file(root, file): + return Check(CHECK_MCP_SHARED, "ok", f"{file} committed", None) + return Check( + CHECK_MCP_SHARED, + "shared-gap", + f"no {file} committed", + [mcp_command(spec, spec.mcp_add)], + ) + + +_HOOK_FIX = ["leji ci --hooks"] + + +def _hook_check(status: HookReport) -> Check: + if status.ownership == "no-git": + return Check(CHECK_HOOK, "missing", _TEXT_HOOK_NO_GIT, None) + if status.state == "current": + return Check( + CHECK_HOOK, + "ok", + f"runs leji checks before each commit: {status.path}", + None, + ) + if status.ownership == "personal": + if status.state == "absent": + return Check(CHECK_HOOK, "missing", _TEXT_HOOK_ABSENT_PERSONAL, list(_HOOK_FIX)) + return Check( + CHECK_HOOK, + "missing", + f"not leji-managed; add the block to {status.path}", + status.snippet.split("\n"), + "snippet", + ) + if status.ownership == "shared": + return Check( + CHECK_HOOK, + "shared-gap", + f"no leji block in {status.path}", + list(_HOOK_FIX), + ) + if status.ownership == "outside-root": + # A linked worktree's hooks live in the common git directory, outside this + # working tree. It is still per-clone state, but the writer refuses anything + # outside the repository root, so the only honest answer is the snippet. + return Check( + CHECK_HOOK, + "missing", + _text_hook_outside_root(status.path), + status.snippet.split("\n"), + "snippet", + ) + # Outside the repository entirely: reported with the snippet, never written. + return Check( + CHECK_HOOK, + "missing", + _text_hook_external(status.path), + status.snippet.split("\n"), + "snippet", + ) + + +# --- the report ----------------------------------------------------------- + +_READY_IDS = (CHECK_CLI, CHECK_MCP, CHECK_HOOK) +_READY_STATUSES = ("ok", "skipped", "n/a") + + +def run_preflight( + root: str, + manifest: Manifest, + host: Optional[StartHost], + detected: list[DetectedHost], + report: EcosystemReport, + io: HandoffIO, +) -> PreflightResult: + """Run every check, in the fixed order, writing nothing. The only child processes + are the bounded version probe and the host's own registration query, both through + the injectable IO.""" + root_abs = os.path.abspath(root) + hook = hook_status(root_abs, runner_argv(report)) + checks = [ + _cli_check(root_abs, manifest, report, io), + _mcp_check(root_abs, host, detected, io), + _mcp_shared_check(root_abs, host), + _hook_check(hook), + ] + ready = all(c.status in _READY_STATUSES for c in checks if c.id in _READY_IDS) + return PreflightResult(ready=ready, checks=checks, hook=hook) + + +def check_document(check: Check) -> dict[str, object]: + """What ``--json`` publishes for one check: exactly the four keys the document + promises, so a render-only field can never reach the scriptable contract.""" + return {"id": check.id, "status": check.status, "detail": check.detail, "fix": check.fix} + + +# The escape each label wears when color is on. The word is styled; its padding is not, +# so the columns line up whether or not the escapes are there. +_STATUS_COLOR = {"ok": "\x1b[32m", "you": "\x1b[33m", "team": "\x1b[36m", "n/a": "\x1b[2m"} +_COLOR_RESET = "\x1b[0m" + + +def color_decision(is_tty: bool, env: Mapping[str, str]) -> bool: + """Whether the Setup block may color its status words: a real terminal that has not + asked for plain text. ``NO_COLOR`` disables at any value, empty included, because the + convention is presence. A pure function of the two things it reads, decided once at + the CLI boundary and injected, so nothing downstream consults the process and every + piped byte is escape-free by construction.""" + return bool(is_tty) and "NO_COLOR" not in env and env.get("TERM") != "dumb" + + +def _summary_line(you: int, team: int) -> str: + if you and team: + return _text_summary_both(_text_summary_fixes(you), team) + if you: + return _text_summary_you(_text_summary_fixes(you)) + if team: + return _text_summary_team(_text_summary_fixes(team)) + return _TEXT_SUMMARY_COMPLETE + + +def render_preflight(checks: list[Check], color: bool = False) -> str: + """The Setup block: a heading, one fixed-column row per check with its fixes under + it, and one closing line counting what is owed. The counts come from the labels the + rows already printed, so the block can never say something its own rows do not.""" + lines = [_HEADING, ""] + you = 0 + team = 0 + for c in checks: + label = _STATUS_LABEL[c.status] + if label == "you": + you += 1 + elif label == "team": + team += 1 + word = f"{_STATUS_COLOR[label]}{label}{_COLOR_RESET}" if color else label + status = word + " " * (_STATUS_WIDTH - len(label)) + subject = _SUBJECT[c.id].ljust(_SUBJECT_WIDTH) + lines.append(f"{_MARGIN}{status}{_GUTTER}{subject}{_GUTTER}{c.detail}") + prompt = "" if c.fix_kind == "snippet" else "$ " + for fix in c.fix or []: + lines.append(f"{_FIX_INDENT}{prompt}{fix}") + lines.append("") + lines.append(f"{_MARGIN}{_summary_line(you, team)}") + return "\n".join(lines) + + +# --- the consented repairs ------------------------------------------------ + + +def offer_preflight_fixes( + root: str, + host: Optional[StartHost], + result: PreflightResult, + runner: list[str], + interactive: bool, + io: HandoffIO, +) -> None: + """Offer the personal repairs the report found, in the order it printed them. Only + state this user or this clone owns is ever offered: the host registration for this + user, and the per-clone hook. A shared gap is never offered, because accepting it + would write a file the repository commits.""" + if not interactive: + return + + def yes(question: str) -> bool: + return io.read_line(question, _OFFER_PROMPT).lower() in ("", "y", "yes") + + mcp = next((c for c in result.checks if c.id == CHECK_MCP), None) + personal = _personal_mcp_add(host.id) if host is not None else None + if mcp is not None and mcp.status == "missing" and personal is not None and io.run is not None: + assert host is not None + if yes(f"Register the Leji MCP server for {host.name} for your user?"): + res = io.run(personal[0], personal[1], root, RunOptions()) + if res.started and res.error is None: + print(f"Registered the Leji MCP server for {host.name}.") + else: + print(f"{personal[0]} did not register cleanly; run it yourself:") + print(f"{_FIX_INDENT}$ {personal[0]} {_argv_line(personal[1])}") + + hook = result.hook + if hook.ownership == "personal" and hook.state == "absent" and yes(_TEXT_OFFER_HOOK): + written = ensure_local_hook(root, runner) + if written.action == "manual": + print(_TEXT_OFFER_HOOK_FAILED) + print(f"\n{written.snippet or ''}") + else: + print(f"Wrote {written.path}; it runs before every commit in this clone.") diff --git a/packages/sdk-py/src/leji/renderlint.py b/packages/sdk-py/src/leji/renderlint.py new file mode 100644 index 0000000..333cce2 --- /dev/null +++ b/packages/sdk-py/src/leji/renderlint.py @@ -0,0 +1,452 @@ +"""The rendering-subset scan: given one markdown document, the constructs in it +that render differently across renderers. The Node SDK's ``lib/renderlint.ts`` is +the executable contract this port follows rule for rule — the rules are stated +there and here in the order they are applied, because a second statement of them (a +grammar, a spec paragraph) would be a source that drifts. + +The rules, in application order: + +1. **Excluded regions are found first.** YAML frontmatter (a leading block only, by + the SDK's own boundary), fenced code blocks, and HTML comments are scanned + before anything else, and nothing inside one is ever reported — text that merely + names a construct is not that construct. Code spans are excluded the same way, + inline, as the scan reaches them. +2. **Three constructs are reported**, and only these three: ``raw-html`` + (CommonMark HTML blocks and inline raw HTML; comments excepted, since Leji's own + generated-block markers are comments), ``footnote`` (the definition and + reference forms alike), and ``math-block`` (a PAIRED ``$$`` delimiter — a lone + one is prose). +3. **Backslash escapes are honored** for all three, per CommonMark: an escaped + ASCII punctuation character is a literal, so ``\\
`` is prose. +4. **Overlapping constructs resolve to the earliest-starting match**, which the + single left-to-right scan below produces by construction, and each match is + attributed to the line it OPENS on — a multi-line HTML block or ``$$`` block + reports once, at its opening line. +5. **One hit per (line, construct)**: the line is the unit, so a line carrying two + inline tags reports ``raw-html`` once. + +What is deliberately NOT reported: inline ``$`` (a currency amount spells it), +unknown fence info strings (the unhighlighted fallback is conforming), and loose +prose shapes. ``adoption/rendering.md`` is the profile these rules serve. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Optional + +from .findings import Finding +from .frontmatter import parse_frontmatter + +# The closed token set. Findings compare on it across the three SDKs; the message +# text does not. +RAW_HTML = "raw-html" +FOOTNOTE = "footnote" +MATH_BLOCK = "math-block" + +#: The one rule this scan produces. ``--strict`` promotes it (see export_cmd). +RENDER_UNSUPPORTED_RULE = "render-unsupported" + + +def render_unsupported_message(construct: str) -> str: + """The shared message template. Identical bytes in all three SDKs by + convention, outside the fixture contract by design.""" + return f"`{construct}` is outside the supported rendering subset; see adoption/rendering.md" + + +@dataclass(frozen=True) +class RenderHit: + """One reported construct: the token, and the 1-based line it opens on.""" + + line: int + construct: str + + +# CommonMark HTML block type 6: a line opening with one of these tags starts a +# block that runs to the next blank line, whatever else the line carries. The list +# is CommonMark's, verbatim, so a `
` closing a block on a later line is block +# content rather than a second construct. +_BLOCK_TAGS = frozenset( + ( + "address article aside base basefont blockquote body caption center col colgroup dd " + "details dialog dir div dl dt fieldset figcaption figure footer form frame frameset " + "h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu menuitem nav " + "noframes ol optgroup option p param search section summary table tbody td tfoot th " + "thead title tr track ul" + ).split(" ") +) + +# CommonMark HTML block type 1: these run to a line carrying a closing tag rather +# than to a blank line, because their content is raw text. +_RAW_TEXT_OPEN = re.compile(r"<(script|pre|style|textarea)([ \t>]|$)", re.IGNORECASE) +_RAW_TEXT_CLOSE = re.compile(r"", re.IGNORECASE) + +# Inline raw HTML, as CommonMark defines it: an open tag, a closing tag, a +# processing instruction, a declaration, or a CDATA section. (A comment is the +# sixth form and the excepted one, handled as an excluded region.) Each is matched +# at exactly the scan position. A declaration takes an ASCII letter of either case +# after `` and `` alike disappear into the renderer, +# which is precisely what the lint exists to warn about. +_OPEN_TAG = re.compile( + r"<[A-Za-z][A-Za-z0-9-]*" + r"(?:[ \t\r\n]+[A-Za-z_:][A-Za-z0-9_.:-]*" + r"(?:[ \t\r\n]*=[ \t\r\n]*(?:[^ \t\r\n\"'=<>`]+|'[^']*'|\"[^\"]*\"))?)*" + r"[ \t\r\n]*/?>" +) +_CLOSE_TAG = re.compile(r"") +_CDATA = re.compile(r"") +_DECLARATION = re.compile(r"") +_PROCESSING = re.compile(r"<\?[\s\S]*?\?>") +# Both footnote forms: the reference `[^id]`, and the definition `[^id]:`, whose +# opening bracket the same match covers. An unclosed `[^` is prose. +_FOOTNOTE_RE = re.compile(r"\[\^[^\][\n]+\]") + +# A fence opener: three or more backticks or tildes. A backtick fence's info string +# may carry no backtick, which is what keeps a code span off this path. +_FENCE_OPEN = re.compile(r"(`{3,}|~{3,})(.*)$") +# A fence closer: the same character, at least as long, alone on its line. +_FENCE_CLOSE = re.compile(r"(`{3,}|~{3,})[ \t]*$") +# A line opening with a tag name, for the type-6/type-7 block test. +_LINE_TAG = re.compile(r"|$)") +# Escapable per CommonMark: ASCII punctuation, and nothing else. +_ESCAPABLE = re.compile(r"[!-/:-@\[-`{-~]") +# A declaration opener, ` list[int]: + """Offsets at which each line begins, so an offset resolves to a line number.""" + starts = [0] + for i, ch in enumerate(text): + if ch == "\n": + starts.append(i + 1) + return starts + + +def _line_of(starts: list[int], offset: int) -> int: + """The 0-based line an offset falls on.""" + lo, hi = 0, len(starts) - 1 + while lo < hi: + mid = (lo + hi + 1) // 2 + if starts[mid] <= offset: + lo = mid + else: + hi = mid - 1 + return lo + + +def _line_text_at(text: str, starts: list[int], li: int) -> str: + """One line's text, without its line terminator (CRLF included).""" + end = starts[li + 1] if li + 1 < len(starts) else len(text) + line = text[starts[li] : end] + if line.endswith("\n"): + line = line[:-1] + return line[:-1] if line.endswith("\r") else line + + +def _line_end_of(text: str, starts: list[int], li: int) -> int: + """The offset just past a line's terminator.""" + return starts[li + 1] if li + 1 < len(starts) else len(text) + + +def _indent_of(line: str) -> int: + """Leading spaces, capped at the four that would make the line indented code.""" + n = 0 + while n < 4 and n < len(line) and line[n] in (" ", "\t"): + n += 1 + return n + + +def _escapable(text: str, i: int) -> bool: + """True when the character at ``i`` exists and is CommonMark-escapable.""" + return i < len(text) and _ESCAPABLE.match(text, i) is not None + + +def _block_regions(text: str, starts: list[int]) -> list[_Region]: + """The block pass: frontmatter, fenced code, HTML comments (all excluded), and + the HTML blocks that report as ``raw-html`` at their opening line. Line-based + and in document order, so a fence inside a comment is comment text and a comment + inside a fence is code — whichever opens first wins.""" + regions: list[_Region] = [] + n = len(text) + li = 0 + + # Frontmatter, by the SDK's own boundary (a LEADING block only; a `---` later in + # the document is a thematic break, and an unterminated block is prose). + fm = parse_frontmatter(text) + if len(fm.body) != n: + end = n - len(fm.body) + regions.append(_Region(start=0, end=end)) + li = len(starts) if end >= n else _line_of(starts, end) + + while li < len(starts): + line = _line_text_at(text, starts, li) + indent = _indent_of(line) + if indent >= 4: + li += 1 + continue + rest = line[indent:] + at = starts[li] + indent + + fence = _FENCE_OPEN.match(rest) + if fence is not None and (fence.group(1)[0] == "~" or "`" not in fence.group(2)): + close = li + 1 + while close < len(starts): + candidate = _line_text_at(text, starts, close) + m = _FENCE_CLOSE.match(candidate[_indent_of(candidate) :]) + if ( + m is not None + and m.group(1)[0] == fence.group(1)[0] + and len(m.group(1)) >= len(fence.group(1)) + ): + break + close += 1 + last = min(close, len(starts) - 1) + regions.append(_Region(start=starts[li], end=_line_end_of(text, starts, last))) + li = last + 1 + continue + + # A comment opening a line is CommonMark HTML block type 2: it runs to the + # line carrying `-->`, and the whole of that line belongs to it. Comments are + # the one HTML form the profile excepts, so the region reports nothing. + if rest.startswith("", at + 4) + last = len(starts) - 1 if close_at == -1 else _line_of(starts, close_at + 3) + regions.append(_Region(start=starts[li], end=_line_end_of(text, starts, last))) + li = last + 1 + continue + + # CommonMark HTML blocks 3, 4 and 5: a processing instruction, a declaration, + # or a CDATA section opening a line is a BLOCK, running to the line carrying + # its terminator (`?>`, `>`, `]]>`) and ending with that whole line — so what + # follows the terminator on it is block content, never a second construct. An + # unterminated one runs to the end of the document, as the comment form does. + # Type 4 takes an ASCII letter of either case, so `` disappears + # from the page. + terminator: Optional[str] = None + if rest.startswith("" + elif rest.startswith("" + elif _DECL_OPEN.match(rest) is not None: + terminator = ">" + if terminator is not None: + close_at = text.find(terminator, at) + last = len(starts) - 1 if close_at == -1 else _line_of(starts, close_at) + regions.append( + _Region(start=starts[li], end=_line_end_of(text, starts, last), construct=RAW_HTML) + ) + li = last + 1 + continue + + if _RAW_TEXT_OPEN.match(rest) is not None: + found = _RAW_TEXT_CLOSE.search(text, at) + last = len(starts) - 1 if found is None else _line_of(starts, found.start()) + regions.append( + _Region(start=starts[li], end=_line_end_of(text, starts, last), construct=RAW_HTML) + ) + li = last + 1 + continue + + # Type 6 (a known block tag opens the line) and type 7 (any complete tag + # alone on a line, which cannot interrupt a paragraph). Both run to the next + # blank line, so the tags closing them are block content. + tag = _LINE_TAG.match(rest) + previous_blank = li == 0 or _line_text_at(text, starts, li - 1).strip() == "" + is_block = (tag is not None and tag.group(1).lower() in _BLOCK_TAGS) or ( + previous_blank and _whole_line_is_tag(rest) + ) + if is_block: + close = li + 1 + while close < len(starts) and _line_text_at(text, starts, close).strip() != "": + close += 1 + regions.append( + _Region( + start=starts[li], end=_line_end_of(text, starts, close - 1), construct=RAW_HTML + ) + ) + li = close + continue + li += 1 + return regions + + +def _whole_line_is_tag(rest: str) -> bool: + """True when the line is one complete open or closing tag and nothing else.""" + for pattern in (_OPEN_TAG, _CLOSE_TAG): + m = pattern.match(rest) + if m is not None and rest[m.end() :].strip() == "": + return True + return False + + +def _skip_region(regions: list[_Region], i: int) -> int: + """The end of the region containing ``i``, or ``i`` when it is outside every + one.""" + for r in regions: + if r.start <= i < r.end: + return r.end + return i + + +def _run_length(text: str, i: int, ch: str) -> int: + """The length of the run of ``ch`` starting at ``i``.""" + n = 0 + while i + n < len(text) and text[i + n] == ch: + n += 1 + return n + + +def _after_code_span(text: str, regions: list[_Region], i: int) -> int: + """A code span: a backtick run closed by a run of exactly the same length. An + unclosed run is literal text, so the scan resumes just past it. Inline state + never crosses a block boundary: a candidate whose closer would lie beyond an + excluded or block region is unclosed AT that boundary, because the region ends + the paragraph the run opened in — so constructs after the region still report.""" + open_run = _run_length(text, i, "`") + j = i + open_run + while j < len(text): + if _skip_region(regions, j) != j: + break + if text[j] == "`": + run = _run_length(text, j, "`") + if run == open_run: + return j + run + j += run + continue + j += 1 + return i + open_run + + +def _next_math_delimiter(text: str, regions: list[_Region], start: int) -> int: + """The next unescaped ``$$`` at or after ``start``, or -1. A delimiter is a + closer only where a delimiter can be read: not inside a code span, not inside a + comment, and not on the far side of a block boundary — a pair no more bridges a + region than a code span does, so an open whose apparent mate sits in one of them + is unpaired, which is prose.""" + j = start + while j < len(text) - 1: + if _skip_region(regions, j) != j: + return -1 + if text[j] == "\\" and _escapable(text, j + 1): + j += 2 + continue + if text[j] == "`": + j = _after_code_span(text, regions, j) + continue + if text.startswith("", j + 4) + j = len(text) if close_at == -1 else close_at + 3 + continue + if text[j] == "$" and text[j + 1] == "$": + return j + j += 1 + return -1 + + +def _inline_html_end(text: str, i: int) -> int: + """An inline raw-HTML form at ``i``, as its end offset, or -1.""" + for pattern in (_CDATA, _PROCESSING, _DECLARATION, _CLOSE_TAG, _OPEN_TAG): + m = pattern.match(text, i) + if m is not None: + return m.end() + return -1 + + +def scan_render_constructs(text: str) -> list[RenderHit]: + """Every reported construct in one markdown document, ordered by (line, + construct) — the order the export's findings carry, and the tie-breaker that + keeps two constructs on one line deterministic across the three SDKs.""" + starts = _line_starts_of(text) + regions = _block_regions(text, starts) + seen: set[tuple[int, str]] = set() + hits: list[RenderHit] = [] + + def record(offset: int, construct: str) -> None: + line = _line_of(starts, offset) + 1 + key = (line, construct) + if key in seen: + return + seen.add(key) + hits.append(RenderHit(line=line, construct=construct)) + + for r in regions: + if r.construct is not None: + record(r.start, r.construct) + + # The inline pass: one left-to-right walk, so the earliest-starting match wins + # every overlap and each match is consumed whole. + i = 0 + while i < len(text): + skip = _skip_region(regions, i) + if skip != i: + i = skip + continue + c = text[i] + if c == "\\" and _escapable(text, i + 1): + i += 2 + continue + if c == "`": + i = _after_code_span(text, regions, i) + continue + if c == "<": + if text.startswith("", i + 4) + i = len(text) if close_at == -1 else close_at + 3 + continue + end = _inline_html_end(text, i) + if end != -1: + record(i, RAW_HTML) + i = end + continue + i += 1 + continue + if c == "[" and i + 1 < len(text) and text[i + 1] == "^": + m = _FOOTNOTE_RE.match(text, i) + if m is not None: + record(i, FOOTNOTE) + i = m.end() + continue + i += 1 + continue + if c == "$" and i + 1 < len(text) and text[i + 1] == "$": + close_at = _next_math_delimiter(text, regions, i + 2) + if close_at != -1: + record(i, MATH_BLOCK) + i = close_at + 2 + continue + # Unpaired: prose, and the scan carries on past it. + i += 2 + continue + i += 1 + + return sorted(hits, key=lambda h: (h.line, h.construct)) + + +def render_lint_findings(rel_path: str, text: str) -> list[Finding]: + """The scan as findings for one document: ``warning`` severity, the repository- + relative path the export carries it at, the opening line, and the token.""" + return [ + Finding( + rule=RENDER_UNSUPPORTED_RULE, + severity="warning", + message=render_unsupported_message(hit.construct), + path=rel_path, + line=hit.line, + construct=hit.construct, + ) + for hit in scan_render_constructs(text) + ] diff --git a/packages/sdk-py/src/leji/serve_cmd.py b/packages/sdk-py/src/leji/serve_cmd.py new file mode 100644 index 0000000..8f8de41 --- /dev/null +++ b/packages/sdk-py/src/leji/serve_cmd.py @@ -0,0 +1,512 @@ +"""The viewer's local preview server: the virtual mounts, the route table, and the +policy headers a browser sees. Every network import the CLI makes lives here and +nowhere else — the static export is a separate module whose transitive imports carry +none of them, which is what makes the export's no-network guarantee checkable rather +than asserted. Mirrors the Node SDK's `commands/serve.ts`. +""" + +from __future__ import annotations + +import os +import posixpath +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Callable, Optional + +from .findings import Finding +from .fsx import 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 + +# 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, + _assemble_sidebar, + _declares_inherits, + _relative_to_root, + _unresolved_profile_page, + resolved_profile_page, +) + +# The SPA shell's policy, sent as a response header on every chrome response so it +# holds for documents reached outside the shell too. Mirrors the meta in +# templates/viewer/index.html; keep the two in step. +CSP_CHROME = ( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; " + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'none'" +) + +# The policy for everything served out of the layer itself. `sandbox` with no +# tokens puts a /content/ document in an opaque origin with scripting off, so a +# governed file framed or opened directly is inert rather than same-origin code. +CSP_CONTENT = "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; sandbox" + +# The host names the local preview answers to. +LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]"}) + +# C0/C1 control characters, stripped from anything attacker-controlled before it +# reaches an operator's terminal through the access log. +_LOG_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") + +# A percent sign that does not begin a complete escape: the malformed form Node and +# Go reject with a 400 and Python's unquote silently keeps as a literal. +_BAD_PERCENT_RE = re.compile(r"%(?![0-9A-Fa-f]{2})") + + +def _log_safe(s: str) -> str: + """Neutralize control bytes in a logged request line: a raw request target can + carry terminal escape sequences, and the access log prints straight to a TTY.""" + return _LOG_CONTROL_RE.sub("?", s) + + +def _loopback_host(host: Optional[str]) -> bool: + """True when the Host header names the loopback interface: hostname only, since + the port a request arrives on is already fixed by the loopback bind. A missing + Host is accepted (an HTTP/1.0 client omits it).""" + if not host: + return True + name = host[: host.find("]") + 1] if host.startswith("[") else host.split(":")[0] + return name.lower() in LOOPBACK_HOSTS + + +CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".md": "text/markdown; 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", + ".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", +} + + +def _url_path_to_rel(url_path: str) -> str: + """A request URL path as a clean relative route key. + + Separators fold to "/" and the path is cleaned against a root, so one request + has one route key on any platform — os.path.normpath follows the host and + answered differently on Windows, missing every "content/" route test. + Canonicalization only; the mount enforces containment. + """ + return posixpath.normpath("/" + url_path.replace("\\", "/")).lstrip("/") + + +class _SafeViewerHandler(BaseHTTPRequestHandler): + """Virtual-mount handler, no symlinks: the generated viewer chrome + (`.leji/viewer/`) is served at `/`, and the layer's markdown (rootPath/) under + `/content/`. Everything else under `.leji/` is denied by name, so the private + roles are reachable by no URL at all. A local preview, not a host. + + The generated sidebar and the stored context index are served live from the + tree behind a fingerprint cache, so a long-running viewer never shows a + deleted or moved document.""" + + root_abs: str = "" + base: str = "" + content_abs: str = "" + viewer_abs: 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() + + def log_message(self, *args): # type: ignore[override] + pass + + _status_code: int = 200 + # The policy sent with the current response: the shell policy by default, the + # inert one once the route is known to be under the content mount. + _csp: str = CSP_CHROME + + def send_response(self, code, message=None): # type: ignore[override] + self._status_code = code + super().send_response(code, message) + + def end_headers(self) -> None: # type: ignore[override] + """Policy headers ride every response, not just the SPA shell: a document + served straight out of /content/ is same-origin and would otherwise run with + no policy at all. Overridden here so no response path can forget them.""" + self.send_header("x-content-type-options", "nosniff") + self.send_header("content-security-policy", self._csp) + super().end_headers() + + def _write_body(self, body: bytes) -> None: + """Write a response body, except on HEAD, which carries headers only. Node + and Go suppress the body themselves; BaseHTTPRequestHandler does not.""" + if self.command != "HEAD": + self.wfile.write(body) + + def _serve_from(self, mount_root: str, sub: str, inert: bool = False) -> None: + # Reject absolute/drive/parent-traversal paths, then realpath + commonpath-contain + # before any filesystem access. `inert` marks the layer's own content mount, + # whose files are never given an active content type however they are named. + # + # An embedded NUL (e.g. GET /content/%00) makes every path call below raise + # ValueError; Node and Go answer a clean 404, so answer one here rather than + # letting the handler blow up. + if "\x00" in sub: + self.send_response(404) + self.end_headers() + self._write_body(b"not found") + return + root_real = os.path.realpath(mount_root) + norm = os.path.normpath(sub).replace(os.sep, "/") if sub else "" + if norm in (".", "/"): + norm = "" + if norm and ( + os.path.isabs(sub) + or sub.startswith(("/", "\\")) + or re.match(r"^[A-Za-z]:", sub) is not None + or norm == ".." + or norm.startswith("../") + ): + self.send_response(403) + self.end_headers() + self._write_body(b"forbidden") + return + target = ( + os.path.join(root_real, "index.html") if norm == "" else os.path.join(root_real, norm) + ) + # The servable-roots whitelist: under `.leji/`, only `viewer/` is servable. + # Judged by name on the requested path and again on the resolved one, so a + # symlink under the content root cannot reach a private role either. + if not servable_path(self.root_abs, target): + self.send_response(404) + self.end_headers() + self._write_body(b"not found") + return + real = os.path.realpath(target) + try: + inside = os.path.commonpath([root_real, real]) == root_real + except ValueError: + inside = False + if not inside: + self.send_response(403) + self.end_headers() + self._write_body(b"forbidden") + return + if os.path.isdir(real): + real = os.path.realpath(os.path.join(real, "index.html")) + try: + inside = os.path.commonpath([root_real, real]) == root_real + except ValueError: + inside = False + if not inside: + self.send_response(403) + self.end_headers() + self._write_body(b"forbidden") + return + if not servable_path(self.root_abs, real): + self.send_response(404) + self.end_headers() + self._write_body(b"not found") + return + try: + with open(real, "rb") as fh: + body = fh.read() + except OSError: + self.send_response(404) + self.end_headers() + self._write_body(b"not found") + return + ext = os.path.splitext(real)[1].lower() + ct = CONTENT_TYPES.get(ext, "application/octet-stream") + if inert and ext in ACTIVE_EXTENSIONS: + ct = "text/plain; charset=utf-8" + self.send_response(200) + self.send_header("content-type", ct) + self.end_headers() + self._write_body(body) + + def _serve_text(self, content_type: str, body: str) -> None: + self.send_response(200) + self.send_header("content-type", content_type) + self.end_headers() + self._write_body(body.encode("utf-8")) + + def _tree_fingerprint(self) -> 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.""" + parts: list[str] = [] + + def add(rel: str) -> None: + try: + st = os.stat(os.path.join(self.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 "."): + add(rel) + return "\n".join(parts) + + def _refresh_cache(self, 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) + if load.manifest is None: + return None + idx = generate_index(self.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, []) + cache = { + "key": key, + "body": body, + "index_json": serialize_index(idx.index) if idx.index is not None else None, + } + type(self)._cache = cache + return cache + + def do_GET(self) -> None: # noqa: N802 + from urllib.parse import unquote, urlsplit + + try: + self._do_get_inner(unquote, urlsplit) + finally: + if self.access_log is not None: + log = type(self).access_log + if log is not None: + # The method and request target are attacker-controlled bytes; + # sanitized so terminal escapes never reach the operator's + # console. Node's parser rejects them outright and Go re-encodes + # the target, so this is the Python leg of the same guarantee. + log(f"{_log_safe(self.command)} {_log_safe(self.path)} {self._status_code}") + + # HEAD answers exactly like GET with the body suppressed (see _write_body), the + # way the Node and Go servers do; the default handler would 501 instead. + def do_HEAD(self) -> None: # noqa: N802 + self.do_GET() + + def _do_get_inner(self, unquote, urlsplit) -> None: + # One handler instance serves every request on a kept-alive connection, so + # the per-response policy starts from the shell default each time. + self._csp = CSP_CHROME + # Loopback binding alone does not stop DNS rebinding: a hostile page whose + # name resolves to 127.0.0.1 reaches this server with its own Host. Only the + # loopback names the viewer is actually addressed by are answered. The port is + # deliberately not part of the test: a rebound request carries the right port + # anyway, so matching it adds nothing. Don't "fix" this by checking it. + if not _loopback_host(self.headers.get("Host")): + self.send_response(403) + self.end_headers() + self._write_body(b"forbidden") + return + try: + # A malformed percent-encoding throws; answer 400 rather than crash. A + # `%` not followed by two hex digits is malformed too — Node's + # decodeURIComponent and Go's PathUnescape both reject it, while Python's + # unquote passes it through as a literal, so it is rejected explicitly. + raw_path = urlsplit(self.path).path + if _BAD_PERCENT_RE.search(raw_path): + raise ValueError("malformed percent-encoding") + url_path = unquote(raw_path, errors="strict") + except (ValueError, UnicodeDecodeError): + self.send_response(400) + self.end_headers() + self._write_body(b"bad request") + return + rel = _url_path_to_rel(url_path) + # The content mount serves the layer's own files; they get the inert policy. + if rel == "content" or rel.startswith("content/"): + self._csp = CSP_CONTENT + # Refuse any dotfile or VCS-internal segment in the REQUEST path: the .leji + # viewer dir is reached only through the mounts below, never by direct URL. + for seg in re.split(r"[/\\]", rel): + if seg == ".git" or (seg.startswith(".") and seg not in (".", "")): + self.send_response(404) + self.end_headers() + self._write_body(b"not found") + return + # The generated sidebar lives in the viewer dir but is served as if at the + # content root, so Docsify's basePath /content/ + _sidebar alias resolve it. + # Docsify fetches it once per page load, so it is rebuilt from the live tree + # on every request: a long-running server never shows a deleted or moved + # 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 + self._serve_from(self.viewer_abs, "_sidebar.md") + return + # The stored context index is served live (same fingerprint cache as the + # sidebar), so per-page classification badges never disagree with the tree. + if rel.startswith("content/"): + try: + load = load_manifest(self.root_abs) + idx_rel = ( + _relative_to_root( + effective_index_path(load.manifest), load.manifest["rootPath"] + ) + if load.manifest is not None + else None + ) + if ( + load.manifest is not 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 + ): + self._serve_text("application/json; charset=utf-8", cache["index_json"]) + return + except Exception: # noqa: BLE001 - fall through to the stored artifact + pass + # The generated Manifest page lives in the viewer dir (gitignored chrome) but + # is linked from the sidebar and fetched under the content root, like + # _sidebar.md. Reserved underscore name; served from the last generation. + if rel == "content/_manifest.md": + self._serve_from(self.viewer_abs, "_manifest.md") + 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 + # on disk is one half, and presenting it as the effective profile is the + # thing a consumer must not do. So this branch fails closed. If anything + # at all goes wrong, a file that declares `inherits` still gets a findings + # page; only a file that is not half a profile falls through to disk. + if sub.endswith(".md"): + repo_rel = f"{self.base}/{sub}" if self.base and self.base != "." else sub + page: Optional[str] = None + try: + load = load_manifest(self.root_abs) + page = ( + None + if load.manifest is None + else resolved_profile_page(self.root_abs, load.manifest, repo_rel) + ) + if ( + page is None + and load.manifest is None + and _declares_inherits(self.root_abs, repo_rel) + ): + page = _unresolved_profile_page( + repo_rel, + [ + Finding( + "artifact-parse", + "error", + "the layer manifest could not be read", + "leji.json", + ) + ], + ) + except Exception as e: # noqa: BLE001 - fail closed, never the raw file + page = ( + _unresolved_profile_page( + repo_rel, + [ + Finding( + "artifact-parse", + "error", + f"the viewer could not resolve this profile: {e}", + repo_rel, + ) + ], + ) + if _declares_inherits(self.root_abs, repo_rel) + else None + ) + if page is not None: + self._serve_text("text/markdown; charset=utf-8", page) + return + self._serve_from(self.content_abs, sub, inert=True) + return + # Everything else (`/`, /index.html, /assets/*) is viewer chrome. + self._serve_from(self.viewer_abs, rel) + + +def serve_viewer( + root: str, + port: int, + root_rel: str = "", + log: Optional[Callable[[str], None]] = 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 + (`.leji/viewer/`) at `/`, the layer's markdown (rootPath/) under `/content/`. + 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().""" + 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 + # The CLI passes a schema-validated rootPath, but a direct SDK caller could pass + # an escaping root_rel (e.g. ".."); refuse to mount content outside the layer root. + 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( + "_BoundSafeViewerHandler", + (_SafeViewerHandler,), + { + "root_abs": root_abs, + "base": base if base != "." else "", + "content_abs": content_abs, + "viewer_abs": viewer_abs, + "access_log": staticmethod(log) if log is not None else None, + "_cache": None, + "_cache_lock": threading.Lock(), + }, + ) + return ThreadingHTTPServer(("127.0.0.1", port), handler_cls) + + +def open_browser(url: str) -> None: + """Best-effort open of url in the default browser (--open / `leji view`). Never + raises or blocks: opening is a convenience, not part of serving. Mirrors the Node + opener (open / cmd start / xdg-open), spawned detached.""" + import subprocess + import sys + + if sys.platform == "darwin": + cmd = ["open", url] + elif sys.platform.startswith("win"): + cmd = ["cmd", "/c", "start", "", url] + else: + cmd = ["xdg-open", url] + try: + subprocess.Popen( # noqa: S603 - fixed opener, url is local + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError: + pass # opening the browser is best-effort diff --git a/packages/sdk-py/src/leji/status.py b/packages/sdk-py/src/leji/status.py index 7f6801d..986dc94 100644 --- a/packages/sdk-py/src/leji/status.py +++ b/packages/sdk-py/src/leji/status.py @@ -76,6 +76,28 @@ def _is_chrome(manifest: Manifest, rel: str) -> bool: ) +def _unindexed_in(root: str, manifest: Manifest, governed: set[str]) -> list[str]: + """Markdown under rootPath that no category index lists, given the governed set. + The one definition of "unindexed"; callers that already resolved the + assignments pass them in rather than resolving the tree twice.""" + root_dir = strip_slash(manifest["rootPath"]) or "." + return sorted( + rel + for rel in walk_tree(root, root_dir) + if rel not in governed and not _is_chrome(manifest, rel) + ) + + +def unindexed_paths(root: str, manifest: Manifest) -> list[str]: + """The unindexed set on its own, for callers that need the count without the + rest of the health report (the `index` generate nudge). Same machinery as + `status_report`, no second walker.""" + assignments, _findings, _shadowed, _skipped = resolve_category_assignments_with_skips( + root, manifest + ) + return _unindexed_in(root, manifest, set(assignments.keys())) + + def status_report(root: str, manifest: Manifest) -> StatusReport: """Build the status report: unindexed reference docs, dangling index entries, stale stored-index paths, and shadowed selectors. Pure computation; the CLI @@ -85,12 +107,7 @@ def status_report(root: str, manifest: Manifest) -> StatusReport: ) governed = set(assignments.keys()) - root_dir = strip_slash(manifest["rootPath"]) or "." - unindexed = sorted( - rel - for rel in walk_tree(root, root_dir) - if rel not in governed and not _is_chrome(manifest, rel) - ) + unindexed = _unindexed_in(root, manifest, governed) dangling = [ DanglingEntry(index_file=f.path or "", detail=f.message) diff --git a/packages/sdk-py/src/leji/update_pin.py b/packages/sdk-py/src/leji/update_pin.py new file mode 100644 index 0000000..34e6459 --- /dev/null +++ b/packages/sdk-py/src/leji/update_pin.py @@ -0,0 +1,449 @@ +"""``leji mounts update-pin``: move ONE declared mount's pin forward to a commit the +resolver has already witnessed, showing the comparison before anything is rewritten. + +Mirrors packages/sdk/src/commands/mounts-update-pin.ts byte-for-byte in behavior and +output. + +Offline by default: the target is the last successfully observed witness, never a +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. + +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, +so the three SDKs produce byte-identical output over any accepted layout. +""" + +from __future__ import annotations + +import datetime as dt +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from .findings import Finding +from .fsx import guard_root, verified_target_read, write_file_atomic_guarded +from .manifest import ( + MANIFEST_FILENAME, + Manifest, + replace_mount_pin_in_manifest_text, +) +from .mounts import ( + MountDecl, + compare_pins, + normalize_source, + refresh_witness, + resolve_default_ref, + retain_pin_in_store, + run_git, + select_comparison, + valid_tracking_ref, +) + +# 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. +MOUNT_UPDATE_PIN_REASONS: dict[str, str] = { + "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-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)" + ), + "mount-declaration-changed": "leji.json changed while the comparison ran; nothing was written", + "mount-pin-non-fast-forward-override": ( + "the pin was moved to a commit that is not a descendant of it" + ), +} + + +@dataclass +class UpdatePinResult: + """What the run did. ``refused`` is a stated outcome, never a crash.""" + + #: ``{name, sourceIdentity, trackingRef, from, to}`` in the order --json emits. + mount: dict[str, object] + pin_report: Optional[dict[str, object]] + #: 'updated' | 'unchanged' | 'dry-run' | 'refused' + action: str + override: bool + findings: list[Finding] = field(default_factory=list) + #: Stable code, present only when the run refused. + reason: Optional[str] = None + #: An internal refusal with no document to report: the manifest parsed and + #: validated, but the pin's own span could not be located or did not hold what + #: the comparison was computed against. Exit 2. + write_error: Optional[str] = None + + +def short_oid(oid: str) -> str: + """A pin at the length every human-facing line uses.""" + return oid[:12] + + +@dataclass(frozen=True) +class _Declaration: + """The manifest's OWN values for the addressed mount, snapshotted at load for the + freshness check the rewrite makes against the verified bytes. + + ``tracking_ref_present`` is carried BESIDE the value because absent and ``null`` + are two different declarations that a bare lookup collapses into the same + ``None``: without it, a mount whose ``trackingRef`` appeared as ``null`` while the + comparison ran would read as unchanged, and the pin would be spliced into a + declaration the schema no longer accepts. Node distinguishes the two for free + (``undefined`` vs ``null``); here it is explicit.""" + + name: str + source: str + pin: str + tracking_ref_present: bool + tracking_ref: object + + +def _declared_entry(manifest: Manifest, name: str) -> dict | None: + """The addressed mount's raw declaration, so its caller can read both the values + and which keys the manifest actually spelled.""" + for m in (manifest.get("federation") or {}).get("mounts") or []: + if m.get("name") == name: + return m + return None + + +def _now_iso(now: dt.datetime | None) -> str: + moment = dt.datetime.now(dt.timezone.utc) if now is None else now.astimezone(dt.timezone.utc) + return moment.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def update_pin_run( # noqa: C901 + root: str, + manifest: Manifest, + name: str, + to: str | None = None, + allow_non_fast_forward: bool = False, + fetch: bool = False, + dry_run: bool = False, + now: dt.datetime | 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.""" + # One observation time for the whole run, as ``status`` takes one for its whole + # execution. + observed_at = _now_iso(now) + entry = _declared_entry(manifest, name) + mount = ( + None + if entry is None + else MountDecl( + name=entry["name"], + source=entry["source"], + pin=entry["pin"], + tracking_ref=entry.get("trackingRef"), + ) + ) + + def refuse( + reason: str, + partial: dict[str, object] | None = None, + pin_report: dict[str, object] | None = None, + ) -> UpdatePinResult: + block: dict[str, object] = { + "name": name, + "sourceIdentity": normalize_source(mount.source) if mount else None, + "trackingRef": mount.tracking_ref if mount else None, + "from": mount.pin if mount else None, + "to": None, + } + block.update(partial or {}) + return UpdatePinResult( + mount=block, + pin_report=pin_report, + action="refused", + override=False, + reason=reason, + findings=[Finding(reason, "error", MOUNT_UPDATE_PIN_REASONS.get(reason, reason), name)], + ) + + if mount is None or entry is None: + return refuse("mount-unknown") + + # (a) The declaration snapshot: the manifest's OWN values, kept for the + # freshness check the rewrite makes against the verified bytes. ``trackingRef`` + # is snapshotted as declared, presence included — absent must stay absent — + # while the ref the comparison actually uses is tracked separately. + declaration = _Declaration( + name=mount.name, + source=mount.source, + pin=mount.pin, + tracking_ref_present="trackingRef" in entry, + tracking_ref=entry.get("trackingRef"), + ) + identity = normalize_source(mount.source) + + def degraded(reason: str, compared_ref: str | None) -> dict[str, object]: + return { + "state": "unknown", + "comparedRef": compared_ref, + "comparisonRepository": None, + "witnessProvenance": None, + "ancestryComplete": False, + "reason": reason, + "observedAt": observed_at, + } + + if identity is None: + return refuse( + "mount-source-unnormalizable", + None, + degraded("mount-source-unnormalizable", mount.tracking_ref), + ) + + if mount.tracking_ref is not None: + if not valid_tracking_ref(mount.tracking_ref): + return refuse( + "mount-tracking-ref-invalid", + None, + degraded("mount-tracking-ref-invalid", mount.tracking_ref), + ) + effective_ref = mount.tracking_ref + elif not fetch: + # Offline, the schema's "absent means the source's default branch" cannot be + # honoured: resolving it needs the network this run was not given. + return refuse("mount-no-tracking-ref", None, degraded("mount-no-tracking-ref", None)) + else: + resolved = resolve_default_ref(mount.source) + if resolved.ref is None or not valid_tracking_ref(resolved.ref): + return refuse( + "mount-default-ref-unavailable", + None, + degraded("mount-default-ref-unavailable", None), + ) + effective_ref = resolved.ref + + # (b i, ii) ``--fetch``, declared source only, in order: retain the CURRENT pin so + # 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) + if retained.repo is None: + return refuse( + "mount-store-fetch-failed", + None, + degraded("mount-store-fetch-failed", effective_ref), + ) + 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): + return refuse( + "mount-witness-refresh-failed", + None, + degraded("mount-witness-refresh-failed", effective_ref), + ) + + # (c) The comparison repository and the ONE witness snapshot this run uses for + # the default target, the report, and the gate alike. + selection = select_comparison(root, mount, effective_ref) + if selection.reason is not None: + return refuse(selection.reason, None, degraded(selection.reason, effective_ref)) + repo = str(selection.repo) + tip_oid = str(selection.tip_oid) + + # (d) The target: an explicit ``--to`` must be held by the repository the + # comparison ran in; otherwise the witness tip itself. + target = tip_oid if to is None else to + if to is not None and not run_git(["-C", repo, "cat-file", "-e", f"{to}^{{commit}}"]).ok: + report = degraded("mount-target-unavailable", effective_ref) + report["comparisonRepository"] = selection.comparison_repository + report["witnessProvenance"] = selection.witness_provenance + return refuse("mount-target-unavailable", {"to": to}, report) + + # (e) The report, computed from the same snapshot ``status`` would report from. + comparison = compare_pins(repo, mount.pin, tip_oid) + mount_block: dict[str, object] = { + "name": mount.name, + "sourceIdentity": identity, + "trackingRef": mount.tracking_ref, + "from": mount.pin, + "to": target, + } + if comparison.reason is not None: + report = degraded(comparison.reason, effective_ref) + report["comparisonRepository"] = selection.comparison_repository + report["witnessProvenance"] = selection.witness_provenance + return refuse(comparison.reason, {"to": target}, report) + pin_report: dict[str, object] = { + "state": comparison.state, + "behind": comparison.behind, + "ahead": comparison.ahead, + "comparedRef": effective_ref, + "comparisonRepository": selection.comparison_repository, + "witnessProvenance": selection.witness_provenance, + "ancestryComplete": comparison.ancestry_complete, + "observedAt": observed_at, + } + + def settled(action: str, override: bool, findings: list[Finding]) -> UpdatePinResult: + return UpdatePinResult( + mount=mount_block, + pin_report=pin_report, + action=action, + override=override, + findings=findings, + ) + + # 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. + def refuse_settled( + reason: str, override: bool = False, warnings: list[Finding] | None = None + ) -> UpdatePinResult: + result = settled( + "refused", + override, + [ + Finding(reason, "error", MOUNT_UPDATE_PIN_REASONS.get(reason, reason), mount.name), + *(warnings or []), + ], + ) + result.reason = reason + return result + + # (f) The gate. Nothing to move is its own success, checked before ancestry: + # asking whether a commit is an ancestor of itself is not the question. + if target == mount.pin: + return settled("unchanged", False, []) + override = False + ancestor = run_git(["-C", repo, "merge-base", "--is-ancestor", mount.pin, target]) + if not ancestor.ok: + # Exit 1 is the answer "no"; anything else is the repository unable to answer. + # A "no" from truncated history is not an answer either, so an incomplete + # repository never yields the not-fast-forward refusal — nor does the + # override bypass it. + if ancestor.code != 1 or not comparison.ancestry_complete: + return refuse_settled("mount-ancestry-incomplete") + if to is None or not allow_non_fast_forward: + return refuse_settled("mount-pin-not-fast-forward") + override = True + warnings = ( + [ + Finding( + "mount-pin-non-fast-forward-override", + "warning", + MOUNT_UPDATE_PIN_REASONS["mount-pin-non-fast-forward-override"], + mount.name, + ) + ] + if override + else [] + ) + + # (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) + if retained_target.repo is None: + return refuse_settled("mount-store-fetch-failed", override, warnings) + + # (g) ``--dry-run`` stops here. The store and network acts ``--fetch`` was asked + # for have already happened; only the manifest rewrite is suppressed. + if dry_run: + return settled("dry-run", override, warnings) + + # (h) The rewrite, through the verified read the trust boundary requires. + root_real = guard_root(root) + manifest_abs = str(Path(root) / MANIFEST_FILENAME) + read = verified_target_read(root_real, manifest_abs, None) + if read.status != "regular": + result = settled("refused", override, warnings) + result.write_error = ( + f'refusing to write through a symlink that escapes the target: "{MANIFEST_FILENAME}"' + ) + return result + original = read.text() + # The bytes that were verified decide whether the declaration this comparison + # was computed against is still the declaration on disk. Containment says WHICH + # file was read; only this says it still says the same thing. + if not _declaration_unchanged(original, declaration): + return refuse_settled("mount-declaration-changed", override, warnings) + try: + rewritten, changed = replace_mount_pin_in_manifest_text( + original, mount.name, mount.pin, target + ) + except RuntimeError as e: + result = settled("refused", override, warnings) + result.write_error = str(e) + return result + if changed: + verdict = write_file_atomic_guarded(root_real, manifest_abs, None, rewritten) + if not verdict.ok: + result = settled("refused", override, warnings) + result.write_error = f'refusing to write outside the repository: "{MANIFEST_FILENAME}"' + return result + return settled("updated", override, warnings) + + +def _declaration_unchanged(text: str, declaration: _Declaration) -> bool: + """Does the verified manifest text still declare the mount this run compared? + Only the four fields that decided the selected repository, the target and the + splice are compared; ownership and routing metadata decide none of them. + + ``trackingRef`` is compared on PRESENCE as well as value: present-and-equal, or + absent-and-still-absent, and nothing in between. A mount that gained a + ``trackingRef`` of ``null`` (or any other spelling) since the comparison is a + changed declaration, not an unchanged one.""" + # These are the only manifest bytes this command reads without the schema having + # cleared them first — the file may have been replaced with anything since the + # comparison — so every shape but the one being looked for is simply "changed". + try: + parsed = json.loads(text) + except ValueError: + return False + federation = parsed.get("federation") if isinstance(parsed, dict) else None + mounts = federation.get("mounts") if isinstance(federation, dict) else None + if not isinstance(mounts, list): + return False + current = next( + (m for m in mounts if isinstance(m, dict) and m.get("name") == declaration.name), None + ) + if current is None: + return False + return ( + current.get("source") == declaration.source + and current.get("pin") == declaration.pin + # Absent must stay absent: under ``--fetch`` the ref actually used may be the + # source's advertised default, which the manifest never spelled. + and ("trackingRef" in current) == declaration.tracking_ref_present + and current.get("trackingRef") == declaration.tracking_ref + ) diff --git a/packages/sdk-py/src/leji/viewer_cmd.py b/packages/sdk-py/src/leji/viewer_cmd.py index 97494fe..f2cd9d3 100644 --- a/packages/sdk-py/src/leji/viewer_cmd.py +++ b/packages/sdk-py/src/leji/viewer_cmd.py @@ -1,8 +1,13 @@ -"""Static viewer generation and local preview, mirroring the Node SDK. +"""Static viewer generation, mirroring the Node SDK: the chrome generation and the +layer helpers both of its consumers share. Presentation is non-normative; this is the reference projection of -context-index.json into a browsable surface (Docsify), plus a localhost-only -static server so `leji viewer serve` works the same in both ecosystems. +context-index.json into a browsable surface (Docsify). The two consumers live in +their own modules, so what each one drags in is visible in the import graph rather +than buried in one file: ``serve_cmd`` keeps the local preview server and every +network import with it, and ``export_cmd`` writes the static site — its transitive +import set carries no network module at all, which is the structural half of the +export's no-network guarantee and is asserted as such. """ from __future__ import annotations @@ -10,32 +15,47 @@ import json import os import posixpath -import threading import unicodedata from dataclasses import dataclass, field -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import Callable, Optional +from typing import Optional import re from .findings import Finding from .frontmatter import parse_frontmatter -from .fsx import resolved_within_root, strip_slash, under_path, walk_tree -from .indexgen import generate_index, serialize_index +from .fsx import ( + open_verified_source, + read_all, + resolved_path, + resolved_path_under, + resolved_within_root, + strip_slash, + under_path, + verified_target_read, + walk_tree, + write_file_guarded, +) +from .indexgen import generate_index +from .layout import ( + LEJI_DIR, + VIEWER_REL, + role_abs, + servable_path, + writable_target, +) from .layer import ( ScannedProfile, resolve_agent_profile, resolve_category_assignments, scan_agent_profiles, - scan_profile_set, + scan_profile_set_with, ) from .manifest import ( CATEGORY_IDS, Manifest, effective_agent_profiles_path, effective_index_path, - load_manifest, ) from .mounts import mount_status, read_text_within from .schemas import templates_dir @@ -67,15 +87,26 @@ # Vendored assets loaded only when mermaid is enabled; skipped otherwise. MERMAID_ASSETS = frozenset({"mermaid.min.js", "docsify-mermaid.js"}) -# The Leji brand blue, the viewer's default accent when no viewer.theme.primary is -# set. DEFAULT_LOGO is the vendored Leji mark. -DEFAULT_THEME_COLOR = "#223F93" -DEFAULT_LOGO = "/assets/leji-logo.svg" - -# A CSS color safe to hand to the page: a hex color or a bare color keyword. The -# accent reaches a stylesheet as a custom-property value, so anything with -# punctuation in it is a CSS-injection sink rather than a color. -SAFE_CSS_COLOR = re.compile(r"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$") +# The Leji brand green, the viewer's default accent when no viewer.theme.primary +# is set. +DEFAULT_THEME_COLOR = "#009F71" + +# The base every URL the generated chrome emits is written against: "/" for the +# local server (the app root, the served flavor's unchanged contract) and "" for an +# export, whose references then resolve against the page itself so the tree hosts +# correctly under a subpath. It is a generation parameter, never a post-hoc rewrite +# of emitted HTML: one code path, two invocations. index.html is the only artifact +# that exists in two flavors — everything else under the chrome is flavor-neutral. +SERVED_BASE = "/" +EXPORT_BASE = "" + +# The one accent format the viewer accepts: a hex color at a length CSS actually +# defines (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). The accent reaches a stylesheet as a +# custom-property value, so anything with punctuation in it is a CSS-injection sink +# rather than a color; hex-only also keeps one canonical form across the three SDKs +# and the schema. Matched with fullmatch, never match: `$` would let a trailing +# newline through where the other SDKs reject it. +SAFE_CSS_COLOR = re.compile(r"#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})") # The template's `{{NAME}}` substitution sites. PLACEHOLDER_RE = re.compile(r"\{\{([A-Z_]+)\}\}") @@ -87,69 +118,104 @@ # # `.svg` is deliberately NOT here. It stays a first-class asset (viewer.logo and # viewer.favicon may point at one under the context root) because the inertness -# comes from the policy, not the content type: every /content/ response carries the -# CSP_CONTENT sandbox below, so an SVG navigated to or framed lands in an opaque +# comes from the policy, not the content type: every /content/ response carries +# serve_cmd's CSP_CONTENT sandbox, so an SVG navigated to or framed lands in an opaque # origin with scripting off, and an SVG loaded as an never runs script # whatever its type. ACTIVE_EXTENSIONS = frozenset({".html", ".htm", ".js", ".mjs", ".xhtml"}) -# The SPA shell's policy, sent as a response header on every chrome response so it -# holds for documents reached outside the shell too. Mirrors the meta in -# templates/viewer/index.html; keep the two in step. -CSP_CHROME = ( - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " - "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; " - "object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'none'" -) - -# The policy for everything served out of the layer itself. `sandbox` with no -# tokens puts a /content/ document in an opaque origin with scripting off, so a -# governed file framed or opened directly is inert rather than same-origin code. -CSP_CONTENT = "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; sandbox" - -# The host names the local preview answers to. -LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "[::1]"}) - -# C0/C1 control characters, stripped from anything attacker-controlled before it -# reaches an operator's terminal through the access log. -_LOG_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") - - -def _log_safe(s: str) -> str: - """Neutralize control bytes in a logged request line: a raw request target can - carry terminal escape sequences, and the access log prints straight to a TTY.""" - return _LOG_CONTROL_RE.sub("?", s) - - -def _loopback_host(host: Optional[str]) -> bool: - """True when the Host header names the loopback interface: hostname only, since - the port a request arrives on is already fixed by the loopback bind. A missing - Host is accepted (an HTTP/1.0 client omits it).""" - if not host: - return True - name = host[: host.find("]") + 1] if host.startswith("[") else host.split(":")[0] - return name.lower() in LOOPBACK_HOSTS - def _resolve_theme_color(manifest: Manifest, findings: list[Finding]) -> str: - """The viewer accent: viewer.theme.primary when it is a plain CSS color, else - the Leji default with a warning. Never the authored value unchecked.""" + """The viewer accent: viewer.theme.primary when it is a hex color, else the + Leji default with a warning. Never the authored value unchecked.""" configured = ((manifest.get("viewer") or {}).get("theme") or {}).get("primary") if not configured: return DEFAULT_THEME_COLOR - if SAFE_CSS_COLOR.match(configured): + if SAFE_CSS_COLOR.fullmatch(configured): return configured findings.append( Finding( "viewer-theme-invalid", "warning", - f'viewer.theme.primary "{configured}" is not a plain CSS color ' - f"(hex or keyword); using {DEFAULT_THEME_COLOR}", + f'viewer.theme.primary "{configured}" is not a hex color ' + f"(#RGB, #RGBA, #RRGGBB, or #RRGGBBAA); using {DEFAULT_THEME_COLOR}", ) ) return DEFAULT_THEME_COLOR +# The bare hex an accent reduces to once the leading `#` is out of the way; the +# length check follows. +HEX_DIGITS = re.compile(r"^[0-9a-f]+$") + + +def _parse_accent_color(value: str) -> Optional[tuple[int, int, int]]: + """The accent as opaque sRGB channels, or None for a value that names no color + the generator can resolve — a keyword, `currentColor`, a malformed hex. Accepts + 3/4/6/8 digit hex, the only form the accent can take; an accent carrying alpha is + composited over white, the viewer's content background, which is the only backdrop + knowable at generation time (the accent itself keeps its authored alpha everywhere + it is used — this composite decides text color, nothing that renders).""" + raw = value.strip().lower() + if not raw.startswith("#"): + return None + digits = raw[1:] + if not HEX_DIGITS.match(digits): + return None + if len(digits) in (3, 4): + full = "".join(c * 2 for c in digits) + elif len(digits) in (6, 8): + full = digits + else: + return None + + def channel(i: int) -> int: + return int(full[i * 2 : i * 2 + 2], 16) + + alpha = channel(3) / 255 if len(full) == 8 else 1.0 + + def over(c: int) -> int: + # Python's round() breaks ties to even where the other SDKs break them + # upward, so the composite truncates an explicit +0.5 instead. + return int(c * alpha + 255 * (1 - alpha) + 0.5) + + return (over(channel(0)), over(channel(1)), over(channel(2))) + + +def _relative_luminance(rgb: tuple[int, int, int]) -> float: + """WCAG relative luminance: linearized sRGB channels, weighted.""" + + def linear(c: int) -> float: + s = c / 255 + return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4 + + return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]) + + +def _contrast_ratio(a: float, b: float) -> float: + """WCAG contrast ratio between two relative luminances.""" + return (max(a, b) + 0.05) / (min(a, b) + 0.05) + + +def _mermaid_text_color(theme_color: str) -> str: + """The mermaid node-text color for an accent, computed here rather than in the + browser: the viewer's boot script sees only what the config block carries, while + this side can resolve every color form viewer.theme.primary accepts. Whichever of + #1a1a1a and #ffffff contrasts more with the accent, or #000000 when neither clears + WCAG AA (4.5:1) — a mid-gray accent, where the extra half-stop of black is the best + text color available. An accent this cannot resolve keeps the dark default, which + is also the boot script's fallback. Mirrors the Node SDK's mermaidTextColor.""" + rgb = _parse_accent_color(theme_color) + if rgb is None: + return "#1a1a1a" + accent = _relative_luminance(rgb) + on_dark = _contrast_ratio(_relative_luminance((0x1A, 0x1A, 0x1A)), accent) + on_light = _contrast_ratio(1.0, accent) + if on_dark < 4.5 and on_light < 4.5: + return "#000000" + return "#1a1a1a" if on_dark >= on_light else "#ffffff" + + def _resolve_viewer_rel(root: str, root_path: str, value: str) -> Optional[str]: """Resolve a viewer-configured file path to a rootPath-relative rel. The canonical form is rootPath-relative, but a repository-root-relative path @@ -188,15 +254,20 @@ def _effective_homepage(root: str, manifest: Manifest, findings: list[Finding]) return strip_slash(configured) -def _resolve_logo(root: str, root_path: str, logo: Optional[str]) -> str: +def _default_logo(base: str) -> str: + """The vendored Leji mark, as the given base addresses it.""" + return f"{base}assets/leji-logo.svg" + + +def _resolve_logo(root: str, root_path: str, logo: Optional[str], base: str) -> str: """Resolve the viewer logo URL: a configured path is served from the content mount (or used as-is when absolute); unset falls back to the vendored mark.""" if not logo: - return DEFAULT_LOGO + return _default_logo(base) if logo.startswith("/") or re.match(r"^https?://", logo): return logo rel = _resolve_viewer_rel(root, root_path, logo) - return f"/content/{rel if rel is not None else strip_slash(logo)}" + return f"{base}content/{rel if rel is not None else strip_slash(logo)}" def resolve_viewer_port(manifest: Manifest, flag_port: Optional[int] = None) -> int: @@ -259,8 +330,18 @@ def _md_link_text(s: str) -> str: def _md_link_dest(s: str) -> str: - """Escape a string for a Markdown link destination (`(...)`): backslash, parens.""" - return re.sub(r"[\\()]", lambda m: "\\" + m.group(0), s) + """Escape a string for a Markdown link destination (`(...)`): backslash, parens. + Destinations are emitted app-root absolute (leading slash): with the viewer's + relativePath routing, a bare rootPath-relative destination would re-resolve + against whatever nested route is current and double-prefix; leading-slash links + are exempt from relative resolution by Docsify's contract. Idempotent: leading + slashes are stripped first, so an already-absolute destination (the sidebar + builders are public API) never becomes `//...`, which Docsify routes as an + external protocol-relative URL. Empty input stays empty, never a bare `/`.""" + escaped = re.sub(r"[\\()]", lambda m: "\\" + m.group(0), s.lstrip("/")) + if not escaped: + return "" + return "/" + escaped @dataclass @@ -525,6 +606,11 @@ def build_sidebar_groups(root: str, manifest: Manifest, entries: list[dict]) -> rel = _relative_to_root(p.rel_path, manifest["rootPath"]) if rel is None: continue + # A declared profiles directory can name a private role; its files are not + # servable, so neither is the label lifted out of one. The route would 404 + # anyway — this keeps the bytes out of the sidebar that links it. + if not _servable_source(root, p.rel_path): + continue name = (p.frontmatter or {}).get("name") title = ( name.strip() @@ -611,7 +697,8 @@ def _reference_tree(root: str, manifest: Manifest, governed_paths: set[str]) -> """The browse zone: every markdown file under rootPath that is NOT governed (in the index), NOT viewer/layer chrome (boot profile, agent profiles, the category index files, overview.md, the generated _sidebar.md), and stays under - rootPath. The `.leji` viewer dir is skipped by the walk itself.""" + rootPath. Generated artifacts live in the root `.leji/`, which the walk skips as + a dot-dir even when rootPath is `.`.""" root_dir_rel = strip_slash(manifest["rootPath"]) or "." profiles_dir = effective_agent_profiles_path(manifest) index_files: set[str] = set() @@ -787,13 +874,13 @@ def build_manifest_page(manifest: Manifest, statuses: list[dict]) -> str: viewer_cfg = manifest.get("viewer") or {} title = viewer_cfg.get("title") or manifest["name"] lines: list[str] = [ - f"# {_esc(title)} — Manifest", + f"# {_esc(title)}: Manifest", "", "A human-readable view of this layer's `leji.json`.", "", "> **Declared** values come straight from the manifest. **Observed** values " - "(mount availability and drift) are read from local projections and Git objects " - "— no network fetch is performed.", + "(mount availability and drift) are read from local projections and Git objects" + "; no network fetch is performed.", "", "## Identity", "", @@ -814,7 +901,7 @@ def build_manifest_page(manifest: Manifest, statuses: list[dict]) -> str: claimed = (manifest.get("conformance") or {}).get("claimedLevel") if claimed: lines.append( - f"| Conformance | claims `{_esc(claimed)}` — run `leji conformance` to verify |" + f"| Conformance | claims `{_esc(claimed)}` (run `leji conformance` to verify) |" ) else: lines.append("| Conformance | no level claimed |") @@ -934,7 +1021,7 @@ def _index_count(c: str) -> int: ) lines.append("") lines.append( - "> `not hydrated` / `unknown` are normal degraded reads — ordinary " + "> `not hydrated` / `unknown` are normal degraded reads; ordinary " "validation never fails just because a mount is unavailable (opt-in " "federation enforcement is separate). Run `leji mounts hydrate`, then " "regenerate the viewer to refresh." @@ -943,7 +1030,7 @@ def _index_count(c: str) -> int: if roled: lines.extend(["", "**Roles**", ""]) for d in roled: - lines.append(f"- **{_esc(d['name'])}** — {_esc(d['role'])}") + lines.append(f"- **{_esc(d['name'])}**: {_esc(d['role'])}") lines.append("") return "\n".join(lines) @@ -978,7 +1065,7 @@ def _unresolved_profile_page(rel_path: str, findings: list[Finding]) -> str: authored file. There is no effective profile to show, and presenting the derived file as if there were would be the error the finding names.""" lines = [ - f"# {_esc(rel_path)} — unresolved profile", + f"# {_esc(rel_path)}: unresolved profile", "", f"> **This profile does not resolve.** {_code_span(rel_path)} declares " "`inherits`, and the inheritance cannot be resolved, so the layer has no " @@ -1018,7 +1105,7 @@ def _render_resolved_profile(profiles: list[ScannedProfile], derived: ScannedPro raw_title = effective.get("name") title = raw_title if isinstance(raw_title, str) else derived_id lines: list[str] = [ - f"# {_esc(title)} — resolved profile", + f"# {_esc(title)}: resolved profile", "", f"> **Resolved profile.** {_code_span(derived.rel_path)} declares " f"`inherits: {_esc(base_id)}`, so this page is the effective profile: posture " @@ -1034,7 +1121,7 @@ def _render_resolved_profile(profiles: list[ScannedProfile], derived: ScannedPro ] for key, value in effective.items(): if not isinstance(value, list): - lines.append(f"- **{_esc(key)}** — {_profile_value(value)}") + lines.append(f"- **{_esc(key)}**: {_profile_value(value)}") continue # Composed posture: label every entry with the profile that supplied it. base_value = base_fm.get(key) @@ -1044,7 +1131,7 @@ def _render_resolved_profile(profiles: list[ScannedProfile], derived: ScannedPro lines.append(" - (empty)") for entry in value: source = base_id if _json_value(entry) in from_base else derived_id - lines.append(f" - {_profile_value(entry)} — from `{_esc(source)}`") + lines.append(f" - {_profile_value(entry)} (from `{_esc(source)}`)") lines.extend(["", "## Effective body", ""]) # The resolver's markers stay in the page (they are what a consumer reads); each # gets a visible line beside it so the rendered view names its source too. @@ -1075,6 +1162,63 @@ def _declares_inherits(root: str, repo_rel: str) -> bool: return False +def _servable_source(root: str, repo_rel: str) -> bool: + """True when the layer file at ``repo_rel`` may be read into something served or + exported: judged by the servable-roots whitelist as requested AND after symlink + resolution, the same pair of checks ``_serve_from`` makes on a response. A path + that resolves into a private ``.leji/`` role fails, however it was spelled.""" + root_abs = resolved_path(str(Path(root).resolve())) + if root_abs is None: + return False + abs_path = role_abs(root_abs, repo_rel) + if not servable_path(root_abs, abs_path): + return False + real = resolved_path_under(root_abs, abs_path) + return real is not None and servable_path(root_abs, real) + + +def _servable_profile_text(root_abs: str, repo_rel: str) -> Optional[str]: + """A profile source read the way check-before-act requires: the requested path is judged, its + RESOLVED path is judged, and the bytes come from the descriptor opened on that + resolved path and proved a regular file — so nothing swapped between the check and + the read (a file, or any directory above it, becoming a symlink) changes what is + composed into a served or exported page. None for anything refused.""" + abs_path = role_abs(root_abs, repo_rel) + if not servable_path(root_abs, abs_path): + return None + src = open_verified_source( + abs_path, + lambda real: ( + servable_path(root_abs, real) + and (real == root_abs or real.startswith(root_abs + os.sep)) + ), + root_abs, + ) + if src.fd is None: + return None + try: + return read_all(src.fd).decode("utf-8") + except UnicodeDecodeError: + return None + finally: + os.close(src.fd) + + +def _servable_profile_set(root: str, manifest: Manifest) -> list[ScannedProfile]: + """The profile set as the viewer may render it: every source read through + :func:`_servable_profile_text`, so no profile living in — or symlinked into — a + private ``.leji/`` role is composed into a served page or an exported one, and the + bytes composed are the bytes that passed the check. Dropped silently, exactly as + the content walk drops unservable content; the scan itself stays total, so + validation still reports on those files.""" + root_abs = resolved_path(str(Path(root).resolve())) + if root_abs is None: + return [] + return scan_profile_set_with( + root, manifest, lambda rel_path: _servable_profile_text(root_abs, rel_path) + ) + + def resolved_profile_page(root: str, manifest: Manifest, repo_rel: str) -> Optional[str]: """The page for ``repo_rel`` when it is an agent profile that declares ``inherits``, else None (every other document is served from disk as authored). @@ -1092,10 +1236,16 @@ def resolved_profile_page(root: str, manifest: Manifest, repo_rel: str) -> Optio bound = repo_rel in ((manifest.get("agents") or {}).values()) if not bound and not under_path(repo_rel, effective_agent_profiles_path(manifest)): return None + # The whitelist, judged before this file is read into a page: a profile that + # resolves into a private `.leji/` role is not the viewer's to render. None + # hands the request back to the content walk, which refuses it the same way + # it refuses any unservable file — this branch never becomes the way in. + if not _servable_source(root, repo_rel): + return None if not _declares_inherits(root, repo_rel): return None committed = True - profiles = scan_profile_set(root, manifest) + profiles = _servable_profile_set(root, manifest) derived = next((p for p in profiles if p.rel_path == repo_rel), None) if derived is None: return _unresolved_profile_page( @@ -1122,7 +1272,7 @@ def resolved_profile_page(root: str, manifest: Manifest, repo_rel: str) -> Optio def _resolved_profile_pages(root: str, manifest: Manifest) -> list[tuple[str, str]]: """Every inheriting profile as its rootPath-relative viewer path and resolved page, so a static export carries what the local server renders.""" - profiles = scan_profile_set(root, manifest) + profiles = _servable_profile_set(root, manifest) out: list[tuple[str, str]] = [] for p in profiles: if not isinstance((p.frontmatter or {}).get("inherits"), str): @@ -1134,7 +1284,9 @@ def _resolved_profile_pages(root: str, manifest: Manifest) -> list[tuple[str, st return out -def _docsify_config(root: str, manifest: Manifest, name_html: str, findings: list[Finding]) -> str: +def _docsify_config( + root: str, manifest: Manifest, name_html: str, base: str, findings: list[Finding] +) -> str: """The Docsify config blob in the Node SDK's exact key order, script-escaped. Appends the homepage viewer-path-missing warning when the configured homepage does not resolve.""" @@ -1146,9 +1298,19 @@ def emoji_for(c: str) -> str: configured = category_emojis.get(c) return configured if configured is not None else CATEGORY_EMOJI[c] + # Resolved before the dict literal so the mermaid text color can be computed from + # the accent; the two resolutions keep their original order, so do the warnings + # they raise. + homepage = _effective_homepage(root, manifest, findings) + theme_color = _resolve_theme_color(manifest, findings) return _json_for_script( { "name": name_html, + # Where the layer's markdown is mounted. Docsify's own key, so the boot + # script configures the router from it rather than hardcoding a root: + # '/content/' served, 'content/' exported (resolved against the page, so + # the tree hosts under any subpath). + "basePath": f"{base}content/", # Hash navigation for the logo/title link: #/ re-routes to the # homepage inside the SPA instead of a full page reload. "nameLink": "#/", @@ -1163,9 +1325,13 @@ def emoji_for(c: str) -> str: "lejiCategories": {c: f"{emoji_for(c)} {CATEGORY_LABELS[c]}" for c in CATEGORY_IDS}, # The homepage is rootPath-relative; teams whose layer has a real # landing page point at it instead of the seeded overview. - "homepage": _effective_homepage(root, manifest, findings), - # After the homepage above, so the two warnings land in the Node order. - "themeColor": _resolve_theme_color(manifest, findings), + "homepage": homepage, + "themeColor": theme_color, + # Mermaid node text, readable against the accent. Computed here because + # this side resolves every accepted color form; the boot script's own + # hex-only fallback covers viewer trees generated before this field. + # Leji's own key, not one Docsify reads, hence the prefix. + "lejiMermaidTextColor": _mermaid_text_color(theme_color), # Read by the boot script's powered-by plugin; false removes the mark. "lejiPoweredBy": viewer_cfg.get("poweredBy") is not False, } @@ -1251,18 +1417,18 @@ def _assemble_sidebar( return build_sidebar(manifest, groups, tree, pins, boot_pinned=boot_pinned) -def generate_viewer(root: str, manifest: Manifest) -> ViewerResult: - """Write the Docsify index.html (frontmatter-stripping hook included) and - the projected _sidebar.md into the context root.""" - 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 - # and write nothing, the same refusal write_index makes. - if any(f.severity == "error" for f in result.findings): - return ViewerResult(written=[], findings=result.findings, entries=0) - entries = (result.index or {}).get("entries", []) - findings_early: list[Finding] = [] +def _mermaid_enabled(manifest: Manifest) -> bool: + """True when the layer keeps mermaid on (the default).""" + return (manifest.get("viewer") or {}).get("mermaid") is not False + +def _build_index_html(root: str, manifest: Manifest, base: str, findings: list[Finding]) -> str: + """The SPA shell for one flavor of the chrome: the template with this layer's + config baked in, every URL it emits written against ``base``. The served flavor + (``"/"``) and the export flavor (``""``) come from this one function, so the + export never gets its HTML rewritten after the fact. ``findings`` collects the + two resolution warnings (homepage, accent) in their established order; the export + invocation discards them, having already reported the generation run's.""" viewer_cfg = manifest.get("viewer") or {} # Display title: viewer.title override, else the context layer name. display_title = viewer_cfg.get("title") or manifest["name"] @@ -1272,7 +1438,7 @@ def generate_viewer(root: str, manifest: Manifest) -> ViewerResult: # `name` rather than Docsify's `logo` option (which prepends basePath /content/ # and 404s). Title is HTML-escaped; the strict CSP (script-src 'self') kills handlers. logo = viewer_cfg.get("logo") - logo_url = _html_escape(_resolve_logo(root, manifest["rootPath"], logo)) + logo_url = _html_escape(_resolve_logo(root, manifest["rootPath"], logo, base)) if logo: name_html = ( f'{_html_escape(display_title)} ViewerResult: if favicon: favicon_rel = _resolve_viewer_rel(root, manifest["rootPath"], favicon) favicon_url = _html_escape( - "/content/" + (favicon_rel if favicon_rel is not None else strip_slash(favicon)) + f"{base}content/" + (favicon_rel if favicon_rel is not None else strip_slash(favicon)) ) else: - favicon_url = _html_escape(DEFAULT_LOGO) - config = _docsify_config(root, manifest, name_html, findings_early) + favicon_url = _html_escape(_default_logo(base)) + config = _docsify_config(root, manifest, name_html, base, findings) # Mermaid is on unless explicitly disabled. When off, the two mermaid scripts # are omitted from the page and their assets are not copied (a leaner viewer). - mermaid_enabled = viewer_cfg.get("mermaid") is not False mermaid_scripts = ( '\n ' '\n ' - if mermaid_enabled + if _mermaid_enabled(manifest) else "" ) # One pass over the template with a resolver map, never four sequential @@ -1314,44 +1479,87 @@ def generate_viewer(root: str, manifest: Manifest) -> ViewerResult: "DOCSIFY_CONFIG": config, "MERMAID_SCRIPTS": mermaid_scripts, } - doc_html = PLACEHOLDER_RE.sub( + return PLACEHOLDER_RE.sub( lambda m: substitutions.get(m.group(1), m.group(0)), (templates_dir() / "viewer" / "index.html").read_text(encoding="utf-8"), ) + + +def generate_viewer(root: str, manifest: Manifest) -> ViewerResult: + """Write the Docsify index.html (frontmatter-stripping hook included) and the + projected _sidebar.md into the root `.leji/viewer/` role.""" + 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 + # and write nothing, the same refusal write_index makes. + if any(f.severity == "error" for f in result.findings): + return ViewerResult(written=[], findings=result.findings, entries=0) + entries = (result.index or {}).get("entries", []) + findings_early: list[Finding] = [] + + # The served flavor: the chrome under `.leji/viewer/` is never export-flavored. + doc_html = _build_index_html(root, manifest, SERVED_BASE, findings_early) sidebar = _assemble_sidebar(root, manifest, entries, findings_early) root_dir = strip_slash(manifest["rootPath"]) or "." + root_abs = str(Path(root).resolve()) findings: list[Finding] = [*result.findings, *findings_early] written: list[str] = [] - # Refuse to write through a symlink that escapes the layer root (a symlinked - # content root, or a pre-placed target file). resolved_within_root resolves the - # nearest existing ancestor, so a not-yet-existing target under a symlinked - # directory is caught before mkdir/write can escape. - def write_within(rel: str, content: bytes | str) -> None: - abs_path = Path(root) / rel - if not resolved_within_root(root, abs_path): + # Check-before-act: the generation target — the `.leji/viewer/` role — is + # realpath-resolved and validated BEFORE a single byte is written. A `.leji/viewer` + # that resolves into a DIFFERENT private role (`.leji/work/`, `.leji/mounts/`, a + # future role), or out of the repository altogether, is refused here, so a + # symlinked viewer can never be written through into the trust domain or out of the + # tree; only its own directory passes. Unresolvable (permission/I/O error, not mere + # absence) fails the check rather than being rebuilt lexically. + resolved_root = resolved_path(root_abs) or root_abs + viewer_target = resolved_path_under(resolved_root, role_abs(resolved_root, VIEWER_REL)) + verdict = ( + None if viewer_target is None else writable_target(resolved_root, viewer_target, VIEWER_REL) + ) + if viewer_target is None or verdict is None or not verdict.ok: + if verdict is None: + message = ( + f"refusing to generate the viewer: {VIEWER_REL}/ cannot be resolved " + "(permission or I/O error); remove the symlink" + ) + elif verdict.outside_root: + message = ( + f"refusing to generate the viewer: {VIEWER_REL}/ resolves outside the " + "repository; remove the symlink" + ) + else: + message = ( + f"refusing to generate the viewer: {VIEWER_REL}/ resolves into " + f"{LEJI_DIR}/{verdict.role} (private); remove the symlink" + ) + findings.append(Finding("viewer-target-refused", "error", message, VIEWER_REL)) + return ViewerResult(written=written, findings=findings, entries=0) + + # Every `.leji/viewer/` write goes back through the chokepoint with the viewer's + # own role, so each file is judged on its RESOLVED path immediately before it is + # written and lands there: the role was validated as a whole above, and this keeps + # a symlink planted inside the tree from redirecting a single file elsewhere. + def write_viewer_file(rel: str, content: bytes | str) -> None: + if not write_file_guarded(resolved_root, str(Path(root) / rel), VIEWER_REL, content).ok: findings.append( Finding( "artifact-parse", "error", - f"viewer path {rel} resolves outside the layer root", + f"viewer path {rel} resolves outside {VIEWER_REL}/", rel, ) ) return - abs_path.parent.mkdir(parents=True, exist_ok=True) - if isinstance(content, bytes): - abs_path.write_bytes(content) - else: - abs_path.write_text(content, encoding="utf-8") written.append(rel) - # The viewer is contained under rootPath/.leji/viewer/ (gitignored), so it never - # collides with the user's own files in the context root and keeps the layer clean. - viewer_dir = ".leji/viewer" if root_dir == "." else f"{root_dir}/.leji/viewer" + # The chrome's role in the unified root `.leji/` (gitignored): outside the context + # root whatever rootPath is, so it never collides with the user's own files and + # never rides a content walk. + viewer_dir = VIEWER_REL for name, content in (("index.html", doc_html), ("_sidebar.md", sidebar)): - write_within(f"{viewer_dir}/{name}", content) + write_viewer_file(f"{viewer_dir}/{name}", content) # Copy every vendored viewer asset (Docsify core, theme, the plugins, and the # webfonts) alongside index.html (no remote CDN). The provenance note is @@ -1361,19 +1569,69 @@ def write_within(rel: str, content: bytes | str) -> None: for asset_path in sorted(p for p in assets_src.iterdir() if p.is_file()): if asset_path.name == "PROVENANCE.txt" or asset_path.name.startswith("."): continue - if not mermaid_enabled and asset_path.name in MERMAID_ASSETS: + # Mermaid off omits its two scripts from the page and their assets here (~3MB). + if not _mermaid_enabled(manifest) and asset_path.name in MERMAID_ASSETS: continue - write_within(f"{assets_rel_dir}/{asset_path.name}", asset_path.read_bytes()) + 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. + # + # 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" overview_abs = Path(root) / overview_rel - if not overview_abs.is_file(): - write_within(overview_rel, _build_overview_seed(manifest, entries)) - elif resolved_within_root(root, overview_abs): - existing = overview_abs.read_text(encoding="utf-8") + overview_resolved = resolved_path_under(resolved_root, str(overview_abs)) + overview_verdict = ( + writable_target(resolved_root, overview_resolved, None) + if overview_resolved is not None and resolved_within_root(root, overview_abs) + else None + ) + overview_read = verified_target_read(resolved_root, str(overview_abs), None) + if overview_verdict is None: + findings.append( + Finding( + "artifact-parse", + "error", + "overview.md resolves outside the layer root", + overview_rel, + ) + ) + elif not overview_verdict.ok: + findings.append( + Finding( + "viewer-target-refused", + "error", + f"refusing to write overview.md: it resolves into " + f"{LEJI_DIR}/{overview_verdict.role} (private); remove the symlink", + overview_rel, + ) + ) + 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. + findings.append( + Finding( + "viewer-target-refused", + "error", + "refusing to write overview.md: it does not resolve to a regular file " + "inside the repository; remove the symlink", + overview_rel, + ) + ) + elif overview_read.status == "absent": + seed = _build_overview_seed(manifest, entries) + 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: @@ -1381,7 +1639,7 @@ def write_within(rel: str, content: bytes | str) -> None: existing[:start] + _map_block(manifest, entries) + existing[end + len(MAP_END) :] ) if updated != existing: - overview_abs.write_text(updated, encoding="utf-8") + write_file_guarded(resolved_root, str(overview_abs), None, updated) else: findings.append( Finding( @@ -1396,572 +1654,8 @@ def write_within(rel: str, content: bytes | str) -> None: # gitignored viewer dir under a reserved underscore name (collision-free with the # user's own files) and served via a dedicated content route (never a committed # file at the context root, so no diff churn). Regenerated every run; pinned. - write_within( + write_viewer_file( f"{viewer_dir}/_manifest.md", build_manifest_page(manifest, mount_status(root, manifest)) ) return ViewerResult(written=written, findings=findings, entries=len(entries)) - - -# The protect-your-context warning surfaced by `leji viewer build` (in stdout and -# as a comment in the exported index.html): a context layer is sensitive and the -# static export should not be hosted somewhere public. -PROTECT_WARNING = ( - "This is your context layer (identity, invariants, decisions, sometimes sensitive " - "internal knowledge). Host the exported folder behind internal authentication, not a " - "public or shared bucket where it could be indexed or leaked. Active file types " - "(.htm, .html, .js, .mjs, .xhtml) are left out of the exported content: a static " - "host would serve them as same-origin documents that execute with no policy." -) - -# The first bytes `viewer build` writes into an exported index.html. A target -# directory carrying this marker is a previous export and may be cleared; any other -# non-empty directory is somebody's content and is never removed. -EXPORT_MARKER = "\n{index_html}", - encoding="utf-8", - ) - - return BuildResult(out=out_display, findings=gen.findings) - - -CONTENT_TYPES = { - ".html": "text/html; charset=utf-8", - ".md": "text/markdown; 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", - ".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", -} - - -def _url_path_to_rel(url_path: str) -> str: - """A request URL path as a clean relative route key. - - Separators fold to "/" and the path is cleaned against a root, so one request - has one route key on any platform — os.path.normpath follows the host and - answered differently on Windows, missing every "content/" route test. - Canonicalization only; the mount enforces containment. - """ - return posixpath.normpath("/" + url_path.replace("\\", "/")).lstrip("/") - - -class _SafeViewerHandler(BaseHTTPRequestHandler): - """Virtual-mount handler, no symlinks: the contained viewer chrome - (rootPath/.leji/viewer/) is served at `/`, and the layer's markdown - (rootPath/) under `/content/`. The internal .leji path is reachable only - through these mounts, never by a direct URL. A local preview, not a host. - - The generated sidebar and the stored context index are served live from the - tree behind a fingerprint cache, so a long-running viewer never shows a - deleted or moved document.""" - - root_abs: str = "" - base: str = "" - content_abs: str = "" - viewer_abs: 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() - - def log_message(self, *args): # type: ignore[override] - pass - - _status_code: int = 200 - # The policy sent with the current response: the shell policy by default, the - # inert one once the route is known to be under the content mount. - _csp: str = CSP_CHROME - - def send_response(self, code, message=None): # type: ignore[override] - self._status_code = code - super().send_response(code, message) - - def end_headers(self) -> None: # type: ignore[override] - """Policy headers ride every response, not just the SPA shell: a document - served straight out of /content/ is same-origin and would otherwise run with - no policy at all. Overridden here so no response path can forget them.""" - self.send_header("x-content-type-options", "nosniff") - self.send_header("content-security-policy", self._csp) - super().end_headers() - - def _write_body(self, body: bytes) -> None: - """Write a response body, except on HEAD, which carries headers only. Node - and Go suppress the body themselves; BaseHTTPRequestHandler does not.""" - if self.command != "HEAD": - self.wfile.write(body) - - def _serve_from(self, mount_root: str, sub: str, inert: bool = False) -> None: - # Reject absolute/drive/parent-traversal paths, then realpath + commonpath-contain - # before any filesystem access. `inert` marks the layer's own content mount, - # whose files are never given an active content type however they are named. - # - # An embedded NUL (e.g. GET /content/%00) makes every path call below raise - # ValueError; Node and Go answer a clean 404, so answer one here rather than - # letting the handler blow up. - if "\x00" in sub: - self.send_response(404) - self.end_headers() - self._write_body(b"not found") - return - root_real = os.path.realpath(mount_root) - norm = os.path.normpath(sub).replace(os.sep, "/") if sub else "" - if norm in (".", "/"): - norm = "" - if norm and ( - os.path.isabs(sub) - or sub.startswith(("/", "\\")) - or re.match(r"^[A-Za-z]:", sub) is not None - or norm == ".." - or norm.startswith("../") - ): - self.send_response(403) - self.end_headers() - self._write_body(b"forbidden") - return - target = ( - os.path.join(root_real, "index.html") if norm == "" else os.path.join(root_real, norm) - ) - real = os.path.realpath(target) - try: - inside = os.path.commonpath([root_real, real]) == root_real - except ValueError: - inside = False - if not inside: - self.send_response(403) - self.end_headers() - self._write_body(b"forbidden") - return - if os.path.isdir(real): - real = os.path.realpath(os.path.join(real, "index.html")) - try: - inside = os.path.commonpath([root_real, real]) == root_real - except ValueError: - inside = False - if not inside: - self.send_response(403) - self.end_headers() - self._write_body(b"forbidden") - return - try: - with open(real, "rb") as fh: - body = fh.read() - except OSError: - self.send_response(404) - self.end_headers() - self._write_body(b"not found") - return - ext = os.path.splitext(real)[1].lower() - ct = CONTENT_TYPES.get(ext, "application/octet-stream") - if inert and ext in ACTIVE_EXTENSIONS: - ct = "text/plain; charset=utf-8" - self.send_response(200) - self.send_header("content-type", ct) - self.end_headers() - self._write_body(body) - - def _serve_text(self, content_type: str, body: str) -> None: - self.send_response(200) - self.send_header("content-type", content_type) - self.end_headers() - self._write_body(body.encode("utf-8")) - - def _tree_fingerprint(self) -> 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.""" - parts: list[str] = [] - - def add(rel: str) -> None: - try: - st = os.stat(os.path.join(self.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 "."): - add(rel) - return "\n".join(parts) - - def _refresh_cache(self, 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) - if load.manifest is None: - return None - idx = generate_index(self.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, []) - cache = { - "key": key, - "body": body, - "index_json": serialize_index(idx.index) if idx.index is not None else None, - } - type(self)._cache = cache - return cache - - def do_GET(self) -> None: # noqa: N802 - from urllib.parse import unquote, urlsplit - - try: - self._do_get_inner(unquote, urlsplit) - finally: - if self.access_log is not None: - log = type(self).access_log - if log is not None: - # The method and request target are attacker-controlled bytes; - # sanitized so terminal escapes never reach the operator's - # console. Node's parser rejects them outright and Go re-encodes - # the target, so this is the Python leg of the same guarantee. - log(f"{_log_safe(self.command)} {_log_safe(self.path)} {self._status_code}") - - # HEAD answers exactly like GET with the body suppressed (see _write_body), the - # way the Node and Go servers do; the default handler would 501 instead. - def do_HEAD(self) -> None: # noqa: N802 - self.do_GET() - - def _do_get_inner(self, unquote, urlsplit) -> None: - # One handler instance serves every request on a kept-alive connection, so - # the per-response policy starts from the shell default each time. - self._csp = CSP_CHROME - # Loopback binding alone does not stop DNS rebinding: a hostile page whose - # name resolves to 127.0.0.1 reaches this server with its own Host. Only the - # loopback names the viewer is actually addressed by are answered. The port is - # deliberately not part of the test: a rebound request carries the right port - # anyway, so matching it adds nothing. Don't "fix" this by checking it. - if not _loopback_host(self.headers.get("Host")): - self.send_response(403) - self.end_headers() - self._write_body(b"forbidden") - return - try: - # A malformed percent-encoding throws; answer 400 rather than crash. - url_path = unquote(urlsplit(self.path).path, errors="strict") - except (ValueError, UnicodeDecodeError): - self.send_response(400) - self.end_headers() - self._write_body(b"bad request") - return - rel = _url_path_to_rel(url_path) - # The content mount serves the layer's own files; they get the inert policy. - if rel == "content" or rel.startswith("content/"): - self._csp = CSP_CONTENT - # Refuse any dotfile or VCS-internal segment in the REQUEST path: the .leji - # viewer dir is reached only through the mounts below, never by direct URL. - for seg in re.split(r"[/\\]", rel): - if seg == ".git" or (seg.startswith(".") and seg not in (".", "")): - self.send_response(404) - self.end_headers() - self._write_body(b"not found") - return - # The generated sidebar lives in the viewer dir but is served as if at the - # content root, so Docsify's basePath /content/ + _sidebar alias resolve it. - # Docsify fetches it once per page load, so it is rebuilt from the live tree - # on every request: a long-running server never shows a deleted or moved - # 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 - self._serve_from(self.viewer_abs, "_sidebar.md") - return - # The stored context index is served live (same fingerprint cache as the - # sidebar), so per-page classification badges never disagree with the tree. - if rel.startswith("content/"): - try: - load = load_manifest(self.root_abs) - idx_rel = ( - _relative_to_root( - effective_index_path(load.manifest), load.manifest["rootPath"] - ) - if load.manifest is not None - else None - ) - if ( - load.manifest is not 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 - ): - self._serve_text("application/json; charset=utf-8", cache["index_json"]) - return - except Exception: # noqa: BLE001 - fall through to the stored artifact - pass - # The generated Manifest page lives in the viewer dir (gitignored chrome) but - # is linked from the sidebar and fetched under the content root, like - # _sidebar.md. Reserved underscore name; served from the last generation. - if rel == "content/_manifest.md": - self._serve_from(self.viewer_abs, "_manifest.md") - 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 - # on disk is one half, and presenting it as the effective profile is the - # thing a consumer must not do. So this branch fails closed. If anything - # at all goes wrong, a file that declares `inherits` still gets a findings - # page; only a file that is not half a profile falls through to disk. - if sub.endswith(".md"): - repo_rel = f"{self.base}/{sub}" if self.base and self.base != "." else sub - page: Optional[str] = None - try: - load = load_manifest(self.root_abs) - page = ( - None - if load.manifest is None - else resolved_profile_page(self.root_abs, load.manifest, repo_rel) - ) - if ( - page is None - and load.manifest is None - and _declares_inherits(self.root_abs, repo_rel) - ): - page = _unresolved_profile_page( - repo_rel, - [ - Finding( - "artifact-parse", - "error", - "the layer manifest could not be read", - "leji.json", - ) - ], - ) - except Exception as e: # noqa: BLE001 - fail closed, never the raw file - page = ( - _unresolved_profile_page( - repo_rel, - [ - Finding( - "artifact-parse", - "error", - f"the viewer could not resolve this profile: {e}", - repo_rel, - ) - ], - ) - if _declares_inherits(self.root_abs, repo_rel) - else None - ) - if page is not None: - self._serve_text("text/markdown; charset=utf-8", page) - return - self._serve_from(self.content_abs, sub, inert=True) - return - # Everything else (`/`, /index.html, /assets/*) is viewer chrome. - self._serve_from(self.viewer_abs, rel) - - -def serve_viewer( - root: str, - port: int, - root_rel: str = "", - log: Optional[Callable[[str], None]] = None, -) -> ThreadingHTTPServer: - """Serve the viewer at the web root, bound to 127.0.0.1 (local preview, never - hosting): viewer chrome (rootPath/.leji/viewer/) at `/`, the layer's markdown - (rootPath/) under `/content/`, no symlinks. `log`, when set, receives one - terse access-log line per request. Caller runs serve_forever() / shutdown().""" - 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 - # The CLI passes a schema-validated rootPath, but a direct SDK caller could pass - # an escaping root_rel (e.g. ".."); refuse to mount content outside the layer root. - if not resolved_within_root(root_abs, Path(content_abs)): - raise ValueError(f'viewer root "{root_rel}" escapes the layer root') - viewer_abs = os.path.join(content_abs, ".leji", "viewer") - handler_cls = type( - "_BoundSafeViewerHandler", - (_SafeViewerHandler,), - { - "root_abs": root_abs, - "base": base if base != "." else "", - "content_abs": content_abs, - "viewer_abs": viewer_abs, - "access_log": staticmethod(log) if log is not None else None, - "_cache": None, - "_cache_lock": threading.Lock(), - }, - ) - return ThreadingHTTPServer(("127.0.0.1", port), handler_cls) - - -def open_browser(url: str) -> None: - """Best-effort open of url in the default browser (--open / `leji view`). Never - raises or blocks: opening is a convenience, not part of serving. Mirrors the Node - opener (open / cmd start / xdg-open), spawned detached.""" - import subprocess - import sys - - if sys.platform == "darwin": - cmd = ["open", url] - elif sys.platform.startswith("win"): - cmd = ["cmd", "/c", "start", "", url] - else: - cmd = ["xdg-open", url] - try: - subprocess.Popen( # noqa: S603 - fixed opener, url is local - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - except OSError: - pass # opening the browser is best-effort diff --git a/packages/sdk-py/src/leji/writeplan.py b/packages/sdk-py/src/leji/writeplan.py index f412d24..6e35db4 100644 --- a/packages/sdk-py/src/leji/writeplan.py +++ b/packages/sdk-py/src/leji/writeplan.py @@ -61,7 +61,7 @@ def build_write_plan( PlanEntry( rel=rel, status="wont-modify", - note="existing file, read-only input — Leji will not modify it", + note="existing file, read-only input; Leji will not modify it", ) ) return entries diff --git a/packages/sdk-py/tests/test_badge.py b/packages/sdk-py/tests/test_badge.py new file mode 100644 index 0000000..75b41a9 --- /dev/null +++ b/packages/sdk-py/tests/test_badge.py @@ -0,0 +1,629 @@ +"""Two halves of one contract. First the constants: every level rendered and +byte-compared against `fixtures/badge/`, the sole oracle, plus the `--out` acceptance +table, the existing-file rule, and the containment matrix over temp trees. Then the +shared fixtures' `badge` blocks, driven through the real CLI entry point. + +Mirrors packages/sdk/test/badge.test.ts. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import shutil +import socket +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +import pytest + +from leji import badge_markdown, badge_run, render_badge +from leji.cli import main + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES = REPO_ROOT / "fixtures" +GOLDEN_DIR = FIXTURES / "badge" + +LEVELS = ["core", "indexed", "governed", "federated"] + + +def _committed_fixture(name: str) -> Path: + """A committed working copy of a fixture: the level a badge states needs a git + baseline, since the `indexed` changelog item is `unknown` until the changelog is in + HEAD (`fixtures/README.md` -> "The `badge` block"). Under the OS temp directory + rather than pytest's, whose paths are long enough to exceed the unix-socket path + limit the containment tests bind at.""" + directory = Path(tempfile.mkdtemp(prefix="leji-badge-")).resolve() + shutil.copytree(FIXTURES / name, directory, dirs_exist_ok=True) + subprocess.run(["git", "init", "-q"], cwd=directory, check=True) + subprocess.run(["git", "add", "-A"], cwd=directory, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Badge Test", + "-c", + "user.email=badge@example.com", + "commit", + "-q", + "-m", + "seed", + ], + cwd=directory, + check=True, + ) + 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.""" + code = main(args) + return code, capsys.readouterr().out + + +def _bind_socket(target: Path) -> socket.socket: + """A unix socket standing at `target`. The caller skips the test when the platform + cannot bind one.""" + if not hasattr(socket, "AF_UNIX"): + pytest.skip("this platform has no unix sockets") + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(str(target)) + except OSError as error: + server.close() + pytest.skip(f"this platform cannot bind a unix socket at {target}: {error}") + return server + + +def _finding_keys(findings) -> list[dict]: + """A finding as `fixtures/README.md` -> "Matching rules" compares one: the triple + (rule, severity, path). Message text is implementation-specific and is never + compared.""" + out = [] + for f in findings: + if isinstance(f, dict): + out.append({"rule": f["rule"], "severity": f["severity"], "path": f.get("path")}) + else: + out.append({"rule": f.rule, "severity": f.severity, "path": f.path}) + return out + + +# --- the canonical bytes ------------------------------------------------------ + + +def test_every_level_renders_the_canonical_badge_byte_for_byte() -> None: + for level in LEVELS: + assert render_badge(level) == (GOLDEN_DIR / f"{level}.svg").read_text(encoding="utf-8"), ( + f"{level}.svg differs from the golden" + ) + assert badge_markdown(level, "leji-badge.svg") == (GOLDEN_DIR / f"{level}.md").read_text( + encoding="utf-8" + ), f"{level}.md differs from the golden" + + +def test_the_claim_is_structural_not_drawn() -> None: + for level in LEVELS: + claim = f"Leji 1.0 · {level} · self-attested" + # The markdown fixture — the alt text an adopter pastes into a README — carries + # the whole claim, which is what lets the face drop it. + md = (GOLDEN_DIR / f"{level}.md").read_text(encoding="utf-8") + assert f"[![{claim}]" in md, f"{level}.md must carry the full alt claim" + + svg = (GOLDEN_DIR / f"{level}.svg").read_text(encoding="utf-8") + assert f"{claim}" in svg, f"{level}.svg must carry the claim" + assert f'aria-label="{claim}"' in svg, f"{level}.svg aria-label must carry the claim" + + # The visible segment is the level alone: the two `<text>` bodies are the + # wordmark and the level, and `self-attested` appears nowhere a renderer draws. + drawn = re.findall(r"<text\b[^>]*>([^<]*)</text>", svg) + assert drawn == ["Leji 1.0", level], ( + f"{level}.svg draws the wordmark and the level, and nothing else" + ) + + +def test_the_markdown_carries_the_canonical_out_value_not_the_default() -> None: + assert badge_markdown("governed", "docs/badge.svg") == ( + "[![Leji 1.0 · governed · self-attested](docs/badge.svg)](https://leji.org/agent-ready/)\n" + ) + + +# --- the `--out` acceptance rule ---------------------------------------------- + + +def test_out_accepts_a_repository_relative_posix_svg_path_and_rejects_everything_else() -> None: + directory = _committed_fixture("valid-badge-governed") + try: + # Accepted, with the canonical POSIX form echoed back: a `.` segment is dropped, + # and a nested target has its parent directories created. + for given, canonical in [ + ("leji-badge.svg", "leji-badge.svg"), + ("./badge.svg", "badge.svg"), + ("docs/badge.svg", "docs/badge.svg"), + ("a/b/c-1_2.svg", "a/b/c-1_2.svg"), + ]: + r = badge_run(str(directory), given) + assert r.usage_error is None, f"{given} must be accepted" + assert r.out == canonical, f"{given} canonicalizes to {canonical}" + assert directory.joinpath(*canonical.split("/")).exists(), f"{canonical} was written" + # Rejected at argument parsing, before conformance runs: no level is reported at + # all, and nothing is written. + for bad in [ + "/abs.svg", + "../x.svg", + "docs/../x.svg", + "a\\b.svg", + "x.png", + "x.svg ", + "a//b.svg", + "doc s/x.svg", + "x.svg#frag", + ".leji/x.svg", + ".leji/dist/x.svg", + ".leji/a/b/x.svg", + ]: + r = badge_run(str(directory), bad) + assert r.usage_error is not None, f"{bad} must be rejected" + assert r.out is None + assert r.level is None + assert r.claimed_level is None, f"{bad} reports no level" + assert r.verified_level is None, f"{bad} reports no level" + # A directory at the target is a rejection too, and the directory survives it. + (directory / "adir.svg").mkdir() + assert badge_run(str(directory), "adir.svg").usage_error is not None, ( + "a directory is never a badge target" + ) + assert (directory / "adir.svg").is_dir() + finally: + shutil.rmtree(directory, ignore_errors=True) + + +# --- the existing-file rule --------------------------------------------------- + + +def test_the_target_file_decides_the_action_by_its_bytes_and_nothing_else() -> None: + directory = _committed_fixture("valid-badge-governed") + target = directory / "leji-badge.svg" + try: + # Absent: written. + assert badge_run(str(directory)).action == "wrote" + assert target.read_text(encoding="utf-8") == render_badge("governed") + + # These exact bytes: unchanged, and not rewritten (the mtime stands). + before = target.stat().st_mtime_ns + assert badge_run(str(directory)).action == "unchanged" + assert target.stat().st_mtime_ns == before, "an unchanged target is never rewritten" + + # Another canonical badge of this contract: overwritten, which is how a level + # change regenerates. All three of the others, not just the neighbouring one. + for level in [lv for lv in LEVELS if lv != "governed"]: + target.write_text(render_badge(level), encoding="utf-8") + assert badge_run(str(directory)).action == "overwrote", ( + f"a stale {level} badge regenerates" + ) + assert target.read_text(encoding="utf-8") == render_badge("governed") + + # Anything else: refused, exit 2's message, the file untouched and never + # truncated. The levels are still reported, the rule running after conformance. + foreign = "<svg><!-- somebody elses file --></svg>\n" + target.write_text(foreign, encoding="utf-8") + r = badge_run(str(directory)) + assert r.refusal == "leji-badge.svg exists and is not a leji badge; remove or rename it" + assert r.out is None + assert r.action is None + assert r.claimed_level == "governed" + assert r.verified_level == "governed" + assert target.read_text(encoding="utf-8") == foreign, ( + "a refusal never edits and never truncates" + ) + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def test_a_nested_out_creates_its_parent_directories_only_when_the_write_happens() -> None: + directory = _committed_fixture("valid-records") # claims core, verifies core + try: + (directory / "leji-badge.svg").write_text("not a badge\n", encoding="utf-8") + assert badge_run(str(directory), "docs/nested/badge.svg").out is not None + assert (directory / "docs" / "nested" / "badge.svg").exists() + # The refusal path writes nothing, so it establishes no directory either. + assert badge_run(str(directory), "leji-badge.svg").refusal is not None + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def test_a_run_that_writes_nothing_establishes_no_directory_on_the_way_to_not_writing() -> None: + # Exit 1 (a claim this run refutes): the nested target and its parent are both absent + # afterwards, so the directory is a consequence of the write and not of the attempt. + failing = _committed_fixture("invalid-governed-no-profile") + try: + r = badge_run(str(failing), "pub/x/badge.svg") + assert r.out is None + assert r.action is None + assert any(f.severity == "error" for f in r.findings) + assert not (failing / "pub" / "x" / "badge.svg").exists(), "the target was never created" + assert not (failing / "pub" / "x").exists(), "the parent was never created" + assert not (failing / "pub").exists(), "nor its parent" + finally: + shutil.rmtree(failing, ignore_errors=True) + + # Exit 2 (a foreign file at a nested target whose parent already exists): the parent + # is left exactly as it was and the target's bytes are untouched. + directory = _committed_fixture("valid-badge-governed") + try: + parent = directory / "pub" + parent.mkdir() + (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) + 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" + finally: + shutil.rmtree(directory, ignore_errors=True) + + +# --- containment: the resolved path decides, in both directions ----------------- + + +def test_an_out_whose_parent_resolves_outside_the_repository_is_refused_and_reads_nothing() -> None: + outside = Path(tempfile.mkdtemp(prefix="leji-outside-")).resolve() + directory = _committed_fixture("valid-badge-governed") + try: + # A file already standing at the escaped location: the run must neither read it + # (it is not the target the check cleared) nor replace it. + 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) + + 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" + assert r.out is None + assert r.action is None + assert (outside / "x.svg").read_text(encoding="utf-8") == planted, ( + "the outside file is untouched" + ) + 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" + finally: + shutil.rmtree(directory, ignore_errors=True) + shutil.rmtree(outside, ignore_errors=True) + + +def test_an_out_whose_parent_resolves_into_leji_is_refused_at_any_depth() -> None: + directory = _committed_fixture("valid-badge-governed") + try: + (directory / ".leji" / "dist").mkdir(parents=True, exist_ok=True) + (directory / "pub").symlink_to(directory / ".leji" / "dist", target_is_directory=True) + r = badge_run(str(directory), "pub/x.svg") + assert r.usage_error is not None or r.refusal is not None, ".leji/ is never a badge target" + assert r.out is None + assert list((directory / ".leji" / "dist").iterdir()) == [], "the private role stays empty" + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def test_an_out_that_is_itself_a_symlink_out_of_the_repository_is_refused() -> None: + outside = Path(tempfile.mkdtemp(prefix="leji-outside-")).resolve() + directory = _committed_fixture("valid-badge-governed") + try: + planted = "somebody elses file\n" + escaped = outside / "foreign.svg" + escaped.write_text(planted, encoding="utf-8") + (directory / "leji-badge.svg").symlink_to(escaped) + + r = badge_run(str(directory)) + assert r.usage_error is not None or r.refusal is not None, ( + "a link out of the repository is refused" + ) + assert r.out is None + assert r.action is None + assert escaped.read_text(encoding="utf-8") == planted, "the link target is byte-untouched" + assert (directory / "leji-badge.svg").is_symlink(), "the link itself is left alone" + finally: + shutil.rmtree(directory, ignore_errors=True) + shutil.rmtree(outside, ignore_errors=True) + + +def test_an_out_that_is_a_dangling_symlink_inside_the_repository_is_refused() -> None: + directory = _committed_fixture("valid-badge-governed") + try: + # The link resolves to a missing file INSIDE the repository, so the resolved + # destination is absent while the entry at the target path is not. A write would + # 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) + + r = badge_run(str(directory)) + assert r.refusal == ( + "leji-badge.svg does not resolve to a regular file inside the repository; " + "nothing was written" + ), "a dangling in-repository link is refused, not written through" + assert r.out is None + assert r.action is None + assert _finding_keys(r.findings) == [ + {"rule": "badge-target-refused", "severity": "error", "path": "leji-badge.svg"} + ] + assert (directory / "leji-badge.svg").is_symlink(), "the link itself is left alone" + assert not (directory / "missing-file.svg").exists(), ( + "the link destination was never created" + ) + assert _snapshot(directory) == before, "the tree is untouched" + finally: + shutil.rmtree(directory, ignore_errors=True) + + +def test_an_out_that_is_a_unix_socket_is_refused_as_a_document_not_as_a_crash(capsys) -> None: + directory = _committed_fixture("valid-badge-governed") + target = directory / "leji-badge.svg" + server: Optional[socket.socket] = None + try: + # A socket is the non-regular entry that no earlier check rejects: it is not a + # 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) + + r = badge_run(str(directory)) + assert r.refusal == ( + "leji-badge.svg does not resolve to a regular file inside the repository; " + "nothing was written" + ), "a socket at the target is refused, in the same words as every other entry" + assert r.out is None + assert r.action is None + assert _finding_keys(r.findings) == [ + {"rule": "badge-target-refused", "severity": "error", "path": "leji-badge.svg"} + ] + assert _snapshot(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. + code, stdout = _run_cli(capsys, ["badge", "--root", str(directory), "--json"]) + assert code == 2, "the refusal exits 2" + doc = json.loads(stdout) + assert sorted(doc.keys()) == sorted(DOCUMENT_KEYS), "the exact JSON key set" + assert doc["command"] == "badge" + assert doc["ok"] is False + assert doc["out"] is None + assert doc["level"] is None + assert doc["markdown"] is None + assert doc["action"] is None + assert _finding_keys(doc["findings"]) == [ + {"rule": "badge-target-refused", "severity": "error", "path": "leji-badge.svg"} + ] + assert doc["summary"] == {"errors": 1, "warnings": 0} + finally: + if server is not None: + server.close() + shutil.rmtree(directory, ignore_errors=True) + + +def test_an_out_symlinked_to_a_unix_socket_is_refused_as_a_document_too(capsys) -> None: + directory = _committed_fixture("valid-badge-governed") + target = directory / "leji-badge.svg" + server: Optional[socket.socket] = None + try: + # The link passes an entry-kind check that stops at the link itself, and the + # verified open then follows it to the socket and fails before it can fstat. So + # the kind that decides is the one at the END of the link. + server = _bind_socket(directory / "sock") + target.symlink_to("sock") + assert target.is_symlink(), "the target is a symlink" + before = _snapshot(directory) + + r = badge_run(str(directory)) + assert r.refusal == ( + "leji-badge.svg does not resolve to a regular file inside the repository; " + "nothing was written" + ), "a link to a socket is refused, in the same words as the socket itself" + assert r.out is None + assert r.action is None + assert _finding_keys(r.findings) == [ + {"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" + + # Through the CLI: the ordinary badge document at exit 2, not a bare error. + code, stdout = _run_cli(capsys, ["badge", "--root", str(directory), "--json"]) + assert code == 2, "the refusal exits 2" + doc = json.loads(stdout) + assert sorted(doc.keys()) == sorted(DOCUMENT_KEYS), "the exact JSON key set" + assert doc["command"] == "badge" + assert doc["ok"] is False + assert doc["out"] is None + assert doc["level"] is None + assert doc["markdown"] is None + assert doc["action"] is None + assert _finding_keys(doc["findings"]) == [ + {"rule": "badge-target-refused", "severity": "error", "path": "leji-badge.svg"} + ] + assert doc["summary"] == {"errors": 1, "warnings": 0} + finally: + if server is not None: + server.close() + shutil.rmtree(directory, ignore_errors=True) + + +# --- the shared fixtures' `badge` blocks -------------------------------------- + +#: 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. +DOCUMENT_KEYS = [ + "command", + "ok", + "findings", + "summary", + "out", + "level", + "claimedLevel", + "verifiedLevel", + "markdown", + "action", +] + + +def _expected(name: str) -> dict: + return json.loads((FIXTURES / name / "expected.json").read_text(encoding="utf-8")) + + +BADGE_FIXTURES = sorted( + p.name + for p in FIXTURES.iterdir() + if (p / "expected.json").is_file() and "badge" in _expected(p.name) +) + + +def _expected_document(block: dict, target_rel: str) -> tuple[list[dict], dict]: + """The findings and the summary a `badge` block PINS — fixed by the block alone, never + read off the document being judged, so a different rule, an extra finding or a missing + one fails. Three outcomes exhaust the block: a success reports nothing; an exit-2 + refusal names the foreign file it would not overwrite; an exit-1 run reports the + conformance error that left nothing honest to state — the claim gate when this run + verified a level below the claim, `badge-unverified` when it verified no level.""" + if block["exit"] == 0: + return [], {"errors": 0, "warnings": 0} + if block["exit"] == 2: + refused = {"rule": "badge-target-foreign", "severity": "error", "path": target_rel} + else: + refused = { + "rule": "badge-unverified" if block["verifiedLevel"] is None else "conformance-claim", + "severity": "error", + "path": "leji.json", + } + return [refused], {"errors": 1, "warnings": 0} + + +def _assert_badge_document(stdout: str, block: dict, target_rel: str, where: str) -> None: + """The whole `--json` document against the block: the exact key set, and every value + the block fixes — including the findings and the summary, pinned above rather than + derived from the document, which is what makes a wrong rule or a stray finding fail + here. The summary's exact key set, its agreement with the findings beside it, and + `ok`'s agreement with both follow from comparing the pinned pair, so they are asserted + by that comparison and not again.""" + doc = json.loads(stdout) + assert sorted(doc.keys()) == sorted(DOCUMENT_KEYS), f"{where}: the exact JSON key set" + assert doc["command"] == "badge", f"{where}: command" + assert doc["out"] == block["out"], f"{where}: out" + assert doc["level"] == block["level"], f"{where}: level" + assert doc["claimedLevel"] == block["claimedLevel"], f"{where}: claimedLevel" + assert doc["verifiedLevel"] == block["verifiedLevel"], f"{where}: verifiedLevel" + assert doc["action"] == block["action"], f"{where}: action" + expected_markdown = ( + None + if block["level"] is None or block["out"] is None + else badge_markdown(block["level"], block["out"]) + ) + assert doc["markdown"] == expected_markdown, f"{where}: markdown" + assert doc["ok"] is (block["exit"] == 0), f"{where}: ok tracks the exit code" + findings, summary = _expected_document(block, target_rel) + assert _finding_keys(doc["findings"]) == findings, ( + f"{where}: the exact findings, on (rule, severity, path)" + ) + assert doc["summary"] == summary, f"{where}: the literal summary" + + +def test_an_out_usage_error_exits_2_and_emits_no_json_document_at_all(capsys) -> None: + directory = _committed_fixture("valid-badge-governed") + try: + for bad in ["x.png", "../x.svg", "/abs.svg", ".leji/x.svg"]: + code, stdout = _run_cli( + capsys, ["badge", "--root", str(directory), "--json", "--out", bad] + ) + assert code == 2, f"{bad} is a usage error" + assert stdout.strip() == "", f"{bad} writes nothing to stdout, so no level" + assert not (directory / "leji-badge.svg").exists(), "and nothing was written" + finally: + shutil.rmtree(directory, ignore_errors=True) + + +@pytest.mark.parametrize("name", BADGE_FIXTURES) +def test_fixture_badge_block(name: str, capsys) -> None: + block = _expected(name)["badge"] + directory = _committed_fixture(name) + try: + preseed = block.get("preseed") + target_rel = (preseed or {}).get("path") or block["out"] or "leji-badge.svg" + target = directory.joinpath(*target_rel.split("/")) + if preseed: + planted_bytes = ( + FIXTURES.joinpath(*preseed["from"].split("/")).read_bytes() + if preseed.get("from") + else preseed.get("bytes", "").encode("utf-8") + ) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(planted_bytes) + planted = target.read_bytes() if preseed else None + + args = block.get("args") or ["badge"] + code, stdout = _run_cli(capsys, [*args, "--root", str(directory), "--json"]) + assert code == block["exit"], f"exit code for {name}: {stdout}" + # The document carries every outcome, refusals included: `ok:false` and the rule + # that refused, at the target path, are pinned inside it. + _assert_badge_document(stdout, block, target_rel, f"{name} (first run)") + + if block["golden"] is not None: + golden = FIXTURES.joinpath(*block["golden"].split("/")).read_bytes() + written = directory.joinpath(*block["out"].split("/")).read_bytes() + assert written == golden, "the written bytes" + # `written: false` is two claims in one: the target does not exist after the run, + # or — when `preseed` planted it — its planted bytes are still there. + if block.get("written") is False: + if planted is None: + assert not target.exists(), f"{target_rel} was never created" + else: + assert target.read_bytes() == planted, f"{target_rel} is byte-untouched" + + rerun = block.get("rerun") + if rerun: + after_first = _snapshot(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 + # run reported the same way, with the write already done. + _assert_badge_document( + stdout, {**block, "action": rerun["action"]}, target_rel, f"{name} (rerun)" + ) + if rerun["byteIdentical"]: + assert _snapshot(directory) == after_first, ( + "a second run is a byte-level no-op across the whole working tree" + ) + finally: + shutil.rmtree(directory, ignore_errors=True) diff --git a/packages/sdk-py/tests/test_canary.py b/packages/sdk-py/tests/test_canary.py new file mode 100644 index 0000000..b58082c --- /dev/null +++ b/packages/sdk-py/tests/test_canary.py @@ -0,0 +1,1062 @@ +"""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 the request corpus (`trustCanary`) and the layout claims +(`export.layout`), so all three SDKs answer identical requests against identical +bytes. Mirrors packages/sdk/test/canary.test.ts. + +Scope: the four F8 layout fixtures — their layout roles, their golden export bytes, +and their canary corpus. The general `export`-block harness (findings, `--strict` +variants) takes every other fixture. +""" + +from __future__ import annotations + +import hashlib +import http.client +import json +import os +import posixpath +import shutil +import threading +from pathlib import Path +from typing import Callable + +import pytest + +from leji import build_viewer, generate_viewer, load_manifest +from leji import export_cmd, fsx +from leji.serve_cmd import serve_viewer + +LAYOUT_FIXTURES = [ + "valid-unified-leji-fresh", + "valid-unified-leji-stale-tree", + "valid-trust-canary-nested-root", + "valid-trust-canary-dot-root", +] + +# The planted byte string. Spelled in each harness and deliberately in no +# `expected.json`: under `rootPath: "."` a fixture's own metadata is exported like +# any other file, so a token literal there would count as a leak. +TOKEN = "LEJI-TRUST-CANARY" + +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. A violation is a harness + error — the fixture is the contract, so a malformed one fails loudly rather than + being repaired here.""" + 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: + """Join a fixture-declared POSIX path onto a working copy.""" + 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. + Regular files and directories only: a symlink anywhere inside a seed is a harness + error, and no seed file is ever executed, so modes stay the platform's default.""" + 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(): + assert entry.name not in (".leji", "dist"), ( + f'seed path component "{entry.name}" is gitignored at any depth; ' + "spell it under the seed name" + ) + _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, name: str, seeds: list[dict]) -> Path: + """A pristine working copy of the fixture with every declared seed materialized.""" + directory = factory.mktemp("leji-canary") + shutil.copytree(FIXTURES / name, directory, dirs_exist_ok=True) + targets: list[str] = [] + for seed in seeds: + src = _fixture_rel(seed["from"], "seed.from") + to = _fixture_rel(seed["to"], "seed.to") + to_abs = _fixture_abs(directory, to) + # A pre-existing target means the working copy is not what the harness thinks + # it is; overlapping targets are a fixture-authoring error, not something to + # resolve by ordering. + assert not to_abs.exists(), f"seed target already exists: {to}" + for other in targets: + assert to != other and not to.startswith(other + "/"), ( + f"seed targets overlap: {to} and {other}" + ) + targets.append(to) + _copy_seed(_fixture_abs(directory, src), to_abs) + 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( + str(p.relative_to(directory)).replace("\\", "/") + for p in directory.rglob("*") + if p.is_file() + ) + + +def _golden_path(fixture_root: Path, declared: str, what: str) -> Path: + """A golden artifact at its declared name, or at the dot-prefixed name beside it: + a `rootPath: "."` fixture exports its own root, so a plainly named golden would be + exported into the next bake of itself. The dot form is skipped by the content + walk, which is what makes it committable there (fixtures/README.md).""" + head, _, rest = _fixture_rel(declared, what).partition("/") + plain = _fixture_abs(fixture_root, head if not rest else f"{head}/{rest}") + if plain.exists(): + return plain + return _fixture_abs(fixture_root, "." + head if not rest else f".{head}/{rest}") + + +def _count_token(directory: Path) -> tuple[int, list[str]]: + """Recursive occurrences of the token under `directory` (an absent directory + counts as zero, which is what a run that wrote no tree leaves behind).""" + if not directory.exists(): + return 0, [] + count = 0 + where: list[str] = [] + needle = TOKEN.encode() + for dirpath, _dirnames, filenames in os.walk(directory): + for name in sorted(filenames): + path = Path(dirpath) / name + if path.is_symlink() or not path.is_file(): + continue + hits = path.read_bytes().count(needle) + if hits: + count += hits + where.append(str(path.relative_to(directory))) + return count, where + + +def _serve(directory: Path, manifest: dict) -> tuple[int, Callable[[], None]]: + """Start the viewer over `directory`; returns its port and a stop callable.""" + server = serve_viewer(str(directory), 0, manifest["rootPath"]) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def stop() -> None: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + return server.server_address[1], stop + + +def _request(port: int, url_path: str) -> tuple[int, str]: + """Issue one request with the corpus's path EXACTLY as written — no URL parsing + on this side, or the encoded and malformed variants would be canonicalized before + the server ever saw them.""" + conn = http.client.HTTPConnection("127.0.0.1", port) + try: + conn.request("GET", url_path) + response = conn.getresponse() + return response.status, response.read().decode("utf-8", "replace") + finally: + conn.close() + + +def _expected(name: str) -> dict: + return json.loads((FIXTURES / name / "expected.json").read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("name", LAYOUT_FIXTURES) +def test_layout_fixture_canary_and_idempotency( + name: str, tmp_path_factory: pytest.TempPathFactory +) -> None: + expected = _expected(name) + expected_export = expected.get("export") + canary = expected.get("trustCanary") + assert expected_export is not None, f"{name} declares an export block" + + directory = _materialize(tmp_path_factory, name, expected.get("seeds") or []) + manifest = load_manifest(str(directory)).manifest + assert manifest is not None, "the fixture manifest loads" + + # The planted bytes are really planted: without this the scans below could pass + # over a fixture that plants nothing. + planted_before: dict[str, str] = {} + for rel in (canary or {}).get("plantedPaths", []): + text = _fixture_abs(directory, _fixture_rel(rel, "plantedPaths entry")).read_text( + encoding="utf-8" + ) + assert TOKEN in text, f"{rel} carries the canary token" + planted_before[rel] = text + # Every path the fixture says must survive the run, as it stands before it. + layout = expected_export.get("layout") or {} + preserved_before: dict[str, str] = {} + for rel in layout.get("preserved", []): + abs_path = _fixture_abs(directory, _fixture_rel(rel, "preserved entry")) + assert abs_path.exists(), f"preserved path exists before the run: {rel}" + if abs_path.is_file(): + preserved_before[rel] = abs_path.read_text(encoding="utf-8") + + # --- the run ------------------------------------------------------------ + first = build_viewer(str(directory), manifest) + exit_code = 1 if any(f.severity == "error" for f in first.findings) else 0 + assert exit_code == expected_export["exit"], f"exit code (findings: {first.findings})" + assert first.out.replace(os.sep, "/") == expected_export["out"], "the declared output directory" + + # --- layout ------------------------------------------------------------- + for role, role_dir in (layout.get("roles") or {}).items(): + abs_path = _fixture_abs(directory, _fixture_rel(role_dir, f"role {role}")) + assert abs_path.is_dir(), f"role {role} established at {role_dir}" + for rel in layout.get("present", []): + assert _fixture_abs(directory, _fixture_rel(rel, "present entry")).exists(), ( + f"present after the run: {rel}" + ) + for rel in layout.get("absent", []): + assert not _fixture_abs(directory, _fixture_rel(rel, "absent entry")).exists(), ( + f"never created: {rel}" + ) + for rel, before in preserved_before.items(): + abs_path = _fixture_abs(directory, rel) + assert abs_path.exists(), f"still present after the run: {rel}" + assert abs_path.read_text(encoding="utf-8") == before, f"byte-identical: {rel}" + + # --- the golden tree ------------------------------------------------------ + golden = expected_export["goldenTree"] + if golden["status"] == "baked": + out = _fixture_abs(directory, _fixture_rel(expected_export["out"], "export out")) + fixture_root = FIXTURES / name + content_dir = _golden_path(fixture_root, golden["contentDir"], "goldenTree.contentDir") + manifest_file = _golden_path(fixture_root, golden["manifest"], "goldenTree.manifest") + written = _files_under(out) + in_content = [f[len("content/") :] for f in written if f.startswith("content/")] + outside = [f for f in written if not f.startswith("content/")] + + # The committed bytes ARE the export's content tree: same paths, same bytes, in + # both directions, so a file that appears or disappears fails here. + assert in_content == _files_under(content_dir), ( + f"{name}: the golden content tree lists exactly what the export wrote" + ) + for rel in in_content: + assert (out / "content").joinpath(*rel.split("/")).read_bytes() == content_dir.joinpath( + *rel.split("/") + ).read_bytes(), f"{name}: exported bytes differ from the golden for content/{rel}" + + # Everything else — chrome, vendored assets, fonts — by digest and size. The two + # sets are disjoint by construction and exhaustive by this comparison. + golden_manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + assert golden_manifest["version"] == 1, "the manifest states its version" + assert sorted(golden_manifest["files"]) == outside, ( + f"{name}: the manifest pins every file outside content/" + ) + for rel in outside: + data = out.joinpath(*rel.split("/")).read_bytes() + pin = golden_manifest["files"][rel] + assert hashlib.sha256(data).hexdigest() == pin["sha256"], rel + assert len(data) == pin["size"], f"{rel} size" + + # --- the export-side scan ------------------------------------------------ + if canary: + scan_root = _fixture_abs( + directory, _fixture_rel(canary["exportScan"]["root"], "exportScan.root") + ) + count, found_in = _count_token(scan_root) + assert count == canary["exportScan"]["occurrences"], ( + f"canary occurrences in {canary['exportScan']['root']}: {', '.join(found_in)}" + ) + + # --- the serve corpus ---------------------------------------------------- + if canary: + port, stop = _serve(directory, manifest) + try: + scan_bodies = (canary["serve"].get("routeScan") or {}).get( + "assertNoTokenIn200Bodies", True + ) + for want in canary["serve"]["requests"]: + status, body = _request(port, want["path"]) + assert status == want["status"], ( + f"{want['path']}{' — ' + want['note'] if want.get('note') else ''}" + ) + if status == 200 and scan_bodies is not False: + assert TOKEN not in body, f"no canary byte in the 200 body of {want['path']}" + finally: + stop() + + # --- idempotency --------------------------------------------------------- + if (expected_export.get("rerun") or {}).get("byteIdentical"): + after_first = _snapshot(directory) + build_viewer(str(directory), manifest) + assert _snapshot(directory) == after_first, ( + "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 into + # anything, and never rewrote them either. + for rel, before in planted_before.items(): + assert _fixture_abs(directory, rel).read_text(encoding="utf-8") == before, ( + f"untouched: {rel}" + ) + + +def _canary_layer(factory: pytest.TempPathFactory) -> Path: + """The dot-root canary layer with its seed materialized: the topology where the + trust domain sits inside the content mount, so a symlink into it resolves inside + every containment check and only the by-name whitelist refuses it.""" + return _materialize( + factory, "valid-trust-canary-dot-root", [{"from": ".leji-seed", "to": ".leji"}] + ) + + +def _load(directory: Path) -> dict: + manifest = load_manifest(str(directory)).manifest + assert manifest is not None + return manifest + + +def _patch_manifest(directory: Path, key: str, value: object) -> None: + path = directory / "leji.json" + declared = json.loads(path.read_text(encoding="utf-8")) + declared[key] = value + path.write_text(json.dumps(declared, indent=2) + "\n", encoding="utf-8") + + +def test_whitelist_refuses_content_symlink_into_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # The one boundary a fixture cannot plant (a seed carries no symlinks) and the one + # the dot convention cannot hold: under `rootPath: "."` the trust domain really is + # inside the content mount, so a symlink there resolves INSIDE the mount root and + # passes every containment check. Only the by-name whitelist refuses it — remove + # the servable_path calls in the serve path and this test serves the canary. + directory = _canary_layer(tmp_path_factory) + manifest = _load(directory) + (directory / "leak.md").symlink_to(Path(".leji") / "work" / "proposal.md") + (directory / "leakdir").symlink_to(Path(".leji") / "work") + # Generate the chrome (and an export) with the symlinks already planted, so the + # serve legs run against a complete layer and the export legs see the bait. + build_viewer(str(directory), manifest) + port, stop = _serve(directory, manifest) + try: + for route in ("/content/leak.md", "/content/leakdir/proposal.md"): + status, body = _request(port, route) + assert status == 404, f"{route} is denied by name, whatever it resolves to" + assert TOKEN not in body, f"no canary byte in the response to {route}" + # The servable role still serves through its own mount: the whitelist denies + # the other roles, not the chrome. + assert _request(port, "/index.html")[0] == 200 + finally: + stop() + # And the export never followed it either (symlinks are skipped, and the target is + # outside the enumerated roots). + assert _count_token(directory / ".leji" / "dist")[0] == 0, "no canary byte in the export" + assert not (directory / ".leji" / "dist" / "content" / "leak.md").exists() + + +# The vectors below share the reason the test above lives here rather than in a +# fixture: they need a symlink (a seed carries none by contract — _copy_seed refuses +# one) or a hostile manifest, which is a per-SDK hazard rather than a shared contract +# the fixtures publish. So they are constructed at runtime, over a fixture's own layer +# and its own planted bytes. + + +def test_whitelist_refuses_bound_profile_in_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _canary_layer(tmp_path_factory) + # A profile pair the resolver really composes: an ordinary base under the layer's + # agents directory, and a derived half planted in the onboarding workspace, bound + # into the roster by a symlink at the content root. Without the whitelist on the + # profile sources, the resolved page renders the planted half verbatim — the + # overlay answers before the content mount ever judges the path. + (directory / "agents").mkdir(parents=True, exist_ok=True) + (directory / "agents" / "core.md").write_text( + "\n".join( + [ + "---", + "id: core", + "name: Core", + "role: core", + "requiredRead:", + " - boot-profile.md", + "mustAskWhen:", + " - anything is unclear", + "---", + "", + "Base body.", + "", + ] + ), + encoding="utf-8", + ) + (directory / ".leji" / "work" / "leak-profile.md").write_text( + "\n".join( + [ + "---", + "id: leak", + "name: Leak", + "role: leak", + "inherits: core", + "---", + "", + f"Planted: {TOKEN}", + "", + ] + ), + encoding="utf-8", + ) + (directory / "leak.md").symlink_to(Path(".leji") / "work" / "leak-profile.md") + _patch_manifest(directory, "agents", {"leak": "leak.md"}) + + manifest = _load(directory) + build_viewer(str(directory), manifest) + port, stop = _serve(directory, manifest) + try: + status, body = _request(port, "/content/leak.md") + assert status == 404, "the profile overlay refuses a source it may not read" + assert TOKEN not in body, "no canary byte in the response" + # The overlay still resolves the profiles it may read. + assert _request(port, "/content/agents/core.md")[0] == 200 + finally: + stop() + count, where = _count_token(directory / ".leji" / "dist") + assert count == 0, f"no canary byte in the export: {', '.join(where)}" + assert not (directory / ".leji" / "dist" / "content" / "leak.md").exists() + + +def test_sidebar_lifts_no_label_out_of_a_private_profiles_dir( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _canary_layer(tmp_path_factory) + # The same scan, reached the other way: a declared `agentProfilesPath` naming a + # private role needs no symlink at all. The page itself was always refused, but the + # sidebar built its label from the file's frontmatter — bytes of a private file, + # served in a 200 body and copied into the export. + (directory / ".leji" / "work" / "p.md").write_text( + "\n".join( + [ + "---", + "id: planted", + f"name: {TOKEN}", + "role: planted", + "requiredRead:", + " - boot-profile.md", + "mustAskWhen:", + " - anything is unclear", + "---", + "", + "Body.", + "", + ] + ), + encoding="utf-8", + ) + _patch_manifest(directory, "machine", {"agentProfilesPath": ".leji/work/"}) + manifest = _load(directory) + build_viewer(str(directory), manifest) + count, where = _count_token(directory / ".leji" / "dist") + assert count == 0, f"no canary byte in the export: {', '.join(where)}" + port, stop = _serve(directory, manifest) + try: + status, body = _request(port, "/content/_sidebar.md") + assert status == 200, "the live sidebar still builds" + assert TOKEN not in body, "and carries no byte of the planted profile" + finally: + stop() + + +# --- The check-before-act invariant on WRITE/CLEAR targets ---------------------------------- +# One structural rule: every location the tool writes into or clears is realpath- +# resolved and validated against its role BEFORE the operation — never after, never +# conditionally. These pin the two write-side vectors two review rounds left open. + + +def test_check_before_act_generation_refuses_viewer_aliased_into_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _canary_layer(tmp_path_factory) + # Point the servable role at another private role, bytes of its own already there. + # Before the check-before-act rule, generation wrote the chrome THROUGH the link + # into the trust domain and only the export's later identity check noticed — after + # the mutation. The aliased directory is snapshotted WHOLE, so any pre-refusal + # write (not just an overwrite of one planted file) is caught. + aliased = directory / ".leji" / "work" / "chrome" + (aliased / "assets").mkdir(parents=True) + (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) + + 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" + + # build_viewer regenerates first, so it inherits the refusal and never reaches the + # destructive clean/copy: no export is produced either. + built = build_viewer(str(directory), manifest) + 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 not (directory / ".leji" / "dist").exists(), "no export was written" + + +def test_check_before_act_default_output_refuses_dist_into_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + directory = _canary_layer(tmp_path_factory) + # The surviving default-bypass vector: the reservation used to be conditioned on a + # caller --out, so a default .leji/dist redirected into the trust domain slipped + # through. Now the default is validated identically — before any clear or write. + planted = directory / ".leji" / "mounts" / "store" / "x" + planted.mkdir(parents=True, exist_ok=True) + (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") + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(directory), manifest, None) + assert _snapshot(directory / ".leji" / "mounts") == before, ( + "nothing was cleared or written in the private role" + ) + assert (planted / "planted").read_text(encoding="utf-8") == f"{TOKEN}\n" + + +def test_check_before_act_out_of_repository_viewer_or_dist_alias_is_refused( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # Containment is absolute: every write this tool makes lands inside the repository + # it was pointed at. A `.leji/viewer` or `.leji/dist` symlinked to a real, empty + # destination outside the tree — once a supported relocate/publish alias — is a + # hard refusal now, with nothing written through it. A user who wants the export + # elsewhere copies the finished folder there. + chrome_home = tmp_path_factory.mktemp("leji-chrome") + relocated = _canary_layer(tmp_path_factory) + (relocated / ".leji" / "viewer").symlink_to(chrome_home) + built = build_viewer(str(relocated), _load(relocated), None) + assert any(f.rule == "viewer-target-refused" for f in built.findings), ( + "the relocated viewer role is refused" + ) + assert not built.wrote, "and the export never runs" + assert list(chrome_home.iterdir()) == [], "nothing was written into the out-of-tree viewer home" + + publish = tmp_path_factory.mktemp("leji-publish") + published = _canary_layer(tmp_path_factory) + (published / ".leji" / "dist").symlink_to(publish) + with pytest.raises(RuntimeError, match="resolves outside the repository"): + build_viewer(str(published), _load(published), None) + assert list(publish.iterdir()) == [], "nothing was written into the out-of-tree publish root" + + +def test_check_before_act_boundary_skip_warns_once_and_a_clean_build_is_silent( + tmp_path_factory: pytest.TempPathFactory, capsys: pytest.CaptureFixture[str] +) -> None: + # A servable-looking source (an .md at the content root) whose resolved path lands + # in a private role: withheld from serve and export, and — unlike an ordinary skip + # — it says why, exactly once, on stderr (never stdout, never --json). + directory = _canary_layer(tmp_path_factory) + (directory / "leak.md").symlink_to(Path(".leji") / "work" / "proposal.md") + manifest = _load(directory) + build_viewer(str(directory), manifest, None) + captured = capsys.readouterr() + warnings = [line for line in captured.err.split("\n") if line.startswith("skipped leak.md:")] + assert len(warnings) == 1, f"the withheld source is named exactly once: {captured.err!r}" + assert "resolves into .leji/work (private); not served or exported" in warnings[0] + assert "skipped leak.md" not in captured.out, "never on stdout" + assert _count_token(directory / ".leji" / "dist")[0] == 0, "no canary byte reached the export" + + # A clean layer (no cross-role source) says nothing on stderr. + clean = _canary_layer(tmp_path_factory) + build_viewer(str(clean), _load(clean), None) + assert [ + line for line in capsys.readouterr().err.split("\n") if line.startswith("skipped ") + ] == [], "a clean build emits no boundary-skip warning" + + +def test_export_refuses_an_out_that_resolves_into_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # The nested topology, deliberately: with the content root a subdirectory, an --out + # at the repository root is a legitimate destination, so the reservation is the only + # rule standing between a redirected path and the private domain. + directory = _materialize( + tmp_path_factory, "valid-trust-canary-nested-root", [{"from": ".leji-seed", "to": ".leji"}] + ) + manifest = _load(directory) + # Proof the destination is otherwise open: an ordinary sibling path exports. + build_viewer(str(directory), manifest, "plain-out") + assert (directory / "plain-out" / "index.html").is_file(), "an ordinary --out exports" + # The same path, redirected: the reservation judges where the write would land, so + # the private role is refused however the destination is spelled. + (directory / "redirect").symlink_to(Path(".leji") / "mounts") + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(directory), manifest, "redirect/export") + assert not (directory / ".leji" / "mounts" / "export").exists(), "nothing written into the role" + # The refusal is not destructive either: the planted bytes are as planted. + planted = directory / ".leji" / "mounts" / "store" / "x" / "planted" + assert TOKEN in planted.read_text(encoding="utf-8"), "the private role is intact" + + +# --- Check-before-act completeness: the overview.md write sites and the resolver's dangling paths. +# These pin the write sites two review rounds after the first left them: overview.md +# (seed AND refresh) is a content write that used to be guarded by containment only, +# and a nested/chained/unresolvable `--out` whose real destination the resolver used to +# rebuild lexically. Each hard-refusal case names, in its comment, the mutation that +# reddens it. + + +def _folds_case(directory: Path) -> bool: + """Whether this directory sits on a filesystem that cannot tell `.leji` from + `.LEJI` — asked of the volume, so a case-variant assertion runs only where the + fold is real.""" + probe = directory / "leji-case-probe" + probe.mkdir(parents=True, exist_ok=True) + try: + return (directory / "LEJI-CASE-PROBE").exists() + finally: + shutil.rmtree(probe, ignore_errors=True) + + +def test_check_before_act_generation_refuses_an_overview_seed_aliased_into_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # rootPath ".", so overview.md is seeded at the repository root. A symlink there + # into a private role is contained (inside the repo) yet crosses the trust + # boundary: containment-only was the gap. The target dangles, so the seed WOULD + # create it inside the role. Mutation that reddens: revert the overview guard to + # resolved_within_root-only (no writable_target) — the seed writes through and + # .leji/<role>/new.md appears. + for role in ("work", "mounts"): + directory = _canary_layer(tmp_path_factory) + role_dir = directory / ".leji" / role + 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) + + gen = generate_viewer(str(directory), manifest) + assert any( + f.rule == "viewer-target-refused" + and f.severity == "error" + and "overview.md" in f.message + and f".leji/{role} (private)" in f.message + for f in gen.findings + ), 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" + + # 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. + directory = _canary_layer(tmp_path_factory) + if _folds_case(directory): + (directory / ".leji" / "work").mkdir(parents=True, exist_ok=True) + (directory / "overview.md").symlink_to(Path(".LEJI") / "work" / "case.md") + manifest = _load(directory) + gen = generate_viewer(str(directory), manifest) + assert any( + f.rule == "viewer-target-refused" and "overview.md" in f.message for f in gen.findings + ), "a case-variant overview.md alias is refused as the role it folds to" + assert not (directory / ".leji" / "work" / "case.md").exists(), ( + "nothing written through the case variant" + ) + + +def test_check_before_act_overview_refresh_refuses_an_alias_into_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # overview.md is a symlink to an EXISTING private file carrying the generated-map + # markers: the refresh branch (is_file true) used to resolved_within_root-check, + # read it, and rewrite the map block THROUGH the link. The check now runs on the + # resolved path before the read. Mutation that reddens: revert to + # resolved_within_root-only — the private file is read and its map block rewritten. + directory = _canary_layer(tmp_path_factory) + target = directory / ".leji" / "mounts" / "existing.md" + target.parent.mkdir(parents=True, exist_ok=True) + original = ( + f"# private {TOKEN}\n" + "<!-- leji:generated-map:start -->STALE<!-- leji:generated-map:end -->\n" + ) + target.write_text(original, encoding="utf-8") + (directory / "overview.md").symlink_to(Path(".leji") / "mounts" / "existing.md") + manifest = _load(directory) + + gen = generate_viewer(str(directory), manifest) + assert any( + f.rule == "viewer-target-refused" + and f.severity == "error" + and "overview.md" in f.message + and ".leji/mounts (private)" in f.message + for f in gen.findings + ), "the refresh refuses the alias with a hard error" + assert target.read_text(encoding="utf-8") == original, ( + "the private file was neither read-then-rewritten nor touched" + ) + + +def test_export_refuses_a_nested_dangling_out_redirecting_into_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # `redirect/export` where `redirect` is a DANGLING symlink into a private role: a + # write would follow it, but the resolver used to climb past the dangling component + # and rebuild `redirect/export` lexically (outside .leji/), so the check passed and + # a target created afterward raced the write into the role. The resolver now follows + # the dangling intermediate link. Mutation that reddens: revert resolved_path's + # intermediate-symlink follow (climb-past) — out_abs reads as outside .leji/ and the + # build is not refused. + directory = _materialize( + tmp_path_factory, "valid-trust-canary-nested-root", [{"from": ".leji-seed", "to": ".leji"}] + ) + 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") + 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, ( + "nothing was cleared or written in the private role" + ) + + # The created-after-validation race, closed: even once the target exists, the same + # resolved path is judged, so the build still refuses (never a one-time dangling + # fluke that a real directory would slip past). + (directory / ".leji" / "mounts" / "ghost").mkdir(parents=True) + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(directory), manifest, "redirect/export") + + +def test_export_refuses_a_chained_dangling_out_that_ends_in_a_private_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # redirect -> hop -> .leji/work/ghost, every hop dangling: the resolver follows the + # chain of intermediate dangling links to the real destination. Mutation that + # reddens: revert resolved_path's intermediate-symlink follow — the chain is rebuilt + # lexically as outside .leji/ and the build is not refused. + directory = _materialize( + tmp_path_factory, "valid-trust-canary-nested-root", [{"from": ".leji-seed", "to": ".leji"}] + ) + manifest = _load(directory) + (directory / "redirect").symlink_to("hop") + (directory / "hop").symlink_to(Path(".leji") / "work" / "ghost") + work_before = _snapshot(directory / ".leji" / "work") + 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, ( + "nothing was cleared or written in the private role" + ) + + +def test_export_treats_an_unresolvable_out_as_a_failure_not_as_absent( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # A non-ENOENT resolution failure (here an unreadable intermediate directory) must + # FAIL the check, never be rebuilt lexically as a not-yet-created target. Mutation + # that reddens: make resolved_path return the lexical path on a non-ENOENT error — + # the build proceeds instead of refusing. Skipped as root, which bypasses the mode. + if hasattr(os, "getuid") and os.getuid() == 0: + pytest.skip("running as root bypasses directory permissions; the EACCES cannot be built") + directory = _materialize( + tmp_path_factory, "valid-trust-canary-nested-root", [{"from": ".leji-seed", "to": ".leji"}] + ) + manifest = _load(directory) + noperm = directory / "noperm" + (noperm / "sub").mkdir(parents=True) + noperm.chmod(0o000) + try: + with pytest.raises(RuntimeError, match=r"cannot be resolved \(permission or I/O error\)"): + build_viewer(str(directory), manifest, "noperm/sub/export") + finally: + noperm.chmod(0o755) + + +def test_check_before_act_refuses_a_case_variant_alias_through_a_non_enumerable_directory( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # The composition the separate case-fold and unresolvable cases left open: a + # `.LEJI/` spelling of the role tree reached through a directory that is + # traversable and writable but NOT enumerable. The canonical spelling is read back + # from the directory, so denying enumeration denies case recovery — and falling + # back to the caller's spelling made the resolved target compare as outside + # `.leji/`, so the write and the clear were permitted straight into a private role. + # An enumeration failure now makes the path unresolvable, which refuses both. + # Mutation that reddens: return the given name from _real_name on an OSError — + # generation writes the chrome into .leji/work and the export clears and writes + # into .leji/mounts. + if hasattr(os, "getuid") and os.getuid() == 0: + pytest.skip("running as root bypasses directory permissions; the mode cannot be built") + + # Write side: `.leji/viewer` aliased to `../.LEJI/work/chrome`. + directory = _canary_layer(tmp_path_factory) + if not _folds_case(directory): + pytest.skip("this volume tells .leji from .LEJI; the case-alias vector cannot be built") + aliased = directory / ".leji" / "work" / "chrome" + (aliased / "assets").mkdir(parents=True) + (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) + # Searchable and writable, but unlistable: the repository directory is the one that + # holds the canonical spelling of `.leji`. + directory.chmod(0o311) + try: + gen = generate_viewer(str(directory), manifest) + assert any( + 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" + finally: + directory.chmod(0o755) + + # Clear side: the default output aliased to an EMPTY directory in a private role, + # so the clearable-export rule cannot be what refuses it. + other = _canary_layer(tmp_path_factory) + (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") + 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, ( + "nothing was cleared or written in the private role" + ) + + +# --- Check-before-act: the check/use gap on the READ side ----------------------------------- +# These need a mutation landing at one exact moment inside a run, which no fixture can +# plant, so they are constructed here — over the canary layer, with the planted bytes +# in a private role. Each names, in its comment, the mutation that reddens it. + + +class _ListedScan: + """A materialized `os.scandir` result: the entries are read eagerly so a swap + performed the instant the listing returns cannot change what the walk enumerated — + which is exactly the window these canaries exercise.""" + + def __init__(self, entries: list) -> None: + self._it = iter(entries) + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> bool: + return False + + def __iter__(self): + return self + + def __next__(self): + return next(self._it) + + def close(self) -> None: + pass + + +def _swap_into_private_role(directory: Path, name: str) -> None: + """Replace the content directory `name` with a symlink into `.leji/work/swapped`, + where the planted bytes already sit.""" + (directory / name).rename(directory / f"{name}-real") + (directory / name).symlink_to(Path(".leji") / "work" / "swapped") + + +def _plant_decoy(directory: Path) -> None: + """The decoy the swapped ancestor would resolve to: a private role holding files + named exactly as the carried ones, so a follow lands planted bytes in the export.""" + decoy = directory / ".leji" / "work" / "swapped" + decoy.mkdir(parents=True, exist_ok=True) + (decoy / "overview.md").write_text(f"# planted {TOKEN}\n", encoding="utf-8") + (decoy / "asset.txt").write_text(f"{TOKEN}\n", encoding="utf-8") + (directory / "domain" / "asset.txt").write_text("an ordinary carried asset\n", encoding="utf-8") + + +def _assert_dropped_not_followed(directory: Path, err: str, why: str) -> None: + """Every read-side canary ends the same way: the export ran to completion, carried + no planted byte, dropped the redirected sources rather than following them, and + said why — once per source, on stderr.""" + dist = directory / ".leji" / "dist" + assert (dist / "index.html").is_file(), "the export still ran to completion" + count, where = _count_token(dist) + assert count == 0, f"no planted byte may reach the export: {', '.join(where)}" + for rel in ("overview.md", "asset.txt"): + assert not (dist / "content" / "domain" / rel).exists(), f"{why}: {rel}" + # A source that now resolves into a private role is a level-2 refusal: dropping it + # silently would leave an operator with a quietly shorter export and no reason. + warnings = [line for line in err.split("\n") if line.startswith("skipped domain/")] + assert warnings, f"the redirected sources must be named on stderr: {err!r}" + for line in warnings: + assert "resolves into .leji/work (private); not served or exported" in line + + +def test_check_before_act_ancestor_swapped_after_enumeration_is_never_followed( + tmp_path_factory: pytest.TempPathFactory, capsys: pytest.CaptureFixture[str], monkeypatch +) -> None: + # The content walk enumerates a real directory; before the export uses what it + # enumerated, that directory becomes a symlink into a private role. Every later read + # or copy BY PATH then goes through the link, with the walk's checks all behind it — + # and a revalidation that lstats the final component alone follows the swapped + # ancestor to a perfectly ordinary file. So a carried source is resolved, its + # RESOLVED path judged, and its bytes taken from the descriptor fstat proved a + # regular file: the check and the use hold one inode. Mutation that reddens: + # revalidate with lstat and read/copy by path again — the planted bytes below are + # linted and land in the export. + directory = _canary_layer(tmp_path_factory) + _plant_decoy(directory) + manifest = _load(directory) + + # The swap, at the one moment that matters: after the walk has read `domain/`'s + # entries and before it uses any of them. Generation runs first over the same tree, + # so the hook arms only once the export resolves its own output target — the first + # thing the pipeline does after generating. + domain_dir = directory / "domain" + dist_abs = directory / ".leji" / "dist" + state = {"armed": False, "swapped": False} + real_scandir = os.scandir + real_resolver = export_cmd.resolved_path_under + + def arming_resolver(base: str, abs_path: str): + if os.path.abspath(abs_path) == str(dist_abs): + state["armed"] = True + return real_resolver(base, abs_path) + + def swapping_scandir(path): + with real_scandir(path) as it: + entries = list(it) + if state["armed"] and not state["swapped"] and os.path.abspath(path) == str(domain_dir): + state["swapped"] = True + _swap_into_private_role(directory, "domain") + return _ListedScan(entries) + + monkeypatch.setattr(export_cmd, "resolved_path_under", arming_resolver) + monkeypatch.setattr(os, "scandir", swapping_scandir) + build_viewer(str(directory), manifest, None) + monkeypatch.undo() + + assert state["swapped"], "the ancestor must have been swapped between the walk and the use" + _assert_dropped_not_followed( + directory, + capsys.readouterr().err, + "the redirected source must be dropped rather than followed", + ) + + +def test_check_before_act_ancestor_swapped_between_check_and_open_is_caught_by_the_recheck( + tmp_path_factory: pytest.TempPathFactory, capsys: pytest.CaptureFixture[str], monkeypatch +) -> None: + # The residual the descriptor pinning left: the swap lands AFTER the resolve that + # authorized the source and BEFORE the open on it, so the open follows the new link + # and the descriptor holds planted bytes while every check has already passed on the + # authorized path. fstat cannot see it — the decoy is a perfectly ordinary regular + # file. The recheck after the open resolves the source once more and requires the + # same location AND the same file identity, so the bytes about to be read are proved + # to be the ones the check judged. Mutation that reddens: drop the recheck in + # open_verified_source and trust fstat alone — the planted bytes land in the export. + directory = _canary_layer(tmp_path_factory) + _plant_decoy(directory) + manifest = _load(directory) + + domain_dir = directory / "domain" + dist_abs = directory / ".leji" / "dist" + state = {"armed": False, "swapped": False} + real_resolver = fsx.resolved_path_under + export_resolver = export_cmd.resolved_path_under + + def arming_resolver(base: str, abs_path: str): + if os.path.abspath(abs_path) == str(dist_abs): + state["armed"] = True + return export_resolver(base, abs_path) + + def swapping_resolver(base: str, abs_path: str): + real = real_resolver(base, abs_path) + if ( + state["armed"] + and not state["swapped"] + and real is not None + and os.path.dirname(real) == str(domain_dir) + ): + state["swapped"] = True + _swap_into_private_role(directory, "domain") + return real + + monkeypatch.setattr(export_cmd, "resolved_path_under", arming_resolver) + monkeypatch.setattr(fsx, "resolved_path_under", swapping_resolver) + build_viewer(str(directory), manifest, None) + monkeypatch.undo() + + assert state["swapped"], "the ancestor must have been swapped between the check and the open" + _assert_dropped_not_followed( + directory, + capsys.readouterr().err, + "the source whose path and descriptor diverged is dropped, never read", + ) + + +def test_export_refuses_a_dangling_output_entry_and_creates_nothing( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # A dangling symlink is a standing entry under both forms — never written through, + # never read as absent. The output used to be resolved before anything judged it, + # so `.leji/dist -> site` with `site` missing BECAME its own destination: the stat + # reported absence, "clearable" followed, and the export created and filled the + # link's target. The original entry is judged first now. Mutation that reddens: + # drop the lstat on the original entry — the build writes through the link. + directory = _materialize( + tmp_path_factory, "valid-trust-canary-nested-root", [{"from": ".leji-seed", "to": ".leji"}] + ) + manifest = _load(directory) + # Settle the internal chrome first: every build regenerates it, so the comparison + # below measures the export's destructive half and nothing else. + generate_viewer(str(directory), manifest) + + (directory / ".leji" / "dist").symlink_to(Path("..") / "site") + (directory / "published").symlink_to("elsewhere") + before = _snapshot(directory) + + with pytest.raises(RuntimeError, match="it is a dangling symlink"): + build_viewer(str(directory), manifest, None) + with pytest.raises(RuntimeError, match="it is a dangling symlink"): + build_viewer(str(directory), manifest, "published") + + assert (directory / ".leji" / "dist").is_symlink(), "the default link is left in place" + 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" diff --git a/packages/sdk-py/tests/test_ci.py b/packages/sdk-py/tests/test_ci.py index dc15ffa..fdc5424 100644 --- a/packages/sdk-py/tests/test_ci.py +++ b/packages/sdk-py/tests/test_ci.py @@ -10,11 +10,15 @@ import pytest from leji.cli import main -from leji.init_cmd import ( - build_azure_pipeline, - build_circleci_config, - build_circleci_snippet, -) + +REPO_ROOT = Path(__file__).resolve().parents[3] +GOLDENS = REPO_ROOT / "fixtures" / "ci-goldens" + + +def golden(name: str) -> str: + """One committed generated-CI golden: the byte oracle both this port and the + reference are checked against.""" + return (GOLDENS / name).read_text(encoding="utf-8") def run(capsys, argv: list[str]) -> tuple[int, str, str]: @@ -67,7 +71,7 @@ def test_ci_writes_when_absent_idempotent_and_exits_1_with_no_manifest( def _seeded_ci_dir(capsys, tmp_path: Path) -> Path: layer = tmp_path / "layer" - layer.mkdir() + layer.mkdir(parents=True) main(["init", "--dir", str(layer), "--yes", "--name", "demo"]) capsys.readouterr() return layer @@ -147,14 +151,30 @@ def test_ci_provider_circleci(capsys, tmp_path: Path) -> None: assert code == 0 assert json.loads(out)["action"] == "created" before = cc.read_text(encoding="utf-8") - assert before == build_circleci_config(), "created config is byte-exact" + assert before == golden("circleci-node-fallback.yml"), "created config is byte-exact" + # A file leji generated is leji's to keep current: the re-run recognizes its own + # bytes and reports unchanged rather than handing back a snippet for a file the + # user never wrote. code, out, _ = run(capsys, ["ci", "--root", str(layer), "--provider", "circleci", "--json"]) assert code == 0 j = json.loads(out) - assert j["action"] == "manual" + assert j["action"] == "unchanged" assert j["created"] is False - assert j["snippet"] == build_circleci_snippet(), "manual snippet is byte-exact" - assert cc.read_text(encoding="utf-8") == before, "existing config left untouched" + assert cc.read_text(encoding="utf-8") == before, "idempotent byte-for-byte" + + # Someone else's config: never modified, and the snippet comes back to add by hand. + foreign = _seeded_ci_dir(capsys, tmp_path / "foreign") + fcc = foreign / ".circleci" / "config.yml" + fcc.parent.mkdir(parents=True, exist_ok=True) + fcc.write_text("version: 2.1\njobs:\n mine: {}\n", encoding="utf-8") + code, out, _ = run(capsys, ["ci", "--root", str(foreign), "--provider", "circleci", "--json"]) + assert code == 0 + j = json.loads(out) + assert j["action"] == "manual" + # The hand-add snippet is the generated config without its two leading lines (the + # ownership marker and `version: 2.1`): it claims nothing in a file leji does not own. + assert j["snippet"] == "\n".join(golden("circleci-node-fallback.yml").split("\n")[2:]) + assert fcc.read_text(encoding="utf-8") == "version: 2.1\njobs:\n mine: {}\n" # Mirrors run.test.ts "ci --provider azure: dedicated pipeline file + activation @@ -170,7 +190,7 @@ def test_ci_provider_azure(capsys, tmp_path: Path) -> None: assert j["created"] is True assert j["workflow"] == ".azure-pipelines/leji.yml" assert "Azure Pipelines does not auto-run" in j["note"] - assert az.read_text(encoding="utf-8") == build_azure_pipeline(), "pipeline file is byte-exact" + assert az.read_text(encoding="utf-8") == golden("azure-node-fallback.yml"), "byte-exact" code, out, _ = run(capsys, ["ci", "--root", str(d1), "--provider", "azure", "--json"]) assert code == 0 assert json.loads(out)["action"] == "unchanged", "idempotent" @@ -322,8 +342,8 @@ def test_ci_hooks_created_idempotent_never_clobbers(tmp_path: Path) -> None: third = ensure_local_hook(str(tmp_path)) assert third.action == "manual", "unmanaged hook is never clobbered" assert third.reason == "foreign-hook" - assert '"$LEJI" validate' in (third.snippet or "") - assert "node_modules/.bin/leji" in (third.snippet or ""), "hook prefers the local bin" + assert "'leji' validate || exit 1" in (third.snippet or "") + assert "node_modules" not in (third.snippet or ""), "the scalar shim is gone" assert "custom hook" in hook_path.read_text(encoding="utf-8"), "foreign hook untouched" @@ -366,7 +386,7 @@ def test_ci_hooks_husky_merges_managed_block(tmp_path: Path) -> None: merged = husky_pre.read_text(encoding="utf-8") assert "npm test" in merged, "existing husky content untouched" assert "# >>> leji hooks (managed) >>>" in merged - assert '"$LEJI" validate || exit 1' in merged + assert "'leji' validate || exit 1" in merged assert not (tmp_path / ".git" / "hooks" / "pre-commit").exists() assert ensure_local_hook(str(tmp_path)).action == "unchanged", "rerun is idempotent" @@ -399,7 +419,7 @@ def test_ci_hooks_custom_dir_writes_managed_file(tmp_path: Path) -> None: assert r.managed == "file" custom = tmp_path / "githooks" / "pre-commit" assert "# leji pre-commit (managed)" in custom.read_text(encoding="utf-8") - assert "node_modules/.bin/leji" in custom.read_text(encoding="utf-8"), "prefers the local bin" + assert "'leji' validate || exit 1" in custom.read_text(encoding="utf-8") assert custom.stat().st_mode & 0o111, "custom hook is executable" assert not (tmp_path / ".git" / "hooks" / "pre-commit").exists() @@ -438,7 +458,7 @@ def test_ci_hooks_path_outside_repo_is_manual( assert r.managed == "file" assert r.reason == "outside-root" assert r.path == f"{outside}/pre-commit", "reports the computed target" - assert '"$LEJI" validate' in (r.snippet or "") + assert "'leji' validate || exit 1" in (r.snippet or "") assert not (outside / "pre-commit").exists(), "nothing written outside the repo" assert not (tmp_path / ".git" / "hooks" / "pre-commit").exists() @@ -476,38 +496,91 @@ def test_ci_hooks_relative_out_of_root_normalized(tmp_path: Path) -> None: # Mirrors units.test.ts "ci: local-first CI variant ..." / "ci: npx @1 fallback ...". -def test_ci_template_variants_local_vs_npx() -> None: - from leji.init_cmd import build_github_workflow - - local = build_github_workflow(True) - assert "- run: npm ci" in local - assert "npx --no-install @leji-org/leji validate" in local - assert "npx -y @leji-org/leji@1" not in local - fallback = build_github_workflow(False) +def test_ci_job_follows_the_repository_manager(capsys, tmp_path: Path) -> None: + """The generated job installs with the manager the repository actually uses, and + declaring without a lockfile is not enough.""" + pnpm = _seeded_ci_dir(capsys, tmp_path / "pnpm") + (pnpm / "package.json").write_text( + '{"devDependencies":{"@leji-org/leji":"^1.3.0"}}', encoding="utf-8" + ) + (pnpm / "pnpm-lock.yaml").write_text("lockfileVersion: 9\n", encoding="utf-8") + run(capsys, ["ci", "--root", str(pnpm), "--provider", "github"]) + wf = (pnpm / ".github" / "workflows" / "leji.yml").read_text(encoding="utf-8") + assert "npm ci" not in wf, "no npm ci in a pnpm repository" + assert "- run: corepack enable && pnpm install --frozen-lockfile" in wf + assert "- run: pnpm exec leji validate" in wf + assert "npx -y @leji-org/leji@1" not in wf, "declared + locked is never the fallback" + + unlocked = _seeded_ci_dir(capsys, tmp_path / "unlocked") + (unlocked / "package.json").write_text( + '{"devDependencies":{"@leji-org/leji":"^1.3.0"}}', encoding="utf-8" + ) + run(capsys, ["ci", "--root", str(unlocked), "--provider", "github"]) + fallback = (unlocked / ".github" / "workflows" / "leji.yml").read_text(encoding="utf-8") assert "npx -y @leji-org/leji@1 validate" in fallback - assert "npm ci" not in fallback -def test_declares_leji_dep_detection(tmp_path: Path) -> None: - from leji.init_cmd import _declares_leji_dep +def test_node_declaration_through_the_detector(tmp_path: Path) -> None: + """The declaration rules, reached only through the detector: the direct + package.json read this port used to carry is gone.""" + from leji.ecosystem import detect_ecosystem + + def declared(pkg: str, name: str) -> bool: + root = tmp_path / name + root.mkdir() + (root / "package.json").write_text(pkg, encoding="utf-8") + (root / "package-lock.json").write_text("", encoding="utf-8") + report = detect_ecosystem(str(root)) + assert report.selected is not None + return report.selected.direct_declared + + assert not declared("{}", "empty") + assert declared('{"devDependencies":{"@leji-org/leji":"^1.3.0"}}', "dev") + assert declared('\ufeff{"dependencies":{"@leji-org/leji":"1.3.0"}}', "bom") + assert not declared('{"dependencies":["@leji-org/leji"]}', "array") + for i, bad in enumerate(["{ not json", '{"dependencies":{"@leji-org/leji":NaN}}']): + root = tmp_path / f"bad{i}" + root.mkdir() + (root / "package.json").write_text(bad, encoding="utf-8") + (root / "package-lock.json").write_text("", encoding="utf-8") + assert detect_ecosystem(str(root)).reason == "unreadable-manifest" + + +def test_ci_dangling_target_is_refused_never_written_through(capsys, tmp_path: Path) -> None: + # Every arm decided presence with an existence check, which follows symlinks: a + # dangling workflow link read as absent and the create landed at the link's + # destination, a name inside the repository the tool never planned. The verified + # read refuses the standing entry instead, in the same words an escaping target gets. + for i, (provider, target_rel) in enumerate( + [ + ("github", ".github/workflows/leji.yml"), + ("gitlab", ".gitlab-ci.yml"), + ("circleci", ".circleci/config.yml"), + ("azure", ".azure-pipelines/leji.yml"), + ] + ): + layer = _seeded_ci_dir(capsys, tmp_path / f"dangling-{i}") + target = layer / target_rel + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to("never-created.yml") + code, _, err = run(capsys, ["ci", "--root", str(layer), "--provider", provider]) + assert code == 2, f"{provider}: dangling target refused" + assert "refusing to write through a symlink that escapes the target" in err + assert not (target.parent / "never-created.yml").exists(), ( + f"{provider}: the dangling link's destination is never created" + ) + assert target.is_symlink(), f"{provider}: the planted link is left exactly as it was" + - assert not _declares_leji_dep(tmp_path), "no package.json" - (tmp_path / "package.json").write_text("{ not json", encoding="utf-8") - assert not _declares_leji_dep(tmp_path), "unparseable package.json" - (tmp_path / "package.json").write_text( - '{"devDependencies":{"@leji-org/leji":"^1.3.0"}}', encoding="utf-8" - ) - assert _declares_leji_dep(tmp_path), "devDependencies entry declares the dep" - # A leading UTF-8 BOM is stripped, so a valid manifest is still detected. - (tmp_path / "package.json").write_bytes( - b"\xef\xbb\xbf" + b'{"dependencies":{"@leji-org/leji":"1.3.0"}}' - ) - assert _declares_leji_dep(tmp_path), "BOM-prefixed manifest declares the dep" - # dependencies as a JSON array is not an object -> treated as absent, not an error. - (tmp_path / "package.json").write_text('{"dependencies":["@leji-org/leji"]}', encoding="utf-8") - assert not _declares_leji_dep(tmp_path), "array dependencies field treated as absent" - # A non-finite JSON constant (NaN) fails the strict parse (matches TS/Go) -> absent. - (tmp_path / "package.json").write_text( - '{"dependencies":{"@leji-org/leji":NaN}}', encoding="utf-8" - ) - assert not _declares_leji_dep(tmp_path), "NaN value fails the strict parse" +def test_ci_gitlab_refuses_a_standing_entry_that_is_not_a_regular_file( + capsys, tmp_path: Path +) -> None: + # The merge reads the bytes it is about to rewrite through the verified read, so a + # target that is not a regular file is the same hard refusal a write to it would be, + # reported in the SDK's own words rather than as an OS read error. + layer = _seeded_ci_dir(capsys, tmp_path) + (layer / "inside-dir").mkdir() + (layer / ".gitlab-ci.yml").symlink_to(layer / "inside-dir") + code, _, err = run(capsys, ["ci", "--root", str(layer), "--provider", "gitlab"]) + assert code == 2 + assert "refusing to write through a symlink that escapes the target" in err diff --git a/packages/sdk-py/tests/test_cli.py b/packages/sdk-py/tests/test_cli.py index d4b4747..c5ee9fe 100644 --- a/packages/sdk-py/tests/test_cli.py +++ b/packages/sdk-py/tests/test_cli.py @@ -2,6 +2,7 @@ import hashlib import json +import os import re import shutil import subprocess @@ -363,6 +364,97 @@ def test_index_generate_writes_and_reports(tmp_path, capsys) -> None: assert payload["entries"] == 3 +def _unindexed_line(n: int) -> str: + """The generate run's closing nudge. Spec-pinned byte for byte and identical in + all three SDKs, so it is asserted as an exact string, never a pattern; the zero + case is asserted as absence.""" + return f"{n} file(s) unindexed: add to a category index or leave as reference deliberately" + + +def test_index_generate_reports_the_unindexed_count(tmp_path, capsys) -> None: + layer = tmp_path / "layer" + shutil.copytree(EXAMPLE, layer) + # Two markdown files under the governed root that no category index lists. + (layer / "docs" / "notes").mkdir() + (layer / "docs" / "notes" / "loose.md").write_text("# Loose\n") + (layer / "docs" / "stray.md").write_text("# Stray\n") + code, out, _ = run_cli(capsys, ["index", "--root", str(layer)]) + # A nudge, never a gate: the count does not move the exit code. + assert code == 0 + assert out.rstrip("\n").split("\n")[-1] == _unindexed_line(2) + + +def test_index_generate_is_quiet_when_nothing_is_unindexed(tmp_path, capsys) -> None: + layer = tmp_path / "layer" + shutil.copytree(EXAMPLE, layer) + code, out, _ = run_cli(capsys, ["index", "--root", str(layer)]) + assert code == 0 + assert "unindexed" not in out + assert out.rstrip("\n").split("\n")[-1].startswith("ok (") + + +def test_index_check_is_unaffected_by_the_unindexed_count(tmp_path, capsys) -> None: + layer = tmp_path / "layer" + shutil.copytree(EXAMPLE, layer) + (layer / "docs" / "stray.md").write_text("# Stray\n") + assert run_cli(capsys, ["index", "--root", str(layer)])[0] == 0 + code, out, _ = run_cli(capsys, ["index", "--check", "--root", str(layer)]) + assert code == 0 + assert "unindexed" not in out + + +def _has_key_deep(value: object, key: str) -> bool: + """Whether key appears anywhere in the document, at any depth. Checking the whole + tree rather than the top level alone is what makes the JSON assertion hold against + a field added later inside summary or a future extra.""" + if isinstance(value, dict): + return key in value or any(_has_key_deep(v, key) for v in value.values()) + if isinstance(value, list): + return any(_has_key_deep(v, key) for v in value) + return False + + +def test_index_json_carries_no_trailing_nudge(tmp_path, capsys) -> None: + layer = tmp_path / "layer" + shutil.copytree(EXAMPLE, layer) + (layer / "docs" / "stray.md").write_text("# Stray\n") + code, out, _ = run_cli(capsys, ["index", "--root", str(layer), "--json"]) + assert code == 0 + # One document and nothing after it: the payload must still parse whole. + payload = json.loads(out) + assert payload["written"] == "docs/context-index.json" + # The nudge is text-mode only. The count is not part of the index run's contract, + # so no consumer may start reading it off this document — not at the top level, + # not tucked into summary or a later extra. + assert not _has_key_deep(payload, "unindexed"), out + + +def test_index_generate_prints_no_nudge_when_the_index_write_fails(tmp_path, capsys) -> None: + # Root bypasses permission bits, so the write would succeed; skip there. + if os.geteuid() == 0: + pytest.skip("root bypasses permission bits") + layer = tmp_path / "layer" + shutil.copytree(EXAMPLE, layer) + (layer / "docs" / "stray.md").write_text("# Stray\n") + # The nudge would have something to say here: the count is nonzero, so the silence + # below is the operational failure's doing and not an empty set. + _, before, _ = run_cli(capsys, ["status", "--root", str(layer), "--json"]) + assert json.loads(before)["unindexed"] + target = layer / "docs" / "context-index.json" + target.chmod(0o444) + try: + code, out, err = run_cli(capsys, ["index", "--root", str(layer)]) + # An operational failure surfaces its error and nothing else: generation never + # completed, so the layer has no count worth reporting. + assert code == 2 + assert err.startswith("leji: ") + assert "context-index.json" in err + assert "permission denied" in err.lower() + assert "unindexed" not in out + finally: + target.chmod(0o644) # restore so the temp tree can be cleaned up + + def test_conformance_marks_failing_core_items(capsys) -> None: code, out, _ = run_cli( capsys, ["conformance", "--root", str(FIXTURES / "invalid-missing-boot-profile"), "--json"] @@ -405,8 +497,8 @@ def test_clijson_documents_exactly_the_accepted_commands(capsys, tmp_path) -> No argv += ["--keep", "1"] # compact requires --keep or --before elif name == "agent": argv += ["--host", "codex", "--name", "reviewer"] # agent requires both - elif name == "mounts locate": - argv.insert(2, "acme-product-context") # locate takes a positional mount name + elif name in ("mounts locate", "mounts update-pin"): + argv.insert(2, "acme-product-context") # both take a positional mount name code = main(argv) capsys.readouterr() assert code != 2, f'"{name}" should not be a usage error' @@ -414,17 +506,20 @@ def test_clijson_documents_exactly_the_accepted_commands(capsys, tmp_path) -> No assert documented == [ "adopt", "agent", + "badge", "changelog check", "changelog compact", "ci", "conformance", "detect", + "export", "freshness", "index", "init", "mounts hydrate", "mounts locate", "mounts status", + "mounts update-pin", "route", "start", "status", @@ -510,7 +605,7 @@ def test_viewer_prints_serve_hint(tmp_path, capsys) -> None: code, out, _ = run_cli(capsys, ["viewer", "--root", str(layer)]) assert code == 0, out assert "serve: leji view" in out - assert "viewer ready (3 entries) → docs/.leji/viewer/" in out + assert "viewer ready (3 entries) → .leji/viewer/" in out def test_viewer_rejects_open(tmp_path, capsys) -> None: @@ -611,3 +706,47 @@ def test_start_on_core_layer_non_tty_falls_back(capsys, tmp_path) -> None: code, out, err = run_cli(capsys, ["start", "--root", str(tmp_path)]) assert code == 0, out + err assert "To enter this context layer" in out + + +# Mirrors run.test.ts "agent: writing the default binding prints the +# selects-vs-loads guidance (human and JSON); other keys and re-runs do not". +def test_agent_default_binding_prints_selects_vs_loads_guidance(capsys, tmp_path) -> None: + guidance = ( + "agents.default selects a role profile; it does not load it. If its instructions " + "must apply before every task, fold them into the boot profile; otherwise keep the " + "profile role-scoped and engage it through the relevant protocol." + ) + + def seed(name: str) -> Path: + d = tmp_path / name + d.mkdir() + run_cli(capsys, ["init", "--dir", str(d), "--yes", "--name", "demo"]) + return d + + # human output: the guidance follows the success lines, byte-exact + layer = seed("agent") + code, out, err = run_cli(capsys, ["agent", "--name", "default", "--root", str(layer)]) + assert code == 0, out + err + assert 'Bound agent "default"' in out + assert out.rstrip("\n").split("\n")[-1] == guidance + # written-only: a re-run binds nothing and stays terse, in both modes + code, out, err = run_cli(capsys, ["agent", "--name", "default", "--root", str(layer)]) + assert code == 0, out + err + assert "selects a role profile" not in out, "no guidance when nothing was bound" + _, out, _ = run_cli(capsys, ["agent", "--name", "default", "--json", "--root", str(layer)]) + assert "note" not in json.loads(out) + # JSON mode carries the same sentence in `note` (the CI activation-note pattern) + code, out, err = run_cli( + capsys, ["agent", "--name", "default", "--json", "--root", str(seed("agent-j"))] + ) + assert code == 0, out + err + assert json.loads(out)["note"] == guidance + # any other binding stays quiet, in both modes + code, out, err = run_cli(capsys, ["agent", "--name", "reviewer", "--root", str(layer)]) + assert code == 0, out + err + assert "selects a role profile" not in out, "no guidance for a non-default key" + code, out, err = run_cli( + capsys, ["agent", "--name", "thought-partner", "--json", "--root", str(layer)] + ) + assert code == 0, out + err + assert "note" not in json.loads(out) diff --git a/packages/sdk-py/tests/test_coverage.py b/packages/sdk-py/tests/test_coverage.py index 6949321..b743342 100644 --- a/packages/sdk-py/tests/test_coverage.py +++ b/packages/sdk-py/tests/test_coverage.py @@ -122,13 +122,13 @@ def test_cli_viewer_text_output_serve_hint(tmp_path, capsys) -> None: out = capsys.readouterr().out assert code == 0 assert "serve: leji view" in out - assert "viewer ready (3 entries) → docs/.leji/viewer/" in out + assert "viewer ready (3 entries) → .leji/viewer/" in out def test_serve_viewer_rejects_escaping_root_rel(tmp_path) -> None: # The CLI passes a schema-validated rootPath, but a direct SDK caller could pass # an escaping root_rel (e.g. ".."); serve_viewer must refuse. Mirrors Node/Go. - from leji.viewer_cmd import serve_viewer + from leji.serve_cmd import serve_viewer layer = _copy(EXAMPLE, tmp_path) for root_rel in ("..", "../.."): @@ -143,7 +143,8 @@ def test_serve_viewer_serves_chrome_content_and_refuses_traversal(tmp_path) -> N import http.client import threading - from leji.viewer_cmd import generate_viewer, serve_viewer + from leji.serve_cmd import serve_viewer + from leji.viewer_cmd import generate_viewer layer = _copy(EXAMPLE, tmp_path) manifest = load_manifest(str(layer)).manifest @@ -195,7 +196,8 @@ def test_serve_viewer_live_sidebar_and_index(tmp_path) -> None: import http.client import threading - from leji.viewer_cmd import generate_viewer, serve_viewer + from leji.serve_cmd import serve_viewer + from leji.viewer_cmd import generate_viewer layer = _copy(EXAMPLE, tmp_path) manifest = load_manifest(str(layer)).manifest @@ -225,7 +227,7 @@ def get(path: str) -> tuple[int, bytes]: ) status, body = get("/content/_sidebar.md") assert status == 200 - assert b"[Fresh Note](fresh-note.md)" in body + assert b"[Fresh Note](/fresh-note.md)" in body assert b"- **Reference**" in body # The stored index path serves the live index JSON. status, body = get("/content/context-index.json") @@ -240,7 +242,7 @@ def get(path: str) -> tuple[int, bytes]: def test_open_browser_spawns_and_swallows_errors(monkeypatch) -> None: # open_browser is best-effort: it spawns the platform opener and never raises, # even when the opener is missing. Covers the happy path and the OSError branch. - from leji import viewer_cmd + from leji import serve_cmd calls: list[list[str]] = [] @@ -249,7 +251,7 @@ def fake_popen(cmd, **_kwargs): return object() monkeypatch.setattr(subprocess, "Popen", fake_popen) - viewer_cmd.open_browser("http://127.0.0.1:5354/") + serve_cmd.open_browser("http://127.0.0.1:5354/") assert len(calls) == 1 assert calls[0][-1] == "http://127.0.0.1:5354/" @@ -257,23 +259,23 @@ def raising_popen(_cmd, **_kwargs): raise OSError("no opener on PATH") monkeypatch.setattr(subprocess, "Popen", raising_popen) - viewer_cmd.open_browser("http://127.0.0.1:5354/") # must not raise + serve_cmd.open_browser("http://127.0.0.1:5354/") # must not raise def test_build_viewer_exports_static_folder_and_contains_output(tmp_path) -> None: # build_viewer was untested in Python: exercise the default export, an absolute # in-repo --out, and the containment guards (escape / repo root / context root). - from leji.viewer_cmd import build_viewer + from leji.export_cmd import build_viewer layer = _copy(EXAMPLE, tmp_path) manifest = load_manifest(str(layer)).manifest - # Default out: a self-contained static folder under the context root, mirroring - # the served URL contract (chrome at the web root, markdown under /content/). + # Default out: the dist role of the unified root `.leji/`, mirroring the served + # URL contract (chrome at the web root, markdown under /content/). result = build_viewer(str(layer), manifest) - assert result.out == "docs/.leji/viewer-dist" + assert result.out == ".leji/dist" assert not has_errors(result.findings) - out = Path(layer) / "docs" / ".leji" / "viewer-dist" + out = Path(layer) / ".leji" / "dist" index = (out / "index.html").read_text() assert index.startswith("<!--") and "Leji viewer" in index # protect warning prepended assert (out / "content" / "domain" / "glossary.md").is_file() @@ -285,8 +287,13 @@ def test_build_viewer_exports_static_folder_and_contains_output(tmp_path) -> Non assert build_viewer(str(layer), manifest, str(abs_out)).out == "dist-abs" assert (abs_out / "index.html").is_file() - # Containment guards: escaping --out, the repo root, and the context root are refused. - for bad in ("../escape", ".", "docs"): + # Containment guards. An escaping --out is answered by the write rule itself, ahead + # of the collision checks: every write stays inside the repository root. + with pytest.raises(RuntimeError, match="resolves outside the repository"): + build_viewer(str(layer), manifest, "../escape") + # The repo root and the context root are the collision usage errors, raised before + # any work starts. + for bad in (".", "docs"): with pytest.raises(RuntimeError, match="must be a path inside the repository"): build_viewer(str(layer), manifest, bad) @@ -942,3 +949,52 @@ def test_conformance_reports_all_four_mount_items_when_none_are_declared(tmp_pat ("mount-routing", "not-applicable"), ("mount-discovery", "not-applicable"), ] + + +# Mirrors units.test.ts "seedChangelogIfMissing treats a dangling changelog link as +# present, never seeding through it" and its out-of-repository twin. +def test_seed_changelog_treats_a_dangling_link_as_present(tmp_path: Path) -> None: + # An existence check follows symlinks, so a dangling changelog link read as absent + # and the seed was created at the link's missing destination. The exclusive create + # judges the ORIGINAL entry, so any standing entry is the same no-op an existing + # changelog is. + from leji.changelog import seed_changelog_if_missing + + layer = _copy(FIXTURES / "valid-minimal-core", tmp_path) + mp = layer / "leji.json" + m = json.loads(mp.read_text(encoding="utf-8")) + m["conformance"] = {**m.get("conformance", {}), "claimedLevel": "indexed"} + mp.write_text(json.dumps(m, indent=2) + "\n", encoding="utf-8") + link = layer / "docs" / "context-changelog.json" + link.symlink_to("never-created.json") + manifest = load_manifest(str(layer)).manifest + assert manifest is not None + + assert seed_changelog_if_missing(str(layer), manifest) is None, ( + "a standing entry is never seeded through" + ) + assert not (layer / "docs" / "never-created.json").exists(), ( + "the dangling link's destination is never created" + ) + assert link.is_symlink(), "the planted link is left exactly as it was" + + +def test_seed_changelog_refuses_a_link_resolving_outside_the_repository( + tmp_path: Path, tmp_path_factory: pytest.TempPathFactory +) -> None: + from leji.changelog import seed_changelog_if_missing + + layer = _copy(FIXTURES / "valid-minimal-core", tmp_path) + outside = tmp_path_factory.mktemp("leji-seed-outside") + mp = layer / "leji.json" + m = json.loads(mp.read_text(encoding="utf-8")) + m["conformance"] = {**m.get("conformance", {}), "claimedLevel": "indexed"} + mp.write_text(json.dumps(m, indent=2) + "\n", encoding="utf-8") + (layer / "docs" / "context-changelog.json").symlink_to(outside / "context-changelog.json") + manifest = load_manifest(str(layer)).manifest + assert manifest is not None + + assert seed_changelog_if_missing(str(layer), manifest) is None, ( + "nothing seeded through a link that leaves the repository" + ) + assert not (outside / "context-changelog.json").exists(), "nothing written outside the root" diff --git a/packages/sdk-py/tests/test_dependency.py b/packages/sdk-py/tests/test_dependency.py new file mode 100644 index 0000000..19df410 --- /dev/null +++ b/packages/sdk-py/tests/test_dependency.py @@ -0,0 +1,438 @@ +"""The declaration offer and the generated CI/hook contract, against the shared +fixtures. The manager is never really spawned: every consent path runs through the +injected fake, which records instead of running.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from leji.cigen import ( + CI_MARKER, + CI_PROVIDERS, + build_ci_file, + ci_variants, + hook_body, + husky_block, + resolve_ci_job, + sh_quote, +) +from leji.dependency import ( + AddResult, + DependencyIO, + DependencyOffer, + dependency_add_failed, + offer_dependency, +) +from leji.ecosystem import ( + consent_disclosure, + detect_ecosystem, + manager_runner_argv, + runner_argv, +) +from leji.init_cmd import ensure_ci_workflow, ensure_local_hook + +REPO_ROOT = Path(__file__).resolve().parents[3] +CASES = REPO_ROOT / "fixtures" / "ecosystem" +GOLDENS = REPO_ROOT / "fixtures" / "ci-goldens" + +CI_REL = { + "github": ".github/workflows/leji.yml", + "gitlab": ".gitlab-ci.yml", + "circleci": ".circleci/config.yml", + "azure": ".azure-pipelines/leji.yml", +} + +MANAGERS = ["npm", "pnpm", "yarn", "bun", "uv", "poetry", "pdm", "pipenv", "go"] +DECLARED_PKG = '{\n "name": "demo",\n "devDependencies": { "@leji-org/leji": "^1" }\n}\n' + + +def golden(name: str) -> str: + return (GOLDENS / name).read_text(encoding="utf-8") + + +def plant(tmp_path: Path, files: dict[str, str], name: str = "root") -> Path: + root = tmp_path / name + root.mkdir(parents=True, exist_ok=True) + for rel, body in files.items(): + (root / rel).write_text(body, encoding="utf-8") + return root + + +# --- the goldens ---------------------------------------------------------- + + +def test_ci_variants_match_goldens() -> None: + variants = ci_variants() + assert len(variants) == len(CI_PROVIDERS) * 12 + for provider, key, body in variants: + assert body == golden(f"{provider}-{key}.yml"), f"{provider}/{key}" + + +def test_hook_goldens() -> None: + for manager in MANAGERS: + argv = manager_runner_argv(manager) + assert argv is not None + assert hook_body(argv) == golden(f"hook-{manager}.sh"), manager + assert husky_block(argv) == golden(f"husky-{manager}.sh"), manager + assert hook_body(["leji"]) == golden("hook-fallback.sh") + assert husky_block(["leji"]) == golden("husky-fallback.sh") + + +def test_sh_quote_and_hook_shape() -> None: + assert sh_quote("pnpm") == "'pnpm'" + assert sh_quote("we'ird") == "'we'\\''ird'" + assert sh_quote("a b") == "'a b'" + assert sh_quote("x$HOME") == "'x$HOME'" + body = hook_body(["weird bin", "quo'te", "$HOME", "`cmd`", "*"]) + assert "'weird bin' 'quo'\\''te' '$HOME' '`cmd`' '*' validate || exit 1" in body + assert "echo 'leji: stored index is stale; run `leji index` and stage the result.' >&2" in body + for argv in (["leji"], ["pnpm", "exec", "leji"], ["go", "tool", "leji"]): + for gone in ("node_modules", "LEJI=", "$LEJI"): + assert gone not in hook_body(argv) + assert gone not in husky_block(argv) + + +def test_marker_and_legacy_job() -> None: + for provider, key, body in ci_variants(): + if provider == "gitlab": + assert "generated by leji ci (managed)" not in body + assert body.startswith("# >>> leji ci (managed) >>>\n") + else: + assert body.startswith(CI_MARKER + "\n"), f"{provider}/{key}" + for provider in ("github", "circleci", "azure"): + before = golden(f"legacy-1.3-{provider}-fallback.yml") + assert golden(f"{provider}-node-fallback.yml") == CI_MARKER + "\n" + before + + +def test_unpinned_disclosure_appears_once_only_where_earned() -> None: + for provider, key, body in ci_variants(): + hits = [line for line in body.split("\n") if "is installed unpinned here" in line] + wants = key in ("poetry-local", "pdm-local", "pipenv-local") or ( + key == "uv-local" and provider != "github" + ) + assert len(hits) == (1 if wants else 0), f"{provider}/{key}" + assert "astral-sh/setup-uv@v5" in golden("github-uv-local.yml") + assert "pip install uv" not in golden("github-uv-local.yml") + assert "pip install uv && uv sync --locked" in golden("gitlab-uv-local.yml") + + +# --- the table ------------------------------------------------------------ + +LOCAL_ROOTS = { + "pnpm": {"package.json": DECLARED_PKG, "pnpm-lock.yaml": ""}, + "npm": {"package.json": DECLARED_PKG, "package-lock.json": ""}, + "uv": { + "pyproject.toml": '[project]\nname = "d"\nversion = "0"\ndependencies = ["leji"]\n', + "uv.lock": "", + }, + "go": { + "go.mod": "module example.com/d\n\ngo 1.24.0\n\n" + "tool github.com/leji-org/leji/packages/sdk-go/cmd/leji\n" + }, +} + + +@pytest.mark.parametrize("manager", sorted(LOCAL_ROOTS)) +@pytest.mark.parametrize("provider", CI_PROVIDERS) +def test_ci_table_local_jobs(tmp_path: Path, manager: str, provider: str) -> None: + root = plant(tmp_path, LOCAL_ROOTS[manager], f"{manager}-{provider}") + result = ensure_ci_workflow(str(root), provider) + assert result.action == "created" + assert (root / CI_REL[provider]).read_text(encoding="utf-8") == golden( + f"{provider}-{manager}-local.yml" + ) + + +def test_ci_table_fallbacks(tmp_path: Path) -> None: + cases = [ + ({"package.json": DECLARED_PKG}, "node-fallback"), + ({"package.json": '{"name":"demo"}\n', "package-lock.json": ""}, "node-fallback"), + ({"package.json": "{}\n", "package-lock.json": "", "yarn.lock": ""}, "node-fallback"), + ({"package.json": '{"packageManager":"hermit@1.0.0"}\n'}, "node-fallback"), + ({"pyproject.toml": '[project]\nname = "d"\nversion = "0"\n'}, "python-fallback"), + ({"requirements.txt": "requests\n"}, "python-fallback"), + ({"go.mod": "module example.com/d\n\ngo 1.23\n"}, "go-fallback"), + ({"go.mod": "module example.com/d\n\ngo 1.24.0\n"}, "go-fallback"), + ( + {"package.json": "{}\n", "pyproject.toml": '[project]\nname="d"\nversion="0"\n'}, + "node-fallback", + ), + ] + for i, (files, key) in enumerate(cases): + root = plant(tmp_path, files, f"fb{i}") + ensure_ci_workflow(str(root), "github") + assert (root / CI_REL["github"]).read_text(encoding="utf-8") == golden( + f"github-{key}.yml" + ), key + + +# --- ownership ------------------------------------------------------------ + + +def test_ci_ownership_upgrades_a_legacy_file(tmp_path: Path) -> None: + for provider in ("github", "circleci", "azure"): + for mode in ("local", "fallback"): + root = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), f"legacy-{provider}-{mode}") + target = root / CI_REL[provider] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(golden(f"legacy-1.3-{provider}-{mode}.yml"), encoding="utf-8") + result = ensure_ci_workflow(str(root), provider) + assert result.action == "updated", f"{provider}/{mode}" + assert target.read_text(encoding="utf-8") == golden(f"{provider}-pnpm-local.yml") + + +def test_ci_ownership_idempotent_and_manager_change(tmp_path: Path) -> None: + for provider in CI_PROVIDERS: + root = plant(tmp_path, dict(LOCAL_ROOTS["uv"]), f"idem-{provider}") + assert ensure_ci_workflow(str(root), provider).action == "created" + after = (root / CI_REL[provider]).read_text(encoding="utf-8") + assert ensure_ci_workflow(str(root), provider).action == "unchanged" + assert (root / CI_REL[provider]).read_text(encoding="utf-8") == after + + change = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), "manager-change") + target = change / CI_REL["github"] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(golden("github-npm-local.yml"), encoding="utf-8") + assert ensure_ci_workflow(str(change), "github").action == "updated" + assert target.read_text(encoding="utf-8") == golden("github-pnpm-local.yml") + + +def test_ci_ownership_leaves_edited_and_foreign_files_alone(tmp_path: Path) -> None: + for provider in ("github", "circleci", "azure"): + edited = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), f"edited-{provider}") + mine = golden(f"{provider}-pnpm-local.yml") + target = edited / CI_REL[provider] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(mine + " - run: echo mine\n", encoding="utf-8") + result = ensure_ci_workflow(str(edited), provider) + assert result.action == "manual" and result.snippet + assert target.read_text(encoding="utf-8") == mine + " - run: echo mine\n" + + foreign = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), f"foreign-{provider}") + ftarget = foreign / CI_REL[provider] + ftarget.parent.mkdir(parents=True, exist_ok=True) + ftarget.write_text("name: someone-elses-pipeline\n", encoding="utf-8") + assert ensure_ci_workflow(str(foreign), provider).action == "manual" + assert ftarget.read_text(encoding="utf-8") == "name: someone-elses-pipeline\n" + + +def test_ci_ownership_is_provider_scoped(tmp_path: Path) -> None: + """The same bytes are leji's at one provider's path and somebody else's at + another's, so a digest match must be provider-scoped.""" + cross = [ + ("github", "legacy-1.3-circleci-local.yml"), + ("github", "legacy-1.3-azure-fallback.yml"), + ("circleci", "legacy-1.3-github-local.yml"), + ("circleci", "legacy-1.3-azure-local.yml"), + ("azure", "legacy-1.3-github-fallback.yml"), + ("azure", "legacy-1.3-circleci-fallback.yml"), + ] + for i, (provider, foreign) in enumerate(cross): + root = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), f"cross{i}") + target = root / CI_REL[provider] + target.parent.mkdir(parents=True, exist_ok=True) + body = golden(foreign) + target.write_text(body, encoding="utf-8") + assert ensure_ci_workflow(str(root), provider).action == "manual", f"{foreign}@{provider}" + assert target.read_text(encoding="utf-8") == body + for provider in ("github", "circleci", "azure"): + root = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), f"own-{provider}") + target = root / CI_REL[provider] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(golden(f"legacy-1.3-{provider}-local.yml"), encoding="utf-8") + assert ensure_ci_workflow(str(root), provider).action == "updated" + + +def test_gitlab_owns_only_its_block(tmp_path: Path) -> None: + root = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), "gitlab-merge") + target = root / ".gitlab-ci.yml" + target.write_text("stages:\n - test\n", encoding="utf-8") + assert ensure_ci_workflow(str(root), "gitlab").action == "updated" + merged = target.read_text(encoding="utf-8") + assert merged.startswith("stages:\n - test\n") + assert golden("gitlab-pnpm-local.yml") in merged + + +# --- the consent path ----------------------------------------------------- + + +def _fake_io(answer: str, result: AddResult): + runs: list[tuple[str, list[str], str]] = [] + questions: list[str] = [] + + def read_line(question: str, fallback: str) -> str: + questions.append(question) + return answer + + def run(bin_name: str, args: list[str], cwd: str) -> AddResult: + runs.append((bin_name, args, cwd)) + return result + + return DependencyIO(read_line=read_line, run=run), runs, questions + + +def _offer( + capsys, fixture: str, interactive: bool, answer: str = "", result: AddResult | None = None +): + root = str(CASES / fixture) + io, runs, _ = _fake_io(answer, result or AddResult(started=True)) + offer = offer_dependency(root, detect_ecosystem(root), interactive, io) + return offer, capsys.readouterr().out, runs + + +def test_offer_yes_runs_the_manager(capsys) -> None: + offer, out, runs = _offer(capsys, "node-pnpm-lock", True, "y") + assert runs == [("pnpm", ["add", "-D", "@leji-org/leji"], str(CASES / "node-pnpm-lock"))] + assert "Detected pnpm (pnpm-lock.yaml)." in out + assert consent_disclosure("pnpm") in out + assert "Running: pnpm add -D @leji-org/leji" in out + assert "Declared @leji-org/leji; a clean install now brings leji." in out + assert offer == DependencyOffer( + offered=True, + ran=True, + command=["pnpm", "add", "-D", "@leji-org/leji"], + exit_code=0, + signal=None, + ) + assert not dependency_add_failed(offer) + + +def test_disclosure_precedes_the_prompt(capsys) -> None: + _, out, _ = _offer(capsys, "node-pnpm-lock", True, "n") + assert out.index("Detected pnpm") < out.index(consent_disclosure("pnpm")) + offer, quiet, runs = _offer(capsys, "node-pnpm-lock", False, "y") + assert "This runs" not in quiet and runs == [] + # Nothing ran, so there is no exit code and no signal to report. + assert offer == DependencyOffer( + offered=True, + ran=False, + command=["pnpm", "add", "-D", "@leji-org/leji"], + exit_code=None, + signal=None, + ) + for fixture, binary in (("python-uv", "uv"), ("go-1.24", "go"), ("node-npm-lock", "npm")): + _, text, _ = _offer(capsys, fixture, True, "n") + assert consent_disclosure(binary) in text + + +def test_decline_runs_nothing(capsys) -> None: + for answer in ("n", "no", "q", "N"): + offer, out, runs = _offer(capsys, "node-pnpm-lock", True, answer) + assert runs == [] and not dependency_add_failed(offer) + assert offer == DependencyOffer( + offered=True, + ran=False, + command=["pnpm", "add", "-D", "@leji-org/leji"], + exit_code=None, + signal=None, + ) + assert "Skipped; declare it later with:\n pnpm add -D @leji-org/leji" in out + + +def test_failure_branches(capsys) -> None: + offer, out, _ = _offer(capsys, "python-uv", True, "y", AddResult(started=True, exit_code=1)) + assert "uv exited 1; run it yourself:\n uv add --dev leji" in out + assert offer == DependencyOffer( + offered=True, ran=True, command=["uv", "add", "--dev", "leji"], exit_code=1, signal=None + ) + assert dependency_add_failed(offer) + + # A signalled manager has no exit code of its own: the signal is the outcome. + offer, out, _ = _offer( + capsys, "go-1.24", True, "y", AddResult(started=True, exit_code=-1, signal="SIGTERM") + ) + assert "go was terminated (SIGTERM); run it yourself:" in out + assert offer == DependencyOffer( + offered=True, + ran=True, + command=["go", "get", "-tool", "github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest"], + exit_code=None, + signal="SIGTERM", + ) + assert dependency_add_failed(offer) + + # A spawn that never started carries neither an exit code nor a signal, and is + # still a failure: reading its null exit code as success would pass a run in + # which nothing happened. + offer, out, _ = _offer(capsys, "node-npm-lock", True, "y", AddResult(started=False)) + assert "npm is not on your PATH; run it yourself once it is:\n npm i -D @leji-org/leji" in out + assert offer == DependencyOffer( + offered=True, + ran=True, + command=["npm", "i", "-D", "@leji-org/leji"], + exit_code=None, + signal=None, + ) + assert dependency_add_failed(offer) + + +def test_signal_name_is_canonical_across_runtimes() -> None: + """Python reports a signalled child as a negative return code; the runner turns + that into the canonical name the other two SDKs print.""" + import subprocess + + from leji.dependency import default_dependency_io + + io = default_dependency_io() + result = io.run("sh", ["-c", "kill -TERM $$"], ".") + assert result.started and result.signal == "SIGTERM" and result.exit_code == -1 + assert subprocess.run(["sh", "-c", "kill -TERM $$"]).returncode == -15 + clean = io.run("sh", ["-c", "exit 7"], ".") + assert clean.started and clean.exit_code == 7 and clean.signal == "" + missing = io.run("leji-no-such-binary-xyz", [], ".") + assert not missing.started + + +def test_never_prompts_without_a_command(capsys) -> None: + offer, out, runs = _offer(capsys, "node-declared", True, "y") + assert runs == [] + # `command` still names the add this repository would use; `offered` is what + # says there is nothing to consent to, because the CLI is already declared. + assert offer == DependencyOffer( + offered=False, + ran=False, + command=["npm", "i", "-D", "@leji-org/leji"], + exit_code=None, + signal=None, + ) + assert out.strip() == "The Leji CLI is already declared in package.json." + for fixture in ( + "python-bare-pyproject", + "python-requirements-only", + "go-1.23-legacy", + "node-two-lockfiles", + "node-refused-evidence", + "node-unreadable-manifest", + "node-packagemanager-unknown", + "none", + "multiple-ecosystems", + "composite-ambiguity", + ): + offer, _, runs = _offer(capsys, fixture, True, "y") + assert runs == [], fixture + assert not offer.offered and not offer.ran and not dependency_add_failed(offer), fixture + + +def test_hook_uses_the_detected_runner(tmp_path: Path) -> None: + import subprocess + + root = plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), "hookrepo") + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + result = ensure_local_hook(str(root)) + assert result.action == "created" + assert (root / result.path).read_text(encoding="utf-8") == golden("hook-pnpm.sh") + assert ensure_local_hook(str(root)).action == "unchanged" + # And the runner the report chooses is what the hook carries. + assert runner_argv(detect_ecosystem(str(root))) == ["pnpm", "exec", "leji"] + + +def test_resolve_ci_job_is_local_only_when_declared_and_locked(tmp_path: Path) -> None: + declared_locked = detect_ecosystem(str(plant(tmp_path, dict(LOCAL_ROOTS["pnpm"]), "dl"))) + assert resolve_ci_job(declared_locked, "github").local is True + unlocked = detect_ecosystem(str(plant(tmp_path, {"package.json": DECLARED_PKG}, "ul"))) + assert resolve_ci_job(unlocked, "github").local is False + assert build_ci_file("github", resolve_ci_job(unlocked, "github")) == golden( + "github-node-fallback.yml" + ) diff --git a/packages/sdk-py/tests/test_ecosystem.py b/packages/sdk-py/tests/test_ecosystem.py new file mode 100644 index 0000000..c8e3fc0 --- /dev/null +++ b/packages/sdk-py/tests/test_ecosystem.py @@ -0,0 +1,345 @@ +"""Shared-fixture conformance for the dependency-ecosystem detector: this port +must report exactly what `fixtures/ecosystem/` pins, byte for byte, and hold the +contract tests that are not fixture cases.""" + +import json +import os +from pathlib import Path + +import pytest + +from leji.ecosystem import ( + detect_ecosystem, + render_ecosystem_block, + render_ecosystem_line, + runner_argv, + text_pip_groups, +) + +REPO_ROOT = Path(__file__).resolve().parents[3] +CASES_DIR = REPO_ROOT / "fixtures" / "ecosystem" +CASE_NAMES = sorted(p.name for p in CASES_DIR.iterdir() if (p / "expected.json").is_file()) + + +def _serialize(report) -> str: + """The one committed formatting of an ecosystem expected.json: the report under + an `ecosystem` key, two-space indent, one trailing newline. Comparing the BYTES + is what pins key order, which a deep comparison cannot see and which the --json + surface makes a public contract.""" + return json.dumps({"ecosystem": report.to_json()}, indent=2, ensure_ascii=False) + "\n" + + +def test_family_is_populated() -> None: + assert len(CASE_NAMES) >= 111, ( + f"expected the full ecosystem fixture family, found {len(CASE_NAMES)}" + ) + + +@pytest.mark.parametrize("name", CASE_NAMES) +def test_ecosystem_fixture(name: str) -> None: + directory = CASES_DIR / name + expected = (directory / "expected.json").read_text(encoding="utf-8") + report = detect_ecosystem(str(directory)) + got = _serialize(report) + # Deep equality first: it names the field that diverged. + assert json.loads(got) == json.loads(expected), name + # Then the bytes, which additionally pin key order and formatting. + assert got == expected, name + + +POSITIVES = [ + "scan-project-deps-inline", + "scan-project-deps-multiline", + "scan-project-deps-specifier", + "scan-project-deps-spaced-header", + "scan-project-deps-single-quoted", + "scan-optional-deps-declared", + "scan-dependency-groups-declared", + "scan-tool-uv-dev-declared", + "scan-tool-uv-dev-marker", + "scan-poetry-deps-key", + "scan-poetry-deps-quoted-key", + "scan-poetry-dev-deps-key", + "scan-poetry-dev-deps-quoted-key", + "scan-poetry-group-key", + "scan-poetry-group-inline-table", + "scan-pdm-dev-array", + "scan-pdm-dev-key", + "scan-pipfile-packages", + "scan-pipfile-packages-quoted-key", + "scan-pipfile-dev-packages", + "scan-pipfile-dev-packages-bare-key", + "scan-requirements-declared", + "scan-requirements-bare", + "scan-requirements-extras", + "scan-plain-quoted-element", + "scan-go-2.0", +] + +NEGATIVES = [ + "scan-project-deps-absent", + "scan-project-deps-prefix-only", + "scan-project-deps-comment", + "scan-optional-deps-absent", + "scan-optional-deps-comment", + "scan-dependency-groups-absent", + "scan-dependency-groups-comment", + "scan-dependency-groups-triple-quoted", + "scan-tool-uv-dev-absent", + "scan-tool-uv-dev-comment", + "scan-tool-uv-other-field", + "scan-poetry-deps-absent", + "scan-poetry-deps-comment", + "scan-poetry-dev-deps-absent", + "scan-poetry-dev-deps-comment", + "scan-poetry-group-absent", + "scan-poetry-group-comment", + "scan-pdm-dev-absent", + "scan-pdm-dev-comment", + "scan-pipfile-packages-absent", + "scan-pipfile-packages-comment", + "scan-pipfile-dev-packages-absent", + "scan-pipfile-dev-packages-comment", + "scan-requirements-indented", + "scan-requirements-comment", + "scan-requirements-prefix-only", + "scan-requirements-include-line", + "scan-triple-quoted-element", + "scan-triple-quoted-element-literal", + "scan-multiline-basic-string", + "scan-multiline-literal-string", + "scan-multiline-string-hides-table", + "scan-project-description", + "scan-project-keywords", + "scan-project-classifiers", + "scan-project-nested-array", + "scan-unrelated-table-key", + "scan-poetry-scripts-key", + "scan-pipfile-scripts", + "scan-go-closed-block", + "scan-go-comment", + "scan-go-1.9", + "scan-go-1.25", +] + + +def _declared(name: str) -> bool: + report = detect_ecosystem(str(CASES_DIR / name)) + assert report.selected is not None, f"{name}: a scanner case always selects one manager" + return report.selected.direct_declared + + +@pytest.mark.parametrize("name", POSITIVES) +def test_scanner_positive(name: str) -> None: + assert _declared(name) is True + + +@pytest.mark.parametrize("name", NEGATIVES) +def test_scanner_negative(name: str) -> None: + assert _declared(name) is False + + +def test_scanner_families_cover_every_shared_case() -> None: + """A shared case this port does not read is a case it can diverge on.""" + claimed = set(POSITIVES) | set(NEGATIVES) + on_disk = {n for n in CASE_NAMES if n.startswith("scan-")} + assert on_disk - claimed == set() + + +def test_go_directive_threshold() -> None: + def manager(name: str) -> str: + report = detect_ecosystem(str(CASES_DIR / name)) + assert report.selected is not None + return report.selected.manager or "" + + assert manager("scan-go-1.9") == "go-legacy" + assert manager("go-1.23-legacy") == "go-legacy" + assert manager("go-no-directive") == "go-legacy" + assert manager("go-1.24") == "go" + assert manager("scan-go-1.25") == "go" + assert manager("scan-go-2.0") == "go" + + +def _plant(tmp_path: Path, files: dict[str, str], name: str = "root") -> str: + directory = tmp_path / name + directory.mkdir() + for rel, body in files.items(): + (directory / rel).write_text(body, encoding="utf-8") + return str(directory) + + +def test_package_manager_grammar(tmp_path: Path) -> None: + """A recognized name wins whatever the lockfiles say, and a value that does not + parse never falls through to one.""" + + def pm(value, extra: dict[str, str] | None = None, name: str = "pm"): + files = {"package.json": json.dumps({"packageManager": value})} + files.update(extra or {}) + return detect_ecosystem(_plant(tmp_path, files, name)) + + for i, (value, want) in enumerate( + [ + ("pnpm@9.12.0", "pnpm"), + ("bun@1.1.30+e1f2a3b4c5", "bun"), + ("yarn@4.1.0-rc.1", "yarn"), + ("npm", "npm"), + ] + ): + report = pm(value, None, f"ok{i}") + assert report.selected is not None and report.selected.manager == want + + over = pm("pnpm@9.12.0", {"yarn.lock": ""}, "over") + assert over.selected is not None and over.selected.manager == "pnpm" + + for i, bad in enumerate( + ["pnpm@@9", "pnpm@", "@9.12.0", "Pnpm@9.12.0", "pnpm 9.12.0", "", "hermit@1.0.0"] + ): + report = pm(bad, {"package-lock.json": ""}, f"bad{i}") + assert report.reason == "unsupported-manager", bad + assert report.all[0].source == "packageManager" + assert report.all[0].candidates == [] + assert report.all[0].add is None + + numeric = detect_ecosystem( + _plant(tmp_path, {"package.json": '{"packageManager":9}'}, "numeric") + ) + assert numeric.reason == "unsupported-manager" + + +def test_evidence_eligibility(tmp_path: Path) -> None: + """A symlinked, dangling or non-regular manifest is refused, never read.""" + outside = _plant( + tmp_path, {"package.json": '{"dependencies":{"@leji-org/leji":"1"}}'}, "outside" + ) + + linked = tmp_path / "linked" + linked.mkdir() + os.symlink(os.path.join(outside, "package.json"), str(linked / "package.json")) + report = detect_ecosystem(str(linked)) + assert report.reason == "refused-evidence" + assert report.all[0].evidence == ["package.json"] + assert report.all[0].direct_declared is False + + dangling = tmp_path / "dangling" + dangling.mkdir() + os.symlink(str(dangling / "gone.json"), str(dangling / "package.json")) + assert detect_ecosystem(str(dangling)).reason == "refused-evidence" + + dir_lock = Path(_plant(tmp_path, {"package.json": "{}"}, "dirlock")) + (dir_lock / "pnpm-lock.yaml").mkdir() + as_dir = detect_ecosystem(str(dir_lock)) + assert as_dir.reason == "refused-evidence" + assert as_dir.all[0].evidence == ["pnpm-lock.yaml"] + + inside = Path(_plant(tmp_path, {"package.json": "{}", "other.json": "{}"}, "inside")) + os.symlink("./other.json", str(inside / "pnpm-lock.yaml")) + assert detect_ecosystem(str(inside)).reason == "refused-evidence" + + py_linked = tmp_path / "pylinked" + py_linked.mkdir() + os.symlink(os.path.join(outside, "package.json"), str(py_linked / "requirements.txt")) + py_report = detect_ecosystem(str(py_linked)) + assert py_report.reason == "refused-evidence" + assert py_report.all[0].ecosystem == "python" + + +def test_unreadable_manifest(tmp_path: Path) -> None: + """An unreadable manifest consults neither locks nor defaults.""" + broken = detect_ecosystem( + _plant(tmp_path, {"package.json": '{ "name": ', "package-lock.json": ""}, "broken") + ) + assert broken.reason == "unreadable-manifest" + assert broken.all[0].manager is None + assert broken.all[0].evidence == [] + assert broken.all[0].add is None + + assert ( + detect_ecosystem(_plant(tmp_path, {"package.json": "[]"}, "array")).reason + == "unreadable-manifest" + ) + + bom = detect_ecosystem( + _plant(tmp_path, {"package.json": '{ "packageManager": "yarn@4.1.0" }'}, "bom") + ) + assert bom.selected is not None and bom.selected.manager == "yarn" + + +def test_no_walk_up(tmp_path: Path) -> None: + parent = Path(_plant(tmp_path, {"package.json": "{}", "package-lock.json": ""}, "parent")) + child = parent / "child" + child.mkdir() + report = detect_ecosystem(str(child)) + assert report.selected is None and report.all == [] and report.reason == "none" + + +def test_runner_argv() -> None: + """The manager runner only when the repository declares the CLI.""" + for name, want in [ + ("node-declared", ["npx", "--no-install", "@leji-org/leji"]), + ("node-pnpm-lock", ["leji"]), + ("go-declared-block", ["go", "tool", "leji"]), + ("python-declared-pyproject-groups", ["uv", "run", "leji"]), + ("none", ["leji"]), + ("node-two-lockfiles", ["leji"]), + ]: + assert runner_argv(detect_ecosystem(str(CASES_DIR / name))) == want, name + + +def test_rendered_block() -> None: + def block(name: str) -> str: + return render_ecosystem_block(detect_ecosystem(str(CASES_DIR / name))) + + assert block("node-pnpm-lock") == ( + "Detected pnpm (pnpm-lock.yaml). To declare the Leji CLI as a dev dependency so a clean install " + "brings leji, run:\n pnpm add -D @leji-org/leji" + ) + assert block("node-declared") == "The Leji CLI is already declared in package.json." + assert block("node-two-lockfiles") == ( + "Detected package.json with package-lock.json and yarn.lock; leji will not guess the package manager. " + "Declare it with the one this repo uses:\n npm i -D @leji-org/leji\n yarn add -D @leji-org/leji" + ) + for name in ( + "node-packagemanager-unknown", + "node-unreadable-manifest", + "node-refused-evidence", + ): + text = block(name) + assert text and "\n npm" not in text, name + assert block("python-bare-pyproject") == "\n".join(text_pip_groups("pyproject.toml")) + assert "pip install -r requirements-dev.txt" in block("python-requirements-only") + assert "go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest" in block( + "go-1.23-legacy" + ) + assert "https://leji.org/quickstart/" in block("none") + + +def test_rendered_line() -> None: + def line(name: str) -> str: + return render_ecosystem_line(detect_ecosystem(str(CASES_DIR / name))) + + assert line("node-pnpm-lock") == "Ecosystem: pnpm (pnpm-lock.yaml); Leji CLI not declared" + assert line("node-declared") == "Ecosystem: npm (package-lock.json); Leji CLI declared" + assert line("python-pipfile-only") == "Ecosystem: pipenv (Pipfile); Leji CLI declared" + assert line("python-tool-uv-no-lock") == "Ecosystem: uv (pyproject.toml); Leji CLI not declared" + assert line("none") == "Ecosystem: none detected" + for name in CASE_NAMES: + assert "\n" not in line(name), name + + +def test_trailing_content_is_unreadable(tmp_path: Path) -> None: + """Trailing content after the first value is not strict JSON: JSON.parse and Go's + decoder both refuse it, and json.loads does too — all three call it unreadable + rather than reading whatever came first.""" + bad = [ + '{"devDependencies":{"@leji-org/leji":"^1"}} trailing garbage', + '{"name":"demo"} {"name":"second"}', + '{"name":"demo"}]', + '{"name":"demo"} null', + ] + for i, body in enumerate(bad): + root = _plant(tmp_path, {"package.json": body, "package-lock.json": ""}, f"trail{i}") + assert detect_ecosystem(root).reason == "unreadable-manifest", body + # Trailing whitespace and a trailing newline are not content. + for i, body in enumerate(['{"name":"demo"}\n', ' {"name":"demo"} \n\n']): + root = _plant(tmp_path, {"package.json": body, "package-lock.json": ""}, f"ws{i}") + assert detect_ecosystem(root).reason is None, body diff --git a/packages/sdk-py/tests/test_export.py b/packages/sdk-py/tests/test_export.py new file mode 100644 index 0000000..0033c84 --- /dev/null +++ b/packages/sdk-py/tests/test_export.py @@ -0,0 +1,430 @@ +"""`leji export` and `leji viewer build`: one operation, two permanently supported +names. What this file pins is the part of that operation the other suites cannot +see: that the pipeline carries no network dependency, that the two names really are +one code path, that no destination flag exists, that `--strict` is scoped to the lint +class, and that a failed run leaves an existing export byte-untouched. + +Mirrors packages/sdk/test/export.test.ts, minus the two legs whose mechanism is +Node's alone (the subprocess spy and the route-equivalence crawl, which the +reference suite owns for all three: the ports do not re-implement the crawler, and +the byte-identical export tree carries its result transitively). +""" + +from __future__ import annotations + +import ast +import hashlib +import json +import re +import shutil +from pathlib import Path + +from leji.cli import main +from leji.export_cmd import STRICT_LINT_RULES +from leji.schemas import load_cli_spec + +REPO_ROOT = Path(__file__).resolve().parents[3] +EXAMPLE = REPO_ROOT / "examples" / "monorepo" +FIXTURES = REPO_ROOT / "fixtures" +SRC = Path(__file__).resolve().parents[1] / "src" / "leji" + + +def _copy(src: Path, dest: Path) -> Path: + shutil.copytree(src, dest) + return dest + + +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.""" + if not directory.exists(): + return [] + acc: list[tuple[str, str]] = [] + for p in sorted(directory.rglob("*")): + rel = str(p.relative_to(directory)).replace("\\", "/") + if p.is_symlink(): + acc.append((rel, "non-regular")) + elif p.is_dir(): + acc.append((rel + "/", "")) + elif p.is_file(): + acc.append((rel, hashlib.sha256(p.read_bytes()).hexdigest())) + else: + acc.append((rel, "non-regular")) + return sorted(acc) + + +# --- module topology ---------------------------------------------------------- +# The structural prong of the no-network guarantee: the export module's transitive +# STATIC import set is CLOSED — every module it reaches outside the package is named +# below, and nothing else may appear. Stated as a denylist the proof would only be as +# complete as the list of network modules someone thought to write down (`imaplib`, +# `telnetlib`, `urllib3`, `websockets`, next year's client library — all invisible); +# stated as a subset, a new dependency of any kind reddens this test until someone +# classifies it deliberately. It catches the static introduction of a dependency and +# nothing else; dynamic side doors are covered by the offline CI leg, and the +# subprocess claim (git and nothing else) by the reference suite's spy. +# +# Every entry below has been checked: none of them opens a socket. Adding one is that +# same decision, made again. +EXPORT_IMPORTS = { + "__future__", + "dataclasses", + "datetime", + "errno", + "functools", + "hashlib", + "importlib.metadata", + "importlib.resources", + "json", + "os", + "pathlib", + "posixpath", + "re", + "secrets", + "shutil", + "stat", + "subprocess", + "sys", + "tempfile", + "typing", + "unicodedata", + # The package's two declared dependencies: schema validation and the frontmatter + # parser. Neither is a network client. + "jsonschema.exceptions", + "jsonschema.validators", + "yaml", +} + + +def _imports_of(path: Path) -> list[str]: + """Every module `path` imports statically, anywhere in the file — module level + and inside a function alike, since a deferred import pulls a module in exactly as + a top-level one does. A package-relative import comes back as `.<module>`.""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + out: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + out.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level: + # `from .fsx import x` and `from . import fsx` alike name a sibling. + if node.module: + out.append(f".{node.module}") + else: + out.extend(f".{alias.name}" for alias in node.names) + elif node.module: + out.append(node.module) + return out + + +def _module_graph(entry: str) -> tuple[set[str], set[str]]: + """The transitive closure of `entry` over sibling modules of the `leji` package, + as module names, plus every external module reached along the way. Third-party + packages are recorded, never walked — exactly as the reference's graph records a + bare specifier without walking node_modules.""" + modules: set[str] = set() + external: set[str] = set() + stack = [entry] + while stack: + name = stack.pop() + if name in modules: + continue + modules.add(name) + source = SRC / f"{name}.py" + assert source.is_file(), f"{name} resolves to a source file" + for spec in _imports_of(source): + if spec.startswith("."): + stack.append(spec[1:]) + else: + external.add(spec) + return modules, external + + +def test_module_graph_of_the_export_imports_only_classified_modules() -> None: + modules, external = _module_graph("export_cmd") + # The graph is real: the export pulls in the chrome generation and the rendering + # lint, so an empty or truncated walk cannot pass this test by accident. + assert "viewer_cmd" in modules, f"the graph reaches the generator: {sorted(modules)}" + assert "renderlint" in modules, "the graph reaches the rendering lint" + assert "mounts" in modules, "the graph reaches the mount status the manifest page renders" + assert len(modules) >= 8, f"the graph is not truncated: {sorted(modules)}" + assert "serve_cmd" not in modules, "the export never reaches the serve module" + + unclassified = sorted(external - EXPORT_IMPORTS) + assert not unclassified, ( + f"the export module graph imports {unclassified}, which this test has not " + "classified: check that it opens no socket, then name it in EXPORT_IMPORTS" + ) + + # Positive control: the serve module DOES import http.server, so the assertions + # above are testing a real property rather than a walker that sees nothing. + _, serve_external = _module_graph("serve_cmd") + assert "http.server" in serve_external, "the serve module graph imports http.server" + + +# --- name equivalence --------------------------------------------------------- + + +def test_export_and_viewer_build_write_byte_identical_trees(tmp_path: Path, capsys) -> None: + a = _copy(EXAMPLE, tmp_path / "a") + b = _copy(EXAMPLE, tmp_path / "b") + assert main(["export", "--root", str(a), "--json"]) == 0 + first = capsys.readouterr().out + assert main(["viewer", "build", "--root", str(b), "--json"]) == 0 + second = capsys.readouterr().out + # The same JSON document under both names, `command` included: the second name is + # the same operation, not a second command that resembles it. + doc = json.loads(first) + assert doc["command"] == "export" + assert first == second, "byte-identical under both names" + assert doc["out"] == ".leji/dist" + assert _snapshot(b) == _snapshot(a), "identical working trees" + + +# --- canonical JSON on every path --------------------------------------------- + + +def test_export_emits_its_canonical_document_on_every_path(tmp_path: Path, capsys) -> None: + (tmp_path / "leji.json").write_text("{ this is not a manifest\n", encoding="utf-8") + + assert main(["export", "--root", str(tmp_path), "--json"]) == 1 + first = capsys.readouterr().out + assert main(["viewer", "build", "--root", str(tmp_path), "--json"]) == 1 + second = capsys.readouterr().out + # One document shape for every outcome of this command: the pre-pipeline failure is + # NOT reported in the generic {command, ok, findings, summary} envelope. + doc = json.loads(first) + assert list(doc) == ["command", "ok", "out", "findings", "warning"] + assert doc["command"] == "export" + assert doc["ok"] is False + assert doc["out"] == ".leji/dist" + assert any(f["severity"] == "error" for f in doc["findings"]), ( + f"the unreadable manifest is reported: {doc['findings']}" + ) + assert doc["warning"].startswith("This is your context layer") + assert first == second, "byte-identical under both names" + + # A caller `--out` is reported as the caller wrote it, on the same shape. + assert main(["export", "--root", str(tmp_path), "--out", "site", "--json"]) == 1 + assert json.loads(capsys.readouterr().out)["out"] == "site" + + +# --- arg rejection ------------------------------------------------------------ + + +def test_export_takes_no_destination_flag_and_its_help_names_no_network( + tmp_path: Path, capsys +) -> None: + layer = _copy(EXAMPLE, tmp_path / "layer") + for argv in ( + ["export", "--endpoint", "x"], + ["export", "--url", "https://example.invalid"], + ["export", "--host", "example.invalid"], + ["export", "--token", "secret"], + ["export", "--port", "8080"], + ["viewer", "build", "--endpoint", "x"], + ): + assert main([*argv, "--root", str(layer)]) == 2, f"{' '.join(argv)} is a usage error" + capsys.readouterr() + # The accept side of the same guarantee, under BOTH names: the allow-list the + # rejection above consults is exactly the globals plus --out and --strict. Read from + # cli.json, which is what the CLI itself rejects against — so a destination flag + # cannot reach the surface without failing here. + spec = load_cli_spec() + for name in ("export", "viewer build"): + cmd = next((c for c in spec["commands"] if c["name"] == name), None) + assert cmd is not None, f"{name} is a documented command" + allowed = sorted( + part.strip().split()[0] + for option in [*spec["globalOptions"], *cmd["options"]] + for part in option["flags"].split(",") + ) + assert allowed == [ + "--help", + "--json", + "--out", + "--root", + "--strict", + "--version", + "-h", + "-v", + ], name + # And the help bytes a person reads describe no network operation: this command + # writes files from files. The whole banned class against the real bytes, not a + # selected few of them. + assert main(["--help", *name.split(" ")]) == 0 + help_text = capsys.readouterr().out + # The flag surface itself is the cli.json assertion above, which holds whatever + # the help layout does; help only has to document it. + for option in cmd["options"]: + assert f" {option['flags']}" in help_text, f"{name} help documents {option['flags']}" + assert "\nGlobal options: see leji --help.\n" in help_text, name + for word in ( + "endpoint", + "token", + "upload", + "api key", + "s3://", + "host", + "url", + "server", + "network", + "browser", + "publish", + "remote", + ): + assert word not in help_text.lower(), f'the {name} help text carries no "{word}"' + + +# --- strict scope and the byte-untouched target -------------------------------- + + +def test_strict_is_scoped_to_the_lint_class_and_leaves_the_target_untouched( + tmp_path: Path, capsys +) -> None: + # A layer that reports a finding without failing generation: a viewer.homepage that + # resolves to nothing is a warning, exported anyway. + directory = _copy(FIXTURES / "valid-unified-leji-fresh", tmp_path / "layer") + manifest_path = directory / "leji.json" + declared = json.loads(manifest_path.read_text(encoding="utf-8")) + declared["viewer"] = {"homepage": "no-such-page.md"} + manifest_path.write_text(json.dumps(declared, indent=2) + "\n", encoding="utf-8") + + assert main(["export", "--root", str(directory), "--json"]) == 0 + plain = capsys.readouterr().out + plain_doc = json.loads(plain) + assert plain_doc["ok"] is True + assert any(f["rule"] == "viewer-path-missing" for f in plain_doc["findings"]), ( + f"the layer reports a finding: {plain_doc['findings']}" + ) + + # `--strict` is scoped to the lint class, not to any finding: an ordinary viewer + # warning stays a warning, and the export is still written. + assert main(["export", "--root", str(directory), "--strict", "--json"]) == 0, ( + "an ordinary warning is not promoted by --strict" + ) + assert capsys.readouterr().out == plain, "the same findings, and still written" + # The class the gate does promote is the rendering lint's, so F4's findings fail a + # strict run. What that promotion DOES is pinned behaviorally by the test below; + # this only names the class the gate is scoped to. + assert "render-unsupported" in STRICT_LINT_RULES, "the lint class is what --strict promotes" + + dist_dir = directory / ".leji" / "dist" + before = _snapshot(dist_dir) + assert before, "an export exists to be protected" + + # An error finding fails the run through the same pre-clean gate: overview.md, + # seeded by the runs above, redirected into a private role. Generation reaches it + # after the chrome is written, so this run proves both halves of the pipeline + # promise at once — the internal chrome IS regenerated, the target is not touched. + (directory / ".leji" / "mounts").mkdir(parents=True, exist_ok=True) + (directory / ".leji" / "mounts" / "stolen.md").write_text("private\n", encoding="utf-8") + overview = directory / "docs" / "overview.md" + assert overview.exists(), "the seeded overview page is there to redirect" + overview.unlink() + overview.symlink_to(directory / ".leji" / "mounts" / "stolen.md") + viewer_dir = directory / ".leji" / "viewer" + shutil.rmtree(viewer_dir) + + assert main(["export", "--root", str(directory), "--json"]) == 1, ( + "an error finding fails the run" + ) + failed_doc = json.loads(capsys.readouterr().out) + assert failed_doc["ok"] is False + assert any(f["severity"] == "error" for f in failed_doc["findings"]), ( + f"the run reports an error finding: {failed_doc['findings']}" + ) + assert _snapshot(dist_dir) == before, "the existing export is byte-untouched" + assert (viewer_dir / "index.html").exists(), "the internal chrome was regenerated regardless" + assert (viewer_dir / "assets").exists(), "the internal chrome carries its assets" + + # The same holds under the other name, and for a target that does not exist yet. + shutil.rmtree(dist_dir) + assert main(["viewer", "build", "--root", str(directory), "--strict"]) == 1 + capsys.readouterr() + assert not dist_dir.exists(), "nothing was written at all" + + +# --- the strict gate, driven by a real lint finding ---------------------------- + + +def test_strict_gate_is_driven_by_a_real_lint_finding(tmp_path: Path, capsys) -> None: + directory = _copy(FIXTURES / "valid-unified-leji-fresh", tmp_path / "layer") + # A real unsupported construct in one of the layer's own documents: the rendering + # lint reads the source the export carries, so the exit codes below are the gate's + # answer to a finding the shipped pipeline produced and not to a planted one. + doc_path = directory / "docs" / "domain" / "overview.md" + with doc_path.open("a", encoding="utf-8") as fh: + fh.write("\nA raw <span>element</span> in the prose.\n") + + # Default run: the lint finding is reported and the export is written anyway — the + # layer's build never breaks on prose. + assert main(["export", "--root", str(directory), "--json"]) == 0, ( + "an ordinary run exports despite the lint finding" + ) + plain_doc = json.loads(capsys.readouterr().out) + assert plain_doc["ok"] is True + assert any( + f["rule"] == "render-unsupported" + and f["severity"] == "warning" + and f["path"] == "docs/domain/overview.md" + and f["line"] == 5 + and f["construct"] == "raw-html" + for f in plain_doc["findings"] + ), f"the lint finding reached the pipeline: {plain_doc['findings']}" + dist_dir = directory / ".leji" / "dist" + before = _snapshot(dist_dir) + assert before, "an export exists to be protected" + + # Same layer, same finding, `--strict`: the run fails and the export it would have + # replaced is left exactly as it was. The chrome is removed first, so the assertion + # that it was regenerated can actually fail: after the default run above it exists + # already, and a strict gate moved ahead of regeneration would pass unnoticed. + viewer_dir = directory / ".leji" / "viewer" + shutil.rmtree(viewer_dir) + assert main(["export", "--root", str(directory), "--strict", "--json"]) == 1, ( + "the lint class fails a strict run" + ) + strict_doc = json.loads(capsys.readouterr().out) + assert strict_doc["ok"] is False + assert any(f["rule"] == "render-unsupported" for f in strict_doc["findings"]) + assert _snapshot(dist_dir) == before, "the existing export is byte-untouched" + # The internal chrome is regenerated regardless: the no-write promise is the + # target's, per the pipeline order. + assert (viewer_dir / "index.html").exists(), "the chrome was regenerated" + assert (viewer_dir / "assets").exists(), "the internal chrome carries its assets" + + # One operation, two names: the gate answers the same under `viewer build`. + assert main(["viewer", "build", "--root", str(directory), "--strict", "--json"]) == 1, ( + "the gate holds under the other name" + ) + capsys.readouterr() + assert _snapshot(dist_dir) == before, "and still byte-untouched" + + +# --- the export flavor's subpath proxy gate ------------------------------------ + + +def test_export_flavor_carries_no_root_absolute_url(tmp_path: Path, capsys) -> None: + directory = _copy(EXAMPLE, tmp_path / "layer") + assert main(["export", "--root", str(directory), "--json"]) == 0 + capsys.readouterr() + served = (directory / ".leji" / "viewer" / "index.html").read_text(encoding="utf-8") + exported = (directory / ".leji" / "dist" / "index.html").read_text(encoding="utf-8") + # One code path, two flavors: the servable area holds the app-root base, the export + # holds the relative one. index.html is the only file that differs. + assert '"basePath":"/content/"' in served + assert 'href="/assets/leji-logo.svg"' in served + assert '"basePath":"content/"' in exported + assert '"basePath":"/content/"' not in exported + # The machine-checkable proxy gate for subpath hosting: nothing in the exported + # shell — attributes or config — addresses the server root. + body = exported.split("-->", 1)[1] + assert re.findall(r'(?:href|src)="/[^"]*"', body) == [] + assert re.findall(r'\\"/(?:content|assets)/[^\\"]*\\"', body) == [] + # The servable area never holds export-flavored bytes, and the two trees agree on + # everything else the chrome ships. + for rel in ("assets/viewer-boot.js", "assets/docsify.min.js"): + 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" diff --git a/packages/sdk-py/tests/test_fsx.py b/packages/sdk-py/tests/test_fsx.py new file mode 100644 index 0000000..a7a6657 --- /dev/null +++ b/packages/sdk-py/tests/test_fsx.py @@ -0,0 +1,395 @@ +"""The write boundary at its own level: the strict within-root primitive, the rule +``guarded_write`` applies through every convenience, and the verified read that +decides what is standing at a target before anything acts on it. The canary suite +pins the same rule through the commands; these pin the mechanism, so a port has a +per-case oracle rather than an end-to-end one. + +Mirrors packages/sdk/test/fsx.test.ts. +""" + +from __future__ import annotations + +import os +import socket +from pathlib import Path + +import pytest + +from leji.fsx import ( + guard_root, + mkdirp_guarded, + open_write_guarded, + rename_guarded, + resolved_within_root, + rm_guarded, + verified_target_read, + write_file_atomic_guarded, + write_file_guarded, +) +from leji.layout import DIST_REL, WORK_REL + + +def _repo(factory: pytest.TempPathFactory) -> Path: + """A temp repository root, realpath-resolved (macOS hands out /var -> /private/var).""" + return Path(os.path.realpath(factory.mktemp("leji-fsx"))) + + +def _outside(factory: pytest.TempPathFactory) -> Path: + """A destination outside any repository, for the escape cases.""" + return Path(os.path.realpath(factory.mktemp("leji-outside"))) + + +# --- the strict within-root primitive ---------------------------------------- + + +def test_resolved_within_root_existing_absent_dangling_escaping_case_variant( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + (root / "file.md").write_text("x\n", encoding="utf-8") + assert resolved_within_root(str(root), root / "file.md"), "an existing file inside root" + assert resolved_within_root(str(root), root / "not-yet" / "file.md"), "a not-yet-created target" + + away = _outside(tmp_path_factory) + (root / "dangling.md").symlink_to(away / "gone.md") + assert not resolved_within_root(str(root), root / "dangling.md"), "a dangling link out of root" + + (away / "real.md").write_text("x\n", encoding="utf-8") + (root / "escape.md").symlink_to(away / "real.md") + assert not resolved_within_root(str(root), root / "escape.md"), "a link resolving out of root" + + (root / "dir").mkdir() + (root / "dir" / "up").symlink_to(away) + assert not resolved_within_root(str(root), root / "dir" / "up" / "new.md"), ( + "a symlinked ancestor" + ) + + # A `.LEJI/` spelling on a case-insensitive filesystem resolves to the directory + # the filesystem actually holds, which is what the `.leji/` rule then judges. + (root / ".leji" / "dist").mkdir(parents=True) + if (root / ".LEJI").exists(): + verdict = write_file_guarded(str(root), str(root / ".LEJI" / "dist" / "x.html"), None, "x") + assert not verdict.ok, "a .LEJI/ spelling is judged as the .leji/ role it opens" + assert verdict.role == "dist" + + +def test_resolved_within_root_unreadable_directory_is_unresolvable( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + if hasattr(os, "getuid") and os.getuid() == 0: + pytest.skip("running as root: a 0o000 directory is still traversable") + root = _repo(tmp_path_factory) + closed = root / "closed" + closed.mkdir() + (closed / "target.md").write_text("x\n", encoding="utf-8") + closed.chmod(0o000) + try: + # os.path.exists, not Path.exists: this must answer False on EACCES rather + # than raising, exactly as the reference SDK's existsSync does. + if os.path.exists(closed / "target.md"): + pytest.skip("this platform allows traversal of a 0o000 directory") + assert not resolved_within_root(str(root), closed / "target.md"), ( + "unresolvable fails closed" + ) + verdict = write_file_guarded(str(root), str(closed / "target.md"), None, "x") + assert not verdict.ok + assert verdict.unresolvable, "and the chokepoint refuses it as unresolvable" + finally: + closed.chmod(0o700) + + +# --- the rule, through the conveniences --------------------------------------- + + +def test_the_write_rule_refuses_outside_the_repository_whatever_the_role( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + away = _outside(tmp_path_factory) + (root / ".leji").mkdir(parents=True) + (root / ".leji" / "dist").symlink_to(away) + + verdict = write_file_guarded(str(root), str(root / DIST_REL / "index.html"), DIST_REL, "x") + assert not verdict.ok, "an own-role target relocated out of the repository is refused" + assert verdict.outside_root + assert list(away.iterdir()) == [], "and nothing was written outside" + + cleared = rm_guarded(str(root), str(root / DIST_REL), DIST_REL) + assert cleared.outside_root, "the clear is refused the same way" + assert away.exists(), "the out-of-tree directory still stands" + + +def test_the_write_rule_own_role_other_role_and_content( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + (root / WORK_REL).mkdir(parents=True) + + crossed = write_file_guarded(str(root), str(root / WORK_REL / "stolen.md"), DIST_REL, "x") + assert not crossed.ok, "the export role may not write into the work role" + assert crossed.role == "work" + assert not (root / WORK_REL / "stolen.md").exists(), "nothing was written" + + assert write_file_guarded(str(root), str(root / DIST_REL / "index.html"), DIST_REL, "x").ok, ( + "own role" + ) + assert write_file_guarded(str(root), str(root / "overview.md"), None, "x").ok, "content" + + roleless = write_file_guarded(str(root), str(root / DIST_REL / "other.html"), None, "x") + assert not roleless.ok, "content has no legitimate .leji/ landing" + assert roleless.role == "dist" + + bare = write_file_guarded(str(root), str(root / ".leji" / "loose.md"), DIST_REL, "x") + assert not bare.ok, "a file loose in .leji/ is not the export role" + assert bare.role == "loose.md", "the role is the first segment under .leji/" + leji_itself = rm_guarded(str(root), str(root / ".leji"), DIST_REL) + assert not leji_itself.ok, ".leji/ itself is never the export role" + assert leji_itself.role == "" + assert (root / WORK_REL).exists(), "and the trust domain still stands" + + +def test_a_parent_symlinked_out_of_root_is_caught_before_the_file_is_created( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + away = _outside(tmp_path_factory) + (root / "redirect").symlink_to(away) + verdict = write_file_guarded(str(root), str(root / "redirect" / "planted.md"), None, "x") + assert not verdict.ok + assert verdict.outside_root + assert list(away.iterdir()) == [], "the parent was not written through" + + +def test_the_conveniences(tmp_path_factory: pytest.TempPathFactory) -> None: + root = _repo(tmp_path_factory) + away = _outside(tmp_path_factory) + + created = write_file_guarded(str(root), str(root / "leji.json"), None, "{}\n", exclusive=True) + assert created.ok + again = write_file_guarded( + str(root), str(root / "leji.json"), None, '{"other":1}\n', exclusive=True + ) + assert not again.ok + assert again.exists, "an existing target is its own verdict, never an overwrite" + assert (root / "leji.json").read_text(encoding="utf-8") == "{}\n", "the bytes are untouched" + + made = mkdirp_guarded(str(root), str(root / DIST_REL / "content"), DIST_REL) + assert made.ok + assert made.real == str(root / DIST_REL / "content"), "the checked resolved path comes back" + + (root / "out").symlink_to(away) + assert not mkdirp_guarded(str(root), str(root / "out" / "deep"), None).ok, "mkdirp is guarded" + assert list(away.iterdir()) == [] + + assert not rename_guarded( + str(root), str(root / "leji.json"), str(root / "out" / "leji.json"), None + ).ok, "a rename with an escaping destination is refused" + assert (root / "leji.json").exists(), "and the source is still there" + assert rename_guarded(str(root), str(root / "leji.json"), str(root / "moved.json"), None).ok + + assert write_file_atomic_guarded(str(root), str(root / "ci.yml"), None, "jobs:\n").ok + assert (root / "ci.yml").read_text(encoding="utf-8") == "jobs:\n" + assert not (root / "ci.yml.leji-tmp").exists(), "the temp sibling is gone" + assert not write_file_atomic_guarded(str(root), str(root / "out" / "ci.yml"), None, "x").ok, ( + "an escaping atomic destination is refused" + ) + + opened = open_write_guarded( + str(root), str(root / DIST_REL / "assets" / "app.css"), DIST_REL, 0o644 + ) + assert opened.ok and opened.fd is not None and opened.real is not None + with os.fdopen(opened.fd, "wb") as f: + f.write(b"body{}\n") + assert Path(opened.real).read_text(encoding="utf-8") == "body{}\n" + refused_open = open_write_guarded(str(root), str(root / "out" / "app.css"), None) + assert not refused_open.ok + assert list(away.iterdir()) == [], "nothing landed outside the repository" + + +def test_an_exclusive_create_is_decided_on_the_standing_entry( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # O_EXCL on the RESOLVED path is not enough: a dangling symlink resolves to its + # missing destination, so resolving first would let `leji.json -> nowhere` create + # the file the link points at. ANY standing entry is `exists`, and nothing anywhere + # is created. + root = _repo(tmp_path_factory) + away = _outside(tmp_path_factory) + (root / WORK_REL).mkdir(parents=True) + (root / WORK_REL / "private.json").write_text("private\n", encoding="utf-8") + target = root / "leji.json" + content = '{"schemaVersion":"1.0"}\n' + + cases: list[tuple[str, object, Path]] = [ + ( + "a dangling link to a contained path", + lambda: target.symlink_to(root / "missing.json"), + root / "missing.json", + ), + ( + "a dangling link out of the repository", + lambda: target.symlink_to(away / "missing.json"), + away / "missing.json", + ), + ( + "a link into another role", + lambda: target.symlink_to(root / WORK_REL / "planted.json"), + root / WORK_REL / "planted.json", + ), + ( + # its destination stands already; the bytes are checked below + "a link to a standing file in another role", + lambda: target.symlink_to(root / WORK_REL / "private.json"), + target, + ), + ("a directory", lambda: target.mkdir(), target), + ] + for name, plant, landing in cases: + plant() # type: ignore[operator] + verdict = write_file_guarded(str(root), str(target), None, content, exclusive=True) + assert not verdict.ok, f"{name}: refused" + assert verdict.exists, f"{name}: reported as an existing target" + if landing != target: + assert not landing.exists(), f"{name}: the link's destination was not created" + if target.is_dir() and not target.is_symlink(): + target.rmdir() + else: + target.unlink() + assert (root / WORK_REL / "private.json").read_text(encoding="utf-8") == "private\n", ( + "the other role's file was never written through" + ) + + # A standing regular file is the ordinary case, and its bytes stay as they were. + target.write_text("original\n", encoding="utf-8") + over_existing = write_file_guarded(str(root), str(target), None, content, exclusive=True) + assert over_existing.exists, "an existing regular file is never overwritten" + assert target.read_text(encoding="utf-8") == "original\n" + target.unlink() + + # Nothing standing: the resolved path is judged, its parents included, and created. + assert write_file_guarded(str(root), str(target), None, content, exclusive=True).ok + assert target.read_text(encoding="utf-8") == content + assert list(away.iterdir()) == [], "nothing was created outside the repository at any point" + assert [p.name for p in (root / WORK_REL).iterdir()] == ["private.json"], "nor in another role" + + +def test_a_refused_write_establishes_no_directory( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + (root / WORK_REL).mkdir(parents=True) + verdict = write_file_guarded( + str(root), str(root / WORK_REL / "deep" / "nested" / "x.md"), DIST_REL, "x" + ) + assert not verdict.ok + assert not (root / WORK_REL / "deep").exists(), "no parent was created for a refused write" + + +# --- the verified read --------------------------------------------------------- + + +def test_verified_target_read_kinds(tmp_path_factory: pytest.TempPathFactory) -> None: + root = _repo(tmp_path_factory) + target = root / "leji-badge.svg" + + assert verified_target_read(str(root), str(target), None).status == "absent" + + target.write_text("svg\n", encoding="utf-8") + regular = verified_target_read(str(root), str(target), None) + assert regular.status == "regular" + assert regular.text() == "svg\n" + target.unlink() + + target.symlink_to(root / "missing.svg") + dangling = verified_target_read(str(root), str(target), None) + assert dangling.status == "refused", "a standing dangling link is never read as absent" + assert dangling.reason == "unverifiable" + target.unlink() + + sock_path = root / "sock" + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(str(sock_path)) + direct = verified_target_read(str(root), str(sock_path), None) + assert direct.status == "refused" + assert direct.reason == "not-regular" + target.symlink_to(sock_path) + linked = verified_target_read(str(root), str(target), None) + assert linked.status == "refused", "a link to a socket is settled on what it resolves to" + assert linked.reason == "not-regular" + target.unlink() + finally: + server.close() + sock_path.unlink(missing_ok=True) + + target.mkdir() + directory = verified_target_read(str(root), str(target), None) + assert directory.status == "refused" + assert directory.reason == "not-regular" + target.rmdir() + + +def test_verified_target_read_outside_root_other_role_and_symlinked_parent( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + away = _outside(tmp_path_factory) + (away / "real.svg").write_text("svg\n", encoding="utf-8") + + escaping = root / "escape.svg" + escaping.symlink_to(away / "real.svg") + out = verified_target_read(str(root), str(escaping), None) + assert out.status == "refused" + assert out.reason == "outside-root" + + (root / WORK_REL).mkdir(parents=True) + (root / WORK_REL / "private.svg").write_text("svg\n", encoding="utf-8") + crossing = root / "crossing.svg" + crossing.symlink_to(root / WORK_REL / "private.svg") + role = verified_target_read(str(root), str(crossing), None) + assert role.status == "refused" + assert role.reason == "other-role" + assert verified_target_read(str(root), str(crossing), WORK_REL).status == "regular", ( + "its own role reads through" + ) + + (root / "redirect").symlink_to(away) + parent = verified_target_read(str(root), str(root / "redirect" / "real.svg"), None) + assert parent.status == "refused" + assert parent.reason == "outside-root" + + +def test_guard_root_resolves_a_root_reached_through_a_symlinked_ancestor( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + root = _repo(tmp_path_factory) + parent = Path(os.path.realpath(tmp_path_factory.mktemp("leji-link"))) + link = parent / "repo" + link.symlink_to(root) + assert guard_root(str(link)) == str(root), "both sides of the rule come through one resolver" + assert write_file_guarded(guard_root(str(link)), str(link / "x.md"), None, "x").ok + assert (root / "x.md").read_text(encoding="utf-8") == "x" + + +def test_verified_target_read_a_symlink_through_a_regular_file( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + # `link -> somefile/child`, where `somefile` is a regular file: following the link + # is ENOTDIR, not absence. The reference SDK's `statSync(..., {throwIfNoEntry: + # false})` hands ENOTDIR back as `undefined` (unlike `lstatSync`, which throws it), + # so the entry-kind pass falls through; its `resolvedPath` then refuses the path as + # unresolvable and the whole read is `refused/unverifiable` with nothing raised. + # Verified against the frozen reference by driving `verifiedTargetRead` from + # packages/sdk/dist over this exact tree, and end to end (`leji ci --provider + # gitlab` with a `.gitlab-ci.yml` shaped this way exits 2 in both SDKs with the + # same byte). Mutation that reddens: propagate NotADirectoryError from the + # symlink-follow stat — this port then raises where the reference refuses. + root = _repo(tmp_path_factory) + (root / "somefile").write_text("x\n", encoding="utf-8") + (root / "link").symlink_to(root / "somefile" / "child") + + read = verified_target_read(str(root), str(root / "link"), None) + + assert read.status == "refused", "an ENOTDIR follow is a refusal, never a raise" + assert read.reason == "unverifiable" + assert read.real is None, "the path could not be resolved at all" diff --git a/packages/sdk-py/tests/test_handoff.py b/packages/sdk-py/tests/test_handoff.py index d0063c5..cb93874 100644 --- a/packages/sdk-py/tests/test_handoff.py +++ b/packages/sdk-py/tests/test_handoff.py @@ -5,7 +5,7 @@ from leji.detect import DetectedHost from leji.init_cmd import HandoffIO, LaunchResult, handoff_offer -BRIEF = "Read ./docs/.leji/onboarding-brief.md and follow it." +BRIEF = "Read ./.leji/work/onboarding-brief.md and follow it." def host(host_id: str, name: str, on_path: bool = True) -> DetectedHost: @@ -115,4 +115,6 @@ def test_returns_false_on_non_clean_exit() -> None: def test_threads_layer_root_into_prompt() -> None: io, launches = fake_io("y") assert handoff_offer({"rootPath": "context/"}, [CLAUDE], True, io) is True - assert launches == [("claude", "Read ./context/.leji/onboarding-brief.md and follow it.")] + # The onboarding workspace is one tree at the repository root, so the prompt is + # the same for a layer rooted anywhere: it never carries a rootPath prefix. + assert launches == [("claude", BRIEF)] diff --git a/packages/sdk-py/tests/test_help.py b/packages/sdk-py/tests/test_help.py new file mode 100644 index 0000000..b040113 --- /dev/null +++ b/packages/sdk-py/tests/test_help.py @@ -0,0 +1,274 @@ +"""Help rendering, the shared goldens, and the em-dash house rule. + +Mirrors packages/sdk/test/help.test.ts. +""" + +import json +from pathlib import Path + +from leji.cli import ( + SDK_VERSION, + _build_command_help, + _build_usage, + _exit_code_column, + _help_row, + _name_column, + _option_column, + _wrap, + main, +) +from leji.conformance import ChecklistItem, ConformanceResult, render_explain +from leji.detect import DetectedHost, render_detect +from leji.ecosystem import EcosystemReport +from leji.schemas import load_cli_spec +from leji.writeplan import build_write_plan + +REPO_ROOT = Path(__file__).resolve().parents[3] +GOLDENS = REPO_ROOT / "fixtures" / "help-goldens" +EXAMPLE = REPO_ROOT / "examples" / "monorepo" + +#: U+2013 and U+2014: the house rule is that no line the CLI prints carries either. +DASHES = ("–", "—") + + +def golden(name: str) -> str: + return (GOLDENS / name).read_text(encoding="utf-8") + + +def golden_name(command: str) -> str: + return command.replace(" ", "-") + ".txt" + + +def has_dash(text: str) -> bool: + return any(d in text for d in DASHES) + + +# --- cli.json integrity: the grouping four consumers read --------------------- + + +def test_cli_json_groups_are_well_formed() -> None: + spec = load_cli_spec() + ids = [g["id"] for g in spec["groups"]] + assert len(set(ids)) == len(ids), "group ids are unique" + for g in spec["groups"]: + assert g["title"], f"{g['id']} has a title" + names = {c["name"]: c for c in spec["commands"]} + for c in spec["commands"]: + assert c["group"] in ids, f"{c['name']} names a declared group" + alias_of = c.get("aliasOf") + if alias_of is None: + continue + assert alias_of in names, f"{c['name']} aliases an existing command" + assert not names[alias_of].get("aliasOf"), f"{c['name']} aliases a primary" + + +# --- the wrapper -------------------------------------------------------------- + + +def test_wrap_collapses_hangs_and_keeps_long_tokens_whole() -> None: + assert _wrap(" one two ", 20, 0, 0) == ["one two"] + assert _wrap("", 20, 0, 0) == [] + assert _wrap("alpha beta gamma delta", 16, 0, 3) == ["alpha beta gamma", " delta"] + # The continuation indent counts against the width, not only the first line. + assert _wrap("alpha beta gamma delta", 16, 0, 8) == ["alpha beta gamma", " delta"] + # A token wider than the line takes a line of its own rather than being split: a + # URL or a flag spelling stays copyable. + assert _wrap("see https://leji.org/cli/#mounts-update-pin now", 20, 0, 3) == [ + "see", + " https://leji.org/cli/#mounts-update-pin", + " now", + ] + + +def test_top_level_usage_goes_through_the_wrapper() -> None: + # No current command is long enough to wrap this line, so the contract is pinned on a + # vector instead: every emitted field passes the wrapper, never just the ones the data + # happens to overflow today. + usage = ( + "Usage: leji mounts update-pin <name> [--to <oid>] [--allow-non-fast-forward] " + "[--fetch] [--dry-run] [--root <dir>] [--json]" + ) + assert "\n".join(_wrap(usage, 80, 0, 7)) + "\n" == golden("wrap-long-usage.txt") + assert "\nUsage: leji <command> [options]\n" in _build_usage() + + +def test_overlong_label_takes_its_own_line() -> None: + label = "--allow-non-fast-forward-with-a-very-long-spelling <oid>" + summary = ( + "Permit a target that is not a descendant of the current pin, in the one spelling " + "long enough to outgrow its column." + ) + assert "\n".join(_help_row(label, 23, summary)) + "\n" == golden("row-overlong-label.txt") + # The clamp is what makes an overlong label reachable: past 27 characters the flag + # outgrows its own column. + assert _option_column([{"flags": label, "summary": ""}]) == 33 + # A label that exactly fills the column would leave no gap, so it takes the line too. + assert _help_row("--exactly-here", 17, "summary") == [ + " --exactly-here", + " summary", + ] + + +def test_row_pads_by_code_points() -> None: + # Two U+1F600 in the label: padding by UTF-16 units leaves the row two columns short + # and misaligns every summary in the block. + label = "--emoji-\U0001f600\U0001f600 <value>" + summary = ( + "A flag carrying astral characters, so a column padded in UTF-16 units misaligns " + "this row by two." + ) + assert "\n".join(_help_row(label, 23, summary)) + "\n" == golden("row-non-bmp.txt") + + +def test_every_label_class_resolves_a_bounded_column() -> None: + opt = lambda *flags: _option_column([{"flags": f, "summary": ""} for f in flags]) # noqa: E731 + name = lambda *names: _name_column([{"name": n} for n in names]) # noqa: E731 + code = lambda *codes: _exit_code_column([{"code": c} for c in codes]) # noqa: E731 + assert opt("--json") == 23 # below the floor: [20, 30] + assert opt("--a-flag-of-thirty-plus-characters <value>") == 33 # above the ceiling + assert name("leji") == 15 # below the floor: [12, 30] + assert name("a-command-name-long-enough-to-outgrow-its-bounded-column") == 33 + assert code("0") == 6 # below the floor: [3, 8] + assert code("0", "127") == 8 + # Code points, not UTF-16 units: an astral label sizes its column by what it prints. + assert opt("--emoji-\U0001f600\U0001f600 <value>") == 24 + + +def test_bounds_hold_through_the_renderers() -> None: + # Rendered, not just computed: a bound the column helper honors and the renderer + # bypasses is exactly the defect this pins. The spec pushes every class past its + # bound at once. + spec = json.loads((GOLDENS / "bounds-spec.json").read_text(encoding="utf-8")) + assert _build_usage(spec) + "\n" == golden("bounds-usage.txt").replace( + "{{version}}", SDK_VERSION, 1 + ) + long_name = "a-command-name-long-enough-to-outgrow-its-bounded-column" + assert _build_command_help(long_name, spec) + "\n" == golden("bounds-command.txt") + + +def test_wrap_measures_code_points() -> None: + # Documented in fixtures/README.md: four U+1F600, two spaces, three ASCII words, + # width 20, first line indented 0 and continuations 3. + text = "\U0001f600\U0001f600\U0001f600\U0001f600 alphabet six666 tail" + assert "\n".join(_wrap(text, 20, 0, 3)) + "\n" == golden("wrap-non-bmp.txt") + + +def test_help_row_hangs_under_its_column() -> None: + assert _help_row("--x", 20, "one two") == [" " + "--x".ljust(17) + "one two"] + + +# --- the goldens -------------------------------------------------------------- + + +def test_help_goldens_match_the_committed_bytes() -> None: + spec = load_cli_spec() + assert _build_usage() + "\n" == golden("usage.txt").replace("{{version}}", SDK_VERSION, 1) + expected = { + "usage.txt", + "wrap-non-bmp.txt", + "wrap-long-usage.txt", + "row-overlong-label.txt", + "row-non-bmp.txt", + "bounds-spec.json", + "bounds-usage.txt", + "bounds-command.txt", + } + for c in spec["commands"]: + name = str(c["name"]) + assert _build_command_help(name) + "\n" == golden(golden_name(name)), name + expected.add(golden_name(name)) + # And nothing committed is orphaned: every golden is one of the surfaces above. + assert {p.name for p in GOLDENS.iterdir()} == expected + + +def test_command_help_lists_own_options_and_points_at_the_globals() -> None: + spec = load_cli_spec() + globals_ = [o["flags"] for o in spec["globalOptions"]] + for c in spec["commands"]: + name = str(c["name"]) + help_text = _build_command_help(name) or "" + assert "\nGlobal options: see leji --help.\n" in help_text, name + for g in globals_: + assert f" {g}" not in help_text, f"{name} does not repeat {g}" + for o in c["options"]: + assert f" {o['flags']}" in help_text, f"{name} lists {o['flags']}" + # Examples are commands to copy, never prose: they are printed as authored, so + # the width contract covers everything above them. + prose = help_text.split("\nExamples:\n")[0] + assert all(len(line) <= 80 for line in prose.split("\n")), name + assert all(len(line) <= 80 for line in _build_usage().split("\n")) + + +# --- the em-dash house rule, checked on the bytes the CLI prints -------------- + + +def test_help_output_carries_no_dash() -> None: + for path in GOLDENS.iterdir(): + assert not has_dash(path.read_text(encoding="utf-8")), path.name + + +def test_cli_json_carries_no_dash() -> None: + raw = (Path(__file__).resolve().parents[1] / "src" / "leji" / "_assets" / "cli.json").read_text( + encoding="utf-8" + ) + assert not has_dash(raw) + + +def test_prose_branches_carry_no_dash(capsys) -> None: + # detect's host lines: synthetic hosts, so the branch runs wherever the suite does. + detect_text = render_detect( + [ + DetectedHost( + id="codex", + name="Codex CLI", + strength="confirmed", + on_path=True, + in_repo=True, + user_config=False, + adapter="AGENTS.md", + ) + ], + EcosystemReport(selected=None, all=[], reason="none"), + ) + assert "Codex CLI: binary on PATH" in detect_text + assert not has_dash(detect_text) + + # conformance --explain's blocker details, likewise: the detail branch needs a + # blocker that carries one, which a passing layer does not produce. + explain = render_explain( + ConformanceResult( + claimed_level="core", + verified_level="core", + items=[ + ChecklistItem( + id="index-current", + level="indexed", + description="a generated context index, current with the tree", + status="fail", + detail="the stored index is stale", + ) + ], + ) + ) + assert ( + "- a generated context index, current with the tree: the stored index is stale" in explain + ) + assert not has_dash(explain) + + # The conformance checklist's own detail column, from a real run. + main(["conformance", "--root", str(EXAMPLE)]) + checklist = capsys.readouterr().out + assert "freshness horizons are declared and checked (report-only is acceptable): " in checklist + assert not has_dash(checklist) + + # The write plan's read-only note is library data rather than a printed line, so it + # is asserted where it is produced. + plan = build_write_plan(str(EXAMPLE), [], ["README.md"]) + assert plan[0].note == "existing file, read-only input; Leji will not modify it" + + +def test_unknown_command_exits_2_with_usage_on_stderr(capsys) -> None: + assert main(["frobnicate"]) == 2 + captured = capsys.readouterr() + assert 'unknown command "frobnicate"' in captured.err + assert "Usage: leji" in captured.err diff --git a/packages/sdk-py/tests/test_inherits.py b/packages/sdk-py/tests/test_inherits.py index 870c4f2..546e1ce 100644 --- a/packages/sdk-py/tests/test_inherits.py +++ b/packages/sdk-py/tests/test_inherits.py @@ -24,7 +24,8 @@ from leji.manifest import load_manifest from leji.schemas import schema_errors from leji.validate import validate_layer -from leji.viewer_cmd import generate_viewer, resolved_profile_page, serve_viewer +from leji.serve_cmd import serve_viewer +from leji.viewer_cmd import generate_viewer, resolved_profile_page REPO_ROOT = Path(__file__).resolve().parents[3] EXAMPLE = REPO_ROOT / "examples" / "monorepo" @@ -596,7 +597,7 @@ def test_an_inheriting_profile_renders_resolved_naming_both_sources(tmp_path: Pa assert "docs/agents/core.md" in page assert "docs/agents/thought-partner.md" in page # Posture entries are labelled with the profile that supplied them. - assert "`docs/system/invariants.md` — from `core`" in page + assert "`docs/system/invariants.md` (from `core`)" in page assert "from `thought-partner`" in page # A profile with no inherits is served from disk as authored. assert resolved_profile_page(str(directory), manifest, "docs/agents/core.md") is None diff --git a/packages/sdk-py/tests/test_localcli.py b/packages/sdk-py/tests/test_localcli.py new file mode 100644 index 0000000..ca49212 --- /dev/null +++ b/packages/sdk-py/tests/test_localcli.py @@ -0,0 +1,1017 @@ +"""The hand-off decision and the launch that follows it, mirroring +packages/sdk/test/localcli.test.ts and packages/sdk/test/proc/handoff.test.ts: the +resolver over the committed ``fixtures/handoff/`` family with the installed state +written here, the launcher over an injected world so every row of its result table is +provable without ending the test runner, and process-level runs through the installed +console script.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import NoReturn, Optional + +import pytest + +import leji.ecosystem +import leji.localcli +from leji.cli import effective_root, main +from leji.localcli import ( + LaunchIo, + LocalCliHandoff, + launch_local_cli, + resolve_local_cli, +) +from leji.schemas import SDK_VERSION + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES = REPO_ROOT / "fixtures" / "handoff" + +#: The platform this suite runs on, for the cases whose target is the POSIX console +#: script. The Windows branch is exercised by passing ``win32`` explicitly, since it +#: resolves declared paths rather than asking the filesystem for an executable bit. +HOST = sys.platform + +#: A self entry no fixture can ever be: the recursion guard is asserted with the real +#: one in its own case. +NOT_SELF = "/nonexistent/not-the-running-script" + +#: The marker the hand-off must reach. It prints its own argv JSON-ENCODED, so argument +#: boundaries are provable rather than inferred from a joined string, and exits 3, a +#: status no leji command returns, so exit forwarding is observable. The JSON shape is +#: the reference SDK's (`JSON.stringify`: no spaces, non-ASCII kept as itself), so the +#: two families' markers are one rule. +MARKER = ( + f"#!{sys.executable}\n" + "import json, sys\n" + 'sys.stdout.write("handoff:python:" + json.dumps(sys.argv[1:], separators=(",", ":"),' + ' ensure_ascii=False) + "\\n")\n' + "raise SystemExit(3)\n" +) + +#: A target that passes every check and still cannot be executed: its interpreter does +#: not exist. This is what a target REMOVED or stripped of its executable bit between +#: the check and the exec leaves behind, deterministically, without a test having to win +#: the race itself. +UNRUNNABLE = "#!/nonexistent/interpreter\n" + + +def marker_line(argv: list[str]) -> str: + return "handoff:python:" + json.dumps(argv, separators=(",", ":"), ensure_ascii=False) + "\n" + + +def add_dist_info( + site: Path, dir_name: str, meta_name: str, version: str, padding: int = 0 +) -> None: + """One installed distribution's metadata. The DIRECTORY name and the ``Name`` field + are set apart, because only the second decides identity: the first is the PEP 376 + spelling that narrows which files are opened at all.""" + dist = site / f"{dir_name}-{version}.dist-info" + dist.mkdir(parents=True, exist_ok=True) + metadata = f"Metadata-Version: 2.1\nName: {meta_name}\nVersion: {version}\n" + if padding > 0: + metadata += f"Description: {'x' * padding}\n" + (dist / "METADATA").write_text(metadata + "\nThe body is not metadata.\n") + + +def install( + env_dir: Path, + *, + version: str = "1.4.0", + name: str = "leji", + meta_name: Optional[str] = None, + body: str = MARKER, + dist_infos: int = 1, + padding: int = 0, + site_entries: int = 0, + lib_dirs: int = 1, +) -> Path: + """The state a committed fixture cannot carry: a virtual environment is never + committed, so the console script and the installed distribution's metadata are + written here, statically, exactly as an install would leave them.""" + script = env_dir / ("Scripts" if sys.platform == "win32" else "bin") / "leji" + script.parent.mkdir(parents=True, exist_ok=True) + script.write_text(body) + script.chmod(0o755) + for lib in range(lib_dirs): + site = env_dir / "lib" / f"python3.{13 + lib}" / "site-packages" + site.mkdir(parents=True, exist_ok=True) + for index in range(dist_infos): + add_dist_info( + site, + name, + meta_name if meta_name is not None else name, + version if index == 0 else f"{version}.{index}", + padding, + ) + for extra in range(site_entries): + (site / f"filler-{extra}").mkdir(exist_ok=True) + return script + + +def seed(name: str, tmp_path: Path) -> Path: + """One case of the family: the committed miniature repository copied out, plus the + installed state its name declares. Every fixture is seeded by a file copy and a + write here; nothing is produced by running a CLI.""" + root = tmp_path / "repo" + shutil.copytree(FIXTURES / name, root) + venv = root / ".venv" + if name in { + "python-eligible", + "python-undeclared", + "python-ambiguous-manager", + "python-unknown-spec-line", + "python-virtual-env-equal", + "python-virtual-env-outside", + }: + install(venv) + elif name in {"python-no-env", "python-refused-manifest"}: + pass # nothing is installed at all + elif name == "python-below-minimum": + install(venv, version="0.9.3") + elif name == "python-malformed-version": + install(venv, version="1.x") + elif name == "python-wrong-name": + # Spelled like this package, declaring another one: the Name field decides. + install(venv, meta_name="leji-extras") + elif name == "python-two-distinfo": + install(venv, dist_infos=2) + elif name == "python-bounds": + install(venv, padding=65 * 1024) + elif name == "python-uv-env-var": + install(root / "envs" / "a") + elif name == "python-virtual-env-elsewhere-inside-root": + install(root / "nested" / ".venv") + elif name == "python-escaped-env": + outside = tmp_path / "outside" + install(outside) + venv.symlink_to(outside) + elif name == "python-escaped-script": + install(venv) + outside = tmp_path / "outside" + install(outside) + script = venv / "bin" / "leji" + script.unlink() + script.symlink_to(outside / "bin" / "leji") + elif name == "python-script-not-regular": + install(venv) + script = venv / "bin" / "leji" + script.unlink() + script.mkdir() + else: + raise AssertionError(f"unseeded fixture {name}") + if name == "python-refused-manifest": + # The manifest that gates the ecosystem resolves outside the repository, so the + # evidence is refused and nothing about this root is decided from it. + outside = tmp_path / "outside" + outside.mkdir(exist_ok=True) + (outside / "pyproject.toml").write_text('[dependency-groups]\ndev = ["leji"]\n') + (root / "pyproject.toml").symlink_to(outside / "pyproject.toml") + install(venv) + return root + + +def resolve_in( + root: Path, + extra: Optional[list[str]] = None, + env: Optional[dict[str, str]] = None, + platform: str = HOST, +) -> Optional[LocalCliHandoff]: + argv = ["--root", str(root), *(extra or [])] + return resolve_local_cli(argv, env or {}, platform, NOT_SELF) + + +# --- the decision table --------------------------------------------------------- + +#: Every fixture whose answer is the same on both platforms, with the reason the +#: hand-off is or is not made. +DECISIONS: list[tuple[str, bool, str]] = [ + ("python-eligible", True, "declared, installed inside the root, and at the minimum"), + ("python-undeclared", False, "installed but not declared: the repository never asked"), + ("python-no-env", False, "declared, but there is no project environment"), + ("python-below-minimum", False, "the installed major is under the layer minimum"), + ("python-malformed-version", False, "the version does not parse"), + ("python-wrong-name", False, "no installed distribution normalizes to leji"), + ("python-two-distinfo", False, "two matches: this environment cannot say which runs"), + ("python-bounds", False, "the metadata is past the read bound"), + ("python-escaped-env", False, "the environment resolves outside the repository"), + ("python-escaped-script", False, "the console script resolves outside the repository"), + ("python-script-not-regular", False, "the console script is not a regular file"), + ("python-refused-manifest", False, "the ecosystem evidence itself was refused"), + ("python-ambiguous-manager", False, "no manager selected, so no environment rule"), + ("python-unknown-spec-line", False, "a spec line this SDK has no minimum for"), +] + + +@pytest.mark.parametrize(("name", "handoff", "why"), DECISIONS) +def test_decision_table(name: str, handoff: bool, why: str, tmp_path: Path) -> None: + root = seed(name, tmp_path) + resolved = resolve_in(root) + assert (resolved is not None) == handoff, why + if resolved is not None: + assert resolved.display == ".venv/bin/leji" + assert resolved.args == ["--root", str(root)] + + +def test_go_and_node_roots_are_not_this_runtimes(tmp_path: Path) -> None: + """A Node or Go repository has no python record to decide on, so this CLI hands off + nothing there: same-runtime only, never across ecosystems.""" + for name in ("node-eligible", "go-tool"): + root = tmp_path / name + shutil.copytree(FIXTURES / name, root) + assert resolve_local_cli(["--root", str(root)], {}, HOST, NOT_SELF) is None + + +def test_polyglot_hands_off_on_its_own_record(tmp_path: Path) -> None: + """A repository declaring both runtimes is decided on THIS runtime's record, never + on the report's overall verdict, which is `multiple-ecosystems`.""" + root = tmp_path / "polyglot" + shutil.copytree(FIXTURES / "polyglot", root) + install(root / ".venv") + resolved = resolve_local_cli(["--root", str(root)], {}, HOST, NOT_SELF) + assert resolved is not None + assert resolved.display == ".venv/bin/leji" + + +def test_recursion_guard(tmp_path: Path) -> None: + """A repository whose installed copy IS the script now running: handing off would + run it again, forever.""" + root = seed("python-eligible", tmp_path) + target = os.path.realpath(root / ".venv" / "bin" / "leji") + assert resolve_local_cli(["--root", str(root)], {}, HOST, target) is None + # The same tree with a different self hands off, so the refusal above is the guard + # and not the fixture being ineligible for another reason. + assert resolve_in(root) is not None + + +def test_unresolvable_self_refuses(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + assert resolve_local_cli(["--root", str(root)], {}, HOST, None) is None + + +# --- one verified derivation ---------------------------------------------------- +# The manager selects the environment and therefore which console script would run, +# so declaration AND manager come from one verified read of the declaring manifest +# rather than from a scan that read the same files by path a moment earlier. + +DECLARATION = ( + '[project]\nname = "joiner-app"\nversion = "0.1.0"\n\n[dependency-groups]\ndev = ["leji"]\n' +) + + +def manifest_only(name: str, tables: str, tmp_path: Path) -> Path: + """A root whose manager is named by the pyproject TEXT alone: no lockfile, so the + `[tool.*]` table is what decides.""" + root = tmp_path / name + root.mkdir(parents=True) + shutil.copy(FIXTURES / "python-eligible" / "leji.json", root / "leji.json") + (root / "pyproject.toml").write_text(DECLARATION + tables) + return root + + +def test_the_manager_comes_from_the_verified_pyproject_text(tmp_path: Path) -> None: + # uv, so the uv rule applies and UV_PROJECT_ENVIRONMENT names the environment. + uv_root = manifest_only("uv", "\n[tool.uv]\npackage = false\n", tmp_path) + install(uv_root / "envs" / "a") + resolved = resolve_in(uv_root, env={"UV_PROJECT_ENVIRONMENT": "envs/a"}) + assert resolved is not None + assert resolved.display == "envs/a/bin/leji" + + # poetry, so the same variable decides nothing and `.venv` is the environment. + poetry_root = manifest_only("poetry", '\n[tool.poetry]\nname = "joiner-app"\n', tmp_path) + install(poetry_root / "envs" / "a") + assert resolve_in(poetry_root, env={"UV_PROJECT_ENVIRONMENT": "envs/a"}) is None + install(poetry_root / ".venv") + resolved = resolve_in(poetry_root, env={"UV_PROJECT_ENVIRONMENT": "envs/a"}) + assert resolved is not None + assert resolved.display == ".venv/bin/leji" + + +def test_two_tool_tables_are_ambiguous_and_hand_off_nothing(tmp_path: Path) -> None: + root = manifest_only("both", "\n[tool.uv]\n\n[tool.pdm]\n", tmp_path) + install(root / ".venv") + assert resolve_in(root) is None + + +def test_the_resolver_never_consults_the_ecosystem_report(monkeypatch, tmp_path: Path) -> None: + """The structural half of the same property: no scan is performed at all, so there + is no earlier read for a swap to sit between.""" + assert not hasattr(leji.localcli, "detect_ecosystem"), "the resolver imports no scan" + + def refuse(_root: str) -> None: + raise AssertionError("the resolver must not call detect_ecosystem") + + monkeypatch.setattr(leji.ecosystem, "detect_ecosystem", refuse) + root = seed("python-eligible", tmp_path) + assert resolve_in(root) is not None + + +# A lockfile's evidence is its NAME, and the name counts only when a verified open of +# it succeeds at decision time. Each shape below is a name that no longer stands for a +# file of this repository, and each must select nothing: the table falls through as if +# the lockfile were absent, rather than the stale name steering the environment. + + +@pytest.mark.parametrize( + "shape", + ["dangling symlink", "symlink out of the root", "symlink inside the root", "directory"], +) +def test_an_unverifiable_lockfile_is_not_evidence(shape: str, tmp_path: Path) -> None: + """With a real `uv.lock` the manager is uv, so UV_PROJECT_ENVIRONMENT names the + environment. With a name that cannot be verified open, the table falls through to + pip, whose environment is `.venv` and which this root does not have: no hand-off, + and in particular no hand-off through the environment the stale name would have + chosen.""" + root = seed("python-uv-env-var", tmp_path) # declares, no `.venv`, envs/a installed + install(root / "envs" / "a") + env = {"UV_PROJECT_ENVIRONMENT": "envs/a"} + resolved = resolve_in(root, env=env) + assert resolved is not None and resolved.display == "envs/a/bin/leji", "the uv rule" + + lock = root / "uv.lock" + lock.unlink() + outside = tmp_path / "outside" + outside.mkdir(exist_ok=True) + (outside / "uv.lock").write_text("version = 1\n") + if shape == "dangling symlink": + lock.symlink_to(root / "never-existed.lock") + elif shape == "symlink out of the root": + lock.symlink_to(outside / "uv.lock") + elif shape == "symlink inside the root": + # A link to a perfectly ordinary file of this repository is still a LINK, and + # evidence reached through one is not this repository's evidence: the entry + # itself is judged, never what it resolves to. + (root / "real.lock").write_text("version = 1\n") + lock.symlink_to(root / "real.lock") + else: + lock.mkdir() + + assert resolve_in(root, env=env) is None, shape + # The proof that the fall-through is the table and not a refusal of the root: give + # pip its own environment and the same tree hands off there instead. + install(root / ".venv") + fell_through = resolve_in(root, env=env) + assert fell_through is not None, shape + assert fell_through.display == ".venv/bin/leji", shape + + +def test_a_lockfile_removed_after_the_listing_selects_nothing(monkeypatch, tmp_path: Path) -> None: + """The window itself, exercised inside ONE resolution: the name is listed, and the + file is gone by the time the decision is made. + + The seam is `python_declares`, the last thing the resolver does between listing the + root and verifying the lock candidates; the wrapper deletes `uv.lock` on its way + through and then delegates. A resolver that selected on the listed name would still + answer `envs/a` here, which is exactly the shape this replaces; one that verifies at + decision time falls through to pip and answers `.venv`. The listing is recorded so + the test can assert the name really was there: this is the window, not an absent + file.""" + root = seed("python-uv-env-var", tmp_path) + install(root / "envs" / "a") + install(root / ".venv") + env = {"UV_PROJECT_ENVIRONMENT": "envs/a"} + before = resolve_in(root, env=env) + assert before is not None and before.display == "envs/a/bin/leji", "the uv rule, intact" + + listed: list[str] = [] + real_listdir = os.listdir + + def recording_listdir(path): # type: ignore[no-untyped-def] + names = real_listdir(path) + if os.path.abspath(str(path)) == os.path.abspath(str(root)): + listed.extend(names) + return names + + real_declares = leji.localcli.python_declares + + def delete_then_declare(*args, **kwargs): # type: ignore[no-untyped-def] + (root / "uv.lock").unlink(missing_ok=True) + return real_declares(*args, **kwargs) + + monkeypatch.setattr(os, "listdir", recording_listdir) + monkeypatch.setattr(leji.localcli, "python_declares", delete_then_declare) + resolved = resolve_in(root, env=env) + + assert "uv.lock" in listed, "the name was listed: the window is what is under test" + assert resolved is not None + assert resolved.display == ".venv/bin/leji", "the vanished name selected nothing" + + +def test_a_lockfile_swapped_to_a_link_during_verification_is_not_evidence( + monkeypatch, tmp_path: Path +) -> None: + """The window inside the verification itself: the entry is a regular file when it + is judged and a link by the time it is opened. + + Nothing has to be swapped back for that to pass a check that only judges the + entry once and then trusts the open, because the open resolves the link and + verifies its target perfectly well. What refuses it is comparing the descriptor's + own identity with the identity of the NAME afterwards: a symlink's inode is never + the inode of the file it points at. + + The seam is `open_verified_source` as the resolver calls it, wrapped so that the + swap happens on the way in, for the lock candidate only.""" + root = seed("python-uv-env-var", tmp_path) + install(root / "envs" / "a") + install(root / ".venv") + env = {"UV_PROJECT_ENVIRONMENT": "envs/a"} + before = resolve_in(root, env=env) + assert before is not None and before.display == "envs/a/bin/leji", "the uv rule, intact" + + lock = root / "uv.lock" + (root / "real.lock").write_text("version = 1\n") # an ordinary file of this repository + real_open = leji.localcli.open_verified_source + swapped: list[str] = [] + + def swap_then_open(abs_path, allow, *args, **kwargs): # type: ignore[no-untyped-def] + if os.path.abspath(str(abs_path)) == os.path.abspath(str(lock)): + lock.unlink() + lock.symlink_to(root / "real.lock") + swapped.append(str(abs_path)) + return real_open(abs_path, allow, *args, **kwargs) + + monkeypatch.setattr(leji.localcli, "open_verified_source", swap_then_open) + resolved = resolve_in(root, env=env) + + assert swapped, "the seam fired: a regular file at the lstat, a link at the open" + assert lock.is_symlink(), "the entry really is a link now" + assert resolved is not None + assert resolved.display == ".venv/bin/leji", "the link selected nothing" + + +def test_an_unreadable_candidate_cannot_suppress_a_valid_family(tmp_path: Path) -> None: + """One candidate failing operationally is absent, not fatal: the family that does + verify still selects, rather than an unreadable name taking the root down with it.""" + root = seed("python-eligible", tmp_path) # declares, uv.lock, `.venv` installed + (root / "poetry.lock").write_text("# poetry lockfile\n") + if not unreadable(root / "uv.lock"): + pytest.skip("this user can read a 0o000 file (root)") + resolved = resolve_in(root) + assert resolved is not None, "the poetry family verified and selects" + assert resolved.display == ".venv/bin/leji" + + +def test_an_oversized_pyproject_hands_off_nothing(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + (root / "pyproject.toml").write_text(DECLARATION + "\n# " + "x" * (65 * 1024) + "\n") + assert resolve_in(root) is None + + +def test_a_pyproject_that_is_a_symlink_is_refused_evidence(tmp_path: Path) -> None: + """The classification refuses a link where a manifest belongs, exactly as the + ecosystem scan does, so no read follows it in or out of the repository.""" + root = seed("python-eligible", tmp_path) + outside = tmp_path / "outside" + outside.mkdir(exist_ok=True) + (outside / "pyproject.toml").write_text(DECLARATION) + (root / "pyproject.toml").unlink() + (root / "pyproject.toml").symlink_to(outside / "pyproject.toml") + assert resolve_in(root) is None + # A link that stays INSIDE the repository is refused on the same rule: what a + # manifest is, not where it points, is what the classification answers. + (root / "pyproject.toml").unlink() + (root / "inner.toml").write_text(DECLARATION) + (root / "pyproject.toml").symlink_to(root / "inner.toml") + assert resolve_in(root) is None + + +def test_a_root_listing_past_the_bound_hands_off_nothing(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + for index in range(513): + (root / f"filler-{index}").mkdir() + assert resolve_in(root) is None + + +# --- unreadable eligibility state ----------------------------------------------- +# Resolution runs BEFORE main() and outside its error handling, so anything that +# raises here would reach the user as a traceback where the global CLI was meant to +# run. Every read is total: refusal and unreadability are both no hand-off. + + +def unreadable(target: Path) -> bool: + """Make one path unreadable, reporting whether this user can be kept out of it + (running as root, nothing can).""" + target.chmod(0o000) + try: + target.read_bytes() + return False + except OSError: + return True + + +def test_an_unreadable_metadata_file_runs_the_global(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + site = root / ".venv" / "lib" / "python3.13" / "site-packages" + if not unreadable(site / "leji-1.4.0.dist-info" / "METADATA"): + pytest.skip("this user can read a 0o000 file (root)") + assert resolve_in(root) is None + + +def test_an_unreadable_spec_line_runs_the_global(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + if not unreadable(root / "leji.json"): + pytest.skip("this user can read a 0o000 file (root)") + assert resolve_in(root) is None + + +def test_an_unreadable_pyproject_runs_the_global(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + if not unreadable(root / "pyproject.toml"): + pytest.skip("this user can read a 0o000 file (root)") + assert resolve_in(root) is None + + +def test_a_leji_json_that_is_a_directory_runs_the_global(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + (root / "leji.json").unlink() + (root / "leji.json").mkdir() + assert resolve_in(root) is None + + +def test_a_spec_line_linked_out_of_the_repository_is_refused(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + outside = tmp_path / "outside" + outside.mkdir(exist_ok=True) + (outside / "leji.json").write_text('{"leji":"1.0"}\n') + (root / "leji.json").unlink() + (root / "leji.json").symlink_to(outside / "leji.json") + assert resolve_in(root) is None + + +@pytest.mark.parametrize( + "body", + [ + json.dumps({"leji": "1.0", "pad": "x" * (65 * 1024)}), + json.dumps({"leji": 1}), + json.dumps(["1.0"]), + "{ not json", + ], +) +def test_a_spec_line_past_the_bound_or_not_a_string_is_refused(body: str, tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + (root / "leji.json").write_text(body) + assert resolve_in(root) is None + + +def test_the_spec_line_is_all_the_wrapper_asks_of_the_manifest(tmp_path: Path) -> None: + """A manifest that would fail validation still names a spec line, and the hand-off + is about which CLI answers, not about whether the layer is valid.""" + root = seed("python-eligible", tmp_path) + (root / "leji.json").write_text(json.dumps({"leji": "1.0"}) + "\n") + assert resolve_in(root) is not None + + +def test_opt_out_at_any_value(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + for value in ("", "0", "1", "no"): + assert resolve_in(root, env={"LEJI_NO_LOCAL": value}) is None, value + assert resolve_in(root, env={"LEJI_NO_LOCAL_OTHER": "1"}) is not None + + +def test_nested_cwd_is_not_the_root(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + nested = root / "docs" / "context" + nested.mkdir(parents=True) + assert resolve_local_cli(["--root", str(nested)], {}, HOST, NOT_SELF) is None + + +def test_argv_is_forwarded_verbatim(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + argv = ["start", "--root", str(root), "--json", "--", "--root", "ignored", "", " ", "ünïcødé"] + resolved = resolve_local_cli(argv, {}, HOST, NOT_SELF) + assert resolved is not None + assert resolved.args == argv + + +def test_a_malformed_root_selects_nothing(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + for argv in (["--root"], ["--root", "--json"], ["--root", ""], ["--name", "--root", str(root)]): + assert resolve_local_cli(argv, {}, HOST, NOT_SELF) is None, " ".join(argv) + + +# --- the environment rule ------------------------------------------------------- +# The manager owns the environment, computed first and alone. VIRTUAL_ENV never +# selects one: an active environment is the shell's state, not this repository's. + + +def test_uv_project_environment_relative_to_the_root(tmp_path: Path) -> None: + root = seed("python-uv-env-var", tmp_path) + resolved = resolve_in(root, env={"UV_PROJECT_ENVIRONMENT": "envs/a"}) + assert resolved is not None + assert resolved.display == "envs/a/bin/leji" + # Without the variable the rule is `.venv`, which this fixture does not have. + assert resolve_in(root) is None + + +def test_uv_project_environment_outside_the_root_is_refused(tmp_path: Path) -> None: + root = seed("python-uv-env-var", tmp_path) + outside = tmp_path / "outside" + install(outside) + assert resolve_in(root, env={"UV_PROJECT_ENVIRONMENT": str(outside)}) is None + assert resolve_in(root, env={"UV_PROJECT_ENVIRONMENT": "../outside"}) is None + + +def test_virtual_env_equal_to_the_computed_env_changes_nothing(tmp_path: Path) -> None: + root = seed("python-virtual-env-equal", tmp_path) + resolved = resolve_in(root, env={"VIRTUAL_ENV": str(root / ".venv")}) + assert resolved is not None + assert resolved.display == ".venv/bin/leji" + + +def test_virtual_env_elsewhere_inside_the_root_is_ignored(tmp_path: Path) -> None: + """A nested environment in a monorepo is not this root's, even though it is inside + the repository: the computed one decides, and here there is none.""" + root = seed("python-virtual-env-elsewhere-inside-root", tmp_path) + assert resolve_in(root, env={"VIRTUAL_ENV": str(root / "nested" / ".venv")}) is None + + +def test_virtual_env_outside_the_root_is_ignored(tmp_path: Path) -> None: + root = seed("python-virtual-env-outside", tmp_path) + outside = tmp_path / "outside" + install(outside) + resolved = resolve_in(root, env={"VIRTUAL_ENV": str(outside)}) + assert resolved is not None + assert resolved.display == ".venv/bin/leji", "the computed environment decides" + + +# --- the bounds ----------------------------------------------------------------- + + +def add_lib_dirs(env_dir: Path, count: int, start: int = 20) -> None: + """Extra interpreter directories, each holding one UNRELATED distribution, so the + only thing that changes is how many directories the search would have to walk.""" + for index in range(count): + site = env_dir / "lib" / f"python3.{start + index}" / "site-packages" + dist = site / "other-1.0.dist-info" + dist.mkdir(parents=True) + (dist / "METADATA").write_text("Name: other\nVersion: 1.0\n") + + +def test_too_many_interpreter_directories(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + add_lib_dirs(root / ".venv", 3) # four in total: still searched, still one match + assert resolve_in(root) is not None + add_lib_dirs(root / ".venv", 1, start=30) # five: past the bound + assert resolve_in(root) is None + + +def test_too_many_site_packages_entries(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + install(root / ".venv", site_entries=513) + assert resolve_in(root) is None + + +def test_too_many_dist_info_candidates(tmp_path: Path) -> None: + """Only the directories that could be this distribution are opened, so an ordinary + environment full of other packages still answers; past the bound on THOSE, nothing + is opened at all.""" + root = seed("python-eligible", tmp_path) + site = root / ".venv" / "lib" / "python3.13" / "site-packages" + for other in range(20): # a real environment's other distributions + add_dist_info(site, f"other{other}", f"other{other}", "1.0") + assert resolve_in(root) is not None + for extra in range(8): # eight more spelled like this one, declaring something else + add_dist_info(site, "leji", "leji-extras", f"9.{extra}") + assert resolve_in(root) is None + + +def test_a_distribution_whose_dist_info_is_not_named_for_it_is_not_found(tmp_path: Path) -> None: + """The narrowing is fail-closed: metadata declaring this distribution under some + other directory spelling is never reached, so the global runs.""" + root = seed("python-no-env", tmp_path) + install(root / ".venv", name="renamed", meta_name="leji") + assert resolve_in(root) is None + + +def test_metadata_past_the_byte_bound(tmp_path: Path) -> None: + root = seed("python-bounds", tmp_path) + assert resolve_in(root) is None + + +# --- the Windows branch --------------------------------------------------------- + + +def test_windows_target_is_the_scripts_executable(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + scripts = root / ".venv" / "Scripts" + scripts.mkdir(parents=True) + exe = scripts / "leji.exe" + exe.write_text(MARKER) + exe.chmod(0o755) + (root / ".venv" / "Lib" / "site-packages" / "leji-1.4.0.dist-info").mkdir(parents=True) + (root / ".venv" / "Lib" / "site-packages" / "leji-1.4.0.dist-info" / "METADATA").write_text( + "Name: leji\nVersion: 1.4.0\n" + ) + resolved = resolve_local_cli(["--root", str(root)], {}, "win32", NOT_SELF) + assert resolved is not None + assert resolved.display == ".venv/Scripts/leji.exe" + + +def test_windows_without_the_scripts_executable_is_refused(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + assert resolve_local_cli(["--root", str(root)], {}, "win32", NOT_SELF) is None + + +# --- the effective root --------------------------------------------------------- + + +def test_effective_root_pinned_cases() -> None: + assert effective_root([]) == "." + assert effective_root(["validate"]) == "." + assert effective_root(["--root", "x"]) == "x" + assert effective_root(["--root=x"]) == "x" + assert effective_root(["--root", "a", "--root", "b"]) == "b", "last --root wins" + assert effective_root(["--root=a", "--root", "b"]) == "b" + assert effective_root(["--json", "--root", "x", "validate"]) == "x" + assert effective_root(["--topics", "a b", "--root", "x"]) == "x" + assert effective_root(["--root", "-"]) == "-", "a bare dash is a value, not a flag" + assert effective_root(["--", "--root", "x"]) == ".", "tokens after -- are not our flags" + assert effective_root(["--root", "x", "--", "--root", "y"]) == "x" + assert effective_root(["--root"]) is None, "a missing value decides nothing" + assert effective_root(["--root", "--json"]) is None, "a flag-looking value decides nothing" + assert effective_root(["--root", ""]) is None, "an empty value is the usage error" + assert effective_root(["--name", "--root", "x"]) is None, "a malformed sequence decides nothing" + assert effective_root(["--root="]) is None + + +def test_effective_root_agrees_with_the_parser(tmp_path: Path, capsys, monkeypatch) -> None: + """The drift test. The parse computes its own root through argparse, so the two are + compared where it is observable: WHICH repository the command answered about. One + root carries a layer and the other does not, so a parse that landed on the other + root cannot produce the same exit code. The cases that are usage errors assert the + usage exit, which is what a None effective root means.""" + layer = tmp_path / "layer" + shutil.copytree(REPO_ROOT / "fixtures" / "valid-minimal-core", layer) + (layer / "expected.json").unlink(missing_ok=True) + empty = tmp_path / "empty" + empty.mkdir() + cases: list[list[str]] = [ + ["validate", "--root", str(layer)], + ["validate", f"--root={layer}"], + ["validate", "--root", str(empty), "--root", str(layer)], + ["validate", "--json", "--root", str(layer)], + ["validate", "--root", str(empty)], + ["validate", "--root"], + ["validate", "--root", "--json"], + ["validate", "--name", "--root", str(layer)], + ] + monkeypatch.chdir(empty) + for argv in cases: + root = effective_root(argv[1:]) + code = main(argv) + capsys.readouterr() + if root is None: + expected = 2 + else: + expected = 0 if os.path.abspath(root) == os.path.abspath(layer) else 1 + assert code == expected, f"{' '.join(argv)} (effective root {root})" + + +# --- the launcher's result table ------------------------------------------------ + + +class Exited(Exception): + """`exit` raises so the launcher's "never returns" contract is observable without + ending the test runner.""" + + +HANDOFF = LocalCliHandoff( + bin="/repo/.venv/bin/leji", args=["validate", "--json"], display=".venv/bin/leji" +) + + +def recorder( + platform: str = "linux", + *, + exec_error: Optional[OSError] = None, + exec_returns: bool = False, + code: int = 0, + run_error: Optional[OSError] = None, +) -> tuple[LaunchIo, dict]: + log: dict = {"stderr": [], "exits": [], "execs": [], "runs": []} + + def _exec(bin_path: str, args: list[str]) -> None: + log["execs"].append((bin_path, args)) + if exec_error is not None: + raise exec_error + if not exec_returns: + raise AssertionError("the default POSIX path must replace this process") + + def _run(bin_path: str, args: list[str]) -> int: + log["runs"].append((bin_path, args)) + if run_error is not None: + raise run_error + return code + + def _exit(status: int) -> NoReturn: + log["exits"].append(status) + raise Exited(f"exit {status}") + + return ( + LaunchIo( + platform=platform, + exec_replace=_exec, + run_child=_run, + stderr=lambda line: log["stderr"].append(line), + exit=_exit, + ), + log, + ) + + +def test_launch_posix_exec_that_returns_is_never_success() -> None: + io, log = recorder(exec_error=None, exec_returns=True) + with pytest.raises(Exited): + launch_local_cli(HANDOFF, io) + assert log["execs"] == [(HANDOFF.bin, HANDOFF.args)] + # A replacement that returned is not success: there is no status to be. + assert log["stderr"] == [ + "leji: cannot run the repository's Leji CLI at .venv/bin/leji: no exit status" + ] + assert log["exits"] == [2] + + +def test_launch_posix_exec_failure_fails_closed() -> None: + io, log = recorder(exec_error=OSError(2, "No such file or directory")) + with pytest.raises(Exited): + launch_local_cli(HANDOFF, io) + assert log["stderr"] == ["leji: cannot run the repository's Leji CLI at .venv/bin/leji: ENOENT"] + assert log["exits"] == [2] + + +def test_launch_exec_failure_without_an_errno_still_names_it() -> None: + io, log = recorder(exec_error=OSError("no code here")) + with pytest.raises(Exited): + launch_local_cli(HANDOFF, io) + assert log["stderr"] == [ + "leji: cannot run the repository's Leji CLI at .venv/bin/leji: no code here" + ] + assert log["exits"] == [2] + + +@pytest.mark.parametrize("status", [0, 1, 2, 3, 127]) +def test_launch_windows_exits_with_the_child_status(status: int) -> None: + io, log = recorder("win32", code=status) + with pytest.raises(Exited): + launch_local_cli(HANDOFF, io) + assert log["runs"] == [(HANDOFF.bin, HANDOFF.args)] + assert log["exits"] == [status] + assert log["stderr"] == [] + + +def test_launch_windows_names_a_signal_and_exits_1() -> None: + io, log = recorder("win32", code=-15) + with pytest.raises(Exited): + launch_local_cli(HANDOFF, io) + assert log["stderr"] == ["leji: the repository's Leji CLI ended by SIGTERM"] + assert log["exits"] == [1] + + +def test_launch_windows_run_failure_fails_closed() -> None: + io, log = recorder("win32", run_error=OSError(13, "Permission denied")) + with pytest.raises(Exited): + launch_local_cli(HANDOFF, io) + assert log["stderr"] == ["leji: cannot run the repository's Leji CLI at .venv/bin/leji: EACCES"] + assert log["exits"] == [2] + + +# --- through the installed console script --------------------------------------- +# The decision table above is unit level; what is proved here is that the console +# script performs it, that a real argv survives the crossing byte for byte, and that +# the exec replacement makes the child's exit status and signal this process's own. + +CONSOLE = REPO_ROOT / "packages" / "sdk-py" / ".venv" / "bin" / "leji" +console_only = pytest.mark.skipif( + not CONSOLE.exists(), reason="the SDK's own console script is not installed" +) + + +def run_console( + argv: list[str], cwd: Path, env: Optional[dict[str, str]] = None +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(CONSOLE), *argv], + cwd=str(cwd), + env={**os.environ, **(env or {})}, + capture_output=True, + text=True, + timeout=60, + ) + + +@console_only +def test_console_hands_off_with_the_argv_it_was_given(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + argv = ["--root", str(root), "--version"] + result = run_console(argv, cwd=REPO_ROOT) + assert result.stdout == marker_line(argv) + assert result.returncode == 3, "the child status is the one that surfaces" + assert result.stderr == "" + + +@console_only +def test_console_forwards_every_argument_shape(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + argv = ["start", "--root", str(root), "--json", "--", "--root", "x", "", " ", "ünïcødé", "--"] + assert run_console(argv, cwd=REPO_ROOT).stdout == marker_line(argv) + + +@console_only +def test_console_uses_the_cwd_and_never_walks_up(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + assert run_console(["--version"], cwd=root).stdout == marker_line(["--version"]) + nested = root / "docs" / "context" + nested.mkdir(parents=True) + assert run_console(["--version"], cwd=nested).stdout == f"{SDK_VERSION}\n" + + +@console_only +def test_console_opt_out_runs_the_global(tmp_path: Path) -> None: + root = seed("python-eligible", tmp_path) + for value in ("", "0", "1"): + result = run_console(["--version"], cwd=root, env={"LEJI_NO_LOCAL": value}) + assert result.stdout == f"{SDK_VERSION}\n", value + assert result.returncode == 0 + + +@console_only +def test_console_runs_the_global_where_the_repository_does_not_qualify(tmp_path: Path) -> None: + for name in ("python-undeclared", "python-no-env", "python-below-minimum", "go-tool"): + root = tmp_path / name + if name == "go-tool": + shutil.copytree(FIXTURES / name, root) + else: + root = seed(name, tmp_path / name) + result = run_console(["--version"], cwd=root) + assert result.stdout == f"{SDK_VERSION}\n", name + assert result.stderr == "", name + assert result.returncode == 0, name + + +@console_only +def test_console_in_the_sdks_own_checkout_runs_itself(tmp_path: Path) -> None: + """The SDK's own `.venv` is not a fixture: this package declares no Leji dependency + of its own and carries no layer manifest, so running the tests from here hands off + nothing. Were it ever eligible, the target would BE this script and the recursion + guard would refuse it.""" + result = run_console(["--version"], cwd=REPO_ROOT / "packages" / "sdk-py") + assert result.stdout == f"{SDK_VERSION}\n" + assert result.returncode == 0 + + +@console_only +def test_console_runs_the_global_on_unreadable_eligibility_state(tmp_path: Path) -> None: + """Through the real script: an unreadable file on the resolution path prints the + global's answer, not a traceback.""" + for index, relative in enumerate( + ( + "leji.json", + "pyproject.toml", + ".venv/lib/python3.13/site-packages/leji-1.4.0.dist-info/METADATA", + ) + ): + root = seed("python-eligible", tmp_path / f"unreadable{index}") + if not unreadable(root / relative): + pytest.skip("this user can read a 0o000 file (root)") + result = run_console(["--version"], cwd=root) + assert result.stdout == f"{SDK_VERSION}\n", relative + assert result.stderr == "", relative + assert result.returncode == 0, relative + + +@console_only +def test_console_fails_closed_when_the_target_cannot_run(tmp_path: Path) -> None: + root = tmp_path / "repo" + shutil.copytree(FIXTURES / "python-eligible", root) + install(root / ".venv", body=UNRUNNABLE) + result = run_console(["validate"], cwd=root) + assert result.returncode == 2 + assert result.stdout == "" + assert result.stderr.startswith( + "leji: cannot run the repository's Leji CLI at .venv/bin/leji: " + ) + + +@console_only +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signal semantics") +def test_console_ends_by_the_childs_signal(tmp_path: Path) -> None: + """With `execv` the parent IS the child, so a child that dies by a signal ends this + process by that signal with nothing to forward.""" + root = tmp_path / "repo" + shutil.copytree(FIXTURES / "python-eligible", root) + install( + root / ".venv", + body=f"#!{sys.executable}\nimport os, signal\nos.kill(os.getpid(), signal.SIGTERM)\n", + ) + result = run_console(["validate"], cwd=root) + assert result.returncode == -signal_number() + + +def signal_number() -> int: + import signal as _signal + + return int(_signal.SIGTERM) diff --git a/packages/sdk-py/tests/test_mcp.py b/packages/sdk-py/tests/test_mcp.py index 2a5255a..8716487 100644 --- a/packages/sdk-py/tests/test_mcp.py +++ b/packages/sdk-py/tests/test_mcp.py @@ -6,6 +6,7 @@ from leji.detect import DetectedHost from leji.init_cmd import ( + RunOptions, HandoffIO, LaunchResult, McpOfferOptions, @@ -68,8 +69,8 @@ def launch( events.append(f"launch:{bin_name}") return LaunchResult(started=True) - def run(bin_name: str, args: list[str], cwd: Optional[str], quiet: bool) -> LaunchResult: - runs.append((bin_name, args, cwd, quiet)) + def run(bin_name: str, args: list[str], cwd: Optional[str], opts: RunOptions) -> LaunchResult: + runs.append((bin_name, args, cwd, opts.quiet)) events.append(f"run:{bin_name}") idx = len(runs) - 1 return results[idx] if idx < len(results) else LaunchResult(started=True) diff --git a/packages/sdk-py/tests/test_mounts.py b/packages/sdk-py/tests/test_mounts.py index cf100f7..25bbcef 100644 --- a/packages/sdk-py/tests/test_mounts.py +++ b/packages/sdk-py/tests/test_mounts.py @@ -1,10 +1,15 @@ """Federation mounts resolver tests, mirroring packages/sdk/test/mounts.test.ts.""" +import errno import hashlib import json import os import shutil +import stat as statmod import subprocess +import tempfile +import threading +import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from pathlib import Path @@ -309,6 +314,37 @@ def test_pin_reachability_reachable_unreachable_off_history_unknown_offline(tmp_ assert head.witness_ref == "refs/heads/main" +def test_fetch_retains_the_pin_by_a_resolver_owned_ref_and_writes_no_fetch_head( + tmp_path, +) -> None: + host, sibling, pin = mounted_pair(tmp_path) + git(sibling, "config", "uploadpack.allowAnySHA1InWant", "true") + manifest = load_manifest(host).manifest + assert manifest is not None + # main moves past the pin, so neither fetch may leave the version of record to + # FETCH_HEAD: only a ref of our own retains it. + (sibling / "b.md").write_text("# b\n", encoding="utf-8") + git(sibling, "add", "-A") + git(sibling, "-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-q", "-m", "b") + with _source_rewrite(sibling): + hydrate_mounts(host, manifest, fetch=True) + identity = normalize_source("https://github.com/acme/product-context") + assert identity is not None + store = Path(host) / ".leji" / "mounts" / "store" / sha256_hex(identity) + assert git(store, "rev-parse", pin_ref_for(identity, pin)) == pin + # Both fetches pass --no-write-fetch-head, so the managed store carries no + # per-run record of where the objects came from. + assert not (store / "FETCH_HEAD").exists(), "no FETCH_HEAD in the managed store" + # And a second --fetch, which refreshes the witness over an existing store, does + # not create one either. + (sibling / "c.md").write_text("# c\n", encoding="utf-8") + git(sibling, "add", "-A") + git(sibling, "-c", "user.name=T", "-c", "user.email=t@example.com", "commit", "-q", "-m", "c") + with _source_rewrite(sibling): + hydrate_mounts(host, manifest, fetch=True) + assert not (store / "FETCH_HEAD").exists(), "still none after a witness refresh" + + def test_conformance_pin_reachable_is_unknown_offline_and_never_awards_federated( tmp_path, ) -> None: @@ -580,3 +616,246 @@ def test_two_matching_submodules_are_ambiguous_even_with_a_resolving_candidate( # Hydration is unaffected: it takes the first candidate and never reads the flag # unless there is none, exactly as the reference does. assert find_object_source(host, mount, identity).kind == "hint" + + +# --- verification is read-only: it may not stage inside the tree it verifies --- + + +def deny_writes(directory: Path): + """Strip write permission from every directory in the tree; returns the undo. On + POSIX this is a real denial for a non-root user. The Windows equivalent is a DENY + ACE rather than a mode, which is a named verify-at-build obligation for the + cross-platform runner, not something these mode bits stand in for.""" + saved = [(p, p.stat().st_mode) for p in [directory, *directory.rglob("*")] if p.is_dir()] + for p, _mode in saved: + p.chmod(0o555) + + def restore() -> None: + for p, mode in saved: + p.chmod(statmod.S_IMODE(mode)) + + return restore + + +def write_denied(directory: Path) -> bool: + """Denial is asserted, never assumed: a mode that a root-owned or ACL-governed run + ignores would make every "did not write" assertion below vacuous.""" + probe = directory / ".write-probe" + try: + probe.write_text("x", encoding="utf-8") + except OSError: + return True + probe.unlink() + return False + + +def tree_snapshot(directory: Path, prefix: str = "") -> list[str]: + """Paths, types, modes, symlink targets, content and directory mtimes: the whole of + what "the tree is byte-for-byte what it was" has to mean here. Content alone would + miss a staging directory created and removed between the two reads — its parent's + mtime is the only trace that survives.""" + out: list[str] = [] + for name in sorted(os.listdir(directory)): + abs_path = directory / name + rel = name if prefix == "" else f"{prefix}/{name}" + st = abs_path.lstat() + mode = oct(statmod.S_IMODE(st.st_mode)) + if statmod.S_ISLNK(st.st_mode): + out.append(f"L {rel} {mode} {os.readlink(os.fsencode(abs_path))!r}") + elif statmod.S_ISDIR(st.st_mode): + out.append(f"D {rel} {mode} {st.st_mtime_ns}") + out.extend(tree_snapshot(abs_path, rel)) + else: + digest = hashlib.sha256(abs_path.read_bytes()).hexdigest() + out.append(f"F {rel} {mode} {st.st_size} {digest}") + return out + + +def verify_residue() -> set[str]: + """Staging directories left behind in the OS temp dir. Compared as a delta, since + the suite's other tests run against the same temp dir.""" + return {n for n in os.listdir(tempfile.gettempdir()) if n.startswith("leji-verify-")} + + +def test_check_integrity_verifies_a_write_denied_host_tree_twice_without_touching_it( + tmp_path, +) -> None: + # `mounts status --check-integrity` staged its comparison tree inside the host's + # own .leji/mounts/, so the read-only diagnostic wrote into the tree it was + # diagnosing — and could not run at all where that tree is not writable. + host, _sibling, _pin = mounted_pair(tmp_path) + manifest = load_manifest(host).manifest + assert manifest is not None + hydrate_mounts(host, manifest) + restore = deny_writes(Path(host)) + try: + assert write_denied(Path(host) / ".leji" / "mounts"), "the mounts dir is write-denied" + assert write_denied(Path(host)), "the host root is write-denied" + before = tree_snapshot(Path(host)) + residue_before = verify_residue() + # Twice: once proves it runs, twice proves the second run is not consuming + # residue the first left behind. + assert mount_status(host, manifest, check_integrity=True)[0]["verified"] is True + assert mount_status(host, manifest, check_integrity=True)[0]["verified"] is True + # The other two callers of the same verification, on the same denied tree. + loc = locate_mount(host, manifest, "acme-product-context") + assert loc["present"] is True + assert loc["verified"] is True + assert federation_enforcement(host, manifest, "available", None) == [] + assert tree_snapshot(Path(host)) == before, "verification wrote into the host tree" + assert verify_residue() - residue_before == set(), "staging outlived its verification" + finally: + restore() + + +def test_two_verifications_at_once_in_one_process_do_not_collide(tmp_path) -> None: + """Two threads running rounds of verification of the host's only mount, every round + entered through a two-thread rendezvous, with the lagging thread then held back to + about half of its last round. + + Both halves earn their place. Without the rendezvous the threads drift into taking + turns and never overlap; with the rendezvous alone they run identical work in + lockstep, and two threads staging the same content into one shared directory at the + same instant still agree — the interleaving that a shared staging directory cannot + survive is one thread starting while the other is mid-verification. Threads, so "the + same process" is literal: a staging name derived from the pid is one name for both + of them.""" + host, _sibling, _pin = mounted_pair(tmp_path) + manifest = load_manifest(host).manifest + assert manifest is not None + hydrate_mounts(host, manifest) + m = manifest["federation"]["mounts"][0] + mount = MountDecl( + name=m["name"], source=m["source"], pin=m["pin"], tracking_ref=m.get("trackingRef") + ) + residue_before = verify_residue() + rounds = 8 + gate = threading.Barrier(2) + + def verify_rounds(lag: bool) -> list[object]: + results: list[object] = [] + last = 0.040 + for _ in range(rounds): + gate.wait() + if lag: + time.sleep(max(0.005, last / 2)) + started_at = time.monotonic() + try: + results.append(verify_projection(host, mount)) + except OSError as exc: # a collision surfaces as ENOENT/ENOTEMPTY + results.append(f"raised {exc.errno}") + last = time.monotonic() - started_at + return results + + with ThreadPoolExecutor(max_workers=2) as pool: + both = list(pool.map(verify_rounds, [False, True])) + # Every one of them verified: a shared staging path has one thread deleting or + # half-writing the tree the other is comparing, which surfaces as ENOENT, + # ENOTEMPTY, or a false verdict on content nobody tampered with. + assert both == [[True] * rounds, [True] * rounds] + assert verify_residue() - residue_before == set() + + +@contextmanager +def _mkdtemp_denied(): + """The allocator seam. An unwritable TMPDIR is not the lever here: ``tempfile`` + falls back to other candidate directories when TMPDIR is unusable and caches the + one it picked, so the test would allocate successfully and never reach the branch + it exists to cover. Restored on the way out.""" + original = tempfile.mkdtemp + + def deny(*_args: object, **_kwargs: object) -> str: + raise PermissionError(errno.EACCES, "Permission denied") + + tempfile.mkdtemp = deny # type: ignore[assignment] + try: + yield + finally: + tempfile.mkdtemp = original + + +def test_an_unusable_temp_directory_makes_verification_unverifiable_never_in_tree( + tmp_path, +) -> None: + host, _sibling, _pin = mounted_pair(tmp_path) + manifest = load_manifest(host).manifest + assert manifest is not None + hydrate_mounts(host, manifest) + before = tree_snapshot(Path(host)) + residue_before = verify_residue() + with _mkdtemp_denied(): + # Unknown: no staging area is a missing prerequisite, exactly like no reachable + # object store. It is never a pass, never a failure, and never a reason to fall + # back into the host tree. + row = mount_status(host, manifest, check_integrity=True)[0] + assert row["present"] is True + assert row["verified"] is None + loc = locate_mount(host, manifest, "acme-product-context") + assert loc["present"] is True + assert loc["verified"] is False + assert "present but not verified" in str(loc["detail"]) + assert "verification prerequisites are unavailable" in str(loc["detail"]) + # The diagnostic names the prerequisite that was actually missing rather than + # blaming the object store, which is reachable here: a reader told to check + # their hint would be reading the wrong end of the failure. + findings = federation_enforcement(host, manifest, "available", None) + assert len(findings) == 1 + assert "cannot be verified" in findings[0].message + assert "verification prerequisites unavailable" in findings[0].message + assert "no writable temp dir" in findings[0].message + assert tree_snapshot(Path(host)) == before, "verification fell back into the host tree" + assert verify_residue() - residue_before == set(), "nothing was staged" + + +def test_a_reachable_store_without_the_pin_is_unverifiable_and_names_the_prerequisite( + tmp_path, +) -> None: + host, _sibling, _pin = mounted_pair(tmp_path) + manifest = load_manifest(host).manifest + assert manifest is not None + hydrate_mounts(host, manifest) + # A real repository, reachable, that simply does not contain this pin. The + # published projection stays published — its cache key comes from the + # declaration, not from whichever store happens to be reachable — so the only + # missing prerequisite is the commit the comparison would be made against. + other = Path(host).parent / "other" + other.mkdir() + git(other, "init", "-q", "-b", "main") + (other / "unrelated.md").write_text("# unrelated\n", encoding="utf-8") + git(other, "add", "-A") + git( + other, + "-c", + "user.name=T", + "-c", + "user.email=t@example.com", + "commit", + "-q", + "-m", + "unrelated", + ) + (Path(host) / ".leji" / "mounts.local.json").write_text( + json.dumps({"mounts": {"acme-product-context": {"repo": "../other"}}}) + "\n", + encoding="utf-8", + ) + m = manifest["federation"]["mounts"][0] + mount = MountDecl( + name=m["name"], source=m["source"], pin=m["pin"], tracking_ref=m.get("trackingRef") + ) + assert verify_projection(host, mount) is None + row = mount_status(host, manifest, check_integrity=True)[0] + assert row["present"] is True + assert row["verified"] is None + loc = locate_mount(host, manifest, "acme-product-context") + assert loc["present"] is True + assert loc["verified"] is False + # The parenthetical is the whole of what makes a projection unverifiable. An + # exhaustive-looking list that omits this branch tells the reader their object + # store is unreachable when it is reachable and their pin is what is missing. + findings = federation_enforcement(host, manifest, "available", None) + assert len(findings) == 1 + assert findings[0].message == ( + 'mount "acme-product-context" projection cannot be verified (verification ' + "prerequisites unavailable: no reachable object store, unresolvable pin, or " + "no writable temp dir); an unverified cache is not evidence" + ) diff --git a/packages/sdk-py/tests/test_onboarding.py b/packages/sdk-py/tests/test_onboarding.py index 88072d8..066a7dd 100644 --- a/packages/sdk-py/tests/test_onboarding.py +++ b/packages/sdk-py/tests/test_onboarding.py @@ -26,6 +26,7 @@ write_index, ) from leji.conformance import ChecklistItem +from leji.ecosystem import detect_ecosystem from leji.init_cmd import add_agent, ensure_local_hook, entering_adopted @@ -54,16 +55,16 @@ def test_init_dry_run_writes_nothing_and_reports_plan(tmp_path: Path) -> None: creates = [e.rel for e in result.plan if e.status == "create"] assert "leji.json" in creates - assert "docs/.leji/onboarding-brief.md" in creates + assert ".leji/work/onboarding-brief.md" in creates # The existing vendor file is detected and explicitly left untouched. untouched = next((e for e in result.plan if e.rel == "CLAUDE.md"), None) assert untouched is not None and untouched.status == "wont-modify" -def test_init_writes_brief_under_dot_dir_excluded_from_index(tmp_path: Path) -> None: +def test_init_writes_brief_in_workspace_role_excluded_from_index(tmp_path: Path) -> None: init_layer(str(tmp_path), yes=True, level="indexed", name="acme-context") - brief = tmp_path / "docs" / ".leji" / "onboarding-brief.md" + brief = tmp_path / ".leji" / "work" / "onboarding-brief.md" assert brief.is_file(), "brief is written" manifest = load_manifest(str(tmp_path)).manifest @@ -628,8 +629,12 @@ def test_render_write_plan_labels_every_status_and_summarizes_counts() -> None: assert re.search(r"1 to create, 1 already present.*1 to convert \(with your consent\)", out) -def test_render_detect_handles_no_hosts_case_and_ranked_case() -> None: - assert "No coding-agent hosts detected" in render_detect([]) +def test_render_detect_handles_no_hosts_case_and_ranked_case(tmp_path) -> None: + # A root with no manifest: the ecosystem line is present in both shapes and says + # so, without changing what the host list reports. + eco = detect_ecosystem(str(tmp_path)) + assert "No coding-agent hosts detected" in render_detect([], eco) + assert "Ecosystem: none detected" in render_detect([], eco) ranked = render_detect( [ DetectedHost( @@ -650,10 +655,12 @@ def test_render_detect_handles_no_hosts_case_and_ranked_case() -> None: user_config=False, adapter=".cursor/rules/leji.md", ), - ] + ], + eco, ) assert re.search(r"confirmed.*Claude Code.*binary on PATH.*CLAUDE\.md", ranked) assert "leji init --agent" in ranked + assert "Ecosystem: none detected" in ranked def test_render_explain_covers_federated_top_and_all_pass_branches() -> None: @@ -798,10 +805,10 @@ def test_solo_brief_is_mode_stamped_and_carries_interview_and_artifact_rules( tmp_path: Path, ) -> None: init_layer(str(tmp_path), yes=True, mode="solo") - brief = (tmp_path / "docs" / ".leji" / "onboarding-brief.md").read_text(encoding="utf-8") + brief = (tmp_path / ".leji" / "work" / "onboarding-brief.md").read_text(encoding="utf-8") assert "**Working mode:** solo" in brief - assert "docs/.leji/onboarding-inputs/" in brief, "drop-folder path rewritten for the root" + assert ".leji/work/onboarding-inputs/" in brief, "drop folder sits in the workspace role" assert "untrusted data" in brief, "artifact consent rules present" assert "<mode>" not in brief, "no unreplaced mode marker" assert "<root>/" not in brief, "no unreplaced root marker" @@ -834,7 +841,7 @@ def _stable(rel: str, content: str) -> str: assert not (a / "docs" / "domain" / "identity.md").exists(), ( "team scaffolds no identity starter" ) - brief = (a / "docs" / ".leji" / "onboarding-brief.md").read_text(encoding="utf-8") + brief = (a / ".leji" / "work" / "onboarding-brief.md").read_text(encoding="utf-8") assert "**Working mode:** team" in brief, "team brief carries a concrete stamp" @@ -914,8 +921,8 @@ def test_adopt_mode_solo_dry_run_writes_nothing_and_plans_the_starters(tmp_path: def test_init_refuses_while_leji_files_are_tracked_leaving_tree_untouched(tmp_path: Path) -> None: _git_init(tmp_path) - (tmp_path / "docs" / ".leji").mkdir(parents=True) - (tmp_path / "docs" / ".leji" / "stale.md").write_text("tracked artifact\n", encoding="utf-8") + (tmp_path / ".leji").mkdir(parents=True) + (tmp_path / ".leji" / "stale.md").write_text("tracked artifact\n", encoding="utf-8") _git_commit_all(tmp_path) with pytest.raises(RuntimeError, match="tracked by git"): @@ -968,7 +975,7 @@ def test_approval_guard_installs_idempotently_preserving_settings(tmp_path: Path ' "hooks": [\n' " {\n" ' "type": "command",\n' - ' "command": "node \\"$CLAUDE_PROJECT_DIR/docs/.leji/hooks/approval-guard.mjs\\""\n' + ' "command": "node \\"$CLAUDE_PROJECT_DIR/.leji/work/hooks/approval-guard.mjs\\""\n' " }\n" " ]\n" " }\n" @@ -980,7 +987,7 @@ def test_approval_guard_installs_idempotently_preserving_settings(tmp_path: Path assert settings["existing"] is True, "unrelated settings preserved" matchers = [e["matcher"] for e in settings["hooks"]["PreToolUse"]] assert matchers == ["Bash", "AskUserQuestion"] - assert (tmp_path / "docs" / ".leji" / "hooks" / "approval-guard.mjs").is_file() + assert (tmp_path / ".leji" / "work" / "hooks" / "approval-guard.mjs").is_file() # Mirrors onboarding.test.ts "approval guard: blocks until written and printed, @@ -995,7 +1002,7 @@ def test_approval_guard_blocks_until_written_and_printed_inert_after_onboarding( if shutil.which("node") is None: pytest.skip("node not on PATH") ensure_approval_guard(str(tmp_path), "docs/") - leji_dir = tmp_path / "docs" / ".leji" + leji_dir = tmp_path / ".leji" / "work" script = leji_dir / "hooks" / "approval-guard.mjs" (leji_dir / "onboarding-brief.md").write_text("brief", encoding="utf-8") @@ -1132,3 +1139,134 @@ def test_hook_stale_index_message_is_literal_not_executed(tmp_path: Path) -> Non assert index_abs.read_text(encoding="utf-8") == before, ( "the hook regenerated a governed artifact" ) + + +def _tree_snapshot(directory: Path) -> dict[str, str]: + """Every entry under `directory` as `path -> bytes` (symlinks by their target), so a + run that must write nothing can be held to the whole tree rather than to one file.""" + out: dict[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(): + out[child] = f"link:{os.readlink(entry)}" + elif entry.is_dir(): + walk(child) + elif entry.is_file(): + out[child] = entry.read_bytes().hex() + + walk("") + return out + + +# Mirrors units.test.ts "init: a .gitignore symlinked out of the repository is refused". +def test_init_refuses_a_gitignore_symlinked_out_of_the_repository( + tmp_path: Path, tmp_path_factory: pytest.TempPathFactory +) -> None: + # Previously the one unguarded write in init: the `.leji/` ignore line went out + # through whatever `.gitignore` resolved to. It now goes through the chokepoint, + # so a planted link out of the tree is a refusal with nothing written through it. + away = Path(os.path.realpath(tmp_path_factory.mktemp("leji-ignore-away"))) + target = away / "gitignore" + target.write_text("node_modules/\n", encoding="utf-8") + (tmp_path / ".gitignore").symlink_to(target) + + with pytest.raises( + RuntimeError, match="refusing to write through a symlink that escapes the target" + ): + init_layer(str(tmp_path), yes=True, name="demo-context") + + assert target.read_text(encoding="utf-8") == "node_modules/\n", ( + "the out-of-tree file is byte-untouched" + ) + assert not (tmp_path / "leji.json").exists(), "the refusal came before any layer write" + + +# Mirrors units.test.ts "agent: a leji.json rewrite that would escape the repository is +# refused, and NOTHING is written". +def test_agent_refuses_an_escaping_manifest_and_writes_nothing( + tmp_path: Path, tmp_path_factory: pytest.TempPathFactory +) -> None: + # The other formerly unguarded write: the in-place manifest edit that binds the + # agent. Binding is two writes (a profile file and the manifest edit), so the + # manifest is judged through the verified read BEFORE either happens: a run that + # cannot finish must not half-finish. Nothing is written, anywhere. + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + init_layer(str(tmp_path), yes=True, name="demo-context") + m = load_manifest(str(tmp_path)).manifest + assert m is not None + away = Path(os.path.realpath(tmp_path_factory.mktemp("leji-agent-away"))) + manifest_abs = tmp_path / "leji.json" + target = away / "leji.json" + manifest_abs.rename(target) + manifest_abs.symlink_to(target) + before = target.read_text(encoding="utf-8") + profile_abs = tmp_path / "docs" / "agents" / "reviewer.md" + assert not profile_abs.exists(), "the profile does not exist before the run" + snapshot = _tree_snapshot(tmp_path) + + with pytest.raises( + RuntimeError, + match='refusing to write through a symlink that escapes the target: "leji.json"', + ): + add_agent(str(tmp_path), m, host=None, name="reviewer") + + assert target.read_text(encoding="utf-8") == before, "the out-of-tree manifest is untouched" + assert not profile_abs.exists(), "the profile was never written" + assert _tree_snapshot(tmp_path) == snapshot, "the whole tree is byte-identical" + + +# Mirrors onboarding.test.ts "leji agent: a dangling profile name refuses the command, +# writing neither half". +def test_agent_dangling_profile_name_refuses_both_halves(tmp_path: Path) -> None: + # An existence check follows symlinks, so a dangling profile link read as absent and + # the profile was written at the link's destination. Both halves are judged before + # either is written, so a refused profile leaves the manifest binding unwritten too. + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + init_layer(str(tmp_path), yes=True, name="demo-context") + m = load_manifest(str(tmp_path)).manifest + assert m is not None + link = tmp_path / "docs" / "agents" / "reviewer.md" + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to("never-created.md") + before = (tmp_path / "leji.json").read_text(encoding="utf-8") + + with pytest.raises( + RuntimeError, match="refusing to write through a symlink that escapes the target" + ): + add_agent(str(tmp_path), m, host="codex", name="reviewer") + + assert not (tmp_path / "docs" / "agents" / "never-created.md").exists(), ( + "the dangling link's destination is never created" + ) + assert (tmp_path / "leji.json").read_text(encoding="utf-8") == before, ( + "the manifest is not rewritten" + ) + assert link.is_symlink(), "the planted link is left exactly as it was" + + +# Mirrors onboarding.test.ts "adopt: a dangling scaffold name is occupied, and the +# alternate name is scaffolded". +def test_adopt_dangling_scaffold_name_is_occupied_and_falls_back(tmp_path: Path) -> None: + # The scaffold names were picked with an existence check, which follows symlinks: a + # dangling boot-profile link read as a free name, and the scaffold would have been + # written at the link's missing destination. The standing entry makes the name + # occupied, so the alternate is taken exactly as it is for an ordinary existing file. + _git_init(tmp_path) + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "notes.md").write_text("# Notes\n", encoding="utf-8") + link = tmp_path / "docs" / "boot-profile.md" + link.symlink_to("never-created.md") + _git_commit_all(tmp_path) + + res = adopt_layer(str(tmp_path), yes=True) + + assert res.manifest["bootProfilePath"] == "docs/leji-boot-profile.md", ( + "the alternate name is scaffolded" + ) + assert not (tmp_path / "docs" / "never-created.md").exists(), ( + "the dangling link's destination is never created" + ) + assert link.is_symlink(), "the planted link is left exactly as it was" diff --git a/packages/sdk-py/tests/test_preflight.py b/packages/sdk-py/tests/test_preflight.py new file mode 100644 index 0000000..fd1baa2 --- /dev/null +++ b/packages/sdk-py/tests/test_preflight.py @@ -0,0 +1,926 @@ +"""`leji start` preflight tests, mirroring packages/sdk/test/preflight.test.ts: the +report is read-only, every probe failure fails closed, and only per-clone or per-user +state is ever offered.""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +from pathlib import Path +from typing import Optional + +import pytest + +from leji.detect import DetectedHost +from leji.ecosystem import detect_ecosystem +from leji.init_cmd import ( + HandoffIO, + _capture_run, + LaunchResult, + RunOptions, + StartHost, + ensure_local_hook, + hook_status, +) +from leji.preflight import ( + Check, + _node_bin_dir, + PreflightResult, + check_document, + color_decision, + offer_preflight_fixes, + render_preflight, + run_preflight, +) + +MANIFEST = {"leji": "1.0", "rootPath": "docs/", "bootProfilePath": "docs/boot-profile.md"} + +CLAUDE_HOST = StartHost(id="claude-code", bin="claude", name="Claude Code") +CODEX_HOST = StartHost(id="codex", bin="codex", name="Codex") + + +def host(host_id: str, name: str) -> DetectedHost: + return DetectedHost( + id=host_id, + name=name, + strength="confirmed", + on_path=True, + in_repo=False, + user_config=False, + adapter=None, + ) + + +DETECTED_CLAUDE = host("claude-code", "Claude Code") +DETECTED_CODEX = host("codex", "Codex") +DETECTED_CURSOR = host("cursor", "Cursor") +DETECTED_COPILOT = host("copilot", "GitHub Copilot") + + +def install_node_bin(root: str) -> str: + """The bin shim a Node package manager's install puts in the tree. The probe executes + this file directly, so every Node case that expects a version has to have it.""" + bin_dir = Path(root) / "node_modules" / ".bin" + bin_dir.mkdir(parents=True, exist_ok=True) + shim = bin_dir / "leji" + shim.write_text("#!/bin/sh\necho 1.4.0\n") + shim.chmod(0o755) + return str(shim) + + +def git_layer(tmp_path: Path, name: str = "repo") -> str: + """A committed example layer in its own git repository: the shape every hook class + is derived from.""" + dir_path = tmp_path / name + (dir_path / "docs").mkdir(parents=True) + (dir_path / "docs" / "boot-profile.md").write_text("# boot\n") + run = lambda *a: subprocess.run( # noqa: E731 + ["git", *a], cwd=str(dir_path), check=True, capture_output=True + ) + run("init", "-q") + run("add", "-A") + run("-c", "user.email=t@e.com", "-c", "user.name=T", "commit", "-qm", "seed") + return str(dir_path) + + +def probe_io( + results: Optional[list[LaunchResult]] = None, answers: Optional[list[str]] = None +) -> tuple[HandoffIO, list[tuple[str, list[str], Optional[str], RunOptions]], list[str]]: + """A scripted IO: ``results`` answers each run in order (the last one repeats), and + every call is recorded so a probe's argv, cwd and bounds can be asserted.""" + runs: list[tuple[str, list[str], Optional[str], RunOptions]] = [] + questions: list[str] = [] + scripted = results if results else [LaunchResult(started=True, stdout="1.4.0\n")] + replies = list(answers or ["y"]) + + def read_line(question: str, _fallback: str) -> str: + questions.append(question) + return replies.pop(0) if len(replies) > 1 else replies[0] + + def launch(*_args: object, **_kwargs: object) -> LaunchResult: + raise AssertionError("the preflight never launches") + + def run(bin_name: str, args: list[str], cwd: Optional[str], opts: RunOptions) -> LaunchResult: + runs.append((bin_name, args, cwd, opts)) + idx = len(runs) - 1 + return scripted[idx] if idx < len(scripted) else scripted[-1] + + return HandoffIO(read_line=read_line, launch=launch, run=run), runs, questions + + +def preflight( + root: str, + io: HandoffIO, + host_sel: Optional[StartHost] = None, + detected: Optional[list[DetectedHost]] = None, +) -> PreflightResult: + return run_preflight(root, MANIFEST, host_sel, detected or [], detect_ecosystem(root), io) + + +def row(result: PreflightResult, check_id: str) -> Check: + found = next((c for c in result.checks if c.id == check_id), None) + assert found is not None, f"no {check_id} check" + return found + + +def git_config(root: str, key: str, value: str) -> None: + subprocess.run(["git", "config", key, value], cwd=root, check=True, capture_output=True) + + +# --- hook_status: one class per ownership ------------------------------------- + + +def test_hook_status_ordinary_clone_is_personal_and_absent(tmp_path: Path) -> None: + s = hook_status(git_layer(tmp_path), ["leji"]) + assert (s.ownership, s.state, s.path, s.managed) == ( + "personal", + "absent", + ".git/hooks/pre-commit", + "file", + ) + + +def test_hook_status_managed_is_current_and_foreign_is_foreign(tmp_path: Path) -> None: + root = git_layer(tmp_path) + ensure_local_hook(root, ["leji"]) + assert hook_status(root, ["leji"]).state == "current" + (Path(root) / ".git" / "hooks" / "pre-commit").write_text("#!/bin/sh\necho mine\n") + s = hook_status(root, ["leji"]) + assert (s.state, s.ownership) == ("foreign", "personal") + + +def test_hook_status_husky_is_shared(tmp_path: Path) -> None: + root = git_layer(tmp_path) + git_config(root, "core.hooksPath", ".husky/_") + s = hook_status(root, ["leji"]) + assert (s.ownership, s.state, s.path, s.managed) == ( + "shared", + "absent", + ".husky/pre-commit", + "block", + ) + + +def test_hook_status_worktree_hooks_path_is_shared(tmp_path: Path) -> None: + root = git_layer(tmp_path) + git_config(root, "core.hooksPath", "githooks") + s = hook_status(root, ["leji"]) + assert (s.ownership, s.path) == ("shared", "githooks/pre-commit") + + +def test_hook_status_global_hooks_path_is_external_and_report_only(tmp_path: Path) -> None: + root = git_layer(tmp_path) + outside = tmp_path / "elsewhere" + outside.mkdir() + git_config(root, "core.hooksPath", str(outside)) + assert hook_status(root, ["leji"]).ownership == "external" + io, _runs, _q = probe_io() + hook = row(preflight(root, io), "hook") + assert hook.status == "missing" + assert "leji pre-commit (managed)" in "\n".join(hook.fix or []) + assert not (outside / "pre-commit").exists(), "nothing was written there" + + +def test_hook_status_linked_worktree_resolves_shared_hooks_dir_as_personal( + tmp_path: Path, +) -> None: + main = git_layer(tmp_path, "main") + wt = str(tmp_path / "wt") + subprocess.run(["git", "worktree", "add", "-q", wt], cwd=main, check=True, capture_output=True) + s = hook_status(wt, ["leji"]) + # The hooks git runs live in the COMMON dir, outside this worktree. It is still + # per-clone state, but the writer refuses everything outside the repository root, + # so it is reported rather than offered. + assert (s.ownership, s.state) == ("outside-root", "absent") + io, _runs, questions = probe_io(answers=["y"]) + result = preflight(wt, io) + hook = row(result, "hook") + assert hook.status == "missing" + assert "hooks dir is outside this worktree" in hook.detail + assert hook.fix is not None and "# leji pre-commit (managed)" in "\n".join(hook.fix) + offer_preflight_fixes(wt, None, result, ["leji"], True, io) + assert questions == [], "a target outside the worktree is never offered" + assert not (Path(main) / ".git" / "hooks" / "pre-commit").exists() + + +def test_hook_status_non_repository_is_no_git(tmp_path: Path) -> None: + s = hook_status(str(tmp_path), ["leji"]) + assert (s.ownership, s.path) == ("no-git", "") + + +# --- the version probe -------------------------------------------------------- + + +def test_cli_undeclared_is_a_shared_gap_carrying_the_declare_command(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text('{"name":"app","packageManager":"pnpm@9.0.0"}\n') + io, _r, _q = probe_io([LaunchResult(started=False, error="spawn leji ENOENT")]) + result = preflight(root, io) + cli = row(result, "cli") + assert cli.status == "shared-gap" + assert cli.fix == ["pnpm add -D @leji-org/leji"] + assert result.ready is False, "a shared cli gap still leaves the clone unready" + + +def test_cli_undeclared_names_an_ambient_leji_as_your_own_install(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text('{"name":"app"}\n') + io, _r, _q = probe_io() + cli = row(preflight(root, io), "cli") + assert cli.detail == "not declared here (PATH has your own 1.4.0)" + + +def test_cli_declared_node_is_probed_by_executing_the_installed_shim(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + (Path(root) / "pnpm-lock.yaml").write_text("lockfileVersion: 9\n") + shim = install_node_bin(root) + io, runs, _q = probe_io() + result = preflight(root, io) + cli = row(result, "cli") + assert cli.status == "ok" + assert cli.detail == "1.4.0 (node_modules/.bin/leji)" + assert cli.fix is None + bin_name, args, cwd, opts = runs[0] + # The shim itself, by absolute path: no `pnpm exec`, no `npx`, no shell. + assert (bin_name, args) == (shim, ["--version"]) + assert cwd == os.path.abspath(root), "the probe runs in the repository root" + assert (opts.capture, opts.quiet, opts.timeout_ms, opts.max_bytes) == (True, True, 10000, 4096) + # The environment REPLACES this process's: no inherited PATH, no HOME of the user's. + assert opts.env is not None + assert opts.env["PATH"] == _node_bin_dir() + assert opts.env["HOME"] != os.environ.get("HOME") + for leaked in ("NODE_OPTIONS", "LD_PRELOAD", "GOPATH"): + assert leaked not in opts.env, f"{leaked} must not reach the probe" + # The clone still has no hook, so one ok row is not readiness. + assert result.ready is False + assert row(result, "hook").status == "missing" + + +def test_cli_node_without_the_installed_shim_is_missing_and_runs_no_manager( + tmp_path: Path, +) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + (Path(root) / "package-lock.json").write_text('{"lockfileVersion":3}\n') + io, runs, _q = probe_io() + cli = row(preflight(root, io), "cli") + assert cli.status == "missing" + assert cli.detail == "not installed yet (node_modules/.bin/leji)" + assert cli.fix == [ + "npm install", + "npx --no-install @leji-org/leji --version", + ] + # Nothing was executed at all: a missing shim is answered from the filesystem. + assert runs == [] + + +def test_cli_shim_resolving_outside_the_repository_is_refused(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + (Path(root) / "package-lock.json").write_text('{"lockfileVersion":3}\n') + outside = tmp_path / "elsewhere" + outside.mkdir() + target = outside / "leji" + target.write_text("#!/bin/sh\necho 9.9.9\n") + target.chmod(0o755) + bin_dir = Path(root) / "node_modules" / ".bin" + bin_dir.mkdir(parents=True) + (bin_dir / "leji").symlink_to(target) + io, runs, _q = probe_io() + cli = row(preflight(root, io), "cli") + assert cli.status == "missing", "a shim pointing out of the repository is not the declared CLI" + assert runs == [] + + +def test_cli_probe_overrides_for_uv_and_go(tmp_path: Path) -> None: + uv_root = git_layer(tmp_path, "uv") + (Path(uv_root) / "pyproject.toml").write_text( + '[project]\nname = "app"\ndependencies = ["leji"]\n' + ) + (Path(uv_root) / "uv.lock").write_text("version = 1\n") + io, runs, _q = probe_io() + preflight(uv_root, io) + assert runs[0][1] == ["run", "--no-sync", "leji", "--version"], "uv never syncs for a probe" + + go_root = git_layer(tmp_path, "go") + (Path(go_root) / "go.mod").write_text( + "module example.com/app\n\ngo 1.24\n\n" + "tool github.com/leji-org/leji/packages/sdk-go/cmd/leji\n" + ) + gio, gruns, _gq = probe_io() + preflight(go_root, gio) + assert gruns[0][1] == ["tool", "leji", "--version"] + go_env = gruns[0][3].env or {} + assert go_env["GOFLAGS"] == "-mod=readonly" + assert go_env["GOTOOLCHAIN"] == "local" + assert go_env["GOPROXY"] == "off" + assert go_env["GOWORK"] == "off", "a workspace file must not redirect the probe" + # A manager has to be found on PATH, so PATH survives; nothing unrelated does. + assert go_env["PATH"] == os.environ.get("PATH") + for leaked in ("NODE_OPTIONS", "LD_PRELOAD", "GOPRIVATE"): + assert leaked not in go_env, f"{leaked} must not reach the probe" + + +def test_cli_every_probe_failure_fails_closed(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + (Path(root) / "package-lock.json").write_text('{"lockfileVersion":3}\n') + install_node_bin(root) + failures = [ + LaunchResult(started=False, error="spawn npx ENOENT"), # never started + LaunchResult(started=True, error="timed out"), # timed out + LaunchResult(started=True, error="exit 1"), # ran, failed + LaunchResult(started=True, stdout="leji version one\n"), # malformed + LaunchResult(started=True, stdout="\n"), # empty + LaunchResult(started=True, error="probe output exceeded the cap"), # over the cap + ] + for outcome in failures: + io, _r, _q = probe_io([outcome]) + cli = row(preflight(root, io), "cli") + assert cli.status == "missing", outcome + assert cli.fix == [ + "npm install", + "npx --no-install @leji-org/leji --version", + ], outcome + + +def test_cli_below_the_spec_line_minimum_is_missing(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + install_node_bin(root) + io, _r, _q = probe_io([LaunchResult(started=True, stdout="0.9.3\n")]) + cli = row(preflight(root, io), "cli") + assert cli.status == "missing" + assert "is below 1.0.0 for spec 1.0" in cli.detail + + +# --- MCP rows ----------------------------------------------------------------- + + +def test_mcp_registered_is_ok_and_unregistered_offers_the_user_scope(tmp_path: Path) -> None: + root = git_layer(tmp_path) + ok_io, _r, _q = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True)] + ) + assert row(preflight(root, ok_io, CLAUDE_HOST, [DETECTED_CLAUDE]), "mcp").status == "ok" + + miss_io, _r2, _q2 = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True, error="exit 1")] + ) + mcp = row(preflight(root, miss_io, CLAUDE_HOST, [DETECTED_CLAUDE]), "mcp") + assert mcp.status == "missing" + assert mcp.fix == ["claude mcp add leji --scope user -- npx -y @leji-org/mcp"] + + +def test_mcp_codex_registers_at_user_level_and_has_no_shared_form(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, _r, _q = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True, error="exit 1")] + ) + result = preflight(root, io, CODEX_HOST, [DETECTED_CODEX]) + assert row(result, "mcp").fix == ["codex mcp add leji -- npx -y @leji-org/mcp"] + assert row(result, "mcp-shared").status == "n/a" + + +def test_mcp_several_hosts_and_no_pick_is_unresolved(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, _r, _q = probe_io() + mcp = row(preflight(root, io, None, [DETECTED_CLAUDE, DETECTED_CODEX]), "mcp") + assert mcp.status == "unresolved" + assert "Claude Code, Codex" in mcp.detail + assert mcp.fix == ["leji start --agent <name>"] + + +def test_mcp_unregisterable_host_gets_the_standard_config_and_its_path(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, _r, _q = probe_io() + mcp = row(preflight(root, io, None, [DETECTED_CURSOR]), "mcp") + assert mcp.status == "missing" + assert mcp.fix is not None and mcp.fix[0] == ".cursor/mcp.json (project scope)" + assert '"@leji-org/mcp"' in "\n".join(mcp.fix) + + +def test_mcp_printed_block_takes_the_shape_the_host_config_file_uses(tmp_path: Path) -> None: + root = git_layer(tmp_path) + # VS Code, which is how GitHub Copilot reads MCP servers, spells the map + # `servers`; pasting the common `mcpServers` block into .vscode/mcp.json leaves + # the editor with a file it ignores. + io, _r, _q = probe_io() + copilot = row(preflight(root, io, None, [DETECTED_COPILOT]), "mcp") + assert copilot.status == "missing" + assert copilot.fix == [ + ".vscode/mcp.json (project scope)", + "{", + ' "servers": {', + ' "leji": { "command": "npx", "args": ["-y", "@leji-org/mcp"] }', + " }", + "}", + ] + # Every other host Leji cannot register for takes the common shape. + cio, _r2, _q2 = probe_io() + cursor = row(preflight(root, cio, None, [DETECTED_CURSOR]), "mcp") + assert cursor.fix is not None and ' "mcpServers": {' in cursor.fix + + +def test_mcp_no_detected_host_is_skipped_and_never_counts_against_ready(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + install_node_bin(root) + ensure_local_hook(root, ["leji"]) + io, _r, _q = probe_io() + result = preflight(root, io) + assert row(result, "mcp").status == "skipped" + assert result.ready is True + + +def test_mcp_shared_presence_and_absence(tmp_path: Path) -> None: + root = git_layer(tmp_path) + absent_io, _r, _q = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True)] + ) + absent = preflight(root, absent_io, CLAUDE_HOST, [DETECTED_CLAUDE]) + gap = row(absent, "mcp-shared") + assert gap.status == "shared-gap" + assert gap.fix == ["claude mcp add leji --scope project -- npx -y @leji-org/mcp"] + assert absent.ready is False, "ready is decided by cli, mcp and hook" + + (Path(root) / ".mcp.json").write_text('{"mcpServers":{}}\n') + present_io, _r2, _q2 = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True)] + ) + present = preflight(root, present_io, CLAUDE_HOST, [DETECTED_CLAUDE]) + assert row(present, "mcp-shared").status == "ok" + + +def test_mcp_shared_never_decides_ready_on_its_own(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + install_node_bin(root) + ensure_local_hook(root, ["npx", "--no-install", "@leji-org/leji"]) + io, _r, _q = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True)] + ) + result = preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]) + assert row(result, "mcp-shared").status == "shared-gap" + assert result.ready is True, "cli, mcp and hook are all ok" + + +# --- the report --------------------------------------------------------------- + + +def test_the_checks_are_always_the_same_four_ids_in_order(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, _r, _q = probe_io() + result = preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]) + assert [c.id for c in result.checks] == ["cli", "mcp", "mcp-shared", "hook"] + + +def test_the_document_projection_publishes_exactly_the_four_keys(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, _r, _q = probe_io() + for c in preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]).checks: + doc = check_document(c) + assert list(doc.keys()) == ["id", "status", "detail", "fix"] + assert doc == {"id": c.id, "status": c.status, "detail": c.detail, "fix": c.fix} + assert "fix_kind" not in json.dumps(doc) + + +# --- the Setup block ------------------------------------------------------------ +# The three scenarios the layout was cut against, as exact bytes: the other two SDKs +# print these same strings, so the render is pinned here rather than described. + +MCP_USER_FIX = "claude mcp add leji --scope user -- npx -y @leji-org/mcp" +MCP_PROJECT_FIX = "claude mcp add leji --scope project -- npx -y @leji-org/mcp" + +# Nothing is this clone's to fix: the CLI, the shared server and the hook are all the +# repository's own state. +ALL_TEAM = [ + Check("cli", "shared-gap", "not declared in this repository", ["npm i -D @leji-org/leji"]), + Check("mcp", "ok", "registered for Claude Code", None), + Check("mcp-shared", "shared-gap", "no .mcp.json committed", [MCP_PROJECT_FIX]), + Check("hook", "shared-gap", "no leji block in .husky/pre-commit", ["leji ci --hooks"]), +] + +# The CLI and the hook are the maintainer's; both MCP registrations are already there. +TEAM_CLI_AND_HOOK = [ + Check( + "cli", + "shared-gap", + "not declared here (PATH has your own 1.4.0)", + ["npm i -D @leji-org/leji"], + ), + Check("mcp", "ok", "registered for Claude Code", None), + Check("mcp-shared", "ok", ".mcp.json committed", None), + Check("hook", "shared-gap", "no leji block in .husky/pre-commit", ["leji ci --hooks"]), +] + +# One fix each: this user's own registration, and the one a maintainer commits. +PERSONAL_AND_TEAM_MCP = [ + Check("cli", "ok", "1.4.0 (node_modules/.bin/leji)", None), + Check("mcp", "missing", "not registered for Claude Code", [MCP_USER_FIX]), + Check("mcp-shared", "shared-gap", "no .mcp.json committed", [MCP_PROJECT_FIX]), + Check("hook", "ok", "runs leji checks before each commit: .git/hooks/pre-commit", None), +] + +SCENARIOS = [ + ( + "every gap belongs to a maintainer", + ALL_TEAM, + "\n".join( + [ + "Setup for this clone", + "", + " team Leji CLI not declared in this repository", + " $ npm i -D @leji-org/leji", + " ok MCP server registered for Claude Code", + " team Team MCP no .mcp.json committed", + f" $ {MCP_PROJECT_FIX}", + " team Git hook no leji block in .husky/pre-commit", + " $ leji ci --hooks", + "", + " 3 fixes for a maintainer. The agent starts either way.", + ] + ), + ), + ( + "the CLI and the hook are a maintainer's, both MCP rows ok", + TEAM_CLI_AND_HOOK, + "\n".join( + [ + "Setup for this clone", + "", + " team Leji CLI not declared here (PATH has your own 1.4.0)", + " $ npm i -D @leji-org/leji", + " ok MCP server registered for Claude Code", + " ok Team MCP .mcp.json committed", + " team Git hook no leji block in .husky/pre-commit", + " $ leji ci --hooks", + "", + " 2 fixes for a maintainer. The agent starts either way.", + ] + ), + ), + ( + "one fix for this user and one for a maintainer", + PERSONAL_AND_TEAM_MCP, + "\n".join( + [ + "Setup for this clone", + "", + " ok Leji CLI 1.4.0 (node_modules/.bin/leji)", + " you MCP server not registered for Claude Code", + f" $ {MCP_USER_FIX}", + " team Team MCP no .mcp.json committed", + f" $ {MCP_PROJECT_FIX}", + " ok Git hook runs leji checks before each commit: .git/hooks/pre-commit", + "", + " 1 fix for you, 1 for a maintainer. The agent starts either way.", + ] + ), + ), +] + + +@pytest.mark.parametrize("name,checks,expected", SCENARIOS) +def test_render_preflight_scenarios(name: str, checks: list[Check], expected: str) -> None: + block = render_preflight(checks) + assert block == expected, name + assert "–" not in block and "—" not in block, "no en or em dash reaches the terminal" + + +def test_no_row_of_any_scenario_wraps_at_80_columns() -> None: + for name, checks, _expected in SCENARIOS: + for line in render_preflight(checks).split("\n"): + # Commands and snippets are exact and exempt: they are what a person pastes. + if line.startswith(" " * 8): + continue + assert len(line) <= 80, f"{name}: {len(line)} columns: {line}" + + +def test_render_preflight_snippet_is_pasted_and_a_command_carries_the_prompt() -> None: + snippet = render_preflight( + [ + Check( + "hook", + "missing", + "add it yourself; hooks run from /etc/hooks", + ["#!/bin/sh", "leji ci"], + "snippet", + ) + ] + ) + assert "\n #!/bin/sh\n leji ci\n" in snippet, snippet + # A check built without a fix kind, the shape an older caller passes, is a command. + legacy = render_preflight( + [Check("hook", "missing", "none yet (per clone)", ["leji ci --hooks"])] + ) + assert "\n $ leji ci --hooks\n" in legacy, legacy + + +def test_render_preflight_nothing_owed_is_one_closing_line() -> None: + block = render_preflight( + [ + Check("cli", "ok", "1.4.0 (node_modules/.bin/leji)", None), + Check("mcp", "skipped", "no coding agent detected", None), + ] + ) + assert block.split("\n")[-1] == " Setup complete." + + +# --- the color convention ------------------------------------------------------- + + +def test_color_off_is_the_default_and_leaves_not_one_escape_byte() -> None: + for _name, checks, _expected in SCENARIOS: + assert "\x1b" not in render_preflight(checks) + assert "\x1b" not in render_preflight(checks, False) + + +def test_color_on_wraps_the_status_word_only_and_the_columns_do_not_move() -> None: + block = render_preflight( + [ + Check("cli", "ok", "1.4.0 (node_modules/.bin/leji)", None), + Check("mcp", "missing", "not registered for Claude Code", None), + Check("mcp-shared", "shared-gap", "no .mcp.json committed", None), + Check("hook", "n/a", "not a git repository", None), + ], + True, + ) + assert block == "\n".join( + [ + "Setup for this clone", + "", + " \x1b[32mok\x1b[0m Leji CLI 1.4.0 (node_modules/.bin/leji)", + " \x1b[33myou\x1b[0m MCP server not registered for Claude Code", + " \x1b[36mteam\x1b[0m Team MCP no .mcp.json committed", + " \x1b[2mn/a\x1b[0m Git hook not a git repository", + "", + " 1 fix for you, 1 for a maintainer. The agent starts either way.", + ] + ) + + +@pytest.mark.parametrize( + "is_tty,env,expected", + [ + (True, {}, True), + (False, {}, False), + (True, {"NO_COLOR": "1"}, False), + (True, {"NO_COLOR": ""}, False), + (False, {"NO_COLOR": ""}, False), + (True, {"TERM": "dumb"}, False), + (True, {"TERM": "xterm-256color"}, True), + (False, {"TERM": "xterm-256color"}, False), + ], +) +def test_color_decision(is_tty: bool, env: dict[str, str], expected: bool) -> None: + assert color_decision(is_tty, env) is expected + + +# --- the consented repairs ---------------------------------------------------- + + +def test_offer_writes_nothing_non_interactively(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, runs, questions = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True, error="exit 1")] + ) + result = preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]) + before = len(runs) + offer_preflight_fixes(root, CLAUDE_HOST, result, ["leji"], False, io) + assert questions == [] + assert len(runs) == before + assert not (Path(root) / ".git" / "hooks" / "pre-commit").exists() + + +def test_offer_registers_then_installs_in_that_order(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, runs, questions = probe_io( + [ + LaunchResult(started=True, stdout="1.4.0\n"), + LaunchResult(started=True, error="exit 1"), + LaunchResult(started=True), + ], + ["y"], + ) + result = preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]) + offer_preflight_fixes(root, CLAUDE_HOST, result, ["leji"], True, io) + assert len(questions) == 2, questions + assert "Register the Leji MCP server for Claude Code" in questions[0] + assert "Install the pre-commit hook" in questions[1] + assert runs[-1][1] == [ + "mcp", + "add", + "leji", + "--scope", + "user", + "--", + "npx", + "-y", + "@leji-org/mcp", + ] + assert (Path(root) / ".git" / "hooks" / "pre-commit").exists() + + +def test_offer_never_offers_a_shared_gap(tmp_path: Path) -> None: + root = git_layer(tmp_path) + git_config(root, "core.hooksPath", ".husky/_") + io, _runs, questions = probe_io(answers=["y"]) + result = preflight(root, io) + assert row(result, "hook").status == "shared-gap" + offer_preflight_fixes(root, None, result, ["leji"], True, io) + assert questions == [], "a committed hook is a maintainer decision" + assert not (Path(root) / ".husky" / "pre-commit").exists() + + +def test_offer_declines_cleanly(tmp_path: Path) -> None: + root = git_layer(tmp_path) + io, _runs, questions = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True, error="exit 1")], + ["n"], + ) + result = preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]) + offer_preflight_fixes(root, CLAUDE_HOST, result, ["leji"], True, io) + assert len(questions) == 2 + assert not (Path(root) / ".git" / "hooks" / "pre-commit").exists() + + +def test_second_run_of_an_all_ok_clone_offers_nothing(tmp_path: Path) -> None: + root = git_layer(tmp_path) + (Path(root) / "package.json").write_text( + '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n' + ) + (Path(root) / ".mcp.json").write_text('{"mcpServers":{}}\n') + install_node_bin(root) + ensure_local_hook(root, ["npx", "--no-install", "@leji-org/leji"]) + io, _runs, questions = probe_io( + [LaunchResult(started=True, stdout="1.4.0\n"), LaunchResult(started=True)], ["y"] + ) + result = preflight(root, io, CLAUDE_HOST, [DETECTED_CLAUDE]) + assert [c.status for c in result.checks] == ["ok", "ok", "ok", "ok"] + assert result.ready is True + offer_preflight_fixes(root, CLAUDE_HOST, result, ["leji"], True, io) + assert questions == [] + + +# --- the capture bounds, against a real child --------------------------------- + + +# The deadline a run that must NOT reach it is given: generous enough that a loaded +# runner cannot trip it, so finishing early can only mean the cap cut the child off. +OVERFLOW_TIMEOUT_MS = 30000 + +# The bound a terminated run has to finish inside: far below the deadline above, and far +# above anything scheduling delay on a busy machine can add. What it proves is which +# mechanism ended the run, not how fast the machine is. +PROMPT_WITHIN_S = 15 + +# 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 not +# because the child happened to exit. +STUB_HOLD = "sleep 60" + + +def test_capture_kills_a_child_that_streams_past_the_cap(tmp_path: Path) -> None: + """The cap is a bound on THIS process's memory, so it has to hold whatever the child + does: a program that never stops printing is cut off and killed, well inside the + timeout, rather than being read to completion and measured afterwards.""" + stub = tmp_path / "flood" + # 1 MiB in 1 KiB lines, far past the 4 KiB cap, then a slow tail so a run that did + # not kill the child would still be waiting. + stub.write_text( + "#!/bin/sh\ni=0\nwhile [ $i -lt 1024 ]; do printf '%1024s' ''; i=$((i+1)); done\n" + f"{STUB_HOLD}\n" + ) + stub.chmod(0o755) + started = time.monotonic() + res = _capture_run( + str(stub), + [], + str(tmp_path), + RunOptions(capture=True, timeout_ms=OVERFLOW_TIMEOUT_MS, max_bytes=4096), + ) + elapsed = time.monotonic() - started + assert res.started is True + assert res.error == "probe output exceeded the cap" + assert len(res.stdout.encode("utf-8")) <= 4096, "no more than the cap is ever held" + assert elapsed < PROMPT_WITHIN_S, ( + f"the cap did not cut the child off: {elapsed:.1f}s, " + f"against a {OVERFLOW_TIMEOUT_MS}ms deadline" + ) + + +CAPTURE_CANARY = "LEJI_PROBE_CANARY" + + +def test_capture_replaces_the_environment_rather_than_extending_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The probe's environment is built from empty: what the caller names is present, + and nothing else of this process's is, however it was set.""" + stub = tmp_path / "echo-canary" + stub.write_text(f"#!/bin/sh\nprintf '%s' \"${{{CAPTURE_CANARY}:-}}\"\n") + stub.chmod(0o755) + monkeypatch.setenv(CAPTURE_CANARY, "leaked") + opts = RunOptions(capture=True, timeout_ms=10000, max_bytes=4096, env={"PATH": "/usr/bin:/bin"}) + res = _capture_run(str(stub), [], str(tmp_path), opts) + assert (res.started, res.error) == (True, None) + assert res.stdout == "", f"the parent's {CAPTURE_CANARY} reached the probe: {res.stdout!r}" + # The positive control: what the caller names IS present, so the empty result above + # is replacement rather than a stub that cannot see any environment. + kept = _capture_run( + str(stub), + [], + str(tmp_path), + RunOptions( + capture=True, + timeout_ms=10000, + max_bytes=4096, + env={"PATH": "/usr/bin:/bin", CAPTURE_CANARY: "named"}, + ), + ) + assert kept.stdout == "named" + + +def test_capture_cap_boundary(tmp_path: Path) -> None: + """Exactly the cap is not overflow; one byte past it is.""" + cap = 64 + for size, capped in ((cap - 1, False), (cap, False), (cap + 1, True)): + stub = tmp_path / f"size-{size}" + stub.write_text(f"#!/bin/sh\nprintf '%{size}s' ''\n") + stub.chmod(0o755) + res = _capture_run( + str(stub), [], str(tmp_path), RunOptions(capture=True, timeout_ms=10000, max_bytes=cap) + ) + got = res.error == "probe output exceeded the cap" + assert got is capped, f"{size} bytes: capped={got}, want {capped} ({res})" + if not capped: + assert len(res.stdout.encode("utf-8")) == size + + +def test_capture_ends_a_sparse_overflow_promptly(tmp_path: Path) -> None: + """One byte past the cap, then a child that holds stdout open and does nothing. + + This is the case a buffered read cannot see: it would wait for a full chunk that + never arrives and only give up at the deadline. The reader asks for exactly the + remaining allowance plus one, so the byte past the cap arrives on its own and ends + the run at once.""" + cap = 64 + stub = tmp_path / "trickle" + stub.write_text(f"#!/bin/sh\nprintf '%{cap + 1}s' ''\n{STUB_HOLD}\n") + stub.chmod(0o755) + started = time.monotonic() + res = _capture_run( + str(stub), + [], + str(tmp_path), + RunOptions(capture=True, timeout_ms=OVERFLOW_TIMEOUT_MS, max_bytes=cap), + ) + elapsed = time.monotonic() - started + assert (res.started, res.error) == (True, "probe output exceeded the cap") + assert elapsed < PROMPT_WITHIN_S, ( + f"a sparse overflow waited for the deadline: {elapsed:.1f}s, " + f"against a {OVERFLOW_TIMEOUT_MS}ms deadline" + ) + + +def test_capture_returns_bounded_output_for_a_well_behaved_child(tmp_path: Path) -> None: + stub = tmp_path / "ok" + stub.write_text("#!/bin/sh\necho 1.4.0\n") + stub.chmod(0o755) + res = _capture_run( + str(stub), [], str(tmp_path), RunOptions(capture=True, timeout_ms=10000, max_bytes=4096) + ) + assert (res.started, res.error, res.stdout.strip()) == (True, None, "1.4.0") + + +def test_capture_times_out_a_child_that_never_finishes(tmp_path: Path) -> None: + stub = tmp_path / "hang" + stub.write_text(f"#!/bin/sh\n{STUB_HOLD}\n") + stub.chmod(0o755) + started = time.monotonic() + res = _capture_run( + str(stub), [], str(tmp_path), RunOptions(capture=True, timeout_ms=500, max_bytes=4096) + ) + elapsed = time.monotonic() - started + assert (res.started, res.error) == (True, "timed out") + # Here the deadline IS the mechanism under test; the bound only has to separate it + # from the child's own 60s, with room for a loaded runner. + assert elapsed < PROMPT_WITHIN_S, f"the timeout did not end the run ({elapsed:.1f}s)" diff --git a/packages/sdk-py/tests/test_render_fixtures.py b/packages/sdk-py/tests/test_render_fixtures.py new file mode 100644 index 0000000..1f7b440 --- /dev/null +++ b/packages/sdk-py/tests/test_render_fixtures.py @@ -0,0 +1,196 @@ +"""The shared render fixtures, driven through the real command: their pinned +findings, their layout, their golden export bytes, and their idempotency. + +The detector's own families live with the detector (test_renderlint.py); what this +file asserts is the contract the three SDKs share — the findings a `--json` consumer +reads, in the canonical order, and the exported tree byte for byte against the +committed goldens. Mirrors the fixture half of packages/sdk/test/renderlint.test.ts. +""" + +from __future__ import annotations + +import hashlib +import json +import posixpath +import shutil +from pathlib import Path + +import pytest + +from leji.cli import main + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES = REPO_ROOT / "fixtures" + +# The layout fixtures are driven by test_canary.py, which asserts their trust corpus +# alongside the same export block; this harness takes the rest. +CANARY_DRIVEN = { + "valid-unified-leji-fresh", + "valid-unified-leji-stale-tree", + "valid-trust-canary-nested-root", + "valid-trust-canary-dot-root", +} + +FINDING_KEYS = ("rule", "severity", "path", "line", "construct") + + +def _expected(name: str) -> dict: + return json.loads((FIXTURES / name / "expected.json").read_text(encoding="utf-8")) + + +EXPORT_FIXTURES = sorted( + p.name + for p in FIXTURES.iterdir() + if (p / "expected.json").is_file() + and p.name not in CANARY_DRIVEN + and "export" in _expected(p.name) +) + + +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 _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 _golden_path(fixture_root: Path, declared: str, what: str) -> Path: + """A golden artifact at its declared name, or at the dot-prefixed name beside it: + a `rootPath: "."` fixture exports its own root, so a plainly named golden would be + exported into the next bake of itself (fixtures/README.md).""" + head, _, rest = _fixture_rel(declared, what).partition("/") + plain = _fixture_abs(fixture_root, head if not rest else f"{head}/{rest}") + if plain.exists(): + return plain + return _fixture_abs(fixture_root, "." + head if not rest else f".{head}/{rest}") + + +def _files_under(directory: Path) -> list[str]: + """Every file under `directory`, as export-root-relative POSIX paths, sorted.""" + return sorted( + str(p.relative_to(directory)).replace("\\", "/") + for p in directory.rglob("*") + if p.is_file() + ) + + +@pytest.mark.parametrize("name", EXPORT_FIXTURES) +def test_render_fixture_export_block(name: str, tmp_path: Path, capsys) -> None: + block = _expected(name)["export"] + directory = tmp_path / name + shutil.copytree(FIXTURES / name, directory) + + preserved_before: dict[str, str] = {} + for rel in block.get("layout", {}).get("preserved", []): + abs_path = _fixture_abs(directory, _fixture_rel(rel, "preserved entry")) + assert abs_path.exists(), f"preserved path exists before the run: {rel}" + if abs_path.is_file(): + preserved_before[rel] = abs_path.read_text(encoding="utf-8") + + # The whole command, under the fixture's own argv: the exit code is the process's, + # and the findings are the ones a `--json` consumer reads. + argv = [*(block.get("args") or ["export"]), "--root", str(directory), "--json"] + code = main(list(argv)) + stdout = capsys.readouterr().out + assert code == block["exit"], f"exit code for {name}: {stdout}" + doc = json.loads(stdout) + assert doc["out"].replace("\\", "/") == block["out"], "the declared output directory" + # Matched on (rule, severity, path, line, construct) IN ORDER — message text is + # never compared, and the order is the canonical one the three SDKs share. + got = [{k: f.get(k) for k in FINDING_KEYS} for f in doc["findings"]] + assert got == block["findings"], f"findings for {name}" + + # `roles` is the layout's role map — which directory each role NAMES — and + # present/absent say which of them a given run establishes: a `--strict` run names + # the export role and deliberately writes nothing at it. + layout = block.get("layout") or {} + absent = {_fixture_rel(rel, "absent entry") for rel in layout.get("absent", [])} + for role, role_dir in (layout.get("roles") or {}).items(): + rel = _fixture_rel(role_dir, f"role {role}") + if rel in absent: + continue + assert _fixture_abs(directory, rel).is_dir(), f"role {role} established at {role_dir}" + for rel in layout.get("present", []): + assert _fixture_abs(directory, _fixture_rel(rel, "present entry")).exists(), ( + f"present after the run: {rel}" + ) + for rel in absent: + assert not _fixture_abs(directory, rel).exists(), f"never created: {rel}" + for rel, before in preserved_before.items(): + assert _fixture_abs(directory, rel).read_text(encoding="utf-8") == before, ( + f"byte-identical after the run: {rel}" + ) + + # --- the golden tree ----------------------------------------------------- + out = _fixture_abs(directory, _fixture_rel(block["out"], "export out")) + golden = block["goldenTree"] + if golden["status"] == "none": + assert not out.exists(), "a run that writes no export tree has nothing to bake" + if golden["status"] == "baked": + content_dir = _golden_path(FIXTURES / name, golden["contentDir"], "goldenTree.contentDir") + manifest_file = _golden_path(FIXTURES / name, golden["manifest"], "goldenTree.manifest") + written = _files_under(out) + in_content = [f[len("content/") :] for f in written if f.startswith("content/")] + outside = [f for f in written if not f.startswith("content/")] + + # The committed bytes ARE the export's content tree: same paths, same bytes, in + # both directions, so a file that appears or disappears fails here. + assert in_content == _files_under(content_dir), ( + f"{name}: the golden content tree lists exactly what the export wrote" + ) + for rel in in_content: + assert (out / "content").joinpath(*rel.split("/")).read_bytes() == content_dir.joinpath( + *rel.split("/") + ).read_bytes(), f"{name}: exported bytes differ from the golden for content/{rel}" + + # Everything else — chrome, vendored assets, fonts — by digest and size. The two + # sets are disjoint by construction and exhaustive by this comparison. + manifest = json.loads(manifest_file.read_text(encoding="utf-8")) + assert manifest["version"] == 1, "the manifest states its version" + assert sorted(manifest["files"]) == outside, ( + f"{name}: the manifest pins every file outside content/" + ) + for rel in outside: + data = out.joinpath(*rel.split("/")).read_bytes() + pin = manifest["files"][rel] + assert hashlib.sha256(data).hexdigest() == pin["sha256"], rel + assert len(data) == pin["size"], f"{rel} size" + + # --- idempotency --------------------------------------------------------- + if (block.get("rerun") or {}).get("byteIdentical"): + after_first = _snapshot(directory) + assert main(list(argv)) == block["exit"], "the second run answers the same" + capsys.readouterr() + assert _snapshot(directory) == after_first, ( + "a second run is a byte-level no-op across the whole working tree" + ) diff --git a/packages/sdk-py/tests/test_renderlint.py b/packages/sdk-py/tests/test_renderlint.py new file mode 100644 index 0000000..f080d66 --- /dev/null +++ b/packages/sdk-py/tests/test_renderlint.py @@ -0,0 +1,283 @@ +"""The rendering-subset scan, family by family over the edges the fixtures state in +prose: what it reports, and — the half a lint lives or dies on — what it stays quiet +about. Ported from the reference suite's families, case for case; the shared render +fixtures drive the same detector through the real command (test_render_fixtures.py). +""" + +from __future__ import annotations + +from leji.renderlint import ( + RAW_HTML, + RENDER_UNSUPPORTED_RULE, + render_lint_findings, + scan_render_constructs, +) + + +def hits(text: str) -> list[str]: + """The scan as `line:construct` strings, which is what a family assertion reads.""" + return [f"{h.line}:{h.construct}" for h in scan_render_constructs(text)] + + +def lines(*parts: str) -> str: + return "\n".join(parts) + + +# --- family: multi-line HTML blocks ------------------------------------------- + + +def test_family_html_blocks_report_once_at_the_opening_line() -> None: + # A block runs to the next blank line, so the tags inside it — the closing one + # included — are block content and not a second construct. + assert hits( + lines("# Doc", "", '<div class="callout">', " inner text", "</div>", "", "after") + ) == ["3:raw-html"] + # Two blocks separated by a blank line are two constructs. + assert hits(lines("<table>", "<tr><td>a</td></tr>", "</table>", "", "<div>", "</div>")) == [ + "1:raw-html", + "5:raw-html", + ] + # A raw-text block (type 1) ends at its closing tag rather than at a blank line, + # so the blank line inside it does not split it into two. + assert hits(lines("<script>", "", "let x = 1;", "", "</script>", "", "prose")) == ["1:raw-html"] + # Inline raw HTML mid-paragraph is the other form, reported on its own line, and a + # line carrying two tags is still one finding: the line is the unit. + assert hits("A paragraph with <b>bold</b> and <i>italic</i> in it.\n") == ["1:raw-html"] + # Negative: a document with no HTML at all reports nothing. + assert hits("# Title\n\nProse with a < less-than and an a > b comparison.\n") == [] + + +# --- family: excluded regions ------------------------------------------------- + + +def test_family_excluded_regions() -> None: + # Code spans, including the multiple-backtick form. + assert hits("The tag `<div>` and `[^ref]` and `$$x$$` are text.\n") == [] + assert hits("A span with a backtick in it: ``a `<b>` span``.\n") == [] + # Fenced blocks, whatever the info string, and a longer fence carrying a shorter + # one: everything between the delimiters is code. + assert hits(lines("```html", "<div>", "</div>", "```")) == [] + assert hits(lines("````markdown", "```html", "<span>x</span>", "```", "````")) == [] + assert hits(lines("~~~", "[^one]: definition", "$$", "x", "$$", "~~~")) == [] + # Comments are the excepted HTML form: nothing inside one is reported, on one line + # or many, at the start of a line or inside prose. + assert hits(lines("<!--", " <div> and [^ref] and $$x$$", "-->", "", "prose")) == [] + assert hits("Prose with <!-- a <div> inside a comment --> and more prose.\n") == [] + # Positive controls: the same constructs outside a region are reported, so the + # assertions above are the exclusion working rather than a scan that sees nothing. + assert hits("The tag <div> and [^ref] and $$x$$ are markup.\n") == [ + "1:footnote", + "1:math-block", + "1:raw-html", + ] + # An unclosed fence excludes the rest of the document, as a renderer reads it. + assert hits(lines("```", "<div>", "[^one]")) == [] + + +# --- family: malformed and unpaired forms ------------------------------------- + + +def test_family_unpaired_or_malformed_is_prose() -> None: + # `$$` needs an open and a close; a lone delimiter is prose. + assert hits("A lone delimiter:\n\n$$\n") == [] + assert hits("$$\na^2 + b^2 = c^2\n$$\n") == ["1:math-block"] + assert hits("An inline pair: $$e = mc^2$$ mid-sentence.\n") == ["1:math-block"] + # A single `$` is deliberately outside the closed token set. + assert hits("An amount of $5 and a variable named $path.\n") == [] + # A footnote needs its closing bracket. + assert hits("An open bracket [^ and nothing closing it.\n") == [] + assert hits("An empty label [^] is not a footnote either.\n") == [] + assert hits("A reference[^one] and its definition.\n\n[^one]: The text.\n") == [ + "1:footnote", + "3:footnote", + ] + # A `<` that opens no valid tag is prose, and a bare tag name is not markup. + assert hits("Compare a < b, and 3<4, and <-- an arrow.\n") == [] + + +# --- family: the YAML frontmatter boundary ------------------------------------ + + +def test_family_frontmatter_boundary() -> None: + assert ( + hits(lines("---", "title: A value with <div> and [^ref] and $$x$$", "---", "", "# Doc", "")) + == [] + ) + # A `---` later in a document is a thematic break, so the text after it is scanned + # like any other prose. + assert hits(lines("# Doc", "", "---", "", "Prose with <div> in it.", "")) == ["5:raw-html"] + # A block that never closes is not frontmatter, so its content is prose — and + # reported, which is the honest read of a document nothing will strip. + assert hits(lines("---", "title: <div>", "", "# Doc", "")) == ["2:raw-html"] + # Frontmatter opens the FILE or it is not frontmatter: a block one line down is a + # thematic break followed by prose. + assert hits(lines("", "---", "title: <div>", "---", "")) == ["3:raw-html"] + + +# --- family: overlaps and same-line ordering ---------------------------------- + + +def test_family_overlaps_and_same_line_ordering() -> None: + # Three constructs on one line, reported in the closed set's alphabetical order — + # the tie-breaker that keeps a same-line group deterministic across the SDKs. + assert hits("All three: [^b], <i>italic</i>, and $$x + y$$ in one sentence.\n") == [ + "1:footnote", + "1:math-block", + "1:raw-html", + ] + # A footnote-looking label inside a tag's attribute belongs to the tag: the + # earliest-starting match consumes it, so the line reports raw HTML only. + assert hits('<span title="[^ref]">text</span>\n') == ["1:raw-html"] + # And the other way round: a tag inside a math pair belongs to the pair. + assert hits("$$ a <b> c $$\n") == ["1:math-block"] + # A math pair spanning lines is attributed to its opening line, and the constructs + # between the delimiters are inside it. + assert hits(lines("$$", "a <b> c [^ref]", "$$", "", "<span>x</span>")) == [ + "1:math-block", + "5:raw-html", + ] + # Block structure outranks the inline pair, as a renderer reads it: a line OPENING + # with a block tag is an HTML block running to the blank line, so the second + # delimiter is inside it and the first never pairs. + assert hits(lines("$$", "<div> [^ref]", "$$", "", "<span>x</span>")) == [ + "2:raw-html", + "5:raw-html", + ] + # Repeats on one line collapse; the same construct on the next line does not. + assert hits("[^a] and [^b] together.\n[^c] alone.\n") == ["1:footnote", "2:footnote"] + + +# --- family: backslash escapes ------------------------------------------------ + + +def test_family_backslash_escapes() -> None: + assert hits("Escaped: \\<div> and \\<b>bold\\</b> are prose.\n") == [] + assert hits("Escaped: \\[^one] in a sentence.\n\n\\[^one]: not a definition.\n") == [] + assert hits("Escaped math: \\$\\$ a^2 \\$\\$ is prose about the notation.\n") == [] + # An HTML entity spells a character, not an element. + assert hits("Entities: <div> and &lt; are text.\n") == [] + # Positive controls for each escape above. + assert hits("Unescaped: <div> here.\n") == ["1:raw-html"] + assert hits("Unescaped: [^one] here.\n") == ["1:footnote"] + assert hits("Unescaped: $$ a^2 $$ here.\n") == ["1:math-block"] + # A backslash before a non-punctuation character is a literal backslash, so the + # construct after it still reports. + assert hits("A backslash \\n then <div>.\n") == ["1:raw-html"] + + +# --- family: the HTML block forms that end mid-line --------------------------- + + +def test_family_block_forms_ending_mid_line() -> None: + # CommonMark type 3: the block ends on the line carrying `?>`, and the WHOLE of + # that line belongs to it — so what follows the terminator there is block content + # rather than a second construct, and the block reports once, at its opening line. + assert hits(lines("<?php", "[^inside]", "?> [^after]")) == ["1:raw-html"] + # Type 4 (a declaration) ends at the first `>`, type 5 (CDATA) at `]]>`; what + # follows the block, on a later line, is scanned normally. + assert hits(lines("<!DOCTYPE html>", "", "[^after]")) == ["1:raw-html", "3:footnote"] + assert hits(lines("<![CDATA[", "[^x]", "]]> [^after]", "", "prose [^real]")) == [ + "1:raw-html", + "5:footnote", + ] + # A block whose terminator never arrives runs to the end of the document, exactly + # as the comment form does. + assert hits(lines("<?php", "[^inside]")) == ["1:raw-html"] + # Negatives. The same forms mid-line are INLINE raw HTML, so the line's remainder + # is still scanned; an escaped opener is prose; one inside a fence is code. + assert hits("Prose <?php echo 1; ?> and [^ref].\n") == ["1:footnote", "1:raw-html"] + assert hits("Escaped \\<?php ?> here.\n") == [] + assert hits(lines("```", "<?php ?>", "```", "[^after]")) == ["4:footnote"] + + +# --- family: inline state never crosses a block boundary ---------------------- + + +def test_family_inline_state_never_bridges_a_block_region() -> None: + # The candidate closer lies beyond a block region, which ended the paragraph the + # run opened in: the backticks are literal at that boundary, so the footnote after + # the region is reported rather than swallowed. + assert hits(lines("Text `open", "<!-- comment -->", "[^after] and a closer `here")) == [ + "3:footnote" + ] + # The same for a `$$` whose apparent mate sits on the far side of the region: an + # unpaired delimiter is prose, and what follows it still reports. + assert hits(lines("$$ open", "<!-- comment -->", "$$ and [^after]")) == ["3:footnote"] + # Positive controls: inside ONE block, both forms still span lines. + assert hits(lines("A span `over", "two lines` and [^after]")) == ["2:footnote"] + assert hits(lines("$$", "a^2 + b^2", "$$")) == ["1:math-block"] + + +# --- family: a mate inside an excluded span, and straddling delimiters --------- + + +def test_family_mate_inside_an_excluded_span() -> None: + # The apparent closer is inside an excluded region, so the open never pairs and the + # line is prose about the notation. + assert hits("$$ open `$$` tail\n") == [] + assert hits("$$ open <!-- $$ --> tail\n") == [] + # Positive controls: a readable mate pairs, and a real pair after an excluded one + # is still found. + assert hits("$$ open $$ tail\n") == ["1:math-block"] + assert hits("`$$` and then a real pair $$x$$\n") == ["1:math-block"] + # Straddling a span's edge, both ways: a footnote whose closing bracket is inside a + # code span still reports — the earliest start wins the overlap — while one that + # OPENS inside the span is span content. + assert hits("[^one `] and text`\n") == ["1:footnote"] + assert hits("`[^one` ] tail\n") == [] + + +# --- family: declaration case, split terminators, and indented openers -------- + + +def test_family_declaration_case_and_terminators() -> None: + # `<!` plus an ASCII letter of EITHER case is a declaration, at block and inline + # positions alike — the rendering the vendored renderer actually produces, and + # CommonMark's own character class. A block one runs to the next `>`, so what sits + # inside the consumed span and what trails the terminator on its line are block + # content rather than constructs of their own. + assert hits(lines("<!foo", "[^inside]", "<!DOCTYPE html> [^tail]", "", "[^after]")) == [ + "1:raw-html", + "5:footnote", + ] + # Unterminated, the block runs to the end of the document, as the comment form does. + assert hits(lines("<!foo", "[^after]")) == ["1:raw-html"] + assert hits("Prose <!foo bar> and [^ref].\n") == ["1:footnote", "1:raw-html"] + # The uppercase spellings, block form and inline form: identical treatment, so the + # assertions above are the grammar and not a case accident. + assert hits(lines("<!DOCTYPE html>", "", "[^after]")) == ["1:raw-html", "3:footnote"] + assert hits('Prose <!ENTITY x "y"> and [^ref].\n') == ["1:footnote", "1:raw-html"] + # A terminator split across two lines is not a terminator: the CDATA block runs on + # to the contiguous `]]>`, and that whole line is block content. + assert hits( + lines("<![CDATA[", "data ]]", "> still inside [^no]", "]]> [^after]", "", "[^real]") + ) == ["1:raw-html", "6:footnote"] + # Indentation decides whether a line opens a block at all: a tab is one indent + # character, so a tab-indented opener still opens one, terminator line included. + assert hits(lines("\t<?php", "[^inside]", "\t?> [^after]", "", "[^real]")) == [ + "1:raw-html", + "5:footnote", + ] + # Four leading spaces are indented code, which opens no block: an unterminated + # opener there swallows nothing, and the line after it still reports. + assert hits(lines(" <?php", "[^after]")) == ["2:footnote"] + + +# --- the finding shape -------------------------------------------------------- + + +def test_findings_carry_the_closed_token_and_the_opening_line() -> None: + findings = render_lint_findings("docs/render/raw-html.md", "# Doc\n\nProse with <div> in it.\n") + assert len(findings) == 1 + f = findings[0] + assert f.rule == RENDER_UNSUPPORTED_RULE + assert f.severity == "warning" + assert f.path == "docs/render/raw-html.md" + assert f.line == 3 + assert f.construct == RAW_HTML + assert f.message == ( + "`raw-html` is outside the supported rendering subset; see adoption/rendering.md" + ) + # The finding's JSON shape carries the two located keys between path and message, + # exactly where the Node and Go documents carry them. + assert list(f.to_dict()) == ["rule", "severity", "path", "line", "construct", "message"] diff --git a/packages/sdk-py/tests/test_route_key.py b/packages/sdk-py/tests/test_route_key.py index 2cf590c..520cc53 100644 --- a/packages/sdk-py/tests/test_route_key.py +++ b/packages/sdk-py/tests/test_route_key.py @@ -8,7 +8,7 @@ """ from leji.init_cmd import pick_docs_root -from leji.viewer_cmd import _url_path_to_rel +from leji.serve_cmd import _url_path_to_rel def test_url_path_to_rel_is_separator_agnostic() -> None: diff --git a/packages/sdk-py/tests/test_source_audit.py b/packages/sdk-py/tests/test_source_audit.py new file mode 100644 index 0000000..febd665 --- /dev/null +++ b/packages/sdk-py/tests/test_source_audit.py @@ -0,0 +1,738 @@ +"""The acceptance check for the write boundary: no production source file of this SDK +reaches a raw filesystem mutation, or a subprocess that could perform one, except at a +symbol named below. Every other write goes through ``leji/fsx.py`` — the chokepoint and +its guarded conveniences — so a new write site is contained by construction rather than +by remembering to contain it, and a reviewer can read the exceptions instead of +re-deriving them. ``docs/practice/trust-boundary.md`` mirrors both lists. + +The scan is an ``ast`` walk with IMPORT-ALIAS RESOLUTION: every call is asked what it +actually calls, so a mutator is recognized by the module function it lands on rather +than by how the call was spelled. ``os.rename(p, q)``, ``import os as o; o.rename(...)``, +``from os import rename``, ``from os import rename as mv``, ``from shutil import rmtree +as nuke``, and ``getattr(os, "rename")`` all resolve to the same ``os.rename``. On top of +that, the unmistakable ``pathlib`` mutator METHODS are matched on the attribute name +whatever the receiver, since Python offers no type resolution here and those names +belong to no other API this repository uses. + +Mirrors packages/sdk/test/source-audit.test.ts. +""" + +from __future__ import annotations + +import ast +import threading +from dataclasses import dataclass +from pathlib import Path + +SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "leji" + +# --- the mutation surface ------------------------------------------------------ + +#: ``os`` functions that create, destroy, move, or re-permission something on disk, +#: plus the descriptor writes: bytes reaching a file through an fd are a mutation as +#: much as bytes reaching it through a path, and the descriptor copy the export runs +#: is an allowed exception BY SYMBOL rather than an unwatched one. +OS_MUTATORS = frozenset( + { + "chmod", + "chown", + "fchmod", + "fchown", + "lchmod", + "lchown", + "link", + "makedirs", + "mkdir", + "mkfifo", + "mknod", + "remove", + "removedirs", + "rename", + "renames", + "replace", + "rmdir", + "symlink", + "truncate", + "ftruncate", + "unlink", + "utime", + "write", + "writev", + "pwrite", + } +) + +#: ``shutil`` copies, moves, and recursive deletes. +SHUTIL_MUTATORS = frozenset( + { + "copy", + "copy2", + "copyfile", + "copymode", + "copystat", + "copytree", + "make_archive", + "move", + "rmtree", + "unpack_archive", + } +) + +#: ``tempfile`` entry points that materialize something on a filesystem. +TEMPFILE_MUTATORS = frozenset( + {"NamedTemporaryFile", "TemporaryDirectory", "TemporaryFile", "mkdtemp", "mkstemp"} +) + +#: Anything that hands work to another program, which can then write whatever it likes. +SUBPROCESS_SPAWNERS = frozenset( + {"Popen", "call", "check_call", "check_output", "getoutput", "getstatusoutput", "run"} +) + +#: ``os``'s own process launchers, the same class as ``subprocess``. +OS_SPAWNERS = frozenset( + { + "execl", + "execle", + "execlp", + "execv", + "execve", + "execvp", + "execvpe", + "fork", + "forkpty", + "popen", + "posix_spawn", + "posix_spawnp", + "spawnl", + "spawnv", + "spawnve", + "system", + } +) + +#: ``pathlib`` mutator methods, matched on the attribute name whatever the receiver: +#: no other API this repository uses carries these names, so an indirect receiver +#: (``p = Path(x); p.write_text(...)``) is caught along with the direct one. +PATH_MUTATOR_METHODS = frozenset( + { + "chmod", + "hardlink_to", + "lchmod", + "mkdir", + "rename", + "rmdir", + "symlink_to", + "touch", + "unlink", + "write_bytes", + "write_text", + } +) + +#: ``Path.replace(target)`` takes ONE argument; ``str.replace(old, new)`` takes at +#: least two. The arity is what tells the path mutator from the string method, which +#: is why this one name is judged separately from the set above. +PATH_REPLACE = "replace" + +MODULE_MUTATORS = { + "os": OS_MUTATORS, + "os.path": frozenset(), + "shutil": SHUTIL_MUTATORS, + "tempfile": TEMPFILE_MUTATORS, +} +MODULE_SPAWNERS = {"subprocess": SUBPROCESS_SPAWNERS, "os": OS_SPAWNERS} + +#: Read-only ``open()`` modes. Any other mode creates, truncates, or appends; an +#: absent mode is Python's default ``"r"``, which is read-only. +READ_ONLY_OPEN_MODES = frozenset({"r", "rb", "rt", "br", "tr"}) + +#: The three spellings of "open a file": the builtin, ``io.open`` (the very same +#: function under another name), and ``Path.open``. All three create or truncate under +#: a write mode, so all three are judged by the mode rather than by the name. The mode +#: sits at a different argument position for the method form, which takes no path. +BUILTIN_OPEN_MODE_INDEX = 1 +PATH_OPEN_MODE_INDEX = 0 + +# --- the allow-lists ----------------------------------------------------------- + +#: The write allow-list, by ``file#symbol`` — never by whole module, so a future raw +#: mutation elsewhere in an allowed file still fails. An entry that matches nothing +#: fails too: a stale exception is an exception nobody is checking. The symbol is the +#: dotted name of the enclosing function (a lambda is transparent, belonging to the +#: function that owns it). +ALLOWED_WRITES: dict[str, str] = { + "fsx.py#write_file_guarded.op": "the chokepoint itself: the guarded write, judged before it acts", + "fsx.py#mkdirp_guarded": "the chokepoint itself: the guarded directory establishment", + "fsx.py#rm_guarded.op": "the chokepoint itself: the guarded clear", + "fsx.py#rename_guarded": "the chokepoint itself: the guarded rename, both ends judged", + "fsx.py#chmod_guarded": "the chokepoint itself: the guarded mode change", + "fsx.py#open_write_guarded": "the chokepoint itself: the guarded destination descriptor", + "fsx.py#write_file_atomic_guarded": "the chokepoint itself: temp sibling plus rename, both ends judged", + "export_cmd.py#_copy_from_descriptor": ( + "writes into the descriptor open_write_guarded returned, never to a path" + ), + "mounts.py#extract_projection": "mounts per-entry protocol, under a store root the chokepoint established", + "mounts.py#_publish_cache_entry": "mounts per-entry protocol: sidecar, marker, publish-by-rename, staging clear", + "mounts.py#hydrate_mounts": "mounts per-entry protocol: clears its own established staging directory", + "mounts.py#verify_projection": "verification staging under the OS temp directory, outside the repository", + "init_cmd.py#init_layer": "root bootstrap: creates the selected root before any repository root exists", + "init_cmd.py#adopt_layer": "root bootstrap: creates the selected root before any repository root exists", +} + +#: The subprocess allow-list. A child process is outside every guard this SDK can +#: enforce, so each caller is named with what it runs and what it may write. +ALLOWED_SUBPROCESSES: dict[str, str] = { + "gitutil.py#_git": "read-only git queries (log, ls-files, status) in the host repository", + "mounts.py#run_git": ( + "the federation resolver: git init/fetch write ONLY into a store or cache root the " + "chokepoint established, plus read-only queries" + ), + "init_cmd.py#_git_config": "read-only `git config --get`", + "init_cmd.py#_hooks_path_config": "read-only `git -C root config core.hooksPath`", + "init_cmd.py#_git_hooks_dir": "read-only `git rev-parse --git-path hooks`", + "init_cmd.py#_git_dirs": "read-only `git rev-parse --git-dir --git-common-dir`", + "init_cmd.py#_capture_run": ( + "the handoff IO's bounded probe: asks for a version with argv only, cwd-pinned to the " + "repository root, stdin closed, stderr discarded, output and wall time capped while the " + "child runs (reads are unbuffered and sized to what the cap still allows, so passing the " + "cap terminates the child promptly, whole session and all), and an environment that " + "REPLACES this process's rather than extending it; it never invokes a package manager's " + "script runner, and it writes nothing" + ), + "init_cmd.py#_default_handoff_io.launch": ( + "the handoff IO: launches the agent host the user chose; its writes are that program, " + "not this SDK" + ), + "dependency.py#default_dependency_io.run": ( + "the declaration offer: runs the repository's OWN package manager add command, argv only " + "and never a shell, and only after the user says yes at init/adopt; its writes are that " + "manager's (manifest and lockfile), not this SDK's" + ), + "init_cmd.py#_default_handoff_io.run": ( + "the handoff IO: runs the host command the user chose, same reasoning as `launch`" + ), + "serve_cmd.py#open_browser": "opens the preview URL in the desktop browser; writes nothing", + "localcli.py#_exec_replace": ( + "the hand-off: the installed console script REPLACES itself with the repository's OWN " + "pinned Leji CLI, chosen only when the repository directly declares it and the copy is " + "installed in the project environment inside the repository with its distribution " + "identity verified and meeting the layer's minimum; argv, never a shell, and the target " + "inherits this terminal and environment because it IS this invocation, so its writes are " + "that copy's rather than this one's" + ), + "localcli.py#_run_child": ( + "the same hand-off on Windows, where a process cannot replace itself: the identical " + "target, run as a child so its return code can be re-raised as this process's exit" + ), +} + + +# --- the analyzer --------------------------------------------------------------- + + +@dataclass(frozen=True) +class Hit: + key: str + line: int + name: str + + +class _Analyzer(ast.NodeVisitor): + """One module's filesystem mutations and subprocess launches. + + Import aliases are resolved first, so the binding a call was reached through does + not matter: ``modules`` maps a local name to the module it aliases (``import os as + o`` -> ``{"o": "os"}``), and ``symbols`` maps a local name to the SET of + ``module.function`` values it may hold (``from os import rename as mv`` -> + ``{"mv": {"os.rename"}}``). A set rather than one value because a name can be bound + more than once — ``x = io.open`` then ``x = os.rename`` — and a static reader cannot + know which binding a call reaches; every possibility is therefore judged, so the + audit over-approximates which names are dangerous and never under-approximates.""" + + def __init__(self, rel: str, tree: ast.AST) -> None: + self.rel = rel + self.writes: list[Hit] = [] + self.subprocesses: list[Hit] = [] + self.modules: dict[str, str] = {} + self.symbols: dict[str, set[str]] = {} + self.scope: list[str] = [] + self._collect_imports(tree) + self._collect_assignments(tree) + + # -- imports --------------------------------------------------------------- + + def _collect_imports(self, tree: ast.AST) -> None: + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + self.modules[alias.asname or alias.name.split(".")[0]] = alias.name + elif isinstance(node, ast.ImportFrom) and node.module is not None and node.level == 0: + for alias in node.names: + local = alias.asname or alias.name + if alias.name in MODULE_MUTATORS or alias.name in MODULE_SPAWNERS: + self.modules[local] = alias.name # `from os import path`-style + self.symbols.setdefault(local, set()).add(f"{node.module}.{alias.name}") + + def _collect_assignments(self, tree: ast.AST) -> None: + """Rebind a watched function to a local name and the call site names neither + the module nor the function: ``mv = os.rename`` then ``mv(a, b)``. Every simple + assignment whose value resolves to a module function is ADDED to the set the + same symbol table an import would populate holds for that name, so the call + resolves identically — and a name assigned twice carries both possibilities, + because which one a call reaches is a runtime fact this reader does not have. + + Run to a TRUE fixpoint — passes until one of them changes nothing — because a + chain (``a = os.rename`` then ``b = a``) resolves one link per pass and + ``ast.walk`` is not source order, so a chain assembled bottom-up needs as many + passes as it has links. No pass cap: a cap silently stops resolving every chain + longer than itself, which is an under-approximation nobody sees. + + Termination rests on the table being MONOTONE, and a monotone table over a + finite set of names cannot loop forever: every set only GROWS, over a universe + of qualified names the module's own imports and attributes already bound, so a + pass can never undo an earlier one and "no change" is both reachable and final. + Neither of the two tempting alternatives works. Last-write-wins does not + terminate at all — two assignments of different values to one name flip it back + and forth and ``changed`` never goes false. Binding a name once (with an upgrade + to a watched value) terminates but UNDER-approximates: it drops the second + watched binding of ``x = io.open`` then ``x = os.rename``, and the call + ``x(a, "r")`` then reads as a read-only open. The table is module-wide rather + than per-scope for the same reason the value is a set: an audit may + over-approximate which names are dangerous, never under-approximate.""" + changed = True + while changed: + changed = False + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + targets: list[ast.expr] = list(node.targets) + value: ast.expr | None = node.value + elif isinstance(node, ast.AnnAssign): + targets, value = [node.target], node.value + else: + continue + qualified = set() if value is None else self._resolutions(value) + if not qualified: + continue + for target in targets: + if not isinstance(target, ast.Name): + continue + known = self.symbols.setdefault(target.id, set()) + if not qualified <= known: + known |= qualified + changed = True + + # -- 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) + + @property + def _key(self) -> str: + return f"{self.rel}#{'.'.join(self.scope) or '(top level)'}" + + def _record(self, into: list[Hit], node: ast.AST, name: str) -> None: + into.append(Hit(key=self._key, line=getattr(node, "lineno", 0), name=name)) + + # -- calls ----------------------------------------------------------------- + + def _resolutions(self, func: ast.expr) -> set[str]: + """Every ``module.function`` a callee may name, following import aliases and + rebinding; empty when the callee is not a module-level function this audit + tracks. More than one member means the name was bound more than once, and each + member is judged on its own — the call is whichever of them runs.""" + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): + module = self.modules.get(func.value.id) + return set() if module is None else {f"{module}.{func.attr}"} + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Attribute): + return {f"{inner}.{func.attr}" for inner in self._resolutions(func.value)} + if isinstance(func, ast.Name): + return set(self.symbols.get(func.id, ())) + return set() + + def _flag_qualified(self, node: ast.Call, qualified: str) -> None: + """Record the call under whichever list this qualified name belongs to. Both + lists are consulted, never one or the other: a name holding both ``os.rename`` + and ``subprocess.run`` is a write hit AND a subprocess hit, so neither the write + allow-list nor the subprocess allow-list can be slipped past by rebinding.""" + module, _, name = qualified.rpartition(".") + if name in MODULE_MUTATORS.get(module, frozenset()): + self._record(self.writes, node, qualified) + if name in MODULE_SPAWNERS.get(module, frozenset()): + self._record(self.subprocesses, node, qualified) + + def _judge_call(self, node: ast.Call, qualified: str) -> None: + """One possibility for what this call lands on. The mode judgement belongs to + the open family alone; any other watched member is a hit whatever the arguments + look like, since the arguments were written for the binding the author had in + mind, not for the one this member names.""" + if qualified == "os.open": + if not _read_only_os_open(node): + self._record(self.writes, node, "os.open") + elif qualified in ("io.open", "builtins.open"): + # `io.open` IS the builtin, reached through the module that defines it. + if not _read_only_open(node, BUILTIN_OPEN_MODE_INDEX): + self._record(self.writes, node, qualified) + else: + self._flag_qualified(node, qualified) + + def visit_Call(self, node: ast.Call) -> None: + for qualified in sorted(self._resolutions(node.func)): + self._judge_call(node, qualified) + # `getattr(os, "rename")` reaches a mutator without ever naming it as a call: + # the module and the attribute are both there to read, so it is resolved the + # same way rather than left as a residual. + if isinstance(node.func, ast.Name) and node.func.id == "getattr" and len(node.args) >= 2: + target, attr = node.args[0], node.args[1] + if isinstance(target, ast.Name) and isinstance(attr, ast.Constant): + module = self.modules.get(target.id) + if module is not None and isinstance(attr.value, str): + self._flag_qualified(node, f"{module}.{attr.value}") + # The builtin `open`, unless the mode is provably read-only. + if ( + isinstance(node.func, ast.Name) + and node.func.id == "open" + and "open" not in self.symbols + ): + if not _read_only_open(node, BUILTIN_OPEN_MODE_INDEX): + self._record(self.writes, node, "open") + # The pathlib mutator methods, whatever the receiver. + if isinstance(node.func, ast.Attribute) and not self._resolutions(node.func): + attr = node.func.attr + if attr in PATH_MUTATOR_METHODS: + self._record(self.writes, node, f"Path.{attr}") + elif attr == PATH_REPLACE and len(node.args) == 1 and not node.keywords: + self._record(self.writes, node, "Path.replace") + elif attr == "open" and not _read_only_open(node, PATH_OPEN_MODE_INDEX): + # `Path.open` takes no path, so its mode is the FIRST argument. + self._record(self.writes, node, "Path.open") + self.generic_visit(node) + + +def _read_only_open(node: ast.Call, mode_index: int) -> bool: + """``open(p)`` and ``open(p, "r")`` read; every other mode creates, truncates or + appends, and a non-literal mode cannot be proven read-only. ``mode_index`` is where + the mode sits positionally: after the path for the builtin and ``io.open``, first + for ``Path.open``, which is already bound to its path.""" + mode: ast.expr | None = node.args[mode_index] if len(node.args) > mode_index else None + for keyword in node.keywords: + if keyword.arg == "mode": + mode = keyword.value + if mode is None: + return True + return isinstance(mode, ast.Constant) and mode.value in READ_ONLY_OPEN_MODES + + +def _read_only_os_open(node: ast.Call) -> bool: + """``os.open`` reads only when its flags are literally ``os.O_RDONLY``; anything + else — a creating or truncating flag set, or flags this audit cannot read — is a + mutation.""" + flags: ast.expr | None = node.args[1] if len(node.args) > 1 else None + for keyword in node.keywords: + if keyword.arg == "flags": + flags = keyword.value + return ( + isinstance(flags, ast.Attribute) + and flags.attr == "O_RDONLY" + and isinstance(flags.value, ast.Name) + ) + + +def _analyze(rel: str, source: str) -> tuple[list[Hit], list[Hit]]: + analyzer = _Analyzer(rel, ast.parse(source)) + analyzer.visit(ast.parse(source)) + return analyzer.writes, analyzer.subprocesses + + +def _audit_tree() -> tuple[list[Hit], list[Hit]]: + """Every production source file of this SDK (there are no tests under ``src/``).""" + files = sorted(SRC_DIR.rglob("*.py")) + assert files, "the audit loaded no source files" + writes: list[Hit] = [] + subprocesses: list[Hit] = [] + for path in files: + rel = path.relative_to(SRC_DIR).as_posix() + found = _analyze(rel, path.read_text(encoding="utf-8")) + writes.extend(found[0]) + subprocesses.extend(found[1]) + return writes, subprocesses + + +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] + + +# --- the acceptance checks ------------------------------------------------------- + + +def test_no_production_source_reaches_a_raw_filesystem_mutation() -> None: + writes, _ = _audit_tree() + outside = _unexpected(writes, ALLOWED_WRITES) + assert outside == [], ( + f"raw filesystem mutations outside the chokepoint: {', '.join(outside)}\n" + "Route the write through leji/fsx.py (write_file_guarded, mkdirp_guarded, rm_guarded, " + "rename_guarded, chmod_guarded, open_write_guarded, write_file_atomic_guarded), or argue " + "the exception into the allow-list." + ) + dead = _stale(writes, ALLOWED_WRITES) + assert dead == [], f"write allow-list entries matching no symbol (delete them): {dead}" + + +def test_every_subprocess_call_is_a_named_reasoned_exception() -> None: + _, subprocesses = _audit_tree() + outside = _unexpected(subprocesses, ALLOWED_SUBPROCESSES) + assert outside == [], ( + f"subprocess calls outside the allow-list: {', '.join(outside)}\n" + "A child process writes wherever it likes; name the caller and say what it runs and what " + "it may write." + ) + dead = _stale(subprocesses, ALLOWED_SUBPROCESSES) + assert dead == [], f"subprocess allow-list entries matching no symbol (delete them): {dead}" + + +# --- the laundering corpus, permanent --------------------------------------------- + +# Each probe below is a way of reaching a mutator that a naive scanner misses; they are +# fed to the same analyzer as production source, so the audit's reach is asserted rather +# than assumed. The negatives at the end are the controls: ordinary calls that share +# their names, or their shape, with the laundering above and must never be flagged. + + +def _reverse_alias_chain(links: int, root: str) -> str: + """A module that aliases ``root`` ``links`` deep, one builder function per link, the + builders DEFINED in REVERSE dependency order: the assignment consuming a link is + walked before the assignment producing it. A pass of the collector can therefore + resolve exactly one more link, so the chain costs one pass per link and any cap stops + resolving a chain longer than itself — while the module stays ordinary, runnable + Python (run the builders in order and ``_l<links>`` IS ``root``), so a miss here is a + real miss rather than an artifact of source a program could never execute.""" + lines = ["import os"] + lines += [f"def _s{i}():\n global _l{i}\n _l{i} = _l{i - 1}" for i in range(links, 1, -1)] + lines.append(f"def _s1():\n global _l1\n _l1 = {root}") + lines.append(f"def reached(a, b):\n _l{links}(a, b)") + return "\n".join(lines) + "\n" + + +#: Long enough that no plausible cap resolves it, and far past the eight passes that +#: once bounded the loop. +CHAIN_LINKS = 14 + +PROBES: list[tuple[str, str, bool]] = [ + ( + "aliased module", + "import os as o\ndef launder(p):\n o.rename(p, p + '.bak')\n", + True, + ), + ( + "destructured import", + "from shutil import rmtree\ndef launder(p):\n rmtree(p)\n", + True, + ), + ( + "renamed destructured import", + "from os import rename as mv\ndef launder(p, q):\n mv(p, q)\n", + True, + ), + ( + "getattr indirection", + "import os\ndef launder(p, q):\n getattr(os, 'rename')(p, q)\n", + True, + ), + ( + "an indirect pathlib receiver", + "def launder(p):\n target = p / 'x'\n target.write_text('x')\n", + True, + ), + ( + "open in write mode", + "def launder(p):\n with open(p, 'w') as f:\n f.write('x')\n", + True, + ), + ( + "os.open with creating flags", + "import os\ndef launder(p):\n os.open(p, os.O_WRONLY | os.O_CREAT)\n", + True, + ), + ( + "a renamed subprocess import", + "from subprocess import run as go\ndef launder():\n go(['rm', '-rf', '/'])\n", + True, + ), + ( + "a descriptor write", + "import os\ndef launder(fd, data):\n os.write(fd, data)\n", + True, + ), + ( + "Path.open in write mode", + "def launder(p):\n with p.open('w') as f:\n f.write('x')\n", + True, + ), + ( + "io.open in write mode", + "import io\ndef launder(p):\n with io.open(p, 'w') as f:\n f.write('x')\n", + True, + ), + ( + "assignment laundering", + "import os\nmv = os.rename\ndef launder(a, b):\n mv(a, b)\n", + True, + ), + ( + "assignment laundering through a chain", + "import os\n_a = os.rmdir\n_b = _a\ndef launder(p):\n _b(p)\n", + True, + ), + ( + f"assignment laundering through a {CHAIN_LINKS}-link chain, defined in reverse", + _reverse_alias_chain(CHAIN_LINKS, "os.rename"), + True, + ), + ( + "negative: Path.open in read mode", + "def read(p):\n with p.open('r') as f:\n return f.read()\n", + False, + ), + ( + "negative: Path.open with no mode", + "def read(p):\n with p.open() as f:\n return f.read()\n", + False, + ), + ( + "negative: a read and a string replace", + "def read(p, line):\n with open(p) as f:\n return f.read().replace('a', 'b')\n", + False, + ), + ( + "negative: a read-only os.open and a dict copy", + "import os\ndef read(p, d):\n fd = os.open(p, os.O_RDONLY)\n return fd, d.copy()\n", + False, + ), + ( + f"negative: a {CHAIN_LINKS}-link chain ending in a function nobody watches", + _reverse_alias_chain(CHAIN_LINKS, "os.getcwd"), + False, + ), +] + + +def test_the_analyzer_sees_through_every_known_laundering() -> None: + for name, source, expected in PROBES: + writes, subprocesses = _analyze("__probe.py", source) + flagged = bool(writes) or bool(subprocesses) + assert flagged == expected, ( + f"probe {name!r}: expected {'a hit' if expected else 'no hit'}, " + f"got writes={[h.name for h in writes]} subprocesses={[h.name for h in subprocesses]}" + ) + + +# --- the resolver's termination, permanent ----------------------------------------- + +# A name REBOUND to a second value is where a resolver that merely overwrites its table +# stops converging: the two assignments hand the name back and forth, `changed` never +# goes false, and an uncapped loop spins forever. Growing a SET per name is what rules +# that out — and keeping every member is what stops the opposite failure, a resolver that +# binds a name once and so never sees the second watched function. These probes pin both, +# in both orders, across the open family and across the write/subprocess line. They are +# kept out of PROBES deliberately: a regression here HANGS rather than returns, so they +# need the wall-clock bound below instead of the shared corpus loop. +REBINDING_PROBES: list[tuple[str, str, list[str], list[str]]] = [ + ( + "rebinding, harmless binding first", + "import os\nx = os.path.join\nx = os.rename\ndef launder(p, q):\n x(p, q)\n", + ["os.rename"], + [], + ), + ( + "rebinding, watched binding first", + "import os\nx = os.rename\nx = os.path.join\ndef launder(p, q):\n x(p, q)\n", + ["os.rename"], + [], + ), + ( + # The arguments were written for the open; the rename is reached with the very + # same call, and a mode argument says nothing about it. + "rebinding from one watched function to another, under a read-only mode", + 'import io, os\nx = io.open\nx = os.rename\ndef launder(a):\n x(a, "r")\n', + ["os.rename"], + [], + ), + ( + "rebinding across the write/subprocess line", + "import os, subprocess\nx = os.rename\nx = subprocess.run\n" + "def launder(a, b):\n x(a, b)\n", + ["os.rename"], + ["subprocess.run"], + ), + ( + "negative: rebinding among functions nobody watches", + "import os\nx = os.path.join\nx = os.getcwd\ndef read():\n return x()\n", + [], + [], + ), +] + +#: Generous by three orders of magnitude — the whole file analyzes in well under a +#: second — because the bound exists to catch a resolver that never returns, not a slow +#: one. +RESOLVER_TIMEOUT_SECONDS = 5.0 + + +def _analyze_within(seconds: float, rel: str, source: str) -> tuple[list[Hit], list[Hit]] | None: + """``_analyze`` on a worker thread; None when it is still running after ``seconds``. + + A resolver that stops converging never leaves a single ``_analyze`` call, so the + only way to fail on it is a wall-clock bound: without one the regression hangs the + whole pytest run instead of failing one test. The worker is a daemon, so a spinning + one cannot hold the run open either.""" + found: list[tuple[list[Hit], list[Hit]]] = [] + failed: list[BaseException] = [] + + def run() -> None: + try: + found.append(_analyze(rel, source)) + except BaseException as error: # reported by the caller, never swallowed + failed.append(error) + + worker = threading.Thread(target=run, daemon=True) + worker.start() + worker.join(seconds) + if failed: + raise failed[0] + return found[0] if found else None + + +def test_the_assignment_resolver_keeps_every_binding_of_a_rebound_name() -> None: + for name, source, expected_writes, expected_subprocesses in REBINDING_PROBES: + found = _analyze_within(RESOLVER_TIMEOUT_SECONDS, "__probe.py", source) + assert found is not None, ( + f"probe {name!r}: the assignment resolver did not converge within " + f"{RESOLVER_TIMEOUT_SECONDS}s. A rebound name must not be able to flip the " + "symbol table back and forth: grow a set per name, so the table is monotone " + "and 'no change' is reachable." + ) + writes, subprocesses = found + assert [h.name for h in writes] == expected_writes, ( + f"probe {name!r}: expected writes {expected_writes}, " + f"got {[h.name for h in writes]}. Every binding a name may hold is judged; " + "dropping one is how a second watched function slips through." + ) + assert [h.name for h in subprocesses] == expected_subprocesses, ( + f"probe {name!r}: expected subprocesses {expected_subprocesses}, " + f"got {[h.name for h in subprocesses]}" + ) diff --git a/packages/sdk-py/tests/test_start_preflight.py b/packages/sdk-py/tests/test_start_preflight.py new file mode 100644 index 0000000..872eba3 --- /dev/null +++ b/packages/sdk-py/tests/test_start_preflight.py @@ -0,0 +1,232 @@ +"""`leji start` end to end, through the real command surface, mirroring +packages/sdk/test/proc/start.test.ts. + +Everything the command could reach outside the repository is a stub on a synthetic +PATH: the CLI it probes, the agent host binaries, and the host commands it would run. +Nothing real is launched or installed here, and the runs are non-interactive (pytest's +stdin is not a TTY), so no prompt can fire either.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +import pytest + +from leji.cli import main + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES = REPO_ROOT / "fixtures" / "start-preflight" + +VERSION_STUB = "echo 1.4.0" + + +def _git_bin() -> str: + """The real git binary, the one program these runs cannot stub: the hook check asks + git where hooks live.""" + found = shutil.which("git") + if found is None: + pytest.skip("git not available") + return found + + +def _stubs(tmp_path: Path, spec: dict[str, str], name: str = "stubs") -> str: + """A directory of executable stubs plus a link to the real git. It is the WHOLE + PATH of every run below, so what host detection finds is exactly what a case + declares and never whatever the machine running the suite has installed.""" + stub_dir = tmp_path / name + stub_dir.mkdir(exist_ok=True) + for bin_name, body in spec.items(): + path = stub_dir / bin_name + path.write_text(f"#!/bin/sh\n{body}\n") + path.chmod(0o755) + link = stub_dir / "git" + if not link.exists(): + link.symlink_to(_git_bin()) + return str(stub_dir) + + +def _fixture( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str, stubs: dict[str, str] +) -> str: + """One seeded joiner root from fixtures/start-preflight/, committed, with the + environment the case declares.""" + root = tmp_path / "repo" + shutil.copytree(FIXTURES / name, root) + # What the manager's own install would have produced for a Node repository that + # declares the CLI. The probe executes this file directly; no `npx`/`pnpm exec` stub + # exists, and none is needed. + pkg = root / "package.json" + if pkg.exists() and "@leji-org/leji" in pkg.read_text(): + bin_dir = root / "node_modules" / ".bin" + bin_dir.mkdir(parents=True) + shim = bin_dir / "leji" + shim.write_text(f"#!/bin/sh\n{VERSION_STUB}\n") + shim.chmod(0o755) + monkeypatch.setenv("PATH", _stubs(tmp_path, stubs)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir(exist_ok=True) + run = lambda *a: subprocess.run( # noqa: E731 + ["git", *a], cwd=str(root), check=True, capture_output=True + ) + run("init", "-q") + run("add", "-A") + run("-c", "user.email=t@e.com", "-c", "user.name=T", "commit", "-qm", "seed") + return str(root) + + +def _run(capsys, argv: list[str]) -> tuple[int, str, str]: + code = main(argv) + captured = capsys.readouterr() + return code, captured.out, captured.err + + +def _doc(out: str) -> dict: + parsed = json.loads(out) + assert isinstance(parsed, dict) + return parsed + + +def test_start_prints_the_setup_block_before_the_entry_instructions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture(tmp_path, monkeypatch, "node-declared", {"leji": VERSION_STUB}) + code, out, err = _run(capsys, ["start", "--root", root]) + assert code == 0, err + setup = out.index("Setup for this clone") + entry = out.index("No coding agent was launched.") + assert entry > setup, out + assert "Starting " not in out, "a non-interactive run never launches a host" + assert "\n ok Leji CLI 1.4.0 (node_modules/.bin/leji)\n" in out + assert "\n n/a MCP server no coding agent detected\n" in out + assert "\n you Git hook none yet (per clone)\n" in out + assert "\n $ leji ci --hooks\n" in out + assert "\n 1 fix for you. The agent starts either way.\n" in out + assert "\x1b" not in out, "a piped run carries no escape" + + +def test_start_undeclared_repository_reports_a_shared_gap_at_exit_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture(tmp_path, monkeypatch, "node-undeclared", {"leji": VERSION_STUB}) + code, out, err = _run(capsys, ["start", "--root", root]) + assert code == 0, err + assert "\n team Leji CLI not declared" in out + assert "\n $ npm i -D @leji-org/leji\n" in out + + +def test_start_json_is_one_report_only_document( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture( + tmp_path, + monkeypatch, + "node-declared", + {"leji": VERSION_STUB, "claude": "exit 1"}, + ) + code, out, err = _run(capsys, ["start", "--root", root, "--json"]) + assert code == 0, err + doc = _doc(out) + assert list(doc.keys()) == ["command", "ok", "ready", "checks", "ecosystem"] + assert (doc["command"], doc["ok"], doc["ready"]) == ("start", True, False) + assert [c["id"] for c in doc["checks"]] == ["cli", "mcp", "mcp-shared", "hook"] + for check in doc["checks"]: + assert list(check.keys()) == ["id", "status", "detail", "fix"] + assert [c["status"] for c in doc["checks"]] == ["ok", "missing", "shared-gap", "missing"] + assert doc["checks"][1]["fix"] == ["claude mcp add leji --scope user -- npx -y @leji-org/mcp"] + assert doc["ecosystem"]["selected"]["manager"] == "npm" + assert "Setup for this clone" not in out + + +def test_start_json_reports_ready_once_the_personal_gaps_are_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture( + tmp_path, + monkeypatch, + "node-mcp-json", + {"leji": VERSION_STUB, "claude": "exit 0"}, + ) + assert _run(capsys, ["ci", "--hooks", "--root", root])[0] == 0 + code, out, err = _run(capsys, ["start", "--root", root, "--json"]) + assert code == 0, err + doc = _doc(out) + assert doc["ready"] is True + assert [c["status"] for c in doc["checks"]] == ["ok", "ok", "ok", "ok"] + + +def test_start_json_several_hosts_and_no_agent_is_unresolved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture( + tmp_path, + monkeypatch, + "node-declared", + {"leji": VERSION_STUB, "claude": "exit 1", "codex": "exit 1"}, + ) + code, out, _err = _run(capsys, ["start", "--root", root, "--json"]) + assert code == 0 + doc = _doc(out) + mcp = next(c for c in doc["checks"] if c["id"] == "mcp") + assert mcp["status"] == "unresolved" + assert mcp["fix"] == ["leji start --agent <name>"] + assert next(c for c in doc["checks"] if c["id"] == "mcp-shared")["status"] == "n/a" + + +def test_start_json_agent_pins_the_host_the_mcp_rows_answer_for( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture( + tmp_path, + monkeypatch, + "node-declared", + {"leji": VERSION_STUB, "claude": "exit 1", "codex": "exit 1"}, + ) + code, out, _err = _run(capsys, ["start", "--root", root, "--agent", "claude-code", "--json"]) + assert code == 0 + doc = _doc(out) + assert next(c for c in doc["checks"] if c["id"] == "mcp")["status"] == "missing" + assert next(c for c in doc["checks"] if c["id"] == "mcp-shared")["status"] == "shared-gap" + + +def test_start_agent_bogus_json_is_a_usage_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture(tmp_path, monkeypatch, "node-declared", {"leji": VERSION_STUB}) + code, out, err = _run(capsys, ["start", "--root", root, "--agent", "bogus", "--json"]) + assert code == 2 + assert "--agent must be a launchable host" in err + assert out.strip() == "", "no document is emitted for a rejected argument" + + +def test_start_json_boot_missing_is_the_error_document( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = _fixture(tmp_path, monkeypatch, "node-declared", {"leji": VERSION_STUB}) + (Path(root) / "docs" / "boot-profile.md").unlink() + code, out, _err = _run(capsys, ["start", "--root", root, "--json"]) + assert code == 1 + doc = _doc(out) + assert list(doc.keys()) == ["command", "ok", "ready", "error", "checks", "ecosystem"] + assert (doc["ok"], doc["ready"], doc["error"], doc["checks"]) == ( + False, + False, + "boot-missing", + [], + ) + + +def test_start_no_manifest_is_the_findings_envelope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + root = tmp_path / "bare" + root.mkdir() + monkeypatch.setenv("PATH", _stubs(tmp_path, {})) + monkeypatch.setenv("HOME", str(tmp_path)) + code, out, _err = _run(capsys, ["start", "--root", str(root), "--json"]) + assert code == 1 + doc = _doc(out) + assert doc["command"] == "start" + assert doc["ok"] is False + assert isinstance(doc["findings"], list) diff --git a/packages/sdk-py/tests/test_units.py b/packages/sdk-py/tests/test_units.py index bd79473..947b272 100644 --- a/packages/sdk-py/tests/test_units.py +++ b/packages/sdk-py/tests/test_units.py @@ -1,8 +1,12 @@ """Unit tests mirroring packages/sdk/test/units.test.ts.""" +import http.client import json +import posixpath +import re import shutil import subprocess +from http.server import ThreadingHTTPServer from pathlib import Path import datetime as dt @@ -546,50 +550,61 @@ def test_viewer_generates_viewer_and_sidebar(tmp_path: Path) -> None: manifest = load_manifest(str(layer)).manifest result = generate_viewer(str(layer), manifest) assert result.written == [ - "docs/.leji/viewer/index.html", - "docs/.leji/viewer/_sidebar.md", - "docs/.leji/viewer/assets/docsify-copy-code.min.js", - "docs/.leji/viewer/assets/docsify-mermaid.js", - "docs/.leji/viewer/assets/docsify-sidebar-collapse.min.css", - "docs/.leji/viewer/assets/docsify-sidebar-collapse.min.js", - "docs/.leji/viewer/assets/docsify.min.js", - "docs/.leji/viewer/assets/fonts-licenses.txt", - "docs/.leji/viewer/assets/leji-logo.svg", - "docs/.leji/viewer/assets/mermaid.min.js", - "docs/.leji/viewer/assets/prism-bash.min.js", - "docs/.leji/viewer/assets/prism-json.min.js", - "docs/.leji/viewer/assets/prism-markdown.min.js", - "docs/.leji/viewer/assets/prism-typescript.min.js", - "docs/.leji/viewer/assets/roboto-mono-400-latin-ext.woff2", - "docs/.leji/viewer/assets/roboto-mono-400-latin.woff2", - "docs/.leji/viewer/assets/roboto-mono-400-vietnamese.woff2", - "docs/.leji/viewer/assets/search.min.js", - "docs/.leji/viewer/assets/source-sans-pro-300-latin-ext.woff2", - "docs/.leji/viewer/assets/source-sans-pro-300-latin.woff2", - "docs/.leji/viewer/assets/source-sans-pro-300-vietnamese.woff2", - "docs/.leji/viewer/assets/source-sans-pro-400-latin-ext.woff2", - "docs/.leji/viewer/assets/source-sans-pro-400-latin.woff2", - "docs/.leji/viewer/assets/source-sans-pro-400-vietnamese.woff2", - "docs/.leji/viewer/assets/source-sans-pro-600-latin-ext.woff2", - "docs/.leji/viewer/assets/source-sans-pro-600-latin.woff2", - "docs/.leji/viewer/assets/source-sans-pro-600-vietnamese.woff2", - "docs/.leji/viewer/assets/viewer-boot.js", - "docs/.leji/viewer/assets/vue.css", - "docs/.leji/viewer/assets/zoom-image.min.js", + ".leji/viewer/index.html", + ".leji/viewer/_sidebar.md", + ".leji/viewer/assets/docsify-copy-code.min.js", + ".leji/viewer/assets/docsify-mermaid.js", + ".leji/viewer/assets/docsify-sidebar-collapse.min.css", + ".leji/viewer/assets/docsify-sidebar-collapse.min.js", + ".leji/viewer/assets/docsify.min.js", + ".leji/viewer/assets/leji-logo.svg", + ".leji/viewer/assets/mermaid.min.js", + ".leji/viewer/assets/prism-bash.min.js", + ".leji/viewer/assets/prism-json.min.js", + ".leji/viewer/assets/prism-markdown.min.js", + ".leji/viewer/assets/prism-typescript.min.js", + ".leji/viewer/assets/roboto-mono-400-latin-ext.woff2", + ".leji/viewer/assets/roboto-mono-400-latin.woff2", + ".leji/viewer/assets/roboto-mono-400-vietnamese.woff2", + ".leji/viewer/assets/search.min.js", + ".leji/viewer/assets/source-sans-pro-300-latin-ext.woff2", + ".leji/viewer/assets/source-sans-pro-300-latin.woff2", + ".leji/viewer/assets/source-sans-pro-300-vietnamese.woff2", + ".leji/viewer/assets/source-sans-pro-400-latin-ext.woff2", + ".leji/viewer/assets/source-sans-pro-400-latin.woff2", + ".leji/viewer/assets/source-sans-pro-400-vietnamese.woff2", + ".leji/viewer/assets/source-sans-pro-600-latin-ext.woff2", + ".leji/viewer/assets/source-sans-pro-600-latin.woff2", + ".leji/viewer/assets/source-sans-pro-600-vietnamese.woff2", + ".leji/viewer/assets/third-party-licenses.txt", + ".leji/viewer/assets/viewer-boot.js", + ".leji/viewer/assets/vue.css", + ".leji/viewer/assets/zoom-image.min.js", "docs/overview.md", - "docs/.leji/viewer/_manifest.md", + ".leji/viewer/_manifest.md", ] - viewer = layer / "docs" / ".leji" / "viewer" + viewer = layer / ".leji" / "viewer" html = (viewer / "index.html").read_text() # The layer name is baked into the JSON config and the document title. assert "acme-billing-context" in html assert "<title>acme-billing-context" in html assert '"homepage":"overview.md"' in html # Default theming: the Leji mark (in the name HTML, served relative to the page - # so basePath does not break it) and the brand blue. + # so basePath does not break it) and the brand green, with the mermaid node-text + # color the SDK computed for it (dark, at 5.14:1 against the accent). assert "/assets/leji-logo.svg" in html - assert '"themeColor":"#223F93"' in html + assert '"themeColor":"#009F71"' in html + assert '"lejiMermaidTextColor":"#1a1a1a"' in html assert (viewer / "assets" / "leji-logo.svg").is_file() + # A configured accent is computed over too, not just the default: a dark accent + # flips the mermaid node text to white, end to end through the generator. + dark_layer = _copy(EXAMPLE, tmp_path / "dark") + dark_manifest = load_manifest(str(dark_layer)).manifest + dark_manifest["viewer"] = {"theme": {"primary": "#164E42"}} + generate_viewer(str(dark_layer), dark_manifest) + dark_html = (dark_layer / ".leji" / "viewer" / "index.html").read_text() + assert '"themeColor":"#164E42"' in dark_html + assert '"lejiMermaidTextColor":"#ffffff"' in dark_html # Mermaid is on by default: the two scripts + their assets are present. assert "assets/mermaid.min.js" in html assert "assets/docsify-mermaid.js" in html @@ -599,7 +614,11 @@ def test_viewer_generates_viewer_and_sidebar(tmp_path: Path) -> None: assert "viewer-boot.js" in html boot_js = (viewer / "assets" / "viewer-boot.js").read_text() assert "stripFrontmatter" in boot_js - assert "basePath: '/content/'" in boot_js + # The content mount is the SDK's value, carried in the config block; the boot + # script routes from it instead of hardcoding a root, which is what lets the + # export flavor be relative. + assert "basePath: lejiContentBase" in boot_js + assert '"basePath":"/content/"' in html, "the served flavor mounts content at the app root" # Vendored assets (core + theme + search/collapse plugins) are copied locally; # no remote CDN, PROVENANCE not shipped. assert (viewer / "assets" / "docsify.min.js").is_file() @@ -610,20 +629,20 @@ def test_viewer_generates_viewer_and_sidebar(tmp_path: Path) -> None: sidebar = (viewer / "_sidebar.md").read_text() assert sidebar == "\n".join( [ - "- [🤖 Boot profile](boot-profile.md)", - "- [📄 Manifest](_manifest.md)", + "- [🤖 Boot profile](/boot-profile.md)", + "- [📄 Manifest](/_manifest.md)", "", "---", "", "- **🤖 Agents**", - " - [Agent Core](agents/core.md)", - " - [Thought Partner (Codex)](agents/thought-partner.md)", + " - [Agent Core](/agents/core.md)", + " - [Thought Partner (Codex)](/agents/thought-partner.md)", "- **📖 Domain**", - " - [Glossary](domain/glossary.md)", + " - [Glossary](/domain/glossary.md)", "- **⚙️ System**", - " - [Invariants](system/invariants.md)", + " - [Invariants](/system/invariants.md)", "- **🧭 Decisions**", - " - [Adopt the Leji context layer](decisions/0001-adopt-leji.md)", + " - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md)", "", ] ) @@ -643,7 +662,7 @@ def test_viewer_brand_config(tmp_path: Path) -> None: "pins": ["docs/domain/glossary.md", "docs/nope.md"], } result = generate_viewer(str(layer), manifest) - viewer = layer / "docs" / ".leji" / "viewer" + viewer = layer / ".leji" / "viewer" html = (viewer / "index.html").read_text() # A relative logo path is served from the content mount; absolute/url is used as-is. assert "/content/assets/brand.svg" in html @@ -654,7 +673,7 @@ def test_viewer_brand_config(tmp_path: Path) -> None: sidebar = (viewer / "_sidebar.md").read_text() top = sidebar.split("---")[0] # The pinned page renders in the top zone. - assert "- [Glossary](domain/glossary.md)" in top + assert "- [Glossary](/domain/glossary.md)" in top # A missing pin is surfaced, not silently dropped. assert any(f.rule == "viewer-pin-missing" and f.path == "docs/nope.md" for f in result.findings) @@ -672,11 +691,11 @@ def test_viewer_path_forms_and_missing_homepage_warns(tmp_path: Path) -> None: } result = generate_viewer(str(layer), manifest) assert not any(f.rule == "viewer-path-missing" for f in result.findings) - html = (layer / "docs" / ".leji" / "viewer" / "index.html").read_text() + html = (layer / ".leji" / "viewer" / "index.html").read_text() assert '"homepage":"HOME.md"' in html assert "/content/HOME.md" in html - sidebar = (layer / "docs" / ".leji" / "viewer" / "_sidebar.md").read_text() - assert "](domain/glossary.md)" in sidebar.split("---")[0] + sidebar = (layer / ".leji" / "viewer" / "_sidebar.md").read_text() + assert "](/domain/glossary.md)" in sidebar.split("---")[0] # An unresolvable homepage is kept as authored and warned about, never silent. manifest["viewer"] = {"mermaid": False, "homepage": "docs/NOPE.md"} bad = generate_viewer(str(layer), manifest) @@ -693,9 +712,9 @@ def test_viewer_boot_pin_replaces_default_line(tmp_path: Path) -> None: "pins": [{"path": "docs/boot-profile.md", "label": "🚀 Start here"}], } generate_viewer(str(layer), manifest) - sidebar = (layer / "docs" / ".leji" / "viewer" / "_sidebar.md").read_text() + sidebar = (layer / ".leji" / "viewer" / "_sidebar.md").read_text() assert "🤖 Boot profile" not in sidebar - assert "- [🚀 Start here](boot-profile.md)" in sidebar + assert "- [🚀 Start here](/boot-profile.md)" in sidebar def test_viewer_build_sidebar_skips_out_of_root_boot_and_renders_plain_entries( @@ -723,7 +742,7 @@ def test_viewer_build_sidebar_skips_out_of_root_boot_and_renders_plain_entries( # The group label is the index-file H1, verbatim, bold; entries render as # plain links. assert "- **💰 Finance**" in sidebar - assert " - [Glossary](domain/glossary.md)" in sidebar + assert " - [Glossary](/domain/glossary.md)" in sidebar # No record badges in the sidebar: kind and date are page-chip metadata now. assert "lj-rec" not in sidebar assert "Empty group" not in sidebar @@ -784,7 +803,7 @@ def test_viewer_mermaid_disabled(tmp_path: Path) -> None: manifest = load_manifest(str(layer)).manifest manifest["viewer"] = {"mermaid": False} result = generate_viewer(str(layer), manifest) - viewer = layer / "docs" / ".leji" / "viewer" + viewer = layer / ".leji" / "viewer" html = (viewer / "index.html").read_text() assert "mermaid.min.js" not in html assert "docsify-mermaid.js" not in html @@ -801,7 +820,7 @@ def test_viewer_after_init(tmp_path: Path) -> None: manifest = load_manifest(str(tmp_path)).manifest result = generate_viewer(str(tmp_path), manifest) assert result.entries == 3 - assert (tmp_path / "docs" / ".leji" / "viewer" / "index.html").is_file() + assert (tmp_path / ".leji" / "viewer" / "index.html").is_file() def test_viewer_serve_localhost(tmp_path: Path) -> None: @@ -845,6 +864,338 @@ def status(path: str) -> int: server.shutdown() +# --- 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 +# two halves: Docsify's relativePath routing (so a link resolves against the +# document carrying it, exactly as the same file reads on disk) and generated +# sidebar destinations emitted app-root absolute (exempt from that resolution). +# These pin both halves, plus the click paths and the not-found contract. + + +def _write_under(layer: Path, rel: str, text: str) -> None: + """Write `rel` (forward-slashed, repo-relative) under `layer`, creating parents.""" + target = layer.joinpath(*rel.split("/")) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + +def _serve_on_free_port(layer: Path, root_rel: str) -> tuple[ThreadingHTTPServer, int]: + """Serve `layer`'s viewer on a free loopback port, returning the running server + and its port. The caller shuts the server down.""" + import threading + + from leji import serve_viewer + + server = serve_viewer(str(layer), 0, root_rel) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, server.server_address[1] + + +def _get(port: int, path: str) -> tuple[int, bytes]: + """Fetch `path` from the loopback viewer, returning the status and body bytes.""" + conn = http.client.HTTPConnection("127.0.0.1", port) + try: + conn.request("GET", path) + resp = conn.getresponse() + return resp.status, resp.read() + finally: + conn.close() + + +def test_viewer_every_sidebar_destination_is_app_root_absolute(tmp_path: Path) -> None: + layer = _copy(EXAMPLE, tmp_path) + # One layer carrying every sidebar entry class at once: a pinned boot profile, + # the always-pinned Manifest chrome, a user pin, grouped index entries, and + # documents nested two directories deep in both the governed and browse zones. + _write_under(layer, "docs/domain/billing/settlement/netting.md", "# Netting\n") + _write_under(layer, "docs/notes/team/onboarding/day-one.md", "# Day one\n") + manifest = load_manifest(str(layer)).manifest + manifest["viewer"] = {"pins": ["docs/boot-profile.md", "docs/domain/glossary.md"]} + result = generate_viewer(str(layer), manifest) + assert [f for f in result.findings if f.severity == "error"] == [] + sidebar = (layer / ".leji" / "viewer" / "_sidebar.md").read_text() + # Each class is present, so the sweep below is not vacuous. + for dest in ( + "/boot-profile.md", # the pinned boot profile + "/_manifest.md", # generated Manifest chrome + "/domain/glossary.md", # a user pin in the top zone + "/system/invariants.md", # a grouped index entry + "/domain/billing/settlement/netting.md", # grouped, nested two deep + "/notes/team/onboarding/day-one.md", # browse zone, nested two deep + ): + assert f"]({dest})" in sidebar, dest + # Every emitted destination, parsed rather than sampled: one bare rel anywhere + # in the sidebar re-resolves against whatever nested route is current. + dests = re.findall(r"\]\(([^)]*)\)", sidebar) + assert len(dests) >= 6, "the matrix produced links to sweep" + for dest in dests: + assert dest.startswith("/"), dest + + +def test_viewer_md_link_dest_is_escaped_absolute_and_idempotent() -> None: + from leji.viewer_cmd import _md_link_dest + + # These vectors are shared verbatim with the Node and Go SDKs + # (test/units.test.ts, viewer_more_test.go): the three must agree byte for byte. + vectors = [ + ("a.md", "/a.md"), + ("dir/b.md", "/dir/b.md"), + # Already absolute: `//...` would be a protocol-relative external URL to + # Docsify. + ("/a.md", "/a.md"), + ("//a.md", "/a.md"), + # Degenerate input passes through rather than becoming a bare `/`. + ("", ""), + ("a(b).md", r"/a\(b\).md"), + ("(x).md", r"/\(x\).md"), + (r"a\b.md", r"/a\\b.md"), + ] + for src, want in vectors: + assert _md_link_dest(src) == want, src + + +def test_viewer_serves_a_document_byte_identical_whatever_links_it_carries( + tmp_path: Path, +) -> None: + layer = _copy(EXAMPLE, tmp_path) + # One instance of every link class a real document mixes. Routing is config plus + # the generated sidebar, never a transform over the author's markdown, so the + # served bytes are the file's. How an image path resolves under relativePath is + # a separate item and is deliberately not asserted here. + body = "\n".join( + [ + "# Links", + "", + "- [parent](../target.md)", + "- [sibling](sibling.md)", + "- [root](/root-target.md)", + "- [fragment](#fragment)", + "- [doc fragment](target.md#fragment)", + "- [query](target.md?q=1)", + "- [external](https://leji.org/spec)", + "", + "![x](assets/x.svg)", + "", + '', + "", + ] + ) + _write_under(layer, "docs/notes/deep/links.md", body) + manifest = load_manifest(str(layer)).manifest + generate_viewer(str(layer), manifest) + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + status, served = _get(port, "/content/notes/deep/links.md") + assert status == 200 + # The viewer never rewrites document markdown. + assert served == (layer / "docs" / "notes" / "deep" / "links.md").read_bytes() + finally: + server.shutdown() + + +def test_viewer_routing_config_ships_in_the_served_and_built_boot_script( + tmp_path: Path, +) -> None: + from leji import build_viewer + + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + generate_viewer(str(layer), manifest) + + # Both settings live in the boot script's static overlay, not the injected JSON + # config block, so the assertion is on the asset text. + def assert_routing(boot: str, where: str) -> None: + assert re.search(r"relativePath:\s*true", boot), f"relativePath is on ({where})" + assert re.search(r"notFoundPage:\s*false", boot), f"notFoundPage is off ({where})" + + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + status, asset = _get(port, "/assets/viewer-boot.js") + assert status == 200 + assert_routing(asset.decode(), "served") + finally: + server.shutdown() + build_viewer(str(layer), manifest, "out") + assert_routing((layer / "out" / "assets" / "viewer-boot.js").read_text(), "built") + + +def _resolve_route(from_rel: str, dest: str) -> str: + """Resolve a markdown destination the way Docsify's relativePath routing does: + against the linking document's own directory, except a leading-slash + destination, which is app-root (content-root) absolute.""" + if dest.startswith("/"): + return dest[1:] + return posixpath.normpath(posixpath.join(posixpath.dirname(from_rel), dest)) + + +def test_viewer_nested_page_links_resolve_to_documents_the_server_has(tmp_path: Path) -> None: + layer = _copy(EXAMPLE, tmp_path) + _write_under(layer, "docs/practice/feature-workflow.md", "# Feature workflow\n") + _write_under(layer, "docs/work/spec.md", "# Spec\n") + _write_under( + layer, + "docs/work/README.md", + "\n".join( + [ + "# Work", + "", + "- [workflow](../practice/feature-workflow.md)", + "- [spec](spec.md)", + "- [glossary](/domain/glossary.md)", + "", + ] + ), + ) + manifest = load_manifest(str(layer)).manifest + generate_viewer(str(layer), manifest) + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + for dest in ("../practice/feature-workflow.md", "spec.md", "/domain/glossary.md"): + target = _resolve_route("work/README.md", dest) + assert _get(port, f"/content/{target}")[0] == 200, dest + # The pre-fix escape: the same `../` destination resolved against the server + # root instead of the router. The server has no such route, which is exactly + # why the link must stay in-app. + assert _get(port, "/practice/feature-workflow.md")[0] == 404 + finally: + server.shutdown() + + +def test_viewer_unknown_document_route_404s_with_no_404_page(tmp_path: Path) -> None: + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + result = generate_viewer(str(layer), manifest) + viewer = layer / ".leji" / "viewer" + # The config disables Docsify's secondary _404.md fetch (pinned by the routing + # config test above) and the viewer generates no such page. That the browser + # therefore makes exactly one failing request is verified at the browser level, + # not here. + assert not (viewer / "_404.md").exists() + assert not any(w.endswith("_404.md") for w in result.written) + server, port = _serve_on_free_port(layer, manifest["rootPath"]) + try: + # The missing document itself is the one 404. + assert _get(port, "/content/does-not-exist.md")[0] == 404 + finally: + server.shutdown() + + +def test_viewer_build_default_output_is_the_dist_role(tmp_path: Path) -> None: + from leji import build_viewer + + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + result = build_viewer(str(layer), manifest) + assert result.out == ".leji/dist", "the default output is root .leji/dist" + assert (layer / ".leji" / "dist" / "index.html").is_file() + assert (layer / ".leji" / "dist" / "content" / "boot-profile.md").is_file() + # The pre-1.4 locations are never created, and nothing reads or writes a tree + # under the context root: a run leaves rootPath/.leji/ absent. + assert not (layer / "docs" / ".leji").exists(), "no tree under the context root" + assert not (layer / ".leji" / "viewer-dist").exists(), "the old output name is not used" + + +def test_viewer_build_out_never_resolves_inside_a_leji_role_but_dist(tmp_path: Path) -> None: + import pytest + + from leji import build_viewer + + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + # The roles are the tool's own: an export target inside any of them is refused, + # including a role this version has never heard of, because the rule denies by + # name rather than listing what to protect. + for target in (".leji", ".leji/mounts", ".leji/mounts/cache", ".leji/viewer", ".leji/work"): + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(layer), manifest, target) + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(layer), manifest, ".leji/future") + # The canary bytes a refusal must never have touched: the private roles are still + # exactly as planted. + (layer / ".leji" / "mounts" / "store").mkdir(parents=True, exist_ok=True) + (layer / ".leji" / "mounts" / "store" / "keep").write_text("private\n") + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(layer), manifest, ".leji/mounts") + assert (layer / ".leji" / "mounts" / "store" / "keep").read_text() == "private\n" + # The reserved role itself is the one accepted spelling. + build_viewer(str(layer), manifest, ".leji/dist") + assert (layer / ".leji" / "dist" / "index.html").is_file() + + +def _case_insensitive_fs(directory: Path) -> bool: + """Whether this directory sits on a filesystem that cannot tell `.leji` from + `.LEJI`. Asked of the volume rather than inferred from the platform: a + case-sensitive volume on macOS and a case-insensitive one on Linux both exist.""" + probe = directory / "leji-case-probe" + probe.mkdir(parents=True, exist_ok=True) + try: + return (directory / "LEJI-CASE-PROBE").exists() + finally: + shutil.rmtree(probe, ignore_errors=True) + + +def test_viewer_build_out_is_judged_in_resolved_form(tmp_path: Path) -> None: + import pytest + + from leji import build_viewer + + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + (layer / ".leji" / "mounts" / "store").mkdir(parents=True) + (layer / ".leji" / "mounts" / "store" / "keep").write_text("private\n") + # A symlink is a spelling, not an exemption: what the write would land in is what + # the reservation judges, so an ordinary-looking --out that redirects into a + # private role is refused exactly as the literal path is. + (layer / "redirect").symlink_to(Path(".leji") / "mounts") + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(layer), manifest, "redirect/export") + assert not (layer / ".leji" / "mounts" / "export").exists(), "nothing written through it" + assert (layer / ".leji" / "mounts" / "store" / "keep").read_text() == "private\n" + # Where the filesystem cannot tell the two spellings apart, `.LEJI/` names the + # reserved role and is refused as one. Where it can, `.LEJI/` is an ordinary + # directory name and there is nothing to assert, so the volume decides. + if _case_insensitive_fs(layer): + with pytest.raises(RuntimeError, match="reserved for the tool's own roles"): + build_viewer(str(layer), manifest, ".LEJI/mounts/export") + assert not (layer / ".leji" / "mounts" / "export").exists() + # The redirection rule is about the destination, not about symlinks: one that + # lands somewhere ordinary still exports. + (layer / "real-out").mkdir() + (layer / "link-out").symlink_to("real-out") + build_viewer(str(layer), manifest, "link-out") + assert (layer / "real-out" / "index.html").is_file(), "the export landed in the resolved target" + + +def test_viewer_build_export_flavor_carries_no_root_absolute_url(tmp_path: Path) -> None: + from leji import build_viewer + + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + build_viewer(str(layer), manifest) + served = (layer / ".leji" / "viewer" / "index.html").read_text() + exported = (layer / ".leji" / "dist" / "index.html").read_text() + # One code path, two flavors: the servable area holds the app-root base, the + # export holds the relative one. index.html is the only file that differs. + assert '"basePath":"/content/"' in served, "the served flavor mounts content at the app root" + assert 'href="/assets/leji-logo.svg"' in served, "the served favicon is app-root absolute" + assert '"basePath":"content/"' in exported, "the exported flavor is relative to the page" + assert '"basePath":"/content/"' not in exported, "no export flavor keeps the app-root base" + # The machine-checkable proxy gate for subpath hosting: nothing in the exported + # shell — attributes or config — addresses the server root. (Sidebar link + # destinations are route strings resolved against basePath, not fetch paths, and + # live in _sidebar.md, not here.) + body = exported[exported.index("-->") + 3 :] + assert re.findall(r'(?:href|src)="/[^"]*"', body) == [] + assert re.findall(r'\\"/(?:content|assets)/[^\\"]*\\"', body) == [] + # The servable area never holds export-flavored bytes, and the two trees agree on + # everything else the chrome ships. + for rel in ("assets/viewer-boot.js", "assets/docsify.min.js"): + assert (layer / ".leji" / "dist" / rel).read_bytes() == ( + layer / ".leji" / "viewer" / rel + ).read_bytes(), f"{rel} is flavor-neutral" + + def test_viewer_build_refuses_out_inside_the_context_root(tmp_path: Path) -> None: """The reported reproduction: exporting into a governed directory used to rm -rf it and then recurse into its own output until the paths grew too long.""" @@ -922,29 +1273,136 @@ def test_viewer_hostile_manifest_cannot_break_out_of_its_substitution_site( manifest = load_manifest(str(layer)).manifest manifest["viewer"] = {"title": "{{MERMAID_SCRIPTS}}", "favicon": "{{DOCSIFY_CONFIG}}"} generate_viewer(str(layer), manifest) - html = (layer / "docs" / ".leji" / "viewer" / "index.html").read_text() + html = (layer / ".leji" / "viewer" / "index.html").read_text() assert "{{MERMAID_SCRIPTS}}" in html assert 'href="/content/{{DOCSIFY_CONFIG}}"' in html assert html.count(" str: + """The one message a rejected accent produces, spelled out here so a change to + the contract's wording fails the suite rather than shipping.""" + return ( + f'viewer.theme.primary "{value}" is not a hex color ' + f"(#RGB, #RGBA, #RRGGBB, or #RRGGBBAA); using #009F71" + ) + + def test_viewer_rejects_an_unusable_theme_color(tmp_path: Path) -> None: """The accent reaches a stylesheet as a custom-property value, so a value with punctuation in it is refused with a warning rather than interpolated.""" layer = _copy(EXAMPLE, tmp_path) manifest = load_manifest(str(layer)).manifest - manifest["viewer"] = {"theme": {"primary": "red; } body { display: none } /*"}} + injection = "red; } body { display: none } /*" + manifest["viewer"] = {"theme": {"primary": injection}} result = generate_viewer(str(layer), manifest) - html = (layer / "docs" / ".leji" / "viewer" / "index.html").read_text() - assert '"themeColor":"#223F93"' in html - assert any( - f.rule == "viewer-theme-invalid" and f.severity == "warning" for f in result.findings - ) + html = (layer / ".leji" / "viewer" / "index.html").read_text() + assert '"themeColor":"#009F71"' in html + warnings = [ + f for f in result.findings if f.rule == "viewer-theme-invalid" and f.severity == "warning" + ] + assert len(warnings) == 1 + assert warnings[0].message == _theme_warning(injection) manifest["viewer"] = {"theme": {"primary": "#ff0000"}} generate_viewer(str(layer), manifest) - assert ( - '"themeColor":"#ff0000"' in (layer / "docs" / ".leji" / "viewer" / "index.html").read_text() - ) + assert '"themeColor":"#ff0000"' in (layer / ".leji" / "viewer" / "index.html").read_text() + + +def test_viewer_accent_is_hex_and_nothing_else(tmp_path: Path) -> None: + """5 and 7 digits are no CSS color at all: they used to reach the page as an + unusable accent with no warning, while the mermaid text color silently defaulted, + leaving accent and text computed from different colors. Keywords are not the + contract either, however real the name.""" + layer = _copy(EXAMPLE, tmp_path) + manifest = load_manifest(str(layer)).manifest + vectors = [ + # The four lengths CSS defines, alpha forms included, case-insensitive. + ("#0f7", True), + ("#1234", True), + ("#009F71", True), + ("#AABBCCDD", True), + ("#12345", False), + ("#1234567", False), + # Keyword acceptance used to fall out of the injection guard, never design. + ("navy", False), + ("notacolor", False), + ("transparent", False), + # A trailing newline does not sneak a hex past the predicate, in any SDK: + # `fullmatch`, never `match`, is what makes that true here. + ("#009F71\n", False), + ] + for accent, accepted in vectors: + manifest["viewer"] = {"theme": {"primary": accent}} + result = generate_viewer(str(layer), manifest) + html = (layer / ".leji" / "viewer" / "index.html").read_text() + warnings = [ + f + for f in result.findings + if f.rule == "viewer-theme-invalid" and f.severity == "warning" + ] + if accepted: + assert warnings == [], accent + assert f'"themeColor":"{accent}"' in html, accent + else: + assert len(warnings) == 1, repr(accent) + assert warnings[0].message == _theme_warning(accent), repr(accent) + assert '"themeColor":"#009F71"' in html, repr(accent) + + +def _wcag_contrast(a: str, b: str) -> float: + """Contrast between two #rrggbb colors, computed here rather than through the + code under test, so the numeric assertions below are derived independently of the + implementation they judge.""" + + def luminance(hex_color: str) -> float: + def channel(i: int) -> float: + c = int(hex_color[1 + i * 2 : 3 + i * 2], 16) / 255 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + + return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2) + + x, y = luminance(a), luminance(b) + return (max(x, y) + 0.05) / (min(x, y) + 0.05) + + +def test_viewer_mermaid_text_color_over_every_accepted_form() -> None: + """The generator resolves what the boot script cannot: the alpha forms, + composited over the viewer's white content ground.""" + from leji.viewer_cmd import _mermaid_text_color + + vectors = [ + # The two brand accents, and the mid-gray class where neither #1a1a1a nor + # #ffffff clears 4.5:1 and black buys the last half-stop. + ("#009F71", "#1a1a1a"), + ("#223F93", "#ffffff"), + ("#777777", "#000000"), + # #RGB expands like the boot script's fallback does. + ("#0f7", "#1a1a1a"), + # Alpha composites over white, which lightens: the same accent at half alpha + # takes dark text, and a black at 47% is light enough for it too. + ("#009F7180", "#1a1a1a"), + ("#0007", "#1a1a1a"), + # Named resolution is gone: navy would take white text if any keyword path + # survived, so the dark default here is the proof it does not. + ("navy", "#1a1a1a"), + # Unresolvable by nature or by typo: the dark default, never a guess. + ("currentColor", "#1a1a1a"), + ("notacolor", "#1a1a1a"), + ("#12345", "#1a1a1a"), + # A dark accent takes white; the case of the authored hex does not matter. + ("#1A1A1A", "#ffffff"), + ("#000080", "#ffffff"), + ] + for accent, want in vectors: + assert _mermaid_text_color(accent) == want, f"{accent} takes {want}" + # The default accent's choice is not merely dark, it is accessible: the numeric + # ratio is what the rule is about, so it is asserted as a number. + assert _wcag_contrast("#009F71", "#1a1a1a") >= 4.5 + assert _wcag_contrast("#223F93", "#ffffff") >= 4.5 + # The #777777 class: black is chosen because both candidates miss, not because it + # wins outright over a passing option. + assert _wcag_contrast("#777777", "#1a1a1a") < 4.5 + assert _wcag_contrast("#777777", "#ffffff") < 4.5 def test_viewer_escapes_html_in_sidebar_labels(tmp_path: Path) -> None: @@ -954,7 +1412,7 @@ def test_viewer_escapes_html_in_sidebar_labels(tmp_path: Path) -> None: manifest = load_manifest(str(layer)).manifest manifest["viewer"] = {"agentsLabel": ""} generate_viewer(str(layer), manifest) - sidebar = (layer / "docs" / ".leji" / "viewer" / "_sidebar.md").read_text() + sidebar = (layer / ".leji" / "viewer" / "_sidebar.md").read_text() assert "\\" in sidebar diff --git a/packages/sdk-py/tests/test_update_pin.py b/packages/sdk-py/tests/test_update_pin.py new file mode 100644 index 0000000..bfca861 --- /dev/null +++ b/packages/sdk-py/tests/test_update_pin.py @@ -0,0 +1,694 @@ +"""`leji mounts update-pin` tests, mirroring packages/sdk/test/update-pin.test.ts. + +Three halves of one contract. First the pin-span scanner over its own byte +fixtures — the only artifact here that needs no git at all. Then the two factorings +out of ``leji/mounts.py``, checked against the callers they were taken from. Then the +shared fixtures' ``updatePin`` block, driven through the real CLI as a process over a +scaffold every SDK's harness builds identically (``fixtures/README.md`` -> "The +``updatePin`` block"). +""" + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from leji.manifest import load_manifest, replace_mount_pin_in_manifest_text +from leji.mounts import ( + MountDecl, + compare_pins, + mount_status, + normalize_source, + pin_ref_for, + retain_pin_in_store, + select_comparison, + witness_ref_for, +) +from leji.update_pin import update_pin_run + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES = REPO_ROOT / "fixtures" +PIN_SPAN_DIR = FIXTURES / "manifest-pin-span" + +# --- the pin-span scanner ----------------------------------------------------- + +PIN_SPAN_CASES = sorted(p.name for p in PIN_SPAN_DIR.iterdir() if p.is_dir()) + +_ERROR_PATTERNS = { + "not-located": r"cannot locate the pin of mount", + "not-from": r"pin of mount .* is not", + "duplicate-key": r"duplicate key", +} + + +@pytest.mark.parametrize("name", PIN_SPAN_CASES) +def test_manifest_pin_span_fixture(name: str) -> None: + case = json.loads((PIN_SPAN_DIR / name / "case.json").read_text(encoding="utf-8")) + text = (PIN_SPAN_DIR / name / "input.json").read_text(encoding="utf-8") + if case["outcome"] == "error": + with pytest.raises(RuntimeError, match=_ERROR_PATTERNS[case["error"]]): + replace_mount_pin_in_manifest_text(text, case["mount"], case["from"], case["to"]) + return + expected = (PIN_SPAN_DIR / name / "expected.json").read_text(encoding="utf-8") + got, changed = replace_mount_pin_in_manifest_text(text, case["mount"], case["from"], case["to"]) + assert changed is True, f"{name}: the span moved" + assert got == expected, f"{name}: byte-exact output" + # Every case is a real manifest before and after: the edit never produces + # something a parser would reject. + json.loads(got) + # And the edit is confined: exactly the pin's own characters differ. + assert len(got) == len(text) + len(case["to"]) - len(case["from"]) + + +def test_a_duplicate_key_on_the_path_to_the_pin_is_refused_never_resolved() -> None: + # The two readers of this document disagree: a lexical scan takes the FIRST + # member, `json.loads` keeps the LAST. Rewriting the first span would report a + # change that every parser of the result still reads as the old pin. + case = json.loads((PIN_SPAN_DIR / "error-duplicate-pin" / "case.json").read_text()) + text = (PIN_SPAN_DIR / "error-duplicate-pin" / "input.json").read_text(encoding="utf-8") + parsed_pin = json.loads(text)["federation"]["mounts"][0]["pin"] + assert parsed_pin != case["from"], ( + "the parser reads the LAST pin, which is not the span a scan finds first" + ) + with pytest.raises(RuntimeError, match=r'duplicate key "pin" in mount "product-context"'): + replace_mount_pin_in_manifest_text(text, case["mount"], case["from"], case["to"]) + # Every key the scanner reads on its way to the pin carries the same rule. + for fixture, message in ( + ("error-duplicate-federation", r'duplicate key "federation" in the manifest root'), + ("error-duplicate-mounts", r'duplicate key "mounts" in "federation"'), + ("error-duplicate-name", r'duplicate key "name" in a federation mount'), + ): + other = (PIN_SPAN_DIR / fixture / "input.json").read_text(encoding="utf-8") + with pytest.raises(RuntimeError, match=message): + replace_mount_pin_in_manifest_text(other, "product-context", case["from"], case["to"]) + + +def test_the_pin_span_moves_only_for_the_addressed_mount() -> None: + text = (PIN_SPAN_DIR / "shared-prefix" / "input.json").read_text(encoding="utf-8") + case = json.loads((PIN_SPAN_DIR / "shared-prefix" / "case.json").read_text()) + # The neighbouring mount's pin is untouched by the move above it. + moved, _ = replace_mount_pin_in_manifest_text(text, case["mount"], case["from"], case["to"]) + before = json.loads(text)["federation"]["mounts"] + after = json.loads(moved)["federation"]["mounts"] + assert after[0]["pin"] == before[0]["pin"] + assert after[1]["pin"] != before[1]["pin"] + # `from == to` is a no-op the caller can rely on, not a rewrite of equal bytes. + same, changed = replace_mount_pin_in_manifest_text( + text, case["mount"], case["from"], case["from"] + ) + assert changed is False + assert same == text + + +# --- the acme-sibling scaffold ------------------------------------------------ + +#: The recipe's fixed commit ids. Every field a commit hashes is pinned by the recipe +#: (``fixtures/README.md``), so these are constants, not observations. +OID_A = "6b06fe51a323212156bb267842bf10187ed4c20e" +OID_B = "3ff2a04361ca9d601180037bdfbc8b6c0a0a8723" +OID_S = "50305153f1a107c6871ab3b3047cb4c225603b0c" +OID_O = "0cb1fb59e73d78ff04cf41de7f177ea0fb940002" +ACME_SOURCE = "https://github.com/acme/product-context" +ACME_IDENTITY = normalize_source(ACME_SOURCE) +assert ACME_IDENTITY is not None + + +def _recipe_env() -> dict[str, str]: + """Author, committer, date, message and content are all fixed, so every commit id + the recipe produces is a constant an ``expected.json`` can carry.""" + env = dict(os.environ) + env.pop("GIT_DIR", None) + env.update( + GIT_AUTHOR_NAME="Leji Fixtures", + GIT_AUTHOR_EMAIL="fixtures@leji.org", + GIT_COMMITTER_NAME="Leji Fixtures", + GIT_COMMITTER_EMAIL="fixtures@leji.org", + GIT_AUTHOR_DATE="2026-01-01T00:00:00 +0000", + GIT_COMMITTER_DATE="2026-01-01T00:00:00 +0000", + ) + return env + + +def git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", "-c", "commit.gpgsign=false", "-c", "core.autocrlf=false", *args], + cwd=cwd, + env=_recipe_env(), + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def _commit(repo: Path, file: str) -> str: + stem = file[:-3] + (repo / file).write_text(f"# {stem}\n", encoding="utf-8") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", stem) + return git(repo, "rev-parse", "HEAD") + + +def build_acme_sibling(directory: Path) -> None: + """The ``acme-sibling`` recipe, normative in ``fixtures/README.md``: a -> b on + main, a side branch off ``a``, and an unrelated orphan branch.""" + directory.mkdir(parents=True, exist_ok=True) + git(directory, "init", "-q", "-b", "main", ".") + assert _commit(directory, "a.md") == OID_A, "recipe commit a" + assert _commit(directory, "b.md") == OID_B, "recipe commit b" + git(directory, "checkout", "-q", "-b", "side", OID_A) + assert _commit(directory, "s.md") == OID_S, "recipe commit s" + git(directory, "checkout", "-q", "--orphan", "other") + git(directory, "rm", "-q", "-rf", ".") + assert _commit(directory, "o.md") == OID_O, "recipe commit o" + git(directory, "checkout", "-q", "main") + # Fetching a commit by id is how the resolver retains a pin, so the recipe's + # repository must serve one the way a real host does. + git(directory, "config", "uploadpack.allowAnySHA1InWant", "true") + + +def _store_path(host: Path) -> Path: + key = hashlib.sha256(ACME_IDENTITY.encode("utf-8")).hexdigest() + return host / ".leji" / "mounts" / "store" / key + + +def build_store(host: Path, sibling: Path, spec: dict) -> None: + """Build the managed store exactly as a successful ``--fetch`` leaves it.""" + store = _store_path(host) + store.mkdir(parents=True, exist_ok=True) + git(host, "init", "--bare", "-q", str(store)) + depth = [] if spec["depth"] is None else ["--depth", str(spec["depth"])] + if spec["pin"] is not None: + git(store, "fetch", "-q", *depth, str(sibling), spec["pin"]) + git(store, "update-ref", pin_ref_for(ACME_IDENTITY, spec["pin"]), spec["pin"]) + if spec["witnessRef"] is not None and spec["witnessOid"] is not None: + git( + store, + "fetch", + "-q", + *depth, + str(sibling), + f"+{spec['witnessOid']}:{witness_ref_for(ACME_IDENTITY, spec['witnessRef'])}", + ) + (store / "FETCH_HEAD").unlink(missing_ok=True) + + +def repin(host: Path, pin: str, tracking_ref) -> None: + """Apply a case's declaration rewrite: the pin it starts from, and whether the + tracking ref is declared at all. A raw-text splice, as the fixture's own contract + requires — the harness never reserializes a manifest either.""" + mp = host / "leji.json" + text = mp.read_text(encoding="utf-8") + text = re.sub(r'("pin": ")[0-9a-f]{40}(")', rf"\g<1>{pin}\g<2>", text, count=1) + if tracking_ref is None: + text = re.sub(r'\s*"trackingRef": "[^"]*",\n', "\n", text, count=1) + mp.write_text(text, encoding="utf-8") + + +def run_cli_proc(args: list[str], env: dict[str, str]) -> tuple[int, str]: + r = subprocess.run( + [sys.executable, "-m", "leji.cli", *args], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + return r.returncode, r.stdout + + +def _plain_env() -> dict[str, str]: + env = dict(os.environ) + env.pop("GIT_DIR", None) + return env + + +def _routed_env(routed: Path) -> dict[str, str]: + env = _plain_env() + env["GIT_CONFIG_COUNT"] = "1" + env["GIT_CONFIG_KEY_0"] = f"url.{routed}.insteadOf" + env["GIT_CONFIG_VALUE_0"] = ACME_SOURCE + return env + + +# --- the two factorings, against the callers they came out of ----------------- + + +def test_select_comparison_and_compare_pins_answer_what_mount_status_reports(tmp_path) -> None: + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + for pin, expected in ( + (OID_A, "behind"), + (OID_B, "up-to-date"), + (OID_S, "diverged"), + (OID_O, "unrelated"), + ): + shutil.rmtree(host, ignore_errors=True) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, pin, "keep") + build_store( + host, + sibling, + {"pin": pin, "witnessRef": "refs/heads/main", "witnessOid": OID_B, "depth": None}, + ) + manifest = load_manifest(str(host)).manifest + assert manifest is not None + row = mount_status(str(host), manifest)[0] + report = row["pinReport"] + assert report["state"] == expected, f"status says {expected}" + selection = select_comparison( + str(host), + MountDecl( + name="product-context", + source=ACME_SOURCE, + pin=pin, + tracking_ref="refs/heads/main", + ), + "refs/heads/main", + ) + assert selection.reason is None, "the matrix selected a repository" + # The helper reports the same repository, provenance and ref status does… + assert selection.comparison_repository == report["comparisonRepository"] + assert selection.witness_provenance == report["witnessProvenance"] + assert selection.compared_ref == report["comparedRef"] + assert selection.tip_oid == OID_B, "the single witness snapshot" + # …and comparing against that one snapshot reproduces the report exactly. + cmp_result = compare_pins(str(selection.repo), pin, str(selection.tip_oid)) + assert cmp_result.reason is None + assert cmp_result.state == report["state"] + assert cmp_result.behind == report["behind"] + assert cmp_result.ahead == report["ahead"] + assert cmp_result.ancestry_complete == report["ancestryComplete"] + + +def test_select_comparison_reports_every_degraded_reason_status_reports(tmp_path) -> None: + mount = MountDecl( + name="product-context", source=ACME_SOURCE, pin=OID_A, tracking_ref="refs/heads/main" + ) + # Nothing holds the pin. + assert ( + select_comparison(str(tmp_path), mount, "refs/heads/main").reason == "mount-pin-unavailable" + ) + # A locator no resolver can normalize, and a ref the resolver refuses. + unnormalizable = MountDecl( + name=mount.name, source="file:///srv/x", pin=mount.pin, tracking_ref=mount.tracking_ref + ) + assert ( + select_comparison(str(tmp_path), unnormalizable, "refs/heads/main").reason + == "mount-source-unnormalizable" + ) + assert ( + select_comparison(str(tmp_path), mount, "refs/heads/main@{1}").reason + == "mount-tracking-ref-invalid" + ) + + +def test_retain_pin_in_store_retains_one_commit_without_touching_the_witness(tmp_path) -> None: + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + host.mkdir() + mount = MountDecl( + name="product-context", source=str(sibling), pin=OID_A, tracking_ref="refs/heads/main" + ) + first = retain_pin_in_store(str(host), mount, ACME_IDENTITY, OID_A) + assert first.repo is not None, first.error + assert git(Path(first.repo), "rev-parse", pin_ref_for(ACME_IDENTITY, OID_A)) == OID_A + # The witness namespace belongs to the refresh, which this primitive is not. + assert git(Path(first.repo), "for-each-ref", "--format=%(refname)", "refs/leji-witness") == "" + # A second commit is retained beside the first, not instead of it. + second = retain_pin_in_store(str(host), mount, ACME_IDENTITY, OID_B) + assert second.repo is not None, second.error + assert git(Path(second.repo), "rev-parse", pin_ref_for(ACME_IDENTITY, OID_A)) == OID_A + assert git(Path(second.repo), "rev-parse", pin_ref_for(ACME_IDENTITY, OID_B)) == OID_B + # A source that serves nothing is a stated failure, never a partial success. + gone = MountDecl( + name=mount.name, + source=str(tmp_path / "gone"), + pin=mount.pin, + tracking_ref=mount.tracking_ref, + ) + missing = retain_pin_in_store(str(host), gone, ACME_IDENTITY, OID_S) + assert missing.repo is None + assert missing.error == "the pin could not be fetched from the source" + + +# --- the shared fixtures' `updatePin` block ----------------------------------- + +#: Exactly the keys `--json` emits, under every outcome that emits a document. +DOCUMENT_KEYS = [ + "command", + "ok", + "findings", + "summary", + "mount", + "pinReport", + "action", + "override", +] + + +def _update_pin_blocks() -> list[tuple[str, dict, dict]]: + out: list[tuple[str, dict, dict]] = [] + for name in sorted(p.name for p in FIXTURES.iterdir() if (p / "expected.json").is_file()): + block = json.loads((FIXTURES / name / "expected.json").read_text()).get("updatePin") + if not block: + continue + for case in block["cases"]: + out.append((name, block, case)) + return out + + +UPDATE_PIN_CASES = _update_pin_blocks() + + +@pytest.mark.parametrize( + ("fixture", "block", "case"), + UPDATE_PIN_CASES, + ids=[f"{name}-{case['id']}" for name, _, case in UPDATE_PIN_CASES], +) +def test_fixture_update_pin_block(fixture: str, block: dict, case: dict, tmp_path) -> None: + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + shutil.copytree(FIXTURES / fixture, host) + repin(host, case["pin"], case.get("trackingRef", "keep")) + if case["store"]: + build_store(host, sibling, case["store"]) + if case["hint"]: + (host / ".leji").mkdir(parents=True, exist_ok=True) + (host / ".leji" / "mounts.local.json").write_text( + json.dumps({"mounts": {block["mount"]: {"repo": str(sibling)}}}) + "\n", + encoding="utf-8", + ) + # The declared source is a locator no test may actually reach, so it is routed at + # git's own level: to the recipe repository for a run that must succeed, and to a + # path that does not exist for one that must fail. + if case["source"] == "none": + env = _plain_env() + else: + env = _routed_env(sibling if case["source"] == "local" else tmp_path / "never-created") + + manifest_path = host / "leji.json" + before = manifest_path.read_bytes() + code, stdout = run_cli_proc([*case["args"], "--root", str(host), "--json"], env) + assert code == case["exit"], f"{case['id']}: exit code ({stdout})" + + if case["action"] is None: + # A usage error reports no outcome at all, and touches nothing. + assert stdout.strip() == "", f"{case['id']}: no document" + assert manifest_path.read_bytes() == before, f"{case['id']}: nothing written" + return + doc = json.loads(stdout) + keys = sorted(DOCUMENT_KEYS + ([] if case["reason"] is None else ["reason"])) + assert sorted(doc.keys()) == keys, f"{case['id']}: the exact JSON key set" + assert doc["command"] == "mounts update-pin" + assert doc["action"] == case["action"], f"{case['id']}: action" + assert doc["override"] == case["override"], f"{case['id']}: override" + assert doc.get("reason") == case["reason"], f"{case['id']}: reason" + assert doc["mount"]["from"] == case["from"], f"{case['id']}: from" + assert doc["mount"]["to"] == case["to"], f"{case['id']}: to" + assert doc["ok"] == (case["reason"] is None), f"{case['id']}: ok tracks the refusal" + assert doc["summary"] == { + "errors": 0 if case["reason"] is None else 1, + "warnings": 1 if case["override"] else 0, + }, f"{case['id']}: the literal summary" + # The findings are what the block pins, never read off the document: a refusal + # names its reason code, an override warns under its own. + expected_findings = sorted( + ( + [] + if case["reason"] is None + else [{"rule": case["reason"], "severity": "error", "path": doc["mount"]["name"]}] + ) + + ( + [ + { + "rule": "mount-pin-non-fast-forward-override", + "severity": "warning", + "path": doc["mount"]["name"], + } + ] + if case["override"] + else [] + ), + key=lambda f: f["rule"], + ) + assert [ + {"rule": f["rule"], "severity": f["severity"], "path": f.get("path")} + for f in doc["findings"] + ] == expected_findings, f"{case['id']}: the exact findings" + if "comparisonRepository" in case: + assert doc["pinReport"]["comparisonRepository"] == case["comparisonRepository"] + if "comparedRef" in case: + assert doc["pinReport"]["comparedRef"] == case["comparedRef"] + + after = manifest_path.read_bytes() + if case["manifestGolden"] is not None: + golden = (FIXTURES / case["manifestGolden"]).read_bytes() + assert after == golden, f"{case['id']}: the written manifest bytes" + # `written: false` is one claim: the manifest is byte-identical to the manifest + # this run started from. + if not case["written"]: + assert after == before, f"{case['id']}: leji.json is byte-untouched" + # A `--fetch` run does the store acts it was asked for even when the rewrite is + # suppressed: dry-run withholds the manifest, not the fetch. + if case["id"] == "dry-run-fetch": + store = _store_path(host) + assert store.is_dir(), "the managed store was established" + assert git(store, "rev-parse", pin_ref_for(ACME_IDENTITY, OID_A)) == OID_A + # Every fetch this command makes passes --no-write-fetch-head, so a run that + # reached the source leaves no per-run record inside the store. + if case["source"] == "local": + assert not (_store_path(host) / "FETCH_HEAD").exists(), f"{case['id']}: no FETCH_HEAD" + + +# --- the branches no fixture can construct ------------------------------------ + + +def test_a_declaration_that_changes_under_the_run_is_refused(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") + build_store( + host, + sibling, + {"pin": OID_A, "witnessRef": "refs/heads/main", "witnessOid": OID_B, "depth": None}, + ) + manifest = load_manifest(str(host)).manifest + assert manifest is not None + # The comparison runs against the manifest object in hand; the file changes its + # `source` before the verified read the rewrite makes. + mp = host / "leji.json" + original = mp.read_text(encoding="utf-8") + moved = original.replace(ACME_SOURCE, "https://github.com/acme/moved-context") + mp.write_text(moved, encoding="utf-8") + r = update_pin_run(str(host), manifest, "product-context") + assert r.action == "refused" + assert r.reason == "mount-declaration-changed" + assert mp.read_text(encoding="utf-8") == moved + + +def _declare_tracking_ref(text: str, spelling: str) -> str: + """Give the mount a ``trackingRef`` member spelled exactly as passed, directly + after its pin. A raw-text splice, like every other edit these fixtures make.""" + return re.sub( + r'("pin": "[0-9a-f]{40}",\n)', + lambda m: f'{m.group(1)} "trackingRef": {spelling},\n', + text, + count=1, + ) + + +@pytest.mark.parametrize("spelling", ["null", '""']) +def test_a_tracking_ref_that_appears_under_the_run_is_a_changed_declaration( + tmp_path, monkeypatch, spelling: str +) -> None: + """A mount declared with NO trackingRef, which gains one while the comparison + runs, is a changed declaration. Presence is the half a bare lookup loses: absent + and `null` both read back as None, so without the presence check the pin would be + spliced into a declaration the schema no longer accepts.""" + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + shutil.copytree(FIXTURES / "warn-update-pin", host) + # Absent at load; only --fetch can resolve the source's advertised default branch, + # which is the one path that reaches the rewrite with no trackingRef declared. + repin(host, OID_A, None) + for key, value in _routed_env(sibling).items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("GIT_DIR", raising=False) + manifest = load_manifest(str(host)).manifest + assert manifest is not None + assert "trackingRef" not in manifest["federation"]["mounts"][0], "absent at load" + mp = host / "leji.json" + appeared = _declare_tracking_ref(mp.read_text(encoding="utf-8"), spelling) + mp.write_text(appeared, encoding="utf-8") + r = update_pin_run(str(host), manifest, "product-context", fetch=True) + assert r.action == "refused", f"trackingRef: {spelling} must not read as unchanged" + assert r.reason == "mount-declaration-changed" + assert mp.read_text(encoding="utf-8") == appeared, "leji.json is byte-untouched" + + +def test_a_declaration_still_absent_at_the_reread_proceeds(tmp_path, monkeypatch) -> None: + """The other half of the same rule: absent-and-still-absent is unchanged, so the + run that resolved its ref from the source still writes.""" + sibling = tmp_path / "sibling" + host = tmp_path / "host" + build_acme_sibling(sibling) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, OID_A, None) + for key, value in _routed_env(sibling).items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("GIT_DIR", raising=False) + manifest = load_manifest(str(host)).manifest + assert manifest is not None + r = update_pin_run(str(host), manifest, "product-context", fetch=True) + assert r.action == "updated", r.reason + assert json.loads((host / "leji.json").read_text())["federation"]["mounts"][0]["pin"] == OID_B + + +def test_a_target_that_cannot_be_retained_under_fetch_refuses_the_move(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") + (host / ".leji").mkdir(parents=True, exist_ok=True) + (host / ".leji" / "mounts.local.json").write_text( + json.dumps({"mounts": {"product-context": {"repo": str(sibling)}}}) + "\n", + encoding="utf-8", + ) + before = (host / "leji.json").read_bytes() + # By the time the TARGET is retained the store already holds it, so the fetch + # never runs and only the ref update can fail: the injection is the branch's one + # reachable path. It names the TARGET, so retaining the current pin — the act + # before the gate — still succeeds and the refusal is unambiguous. + env = _routed_env(sibling) + env["LEJI_TEST_FAIL_PIN_REF"] = OID_B + 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["action"] == "refused" + 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"] + 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 + + +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" + build_acme_sibling(sibling) + shutil.copytree(FIXTURES / "warn-update-pin", host) + repin(host, OID_A, "keep") + build_store( + host, + sibling, + {"pin": OID_A, "witnessRef": "refs/heads/main", "witnessOid": OID_B, "depth": None}, + ) + mp = host / "leji.json" + with pytest.raises(RuntimeError, match=r'pin of mount "product-context" is not'): + replace_mount_pin_in_manifest_text( + mp.read_text(encoding="utf-8"), "product-context", OID_S, OID_B + ) + # And the same refusal reaches the CLI as exit 2 with no document at all. + code, stdout = run_cli_proc( + [ + "mounts", + "update-pin", + "product-context", + "--to", + "z" * 40, + "--root", + str(host), + "--json", + ], + _plain_env(), + ) + assert code == 2, "a malformed --to never reaches the scanner" + assert stdout.strip() == "" + + +# --- the CLI surface ---------------------------------------------------------- + + +def test_the_mounts_sub_guard_accepts_update_pin_and_rejects_everything_else( + capsys, tmp_path +) -> None: + from leji.cli import main + + shutil.copytree(FIXTURES / "warn-update-pin", tmp_path / "layer") + layer = str(tmp_path / "layer") + # Accepted spellings reach their command (never the sub-guard's exit 2)… + for sub in ("hydrate", "status", "locate", "update-pin"): + argv = ["mounts", sub] + if sub in ("locate", "update-pin"): + argv.append("product-context") + argv += ["--root", layer] + assert main(argv) != 2, " ".join(argv) + capsys.readouterr() + # …and every other spelling, including a bare `mounts`, is the guard. + for sub in ([], ["nope"], ["update"], ["updatepin"], ["update-pins"], ["Update-Pin"]): + assert main(["mounts", *sub, "--root", layer]) == 2, f"mounts {' '.join(sub)}" + capsys.readouterr() + + +def test_update_pin_takes_one_positional_and_only_its_declared_flags(capsys, tmp_path) -> None: + from leji.cli import main + + shutil.copytree(FIXTURES / "warn-update-pin", tmp_path / "layer") + layer = str(tmp_path / "layer") + + def run(argv: list[str]) -> int: + code = main([*argv, "--root", layer]) + capsys.readouterr() + return code + + # The positional budget gains this command's one name, as `mounts locate` has. + assert run(["mounts", "update-pin", "product-context"]) != 2 + assert run(["mounts", "update-pin", "product-context", "surplus"]) == 2 + assert run(["mounts", "update-pin"]) == 2, "the name is required" + # Flags declared on this command are accepted; a flag declared elsewhere is not, + # and neither is a destination parameter, which this command has none of. + assert run(["mounts", "update-pin", "product-context", "--dry-run", "--fetch"]) != 2 + for argv in ( + ["mounts", "update-pin", "product-context", "--check-integrity"], + ["mounts", "update-pin", "product-context", "--strict"], + ["mounts", "update-pin", "product-context", "--endpoint", "x"], + ["mounts", "status", "--to", OID_B], + ["mounts", "status", "--allow-non-fast-forward"], + ): + assert run(argv) == 2, " ".join(argv) + # `--to` takes a full lowercase hex commit id in either spelling, and nothing else. + assert run(["mounts", "update-pin", "product-context", f"--to={OID_B}"]) != 2 + assert run(["mounts", "update-pin", "product-context", "--to", "0" * 64]) != 2 + for bad in ("xyz", OID_B[:12], OID_B.upper(), "0" * 41, "0" * 63, ""): + assert run(["mounts", "update-pin", "product-context", "--to", bad]) == 2, f"--to {bad}" + assert run(["mounts", "update-pin", "product-context", "--to", "--json"]) == 2 + # The override is meaningless without a named target, and says so. + assert run(["mounts", "update-pin", "product-context", "--allow-non-fast-forward"]) == 2 + + +def test_update_pin_help_exits_zero_and_names_no_network_destination(capsys) -> None: + from leji.cli import main + + assert main(["mounts", "update-pin", "--help"]) == 0 + help_text = capsys.readouterr().out + assert "leji mounts update-pin" in help_text + # The only network vocabulary this command may carry is what `mounts hydrate` + # already documents: the declared source, and nothing addressable by the caller. + for banned in ("endpoint", "token", "upload", "registry", "api.", "http://", "account"): + assert banned not in help_text.lower(), f'help must not mention "{banned}"' diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 1c5d5ba..3dc247b 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -12,6 +12,7 @@ leji index --check # fail when the index is stale leji changelog check # append-only discipline leji freshness # review-horizon report leji conformance # score the layer against its claimed level +leji badge # write the self-attested conformance badge and its markdown leji status # unindexed, dangling, and stale documents leji route # the governed context a task's scope routes to leji viewer # generate the static viewer for the context layer @@ -26,15 +27,23 @@ leji agent --name # bind an additional named agent into the layer leji mounts hydrate # materialize declared federation mounts into the resolver cache leji mounts status # each mount's availability, integrity, and pin ancestry leji mounts locate # resolver state for one mount: projection path, pin, verification +leji mounts update-pin # move one mount's declared pin, verified against the source leji changelog compact # fold the oldest changelog entries into one compaction entry ``` See the full command reference (flags, exit codes, examples) at https://leji.org/cli/. +Inside a repository that declares `@leji-org/leji`, has it installed under +`node_modules`, and whose copy meets the layer's minimum, `leji` runs that copy; +set `LEJI_NO_LOCAL` to any value to run this one. Yarn Plug'n'Play installs no +`node_modules`, so there is no installed copy to run and this one answers. + Behaviorally identical to the `leji` package on PyPI and the Go SDK: same commands, same flags, same findings, same exit codes (0 clean, 1 findings, 2 -usage error). All three implementations are tested against one shared fixture +usage error); the one runtime-specific behavior is the hand-off above, which the +Node and Python CLIs perform and the Go CLI does not (there, run the pinned copy +with `go tool leji`). All three implementations are tested against one shared fixture suite. Install whichever matches your toolchain; agents and CI see the same tool either way. diff --git a/packages/sdk/assets-manifest.json b/packages/sdk/assets-manifest.json index fc4933a..8f441ca 100644 --- a/packages/sdk/assets-manifest.json +++ b/packages/sdk/assets-manifest.json @@ -5,24 +5,23 @@ "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:94d2f8503120a19c77e5a742158a790cdce0223a211376fbc34d92b0b3b95c56", + "schemas/context-manifest.schema.json": "sha256:dd24a91bb4938f6b6b986928140a997774d5c90bcb720bfc57ef5d7e332b56e2", "schemas/decision-record.schema.json": "sha256:f5db3e68be8b2233b9029949d79109b4784ce43ef1a1cd26e44a0427c8915b07", "templates/README.md": "sha256:3fa28c144a26076cc75dc2a6d23014d61370abcb2073afa7d5bd3f26af4884c7", "templates/agent-profile.md": "sha256:fb1cf77aeaffc10718795231b936a7eab9b54c9221f4de072677e3afd3656545", - "templates/agents/core.md": "sha256:6118a42d72712be08e10ba21843894b6afb895b350af6f5a308375f7d7281c5a", - "templates/boot-profile.md": "sha256:07f4b6a7ca03ffdbb5a2bebfc668560fbbffc6bb81dfc5bde2d775a21f647490", + "templates/agents/core.md": "sha256:496814fd60776312c3d4e96547defa17bcd26c55af6f6c688c22d19c842c0295", + "templates/boot-profile.md": "sha256:e3cfe63f45bc0ce0f761f90542bc8eb2afeca995072d143f2cb9b1efb409d365", "templates/decision-record.md": "sha256:ea122e7ceb5984b1610c66df2503128d619b312b5b5fc57c29f4e017a300c91c", "templates/identity.md": "sha256:dc9b30ab64ba13f27db998c1bb070e5f560b8c442881566f3426fcf637fbfc62", "templates/leji.json": "sha256:4b265b83901e8995a4aa0f81984bda6a4b26373cec15baa7b825e832942d34cc", - "templates/onboarding-brief.md": "sha256:adc7d29025dc020f4a8c8c6d709560169df48b3bc15cee2bd3eedb5f09c4962a", - "templates/viewer/assets/PROVENANCE.txt": "sha256:d07eae4c00e37c93fde3b2978d54ba67a70cd4cd84541c74da3a7eaa85e56e42", + "templates/onboarding-brief.md": "sha256:3a4296e23829ca2cb64a87a3b009ac0e4d9a9539af79e22e3046caf74c45adbf", + "templates/viewer/assets/PROVENANCE.txt": "sha256:75425f589e57e4a3198eafc74b37030a5cfbb434abcb4990bf5d5e28bd808cb0", "templates/viewer/assets/docsify-copy-code.min.js": "sha256:942bc51b3bfb12be62f7be9edf67a85f441a0ec740d051db4e8fba3a8f6cb41c", "templates/viewer/assets/docsify-mermaid.js": "sha256:4850a5afd73684cd0b7d399f63aa71003cff754f0cdfc00de92427d5fe03b8c7", "templates/viewer/assets/docsify-sidebar-collapse.min.css": "sha256:ef27a5cc38b5fe5608afd766a9d3c181ab981131398a05fc2b37ddfa0b5abdc9", "templates/viewer/assets/docsify-sidebar-collapse.min.js": "sha256:78282f65a6dc77f098ad856b32f64b167e363cc4c8d8767879ab8c4577c5b363", "templates/viewer/assets/docsify.min.js": "sha256:9123f808d3f6ad736b4a8f99944a611f87c5d4f9328030080a5c029ed5f450a5", - "templates/viewer/assets/fonts-licenses.txt": "sha256:0a7d0278d1bbf74d54d6b844832e19a4f5ccb817ff24770a6059130dd48758b5", - "templates/viewer/assets/leji-logo.svg": "sha256:85cf45fdc760047cf44fa8d001a247634095a83886d55f866ac699dc44257ca0", + "templates/viewer/assets/leji-logo.svg": "sha256:3af8bf13388fda8bb02997a23c97ec8fc155965bd13dbdf8dcbdace3556a8650", "templates/viewer/assets/mermaid.min.js": "sha256:217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87", "templates/viewer/assets/prism-bash.min.js": "sha256:89c99aa252fb53a05447998bd1f4ab9ac66010f77d43e7ca6685a3598adb4462", "templates/viewer/assets/prism-json.min.js": "sha256:41558ab2e462d9c2b14aba1966684419b74cc425b02884c8010337bac91ec63e", @@ -41,10 +40,11 @@ "templates/viewer/assets/source-sans-pro-600-latin-ext.woff2": "sha256:9d8b9b83f39fe3768c876486e92bb995c1a92c9e85b69481da84e5444ecc980f", "templates/viewer/assets/source-sans-pro-600-latin.woff2": "sha256:156650610835fe32914722ecfc8dab0ebbb84795e201b842158afa0ea873cfa4", "templates/viewer/assets/source-sans-pro-600-vietnamese.woff2": "sha256:615c0d875de2ec25e22bba41b5cd0e1184517a90916cfac8a4be8467539a5c8f", - "templates/viewer/assets/viewer-boot.js": "sha256:89be9a6cd3c3902b358e7dadcb7ad2ecaf34773c30d1b993af5a8e086db1b611", - "templates/viewer/assets/vue.css": "sha256:9c87099992a5da838a432ffacbdf86705603b916a726c382521d050fbda4564a", + "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/zoom-image.min.js": "sha256:c142e32432c4fd0d47ea1a6d5640a66d4ffa9a331496a5bdb45c0449f6d381f9", - "templates/viewer/index.html": "sha256:d5dd1eca373320b26918ef57836100531ebeb53281fc7198b14e9e8b865a13b6", + "templates/viewer/index.html": "sha256:127dadfdca91e9e93739b4e2898ab1ec33fe0b5658354288d960e3e2989f6998", "templates/writing-style.md": "sha256:ee17bb1b97cbe87c4d8ef59b80b2e1d03d997d8839a98d3eb2080c540efa7b2c" } } diff --git a/packages/sdk/cli.json b/packages/sdk/cli.json index 72496e2..4f21624 100644 --- a/packages/sdk/cli.json +++ b/packages/sdk/cli.json @@ -1,11 +1,11 @@ { "name": "leji", - "summary": "Reference CLI for the Leji specification: validate, index, changelog, freshness, conformance, status, route, viewer/view, detect, adopt, init, start, ci, and agent for a shared context layer.", + "summary": "Reference CLI for the Leji specification: validate, index, changelog, freshness, conformance, badge, status, route, mounts, export, viewer/view, detect, adopt, init, start, ci, and agent for a shared context layer.", "usage": "leji [options]", "globalOptions": [ { "flags": "--root ", - "summary": "Repository root to operate on (default: the current directory)." + "summary": "Repository root to operate on (default: the current directory). With the Node and Python CLIs, a root that declares and installs the Leji CLI for that runtime, meeting the layer's minimum, runs that copy." }, { "flags": "--json", @@ -34,9 +34,242 @@ "meaning": "Usage error, or an internal failure (e.g. init refusing to overwrite)." } ], + "groups": [ + { + "id": "start", + "title": "Get started" + }, + { + "id": "everyday", + "title": "Every day" + }, + { + "id": "federation", + "title": "Federation" + }, + { + "id": "viewer", + "title": "Viewer and export" + } + ], "commands": [ + { + "name": "init", + "group": "start", + "summary": "Bootstrap a new context layer from the templates.", + "usage": "leji init [--dir ] [--yes] [--mode ] [--level ] [--name ] [--agent ] [--no-agents] [--dry-run] [--json]", + "description": "Scaffolds a new context layer from the templates.", + "details": [ + "Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the portable entrypoint many agent hosts read, redirecting to the boot profile; `--no-agents` skips it), seeded category documents, a first decision record, an agent onboarding brief, and a generated index, so the scaffold is ready for the CI job `leji ci` writes. At the indexed level it also writes the machine changelog. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", + "`--mode solo` (a team of one) also seeds identity and writing-style starters, maps the practice category, routes identity and writing work in the boot profile, and points the onboarding brief at the owner interview (answer in text or with dropped files).", + "Refuses to overwrite an existing `leji.json`, and refuses when the git tree has uncommitted changes; never overwrites individual files.", + "Reports the repository's dependency ecosystem (its package manager, from the manifest and lockfiles present) and how to declare the Leji CLI as a dev dependency there, so a clean install brings `leji`; on a real terminal it offers to run that manager's own add command, and only on your explicit yes. `--yes`, a non-TTY and `--json` print the command instead of running it, and leji never edits a manifest or lockfile itself.", + "`--dry-run` prints the write plan without writing.", + "Also backs `npm create leji`." + ], + "options": [ + { + "flags": "--dir ", + "summary": "Target directory (default: the current directory)." + }, + { + "flags": "--yes, -y", + "summary": "Accept all defaults; run non-interactively." + }, + { + "flags": "--mode ", + "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." + }, + { + "flags": "--level ", + "summary": "Conformance level to claim: core or indexed (default: core)." + }, + { + "flags": "--name ", + "summary": "Context layer name (default: derived from the directory)." + }, + { + "flags": "--agent ", + "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." + }, + { + "flags": "--no-agents", + "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." + }, + { + "flags": "--dry-run", + "summary": "Print the write plan and exit without creating any files." + } + ], + "examples": [ + "leji init", + "leji init --dry-run", + "leji init --mode solo", + "leji init --agent claude-code" + ] + }, + { + "name": "adopt", + "group": "start", + "summary": "Adopt Leji into an existing repository.", + "usage": "leji adopt [--dir ] [--yes] [--mode ] [--agent ] [--wire-adapters] [--no-agents] [--dry-run] [--json]", + "description": "Brings Leji into a repository that already has docs and agent config.", + "details": [ + "Reuses an existing `docs/` root, migrates any vendor entrypoints (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without modifying the originals, and seeds the scaffold.", + "Writes a generated index, so the adopted context layer is ready for the CI job `leji ci` writes. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", + "`--wire-adapters` converts those entrypoints to one-line redirects, after migrating their content.", + "When no `AGENTS.md` exists, writes a pointer-only one (the portable entrypoint many agent hosts read) redirecting to the boot profile; `--no-agents` skips it, and an existing file is never touched.", + "`--mode solo` (a team of one) also seeds identity and writing-style starters and points the onboarding brief at the owner interview; existing files are never overwritten.", + "Refuses when a `leji.json` exists, `--dry-run` included: a repository that already has a context layer has nothing to adopt. Also refuses when the git tree has uncommitted changes, which `--dry-run` is exempt from because it writes nothing.", + "Reports the repository's dependency ecosystem (its package manager, from the manifest and lockfiles present) and how to declare the Leji CLI as a dev dependency there, so a clean install brings `leji`; on a real terminal it offers to run that manager's own add command, and only on your explicit yes. `--yes`, a non-TTY and `--json` print the command instead of running it, and leji never edits a manifest or lockfile itself.", + "Also backs `npm create leji` on a repository that already carries docs or an agent entrypoint." + ], + "options": [ + { + "flags": "--dir ", + "summary": "Target directory (default: the current directory)." + }, + { + "flags": "--yes, -y", + "summary": "Accept all defaults; run non-interactively." + }, + { + "flags": "--mode ", + "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." + }, + { + "flags": "--agent ", + "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." + }, + { + "flags": "--wire-adapters", + "summary": "Convert present vendor entrypoints to redirects (consented; content migrated first)." + }, + { + "flags": "--no-agents", + "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." + }, + { + "flags": "--dry-run", + "summary": "Print the write plan and exit without changing anything." + } + ], + "examples": [ + "leji adopt", + "leji adopt --dry-run", + "leji adopt --mode solo", + "leji adopt --wire-adapters" + ] + }, + { + "name": "start", + "group": "start", + "summary": "Open a coding agent in this context layer, booted from the boot profile.", + "usage": "leji start [--agent ] [--root ] [--json] [-- ]", + "description": "Detects an installed agent (or use --agent), launches it from the context root, and points it at the boot profile so it loads the team's context first. The agent-facing counterpart to `leji view`. Several detected agents prompt for which; with none detected or in a non-interactive shell, it prints the command to run. Everything after a literal -- passes verbatim to the launched host binary, before the boot prompt. Host-specific flags ride with a pinned host: `leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which could hand the flag to whichever host gets picked.", + "details": [ + "Before the agent starts, prints a Setup block for this clone: whether the Leji CLI this repository declares resolves here and meets the minimum version for the layer's spec line, whether the MCP server is registered for the selected host, whether the shared `.mcp.json` is committed, and whether the pre-commit hook is installed.", + "Each row is personal or shared. Personal state (your host's MCP registration, this clone's `.git` hook) is offered on a real terminal and printed as an exact command otherwise; shared state (the dependency declaration, a committed `.mcp.json`, a hooks directory inside the working tree) is only ever reported, with the command a maintainer runs and commits. A gap never blocks entry: the agent still boots.", + "`--json` makes it report-only: one document with `ready` and the same checks, no prompts and no launch, exit 0 even when `ready` is false. The launch-selection arguments are accepted and have no effect there; an `--agent` naming no launchable host is still a usage error." + ], + "options": [ + { + "flags": "--agent ", + "summary": "Launch a specific host (claude-code or codex) instead of auto-detecting." + }, + { + "flags": "-- ", + "summary": "Pass the remaining arguments verbatim to the launched host binary; pin --agent when they are host-specific." + } + ], + "examples": [ + "leji start", + "leji start --agent codex", + "leji start --agent claude-code -- --chrome" + ] + }, + { + "name": "agent", + "group": "start", + "summary": "Bind an additional named agent into an existing context layer.", + "usage": "leji agent --name [--host ] [--role ] [--root ] [--json]", + "description": "Adds a second (or third) agent to a context layer that already has a `leji.json`.", + "details": [ + "Writes a starter agent profile under the agent-profiles path and binds it in the manifest's agents map via an in-place edit that preserves the rest of the file.", + "Never writes an agent-host entrypoint file. The portable `AGENTS.md` pointer is written by `init` and `adopt` (unless `--no-agents`); single-vendor files like `CLAUDE.md` are only ever converted from an existing one by `adopt --wire-adapters`.", + "`--host` is optional: a host pins the profile to a specific external CLI; with none, it's a host-agnostic resident agent any host can run.", + "The role defaults to reviewer; pass `--role` for a different one.", + "Binding the `default` key prints a note: `agents.default` selects a role profile, it does not load it, so instructions that must apply before every task belong in the boot profile rather than that profile. This note prints once, when the binding is written.", + "Idempotent: an existing profile or binding is left untouched. Requires an existing context layer." + ], + "options": [ + { + "flags": "--name ", + "summary": "Name for the agent; also its profile id and agents-map key (kebab-case)." + }, + { + "flags": "--host ", + "summary": "Optional. Pin the profile to a host (claude-code, codex, copilot, gemini, cursor, windsurf; aliases ok); omit for a host-agnostic resident agent." + }, + { + "flags": "--role ", + "summary": "Role the agent fills (default: reviewer)." + } + ], + "examples": [ + "leji agent --name porter --role porter", + "leji agent --host codex --name reviewer", + "leji agent --host claude-code --name thought-partner --role advisor" + ] + }, + { + "name": "detect", + "group": "start", + "summary": "Detect the coding-agent hosts available on this machine.", + "usage": "leji detect [--root ] [--json]", + "description": "Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable binary, a config file in the repository, or a user-level config directory. Writes nothing; use it to decide which host to open with `leji start --agent ` (launchable hosts today: claude-code and codex; other detected hosts enter the context layer through their vendor-file redirect). Also reports the repository's own dependency ecosystem: the package manager its manifest and lockfiles name, whether the Leji CLI is already declared as a dev dependency there, and the command that would declare it.", + "options": [], + "examples": [ + "leji detect", + "leji detect --json" + ] + }, + { + "name": "ci", + "group": "start", + "summary": "Add a CI workflow that runs leji validate and index --check on every change.", + "usage": "leji ci [--provider ] [--hooks] [--root ] [--json]", + "description": "Adds a CI job that runs `leji validate` and `leji index --check` on every change, so your context layer stays honest in CI (the same two gates the `--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place is left untouched.", + "details": [ + "The provider is inferred from the `origin` remote (a github.com host selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure Pipelines) and falls back to GitHub when the remote names none; `--provider` overrides the inference, and CircleCI is never inferred.", + "GitHub: writes its own workflow at `.github/workflows/leji.yml`.", + "GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if it's absent.", + "CircleCI: writes `.circleci/config.yml` if absent; when a config already exists that leji did not generate, prints a snippet to add by hand instead of editing it.", + "Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not auto-discover it, so activation is manual: create a pipeline that points at the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), then add a build-validation branch policy on `main` for pull-request checks. This activation note prints once, on first creation.", + "Local hook (`--hooks`): writes a managed pre-commit running `leji validate` and `leji index --check` through the same detected runner the CI job uses (`'pnpm' 'exec' 'leji' validate`, and so on), each argument single-quoted for the shell; a repository that does not declare the CLI runs the `leji` on PATH. `core.hooksPath` is detected, so a husky repo gets a managed block merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an existing unmanaged hook is never touched, its snippet printed to add by hand.", + "Local-first, through the package manager this repository actually uses: `leji ci` detects it from the manifest and lockfiles present, and when the repository DECLARES the Leji CLI and carries that manager's lock evidence the generated job installs its locked dependencies and runs the local binary (`corepack enable && pnpm install --frozen-lockfile` then `pnpm exec leji`, `uv sync --locked` then `uv run leji`, `go mod download` then `go tool leji`, and so on). Everything else takes a fallback that needs no manifest: `npx @leji-org/leji@1` for Node, several ecosystems and none; `pip install 'leji>=1,<2'` for Python; `go install .../cmd/leji@latest` for Go. A bootstrap tool the job installs unpinned (poetry, pdm, pipenv, uv outside GitHub) is disclosed in one comment line.", + "Generated files carry the marker `# generated by leji ci (managed) v2`. A re-run replaces a whole file (GitHub, CircleCI, Azure) only when its bytes are ones leji generated, in this release or an earlier one, so a manager change or an upgrade refreshes the job; a generated file you edited, and any file you wrote yourself, are left untouched with a snippet to add by hand. Editing the file, or deleting the marker, is the opt-out. GitLab owns only its marker-delimited block inside `.gitlab-ci.yml`." + ], + "options": [ + { + "flags": "--hooks", + "summary": "Write a managed local pre-commit running validate + index --check; core.hooksPath is detected, so a husky repo gets a managed block in .husky/pre-commit, and an existing unmanaged hook is left untouched with the snippet printed." + }, + { + "flags": "--provider ", + "summary": "CI provider: github (default when no remote is recognizable), gitlab, circleci, or azure. Without this flag the provider is inferred from the origin remote." + } + ], + "examples": [ + "leji ci", + "leji ci --provider gitlab", + "leji ci --provider azure", + "leji ci --hooks" + ] + }, { "name": "validate", + "group": "everyday", "summary": "Validate the context layer: manifest, artifacts, frontmatter, and lint rules.", "usage": "leji validate [--content] [--federation [--paths ]] [--root ] [--json]", "description": "Loads leji.json and checks it against the schemas and the lint rules: declared files exist, categories are populated, vendor entrypoints redirect to the boot profile, frontmatter is valid, and (per the claimed conformance level) the index is current and the changelog is append-only. One of the two gates the generated CI runs, beside `leji index --check`. With --content it also runs a warning-only content lint (placeholder text, generic boot identity, thin categories) that never errors and never affects a conformance level.", @@ -62,6 +295,7 @@ }, { "name": "index", + "group": "everyday", "summary": "Generate the context index at the declared path, or verify it is current.", "usage": "leji index [--check] [--root ] [--json]", "description": "Resolves the category index files to the documents they list and writes the context index to machine.indexPath. Ids are carried across a move when the move is unambiguous: a document whose path changes keeps its id if its content is unchanged and that content is unique in the context layer. A move that also edits the content, or that moves one of several byte-identical documents, cannot be carried and mints a fresh id. Declare a frontmatter id to make a document's id survive any move; that is the only unconditional guarantee. With --check it writes nothing and instead fails when the stored index no longer matches what the index files resolve to (a stale index is a hard failure).", @@ -77,81 +311,82 @@ ] }, { - "name": "changelog check", - "summary": "Verify the machine changelog: schema and append-only discipline.", - "usage": "leji changelog check [--strict] [--root ] [--json]", - "description": "Validates the declared changelog against its schema and checks append-only discipline against the committed state of the file at HEAD: surviving entries are immutable, and entries may be removed only from the oldest end and only alongside a compaction entry. The comparison is against HEAD, so it catches an uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI checkout the working tree is HEAD, so it does not by itself detect a rewrite that arrives already committed. Reviewing the diff covers that. Without git the discipline is unverifiable and reported as a warning.", + "name": "status", + "group": "everyday", + "summary": "Report unindexed, dangling, and stale documents in the context layer.", + "usage": "leji status [--strict] [--root ] [--json]", + "description": "Informational health report: markdown under the context root that no category index lists (reference content), index entries whose listed path does not resolve (dangling), and stored-index paths the index files no longer resolve to (stale). It also reports shadowed entries and skipped READMEs, which are informational only, and whether the context layer at HEAD would project completely if a host mounted it (the closure enumerated, the failure detail, or no commit to judge). Report-only by default; exit 0. With --strict, exits nonzero when an unindexed, dangling, stale, or pending document is flagged (shadowed and skipped-README entries never fail the run), for CI use.", "options": [ { "flags": "--strict", - "summary": "Treat an unverifiable append-only check (no git baseline) as an error." + "summary": "Exit nonzero when an unindexed, dangling, stale, or pending document is flagged, for CI." } ], "examples": [ - "leji changelog check", - "leji changelog check --strict" + "leji status", + "leji status --strict --json" ] }, { - "name": "changelog compact", - "summary": "Fold the oldest changelog entries into a single compaction entry.", - "usage": "leji changelog compact [--keep ] [--before ] [--root ] [--json]", - "description": "Compacts the oldest end of the machine changelog, folding entries into a single compaction entry that records how many were folded and the id range removed.", - "details": [ - "Selection: `--keep ` folds every entry except the newest n; `--before ` folds entries dated before the given day. With both, an entry folds only if it satisfies both (the intersection).", - "At least one of `--keep` or `--before` is required.", - "The folded set is always a contiguous run from the oldest end, so the result still satisfies the append-only discipline that `leji changelog check` enforces." - ], + "name": "conformance", + "group": "everyday", + "summary": "Score the context layer against its claimed conformance level.", + "usage": "leji conformance [--explain] [--federation verify] [--root ] [--json]", + "description": "Runs the `core`, `indexed`, `governed`, and `federated` checklists. Machine-checkable items pass or fail; process items (review gate, CI, external consumers) are reported as manual. A machine failure at or below the claimed level is an error; evidence this run could not obtain reports `unknown`, which caps the verified level without refuting the claim. With --explain it also prints what it would take to reach the next level.", "options": [ { - "flags": "--keep ", - "summary": "Keep the newest n entries; fold everything older. Must be a positive integer." + "flags": "--explain", + "summary": "Print actionable guidance for reaching the next conformance level." }, { - "flags": "--before ", - "summary": "Fold entries dated strictly before this YYYY-MM-DD day." + "flags": "--federation verify", + "summary": "Run the networked pin-reachability probe against each mount's source (git ls-remote + witness-ref ancestry). Without it the pin-reachable item reports unknown, which never awards the federated level." } ], "examples": [ - "leji changelog compact --keep 50", - "leji changelog compact --before 2026-01-01", - "leji changelog compact --keep 50 --before 2026-01-01" + "leji conformance", + "leji conformance --explain", + "leji conformance --json" ] }, { - "name": "freshness", - "summary": "Report review horizons across category documents and agent profiles.", - "usage": "leji freshness [--strict] [--root ] [--json]", - "description": "Lists documents whose freshness.reviewAfter horizon has passed (expired) or falls within the next 30 days (upcoming). Report-only by default; expired horizons are warnings.", + "name": "badge", + "group": "everyday", + "summary": "Write the self-attested conformance badge for this repository.", + "usage": "leji badge [--root ] [--out ] [--json]", + "description": "Writes one SVG (default: leji-badge.svg at the repository root) and prints the markdown line that embeds it. Self-attested: the badge states the level `leji conformance` verified in this offline run, never more than the layer claims and possibly less, and a claim this run could not confirm is named beside it. Nothing is sent anywhere and no service or registry is involved; the bytes are constants, and the file is yours to commit. A run with an error finding, or one that verified no level at all, writes nothing and exits 1. An existing target is replaced only when its bytes are a badge this command wrote, which is how a level change regenerates; any other file is left untouched and the run refuses.", "options": [ { - "flags": "--strict", - "summary": "Treat expired horizons as errors instead of warnings." + "flags": "--out ", + "summary": "Where to write the badge (default: leji-badge.svg). A repository-relative POSIX path over [A-Za-z0-9._/-] with no \"..\" segment, ending .svg, resolving inside the repository and never inside .leji/." } ], "examples": [ - "leji freshness", - "leji freshness --strict --json" + "leji badge", + "leji badge --out docs/badge.svg", + "leji badge --json" ] }, { - "name": "status", - "summary": "Report unindexed, dangling, and stale documents in the context layer.", - "usage": "leji status [--strict] [--root ] [--json]", - "description": "Informational health report: markdown under the context root that no category index lists (reference content), index entries whose listed path does not resolve (dangling), and stored-index paths the index files no longer resolve to (stale). It also reports shadowed entries and skipped READMEs, which are informational only, and whether the context layer at HEAD would project completely if a host mounted it (the closure enumerated, the failure detail, or no commit to judge). Report-only by default; exit 0. With --strict, exits nonzero when an unindexed, dangling, stale, or pending document is flagged (shadowed and skipped-README entries never fail the run), for CI use.", + "name": "freshness", + "group": "everyday", + "summary": "Report review horizons across category documents and agent profiles.", + "usage": "leji freshness [--strict] [--root ] [--json]", + "description": "Lists documents whose freshness.reviewAfter horizon has passed (expired) or falls within the next 30 days (upcoming). Report-only by default; expired horizons are warnings.", "options": [ { "flags": "--strict", - "summary": "Exit nonzero when an unindexed, dangling, stale, or pending document is flagged, for CI." + "summary": "Treat expired horizons as errors instead of warnings." } ], "examples": [ - "leji status", - "leji status --strict --json" + "leji freshness", + "leji freshness --strict --json" ] }, { "name": "route", + "group": "everyday", "summary": "Show the governed context a task's scope routes to.", "usage": "leji route [--paths ] [--categories ] [--topics ]... [--as-of ] [--root ] [--json]", "description": "Read-only: given a task's scope (repository-relative paths it reads or changes, plus any categories and topics it names), print the slice of governed context that scope selects per the Task routing algorithm. Paths select the governed entries that contain them or are contained by them, and a path that is itself a governed document signals that document's category for decision and mount matching without expanding it; only a category the task explicitly names expands that category's intent documents and record candidates. Topics select sibling mounts and nothing else. Prints the expanded categories and the signalled ones, the governed documents (with each document's review horizon and whether it has expired), the record candidates a reader loads by judgment, the live decision records routed to the task, and the sibling mounts the supplied category and topic signals match. It computes the scope-dependent portion only: the boot profile's unconditional load set and the active agent profile's requiredRead are the caller's baseline and are never emitted here. Reads and reports context; it never executes a task.", @@ -188,28 +423,52 @@ ] }, { - "name": "conformance", - "summary": "Score the context layer against its claimed conformance level.", - "usage": "leji conformance [--explain] [--federation verify] [--root ] [--json]", - "description": "Runs the `core`, `indexed`, `governed`, and `federated` checklists. Machine-checkable items pass or fail; process items (review gate, CI, external consumers) are reported as manual. A machine failure at or below the claimed level is an error; evidence this run could not obtain reports `unknown`, which caps the verified level without refuting the claim. With --explain it also prints what it would take to reach the next level.", + "name": "changelog check", + "group": "everyday", + "summary": "Verify the machine changelog: schema and append-only discipline.", + "usage": "leji changelog check [--strict] [--root ] [--json]", + "description": "Validates the declared changelog against its schema and checks append-only discipline against the committed state of the file at HEAD: surviving entries are immutable, and entries may be removed only from the oldest end and only alongside a compaction entry. The comparison is against HEAD, so it catches an uncommitted rewrite (which is what the pre-commit hook uses it for); in a CI checkout the working tree is HEAD, so it does not by itself detect a rewrite that arrives already committed. Reviewing the diff covers that. Without git the discipline is unverifiable and reported as a warning.", "options": [ { - "flags": "--explain", - "summary": "Print actionable guidance for reaching the next conformance level." + "flags": "--strict", + "summary": "Treat an unverifiable append-only check (no git baseline) as an error." + } + ], + "examples": [ + "leji changelog check", + "leji changelog check --strict" + ] + }, + { + "name": "changelog compact", + "group": "everyday", + "summary": "Fold the oldest changelog entries into a single compaction entry.", + "usage": "leji changelog compact [--keep ] [--before ] [--root ] [--json]", + "description": "Compacts the oldest end of the machine changelog, folding entries into a single compaction entry that records how many were folded and the id range removed.", + "details": [ + "Selection: `--keep ` folds every entry except the newest n; `--before ` folds entries dated before the given day. With both, an entry folds only if it satisfies both (the intersection).", + "At least one of `--keep` or `--before` is required.", + "The folded set is always a contiguous run from the oldest end, so the result still satisfies the append-only discipline that `leji changelog check` enforces." + ], + "options": [ + { + "flags": "--keep ", + "summary": "Keep the newest n entries; fold everything older. Must be a positive integer." }, { - "flags": "--federation verify", - "summary": "Run the networked pin-reachability probe against each mount's source (git ls-remote + witness-ref ancestry). Without it the pin-reachable item reports unknown, which never awards the federated level." + "flags": "--before ", + "summary": "Fold entries dated strictly before this YYYY-MM-DD day." } ], "examples": [ - "leji conformance", - "leji conformance --explain", - "leji conformance --json" + "leji changelog compact --keep 50", + "leji changelog compact --before 2026-01-01", + "leji changelog compact --keep 50 --before 2026-01-01" ] }, { "name": "mounts hydrate", + "group": "federation", "summary": "Materialize declared federation mounts into the resolver cache.", "usage": "leji mounts hydrate [--fetch] [--root ] [--json]", "description": "For each declared federation mount, resolves the pinned commit from a local object store (an explicit hint in .leji/mounts.local.json, the resolver-managed store, or a unique matching submodule's object database) and extracts the sibling's layer projection into the gitignored cache under .leji/mounts/. The projection is the deduplicated union of everything the sibling's own manifest makes readable at the pin: the root leji.json, the tree under its declared context root, its boot profile, its machine index and changelog files when present, its agent-profiles and decision-records trees when present, every agent profile its agents map binds, every category index file, and every governed path its pinned generated index lists, wherever those live. The failure boundary follows the same line: a referenced or schema-required file absent at the pin (the boot profile, a category index, a bound agent profile, an indexed governed path) fails the projection naming the declaring artifact and the missing path, while an absent directory or an absent machine artifact contributes nothing and fails nothing. The only mutating mounts command, and offline by default: --fetch establishes the resolver-managed store for every declared mount, including one a hint already resolves, fetching the pin from the declared source, retaining it under refs/leji-pin/v1/, and refreshing the managed witness under refs/leji-witness/v1/ (the only writer of that namespace, since `mounts status` never fetches). Best-effort: an unavailable mount is reported and skipped (degraded knowledge, never a failed run); the exit code reflects declaration, safety, or projection errors only.", @@ -226,6 +485,7 @@ }, { "name": "mounts status", + "group": "federation", "summary": "Report each mount's availability, integrity, and pin ancestry.", "usage": "leji mounts status [--check-integrity] [--root ] [--json]", "description": "Read-only diagnostics for the declared federation mounts: whether the pinned projection is present in the cache, and an ancestry-aware pin report against the declared witness ref (trackingRef) computed from a reachable local object store: up-to-date, behind N, ahead, diverged, unrelated, or unknown, always naming the compared ref, the category of repository the comparison ran in (comparisonRepository: managed-store, hint, or submodule), whether the witness was the resolver's own ref or one it does not own (witnessProvenance), the observation time, and ancestry completeness. --check-integrity additionally re-derives the projection from the object store and compares it byte-for-byte (paths, modes, symlinks) against the cache. Never mutates and never touches the network.", @@ -242,20 +502,52 @@ }, { "name": "mounts locate", + "group": "federation", "summary": "Print resolver state for one mount: projection path, pin, verification.", "usage": "leji mounts locate [--root ] [--json]", - "description": "Resolves a declared mount's hydrated projection through resolver state (never by inferring cache paths): the projection directory, the pin, whether the bytes are present, and whether they verified against a reachable object store this run. Readers obtain the mounted content's location from this command; a projection that cannot be verified is reported as present but unverified. Exits 0 when the projection is present, 1 otherwise.", + "description": "Resolves a declared mount's hydrated projection through resolver state (never by inferring cache paths): the projection directory, the pin, whether the bytes are present, and whether they verified this run. Readers obtain the mounted content's location from this command; a projection that cannot be verified is reported as present but unverified, which includes the case where a verification prerequisite (a reachable object store, a resolvable pin, a writable temp dir) is unavailable. Exits 0 when the projection is present, 1 otherwise.", "options": [], "examples": [ "leji mounts locate product-context", "leji mounts locate product-context --json" ] }, + { + "name": "mounts update-pin", + "group": "federation", + "summary": "Move a declared mount's pin forward to a witnessed commit, showing the comparison first.", + "usage": "leji mounts update-pin [--to ] [--allow-non-fast-forward] [--fetch] [--dry-run] [--root ] [--json]", + "description": "Rewrites one declared federation mount's pin in leji.json, after printing where that pin stands against its tracking ref. Offline by default: the target is the last successfully observed witness in a reachable object store (the resolver-managed store first, then a hint or a unique matching submodule holding both the pin and the ref), never a claim that the source was looked at during this run. --fetch observes the declared source and nothing else, in three acts: retain the current pin in the resolver-managed store, refresh the managed witness ref once, and retain the target once the comparison has passed; any of them failing refuses the move with a stable reason and leaves leji.json untouched, though objects and refs already fetched stay in the managed store. With no trackingRef declared the run refuses offline, and under --fetch resolves the source's advertised default branch for this run and reports it as the compared ref. The pin moves forward only: a target that is not a descendant of the current pin is refused unless BOTH --to and --allow-non-fast-forward are given, which is recorded as a warning and as override in --json; neither flag bypasses a repository whose ancestry is incomplete. --to takes a full 40- or 64-character lowercase hex commit id the comparison repository already holds. --dry-run computes and prints everything and writes no manifest byte; combined with --fetch it still performs that flag's store and network acts, so fetched objects and refs land in the managed store. Only the pin's own bytes are replaced, so field order, formatting and unmodeled keys survive. Hydration is a separate step: the run prints the leji mounts hydrate command that materializes the new pin, and the cache entry for the old pin is left in place for you to remove by hand. Exit 0 when the pin was updated, was already the target, or the run was a dry run; 1 when the move was refused with a stable reason; 2 for a usage error, or when the addressed pin cannot be located in leji.json.", + "options": [ + { + "flags": "--to ", + "summary": "Move to this exact commit instead of the witness tip; it must already be held by the comparison repository." + }, + { + "flags": "--allow-non-fast-forward", + "summary": "Permit a target that is not a descendant of the current pin. Valid only with --to, and always warned." + }, + { + "flags": "--fetch", + "summary": "Observe the declared source: retain the current pin, refresh the managed witness ref, and retain the target." + }, + { + "flags": "--dry-run", + "summary": "Show the comparison and what would change; write no manifest byte. With --fetch, the store and network acts still happen." + } + ], + "examples": [ + "leji mounts update-pin product-context", + "leji mounts update-pin product-context --fetch --dry-run", + "leji mounts update-pin product-context --to 7d3f2a19c4e8b6a0d5f1c2e9b8a7f6d5c4b3a2e1" + ] + }, { "name": "viewer", + "group": "viewer", "summary": "Generate the static viewer for the context layer.", "usage": "leji viewer [--root ] [--json]", - "description": "Projects the context index into a browsable Docsify viewer: writes a frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored viewer assets into the context layer's contained viewer directory. Presentation is non-normative; this is the reference projection. Generates only; use `leji viewer serve` (or `leji view`) to preview it locally, and `leji viewer build` to export a self-contained copy.", + "description": "Projects the context index into a browsable Docsify viewer: writes a frontmatter-stripping index.html, a deterministic _sidebar.md, and the vendored viewer assets into the context layer's contained viewer directory. Presentation is non-normative; this is the reference projection. Generates only; use `leji viewer serve` (or `leji view`) to preview it locally, and `leji export` (spelled `leji viewer build` inside the viewer subsystem) to write a self-contained static site.", "options": [], "examples": [ "leji viewer", @@ -264,6 +556,7 @@ }, { "name": "viewer serve", + "group": "viewer", "summary": "Generate the viewer and serve it locally.", "usage": "leji viewer serve [--port ] [--open] [--root ] [--json]", "description": "Generates the viewer, then serves it on localhost (a local preview, never hosting) at the web root. With --open it also opens your default browser at the viewer.", @@ -283,24 +576,10 @@ "leji viewer serve --port 0" ] }, - { - "name": "viewer build", - "summary": "Export a self-contained static viewer folder for internal hosting.", - "usage": "leji viewer build [--out ] [--root ] [--json]", - "description": "Regenerates the viewer and materializes it into a standalone static folder (default: .leji/viewer-dist/, kept out of git) that any host serves as-is. A custom --out must resolve inside the repository. The exported index.html warns that a context layer is sensitive and should be hosted behind internal authentication, not a public bucket.", - "options": [ - { - "flags": "--out ", - "summary": "Output directory for the export (default: .leji/viewer-dist inside the context root; must resolve inside the repository)." - } - ], - "examples": [ - "leji viewer build", - "leji viewer build --out dist/site" - ] - }, { "name": "view", + "group": "viewer", + "aliasOf": "viewer serve", "summary": "Alias for `leji viewer serve` (and opens the browser).", "usage": "leji view [--port ] [--root ]", "description": "One-word shortcut to browse the context layer: generates the viewer, serves it on localhost, and opens your default browser. Equivalent to `leji viewer serve --open`.", @@ -316,201 +595,47 @@ ] }, { - "name": "start", - "summary": "Open a coding agent in this context layer, booted from the boot profile.", - "usage": "leji start [--agent ] [--root ] [-- ]", - "description": "Detects an installed agent (or use --agent), launches it from the context root, and points it at the boot profile so it loads the team's context first. The agent-facing counterpart to `leji view`. Several detected agents prompt for which; with none detected or in a non-interactive shell, it prints the command to run. Everything after a literal -- passes verbatim to the launched host binary, before the boot prompt. Host-specific flags ride with a pinned host: `leji start --agent claude-code -- --chrome`, never bare `-- --chrome`, which could hand the flag to whichever host gets picked.", - "options": [ - { - "flags": "--agent ", - "summary": "Launch a specific host (claude-code or codex) instead of auto-detecting." - }, - { - "flags": "-- ", - "summary": "Pass the remaining arguments verbatim to the launched host binary; pin --agent when they are host-specific." - } - ], - "examples": [ - "leji start", - "leji start --agent codex", - "leji start --agent claude-code -- --chrome" - ] - }, - { - "name": "detect", - "summary": "Detect the coding-agent hosts available on this machine.", - "usage": "leji detect [--root ] [--json]", - "description": "Best-effort, read-only detection of installed agent hosts (Claude Code, Codex, Copilot, Gemini, Cursor, Windsurf), ranked by signal strength: a runnable binary, a config file in the repository, or a user-level config directory. Writes nothing; use it to decide which host to open with `leji start --agent ` (launchable hosts today: claude-code and codex; other detected hosts enter the context layer through their vendor-file redirect).", - "options": [], - "examples": [ - "leji detect", - "leji detect --json" - ] - }, - { - "name": "adopt", - "summary": "Adopt Leji into an existing repository.", - "usage": "leji adopt [--dir ] [--yes] [--mode ] [--agent ] [--wire-adapters] [--no-agents] [--dry-run]", - "description": "Brings Leji into a repository that already has docs and agent config.", - "details": [ - "Reuses an existing `docs/` root, migrates any vendor entrypoints (`CLAUDE.md`, `AGENTS.md`, and so on) into the context layer without modifying the originals, and seeds the scaffold.", - "Writes a generated index, so the adopted context layer is ready for the CI job `leji ci` writes. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", - "`--wire-adapters` converts those entrypoints to one-line redirects, after migrating their content.", - "When no `AGENTS.md` exists, writes a pointer-only one (the portable entrypoint many agent hosts read) redirecting to the boot profile; `--no-agents` skips it, and an existing file is never touched.", - "`--mode solo` (a team of one) also seeds identity and writing-style starters and points the onboarding brief at the owner interview; existing files are never overwritten.", - "Refuses when a `leji.json` exists, `--dry-run` included: a repository that already has a context layer has nothing to adopt. Also refuses when the git tree has uncommitted changes, which `--dry-run` is exempt from because it writes nothing." - ], + "name": "export", + "group": "viewer", + "summary": "Export the context layer as a self-contained static site.", + "usage": "leji export [--out ] [--strict] [--root ] [--json]", + "description": "Regenerates the viewer chrome, then writes the static site from the layer on disk (default: .leji/dist/, kept out of git), complete on its own and servable as-is, including under a subpath. Everything the site needs travels with it: nothing is read from anywhere but the layer when it is written, and nothing is read from anywhere but the site's own files when it is opened. `leji viewer build` is the viewer subsystem's name for this same operation, beside `leji viewer serve`; both names are permanently supported and behave identically. A custom --out must resolve inside the repository, and never inside .leji/ except exactly .leji/dist. The exported index.html warns that a context layer is sensitive and belongs behind internal authentication, not in a public bucket.", "options": [ { - "flags": "--dir ", - "summary": "Target directory (default: the current directory)." - }, - { - "flags": "--yes, -y", - "summary": "Accept all defaults; run non-interactively." - }, - { - "flags": "--mode ", - "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." - }, - { - "flags": "--agent ", - "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." - }, - { - "flags": "--wire-adapters", - "summary": "Convert present vendor entrypoints to redirects (consented; content migrated first)." - }, - { - "flags": "--no-agents", - "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." - }, - { - "flags": "--dry-run", - "summary": "Print the write plan and exit without changing anything." - } - ], - "examples": [ - "leji adopt", - "leji adopt --dry-run", - "leji adopt --mode solo", - "leji adopt --wire-adapters" - ] - }, - { - "name": "init", - "summary": "Bootstrap a new context layer from the templates.", - "usage": "leji init [--dir ] [--yes] [--mode ] [--level ] [--name ] [--agent ] [--no-agents] [--dry-run]", - "description": "Scaffolds a new context layer from the templates.", - "details": [ - "Writes `leji.json`, a boot profile, a pointer-only `AGENTS.md` (the portable entrypoint many agent hosts read, redirecting to the boot profile; `--no-agents` skips it), seeded category documents, a first decision record, an agent onboarding brief, and a generated index, so the scaffold is ready for the CI job `leji ci` writes. At the indexed level it also writes the machine changelog. The index is a requirement of `indexed`, not of `core`; a hand-authored core context layer without one still conforms.", - "`--mode solo` (a team of one) also seeds identity and writing-style starters, maps the practice category, routes identity and writing work in the boot profile, and points the onboarding brief at the owner interview (answer in text or with dropped files).", - "Refuses to overwrite an existing `leji.json`, and refuses when the git tree has uncommitted changes; never overwrites individual files.", - "`--dry-run` prints the write plan without writing.", - "Also backs `npm create leji`." - ], - "options": [ - { - "flags": "--dir ", - "summary": "Target directory (default: the current directory)." - }, - { - "flags": "--yes, -y", - "summary": "Accept all defaults; run non-interactively." - }, - { - "flags": "--mode ", - "summary": "Working mode: solo (team of one; seeds identity + writing-style starters) or team (default)." - }, - { - "flags": "--level ", - "summary": "Conformance level to claim: core or indexed (default: core)." - }, - { - "flags": "--name ", - "summary": "Context layer name (default: derived from the directory)." - }, - { - "flags": "--agent ", - "summary": "Host to open in the context layer after the command (claude-code or codex). Selects the handoff host; the interactive flow may separately offer to register the MCP server or install the approval guard, each disclosed and consented to." - }, - { - "flags": "--no-agents", - "summary": "Skip generating the portable AGENTS.md pointer (default: written when absent)." - }, - { - "flags": "--dry-run", - "summary": "Print the write plan and exit without creating any files." - } - ], - "examples": [ - "leji init", - "leji init --dry-run", - "leji init --mode solo", - "leji init --agent claude-code" - ] - }, - { - "name": "ci", - "summary": "Add a CI workflow that runs leji validate and index --check on every change.", - "usage": "leji ci [--provider ] [--hooks] [--root ] [--json]", - "description": "Adds a CI job that runs `leji validate` and `leji index --check` on every change, so your context layer stays honest in CI (the same two gates the `--hooks` pre-commit runs locally). Idempotent: a Leji workflow already in place is left untouched.", - "details": [ - "The provider is inferred from the `origin` remote (a github.com host selects GitHub, any gitlab host GitLab, an Azure DevOps host Azure Pipelines) and falls back to GitHub when the remote names none; `--provider` overrides the inference, and CircleCI is never inferred.", - "GitHub: writes its own workflow at `.github/workflows/leji.yml`.", - "GitLab: merges a managed block into `.gitlab-ci.yml`, creating the file if it's absent.", - "CircleCI: writes `.circleci/config.yml` if absent; when a config already exists, prints a snippet to add by hand instead of editing it.", - "Azure DevOps: writes `.azure-pipelines/leji.yml`. ADO does not auto-discover it, so activation is manual: create a pipeline that points at the file (e.g. `az pipelines create --yml-path .azure-pipelines/leji.yml`), then add a build-validation branch policy on `main` for pull-request checks. This activation note prints once, on first creation.", - "Local hook (`--hooks`): writes a managed pre-commit running `leji validate` and `leji index --check`. `core.hooksPath` is detected, so a husky repo gets a managed block merged into `.husky/pre-commit` rather than a dead `.git/hooks` file; an existing unmanaged hook is never touched, its snippet printed to add by hand.", - "Local-first: when the repository declares `@leji-org/leji` in its package.json, the generated CI job runs that lockfile-pinned install (`npm ci`, then `npx --no-install @leji-org/leji` for `validate` and `index --check`); a repository without it falls back to `npx @leji-org/leji@1`. The generated hook independently prefers a repo-local `node_modules/.bin/leji` when present, else the `leji` on PATH (it does not run `npm ci` or read the dependency declaration)." - ], - "options": [ - { - "flags": "--hooks", - "summary": "Write a managed local pre-commit running validate + index --check; core.hooksPath is detected, so a husky repo gets a managed block in .husky/pre-commit, and an existing unmanaged hook is left untouched with the snippet printed." + "flags": "--out ", + "summary": "Output directory for the export (default: .leji/dist; must resolve inside the repository, and never inside .leji/ except exactly .leji/dist)." }, { - "flags": "--provider ", - "summary": "CI provider: github (default when no remote is recognizable), gitlab, circleci, or azure. Without this flag the provider is inferred from the origin remote." + "flags": "--strict", + "summary": "Fail the export on any lint finding and write nothing, leaving an existing export untouched. Without it, lint findings are reported as warnings and the export is still written." } ], "examples": [ - "leji ci", - "leji ci --provider gitlab", - "leji ci --provider azure", - "leji ci --hooks" + "leji export", + "leji export --out site", + "leji export --strict --json" ] }, { - "name": "agent", - "summary": "Bind an additional named agent into an existing context layer.", - "usage": "leji agent --name [--host ] [--role ] [--root ] [--json]", - "description": "Adds a second (or third) agent to a context layer that already has a `leji.json`.", - "details": [ - "Writes a starter agent profile under the agent-profiles path and binds it in the manifest's agents map via an in-place edit that preserves the rest of the file.", - "Never writes an agent-host entrypoint file. The portable `AGENTS.md` pointer is written by `init` and `adopt` (unless `--no-agents`); single-vendor files like `CLAUDE.md` are only ever converted from an existing one by `adopt --wire-adapters`.", - "`--host` is optional: a host pins the profile to a specific external CLI; with none, it's a host-agnostic resident agent any host can run.", - "The role defaults to reviewer; pass `--role` for a different one.", - "Idempotent: an existing profile or binding is left untouched. Requires an existing context layer." - ], + "name": "viewer build", + "group": "viewer", + "aliasOf": "export", + "summary": "The viewer subsystem's name for `leji export`: write the static site.", + "usage": "leji viewer build [--out ] [--strict] [--root ] [--json]", + "description": "The same operation as `leji export`, under the viewer subsystem's own name beside `leji viewer serve`: one code path, identical output, identical exits. Both names are permanently supported; `leji export` is the name the documentation leads with. Run `leji export --help` for the full description.", "options": [ { - "flags": "--name ", - "summary": "Name for the agent; also its profile id and agents-map key (kebab-case)." - }, - { - "flags": "--host ", - "summary": "Optional. Pin the profile to a host (claude-code, codex, copilot, gemini, cursor, windsurf; aliases ok); omit for a host-agnostic resident agent." + "flags": "--out ", + "summary": "Output directory for the export (default: .leji/dist; must resolve inside the repository, and never inside .leji/ except exactly .leji/dist)." }, { - "flags": "--role ", - "summary": "Role the agent fills (default: reviewer)." + "flags": "--strict", + "summary": "Fail the export on any lint finding and write nothing, leaving an existing export untouched. Without it, lint findings are reported as warnings and the export is still written." } ], "examples": [ - "leji agent --name porter --role porter", - "leji agent --host codex --name reviewer", - "leji agent --host claude-code --name thought-partner --role advisor" + "leji viewer build", + "leji viewer build --out site" ] } ] diff --git a/packages/sdk/jsr.json b/packages/sdk/jsr.json index 2bb6e1d..4f0a9de 100644 --- a/packages/sdk/jsr.json +++ b/packages/sdk/jsr.json @@ -1,6 +1,6 @@ { "name": "@leji-org/leji", - "version": "1.3.1", + "version": "1.4.0", "license": "Apache-2.0", "exports": "./src/index.ts", "publish": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 36f7ecf..eadbbac 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@leji-org/leji", - "version": "1.3.1", + "version": "1.4.0", "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", @@ -40,6 +40,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./internal/create": { + "types": "./dist/internal/create.d.ts", + "default": "./dist/internal/create.js" } }, "files": [ @@ -50,7 +54,7 @@ "LICENSE" ], "scripts": { - "build": "tsc", + "build": "tsc && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"", "pretest": "npm run build", "test": "node --test test/*.test.ts test/proc/*.test.ts", "test:unit": "node --test test/*.test.ts", diff --git a/packages/sdk/schemas/context-manifest.schema.json b/packages/sdk/schemas/context-manifest.schema.json index abaf3a2..585c59e 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 CSS color (e.g. \"#223F93\"). Drives links, the active state, and diagram accents." + "description": "Primary/accent color as a hex CSS color (e.g. \"#009F71\"). Drives links, the active state, and diagram accents." } } }, diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index a1acd66..1842c0f 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -1,4 +1,25 @@ #!/usr/bin/env node +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { run } from './index.js'; +import { launchLocalCli, resolveLocalCli } from './lib/localcli.js'; + +/** This file, with every symlink resolved: the one path the hand-off must never + * select, or a repository whose install points back here would run us forever. An + * entry that cannot be resolved refuses the hand-off rather than risking that. */ +function selfEntry(): string | null { + try { + return fs.realpathSync.native(fileURLToPath(import.meta.url)); + } catch { + return null; + } +} + +// Before anything is parsed: inside a repository that declares the Leji CLI and has +// it installed, this invocation belongs to that pinned copy rather than to whichever +// global PATH found. Only the installed executable does this; `run()` is a library +// call and never hands work to another program. +const local = resolveLocalCli(process.argv.slice(2), process.env, process.platform, selfEntry()); +if (local.kind === 'handoff') launchLocalCli(local); process.exit(await run(process.argv.slice(2))); diff --git a/packages/sdk/src/commands/badge.ts b/packages/sdk/src/commands/badge.ts new file mode 100644 index 0000000..3875cbb --- /dev/null +++ b/packages/sdk/src/commands/badge.ts @@ -0,0 +1,280 @@ +import * as path from 'node:path'; +import { type Finding, finding, sortFindings } from '../lib/findings.js'; +import { isDir, resolvedPath, resolvedWithinRoot, verifiedTargetRead, writeFileGuarded } from '../lib/fsx.js'; +import { writableTarget } from '../lib/layout.js'; +import { type ConformanceLevel, CONFORMANCE_LEVELS } from '../lib/manifest.js'; +import { conformanceReport } from './conformance.js'; + +/** + * `leji badge`: the local, self-attested conformance badge. The command scores the + * layer with `conformanceReport` (federation never verified, so the run is offline + * by construction), renders the canonical SVG for the level THIS run verified, and + * prints the markdown that embeds it. There is no endpoint, no registry, and no + * hosted service anywhere in this module or the ones it imports: the bytes are + * constants, and the only thing that varies is which level's constants are used. + * + * The four files under `fixtures/badge/` are the byte oracle for everything here, + * and `fixtures/README.md` -> "The `badge` block" is the normative contract the + * three SDKs implement. + */ + +/** The badge target when `--out` is not given: a repository-root file, one + * copy-paste from a root README. */ +export const DEFAULT_BADGE_OUT = 'leji-badge.svg'; + +/** The page the markdown wrapper links. One constant, never configurable. */ +const AGENT_READY_URL = 'https://leji.org/agent-ready/'; + +/** The `--out` acceptance rule, quoted verbatim by the usage error that rejects a + * path (`fixtures/README.md` -> "The `--out` acceptance rule"). */ +export const OUT_RULE = + '--out takes a repository-relative POSIX path over [A-Za-z0-9._/-], with no leading /, no backslash, ' + + 'no ".." segment, no empty segment, and ending .svg'; + +/** The mark: the single `` of `packages/site/src/assets/leji-icon.svg` + * (viewBox `0 0 370 391`), inlined as a constant rather than read at runtime. The + * badge is a frozen byte contract, so it can never depend on a file a caller could + * replace or a package could ship differently. */ +const MARK_PATH = + 'M185.038 77.918C162.621 77.942 144.384 96.185 144.372 118.607C144.382 136.031 155.422 150.887 170.856 156.67V305.245H199.225V156.671C214.663 150.888 225.707 136.031 225.724 118.608C225.703 96.184 207.46 77.942 185.038 77.918ZM185.043 130.9C178.268 130.896 172.747 125.372 172.747 118.607C172.747 111.833 178.262 106.318 185.037 106.303C191.816 106.318 197.337 111.832 197.337 118.607C197.337 125.372 191.811 130.896 185.043 130.9ZM349.766 22.16C336.469 8.72897 317.174 0.943 295.071 0H74.715C52.613 0.943 33.319 8.72897 20.021 22.16C7.09602 35.134 -0.0149763 52.521 2.36824e-05 71.128C2.36824e-05 87.589 5.66601 103.074 15.951 114.726C26.651 126.955 42.597 134.172 59.95 134.713C63.415 134.798 81.128 134.812 97.081 127.028V311.413C81.642 317.196 70.595 332.056 70.585 349.48C70.597 371.898 88.841 390.139 111.255 390.156C133.679 390.139 151.919 371.898 151.935 349.48C151.923 332.055 140.88 317.2 125.443 311.417V58.449H244.589V211.559C229.155 217.342 218.114 232.193 218.105 249.622C218.118 272.053 236.357 290.295 258.779 290.295C281.193 290.295 299.431 272.053 299.453 249.622C299.437 232.193 288.39 217.338 272.957 211.559V127.147C288.846 134.809 306.393 134.797 309.84 134.712C327.192 134.171 343.138 126.953 353.838 114.725C364.123 103.074 369.789 87.588 369.789 71.127C369.801 52.521 362.692 35.134 349.766 22.16ZM111.261 361.782C104.487 361.763 98.965 356.247 98.959 349.491C98.965 342.705 104.486 337.187 111.254 337.187C118.024 337.187 123.543 342.709 123.559 349.476C123.543 356.247 118.016 361.764 111.261 361.782ZM258.786 261.931C252.005 261.917 246.483 256.392 246.483 249.622C246.483 242.843 252.004 237.334 258.778 237.334C265.542 237.334 271.063 242.847 271.079 249.612C271.063 256.386 265.542 261.917 258.786 261.931ZM332.597 95.914C326.347 102.87 318.107 106.295 307.41 106.378C288.412 106.165 281.539 100.531 277.324 95.052C275.131 92.123 273.783 88.628 272.955 85.317V58.45H300.57C303.603 58.45 306.808 59.782 307.015 59.87C308.813 60.684 310.429 61.838 311.277 63.098C311.993 64.174 312.539 65.34 312.574 67.815C312.523 70.481 311.668 72.002 310.269 73.352C308.873 74.643 306.746 75.487 304.64 75.471C301.994 75.386 299.636 74.504 297.46 71.373C294.728 67.264 289.187 66.153 285.077 68.885C282.002 70.933 280.603 74.56 281.247 77.978C287.59 93.654 305.199 93.329 305.415 93.324C311.64 93.133 317.642 90.795 322.325 86.535C327.213 82.137 330.485 75.362 330.436 67.815C330.47 61.991 328.678 56.727 325.883 52.813C323.102 48.875 319.584 46.292 316.354 44.568C310.887 41.694 306.067 40.887 304.242 40.68H66.514C64.69 40.887 59.868 41.694 54.402 44.568C51.173 46.293 47.654 48.875 44.873 52.813C42.079 56.727 40.286 61.991 40.321 67.815C40.272 75.362 43.544 82.136 48.431 86.535C53.116 90.795 59.116 93.133 65.342 93.324C65.557 93.329 83.167 93.654 89.51 77.978C90.153 74.56 88.754 70.933 85.68 68.885C81.57 66.154 76.028 67.264 73.296 71.373C71.119 74.504 68.762 75.386 66.117 75.471C64.011 75.487 61.884 74.643 60.488 73.352C59.09 72.001 58.232 70.481 58.182 67.815C58.216 65.34 58.763 64.174 59.479 63.098C60.327 61.838 61.943 60.685 63.741 59.87C63.949 59.782 67.152 58.45 70.185 58.45H97.08V84.257C96.283 87.871 94.891 91.81 92.463 95.053C88.248 100.532 81.375 106.166 62.375 106.379C51.68 106.296 43.439 102.871 37.19 95.915C31.563 89.595 28.351 80.556 28.371 71.129C28.379 60.046 32.544 49.776 40.101 42.199C49.323 33.015 62.602 28.34 79.568 28.291H290.218C307.183 28.34 320.462 33.016 329.684 42.199C337.242 49.776 341.407 60.046 341.415 71.129C341.435 80.555 338.223 89.594 332.597 95.914Z'; + +/** + * The one per-level constant: the wordmark's `textLength` is fixed at 41 and every + * other width derives from the status text's (`fixtures/README.md` -> "The + * canonical badge bytes"). Horizontal padding is 5 either side, the mark occupies a + * 14-wide slot with a 3-wide gap, so the identity segment is 5+14+3+41+5 = 68 and + * the status segment is `textLength + 10`. + */ +const STATUS_TEXT_LENGTH: Record = { + core: 24, + indexed: 43, + governed: 52, + federated: 53, +}; + +/** The identity segment's fixed width, and the wordmark it carries. */ +const IDENTITY_WIDTH = 68; +const WORDMARK = 'Leji 1.0'; + +/** + * The accessible name and the markdown alt text: one string, three places. It + * carries the full self-attestation claim, which the badge FACE does not: the + * visible status segment is the level alone, and the claim stays structural — in + * the ``, the `aria-label`, and the markdown alt — with the linked + * agent-ready page carrying the story. + */ +export function badgeLabel(level: ConformanceLevel): string { + return `${WORDMARK} · ${level} · self-attested`; +} + +/** + * The canonical badge for one level, byte for byte: shields-flat shape, height 20, + * rounded by a clipPath, the mark and wordmark on the `#183D3B` identity segment + * and `<level>` alone on the `#009F71` status segment. No XML declaration, no BOM, + * no comment, no timestamp, no version string; UTF-8, LF, one trailing newline. + * Compared against `fixtures/badge/<level>.svg` by unit test. + */ +export function renderBadge(level: ConformanceLevel): string { + const status = STATUS_TEXT_LENGTH[level]; + const statusWidth = status + 10; + const width = IDENTITY_WIDTH + statusWidth; + const label = badgeLabel(level); + return ( + `<svg xmlns="http://www.w3.org/2000/svg" role="img" width="${width}" height="20" aria-label="${label}">\n` + + `<title>${label}\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `\n` + + `${WORDMARK}\n` + + `${level}\n` + + `\n` + + `\n` + ); +} + +/** The one markdown line the command prints: the badge image, wrapped in a link to + * the agent-ready page. `out` is the canonical POSIX path, relative to the + * repository root, so a root README embeds it as written. */ +export function badgeMarkdown(level: ConformanceLevel, out: string): string { + return `[![${badgeLabel(level)}](${out})](${AGENT_READY_URL})\n`; +} + +/** Every canonical badge of this contract, which is exactly what an existing file + * is recognized against: its own bytes, and no marker, sidecar, or state. */ +const CANONICAL_BADGES: readonly string[] = CONFORMANCE_LEVELS.map(renderBadge); + +/** What the run did to the target file: `wrote` it (absent), left it `unchanged` + * (it already held these exact bytes), or `overwrote` another canonical badge of + * this contract, which is how a level change regenerates. */ +export type BadgeAction = 'wrote' | 'unchanged' | 'overwrote'; + +/** One `leji badge` run, in the shape the caller renders in either channel. A + * failed run carries `out`, `level`, `markdown`, and `action` as null and says why + * in `findings`; `claimedLevel` and `verifiedLevel` are reported whatever the + * outcome, so a refusal is still honest about what the layer claims. */ +export interface BadgeResult { + out: string | null; + level: ConformanceLevel | null; + claimedLevel: ConformanceLevel | null; + verifiedLevel: ConformanceLevel | null; + markdown: string | null; + action: BadgeAction | null; + findings: Finding[]; + /** `--out` was rejected at argument parsing, before conformance ran: the caller + * prints this in the CLI's usage-error form and exits 2, reporting no level. */ + usageError?: string; + /** The target exists and is not a badge of this contract: exit 2, the file + * untouched. Reported after conformance, so the levels above are populated. */ + refusal?: string; +} + +/** The syntax half of the `--out` rule, on the spelling alone. */ +function acceptedOutSyntax(out: string): boolean { + if (!/^[A-Za-z0-9._/-]+$/.test(out)) return false; + if (out.startsWith('/') || !out.endsWith('.svg')) return false; + return out.split('/').every((seg) => seg !== '' && seg !== '..'); +} + +/** The canonical POSIX form of an accepted `--out`: the spelling with its `.` + * segments dropped, which is what stdout, `--json`, and the markdown carry. */ +function canonicalOut(out: string): string { + return out + .split('/') + .filter((seg) => seg !== '.') + .join('/'); +} + +/** + * The `--out` check, run at argument parsing and BEFORE conformance: the syntax + * rule above, then containment of the RESOLVED path — inside the repository, never + * under `.leji/` at any depth (that tree is the tool's own domain and the badge is + * user content), and not a directory. Returns the usage-error text on a rejection, + * else the canonical relative path and the resolved absolute one. + */ +function checkOut(rootAbs: string, out: string): { error: string } | { rel: string; abs: string } { + if (!acceptedOutSyntax(out)) return { error: `${OUT_RULE} (got "${out}")` }; + const rel = canonicalOut(out); + const abs = path.resolve(rootAbs, rel); + const resolved = resolvedPath(abs); + if (resolved === null) return { error: `--out "${rel}" cannot be resolved (permission or I/O error)` }; + if (!resolvedWithinRoot(rootAbs, abs)) return { error: `--out "${rel}" must resolve inside the repository` }; + // No own role: the badge has no legitimate `.leji/` landing at any depth. + const verdict = writableTarget(rootAbs, resolved, null); + if (!verdict.ok) { + return { error: `--out "${rel}" resolves inside .leji/, the tool's own domain; the badge is user content` }; + } + if (isDir(resolved)) return { error: `--out "${rel}" is a directory` }; + return { rel, abs }; +} + +/** + * Run `leji badge` over `root`, writing the badge for the level this offline run + * verified. The order is fixed and is part of the contract: `--out` is judged + * first (a usage error reports no level at all), then conformance decides whether + * there is anything honest to state, and only then does the existing target decide + * the action. + */ +export function badgeRun(root: string, out: string = DEFAULT_BADGE_OUT): BadgeResult { + const rootAbs = resolvedPath(path.resolve(root)) ?? path.resolve(root); + const checked = checkOut(rootAbs, out); + const empty: BadgeResult = { + out: null, + level: null, + claimedLevel: null, + verifiedLevel: null, + markdown: null, + action: null, + findings: [], + }; + if ('error' in checked) return { ...empty, usageError: checked.error }; + + // Federation is never verified: the badge states what an offline run established, + // which is why it can sit below the claim and never above it. + const report = conformanceReport(root); + const levels = { claimedLevel: report.claimedLevel, verifiedLevel: report.verifiedLevel }; + if (report.findings.some((f) => f.severity === 'error')) { + return { ...empty, ...levels, findings: sortFindings(report.findings) }; + } + if (report.verifiedLevel === null) { + return { + ...empty, + ...levels, + findings: sortFindings([ + ...report.findings, + finding( + 'badge-unverified', + 'error', + 'no level verified in this run; the badge states only what was verified', + 'leji.json', + ), + ]), + }; + } + + const level = report.verifiedLevel; + const svg = renderBadge(level); + + // Check-before-act, badge-side. `checkOut` judged the target as it was spelled + // at argument parsing; the read below and the write after it are separate acts, + // and a component of the path can become a symlink in between. So the boundary is + // re-established immediately before each act, on the RESOLVED path, by the shared + // rule itself: `verifiedTargetRead` for the read, the guarded write for the write. + const refuseTarget = (): BadgeResult => ({ + ...empty, + ...levels, + findings: sortFindings([ + ...report.findings, + finding( + 'badge-target-refused', + 'error', + `${checked.rel} does not resolve to a regular file inside the repository`, + checked.rel, + ), + ]), + refusal: `${checked.rel} does not resolve to a regular file inside the repository; nothing was written`, + }); + + // The existing target is read through the shared verified read: the standing entry + // decides its own kind (a directory, a socket, a link to one: refused, never + // written through), the resolved location is judged by the same rule the write + // below is judged by, and the bytes come from the descriptor proved to be that + // file. Absence is decided on the ORIGINAL entry, so a dangling link — standing, + // resolving nowhere — is a refusal rather than an absent target written through. + const read = verifiedTargetRead(rootAbs, checked.abs, null); + if (read.status === 'refused') return refuseTarget(); + const existing = read.status === 'regular' ? read.bytes.toString('utf8') : null; + if (existing !== null && !CANONICAL_BADGES.includes(existing)) { + return { + ...empty, + ...levels, + findings: sortFindings([ + ...report.findings, + finding('badge-target-foreign', 'error', `${checked.rel} is not a leji badge`, checked.rel), + ]), + refusal: `${checked.rel} exists and is not a leji badge; remove or rename it`, + }; + } + const action: BadgeAction = existing === null ? 'wrote' : existing === svg ? 'unchanged' : 'overwrote'; + if (action !== 'unchanged') { + // The guarded-write chokepoint re-resolves the target immediately before the + // write and answers the whole boundary — inside the repository, outside + // `.leji/` — so nothing here is inherited from the parse-time verdict. Parent + // directories are created only inside a write that happens: a run that writes + // nothing (a refusal, an unchanged target) establishes no directory either. + const verdict = writeFileGuarded(rootAbs, checked.abs, null, svg); + if (!verdict.ok) return refuseTarget(); + } + return { + out: checked.rel, + level, + claimedLevel: report.claimedLevel, + verifiedLevel: level, + markdown: badgeMarkdown(level, checked.rel), + action, + findings: sortFindings(report.findings), + }; +} diff --git a/packages/sdk/src/commands/changelog.ts b/packages/sdk/src/commands/changelog.ts index 4f497ba..9838387 100644 --- a/packages/sdk/src/commands/changelog.ts +++ b/packages/sdk/src/commands/changelog.ts @@ -1,8 +1,6 @@ -import * as fs from 'node:fs'; import * as path from 'node:path'; import { type Finding, finding } from '../lib/findings.js'; -import { resolvedWithinRoot } from '../lib/fsx.js'; -import { readJsonArtifact } from '../lib/layer.js'; +import { guardRoot, verifiedTargetRead, writeFileGuarded } from '../lib/fsx.js'; import { type Manifest, claimedLevel, effectiveChangelogPath, levelAtLeast } from '../lib/manifest.js'; interface ChangelogEntry { @@ -102,13 +100,17 @@ function today(): string { * (lets `leji index` complete the indexed surface for a layer upgraded after init). * Returns the seeded path, or null when nothing was written (not indexed, already * present, or a symlink would escape the root). Never overwrites. + * + * "Missing" is decided by the exclusive create itself rather than by a pathname + * check, because `existsSync` follows symlinks: a dangling link at the changelog + * name reads as absent and the seed would be created at the link's destination. The + * exclusive create judges the ORIGINAL entry, so any standing entry is the same + * already-present no-op an existing changelog is. */ export function seedChangelogIfMissing(root: string, manifest: Manifest): string | null { if (!levelAtLeast(claimedLevel(manifest), 'indexed')) return null; const rel = effectiveChangelogPath(manifest); const abs = path.join(root, rel); - if (fs.existsSync(abs)) return null; - if (!resolvedWithinRoot(path.resolve(root), abs)) return null; const log: Changelog = { $schema: 'https://leji.org/schemas/v1.0/context-changelog.schema.json', schemaVersion: '1.0', @@ -124,8 +126,7 @@ export function seedChangelogIfMissing(root: string, manifest: Manifest): string }, ], }; - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, serializeChangelog(log)); + if (!writeFileGuarded(guardRoot(root), abs, null, serializeChangelog(log), { exclusive: true }).ok) return null; return rel; } @@ -158,9 +159,21 @@ export function compactChangelog(root: string, manifest: Manifest, opts: Compact path: rel, }; } - const { data, finding: parseFinding } = readJsonArtifact(root, rel); - if (parseFinding) return { findings: [parseFinding], folded: 0, kept: 0, path: rel }; - if (!data) { + // Compaction rewrites the file it just read, so the bytes it folds come from the + // verified read rather than from a pathname read once and written again: a refusal + // (outside the layer root, a private role, an entry that is not a regular file) is + // reported exactly as an unreadable artifact, and nothing is written. + const rootReal = guardRoot(root); + const read = verifiedTargetRead(rootReal, path.join(root, rel), null); + if (read.status === 'refused') { + return { + findings: [finding('artifact-parse', 'error', `artifact ${rel} resolves outside the layer root`, rel)], + folded: 0, + kept: 0, + path: rel, + }; + } + if (read.status === 'absent') { return { findings: [finding('changelog-required', 'error', `changelog ${rel} does not exist`, rel)], folded: 0, @@ -168,7 +181,18 @@ export function compactChangelog(root: string, manifest: Manifest, opts: Compact path: rel, }; } - const log = data as Changelog; + let parsed: unknown; + try { + parsed = JSON.parse(read.bytes.toString('utf8')); + } catch (e) { + return { + findings: [finding('artifact-parse', 'error', `invalid JSON: ${(e as Error).message}`, rel)], + folded: 0, + kept: 0, + path: rel, + }; + } + const log = parsed as Changelog; const original = Array.isArray(log.entries) ? log.entries.filter((e): e is ChangelogEntry => e !== null && typeof e === 'object') : []; @@ -220,7 +244,7 @@ export function compactChangelog(root: string, manifest: Manifest, opts: Compact const next: Changelog = { ...log, entries: [...survivors, compaction] }; const abs = path.join(root, rel); - if (!resolvedWithinRoot(path.resolve(root), abs)) { + if (!writeFileGuarded(guardRoot(root), abs, null, serializeChangelog(next)).ok) { return { findings: [finding('artifact-parse', 'error', `changelog path ${rel} resolves outside the layer root`, rel)], folded: 0, @@ -228,8 +252,6 @@ export function compactChangelog(root: string, manifest: Manifest, opts: Compact path: rel, }; } - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, serializeChangelog(next)); return { findings: [], folded: folded.length, kept: next.entries.length, path: rel }; } diff --git a/packages/sdk/src/commands/conformance.ts b/packages/sdk/src/commands/conformance.ts index 80ea062..1e2b2d6 100644 --- a/packages/sdk/src/commands/conformance.ts +++ b/packages/sdk/src/commands/conformance.ts @@ -467,7 +467,7 @@ export function renderExplain(result: ConformanceResult): string { : b.status === 'unknown' ? ' (evidence unobtainable in this run; unknown never awards the level)' : ''; - lines.push(` - ${b.description}${b.detail ? ` — ${b.detail}` : ''}${how}`); + lines.push(` - ${b.description}${b.detail ? `: ${b.detail}` : ''}${how}`); } } lines.push( diff --git a/packages/sdk/src/commands/detect.ts b/packages/sdk/src/commands/detect.ts index 778f123..6780b1b 100644 --- a/packages/sdk/src/commands/detect.ts +++ b/packages/sdk/src/commands/detect.ts @@ -1,18 +1,23 @@ import { type DetectedHost, detectHosts } from '../lib/detect.js'; +import { type EcosystemReport, detectEcosystem, renderEcosystemLine } from '../lib/ecosystem.js'; -/** Result of `detect`: the agent hosts available to this user, ranked. */ +/** Result of `detect`: the agent hosts available to this user, ranked, and the + * dependency ecosystem of the repository itself. */ export interface DetectResult { hosts: DetectedHost[]; + ecosystem: EcosystemReport; } export function detectLayer(root: string): DetectResult { - return { hosts: detectHosts({ root }) }; + return { hosts: detectHosts({ root }), ecosystem: detectEcosystem(root) }; } /** Human-readable detection report. */ -export function renderDetect(hosts: DetectedHost[]): string { +export function renderDetect(result: DetectResult): string { + const { hosts, ecosystem } = result; + const ecoLine = renderEcosystemLine(ecosystem); if (hosts.length === 0) { - return 'No coding-agent hosts detected. Leji works without one; the onboarding brief still guides any agent you point at it.'; + return `No coding-agent hosts detected. Leji works without one; the onboarding brief still guides any agent you point at it.\n\n${ecoLine}`; } const lines = ['Detected agent hosts (strongest signal first):']; for (const h of hosts) { @@ -20,8 +25,11 @@ export function renderDetect(hosts: DetectedHost[]): string { .filter(Boolean) .join(', '); const adapter = h.adapter ? `adapter ${h.adapter}` : 'directory-style adapter (wiring deferred)'; - lines.push(` ${h.strength.padEnd(16)} ${h.name} — ${signals}; ${adapter}`); + lines.push(` ${h.strength.padEnd(16)} ${h.name}: ${signals}; ${adapter}`); } + // One line about the repository's own ecosystem: what would declare and run the + // CLI here. The full offer block belongs to init/adopt, which can act on it. + lines.push('', ecoLine); // `--agent` names the host Leji launches, and only claude-code and codex accept // an inline prompt; suggesting `--agent ` for every detected host offered // a command the flag rejects. diff --git a/packages/sdk/src/commands/export.ts b/packages/sdk/src/commands/export.ts new file mode 100644 index 0000000..245c335 --- /dev/null +++ b/packages/sdk/src/commands/export.ts @@ -0,0 +1,459 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { type Finding, sortFindings } from '../lib/findings.js'; +import { + mkdirpGuarded, + openVerifiedSource, + openWriteGuarded, + resolvedPath, + resolvedWithinRoot, + rmGuarded, + stripSlash, + verifiedTargetRead, + writeFileGuarded, +} from '../lib/fsx.js'; +import { + type TargetVerdict, + DIST_REL, + LEJI_DIR, + VIEWER_REL, + lejiRole, + servablePath, + writableTarget, +} from '../lib/layout.js'; +import { type Manifest } from '../lib/manifest.js'; +import { renderLintFindings } from '../lib/renderlint.js'; +import { ACTIVE_EXTENSIONS, buildIndexHtml, generateViewer, resolvedProfilePages } from './viewer.js'; + +/** + * `leji export` (and `leji viewer build`, its co-equal name for the same + * operation): the static export pipeline, in its own module so its transitive + * import set can be checked. Nothing here — and nothing it imports — pulls in + * `node:http`/`https`/`net`/`dgram` or calls `fetch`; the local preview server + * keeps all of that in `serve.ts`. The only subprocess the pipeline reaches is + * `git`, through the mount status the manifest page renders, with lazy fetch + * disabled. A module-graph test and a subprocess spy pin both claims. + */ + +/** Protect-your-context warning shown by `leji export` and embedded in the exported index.html. */ +export const PROTECT_WARNING = + 'This is your context layer (identity, invariants, decisions, sometimes sensitive internal knowledge). Host the exported folder behind internal authentication, not a public or shared bucket where it could be indexed or leaked. Active file types (.htm, .html, .js, .mjs, .xhtml) are left out of the exported content: a static host would serve them as same-origin documents that execute with no policy.'; + +/** The first bytes an export writes into its index.html, under either of the + * command's names. A target directory carrying this marker is a previous export + * and may be cleared; any other non-empty directory is somebody's content and is + * never removed. The marker is a byte contract shared with the Go and Python + * SDKs, so it reads as it has always read: an export written by any of the three, + * under either name, is clearable by any of the three. */ +const EXPORT_MARKER = '\n${indexHtml}`, + ); + + return { out: outDisplay, findings, wrote: true }; +} diff --git a/packages/sdk/src/commands/indexgen.ts b/packages/sdk/src/commands/indexgen.ts index 826ad6f..bcd6538 100644 --- a/packages/sdk/src/commands/indexgen.ts +++ b/packages/sdk/src/commands/indexgen.ts @@ -2,9 +2,9 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { type Finding, finding } from '../lib/findings.js'; -import { isFile, readText, realpathWithin, resolvedWithinRoot } from '../lib/fsx.js'; +import { guardRoot, isFile, resolvedWithinRoot, verifiedTargetRead, writeFileGuarded } from '../lib/fsx.js'; import { gitLastModified, gitToplevel } from '../lib/git.js'; -import { duplicateIdFindings, readJsonArtifact, scanCategories } from '../lib/layer.js'; +import { duplicateIdFindings, scanCategories } from '../lib/layer.js'; import { type Manifest, effectiveIndexPath } from '../lib/manifest.js'; import { SDK_VERSION, SUPPORTED_LINES, schemaErrors } from '../lib/schemas.js'; @@ -89,10 +89,22 @@ function strArray(v: unknown): string[] | undefined { return out.length > 0 ? out : undefined; } +/** The stored index, or null when there is none this run can act on. Read through + * the verified read, not by pathname: generation carries ids out of these bytes into + * the index it writes back to this same path, so the file that was judged must be the + * file that is read. Absent, unparsable, or a standing entry that cannot be verified + * all mean "no stored index" — nothing is carried, and the write chokepoint judges the + * destination again on its own. */ export function loadStoredIndex(root: string, manifest: Manifest): ContextIndex | null { const rel = effectiveIndexPath(manifest); - if (!isFile(path.join(root, rel))) return null; - const { data } = readJsonArtifact(root, rel); + const read = verifiedTargetRead(guardRoot(root), path.join(root, rel), null); + if (read.status !== 'regular') return null; + let data: unknown; + try { + data = JSON.parse(read.bytes.toString('utf8')); + } catch { + return null; + } if (!data || typeof data !== 'object') return null; return data as ContextIndex; } @@ -254,7 +266,7 @@ export function checkIndex(root: string, manifest: Manifest): IndexResult { findings.push(finding('index-required', 'error', `index ${rel} does not exist; run \`leji index\``, rel)); return { index: null, findings, stale: true }; } - if (!realpathWithin(path.resolve(root), path.join(root, rel))) { + if (!resolvedWithinRoot(path.resolve(root), path.join(root, rel))) { findings.push(finding('artifact-parse', 'error', `artifact ${rel} resolves outside the layer root`, rel)); return { index: null, findings, stale: true }; } @@ -400,9 +412,9 @@ export function writeIndex(root: string, manifest: Manifest): IndexResult { } if (result.index) { const abs = path.join(root, rel); - // Contain before mkdir: resolvedWithinRoot resolves the nearest existing - // ancestor, catching a symlinked ancestor before write can escape the root. - if (!resolvedWithinRoot(path.resolve(root), abs)) { + // The write chokepoint judges the RESOLVED destination immediately before the + // write, catching a symlinked ancestor before anything is created under it. + if (!writeFileGuarded(guardRoot(root), abs, null, serializeIndex(result.index)).ok) { return { index: result.index, findings: [ @@ -411,8 +423,6 @@ export function writeIndex(root: string, manifest: Manifest): IndexResult { ], }; } - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, serializeIndex(result.index)); } return result; } diff --git a/packages/sdk/src/commands/init.ts b/packages/sdk/src/commands/init.ts index dfc90e5..05d4bc6 100644 --- a/packages/sdk/src/commands/init.ts +++ b/packages/sdk/src/commands/init.ts @@ -1,3 +1,4 @@ +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as readline from 'node:readline'; @@ -12,7 +13,20 @@ import { loadManifest, } from '../lib/manifest.js'; import { templatesDir } from '../lib/schemas.js'; -import { exists, isDir, isFile, joinUnderRoot, readText, resolvedWithinRoot, stripSlash, toPosix } from '../lib/fsx.js'; +import { + chmodGuarded, + guardRoot, + isDir, + isFile, + joinUnderRoot, + resolvedWithinRoot, + stripSlash, + toPosix, + verifiedTargetRead, + writeFileAtomicGuarded, + writeFileGuarded, +} from '../lib/fsx.js'; +import { type TargetVerdict, LEJI_DIR, WORK_REL } from '../lib/layout.js'; import { type PlanEntry, type PlannedWrite, buildWritePlan } from '../lib/writeplan.js'; import { type DetectedHost, @@ -22,6 +36,15 @@ import { detectHosts, resolveHostId, } from '../lib/detect.js'; +import { + type EcosystemReport, + DEP_NAME, + ECOSYSTEM_TEXT, + detectEcosystem, + managerRunnerArgv, + renderEcosystemBlock, + runnerArgv, +} from '../lib/ecosystem.js'; import { trackedUnder, workingTreeClean } from '../lib/git.js'; import { type Finding, hasErrors } from '../lib/findings.js'; import { KNOWN_VENDOR_FILES } from './validate.js'; @@ -96,17 +119,38 @@ export function defaultLayout(rootPath: string): ScaffoldLayout { }; } -/** Pick the first candidate name (under rootPath) that does not already exist on - * disk, so `adopt` never writes its scaffold over a repo's existing content. */ +/** + * True when NOTHING stands at `abs`, which is what makes a candidate name free. + * + * The ORIGINAL directory entry decides it, exactly as an exclusive create does: + * `existsSync` follows symlinks, so a dangling link reads as a free name and the + * write that follows lands at the link's missing destination. Any standing entry, a + * dangling link included, is occupied. + * + * A name nothing stands at is free even when it resolves out of this tool's reach, + * because the search is for an unused NAME, not a permission to write: every other + * name under a context root symlinked out of the repository resolves out of reach + * too, so refusing them one by one would never terminate. The write itself is judged + * where it always is, at the chokepoint, which refuses that target as it has. + */ +function nothingStandsAt(abs: string): boolean { + return fs.lstatSync(abs, { throwIfNoEntry: false }) === undefined; +} + +/** Pick the first candidate name (under rootPath) that is free, so `adopt` never + * writes its scaffold over a repo's existing content. Occupancy is decided on the + * standing entry rather than by `exists`, so a dangling candidate link is occupied + * and the next name is tried, exactly as an existing file has always been. */ function resolveScaffoldPath(root: string, rootPath: string, name: string, alternates: string[], dir: boolean): string { + const free = (rel: string): boolean => nothingStandsAt(path.join(root, stripSlash(rel))); const suffix = dir ? '/' : ''; for (const candidate of [name, ...alternates]) { const rel = joinUnderRoot(rootPath, candidate + suffix); - if (!exists(path.join(root, stripSlash(rel)))) return rel; + if (free(rel)) return rel; } for (let n = 2; ; n++) { const rel = joinUnderRoot(rootPath, `${name}-${n}${suffix}`); - if (!exists(path.join(root, stripSlash(rel)))) return rel; + if (free(rel)) return rel; } } @@ -350,42 +394,98 @@ function safeResolve(rootAbs: string, rel: string): string { return abs; } +/** Write a file this command owns, once: never over an existing one, and never + * through a standing entry it cannot verify. The skip is decided by the verified + * read rather than a pathname check, because `existsSync` follows symlinks — a + * dangling link at the target reads as absent and the guarded write then lands at + * the link's destination, a name this command never planned. Only `absent` is free; + * a regular file is the never-overwrite skip; anything else standing there is the + * escape refusal, with nothing written. */ function writeFileOnce(rootAbs: string, rel: string, content: string, written: string[]): void { const abs = safeResolve(rootAbs, rel); - if (!resolvedWithinRoot(rootAbs, abs)) { + const rootReal = guardRoot(rootAbs); + const standing = verifiedTargetRead(rootReal, abs, initRole(rel)); + if (standing.status === 'regular') return; + if (standing.status === 'refused') { throw new Error(`refusing to write through a symlink that escapes the target: "${rel}"`); } - if (fs.existsSync(abs)) return; - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content); + guardedOrRefuse(rel, writeFileGuarded(rootReal, abs, initRole(rel), content)); written.push(rel); } -/** Ensure the root .gitignore ignores `.leji/` (generated viewer + transient - * brief). Idempotent and matches the exact line, so a comment or `docs/.leji/` - * is not treated as equivalent. */ +/** 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. */ +function initRole(rel: string): string | null { + return rel === WORK_REL || rel.startsWith(`${WORK_REL}/`) ? WORK_REL : null; +} + +/** Every init/adopt write goes through the chokepoint, and a refused verdict is the + * one error this command has always raised for an escaping target: the layer is + * scaffolded inside the repository it was pointed at, or not at all. */ +function guardedOrRefuse(rel: string, verdict: TargetVerdict): void { + if (!verdict.ok) { + throw new Error(`refusing to write through a symlink that escapes the target: "${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 + * are compared for the draft report, so no act rests on a second read by pathname of + * a file this command then rewrites. An entry that cannot be verified as a regular + * file inside the repository is treated as absent, exactly as an escaping symlink + * already was. + */ +function verifiedVendorFiles(root: string): Map { + const rootReal = guardRoot(root); + const present = new Map(); + for (const rel of KNOWN_VENDOR_FILES) { + const read = verifiedTargetRead(rootReal, path.join(root, rel), null); + if (read.status === 'regular') present.set(rel, read.bytes.toString('utf8')); + } + return present; +} + +/** + * Read a file this command is about to merge and rewrite, through the verified read + * rather than by pathname: the bytes that decide the merge come from the descriptor + * the rule cleared, so the file that was judged is the file that is read and then + * written. Null when nothing stands there (the create path); a standing entry that + * cannot be verified as a regular file inside the repository is the same refusal a + * write to it would be. + */ +function readMergeSource(rootReal: string, abs: string, rel: string): string | null { + const read = verifiedTargetRead(rootReal, abs, initRole(rel)); + if (read.status === 'refused') { + throw new Error(`refusing to write through a symlink that escapes the target: "${rel}"`); + } + return read.status === 'regular' ? read.bytes.toString('utf8') : null; +} + +/** Ensure the root .gitignore ignores `.leji/` — the one line that covers every + * role of the unified tree (chrome, export output, onboarding workspace, mounts) + * and any role added later. Idempotent and matches the exact line, so a comment or + * `docs/.leji/` is not treated as equivalent. */ function ensureLejiGitignored(rootAbs: string): void { const abs = path.join(rootAbs, '.gitignore'); - const entry = '.leji/'; - const text = isFile(abs) ? readText(abs) : ''; + const entry = `${LEJI_DIR}/`; + const rootReal = guardRoot(rootAbs); + const text = readMergeSource(rootReal, abs, '.gitignore') ?? ''; if (text.split('\n').includes(entry)) return; - if (text === '') { - fs.writeFileSync(abs, entry + '\n'); - } else { - fs.writeFileSync(abs, text + (text.endsWith('\n') ? '' : '\n') + entry + '\n'); - } + const next = text === '' ? entry + '\n' : text + (text.endsWith('\n') ? '' : '\n') + entry + '\n'; + guardedOrRefuse('.gitignore', writeFileGuarded(rootReal, abs, null, next)); } -/** Refuse to write the transient onboarding workspace while any file under - * `/.leji/` is tracked by git: tracked means the ignore boundary is - * not intact, and private artifacts could land in history. The fix is the - * owner's call (git rm --cached), never run silently. */ -function assertLejiWorkspacePrivate(root: string, rootPath: string): void { - const lejiDir = joinUnderRoot(rootPath, '.leji/'); - const tracked = trackedUnder(root, stripSlash(lejiDir)); +/** Refuse to write the transient onboarding workspace while any file under the + * root `.leji/` is tracked by git: tracked means the ignore boundary is not + * intact, and private artifacts could land in history. The fix is the owner's + * call (git rm --cached), never run silently. */ +function assertLejiWorkspacePrivate(root: string): void { + const tracked = trackedUnder(root, LEJI_DIR); if (tracked && tracked.length > 0) { throw new Error( - `${tracked.length} file(s) under ${lejiDir} are tracked by git; untrack them (git rm --cached) so onboarding artifacts stay private`, + `${tracked.length} file(s) under ${LEJI_DIR}/ are tracked by git; untrack them (git rm --cached) so onboarding artifacts stay private`, ); } } @@ -393,19 +493,16 @@ function assertLejiWorkspacePrivate(root: string, rootPath: string): void { /** Create leji.json with O_EXCL (`wx`) so check-then-write is atomic: a concurrent * run or a planted symlink can't be overwritten or followed. EEXIST surfaces as the * same "already exists" error as the entry point's initial guard. */ -function writeManifestExclusive(abs: string, content: string, mode: 'init' | 'adopt'): void { - try { - fs.writeFileSync(abs, content, { flag: 'wx' }); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'EEXIST') { - throw new Error( - mode === 'adopt' - ? 'leji.json already exists here; this repository already has a Leji layer' - : 'leji.json already exists here; init refuses to overwrite an existing layer', - ); - } - throw e; +function writeManifestExclusive(rootAbs: string, abs: string, content: string, mode: 'init' | 'adopt'): void { + const verdict = writeFileGuarded(guardRoot(rootAbs), abs, null, content, { exclusive: true }); + if (verdict.exists === true) { + throw new Error( + mode === 'adopt' + ? 'leji.json already exists here; this repository already has a Leji layer' + : 'leji.json already exists here; init refuses to overwrite an existing layer', + ); } + guardedOrRefuse('leji.json', verdict); } const CATEGORY_STUBS: Record = { @@ -542,6 +639,9 @@ function buildBootProfile(answers: InitAnswers): string { function buildCoreProfile(answers: InitAnswers): string { let text = readTemplate(path.join('agents', 'core.md')); text = text.replaceAll('docs/', joinUnderRoot(answers.rootPath, '')); + // The escalation line names a person, so the scaffold fills it: a profile that + // shipped `` would be the placeholder the lint exists to catch. + text = text.replaceAll('', answers.ownerName); if (!answers.categories.includes('governance')) { text = text.replace(/^ {2}- .*governance\/\n/m, ` - ${joinUnderRoot(answers.rootPath, 'decisions/')}\n`); } @@ -571,7 +671,7 @@ deciders: ## Context -Engineering knowledge lived in heads, chat threads, and per-tool config files. People and agents had no single place to read how this team thinks. +This repository takes a shared, versioned context layer: one record of how it works, kept in the repository and read by people and agents alike. ## Decision @@ -579,7 +679,7 @@ Adopt Leji at the \`${answers.level}\` level: ${indexedLine}. ## Consequences -Vendor config files become one-line redirects. Context fixes ride the same review gate as the work that surfaces them. ${answers.ownerName} owns the layer. +Context changes ride the same review gate as the work that surfaces them, and ${answers.ownerName} owns the layer. Agent entrypoints point at the context layer rather than carrying their own copy: the portable \`AGENTS.md\` pointer where the scaffold writes one, and vendor entrypoints only where \`leji adopt --wire-adapters\` converts them with your consent. `; } @@ -604,19 +704,19 @@ function buildChangelog(answers: InitAnswers, written: string[]): string { } /** The transient onboarding brief, rewritten for the chosen root (joinUnderRoot('.', '') - * is '', so a "." root yields `.leji/...`, not `..leji/`) and stamped with the - * working mode so the agent runs the right interview without re-asking. */ + * is '', so a "." root yields `context/...`, not `.context/`) and stamped with the + * working mode so the agent runs the right interview without re-asking. The + * workspace paths it names are root-relative already and need no rewriting. */ function buildBrief(answers: InitAnswers): string { return readTemplate('onboarding-brief.md') .replaceAll('/', joinUnderRoot(answers.rootPath, '')) .replaceAll('', answers.mode); } -/** Path of the transient onboarding brief, under a dot-directory so it is - * excluded from the index, the viewer, and the changelog. */ -export function briefPath(rootPath: string): string { - return joinUnderRoot(rootPath, '.leji/onboarding-brief.md'); -} +/** Path of the transient onboarding brief: the workspace role of the unified root + * `.leji/`, under a dot-directory so it is excluded from the index, the viewer, and + * the changelog. Root-relative whatever rootPath is. */ +export const BRIEF_PATH = `${WORK_REL}/onboarding-brief.md`; /** The CI workflow path, relative to the repository root. */ export const CI_WORKFLOW_PATH = '.github/workflows/leji.yml'; @@ -627,10 +727,6 @@ export const AZURE_PIPELINE_PATH = '.azure-pipelines/leji.yml'; const GITLAB_MARKER_START = '# >>> leji ci (managed) >>>'; const GITLAB_MARKER_END = '# <<< leji ci (managed) <<<'; -// The npm package name; its presence in the repo's package.json selects the -// local-first CI and hook variants over the `npx @leji-org/leji@1` fallback. -const DEP_NAME = '@leji-org/leji'; - // Azure Pipelines does not auto-discover a YAML file (unlike the other three), so // the file is written but the pipeline still has to be created in Azure DevOps. const AZURE_ACTIVATION_NOTE = @@ -651,40 +747,57 @@ export interface HookResult { } const HOOK_MARKER = '# leji pre-commit (managed)'; + +/** + * One argv element, quoted for `sh`. Single quotes take everything literally, and + * an embedded quote is closed, escaped, and reopened (`'\''`) — the one escape a + * POSIX shell accepts inside them. The runner comes from the repository's own + * package manager, so it is never interpolated raw into generated shell. + */ +export function shQuote(word: string): string { + return `'${word.split("'").join(`'\\''`)}'`; +} + +/** The runner argv as one quoted command prefix: `'pnpm' 'exec' 'leji'`. */ +function shCommand(runner: string[]): string { + return runner.map(shQuote).join(' '); +} + // The failure message is single-quoted for the SHELL, not just for this template // literal: the backticks around `leji index` are literal text, and inside a // double-quoted echo sh would run them as a command substitution (regenerating the // index the hook just refused a commit over). Never emit an unquoted backtick, // `$(`, or `$VAR` into generated shell unless expansion is the intent. -const HOOK_BODY = `#!/bin/sh -${HOOK_MARKER} -# Validate the context layer and refuse a commit that would leave the stored -# index stale. Local mirror of the CI gate, preferring a repo-local install; -# delete this file to opt out. -LEJI="leji" -[ -x "node_modules/.bin/leji" ] && LEJI="node_modules/.bin/leji" -"$LEJI" validate || exit 1 -"$LEJI" index --check || { +const HOOK_GATES = (runner: string[]): string => { + const leji = shCommand(runner); + return `${leji} validate || exit 1 +${leji} index --check || { echo 'leji: stored index is stale; run \`leji index\` and stage the result.' >&2 exit 1 } `; +}; + +/** The standalone managed pre-commit hook, running the repository's own runner. */ +export function HOOK_BODY(runner: string[]): string { + return `#!/bin/sh +${HOOK_MARKER} +# Validate the context layer and refuse a commit that would leave the stored +# index stale. Local mirror of the CI gate; delete this file to opt out. +${HOOK_GATES(runner)}`; +} const HUSKY_MARKER_START = '# >>> leji hooks (managed) >>>'; const HUSKY_MARKER_END = '# <<< leji hooks (managed) <<<'; -// The same two gates HOOK_BODY runs (preferring a repo-local install), wrapped in -// markers so the block can be merged into a husky repo's hand-authored -// `.husky/pre-commit` without touching its rest. -const HUSKY_BLOCK = `${HUSKY_MARKER_START} -LEJI="leji" -[ -x "node_modules/.bin/leji" ] && LEJI="node_modules/.bin/leji" -"$LEJI" validate || exit 1 -"$LEJI" index --check || { - echo 'leji: stored index is stale; run \`leji index\` and stage the result.' >&2 - exit 1 -} -${HUSKY_MARKER_END} + +/** The same two gates HOOK_BODY runs, wrapped in markers so the block can be + * merged into a husky repo's hand-authored `.husky/pre-commit` without touching + * its rest. */ +export function HUSKY_BLOCK(runner: string[]): string { + return `${HUSKY_MARKER_START} +${HOOK_GATES(runner)}${HUSKY_MARKER_END} `; +} /** The configured `core.hooksPath` for the repo at `root`, or null when unset. Run * from the repo root (git -C) so local, global, and system scopes resolve; an argv @@ -718,6 +831,28 @@ function gitHooksDir(rootAbs: string): string | null { } } +/** Git's own directories for the repo at `rootAbs`, resolved absolute: this working + * tree's git dir and the common dir it shares with every linked worktree. Both are + * read-only queries, and together they are what decides whether a hook target is + * clone-local (personal) rather than committed (shared). Null when this is not a git + * repository. */ +function gitDirs(rootAbs: string): { gitDir: string; commonDir: string } | null { + try { + const out = execFileSync('git', ['-C', rootAbs, 'rev-parse', '--git-dir', '--git-common-dir'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const lines = out + .split('\n') + .map((l) => l.trim()) + .filter((l) => l !== ''); + if (lines.length < 2) return null; + return { gitDir: path.resolve(rootAbs, lines[0]), commonDir: path.resolve(rootAbs, lines[1]) }; + } catch { + return null; + } +} + /** Husky shape of the configured hooks path: `underscore` for husky v9 (`.husky/_`), * `direct` for husky v8 (`.husky`), or null when not husky-shaped or unset. Decides * block-vs-file routing and (with the resolved hooks dir) the `.husky/pre-commit` @@ -731,6 +866,93 @@ function huskyShape(rootAbs: string, hooksPath: string | null): 'underscore' | ' return null; } +/** + * Who owns the pre-commit hook this repository would get, decided by where the write + * would actually land rather than by the mechanism that would perform it: + * `personal` under git's own directories AND inside this working tree (`.git/hooks`, + * a `core.hooksPath` resolving inside them) — per clone, never committed, and safe to + * write; `shared` inside the working tree but not under git's directories (husky, a + * `githooks/` hooks path) — committed, so a maintainer's call; `outside-root` under + * git's directories but OUTSIDE this working tree (a linked worktree, whose hooks + * live in the common git directory) — per clone, but the writer refuses to write + * outside the repository root, so it is reported; `external` anywhere else (a global + * or `$HOME` hooks path, a symlink escaping the repository) — reported, never + * written; `no-git` when there is no repository to hang a hook on. + */ +export type HookOwnership = 'personal' | 'shared' | 'outside-root' | 'external' | 'no-git'; + +/** What stands at that target: leji's own managed hook or block, nothing at all, or + * a hook this tool did not write. */ +export type HookState = 'current' | 'absent' | 'foreign'; + +/** The read-only answer `leji start` reports and `ensureLocalHook` would act on. */ +export interface HookStatus { + ownership: HookOwnership; + state: HookState; + /** The target, repository-relative when it lies inside the repository, else the + * absolute path git resolved; empty when there is no repository. */ + path: string; + managed: 'file' | 'block'; + /** What a person adds by hand where leji must not write. */ + snippet: string; +} + +/** The hook file's text, or null when nothing readable stands there. Read-only: this + * answers a question, and every write still goes through `ensureLocalHook`. */ +function hookText(abs: string): string | null { + try { + if (!fs.statSync(abs).isFile()) return null; + return fs.readFileSync(abs, 'utf8'); + } catch { + return null; + } +} + +/** + * `ensureLocalHook`'s resolve step, without the write: where the managed pre-commit + * hook would go for this repository, who owns that location, and what stands there + * now. The whole point is that a report can be produced without touching anything — + * `leji start` prints it, and only a consented repair goes on to `ensureLocalHook`. + */ +export function hookStatus(root: string, runner?: string[]): HookStatus { + const rootAbs = path.resolve(root); + const argv = runner ?? runnerArgv(detectEcosystem(rootAbs)); + const hooksDir = gitHooksDir(rootAbs); + const dirs = gitDirs(rootAbs); + if (hooksDir === null || dirs === null) { + return { ownership: 'no-git', state: 'absent', path: '', managed: 'file', snippet: HOOK_BODY(argv) }; + } + const shape = huskyShape(rootAbs, hooksPathConfig(rootAbs)); + const target = + shape === 'underscore' ? path.join(path.dirname(hooksDir), 'pre-commit') : path.join(hooksDir, 'pre-commit'); + const managed: 'file' | 'block' = shape ? 'block' : 'file'; + // Git's directories are tested FIRST: an ordinary `.git/hooks` also lies inside the + // working tree, and it is per-clone state, not something a commit can carry. A + // clone-local target that nonetheless falls outside this working tree (a linked + // worktree's shared hooks directory) is reported rather than offered: the writer + // refuses everything outside the repository root, so offering it would promise a + // write that cannot happen. + const inRepo = resolvedWithinRoot(rootAbs, target); + const cloneLocal = resolvedWithinRoot(dirs.gitDir, target) || resolvedWithinRoot(dirs.commonDir, target); + const ownership: HookOwnership = cloneLocal + ? inRepo + ? 'personal' + : 'outside-root' + : inRepo + ? 'shared' + : 'external'; + const existing = hookText(target); + const marker = managed === 'block' ? HUSKY_MARKER_START : HOOK_MARKER; + const state: HookState = existing === null ? 'absent' : existing.includes(marker) ? 'current' : 'foreign'; + return { + ownership, + state, + path: inRepo ? toPosix(path.relative(rootAbs, target)) : toPosix(target), + managed, + snippet: managed === 'block' ? HUSKY_BLOCK(argv) : HOOK_BODY(argv), + }; +} + /** Write a managed pre-commit hook running the same checks CI runs, so drift is * caught before a commit instead of at the pipeline. The write location is git's * effective hooks dir (`rev-parse --git-path hooks`); core.hooksPath decides whether @@ -738,10 +960,14 @@ function huskyShape(rootAbs: string, hooksPath: string | null): 'underscore' | ' * or a standalone managed hook is written. A hooks dir resolving outside the repo (a * global `core.hooksPath`) is never written — the snippet comes back for a manual * hand-add, as does an existing unmanaged hook. */ -export function ensureLocalHook(root: string): HookResult { +export function ensureLocalHook(root: string, runner?: string[]): HookResult { const rootAbs = path.resolve(root); const hooksDir = gitHooksDir(rootAbs); if (hooksDir === null) throw new Error('not a git repository (no .git directory); hooks need one'); + // The hook runs what a clean install of THIS repository provides: the detected + // manager's runner when the CLI is actually declared, else the plain binary on + // PATH. Injectable so a test pins a runner without planting a manifest. + const argv = runner ?? runnerArgv(detectEcosystem(rootAbs)); const shape = huskyShape(rootAbs, hooksPathConfig(rootAbs)); // Husky's user-editable hook is `.husky/pre-commit`: the hooks dir itself for v8 // (`.husky`), its parent for v9 (`.husky/_`). Only a direct v8 hook is run by git @@ -753,34 +979,58 @@ export function ensureLocalHook(root: string): HookResult { return { path: toPosix(target), action: 'manual', - snippet: shape ? HUSKY_BLOCK : HOOK_BODY, + snippet: shape ? HUSKY_BLOCK(argv) : HOOK_BODY(argv), managed: shape ? 'block' : 'file', reason: 'outside-root', }; } const rel = toPosix(path.relative(rootAbs, target)); - return shape ? ensureHuskyBlock(target, rel, shape === 'direct') : ensureHookFile(target, rel); + // The hook is written through the chokepoint, judged on the resolved path at the + // act; a target that stopped resolving inside the repository between the check + // above and the write comes back as the same hand-add result that check returns. + const manual = (managed: 'file' | 'block'): HookResult => ({ + path: toPosix(target), + action: 'manual', + snippet: managed === 'block' ? HUSKY_BLOCK(argv) : HOOK_BODY(argv), + managed, + reason: 'outside-root', + }); + const guardRootAbs = guardRoot(rootAbs); + return shape + ? ensureHuskyBlock(guardRootAbs, target, rel, shape === 'direct', manual, argv) + : ensureHookFile(guardRootAbs, target, rel, manual, argv); } /** Write/refresh the standalone managed pre-commit hook at `hookAbs`. Ours (marker * present) is created/updated; an existing unmanaged hook is never touched and its * replacement snippet comes back for a manual merge. */ -function ensureHookFile(hookAbs: string, rel: string): HookResult { - const existing = isFile(hookAbs) ? readText(hookAbs) : null; +function ensureHookFile( + rootAbs: string, + hookAbs: string, + rel: string, + manual: (managed: 'file' | 'block') => HookResult, + runner: string[], +): HookResult { + const body = HOOK_BODY(runner); + // The hook's own bytes decide whether it is ours to rewrite, so they come from the + // verified read: an entry standing at the hook path that cannot be verified as a + // regular file inside the repository is reported for a hand-add, never merged. + const hookRead = verifiedTargetRead(rootAbs, hookAbs, null); + if (hookRead.status === 'refused') return manual('file'); + const existing = hookRead.status === 'regular' ? hookRead.bytes.toString('utf8') : null; if (existing !== null && !existing.includes(HOOK_MARKER)) { - return { path: rel, action: 'manual', snippet: HOOK_BODY, managed: 'file', reason: 'foreign-hook' }; + return { path: rel, action: 'manual', snippet: body, managed: 'file', reason: 'foreign-hook' }; } - if (existing === HOOK_BODY) { + if (existing === body) { // Byte-current. A standalone hook is run by git itself, so a non-executable // file is a mode-only correction reported updated, not unchanged. if (!isExecutable(hookAbs)) { - fs.chmodSync(hookAbs, 0o755); + if (!chmodGuarded(rootAbs, hookAbs, null, 0o755).ok) return manual('file'); return { path: rel, action: 'updated', managed: 'file' }; } return { path: rel, action: 'unchanged', managed: 'file' }; } - fs.mkdirSync(path.dirname(hookAbs), { recursive: true }); - fs.writeFileSync(hookAbs, HOOK_BODY, { mode: 0o755 }); + if (!writeFileGuarded(rootAbs, hookAbs, null, body, { mode: 0o755 }).ok) return manual('file'); return { path: rel, action: existing === null ? 'created' : 'updated', managed: 'file' }; } @@ -791,21 +1041,34 @@ function ensureHookFile(hookAbs: string, rel: string): HookResult { * user-authored husky hook is left untouched. `requireExec` (a direct `.husky` hook * git runs itself) forces mode 0755: a byte-current but non-executable file is a * mode-only correction reported `updated`. */ -function ensureHuskyBlock(hookAbs: string, rel: string, requireExec: boolean): HookResult { - const existing = isFile(hookAbs) ? readText(hookAbs) : null; +function ensureHuskyBlock( + rootAbs: string, + hookAbs: string, + rel: string, + requireExec: boolean, + manual: (managed: 'file' | 'block') => HookResult, + runner: string[], +): HookResult { + const block = HUSKY_BLOCK(runner); + // The user's own hook is merged, so its bytes come from the verified read: what the + // merge judged is what the rewrite is based on. + const hookRead = verifiedTargetRead(rootAbs, hookAbs, null); + if (hookRead.status === 'refused') return manual('block'); + const existing = hookRead.status === 'regular' ? hookRead.bytes.toString('utf8') : null; if (existing === null) { - fs.mkdirSync(path.dirname(hookAbs), { recursive: true }); - fs.writeFileSync(hookAbs, `#!/bin/sh\n${HUSKY_BLOCK}`, { mode: 0o755 }); + if (!writeFileGuarded(rootAbs, hookAbs, null, `#!/bin/sh\n${block}`, { mode: 0o755 }).ok) { + return manual('block'); + } return { path: rel, action: 'created', managed: 'block' }; } - const merged = mergeManagedBlock(existing, HUSKY_BLOCK, HUSKY_MARKER_START, HUSKY_MARKER_END); + const merged = mergeManagedBlock(existing, block, HUSKY_MARKER_START, HUSKY_MARKER_END); if (merged !== existing) { - fs.writeFileSync(hookAbs, merged); - if (requireExec) fs.chmodSync(hookAbs, 0o755); + if (!writeFileGuarded(rootAbs, hookAbs, null, merged).ok) return manual('block'); + if (requireExec && !chmodGuarded(rootAbs, hookAbs, null, 0o755).ok) return manual('block'); return { path: rel, action: 'updated', managed: 'block' }; } if (requireExec && !isExecutable(hookAbs)) { - fs.chmodSync(hookAbs, 0o755); + if (!chmodGuarded(rootAbs, hookAbs, null, 0o755).ok) return manual('block'); return { path: rel, action: 'updated', managed: 'block' }; } return { path: rel, action: 'unchanged', managed: 'block' }; @@ -844,140 +1107,184 @@ export interface CiResult { } /** - * Add a CI workflow running `leji validate` (the `leji ci` command). GitHub: own - * workflow file. GitLab: create-or-merge a marker-delimited managed block in the - * shared `.gitlab-ci.yml`. CircleCI: created if absent, else left untouched with a - * hand-add snippet returned. Azure: own file plus an activation note (ADO doesn't - * auto-discover). All deterministic text so the three SDKs stay byte-identical. - * Refuses a symlink that escapes root. + * Digests of every whole file this generator has ever written, so a file leji + * created in an EARLIER release is still recognized as its own and upgraded rather + * than abandoned. Appended at each release; the marker line carries the generator + * version that wrote a file, and these digests carry the ones that predate it. + * + * Keyed by provider, and consulted only for the provider whose path is being + * written: the same bytes are leji's workflow at `.github/workflows/leji.yml` and + * somebody else's file at `.azure-pipelines/leji.yml`. + * + * Seeded with the pre-1.4 (1.3.x) variants, which carry no marker at all: two per + * whole-file provider, the local-install job and the `npx @leji-org/leji@1` + * fallback. Each release since appends its own twelve at pre-flight, enumerated by + * `ciVariants()` and printed by a test (`LEJI_PRINT_CI_DIGESTS=1`), so the next + * release still recognizes them; while a release is current its variants are also + * compared by bytes, which is strictly stronger. + */ +const KNOWN_GENERATED: Record = { + // 1.3.x GitHub Actions: local install, then the npx fallback. + github: [ + 'ef38ea0bc0daa13b9856ca9abeb5f2229ae2465aeed2f61bd94f557a1806f13d', + '1c2afeb4d3043f94823ac0c1a254a8735c0fe87844cd454cf8ae07fbfa6588d4', + // 1.4.0: the twelve job variants, in ciVariants() order. + '616638c5c1594e8faeb38cd476b9c5e0a4d12889a01b54076a0f8ffceba8a1a8', // npm-local + '1b9afce2109d75c86ba3e3b33abd2466d55509d7e46b5ef79a9407d3df566154', // pnpm-local + 'e16f877d33a5be6e2c720112692167e9442fb5c63a2335f95401b7a891d46e18', // yarn-local + 'e0819c85b4b3e540472fa5d2a3820ae0c4837a41b66cc97de84581c1b19ede96', // bun-local + '7b3d400ea23799ebf26541bffe059db4e9f60021fdf0f783c1f1cfa7a532816d', // uv-local + '87089be204a85a61dfcfbcb9f4a9f2ad2f5afc8b00f384d52dfbd81c63e0296f', // poetry-local + '48b3c7a1751b65bbea29421e7e952ac8c2720169ff332ea0e277573a2fb582c0', // pdm-local + '809d7ee991b8c1182442d93e326d4dc3ad9e0993f91f4da83aac8187c98e90bb', // pipenv-local + 'e952a6109a05d97adc2791f641f807245c75ba401ced279964b06fcc2987b92e', // go-local + 'ae3385d9deac83936000100621078011b1918a66237d4ae1ef72770dc677914a', // node-fallback + 'b0be130068ad150eb7f59a2166a4fc22601e10ec961d2144d4c158602fde1f9c', // python-fallback + '91b37a1c14fcc6f237d9600b8f49bb936eb2091f418fe8932fc94cdbfe91454f', // go-fallback + ], + // GitLab owns a marked block inside a shared file, never a whole file, so it + // recognizes its own output by the markers and registers no digests. + gitlab: [], + circleci: [ + '99a942be4f0ac62672af68a9d33e17328441e64f3b28b52ff8dafede0a5ce9f0', + 'cf813aa8c65a5efa64500628bc51c73d3ae3a5f56ec47386f5525de0828818d3', + // 1.4.0: the twelve job variants, in ciVariants() order. + 'e72c78146170d54a4b79326b3a8933ba0ca54bf76be665b45f47009729a864b1', // npm-local + '039559039ccda2dca14da3366cb1ca56895f4eaf48a395a7dd75f4a3614dabc1', // pnpm-local + 'ba5303502b7fc3e70174b163666fe27af855663b2b66900a0e1affcd3ee3290b', // yarn-local + 'e46e865183f764c1d6bf594e9d074744911221232db2805ea3de3896fa920ce4', // bun-local + '4b974432dc0d1939a89e8ca9c130ec16c70e1aeb1252fd5895785018e9e84f98', // uv-local + '3cc62af0609c563e0268e632285d28d7365c4ce81652e1c0d9f3f62496f01665', // poetry-local + 'b519fa8e216b62a8da7ce0f98b53de81dde62f4c3bed924b7fbc59b7b5f8af1f', // pdm-local + '6786b3df303d00239170bf0365e7ff66662fcfc0912ea6cd992547e1422eed9a', // pipenv-local + '43ca7c5fce284555d5b72a6cd69c153e295ae01f921dd5e2b755e11d4e3e9e8f', // go-local + 'b82f65f616ec46445e43b8c1680618428cce89ee17e69f0a1e2b94b4ace2fc9b', // node-fallback + '0d2135d41e50be5fa6811bdc9aa85fc0ce4110ec918e17c139cd969087834e4b', // python-fallback + '704a4b3c3f8880c505e181eed154234e4640cdad5d13a3c9dfe5b4cdcdfbd3d9', // go-fallback + ], + azure: [ + '71fb19e18660e84ec4a2b9364ea6a9dea0ca7aff8bb52ede8d5c3f4d77c68669', + '7a086e5cd0f2e8a2e67b925ec54b8e8febb1bca016e1893c95fd00815d86c63a', + // 1.4.0: the twelve job variants, in ciVariants() order. + '9c8127bfb670731eb08089b02a1adecc135bc32524699793e83e23eb4143e4f1', // npm-local + '099befce80e7420297583ee3a88bed97f01c073dbb756bbf64ad37b321eb506a', // pnpm-local + '5d1f2642c97954b6fca52fa8239534c5633cc3bc512c7946f2515473bde58bb7', // yarn-local + '9e60a214631033172e3021d773581f15db14c3e1e05d1673f05d7752ff054b03', // bun-local + 'c4d041736cedbd2092667480bbaf91711f3696997256456fa276a59660c66555', // uv-local + '2913197fc21a1fbf3587695af2b2d2215b6f9966507b542ecc08e44727b5bf2b', // poetry-local + 'f756c8ea1ff653d4bed01ac60aa9ba26b426936cf19a5be54508a166e66ca6f6', // pdm-local + '7a452ce135e706fdada5f953a18bdaafb0fd453650d061b0e07f62e5309d6d58', // pipenv-local + 'ce1b7546d140ba09838e9af8dab92ecebf75f20c7e7714c2eeb210ab1f1422d3', // go-local + '1a4167a0b4b3a5b7528d7a6d0bcefeabd37f617b02f82772fd6c74c148d9e17e', // node-fallback + 'c9cd3d115cb4f4d9db1b3f523cfbbf1397923df6142c58e5e6c8562ff57fa908', // python-fallback + '23679c491cbd53e39ffc5d940de7b97865f1b944bf55ad1bd7e73b8c57520606', // go-fallback + ], +}; + +/** + * Is this file leji's to replace? Yes when its bytes are one this generator can + * write right now, or when its digest is one an earlier release wrote. A file the + * user edited matches neither, and is left alone with a snippet — editing a + * generated file, or deleting its marker, is the opt-out, and it is honored. */ -export function ensureCiWorkflow(root: string, provider: CiProvider): CiResult { +function isLejiGenerated(provider: CiProvider, text: string): boolean { + if (ciVariants().some((v) => v.provider === provider && v.bytes === text)) return true; + const digest = crypto.createHash('sha256').update(text, 'utf8').digest('hex'); + // Scoped to THIS provider: a file that is leji's at one provider's path is a + // foreign file at another's, and a foreign file is never replaced. + return KNOWN_GENERATED[provider].includes(digest); +} + +/** + * Add a CI workflow running `leji validate` (the `leji ci` command), with the job + * the repository's own package manager needs. GitHub, CircleCI and Azure own whole + * files: created when absent, REPLACED when the file standing there is one leji + * generated (this release or an earlier one), and left untouched with a hand-add + * snippet when it is foreign or was edited. GitLab owns a marker-delimited block + * inside the shared `.gitlab-ci.yml` and merges it. All deterministic text so the + * three SDKs stay byte-identical. Refuses a symlink that escapes root. + */ +export function ensureCiWorkflow(root: string, provider: CiProvider, report?: EcosystemReport): CiResult { const rootAbs = path.resolve(root); - // Local-first: a repo that declares @leji-org/leji runs its lockfile-pinned - // install; a repo without one falls back to `npx @leji-org/leji@1`. - const local = declaresLejiDep(rootAbs) && hasNpmLockfile(rootAbs); - switch (provider) { - case 'github': { - const abs = path.join(rootAbs, CI_WORKFLOW_PATH); - guardWithinRoot(rootAbs, abs, CI_WORKFLOW_PATH); - if (fs.existsSync(abs)) return { provider, path: CI_WORKFLOW_PATH, action: 'unchanged' }; - writeFileAtomic(rootAbs, abs, CI_WORKFLOW_PATH, buildGithubWorkflow(local)); - return { provider, path: CI_WORKFLOW_PATH, action: 'created' }; + // Local-first: a repository that DECLARES the CLI and carries its manager's lock + // evidence installs its own locked dependencies and runs the local binary; every + // other state takes the fallback that needs no manifest. + const job = resolveCiJob(report ?? detectEcosystem(rootAbs), provider); + // Every arm decides what stands at its target through the verified read, never + // through a pathname check: `existsSync` follows symlinks, so a dangling link at + // the workflow path reads as absent and the create lands at the link's + // destination. `null` is the create path; a verified regular file is judged by its + // bytes; a standing entry that cannot be verified is the same refusal a write to + // it would be. + const rootReal = guardRoot(rootAbs); + + /** The shared whole-file arm: create, replace what we own, or hand back a snippet. */ + const wholeFile = (rel: string, snippet: string, note?: string): CiResult => { + const abs = path.join(rootAbs, rel); + guardWithinRoot(rootAbs, abs, rel); + const content = buildCiFile(provider, job); + const existing = readMergeSource(rootReal, abs, rel); + if (existing === null) { + writeFileAtomic(rootAbs, abs, rel, content); + return note ? { provider, path: rel, action: 'created', note } : { provider, path: rel, action: 'created' }; + } + if (existing === content) return { provider, path: rel, action: 'unchanged' }; + if (!isLejiGenerated(provider, existing)) { + return { provider, path: rel, action: 'manual', snippet }; } + writeFileAtomic(rootAbs, abs, rel, content); + return { provider, path: rel, action: 'updated' }; + }; + + switch (provider) { + case 'github': + return wholeFile(CI_WORKFLOW_PATH, buildGithubWorkflow(job)); case 'gitlab': { const abs = path.join(rootAbs, GITLAB_CI_PATH); guardWithinRoot(rootAbs, abs, GITLAB_CI_PATH); - const block = buildGitlabBlock(local); - if (!fs.existsSync(abs)) { + const block = buildGitlabBlock(job); + // The merge is a read-then-write of one target, so the bytes come from the + // verified read: the file the rule judged is the file that is read and then + // rewritten. + const text = readMergeSource(rootReal, abs, GITLAB_CI_PATH); + if (text === null) { writeFileAtomic(rootAbs, abs, GITLAB_CI_PATH, block); return { provider, path: GITLAB_CI_PATH, action: 'created' }; } - const text = fs.readFileSync(abs, 'utf8'); const merged = mergeGitlabBlock(text, block); if (merged === text) return { provider, path: GITLAB_CI_PATH, action: 'unchanged' }; writeFileAtomic(rootAbs, abs, GITLAB_CI_PATH, merged); return { provider, path: GITLAB_CI_PATH, action: 'updated' }; } - case 'circleci': { - const abs = path.join(rootAbs, CIRCLECI_CONFIG_PATH); - guardWithinRoot(rootAbs, abs, CIRCLECI_CONFIG_PATH); - if (fs.existsSync(abs)) { - return { provider, path: CIRCLECI_CONFIG_PATH, action: 'manual', snippet: buildCircleCiSnippet(local) }; - } - writeFileAtomic(rootAbs, abs, CIRCLECI_CONFIG_PATH, buildCircleCiConfig(local)); - return { provider, path: CIRCLECI_CONFIG_PATH, action: 'created' }; - } - case 'azure': { - const abs = path.join(rootAbs, AZURE_PIPELINE_PATH); - guardWithinRoot(rootAbs, abs, AZURE_PIPELINE_PATH); + case 'circleci': + return wholeFile(CIRCLECI_CONFIG_PATH, buildCircleCiSnippet(job)); + case 'azure': // Activation note is created-only: a re-run on an existing file stays quiet. - if (fs.existsSync(abs)) return { provider, path: AZURE_PIPELINE_PATH, action: 'unchanged' }; - writeFileAtomic(rootAbs, abs, AZURE_PIPELINE_PATH, buildAzurePipeline(local)); - return { provider, path: AZURE_PIPELINE_PATH, action: 'created', note: AZURE_ACTIVATION_NOTE }; - } + return wholeFile(AZURE_PIPELINE_PATH, buildAzurePipeline(job), AZURE_ACTIVATION_NOTE); default: // Unreachable from the CLI (validates first); guards direct helper callers. throw new Error(`unknown provider "${provider}"`); } } -/** True when the repo's root package.json declares `@leji-org/leji` under - * `dependencies` or `devDependencies`. Deterministic and identical across SDKs: read - * bytes, strip a single leading UTF-8 BOM, strict JSON parse (any error → not - * declared), and count `dependencies`/`devDependencies` only when they are JSON - * objects holding the exact key (any other type → absent, never an error). */ -/** - * The generated local-install job runs `npm ci`, which requires an npm lockfile. - * A pnpm, Yarn or Bun repository can declare the dependency and still have no - * `package-lock.json`, and the job would fail before Leji ran. Declaring the - * dependency is therefore not sufficient: the lockfile has to be there too, or the - * generator falls back to the version-pinned `npx` form that needs no install. - */ -function hasNpmLockfile(rootAbs: string): boolean { - return fs.existsSync(path.join(rootAbs, 'package-lock.json')); -} - -function declaresLejiDep(rootAbs: string): boolean { - let raw: string; - try { - raw = readText(path.join(rootAbs, 'package.json')); - } catch { - return false; - } - // Strip a single leading UTF-8 BOM (utf8 decoding surfaces it as U+FEFF). - if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); - let pkg: unknown; - try { - pkg = JSON.parse(raw); - } catch { - return false; - } - if (!isJsonObject(pkg)) return false; - for (const field of ['dependencies', 'devDependencies'] as const) { - const deps = pkg[field]; - if (isJsonObject(deps) && Object.prototype.hasOwnProperty.call(deps, DEP_NAME)) return true; - } - return false; -} - -/** A non-null, non-array JSON object. */ -function isJsonObject(x: unknown): x is Record { - return typeof x === 'object' && x !== null && !Array.isArray(x); -} - function guardWithinRoot(rootAbs: string, abs: string, rel: string): void { if (!resolvedWithinRoot(rootAbs, abs)) { throw new Error(`refusing to write through a symlink that escapes the target: "${rel}"`); } } -/** Write `abs` atomically (sibling temp + rename) so an interrupted write never - * leaves a partial file. On failure the temp is removed and a deterministic, - * OS-text-free error is raised so the three SDKs report I/O failures identically. */ +/** Write `abs` atomically (sibling temp + rename, both ends judged by the write + * chokepoint) so an interrupted write never leaves a partial file. On failure the + * temp is removed and a deterministic, OS-text-free error is raised so the three + * SDKs report I/O failures identically. */ function writeFileAtomic(rootAbs: string, abs: string, rel: string, contents: string): void { - const tmp = `${abs}.leji-tmp`; - // The temp path must not escape root either (a planted `.leji-tmp` - // symlink would otherwise be written through before the rename). - guardWithinRoot(rootAbs, tmp, rel); + let verdict; try { - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(tmp, contents); - maybeInjectWriteFailure(); - fs.renameSync(tmp, abs); + verdict = writeFileAtomicGuarded(guardRoot(rootAbs), abs, initRole(rel), contents); } catch (e) { - try { - fs.rmSync(tmp, { force: true }); - } catch { - /* best-effort cleanup; surface the normalized write error below */ - } throw new Error(writeFailureMessage(rel, e)); } -} - -/** Test-only fault injection: with LEJI_TEST_FAIL_RENAME set, fail after the temp - * file exists but before rename, to exercise the cleanup/normalized-error path. */ -function maybeInjectWriteFailure(): void { - if (process.env.LEJI_TEST_FAIL_RENAME) throw new Error('injected write failure'); + guardedOrRefuse(rel, verdict); } /** A deterministic, OS-text-free message for a failed CI-file write, so stderr stays @@ -1029,122 +1336,337 @@ function stripManagedBlocks(text: string, startMarker: string, endMarker: string } } -// Local-first CI: a repo that declares @leji-org/leji installs its lockfile-pinned -// deps and runs the local bin (`npx --no-install` fails loudly rather than fetch a -// floating version); a repo without one falls back to `npx @leji-org/leji@1`, which -// pins the SDK to its current major (@1): additive-only within a major so a valid -// layer stays valid, and a breaking major never reaches adopter CI without a bump. +// --- the generated CI job ------------------------------------------------- +// One table, one job resolution, four renderers. Every cell an adopter's pipeline +// runs is stated here rather than assembled at the call site, so the three SDKs +// transcribe data instead of re-deriving prose, and a reviewer reads the matrix. + +/** Every provider `leji ci` generates for, in a fixed order. */ +export const CI_PROVIDERS: CiProvider[] = ['github', 'gitlab', 'circleci', 'azure']; + +/** The runtime a generated job needs on its runner: which setup step or image. */ +type CiRuntime = 'node' | 'bun' | 'python' | 'go'; + +/** One package manager's CI facts. `pipBootstrap` names a tool that has to be + * installed with pip wherever the provider offers no dedicated setup action. */ +interface CiManagerCell { + runtime: CiRuntime; + install: string; + pipBootstrap?: string; + /** A bootstrap tool the job installs unpinned, disclosed in one comment line. */ + unpinned?: string; +} + +/** Manager -> install command and runtime. The runner argv is NOT duplicated here: + * it comes from the detection report, which owns the one runner table. */ +const CI_MANAGERS: Record = { + npm: { runtime: 'node', install: 'npm ci' }, + pnpm: { runtime: 'node', install: 'corepack enable && pnpm install --frozen-lockfile' }, + yarn: { runtime: 'node', install: 'corepack enable && yarn install --frozen-lockfile' }, + bun: { runtime: 'bun', install: 'bun install --frozen-lockfile' }, + uv: { runtime: 'python', install: 'uv sync --locked', pipBootstrap: 'uv' }, + poetry: { runtime: 'python', install: 'pip install poetry && poetry install', unpinned: 'poetry' }, + pdm: { runtime: 'python', install: 'pip install pdm && pdm install', unpinned: 'pdm' }, + pipenv: { runtime: 'python', install: 'pip install pipenv && pipenv install --dev', unpinned: 'pipenv' }, + go: { runtime: 'go', install: 'go mod download' }, +}; + +/** The one job a provider renders: what to set up, what to install, what to run. */ +interface CiJob { + runtime: CiRuntime; + install: string[]; + runner: string[]; + /** A tool installed unpinned by `install`, disclosed above it. */ + unpinned: string | null; + /** uv through its own GitHub action rather than pip. */ + uvAction: boolean; + /** Whether the job installs the repository's own locked dependencies. */ + local: boolean; +} + +/** The CLI as CI reaches it when the repository does not declare it: version-pinned + * to the current major, which is additive-only, so a valid layer stays valid and a + * breaking major never reaches adopter CI without a bump. */ +const CI_FALLBACK_NODE = ['npx', '-y', `${DEP_NAME}@1`]; +const CI_FALLBACK_PY_INSTALL = "pip install 'leji>=1,<2'"; +const CI_FALLBACK_GO_INSTALL = 'go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest'; + +/** + * Which job this repository gets. Local-first: a repository that DECLARES the CLI + * and has the manager's lock evidence installs its own locked dependencies and runs + * the local binary. Everything else — undeclared, unlocked, ambiguous, unsupported, + * unreadable, refused evidence, several ecosystems, none — takes the fallback for + * its ecosystem, which needs no manifest and no lockfile. + */ +function resolveCiJob(report: EcosystemReport, provider: CiProvider): CiJob { + const selected = report.selected; + const cell = selected?.manager != null ? CI_MANAGERS[selected.manager] : undefined; + if (selected && cell && selected.directDeclared && selected.lockEvidenced && selected.runner) { + // uv is the one manager with a first-party setup action; everywhere else it is + // pip-installed like poetry/pdm/pipenv, and disclosed the same way. + const uvAction = provider === 'github' && cell.pipBootstrap === 'uv'; + const bootstrap = cell.pipBootstrap && !uvAction ? cell.pipBootstrap : null; + return { + runtime: cell.runtime, + install: [bootstrap ? `pip install ${bootstrap} && ${cell.install}` : cell.install], + runner: selected.runner, + unpinned: cell.unpinned ?? bootstrap, + uvAction, + local: true, + }; + } + const ecosystem = report.all.length === 1 ? report.all[0].ecosystem : null; + if (ecosystem === 'python') { + return { + runtime: 'python', + install: [CI_FALLBACK_PY_INSTALL], + runner: ['leji'], + unpinned: null, + uvAction: false, + local: false, + }; + } + if (ecosystem === 'go') { + return { + runtime: 'go', + install: [CI_FALLBACK_GO_INSTALL], + runner: ['leji'], + unpinned: null, + uvAction: false, + local: false, + }; + } + // Node, several ecosystems, and none alike: the job that needs no package manager. + return { runtime: 'node', install: [], runner: CI_FALLBACK_NODE, unpinned: null, uvAction: false, local: false }; +} + +/** The generator schema version. Bumped when the generated shape changes, so the + * marker says which generation wrote a file; pre-1.4 output is implicitly v1. */ +const CI_GENERATOR_VERSION = 2; +const CI_MARKER = `# generated by leji ci (managed) v${CI_GENERATOR_VERSION}`; + +/** The one disclosure line for a job that installs a bootstrap tool unpinned. */ +function unpinnedNote(job: CiJob): string | null { + return job.unpinned === null + ? null + : `# ${job.unpinned} is installed unpinned here; pin it if your project pins it.`; +} + +/** GitHub Actions setup steps for a runtime, already at the steps' indentation. */ +function githubSetup(job: CiJob): string[] { + switch (job.runtime) { + case 'node': + return [' - uses: actions/setup-node@v4', ' with:', " node-version: '22'"]; + case 'bun': + return [' - uses: oven-sh/setup-bun@v2']; + case 'python': + return [ + ' - uses: actions/setup-python@v5', + ' with:', + " python-version: '3.12'", + ...(job.uvAction ? [' - uses: astral-sh/setup-uv@v5'] : []), + ]; + case 'go': + return [' - uses: actions/setup-go@v5', ' with:', " go-version: '1.24'"]; + } +} /** GitHub Actions workflow: a standalone file under .github/workflows/. */ -function buildGithubWorkflow(local: boolean): string { - const run = local - ? ` - run: npm ci - - run: npx --no-install @leji-org/leji validate - - run: npx --no-install @leji-org/leji index --check` - : ` - run: npx -y @leji-org/leji@1 validate - - run: npx -y @leji-org/leji@1 index --check`; - return `name: leji -on: [push, pull_request] -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' -${run} -`; +function buildGithubWorkflow(job: CiJob): string { + const note = unpinnedNote(job); + const lines = [ + CI_MARKER, + 'name: leji', + 'on: [push, pull_request]', + 'jobs:', + ' validate:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v4', + ...githubSetup(job), + ...(note ? [` ${note}`] : []), + ...job.install.map((cmd) => ` - run: ${cmd}`), + ` - run: ${job.runner.join(' ')} validate`, + ` - run: ${job.runner.join(' ')} index --check`, + ]; + return lines.join('\n') + '\n'; +} + +/** The container image a job runs in on the image-based providers. */ +function ciImage(runtime: CiRuntime): string { + switch (runtime) { + case 'node': + return 'node:22'; + case 'bun': + return 'oven/bun:1'; + case 'python': + return 'python:3.12'; + case 'go': + return 'golang:1.24'; + } } /** GitLab CI: a marker-delimited job merged into the shared .gitlab-ci.yml. */ -function buildGitlabBlock(local: boolean): string { - const script = local - ? ` - npm ci - - npx --no-install @leji-org/leji validate - - npx --no-install @leji-org/leji index --check` - : ` - npx -y @leji-org/leji@1 validate - - npx -y @leji-org/leji@1 index --check`; +function buildGitlabBlock(job: CiJob): string { + const note = unpinnedNote(job); // `.pre` is always available. Without an explicit stage GitLab assigns `test`, // and a pipeline whose own `stages:` list omits `test` rejects the whole // configuration, so the generated job would break an existing pipeline it was // merged into. - return `${GITLAB_MARKER_START} -leji-validate: - stage: .pre - image: node:22 - script: -${script} -${GITLAB_MARKER_END} -`; + const lines = [ + GITLAB_MARKER_START, + 'leji-validate:', + ' stage: .pre', + ` image: ${ciImage(job.runtime)}`, + ' script:', + ...(note ? [` ${note}`] : []), + ...job.install.map((cmd) => ` - ${cmd}`), + ` - ${job.runner.join(' ')} validate`, + ` - ${job.runner.join(' ')} index --check`, + GITLAB_MARKER_END, + ]; + return lines.join('\n') + '\n'; } /** CircleCI job steps, shared by the full config and the hand-add snippet. */ -function circleCiSteps(local: boolean): string { - return local - ? ` - checkout - - run: npm ci - - run: npx --no-install @leji-org/leji validate - - run: npx --no-install @leji-org/leji index --check` - : ` - checkout - - run: npx -y @leji-org/leji@1 validate - - run: npx -y @leji-org/leji@1 index --check`; +function circleCiJob(job: CiJob): string[] { + const note = unpinnedNote(job); + return [ + 'jobs:', + ' leji-validate:', + ' docker:', + ` - image: ${ciImage(job.runtime)}`, + ' steps:', + ' - checkout', + ...(note ? [` ${note}`] : []), + ...job.install.map((cmd) => ` - run: ${cmd}`), + ` - run: ${job.runner.join(' ')} validate`, + ` - run: ${job.runner.join(' ')} index --check`, + 'workflows:', + ' leji:', + ' jobs:', + ' - leji-validate', + ]; } /** CircleCI config written when .circleci/config.yml is absent. */ -function buildCircleCiConfig(local: boolean): string { - return `version: 2.1 -jobs: - leji-validate: - docker: - - image: node:22 - steps: -${circleCiSteps(local)} -workflows: - leji: - jobs: - - leji-validate -`; +function buildCircleCiConfig(job: CiJob): string { + return [CI_MARKER, 'version: 2.1', ...circleCiJob(job)].join('\n') + '\n'; } -/** The jobs + workflows fragment to add by hand to an existing CircleCI config. */ -function buildCircleCiSnippet(local: boolean): string { - return `jobs: - leji-validate: - docker: - - image: node:22 - steps: -${circleCiSteps(local)} -workflows: - leji: - jobs: - - leji-validate -`; +/** The jobs + workflows fragment to add by hand to an existing CircleCI config. + * No marker: it is pasted into a file leji does not own. */ +function buildCircleCiSnippet(job: CiJob): string { + return circleCiJob(job).join('\n') + '\n'; +} + +/** Azure Pipelines setup tasks for a runtime, at the steps' indentation. */ +function azureSetup(job: CiJob): string[] { + switch (job.runtime) { + case 'node': + return [' - task: NodeTool@0', ' inputs:', " versionSpec: '22.x'"]; + case 'bun': + return [ + ' - task: NodeTool@0', + ' inputs:', + " versionSpec: '22.x'", + ' - script: npm install -g bun', + ' displayName: install bun', + ]; + case 'python': + return [' - task: UsePythonVersion@0', ' inputs:', " versionSpec: '3.12'"]; + case 'go': + return [' - task: GoTool@0', ' inputs:', " version: '1.24'"]; + } } /** Azure Pipelines: a dedicated .azure-pipelines/leji.yml the user wires to a pipeline. */ -function buildAzurePipeline(local: boolean): string { - const steps = local - ? ` - script: npm ci - displayName: install - - script: npx --no-install @leji-org/leji validate - displayName: leji validate - - script: npx --no-install @leji-org/leji index --check - displayName: leji index --check` - : ` - script: npx -y @leji-org/leji@1 validate - displayName: leji validate - - script: npx -y @leji-org/leji@1 index --check - displayName: leji index --check`; - return `trigger: - - main -pool: - vmImage: ubuntu-latest -steps: - - task: NodeTool@0 - inputs: - versionSpec: '22.x' -${steps} -`; +function buildAzurePipeline(job: CiJob): string { + const note = unpinnedNote(job); + const lines = [ + CI_MARKER, + 'trigger:', + ' - main', + 'pool:', + ' vmImage: ubuntu-latest', + 'steps:', + ...azureSetup(job), + ...(note ? [` ${note}`] : []), + ...job.install.flatMap((cmd) => [` - script: ${cmd}`, ' displayName: install']), + ` - script: ${job.runner.join(' ')} validate`, + ' displayName: leji validate', + ` - script: ${job.runner.join(' ')} index --check`, + ' displayName: leji index --check', + ]; + return lines.join('\n') + '\n'; +} + +/** The whole file a provider writes for a job, or null where the provider owns a + * block inside a shared file (GitLab) rather than a file of its own. */ +function buildCiFile(provider: CiProvider, job: CiJob): string { + switch (provider) { + case 'github': + return buildGithubWorkflow(job); + case 'gitlab': + return buildGitlabBlock(job); + case 'circleci': + return buildCircleCiConfig(job); + case 'azure': + return buildAzurePipeline(job); + } +} + +/** Every job this generator can produce, in a fixed order: the nine local manager + * cells, then the three ecosystem fallbacks. The enumeration is what proves the + * digest registry complete and what bakes the golden fixtures. */ +function ciJobVariants(provider: CiProvider): { key: string; job: CiJob }[] { + const out: { key: string; job: CiJob }[] = []; + for (const [manager, cell] of Object.entries(CI_MANAGERS)) { + const uvAction = provider === 'github' && cell.pipBootstrap === 'uv'; + const bootstrap = cell.pipBootstrap && !uvAction ? cell.pipBootstrap : null; + out.push({ + key: `${manager}-local`, + job: { + runtime: cell.runtime, + install: [bootstrap ? `pip install ${bootstrap} && ${cell.install}` : cell.install], + runner: managerRunnerArgv(manager) ?? ['leji'], + unpinned: cell.unpinned ?? bootstrap, + uvAction, + local: true, + }, + }); + } + for (const [key, job] of [ + ['node-fallback', { runtime: 'node' as CiRuntime, install: [], runner: CI_FALLBACK_NODE }], + ['python-fallback', { runtime: 'python' as CiRuntime, install: [CI_FALLBACK_PY_INSTALL], runner: ['leji'] }], + ['go-fallback', { runtime: 'go' as CiRuntime, install: [CI_FALLBACK_GO_INSTALL], runner: ['leji'] }], + ] as const) { + out.push({ + key, + job: { + ...job, + install: [...job.install], + runner: [...job.runner], + unpinned: null, + uvAction: false, + local: false, + }, + }); + } + return out; +} + +/** Every generated artifact of the CURRENT generator: provider, variant key, and + * bytes. Exported for the tests that bake `fixtures/ci-goldens/` and prove the + * digest registry lists every variant this release can write. */ +export function ciVariants(): { provider: CiProvider; key: string; bytes: string }[] { + const out: { provider: CiProvider; key: string; bytes: string }[] = []; + for (const provider of CI_PROVIDERS) { + for (const { key, job } of ciJobVariants(provider)) { + out.push({ provider, key, bytes: buildCiFile(provider, job) }); + } + } + return out; } // Name (also the agent-profile `id`/agents-map key) and role must be kebab @@ -1214,9 +1736,17 @@ from the boot profile and core profile; it never loosens it. `; } +/** Guidance for the `default` binding: selecting a role profile there is not the + * same as loading it, a distinction the key's name invites readers to miss. + * Written-only, like the CI activation note: a re-run that binds nothing stays + * terse. */ +const AGENTS_DEFAULT_NOTE = + 'agents.default selects a role profile; it does not load it. If its instructions must apply before every task, fold them into the boot profile; otherwise keep the profile role-scoped and engage it through the relevant protocol.'; + /** What `addAgent` did. Each artifact is independently idempotent: a false * `*Created`/`manifestChanged` means it was already there. `hostId` is undefined - * for a host-agnostic resident agent (no `--host`). */ + * for a host-agnostic resident agent (no `--host`). `note` is advisory text the + * caller surfaces verbatim (present when the `default` binding is written). */ export interface AgentResult { name: string; role: string; @@ -1224,6 +1754,7 @@ export interface AgentResult { profilePath: string; profileCreated: boolean; manifestChanged: boolean; + note?: string; } /** Wire a named agent into an existing layer (the `leji agent` command): write a @@ -1253,23 +1784,43 @@ export function addAgent( const base = effectiveAgentProfilesPath(manifest); const profileRel = (base.endsWith('/') ? base : `${base}/`) + `${name}.md`; const profileAbs = path.join(rootAbs, profileRel); - let profileCreated = false; - if (!isFile(profileAbs)) { - if (!resolvedWithinRoot(rootAbs, profileAbs)) { - throw new Error(`refusing to write through a symlink that escapes the target: "${profileRel}"`); - } - fs.mkdirSync(path.dirname(profileAbs), { recursive: true }); - fs.writeFileSync(profileAbs, buildAgentProfile(name, role, hostId, manifest.rootPath)); - profileCreated = true; - } - + const rootReal = guardRoot(rootAbs); + + // Both halves of this command are judged BEFORE either is written: binding an agent + // means a profile file and a manifest edit, and a run that can only do one of them + // must do neither. The manifest is read through the verified read (its bytes are + // spliced and written straight back), so a target that cannot be verified as a + // regular file inside the repository refuses the whole command with nothing + // written. `absent` refuses too: this command edits a manifest, it never creates one. const manifestAbs = path.join(rootAbs, 'leji.json'); - const original = readText(manifestAbs); + const manifestRead = verifiedTargetRead(rootReal, manifestAbs, null); + if (manifestRead.status !== 'regular') { + throw new Error(`refusing to write through a symlink that escapes the target: "leji.json"`); + } + const original = manifestRead.bytes.toString('utf8'); const text = bindAgentInManifestText(original, name, profileRel).text; const manifestChanged = text !== original; - if (manifestChanged) fs.writeFileSync(manifestAbs, text); - return { name, role, hostId, profilePath: profileRel, profileCreated, manifestChanged }; + // The profile half is judged next, still before either write: a pathname check + // follows symlinks, so a dangling link at the profile name reads as absent and the + // write lands at the link's destination. Only `absent` is written; a verified + // regular file is the never-overwrite skip this command has always made; anything + // else standing there refuses the whole command with nothing written. + const profileRead = verifiedTargetRead(rootReal, profileAbs, null); + if (profileRead.status === 'refused') { + throw new Error(`refusing to write through a symlink that escapes the target: "${profileRel}"`); + } + const profileCreated = profileRead.status === 'absent'; + + if (profileCreated) { + const profile = buildAgentProfile(name, role, hostId, manifest.rootPath); + guardedOrRefuse(profileRel, writeFileGuarded(rootReal, profileAbs, null, profile)); + } + if (manifestChanged) guardedOrRefuse('leji.json', writeFileGuarded(rootReal, manifestAbs, null, text)); + + const result: AgentResult = { name, role, hostId, profilePath: profileRel, profileCreated, manifestChanged }; + if (name === 'default' && manifestChanged) result.note = AGENTS_DEFAULT_NOTE; + return result; } /** Refuse to mutate a dirty working tree: the "git restore cleanly undoes Leji's @@ -1338,7 +1889,7 @@ export async function initLayer(options: InitOptions): Promise { writes.push({ rel: `${layout.contextDir}${category}.md`, content: categoryIndexFile(r, category) }); } writes.push({ rel: `${layout.agentsDir}core.md`, content: buildCoreProfile(answers) }); - writes.push({ rel: briefPath(r), content: buildBrief(answers) }); + writes.push({ rel: BRIEF_PATH, content: buildBrief(answers) }); if (answers.level === 'indexed') { // Changelog records the seeded paths (the planned set, minus changelog/index). // Dot-paths (the transient `.leji/` brief) are excluded from the governed @@ -1369,14 +1920,11 @@ export async function initLayer(options: InitOptions): Promise { // The tracked-file preflight and the `.leji/` ignore run BEFORE any write at // all, so the private onboarding workspace can never land in git and a failed // preflight leaves the tree untouched. - assertLejiWorkspacePrivate(root, r); + assertLejiWorkspacePrivate(root); ensureLejiGitignored(root); // leji.json is created exclusively ('wx'): O_EXCL closes the check-then-write // race and won't follow a symlink at the final component. - if (!resolvedWithinRoot(root, path.join(root, 'leji.json'))) { - throw new Error('refusing to write through a symlink that escapes the target: "leji.json"'); - } - writeManifestExclusive(path.join(root, 'leji.json'), writes[0].content, 'init'); + writeManifestExclusive(root, path.join(root, 'leji.json'), writes[0].content, 'init'); written.push('leji.json'); // The changelog is held back until the index generates cleanly. Seeding it off a // tree that cannot be indexed would leave a layer claiming `indexed` with a @@ -1411,7 +1959,7 @@ export async function initLayer(options: InitOptions): Promise { // --- adoption (existing repositories) --- -const DOCS_CANDIDATES = ['docs/', 'doc/', 'documentation/']; +export const DOCS_CANDIDATES = ['docs/', 'doc/', 'documentation/']; /** * The existing docs directory, named as it is on disk, or null if there is none. @@ -1558,15 +2106,14 @@ export async function adoptLayer(options: AdoptOptions): Promise { const bootRel = `${detectedRoot}boot-profile.md`; const canonicalRedirect = adapterContent(bootRel).trim(); - const vendorPresent = KNOWN_VENDOR_FILES.filter((rel) => isFile(path.join(root, rel))) - // A vendor file that symlinks outside root is treated as absent. - .filter((rel) => resolvedWithinRoot(root, path.join(root, rel))); + const vendor = verifiedVendorFiles(root); + const vendorPresent = [...vendor.keys()]; // Migrate any vendor file not already exactly Leji's redirect, so its content is // archived before --wire-adapters overwrites it. A canonical-redirect or empty // file has nothing to preserve. - const notCanonical = (rel: string) => readText(path.join(root, rel)).trim() !== canonicalRedirect; + const notCanonical = (rel: string) => vendor.get(rel)!.trim() !== canonicalRedirect; const toMigrate = vendorPresent.filter((rel) => { - const t = readText(path.join(root, rel)).trim(); + const t = vendor.get(rel)!.trim(); return t.length > 0 && t !== canonicalRedirect; }); @@ -1631,11 +2178,12 @@ export async function adoptLayer(options: AdoptOptions): Promise { writes.push({ rel: `${layout.contextDir}${category}.md`, content: categoryIndexFile(r, category) }); } writes.push({ rel: `${layout.agentsDir}core.md`, content: buildCoreProfile(answers) }); - writes.push({ rel: briefPath(r), content: buildBrief(answers) }); + writes.push({ rel: BRIEF_PATH, content: buildBrief(answers) }); const migrated: string[] = []; const migrationDocByVendor = new Map(); const plannedRels = new Set(writes.map((w) => w.rel)); + const rootReal = guardRoot(root); for (const rel of toMigrate) { const base = path .basename(rel) @@ -1645,15 +2193,19 @@ export async function adoptLayer(options: AdoptOptions): Promise { .replace(/^-|-$/g, ''); // Disambiguate against BOTH the planned writes and what's on disk, so the // migrated copy is never skipped by writeFileOnce (a skipped copy plus - // --wire-adapters overwriting the entrypoint would lose the original). + // --wire-adapters overwriting the entrypoint would lose the original). The + // on-disk half is decided on the standing entry, never by `existsSync`, which + // follows symlinks: a dangling candidate would read as a free name and the + // archive would be written at the link's missing destination. Any standing + // entry is occupied and the next name is tried (the rule `archivePath` mirrors). let slug = base; let docRel = `${joinUnderRoot(r, 'governance/')}imported-${slug}.md`; - for (let n = 2; plannedRels.has(docRel) || exists(path.join(root, stripSlash(docRel))); n++) { + for (let n = 2; plannedRels.has(docRel) || !nothingStandsAt(path.join(root, stripSlash(docRel))); n++) { slug = `${base}-${n}`; docRel = `${joinUnderRoot(r, 'governance/')}imported-${slug}.md`; } plannedRels.add(docRel); - writes.push({ rel: docRel, content: migrationDoc(rel, readText(path.join(root, rel))) }); + writes.push({ rel: docRel, content: migrationDoc(rel, vendor.get(rel)!) }); migrationDocByVendor.set(rel, docRel); migrated.push(rel); } @@ -1674,7 +2226,7 @@ export async function adoptLayer(options: AdoptOptions): Promise { buildWritePlan(root, [...writes, { rel: indexRel, content: '' }], wontModify, toConvert), indexRel, ); - const draft = wontModify.some((rel) => !readText(path.join(root, rel)).includes(bootRel)); + const draft = wontModify.some((rel) => !vendor.get(rel)!.includes(bootRel)); if (options.dryRun) { return { @@ -1699,12 +2251,9 @@ export async function adoptLayer(options: AdoptOptions): Promise { // The tracked-file preflight and the `.leji/` ignore run BEFORE any write at // all, so the private onboarding workspace can never land in git and a failed // preflight leaves the tree untouched. - assertLejiWorkspacePrivate(root, r); + assertLejiWorkspacePrivate(root); ensureLejiGitignored(root); - if (!resolvedWithinRoot(root, path.join(root, 'leji.json'))) { - throw new Error('refusing to write through a symlink that escapes the target: "leji.json"'); - } - writeManifestExclusive(path.join(root, 'leji.json'), writes[0].content, 'adopt'); + writeManifestExclusive(root, path.join(root, 'leji.json'), writes[0].content, 'adopt'); written.push('leji.json'); const convert = new Set(toConvert); for (const w of writes.slice(1)) { @@ -1714,10 +2263,7 @@ export async function adoptLayer(options: AdoptOptions): Promise { const docRel = migrationDocByVendor.get(w.rel); if (docRel && !written.includes(docRel)) continue; const abs = safeResolve(root, w.rel); - if (!resolvedWithinRoot(root, abs)) { - throw new Error(`refusing to write through a symlink that escapes the target: "${w.rel}"`); - } - fs.writeFileSync(abs, w.content); + guardedOrRefuse(w.rel, writeFileGuarded(guardRoot(root), abs, initRole(w.rel), w.content)); written.push(w.rel); } else { writeFileOnce(root, w.rel, w.content, written); @@ -1749,6 +2295,7 @@ export async function adoptLayer(options: AdoptOptions): Promise { * disk — the normal case, `adopt` having archived it on the first pass. Mirrors the * slug and disambiguation rules `adoptLayer` uses. */ function archivePath(root: string, rootPath: string, vendorRel: string, doc: string): string | null { + const rootReal = guardRoot(root); const base = path .basename(vendorRel) .replace(/\.md$/i, '') @@ -1758,8 +2305,15 @@ function archivePath(root: string, rootPath: string, vendorRel: string, doc: str for (let n = 1; ; n++) { const rel = `${joinUnderRoot(rootPath, 'governance/')}imported-${n === 1 ? base : `${base}-${n}`}.md`; const abs = path.join(root, stripSlash(rel)); - if (!exists(abs)) return rel; - if (isFile(abs) && readText(abs) === doc) return null; + // The candidate is judged on the standing entry and, when one stands, on its + // verified bytes: a pathname existence check follows symlinks, so a dangling + // candidate link would read as free and the write would follow it to its missing + // destination. Nothing standing is free; the identical archive is already on + // disk; anything else — different bytes, or a standing entry this run cannot + // verify — is occupied, and the next name is tried. + if (nothingStandsAt(abs)) return rel; + const standing = verifiedTargetRead(rootReal, abs, null); + if (standing.status === 'regular' && standing.bytes.toString('utf8') === doc) return null; } } @@ -1783,17 +2337,16 @@ async function wireAdaptersIntoLayer(root: string, options: AdoptOptions): Promi const r = manifest.rootPath; const bootRel = manifest.bootProfilePath; const redirect = adapterContent(bootRel); - const vendorPresent = KNOWN_VENDOR_FILES.filter((rel) => isFile(path.join(root, rel))) - // A vendor file that symlinks outside root is treated as absent, as in `adopt`. - .filter((rel) => resolvedWithinRoot(root, path.join(root, rel))); - const toConvert = vendorPresent.filter((rel) => readText(path.join(root, rel)).trim() !== redirect.trim()); + const vendor = verifiedVendorFiles(root); + const vendorPresent = [...vendor.keys()]; + const toConvert = vendorPresent.filter((rel) => vendor.get(rel)!.trim() !== redirect.trim()); // Archives first, so a vendor entrypoint is never overwritten before its content // is on disk; an empty file has nothing to preserve. const writes: PlannedWrite[] = []; const archived: string[] = []; for (const rel of toConvert) { - const content = readText(path.join(root, rel)); + const content = vendor.get(rel)!; if (content.trim() === '') continue; const doc = migrationDoc(rel, content); const docRel = archivePath(root, r, rel, doc); @@ -1822,13 +2375,10 @@ async function wireAdaptersIntoLayer(root: string, options: AdoptOptions): Promi if (options.dryRun) return { ...base, findings: [], written: [], dryRun: true }; const written: string[] = []; + const rootReal = guardRoot(root); for (const w of writes) { const abs = safeResolve(root, w.rel); - if (!resolvedWithinRoot(root, abs)) { - throw new Error(`refusing to write through a symlink that escapes the target: "${w.rel}"`); - } - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, w.content); + guardedOrRefuse(w.rel, writeFileGuarded(rootReal, abs, initRole(w.rel), w.content)); written.push(w.rel); } // Only an archive lands inside the layer, so only an archive can stale the stored @@ -1936,19 +2486,31 @@ export interface HandoffIo { cwd?: string, hostArgs?: string[], ): { error?: Error; status?: number | null; signal?: NodeJS.Signals | null }; - /** Run a host subcommand (the MCP presence check / register) from `cwd`, returning - * spawnSync's shape. `quiet` suppresses child output (the check); otherwise the - * child inherits the terminal so the user sees the host's own output. */ + /** Run a host subcommand (the MCP presence check / register) or a bounded probe + * from `cwd`, returning spawnSync's shape. `quiet` suppresses child output (the + * check); otherwise the child inherits the terminal so the user sees the host's + * own output. `capture` reads stdout back instead — bounded by `timeoutMs` and + * `maxBytes`, with stdin closed and stderr discarded — which is what the preflight + * version probe needs; `env`, when given, REPLACES the environment entirely + * (nothing of this process's is inherited), which is how the probe stays + * sanitized. */ run( bin: string, args: string[], cwd: string | undefined, - opts: { quiet: boolean }, - ): { error?: Error; status?: number | null; signal?: NodeJS.Signals | null }; + opts: { + quiet: boolean; + capture?: boolean; + timeoutMs?: number; + maxBytes?: number; + env?: Record; + }, + ): { error?: Error; status?: number | null; signal?: NodeJS.Signals | null; stdout?: string }; } -/** Real handoff I/O: a one-shot stdin line reader and a stdio-inherit spawn. */ -function defaultHandoffIo(): HandoffIo { +/** Real handoff I/O: a one-shot stdin line reader and a stdio-inherit spawn. Exported + * so one command can build it once and hand the same IO to every step of its flow. */ +export function defaultHandoffIo(): HandoffIo { return { async readLine(question, fallback) { const reader = new LineReader(); @@ -1969,7 +2531,23 @@ function defaultHandoffIo(): HandoffIo { return spawnSync(bin, [...(hostArgs ?? []), promptArg], { stdio: 'inherit', cwd }); }, run(bin, args, cwd, opts) { - return spawnSync(bin, args, { stdio: opts.quiet ? 'ignore' : 'inherit', cwd }); + if (!opts.capture) { + const plain = spawnSync(bin, args, { stdio: opts.quiet ? 'ignore' : 'inherit', cwd }); + return { error: plain.error, status: plain.status, signal: plain.signal }; + } + // A captured run is a probe: stdin closed so nothing can prompt, stderr + // discarded, output and wall time bounded. Exceeding either bound comes back + // as `error`, which every caller treats as a failed probe. + const res = spawnSync(bin, args, { + stdio: ['ignore', 'pipe', 'ignore'], + cwd, + encoding: 'utf8', + timeout: opts.timeoutMs, + maxBuffer: opts.maxBytes, + // The probe supplies its whole environment; nothing of ours is inherited. + env: opts.env ?? process.env, + }); + return { error: res.error, status: res.status, signal: res.signal, stdout: res.stdout ?? '' }; }, }; } @@ -2028,7 +2606,7 @@ export async function handoffOffer( ): Promise { if (!interactive) return false; const hosts = promptCapableHosts(detected); - const promptArg = `Read ./${briefPath(manifest.rootPath)} and follow it.`; + const promptArg = `Read ./${BRIEF_PATH} and follow it.`; // --agent forces a specific launchable host (skipping the prompt); otherwise the // detected hosts drive the offer. let chosen: PromptHost | null; @@ -2051,6 +2629,94 @@ export async function handoffOffer( return launchHost(chosen, promptArg, io, cwd); } +/** Options for `offerDependency`, the post-scaffold declaration offer. */ +export interface DependencyOfferOptions { + /** Absolute layer root: the cwd the manager runs in, so its manifest and lock + * edits land in this repository and nowhere else. */ + root: string; + report: EcosystemReport; + /** A real TTY, not --yes, and not --json; the manager never runs otherwise. */ + interactive: boolean; + io?: HandoffIo; +} + +/** + * What the declaration step did. `ran` means the add was consented to and + * attempted: with `exitCode: null` and `signal: null` it never started at all + * (spawn error), which counts as a failure exactly like a non-zero exit. + */ +export interface DependencyOffer { + offered: boolean; + ran: boolean; + command: string[] | null; + exitCode: number | null; + signal: string | null; +} + +/** True when a consented add did not succeed, so the command must not exit 0: the + * layer is written but the durable setup the run promised was not reached. */ +export function dependencyAddFailed(offer: DependencyOffer): boolean { + return offer.ran && (offer.exitCode !== 0 || offer.signal !== null); +} + +/** + * After the scaffold is written, tell the user how a clean install of this + * repository will bring `leji`, and offer to run their own package manager's add + * command. leji writes no manifest or lockfile byte itself: the manager owns both + * formats, so the only thing that changes the repository here is a command the + * user explicitly accepted. + * + * The block is ALWAYS printed (this function is simply not called under `--json`, + * which is a single-document mode). The prompt fires only when the run is + * interactive, an add command exists for the detected manager, and the CLI is not + * already declared. Never a shell: argv, cwd, inherited stdio, through the + * injectable `io.run` the tests replace with a fake. + */ +export async function offerDependency(opts: DependencyOfferOptions): Promise { + const text = ECOSYSTEM_TEXT.consent; + console.log('\n' + renderEcosystemBlock(opts.report)); + const selected = opts.report.selected; + const command = selected?.add ?? null; + const offered = command !== null && !selected!.directDeclared; + const skipped: DependencyOffer = { offered, ran: false, command, exitCode: null, signal: null }; + if (!offered || !opts.interactive) return skipped; + + const io = opts.io ?? defaultHandoffIo(); + // Consent is only consent if it is informed: the manager runs here, as this user, + // with this environment, and does whatever it normally does. + console.log(text.disclosure(command[0])); + const answer = (await io.readLine(text.prompt, 'Y/n')).toLowerCase(); + if (!(answer === '' || answer === 'y' || answer === 'yes')) { + console.log(text.declined); + console.log(text.command(command)); + return skipped; + } + console.log(text.running(command)); + const res = io.run(command[0], command.slice(1), opts.root, { quiet: false }); + const exitCode = res.status ?? null; + const signal = res.signal ?? null; + const outcome: DependencyOffer = { offered, ran: true, command, exitCode, signal }; + // A spawn that never started surfaces as `error`, never as an exit code, so it + // is reported as a missing binary rather than as a failed add. + if (res.error) { + console.log(text.missing(command[0])); + console.log(text.command(command)); + return { ...outcome, exitCode: null, signal: null }; + } + if (signal !== null) { + console.log(text.signaled(command[0], signal)); + console.log(text.command(command)); + return outcome; + } + if (exitCode !== 0) { + console.log(text.exited(command[0], exitCode ?? 1)); + console.log(text.command(command)); + return outcome; + } + console.log(text.declared(selected!.ecosystem)); + return outcome; +} + /** Options for `offerMcpInstall`, the pre-handoff MCP registration offer. */ export interface McpOfferOptions { /** Absolute layer root: the cwd for the check/register, so a project-scoped write @@ -2148,7 +2814,7 @@ function bootPrompt(bootRel: string): string { /** The onboarding approval guard: a transient Claude Code PreToolUse hook that * counters the ask-prompt pattern. AskUserQuestion stays blocked until the - * proposal is written to /.leji/proposal.md AND printed as message + * proposal is written to .leji/work/proposal.md AND printed as message * text; the corrective message lands at the action boundary, where instruction * reliably reaches the model. Self-disabling once the onboarding brief is gone; * the finalize step removes it entirely. */ @@ -2209,12 +2875,15 @@ process.exit(2); export type GuardAction = 'installed' | 'unchanged'; -/** Write the guard script under /.leji/hooks/ and merge its - * PreToolUse entry into .claude/settings.json (created if absent, other - * settings preserved). Idempotent: an existing guard entry is left untouched. */ +/** 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. + * `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 { + void rootPath; const rootAbs = path.resolve(root); - const lejiRel = joinUnderRoot(rootPath, '.leji'); + const lejiRel = WORK_REL; const scriptRel = `${lejiRel}/hooks/approval-guard.mjs`; const scriptAbs = path.join(rootAbs, scriptRel); guardWithinRoot(rootAbs, scriptAbs, scriptRel); @@ -2223,7 +2892,9 @@ export function ensureApprovalGuard(root: string, rootPath: string): GuardAction const settingsAbs = path.join(rootAbs, settingsRel); guardWithinRoot(rootAbs, settingsAbs, settingsRel); let settings: Record = {}; - const existing = isFile(settingsAbs) ? readText(settingsAbs) : null; + // The settings file is parsed, merged, and written back, so its bytes come from the + // verified read rather than from the pathname the merge later writes to. + const existing = readMergeSource(guardRoot(rootAbs), settingsAbs, settingsRel); if (existing !== null && existing.trim() !== '') { try { settings = JSON.parse(existing) as Record; @@ -2273,7 +2944,7 @@ export async function offerApprovalGuard(opts: GuardOfferOptions): Promise const io = opts.io ?? defaultHandoffIo(); const answer = ( await io.readLine( - 'Add the temporary onboarding guard for Claude Code, in this repository only? It has the agent print its proposal before asking for approval. Writes two project-local files (a hook entry in this repo\u2019s .claude/settings.json, a script in the gitignored .leji/ workspace); nothing outside this repository is touched, and the finalize step removes both', + 'Add the temporary onboarding guard for Claude Code, in this repository only? It has the agent print its proposal before asking for approval. Writes two project-local files (a hook entry in this repo\u2019s .claude/settings.json, a script in the gitignored .leji/work/ workspace); nothing outside this repository is touched, and the finalize step removes both', 'Y/n', ) ).toLowerCase(); @@ -2281,7 +2952,7 @@ export async function offerApprovalGuard(opts: GuardOfferOptions): Promise const action = ensureApprovalGuard(opts.root, opts.rootPath); console.log( action === 'installed' - ? 'Onboarding guard added (this repository only: .claude/settings.json hook + .leji/hooks/approval-guard.mjs; removed at finalize).' + ? 'Onboarding guard added (this repository only: .claude/settings.json hook + .leji/work/hooks/approval-guard.mjs; removed at finalize).' : 'Onboarding guard already present in this repository; refreshed the script.', ); } @@ -2302,7 +2973,42 @@ export interface StartOptions { /** Extra arguments passed verbatim to the launched host binary, before the * prompt (from `leji start -- `, e.g. Claude Code's --chrome). */ hostArgs?: string[]; + /** The host the caller already resolved, so the preflight report can name it + * before the launch takes the terminal. `undefined` resolves it here as before; + * `null` is an explicit "no host", which falls back to the printed commands. */ + host?: PromptHost | null; + io?: HandoffIo; +} + +/** Whether the manifest's boot profile is a safe relative path that actually exists: + * the one condition `leji start` refuses to run under, checked before anything is + * reported or launched. */ +export function bootProfileReady(root: string, manifest: Manifest): boolean { + const bootRel = manifest.bootProfilePath; + return RELATIVE_PATH_RE.test(bootRel) && isFile(path.join(path.resolve(root), bootRel)); +} + +/** Which host `leji start` targets: `--agent` forces one, a single detected + * prompt-capable host is it, and several ask (interactive only). Split out of + * `enterLayer` so the preflight can report on the host this run has actually + * selected. Throws on an unknown or non-launchable `--agent`, as before. */ +export async function resolveStartHost(opts: { + detected: DetectedHost[]; + agent?: string; + interactive: boolean; io?: HandoffIo; +}): Promise { + if (opts.agent) return assertAgentHost(opts.agent); + const hosts = promptCapableHosts(opts.detected); + if (hosts.length === 1) return hosts[0]; + if (hosts.length > 1 && opts.interactive) return pickFromMultiple(hosts, opts.io ?? defaultHandoffIo()); + return null; +} + +/** The detected hosts `leji start` could launch, ranked — what the preflight names + * when several are present and none was picked. */ +export function startHosts(detected: DetectedHost[]): PromptHost[] { + return promptCapableHosts(detected); } /** `leji start`: boot a coding agent into an existing layer, pointed at the boot @@ -2312,19 +3018,16 @@ export interface StartOptions { * (boot path unsafe or absent). Throws on an unknown/non-launchable --agent. */ export async function enterLayer(opts: StartOptions): Promise { const root = path.resolve(opts.root); - const bootRel = opts.manifest.bootProfilePath; - if (!RELATIVE_PATH_RE.test(bootRel) || !isFile(path.join(root, bootRel))) return 'boot-missing'; + if (!bootProfileReady(root, opts.manifest)) return 'boot-missing'; const io = opts.io ?? defaultHandoffIo(); - const promptArg = bootPrompt(bootRel); + const promptArg = bootPrompt(opts.manifest.bootProfilePath); - let host: PromptHost | null = null; - if (opts.agent) { - host = assertAgentHost(opts.agent); - } else { - const hosts = promptCapableHosts(opts.detected); - if (hosts.length === 1) host = hosts[0]; - else if (hosts.length > 1 && opts.interactive) host = await pickFromMultiple(hosts, io); - } + // A caller that already resolved the host (the preflight names it before the + // launch) passes it in; `undefined` means resolve it here, as before. + const host = + opts.host !== undefined + ? opts.host + : await resolveStartHost({ detected: opts.detected, agent: opts.agent, interactive: opts.interactive, io }); if (!host || !opts.interactive) return 'fallback'; return launchHost(host, promptArg, io, root, opts.hostArgs) ? 'launched' : 'fallback'; @@ -2351,7 +3054,7 @@ export function enteringViaBoot(manifest: Manifest, hostArgs?: string[]): string /** Post-init guidance, printed by the CLI. The team copy is unchanged from * pre-mode releases; solo swaps one sentence to name the interview. */ export function enteringTheLayer(manifest: Manifest, mode: WorkingMode = 'team'): string { - const brief = briefPath(manifest.rootPath); + const brief = BRIEF_PATH; const how = mode === 'solo' ? [ diff --git a/packages/sdk/src/commands/mounts-update-pin.ts b/packages/sdk/src/commands/mounts-update-pin.ts new file mode 100644 index 0000000..c8ab850 --- /dev/null +++ b/packages/sdk/src/commands/mounts-update-pin.ts @@ -0,0 +1,357 @@ +import * as path from 'node:path'; +import { type Finding, finding } from '../lib/findings.js'; +import { guardRoot, verifiedTargetRead, writeFileAtomicGuarded } from '../lib/fsx.js'; +import { MANIFEST_FILENAME, type Manifest, replaceMountPinInManifestText } from '../lib/manifest.js'; +import { + type MountDecl, + type StatusResult, + comparePins, + normalizeSource, + refreshWitness, + resolveDefaultRef, + retainPinInStore, + runGit, + selectComparison, + validTrackingRef, +} from '../lib/mounts.js'; + +/** + * `leji mounts update-pin`: move ONE declared mount's pin forward to a commit the + * resolver has already witnessed, showing the comparison before anything is + * rewritten. + * + * Offline by default: the target is the last successfully observed witness, never a + * 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. + * + * The manifest is rewritten by replacing the addressed pin's own byte span + * (`replaceMountPinInManifestText`), never by reserializing, so the three SDKs + * produce byte-identical output over any accepted layout. + */ + +/** What the run did. `refused` is a stated outcome, never a crash. */ +export type UpdatePinAction = 'updated' | 'unchanged' | 'dry-run' | 'refused'; + +export interface UpdatePinResult { + mount: { + name: string; + sourceIdentity: string | null; + /** The DECLARED tracking ref, never the default resolved under `--fetch` — + * that one is reported as `pinReport.comparedRef`. */ + trackingRef: string | null; + from: string | null; + to: string | null; + }; + pinReport: StatusResult['pinReport'] | null; + action: UpdatePinAction; + override: boolean; + /** Stable code, present only when the run refused. */ + reason?: string; + findings: Finding[]; + /** An internal refusal with no document to report: the manifest parsed and + * validated, but the pin's own span could not be located or did not hold what + * the comparison was computed against. Exit 2. */ + writeError?: string; +} + +export interface UpdatePinOptions { + name: string; + to?: string; + allowNonFastForward?: boolean; + fetch?: boolean; + dryRun?: boolean; + /** Injectable observation clock, so tests and fixtures are stable. */ + now?: () => Date; +} + +/** A pin at the length every human-facing line uses. */ +export function shortOid(oid: string): string { + return oid.slice(0, 12); +} + +function declaredMount(manifest: Manifest, name: string): MountDecl | undefined { + for (const m of manifest.federation?.mounts ?? []) { + if (m.name === name) { + return { + name: m.name, + source: m.source, + pin: m.pin, + ...(m.trackingRef === undefined ? {} : { trackingRef: m.trackingRef }), + }; + } + } + return undefined; +} + +/** + * 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. + */ +export function updatePinRun(root: string, manifest: Manifest, opts: UpdatePinOptions): UpdatePinResult { + // One observation time for the whole run, as `status` takes one for its whole + // execution. + const observedAt = (opts.now ? opts.now() : new Date()).toISOString(); + const mount = declaredMount(manifest, opts.name); + + const refuse = ( + reason: string, + partial: Partial = {}, + pinReport: StatusResult['pinReport'] | null = null, + ): UpdatePinResult => ({ + mount: { + name: opts.name, + sourceIdentity: mount ? normalizeSource(mount.source) : null, + trackingRef: mount?.trackingRef ?? null, + from: mount?.pin ?? null, + to: null, + ...partial, + }, + pinReport, + action: 'refused', + override: false, + reason, + findings: [finding(reason, 'error', MOUNT_UPDATE_PIN_REASONS[reason] ?? reason, opts.name)], + }); + + if (!mount) return refuse('mount-unknown'); + + // (a) The declaration snapshot: the manifest's OWN values, kept for the + // freshness check the rewrite makes against the verified bytes. `trackingRef` + // is snapshotted as declared — absent must stay absent — while the ref the + // comparison actually uses is tracked separately. + const declaration = { + name: mount.name, + source: mount.source, + pin: mount.pin, + declaredTrackingRef: mount.trackingRef, + }; + const identity = normalizeSource(mount.source); + const degraded = (reason: string, comparedRef: string | null): StatusResult['pinReport'] => ({ + state: 'unknown', + comparedRef, + comparisonRepository: null, + witnessProvenance: null, + ancestryComplete: false, + reason, + observedAt, + }); + if (identity === null) { + return refuse( + 'mount-source-unnormalizable', + {}, + degraded('mount-source-unnormalizable', mount.trackingRef ?? null), + ); + } + + let effectiveRef: string; + if (mount.trackingRef !== undefined) { + if (!validTrackingRef(mount.trackingRef)) { + return refuse('mount-tracking-ref-invalid', {}, degraded('mount-tracking-ref-invalid', mount.trackingRef)); + } + effectiveRef = mount.trackingRef; + } else if (!opts.fetch) { + // Offline, the schema's "absent means the source's default branch" cannot be + // honoured: resolving it needs the network this run was not given. + return refuse('mount-no-tracking-ref', {}, degraded('mount-no-tracking-ref', null)); + } else { + const resolved = resolveDefaultRef(mount.source); + if ('error' in resolved || !validTrackingRef(resolved.ref)) { + return refuse('mount-default-ref-unavailable', {}, degraded('mount-default-ref-unavailable', null)); + } + effectiveRef = resolved.ref; + } + + // (b i, ii) `--fetch`, declared source only, in order: retain the CURRENT pin so + // 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); + if (retained.repo === null) { + return refuse('mount-store-fetch-failed', {}, degraded('mount-store-fetch-failed', effectiveRef)); + } + const witnessMount: MountDecl = { ...mount, trackingRef: effectiveRef }; + if (!refreshWitness(retained.repo, witnessMount, identity)) { + return refuse('mount-witness-refresh-failed', {}, degraded('mount-witness-refresh-failed', effectiveRef)); + } + } + + // (c) The comparison repository and the ONE witness snapshot this run uses for + // the default target, the report, and the gate alike. + const selection = selectComparison(root, mount, effectiveRef); + if ('reason' in selection) return refuse(selection.reason, {}, degraded(selection.reason, effectiveRef)); + const { repo, comparisonRepository, witnessProvenance, tipOid } = selection; + + // (d) The target: an explicit `--to` must be held by the repository the + // comparison ran in; otherwise the witness tip itself. + const target = opts.to ?? tipOid; + if (opts.to !== undefined && !runGit(['-C', repo, 'cat-file', '-e', `${opts.to}^{commit}`]).ok) { + return refuse( + 'mount-target-unavailable', + { to: opts.to }, + { ...degraded('mount-target-unavailable', effectiveRef), comparisonRepository, witnessProvenance }, + ); + } + + // (e) The report, computed from the same snapshot `status` would report from. + const comparison = comparePins(repo, mount.pin, tipOid); + const mountBlock = { + name: mount.name, + sourceIdentity: identity, + trackingRef: mount.trackingRef ?? null, + from: mount.pin, + to: target, + }; + if ('reason' in comparison) { + return refuse( + comparison.reason, + { to: target }, + { ...degraded(comparison.reason, effectiveRef), comparisonRepository, witnessProvenance }, + ); + } + const pinReport: StatusResult['pinReport'] = { + state: comparison.state, + behind: comparison.behind, + ahead: comparison.ahead, + comparedRef: effectiveRef, + comparisonRepository, + witnessProvenance, + ancestryComplete: comparison.ancestryComplete, + observedAt, + }; + const settled = (action: UpdatePinAction, override: boolean, findings: Finding[]): UpdatePinResult => ({ + mount: mountBlock, + pinReport, + action, + override, + findings, + }); + // 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 => ({ + ...settled('refused', override, [ + finding(reason, 'error', MOUNT_UPDATE_PIN_REASONS[reason] ?? reason, mount.name), + ...warnings, + ]), + reason, + }); + + // (f) The gate. Nothing to move is its own success, checked before ancestry: + // asking whether a commit is an ancestor of itself is not the question. + if (target === mount.pin) return settled('unchanged', false, []); + let override = false; + const ancestor = runGit(['-C', repo, 'merge-base', '--is-ancestor', mount.pin, target]); + if (!ancestor.ok) { + // Exit 1 is the answer "no"; anything else is the repository unable to answer. + // A "no" from truncated history is not an answer either, so an incomplete + // repository never yields the not-fast-forward refusal — nor does the + // override bypass it. + if (ancestor.code !== 1 || !comparison.ancestryComplete) return refuseSettled('mount-ancestry-incomplete'); + if (opts.to === undefined || !opts.allowNonFastForward) return refuseSettled('mount-pin-not-fast-forward'); + override = true; + } + const warnings: Finding[] = override + ? [ + finding( + 'mount-pin-non-fast-forward-override', + 'warning', + MOUNT_UPDATE_PIN_REASONS['mount-pin-non-fast-forward-override'], + mount.name, + ), + ] + : []; + + // (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); + } + + // (g) `--dry-run` stops here. The store and network acts `--fetch` was asked for + // have already happened; only the manifest rewrite is suppressed. + if (opts.dryRun) return settled('dry-run', override, warnings); + + // (h) The rewrite, through the verified read the trust boundary requires. + const rootReal = guardRoot(root); + const manifestAbs = path.join(root, MANIFEST_FILENAME); + const read = verifiedTargetRead(rootReal, manifestAbs, null); + if (read.status !== 'regular') { + return { + ...settled('refused', override, warnings), + writeError: `refusing to write through a symlink that escapes the target: "${MANIFEST_FILENAME}"`, + }; + } + const original = read.bytes.toString('utf8'); + // The bytes that were verified decide whether the declaration this comparison + // was computed against is still the declaration on disk. Containment says WHICH + // file was read; only this says it still says the same thing. + if (!declarationUnchanged(original, declaration)) + return refuseSettled('mount-declaration-changed', override, warnings); + let rewritten: { text: string; changed: boolean }; + try { + rewritten = replaceMountPinInManifestText(original, mount.name, mount.pin, target); + } catch (e) { + return { ...settled('refused', override, warnings), writeError: (e as Error).message }; + } + if (rewritten.changed) { + const verdict = writeFileAtomicGuarded(rootReal, manifestAbs, null, rewritten.text); + if (!verdict.ok) { + return { + ...settled('refused', override, warnings), + writeError: `refusing to write outside the repository: "${MANIFEST_FILENAME}"`, + }; + } + } + return settled('updated', override, warnings); +} + +/** Does the verified manifest text still declare the mount this run compared? Only + * the four fields that decided the selected repository, the target and the splice + * are compared; ownership and routing metadata decide none of them. */ +function declarationUnchanged( + text: string, + declaration: { name: string; source: string; pin: string; declaredTrackingRef: string | undefined }, +): boolean { + let parsed: Manifest; + try { + parsed = JSON.parse(text) as Manifest; + } catch { + return false; + } + const current = (parsed.federation?.mounts ?? []).find((m) => m.name === declaration.name); + if (!current) return false; + return ( + current.source === declaration.source && + current.pin === declaration.pin && + // Absent must stay absent: under `--fetch` the ref actually used may be the + // source's advertised default, which the manifest never spelled. + current.trackingRef === declaration.declaredTrackingRef + ); +} + +/** 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. */ +export const MOUNT_UPDATE_PIN_REASONS: Record = { + '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-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)', + 'mount-declaration-changed': 'leji.json changed while the comparison ran; nothing was written', + 'mount-pin-non-fast-forward-override': 'the pin was moved to a commit that is not a descendant of it', +}; diff --git a/packages/sdk/src/commands/preflight.ts b/packages/sdk/src/commands/preflight.ts new file mode 100644 index 0000000..d6cc15a --- /dev/null +++ b/packages/sdk/src/commands/preflight.ts @@ -0,0 +1,662 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { type Manifest } from '../lib/manifest.js'; +import { type DetectedHost, hostSpec, mcpCommand, mcpJsonConfig } from '../lib/detect.js'; +import { type EcosystemReport, managerInstallArgv, runnerArgv } from '../lib/ecosystem.js'; +import { resolvedWithinRoot } from '../lib/fsx.js'; +import { type HandoffIo, type HookStatus, type PromptHost, ensureLocalHook, hookStatus, startHosts } from './init.js'; + +/** + * `leji start`'s preflight: what a person who just cloned an adopted repository has + * to fix before the layer's tooling actually works here, computed READ-ONLY and + * reported as a fixed list of rows. + * + * The distinction the whole report turns on is who owns each gap. A gap in state that + * lives in this clone or in this user's own configuration is PERSONAL: it is offered, + * on a real terminal, and otherwise printed as an exact command. A gap in state the + * repository commits is SHARED: it is reported with the maintainer's command and + * never repaired here, because that write would land in files the whole team owns. + * Nothing here blocks entry either way: the agent still boots, and `--json`'s `ready` + * is the scriptable signal. + */ + +/** The checks, in the fixed order every report and every SDK prints them. */ +export type CheckId = 'cli' | 'mcp' | 'mcp-shared' | 'hook'; + +/** + * What one check found. `ok` needs nothing; `missing` is personal (offered here, or + * printed); `shared-gap` is the repository's own state, for a maintainer; `skipped` + * means the check does not apply to this machine; `n/a` means it does not apply to + * this host; `unresolved` means the run could not tell which host to answer for. + */ +export type CheckStatus = 'ok' | 'missing' | 'shared-gap' | 'skipped' | 'n/a' | 'unresolved'; + +/** How a fix prints: a `command` line takes the `$ ` prompt, a `snippet` is pasted as it + * stands (a config block, a hook body). Render-only, so it never reaches `--json`. */ +export type FixKind = 'command' | 'snippet'; + +export interface Check { + id: CheckId; + status: CheckStatus; + detail: string; + /** The exact commands that close this gap, or null when there is nothing to run. */ + fix: string[] | null; + /** Set by every check built here; absent input renders as a command. */ + fixKind?: FixKind; +} + +export interface PreflightResult { + /** Every check whose id is cli, mcp or hook is ok, skipped, or not applicable. + * The shared MCP row is project hygiene and never counts against it. */ + ready: boolean; + checks: Check[]; + /** The resolved hook target, so the consent step acts on what the report saw. */ + hook: HookStatus; +} + +// --- the text table ------------------------------------------------------- +// Every string the Setup block prints lives here once, so the three SDKs transcribe +// one table rather than re-deriving prose. + +// The block's fixed geometry: ``, so +// every detail starts at the same column and the status word is the first thing read. A +// fix line is indented under the SUBJECT column, a half indent that reads as "belongs to +// the row above" and keeps long commands inside 80 columns. +const MARGIN = ' '; +const GUTTER = ' '; +const STATUS_WIDTH = 4; +const SUBJECT_WIDTH = 10; +const FIX_INDENT = ' '; + +/** The lowest SDK version that shipped support for a spec line. A layer declares + * exactly one version expectation, its spec line; this is what that expectation means + * for the CLI resolved here. The comparison is on the major, which is where a line's + * support is added or dropped. */ +export const MIN_SDK_FOR_SPEC_LINE: Record = { '1.0': '1.0.0' }; + +const START_TEXT = { + heading: 'Setup for this clone', + /** The word that names WHO owns the row. The `CheckStatus` enum stays the contract; + * these labels are what a person reads, and several statuses share one. */ + status: { + ok: 'ok', + missing: 'you', + 'shared-gap': 'team', + skipped: 'n/a', + 'n/a': 'n/a', + unresolved: 'you', + } as Record, + subject: { + cli: 'Leji CLI', + mcp: 'MCP server', + 'mcp-shared': 'Team MCP', + hook: 'Git hook', + } as Record, + // Every detail is one short clause. Where a template carries a path, the path is its + // LAST token: a row that overflows overflows into the path, never through the prose, + // and nothing here is ever clipped (a truncated path misleads). + cli: { + ok: (version: string, runner: string): string => `${version} (${runner})`, + notInstalled: (bin: string): string => `not installed yet (${bin})`, + verify: (runner: string): string => `${runner} --version`, + belowMinimum: (version: string, runner: string, minimum: string, line: string): string => + `${version} (${runner}) is below ${minimum} for spec ${line}`, + unresolvable: (runner: string): string => `${runner} reported no version here`, + undeclared: 'not declared in this repository', + undeclaredAmbient: (version: string): string => `not declared here (PATH has your own ${version})`, + unresolvableNoInstall: (runner: string): string => `${runner} reported no version; run this repo's install`, + }, + mcp: { + registered: (host: string): string => `registered for ${host}`, + missing: (host: string): string => `not registered for ${host}`, + manual: (host: string): string => `not registered for ${host}; add it yourself:`, + /** The first line of that snippet: where the block goes, and at which scope. */ + manualPath: (config: string, scope: string): string => `${config} (${scope} scope)`, + unresolved: (hosts: string[]): string => `pick one: ${hosts.join(', ')}`, + none: 'no coding agent detected', + }, + mcpShared: { + present: (file: string): string => `${file} committed`, + absent: (file: string): string => `no ${file} committed`, + other: 'none for this host', + noHost: 'no host selected', + }, + hook: { + current: (target: string): string => `runs leji checks before each commit: ${target}`, + absentPersonal: 'none yet (per clone)', + absentShared: (target: string): string => `no leji block in ${target}`, + foreign: (target: string): string => `not leji-managed; add the block to ${target}`, + outsideRoot: (target: string): string => `hooks dir is outside this worktree: ${target}`, + external: (target: string): string => `add it yourself; hooks run from ${target}`, + noGit: 'not a git repository', + }, + /** The closing line: who owes how many fixes, and that neither answer blocks entry. */ + summary: { + complete: 'Setup complete.', + fixes: (n: number): string => `${n} ${n === 1 ? 'fix' : 'fixes'}`, + you: (fixes: string): string => `${fixes} for you. The agent starts either way.`, + team: (fixes: string): string => `${fixes} for a maintainer. The agent starts either way.`, + both: (fixes: string, team: number): string => + `${fixes} for you, ${team} for a maintainer. The agent starts either way.`, + }, + offer: { + mcp: (host: string): string => `Register the Leji MCP server for ${host} for your user?`, + mcpDone: (host: string): string => `Registered the Leji MCP server for ${host}.`, + mcpFailed: (bin: string): string => `${bin} did not register cleanly; run it yourself:`, + hook: 'Install the pre-commit hook for this clone (validate + index --check)?', + hookDone: (target: string): string => `Wrote ${target}; it runs before every commit in this clone.`, + hookFailed: 'The hook could not be written here; add it yourself:', + prompt: 'Y/n', + }, +}; + +/** The whole message table, exported for the ports to transcribe. Not re-exported + * from the package index: these are the CLI's words, not a library API. */ +export const START_TEXT_TABLE = START_TEXT; + +// --- the version probe ---------------------------------------------------- + +/** How long a probe may take, and how much of its output is read. A probe that + * exceeds either bound fails closed, exactly like one that never started. */ +const PROBE_TIMEOUT_MS = 10_000; +const PROBE_MAX_BYTES = 4096; + +/** + * The one path a probe may execute directly: the bin shim a Node package manager + * installs for the declared dependency. It is a file this repository's own install + * put there, not a script the repository authors, which is the whole reason it is + * safe to run when `npm`/`pnpm`/`yarn`/`bun` are not. + */ +const NODE_BIN_REL = 'node_modules/.bin/leji'; + +/** The Node managers whose declared CLI arrives as that shim. */ +const NODE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']); + +/** + * What the probe runs for a manager whose CLI is a console-script entry of the + * declared dependency rather than a file in the repository. Each carries the flag + * that keeps the manager from installing, syncing, or fetching anything, and none of + * them runs a script the repository declares. + */ +const MANAGER_PROBE_ARGV: Record = { + uv: ['uv', 'run', '--no-sync', 'leji'], + poetry: ['poetry', 'run', 'leji'], + pdm: ['pdm', 'run', 'leji'], + pipenv: ['pipenv', 'run', 'leji'], + go: ['go', 'tool', 'leji'], +}; + +/** The environment the Go probe forces: a read-only module graph, no toolchain + * download, no module proxy, and no workspace file redirecting the build. */ +const GO_PROBE_ENV: Record = { + GOFLAGS: '-mod=readonly', + GOTOOLCHAIN: 'local', + GOPROXY: 'off', + GOWORK: 'off', +}; + +/** + * The variables the probe passes through whatever it runs. Everything else in the + * caller's environment is dropped: a probe is not the user's shell, and an inherited + * `NODE_OPTIONS`, `npm_config_*`, or `LD_PRELOAD` is exactly the kind of thing that + * turns "ask for a version" into "run something else". + */ +const PROBE_PLATFORM_ENV = ['SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP', 'WINDIR']; + +/** Per-manager configuration the probe keeps, because without it the manager cannot + * find the environment it is being asked about. Nothing beyond this is inherited. */ +const PROBE_MANAGER_ENV: Record = { + uv: ['UV_CACHE_DIR', 'UV_PROJECT_ENVIRONMENT', 'VIRTUAL_ENV'], + poetry: ['POETRY_HOME', 'POETRY_VIRTUALENVS_PATH', 'POETRY_CACHE_DIR', 'VIRTUAL_ENV'], + pdm: ['PDM_HOME', 'PDM_CACHE_DIR', 'VIRTUAL_ENV'], + pipenv: ['PIPENV_VENV_IN_PROJECT', 'WORKON_HOME', 'VIRTUAL_ENV'], + go: ['GOPATH', 'GOMODCACHE', 'GOCACHE', 'GOBIN'], +}; + +function passThrough(names: string[], into: Record): void { + for (const name of names) { + const value = process.env[name]; + if (value !== undefined) into[name] = value; + } +} + +/** + * The environment for a probe that has to find a program on the caller's PATH (a + * package manager, or the ambient `leji`): PATH and HOME survive because the manager + * cannot answer without them, plus the manager's own named configuration. Nothing + * else does. + */ +function spawnedProbeEnv(manager: string | null): Record { + const env: Record = {}; + passThrough(['PATH', 'Path', 'HOME'], env); + passThrough(PROBE_PLATFORM_ENV, env); + if (manager !== null) passThrough(PROBE_MANAGER_ENV[manager] ?? [], env); + if (manager === 'go') Object.assign(env, GO_PROBE_ENV); + return env; +} + +/** + * The environment for the direct execution of the repository's own bin shim: nothing + * of the caller's is inherited at all. PATH holds only the directory of the running + * Node binary, because the shim's interpreter line resolves `node` there, and HOME + * points at a temporary directory so no user configuration is read. + */ +function directProbeEnv(): Record { + const env: Record = { PATH: path.dirname(process.execPath), HOME: os.tmpdir() }; + passThrough(PROBE_PLATFORM_ENV, env); + return env; +} + +/** A bare `..` with an optional prerelease or build tail, which + * is what every `leji --version` prints. Anything else is not a version this probe + * will believe. */ +export const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/; + +function parseVersion(stdout: string): { text: string; major: number } | null { + const line = stdout.split('\n').find((l) => l.trim() !== ''); + if (line === undefined) return null; + const m = VERSION_RE.exec(line.trim()); + return m === null ? null : { text: line.trim(), major: Number(m[1]) }; +} + +/** + * How this repository's declared CLI would be asked for its version. `direct` is the + * installed Node shim, executed as a file; `spawned` is a manager or the ambient + * binary, found on the caller's PATH; `absent` is a Node repository whose install has + * not produced the shim (not installed, or a Yarn PnP tree that has no bin directory) + * — reported, never worked around by asking a package manager to run a script. + */ +type ProbePlan = + | { kind: 'direct'; bin: string; args: string[]; env: Record } + | { kind: 'spawned'; bin: string; args: string[]; env: Record } + | { kind: 'absent' }; + +/** The names a Node bin shim can take, strongest first. Windows installs a `.cmd` + * wrapper beside (or instead of) the extensionless shim. */ +function binCandidates(): string[] { + return process.platform === 'win32' ? ['leji.cmd', 'leji.exe', 'leji'] : ['leji']; +} + +/** + * The installed shim's absolute path, or null. Every condition is checked before the + * path is ever executed: a regular file after symlinks are followed (npm installs the + * shim AS a symlink, so links are expected), resolving inside the real repository + * root, and executable where the platform records that. + */ +export function installedNodeBin(root: string): string | null { + for (const name of binCandidates()) { + const abs = path.join(root, 'node_modules', '.bin', name); + if (!resolvedWithinRoot(root, abs)) continue; + let st: fs.Stats; + try { + st = fs.statSync(abs); + } catch { + continue; + } + if (!st.isFile()) continue; + if (process.platform !== 'win32' && (st.mode & 0o111) === 0) continue; + return abs; + } + return null; +} + +function probePlan(root: string, report: EcosystemReport): ProbePlan { + const selected = report.selected; + const manager = selected !== null && selected.directDeclared ? selected.manager : null; + if (manager !== null && NODE_MANAGERS.has(manager)) { + const bin = installedNodeBin(root); + return bin === null ? { kind: 'absent' } : { kind: 'direct', bin, args: [], env: directProbeEnv() }; + } + const argv = manager === null ? null : MANAGER_PROBE_ARGV[manager]; + if (argv !== undefined && argv !== null) { + return { kind: 'spawned', bin: argv[0], args: argv.slice(1), env: spawnedProbeEnv(manager) }; + } + // Undeclared, pip, and pre-1.24 Go all reach the CLI the same way a person does: + // whatever `leji` the PATH resolves, run with the same sanitized environment. + return { kind: 'spawned', bin: 'leji', args: [], env: spawnedProbeEnv(null) }; +} + +/** + * Ask the CLI this repository would run for its version. Argv, never a shell; cwd + * pinned to the root; stdin closed; output and time bounded; a sanitized environment; + * and never a package manager's script runner. Every failure mode — a missing + * executable, a non-zero exit, a timeout, output that is not a version — comes back + * as null, because a probe that cannot answer is not evidence that the CLI is there. + */ +function probeVersion(root: string, plan: ProbePlan, io: HandoffIo): { text: string; major: number } | null { + if (plan.kind === 'absent') return null; + const res = io.run(plan.bin, [...plan.args, '--version'], root, { + quiet: true, + capture: true, + timeoutMs: PROBE_TIMEOUT_MS, + maxBytes: PROBE_MAX_BYTES, + env: plan.env, + }); + if (res.error || res.signal != null || (res.status ?? 1) !== 0) return null; + return parseVersion(res.stdout ?? ''); +} + +// --- the checks ----------------------------------------------------------- + +function check( + id: CheckId, + status: CheckStatus, + detail: string, + fix: string[] | null, + fixKind: FixKind = 'command', +): Check { + return { id, status, detail, fix, fixKind }; +} + +/** The command line a fix names, from the argv the tables already carry. */ +function line(argv: string[]): string { + return argv.join(' '); +} + +function cliCheck(root: string, manifest: Manifest, report: EcosystemReport, io: HandoffIo): Check { + const selected = report.selected; + const runner = runnerArgv(report); + const specLine = manifest.leji; + const minimum = MIN_SDK_FOR_SPEC_LINE[specLine]; + const plan = probePlan(root, report); + + if (selected === null || !selected.directDeclared) { + // The gap is the repository's declaration, which is a committed file: report it + // with the maintainer's command whatever this machine happens to have. The plain + // `leji` is still probed, so an ambient install is named as what it is. + const found = probeVersion(root, plan, io); + const add = selected?.add ?? null; + return check( + 'cli', + 'shared-gap', + found === null ? START_TEXT.cli.undeclared : START_TEXT.cli.undeclaredAmbient(found.text), + add === null ? null : [line(add)], + ); + } + const found = probeVersion(root, plan, io); + // The row names what actually answered: the installed shim for a Node repository, + // and the manager's own runner everywhere else. + const shown = plan.kind === 'direct' || plan.kind === 'absent' ? NODE_BIN_REL : line(runner); + const install = selected.manager === null ? null : managerInstallArgv(selected.manager); + // A Node repository whose shim is absent gets the install command AND the way to + // confirm it worked, because leji will not run a package manager to find out. + const fix = + install === null + ? null + : plan.kind === 'direct' || plan.kind === 'absent' + ? [line(install), START_TEXT.cli.verify(line(runner))] + : [line(install)]; + if (plan.kind === 'absent') { + return check('cli', 'missing', START_TEXT.cli.notInstalled(NODE_BIN_REL), fix); + } + if (found === null) { + // A manager with no single install command (pip, pre-1.24 Go) has no argv to + // print, so the row itself has to carry the instruction. + const detail = + install === null ? START_TEXT.cli.unresolvableNoInstall(shown) : START_TEXT.cli.unresolvable(shown); + return check('cli', 'missing', detail, fix); + } + if (minimum !== undefined && found.major < Number(minimum.split('.')[0])) { + return check('cli', 'missing', START_TEXT.cli.belowMinimum(found.text, shown, minimum, specLine), fix); + } + return check('cli', 'ok', START_TEXT.cli.ok(found.text, shown), null); +} + +/** The argv that registers the server for THIS USER on a host, or null when the host + * has no registration command at all. */ +function personalMcpAdd(hostId: string): { bin: string; argv: string[] } | null { + const spec = hostSpec(hostId); + const argv = spec?.mcpAddUser ?? spec?.mcpAdd; + return spec === undefined || argv === undefined ? null : { bin: spec.bins[0], argv }; +} + +function mcpCheck(root: string, host: PromptHost | null, detected: DetectedHost[], io: HandoffIo): Check { + if (host === null) { + const launchable = startHosts(detected); + if (launchable.length > 1) { + return check('mcp', 'unresolved', START_TEXT.mcp.unresolved(launchable.map((h) => h.name)), [ + 'leji start --agent ', + ]); + } + // A host Leji cannot register for is still worth a row: the person can add the + // standard configuration by hand, which is the only fix that exists for it. + for (const h of detected) { + const spec = hostSpec(h.id); + if (spec?.mcpConfig === undefined) continue; + return check( + 'mcp', + 'missing', + START_TEXT.mcp.manual(h.name), + [ + START_TEXT.mcp.manualPath(spec.mcpConfig.path, spec.mcpConfig.scope), + ...mcpJsonConfig(spec.mcpConfig.shape).split('\n'), + ], + 'snippet', + ); + } + return check('mcp', 'skipped', START_TEXT.mcp.none, null); + } + const spec = hostSpec(host.id); + if (spec?.mcpCheck !== undefined) { + const res = io.run(host.bin, spec.mcpCheck, root, { quiet: true }); + if (!res.error && res.signal == null && res.status === 0) { + return check('mcp', 'ok', START_TEXT.mcp.registered(host.name), null); + } + } + const personal = personalMcpAdd(host.id); + return check( + 'mcp', + 'missing', + START_TEXT.mcp.missing(host.name), + personal === null ? null : [`${personal.bin} ${line(personal.argv)}`], + ); +} + +/** True when a regular file stands at `rel` directly inside the repository root. */ +function committedFile(root: string, rel: string): boolean { + const abs = path.join(root, rel); + try { + if (!fs.statSync(abs).isFile()) return false; + } catch { + return false; + } + return resolvedWithinRoot(root, abs); +} + +function mcpSharedCheck(root: string, host: PromptHost | null): Check { + if (host === null) return check('mcp-shared', 'n/a', START_TEXT.mcpShared.noHost, null); + const spec = hostSpec(host.id); + const file = spec?.mcpSharedFile; + if (spec === undefined || file === undefined || spec.mcpAdd === undefined) { + return check('mcp-shared', 'n/a', START_TEXT.mcpShared.other, null); + } + if (committedFile(root, file)) { + return check('mcp-shared', 'ok', START_TEXT.mcpShared.present(file), null); + } + return check('mcp-shared', 'shared-gap', START_TEXT.mcpShared.absent(file), [mcpCommand(spec, spec.mcpAdd)]); +} + +const HOOK_FIX = ['leji ci --hooks']; + +function hookCheck(status: HookStatus): Check { + if (status.ownership === 'no-git') return check('hook', 'missing', START_TEXT.hook.noGit, null); + if (status.state === 'current') return check('hook', 'ok', START_TEXT.hook.current(status.path), null); + if (status.ownership === 'personal') { + return status.state === 'absent' + ? check('hook', 'missing', START_TEXT.hook.absentPersonal, HOOK_FIX) + : check('hook', 'missing', START_TEXT.hook.foreign(status.path), status.snippet.split('\n'), 'snippet'); + } + if (status.ownership === 'shared') { + return check('hook', 'shared-gap', START_TEXT.hook.absentShared(status.path), HOOK_FIX); + } + if (status.ownership === 'outside-root') { + // A linked worktree's hooks live in the common git directory, outside this + // working tree. It is still per-clone state, but the writer refuses anything + // outside the repository root, so the only honest answer is the snippet. + return check('hook', 'missing', START_TEXT.hook.outsideRoot(status.path), status.snippet.split('\n'), 'snippet'); + } + // Outside the repository entirely: reported with the snippet, never written. + return check('hook', 'missing', START_TEXT.hook.external(status.path), status.snippet.split('\n'), 'snippet'); +} + +// --- the report ----------------------------------------------------------- + +export interface PreflightOptions { + /** Absolute or relative layer root; every check reads from it and nothing else. */ + root: string; + manifest: Manifest; + /** The host `leji start` resolved for this run, or null when none was selected. */ + host: PromptHost | null; + detected: DetectedHost[]; + report: EcosystemReport; + io: HandoffIo; +} + +/** Ids that decide readiness: the shared registration is project hygiene, and a + * maintainer's gap never makes this clone unready. */ +const READY_IDS: CheckId[] = ['cli', 'mcp', 'hook']; +const READY_STATUSES: CheckStatus[] = ['ok', 'skipped', 'n/a']; + +/** + * Run every check, in the fixed order, writing nothing. The only child processes are + * the bounded version probe and the host's own registration query, both through the + * injectable IO. + */ +export function runPreflight(opts: PreflightOptions): PreflightResult { + const root = path.resolve(opts.root); + const hook = hookStatus(root, runnerArgv(opts.report)); + const checks: Check[] = [ + cliCheck(root, opts.manifest, opts.report, opts.io), + mcpCheck(root, opts.host, opts.detected, opts.io), + mcpSharedCheck(root, opts.host), + hookCheck(hook), + ]; + const ready = checks.every((c) => !READY_IDS.includes(c.id) || READY_STATUSES.includes(c.status)); + return { ready, checks, hook }; +} + +/** What `--json` publishes for one check: exactly the four keys the document promises, so + * a render-only field can never reach the scriptable contract. */ +export interface CheckDocument { + id: CheckId; + status: CheckStatus; + detail: string; + fix: string[] | null; +} + +/** The projection every document mode goes through, rather than serializing a Check. */ +export function checkDocument(c: Check): CheckDocument { + return { id: c.id, status: c.status, detail: c.detail, fix: c.fix }; +} + +/** The escape each label wears when color is on. The word is styled; its padding is not, + * so the columns line up whether or not the escapes are there. */ +const STATUS_COLOR: Record = { + ok: '\x1b[32m', + you: '\x1b[33m', + team: '\x1b[36m', + 'n/a': '\x1b[2m', +}; +const COLOR_RESET = '\x1b[0m'; + +/** + * Whether the Setup block may color its status words: a real terminal that has not asked + * for plain text. `NO_COLOR` disables at any value, empty included, because the convention + * is presence. A pure function of the two things it reads, decided once at the CLI + * boundary and injected, so nothing downstream consults the process and every piped byte + * is escape-free by construction. + */ +export function colorDecision(isTTY: boolean, env: NodeJS.ProcessEnv): boolean { + return isTTY && !('NO_COLOR' in env) && env.TERM !== 'dumb'; +} + +export interface RenderPreflightOptions { + /** Off unless the boundary says otherwise. */ + color?: boolean; +} + +function summaryLine(you: number, team: number): string { + const s = START_TEXT.summary; + if (you > 0 && team > 0) return s.both(s.fixes(you), team); + if (you > 0) return s.you(s.fixes(you)); + if (team > 0) return s.team(s.fixes(team)); + return s.complete; +} + +/** + * The Setup block: a heading, one fixed-column row per check with its fixes under it, and + * one closing line counting what is owed. The counts come from the labels the rows already + * printed, so the block can never say something its own rows do not. + */ +export function renderPreflight(checks: Check[], options: RenderPreflightOptions = {}): string { + const lines = [START_TEXT.heading, '']; + let you = 0; + let team = 0; + for (const c of checks) { + const label = START_TEXT.status[c.status]; + if (label === 'you') you++; + else if (label === 'team') team++; + const word = options.color === true ? `${STATUS_COLOR[label] ?? ''}${label}${COLOR_RESET}` : label; + const status = `${word}${' '.repeat(STATUS_WIDTH - label.length)}`; + const subject = START_TEXT.subject[c.id].padEnd(SUBJECT_WIDTH); + lines.push(`${MARGIN}${status}${GUTTER}${subject}${GUTTER}${c.detail}`); + const prompt = (c.fixKind ?? 'command') === 'command' ? '$ ' : ''; + for (const fix of c.fix ?? []) lines.push(`${FIX_INDENT}${prompt}${fix}`); + } + lines.push('', `${MARGIN}${summaryLine(you, team)}`); + return lines.join('\n'); +} + +// --- the consented repairs ------------------------------------------------ + +export interface PreflightOfferOptions { + root: string; + host: PromptHost | null; + result: PreflightResult; + /** The runner the hook would be written with, so the report and the write agree. */ + runner: string[]; + /** A real TTY and not --json; nothing is offered or written otherwise. */ + interactive: boolean; + io: HandoffIo; +} + +/** + * Offer the personal repairs the report found, in the order it printed them. Only + * state this user or this clone owns is ever offered: the host registration for this + * user, and the per-clone hook. A shared gap is never offered, because accepting it + * would write a file the repository commits. + */ +export async function offerPreflightFixes(opts: PreflightOfferOptions): Promise { + if (!opts.interactive) return; + const byId = (id: CheckId): Check | undefined => opts.result.checks.find((c) => c.id === id); + const yes = async (question: string): Promise => { + const a = (await opts.io.readLine(question, START_TEXT.offer.prompt)).toLowerCase(); + return a === '' || a === 'y' || a === 'yes'; + }; + + const mcp = byId('mcp'); + const personal = opts.host === null ? null : personalMcpAdd(opts.host.id); + if (mcp?.status === 'missing' && opts.host !== null && personal !== null) { + if (await yes(START_TEXT.offer.mcp(opts.host.name))) { + const res = opts.io.run(personal.bin, personal.argv, opts.root, { quiet: false }); + if (!res.error && res.signal == null && (res.status == null || res.status === 0)) { + console.log(START_TEXT.offer.mcpDone(opts.host.name)); + } else { + console.log(START_TEXT.offer.mcpFailed(personal.bin)); + console.log(`${FIX_INDENT}$ ${personal.bin} ${line(personal.argv)}`); + } + } + } + + const hook = opts.result.hook; + if (hook.ownership === 'personal' && hook.state === 'absent' && (await yes(START_TEXT.offer.hook))) { + const written = ensureLocalHook(opts.root, opts.runner); + if (written.action === 'manual') { + console.log(START_TEXT.offer.hookFailed); + console.log(`\n${written.snippet ?? ''}`); + } else { + console.log(START_TEXT.offer.hookDone(written.path)); + } + } +} diff --git a/packages/sdk/src/commands/serve.ts b/packages/sdk/src/commands/serve.ts new file mode 100644 index 0000000..18d0def --- /dev/null +++ b/packages/sdk/src/commands/serve.ts @@ -0,0 +1,346 @@ +import { spawn } from 'node:child_process'; +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 { generateIndex } from './indexgen.js'; +import { + ACTIVE_EXTENSIONS, + assembleSidebar, + declaresInherits, + relativeToRoot, + resolvedProfilePage, + unresolvedProfilePage, +} from './viewer.js'; + +/** + * The local preview server: the ONE module that speaks HTTP. The export pipeline + * lives in `export.ts` and the chrome generation in `viewer.ts`, neither of which + * imports this file or any network module — that separation is what makes the + * export's no-network guarantee checkable by a module-graph test rather than by + * reading the code. + */ + +/** The SPA shell's policy, sent as a response header on every chrome response so + * it holds for documents reached outside the shell too. Mirrors the meta in + * templates/viewer/index.html; keep the two in step. */ +const CSP_CHROME = + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'none'"; + +/** The policy for everything served out of the layer itself. `sandbox` with no + * tokens puts a /content/ document in an opaque origin with scripting off, so a + * governed file framed or opened directly is inert rather than same-origin code. */ +const CSP_CONTENT = "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; sandbox"; + +/** The host names the local preview answers to. A missing Host is accepted (an + * HTTP/1.0 client omits it); anything else is a request that reached the loopback + * socket under somebody else's name. */ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** True when the Host header names the loopback interface: hostname only, since + * the port a request arrives on is already fixed by the loopback bind. */ +export function loopbackHost(host: string | undefined): boolean { + if (host === undefined || host === '') return true; + const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0]; + return LOOPBACK_HOSTS.has(name.toLowerCase()); +} + +const CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.md': 'text/markdown; 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', + '.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', +}; + +/** + * A request URL path as a clean relative route key. Separators fold to `/` and + * the path is cleaned against a root, so one request has one route key on any + * platform — `path.normalize` follows the host and answered differently on + * Windows, missing every `content/` route test. Canonicalization only; the mount + * enforces containment. + */ +export function urlPathToRel(urlPath: string): string { + const cleaned = path.posix.normalize('/' + urlPath.replaceAll('\\', '/')); + // Trimmed by index rather than by regex. `normalize` has already collapsed every + // run of separators, so an anchored `/\/+$/` could only ever match one character + // here, but that is an invariant of another function: a linear scan holds on its + // own and does not read as a polynomial regex over a request path. + let start = 0; + let end = cleaned.length; + while (start < end && cleaned.charCodeAt(start) === 47) start++; + while (end > start && cleaned.charCodeAt(end - 1) === 47) end--; + return cleaned.slice(start, end); +} + +/** + * 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 + * (`.leji/viewer/`) at `/`, layer markdown (`rootPath/`) under `/content/`; + * `/content/_sidebar.md` maps to the generated sidebar in 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. + * Returns the listening server; port 0 picks free. + */ +export function serveViewer( + root: string, + port: number, + rootRel = '', + opts: { log?: (line: string) => void } = {}, +): Promise { + const rootAbs = fs.realpathSync(path.resolve(root)); + const base = stripSlash(rootRel); + const contentAbs = base && base !== '.' ? path.join(rootAbs, base) : rootAbs; + // The CLI passes a validated rootPath, but a direct SDK caller could pass an + // escaping rootRel (e.g. ".."); refuse to mount content outside the layer root. + if (!resolvedWithinRoot(rootAbs, contentAbs)) { + throw new Error(`viewer root "${rootRel}" escapes the layer root`); + } + const viewerAbs = path.join(rootAbs, VIEWER_REL); + + // 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. + // `inert` marks the layer's own content mount, whose files are never given an + // active content type however they are named. + const serveFrom = (res: http.ServerResponse, mountRoot: string, sub: string, inert = false): void => { + let abs = sub === '' ? path.join(mountRoot, 'index.html') : path.join(mountRoot, sub); + if (abs !== mountRoot && !abs.startsWith(mountRoot + path.sep)) { + res.writeHead(403).end('forbidden'); + return; + } + // The servable-roots whitelist: under `.leji/`, only `viewer/` is servable. + // Judged by name on the requested path and again on the resolved one, so a + // symlink under the content root cannot reach a private role either. + if (!servablePath(rootAbs, abs)) { + res.writeHead(404).end('not found'); + return; + } + try { + if (fs.statSync(abs).isDirectory()) abs = path.join(abs, 'index.html'); + const real = fs.realpathSync(abs); + if (real !== mountRoot && !real.startsWith(mountRoot + path.sep)) { + res.writeHead(403).end('forbidden'); + return; + } + if (!servablePath(rootAbs, real)) { + res.writeHead(404).end('not found'); + return; + } + const body = fs.readFileSync(real); + const ext = path.extname(real).toLowerCase(); + res.writeHead(200, { + 'content-type': + inert && ACTIVE_EXTENSIONS.has(ext) + ? 'text/plain; charset=utf-8' + : (CONTENT_TYPES[ext] ?? 'application/octet-stream'), + }); + res.end(body); + } catch { + res.writeHead(404).end('not found'); + } + }; + + // 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 + // 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; + const treeFingerprint = (): string => { + const parts: string[] = []; + const add = (rel: string): void => { + try { + const st = fs.statSync(path.join(rootAbs, rel)); + parts.push(`${rel}\u0000${st.mtimeMs}\u0000${st.size}`); + } catch { + parts.push(`${rel}\u0000gone`); + } + }; + add('leji.json'); + for (const rel of walkTree(rootAbs, base || '.')) add(rel); + return parts.join('\n'); + }; + + const server = http.createServer((req, res) => { + // Access log: one terse line per request, after the status is known. + if (opts.log) { + res.on('finish', () => opts.log!(`${req.method ?? 'GET'} ${req.url ?? '/'} ${res.statusCode}`)); + } + // Policy headers ride every response, not just the SPA shell: a document + // served straight out of /content/ is same-origin and would otherwise run + // with no policy at all. Set before any write; the content mount downgrades + // to the inert policy once the route is known. + res.setHeader('x-content-type-options', 'nosniff'); + res.setHeader('content-security-policy', CSP_CHROME); + // Loopback binding alone does not stop DNS rebinding: a hostile page whose + // name resolves to 127.0.0.1 reaches this server with its own Host. Only the + // loopback names the viewer is actually addressed by are answered. The port is + // deliberately not part of the test: a rebound request carries the right port + // anyway, so matching it adds nothing. Don't "fix" this by checking it. + if (!loopbackHost(req.headers.host)) { + res.writeHead(403).end('forbidden'); + return; + } + let urlPath: string; + try { + // Concatenated, not resolved against a base: a target beginning with "//" + // parses as protocol-relative, which moves its first segment into the host + // and loses it. A malformed percent-encoding (e.g. GET /%E0%A4%A) throws + // URIError; answer 400 rather than letting it crash the server. + urlPath = decodeURIComponent(new URL('http://localhost' + (req.url ?? '/')).pathname); + } catch { + res.writeHead(400).end('bad request'); + return; + } + const rel = urlPathToRel(urlPath); + if (rel === 'content' || rel.startsWith('content/')) res.setHeader('content-security-policy', CSP_CONTENT); + // Refuse any dotfile or VCS-internal segment in the request path: the .leji + // viewer dir is reached only through the mounts below, never by direct URL. + if (rel.split(/[/\\]/).some((seg) => seg === '.git' || (seg.startsWith('.') && seg !== '.' && seg !== ''))) { + res.writeHead(404).end('not found'); + return; + } + // The generated sidebar lives in the viewer dir but is served as if at the + // content root, so Docsify's basePath /content/ + _sidebar alias resolves it. + // Docsify fetches it once per page load, so it is rebuilt from the live tree + // on every request: a long-running server never shows a deleted or moved + // 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 + } + serveFrom(res, viewerAbs, '_sidebar.md'); + return; + } + // The stored context index is served live (same fingerprint cache as the + // sidebar), so per-page classification badges never disagree with the tree. + if (rel.startsWith('content/')) { + try { + const { manifest } = loadManifest(rootAbs); + 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, + }; + } + } + if (sidebarCache !== null && sidebarCache.key === key && sidebarCache.indexJson !== null) { + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); + res.end(sidebarCache.indexJson); + return; + } + } + } catch { + // fall through to the stored artifact + } + } + // The generated Manifest page lives in the viewer dir (gitignored chrome) but + // is linked from the sidebar and fetched under the content root, like + // _sidebar.md. Reserved underscore name; served from the last generation. + if (rel === 'content/_manifest.md') { + serveFrom(res, viewerAbs, '_manifest.md'); + 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 + // on disk is one half, and presenting it as the effective profile is the + // thing a consumer must not do. So this branch fails closed. If anything + // at all goes wrong, a file that declares `inherits` still gets a findings + // page; only a file that is not half a profile falls through to disk. + if (sub.endsWith('.md')) { + const repoRel = base && base !== '.' ? `${base}/${sub}` : sub; + let page: string | null = null; + try { + const { manifest } = loadManifest(rootAbs); + page = manifest === null ? null : resolvedProfilePage(rootAbs, manifest, repoRel); + if (page === null && manifest === null && declaresInherits(rootAbs, repoRel)) { + page = unresolvedProfilePage(repoRel, [ + finding('artifact-parse', 'error', 'the layer manifest could not be read', 'leji.json'), + ]); + } + } catch (e) { + page = declaresInherits(rootAbs, repoRel) + ? unresolvedProfilePage(repoRel, [ + finding( + 'artifact-parse', + 'error', + `the viewer could not resolve this profile: ${(e as Error).message}`, + repoRel, + ), + ]) + : null; + } + if (page !== null) { + res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); + res.end(page); + return; + } + } + serveFrom(res, contentAbs, sub, true); + return; + } + // Everything else (`/`, /index.html, /assets/*) is viewer chrome. + serveFrom(res, viewerAbs, rel); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +/** + * Best-effort open of `url` in the default browser (`--open` / `leji view`). Never + * throws or blocks; a missing opener is a silent no-op. + */ +export function openBrowser(url: string): void { + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; + try { + const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); + child.on('error', () => {}); + child.unref(); + } catch { + /* opening the browser is best-effort */ + } +} diff --git a/packages/sdk/src/commands/status.ts b/packages/sdk/src/commands/status.ts index 11e4e78..1bc147a 100644 --- a/packages/sdk/src/commands/status.ts +++ b/packages/sdk/src/commands/status.ts @@ -66,15 +66,30 @@ function isChrome(manifest: Manifest, rel: string): boolean { ); } +/** Markdown under rootPath that no category index lists, given the governed set. + * The one definition of "unindexed"; callers that already resolved the + * assignments pass them in rather than resolving the tree twice. */ +function unindexedIn(root: string, manifest: Manifest, governed: Set): string[] { + const rootDir = stripSlash(manifest.rootPath) || '.'; + return walkTree(root, rootDir) + .filter((rel) => !governed.has(rel) && !isChrome(manifest, rel)) + .sort(); +} + +/** The unindexed set on its own, for callers that need the count without the + * rest of the health report (the `index` generate nudge). Same machinery as + * `statusReport`, no second walker. */ +export function unindexedPaths(root: string, manifest: Manifest): string[] { + const resolved = resolveCategoryAssignments(root, manifest); + return unindexedIn(root, manifest, new Set(resolved.assignments.keys())); +} + /** Pure computation; the CLI renders and decides exit. */ export function statusReport(root: string, manifest: Manifest): StatusReport { const resolved = resolveCategoryAssignments(root, manifest); const governed = new Set(resolved.assignments.keys()); - const rootDir = stripSlash(manifest.rootPath) || '.'; - const unindexed = walkTree(root, rootDir) - .filter((rel) => !governed.has(rel) && !isChrome(manifest, rel)) - .sort(); + const unindexed = unindexedIn(root, manifest, governed); const dangling: DanglingEntry[] = resolved.findings .filter( diff --git a/packages/sdk/src/commands/viewer.ts b/packages/sdk/src/commands/viewer.ts index 31337f2..38d813e 100644 --- a/packages/sdk/src/commands/viewer.ts +++ b/packages/sdk/src/commands/viewer.ts @@ -1,29 +1,43 @@ -import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; -import * as http from 'node:http'; import * as path from 'node:path'; import { type Finding, finding } from '../lib/findings.js'; -import { isFile, readText, readTextWithin, resolvedWithinRoot, stripSlash, underPath, walkTree } from '../lib/fsx.js'; +import { + isFile, + openVerifiedSource, + readText, + readTextWithin, + resolvedPath, + resolvedWithinRoot, + stripSlash, + underPath, + verifiedTargetRead, + walkTree, + writeFileGuarded, +} from '../lib/fsx.js'; import { parseFrontmatter } from '../lib/frontmatter.js'; +import { LEJI_DIR, VIEWER_REL, servablePath, writableTarget } from '../lib/layout.js'; import { type ScannedProfile, resolveAgentProfile, resolveCategoryAssignments, scanAgentProfiles, - scanProfileSet, + scanProfileSetWith, } from '../lib/layer.js'; import { mountStatus, type StatusResult } from '../lib/mounts.js'; -import { - type Manifest, - CATEGORY_IDS, - effectiveAgentProfilesPath, - effectiveIndexPath, - loadManifest, -} from '../lib/manifest.js'; +import { type Manifest, CATEGORY_IDS, effectiveAgentProfilesPath, effectiveIndexPath } from '../lib/manifest.js'; import { templatesDir } from '../lib/schemas.js'; import { byteCompare } from '../lib/text.js'; import { generateIndex } from './indexgen.js'; +/** + * The viewer's chrome: the sidebar, the manifest page, the resolved-profile pages, + * and the SPA shell in its two flavors, generated into the `.leji/viewer/` role. + * Everything here is offline and filesystem-only. The two consumers live beside it + * and never merge back into it: `serve.ts` (the local preview, the one module that + * speaks HTTP) and `export.ts` (the static export, whose no-network guarantee is + * checkable precisely because this module and its own imports reach no socket). + */ + /** Preview-port precedence: explicit --port, then manifest viewer.port, then 5354 (LEJI on a phone keypad). */ export function resolveViewerPort(manifest: Manifest, flagPort?: number): number { return flagPort ?? manifest.viewer?.port ?? 5354; @@ -59,16 +73,33 @@ const CATEGORY_EMOJI: Record = { /** Vendored assets loaded only when mermaid is enabled; skipped otherwise. */ const MERMAID_ASSETS = new Set(['mermaid.min.js', 'docsify-mermaid.js']); -/** The Leji brand blue, the viewer's default accent when no viewer.theme.primary is set. */ -const DEFAULT_THEME_COLOR = '#223F93'; -const DEFAULT_LOGO = '/assets/leji-logo.svg'; +/** The Leji brand green, the viewer's default accent when no viewer.theme.primary is set. */ +const DEFAULT_THEME_COLOR = '#009F71'; + +/** + * The base every URL the generated chrome emits is written against: `'/'` for the + * local server (the app root, the served flavor's unchanged contract) and `''` for + * an export, whose references then resolve against the page itself so the tree + * hosts correctly under a subpath. It is a generation parameter, never a post-hoc + * rewrite of emitted HTML: one code path, two invocations. `index.html` is the only + * artifact that exists in two flavors — everything else under the chrome is + * flavor-neutral. + */ +export type ChromeBase = '/' | ''; + +/** The vendored Leji mark, as the given base addresses it. */ +function defaultLogo(base: ChromeBase): string { + return `${base}assets/leji-logo.svg`; +} -/** A CSS color safe to hand to the page: a hex color or a bare color keyword. - * The accent reaches a stylesheet as a custom-property value, so anything with - * punctuation in it is a CSS-injection sink rather than a color. */ -const SAFE_CSS_COLOR = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$/; +/** The one accent format the viewer accepts: a hex color at a length CSS actually + * defines (#RGB, #RGBA, #RRGGBB, #RRGGBBAA). The accent reaches a stylesheet as a + * custom-property value, so anything with punctuation in it is a CSS-injection + * sink rather than a color; hex-only also keeps one canonical form across the + * three SDKs and the schema. */ +const SAFE_CSS_COLOR = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; -/** The viewer accent: viewer.theme.primary when it is a plain CSS color, else the +/** The viewer accent: viewer.theme.primary when it is a hex color, else the * Leji default with a warning. Never the authored value unchecked. */ function resolveThemeColor(manifest: Manifest, findings: Finding[]): string { const configured = manifest.viewer?.theme?.primary; @@ -78,12 +109,69 @@ function resolveThemeColor(manifest: Manifest, findings: Finding[]): string { finding( 'viewer-theme-invalid', 'warning', - `viewer.theme.primary "${configured}" is not a plain CSS color (hex or keyword); using ${DEFAULT_THEME_COLOR}`, + `viewer.theme.primary "${configured}" is not a hex color (#RGB, #RGBA, #RRGGBB, or #RRGGBBAA); using ${DEFAULT_THEME_COLOR}`, ), ); return DEFAULT_THEME_COLOR; } +/** The accent as opaque sRGB channels, or null for a value that names no color the + * generator can resolve — a keyword, `currentColor`, a malformed hex. Accepts + * 3/4/6/8-digit hex, the only form the accent can take; an accent carrying alpha is + * composited over white, the viewer's content background, which is the only backdrop + * knowable at generation time (the accent itself keeps its authored alpha everywhere + * it is used — this composite decides text color, nothing that renders). */ +function parseAccentColor(value: string): { r: number; g: number; b: number } | null { + const raw = value.trim().toLowerCase(); + if (!raw.startsWith('#')) return null; + const hex = raw.slice(1); + if (!/^[0-9a-f]+$/.test(hex)) return null; + const full = + hex.length === 3 || hex.length === 4 + ? [...hex].map((c) => c + c).join('') + : hex.length === 6 || hex.length === 8 + ? hex + : null; + if (full === null) return null; + const channel = (i: number): number => parseInt(full.slice(i * 2, i * 2 + 2), 16); + const alpha = full.length === 8 ? channel(3) / 255 : 1; + const over = (c: number): number => Math.round(c * alpha + 255 * (1 - alpha)); + return { r: over(channel(0)), g: over(channel(1)), b: over(channel(2)) }; +} + +/** WCAG relative luminance: linearized sRGB channels, weighted. */ +function relativeLuminance(rgb: { r: number; g: number; b: number }): number { + const linear = (c: number): number => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(rgb.r) + 0.7152 * linear(rgb.g) + 0.0722 * linear(rgb.b); +} + +/** WCAG contrast ratio between two relative luminances. */ +function contrastRatio(a: number, b: number): number { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** + * The mermaid node-text color for an accent, computed here rather than in the + * browser: the viewer's boot script sees only what the config block carries, while + * this side can resolve every color form viewer.theme.primary accepts. Whichever of + * #1a1a1a and #ffffff contrasts more with the accent, or #000000 when neither + * clears WCAG AA (4.5:1) — a mid-gray accent, where the extra half-stop of black is + * the best text color available. An accent this cannot resolve keeps the dark + * default, which is also the boot script's fallback. + */ +export function mermaidTextColor(themeColor: string): string { + const rgb = parseAccentColor(themeColor); + if (rgb === null) return '#1a1a1a'; + const accent = relativeLuminance(rgb); + const onDark = contrastRatio(relativeLuminance({ r: 0x1a, g: 0x1a, b: 0x1a }), accent); + const onLight = contrastRatio(1, accent); + if (onDark < 4.5 && onLight < 4.5) return '#000000'; + return onDark >= onLight ? '#1a1a1a' : '#ffffff'; +} + /** Resolve a viewer-configured file path to a rootPath-relative rel. The * canonical form is rootPath-relative, but a repository-root-relative path * under the context root is accepted too (`docs/README.md` for `README.md`): @@ -119,11 +207,11 @@ function effectiveHomepage(root: string, manifest: Manifest, findings: Finding[] /** Resolve the viewer logo URL: a configured path is served from the content mount * (or used as-is when absolute); unset falls back to the vendored Leji mark. */ -function resolveLogo(root: string, rootPath: string, logo: string | undefined): string { - if (!logo) return DEFAULT_LOGO; +function resolveLogo(root: string, rootPath: string, logo: string | undefined, base: ChromeBase): string { + if (!logo) return defaultLogo(base); if (logo.startsWith('/') || /^https?:\/\//.test(logo)) return logo; const rel = resolveViewerRel(root, rootPath, logo); - return `/content/${rel ?? stripSlash(logo)}`; + return `${base}content/${rel ?? stripSlash(logo)}`; } /** Escape text for safe interpolation into HTML element/attribute content. */ @@ -149,7 +237,7 @@ function jsonForScript(value: unknown): string { .replaceAll('
', '\\u2029'); } -function relativeToRoot(relPath: string, rootPath: string): string | null { +export function relativeToRoot(relPath: string, rootPath: string): string | null { const base = stripSlash(rootPath); if (base === '' || base === '.') return relPath; if (relPath.startsWith(base + '/')) return relPath.slice(base.length + 1); @@ -163,9 +251,17 @@ function mdLinkText(s: string): string { return s.replace(/[\\[\]<>]/g, '\\$&'); } -/** Escape a string for a Markdown link destination (`(...)`): backslash, parens. */ +/** Escape a string for a Markdown link destination (`(...)`): backslash, parens. + * Destinations are emitted app-root absolute (leading slash): with the viewer's + * relativePath routing, a bare rootPath-relative destination would re-resolve + * against whatever nested route is current and double-prefix; leading-slash links + * are exempt from relative resolution by Docsify's contract. Idempotent: leading + * slashes are stripped first, so an already-absolute destination (the sidebar + * builders are public API) never becomes `//…`, which Docsify routes as an + * external protocol-relative URL. Empty input stays empty, never a bare `/`. */ function mdLinkDest(s: string): string { - return s.replace(/[\\()]/g, '\\$&'); + const escaped = s.replace(/^\/+/, '').replace(/[\\()]/g, '\\$&'); + return escaped === '' ? '' : '/' + escaped; } /** A reference doc shown in the browse zone: rootPath-relative path + display title. */ @@ -425,6 +521,10 @@ export function buildSidebarGroups( for (const p of scanAgentProfiles(root, manifest)) { const rel = relativeToRoot(p.relPath, manifest.rootPath); if (rel === null) continue; + // A declared profiles directory can name a private role; its files are not + // servable, so neither is the label lifted out of one. The route would 404 + // anyway — this keeps the bytes out of the sidebar that links it. + if (!servableSource(root, p.relPath)) continue; const name = p.frontmatter?.name; const title = typeof name === 'string' && name.trim() !== '' ? name.trim() : sidebarLabel(root, p.relPath, rel); agentMembers.push({ rel, title }); @@ -497,8 +597,9 @@ function sidebarLabel(root: string, relPath: string, rootRel: string): string { /** * 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, generated _sidebar.md). The `.leji` viewer dir is skipped by - * the walk. Returns rootPath-relative nodes for the sidebar tree. + * files, overview.md, generated _sidebar.md). Generated artifacts live in the root + * `.leji/`, which the walk skips as a dot-dir even when rootPath is `.`. Returns + * rootPath-relative nodes for the sidebar tree. */ function referenceTree(root: string, manifest: Manifest, governedPaths: Set): TreeNode[] { const rootDirRel = stripSlash(manifest.rootPath) || '.'; @@ -632,11 +733,11 @@ function mermaidLabel(s: string): string { export function buildManifestPage(manifest: Manifest, statuses: StatusResult[]): string { const title = manifest.viewer?.title ?? manifest.name; const lines: string[] = [ - `# ${esc(title)} — Manifest`, + `# ${esc(title)}: Manifest`, '', "A human-readable view of this layer's `leji.json`.", '', - '> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects — no network fetch is performed.', + '> **Declared** values come straight from the manifest. **Observed** values (mount availability and drift) are read from local projections and Git objects; no network fetch is performed.', '', '## Identity', '', @@ -650,7 +751,7 @@ export function buildManifestPage(manifest: Manifest, statuses: StatusResult[]): if (owner) lines.push(`| Owner | ${esc(owner.name)}${owner.contact ? ` (${codeSpan(owner.contact)})` : ''} |`); const claimed = manifest.conformance?.claimedLevel; lines.push( - `| Conformance | ${claimed ? `claims \`${esc(claimed)}\` — run \`leji conformance\` to verify` : 'no level claimed'} |`, + `| Conformance | ${claimed ? `claims \`${esc(claimed)}\` (run \`leji conformance\` to verify)` : 'no level claimed'} |`, ); lines.push('', '## Entrypoints', '', '| Purpose | Path |', '| --- | --- |'); @@ -748,12 +849,12 @@ export function buildManifestPage(manifest: Manifest, statuses: StatusResult[]): } lines.push( '', - '> `not hydrated` / `unknown` are normal degraded reads — ordinary validation never fails just because a mount is unavailable (opt-in federation enforcement is separate). Run `leji mounts hydrate`, then regenerate the viewer to refresh.', + '> `not hydrated` / `unknown` are normal degraded reads; ordinary validation never fails just because a mount is unavailable (opt-in federation enforcement is separate). Run `leji mounts hydrate`, then regenerate the viewer to refresh.', ); const roled = mounts.filter((d) => d.role); if (roled.length > 0) { lines.push('', '**Roles**', ''); - for (const d of roled) lines.push(`- **${esc(d.name)}** — ${esc(d.role ?? '')}`); + for (const d of roled) lines.push(`- **${esc(d.name)}**: ${esc(d.role ?? '')}`); } } lines.push(''); @@ -775,9 +876,9 @@ function profileValue(value: unknown): string { * resolve renders its findings instead: there is no effective profile to show, and * presenting the derived file as if there were would be the error the finding names. */ -function unresolvedProfilePage(relPath: string, findings: Finding[]): string { +export function unresolvedProfilePage(relPath: string, findings: Finding[]): string { const lines = [ - `# ${esc(relPath)} — unresolved profile`, + `# ${esc(relPath)}: unresolved profile`, '', `> **This profile does not resolve.** ${codeSpan(relPath)} declares \`inherits\`, and the inheritance cannot be resolved, so the layer has no effective profile for this role. The file on disk is only its own half and is not shown here: a consumer that cannot resolve an inherited profile must not apply the derived file alone.`, '', @@ -807,7 +908,7 @@ function renderResolvedProfile(profiles: ScannedProfile[], derived: ScannedProfi const effective = resolved.frontmatter; const title = typeof effective.name === 'string' ? effective.name : derivedId; const lines: string[] = [ - `# ${esc(title)} — resolved profile`, + `# ${esc(title)}: resolved profile`, '', `> **Resolved profile.** ${codeSpan(derived.relPath)} declares \`inherits: ${esc(baseId)}\`, so this page is the effective profile: posture from ${codeSpan(baseRel)} first, then this profile's own, with exact duplicates dropped. Every other field is this profile's own; both bodies are operative, base first. The file on disk carries only its own half.`, '', @@ -818,7 +919,7 @@ function renderResolvedProfile(profiles: ScannedProfile[], derived: ScannedProfi ]; for (const [key, value] of Object.entries(effective)) { if (!Array.isArray(value)) { - lines.push(`- **${esc(key)}** — ${profileValue(value)}`); + lines.push(`- **${esc(key)}**: ${profileValue(value)}`); continue; } // Composed posture: label every entry with the profile that supplied it. @@ -829,7 +930,7 @@ function renderResolvedProfile(profiles: ScannedProfile[], derived: ScannedProfi if (value.length === 0) lines.push(' - (empty)'); for (const entry of value) { const source = fromBase.has(JSON.stringify(entry) ?? '') ? baseId : derivedId; - lines.push(` - ${profileValue(entry)} — from \`${esc(source)}\``); + lines.push(` - ${profileValue(entry)} (from \`${esc(source)}\`)`); } } lines.push('', '## Effective body', ''); @@ -848,7 +949,7 @@ function renderResolvedProfile(profiles: ScannedProfile[], derived: ScannedProfi /** True when the file at `repoRel` declares `inherits`, so it is one half of a * profile and must never reach a reader as the effective one. Manifest-free and * total, so the serve path can still classify when nothing else is readable. */ -function declaresInherits(root: string, repoRel: string): boolean { +export function declaresInherits(root: string, repoRel: string): boolean { try { const text = readTextWithin(path.resolve(root), path.join(root, repoRel)); return text !== null && typeof parseFrontmatter(text).data?.inherits === 'string'; @@ -857,6 +958,57 @@ function declaresInherits(root: string, repoRel: string): boolean { } } +/** + * True when the layer file at `repoRel` may be read into something served or + * exported: judged by the servable-roots whitelist as requested AND after symlink + * resolution, the same pair of checks `serveFrom` makes on a response. A path that + * resolves into a private `.leji/` role fails, however it was spelled. + */ +function servableSource(root: string, repoRel: string): boolean { + const rootAbs = resolvedPath(path.resolve(root)); + if (rootAbs === null) return false; + const abs = path.join(rootAbs, repoRel); + if (!servablePath(rootAbs, abs)) return false; + const real = resolvedPath(abs); + return real !== null && servablePath(rootAbs, real); +} + +/** + * A profile source read the way check-before-act requires: the requested path is judged, its + * RESOLVED path is judged, and the bytes come from the descriptor opened on that + * resolved path and proved a regular file — so nothing swapped between the check and + * the read (a file, or any directory above it, becoming a symlink) changes what is + * composed into a served or exported page. Null for anything refused. + */ +function servableProfileText(rootAbs: string, repoRel: string): string | null { + const abs = path.join(rootAbs, repoRel); + if (!servablePath(rootAbs, abs)) return null; + const { fd } = openVerifiedSource( + abs, + (real) => servablePath(rootAbs, real) && (real === rootAbs || real.startsWith(rootAbs + path.sep)), + ); + if (fd === null) return null; + try { + return fs.readFileSync(fd, 'utf8'); + } finally { + fs.closeSync(fd); + } +} + +/** + * The profile set as the viewer may render it: every source read through + * `servableProfileText`, so no profile living in — or symlinked into — a private + * `.leji/` role is composed into a served page or an exported one, and the bytes + * composed are the bytes that passed the check. Dropped silently, exactly as the + * content walk drops unservable content; the scan itself stays total, so validation + * still reports on those files. + */ +function servableProfileSet(root: string, manifest: Manifest): ScannedProfile[] { + const rootAbs = resolvedPath(path.resolve(root)); + if (rootAbs === null) return []; + return scanProfileSetWith(root, manifest, (relPath) => servableProfileText(rootAbs, relPath)); +} + /** * The page for `repoRel` when it is an agent profile that declares `inherits`, * else null (every other document is served from disk as authored). @@ -875,9 +1027,14 @@ export function resolvedProfilePage(root: string, manifest: Manifest, repoRel: s // file's own frontmatter (a profile that inherits nothing is served as-is). const bound = Object.values(manifest.agents ?? {}).includes(repoRel); if (!bound && !underPath(repoRel, effectiveAgentProfilesPath(manifest))) return null; + // The whitelist, judged before this file is read into a page: a profile that + // resolves into a private `.leji/` role is not the viewer's to render. Null + // hands the request back to the content walk, which refuses it the same way + // it refuses any unservable file — this branch never becomes the way in. + if (!servableSource(root, repoRel)) return null; if (!declaresInherits(root, repoRel)) return null; committed = true; - const profiles = scanProfileSet(root, manifest); + const profiles = servableProfileSet(root, manifest); const derived = profiles.find((p) => p.relPath === repoRel); if (!derived) { return unresolvedProfilePage(repoRel, [ @@ -895,8 +1052,8 @@ export function resolvedProfilePage(root: string, manifest: Manifest, repoRel: s /** Every inheriting profile as its rootPath-relative viewer path and resolved * page, so a static export carries what the local server renders. */ -function resolvedProfilePages(root: string, manifest: Manifest): { rel: string; page: string }[] { - const profiles = scanProfileSet(root, manifest); +export function resolvedProfilePages(root: string, manifest: Manifest): { rel: string; page: string }[] { + const profiles = servableProfileSet(root, manifest); const out: { rel: string; page: string }[] = []; for (const p of profiles) { if (typeof p.frontmatter?.inherits !== 'string') continue; @@ -935,7 +1092,7 @@ ${mapBlock(manifest, entries)} * boot-pin replacement), pin-filtered groups, and the homepage-excluded * reference tree. Used by generation and by the serve path, which rebuilds it * per fetch so a long-running viewer never shows a deleted or moved document. */ -function assembleSidebar( +export function assembleSidebar( root: string, manifest: Manifest, entries: { id: string; path: string; title: string; category: string; kind?: string; date?: string }[], @@ -1009,16 +1166,15 @@ function assembleSidebar( return buildSidebar(manifest, groups, tree, pins, { bootPinned }); } -export function generateViewer(root: string, manifest: Manifest): 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 }; - } - const entries = result.index?.entries ?? []; - const findingsEarly: Finding[] = []; - +/** + * The SPA shell for one flavor of the chrome: the template with this layer's config + * baked in, every URL it emits written against `base`. The served flavor (`'/'`) and + * the export flavor (`''`) come from this one function, so the export never gets its + * HTML rewritten after the fact. `findings` collects the two resolution warnings + * (homepage, accent) in their established order; the export invocation discards + * them, having already reported the generation run's. + */ +export function buildIndexHtml(root: string, manifest: Manifest, base: ChromeBase, findings: Finding[]): string { // Display title: viewer.title override, else the context layer name. const displayTitle = manifest.viewer?.title ?? manifest.name; // The sidebar header. A configured brand logo renders as a centered block (the @@ -1026,7 +1182,7 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { // mark renders small and inline beside the title text. Raw HTML inside // `name` rather than Docsify's `logo` option (which prepends basePath /content/ // and 404s). Title is HTML-escaped; the strict CSP (script-src 'self') kills handlers. - const logoUrl = htmlEscape(resolveLogo(root, manifest.rootPath, manifest.viewer?.logo)); + const logoUrl = htmlEscape(resolveLogo(root, manifest.rootPath, manifest.viewer?.logo, base)); const nameHtml = manifest.viewer?.logo ? `${htmlEscape(displayTitle)}` : `` + @@ -1035,8 +1191,8 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { // to the vendored Leji mark. const faviconUrl = htmlEscape( manifest.viewer?.favicon - ? `/content/${resolveViewerRel(root, manifest.rootPath, manifest.viewer.favicon) ?? stripSlash(manifest.viewer.favicon)}` - : DEFAULT_LOGO, + ? `${base}content/${resolveViewerRel(root, manifest.rootPath, manifest.viewer.favicon) ?? stripSlash(manifest.viewer.favicon)}` + : defaultLogo(base), ); // Mermaid on unless explicitly disabled; off omits both scripts and skips copying // their assets (~3MB smaller viewer). @@ -1045,6 +1201,11 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { ? '\n ' + '\n ' : ''; + // Resolved before the config literal so the mermaid text color can be computed + // from the accent; the two resolutions keep their original order, so do the + // findings they raise. + const homepage = effectiveHomepage(root, manifest, findings); + const themeColor = resolveThemeColor(manifest, findings); // One pass over the template with a resolver map, never four sequential // replaces: a sequential pass re-scans what the previous one substituted, so a // manifest string like "{{DOCSIFY_CONFIG}}" in viewer.title or viewer.favicon @@ -1054,6 +1215,11 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { FAVICON_URL: faviconUrl, DOCSIFY_CONFIG: jsonForScript({ name: nameHtml, + // Where the layer's markdown is mounted. Docsify's own key, so the boot + // script configures the router from it rather than hardcoding a root: + // '/content/' served, 'content/' exported (resolved against the page, so + // the tree hosts under any subpath). + basePath: `${base}content/`, // Hash navigation for the logo/title link: #/ re-routes to the // homepage inside the SPA instead of a full page reload. nameLink: '#/', @@ -1072,16 +1238,35 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { // The homepage is rootPath-relative; teams whose layer has a real // landing page point at it instead of the seeded overview. - homepage: effectiveHomepage(root, manifest, findingsEarly), - themeColor: resolveThemeColor(manifest, findingsEarly), + homepage, + themeColor, + // Mermaid node text, readable against the accent. Computed here because + // this side resolves every accepted color form; the boot script's own + // hex-only fallback covers viewer trees generated before this field. + // Leji's own key, not one Docsify reads, hence the prefix. + lejiMermaidTextColor: mermaidTextColor(themeColor), // Read by the boot script's powered-by plugin; false removes the mark. lejiPoweredBy: manifest.viewer?.poweredBy !== false, }), MERMAID_SCRIPTS: mermaidScripts, }; - const html = fs + return fs .readFileSync(path.join(templatesDir(), 'viewer', 'index.html'), 'utf8') .replace(/\{\{([A-Z_]+)\}\}/g, (whole, key: string) => substitutions[key] ?? whole); +} + +export function generateViewer(root: string, manifest: Manifest): 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 }; + } + const entries = result.index?.entries ?? []; + const findingsEarly: Finding[] = []; + + // The served flavor: the chrome under `.leji/viewer/` is never export-flavored. + const html = buildIndexHtml(root, manifest, '/', findingsEarly); const sidebar = assembleSidebar(root, manifest, entries, findingsEarly); const rootDir = stripSlash(manifest.rootPath) || '.'; @@ -1089,55 +1274,123 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { const findings: Finding[] = [...result.findings, ...findingsEarly]; const written: string[] = []; - // Refuse to write through a symlink escaping the layer root. resolvedWithinRoot - // resolves the nearest existing ancestor, so a not-yet-existing target under a - // symlinked directory is caught before mkdir/write can escape. - const writeWithin = (rel: string, content: string | Buffer): void => { - const abs = path.join(root, rel); - if (!resolvedWithinRoot(rootAbs, abs)) { - findings.push(finding('artifact-parse', 'error', `viewer path ${rel} resolves outside the layer root`, rel)); + // Check-before-act: the generation target — the `.leji/viewer/` role — is + // realpath-resolved and validated BEFORE a single byte is written. A `.leji/viewer` + // that resolves into a DIFFERENT private role (`.leji/work/`, `.leji/mounts/`, a + // future role), or out of the repository altogether, is refused here, so a + // symlinked viewer can never be written through into the trust domain or out of the + // tree; only its own directory passes. Unresolvable (permission/I/O error, not mere + // absence) fails the check rather than being rebuilt lexically. + const resolvedRoot = resolvedPath(rootAbs) ?? rootAbs; + const viewerTarget = resolvedPath(path.join(resolvedRoot, VIEWER_REL)); + const verdict = viewerTarget === null ? null : writableTarget(resolvedRoot, viewerTarget, VIEWER_REL); + if (viewerTarget === null || verdict === null || !verdict.ok) { + findings.push( + finding( + 'viewer-target-refused', + 'error', + viewerTarget === null + ? `refusing to generate the viewer: ${VIEWER_REL}/ cannot be resolved (permission or I/O error); remove the symlink` + : verdict!.outsideRoot === true + ? `refusing to generate the viewer: ${VIEWER_REL}/ resolves outside the repository; remove the symlink` + : `refusing to generate the viewer: ${VIEWER_REL}/ resolves into ${LEJI_DIR}/${verdict!.role} (private); remove the symlink`, + VIEWER_REL, + ), + ); + return { written, findings, entries: 0 }; + } + + // Every `.leji/viewer/` write goes back through the chokepoint with the viewer's + // own role, so each file is judged on its RESOLVED path immediately before it is + // written and lands there: the role was validated as a whole above, and this keeps + // a symlink planted inside the tree from redirecting a single file elsewhere. + const writeViewerFile = (rel: string, content: string | Buffer): void => { + const verdict = writeFileGuarded(resolvedRoot, path.join(root, rel), VIEWER_REL, content); + if (!verdict.ok) { + findings.push(finding('artifact-parse', 'error', `viewer path ${rel} resolves outside ${VIEWER_REL}/`, rel)); return; } - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, content); written.push(rel); }; - // Contained under rootPath/.leji/viewer/ (gitignored) so it never collides with - // the user's own files in the context root. - const viewerDir = rootDir === '.' ? '.leji/viewer' : `${rootDir}/.leji/viewer`; + // The chrome's role in the unified root `.leji/` (gitignored): outside the + // context root whatever rootPath is, so it never collides with the user's own + // files and never rides a content walk. + const viewerDir = VIEWER_REL; for (const [name, content] of [ ['index.html', html], ['_sidebar.md', sidebar], ] as const) { - writeWithin(`${viewerDir}/${name}`, content); + writeViewerFile(`${viewerDir}/${name}`, content); } // 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'); const assetsRel = `${viewerDir}/assets`; + // Mermaid off omits its two scripts from the page and their assets here (~3MB). + const mermaidEnabled = manifest.viewer?.mermaid !== false; for (const asset of fs.readdirSync(assetsSrc).sort()) { if (asset === 'PROVENANCE.txt' || asset.startsWith('.')) continue; if (!mermaidEnabled && MERMAID_ASSETS.has(asset)) continue; const bytes = fs.readFileSync(path.join(assetsSrc, asset)); - writeWithin(`${assetsRel}/${asset}`, bytes); + writeViewerFile(`${assetsRel}/${asset}`, bytes); } // 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. + // + // 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`; const overviewAbs = path.join(root, overviewRel); - if (!isFile(overviewAbs)) { - writeWithin(overviewRel, buildOverviewSeed(manifest, entries)); - } else if (resolvedWithinRoot(rootAbs, overviewAbs)) { - const existing = readText(overviewAbs); + const overviewResolved = resolvedPath(overviewAbs); + const overviewVerdict = + overviewResolved !== null && resolvedWithinRoot(rootAbs, overviewAbs) + ? writableTarget(resolvedRoot, overviewResolved, null) + : null; + const overviewRead = verifiedTargetRead(resolvedRoot, overviewAbs, null); + if (overviewVerdict === null) { + findings.push(finding('artifact-parse', 'error', `overview.md resolves outside the layer root`, overviewRel)); + } else if (!overviewVerdict.ok) { + findings.push( + finding( + 'viewer-target-refused', + 'error', + `refusing to write overview.md: it resolves into ${LEJI_DIR}/${overviewVerdict.role} (private); remove the symlink`, + overviewRel, + ), + ); + } 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. + findings.push( + finding( + 'viewer-target-refused', + 'error', + `refusing to write overview.md: it does not resolve to a regular file inside the repository; remove the symlink`, + overviewRel, + ), + ); + } else if (overviewRead.status === 'absent') { + const seeded = writeFileGuarded(resolvedRoot, overviewAbs, null, buildOverviewSeed(manifest, entries)); + 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) fs.writeFileSync(overviewAbs, updated); + if (updated !== existing) { + writeFileGuarded(resolvedRoot, overviewAbs, null, updated); + } } else { findings.push( finding( @@ -1154,15 +1407,11 @@ export function generateViewer(root: string, manifest: Manifest): ViewerResult { // gitignored viewer dir under a reserved underscore name (collision-free with the // user's own files) and served via a dedicated content route (never a committed // file at the context root, so no diff churn). Regenerated every run; pinned. - writeWithin(`${viewerDir}/_manifest.md`, buildManifestPage(manifest, mountStatus(root, manifest))); + writeViewerFile(`${viewerDir}/_manifest.md`, buildManifestPage(manifest, mountStatus(root, manifest))); return { written, findings, entries: entries.length }; } -/** Protect-your-context warning shown by `leji viewer build` and embedded in the exported index.html. */ -export const PROTECT_WARNING = - 'This is your context layer (identity, invariants, decisions, sometimes sensitive internal knowledge). Host the exported folder behind internal authentication, not a public or shared bucket where it could be indexed or leaked. Active file types (.htm, .html, .js, .mjs, .xhtml) are left out of the exported content: a static host would serve them as same-origin documents that execute with no policy.'; - /** * Extensions a browser would run as an active, same-origin document. Under * `/content/` they are served as text/plain instead of their active type, and @@ -1172,441 +1421,11 @@ export const PROTECT_WARNING = * `.svg` is deliberately NOT here. It stays a first-class asset (viewer.logo and * viewer.favicon may point at one under the context root) because the inertness * comes from the policy, not the content type: every /content/ response carries - * the sandbox CSP below, so an SVG navigated to or framed lands in an opaque - * origin with scripting off, and an SVG loaded as an never runs script - * whatever its type. - */ -const ACTIVE_EXTENSIONS = new Set(['.html', '.htm', '.js', '.mjs', '.xhtml']); - -/** The SPA shell's policy, sent as a response header on every chrome response so - * it holds for documents reached outside the shell too. Mirrors the meta in - * templates/viewer/index.html; keep the two in step. */ -const CSP_CHROME = - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; frame-src 'none'"; - -/** The policy for everything served out of the layer itself. `sandbox` with no - * tokens puts a /content/ document in an opaque origin with scripting off, so a - * governed file framed or opened directly is inert rather than same-origin code. */ -const CSP_CONTENT = "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; sandbox"; - -/** The host names the local preview answers to. A missing Host is accepted (an - * HTTP/1.0 client omits it); anything else is a request that reached the loopback - * socket under somebody else's name. */ -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); - -/** True when the Host header names the loopback interface: hostname only, since - * the port a request arrives on is already fixed by the loopback bind. */ -export function loopbackHost(host: string | undefined): boolean { - if (host === undefined || host === '') return true; - const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0]; - return LOOPBACK_HOSTS.has(name.toLowerCase()); -} - -/** The first bytes `viewer build` writes into an exported index.html. A target - * directory carrying this marker is a previous export and may be cleared; any - * other non-empty directory is somebody's content and is never removed. */ -const EXPORT_MARKER = '\n${indexHtml}`, - ); - - return { out: outDisplay, findings: gen.findings }; -} - -const CONTENT_TYPES: Record = { - '.html': 'text/html; charset=utf-8', - '.md': 'text/markdown; 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', - '.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', -}; - -/** - * A request URL path as a clean relative route key. Separators fold to `/` and - * the path is cleaned against a root, so one request has one route key on any - * platform — `path.normalize` follows the host and answered differently on - * Windows, missing every `content/` route test. Canonicalization only; the mount - * enforces containment. - */ -export function urlPathToRel(urlPath: string): string { - const cleaned = path.posix.normalize('/' + urlPath.replaceAll('\\', '/')); - // Trimmed by index rather than by regex. `normalize` has already collapsed every - // run of separators, so an anchored `/\/+$/` could only ever match one character - // here, but that is an invariant of another function: a linear scan holds on its - // own and does not read as a polynomial regex over a request path. - let start = 0; - let end = cleaned.length; - while (start < end && cleaned.charCodeAt(start) === 47) start++; - while (end > start && cleaned.charCodeAt(end - 1) === 47) end--; - return cleaned.slice(start, end); -} - -/** - * Serve the viewer at the web root, bound to 127.0.0.1 (local preview, never - * hosting). Virtual mounts, no symlinks: chrome (rootPath/.leji/viewer/) at `/`, - * layer markdown (rootPath/) under `/content/`; `/content/_sidebar.md` maps to the - * generated sidebar in viewer/. The internal .leji path is reachable only through - * these mounts, never by direct URL. Returns the listening server; port 0 picks free. - */ -export function serveViewer( - root: string, - port: number, - rootRel = '', - opts: { log?: (line: string) => void } = {}, -): Promise { - const rootAbs = fs.realpathSync(path.resolve(root)); - const base = stripSlash(rootRel); - const contentAbs = base && base !== '.' ? path.join(rootAbs, base) : rootAbs; - // The CLI passes a validated rootPath, but a direct SDK caller could pass an - // escaping rootRel (e.g. ".."); refuse to mount content outside the layer root. - if (!resolvedWithinRoot(rootAbs, contentAbs)) { - throw new Error(`viewer root "${rootRel}" escapes the layer root`); - } - const viewerAbs = path.join(contentAbs, '.leji', 'viewer'); - - // 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. - // `inert` marks the layer's own content mount, whose files are never given an - // active content type however they are named. - const serveFrom = (res: http.ServerResponse, mountRoot: string, sub: string, inert = false): void => { - let abs = sub === '' ? path.join(mountRoot, 'index.html') : path.join(mountRoot, sub); - if (abs !== mountRoot && !abs.startsWith(mountRoot + path.sep)) { - res.writeHead(403).end('forbidden'); - return; - } - try { - if (fs.statSync(abs).isDirectory()) abs = path.join(abs, 'index.html'); - const real = fs.realpathSync(abs); - if (real !== mountRoot && !real.startsWith(mountRoot + path.sep)) { - res.writeHead(403).end('forbidden'); - return; - } - const body = fs.readFileSync(abs); - const ext = path.extname(abs).toLowerCase(); - res.writeHead(200, { - 'content-type': - inert && ACTIVE_EXTENSIONS.has(ext) - ? 'text/plain; charset=utf-8' - : (CONTENT_TYPES[ext] ?? 'application/octet-stream'), - }); - res.end(body); - } catch { - res.writeHead(404).end('not found'); - } - }; - - // 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 - // 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; - const treeFingerprint = (): string => { - const parts: string[] = []; - const add = (rel: string): void => { - try { - const st = fs.statSync(path.join(rootAbs, rel)); - parts.push(`${rel}\u0000${st.mtimeMs}\u0000${st.size}`); - } catch { - parts.push(`${rel}\u0000gone`); - } - }; - add('leji.json'); - for (const rel of walkTree(rootAbs, base || '.')) add(rel); - return parts.join('\n'); - }; - - const server = http.createServer((req, res) => { - // Access log: one terse line per request, after the status is known. - if (opts.log) { - res.on('finish', () => opts.log!(`${req.method ?? 'GET'} ${req.url ?? '/'} ${res.statusCode}`)); - } - // Policy headers ride every response, not just the SPA shell: a document - // served straight out of /content/ is same-origin and would otherwise run - // with no policy at all. Set before any write; the content mount downgrades - // to the inert policy once the route is known. - res.setHeader('x-content-type-options', 'nosniff'); - res.setHeader('content-security-policy', CSP_CHROME); - // Loopback binding alone does not stop DNS rebinding: a hostile page whose - // name resolves to 127.0.0.1 reaches this server with its own Host. Only the - // loopback names the viewer is actually addressed by are answered. The port is - // deliberately not part of the test: a rebound request carries the right port - // anyway, so matching it adds nothing. Don't "fix" this by checking it. - if (!loopbackHost(req.headers.host)) { - res.writeHead(403).end('forbidden'); - return; - } - let urlPath: string; - try { - // Concatenated, not resolved against a base: a target beginning with "//" - // parses as protocol-relative, which moves its first segment into the host - // and loses it. A malformed percent-encoding (e.g. GET /%E0%A4%A) throws - // URIError; answer 400 rather than letting it crash the server. - urlPath = decodeURIComponent(new URL('http://localhost' + (req.url ?? '/')).pathname); - } catch { - res.writeHead(400).end('bad request'); - return; - } - const rel = urlPathToRel(urlPath); - if (rel === 'content' || rel.startsWith('content/')) res.setHeader('content-security-policy', CSP_CONTENT); - // Refuse any dotfile or VCS-internal segment in the request path: the .leji - // viewer dir is reached only through the mounts below, never by direct URL. - if (rel.split(/[/\\]/).some((seg) => seg === '.git' || (seg.startsWith('.') && seg !== '.' && seg !== ''))) { - res.writeHead(404).end('not found'); - return; - } - // The generated sidebar lives in the viewer dir but is served as if at the - // content root, so Docsify's basePath /content/ + _sidebar alias resolves it. - // Docsify fetches it once per page load, so it is rebuilt from the live tree - // on every request: a long-running server never shows a deleted or moved - // 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 - } - serveFrom(res, viewerAbs, '_sidebar.md'); - return; - } - // The stored context index is served live (same fingerprint cache as the - // sidebar), so per-page classification badges never disagree with the tree. - if (rel.startsWith('content/')) { - try { - const { manifest } = loadManifest(rootAbs); - 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, - }; - } - } - if (sidebarCache !== null && sidebarCache.key === key && sidebarCache.indexJson !== null) { - res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); - res.end(sidebarCache.indexJson); - return; - } - } - } catch { - // fall through to the stored artifact - } - } - // The generated Manifest page lives in the viewer dir (gitignored chrome) but - // is linked from the sidebar and fetched under the content root, like - // _sidebar.md. Reserved underscore name; served from the last generation. - if (rel === 'content/_manifest.md') { - serveFrom(res, viewerAbs, '_manifest.md'); - 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 - // on disk is one half, and presenting it as the effective profile is the - // thing a consumer must not do. So this branch fails closed. If anything - // at all goes wrong, a file that declares `inherits` still gets a findings - // page; only a file that is not half a profile falls through to disk. - if (sub.endsWith('.md')) { - const repoRel = base && base !== '.' ? `${base}/${sub}` : sub; - let page: string | null = null; - try { - const { manifest } = loadManifest(rootAbs); - page = manifest === null ? null : resolvedProfilePage(rootAbs, manifest, repoRel); - if (page === null && manifest === null && declaresInherits(rootAbs, repoRel)) { - page = unresolvedProfilePage(repoRel, [ - finding('artifact-parse', 'error', 'the layer manifest could not be read', 'leji.json'), - ]); - } - } catch (e) { - page = declaresInherits(rootAbs, repoRel) - ? unresolvedProfilePage(repoRel, [ - finding( - 'artifact-parse', - 'error', - `the viewer could not resolve this profile: ${(e as Error).message}`, - repoRel, - ), - ]) - : null; - } - if (page !== null) { - res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); - res.end(page); - return; - } - } - serveFrom(res, contentAbs, sub, true); - return; - } - // Everything else (`/`, /index.html, /assets/*) is viewer chrome. - serveFrom(res, viewerAbs, rel); - }); - return new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(port, '127.0.0.1', () => resolve(server)); - }); -} - -/** - * Best-effort open of `url` in the default browser (`--open` / `leji view`). Never - * throws or blocks; a missing opener is a silent no-op. + * the sandbox CSP the serve module sets, so an SVG navigated to or framed lands + * in an opaque origin with scripting off, and an SVG loaded as an never + * runs script whatever its type. + * + * Shared by the two consumers of this module: the serve path types these files + * inert, and the export leaves them out of the tree entirely. */ -export function openBrowser(url: string): void { - const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; - const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; - try { - const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); - child.on('error', () => {}); - child.unref(); - } catch { - /* opening the browser is best-effort */ - } -} +export const ACTIVE_EXTENSIONS = new Set(['.html', '.htm', '.js', '.mjs', '.xhtml']); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cce426e..8efe00a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,21 +1,18 @@ import { type Finding, finding, hasErrors, sortFindings, summarize } from './lib/findings.js'; -import { stripSlash } from './lib/fsx.js'; +import { DIST_REL, VIEWER_REL } from './lib/layout.js'; import { effectiveChangelogPath, effectiveIndexPath, loadManifest } from './lib/manifest.js'; -import { type CliOption, SDK_VERSION, SUPPORTED_LINES, loadCliSpec } from './lib/schemas.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'; import { checkIndex, generateIndex, writeIndex } from './commands/indexgen.js'; import { checkChangelogAppendOnly, validateLayer } from './commands/validate.js'; import { compactChangelog, seedChangelogIfMissing } from './commands/changelog.js'; import { conformanceReport, renderExplain } from './commands/conformance.js'; -import { - PROTECT_WARNING, - buildViewer, - generateViewer, - openBrowser, - resolveViewerPort, - serveViewer, -} from './commands/viewer.js'; +import { type BadgeResult, DEFAULT_BADGE_OUT, badgeLabel, badgeRun } from './commands/badge.js'; +import { type BuildResult, PROTECT_WARNING, buildViewer } from './commands/export.js'; +import { generateViewer, resolveViewerPort } from './commands/viewer.js'; +import { openBrowser, serveViewer } from './commands/serve.js'; import { freshnessReport } from './commands/freshness.js'; -import { statusReport } from './commands/status.js'; +import { statusReport, unindexedPaths } from './commands/status.js'; import { addAgent, adoptLayer, @@ -31,16 +28,41 @@ import { ensureApprovalGuard, ensureLocalHook, offerApprovalGuard, + offerDependency, + dependencyAddFailed, offerMcpInstall, initLayer, + bootProfileReady, + defaultHandoffIo, + resolveStartHost, } from './commands/init.js'; +import { + checkDocument, + colorDecision, + offerPreflightFixes, + renderPreflight, + runPreflight, +} from './commands/preflight.js'; import { detectLayer, renderDetect } from './commands/detect.js'; import { detectHosts } from './lib/detect.js'; +import { + type EcosystemReport, + detectEcosystem, + renderEcosystemBlock, + renderEcosystemLine, + runnerArgv, +} from './lib/ecosystem.js'; import { gitOriginUrl } from './lib/git.js'; import { renderWritePlan } from './lib/writeplan.js'; import { CATEGORY_IDS, type CategoryId } from './lib/manifest.js'; import { route } from './lib/route.js'; import { federationEnforcement, hydrateMounts, locateMount, mountStatus } from './lib/mounts.js'; +import { + type UpdatePinResult, + MOUNT_UPDATE_PIN_REASONS, + shortOid, + updatePinRun, +} from './commands/mounts-update-pin.js'; export { validateLayer } from './commands/validate.js'; export { checkIndex, generateIndex, writeIndex } from './commands/indexgen.js'; @@ -49,15 +71,17 @@ export { compactChangelog, seedChangelogIfMissing, serializeChangelog } from './ export { conformanceReport, renderExplain } from './commands/conformance.js'; export { buildSidebar, - buildViewer, buildLayerMap, buildManifestPage, generateViewer, resolveViewerPort, resolvedProfilePage, - serveViewer, - urlPathToRel, } from './commands/viewer.js'; +export { badgeLabel, badgeMarkdown, badgeRun, renderBadge, DEFAULT_BADGE_OUT, OUT_RULE } from './commands/badge.js'; +export { buildViewer } from './commands/export.js'; +export { MOUNT_UPDATE_PIN_REASONS, updatePinRun } from './commands/mounts-update-pin.js'; +export type { UpdatePinAction, UpdatePinResult, UpdatePinOptions } from './commands/mounts-update-pin.js'; +export { serveViewer, urlPathToRel } from './commands/serve.js'; export { profileInheritanceFindings, resolveAgentProfile, scanProfileSet } from './lib/layer.js'; export type { ResolvedProfile, ScannedProfile } from './lib/layer.js'; export { freshnessReport } from './commands/freshness.js'; @@ -90,8 +114,39 @@ export type { RoutedMount, DecisionMatch, } from './lib/route.js'; -export { detectHosts, resolveHostId, adapterContent, HOST_SPECS } from './lib/detect.js'; -export { loadManifest, validateManifestObject } from './lib/manifest.js'; +export { + detectHosts, + resolveHostId, + adapterContent, + HOST_SPECS, + MCP_JSON_CONFIG, + mcpCommand, + mcpJsonConfig, +} from './lib/detect.js'; +export { bootProfileReady, hookStatus, resolveStartHost, startHosts } from './commands/init.js'; +export type { HookOwnership, HookState, HookStatus } from './commands/init.js'; +export { offerPreflightFixes, renderPreflight, runPreflight } from './commands/preflight.js'; +export type { + Check, + CheckId, + CheckStatus, + PreflightOptions, + PreflightOfferOptions, + PreflightResult, +} from './commands/preflight.js'; +export { dependencyAddFailed, offerDependency } from './commands/init.js'; +export type { DependencyOffer, DependencyOfferOptions } from './commands/init.js'; +export { detectEcosystem, renderEcosystemBlock, renderEcosystemLine, runnerArgv } from './lib/ecosystem.js'; +export type { + EcoCandidate, + EcoReason, + EcoResult, + EcoSource, + EcoStatus, + EcosystemId, + EcosystemReport, +} from './lib/ecosystem.js'; +export { loadManifest, replaceMountPinInManifestText, validateManifestObject } from './lib/manifest.js'; export { SDK_VERSION, SUPPORTED_LINES, loadCliSpec } from './lib/schemas.js'; export type { Finding, Severity } from './lib/findings.js'; export type { Manifest, ManifestLoad, ConformanceLevel, CategoryId } from './lib/manifest.js'; @@ -99,6 +154,7 @@ export type { CliSpec, CliOption } from './lib/schemas.js'; export type { ContextIndex, IndexEntry } from './commands/indexgen.js'; export type { CompactOptions, CompactResult } from './commands/changelog.js'; export type { ConformanceResult, ChecklistItem } from './commands/conformance.js'; +export type { BadgeAction, BadgeResult } from './commands/badge.js'; export type { FreshnessReport } from './commands/freshness.js'; export type { StatusReport, DanglingEntry } from './commands/status.js'; export type { @@ -115,43 +171,76 @@ export type { StartOutcome, } from './commands/init.js'; -/** Top-level help, generated from cli.json so it can't drift. Lists commands and - * global options only; per-command options live in `leji --help`. */ -export function renderUsage(): string { - const spec = loadCliSpec(); +/** Top-level help, generated from cli.json so it can't drift: the commands by group, + * the global options, and the exit codes. Per-command options live in + * `leji --help`. */ +export function renderUsage(spec: CliSpec = loadCliSpec()): string { const out: string[] = [ - `leji ${SDK_VERSION}: reference CLI for the Leji specification (spec line ${SUPPORTED_LINES.join(', ')})`, + // Every emitted field goes through the wrapper, including the ones no current + // value is long enough to overflow: a longer version string or group title must + // not be what discovers that a line was never wrapped. + ...wrap( + `leji ${SDK_VERSION}: reference CLI for the Leji specification (spec line ${SUPPORTED_LINES.join(', ')})`, + HELP_WIDTH, + 0, + 3, + ), '', - `Usage: ${spec.usage}`, - '', - 'Commands:', + ...wrap(`Usage: ${spec.usage}`, HELP_WIDTH, 0, 7), ]; - const cmdWidth = Math.max(...spec.commands.map((c) => c.name.length)) + 3; - for (const c of spec.commands) out.push(` ${c.name.padEnd(cmdWidth)}${c.summary}`); - const optWidth = Math.max(...spec.globalOptions.map((o) => o.flags.length)) + 3; + // One name column across every group, so the summaries line up down the whole + // list rather than jumping per section. + const cmdCol = nameColumn(spec.commands.map((c) => c.name)); + const primaries = spec.commands.filter((c) => !c.aliasOf); + const aliasesOf = (name: string) => spec.commands.filter((c) => c.aliasOf === name); + for (const g of spec.groups) { + out.push('', ...wrap(`${g.title}:`, HELP_WIDTH, 0, 0)); + for (const c of primaries.filter((c) => c.group === g.id)) { + out.push(...helpRow(c.name, cmdCol, c.summary)); + // An alias earns a line under its primary, not a row of its own: it is the + // same command, and repeating the summary reads as a second one. It keeps the + // name column, so the right-hand column stays straight down the whole list. + for (const a of aliasesOf(c.name)) out.push(...helpRow(a.name, cmdCol, `(alias of ${c.name})`)); + } + } + + const optCol = optionColumn(spec.globalOptions.map((o) => o.flags)); out.push('', 'Options:'); - for (const o of spec.globalOptions) out.push(` ${o.flags.padEnd(optWidth)}${o.summary}`); + for (const o of spec.globalOptions) out.push(...helpRow(o.flags, optCol, o.summary)); + + // The meaning hangs under itself, like every other two-column block here, so a + // continuation line is never mistaken for another code. + const codeCol = exitCodeColumn(spec.exitCodes.map((e) => String(e.code))); + out.push('', 'Exit codes:'); + for (const e of spec.exitCodes) out.push(...helpRow(String(e.code), codeCol, e.meaning)); out.push('', 'Run `leji --help` for a command and its options.', 'Full reference: https://leji.org/cli/'); return out.join('\n'); } -/** Per-command help, generated from cli.json. Returns null for an unknown command - * so the caller falls back to top-level usage. */ -export function renderCommandHelp(name: string): string | null { - const spec = loadCliSpec(); - const cmd = spec.commands.find((c) => c.name === name); +/** Per-command help, generated from cli.json: this command's own options only, with + * the globals one pointer away. Returns null for an unknown command so the caller + * falls back to top-level usage. */ +export function renderCommandHelp(name: string, spec: CliSpec = loadCliSpec()): string | null { + const cmd: CliCommand | undefined = spec.commands.find((c) => c.name === name); if (!cmd) return null; - const out: string[] = [`leji ${cmd.name}: ${cmd.summary}`, '', `Usage: ${cmd.usage}`, '', cmd.description]; + const out: string[] = [ + ...wrap(`leji ${cmd.name}: ${cmd.summary}`, HELP_WIDTH, 0, 3), + '', + ...wrap(`Usage: ${cmd.usage}`, HELP_WIDTH, 0, 7), + ]; + for (const para of cmd.description.split(/\n[ \t]*\n/)) out.push('', ...wrap(para, HELP_WIDTH, 0, 0)); if (cmd.details && cmd.details.length > 0) { out.push('', 'Details:'); - for (const d of cmd.details) out.push(` - ${d}`); + for (const d of cmd.details) out.push(...wrap(`- ${d}`, HELP_WIDTH, 3, 5)); } - const opts: CliOption[] = [...spec.globalOptions, ...cmd.options]; - const optWidth = Math.max(...opts.map((o) => o.flags.length)) + 3; - out.push('', 'Options:'); - for (const o of opts) out.push(` ${o.flags.padEnd(optWidth)}${o.summary}`); + if (cmd.options.length > 0) { + const optCol = optionColumn(cmd.options.map((o) => o.flags)); + out.push('', 'Options:'); + for (const o of cmd.options) out.push(...helpRow(o.flags, optCol, o.summary)); + } + out.push('', 'Global options: see leji --help.'); if (cmd.examples && cmd.examples.length > 0) { out.push('', 'Examples:'); for (const e of cmd.examples) out.push(` ${e}`); @@ -176,6 +265,7 @@ interface Flags { hooks: boolean; explain: boolean; fetch: boolean; + allowNonFastForward: boolean; checkIntegrity: boolean; help: boolean; version: boolean; @@ -198,6 +288,7 @@ interface Flags { topics?: string[]; asOf?: string; federation?: string; + to?: string; } /** A following token that is itself a flag (not a bare "-") cannot be a flag's @@ -240,10 +331,42 @@ function expandEqualsFlags(argv: string[]): string[] { return out; } +/** + * The repository root this argv lands on, decided ONCE and used by everything that + * has to agree about it: the parse below, and the installed executable's hand-off to + * a repository's own pinned CLI, which must select the same repository the command + * would then operate on. Same scan as the parse (`--flag=value` expanded, every + * declared value flag consuming its own value, the literal `--` ending our flags), + * so `--root` is read from the same token stream rather than from a second reading + * of it. Last `--root` wins; the default is the current directory. + * + * Null when the scan cannot tell: a value flag with no value, or one whose value is + * itself a flag, is the usage error `parseFlags` reports, and a root guessed out of + * a malformed command line is exactly the wrong thing to hand an invocation to. + */ +export function effectiveRoot(argv: string[]): string | null { + const expanded = expandEqualsFlags(argv); + let root = '.'; + for (let i = 0; i < expanded.length; i++) { + const arg = expanded[i]; + if (arg === '--') break; // host pass-through: never our flags + if (!VALUE_FLAGS.has(arg)) continue; + const value = expanded[++i]; + if (value === undefined || isFlagToken(value)) return null; + if (arg !== '--root') continue; + if (value === '') return null; // `--root ""` is the usage error, not a root + root = value; + } + return root; +} + function parseFlags(argv: string[]): { flags: Flags; rest: string[]; error?: string } { + // The root comes from the shared scan, never from a second derivation here: the + // wrapper that may hand this invocation to another CLI reads the same one. + const root = effectiveRoot(argv) ?? '.'; argv = expandEqualsFlags(argv); const flags: Flags = { - root: '.', + root, json: false, check: false, strict: false, @@ -256,6 +379,7 @@ function parseFlags(argv: string[]): { flags: Flags; rest: string[]; error?: str hooks: false, explain: false, fetch: false, + allowNonFastForward: false, checkIntegrity: false, help: false, version: false, @@ -278,9 +402,10 @@ function parseFlags(argv: string[]): { flags: Flags; rest: string[]; error?: str break; } case '--root': { + // The value is validated here; the root itself was decided by + // `effectiveRoot` above, so the two can never disagree. const v = argv[++i]; if (!v || isFlagToken(v)) return { flags, rest, error: '--root requires a value' }; - flags.root = v; break; } case '--dir': { @@ -388,6 +513,20 @@ function parseFlags(argv: string[]): { flags: Flags; rest: string[]; error?: str case '--fetch': flags.fetch = true; break; + case '--allow-non-fast-forward': + flags.allowNonFastForward = true; + break; + case '--to': { + const v = argv[++i]; + if (!v || isFlagToken(v)) return { flags, rest, error: '--to requires a value' }; + // The schema's own pin shape: a full commit id, never an abbreviation + // and never a revision expression, so all three SDKs accept one spelling. + if (!/^[0-9a-f]{40}$/.test(v) && !/^[0-9a-f]{64}$/.test(v)) { + return { flags, rest, error: '--to must be a full 40- or 64-character lowercase hex commit id' }; + } + flags.to = v; + break; + } case '--federation': { const v = argv[++i]; if (!v || isFlagToken(v)) return { flags, rest, error: '--federation requires a value' }; @@ -473,6 +612,7 @@ const VALUE_FLAGS = new Set([ '--topics', '--as-of', '--federation', + '--to', ]); function flagTokens(flagsStr: string): string[] { @@ -521,7 +661,8 @@ function allowedFlagsFor(command: string, sub: string | undefined): Set function printFindings(findings: Finding[]): void { for (const f of sortFindings(findings)) { - const where = f.path ? ` ${f.path}` : ''; + // 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}`); } } @@ -607,6 +748,32 @@ function isCalendarDate(v: string): boolean { return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v; } +/** + * The one `--json` document `init` and `adopt` emit: a single object, like every + * other command's, carrying what the run wrote and the repository's dependency + * ecosystem. `--json` is non-interactive by construction, so nothing here can have + * prompted or run a package manager; the report is what a consumer acts on. + */ +function emitScaffold( + command: 'init' | 'adopt', + findings: Finding[], + written: string[], + ecosystem: EcosystemReport, + dryRun = false, +): number { + const sorted = sortFindings(findings); + const summary = summarize(sorted); + const ok = summary.errors === 0; + console.log( + JSON.stringify( + { command, ok, findings: sorted, summary, ...(dryRun ? { dryRun } : {}), written, ecosystem }, + null, + 2, + ), + ); + return ok ? 0 : 1; +} + function reportScaffoldIndex(findings: Finding[]): number { if (!hasErrors(findings)) return 0; console.log(''); @@ -618,6 +785,191 @@ function reportScaffoldIndex(findings: Finding[]): number { return 1; } +/** + * The `index` generate run's closing nudge. Byte-identical in all three SDKs and + * quiet at zero: a layer with nothing unindexed says nothing. + */ +function printUnindexedNudge(count: number): void { + if (count <= 0) return; + console.log(`${count} file(s) unindexed: add to a category index or leave as reference deliberately`); +} + +/** + * 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 by + * construction — same default output, same JSON document, same exits. + * + * 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 (thrown, and rendered by the caller's catch). + */ +function runExport(flags: Flags): 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 })); +} + +/** The one export report, for every outcome the pipeline can reach. */ +function reportExport(flags: Flags, r: BuildResult): number { + const sorted = sortFindings(r.findings); + if (flags.json) { + // The canonical JSON document for this command, under either name. + console.log( + JSON.stringify( + { command: 'export', ok: r.wrote, out: r.out, findings: sorted, warning: PROTECT_WARNING }, + null, + 2, + ), + ); + return r.wrote ? 0 : 1; + } + if (!r.wrote) { + const s = summarize(sorted); + printFindings(sorted); + console.log( + `failed (${s.errors} error${s.errors === 1 ? '' : 's'}, ${s.warnings} warning${s.warnings === 1 ? '' : 's'}${flags.strict ? '; strict, nothing written' : ''})`, + ); + return 1; + } + // Human mode says where the export went and repeats the protect-your-context + // warning, which is the part a person must act on before hosting it. + console.log(`Exported the static viewer to ${r.out}/`); + console.log(`\n${PROTECT_WARNING}`); + return 0; +} + +/** + * The one `leji badge` report, for every outcome the command can reach. The JSON + * document is the shared `emit()` shape plus the badge's own fields, emitted under + * success and refusal alike so a consumer parses one document; the human channel + * says what was written and hands over the markdown line to paste. + * + * Exits: `0` the badge is written or already current, `1` a conformance error + * finding or nothing machine-verified in this run, `2` a `--out` usage error + * (rendered by the caller, with no level reported) or a refusal to overwrite a file + * that is not a badge of this contract. + */ +function reportBadge(flags: Flags, r: BadgeResult): number { + const findings = sortFindings(r.findings); + const summary = summarize(findings); + const ok = summary.errors === 0; + const code = r.refusal !== undefined ? 2 : ok ? 0 : 1; + if (flags.json) { + console.log( + JSON.stringify( + { + command: 'badge', + ok, + findings, + summary, + out: r.out, + level: r.level, + claimedLevel: r.claimedLevel, + verifiedLevel: r.verifiedLevel, + markdown: r.markdown, + action: r.action, + }, + null, + 2, + ), + ); + if (r.refusal !== undefined) console.error(`leji: ${r.refusal}`); + return code; + } + if (r.refusal !== undefined) { + console.error(`leji: ${r.refusal}`); + return 2; + } + if (!ok) { + printFindings(findings); + console.log('Run leji conformance --explain.'); + return 1; + } + const verb = r.action === 'wrote' ? 'Wrote' : r.action === 'overwrote' ? 'Overwrote' : 'Unchanged'; + console.log(`${verb} ${r.out}: ${badgeLabel(r.level!)}`); + // The badge states what this run verified, so a claim it did not reach is said + // out loud rather than quietly dropped. + if (r.claimedLevel !== null && r.claimedLevel !== r.verifiedLevel) { + console.log( + `Claimed ${r.claimedLevel}; this offline run verified ${r.verifiedLevel} (leji conformance --federation=verify checks the claim).`, + ); + } + console.log('\nAdd it to your README (paths are relative to the repository root):\n'); + console.log(r.markdown!.trimEnd()); + return 0; +} + +/** + * Render one `mounts update-pin` run. The comparison is shown first, then what the + * run did with it, then the follow-up act this command deliberately does not + * perform. Every string is Leji-authored: git's stderr never reaches output. + */ +function reportUpdatePin(flags: Flags, r: UpdatePinResult): number { + // An internal refusal after validation carries no document at all: there is no + // outcome to report, only the act this run would not perform. + if (r.writeError !== undefined) { + console.error(`leji: ${r.writeError}`); + return 2; + } + const findings = sortFindings(r.findings); + const summary = summarize(findings); + const ok = summary.errors === 0; + if (flags.json) { + console.log( + JSON.stringify( + { + command: 'mounts update-pin', + ok, + findings, + summary, + mount: r.mount, + pinReport: r.pinReport, + action: r.action, + override: r.override, + ...(r.reason === undefined ? {} : { reason: r.reason }), + }, + null, + 2, + ), + ); + return ok ? 0 : 1; + } + const rep = r.pinReport; + if (rep !== null && rep.state !== 'unknown' && r.mount.to !== null && r.mount.from !== null) { + // Offline, the witness is the last one successfully observed — never a claim + // that the source was looked at during this run. + const observed = flags.fetch ? '' : ' (last observed witness; run with --fetch to observe the source)'; + console.log( + `${r.mount.name} @ ${shortOid(r.mount.from)} → ${shortOid(r.mount.to)} · pin: ${rep.state} ` + + `(behind ${rep.behind}, ahead ${rep.ahead}) · via ${rep.comparisonRepository}${observed}`, + ); + } + const overridden = r.override ? ' (non-fast-forward, overridden)' : ''; + const from12 = r.mount.from === null ? '' : shortOid(r.mount.from); + const to12 = r.mount.to === null ? '' : shortOid(r.mount.to); + switch (r.action) { + case 'updated': + console.log(`Updated leji.json: ${r.mount.name} pin ${from12} → ${to12}${overridden}`); + // Moving the pin is one act; materializing the new projection is another. + console.log(`Run leji mounts hydrate${flags.fetch ? '' : ' --fetch'} to hydrate the new pin.`); + break; + case 'unchanged': + console.log(`Unchanged: ${r.mount.name} pin ${from12} is already the target`); + break; + 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}`); + break; + } + return ok ? 0 : 1; +} + function emit(command: string, findings: Finding[], json: boolean, extra: Record = {}): number { const sorted = sortFindings(findings); const summary = summarize(sorted); @@ -685,7 +1037,8 @@ export async function run(argv: string[]): Promise { // was accepted by two implementations out of three. { const expected = - (TWO_WORD_COMMANDS.has(command) && sub ? 2 : 1) + (command === 'mounts' && sub === 'locate' ? 1 : 0); + (TWO_WORD_COMMANDS.has(command) && sub ? 2 : 1) + + (command === 'mounts' && (sub === 'locate' || sub === 'update-pin') ? 1 : 0); // `view` has its own usage message for a stray subcommand, and it is the more // useful one; let that case fall through to it. if (command !== 'view' && rest.length > expected) { @@ -745,11 +1098,17 @@ export async function run(argv: string[]): Promise { // and has no changelog yet, seed it (the changelog is otherwise only // written by `init --level indexed`). No-op at core or when present. const seededChangelog = wrote ? seedChangelogIfMissing(flags.root, manifest) : undefined; - return emit('index', [...findings, ...result.findings], flags.json, { + const code = emit('index', [...findings, ...result.findings], flags.json, { ...(wrote ? { written: effectiveIndexPath(manifest) } : {}), entries: wrote ? (result.index?.entries.length ?? 0) : 0, ...(seededChangelog ? { changelog: seededChangelog } : {}), }); + // A generate run ends by naming what the layer governs but does not + // index. A nudge, never a gate: the exit code is emit's alone, and + // nothing is printed when the count is zero. Text output only; --json + // carries one document and nothing after it. + if (!flags.json) printUnindexedNudge(unindexedPaths(flags.root, manifest).length); + return code; } case 'changelog': { if (sub === 'check') { @@ -940,7 +1299,7 @@ export async function run(argv: string[]): Promise { : item.status === 'not-applicable' ? 'n/a ' : 'manual '; - console.log(`${mark} [${item.level}] ${item.description}${item.detail ? ` — ${item.detail}` : ''}`); + console.log(`${mark} [${item.level}] ${item.description}${item.detail ? `: ${item.detail}` : ''}`); } console.log(''); if (flags.explain) console.log(renderExplain(result) + '\n'); @@ -962,13 +1321,39 @@ export async function run(argv: string[]): Promise { }); } case 'mounts': { - if (sub !== 'hydrate' && sub !== 'status' && sub !== 'locate') { - console.error('leji: usage: leji mounts \n'); + if (sub !== 'hydrate' && sub !== 'status' && sub !== 'locate' && sub !== 'update-pin') { + console.error('leji: usage: leji mounts \n'); console.error(USAGE); return 2; } + // Argument shape is settled before anything on disk is read: a usage + // error is never contingent on a manifest loading. + if (sub === 'update-pin') { + if (!rest[2]) { + console.error('leji: usage: leji mounts update-pin [--to ]\n'); + console.error(USAGE); + return 2; + } + if (flags.allowNonFastForward && flags.to === undefined) { + console.error('leji: --allow-non-fast-forward is valid only with an explicit --to \n'); + console.error(USAGE); + return 2; + } + } const { manifest, findings } = loadManifest(flags.root); if (!manifest) return emit(`mounts ${sub}`, findings, flags.json); + if (sub === 'update-pin') { + return reportUpdatePin( + flags, + updatePinRun(flags.root, manifest, { + name: rest[2], + to: flags.to, + allowNonFastForward: flags.allowNonFastForward, + fetch: flags.fetch, + dryRun: flags.dryRun, + }), + ); + } if (sub === 'hydrate') { const r = hydrateMounts(flags.root, manifest, { fetch: flags.fetch }); if (r.fatal) { @@ -1046,6 +1431,19 @@ export async function run(argv: string[]): Promise { } return 0; } + case 'badge': { + const result = badgeRun(flags.root, flags.out ?? DEFAULT_BADGE_OUT); + // A rejected `--out` is a usage error, in the CLI's usage-error form and + // ahead of every level the command could have reported. + if (result.usageError !== undefined) { + console.error(`leji: ${result.usageError}\n`); + console.error(USAGE); + return 2; + } + return reportBadge(flags, result); + } + case 'export': + return runExport(flags); case 'view': case 'viewer': { // `leji view` is an alias for `leji viewer serve` that also opens the @@ -1061,27 +1459,7 @@ export async function run(argv: string[]): Promise { console.error(USAGE); return 2; } - if (command === 'viewer' && sub === 'build') { - const { manifest, findings } = loadManifest(flags.root); - if (!manifest) return emit('viewer build', findings, flags.json); - const r = buildViewer(flags.root, manifest, flags.out); - if (r.findings.some((f) => f.severity === 'error')) { - return emit('viewer build', r.findings, flags.json); - } - if (flags.json) { - console.log( - JSON.stringify( - { command: 'viewer build', ok: true, out: r.out, warning: PROTECT_WARNING }, - null, - 2, - ), - ); - } else { - console.log(`Exported the static viewer to ${r.out}/`); - console.log(`\n${PROTECT_WARNING}`); - } - return 0; - } + if (command === 'viewer' && sub === 'build') return runExport(flags); const wantServe = isAlias || sub === 'serve'; const wantOpen = flags.open || isAlias; const { manifest, findings } = loadManifest(flags.root); @@ -1100,7 +1478,7 @@ export async function run(argv: string[]): Promise { : 0; if (!wantServe || code !== 0) { if (!flags.json && code === 0) { - const dir = `${stripSlash(manifest.rootPath) || '.'}/.leji/viewer/`; + const dir = `${VIEWER_REL}/`; console.log(`viewer ready (${result.entries} entries) → ${dir} serve: leji view`); } return code; @@ -1122,16 +1500,20 @@ export async function run(argv: string[]): Promise { return 0; } case 'ci': { + // One detection for the whole command: the hook and the CI job both run + // what a clean install of this repository provides. + const ecosystem = detectEcosystem(flags.root); if (flags.hooks) { const { manifest, findings } = loadManifest(flags.root); if (!manifest) return emit('ci', findings, flags.json); - const h = ensureLocalHook(flags.root); + const h = ensureLocalHook(flags.root, runnerArgv(ecosystem)); if (flags.json) { const out: Record = { command: 'ci', ok: true, hook: h.path, action: h.action }; if (h.action === 'manual') { out.reason = h.reason; out.snippet = h.snippet; } + out.ecosystem = ecosystem; console.log(JSON.stringify(out, null, 2)); } else if (h.action === 'manual') { const lead = @@ -1154,6 +1536,7 @@ export async function run(argv: string[]): Promise { `${h.action === 'unchanged' ? 'Hook already current' : 'Wrote'} ${h.path} (validate + index --check before every commit; per-clone, delete to opt out).`, ); } + if (!flags.json) console.log(renderEcosystemLine(ecosystem)); return 0; } // No --provider: infer from the origin remote (a GitLab repo must @@ -1177,7 +1560,7 @@ export async function run(argv: string[]): Promise { } const { manifest, findings } = loadManifest(flags.root); if (!manifest) return emit('ci', findings, flags.json); - const r = ensureCiWorkflow(flags.root, provider as CiProvider); + const r = ensureCiWorkflow(flags.root, provider as CiProvider, ecosystem); if (flags.json) { const out: Record = { command: 'ci', @@ -1189,6 +1572,7 @@ export async function run(argv: string[]): Promise { }; if (r.action === 'manual') out.snippet = r.snippet; if (r.note) out.note = r.note; + out.ecosystem = ecosystem; console.log(JSON.stringify(out, null, 2)); } else { switch (r.action) { @@ -1202,12 +1586,15 @@ export async function run(argv: string[]): Promise { console.log(`${r.path} already present; nothing to do.`); break; case 'manual': + // Not leji's file: it was written by hand, or a generated one was + // edited. Either way the edit is the opt-out, and it is honored. console.log( - `${r.path} already exists; not modifying it. Add this to your CircleCI config:\n\n${r.snippet}`, + `${r.path} already exists and was not generated by leji; not modifying it. Add this yourself:\n\n${r.snippet}`, ); break; } if (r.note) console.log(r.note); + console.log(renderEcosystemLine(ecosystem)); } return 0; } @@ -1221,21 +1608,17 @@ export async function run(argv: string[]): Promise { if (!manifest) return emit('agent', findings, flags.json); const r = addAgent(flags.root, manifest, { host: flags.host, name: flags.name, role: flags.role }); if (flags.json) { - console.log( - JSON.stringify( - { - command: 'agent', - ok: true, - name: r.name, - role: r.role, - host: r.hostId ?? null, - profile: r.profilePath, - created: { profile: r.profileCreated, manifest: r.manifestChanged }, - }, - null, - 2, - ), - ); + const out: Record = { + command: 'agent', + ok: true, + name: r.name, + role: r.role, + host: r.hostId ?? null, + profile: r.profilePath, + created: { profile: r.profileCreated, manifest: r.manifestChanged }, + }; + if (r.note) out.note = r.note; + console.log(JSON.stringify(out, null, 2)); } else { const lines: string[] = []; lines.push(r.profileCreated ? `Wrote ${r.profilePath}` : `${r.profilePath} already present`); @@ -1245,6 +1628,7 @@ export async function run(argv: string[]): Promise { ? `Bound agent "${r.name}" (${roleHost}) in leji.json` : `agent "${r.name}" already bound in leji.json; nothing to do.`, ); + if (r.note) lines.push(r.note); console.log(lines.join('\n')); } return 0; @@ -1253,28 +1637,101 @@ export async function run(argv: string[]): Promise { const { manifest, findings } = loadManifest(flags.root); if (!manifest) return emit('start', findings, flags.json); const detected = detectHosts({ root: flags.root }); - const interactive = !flags.yes && Boolean(process.stdin.isTTY); + // The repository's own ecosystem, read once: the preflight probes the + // runner it names, and the JSON document reports it. + const ecosystem = detectEcosystem(flags.root); + // --json is a single-document mode, so it is never interactive: nothing + // prompts, nothing launches, and no repair can run under it. + const interactive = !flags.yes && !flags.json && Boolean(process.stdin.isTTY); + // The boot profile is checked first, before any report or prompt: a layer + // whose entrypoint is missing has nothing to enter. + if (!bootProfileReady(flags.root, manifest)) { + if (flags.json) { + console.log( + JSON.stringify( + { command: 'start', ok: false, ready: false, error: 'boot-missing', checks: [], ecosystem }, + null, + 2, + ), + ); + } else { + console.error( + `leji: boot profile ${manifest.bootProfilePath} is missing or invalid; run leji validate`, + ); + } + return 1; + } + // The host is resolved BEFORE the report, so the MCP rows answer for the + // host this run actually targets. An --agent naming no launchable host + // throws here, exactly as it did inside enterLayer: a usage error. + const io = defaultHandoffIo(); + const host = await resolveStartHost({ detected, agent: flags.agent, interactive, io }); + const preflight = runPreflight({ + root: flags.root, + manifest, + host, + detected, + report: ecosystem, + io, + }); + if (flags.json) { + // Report only: the launch-selection arguments are accepted and have no + // effect, and a gap is reported rather than blocking (`ready` is the + // scriptable signal). + console.log( + JSON.stringify( + { + command: 'start', + ok: true, + ready: preflight.ready, + // Projected, never the raw checks: the document publishes four keys, + // and a field the renderer needs is not one of them. + checks: preflight.checks.map(checkDocument), + ecosystem, + }, + null, + 2, + ), + ); + return 0; + } + // The one place color is decided: a terminal question, asked at the boundary and + // injected, so the block itself never consults the process. + const color = colorDecision(Boolean(process.stdout.isTTY), process.env); + console.log('\n' + renderPreflight(preflight.checks, { color })); + await offerPreflightFixes({ + root: flags.root, + host, + result: preflight, + runner: runnerArgv(ecosystem), + interactive, + io, + }); const outcome = await enterLayer({ root: flags.root, manifest, detected, agent: flags.agent, + host, interactive, hostArgs: flags.hostArgs, + io, }); - if (outcome === 'boot-missing') { - console.error(`leji: boot profile ${manifest.bootProfilePath} is missing or invalid; run leji validate`); - return 1; - } if (outcome === 'fallback') console.log(enteringViaBoot(manifest, flags.hostArgs)); return 0; } case 'detect': { const result = detectLayer(flags.root); if (flags.json) { - console.log(JSON.stringify({ command: 'detect', ok: true, hosts: result.hosts }, null, 2)); + console.log( + JSON.stringify( + { command: 'detect', ok: true, hosts: result.hosts, ecosystem: result.ecosystem }, + null, + 2, + ), + ); } else { - console.log(renderDetect(result.hosts)); + console.log(renderDetect(result)); } return 0; } @@ -1289,7 +1746,11 @@ export async function run(argv: string[]): Promise { agent: flags.agent, mode: flags.mode, }); + // The repository's own dependency ecosystem, read once and reported by + // every output mode: the human block, the JSON document, and the offer. + const ecosystem = detectEcosystem(result.root); if (result.dryRun) { + if (flags.json) return emitScaffold('adopt', result.findings, [], ecosystem, true); // A wire-only run scaffolds nothing, so "Adopting the existing // repository" misnames it: the layer is already there and the plan // beneath is entrypoint conversions. @@ -1300,12 +1761,20 @@ export async function run(argv: string[]): Promise { ); console.log('\n' + renderWritePlan(result.plan)); console.log('\nNo files written (--dry-run). Re-run without --dry-run to apply.'); + console.log('\n' + renderEcosystemBlock(ecosystem)); return 0; } + if (flags.json) return emitScaffold('adopt', result.findings, result.written, ecosystem); console.log(`\nWrote ${result.written.length} files (context root: ${result.detectedRoot}):`); for (const rel of result.written) console.log(` ${rel}`); const indexFailed = reportScaffoldIndex(result.findings); - const interactive = !flags.yes && Boolean(process.stdin.isTTY); + // --json is a single-document mode, so it is never interactive: nothing + // prompts, and no package manager can run under it. + const interactive = !flags.yes && !flags.json && Boolean(process.stdin.isTTY); + // A wire-only run scaffolds no layer, so it makes no declaration offer. + const dependency = result.wiredOnly + ? null + : await offerDependency({ root: result.root, report: ecosystem, interactive }); const mcp = await offerMcpInstall({ root: result.root, detected: result.detected, @@ -1332,7 +1801,9 @@ export async function run(argv: string[]): Promise { ) { console.log(enteringAdopted(result)); } - return indexFailed; + // The layer is written either way; a consented add that failed means the + // durable setup this run promised was not reached, and the exit says so. + return indexFailed || (dependency !== null && dependencyAddFailed(dependency)) ? 1 : 0; } case 'init': { const result = await initLayer({ @@ -1345,18 +1816,23 @@ export async function run(argv: string[]): Promise { agent: flags.agent, mode: flags.mode, }); + const ecosystem = detectEcosystem(result.root); if (result.dryRun) { + if (flags.json) return emitScaffold('init', result.findings, [], ecosystem, true); console.log('\n' + renderWritePlan(result.plan)); console.log('\nNo files written (--dry-run). Re-run without --dry-run to create them.'); + console.log('\n' + renderEcosystemBlock(ecosystem)); return 0; } + if (flags.json) return emitScaffold('init', result.findings, result.written, ecosystem); console.log(`\nWrote ${result.written.length} files:`); for (const rel of result.written) console.log(` ${rel}`); // The index could not be generated: the scaffold is on disk but its // generated CI would fail, so say why and exit nonzero rather than // report a success the layer does not have. const indexFailed = reportScaffoldIndex(result.findings); - const interactive = !flags.yes && Boolean(process.stdin.isTTY); + const interactive = !flags.yes && !flags.json && Boolean(process.stdin.isTTY); + const dependency = await offerDependency({ root: result.root, report: ecosystem, interactive }); const mcp = await offerMcpInstall({ root: result.root, detected: result.detected, @@ -1383,7 +1859,7 @@ export async function run(argv: string[]): Promise { ) { console.log(enteringTheLayer(result.manifest, result.mode)); } - return indexFailed; + return indexFailed || dependencyAddFailed(dependency) ? 1 : 0; } default: console.error(`leji: unknown command "${command}"\n`); diff --git a/packages/sdk/src/internal/create.ts b/packages/sdk/src/internal/create.ts new file mode 100644 index 0000000..7e4e90c --- /dev/null +++ b/packages/sdk/src/internal/create.ts @@ -0,0 +1,134 @@ +/** + * INTERNAL, UNVERSIONED. Reached only through `@leji-org/leji/internal/create`, and + * only by `create-leji` in this repository. It carries no semver promise: it may change + * shape or disappear in any release, and nothing outside this repository should import + * it. The stable surfaces are the CLI and the package root export. + * + * One purpose-specific classifier so the `npm create leji` router and `leji adopt` + * cannot disagree about what an existing repository looks like. The rule lives here + * once, over adopt's own docs-root and vendor-entrypoint data. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { DOCS_CANDIDATES, pickDocsRoot } from '../commands/init.js'; +import { KNOWN_VENDOR_FILES } from '../commands/validate.js'; +import { isDir, isFile, stripSlash } from '../lib/fsx.js'; + +export { DOCS_CANDIDATES } from '../commands/init.js'; +export { KNOWN_VENDOR_FILES } from '../commands/validate.js'; + +/** + * What a caller-selected directory is, for the one decision `create-leji` makes: + * + * 'missing' nothing stands there -> `leji init` creates it + * 'unreadable' cannot be listed as a directory -> refuse; a broken symlink, a + * file, or a directory we may not read is never guessed at + * 'adopted' a `leji.json` manifest is there -> nothing to bootstrap + * 'adopt' a docs root or an agent entrypoint already exists + * 'init' anything else + */ +export type CreateTargetState = 'missing' | 'unreadable' | 'adopted' | 'adopt' | 'init'; + +/** + * The real path of `rel` under the already-resolved target, or null when it does not + * resolve to something inside it. + * + * Every path this classifier inspects goes through here, because each one is a place a + * symlink can point somewhere else: a `docs` link, a `leji.json` link, or an escaping + * `.github` two components up from the entry that was named. `realpathSync` resolves the + * whole chain, so an intermediate link is caught as surely as a final one, and the answer + * is checked against the target before anything is read. An entry that escapes, or that + * dangles, is simply not there as far as routing is concerned: the classifier reports no + * finding of its own and writes nothing, since its only job is to name the command that + * runs next, and `init`/`adopt` enforce their own preconditions on the paths they touch. + */ +function insideTarget(rootReal: string, rel: string): string | null { + let real: string; + try { + real = fs.realpathSync(path.join(rootReal, rel)); + } catch { + return null; + } + return withinRoot(rootReal, real) ? real : null; +} + +/** + * Is `real` the target `rootReal` or something under it? Both are already-resolved + * absolute paths, so this is a pure comparison of strings and the one place the rule + * lives. Exported for its own tests: a filesystem root is the case that cannot be + * exercised by classifying a real directory. + * + * A filesystem root is its own separator. `path.parse` names it per platform (`/`, and + * `C:\` on Windows), and appending another separator there would ask whether `/foo` + * starts with `//`, which it does not: every child of a root target would have read as + * an escape. Everywhere else the separator has to be appended, or `/repo` would claim + * `/repository` as its own. + */ +export function withinRoot(rootReal: string, real: string): boolean { + if (real === rootReal) return true; + const prefix = rootReal === path.parse(rootReal).root ? rootReal : rootReal + path.sep; + return real.startsWith(prefix); +} + +/** + * Classify the selected target directory. Reads a directory listing and a handful of + * exact target-relative paths; writes nothing, and never searches recursively, so + * pointing it at one package of a monorepo classifies that package rather than the + * repository around it. + * + * Nothing outside the target decides the answer. The target is resolved first, so the + * classification and the command that follows it look at the same directory, and every + * path inspected under it is resolved and required to land inside it (`insideTarget`): + * a `docs` or `leji.json` that is a symlink out of the target, and an entry reached + * through a symlinked parent, count as absent rather than as evidence. + */ +export function classifyTarget(dir: string): CreateTargetState { + let real: string; + try { + real = fs.realpathSync(dir); + } catch (err) { + // ENOENT from realpath is either "nothing there" or a dangling symlink, and the + // two want opposite answers: `init` creates a missing directory, but it would + // write through a broken link into a path the caller did not name. `lstat` is + // what separates them, since it succeeds on the link itself. + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + try { + fs.lstatSync(dir); + return 'unreadable'; + } catch { + return 'missing'; + } + } + return 'unreadable'; + } + + let names: string[]; + try { + names = fs.readdirSync(real); + } catch { + // EACCES (unreadable directory) and ENOTDIR (the target is a file) alike: the + // state cannot be established, so no command runs. + return 'unreadable'; + } + + const manifest = insideTarget(real, 'leji.json'); + if (manifest !== null && isFile(manifest)) return 'adopted'; + + // Only the names a docs candidate could match are resolved: `pickDocsRoot` never + // matches anything else, and the listing of a repository root is not worth a realpath + // per entry. + const wanted = new Set(DOCS_CANDIDATES.map((c) => stripSlash(c).toLowerCase())); + const docsDirs = names.filter((n) => { + if (!wanted.has(n.toLowerCase())) return false; + const abs = insideTarget(real, n); + return abs !== null && isDir(abs); + }); + if (pickDocsRoot(docsDirs) !== null) return 'adopt'; + + // Presence, not `isFile`, because the question here is whether the repository already + // carries agent context, not whether `adopt` will rewrite the entry: `.cursor/rules` + // is a directory in a real Cursor repository. + if (KNOWN_VENDOR_FILES.some((rel) => insideTarget(real, rel) !== null)) return 'adopt'; + + return 'init'; +} diff --git a/packages/sdk/src/lib/detect.ts b/packages/sdk/src/lib/detect.ts index 25191af..a6a4253 100644 --- a/packages/sdk/src/lib/detect.ts +++ b/packages/sdk/src/lib/detect.ts @@ -21,12 +21,56 @@ export interface HostSpec { /** Argv that reports whether the Leji MCP server is already registered (exit 0 = * present); used to skip the install offer when it's already there. */ mcpCheck?: string[]; + /** Argv that registers the server for THIS USER, across every project. Absent + * when `mcpAdd` is already the user-level form (Codex has no other scope). */ + mcpAddUser?: string[]; + /** The committed file a shared (project-scope) registration writes, repository + * root relative. Only a host whose `mcpAdd` writes into the repository has one. */ + mcpSharedFile?: string; + /** Where a host with no registration command reads its MCP configuration, for a + * host Leji can only tell the user about, and which shape that file takes. */ + mcpConfig?: { path: string; scope: 'project' | 'user'; shape: McpConfigShape }; } +/** + * The top-level key an MCP client's configuration file uses for its server map. + * `mcpServers` is the common one; VS Code (and GitHub Copilot through it) spells the + * same map `servers` in `.vscode/mcp.json`, so a client told to paste the common + * block there ends up with a file the editor ignores. + */ +export type McpConfigShape = 'mcpServers' | 'servers'; + /** The registered server name and the npm package behind the local Leji MCP server. */ export const MCP_SERVER_NAME = 'leji'; export const MCP_PACKAGE = '@leji-org/mcp'; +/** + * The MCP client configuration for the local Leji server, in the shape one client's + * configuration file takes. The SDK owns these bytes: the MCP package README and the + * website quote the `mcpServers` form, and a repo test asserts the three of them + * agree, so the instruction a user reads is one text. + */ +export function mcpJsonConfig(shape: McpConfigShape): string { + return `{ + "${shape}": { + "${MCP_SERVER_NAME}": { "command": "npx", "args": ["-y", "${MCP_PACKAGE}"] } + } +}`; +} + +/** The common form, the one the README and the website publish. */ +export const MCP_JSON_CONFIG: string = mcpJsonConfig('mcpServers'); + +/** One host command line as a user would type it: the host binary, then the argv. */ +export function mcpCommand(spec: HostSpec, argv: string[]): string { + return `${spec.bins[0]} ${argv.join(' ')}`; +} + +/** The host spec with this id, or undefined. */ +export function hostSpec(id: string): HostSpec | undefined { + return HOST_SPECS.find((s) => s.id === id); +} + /** * The portable discovery adapter. `AGENTS.md` is a cross-host entrypoint * convention (stewarded by the Linux Foundation's Agentic AI Foundation, read @@ -48,6 +92,10 @@ export const HOST_SPECS: HostSpec[] = [ // Project scope writes a committed `.mcp.json` so the whole team gets the server. mcpAdd: ['mcp', 'add', MCP_SERVER_NAME, '--scope', 'project', '--', 'npx', '-y', MCP_PACKAGE], mcpCheck: ['mcp', 'get', MCP_SERVER_NAME], + // User scope is the personal form: it registers for every project of this + // user without touching a file the repository commits. + mcpAddUser: ['mcp', 'add', MCP_SERVER_NAME, '--scope', 'user', '--', 'npx', '-y', MCP_PACKAGE], + mcpSharedFile: '.mcp.json', }, { id: 'codex', @@ -69,6 +117,7 @@ export const HOST_SPECS: HostSpec[] = [ repoFiles: ['.github/copilot-instructions.md'], userDirs: [], adapter: '.github/copilot-instructions.md', + mcpConfig: { path: '.vscode/mcp.json', scope: 'project', shape: 'servers' }, }, { id: 'gemini', @@ -77,6 +126,7 @@ export const HOST_SPECS: HostSpec[] = [ repoFiles: ['GEMINI.md', '.gemini'], userDirs: ['.gemini'], adapter: 'GEMINI.md', + mcpConfig: { path: '.gemini/settings.json', scope: 'project', shape: 'mcpServers' }, }, { id: 'cursor', @@ -85,6 +135,7 @@ export const HOST_SPECS: HostSpec[] = [ repoFiles: ['.cursor/rules', '.cursorrules'], userDirs: [], adapter: '.cursor/rules/leji.md', + mcpConfig: { path: '.cursor/mcp.json', scope: 'project', shape: 'mcpServers' }, }, { id: 'windsurf', @@ -93,6 +144,7 @@ export const HOST_SPECS: HostSpec[] = [ repoFiles: ['.windsurf/rules', '.windsurfrules'], userDirs: [], adapter: '.windsurf/rules/leji.md', + mcpConfig: { path: '~/.codeium/windsurf/mcp_config.json', scope: 'user', shape: 'mcpServers' }, }, ]; diff --git a/packages/sdk/src/lib/ecosystem.ts b/packages/sdk/src/lib/ecosystem.ts new file mode 100644 index 0000000..d58615c --- /dev/null +++ b/packages/sdk/src/lib/ecosystem.ts @@ -0,0 +1,960 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolvedWithinRoot } from './fsx.js'; + +/** + * Which dependency ecosystem owns a repository root, which package manager runs + * it, how the Leji CLI is declared as a dev dependency there, and how a hook or CI + * job should invoke it. + * + * Pure and offline: it reads a bounded set of files directly under the root and + * writes nothing, launches nothing, and never walks up out of the root (an add in + * a parent directory would write outside the root the user targeted). Every answer + * is a total decision table over repository evidence, so the three SDKs return the + * same report for the same tree. + */ + +/** The npm package name. Its presence in `package.json`'s dependency maps is what + * `directDeclared` reports for a Node repository, and what selects the local-first + * CI and hook variants over the `npx @leji-org/leji@1` fallback. */ +export const DEP_NAME = '@leji-org/leji'; +/** The distribution name on PyPI and the name a Python manifest declares. */ +const PY_DIST = 'leji'; +/** The Go module path a `tool` directive names for the CLI. */ +const GO_TOOL_PATH = 'github.com/leji-org/leji/packages/sdk-go/cmd/leji'; + +export type EcosystemId = 'node' | 'python' | 'go'; + +/** Why an ecosystem's manager could not be selected, or `ok` when it was. */ +export type EcoStatus = 'ok' | 'ambiguous-manager' | 'unsupported-manager' | 'unreadable-manifest' | 'refused-evidence'; + +/** What evidenced the manager: the `packageManager` field, a lockfile, a + * `[tool.*]` table, the manifest itself (`Pipfile`), or the ecosystem's default. */ +export type EcoSource = 'packageManager' | 'lockfile' | 'tool-table' | 'manifest' | 'default'; + +/** The report's overall verdict: `null` when one ecosystem answered cleanly. */ +export type EcoReason = Exclude | 'multiple-ecosystems' | 'none'; + +/** One package manager the evidence could not choose between, with the command + * that would declare Leji under it (`null` for a print-only manager). */ +export interface EcoCandidate { + manager: string; + add: string[] | null; +} + +/** + * One gated ecosystem's answer. TOTAL: every field is set on every outcome, so a + * consumer never has to know which branch produced the result. + */ +export interface EcoResult { + ecosystem: EcosystemId; + status: EcoStatus; + /** The manifest that gated the ecosystem, repository-root-relative. */ + manifest: string | null; + manager: string | null; + source: EcoSource | null; + /** The files that evidenced the manager decision — lockfiles present, plus + * every root `requirements*.txt` for Python — or, on `refused-evidence`, the + * names that were refused. Sorted as documented per ecosystem. */ + evidence: string[]; + /** Argv that declares Leji as a dev dependency; `null` for a print-only + * manager (pip, pre-1.24 Go) and whenever no manager was selected. */ + add: string[] | null; + /** Argv that runs the declared CLI through this manager. */ + runner: string[] | null; + directDeclared: boolean; + lockEvidenced: boolean; + candidates: EcoCandidate[]; +} + +/** The whole answer for one root. `selected` is non-null only when exactly one + * ecosystem is gated AND it chose a manager. */ +export interface EcosystemReport { + selected: EcoResult | null; + all: EcoResult[]; + reason: EcoReason | null; +} + +/** Per-manager commands. `add` is `null` for a manager that cannot declare a dev + * dependency from the command line; its guidance is printed instead. `install` is + * the manager's own plain install — what a joiner runs on a fresh clone so the + * declared CLI resolves — and is `null` for a manager whose install depends on which + * requirements file the repository uses. Argv arrays, never shell strings. No version + * pin: the lockfile pins the exact version, and Go needs a selector, so it takes + * `@latest`. */ +const MANAGER_COMMANDS: Record = { + npm: { add: ['npm', 'i', '-D', DEP_NAME], runner: ['npx', '--no-install', DEP_NAME], install: ['npm', 'install'] }, + pnpm: { add: ['pnpm', 'add', '-D', DEP_NAME], runner: ['pnpm', 'exec', 'leji'], install: ['pnpm', 'install'] }, + yarn: { add: ['yarn', 'add', '-D', DEP_NAME], runner: ['yarn', 'leji'], install: ['yarn', 'install'] }, + bun: { add: ['bun', 'add', '-d', DEP_NAME], runner: ['bun', 'run', 'leji'], install: ['bun', 'install'] }, + uv: { add: ['uv', 'add', '--dev', PY_DIST], runner: ['uv', 'run', 'leji'], install: ['uv', 'sync'] }, + poetry: { + add: ['poetry', 'add', '--group', 'dev', PY_DIST], + runner: ['poetry', 'run', 'leji'], + install: ['poetry', 'install'], + }, + pdm: { add: ['pdm', 'add', '-dG', 'dev', PY_DIST], runner: ['pdm', 'run', 'leji'], install: ['pdm', 'install'] }, + pipenv: { + add: ['pipenv', 'install', '--dev', PY_DIST], + runner: ['pipenv', 'run', 'leji'], + install: ['pipenv', 'install', '--dev'], + }, + pip: { add: null, runner: ['leji'], install: null }, + go: { + add: ['go', 'get', '-tool', `${GO_TOOL_PATH}@latest`], + runner: ['go', 'tool', 'leji'], + // The same command F10's CI table installs a Go repository's tools with. + install: ['go', 'mod', 'download'], + }, + 'go-legacy': { add: null, runner: ['leji'], install: null }, +}; + +/** The fallback runner: the CLI on PATH, for every repository that has not + * declared it. */ +const PLAIN_RUNNER = ['leji']; + +const NODE_MANIFEST = 'package.json'; +const GO_MANIFEST = 'go.mod'; +const PYPROJECT = 'pyproject.toml'; +const PIPFILE = 'Pipfile'; + +/** Node lockfile families, in the fixed order every list of them uses. Two names + * mark bun (text and binary); either one is presence-only evidence. */ +const NODE_LOCKS: { file: string; manager: string }[] = [ + { file: 'package-lock.json', manager: 'npm' }, + { file: 'pnpm-lock.yaml', manager: 'pnpm' }, + { file: 'yarn.lock', manager: 'yarn' }, + { file: 'bun.lock', manager: 'bun' }, + { file: 'bun.lockb', manager: 'bun' }, +]; +const NODE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun']; + +/** Python lock families, in the fixed order every list of them uses. `Pipfile` + * is a family member without being a lock: it selects pipenv, but only + * `Pipfile.lock` evidences a lock. */ +const PY_LOCKS: { file: string; manager: string; lock: boolean }[] = [ + { file: 'uv.lock', manager: 'uv', lock: true }, + { file: 'poetry.lock', manager: 'poetry', lock: true }, + { file: 'pdm.lock', manager: 'pdm', lock: true }, + { file: 'Pipfile.lock', manager: 'pipenv', lock: true }, + { file: PIPFILE, manager: 'pipenv', lock: false }, +]; +/** `[tool.]` tables that name a manager when no lock family is present. */ +const PY_TOOL_TABLES: { table: string; manager: string }[] = [ + { table: 'tool.uv', manager: 'uv' }, + { table: 'tool.poetry', manager: 'poetry' }, + { table: 'tool.pdm', manager: 'pdm' }, +]; +/** Root files that gate the Python ecosystem alongside the two manifests. */ +const REQUIREMENTS_RE = /^requirements[A-Za-z0-9._-]*\.txt$/; + +// --- the human block ------------------------------------------------------ +// Every string the offer prints lives here once, so the three SDKs transcribe one +// table rather than re-deriving prose. The block is always printed; the prompt +// that may follow it is not this module's business. + +const OFFER_LEAD = 'To declare the Leji CLI as a dev dependency so a clean install brings leji, run:'; +const DECLARE_WITH_TOOL = 'Declare the Leji CLI as a dev dependency with the tool this repo uses.'; +const INDENT = ' '; + +const TEXT = { + offer: (manager: string, file: string): string => `Detected ${manager} (${file}). ${OFFER_LEAD}`, + declared: (manifest: string): string => `The Leji CLI is already declared in ${manifest}.`, + ambiguous: (manifest: string, files: string[]): string => + `Detected ${manifest} with ${joinAnd(files)}; leji will not guess the package manager. Declare it with the one this repo uses:`, + multiple: (manifests: string[], commands: boolean): string => + `Detected ${joinAnd(manifests)}; leji will not guess which ecosystem owns this repository. Declare it with the one this repo uses${commands ? ':' : '.'}`, + none: [ + 'No package.json, pyproject.toml or go.mod here, so there is nothing for leji to declare itself in. Install the Leji CLI for yourself:', + `${INDENT}npm install -g ${DEP_NAME}`, + 'Other runtimes and the full walkthrough: https://leji.org/quickstart/', + ], + unsupported: (manifest: string): string => + `Detected ${manifest}, whose packageManager field names a package manager leji does not know; leji will not guess. ${DECLARE_WITH_TOOL}`, + unreadable: (manifest: string): string => + `Could not read ${manifest}, so leji will not guess the package manager. ${DECLARE_WITH_TOOL}`, + refused: (files: string[]): string => + `Refusing to read ${joinAnd(files)}: not a regular file inside this repository. ${DECLARE_WITH_TOOL}`, + pipGroups: (file: string): string[] => [ + `Detected pip (${file}). To declare the Leji CLI as a dev dependency so a clean install brings leji, add to ${PYPROJECT}:`, + `${INDENT}[dependency-groups]`, + `${INDENT}dev = ["${PY_DIST}"]`, + 'then run it with pip 25.1 or newer:', + `${INDENT}pip install --group dev`, + ], + pipRequirements: (file: string): string[] => [ + `Detected pip (${file}). To declare the Leji CLI as a dev dependency so a clean install brings leji, add a line \`${PY_DIST}\` to requirements-dev.txt, then run:`, + `${INDENT}pip install -r requirements-dev.txt`, + ], + goLegacy: (file: string): string[] => [ + `Detected Go (${file}) without a go directive of 1.24 or newer, so leji cannot be declared as a module tool. Install the Leji CLI for yourself:`, + `${INDENT}go install ${GO_TOOL_PATH}@latest`, + ], + line: { + selected: (manager: string, file: string, declared: boolean): string => + `Ecosystem: ${manager} (${file}); Leji CLI ${declared ? 'declared' : 'not declared'}`, + none: 'Ecosystem: none detected', + multiple: (manifests: string[]): string => + `Ecosystem: ${joinAnd(manifests)}; leji will not guess which one owns this repository`, + ambiguous: (manifest: string, files: string[]): string => + `Ecosystem: ${manifest} with ${joinAnd(files)}; leji will not guess the package manager`, + unsupported: (manifest: string): string => `Ecosystem: ${manifest}; unrecognized packageManager field`, + unreadable: (manifest: string): string => `Ecosystem: ${manifest}; unreadable`, + refused: (files: string[]): string => `Ecosystem: ${joinAnd(files)}; not a regular file inside this repository`, + }, + /** The consent path (plan section 3): the prompt, and every outcome of running + * the manager's own add command. leji writes no manifest byte itself, so these + * are the only words it owns once the user says yes. */ + consent: { + /** Printed immediately before the prompt, interactive runs only. The manager + * runs here, as the user, with the user's environment: say so before asking, + * not after. */ + disclosure: (bin: string): string => + `This runs ${bin} here with your environment, as when you run it yourself: it will contact its registry and may run install scripts.`, + prompt: 'Run it now?', + running: (command: string[]): string => `Running: ${command.join(' ')}`, + /** Names what was actually declared, per ecosystem: the npm package, the + * PyPI distribution, or the Go module tool. */ + declared: (ecosystem: EcosystemId): string => + `Declared ${DECLARED_SUBJECT[ecosystem]}; a clean install now brings leji.`, + exited: (bin: string, code: number): string => `${bin} exited ${code}; run it yourself:`, + signaled: (bin: string, signal: string): string => `${bin} was terminated (${signal}); run it yourself:`, + missing: (bin: string): string => `${bin} is not on your PATH; run it yourself once it is:`, + declined: 'Skipped; declare it later with:', + /** One indented command line, so no caller re-derives the indentation. */ + command: (command: string[]): string => `${INDENT}${command.join(' ')}`, + }, +}; + +/** What each ecosystem's add command actually declares. */ +const DECLARED_SUBJECT: Record = { + node: DEP_NAME, + python: PY_DIST, + go: 'the leji module tool', +}; + +/** + * The whole message table, exported for the two callers that print outside this + * module (the declaration offer) and for the ports to transcribe. Not re-exported + * from the package index: these are the CLI's words, not a library API. + */ +export const ECOSYSTEM_TEXT = TEXT; + +/** `a`, `a and b`, `a, b and c` — the one list join every message uses. */ +function joinAnd(items: string[]): string { + if (items.length === 0) return ''; + if (items.length === 1) return items[0]; + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; +} + +// --- evidence eligibility ------------------------------------------------- + +/** What stands at one probed name directly under the root. A gated file counts + * only when `lstat` says regular file AND its real path lies inside the real + * root: a symlink, a dangling link, a directory, a socket or a FIFO is refused + * rather than read, so no manifest or lockfile can redirect the answer out of the + * repository the user pointed at. */ +type EntryKind = 'absent' | 'eligible' | 'refused'; + +function classify(rootAbs: string, name: string): EntryKind { + const abs = path.join(rootAbs, name); + let st: fs.Stats; + try { + st = fs.lstatSync(abs); + } catch { + return 'absent'; + } + if (!st.isFile()) return 'refused'; + return resolvedWithinRoot(rootAbs, abs) ? 'eligible' : 'refused'; +} + +/** The probed names of one root, classified once. */ +class RootScan { + private readonly kinds = new Map(); + private entries: string[] | null = null; + constructor(readonly rootAbs: string) {} + kind(name: string): EntryKind { + let k = this.kinds.get(name); + if (k === undefined) { + k = classify(this.rootAbs, name); + this.kinds.set(name, k); + } + return k; + } + present(name: string): boolean { + return this.kind(name) !== 'absent'; + } + eligible(name: string): boolean { + return this.kind(name) === 'eligible'; + } + /** The refused names among `names`, in the order given. */ + refused(names: string[]): string[] { + return names.filter((n) => this.kind(n) === 'refused'); + } + /** The bytes of one probed name, or null. Structurally gated: a name that is + * not an eligible regular file inside the real root is never opened, so no + * read can bypass the eligibility rule by being spelled at a new call site. */ + read(name: string): string | null { + if (this.kind(name) !== 'eligible') return null; + try { + return fs.readFileSync(path.join(this.rootAbs, name), 'utf8'); + } catch { + return null; + } + } + /** Every root entry matching `re`, sorted bytewise (never by locale: the three + * SDKs must agree, and a locale collation orders `requirements-Test.txt` + * against `requirements-dev.txt` differently from byte order). */ + matching(re: RegExp): string[] { + if (this.entries === null) { + try { + this.entries = fs.readdirSync(this.rootAbs); + } catch { + this.entries = []; + } + } + return this.entries.filter((n) => re.test(n)).sort(byteCompare); + } +} + +function byteCompare(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +// --- result construction -------------------------------------------------- + +/** What a branch decided; everything it leaves out is the neutral value. */ +interface Decision { + ecosystem: EcosystemId; + status?: EcoStatus; + manifest: string | null; + manager?: string | null; + source?: EcoSource | null; + evidence?: string[]; + directDeclared?: boolean; + lockEvidenced?: boolean; + candidates?: EcoCandidate[]; +} + +/** + * The one EcoResult constructor. Every field of the result is set here, in the + * fixed key order the JSON contract pins, so no branch can build a partial + * outcome and `add`/`runner` always follow the manager rather than the branch. + */ +function result(d: Decision): EcoResult { + const commands = d.manager == null ? undefined : MANAGER_COMMANDS[d.manager]; + return { + ecosystem: d.ecosystem, + status: d.status ?? 'ok', + manifest: d.manifest, + manager: d.manager ?? null, + source: d.source ?? null, + evidence: d.evidence ?? [], + add: commands ? commands.add : null, + runner: commands ? commands.runner : null, + directDeclared: d.directDeclared ?? false, + lockEvidenced: d.lockEvidenced ?? false, + candidates: d.candidates ?? [], + }; +} + +function candidatesFor(managers: string[]): EcoCandidate[] { + return managers.map((manager) => ({ manager, add: MANAGER_COMMANDS[manager]?.add ?? null })); +} + +/** Unique, order-preserving. */ +function uniq(items: string[]): string[] { + return items.filter((x, i) => items.indexOf(x) === i); +} + +// --- Node ----------------------------------------------------------------- + +/** `[@[+]]`, corepack's grammar. A value that is present but + * does not parse is malformed — never a fall-through to a lockfile or the default, + * because explicit repository evidence is never overridden by a guess. */ +const PACKAGE_MANAGER_RE = + /^([a-z][a-z0-9-]*)(?:@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)(?:\+([A-Za-z0-9._-]+))?)?$/; + +function nodeResult(scan: RootScan): EcoResult { + const refused = scan.refused([NODE_MANIFEST, ...NODE_LOCKS.map((l) => l.file)]); + if (refused.length > 0) { + return result({ + ecosystem: 'node', + status: 'refused-evidence', + manifest: NODE_MANIFEST, + evidence: refused.sort(byteCompare), + }); + } + const raw = scan.read(NODE_MANIFEST); + const pkg = raw === null ? null : parsePackageJson(raw); + if (pkg === null) { + return result({ ecosystem: 'node', status: 'unreadable-manifest', manifest: NODE_MANIFEST }); + } + const locks = NODE_LOCKS.filter((l) => scan.eligible(l.file)); + const evidence = locks.map((l) => l.file); + const directDeclared = declaresDepIn(pkg); + const selected = (manager: string, source: EcoSource): EcoResult => + result({ + ecosystem: 'node', + manifest: NODE_MANIFEST, + manager, + source, + evidence, + directDeclared, + lockEvidenced: locks.some((l) => l.manager === manager), + }); + + const pm = pkg.packageManager; + if (pm !== undefined) { + const parsed = typeof pm === 'string' ? PACKAGE_MANAGER_RE.exec(pm) : null; + const name = parsed ? parsed[1] : null; + if (name === null || !NODE_MANAGERS.includes(name)) { + return result({ + ecosystem: 'node', + status: 'unsupported-manager', + manifest: NODE_MANIFEST, + source: 'packageManager', + evidence, + directDeclared, + }); + } + return selected(name, 'packageManager'); + } + + const families = uniq(locks.map((l) => l.manager)); + if (families.length > 1) { + return result({ + ecosystem: 'node', + status: 'ambiguous-manager', + manifest: NODE_MANIFEST, + evidence, + directDeclared, + candidates: candidatesFor(families), + }); + } + return families.length === 1 ? selected(families[0], 'lockfile') : selected('npm', 'default'); +} + +/** Strict JSON after one BOM strip; anything else — unparseable, or parsed to + * something that is not a JSON object — leaves the manifest unreadable, and locks + * and defaults are not consulted from incomplete evidence. */ +function parsePackageJson(raw: string): Record | null { + const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + return isJsonObject(parsed) ? parsed : null; +} + +function declaresDepIn(pkg: Record): boolean { + for (const field of ['dependencies', 'devDependencies'] as const) { + const deps = pkg[field]; + if (isJsonObject(deps) && Object.prototype.hasOwnProperty.call(deps, DEP_NAME)) return true; + } + return false; +} + +/** A non-null, non-array JSON object. */ +function isJsonObject(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} + +// --- Python --------------------------------------------------------------- + +function pythonResult(scan: RootScan): EcoResult { + const requirements = scan.matching(REQUIREMENTS_RE); + const manifest = pythonManifest(scan, requirements); + const refused = scan.refused(uniq([PYPROJECT, ...PY_LOCKS.map((l) => l.file), ...requirements])); + if (refused.length > 0) { + return result({ + ecosystem: 'python', + status: 'refused-evidence', + manifest, + evidence: refused.sort(byteCompare), + }); + } + const pyprojectText = scan.eligible(PYPROJECT) ? scan.read(PYPROJECT) : null; + const pipfileText = scan.eligible(PIPFILE) ? scan.read(PIPFILE) : null; + if ((scan.eligible(PYPROJECT) && pyprojectText === null) || (scan.eligible(PIPFILE) && pipfileText === null)) { + return result({ ecosystem: 'python', status: 'unreadable-manifest', manifest }); + } + + const present = PY_LOCKS.filter((l) => scan.eligible(l.file)); + const evidence = [...present.map((l) => l.file), ...requirements]; + const directDeclared = pythonDeclared(scan, pyprojectText, pipfileText, requirements); + const families = uniq(present.map((l) => l.manager)); + + if (families.length > 1) { + return result({ + ecosystem: 'python', + status: 'ambiguous-manager', + manifest, + evidence, + directDeclared, + candidates: candidatesFor(families), + }); + } + if (families.length === 1) { + // `Pipfile` alone selects pipenv from the manifest itself; only `Pipfile.lock` + // is lock evidence, which is what CI reads to choose a locked install. + const lock = present.find((l) => l.manager === families[0] && l.lock); + return result({ + ecosystem: 'python', + manifest, + manager: families[0], + source: lock ? 'lockfile' : 'manifest', + evidence, + directDeclared, + lockEvidenced: lock !== undefined, + }); + } + + const tables = pyprojectText === null ? [] : PY_TOOL_TABLES.filter((t) => tomlHasTable(pyprojectText, t.table)); + if (tables.length > 1) { + return result({ + ecosystem: 'python', + status: 'ambiguous-manager', + manifest, + evidence, + directDeclared, + candidates: candidatesFor(tables.map((t) => t.manager)), + }); + } + if (tables.length === 1) { + return result({ + ecosystem: 'python', + manifest, + manager: tables[0].manager, + source: 'tool-table', + evidence, + directDeclared, + }); + } + // Nothing named a manager: pip is the ecosystem's default, and it is print-only. + return result({ ecosystem: 'python', manifest, manager: 'pip', source: 'default', evidence, directDeclared }); +} + +/** Manifest precedence: pyproject, then Pipfile, then the conventional + * requirements files. Decided on presence, so a refused entry still names what was + * refused. */ +function pythonManifest(scan: RootScan, requirements: string[]): string | null { + if (scan.present(PYPROJECT)) return PYPROJECT; + if (scan.present(PIPFILE)) return PIPFILE; + for (const name of ['requirements-dev.txt', 'requirements.txt']) { + if (requirements.includes(name)) return name; + } + return requirements[0] ?? null; +} + +function pythonDeclared( + scan: RootScan, + pyprojectText: string | null, + pipfileText: string | null, + requirements: string[], +): boolean { + if (pyprojectText !== null && tomlDeclaresLeji(pyprojectText, PYPROJECT_FIELDS)) return true; + if (pipfileText !== null && tomlDeclaresLeji(pipfileText, PIPFILE_FIELDS)) return true; + for (const name of requirements) { + if (!scan.eligible(name)) continue; + const text = scan.read(name); + if (text !== null && requirementsDeclareLeji(text)) return true; + } + return false; +} + +// --- Go ------------------------------------------------------------------- + +const GO_DIRECTIVE_RE = /^go\s+(\d+)\.(\d+)/; + +function goResult(scan: RootScan): EcoResult { + const refused = scan.refused([GO_MANIFEST]); + if (refused.length > 0) { + return result({ ecosystem: 'go', status: 'refused-evidence', manifest: GO_MANIFEST, evidence: refused }); + } + const text = scan.read(GO_MANIFEST); + if (text === null) { + return result({ ecosystem: 'go', status: 'unreadable-manifest', manifest: GO_MANIFEST }); + } + // Tool dependencies are a Go 1.24 feature; an older or missing directive gets + // the per-person install instead. `go.sum` is the manager's business, so a + // declared tool is its own lock evidence. + const directDeclared = goDeclaresTool(text); + const modern = goDirectiveAtLeast(text, 1, 24); + return result({ + ecosystem: 'go', + manifest: GO_MANIFEST, + manager: modern ? 'go' : 'go-legacy', + source: 'manifest', + directDeclared, + lockEvidenced: modern && directDeclared, + }); +} + +function goDirectiveAtLeast(text: string, major: number, minor: number): boolean { + for (const line of splitLines(text)) { + const m = GO_DIRECTIVE_RE.exec(line.trim()); + if (!m) continue; + const found = [Number(m[1]), Number(m[2])]; + return found[0] > major || (found[0] === major && found[1] >= minor); + } + return false; +} + +/** A `tool ` line, or that path inside a `tool (` block. */ +function goDeclaresTool(text: string): boolean { + let inBlock = false; + for (const raw of splitLines(text)) { + const cut = raw.indexOf('//'); + const line = (cut >= 0 ? raw.slice(0, cut) : raw).trim(); + if (line === '') continue; + if (inBlock) { + if (line === ')') inBlock = false; + else if (line === GO_TOOL_PATH) return true; + continue; + } + if (/^tool\s*\($/.test(line)) { + inBlock = true; + continue; + } + if (line === `tool ${GO_TOOL_PATH}`) return true; + } + return false; +} + +// --- the TOML dependency scan --------------------------------------------- + +/** + * Which fields of a TOML document declare a dependency. Deliberately not a TOML + * parser: a field-specific, stateful line scan that tracks the current table, + * triple-quoted string state, and the bracket depth of the one array it is + * inspecting. Only the listed fields are inspected, so a description, a comment, + * or an unrelated table cannot produce a false positive — and a false positive is + * the expensive error here, because it suppresses the only offer the user gets. + */ +interface TomlFields { + /** Tables whose `leji = …` / `"leji" = …` key declares the dependency. */ + keyTable(table: string): boolean; + /** Table + key naming an array whose elements declare dependencies. */ + arrayField(table: string, key: string): boolean; +} + +const PYPROJECT_FIELDS: TomlFields = { + keyTable: (t) => + t === 'tool.poetry.dependencies' || + t === 'tool.poetry.dev-dependencies' || + t === 'tool.pdm.dev-dependencies' || + /^tool\.poetry\.group\.[^.]+\.dependencies$/.test(t), + arrayField: (t, k) => + (t === 'project' && k === 'dependencies') || + t === 'project.optional-dependencies' || + t === 'dependency-groups' || + (t === 'tool.uv' && k === 'dev-dependencies') || + t === 'tool.pdm.dev-dependencies', +}; + +const PIPFILE_FIELDS: TomlFields = { + keyTable: (t) => t === 'packages' || t === 'dev-packages', + arrayField: () => false, +}; + +/** A requirement whose distribution name is exactly `leji`: the name, then the + * end of the token or one of the characters that can follow a name in PEP 508 / + * requirements syntax. */ +const LEJI_REQUIREMENT_RE = /^leji($|[[=<>~!;,\s])/; + +function tomlDeclaresLeji(text: string, fields: TomlFields): boolean { + let table = ''; + let triple: string | null = null; + let depth = 0; + let inspecting = false; + for (const line of splitLines(text)) { + let i = 0; + if (triple !== null) { + const close = line.indexOf(triple); + if (close < 0) continue; + i = close + 3; + triple = null; + } else if (depth === 0) { + const header = tomlTableHeader(line); + if (header !== null) { + table = header; + continue; + } + const key = tomlKeyAt(line); + if (key === null) continue; + if (key.name === PY_DIST && fields.keyTable(table)) return true; + inspecting = fields.arrayField(table, key.name); + i = key.valueAt; + } + // One character scan carries the rest: strings (whose contents are the only + // things that can match), bracket depth (which says whether we are inside the + // inspected array), comments, and a triple quote that runs past this line. + while (i < line.length) { + const c = line[i]; + if (c === '#') break; + if (c === '"' || c === "'") { + const fence = c.repeat(3); + if (line.startsWith(fence, i)) { + const close = line.indexOf(fence, i + 3); + if (close < 0) { + triple = fence; + break; + } + // A triple-quoted string is skipped ENTIRELY, on one line as across + // several: the scanner has no TOML parser to tell a multi-line + // dependency from prose that merely starts with the name, so the + // conservative answer is the only safe one (a false positive + // suppresses the offer; a false negative costs one redundant offer). + i = close + 3; + continue; + } + const s = tomlReadString(line, i, c); + if (depth > 0 && inspecting && LEJI_REQUIREMENT_RE.test(s.text)) return true; + i = s.end; + continue; + } + if (c === '[') depth++; + else if (c === ']' && depth > 0 && --depth === 0) inspecting = false; + i++; + } + } + return false; +} + +/** + * True when the document opens the given table, or any table under it: TOML + * defines `tool.poetry` implicitly when a document writes only + * `[tool.poetry.dependencies]`, and a manager's table is present either way. The + * dot is what keeps `[tool.uvicorn]` from answering for `tool.uv`. Same header + * rules as the dependency scan, including the multi-line-string state that keeps a + * table name inside a description from counting. + */ +function tomlHasTable(text: string, table: string): boolean { + let triple: string | null = null; + for (const line of splitLines(text)) { + if (triple !== null) { + const close = line.indexOf(triple); + if (close < 0) continue; + triple = null; + continue; + } + const header = tomlTableHeader(line); + if (header !== null) { + if (header === table || header.startsWith(`${table}.`)) return true; + continue; + } + const opened = tomlOpensTriple(line); + if (opened !== null) triple = opened; + } + return false; +} + +/** The triple quote a line leaves open, or null. */ +function tomlOpensTriple(line: string): string | null { + let i = 0; + let open: string | null = null; + while (i < line.length) { + const c = line[i]; + if (c === '#') break; + if (c === '"' || c === "'") { + const fence = c.repeat(3); + if (line.startsWith(fence, i)) { + const close = line.indexOf(fence, i + 3); + if (close < 0) { + open = fence; + break; + } + i = close + 3; + continue; + } + i = tomlReadString(line, i, c).end; + continue; + } + i++; + } + return open; +} + +/** `[table]` or `[[array-of-tables]]`, with inner whitespace removed. */ +function tomlTableHeader(line: string): string | null { + const arr = /^\s*\[\[\s*([^\]]+?)\s*\]\]\s*(?:#.*)?$/.exec(line); + if (arr) return arr[1].replace(/\s+/g, ''); + const one = /^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/.exec(line); + return one ? one[1].replace(/\s+/g, '') : null; +} + +/** The key a line assigns to, bare or quoted, and where its value starts. */ +function tomlKeyAt(line: string): { name: string; valueAt: number } | null { + const m = /^\s*(?:"([^"]*)"|'([^']*)'|([A-Za-z0-9_.-]+))\s*=\s*/.exec(line); + if (!m) return null; + return { name: m[1] ?? m[2] ?? m[3] ?? '', valueAt: m[0].length }; +} + +/** One single-line basic or literal string, from its opening quote. Escapes are + * consumed, not decoded: only a `leji` prefix is ever tested against the result. */ +function tomlReadString(line: string, start: number, quote: string): { text: string; end: number } { + let text = ''; + let i = start + 1; + while (i < line.length) { + const c = line[i]; + if (quote === '"' && c === '\\') { + text += line[i + 1] ?? ''; + i += 2; + continue; + } + if (c === quote) return { text, end: i + 1 }; + text += c; + i++; + } + return { text, end: line.length }; +} + +/** A `leji` requirement line in a requirements file: the name at the start of the + * line, then end-of-line or a character that can follow a name. */ +function requirementsDeclareLeji(text: string): boolean { + for (const line of splitLines(text)) { + if (/^leji($|[\s[=<>~!;,#])/.test(line)) return true; + } + return false; +} + +function splitLines(text: string): string[] { + return text.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l)); +} + +// --- the report ----------------------------------------------------------- + +/** + * Detect the dependency ecosystems gated by files directly under `rootAbs`. + * Reads; never writes, never runs anything, never walks up. + */ +export function detectEcosystem(rootAbs: string): EcosystemReport { + const scan = new RootScan(path.resolve(rootAbs)); + const all: EcoResult[] = []; + // Fixed order, so `all` reads the same in every report and in every SDK. + if (scan.present(NODE_MANIFEST)) all.push(nodeResult(scan)); + const pythonGated = scan.present(PYPROJECT) || scan.present(PIPFILE) || scan.matching(REQUIREMENTS_RE).length > 0; + if (pythonGated) all.push(pythonResult(scan)); + if (scan.present(GO_MANIFEST)) all.push(goResult(scan)); + + if (all.length === 0) return { selected: null, all, reason: 'none' }; + if (all.length > 1) return { selected: null, all, reason: 'multiple-ecosystems' }; + const only = all[0]; + // A manager-less single ecosystem carries its own reason up: the report's + // `reason` is never a second, independently derived verdict. + if (only.status !== 'ok') return { selected: null, all, reason: only.status }; + return { selected: only, all, reason: null }; +} + +/** The runner argv for one manager name, or null when leji does not know it. One + * runner table serves detection, the hook, and CI. */ +export function managerRunnerArgv(manager: string): string[] | null { + return MANAGER_COMMANDS[manager]?.runner ?? null; +} + +/** The plain install argv for one manager name: what a joiner runs on a fresh clone + * so the CLI the repository declares actually resolves. Null when leji does not know + * the manager, or when the manager has no single install command. */ +export function managerInstallArgv(manager: string): string[] | null { + return MANAGER_COMMANDS[manager]?.install ?? null; +} + +/** The argv a hook or CI job runs `leji` with: the detected manager's runner when + * the repository actually declares the CLI, else the plain fallback on PATH. */ +export function runnerArgv(report: EcosystemReport): string[] { + const s = report.selected; + return s !== null && s.directDeclared && s.runner !== null ? s.runner : PLAIN_RUNNER; +} + +/** The always-printed human block: what was detected, and what to run to declare + * the Leji CLI. Never a prompt, never a command run — the caller owns both. */ +export function renderEcosystemBlock(report: EcosystemReport): string { + return blockLines(report).join('\n'); +} + +function blockLines(report: EcosystemReport): string[] { + if (report.reason === 'none') return TEXT.none; + if (report.reason === 'multiple-ecosystems') { + // A print-only ecosystem (pip, pre-1.24 Go) contributes no command here; the + // lead sentence closes with a period rather than dangling a colon. + const commands = report.all.flatMap(commandLines); + return [ + TEXT.multiple( + report.all.map((r) => r.manifest ?? r.ecosystem), + commands.length > 0, + ), + ...commands, + ]; + } + const only = report.all[0]; + if (only.directDeclared && only.manifest !== null) return [TEXT.declared(only.manifest)]; + switch (only.status) { + case 'refused-evidence': + return [TEXT.refused(only.evidence)]; + case 'unreadable-manifest': + return [TEXT.unreadable(only.manifest ?? '')]; + case 'unsupported-manager': + return [TEXT.unsupported(only.manifest ?? '')]; + case 'ambiguous-manager': + return [TEXT.ambiguous(only.manifest ?? '', only.evidence), ...commandLines(only)]; + default: + return okLines(only); + } +} + +/** The offer for one ecosystem that chose a manager. A manager with no add + * command prints its own guidance instead. */ +function okLines(r: EcoResult): string[] { + if (r.manager === 'pip') { + return r.manifest === PYPROJECT ? TEXT.pipGroups(deciderFile(r)) : TEXT.pipRequirements(deciderFile(r)); + } + if (r.manager === 'go-legacy') return TEXT.goLegacy(deciderFile(r)); + return [TEXT.offer(r.manager ?? '', deciderFile(r)), ...commandLines(r)]; +} + +/** The indented command line(s) for one result: its own add command, or one per + * candidate when the evidence could not choose. */ +function commandLines(r: EcoResult): string[] { + if (r.add !== null) return [`${INDENT}${r.add.join(' ')}`]; + return r.candidates.filter((c) => c.add !== null).map((c) => `${INDENT}${(c.add as string[]).join(' ')}`); +} + +/** The one file a message names as the evidence for the manager: the lockfile + * that selected it, the pyproject that carried its tool table, or the manifest. */ +function deciderFile(r: EcoResult): string { + if (r.source === 'lockfile') { + const lock = r.evidence.find( + (f) => + NODE_LOCKS.some((l) => l.file === f && l.manager === r.manager) || + PY_LOCKS.some((l) => l.file === f && l.manager === r.manager && l.lock), + ); + if (lock !== undefined) return lock; + } + if (r.source === 'tool-table') return PYPROJECT; + if (r.ecosystem === 'python' && r.source === 'manifest') return PIPFILE; + return r.manifest ?? ''; +} + +/** The one line `leji detect` prints about the ecosystem. */ +export function renderEcosystemLine(report: EcosystemReport): string { + if (report.reason === 'none') return TEXT.line.none; + if (report.reason === 'multiple-ecosystems') { + return TEXT.line.multiple(report.all.map((r) => r.manifest ?? r.ecosystem)); + } + const only = report.all[0]; + switch (only.status) { + case 'refused-evidence': + return TEXT.line.refused(only.evidence); + case 'unreadable-manifest': + return TEXT.line.unreadable(only.manifest ?? ''); + case 'unsupported-manager': + return TEXT.line.unsupported(only.manifest ?? ''); + case 'ambiguous-manager': + return TEXT.line.ambiguous(only.manifest ?? '', only.evidence); + default: + return TEXT.line.selected(only.manager ?? '', deciderFile(only), only.directDeclared); + } +} diff --git a/packages/sdk/src/lib/findings.ts b/packages/sdk/src/lib/findings.ts index aece826..0400292 100644 --- a/packages/sdk/src/lib/findings.ts +++ b/packages/sdk/src/lib/findings.ts @@ -10,6 +10,12 @@ export interface Finding { severity: Severity; /** Repository-root-relative POSIX path the finding points at, when it has one. */ path?: string; + /** 1-based line within `path`, when the rule locates one (the rendering lint). */ + line?: number; + /** The closed-token construct a rule names, when it carries one: what the three + * SDKs compare on for `render-unsupported`, message text being outside the + * contract. */ + construct?: string; message: string; } @@ -22,12 +28,20 @@ export function finding(rule: string, severity: Severity, message: string, path? return path === undefined ? { rule, severity, message } : { rule, severity, path, message }; } +/** Findings in canonical order: (path, line, rule, construct), message last as the + * final tie-break. The line and construct keys carry the rendering lint's ordering + * — two constructs reported on one line stay in the same order in all three SDKs — + * and change nothing for a rule that locates neither. */ export function sortFindings(findings: Finding[]): Finding[] { return [...findings].sort((a, b) => { const p = byteCompare(a.path ?? '', b.path ?? ''); if (p !== 0) return p; + const l = (a.line ?? 0) - (b.line ?? 0); + if (l !== 0) return l; const r = byteCompare(a.rule, b.rule); - return r !== 0 ? r : byteCompare(a.message, b.message); + if (r !== 0) return r; + const c = byteCompare(a.construct ?? '', b.construct ?? ''); + return c !== 0 ? c : byteCompare(a.message, b.message); }); } diff --git a/packages/sdk/src/lib/fsx.ts b/packages/sdk/src/lib/fsx.ts index 0f04b91..370f1cb 100644 --- a/packages/sdk/src/lib/fsx.ts +++ b/packages/sdk/src/lib/fsx.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { type TargetVerdict, writableTarget } from './layout.js'; export function toPosix(p: string): string { return p.split(path.sep).join('/'); @@ -31,62 +32,445 @@ export function readText(abs: string): string { /** * Read a declared file's text, only if it is a regular file whose real path stays - * within `rootAbs`. Returns null when missing, not a regular file, or symlinked - * out of root. Use for every manifest-declared path so a hostile layer cannot use - * a symlink to redirect a reader (CLI, or MCP exposing reads to an agent) out. + * within `rootAbs`. Returns null when missing, not a regular file, symlinked out of + * root, or unresolvable. Use for every manifest-declared path so a hostile layer + * cannot use a symlink to redirect a reader (CLI, or MCP exposing reads to an agent) + * out. */ export function readTextWithin(rootAbs: string, abs: string): string | null { - if (!isFile(abs) || !realpathWithin(rootAbs, abs)) return null; + if (!isFile(abs) || !resolvedWithinRoot(rootAbs, abs)) return null; return readText(abs); } /** - * True when `abs` resolves (following symlinks) within `rootAbs`. Escaping - * symlinks are rejected; a non-existent path cannot escape, so it is allowed. + * `abs` with every symlink in it resolved, and with the filesystem's own spelling + * of each existing component — so a case-variant path on a case-insensitive + * filesystem comes back canonical. A path that does not exist yet resolves through + * its nearest existing ancestor, with the remainder re-appended, so a caller can + * judge a write target before anything is created under it. Null when even the + * ancestor cannot be resolved. + * + * Judge with this whenever a decision and the write it guards must be about the + * same path: a lexical comparison answers for the spelling, not for the file. + * + * `realpathSync.native`, deliberately: the JavaScript implementation hands back the + * spelling it was given, so on a case-insensitive filesystem `.LEJI/x` stays + * `.LEJI/x` and compares unequal to the very directory it opens. Only the platform + * call reports the name the filesystem actually holds. */ -export function realpathWithin(rootAbs: string, abs: string): boolean { - let resolvedRoot: string; +export function resolvedPath(abs: string): string | null { try { - resolvedRoot = fs.realpathSync(rootAbs); - } catch { - return false; - } - let real: string; - try { - real = fs.realpathSync(abs); - } catch { - return true; // non-existent target cannot point outside via a symlink + return fs.realpathSync.native(abs); // path exists (e.g. overwrite target / vendor file) + } catch (e) { + // Only genuine nonexistence is rebuilt lexically from the nearest existing + // ancestor. A permission or I/O error (EACCES, EIO, ELOOP, ENOTDIR, …) means + // the path exists but cannot be resolved: it FAILS the check (null) rather + // than being reconstructed as if it were an absent write target — a resolved + // decision and the write it guards must be about the same real path. + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') return null; + // A dangling symlink at the final component: realpath cannot follow it to a + // missing target, but a write WOULD follow it there, so resolve the link's + // target rather than treating the link's own name as the location — otherwise a + // symlink into a private role reads as its own path and slips the boundary. + // (realpath already proved the chain has no loop; a loop throws ELOOP, refused + // above as unresolvable.) A missing final component that is not a symlink falls + // through to the ancestor walk, the normal not-yet-created write target. + let st: fs.Stats | undefined; + try { + st = fs.lstatSync(abs); + } catch { + st = undefined; + } + if (st?.isSymbolicLink()) { + return resolvedPath(path.resolve(path.dirname(abs), fs.readlinkSync(abs))); + } + // Walk to the nearest existing ancestor. A dangling symlink in an INTERMEDIATE + // component is not "absent": a write would follow it, so follow it here too — + // resolve the link and re-root the remainder onto its target, rather than + // climbing past it and rebuilding the link's own name lexically. Otherwise a + // nested `redirect/export` whose `redirect` dangles into a private role reads + // as `.../redirect/export` (outside `.leji/`) and a target created after the + // check lands the write inside the role — the check/use race this closes. + let p = path.dirname(abs); + while (!fs.existsSync(p) && path.dirname(p) !== p) { + let linkStat: fs.Stats | undefined; + try { + linkStat = fs.lstatSync(p, { throwIfNoEntry: false }); + } catch { + return null; // p is present but cannot be lstat'd (permission/I/O): unresolvable + } + if (linkStat?.isSymbolicLink()) { + return resolvedPath(path.join(path.resolve(path.dirname(p), fs.readlinkSync(p)), path.relative(p, abs))); + } + p = path.dirname(p); + } + try { + return path.join(fs.realpathSync.native(p), path.relative(p, abs)); + } catch { + return null; + } } - return real === resolvedRoot || real.startsWith(resolvedRoot + path.sep); +} + +/** + * The repository root as every guard judges it: absolute and realpath-resolved, + * falling back to the absolute spelling when it cannot be resolved at all. Both + * sides of the containment rule must come through the same resolver, or a root + * reached through a symlinked ancestor (`/tmp` -> `/private/tmp`) compares unequal + * to its own children and every write under it reads as an escape. + */ +export function guardRoot(root: string): string { + const abs = path.resolve(root); + return resolvedPath(abs) ?? abs; } /** * True when `abs` resolves (following symlinks) within `rootAbs`, even when `abs` - * does not yet exist. Unlike `realpathWithin`, a non-existent target is checked + * does not yet exist: a non-existent target is checked * via its nearest existing ancestor, so a symlinked ancestor that escapes root is * caught before a write creates the file under it. */ export function resolvedWithinRoot(rootAbs: string, abs: string): boolean { - let real: string; + const real = resolvedPath(abs); + if (real === null) return false; + let realRoot: string; try { - real = fs.realpathSync(abs); // path exists (e.g. overwrite target / vendor file) + // Both sides through the same resolver: a root resolved one way and a child + // the other would differ in spelling alone and read as an escape. + realRoot = fs.realpathSync.native(rootAbs); } catch { - // Does not exist yet: resolve the nearest existing ancestor, then re-append. - let p = path.dirname(abs); - while (!fs.existsSync(p) && path.dirname(p) !== p) p = path.dirname(p); + return false; + } + 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. */ +function judgeTarget( + rootAbs: string, + targetAbs: string, + ownRoleRel: string | null, +): { 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 }; +} + +/** + * The single guarded-write chokepoint (check-before-act). Realpath-resolve + * `targetAbs`, run {@link writableTarget} on the resolved path, and perform the + * write or clear — through `op`, on that resolved path — ONLY when the target is + * allowed to land there, which means all of: it resolves at all; it resolves INSIDE + * the repository root, with no exceptions; and it lands outside root `.leji/` or + * inside the one role `ownRoleRel` names. On refusal nothing is touched: the verdict + * is returned (unresolvable, outside the repository, or the private `.leji/` role the + * target crossed into) so the caller renders the mandated hard refusal in its own + * channel — a generation `finding`, or a thrown build error — before any byte is + * written. + * + * `rootAbs` must already be realpath-resolved ({@link guardRoot}). `ownRoleRel` names + * the one `.leji/` role this write may legitimately land in, or `null` when the target + * has no `.leji/` role at all (user content such as overview.md). One home for every + * write whose target derives from user-influenceable input, so a new write site is + * guarded by construction rather than by remembering to guard it — and the guarded + * conveniences below are how command modules reach it, so no command spells a raw + * write primitive of its own. + */ +export function guardedWrite( + rootAbs: string, + targetAbs: string, + ownRoleRel: string | null, + op: (resolved: string) => void, +): TargetVerdict { + const { verdict, resolved } = judgeTarget(rootAbs, targetAbs, ownRoleRel); + if (verdict.ok && resolved !== null) op(resolved); + return verdict; +} + +/** + * Write `bytes` to a guarded target, creating its parent directories only when the + * write itself happens (a refused run establishes nothing). `opts.mode` sets the + * mode at creation; `opts.exclusive` creates with O_EXCL, so a target that already + * exists comes back as the `exists` verdict rather than being overwritten or + * followed through a planted symlink. + * + * An exclusive create is decided on the ORIGINAL directory entry before anything is + * resolved: ANY standing entry — a regular file, a directory, a symlink whether it + * dangles or not — is `exists`. Resolving first would defeat the point, because a + * dangling symlink resolves to its missing destination, and `O_EXCL` on that + * destination would happily create the file the link points at. Nothing stands there + * ⇒ the resolved path is judged (its parents included) and `O_EXCL` still closes the + * race between that judgement and the create. + */ +export function writeFileGuarded( + rootAbs: string, + targetAbs: string, + ownRoleRel: string | null, + bytes: string | Buffer, + opts: { mode?: number; exclusive?: boolean } = {}, +): TargetVerdict { + if (opts.exclusive === true && fs.lstatSync(targetAbs, { throwIfNoEntry: false }) !== undefined) { + return { ok: false, exists: true }; + } + let exists = false; + const verdict = guardedWrite(rootAbs, targetAbs, ownRoleRel, (resolved) => { + const options: { mode?: number; flag?: string } = {}; + if (opts.mode !== undefined) options.mode = opts.mode; + if (opts.exclusive === true) options.flag = 'wx'; + fs.mkdirSync(path.dirname(resolved), { recursive: true }); try { - real = path.join(fs.realpathSync(p), path.relative(p, abs)); + fs.writeFileSync(resolved, bytes, options); + } catch (e) { + if (opts.exclusive === true && (e as NodeJS.ErrnoException).code === 'EEXIST') { + exists = true; + return; + } + throw e; + } + }); + return exists ? { ok: false, exists: true } : verdict; +} + +/** A guarded directory: the RESOLVED directory the rule judged, so every act that + * follows works from the path that was checked rather than re-joining its own. */ +export type GuardedDir = { ok: true; real: string } | ({ ok: false } & TargetVerdict); + +/** Create a guarded directory and every missing parent, and hand back the resolved + * path it was created at. */ +export function mkdirpGuarded(rootAbs: string, targetAbs: string, ownRoleRel: string | null): GuardedDir { + const { verdict, resolved } = judgeTarget(rootAbs, targetAbs, ownRoleRel); + if (!verdict.ok || resolved === null) return { ...verdict, ok: false }; + fs.mkdirSync(resolved, { recursive: true }); + return { ok: true, real: resolved }; +} + +/** Clear a guarded target: recursive, and absent is success (the clean-rebuild + * form every generator uses). */ +export function rmGuarded(rootAbs: string, targetAbs: string, ownRoleRel: string | null): TargetVerdict { + return guardedWrite(rootAbs, targetAbs, ownRoleRel, (resolved) => { + fs.rmSync(resolved, { recursive: true, force: true }); + }); +} + +/** Rename with BOTH ends judged before either is touched, so neither the source + * nor the destination can be redirected out of the rule by a planted symlink. */ +export function renameGuarded( + rootAbs: string, + fromAbs: string, + toAbs: string, + ownRoleRel: string | null, +): TargetVerdict { + const from = judgeTarget(rootAbs, fromAbs, ownRoleRel); + if (!from.verdict.ok || from.resolved === null) return from.verdict; + const to = judgeTarget(rootAbs, toAbs, ownRoleRel); + if (!to.verdict.ok || to.resolved === null) return to.verdict; + fs.renameSync(from.resolved, to.resolved); + return { ok: true }; +} + +/** Set the mode of a guarded target. */ +export function chmodGuarded( + rootAbs: string, + targetAbs: string, + ownRoleRel: string | null, + mode: number, +): TargetVerdict { + return guardedWrite(rootAbs, targetAbs, ownRoleRel, (resolved) => { + fs.chmodSync(resolved, mode); + }); +} + +/** A guarded destination opened for writing: the descriptor and the resolved path + * it is bound to, or the refusal verdict. The caller writes into `fd` and closes + * it; the bytes then land in the file the rule judged, never in a path re-opened + * afterwards. */ +export type GuardedOpen = { ok: true; fd: number; real: string } | ({ ok: false } & TargetVerdict); + +/** Open a guarded destination for writing (truncating), creating its parent + * directories only when the open actually happens. */ +export function openWriteGuarded( + rootAbs: string, + targetAbs: string, + ownRoleRel: string | null, + opts: { mode?: number } = {}, +): GuardedOpen { + const { verdict, resolved } = judgeTarget(rootAbs, targetAbs, ownRoleRel); + if (!verdict.ok || resolved === null) return { ...verdict, ok: false }; + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + const fd = opts.mode === undefined ? fs.openSync(resolved, 'w') : fs.openSync(resolved, 'w', opts.mode); + return { ok: true, fd, real: resolved }; +} + +/** + * Write a guarded target atomically: a temp sibling in the same directory, then a + * rename onto the destination, so an interrupted write never leaves a partial file. + * Both paths are judged before either is touched — a planted `.leji-tmp` + * symlink would otherwise be written through before the rename — and the temp is + * removed when anything fails, so the whole compound operation lives here rather + * than being re-composed at each call site. + */ +export function writeFileAtomicGuarded( + rootAbs: string, + targetAbs: string, + ownRoleRel: string | null, + bytes: string | Buffer, +): TargetVerdict { + const tmp = judgeTarget(rootAbs, `${targetAbs}.leji-tmp`, ownRoleRel); + if (!tmp.verdict.ok || tmp.resolved === null) return tmp.verdict; + const dest = judgeTarget(rootAbs, targetAbs, ownRoleRel); + if (!dest.verdict.ok || dest.resolved === null) return dest.verdict; + try { + fs.mkdirSync(path.dirname(dest.resolved), { recursive: true }); + fs.writeFileSync(tmp.resolved, bytes); + maybeInjectWriteFailure(); + fs.renameSync(tmp.resolved, dest.resolved); + } catch (e) { + try { + fs.rmSync(tmp.resolved, { force: true }); } catch { - return false; + /* best-effort cleanup; the caller reports the original failure */ } + throw e; + } + return { ok: true }; +} + +/** Test-only fault injection for {@link writeFileAtomicGuarded}: with + * LEJI_TEST_FAIL_RENAME set, fail after the temp file exists but before the rename, + * to exercise the cleanup and the caller's normalized-error path. */ +function maybeInjectWriteFailure(): void { + if (process.env.LEJI_TEST_FAIL_RENAME) throw new Error('injected write failure'); +} + +/** An opened source: the descriptor when the source passed every check (the caller + * closes it), else null — with the resolved path, when it could be resolved at all, + * so a refusal can name where the source actually landed. */ +export interface VerifiedSource { + fd: number | null; + real: string | null; +} + +/** + * The guarded-READ counterpart of {@link guardedWrite} (check-before-act), for + * every source whose bytes are about to be served, linted, or exported. Resolve + * `abs` natively, judge the RESOLVED path with `allow`, then open that path and + * prove the DESCRIPTOR is a regular file with `fstat` — so the file the check + * judged is the file the read gets. A path-based check leaves two windows open: an + * ancestor directory swapped to a symlink after enumeration (an `lstat` of the final + * component follows it and reports an ordinary file), and the gap between any check + * and a later read or copy by path. Reading from the descriptor closes both: the + * inode is pinned by the open. + * + * The open itself is by path, so one window survives that: a swap landing between the + * resolve above and the open makes the open follow the new link, and `fstat` sees only + * an ordinary regular file. So the source is resolved ONCE MORE after the open and the + * descriptor is required to be that same location and that same (dev, ino) — the bytes + * about to be read are then provably the ones `allow` judged. What remains is the + * recorded check-before-act limit (`docs/practice/trust-boundary.md`): an attacker must + * swap AND revert within the open→recheck span to pass both resolutions, since portable + * Node offers no `openat` to walk the path once. + * + * The caller closes `fd` when it is non-null, and owns the refusal semantics — a + * silent drop, a boundary warning, or an error — since only it knows which the + * source deserves. A source that vanished between the check and the open is one such + * refusal; any other I/O error on an allowed path is the filesystem failing rather + * than the boundary refusing, so it throws as a read by path always has. + */ +export function openVerifiedSource(abs: string, allow: (resolved: string) => boolean): VerifiedSource { + const real = resolvedPath(abs); + if (real === null || !allow(real)) return { fd: null, real }; + let fd: number; + try { + fd = fs.openSync(real, 'r'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + return { fd: null, real }; // gone between the check and the open } - let realRoot: string; try { - realRoot = fs.realpathSync(rootAbs); + const opened = fs.fstatSync(fd); + if (!opened.isFile()) { + fs.closeSync(fd); + return { fd: null, real }; + } + // The recheck. A refusal names where the source resolves NOW, not where it + // resolved before the swap, so the caller's boundary message points at the role + // the bytes would actually have come from. + const recheck = resolvedPath(abs); + const landed = recheck === null ? null : fs.statSync(recheck); + if (recheck !== real || landed === null || landed.dev !== opened.dev || landed.ino !== opened.ino) { + fs.closeSync(fd); + return { fd: null, real: recheck ?? real }; + } } catch { + fs.closeSync(fd); + return { fd: null, real }; + } + return { fd, real }; +} + +/** What stood at a read-then-act target, judged by the same rule the write will be: + * nothing (`absent`), a regular file whose verified bytes are carried along + * (`regular`), or a standing entry this run refuses to act through (`refused`, with + * the reason and where it resolved, when it resolved at all). */ +export type VerifiedTargetRead = + | { status: 'absent'; real: string } + | { status: 'regular'; real: string; bytes: Buffer } + | { + status: 'refused'; + real: string | null; + reason: 'outside-root' | 'other-role' | 'not-regular' | 'unverifiable'; + }; + +/** + * Read a target that is about to be written, under the write rule itself: the + * shape every "look at what is there, then act on it" command needs, so none of + * them re-composes it. + * + * The ORIGINAL directory entry decides the kind first — a socket, a FIFO, a device + * node or a directory standing at the target is refused rather than opened, and a + * symlink is settled on what it resolves TO, because the open would follow it. + * Then {@link openVerifiedSource} judges the RESOLVED path against + * {@link writableTarget} for this role and proves the descriptor is that same + * regular file, so the bytes come back from the inode the rule cleared. + * + * `absent` is decided on the original entry, never on where it resolves: a dangling + * symlink resolves to a missing destination while the link itself is still standing, + * and a standing entry this run could not verify is `refused/unverifiable`, never a + * write through it. Operational I/O failures on an allowed path PROPAGATE, as a read + * by path always has; only containment, entry kind, and verification become refusals. + */ +export function verifiedTargetRead(rootAbs: string, targetAbs: string, ownRoleRel: string | null): VerifiedTargetRead { + const entry = fs.lstatSync(targetAbs, { throwIfNoEntry: false }); + if (entry !== undefined && !entry.isFile() && !entry.isSymbolicLink()) { + return { status: 'refused', real: resolvedPath(targetAbs), reason: 'not-regular' }; + } + if (entry?.isSymbolicLink()) { + const followed = fs.statSync(targetAbs, { throwIfNoEntry: false }); + if (followed !== undefined && !followed.isFile()) { + return { status: 'refused', real: resolvedPath(targetAbs), reason: 'not-regular' }; + } + } + let refusal: 'outside-root' | 'other-role' | null = null; + const { fd, real } = openVerifiedSource(targetAbs, (resolved) => { + const verdict = writableTarget(rootAbs, resolved, ownRoleRel); + if (verdict.ok) return true; + refusal = verdict.outsideRoot === true ? 'outside-root' : 'other-role'; return false; + }); + if (fd !== null && real !== null) { + try { + return { status: 'regular', real, bytes: fs.readFileSync(fd) }; + } finally { + fs.closeSync(fd); + } } - return real === realRoot || real.startsWith(realRoot + path.sep); + if (refusal !== null) return { status: 'refused', real, reason: refusal }; + if (real === null) return { status: 'refused', real: null, reason: 'unverifiable' }; + // Nothing verified was opened, and only ONE thing may follow from that: the + // target is absent. Anything still standing there is a refusal. + return fs.lstatSync(targetAbs, { throwIfNoEntry: false }) === undefined + ? { status: 'absent', real } + : { status: 'refused', real, reason: 'unverifiable' }; } /** @@ -97,7 +481,7 @@ export function walkMd(root: string, relPath: string): string[] { const rootAbs = path.resolve(root); const abs = path.join(root, relPath); if (isFile(abs)) { - return relPath.endsWith('.md') && realpathWithin(rootAbs, abs) ? [toPosix(relPath)] : []; + return relPath.endsWith('.md') && resolvedWithinRoot(rootAbs, abs) ? [toPosix(relPath)] : []; } if (!isDir(abs)) return []; const out: string[] = []; @@ -109,10 +493,10 @@ export function walkMd(root: string, relPath: string): string[] { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === 'node_modules') continue; - if (!realpathWithin(rootAbs, full)) continue; + if (!resolvedWithinRoot(rootAbs, full)) continue; stack.push(full); } else if (entry.isFile() && entry.name.endsWith('.md')) { - if (!realpathWithin(rootAbs, full)) continue; + if (!resolvedWithinRoot(rootAbs, full)) continue; out.push(toPosix(path.relative(root, full))); } } diff --git a/packages/sdk/src/lib/layer.ts b/packages/sdk/src/lib/layer.ts index dd6a464..b3272e2 100644 --- a/packages/sdk/src/lib/layer.ts +++ b/packages/sdk/src/lib/layer.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; import { type Finding, finding } from './findings.js'; -import { exists, isDir, isFile, readText, readTextWithin, realpathWithin, underPath, walkMd } from './fsx.js'; +import { exists, isDir, isFile, readText, readTextWithin, resolvedWithinRoot, underPath, walkMd } from './fsx.js'; import { parseFrontmatter } from './frontmatter.js'; import { type DocKind, parseIndexFile } from './indexfile.js'; import { @@ -341,23 +341,36 @@ function scanFrontmatterArtifact( return { relPath, frontmatter: fm.data, body: fm.body, findings }; } +/** + * How a scan gets one artifact's bytes, and whether it may have them at all. The + * default reads by path; a caller composing something it will serve or export passes + * a reader that binds the check to the read (check-before-act), and returns null for a source it + * refuses — missing, not a regular file, or resolving somewhere it may not be read + * from. A refused artifact is dropped from the scan, exactly as the whitelist filter + * it replaces dropped it, so validation (which passes no reader) is unaffected. + */ +export type ArtifactReader = (relPath: string) => string | null; + function scanFrontmatterArtifacts( root: string, dir: string, schemaName: 'agent-profile' | 'decision-record', rule: string, + read?: ArtifactReader, ): ScannedProfile[] { const out: ScannedProfile[] = []; for (const relPath of walkMd(root, dir)) { if (path.posix.basename(relPath).toLowerCase() === 'readme.md') continue; - out.push(scanFrontmatterArtifact(readText(path.join(root, relPath)), relPath, schemaName, rule)); + const text = read === undefined ? readText(path.join(root, relPath)) : read(relPath); + if (text === null) continue; + out.push(scanFrontmatterArtifact(text, relPath, schemaName, rule)); } return out; } -export function scanAgentProfiles(root: string, manifest: Manifest): ScannedProfile[] { +export function scanAgentProfiles(root: string, manifest: Manifest, read?: ArtifactReader): ScannedProfile[] { const dir = effectiveAgentProfilesPath(manifest); - return scanFrontmatterArtifacts(root, dir, 'agent-profile', 'profile-frontmatter'); + return scanFrontmatterArtifacts(root, dir, 'agent-profile', 'profile-frontmatter', read); } /** @@ -376,12 +389,21 @@ export function scanAgentProfiles(root: string, manifest: Manifest): ScannedProf * reporting either twice. */ export function scanProfileSet(root: string, manifest: Manifest): ScannedProfile[] { - const profiles = scanAgentProfiles(root, manifest); + return scanProfileSetWith(root, manifest); +} + +/** + * The same scan through a caller's reader — the seam a viewer or export needs and + * nobody outside this package does, so it stays out of the barrel (`index.ts`) + * while {@link scanProfileSet} keeps the surface others build against. + */ +export function scanProfileSetWith(root: string, manifest: Manifest, read?: ArtifactReader): ScannedProfile[] { + const profiles = scanAgentProfiles(root, manifest, read); const dir = effectiveAgentProfilesPath(manifest); const seen = new Set(profiles.map((p) => p.relPath)); for (const rel of Object.values(manifest.agents ?? {})) { if (seen.has(rel) || underPath(rel, dir)) continue; - const text = readTextWithin(path.resolve(root), path.join(root, rel)); + const text = read === undefined ? readTextWithin(path.resolve(root), path.join(root, rel)) : read(rel); if (text === null) continue; // missing or escaping: the agents-map check owns that seen.add(rel); profiles.push(scanFrontmatterArtifact(text, rel, 'agent-profile', 'profile-frontmatter')); @@ -642,7 +664,7 @@ export function readJsonArtifact(root: string, relPath: string): { data: unknown if (!isFile(abs)) { return { data: null }; } - if (!realpathWithin(path.resolve(root), abs)) { + if (!resolvedWithinRoot(path.resolve(root), abs)) { return { data: null, finding: finding('artifact-parse', 'error', `artifact ${relPath} resolves outside the layer root`, relPath), diff --git a/packages/sdk/src/lib/layout.ts b/packages/sdk/src/lib/layout.ts new file mode 100644 index 0000000..8d0550b --- /dev/null +++ b/packages/sdk/src/lib/layout.ts @@ -0,0 +1,105 @@ +import * as path from 'node:path'; + +/** + * The unified `.leji/` layout: one tree at the repository root holding every role + * the tool owns, whatever `rootPath` the layer declares. Roles are + * repository-root-relative by construction — a generated artifact never lives + * inside the context root, so the content walk and the served content mount carry + * nothing of the tool's own. + * + * - `mounts/` + `mounts.local.json` — the private federation domain (owned by + * lib/mounts.ts, which spells the paths inside it; never servable, never + * exportable). + * - `viewer/` — generated chrome, the ONE servable role. + * - `dist/` — the default export output. + * - `work/` — the transient onboarding workspace. + */ +export const LEJI_DIR = '.leji'; + +/** Generated viewer chrome (index.html, _sidebar.md, _manifest.md, assets/). */ +export const VIEWER_REL = `${LEJI_DIR}/viewer`; + +/** Default export output; the only role a caller-supplied `--out` may name. */ +export const DIST_REL = `${LEJI_DIR}/dist`; + +/** Transient onboarding workspace (brief, proposal, hooks). */ +export const WORK_REL = `${LEJI_DIR}/work`; + +/** The private federation domain: managed object stores, projection cache, staging. */ +export const MOUNTS_REL = `${LEJI_DIR}/mounts`; + +/** True when `abs` is `dir` or sits underneath it. */ +function under(dir: string, abs: string): boolean { + return abs === dir || abs.startsWith(dir + path.sep); +} + +/** + * The servable-roots whitelist: a path may be served or exported only when it + * lies outside root `.leji/` entirely, or inside `.leji/viewer/`. Every other + * role under `.leji/` — the private mounts domain, the export output, the + * onboarding workspace, and any role added later — is denied **by name**, so a + * new role is born unservable and no relaxation of the dot-segment refusal (kept + * as defense in depth) can open the trust domain as a side effect. + * + * `rootAbs` must be a resolved (realpath'd) repository root, and `abs` is judged + * both as requested and after symlink resolution: the name is what decides, not + * how the caller spelled it. + */ +export function servablePath(rootAbs: string, abs: string): boolean { + const leji = path.join(rootAbs, LEJI_DIR); + if (!under(leji, abs)) return true; + return under(path.join(rootAbs, VIEWER_REL), abs); +} + +/** + * The private `.leji/` role a resolved path falls into: the first path segment + * under `.leji/` (`mounts`, `work`, `dist`, `viewer`, or any future role name), + * or `''` when the path is `.leji/` itself. Callers establish that `abs` is under + * `.leji/` before asking; used to name the role in a boundary message. + */ +export function lejiRole(rootAbs: string, abs: string): string { + const rest = path.relative(path.join(rootAbs, LEJI_DIR), abs); + return rest === '' ? '' : rest.split(path.sep)[0]; +} + +/** The verdict of {@link writableTarget}: 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. */ +export interface TargetVerdict { + ok: boolean; + role?: string; + unresolvable?: boolean; + outsideRoot?: true; + exists?: true; +} + +/** + * The check-before-act rule for a WRITE or CLEAR target, judged on the RESOLVED + * path immediately before the act, in this order: + * + * 1. The target must resolve INSIDE the repository root. Every write this tool makes + * lands in the repository it was pointed at, with no exceptions: a `.leji/` role + * symlinked out of the tree is refused rather than followed. A user who wants the + * export somewhere else copies the finished folder there. + * 2. A target under root `.leji/` is refused — that tree is the tool's own trust + * domain — UNLESS `ownRoleRel` is given and the target lies under that one role. + * 3. Anything else inside the repository is ordinary content and is allowed. + * + * Both `rootAbs` and `resolvedAbs` must be realpath-resolved, so a redirecting + * symlink or a case-variant spelling is judged by where it lands, not by how it was + * written. One home for the rule, called before every write and clear. + * + * `ownRoleRel` names the ONE `.leji/` role the target may land in, as a lexical path + * under the resolved root; pass `null` when the target has no legitimate `.leji/` + * role at all (user content such as overview.md, which lives under the content root, + * never inside `.leji/`) — then any `.leji/` landing is refused. + */ +export function writableTarget(rootAbs: string, resolvedAbs: string, ownRoleRel: string | null): TargetVerdict { + if (!under(rootAbs, resolvedAbs)) return { ok: false, outsideRoot: true }; + const leji = path.join(rootAbs, LEJI_DIR); + if (!under(leji, resolvedAbs)) return { ok: true }; // inside the repository, outside .leji/ + if (ownRoleRel !== null && under(path.join(rootAbs, ownRoleRel), resolvedAbs)) return { ok: true }; // its own role + return { ok: false, role: lejiRole(rootAbs, resolvedAbs) }; +} diff --git a/packages/sdk/src/lib/localcli.ts b/packages/sdk/src/lib/localcli.ts new file mode 100644 index 0000000..50602da --- /dev/null +++ b/packages/sdk/src/lib/localcli.ts @@ -0,0 +1,341 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { MIN_SDK_FOR_SPEC_LINE, VERSION_RE, installedNodeBin } from '../commands/preflight.js'; +import { effectiveRoot } from '../index.js'; +import { DEP_NAME, detectEcosystem } from './ecosystem.js'; +import { guardRoot, openVerifiedSource, resolvedPath, toPosix } from './fsx.js'; +import { MANIFEST_FILENAME } from './manifest.js'; + +/** + * The hand-off the INSTALLED EXECUTABLE performs before it parses anything: inside a + * repository that declares the Leji CLI and has it installed, an invocation of the + * global `leji` belongs to the repository's own pinned copy, so a teammate, a hook, + * CI, and a person typing `leji` all run one version of the tool. + * + * Nothing here is reachable from the library. `run()` is imported in-process by the + * scaffolder and by tests, and a library call must never turn into another program. + * + * Every negative outcome is SILENT and spawns nothing: the global runs exactly as it + * did before, so a repository that does not qualify pays a few bounded reads and + * notices no difference. The reads that decide the execution are the verified form + * (`openVerifiedSource`), because the bytes that decide what runs must come from the + * file the containment check cleared. The residual is the recorded check-before-act limit, stated + * in `docs/practice/trust-boundary.md`: a target swapped between the check and the + * exec cannot be closed portably, and it is named in the allowance rather than + * claimed away. + */ + +/** The node ecosystem statuses that still permit a hand-off. Which package manager + * a repository uses is irrelevant here: the target is the installed package itself, + * never a manager's script runner, so an ambiguous or unknown manager does not stop + * a copy that is provably installed inside the repository. A manifest that could not + * be read, or evidence the scan refused, is not "installed here" and stops it. */ +const NODE_ACCEPTED_STATUSES: ReadonlySet = new Set(['ok', 'ambiguous-manager', 'unsupported-manager']); + +/** The one variable that turns the hand-off off, set to ANY value including empty. + * Nothing of ours is ever ADDED to the environment: the agent host `leji start` + * launches inherits the user's environment untouched, so no sentinel of ours can + * leak into it and silently disable the hand-off for everything it runs. */ +const OPT_OUT = 'LEJI_NO_LOCAL'; + +/** The installed package's own metadata, read whole and bounded. A package manifest + * is a few kilobytes; anything past this is not one, and reading it is not this + * wrapper's job. */ +const MAX_METADATA_BYTES = 64 * 1024; + +/** No repository copy runs this invocation: the global CLI continues. */ +export interface LocalCliNone { + kind: 'none'; +} + +/** The repository's own CLI, and exactly how to run it. `args` is the argv this + * process received, verbatim. */ +export interface LocalCliHandoff { + kind: 'handoff'; + /** The program to execute. Argv, never a shell. */ + bin: string; + args: string[]; + /** The target as a failure would name it: repository-relative, POSIX-spelled. */ + display: string; +} + +export type LocalCli = LocalCliNone | LocalCliHandoff; + +const NONE: LocalCliNone = { kind: 'none' }; + +/** + * Decide whether this invocation belongs to a repository's own pinned CLI. + * + * `selfEntryRealpath` is the resolved path of the running entry file, or null when + * it could not be resolved; the hand-off is refused when the package's own ENTRY is + * that file, which is what keeps a repository whose install points back at this very + * executable from handing off to itself forever. An unknown self is refused for the + * same reason. + * + * The entry is the identity of the copy, on every platform, and it is deliberately + * NOT the thing POSIX executes: a package manager's shim may be a symlink to the + * entry (npm, bun, Yarn's node-modules linker) or a small script that runs it + * (pnpm), and only the first has a realpath that equals the entry. Comparing the + * shim would leave the script shape unguarded, and the loop it opens is unbounded: + * the global runs the shim, the shim runs the entry, and the entry resolves the shim + * again forever. So identity is resolved from the package's own `bin.leji` and the + * shim is only what gets executed. + * + * All of the following must hold, and each one is checked on the resolved path + * rather than on a spelling: + * + * | condition | why | + * | --- | --- | + * | `LEJI_NO_LOCAL` absent from the environment | the single opt-out | + * | the argv names a root at all | a malformed command line selects no repository | + * | the root has a node record whose status is accepted | it is a Node repository | + * | that record declares the CLI directly | the repository committed the intent | + * | the layer's spec line reads back and has a minimum | the bar to meet | + * | the installed package identifies itself as this package | a directory spelling is not an identity | + * | its version parses and its major meets the minimum | an older copy cannot serve this layer | + * | its entry resolves inside the package's own directory | a `bin` field is repository-controlled text | + * | the executable resolves inside the real root | never a linked copy elsewhere | + * | that entry is not this running entry | no recursion, whatever shape the shim has | + * + * Every read here is TOTAL: an unreadable manifest, a permission error, a directory + * where a file was expected, or any other I/O failure is no hand-off, never an + * exception. This runs before `run()` and outside its error handling, so a throw + * would be a stack trace where the global CLI was supposed to run. + */ +export function resolveLocalCli( + argv: string[], + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + selfEntryRealpath: string | null, +): LocalCli { + try { + return resolve(argv, env, platform, selfEntryRealpath); + } catch { + return NONE; + } +} + +function resolve( + argv: string[], + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + selfEntryRealpath: string | null, +): LocalCli { + if (OPT_OUT in env) return NONE; + if (selfEntryRealpath === null) return NONE; + const rootArg = effectiveRoot(argv); + if (rootArg === null) return NONE; + const rootAbs = path.resolve(rootArg); + const rootReal = guardRoot(rootAbs); + + const node = detectEcosystem(rootAbs).all.find((r) => r.ecosystem === 'node'); + if (node === undefined || !NODE_ACCEPTED_STATUSES.has(node.status) || !node.directDeclared) return NONE; + + const specLine = readSpecLine(rootReal, path.join(rootAbs, MANIFEST_FILENAME)); + if (specLine === null) return NONE; + const minimum = MIN_SDK_FOR_SPEC_LINE[specLine]; + if (minimum === undefined) return NONE; + + const packageDir = path.join(rootAbs, 'node_modules', ...DEP_NAME.split('/')); + const metadata = readInstalledMetadata(rootReal, path.join(packageDir, 'package.json')); + if (metadata === null || metadata.name !== DEP_NAME) return NONE; + if (typeof metadata.version !== 'string') return NONE; + const version = VERSION_RE.exec(metadata.version); + if (version === null || Number(version[1]) < Number(minimum.split('.')[0])) return NONE; + + // The identity of the copy, on every platform: what a hand-off would end up + // running, whichever shape the shim in front of it has. + const entry = packageEntry(packageDir, metadata); + if (entry === null || entry.real === selfEntryRealpath) return NONE; + + if (platform === 'win32') { + // The `.cmd` shim cannot be executed without a shell, and this tool passes + // argv and never a command line, so Windows runs the entry under this Node. + const display = toPosix(path.relative(rootAbs, entry.file)); + return { kind: 'handoff', bin: process.execPath, args: [entry.file, ...argv], display }; + } + // POSIX executes the shim the manager installed, exactly as `leji start`'s probe + // does, so whatever setup that manager's shim performs is preserved rather than + // guessed at. It must still be the repository's own: a regular file after + // symlinks, resolving inside the real root, executable. + const shim = installedNodeBin(rootAbs); + if (shim === null) return NONE; + return { kind: 'handoff', bin: shim, args: [...argv], display: toPosix(path.relative(rootAbs, shim)) }; +} + +/** The package's own entry: where it really lives, and the path to run it by. */ +interface Entry { + file: string; + real: string; +} + +/** + * The entry the installed package declares (`bin` as a string or as a map), or null. + * It must resolve INSIDE the package's own resolved directory and be a regular file: + * a `bin` field is repository-controlled text, and a path escaping the package it + * belongs to is not this package's entry, whatever else it is. + */ +function packageEntry(packageDir: string, metadata: InstalledMetadata): Entry | null { + const bin = metadata.bin; + const declared = + typeof bin === 'string' + ? bin + : typeof bin === 'object' && bin !== null + ? (bin as Record)['leji'] + : undefined; + if (typeof declared !== 'string' || declared === '') return null; + const packageReal = resolvedPath(packageDir); + if (packageReal === null) return null; + const file = path.join(packageDir, declared); + const real = resolvedPath(file); + if (real === null || !(real === packageReal || real.startsWith(packageReal + path.sep))) return null; + let entry: fs.Stats; + try { + entry = fs.statSync(real); + } catch { + return null; + } + return entry.isFile() ? { file, real } : null; +} + +/** The fields of the installed package's metadata this wrapper reads. */ +interface InstalledMetadata { + name?: unknown; + version?: unknown; + bin?: unknown; +} + +/** + * The layer's declared spec line, read the way the bytes that decide an execution + * have to be: through the verified helper, inside the real root, bounded, and total. + * Only this one field is the wrapper's business. Whether the rest of the manifest is + * a valid layer is `run()`'s question, asked after the hand-off decision and by + * whichever CLI ends up answering it. + */ +function readSpecLine(rootReal: string, abs: string): string | null { + const data = readVerifiedJson(rootReal, abs); + if (data === null) return null; + const line = (data as { leji?: unknown }).leji; + return typeof line === 'string' ? line : null; +} + +/** + * The installed package's `package.json`, or null. Verified: the path is resolved, + * the resolved path is required to stay inside the real repository root, and the + * bytes come from the descriptor `fstat` proved a regular file, so the metadata that + * decides what runs is the metadata the containment check judged. Bounded, malformed + * input included: a size past the cap, unparseable JSON, or anything that is not a + * JSON object is simply no hand-off. + */ +function readInstalledMetadata(rootReal: string, abs: string): InstalledMetadata | null { + return readVerifiedJson(rootReal, abs); +} + +/** + * One JSON document whose bytes decide whether repository code runs. Verified: the + * path is resolved, the resolved path is required to stay inside the real repository + * root, and the bytes come from the descriptor `fstat` proved a regular file, so the + * document that decides is the document the containment check judged. Bounded and + * total: a size past the cap, an I/O failure, unparseable JSON, or anything that is + * not a JSON object is simply no hand-off. + */ +function readVerifiedJson(rootReal: string, abs: string): Record | null { + let fd: number | null = null; + try { + fd = openVerifiedSource(abs, (real) => real === rootReal || real.startsWith(rootReal + path.sep)).fd; + if (fd === null) return null; + if (fs.fstatSync(fd).size > MAX_METADATA_BYTES) return null; + const data: unknown = JSON.parse(fs.readFileSync(fd, 'utf8')); + return typeof data === 'object' && data !== null && !Array.isArray(data) + ? (data as Record) + : null; + } catch { + return null; + } finally { + if (fd !== null) fs.closeSync(fd); + } +} + +/** What running the repository's CLI produced: its exit status, the signal that + * ended it, or the failure to start it at all. The three are exhaustive, and the + * fourth row of the table below is the pair that should be impossible. */ +export interface LaunchOutcome { + status: number | null; + signal: NodeJS.Signals | null; + error?: Error; +} + +/** Everything the launcher does to the outside world, injectable so every row of the + * result table is provable without ending the test runner. */ +export interface LaunchIo { + platform: NodeJS.Platform; + spawn(bin: string, args: string[]): LaunchOutcome; + /** Re-raise a signal on ourselves so the shell sees the termination the child + * had, with our own handlers removed first: the default disposition, not a + * listener of ours, has to be what acts. */ + reraise(signal: NodeJS.Signals): void; + stderr(line: string): void; + exit(code: number): never; +} + +/** The real world. Argv, never a shell, and the child inherits this terminal: it IS + * this invocation now, so its stdin, stdout, stderr, cwd and environment are ours. */ +function spawnInherit(bin: string, args: string[]): LaunchOutcome { + const result = spawnSync(bin, args, { stdio: 'inherit' }); + return { status: result.status, signal: result.signal, error: result.error }; +} + +function defaultLaunchIo(): LaunchIo { + return { + platform: process.platform, + spawn: spawnInherit, + reraise: (signal) => { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + }, + stderr: (line) => { + console.error(line); + }, + exit: (code) => process.exit(code), + }; +} + +/** + * Run the repository's CLI and become its result. Never returns. + * + * | outcome | what this process does | + * | --- | --- | + * | integer status | exits with it: the child's 0/1/2 contract is the one that surfaces | + * | signal, POSIX | re-raises it on itself, so the shell sees the same termination; exits 128+signum if that somehow leaves us alive | + * | signal, Windows | names it on stderr and exits 1, the documented limitation | + * | the spawn failed | names it on stderr and exits 2 | + * | neither status nor signal | the same fail-closed exit 2 | + * + * Failure is CLOSED, never a quiet fall-through to the global CLI: an eligible + * pinned copy was already selected, so running a different version instead would + * recreate the exact drift this hand-off exists to remove, possibly under a command + * that writes. + */ +export function launchLocalCli(handoff: LocalCliHandoff, io: LaunchIo = defaultLaunchIo()): never { + const result = io.spawn(handoff.bin, handoff.args); + if (result.error !== undefined) { + return failed(io, handoff, (result.error as NodeJS.ErrnoException).code ?? result.error.message); + } + if (result.signal !== null) { + if (io.platform === 'win32') { + io.stderr(`leji: the repository's Leji CLI ended by ${result.signal}`); + return io.exit(1); + } + io.reraise(result.signal); + return io.exit(128 + (os.constants.signals[result.signal] ?? 0)); + } + if (result.status === null) return failed(io, handoff, 'no exit status'); + return io.exit(result.status); +} + +function failed(io: LaunchIo, handoff: LocalCliHandoff, code: string): never { + io.stderr(`leji: cannot run the repository's Leji CLI at ${handoff.display}: ${code}`); + return io.exit(2); +} diff --git a/packages/sdk/src/lib/manifest.ts b/packages/sdk/src/lib/manifest.ts index fed7ad2..9421122 100644 --- a/packages/sdk/src/lib/manifest.ts +++ b/packages/sdk/src/lib/manifest.ts @@ -268,3 +268,234 @@ export function bindAgentInManifestText( } return { text: insertAfterMarkerLine(text, '"agents": {', ` ${entry},`), changed: true }; } + +// --- The mount pin span ------------------------------------------------------- +// +// `leji mounts update-pin` moves one declared pin. The agent edits above anchor on +// the canonical two-space layout, which the manifest schema does not require, so a +// pin move gets a lexical scanner instead: it walks the document as JSON tokens, +// finds `federation.mounts[i]` whose `name` equals the addressed mount, and returns +// the byte span of THAT object's `pin` string value. Only that span is replaced. +// Nothing is reserialized or normalized, so field order, indentation, line endings, +// escapes, unmodeled keys, and every other byte of the file survive untouched. + +/** A lexical failure: the document is not shaped the way a manifest is. Callers + * turn it into the same "cannot locate" refusal as a missing mount, because both + * mean the same thing operationally — this text has no such pin to move. */ +class PinScanError extends Error {} + +/** + * A duplicate key on the path to the pin. JSON does not forbid one, and the two + * readers of this document disagree about which wins: a lexical scan takes the + * FIRST member, `JSON.parse` keeps the LAST. So a manifest carrying two `pin` keys + * on the addressed mount could have its first span rewritten while the pin every + * parser reads stays exactly as it was — a reported change that changed nothing. + * The scanner refuses that document instead of picking a winner, and this error + * carries its own message out rather than collapsing into "cannot locate". + */ +class PinAmbiguityError extends Error {} + +/** + * The one member named `key`, or null when there is none. Two or more is refused: + * every key this scanner reads sits on the path to the pin, so an ambiguous one + * makes the whole edit ambiguous. + */ +function uniqueMember( + members: { key: string; valueAt: number }[], + key: string, + where: string, +): { key: string; valueAt: number } | null { + const matches = members.filter((m) => m.key === key); + if (matches.length > 1) throw new PinAmbiguityError(`duplicate key ${JSON.stringify(key)} ${where}`); + return matches[0] ?? null; +} + +/** Index of the first character at or after `i` that is not JSON whitespace. */ +function skipJsonWs(text: string, i: number): number { + while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++; + return i; +} + +/** One JSON string starting at the opening quote: its decoded value (escapes + * resolved, for comparison only) and the span of its RAW contents between the + * quotes, which is the only thing an edit ever replaces. */ +function scanJsonString(text: string, i: number): { value: string; contentStart: number; end: number } { + if (text[i] !== '"') throw new PinScanError('expected a string'); + const contentStart = i + 1; + let out = ''; + let j = contentStart; + while (j < text.length) { + const c = text[j]; + if (c === '"') return { value: out, contentStart, end: j + 1 }; + if (c !== '\\') { + out += c; + j++; + continue; + } + const esc = text[j + 1]; + j += 2; + switch (esc) { + case '"': + case '\\': + case '/': + out += esc; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + case 'u': { + const hex = text.slice(j, j + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new PinScanError('malformed \\u escape'); + // Code units are appended as they come: a surrogate PAIR spelled as two + // escapes reassembles into its astral character by the same rule the + // parser uses, so an escaped name compares equal to a raw one. + out += String.fromCharCode(parseInt(hex, 16)); + j += 4; + break; + } + default: + throw new PinScanError('unknown escape'); + } + } + throw new PinScanError('unterminated string'); +} + +/** Index just past the value beginning at `i`, whatever it is. Objects and arrays + * are skipped STRUCTURALLY (nesting counted through their own members), so a `pin` + * key inside some unrelated nested object is never mistaken for a mount's. */ +function skipJsonValue(text: string, i: number): number { + i = skipJsonWs(text, i); + const c = text[i]; + if (c === '"') return scanJsonString(text, i).end; + if (c === '{' || c === '[') { + const close = c === '{' ? '}' : ']'; + let j = i + 1; + for (;;) { + j = skipJsonWs(text, j); + if (j >= text.length) throw new PinScanError('unterminated container'); + if (text[j] === close) return j + 1; + if (text[j] === ',' || text[j] === ':') { + j++; + continue; + } + j = skipJsonValue(text, j); + } + } + // A literal or a number: everything up to the next structural character. + let j = i; + while (j < text.length && !' \t\n\r,}]'.includes(text[j])) j++; + if (j === i) throw new PinScanError('expected a value'); + return j; +} + +/** Each member of the object beginning at `i`, as (decoded key, index of its + * value); plus the index just past the object. */ +function jsonMembers(text: string, i: number): { members: { key: string; valueAt: number }[]; end: number } { + i = skipJsonWs(text, i); + if (text[i] !== '{') throw new PinScanError('expected an object'); + const members: { key: string; valueAt: number }[] = []; + let j = i + 1; + for (;;) { + j = skipJsonWs(text, j); + if (j >= text.length) throw new PinScanError('unterminated object'); + if (text[j] === '}') return { members, end: j + 1 }; + if (text[j] === ',') { + j++; + continue; + } + const key = scanJsonString(text, j); + j = skipJsonWs(text, key.end); + if (text[j] !== ':') throw new PinScanError('expected ":"'); + const valueAt = skipJsonWs(text, j + 1); + members.push({ key: key.value, valueAt }); + j = skipJsonValue(text, valueAt); + } +} + +/** The raw span of `federation.mounts[i].pin` for the mount named `name`, with the + * value the span currently holds. Null when no such mount, or no `pin` on it. */ +function findMountPinSpan( + text: string, + name: string, +): { value: string; contentStart: number; contentEnd: number } | null { + const root = jsonMembers(text, 0); + const federation = uniqueMember(root.members, 'federation', 'in the manifest root'); + if (!federation) return null; + const mountsKey = uniqueMember(jsonMembers(text, federation.valueAt).members, 'mounts', 'in "federation"'); + if (!mountsKey) return null; + let i = skipJsonWs(text, mountsKey.valueAt); + if (text[i] !== '[') throw new PinScanError('expected an array'); + i++; + for (;;) { + i = skipJsonWs(text, i); + if (i >= text.length) throw new PinScanError('unterminated array'); + if (text[i] === ']') return null; + if (text[i] === ',') { + i++; + continue; + } + if (text[i] !== '{') { + i = skipJsonValue(text, i); + continue; + } + const entry = jsonMembers(text, i); + // A mount whose own name is ambiguous cannot be told apart from the addressed + // one, so the document is refused before any element is matched. + const nameMember = uniqueMember(entry.members, 'name', 'in a federation mount'); + if (nameMember && text[nameMember.valueAt] === '"' && scanJsonString(text, nameMember.valueAt).value === name) { + const pinMember = uniqueMember(entry.members, 'pin', `in mount ${JSON.stringify(name)}`); + if (!pinMember) return null; + if (text[pinMember.valueAt] !== '"') throw new PinScanError('pin is not a string'); + const pin = scanJsonString(text, pinMember.valueAt); + return { value: pin.value, contentStart: pin.contentStart, contentEnd: pin.end - 1 }; + } + i = entry.end; + } +} + +/** + * Move one declared mount's pin, in place. `from` is what the span must currently + * hold — the value the comparison was computed against — so a manifest that moved + * underneath the run is refused rather than overwritten. Everything outside the pin + * value's own bytes is returned exactly as it came in. + * + * Throws when the pin cannot be located, or holds something other than `from`. + * Both are internal refusals after the manifest has already parsed and validated. + */ +export function replaceMountPinInManifestText( + text: string, + name: string, + from: string, + to: string, +): { text: string; changed: boolean } { + let span: ReturnType; + try { + span = findMountPinSpan(text, name); + } catch (e) { + // An ambiguous document is refused on its own terms; a merely malformed one + // is the same answer as a mount that is not there. + if (e instanceof PinAmbiguityError) throw new Error(`${MANIFEST_FILENAME}: ${e.message}`); + if (!(e instanceof PinScanError)) throw e; + span = null; + } + if (span === null) { + throw new Error(`${MANIFEST_FILENAME}: cannot locate the pin of mount ${JSON.stringify(name)}`); + } + if (span.value !== from) { + throw new Error(`${MANIFEST_FILENAME}: pin of mount ${JSON.stringify(name)} is not ${JSON.stringify(from)}`); + } + if (from === to) return { text, changed: false }; + return { text: text.slice(0, span.contentStart) + to + text.slice(span.contentEnd), changed: true }; +} diff --git a/packages/sdk/src/lib/mounts.ts b/packages/sdk/src/lib/mounts.ts index 6f570dc..7c91e5c 100644 --- a/packages/sdk/src/lib/mounts.ts +++ b/packages/sdk/src/lib/mounts.ts @@ -1,8 +1,10 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { exists, isDir, isFile, readTextWithin } from './fsx.js'; +import { exists, guardRoot, isDir, isFile, mkdirpGuarded, readTextWithin } from './fsx.js'; +import { MOUNTS_REL } from './layout.js'; import { type Manifest, allStringsScalar } from './manifest.js'; import { schemaErrors } from './schemas.js'; import { byteCompare } from './text.js'; @@ -165,7 +167,24 @@ export function cacheKeyFor(sourceIdentity: string, pin: string): string { } export function mountsDir(root: string): string { - return path.join(root, '.leji', 'mounts'); + return path.join(root, MOUNTS_REL); +} + +/** + * 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. Null when the rule refuses it: a planted `.leji/mounts` symlink + * into another role or out of the repository is caught here, once, instead of being + * followed by every per-entry write underneath. + * + * The per-entry protocol below (hashed identities, contained relative paths, the + * symlink-escape rules, publish-by-rename) is the declared 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`. + */ +function establishMountsDir(root: string, dirAbs: string): string | null { + const established = mkdirpGuarded(guardRoot(root), dirAbs, MOUNTS_REL); + return established.ok ? established.real : null; } /** Machine-local resolution hints (never committed): .leji/mounts.local.json. */ @@ -240,7 +259,7 @@ function hasCommit(repo: string, pin: string): boolean { } /** Resolve a revision to a commit id in `repo`; null when it does not resolve. */ -function revOid(repo: string, rev: string): string | null { +export function revOid(repo: string, rev: string): string | null { const r = runGit(['-C', repo, 'rev-parse', '--verify', '--quiet', `${rev}^{commit}`]); return r.ok ? r.stdout.toString('utf8').trim() : null; } @@ -347,28 +366,47 @@ export function findObjectSource( } /** - * 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. + * 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 + * caller that needs more than one commit retained pays exactly one round trip per + * commit and no extra observation of a moving ref. + * + * The declared pin and an explicitly named target are both retained through this, + * so the version of record and the version being moved to are equally safe from + * git maintenance. */ -export function fetchIntoStore( +/** + * Test-only fault injection for {@link retainPinInStore}: with + * LEJI_TEST_FAIL_PIN_REF set to a commit id, retaining exactly that commit fails at + * the ref. It exists because the TARGET-retention refusal has no other reachable + * path — by the time the target is retained, the comparison repository IS the + * managed store and already holds the commit, so the fetch never runs and only the + * ref update can fail. + */ +function retentionInjectedFailure(oid: string): boolean { + return process.env.LEJI_TEST_FAIL_PIN_REF === oid; +} + +export function retainPinInStore( root: string, mount: MountDecl, sourceIdentity: string, -): { repo: string | null; witnessRefreshFailed?: boolean; error?: string } { + oid: string, +): { 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 = storeDir(root, sourceIdentity); + const store = establishMountsDir(root, storeDir(root, sourceIdentity)); + if (store === null) return failed('the managed store could not be initialized'); if (!isGitRepo(store)) { - fs.mkdirSync(store, { recursive: true }); if (!runGit(['init', '--bare', '-q', store]).ok) return failed('the managed store could not be initialized'); } - // The pin is immutable: a store that already holds it needs no round trip. The - // declared pin is resolved directly, never read back out of FETCH_HEAD, so the - // fetch has no reason to write one and races with a concurrent fetch. - if (!hasCommit(store, mount.pin)) { + // A commit id is immutable: a store that already holds it needs no round trip. + // The id is resolved directly, never read back out of FETCH_HEAD, so the fetch + // has no reason to write one and races with a concurrent fetch. + if (!hasCommit(store, oid)) { const spec = [ '-C', store, @@ -378,17 +416,36 @@ export function fetchIntoStore( '-q', '--no-write-fetch-head', mount.source, - mount.pin, + oid, ]; if (!runGit(spec).ok) return failed('the pin could not be fetched from the source'); } - // Retain the pin by a ref of our own: without it, git maintenance may prune the + // Retain it by a ref of our own: without it, git maintenance may prune the // version of record. - const pinOid = revOid(store, mount.pin); + const pinOid = revOid(store, oid); if (pinOid === null) return failed('fetched, but the pin is not reachable'); - if (!runGit(['-C', store, 'update-ref', pinRefFor(sourceIdentity, pinOid), pinOid]).ok) { + if ( + retentionInjectedFailure(pinOid) || + !runGit(['-C', store, 'update-ref', pinRefFor(sourceIdentity, pinOid), pinOid]).ok + ) { return failed('the pin could not be retained by a ref in the managed store'); } + return { repo: store }; +} + +/** + * 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. + */ +export function fetchIntoStore( + root: string, + mount: MountDecl, + sourceIdentity: string, +): { repo: string | null; witnessRefreshFailed?: boolean; error?: string } { + const retained = retainPinInStore(root, mount, sourceIdentity, mount.pin); + 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 // 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 @@ -400,7 +457,7 @@ export function fetchIntoStore( } /** The raw object a ref points at, unpeeled; null when the ref does not exist. */ -function refOid(repo: string, ref: string): string | null { +export function refOid(repo: string, ref: string): string | null { const r = runGit(['-C', repo, 'rev-parse', '--verify', '--quiet', ref]); return r.ok ? r.stdout.toString('utf8').trim() : null; } @@ -413,11 +470,24 @@ function refOid(repo: string, ref: string): string | null { * writer published first (a valid outcome), and a failure leaves the previous * witness in place. */ -function refreshWitness(store: string, mount: MountDecl, sourceIdentity: string): boolean { +export function refreshWitness(store: string, mount: MountDecl, sourceIdentity: string): boolean { const witnessRef = witnessRefFor(sourceIdentity, mount.trackingRef!); const tempRef = `${WITNESS_REF_NAMESPACE}/tmp/${process.pid}-${crypto.randomBytes(8).toString('hex')}`; const spec = `+${mount.trackingRef}:${tempRef}`; - const fetch = runGit(['-C', store, '-c', 'fetch.recurseSubmodules=no', 'fetch', '-q', mount.source, spec]); + // `--no-write-fetch-head` for the same reason retention passes it: the ref this + // fetch cares about is the temporary one in the refspec, and a FETCH_HEAD left + // behind is a per-run path recorded inside the managed store. + const fetch = runGit([ + '-C', + store, + '-c', + 'fetch.recurseSubmodules=no', + 'fetch', + '-q', + '--no-write-fetch-head', + mount.source, + spec, + ]); const tip = fetch.ok ? refOid(store, tempRef) : null; // An empty is git's "must not exist yet". const expected = refOid(store, witnessRef) ?? ''; @@ -1163,9 +1233,21 @@ export function hydrateMounts( continue; } // Staged inside the entry's own directory, so publication is a rename on one - // filesystem, and under a per-process name, so no two producers collide. - const staging = path.join(cacheDir, `.staging-${stagingToken()}`); - fs.mkdirSync(staging, { recursive: true }); + // 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()}`)); + if (staging === null) { + outcomes.push( + outcome({ + name: mount.name, + status: 'error', + detail: 'the cache entry destination could not be established', + }), + ); + continue; + } + const cacheEntryDir = path.dirname(staging); const projected = extractProjection(src.repo, mount.pin, staging); if (!projected.ok) { fs.rmSync(staging, { recursive: true, force: true }); @@ -1201,7 +1283,7 @@ export function hydrateMounts( hydratedAt: new Date().toISOString(), }; // The whole tree is extracted and validated before it is publishable. - const published = publishCacheEntry(cacheDir, staging, JSON.stringify(metadata, null, 2) + '\n'); + const published = publishCacheEntry(cacheEntryDir, staging, JSON.stringify(metadata, null, 2) + '\n'); if (published.status === 'error') { outcomes.push(outcome({ name: mount.name, status: 'error', detail: published.detail })); continue; @@ -1223,8 +1305,10 @@ export function hydrateMounts( /** * Verify a cached projection against a reachable object store: every projected - * file's bytes and mode against the pinned tree. Returns null when no object - * store is reachable (unverifiable), true/false otherwise. + * file's bytes and mode against the pinned tree. Returns null when a prerequisite + * for verifying is unavailable — no reachable object store, an unresolvable pin, + * no writable temp dir — leaving the projection unverified rather than judged; + * true/false otherwise. */ export function verifyProjection(root: string, mount: MountDecl): boolean | null { const identity = normalizeSource(mount.source); @@ -1237,9 +1321,19 @@ export function verifyProjection(root: string, mount: MountDecl): boolean | null const commitR = runGit(['-C', src.repo, 'rev-parse', `${mount.pin}^{commit}`]); if (!commitR.ok) return null; const commit = commitR.stdout.toString('utf8').trim(); - const staging = path.join(mountsDir(root), `verify-${process.pid}`); - fs.rmSync(staging, { recursive: true, force: true }); - fs.mkdirSync(staging, { recursive: true }); + // Staging happens outside the host: verifying is a read-only question, so asking + // it must not write into the tree being asked about (a read-only or shared + // checkout could not answer otherwise). The name is allocated, never constructed + // and pre-deleted: a guessed path is a path a concurrent verification is already + // using, and deleting it is how one run made another fail. Cleanup is installed + // the moment allocation succeeds. A failed allocation is one more unavailable + // prerequisite — unverifiable, never an error and never an in-tree fallback. + let staging: string; + try { + staging = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-verify-')); + } catch { + return null; + } try { const projected = extractProjection(src.repo, commit, staging); if (!projected.ok) return false; @@ -1328,11 +1422,75 @@ export function locateMount(root: string, manifest: Manifest, name: string): Loc verified, path: present ? projDir : null, ...(present && !verified - ? { detail: 'projection present but not verified against a reachable object store' } + ? { + detail: + 'projection present but not verified: it does not match its pin, or verification prerequisites are unavailable', + } : {}), }; } +/** Which repository answers a pin comparison, and the ONE witness snapshot it + * answered with. `reason` is the degraded alternative: a stable status code and + * nothing selected. */ +export type ComparisonSelection = + | { + repo: string; + comparisonRepository: NonNullable; + witnessProvenance: NonNullable; + comparedRef: string; + tipOid: string; + } + | { reason: string }; + +/** + * The store-first availability matrix, resolved once: the resolver's own witness + * in the managed store first, then the first object source that holds BOTH the pin + * and the compared ref. The pin and the witness always come from the same + * repository, and nothing here fetches. + * + * `tipOid` is the single witness snapshot for the whole operation. `status` reports + * from it and `update-pin` targets, counts and gates from it, so no caller can end + * up describing two different commits by re-reading a ref that moved in between. + */ +export function selectComparison(root: string, mount: MountDecl, effectiveRef: string): ComparisonSelection { + const identity = normalizeSource(mount.source); + if (identity === null) return { reason: 'mount-source-unnormalizable' }; + if (!validTrackingRef(effectiveRef)) return { reason: 'mount-tracking-ref-invalid' }; + // Row 1: the managed store holds the pin and the resolver's own witness. + const store = storeDir(root, identity); + const managedTip = + isGitRepo(store) && hasCommit(store, mount.pin) ? revOid(store, witnessRefFor(identity, effectiveRef)) : null; + // Row 2: the first pin-holding source that also resolves the ref itself. A + // candidate holding only the pin is passed over, never allowed to mask a + // later one holding both. + const offline = + managedTip === null ? objectSourceCandidates(root, mount, identity) : { candidates: [], ambiguous: false }; + let selected: { repo: string; kind: ObjectSourceKind; tip: string } | null = + managedTip === null ? null : { repo: store, kind: 'store', tip: managedTip }; + for (const candidate of offline.candidates) { + const tip = revOid(candidate.repo, effectiveRef); + if (tip !== null) { + selected = { ...candidate, tip }; + break; + } + } + if (selected === null) { + // Ambiguity is its own answer: those repositories were never consulted, + // so reporting the pin or the witness unavailable would claim more than + // was checked. + if (offline.ambiguous) return { reason: 'mount-source-ambiguous' }; + return { reason: offline.candidates.length === 0 ? 'mount-pin-unavailable' : 'mount-witness-unavailable' }; + } + return { + repo: selected.repo, + comparisonRepository: selected.kind === 'store' ? 'managed-store' : selected.kind, + witnessProvenance: managedTip !== null ? 'managed' : 'unmanaged', + comparedRef: effectiveRef, + tipOid: selected.tip, + }; +} + /** * Report every declared mount's pin against its witness, offline. Comparison is * the availability matrix: the resolver's own witness in the managed store first, @@ -1379,87 +1537,80 @@ export function mountStatus( if (mount.trackingRef === undefined) return unknown('mount-no-tracking-ref'); if (!validTrackingRef(mount.trackingRef)) return unknown('mount-tracking-ref-invalid'); - // Row 1: the managed store holds the pin and the resolver's own witness. - const store = storeDir(root, identity); - const managedTip = - isGitRepo(store) && hasCommit(store, mount.pin) - ? revOid(store, witnessRefFor(identity, mount.trackingRef)) - : null; - // Row 2: the first pin-holding source that also resolves the ref itself. A - // candidate holding only the pin is passed over, never allowed to mask a - // later one holding both. - const offline = - managedTip === null ? objectSourceCandidates(root, mount, identity) : { candidates: [], ambiguous: false }; - let selected: { repo: string; kind: ObjectSourceKind; tip: string } | null = - managedTip === null ? null : { repo: store, kind: 'store', tip: managedTip }; - for (const candidate of offline.candidates) { - const tip = revOid(candidate.repo, mount.trackingRef); - if (tip !== null) { - selected = { ...candidate, tip }; - break; - } - } - if (selected === null) { - // Ambiguity is its own answer: those repositories were never consulted, - // so reporting the pin or the witness unavailable would claim more than - // was checked. - if (offline.ambiguous) return unknown('mount-source-ambiguous'); - return unknown(offline.candidates.length === 0 ? 'mount-pin-unavailable' : 'mount-witness-unavailable'); - } - const { repo, tip: tipOid } = selected; - const comparisonRepository: StatusResult['pinReport']['comparisonRepository'] = - selected.kind === 'store' ? 'managed-store' : selected.kind; - const witnessProvenance = managedTip !== null ? 'managed' : 'unmanaged'; - - const behind = countRange(repo, mount.pin, tipOid); - const ahead = countRange(repo, tipOid, mount.pin); - if (behind === null || ahead === null) { - return unknown('mount-ancestry-incomplete', comparisonRepository, witnessProvenance); - } - const shallow = runGit(['-C', repo, 'rev-parse', '--is-shallow-repository']); - const ancestryComplete = shallow.ok && shallow.stdout.toString('utf8').trim() === 'false'; - // Both counts positive is either divergence or two unrelated histories, and - // only a merge base tells them apart. Exit 1 is the answer "no merge base"; - // any other failure is the repository unable to answer, never an answer. - // Truncated history can also lose a merge base that exists, so `unrelated` - // is a claim only complete ancestry makes. - let disjoint = false; - if (behind > 0 && ahead > 0) { - const mergeBase = runGit(['-C', repo, 'merge-base', mount.pin, tipOid]); - if (!mergeBase.ok && mergeBase.code !== 1) { - return unknown('mount-ancestry-incomplete', comparisonRepository, witnessProvenance); - } - disjoint = !mergeBase.ok; - if (disjoint && !ancestryComplete) { - return unknown('mount-ancestry-incomplete', comparisonRepository, witnessProvenance); - } + const selection = selectComparison(root, mount, mount.trackingRef); + if ('reason' in selection) return unknown(selection.reason); + const { repo, tipOid, comparisonRepository, witnessProvenance } = selection; + + const comparison = comparePins(repo, mount.pin, tipOid); + if ('reason' in comparison) { + return unknown(comparison.reason, comparisonRepository, witnessProvenance); } - const pinState: StatusResult['pinReport']['state'] = - behind === 0 && ahead === 0 - ? 'up-to-date' - : behind > 0 && ahead > 0 - ? disjoint - ? 'unrelated' - : 'diverged' - : behind > 0 - ? 'behind' - : 'ahead'; return { ...base, pinReport: { - state: pinState, - behind, - ahead, + state: comparison.state, + behind: comparison.behind, + ahead: comparison.ahead, comparedRef: mount.trackingRef, comparisonRepository, witnessProvenance, - ancestryComplete, + ancestryComplete: comparison.ancestryComplete, observedAt, }, }; }); } +/** A settled pin comparison: never `unknown`, because a repository that cannot + * answer the range returns the reason instead. */ +export interface PinComparison { + state: Exclude; + behind: number; + ahead: number; + ancestryComplete: boolean; +} + +/** + * Where the pin stands against ONE witness snapshot, in ONE repository. Shared by + * `status`, which reports it, and `update-pin`, which additionally gates on it — so + * the two can never describe the same pair of commits differently. + */ +export function comparePins( + repo: string, + pin: string, + tipOid: string, +): PinComparison | { reason: 'mount-ancestry-incomplete' } { + const incomplete = { reason: 'mount-ancestry-incomplete' } as const; + const behind = countRange(repo, pin, tipOid); + const ahead = countRange(repo, tipOid, pin); + if (behind === null || ahead === null) return incomplete; + const shallow = runGit(['-C', repo, 'rev-parse', '--is-shallow-repository']); + const ancestryComplete = shallow.ok && shallow.stdout.toString('utf8').trim() === 'false'; + // Both counts positive is either divergence or two unrelated histories, and + // only a merge base tells them apart. Exit 1 is the answer "no merge base"; + // any other failure is the repository unable to answer, never an answer. + // Truncated history can also lose a merge base that exists, so `unrelated` + // is a claim only complete ancestry makes. + let disjoint = false; + if (behind > 0 && ahead > 0) { + const mergeBase = runGit(['-C', repo, 'merge-base', pin, tipOid]); + if (!mergeBase.ok && mergeBase.code !== 1) return incomplete; + disjoint = !mergeBase.ok; + if (disjoint && !ancestryComplete) return incomplete; + } + const state: PinComparison['state'] = + behind === 0 && ahead === 0 + ? 'up-to-date' + : behind > 0 && ahead > 0 + ? disjoint + ? 'unrelated' + : 'diverged' + : behind > 0 + ? 'behind' + : 'ahead'; + return { state, behind, ahead, ancestryComplete }; +} + /** Commits in `from..to`, or null when the range cannot be counted (missing objects). */ function countRange(repo: string, from: string, to: string): number | null { const r = runGit(['-C', repo, 'rev-list', '--count', `${from}..${to}`]); @@ -1468,6 +1619,21 @@ function countRange(repo: string, from: string, to: string): number | null { return Number.isFinite(n) ? n : null; } +/** + * The ref a source advertises as its default branch: `HEAD`'s symref target, read + * with `ls-remote --symref`. The one lookup in this module that reaches the network + * without the caller having named a ref, so both failures stay distinguishable — + * the source could not be reached at all, or it advertises no symref to follow. + */ +export function resolveDefaultRef(source: string): { ref: string } | { error: 'unreachable' | 'no-symref' } { + // The locator becomes argv here: anything option-shaped is refused, never passed. + if (source.startsWith('-')) return { error: 'unreachable' }; + const head = runGit(['ls-remote', '--symref', source, 'HEAD']); + if (!head.ok) return { error: 'unreachable' }; + const m = /^ref:\s+(\S+)\s+HEAD/m.exec(head.stdout.toString('utf8')); + return m ? { ref: m[1] } : { error: 'no-symref' }; +} + export interface ReachabilityResult { state: 'reachable' | 'unreachable' | 'unknown'; witnessRef: string | null; @@ -1491,11 +1657,18 @@ export function checkPinReachability(root: string, mount: MountDecl): Reachabili // Resolve the witness ref: declared, or the source's advertised default branch. let witnessRef = mount.trackingRef ?? null; if (witnessRef === null) { - const head = runGit(['ls-remote', '--symref', mount.source, 'HEAD']); - if (!head.ok) return { state: 'unknown', witnessRef: null, detail: 'the source could not be reached' }; - const m = /^ref:\s+(\S+)\s+HEAD/m.exec(head.stdout.toString('utf8')); - if (!m) return { state: 'unknown', witnessRef: null, detail: 'source advertises no HEAD symref' }; - witnessRef = m[1]; + const resolved = resolveDefaultRef(mount.source); + if ('error' in resolved) { + return { + state: 'unknown', + witnessRef: null, + detail: + resolved.error === 'unreachable' + ? 'the source could not be reached' + : 'source advertises no HEAD symref', + }; + } + witnessRef = resolved.ref; } const adv = runGit(['ls-remote', mount.source, witnessRef]); if (!adv.ok) return { state: 'unknown', witnessRef, detail: 'the source could not be reached' }; @@ -1504,9 +1677,11 @@ 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 = path.join(mountsDir(root), 'store', sha256Hex(identity)); + const store = establishMountsDir(root, path.join(mountsDir(root), 'store', sha256Hex(identity))); + if (store === null) { + return { state: 'unknown', witnessRef, detail: 'the managed store could not be initialized' }; + } if (!isGitRepo(store)) { - fs.mkdirSync(store, { recursive: true }); const init = runGit(['init', '--bare', '-q', store]); if (!init.ok) return { state: 'unknown', witnessRef, detail: 'the managed store could not be initialized' }; } @@ -1567,7 +1742,7 @@ export function federationEnforcement( message: verified === false ? `mount "${mount.name}" projection does not match its pin; re-run \`leji mounts hydrate\`` - : `mount "${mount.name}" projection cannot be verified (no reachable object store); an unverified cache is not evidence`, + : `mount "${mount.name}" projection cannot be verified (verification prerequisites unavailable: no reachable object store, unresolvable pin, or no writable temp dir); an unverified cache is not evidence`, path: mount.name, }); } diff --git a/packages/sdk/src/lib/renderlint.ts b/packages/sdk/src/lib/renderlint.ts new file mode 100644 index 0000000..50aee4d --- /dev/null +++ b/packages/sdk/src/lib/renderlint.ts @@ -0,0 +1,430 @@ +import { type Finding } from './findings.js'; +import { parseFrontmatter } from './frontmatter.js'; + +/** + * The rendering-subset scan: given one markdown document, the constructs in it + * that render differently across renderers. This module is the executable + * contract the Go and Python SDKs port — the rules are stated here once, in the + * order they are applied, because a second statement of them (a grammar, a spec + * paragraph) would be a source that drifts. + * + * The rules, in application order: + * + * 1. **Excluded regions are found first.** YAML frontmatter (a leading block + * only, by the SDK's own boundary), fenced code blocks, and HTML comments are + * scanned before anything else, and nothing inside one is ever reported — + * text that merely names a construct is not that construct. Code spans are + * excluded the same way, inline, as the scan reaches them. + * 2. **Three constructs are reported**, and only these three: + * `raw-html` (CommonMark HTML blocks and inline raw HTML; comments excepted, + * since Leji's own generated-block markers are comments), `footnote` (the + * definition and reference forms alike), and `math-block` (a PAIRED `$$` + * delimiter — a lone one is prose). + * 3. **Backslash escapes are honored** for all three, per CommonMark: an escaped + * ASCII punctuation character is a literal, so `\
` is prose. + * 4. **Overlapping constructs resolve to the earliest-starting match**, which the + * single left-to-right scan below produces by construction, and each match is + * attributed to the line it OPENS on — a multi-line HTML block or `$$` block + * reports once, at its opening line. + * 5. **One hit per (line, construct)**: the line is the unit, so a line carrying + * two inline tags reports `raw-html` once. + * + * What is deliberately NOT reported: inline `$` (a currency amount spells it), + * unknown fence info strings (the unhighlighted fallback is conforming), and + * loose prose shapes. `adoption/rendering.md` is the profile these rules serve. + */ + +/** The closed token set. Findings compare on it across the three SDKs; the + * message text does not. */ +export type RenderConstruct = 'raw-html' | 'footnote' | 'math-block'; + +/** The one rule this scan produces. `--strict` promotes it (see `export.ts`). */ +export const RENDER_UNSUPPORTED_RULE = 'render-unsupported'; + +/** The shared message template. Identical bytes in all three SDKs by convention, + * outside the fixture contract by design. */ +export function renderUnsupportedMessage(construct: RenderConstruct): string { + return `\`${construct}\` is outside the supported rendering subset; see adoption/rendering.md`; +} + +/** One reported construct: the token, and the 1-based line it opens on. */ +export interface RenderHit { + line: number; + construct: RenderConstruct; +} + +/** + * CommonMark HTML block type 6: a line opening with one of these tags starts a + * block that runs to the next blank line, whatever else the line carries. The + * list is CommonMark's, verbatim, so a `
` closing a block on a later line + * is block content rather than a second construct. + */ +const BLOCK_TAGS = new Set( + ( + 'address article aside base basefont blockquote body caption center col colgroup dd details dialog dir div dl ' + + 'dt fieldset figcaption figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend ' + + 'li link main menu menuitem nav noframes ol optgroup option p param search section summary table tbody td ' + + 'tfoot th thead title tr track ul' + ).split(' '), +); + +/** CommonMark HTML block type 1: these run to a line carrying a closing tag + * rather than to a blank line, because their content is raw text. */ +const RAW_TEXT_OPEN = /^<(script|pre|style|textarea)([ \t>]|$)/i; +const RAW_TEXT_CLOSE = /<\/(script|pre|style|textarea)>/i; + +// Inline raw HTML, as CommonMark defines it: an open tag, a closing tag, a +// processing instruction, a declaration, or a CDATA section. (A comment is the +// sixth form and the excepted one, handled as an excluded region.) Sticky, so +// each is tried at exactly the scan position. A declaration takes an ASCII letter +// of either case after `` and `` alike disappear +// into the renderer, which is precisely what the lint exists to warn about. +const OPEN_TAG = + /<[A-Za-z][A-Za-z0-9-]*(?:[ \t\r\n]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t\r\n]*=[ \t\r\n]*(?:[^ \t\r\n"'=<>`]+|'[^']*'|"[^"]*"))?)*[ \t\r\n]*\/?>/y; +const CLOSE_TAG = /<\/[A-Za-z][A-Za-z0-9-]*[ \t\r\n]*>/y; +const CDATA = //y; +const DECLARATION = //y; +const PROCESSING = /<\?[\s\S]*?\?>/y; +/** Both footnote forms: the reference `[^id]`, and the definition `[^id]:`, + * whose opening bracket the same match covers. An unclosed `[^` is prose. */ +const FOOTNOTE = /\[\^[^\][\n]+\]/y; + +/** A fence opener: three or more backticks or tildes. A backtick fence's info + * string may carry no backtick, which is what keeps a code span off this path. */ +const FENCE_OPEN = /^(`{3,}|~{3,})(.*)$/; +/** A fence closer: the same character, at least as long, alone on its line. */ +const FENCE_CLOSE = /^(`{3,}|~{3,})[ \t]*$/; +/** Escapable per CommonMark: ASCII punctuation, and nothing else. */ +const ESCAPABLE = /[!-/:-@[-`{-~]/; + +/** + * A span the scan treats as one unit: an excluded region (`construct: null`), or + * a block-level construct reported at its opening line. Regions are produced in + * document order and never overlap. + */ +interface Region { + start: number; + end: number; + construct: RenderConstruct | null; +} + +/** Offsets at which each line begins, so an offset resolves to a line number. */ +function lineStartsOf(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1); + return starts; +} + +/** The 0-based line an offset falls on. */ +function lineOf(starts: number[], offset: number): number { + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= offset) lo = mid; + else hi = mid - 1; + } + return lo; +} + +/** One line's text, without its line terminator (CRLF included). */ +function lineTextAt(text: string, starts: number[], li: number): string { + const end = li + 1 < starts.length ? starts[li + 1] : text.length; + return text.slice(starts[li], end).replace(/\r?\n$/, ''); +} + +/** The offset just past a line's terminator. */ +function lineEndOf(text: string, starts: number[], li: number): number { + return li + 1 < starts.length ? starts[li + 1] : text.length; +} + +/** Leading spaces, capped at the four that would make the line indented code. */ +function indentOf(line: string): number { + let n = 0; + while (n < 4 && (line[n] === ' ' || line[n] === '\t')) n++; + return n; +} + +/** + * The block pass: frontmatter, fenced code, HTML comments (all excluded), and + * the HTML blocks that report as `raw-html` at their opening line. Line-based + * and in document order, so a fence inside a comment is comment text and a + * comment inside a fence is code — whichever opens first wins. + */ +function blockRegions(text: string, starts: number[]): Region[] { + const regions: Region[] = []; + const n = text.length; + let li = 0; + + // Frontmatter, by the SDK's own boundary (a LEADING block only; a `---` later + // in the document is a thematic break, and an unterminated block is prose). + const fm = parseFrontmatter(text); + if (fm.body.length !== n) { + const end = n - fm.body.length; + regions.push({ start: 0, end, construct: null }); + li = end >= n ? starts.length : lineOf(starts, end); + } + + while (li < starts.length) { + const line = lineTextAt(text, starts, li); + const indent = indentOf(line); + if (indent >= 4) { + li++; + continue; + } + const rest = line.slice(indent); + const at = starts[li] + indent; + + const fence = FENCE_OPEN.exec(rest); + if (fence !== null && (fence[1][0] === '~' || !fence[2].includes('`'))) { + let close = li + 1; + for (; close < starts.length; close++) { + const candidate = lineTextAt(text, starts, close); + const m = FENCE_CLOSE.exec(candidate.slice(indentOf(candidate))); + if (m !== null && m[1][0] === fence[1][0] && m[1].length >= fence[1].length) break; + } + const last = Math.min(close, starts.length - 1); + regions.push({ start: starts[li], end: lineEndOf(text, starts, last), construct: null }); + li = last + 1; + continue; + } + + // A comment opening a line is CommonMark HTML block type 2: it runs to the + // line carrying `-->`, and the whole of that line belongs to it. Comments + // are the one HTML form the profile excepts, so the region reports nothing. + if (rest.startsWith('', at + 4); + const last = close === -1 ? starts.length - 1 : lineOf(starts, close + 3); + regions.push({ start: starts[li], end: lineEndOf(text, starts, last), construct: null }); + li = last + 1; + continue; + } + + // CommonMark HTML blocks 3, 4 and 5: a processing instruction, a declaration, + // or a CDATA section opening a line is a BLOCK, running to the line carrying + // its terminator (`?>`, `>`, `]]>`) and ending with that whole line — so what + // follows the terminator on it is block content, never a second construct. An + // unterminated one runs to the end of the document, as the comment form does. + // Type 4 takes an ASCII letter of either case, so `` disappears from the page. + const terminator = rest.startsWith('' + : rest.startsWith('' + : /^' + : null; + if (terminator !== null) { + const close = text.indexOf(terminator, at); + const last = close === -1 ? starts.length - 1 : lineOf(starts, close); + regions.push({ start: starts[li], end: lineEndOf(text, starts, last), construct: 'raw-html' }); + li = last + 1; + continue; + } + + if (RAW_TEXT_OPEN.test(rest)) { + const rel = text.slice(at).search(RAW_TEXT_CLOSE); + const last = rel === -1 ? starts.length - 1 : lineOf(starts, at + rel); + regions.push({ start: starts[li], end: lineEndOf(text, starts, last), construct: 'raw-html' }); + li = last + 1; + continue; + } + + // Type 6 (a known block tag opens the line) and type 7 (any complete tag + // alone on a line, which cannot interrupt a paragraph). Both run to the + // next blank line, so the tags closing them are block content. + const tag = /^<\/?([A-Za-z][A-Za-z0-9-]*)([ \t]|\/?>|$)/.exec(rest); + const previousBlank = li === 0 || lineTextAt(text, starts, li - 1).trim() === ''; + const isBlock = (tag !== null && BLOCK_TAGS.has(tag[1].toLowerCase())) || (previousBlank && wholeLineIsTag(rest)); + if (isBlock) { + let close = li + 1; + while (close < starts.length && lineTextAt(text, starts, close).trim() !== '') close++; + regions.push({ start: starts[li], end: lineEndOf(text, starts, close - 1), construct: 'raw-html' }); + li = close; + continue; + } + li++; + } + return regions; +} + +/** True when the line is one complete open or closing tag and nothing else. */ +function wholeLineIsTag(rest: string): boolean { + for (const re of [OPEN_TAG, CLOSE_TAG]) { + re.lastIndex = 0; + const m = re.exec(rest); + if (m !== null && rest.slice(m[0].length).trim() === '') return true; + } + return false; +} + +/** The end of the region containing `i`, or `i` when it is outside every one. */ +function skipRegion(regions: Region[], i: number): number { + for (const r of regions) if (i >= r.start && i < r.end) return r.end; + return i; +} + +/** The length of the run of `ch` starting at `i`. */ +function runLength(text: string, i: number, ch: string): number { + let n = 0; + while (i + n < text.length && text[i + n] === ch) n++; + return n; +} + +/** + * A code span: a backtick run closed by a run of exactly the same length. An + * unclosed run is literal text, so the scan resumes just past it. Inline state + * never crosses a block boundary: a candidate whose closer would lie beyond an + * excluded or block region is unclosed AT that boundary, because the region ends + * the paragraph the run opened in — so constructs after the region still report. + */ +function afterCodeSpan(text: string, regions: Region[], i: number): number { + const open = runLength(text, i, '`'); + let j = i + open; + while (j < text.length) { + if (skipRegion(regions, j) !== j) break; + if (text[j] === '`') { + const run = runLength(text, j, '`'); + if (run === open) return j + run; + j += run; + continue; + } + j++; + } + return i + open; +} + +/** + * The next unescaped `$$` at or after `from`, or -1. A delimiter is a closer only + * where a delimiter can be read: not inside a code span, not inside a comment, and + * not on the far side of a block boundary — a pair no more bridges a region than a + * code span does, so an open whose apparent mate sits in one of them is unpaired, + * which is prose. + */ +function nextMathDelimiter(text: string, regions: Region[], from: number): number { + let j = from; + while (j < text.length - 1) { + if (skipRegion(regions, j) !== j) return -1; + if (text[j] === '\\' && ESCAPABLE.test(text[j + 1] ?? '')) { + j += 2; + continue; + } + if (text[j] === '`') { + j = afterCodeSpan(text, regions, j); + continue; + } + if (text.startsWith('', j + 4); + j = close === -1 ? text.length : close + 3; + continue; + } + if (text[j] === '$' && text[j + 1] === '$') return j; + j++; + } + return -1; +} + +/** An inline raw-HTML form at `i`, as its end offset, or -1. */ +function inlineHtmlEnd(text: string, i: number): number { + for (const re of [CDATA, PROCESSING, DECLARATION, CLOSE_TAG, OPEN_TAG]) { + re.lastIndex = i; + const m = re.exec(text); + if (m !== null) return i + m[0].length; + } + return -1; +} + +/** + * Every reported construct in one markdown document, ordered by (line, + * construct) — the order the export's findings carry, and the tie-breaker that + * keeps two constructs on one line deterministic across the three SDKs. + */ +export function scanRenderConstructs(text: string): RenderHit[] { + const starts = lineStartsOf(text); + const regions = blockRegions(text, starts); + const seen = new Set(); + const hits: RenderHit[] = []; + const record = (offset: number, construct: RenderConstruct): void => { + const line = lineOf(starts, offset) + 1; + const key = `${line}${construct}`; + if (seen.has(key)) return; + seen.add(key); + hits.push({ line, construct }); + }; + + for (const r of regions) if (r.construct !== null) record(r.start, r.construct); + + // The inline pass: one left-to-right walk, so the earliest-starting match + // wins every overlap and each match is consumed whole. + let i = 0; + while (i < text.length) { + const skip = skipRegion(regions, i); + if (skip !== i) { + i = skip; + continue; + } + const c = text[i]; + if (c === '\\' && ESCAPABLE.test(text[i + 1] ?? '')) { + i += 2; + continue; + } + if (c === '`') { + i = afterCodeSpan(text, regions, i); + continue; + } + if (c === '<') { + if (text.startsWith('', i + 4); + i = close === -1 ? text.length : close + 3; + continue; + } + const end = inlineHtmlEnd(text, i); + if (end !== -1) { + record(i, 'raw-html'); + i = end; + continue; + } + i++; + continue; + } + if (c === '[' && text[i + 1] === '^') { + FOOTNOTE.lastIndex = i; + const m = FOOTNOTE.exec(text); + if (m !== null) { + record(i, 'footnote'); + i += m[0].length; + continue; + } + i++; + continue; + } + if (c === '$' && text[i + 1] === '$') { + const close = nextMathDelimiter(text, regions, i + 2); + if (close !== -1) { + record(i, 'math-block'); + i = close + 2; + continue; + } + // Unpaired: prose, and the scan carries on past it. + i += 2; + continue; + } + i++; + } + + hits.sort((a, b) => a.line - b.line || (a.construct < b.construct ? -1 : a.construct > b.construct ? 1 : 0)); + return hits; +} + +/** The scan as findings for one document: `warning` severity, the repository- + * relative path the export carries it at, the opening line, and the token. */ +export function renderLintFindings(relPath: string, text: string): Finding[] { + return scanRenderConstructs(text).map((hit) => ({ + rule: RENDER_UNSUPPORTED_RULE, + severity: 'warning' as const, + path: relPath, + line: hit.line, + construct: hit.construct, + message: renderUnsupportedMessage(hit.construct), + })); +} diff --git a/packages/sdk/src/lib/schemas.ts b/packages/sdk/src/lib/schemas.ts index 518781d..b445c82 100644 --- a/packages/sdk/src/lib/schemas.ts +++ b/packages/sdk/src/lib/schemas.ts @@ -41,8 +41,17 @@ export interface CliOption { flags: string; summary: string; } +/** One display section of the command list, in the order help and the site show them. */ +export interface CliGroup { + id: string; + title: string; +} export interface CliCommand { name: string; + /** The `groups` id this command is listed under; exactly one, and always a declared id. */ + group: string; + /** Set on an alias: the primary command it stands for, itself never an alias. */ + aliasOf?: string; summary: string; usage: string; description: string; @@ -57,6 +66,7 @@ export interface CliSpec { usage: string; globalOptions: CliOption[]; exitCodes: { code: number; meaning: string }[]; + groups: CliGroup[]; commands: CliCommand[]; } diff --git a/packages/sdk/src/lib/text.ts b/packages/sdk/src/lib/text.ts index 760c5e6..bd2d322 100644 --- a/packages/sdk/src/lib/text.ts +++ b/packages/sdk/src/lib/text.ts @@ -28,3 +28,88 @@ export function byteCompare(a: string, b: string): number { export function isScalarString(s: string): boolean { return !/\p{Surrogate}/u.test(s); } + +/** + * The one line-wrapper behind every terminal help surface, so the three SDKs emit + * the same bytes: whitespace runs collapse to one space, the first line is indented + * by `indentFirst` and every continuation by `indentRest`, and width is counted in + * Unicode CODE POINTS — never UTF-16 units, which would measure an astral character + * as two and wrap a line early in JavaScript alone. A token that cannot fit the + * remaining width takes a line of its own, unbroken (URLs and flag spellings stay + * copyable). Returns the finished lines, indents included; empty text yields none. + */ +/** + * Terminal help wraps at a fixed width, never the actual terminal's: help bytes are + * a shared contract across the three SDKs, so they may not depend on the environment. + */ +export const HELP_WIDTH = 80; + +export function wrap(text: string, width: number, indentFirst: number, indentRest: number): string[] { + const words = text.split(/\s+/).filter((w) => w !== ''); + if (words.length === 0) return []; + const lines: string[] = []; + let indent = indentFirst; + let current = ''; + for (const word of words) { + const room = width - indent - [...current].length; + if (current === '') current = word; + else if ([...word].length + 1 <= room) current += ' ' + word; + else { + lines.push(' '.repeat(indent) + current); + indent = indentRest; + current = word; + } + } + lines.push(' '.repeat(indent) + current); + return lines; +} + +/** + * One row of a two-column help block: a label on the left, its prose on the right, + * the prose hanging under itself at `col`. A label that would leave no gap before + * its summary — one at least as wide as the column, which the option column's clamp + * makes reachable — takes the line alone and its summary starts on the next line at + * the same column, so a long flag never concatenates into the text describing it. + * Width is counted in code points, like `wrap` itself. + */ +export function helpRow(label: string, col: number, text: string, width = HELP_WIDTH): string[] { + const lines = wrap(text, width, col, col); + const labelWidth = [...label].length; + if (labelWidth >= col - 3) { + const head = ` ${label}`; + return lines.length === 0 ? [head] : [head, ...lines]; + } + // Padded by CODE POINTS, never `padEnd`: that counts UTF-16 units, so an astral + // character in a flag or command name would pad two columns short and misalign the + // whole block in JavaScript alone. + const head = ` ${label}${' '.repeat(col - 3 - labelWidth)}`; + if (lines.length === 0) return [head.trimEnd()]; + return [head + lines[0].slice(col), ...lines.slice(1)]; +} + +/** + * Where a two-column block's right column starts: the longest label plus a gap, kept + * inside a band so one long label cannot push every summary to the right edge, and + * measured in CODE POINTS. Past the band's top the label outgrows the column and + * `helpRow` gives it its own line. Every dynamic label class in terminal help resolves + * its column here — the bounds are the class's contract, identical in all three SDKs. + */ +export function boundedColumn(labels: string[], gap: number, min: number, max: number): number { + const longest = Math.max(0, ...labels.map((l) => [...l].length)); + return 3 + Math.min(max, Math.max(min, longest + gap)); +} + +/** Option rows, top-level and per-command: flags plus 3, bounded to [20, 30]. */ +export function optionColumn(flags: string[]): number { + return boundedColumn(flags, 3, 20, 30); +} + +/** Command and alias rows: the name plus 3, bounded to [12, 30]. */ +export function nameColumn(names: string[]): number { + return boundedColumn(names, 3, 12, 30); +} + +/** Exit-code rows: the code plus 2 (they are digits, not words), bounded to [3, 8]. */ +export function exitCodeColumn(codes: string[]): number { + return boundedColumn(codes, 2, 3, 8); +} diff --git a/packages/sdk/src/lib/writeplan.ts b/packages/sdk/src/lib/writeplan.ts index 41069bb..5e41130 100644 --- a/packages/sdk/src/lib/writeplan.ts +++ b/packages/sdk/src/lib/writeplan.ts @@ -51,7 +51,7 @@ export function buildWritePlan( entries.push({ rel, status: 'wont-modify', - note: 'existing file, read-only input — Leji will not modify it', + note: 'existing file, read-only input; Leji will not modify it', }); } return entries; diff --git a/packages/sdk/templates/agents/core.md b/packages/sdk/templates/agents/core.md index 938600d..f60446f 100644 --- a/packages/sdk/templates/agents/core.md +++ b/packages/sdk/templates/agents/core.md @@ -24,4 +24,4 @@ The shared posture for all agents working in this repository. Role profiles inhe ## Escalation - +Ask the primary owner () whenever mustAskWhen applies; record durable rulings in decisions. diff --git a/packages/sdk/templates/boot-profile.md b/packages/sdk/templates/boot-profile.md index f790ceb..132d1b3 100644 --- a/packages/sdk/templates/boot-profile.md +++ b/packages/sdk/templates/boot-profile.md @@ -7,6 +7,10 @@ +## Setup + +For a person preparing a fresh clone before opening an agent: install the repository's dependencies with its package manager, then run `leji start`: it checks that the Leji CLI resolves here, offers your agent's MCP registration and the pre-commit hook, and boots your agent from this profile. An agent already running from this profile has nothing to do here. + ## Loading Read before any task (keep this set small; it is paid on every task): @@ -57,3 +61,4 @@ When you change anything in this context layer: - Append an entry to `docs/context-changelog.json`: id, date, type, one-line summary, affected paths. - Decisions get a record in `docs/decisions/`; copy the shape of an existing one. - Regenerate `docs/context-index.json` when files are added, moved, or retitled. +- **A new file is categorized the moment it is created**: add it to the right category index, or deliberately leave it as an ungoverned reference and say so, in the same change set; the unindexed count (`leji status`) must be a choice, never a surprise. When the right category isn't obvious, ask the layer's owners instead of guessing. diff --git a/packages/sdk/templates/onboarding-brief.md b/packages/sdk/templates/onboarding-brief.md index 4bd34e6..e012a19 100644 --- a/packages/sdk/templates/onboarding-brief.md +++ b/packages/sdk/templates/onboarding-brief.md @@ -1,9 +1,9 @@ + It lives in the gitignored onboarding workspace (`.leji/work/`) at the repository root, + beside the generated viewer, so it is excluded from the index, the viewer, and the + changelog. --> # Onboarding brief for the agent @@ -99,7 +99,7 @@ The three file paths: 2. **Local path**: the owner drags a file into the terminal (which pastes its path) or types a path. Read the file in place; do not copy or move it. 3. **Drop folder**: if the owner says "open the drop folder," create - `/.leji/onboarding-inputs/`, run the safety checks in the next section, print its + `.leji/work/onboarding-inputs/`, run the safety checks in the next section, print its absolute path, and open it in the system file browser where supported. The owner copies files in and tells you when they are ready. @@ -114,18 +114,18 @@ Raw artifacts (emails, PDFs, bios, brand documents, writing samples) are **priva never content**. They must never enter the governed tree, the index, the changelog, or any commit. -**The transient workspace.** Everything artifact-related lives only under `/.leji/`: +**The transient workspace.** Everything artifact-related lives only under `.leji/work/`: -- `/.leji/onboarding-inputs/` for dropped or copied raw artifacts, -- `/.leji/onboarding-work/` for temporary extraction scratch, if needed, -- `/.leji/onboarding-sources.json`, a private source ledger you maintain: for each +- `.leji/work/onboarding-inputs/` for dropped or copied raw artifacts, +- `.leji/work/onboarding-work/` for temporary extraction scratch, if needed, +- `.leji/work/onboarding-sources.json`, a private source ledger you maintain: for each artifact record a short display name, kind, where it came from (attachment, external file, drop folder), and which sections it informed. No file contents in the ledger. **Before accepting any artifact**, verify the boundary is intact: -- Confirm `.leji/` is ignored (`git check-ignore /.leji` succeeds). -- Confirm nothing under `/.leji/` is tracked (`git ls-files /.leji` is empty). If +- Confirm `.leji/` is ignored (`git check-ignore .leji` succeeds). +- Confirm nothing under `.leji/` is tracked (`git ls-files .leji` is empty). If anything is tracked, stop artifact intake and tell the owner exactly what is tracked; do not run `git rm --cached` yourself. @@ -239,7 +239,7 @@ human-readable terms (for example "owner-provided 2025 brand guide"), nothing mo markers and `status: proposed` decisions as owner confirmations pending, and `leji status` reports what is still unindexed, dangling, or stale. 7. **Write the proposal, print it, then ask.** Phase 1 ends with the STOP section's two - steps, in order: the whole proposal written to `/.leji/proposal.md` and printed as + steps, in order: the whole proposal written to `.leji/work/proposal.md` and printed as plain text in your reply, then the approval prompt directly after it. Going from tool calls straight into the question tool without the printed summary is a protocol violation, not a shortcut: the owner must be able to read the full proposal without stepping through the @@ -285,7 +285,7 @@ samples is a proposal until confirmed. Do not relabel aspiration as voice to byp Two steps, strictly ordered, after every draft is written and sanity-checked: 1. **Write and print the confirmation summary.** Write the whole proposal to - `/.leji/proposal.md`, first line exactly `# Proposal for approval`, covering the + `.leji/work/proposal.md`, first line exactly `# Proposal for approval`, covering the load-bearing claims below, then print that same content as plain, readable text in your reply. The printed message comes IMMEDIATELY before the approval prompt: no tool calls, file edits, or checks in between. Never point at earlier tool output or file diffs as the @@ -314,14 +314,14 @@ Ask only what you could not verify. A few sharp questions beat a long interview. ## Phase 2: finalize (only after the owner confirms) - Adjust the index files to the owner's calls: promote, downgrade to reference, or recategorize. -- Remove the onboarding guard if installed: delete `/.leji/hooks/`, the +- Remove the onboarding guard if installed: delete `.leji/work/hooks/`, the `AskUserQuestion` PreToolUse entry it added to `.claude/settings.json`, and the - `/.leji/proposal.md` artifact (all transient onboarding machinery, never part of + `.leji/work/proposal.md` artifact (all transient onboarding machinery, never part of the layer). - Replace each `TODO(confirm-…)` with the confirmed wording (or correct it to what the owner said). - Flip each confirmed `status: proposed` decision to `status: accepted`. - Leave any genuinely-unknown plain `TODO:` in place and call it out. -- **Leak check** before anything else: nothing under `/.leji/` is tracked; no raw input +- **Leak check** before anything else: nothing under `.leji/` is tracked; no raw input filenames, hashes, or absolute private paths appear in governed documents; no email headers or raw excerpts survive in the proposed content. - Run `leji index` to regenerate the index, `leji status` to confirm nothing governed is left @@ -332,11 +332,11 @@ Ask only what you could not verify. A few sharp questions beat a long interview. locally on 127.0.0.1, and opens it in the browser. Offer to run it (or hand them the command); seeing the layer is what closes the loop for the humans who will rely on it. - As your last step, once everything above passes, delete the transient onboarding files: - this brief (`/.leji/onboarding-brief.md`), `/.leji/onboarding-inputs/`, - `/.leji/onboarding-work/`, and `/.leji/onboarding-sources.json`. They are + this brief (`.leji/work/onboarding-brief.md`), `.leji/work/onboarding-inputs/`, + `.leji/work/onboarding-work/`, and `.leji/work/onboarding-sources.json`. They are scaffolding and private evidence, not context. Never delete or modify the owner's external - originals. Leave the rest of `/.leji/` in place (it holds the generated viewer and is - gitignored). + originals. Leave the rest of `.leji/` in place (it holds the generated viewer and the + federation cache, and is gitignored). In your final report, **quote the owner's confirmation** of the classification, invariants, gates, and (in solo mode) the identity and writing-style synthesis. The tool cannot prove a @@ -345,7 +345,7 @@ conversation happened; your report and the repository's review gate are the reco ## Boundaries Only create or edit files Leji owns under the context root, plus the transient workspace named -above (`/.leji/onboarding-inputs/`, `onboarding-work/`, `onboarding-sources.json`), +above (`.leji/work/onboarding-inputs/`, `onboarding-work/`, `onboarding-sources.json`), which you create and delete as described. Treat existing `CLAUDE.md`, `AGENTS.md`, `.cursor/rules`, `.github/copilot-instructions.md` and similar as **read-only inputs to learn from**; never rewrite them, and never wire a vendor redirect without showing the owner the diff --git a/packages/sdk/templates/viewer/assets/PROVENANCE.txt b/packages/sdk/templates/viewer/assets/PROVENANCE.txt index 0852c68..eb98b5d 100644 --- a/packages/sdk/templates/viewer/assets/PROVENANCE.txt +++ b/packages/sdk/templates/viewer/assets/PROVENANCE.txt @@ -33,4 +33,11 @@ mermaid.min.js, docsify-mermaid.js leji-logo.svg The default viewer logo (the Leji mark). Overridden per-layer by viewer.logo. +third-party-licenses.txt + The consolidated notice for everything vendored here: a component list (name, + version, copyright, license) followed by each license text once. Unlike this + note it DOES ship, into every generated viewer/ and every exported dist/, so + the redistributed components carry their notices. Update it whenever an asset + is added, removed, or bumped. + This note is documentation only; the SDKs never copy it into a user's output. diff --git a/packages/sdk/templates/viewer/assets/fonts-licenses.txt b/packages/sdk/templates/viewer/assets/fonts-licenses.txt deleted file mode 100644 index a46662c..0000000 --- a/packages/sdk/templates/viewer/assets/fonts-licenses.txt +++ /dev/null @@ -1,15 +0,0 @@ -Vendored webfont licenses - -Source Sans Pro (source-sans-pro-*.woff2) - Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), - with Reserved Font Name 'Source'. All Rights Reserved. Source is a - trademark of Adobe Systems Incorporated in the United States and/or - other countries. - Licensed under the SIL Open Font License, Version 1.1. - https://openfontlicense.org - -Roboto Mono (roboto-mono-*.woff2) - Copyright 2015 The Roboto Mono Project Authors - (https://github.com/googlefonts/robotomono) - Licensed under the Apache License, Version 2.0. - http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/sdk/templates/viewer/assets/leji-logo.svg b/packages/sdk/templates/viewer/assets/leji-logo.svg index 490b6b6..33944b0 100644 --- a/packages/sdk/templates/viewer/assets/leji-logo.svg +++ b/packages/sdk/templates/viewer/assets/leji-logo.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/sdk/templates/viewer/assets/third-party-licenses.txt b/packages/sdk/templates/viewer/assets/third-party-licenses.txt new file mode 100644 index 0000000..7bd07c2 --- /dev/null +++ b/packages/sdk/templates/viewer/assets/third-party-licenses.txt @@ -0,0 +1,408 @@ +Third-party notices for the Leji viewer +====================================== + +This file travels with the generated viewer chrome and with every static site +`leji export` writes: it names each third-party component bundled beside it and +carries the full text of every license those components are used under, once +each, after the component list. Components are redistributed unmodified except +where a note says otherwise. The Leji mark (leji-logo.svg) is not third-party +material; Leji's own license is LICENSE.md in the Leji repository. + +Where a component's upstream version is not recorded in the Leji repository, +this file says so rather than guessing. + + +Components +---------- + +docsify + Version: 4.13.1 + License: MIT + Copyright (c) 2016 - present Docsify Contributors + Files: docsify.min.js; vue.css (the vendored theme, with a Leji brand block + appended); search.min.js (full-text search plugin); zoom-image.min.js + (image-zoom plugin). + +docsify-sidebar-collapse + Version: not recorded + License: MIT + Copyright (c) 2018 iPeng6 + Files: docsify-sidebar-collapse.min.js, docsify-sidebar-collapse.min.css. + +docsify-copy-code + Version: 2.1.1 + License: MIT + Copyright (c) 2017-2020 JP Erasmus + Files: docsify-copy-code.min.js. + +docsify-mermaid + Version: 2.0.1 + License: ISC + Copyright (c) Paul-Julien Vauthier + Files: docsify-mermaid.js. + +Mermaid + Version: 11.14.0 + License: MIT + Copyright (c) 2014 - 2022 Knut Sveidqvist + Files: mermaid.min.js. Bundled only while the layer leaves viewer.mermaid + enabled; disabling it ships neither this file nor the plugin above. + +Prism + Version: not recorded + License: MIT + Copyright (c) 2012 Lea Verou + Files: prism-bash.min.js, prism-json.min.js, prism-markdown.min.js, + prism-typescript.min.js (language components extending the Prism core + that docsify.min.js bundles). + +Source Sans Pro + Version: not recorded + License: SIL Open Font License 1.1 + Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), + with Reserved Font Name 'Source'. All Rights Reserved. Source is a + trademark of Adobe Systems Incorporated in the United States and/or + other countries. + Files: source-sans-pro-*.woff2. + +Roboto Mono + Version: not recorded + License: Apache License 2.0 + Copyright 2015 The Roboto Mono Project Authors + (https://github.com/googlefonts/robotomono) + Files: roboto-mono-*.woff2. + + +MIT License (docsify, docsify-sidebar-collapse, docsify-copy-code, Mermaid, Prism) +================================================================================== + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +ISC License (docsify-mermaid) +============================= + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + +SIL Open Font License 1.1 (Source Sans Pro) +=========================================== + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +Apache License 2.0 (Roboto Mono) +================================ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sdk/templates/viewer/assets/viewer-boot.js b/packages/sdk/templates/viewer/assets/viewer-boot.js index adada39..aecd89a 100644 --- a/packages/sdk/templates/viewer/assets/viewer-boot.js +++ b/packages/sdk/templates/viewer/assets/viewer-boot.js @@ -3,30 +3,117 @@ // Kept as a vendored file (not inline) so the page can run under a strict // Content-Security-Policy (script-src 'self'), which blocks any script injected // through served Markdown content. Written alongside the page by `leji viewer`. -// Pick a readable mermaid node-text color for the layer's accent: dark text on a -// light accent, white on a dark one. Parses #rgb or #rrggbb (case-insensitive); -// an unparseable value keeps the dark default. +// Fallback mermaid node-text color for the layer's accent. The SDK computes this +// server-side and ships it in the config block (lejiMermaidTextColor), over every +// color form the manifest accepts; this covers only a viewer tree generated before +// that field existed, so it parses #rgb and #rrggbb and nothing else. WCAG relative +// luminance over linearized sRGB: whichever of #1a1a1a and #ffffff contrasts more +// with the accent, or #000000 when neither clears 4.5:1 (a mid-gray accent, where +// the extra half-stop of black is the best text color available). An unparseable +// value keeps the dark default. function lejiMermaidTextColor(accent) { var hex = String(accent || '').replace(/^#/, ''); if (hex.length === 3) { hex = hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2); } if (!/^[0-9a-fA-F]{6}$/.test(hex)) return '#1a1a1a'; - var r = parseInt(hex.slice(0, 2), 16); - var g = parseInt(hex.slice(2, 4), 16); - var b = parseInt(hex.slice(4, 6), 16); - var brightness = (299 * r + 587 * g + 114 * b) / 1000; - return brightness >= 150 ? '#1a1a1a' : '#ffffff'; + var luminance = function (h) { + var channel = function (i) { + var c = parseInt(h.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4); + }; + var ratio = function (a, b) { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }; + var accentLuminance = luminance(hex); + var onDark = ratio(luminance('1a1a1a'), accentLuminance); + var onLight = ratio(luminance('ffffff'), accentLuminance); + if (onDark < 4.5 && onLight < 4.5) return '#000000'; + return onDark >= onLight ? '#1a1a1a' : '#ffffff'; } -window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify-config').textContent), { +// Resolve a raw-HTML `` against the document that carries it, exactly as +// Docsify's relativePath routing already resolves the markdown image form. Returns +// the path under `contentBase` (query and fragment preserved) or null for a src that must be +// left as authored: empty, fragment- or query-only, root-relative, backslash-led, +// protocol-relative, any scheme reference, and any traversal escaping /content/ — +// traversal is rejected rather than clamped, because the server canonicalizes and a +// clamped path would quietly address the viewer chrome instead of the layer. +// Containment is judged on the decoded, normalized path, not the literal one, +// because the server canonicalizes percent-encoding and separators before it +// routes — an encoded `..` reads as traversal there even though URL keeps it. +// The value is first put through URL parsing's own input preprocessing — leading +// and trailing C0-control-and-space characters trimmed, then ASCII tab, LF, and +// CR removed anywhere in the value — so classification sees exactly what the +// parser sees; otherwise a padded or tab-split scheme reference slips past the +// first-character and scheme checks and gets rewritten. +function lejiResolveImgSrc(src, docDir, contentBase) { + var origin = 'http://leji.invalid'; + var raw = String(src || '') + .replace(/^[\x00-\x20]+/, '') + .replace(/[\x00-\x20]+$/, '') + .replace(/[\t\n\r]/g, ''); + if (raw === '') return null; + var first = raw.charAt(0); + if (first === '#' || first === '?' || first === '/' || first === '\\') return null; + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw)) return null; + var url; + try { + url = new URL(raw, origin + '/content/' + (docDir ? docDir + '/' : '')); + } catch (e) { + return null; + } + if (url.origin !== origin) return null; + if (url.pathname.indexOf('/content/') !== 0) return null; + var decoded; + try { + decoded = decodeURIComponent(url.pathname); + } catch (e) { + return null; + } + var parts = decoded.replace(/\\/g, '/').split('/'); + var kept = []; + for (var i = 0; i < parts.length; i++) { + if (parts[i] === '' || parts[i] === '.') continue; + if (parts[i] === '..') kept.pop(); + else kept.push(parts[i]); + } + if (('/' + kept.join('/')).indexOf('/content/') !== 0) return null; + // Re-based onto the content mount as this page addresses it: '/content/…' when + // served locally, 'content/…' in an export, which the browser then resolves + // against the page so a subpath-hosted tree still finds the file. + return contentBase + url.pathname.slice('/content/'.length) + url.search + url.hash; +} + +var lejiConfig = JSON.parse(document.getElementById('leji-docsify-config').textContent); +// Where this page addresses the layer's markdown, from the SDK's config block: +// '/content/' for the local server, 'content/' for an export. Everything the page +// fetches for itself is derived from it, so one generated value moves the whole +// chrome between the app root and a relative base. Older viewer trees carry no +// basePath in their config; they were server-flavored, so the app root is the +// correct fallback. +var lejiContentBase = typeof lejiConfig.basePath === 'string' ? lejiConfig.basePath : '/content/'; + +window.$docsify = Object.assign(lejiConfig, { // The viewer chrome lives at the web root; the layer's markdown is mounted under - // /content/. basePath points Docsify at the content mount; the alias maps every - // nested `_sidebar.md` lookup to the single generated sidebar (so nested routes do - // not 404), which basePath then resolves to /content/_sidebar.md. - basePath: '/content/', + // the content base above. basePath points Docsify at the content mount; the alias + // maps every nested `_sidebar.md` lookup to the single generated sidebar (so + // nested routes do not 404), which basePath then resolves to _sidebar.md. + basePath: lejiContentBase, loadSidebar: '_sidebar.md', alias: { '/.*/_sidebar.md': '_sidebar.md' }, + // Markdown links resolve against the document that carries them, matching how + // the same files read on disk and on any git host. Generated sidebar links are + // emitted app-root absolute (leading slash) so they are unaffected. Without + // this, a `../`-style link on a nested page escapes the router entirely. + relativePath: true, + // A missing document renders Docsify's in-app not-found message; the vendored + // runtime's default (true) would issue a second, always-failing fetch for a + // `_404.md` no layer ships. The primary missing-document 404 is inherent to + // static serving. + notFoundPage: false, subMaxLevel: 3, auto2top: true, // Docsify's script execution runs a `new Function(...)` over a rendered page's @@ -50,6 +137,26 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, ''); }); }, + function resolveImageSrc(hook, vm) { + // relativePath resolves markdown images; a raw-HTML + // passes through untouched and the browser resolves it against the page URL, + // so on a nested page it 404s. Rewrite at render rather than on the way out: + // a document's served bytes are the file's, verbatim. afterEach runs before + // the compiled HTML is inserted, so the unresolved URL is never requested. + hook.afterEach(function (html, next) { + var rel = vm.route && vm.route.file ? vm.route.file : ''; + var cut = rel.lastIndexOf('/'); + var docDir = cut === -1 ? '' : rel.slice(0, cut); + // Parsed in a detached container, never regexed over the HTML string. + var container = document.createElement('div'); + container.innerHTML = html; + container.querySelectorAll('img[src]').forEach(function (img) { + var resolved = lejiResolveImgSrc(img.getAttribute('src'), docDir, lejiContentBase); + if (resolved !== null) img.setAttribute('src', resolved); + }); + next(container.innerHTML); + }); + }, function categoryBadge(hook, vm) { // Top-right classification chip: the category (emoji + label) every // governed page carries for agents, made visible to people. Records @@ -60,8 +167,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify if (!cfg.lejiIndexRel || !cfg.lejiCategories) return; hook.doneEach(function () { var rel = vm.route && vm.route.file ? vm.route.file : ''; - fetch('/content/' + cfg.lejiIndexRel, { cache: 'no-store' }) - .then(function (r) { return r.ok ? r.json() : null; }) + fetch(lejiContentBase + cfg.lejiIndexRel, { cache: 'no-store' }) + .then(function (r) { + return r.ok ? r.json() : null; + }) .then(function (idx) { var label = null; if (rel === cfg.lejiBootPath) { @@ -76,11 +185,17 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify prefix = path.slice(0, path.length - rel.length); break; } - if (path === rel) { prefix = ''; break; } + if (path === rel) { + prefix = ''; + break; + } } var entry = null; for (var j = 0; j < idx.entries.length; j++) { - if (idx.entries[j].path === (prefix === null ? rel : prefix + rel)) { entry = idx.entries[j]; break; } + if (idx.entries[j].path === (prefix === null ? rel : prefix + rel)) { + entry = idx.entries[j]; + break; + } } if (entry) { label = cfg.lejiCategories[entry.category] || entry.category; @@ -90,7 +205,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify } } var el = document.querySelector('.lj-cat'); - if (!label) { if (el) el.remove(); return; } + if (!label) { + if (el) el.remove(); + return; + } if (!el) { el = document.createElement('div'); el.className = 'lj-cat'; @@ -98,7 +216,9 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify } el.textContent = label; }) - .catch(function () { /* badge is best-effort chrome */ }); + .catch(function () { + /* badge is best-effort chrome */ + }); }); }, function sidebarLoadingState(hook) { @@ -131,7 +251,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify }, function brandMermaid(hook) { // Theme mermaid diagrams from the layer's accent color; runs at init so - // it lands after mermaid.min.js (loaded last) is present. + // it lands after mermaid.min.js (loaded last) is present. The node-text + // color is the SDK's, computed at generation time over every color form + // the manifest accepts; the local fallback covers only a viewer tree + // generated before that field shipped. hook.init(function () { if (!window.mermaid || !window.$docsify.themeColor) return; window.mermaid.initialize({ @@ -139,9 +262,10 @@ window.$docsify = Object.assign(JSON.parse(document.getElementById('leji-docsify theme: 'base', themeVariables: { primaryColor: window.$docsify.themeColor, - primaryTextColor: lejiMermaidTextColor(window.$docsify.themeColor), + primaryTextColor: + window.$docsify.lejiMermaidTextColor || lejiMermaidTextColor(window.$docsify.themeColor), lineColor: '#666', - tertiaryColor: '#f8f9fa', + tertiaryColor: '#f7f8f5', }, }); }); diff --git a/packages/sdk/templates/viewer/assets/vue.css b/packages/sdk/templates/viewer/assets/vue.css index 6a86ff6..1fc6850 100644 --- a/packages/sdk/templates/viewer/assets/vue.css +++ b/packages/sdk/templates/viewer/assets/vue.css @@ -1,5 +1,5 @@ /* Vendored webfonts (self-contained; no CDN). Source Sans Pro: SIL OFL 1.1; - Roboto Mono: Apache-2.0. See fonts-licenses.txt. */ + Roboto Mono: Apache-2.0. See third-party-licenses.txt. */ @font-face { font-family: 'Roboto Mono'; font-style: normal; @@ -973,14 +973,16 @@ code .token { (see the @font-face rules at the top of this file). ========================================================================== */ :root { - --theme-color: #223f93; /* recolors every var(--theme-color) rule above */ - --leji-blue-deep: #162960; - --leji-blue: #223f93; - --leji-gold: #ffbd6e; - --leji-paper: #f8f9fa; + --theme-color: #009f71; /* recolors every var(--theme-color) rule above */ + --leji-brand: #009f71; /* the Leji mark green: brand moments, never small text */ + --leji-link: #007d59; /* the accessible green for links and small text */ + --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-line: #e0e0e0; + --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 */ color-scheme: light; } @@ -1019,7 +1021,7 @@ body { color: var(--theme-color); text-decoration: none; } -/* Active document: a plain blue text change, nothing else. */ +/* Active document: a plain accent-colored text change, nothing else. */ .sidebar ul li.active > a { color: var(--theme-color) !important; border-right: none; @@ -1098,7 +1100,7 @@ body { .search input:focus { outline: none; border-color: var(--theme-color); - box-shadow: 0 0 0 2px rgba(34, 63, 147, 0.12); + box-shadow: 0 0 0 2px rgba(0, 159, 113, 0.12); } .search .results-panel { background: var(--leji-paper); @@ -1137,14 +1139,14 @@ body { background-color: var(--leji-paper) !important; } .leji-powered a { - color: var(--leji-blue) !important; + color: var(--leji-link) !important; text-decoration: none; } .leji-powered a:hover { - color: #162960 !important; + color: var(--leji-deep) !important; } .leji-powered .spark { - color: var(--leji-gold); + color: var(--leji-brand); } .leji-powered strong { font-weight: 600; @@ -1154,18 +1156,22 @@ body { color: var(--theme-color); } /* brand-tinted inline code, replacing the stock orange. Scoped away from - pre > code so fenced blocks keep the stock panel and token colors. */ + 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); - background: rgba(34, 63, 147, 0.07); + background: var(--leji-code-bg); +} +/* The fenced-code panel takes the same ground, replacing the stock neutral gray. */ +.markdown-section pre { + background-color: var(--leji-code-bg); } .markdown-section pre > code { color: #525252; background: none; } .markdown-section blockquote { - border-left: 3px solid var(--leji-gold); + border-left: 3px solid var(--leji-accent); color: var(--leji-ink-soft); } diff --git a/packages/sdk/templates/viewer/index.html b/packages/sdk/templates/viewer/index.html index bdf7c6d..924ce01 100644 --- a/packages/sdk/templates/viewer/index.html +++ b/packages/sdk/templates/viewer/index.html @@ -31,9 +31,10 @@ diff --git a/packages/sdk/test/badge.test.ts b/packages/sdk/test/badge.test.ts new file mode 100644 index 0000000..dade31f --- /dev/null +++ b/packages/sdk/test/badge.test.ts @@ -0,0 +1,663 @@ +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'; +import * as path from 'node:path'; +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'; + +// Two halves of one contract. First the constants: every level rendered and +// byte-compared against `fixtures/badge/`, the sole oracle, plus the `--out` +// acceptance table and the existing-file rule over temp trees. Then the shared +// fixtures' `badge` blocks, driven through the real CLI as a process. + +const execFileAsync = promisify(execFile); +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const fixturesDir = path.join(repoRoot, 'fixtures'); +const goldenDir = path.join(fixturesDir, 'badge'); +const cli = path.join(pkgRoot, 'dist', 'cli.js'); + +const LEVELS: readonly ConformanceLevel[] = ['core', 'indexed', 'governed', 'federated']; + +// --- the canonical bytes ------------------------------------------------------ + +test('every level renders the canonical badge byte for byte', () => { + for (const level of LEVELS) { + assert.equal( + renderBadge(level), + fs.readFileSync(path.join(goldenDir, `${level}.svg`), 'utf8'), + `${level}.svg differs from the golden`, + ); + assert.equal( + badgeMarkdown(level, 'leji-badge.svg'), + fs.readFileSync(path.join(goldenDir, `${level}.md`), 'utf8'), + `${level}.md differs from the golden`, + ); + } +}); + +test('the claim is structural: absent from the badge face, present in title, aria-label and alt', () => { + for (const level of LEVELS) { + const claim = `Leji 1.0 · ${level} · self-attested`; + // The markdown fixture — the alt text an adopter pastes into a README — carries + // the whole claim, which is what lets the face drop it. + const md = fs.readFileSync(path.join(goldenDir, `${level}.md`), 'utf8'); + assert.ok(md.includes(`[![${claim}]`), `${level}.md must carry the full alt claim`); + + const svg = fs.readFileSync(path.join(goldenDir, `${level}.svg`), 'utf8'); + assert.ok(svg.includes(`${claim}`), `${level}.svg must carry the claim`); + assert.ok(svg.includes(`aria-label="${claim}"`), `${level}.svg aria-label must carry the claim`); + + // The visible segment is the level alone: the two `<text>` bodies are the + // wordmark and the level, and `self-attested` appears nowhere a renderer draws. + const drawn = [...svg.matchAll(/<text\b[^>]*>([^<]*)<\/text>/g)].map((m) => m[1]); + assert.deepEqual(drawn, ['Leji 1.0', level], `${level}.svg draws the wordmark and the level, and nothing else`); + } +}); + +test('the markdown carries the canonical --out value, not the default', () => { + assert.equal( + badgeMarkdown('governed', 'docs/badge.svg'), + '[![Leji 1.0 · governed · self-attested](docs/badge.svg)](https://leji.org/agent-ready/)\n', + ); +}); + +// --- the `--out` acceptance rule ---------------------------------------------- + +/** A committed working copy of a fixture: the level a badge states needs a git + * baseline, since the `indexed` changelog item is `unknown` until the changelog is + * in HEAD (`fixtures/README.md` -> "The `badge` block"). */ +function committedFixture(name: string): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-badge-'))); + fs.cpSync(path.join(fixturesDir, name), dir, { recursive: true }); + 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=Badge Test', '-c', 'user.email=badge@example.com', 'commit', '-q', '-m', 'seed'); + return dir; +} + +test('--out accepts a repository-relative POSIX .svg path and rejects everything else', () => { + const dir = committedFixture('valid-badge-governed'); + try { + // Accepted, with the canonical POSIX form echoed back: a `.` segment is + // dropped, and a nested target has its parent directories created. + for (const [given, canonical] of [ + ['leji-badge.svg', 'leji-badge.svg'], + ['./badge.svg', 'badge.svg'], + ['docs/badge.svg', 'docs/badge.svg'], + ['a/b/c-1_2.svg', 'a/b/c-1_2.svg'], + ] as const) { + const r = badgeRun(dir, given); + assert.equal(r.usageError, undefined, `${given} must be accepted`); + assert.equal(r.out, canonical, `${given} canonicalizes to ${canonical}`); + assert.ok(fs.existsSync(path.join(dir, ...canonical.split('/'))), `${canonical} was written`); + } + // Rejected at argument parsing, before conformance runs: no level is reported + // at all, and nothing is written. + for (const bad of [ + '/abs.svg', + '../x.svg', + 'docs/../x.svg', + 'a\\b.svg', + 'x.png', + 'x.svg ', + 'a//b.svg', + 'doc s/x.svg', + 'x.svg#frag', + '.leji/x.svg', + '.leji/dist/x.svg', + '.leji/a/b/x.svg', + ]) { + const r = badgeRun(dir, bad); + assert.ok(r.usageError !== undefined, `${bad} must be rejected`); + assert.equal(r.out, null); + assert.equal(r.level, null); + assert.equal(r.claimedLevel, null, `${bad} reports no level`); + assert.equal(r.verifiedLevel, null, `${bad} reports no level`); + } + // A directory at the target is a rejection too, and the directory survives it. + fs.mkdirSync(path.join(dir, 'adir.svg')); + assert.ok(badgeRun(dir, 'adir.svg').usageError !== undefined, 'a directory is never a badge target'); + assert.ok(fs.statSync(path.join(dir, 'adir.svg')).isDirectory()); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- the existing-file rule --------------------------------------------------- + +test('the target file decides the action, by its bytes and nothing else', () => { + const dir = committedFixture('valid-badge-governed'); + const target = path.join(dir, 'leji-badge.svg'); + try { + // Absent: written. + assert.equal(badgeRun(dir).action, 'wrote'); + assert.equal(fs.readFileSync(target, 'utf8'), renderBadge('governed')); + + // These exact bytes: unchanged, and not rewritten (the mtime stands). + const before = fs.statSync(target).mtimeMs; + assert.equal(badgeRun(dir).action, 'unchanged'); + assert.equal(fs.statSync(target).mtimeMs, before, 'an unchanged target is never rewritten'); + + // Another canonical badge of this contract: overwritten, which is how a level + // change regenerates. All three of the others, not just the neighbouring one. + for (const level of LEVELS.filter((l) => l !== 'governed')) { + fs.writeFileSync(target, renderBadge(level)); + assert.equal(badgeRun(dir).action, 'overwrote', `a stale ${level} badge regenerates`); + assert.equal(fs.readFileSync(target, 'utf8'), renderBadge('governed')); + } + + // Anything else: refused, exit 2's message, the file untouched and never + // truncated. The levels are still reported, the rule running after conformance. + const foreign = '<svg><!-- somebody elses file --></svg>\n'; + fs.writeFileSync(target, foreign); + const r = badgeRun(dir); + assert.equal(r.refusal, 'leji-badge.svg exists and is not a leji badge; remove or rename it'); + assert.equal(r.out, null); + assert.equal(r.action, null); + assert.equal(r.claimedLevel, 'governed'); + assert.equal(r.verifiedLevel, 'governed'); + assert.equal(fs.readFileSync(target, 'utf8'), foreign, 'a refusal never edits and never truncates'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a nested --out creates its parent directories only when the write happens', () => { + const dir = committedFixture('valid-records'); // claims core, verifies core + try { + fs.writeFileSync(path.join(dir, 'leji-badge.svg'), 'not a badge\n'); + assert.ok(badgeRun(dir, 'docs/nested/badge.svg').out !== null); + assert.ok(fs.existsSync(path.join(dir, 'docs', 'nested', 'badge.svg'))); + // The refusal path writes nothing, so it establishes no directory either. + assert.ok(badgeRun(dir, 'leji-badge.svg').refusal !== undefined); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a run that writes nothing establishes no directory on the way to not writing', () => { + // Exit 1 (a claim this run refutes): the nested target and its parent are both + // absent afterwards, so the directory is a consequence of the write and not of + // the attempt. + const failing = committedFixture('invalid-governed-no-profile'); + try { + const r = badgeRun(failing, 'pub/x/badge.svg'); + assert.equal(r.out, null); + assert.equal(r.action, null); + assert.ok(r.findings.some((f) => f.severity === 'error')); + assert.ok(!fs.existsSync(path.join(failing, 'pub', 'x', 'badge.svg')), 'the target was never created'); + assert.ok(!fs.existsSync(path.join(failing, 'pub', 'x')), 'the parent was never created'); + assert.ok(!fs.existsSync(path.join(failing, 'pub')), 'nor its parent'); + } finally { + fs.rmSync(failing, { recursive: true, force: true }); + } + + // Exit 2 (a foreign file at a nested target whose parent already exists): the + // parent is left exactly as it was and the target's bytes are untouched. + const dir = committedFixture('valid-badge-governed'); + try { + const parent = path.join(dir, 'pub'); + fs.mkdirSync(parent); + 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 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'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- containment: the resolved path decides, in both directions ----------------- + +test('a --out whose parent resolves outside the repository is refused, and reads nothing', () => { + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-outside-'))); + const dir = committedFixture('valid-badge-governed'); + try { + // A file already standing at the escaped location: the run must neither read + // it (it is not the target the check cleared) nor replace it. + 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 r = badgeRun(dir, 'pub/x.svg'); + assert.ok(r.usageError !== undefined || r.refusal !== undefined, 'the escape is refused'); + assert.equal(r.out, null); + 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'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test('a --out whose parent resolves into .leji/ is refused, at any depth', () => { + const dir = committedFixture('valid-badge-governed'); + try { + fs.mkdirSync(path.join(dir, '.leji', 'dist'), { recursive: true }); + fs.symlinkSync(path.join(dir, '.leji', 'dist'), path.join(dir, 'pub'), 'dir'); + const r = badgeRun(dir, 'pub/x.svg'); + assert.ok(r.usageError !== undefined || r.refusal !== undefined, '.leji/ is never a badge target'); + assert.equal(r.out, null); + assert.deepEqual(fs.readdirSync(path.join(dir, '.leji', 'dist')), [], 'the private role stays empty'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a --out that is itself a symlink out of the repository is refused, target untouched', () => { + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-outside-'))); + const dir = committedFixture('valid-badge-governed'); + try { + const planted = 'somebody elses file\n'; + const escaped = path.join(outside, 'foreign.svg'); + fs.writeFileSync(escaped, planted); + fs.symlinkSync(escaped, path.join(dir, 'leji-badge.svg')); + + const r = badgeRun(dir); + assert.ok(r.usageError !== undefined || r.refusal !== undefined, 'a link out of the repository is refused'); + assert.equal(r.out, null); + assert.equal(r.action, null); + assert.equal(fs.readFileSync(escaped, 'utf8'), planted, 'the link target is byte-untouched'); + assert.ok(fs.lstatSync(path.join(dir, 'leji-badge.svg')).isSymbolicLink(), 'the link itself is left alone'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test('a --out that is a dangling symlink inside the repository is refused, nothing created', () => { + const dir = committedFixture('valid-badge-governed'); + try { + // The link resolves to a missing file INSIDE the repository, so the resolved + // destination is absent while the entry at the target path is not. A write + // 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 r = badgeRun(dir); + assert.equal( + r.refusal, + 'leji-badge.svg does not resolve to a regular file inside the repository; nothing was written', + 'a dangling in-repository link is refused, not written through', + ); + assert.equal(r.out, null); + assert.equal(r.action, null); + assert.deepEqual( + r.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + [{ rule: 'badge-target-refused', severity: 'error', path: 'leji-badge.svg' }], + ); + 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'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a --out that is a unix socket is refused as a document, not as a crash', async (t) => { + const dir = committedFixture('valid-badge-governed'); + const target = path.join(dir, 'leji-badge.svg'); + let server: net.Server | null = null; + try { + // A socket is the non-regular entry that no earlier check rejects: it is not a + // 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. + try { + server = await new Promise<net.Server>((resolve, reject) => { + const s = net.createServer(); + s.once('error', reject); + s.listen(target, () => resolve(s)); + }); + } catch (e) { + t.skip(`this platform cannot bind a unix socket at the badge target: ${(e as Error).message}`); + return; + } + assert.ok(fs.lstatSync(target).isSocket(), 'the target is a socket'); + const before = snapshot(dir); + + const r = badgeRun(dir); + assert.equal( + r.refusal, + 'leji-badge.svg does not resolve to a regular file inside the repository; nothing was written', + 'a socket at the target is refused, in the same words as every other standing entry', + ); + assert.equal(r.out, null); + assert.equal(r.action, null); + assert.deepEqual( + r.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + [{ 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'); + + // 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. + const cliRun = await runCliProc(['badge', '--root', dir, '--json']); + assert.equal(cliRun.code, 2, 'the refusal exits 2'); + const doc = JSON.parse(cliRun.stdout) as BadgeDocument; + assert.deepEqual(Object.keys(doc).sort(), [...DOCUMENT_KEYS].sort(), 'the exact JSON key set'); + assert.equal(doc.command, 'badge'); + assert.equal(doc.ok, false); + assert.equal(doc.out, null); + assert.equal(doc.level, null); + assert.equal(doc.markdown, null); + assert.equal(doc.action, null); + assert.deepEqual( + doc.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + [{ rule: 'badge-target-refused', severity: 'error', path: 'leji-badge.svg' }], + ); + assert.deepEqual(doc.summary, { errors: 1, warnings: 0 }); + } finally { + server?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a --out symlinked to a unix socket in the repository is refused as a document too', async (t) => { + const dir = committedFixture('valid-badge-governed'); + const sock = path.join(dir, 'sock'); + const target = path.join(dir, 'leji-badge.svg'); + let server: net.Server | null = null; + try { + // The link passes an entry-kind check that stops at the link itself, and the + // verified open then follows it to the socket and raises before it can fstat. + // So the kind that decides is the one at the END of the link. + try { + server = await new Promise<net.Server>((resolve, reject) => { + const s = net.createServer(); + s.once('error', reject); + s.listen(sock, () => resolve(s)); + }); + } catch (e) { + t.skip(`this platform cannot bind a unix socket in the badge fixture: ${(e as Error).message}`); + return; + } + 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 r = badgeRun(dir); + assert.equal( + r.refusal, + 'leji-badge.svg does not resolve to a regular file inside the repository; nothing was written', + 'a link to a socket is refused, in the same words as the socket itself', + ); + assert.equal(r.out, null); + assert.equal(r.action, null); + assert.deepEqual( + r.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + [{ rule: 'badge-target-refused', severity: 'error', path: 'leji-badge.svg' }], + ); + 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'); + + // Through the real bin: the ordinary badge document at exit 2, not the generic + // handler's bare error. + const cliRun = await runCliProc(['badge', '--root', dir, '--json']); + assert.equal(cliRun.code, 2, 'the refusal exits 2'); + const doc = JSON.parse(cliRun.stdout) as BadgeDocument; + assert.deepEqual(Object.keys(doc).sort(), [...DOCUMENT_KEYS].sort(), 'the exact JSON key set'); + assert.equal(doc.command, 'badge'); + assert.equal(doc.ok, false); + assert.equal(doc.out, null); + assert.equal(doc.level, null); + assert.equal(doc.markdown, null); + assert.equal(doc.action, null); + assert.deepEqual( + doc.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + [{ rule: 'badge-target-refused', severity: 'error', path: 'leji-badge.svg' }], + ); + assert.deepEqual(doc.summary, { errors: 1, warnings: 0 }); + } finally { + server?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- the shared fixtures' `badge` blocks -------------------------------------- + +interface ExpectedBadge { + args?: string[]; + exit: number; + out: string | null; + level: string | null; + claimedLevel: string | null; + verifiedLevel: string | null; + golden: string | null; + action: string | null; + written?: boolean; + preseed?: { path: string; from?: string; bytes?: string }; + rerun?: { action: string; byteIdentical: boolean }; +} + +interface CliResult { + code: number; + stdout: string; +} + +/** The real bin, as a process: the exit code is the process's own. */ +async function runCliProc(args: string[]): Promise<CliResult> { + try { + const { stdout } = await execFileAsync('node', [cli, ...args], { cwd: repoRoot }); + return { code: 0, stdout }; + } catch (e) { + const err = e as { code?: number; stdout?: string }; + return { code: err.code ?? 1, stdout: err.stdout ?? '' }; + } +} + +/** 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<string, string>()): Map<string, string> { + 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 = [ + 'command', + 'ok', + 'findings', + 'summary', + 'out', + 'level', + 'claimedLevel', + 'verifiedLevel', + 'markdown', + 'action', +]; + +interface BadgeDocument { + command: string; + ok: boolean; + findings: { rule: string; severity: string; path?: string; message: string }[]; + summary: { errors: number; warnings: number }; + out: string | null; + level: string | null; + claimedLevel: string | null; + verifiedLevel: string | null; + markdown: string | null; + action: string | null; +} + +/** A finding as `fixtures/README.md` -> "Matching rules" compares one: the triple + * (rule, severity, path). Message text is implementation-specific and is never + * compared — everything that identifies the finding is here. */ +interface FindingKey { + rule: string; + severity: string; + path?: string; +} + +/** + * The findings and the summary a `badge` block PINS — fixed by the block alone, + * never read off the document being judged, so a different rule, an extra finding + * or a missing one fails. Three outcomes exhaust the block: a success reports + * nothing; an exit-2 refusal names the foreign file it would not overwrite; an + * exit-1 run reports the conformance error that left nothing honest to state — the + * claim gate when this run verified a level below the claim, `badge-unverified` + * when it verified no level at all. + */ +function expectedDocument( + block: ExpectedBadge, + targetRel: string, +): { findings: FindingKey[]; summary: { errors: number; warnings: number } } { + if (block.exit === 0) return { findings: [], summary: { errors: 0, warnings: 0 } }; + const refused: FindingKey = + block.exit === 2 + ? { rule: 'badge-target-foreign', severity: 'error', path: targetRel } + : { + rule: block.verifiedLevel === null ? 'badge-unverified' : 'conformance-claim', + severity: 'error', + path: 'leji.json', + }; + return { findings: [refused], summary: { errors: 1, warnings: 0 } }; +} + +/** The whole `--json` document against the block: the exact key set, and every + * value the block fixes — including the findings and the summary, pinned above + * rather than derived from the document, which is what makes a wrong rule or a + * stray finding fail here. The summary's exact key set, its agreement with the + * findings beside it, and `ok`'s agreement with both follow from comparing the + * pinned pair, so they are asserted by that comparison and not again. */ +function assertBadgeDocument(stdout: string, block: ExpectedBadge, targetRel: string, where: string): BadgeDocument { + const doc = JSON.parse(stdout) as BadgeDocument; + assert.deepEqual(Object.keys(doc).sort(), [...DOCUMENT_KEYS].sort(), `${where}: the exact JSON key set`); + assert.equal(doc.command, 'badge', `${where}: command`); + assert.equal(doc.out, block.out, `${where}: out`); + assert.equal(doc.level, block.level, `${where}: level`); + assert.equal(doc.claimedLevel, block.claimedLevel, `${where}: claimedLevel`); + assert.equal(doc.verifiedLevel, block.verifiedLevel, `${where}: verifiedLevel`); + assert.equal(doc.action, block.action, `${where}: action`); + assert.equal( + doc.markdown, + block.level === null || block.out === null ? null : badgeMarkdown(block.level as ConformanceLevel, block.out), + `${where}: markdown`, + ); + assert.equal(doc.ok, block.exit === 0, `${where}: ok tracks the exit code`); + const expected = expectedDocument(block, targetRel); + assert.deepEqual( + doc.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + expected.findings, + `${where}: the exact findings, on (rule, severity, path)`, + ); + assert.deepEqual(doc.summary, expected.summary, `${where}: the literal summary`); + return doc; +} + +test('a --out usage error exits 2 and emits no JSON document at all', async () => { + const dir = committedFixture('valid-badge-governed'); + try { + for (const bad of ['x.png', '../x.svg', '/abs.svg', '.leji/x.svg']) { + const r = await runCliProc(['badge', '--root', dir, '--json', '--out', bad]); + assert.equal(r.code, 2, `${bad} is a usage error`); + assert.equal(r.stdout.trim(), '', `${bad} writes nothing to stdout, so no level is reported`); + } + assert.ok(!fs.existsSync(path.join(dir, 'leji-badge.svg')), 'and nothing was written'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +for (const name of fs.readdirSync(fixturesDir).sort()) { + const expectedFile = path.join(fixturesDir, name, 'expected.json'); + if (!fs.existsSync(expectedFile)) continue; + const block = (JSON.parse(fs.readFileSync(expectedFile, 'utf8')) as { badge?: ExpectedBadge }).badge; + if (!block) continue; + + test(`fixture ${name}: the badge block`, async () => { + const dir = committedFixture(name); + try { + const targetRel = block.preseed?.path ?? block.out ?? 'leji-badge.svg'; + const target = path.join(dir, ...targetRel.split('/')); + if (block.preseed) { + const bytes = block.preseed.from + ? fs.readFileSync(path.join(fixturesDir, ...block.preseed.from.split('/'))) + : Buffer.from(block.preseed.bytes ?? '', 'utf8'); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, bytes); + } + const planted = block.preseed ? fs.readFileSync(target) : null; + + const args = block.args ?? ['badge']; + const first = await runCliProc([...args, '--root', dir, '--json']); + assert.equal(first.code, block.exit, `exit code for ${name}: ${first.stdout}`); + // The document carries every outcome, refusals included: `ok:false` and the + // rule that refused, at the target path, are pinned inside it. + assertBadgeDocument(first.stdout, block, targetRel, `${name} (first run)`); + + if (block.golden !== null) { + const golden = fs.readFileSync(path.join(fixturesDir, ...block.golden.split('/'))); + assert.deepEqual(fs.readFileSync(path.join(dir, ...block.out!.split('/'))), golden, 'the written bytes'); + } + // `written: false` is two claims in one: the target does not exist after the + // run, or — when `preseed` planted it — its planted bytes are still there. + if (block.written === false) { + if (planted === null) assert.ok(!fs.existsSync(target), `${targetRel} was never created`); + else assert.deepEqual(fs.readFileSync(target), planted, `${targetRel} is byte-untouched`); + } + + if (block.rerun) { + const afterFirst = snapshot(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 + // same run reported the same way, with the write already done. + assertBadgeDocument( + second.stdout, + { ...block, action: block.rerun.action, preseed: undefined }, + targetRel, + `${name} (rerun)`, + ); + if (block.rerun.byteIdentical) { + assert.deepEqual( + [...snapshot(dir).entries()].sort(), + [...afterFirst.entries()].sort(), + 'a second run is a byte-level no-op across the whole working tree', + ); + } + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} diff --git a/packages/sdk/test/canary.test.ts b/packages/sdk/test/canary.test.ts new file mode 100644 index 0000000..3622363 --- /dev/null +++ b/packages/sdk/test/canary.test.ts @@ -0,0 +1,1090 @@ +import { strict as assert } from 'node:assert'; +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 os from 'node:os'; +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'; + +// 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 +// the request corpus (`trustCanary`) and the layout claims (`export.layout`), so all +// three SDKs answer identical requests against identical bytes. +// +// Scope: the four F8 layout fixtures — their layout roles, their golden export +// bytes, and their canary corpus. The general `export`-block harness (findings, +// `--strict` variants) takes every other fixture. +const LAYOUT_FIXTURES = [ + 'valid-unified-leji-fresh', + 'valid-unified-leji-stale-tree', + 'valid-trust-canary-nested-root', + 'valid-trust-canary-dot-root', +]; + +/** The planted byte string. Spelled in each harness and deliberately in no + * `expected.json`: under `rootPath: "."` a fixture's own metadata is exported like + * any other file, so a token literal there would count as a leak. */ +const TOKEN = 'LEJI-TRUST-CANARY'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const fixturesDir = path.join(repoRoot, 'fixtures'); + +interface Seed { + from: string; + to: string; +} + +interface ExpectedExport { + exit: number; + out: string; + layout?: { + roles?: Record<string, string>; + present?: string[]; + absent?: string[]; + preserved?: string[]; + }; + rerun?: { byteIdentical?: boolean }; + goldenTree: { status: 'pending' | 'baked' | 'none'; contentDir?: string; manifest?: string }; +} + +interface ExpectedCanary { + topology: 'nested' | 'dot-root'; + plantedPaths: string[]; + serve: { + requests: { path: string; status: number; note?: string }[]; + routeScan?: { assertNoTokenIn200Bodies?: boolean }; + }; + exportScan: { root: string; occurrences: number }; +} + +/** A fixture-declared path, as the README fixes it: repository-root-relative POSIX, + * normalized, no `..` segment, never absolute. A violation is a harness error — + * the fixture is the contract, so a malformed one fails loudly rather than being + * repaired here. */ +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. Regular + * files and directories only: a symlink anywhere inside a seed is a harness error, + * and no seed file is ever executed, so modes stay the platform's default. */ +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()) { + assert.ok( + entry.name !== '.leji' && entry.name !== 'dist', + `seed path component "${entry.name}" is gitignored at any depth; spell it under the seed name`, + ); + copySeed(src, dest); + } else { + assert.ok(entry.isFile(), `seed carries a non-regular file: ${src}`); + fs.copyFileSync(src, dest); + } + } +} + +/** A pristine working copy of the fixture with every declared seed materialized. */ +function materialize(name: string, seeds: Seed[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-canary-')); + fs.cpSync(path.join(fixturesDir, name), dir, { recursive: true }); + const targets: string[] = []; + for (const seed of seeds) { + const from = fixtureRel(seed.from, 'seed.from'); + const to = fixtureRel(seed.to, 'seed.to'); + const toAbs = path.join(dir, ...to.split('/')); + // A pre-existing target means the working copy is not what the harness thinks + // it is; overlapping targets are a fixture-authoring error, not something to + // resolve by ordering. + assert.ok(!fs.existsSync(toAbs), `seed target already exists: ${to}`); + for (const other of targets) { + assert.ok(to !== other && !to.startsWith(other + '/'), `seed targets overlap: ${to} and ${other}`); + } + targets.push(to); + copySeed(path.join(dir, ...from.split('/')), toAbs); + } + 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<string, string>()): Map<string, string> { + 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 })) { + const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; + if (entry.isDirectory()) filesUnder(dir, childRel, acc); + else acc.push(childRel); + } + return acc.sort(); +} + +/** A golden artifact at its declared name, or at the dot-prefixed name beside it: + * a `rootPath: "."` fixture exports its own root, so a plainly named golden would + * be exported into the next bake of itself. The dot form is skipped by the content + * walk, which is what makes it committable there (fixtures/README.md). */ +function goldenPath(fixtureRoot: string, declared: string, what: string): string { + const [head, ...rest] = fixtureRel(declared, what).split('/'); + const plain = path.join(fixtureRoot, head, ...rest); + return fs.existsSync(plain) ? plain : path.join(fixtureRoot, '.' + head, ...rest); +} + +/** Recursive occurrences of the token under `dir` (absent dir counts as zero, which + * is what a run that wrote no tree leaves behind). */ +function countToken(dir: string): { count: number; where: string[] } { + if (!fs.existsSync(dir)) return { count: 0, where: [] }; + let count = 0; + const where: string[] = []; + const walk = (rel: string): void => { + const abs = rel === '' ? dir : path.join(dir, rel); + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; + if (entry.isDirectory()) walk(childRel); + else if (entry.isFile()) { + const hits = fs.readFileSync(path.join(dir, childRel)).toString('binary').split(TOKEN).length - 1; + if (hits > 0) { + count += hits; + where.push(childRel); + } + } + } + }; + walk(''); + return { count, where }; +} + +/** The stderr a build writes while it runs: check-before-act level-2 refusals are named there + * and nowhere else, so every canary that expects one reads it here. */ +function capture(): { restore: () => void; text: () => string } { + const chunks: string[] = []; + const orig = process.stderr.write.bind(process.stderr); + (process.stderr as unknown as { write: unknown }).write = (s: unknown): boolean => { + chunks.push(String(s)); + return true; + }; + return { + restore: () => { + (process.stderr as unknown as { write: unknown }).write = orig; + }, + text: () => chunks.join(''), + }; +} + +/** Issue one request with the corpus's path EXACTLY as written — no URL parsing on + * this side, or the encoded and malformed variants would be canonicalized before the + * server ever saw them. */ +function request(port: number, urlPath: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: urlPath, method: 'GET' }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }); + req.on('error', reject); + req.end(); + }); +} + +for (const name of LAYOUT_FIXTURES) { + const expected = JSON.parse(fs.readFileSync(path.join(fixturesDir, name, 'expected.json'), 'utf8')) as { + seeds?: Seed[]; + export?: ExpectedExport; + trustCanary?: ExpectedCanary; + }; + const expectedExport = expected.export; + const canary = expected.trustCanary; + assert.ok(expectedExport, `${name} declares an export block`); + + test(`fixture ${name}: the unified layout, the canary corpus, and idempotency`, async () => { + const dir = materialize(name, expected.seeds ?? []); + const { manifest } = loadManifest(dir); + assert.ok(manifest, 'the fixture manifest loads'); + + // The planted bytes are really planted: without this the scans below could + // pass over a fixture that plants nothing. + const plantedBefore = new Map<string, string>(); + for (const rel of canary?.plantedPaths ?? []) { + const abs = path.join(dir, ...fixtureRel(rel, 'plantedPaths entry').split('/')); + const text = fs.readFileSync(abs, 'utf8'); + assert.ok(text.includes(TOKEN), `${rel} carries the canary token`); + plantedBefore.set(rel, text); + } + // Every path the fixture says must survive the run, as it stands before it. + const preservedBefore = new Map<string, string>(); + for (const rel of expectedExport.layout?.preserved ?? []) { + const abs = path.join(dir, ...fixtureRel(rel, 'preserved entry').split('/')); + assert.ok(fs.existsSync(abs), `preserved path exists before the run: ${rel}`); + if (fs.statSync(abs).isFile()) preservedBefore.set(rel, fs.readFileSync(abs, 'utf8')); + } + + // --- the run ------------------------------------------------------------- + const first = buildViewer(dir, manifest); + const exit = first.findings.some((f) => f.severity === 'error') ? 1 : 0; + assert.equal(exit, expectedExport.exit, `exit code (findings: ${JSON.stringify(first.findings)})`); + assert.equal(first.out.split(path.sep).join('/'), expectedExport.out, 'the declared output directory'); + + // --- layout -------------------------------------------------------------- + for (const [role, roleDir] of Object.entries(expectedExport.layout?.roles ?? {})) { + const abs = path.join(dir, ...fixtureRel(roleDir, `role ${role}`).split('/')); + assert.ok(fs.existsSync(abs) && fs.statSync(abs).isDirectory(), `role ${role} established at ${roleDir}`); + } + for (const rel of expectedExport.layout?.present ?? []) { + const abs = path.join(dir, ...fixtureRel(rel, 'present entry').split('/')); + assert.ok(fs.existsSync(abs), `present after the run: ${rel}`); + } + for (const rel of expectedExport.layout?.absent ?? []) { + const abs = path.join(dir, ...fixtureRel(rel, 'absent entry').split('/')); + assert.ok(!fs.existsSync(abs), `never created: ${rel}`); + } + for (const [rel, before] of preservedBefore) { + const abs = path.join(dir, ...rel.split('/')); + assert.ok(fs.existsSync(abs), `still present after the run: ${rel}`); + assert.equal(fs.readFileSync(abs, 'utf8'), before, `byte-identical after the run: ${rel}`); + } + + // --- the golden tree ----------------------------------------------------- + const out = path.join(dir, ...fixtureRel(expectedExport.out, 'export out').split('/')); + if (expectedExport.goldenTree.status === 'baked') { + const fixtureRoot = path.join(fixturesDir, name); + const contentDir = goldenPath(fixtureRoot, expectedExport.goldenTree.contentDir!, 'goldenTree.contentDir'); + const manifestFile = goldenPath(fixtureRoot, expectedExport.goldenTree.manifest!, 'goldenTree.manifest'); + const written = filesUnder(out); + const inContent = written.filter((f) => f.startsWith('content/')); + const outside = written.filter((f) => !f.startsWith('content/')); + + // The committed bytes ARE the export's content tree: same paths, same bytes, + // in both directions, so a file that appears or disappears fails here. + assert.deepEqual( + inContent.map((f) => f.slice('content/'.length)), + filesUnder(contentDir), + `${name}: the golden content tree lists exactly what the export wrote`, + ); + for (const rel of filesUnder(contentDir)) { + assert.deepEqual( + fs.readFileSync(path.join(out, 'content', ...rel.split('/'))), + fs.readFileSync(path.join(contentDir, ...rel.split('/'))), + `${name}: exported bytes differ from the golden for content/${rel}`, + ); + } + + // Everything else — chrome, vendored assets, fonts — by digest and size. The + // two sets are disjoint by construction and exhaustive by this comparison. + const goldenManifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) as { + version: number; + files: Record<string, { sha256: string; size: number }>; + }; + assert.equal(goldenManifest.version, 1, 'the manifest states its version'); + assert.deepEqual( + Object.keys(goldenManifest.files), + outside, + `${name}: the manifest pins every file outside content/`, + ); + for (const rel of outside) { + const bytes = fs.readFileSync(path.join(out, ...rel.split('/'))); + assert.equal( + crypto.createHash('sha256').update(bytes).digest('hex'), + goldenManifest.files[rel].sha256, + rel, + ); + assert.equal(bytes.length, goldenManifest.files[rel].size, `${rel} size`); + } + } + + // --- the export-side scan ------------------------------------------------ + if (canary) { + const scanRoot = path.join(dir, ...fixtureRel(canary.exportScan.root, 'exportScan.root').split('/')); + const found = countToken(scanRoot); + assert.equal( + found.count, + canary.exportScan.occurrences, + `canary occurrences in ${canary.exportScan.root}: ${found.where.join(', ')}`, + ); + } + + // --- the serve corpus ---------------------------------------------------- + if (canary) { + const server = await serveViewer(dir, 0, manifest.rootPath); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + for (const want of canary.serve.requests) { + const res = await request(port, want.path); + assert.equal(res.status, want.status, `${want.path}${want.note ? ` — ${want.note}` : ''}`); + if (res.status === 200 && canary.serve.routeScan?.assertNoTokenIn200Bodies !== false) { + assert.ok(!res.body.includes(TOKEN), `no canary byte in the 200 body of ${want.path}`); + } + } + } finally { + server.close(); + } + } + + // --- idempotency --------------------------------------------------------- + if (expectedExport.rerun?.byteIdentical) { + const afterFirst = snapshot(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', + ); + } + + // The planted bytes are still exactly as planted: the tool never read them + // into anything, and never rewrote them either. + for (const [rel, before] of plantedBefore) { + assert.equal(fs.readFileSync(path.join(dir, ...rel.split('/')), 'utf8'), before, `untouched: ${rel}`); + } + fs.rmSync(dir, { recursive: true, force: true }); + }); +} + +// The one boundary a fixture cannot plant (a seed carries no symlinks) and the one +// the dot convention cannot hold: under `rootPath: "."` the trust domain really is +// inside the content mount, so a symlink there resolves INSIDE the mount root and +// passes every containment check. Only the by-name whitelist refuses it — remove +// the `servablePath` calls in the serve path and this test serves the canary. +test('the servable-roots whitelist refuses a content symlink into a private role', async () => { + const dir = materialize('valid-trust-canary-dot-root', [{ from: '.leji-seed', to: '.leji' }]); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + fs.symlinkSync(path.join('.leji', 'work', 'proposal.md'), path.join(dir, 'leak.md')); + fs.symlinkSync(path.join('.leji', 'work'), path.join(dir, 'leakdir')); + // Generate the chrome (and an export) with the symlinks already planted, so the + // serve legs run against a complete layer and the export legs see the bait. + buildViewer(dir, manifest); + const server = await serveViewer(dir, 0, manifest.rootPath); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + for (const route of ['/content/leak.md', '/content/leakdir/proposal.md']) { + const res = await request(port, route); + assert.equal(res.status, 404, `${route} is denied by name, whatever it resolves to`); + assert.ok(!res.body.includes(TOKEN), `no canary byte in the response to ${route}`); + } + // The servable role still serves through its own mount: the whitelist denies + // the other roles, not the chrome. + assert.equal((await request(port, '/index.html')).status, 200); + } finally { + server.close(); + } + // And the export never followed it either (symlinks are skipped, and the target + // is outside the enumerated roots). + assert.equal(countToken(path.join(dir, '.leji', 'dist')).count, 0, 'no canary byte in the export'); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'dist', 'content', 'leak.md')), 'the symlink is not exported'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// The vectors below share the reason the test above lives here rather than in a +// fixture: they need a symlink (a seed carries none by contract — copySeed refuses +// one) or a hostile manifest, which is a per-SDK hazard rather than a shared +// contract the fixtures publish. So they are constructed at runtime, over a +// fixture's own layer and its own planted bytes, and they stay in this file because +// what they pin is the trust boundary the fixtures pin everywhere else. +// +// Each takes a path that reaches the private roles WITHOUT touching the ordinary +// content walk: the profile overlay renders its own pages, the sidebar lifts labels +// out of the profile scan, the export copies the chrome by name, and `--out` is the +// caller's own path. The dot convention and the content walk's symlink skip say +// nothing about any of them. + +/** The dot-root canary layer with its seed materialized: the topology where the + * trust domain sits inside the content mount, so a symlink into it resolves inside + * every containment check and only the by-name whitelist refuses it. */ +function canaryLayer(): string { + return materialize('valid-trust-canary-dot-root', [{ from: '.leji-seed', to: '.leji' }]); +} + +test('the whitelist refuses a bound agent profile that resolves into a private role', async () => { + const dir = canaryLayer(); + // A profile pair the resolver really composes: an ordinary base under the layer's + // agents directory, and a derived half planted in the onboarding workspace, bound + // into the roster by a symlink at the content root. Without the whitelist on the + // profile sources, the resolved page renders the planted half verbatim — the + // overlay answers before the content mount ever judges the path. + fs.mkdirSync(path.join(dir, 'agents'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'agents', 'core.md'), + [ + '---', + 'id: core', + 'name: Core', + 'role: core', + 'requiredRead:', + ' - boot-profile.md', + 'mustAskWhen:', + ' - anything is unclear', + '---', + '', + 'Base body.', + '', + ].join('\n'), + ); + fs.writeFileSync( + path.join(dir, '.leji', 'work', 'leak-profile.md'), + ['---', 'id: leak', 'name: Leak', 'role: leak', 'inherits: core', '---', '', `Planted: ${TOKEN}`, ''].join('\n'), + ); + fs.symlinkSync(path.join('.leji', 'work', 'leak-profile.md'), path.join(dir, 'leak.md')); + const manifestPath = path.join(dir, 'leji.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>; + declared.agents = { leak: 'leak.md' }; + fs.writeFileSync(manifestPath, JSON.stringify(declared, null, 2) + '\n'); + + const { manifest } = loadManifest(dir); + assert.ok(manifest, 'the layer still loads with the profile bound'); + buildViewer(dir, manifest); + const server = await serveViewer(dir, 0, manifest.rootPath); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + const res = await request(port, '/content/leak.md'); + assert.equal(res.status, 404, 'the profile overlay refuses a source it may not read'); + assert.ok(!res.body.includes(TOKEN), 'no canary byte in the response'); + // The overlay still resolves the profiles it may read. + assert.equal((await request(port, '/content/agents/core.md')).status, 200); + } finally { + server.close(); + } + const found = countToken(path.join(dir, '.leji', 'dist')); + assert.equal(found.count, 0, `no canary byte in the export: ${found.where.join(', ')}`); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'dist', 'content', 'leak.md')), 'and no page written for it'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('the sidebar lifts no label out of a profiles directory that is a private role', async () => { + const dir = canaryLayer(); + // The same scan, reached the other way: a declared `agentProfilesPath` naming a + // private role needs no symlink at all. The page itself was always refused, but + // the sidebar built its label from the file's frontmatter — bytes of a private + // file, served in a 200 body and copied into the export. + fs.writeFileSync( + path.join(dir, '.leji', 'work', 'p.md'), + [ + '---', + 'id: planted', + `name: ${TOKEN}`, + 'role: planted', + 'requiredRead:', + ' - boot-profile.md', + 'mustAskWhen:', + ' - anything is unclear', + '---', + '', + 'Body.', + '', + ].join('\n'), + ); + const manifestPath = path.join(dir, 'leji.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>; + declared.machine = { agentProfilesPath: '.leji/work/' }; + fs.writeFileSync(manifestPath, JSON.stringify(declared, null, 2) + '\n'); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + buildViewer(dir, manifest); + const found = countToken(path.join(dir, '.leji', 'dist')); + assert.equal(found.count, 0, `no canary byte in the export: ${found.where.join(', ')}`); + const server = await serveViewer(dir, 0, manifest.rootPath); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + const sidebar = await request(port, '/content/_sidebar.md'); + assert.equal(sidebar.status, 200, 'the live sidebar still builds'); + assert.ok(!sidebar.body.includes(TOKEN), 'and carries no byte of the planted profile'); + } finally { + server.close(); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- The check-before-act invariant on WRITE/CLEAR targets ---------------------------------- +// One structural rule: every location the tool writes into or clears is realpath- +// resolved and validated against its role BEFORE the operation — never after, never +// conditionally. These pin the two write-side vectors two review rounds left open. + +test('check-before-act: generation refuses a .leji/viewer aliased into a private role, before writing a byte', () => { + const dir = canaryLayer(); + // Point the servable role at another private role, bytes of its own already there. + // Before the check-before-act rule, generation wrote the chrome THROUGH the link into the trust domain and + // only the export's later identity check noticed — after the mutation. The aliased + // directory is snapshotted WHOLE, so any pre-refusal write (not just an overwrite of + // one planted file) is caught. + const aliased = path.join(dir, '.leji', 'work', 'chrome'); + fs.mkdirSync(path.join(aliased, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(aliased, 'assets', 'planted.txt'), `${TOKEN}\n`); + 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 gen = generateViewer(dir, manifest); + assert.ok( + gen.findings.some((f) => f.rule === 'viewer-target-refused' && f.severity === 'error'), + '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'); + + // buildViewer regenerates first, so it inherits the refusal and never reaches the + // destructive clean/copy: no export is produced either. + const built = buildViewer(dir, manifest); + assert.ok( + 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.ok(!fs.existsSync(path.join(dir, '.leji', 'dist')), 'no export was written'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('check-before-act: a DEFAULT-output build refuses when .leji/dist resolves into a private role', () => { + const dir = canaryLayer(); + // The surviving default-bypass vector: the reservation used to be conditioned on a + // caller --out, so a default .leji/dist redirected into the trust domain slipped + // through. Now the default is validated identically — before any clear or write. + const planted = path.join(dir, '.leji', 'mounts', 'store', 'x'); + fs.mkdirSync(planted, { recursive: true }); + fs.writeFileSync(path.join(planted, 'planted'), `${TOKEN}\n`); + 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(); + 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(), + before, + 'nothing was cleared or written in the private role', + ); + assert.equal(fs.readFileSync(path.join(planted, 'planted'), 'utf8'), `${TOKEN}\n`, 'the planted bytes are intact'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('check-before-act: an out-of-repository .leji/viewer or .leji/dist alias is REFUSED, and nothing is written outside', () => { + // Containment is absolute: every write this tool makes lands inside the repository + // it was pointed at. A `.leji/viewer` or `.leji/dist` symlinked to a real, empty + // destination outside the tree — once a supported relocate/publish alias — is a + // hard refusal now, with nothing written through it. A user who wants the export + // elsewhere copies the finished folder there. + const chromeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-chrome-')); + const relocated = canaryLayer(); + fs.symlinkSync(chromeHome, path.join(relocated, '.leji', 'viewer')); + const { manifest } = loadManifest(relocated); + assert.ok(manifest); + const built = buildViewer(relocated, manifest, undefined); + assert.ok( + built.findings.some((f) => f.rule === 'viewer-target-refused'), + 'the relocated viewer role is refused', + ); + assert.equal(built.wrote, false, 'and the export never runs'); + assert.deepEqual(fs.readdirSync(chromeHome), [], 'nothing was written into the out-of-tree viewer home'); + + const publish = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-publish-')); + const published = canaryLayer(); + fs.symlinkSync(publish, path.join(published, '.leji', 'dist')); + const { manifest: m2 } = loadManifest(published); + assert.ok(m2); + assert.throws( + () => buildViewer(published, m2, undefined), + /resolves outside the repository/, + 'the out-of-tree publish target is refused, not written', + ); + assert.deepEqual(fs.readdirSync(publish), [], 'nothing was written into the out-of-tree publish root'); + + fs.rmSync(relocated, { recursive: true, force: true }); + fs.rmSync(published, { recursive: true, force: true }); + fs.rmSync(chromeHome, { recursive: true, force: true }); + fs.rmSync(publish, { recursive: true, force: true }); +}); + +test('check-before-act level-2: the boundary skip warns once on stderr, and a clean build is silent', () => { + // A servable-looking source (an .md at the content root) whose resolved path lands + // in a private role: withheld from serve and export, and — unlike an ordinary + // skip — it says why, exactly once, on stderr (never stdout, never --json). + const dir = canaryLayer(); + fs.symlinkSync(path.join('.leji', 'work', 'proposal.md'), path.join(dir, 'leak.md')); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + let cap = capture(); + try { + buildViewer(dir, manifest, undefined); + } finally { + cap.restore(); + } + const warnings = cap + .text() + .split('\n') + .filter((l) => l.startsWith('skipped leak.md:')); + assert.equal(warnings.length, 1, `the withheld source is named exactly once: ${JSON.stringify(cap.text())}`); + assert.match(warnings[0], /resolves into \.leji\/work \(private\); not served or exported/); + assert.equal(countToken(path.join(dir, '.leji', 'dist')).count, 0, 'and no canary byte reached the export'); + fs.rmSync(dir, { recursive: true, force: true }); + + // A clean layer (no cross-role source) says nothing on stderr. + const clean = canaryLayer(); + const { manifest: m2 } = loadManifest(clean); + assert.ok(m2); + cap = capture(); + try { + buildViewer(clean, m2, undefined); + } finally { + cap.restore(); + } + assert.equal( + cap + .text() + .split('\n') + .filter((l) => l.startsWith('skipped ')).length, + 0, + 'a clean build emits no boundary-skip warning', + ); + fs.rmSync(clean, { recursive: true, force: true }); +}); + +test('check-before-act: an ancestor swapped to a symlink AFTER enumeration is never followed at use', () => { + // The check/use gap on the READ side. The content walk enumerates a real directory; + // before the export uses what it enumerated, that directory becomes a symlink into + // a private role. Every later read or copy BY PATH then goes through the link, with + // the walk's checks all behind it — and a revalidation that lstats the final + // component alone follows the swapped ancestor to a perfectly ordinary file. So a + // carried source is resolved, its RESOLVED path judged, and its bytes taken from the + // descriptor `fstat` proved a regular file: the check and the use hold one inode. + // Mutation that reddens: revalidate with lstat and read/copy by path again — the + // planted bytes below are linted and land in the export. + const dir = fs.realpathSync(canaryLayer()); + fs.writeFileSync(path.join(dir, 'domain', 'asset.txt'), 'an ordinary carried asset\n'); + const decoy = path.join(dir, '.leji', 'work', 'swapped'); + fs.mkdirSync(decoy, { recursive: true }); + fs.writeFileSync(path.join(decoy, 'overview.md'), `# planted ${TOKEN}\n`); + fs.writeFileSync(path.join(decoy, 'asset.txt'), `${TOKEN}\n`); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + + // The swap, at the one moment that matters: after the export walk has read + // `domain/`'s entries and before it uses any of them. Generation runs first and + // walks the same tree, so the hook arms only once the export resolves its own + // output target — the first thing the pipeline does after generating. Builtin ESM + // bindings are snapshotted at link time, hence the CJS patch plus the resync (the + // idiom the export suite's subprocess spy uses). + const require = createRequire(import.meta.url); + const nodeFs = require('node:fs') as Record<string, unknown>; + const readdirSync = nodeFs.readdirSync as (...args: unknown[]) => unknown; + const realpathSync = nodeFs.realpathSync as { native: (p: string) => string }; + const nativeRealpath = realpathSync.native; + const domainDir = path.join(dir, 'domain'); + const distDir = path.join(dir, '.leji', 'dist'); + let armed = false; + let swapped = false; + realpathSync.native = (p: string): string => { + if (typeof p === 'string' && path.resolve(p) === distDir) armed = true; + return nativeRealpath(p); + }; + nodeFs.readdirSync = (...args: unknown[]): unknown => { + const entries = readdirSync(...args); + if (armed && !swapped && typeof args[0] === 'string' && path.resolve(args[0]) === domainDir) { + swapped = true; + fs.renameSync(domainDir, path.join(dir, 'domain-real')); + fs.symlinkSync(path.join('.leji', 'work', 'swapped'), domainDir); + } + return entries; + }; + Module.syncBuiltinESMExports(); + const cap = capture(); + try { + buildViewer(dir, manifest, undefined); + } finally { + cap.restore(); + nodeFs.readdirSync = readdirSync; + realpathSync.native = nativeRealpath; + Module.syncBuiltinESMExports(); + } + + assert.ok(swapped, 'the ancestor was swapped between the walk and the use'); + assert.ok(fs.existsSync(path.join(distDir, 'index.html')), 'the export still ran to completion'); + const found = countToken(distDir); + assert.equal(found.count, 0, `no planted byte reached the export: ${found.where.join(', ')}`); + for (const rel of ['overview.md', 'asset.txt']) { + assert.ok( + !fs.existsSync(path.join(distDir, 'content', 'domain', rel)), + `the redirected source is dropped rather than followed: ${rel}`, + ); + } + // A source that now resolves into a private role is a level-2 refusal: dropping it + // silently would leave an operator with a quietly shorter export and no reason. + const warnings = cap + .text() + .split('\n') + .filter((l) => l.startsWith('skipped domain/')); + assert.ok(warnings.length > 0, `the redirected sources are named on stderr: ${JSON.stringify(cap.text())}`); + for (const line of warnings) { + assert.match(line, /resolves into \.leji\/work \(private\); not served or exported/); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('check-before-act: an ancestor swapped between the check and the open is caught by the post-open recheck', () => { + // The residual the descriptor pinning left: the swap lands AFTER the realpath that + // authorized the source and BEFORE the open on it, so the open follows the new link + // and the descriptor holds planted bytes while every check has already passed on the + // authorized path. `fstat` cannot see it — the decoy is a perfectly ordinary regular + // file. The recheck after the open resolves the source once more and requires the + // same location AND the same inode, so the bytes about to be read are proved to be + // the ones the check judged. Mutation that reddens: drop the recheck and trust + // `fstat` alone — the planted bytes below land in the export. + const dir = fs.realpathSync(canaryLayer()); + const decoy = path.join(dir, '.leji', 'work', 'swapped'); + fs.mkdirSync(decoy, { recursive: true }); + fs.writeFileSync(path.join(decoy, 'overview.md'), `# planted ${TOKEN}\n`); + fs.writeFileSync(path.join(decoy, 'asset.txt'), `${TOKEN}\n`); + fs.writeFileSync(path.join(dir, 'domain', 'asset.txt'), 'an ordinary carried asset\n'); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + + // The swap, at the one moment the recheck exists for: the source's realpath has + // just been resolved and authorized, and the ancestor becomes a symlink before the + // open on that path. Deterministic, not a race — the patched resolver performs it + // inline, so the window is exercised on every run. Generation runs first over the + // same tree, so — as in the enumeration canary — the hook arms only once the export + // resolves its own output target. + const require = createRequire(import.meta.url); + const nodeFs = require('node:fs') as Record<string, unknown>; + const realpathSync = nodeFs.realpathSync as { native: (p: string) => string }; + const nativeRealpath = realpathSync.native; + const domainDir = path.join(dir, 'domain'); + const distDir = path.join(dir, '.leji', 'dist'); + let armed = false; + let swapped = false; + realpathSync.native = (p: string): string => { + if (typeof p === 'string' && path.resolve(p) === distDir) armed = true; + const real = nativeRealpath(p); + if (armed && !swapped && path.dirname(real) === domainDir) { + swapped = true; + fs.renameSync(domainDir, path.join(dir, 'domain-real')); + fs.symlinkSync(path.join('.leji', 'work', 'swapped'), domainDir); + } + return real; + }; + Module.syncBuiltinESMExports(); + const cap = capture(); + try { + buildViewer(dir, manifest, undefined); + } finally { + cap.restore(); + realpathSync.native = nativeRealpath; + Module.syncBuiltinESMExports(); + } + + assert.ok(swapped, 'the ancestor was swapped between the check and the open'); + assert.ok(fs.existsSync(path.join(distDir, 'index.html')), 'the export still ran to completion'); + const found = countToken(distDir); + assert.equal(found.count, 0, `no planted byte reached the export: ${found.where.join(', ')}`); + for (const rel of ['overview.md', 'asset.txt']) { + assert.ok( + !fs.existsSync(path.join(distDir, 'content', 'domain', rel)), + `the source whose path and descriptor diverged is dropped, never read: ${rel}`, + ); + } + const warnings = cap + .text() + .split('\n') + .filter((l) => l.startsWith('skipped domain/')); + assert.ok(warnings.length > 0, `the divergent source is named on stderr: ${JSON.stringify(cap.text())}`); + for (const line of warnings) { + assert.match(line, /resolves into \.leji\/work \(private\); not served or exported/); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('the export refuses an --out that resolves into a private role', () => { + // The nested topology, deliberately: with the content root a subdirectory, an + // --out at the repository root is a legitimate destination, so the reservation is + // the only rule standing between a redirected path and the private domain. + const dir = materialize('valid-trust-canary-nested-root', [{ from: '.leji-seed', to: '.leji' }]); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + // Proof the destination is otherwise open: an ordinary sibling path exports. + assert.doesNotThrow(() => buildViewer(dir, manifest, 'plain-out')); + assert.ok(fs.existsSync(path.join(dir, 'plain-out', 'index.html')), 'an ordinary --out at the root exports'); + // The same path, redirected: the reservation judges where the write would land, + // so the private role is refused however the destination is spelled. + fs.symlinkSync(path.join('.leji', 'mounts'), path.join(dir, 'redirect')); + assert.throws( + () => buildViewer(dir, manifest, 'redirect/export'), + /reserved for the tool's own roles/, + 'a redirected --out is refused', + ); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'mounts', 'export')), 'nothing was written into the private role'); + // The refusal is not destructive either: the planted bytes are as planted. + const plantedRel = path.join('.leji', 'mounts', 'store', 'x', 'planted'); + assert.ok(fs.readFileSync(path.join(dir, plantedRel), 'utf8').includes(TOKEN), 'the private role is intact'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- Check-before-act completeness: the overview.md write sites and the resolver's dangling paths. +// These pin the write sites two review rounds after the first left them: overview.md +// (seed AND refresh) is a content write that used to be guarded by containment only, +// and a nested/chained/unresolvable `--out` whose real destination the resolver used +// to rebuild lexically. Each hard-refusal case names, in its comment, the mutation +// that reddens it. + +/** Whether this directory sits on a filesystem that cannot tell `.leji` from + * `.LEJI` — asked of the volume, so a case-variant assertion runs only where the + * fold is real. */ +function foldsCase(dir: string): boolean { + const probe = path.join(dir, 'leji-case-probe'); + fs.mkdirSync(probe, { recursive: true }); + try { + return fs.existsSync(path.join(dir, 'LEJI-CASE-PROBE')); + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } +} + +test('check-before-act: generation refuses an overview.md SEED aliased into a private role, before writing through it', () => { + // rootPath ".", so overview.md is seeded at the repository root. A symlink there + // into a private role is contained (inside the repo) yet crosses the trust + // boundary: containment-only was the gap. The target dangles, so the seed WOULD + // create it inside the role. Mutation that reddens: revert the overview guard to + // resolvedWithinRoot-only (no writableTarget) — the seed writes through and + // .leji/work/new.md appears. + for (const role of ['work', 'mounts'] as const) { + const dir = canaryLayer(); + const roleDir = path.join(dir, '.leji', role); + fs.mkdirSync(roleDir, { recursive: true }); + 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 gen = generateViewer(dir, manifest); + assert.ok( + gen.findings.some( + (f) => + f.rule === 'viewer-target-refused' && + f.severity === 'error' && + f.message.includes('overview.md') && + f.message.includes(`.leji/${role} (private)`), + ), + `generation refuses the overview.md seed into .leji/${role} with a hard error`, + ); + 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`); + fs.rmSync(dir, { recursive: true, force: true }); + } + + // 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. + const dir = canaryLayer(); + if (foldsCase(dir)) { + fs.mkdirSync(path.join(dir, '.leji', 'work'), { recursive: true }); + fs.symlinkSync(path.join('.LEJI', 'work', 'case.md'), path.join(dir, 'overview.md')); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + const gen = generateViewer(dir, manifest); + assert.ok( + gen.findings.some((f) => f.rule === 'viewer-target-refused' && /overview\.md/.test(f.message)), + 'a case-variant overview.md alias is refused as the role it folds to', + ); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'work', 'case.md')), 'nothing written through the case variant'); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('check-before-act: the overview.md REFRESH refuses an alias into a private role before reading or writing it', () => { + // overview.md is a symlink to an EXISTING private file carrying the generated-map + // markers: the refresh branch (isFile true) used to resolvedWithinRoot-check, read + // it, and rewrite the map block THROUGH the link. The check now runs on the + // resolved path before the read. Mutation that reddens: revert to + // resolvedWithinRoot-only — the private file is read and its map block rewritten. + const dir = canaryLayer(); + const target = path.join(dir, '.leji', 'mounts', 'existing.md'); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const original = `# private ${TOKEN}\n<!-- leji:generated-map:start -->STALE<!-- leji:generated-map:end -->\n`; + fs.writeFileSync(target, original); + fs.symlinkSync(path.join('.leji', 'mounts', 'existing.md'), path.join(dir, 'overview.md')); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + + const gen = generateViewer(dir, manifest); + assert.ok( + gen.findings.some( + (f) => + f.rule === 'viewer-target-refused' && + f.severity === 'error' && + f.message.includes('overview.md') && + f.message.includes('.leji/mounts (private)'), + ), + 'the refresh refuses the alias with a hard error', + ); + assert.equal( + fs.readFileSync(target, 'utf8'), + original, + 'the private file was neither read-then-rewritten nor touched', + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('the export refuses a NESTED dangling --out whose intermediate component redirects into a private role', () => { + // `redirect/export` where `redirect` is a DANGLING symlink into a private role: a + // write would follow it, but the resolver used to climb past the dangling + // component and rebuild `redirect/export` lexically (outside .leji/), so the check + // passed and a target created afterward raced the write into the role. The + // resolver now follows the dangling intermediate link. Mutation that reddens: + // revert resolvedPath's intermediate-symlink follow (climb-past) — outAbs reads as + // outside .leji/ and the build is not refused. + const dir = materialize('valid-trust-canary-nested-root', [{ from: '.leji-seed', to: '.leji' }]); + const { manifest } = loadManifest(dir); + 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(); + assert.throws( + () => buildViewer(dir, manifest, 'redirect/export'), + /reserved for the tool's own roles/, + 'a nested dangling --out into a private role is refused', + ); + assert.ok( + !fs.existsSync(path.join(dir, '.leji', 'mounts', 'ghost')), + 'the dangling target was not created by the build', + ); + assert.deepEqual( + [...snapshot(path.join(dir, '.leji', 'mounts')).entries()].sort(), + mountsBefore, + 'nothing was cleared or written in the private role', + ); + + // The created-after-validation race, closed: even once the target exists, the same + // resolved path is judged, so the build still refuses (never a one-time dangling + // fluke that a real directory would slip past). + fs.mkdirSync(path.join(dir, '.leji', 'mounts', 'ghost'), { recursive: true }); + assert.throws( + () => buildViewer(dir, manifest, 'redirect/export'), + /reserved for the tool's own roles/, + 'and refused again once the target is a real directory', + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('the export refuses a CHAINED dangling --out that ends in a private role', () => { + // redirect -> hop -> .leji/work/ghost, every hop dangling: the resolver follows the + // chain of intermediate dangling links to the real destination. Mutation that + // reddens: revert resolvedPath's intermediate-symlink follow — the chain is rebuilt + // lexically as outside .leji/ and the build is not refused. + const dir = materialize('valid-trust-canary-nested-root', [{ from: '.leji-seed', to: '.leji' }]); + const { manifest } = loadManifest(dir); + 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(); + 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(), + workBefore, + 'nothing was cleared or written in the private role', + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('the export treats an UNRESOLVABLE --out (permission/I/O) as a failure, not as absent', (t) => { + // A non-ENOENT resolution failure (here an unreadable intermediate directory) must + // FAIL the check, never be rebuilt lexically as a not-yet-created target. Mutation + // that reddens: make resolvedPath return the lexical path on a non-ENOENT error — + // the build proceeds instead of refusing. Skipped as root, which bypasses the mode. + if (typeof process.getuid === 'function' && process.getuid() === 0) { + t.skip('running as root bypasses directory permissions; the EACCES cannot be constructed'); + return; + } + const dir = materialize('valid-trust-canary-nested-root', [{ from: '.leji-seed', to: '.leji' }]); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + const noperm = path.join(dir, 'noperm'); + fs.mkdirSync(path.join(noperm, 'sub'), { recursive: true }); + fs.chmodSync(noperm, 0o000); + try { + assert.throws( + () => buildViewer(dir, manifest, 'noperm/sub/export'), + /cannot be resolved \(permission or I\/O error\)/, + 'an unresolvable --out is refused, not treated as an absent write target', + ); + } finally { + fs.chmodSync(noperm, 0o755); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('the export refuses a DANGLING output entry, default or --out, and creates nothing', () => { + // A dangling symlink is a standing entry under both forms — never written through, + // never read as absent. The output used to be resolved before anything judged it, + // so `.leji/dist -> site` with `site` missing BECAME its own destination: statSync + // reported absence, "clearable" followed, and the export created and filled the + // link's target. The original entry is judged first now. Mutation that reddens: + // drop the lstat on the original entry — the build writes through the link. + const dir = materialize('valid-trust-canary-nested-root', [{ from: '.leji-seed', to: '.leji' }]); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + // Settle the internal chrome first: every build regenerates it, so the comparison + // below measures the export's destructive half and nothing else. + generateViewer(dir, manifest); + + fs.symlinkSync(path.join('..', 'site'), path.join(dir, '.leji', 'dist')); + fs.symlinkSync('elsewhere', path.join(dir, 'published')); + const before = [...snapshot(dir).entries()].sort(); + + assert.throws( + () => buildViewer(dir, manifest, undefined), + /it is a dangling symlink/, + 'the default output is refused before it is resolved', + ); + assert.throws( + () => buildViewer(dir, manifest, 'published'), + /it is a dangling symlink/, + 'and so is a caller --out that is otherwise a legal target', + ); + + assert.ok(fs.lstatSync(path.join(dir, '.leji', 'dist')).isSymbolicLink(), 'the default link is left in place'); + 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'); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/packages/sdk/test/ci-generation.test.ts b/packages/sdk/test/ci-generation.test.ts new file mode 100644 index 0000000..7be5631 --- /dev/null +++ b/packages/sdk/test/ci-generation.test.ts @@ -0,0 +1,331 @@ +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 os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { ensureCiWorkflow, ensureLocalHook } from '../dist/index.js'; +import { CI_PROVIDERS, HOOK_BODY, HUSKY_BLOCK, ciVariants, shQuote } from '../dist/commands/init.js'; +import { detectEcosystem, managerRunnerArgv, runnerArgv } from '../dist/lib/ecosystem.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const goldens = path.join(repoRoot, 'fixtures', 'ci-goldens'); +const golden = (name: string) => fs.readFileSync(path.join(goldens, name), 'utf8'); + +const REL: Record<string, string> = { + github: '.github/workflows/leji.yml', + gitlab: '.gitlab-ci.yml', + circleci: '.circleci/config.yml', + azure: '.azure-pipelines/leji.yml', +}; + +function plant(files: Record<string, string>): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-ci-gen-')); + for (const [rel, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), body); + return dir; +} + +const declaredPkg = (extra = {}) => + JSON.stringify({ name: 'demo', devDependencies: { '@leji-org/leji': '^1' }, ...extra }, null, 2) + '\n'; + +/** A repository that declares the CLI and carries the manager's lock evidence. */ +const ROOTS = { + pnpm: { 'package.json': declaredPkg(), 'pnpm-lock.yaml': '' }, + npm: { 'package.json': declaredPkg(), 'package-lock.json': '' }, + uv: { 'pyproject.toml': '[project]\nname = "d"\nversion = "0"\ndependencies = ["leji"]\n', 'uv.lock': '' }, + go: { + 'go.mod': 'module example.com/d\n\ngo 1.24.0\n\ntool github.com/leji-org/leji/packages/sdk-go/cmd/leji\n', + }, + undeclared: { 'package.json': '{"name":"demo"}\n', 'package-lock.json': '' }, + none: {}, +}; + +// --- the goldens ----------------------------------------------------------- + +test('ci goldens: every generated variant matches its committed bytes', () => { + const variants = ciVariants(); + assert.equal(variants.length, CI_PROVIDERS.length * 12, 'nine local managers plus three fallbacks per provider'); + for (const v of variants) { + assert.equal(v.bytes, golden(`${v.provider}-${v.key}.yml`), `${v.provider}/${v.key}`); + } + // And nothing committed is orphaned: every golden is produced by the generator. + const expected = new Set([ + ...variants.map((v) => `${v.provider}-${v.key}.yml`), + ...['npm', 'pnpm', 'yarn', 'bun', 'uv', 'poetry', 'pdm', 'pipenv', 'go', 'fallback'].flatMap((m) => [ + `hook-${m}.sh`, + `husky-${m}.sh`, + ]), + ...['github', 'circleci', 'azure'].flatMap((p) => [`legacy-1.3-${p}-local.yml`, `legacy-1.3-${p}-fallback.yml`]), + ]); + assert.deepEqual(fs.readdirSync(goldens).sort(), [...expected].sort()); +}); + +test('ci goldens: the hook bodies match, for every runner', () => { + for (const m of ['npm', 'pnpm', 'yarn', 'bun', 'uv', 'poetry', 'pdm', 'pipenv', 'go']) { + const argv = managerRunnerArgv(m)!; + assert.equal(HOOK_BODY(argv), golden(`hook-${m}.sh`), m); + assert.equal(HUSKY_BLOCK(argv), golden(`husky-${m}.sh`), m); + } + assert.equal(HOOK_BODY(['leji']), golden('hook-fallback.sh')); + assert.equal(HUSKY_BLOCK(['leji']), golden('husky-fallback.sh')); +}); + +test('ci: the digests of this release, for the next release to append to KNOWN_GENERATED', () => { + // Not an assertion about the registry's contents: the CURRENT variants are matched + // by bytes, which is stronger than a digest. This prints what the NEXT release + // must carry once these bytes are history. + const lines = ciVariants().map( + (v) => ` '${crypto.createHash('sha256').update(v.bytes, 'utf8').digest('hex')}', // ${v.provider} ${v.key}`, + ); + assert.equal(lines.length, 48); + if (process.env.LEJI_PRINT_CI_DIGESTS) console.log(lines.join('\n')); +}); + +// --- the table ------------------------------------------------------------- + +test('ci table: a declared, lock-evidenced repository gets its own manager job', () => { + for (const [name, expected] of [ + ['pnpm', 'pnpm-local'], + ['npm', 'npm-local'], + ['uv', 'uv-local'], + ['go', 'go-local'], + ] as const) { + const dir = plant(ROOTS[name]); + for (const provider of CI_PROVIDERS) { + const r = ensureCiWorkflow(dir, provider); + assert.equal(r.action, 'created', `${name}/${provider}`); + assert.equal( + fs.readFileSync(path.join(dir, REL[provider]), 'utf8'), + golden(`${provider}-${expected}.yml`), + `${name}/${provider}`, + ); + } + } +}); + +test('ci table: local needs BOTH the declaration and the lock evidence', () => { + // Declared but no lockfile: `npm ci`-class installs would fail before leji ran. + const unlocked = plant({ 'package.json': declaredPkg() }); + ensureCiWorkflow(unlocked, 'github'); + assert.equal( + fs.readFileSync(path.join(unlocked, REL.github), 'utf8'), + golden('github-node-fallback.yml'), + 'declared without a lock takes the fallback', + ); + // Locked but undeclared: `npx --no-install` would find nothing. + const undeclared = plant(ROOTS.undeclared); + ensureCiWorkflow(undeclared, 'github'); + assert.equal(fs.readFileSync(path.join(undeclared, REL.github), 'utf8'), golden('github-node-fallback.yml')); +}); + +test('ci table: each ecosystem falls back to a job that needs no manifest', () => { + const cases: [Record<string, string>, string][] = [ + [ROOTS.none, 'node-fallback'], + [ROOTS.undeclared, 'node-fallback'], + [{ 'package.json': '{}\n', 'package-lock.json': '', 'yarn.lock': '' }, 'node-fallback'], // ambiguous + [{ 'package.json': '{"packageManager":"hermit@1.0.0"}\n' }, 'node-fallback'], // unsupported + [{ 'pyproject.toml': '[project]\nname = "d"\nversion = "0"\n' }, 'python-fallback'], + [{ 'requirements.txt': 'requests\n' }, 'python-fallback'], + [{ 'go.mod': 'module example.com/d\n\ngo 1.23\n' }, 'go-fallback'], + [{ 'go.mod': 'module example.com/d\n\ngo 1.24.0\n' }, 'go-fallback'], // 1.24 but undeclared + [{ 'package.json': '{}\n', 'pyproject.toml': '[project]\nname="d"\nversion="0"\n' }, 'node-fallback'], // multiple + ]; + for (const [files, expected] of cases) { + const dir = plant(files); + ensureCiWorkflow(dir, 'github'); + assert.equal(fs.readFileSync(path.join(dir, REL.github), 'utf8'), golden(`github-${expected}.yml`), expected); + } +}); + +test('ci table: the bootstrap disclosure appears exactly once, only where a tool is unpinned', () => { + const note = /^\s*#\s(poetry|pdm|pipenv|uv) is installed unpinned here; pin it if your project pins it\.$/m; + for (const v of ciVariants()) { + const hits = v.bytes.split('\n').filter((l) => /is installed unpinned here/.test(l)); + const wantsNote = /^(poetry|pdm|pipenv)-local$/.test(v.key) || (v.key === 'uv-local' && v.provider !== 'github'); + assert.equal(hits.length, wantsNote ? 1 : 0, `${v.provider}/${v.key}`); + if (wantsNote) assert.match(v.bytes, note, `${v.provider}/${v.key}`); + } + // uv on GitHub uses its own setup action, so nothing is pip-installed there. + assert.match(golden('github-uv-local.yml'), /astral-sh\/setup-uv@v5/); + assert.doesNotMatch(golden('github-uv-local.yml'), /pip install uv/); + assert.match(golden('gitlab-uv-local.yml'), /pip install uv && uv sync --locked/); +}); + +test('ci: the marker names the generator version on every whole file, and never inside the GitLab block', () => { + for (const v of ciVariants()) { + if (v.provider === 'gitlab') { + assert.doesNotMatch(v.bytes, /generated by leji ci \(managed\)/, 'the GitLab block keeps its own markers'); + assert.match(v.bytes, /^# >>> leji ci \(managed\) >>>\n/); + } else { + assert.match(v.bytes, /^# generated by leji ci \(managed\) v2\n/, `${v.provider}/${v.key}`); + } + } +}); + +test('ci: the Node fallback job is the pre-1.4 job, line for line, plus the marker', () => { + // The one compatibility promise of this change: a repository that was getting the + // npx job keeps exactly that job. Only the ownership marker is new. + for (const provider of ['github', 'circleci', 'azure'] as const) { + const before = golden(`legacy-1.3-${provider}-fallback.yml`); + const now = golden(`${provider}-node-fallback.yml`); + assert.equal(now, `# generated by leji ci (managed) v2\n${before}`, provider); + } + assert.equal( + golden('gitlab-node-fallback.yml'), + fs.readFileSync(path.join(goldens, 'gitlab-node-fallback.yml'), 'utf8'), + ); +}); + +// --- ownership ------------------------------------------------------------- + +test('ci ownership: a file generated by an EARLIER release is upgraded, not abandoned', () => { + for (const provider of ['github', 'circleci', 'azure'] as const) { + for (const mode of ['local', 'fallback'] as const) { + const dir = plant(ROOTS.pnpm); + const abs = path.join(dir, REL[provider]); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, golden(`legacy-1.3-${provider}-${mode}.yml`)); + const r = ensureCiWorkflow(dir, provider); + assert.equal(r.action, 'updated', `${provider}/${mode}`); + assert.equal(fs.readFileSync(abs, 'utf8'), golden(`${provider}-pnpm-local.yml`), `${provider}/${mode}`); + } + } +}); + +test('ci ownership: the legacy registry is scoped to its own provider', () => { + // The same bytes are leji's workflow at one provider's path and somebody else's + // file at another's. A digest match must therefore be provider-scoped, or a + // hand-written Azure pipeline that happens to hold CircleCI-shaped bytes gets + // silently replaced. + const CROSS: [string, string][] = [ + ['github', 'legacy-1.3-circleci-local.yml'], + ['github', 'legacy-1.3-azure-fallback.yml'], + ['circleci', 'legacy-1.3-github-local.yml'], + ['circleci', 'legacy-1.3-azure-local.yml'], + ['azure', 'legacy-1.3-github-fallback.yml'], + ['azure', 'legacy-1.3-circleci-fallback.yml'], + ]; + for (const [provider, foreign] of CROSS) { + const dir = plant(ROOTS.pnpm); + const abs = path.join(dir, REL[provider]); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + const bytes = golden(foreign); + fs.writeFileSync(abs, bytes); + const r = ensureCiWorkflow(dir, provider as 'github' | 'circleci' | 'azure'); + assert.equal(r.action, 'manual', `${foreign} at the ${provider} path is foreign`); + assert.equal(fs.readFileSync(abs, 'utf8'), bytes, `${provider}: left untouched`); + } + // And the same bytes at their OWN provider's path are still recognized. + for (const provider of ['github', 'circleci', 'azure'] as const) { + const dir = plant(ROOTS.pnpm); + const abs = path.join(dir, REL[provider]); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, golden(`legacy-1.3-${provider}-local.yml`)); + assert.equal(ensureCiWorkflow(dir, provider).action, 'updated', provider); + } +}); + +test('ci ownership: a re-run is byte-identical and reported unchanged', () => { + for (const provider of CI_PROVIDERS) { + const dir = plant(ROOTS.uv); + assert.equal(ensureCiWorkflow(dir, provider).action, 'created', provider); + const after = fs.readFileSync(path.join(dir, REL[provider]), 'utf8'); + const again = ensureCiWorkflow(dir, provider); + assert.equal(again.action, 'unchanged', provider); + assert.equal(fs.readFileSync(path.join(dir, REL[provider]), 'utf8'), after, provider); + } +}); + +test('ci ownership: a manager change rewrites the job leji owns', () => { + const dir = plant(ROOTS.pnpm); + const abs = path.join(dir, REL.github); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + // A file this generator wrote for a different manager: still leji's. + fs.writeFileSync(abs, golden('github-npm-local.yml')); + const r = ensureCiWorkflow(dir, 'github'); + assert.equal(r.action, 'updated'); + assert.equal(fs.readFileSync(abs, 'utf8'), golden('github-pnpm-local.yml')); +}); + +test('ci ownership: an edited generated file and a foreign file are both left alone', () => { + for (const provider of ['github', 'circleci', 'azure'] as const) { + // Edited: the marker is still there, the bytes are not ours. The edit IS the + // opt-out, so it is honored rather than overwritten. + const edited = plant(ROOTS.pnpm); + const eAbs = path.join(edited, REL[provider]); + fs.mkdirSync(path.dirname(eAbs), { recursive: true }); + const mine = golden(`${provider}-pnpm-local.yml`); + fs.writeFileSync(eAbs, mine + ' - run: echo mine\n'); + const eRes = ensureCiWorkflow(edited, provider); + assert.equal(eRes.action, 'manual', `${provider} edited`); + assert.ok(eRes.snippet, `${provider} edited: a snippet to merge by hand`); + assert.equal(fs.readFileSync(eAbs, 'utf8'), mine + ' - run: echo mine\n', `${provider}: untouched`); + + const foreign = plant(ROOTS.pnpm); + const fAbs = path.join(foreign, REL[provider]); + fs.mkdirSync(path.dirname(fAbs), { recursive: true }); + fs.writeFileSync(fAbs, 'name: someone-elses-pipeline\n'); + const fRes = ensureCiWorkflow(foreign, provider); + assert.equal(fRes.action, 'manual', `${provider} foreign`); + assert.equal(fs.readFileSync(fAbs, 'utf8'), 'name: someone-elses-pipeline\n', `${provider}: untouched`); + } +}); + +test('ci ownership: GitLab still owns only its marked block', () => { + const dir = plant(ROOTS.pnpm); + const abs = path.join(dir, REL.gitlab); + fs.writeFileSync(abs, 'stages:\n - test\n'); + assert.equal(ensureCiWorkflow(dir, 'gitlab').action, 'updated'); + const merged = fs.readFileSync(abs, 'utf8'); + assert.ok(merged.startsWith('stages:\n - test\n'), 'the surrounding config is preserved'); + assert.ok(merged.includes(golden('gitlab-pnpm-local.yml')), 'the managed block is exact'); +}); + +// --- the hook -------------------------------------------------------------- + +test('hook: the runner is the repository’s when the CLI is declared, else the plain binary', () => { + for (const [name, expected] of [ + ['pnpm', ['pnpm', 'exec', 'leji']], + ['uv', ['uv', 'run', 'leji']], + ['go', ['go', 'tool', 'leji']], + ['undeclared', ['leji']], + ['none', ['leji']], + ] as const) { + const dir = plant(ROOTS[name]); + assert.deepEqual(runnerArgv(detectEcosystem(dir)), expected, name); + } +}); + +test('hook: every argv element is single-quoted for sh, with the one legal escape', () => { + assert.equal(shQuote('pnpm'), "'pnpm'"); + assert.equal(shQuote("we'ird"), "'we'\\''ird'"); + assert.equal(shQuote('a b'), "'a b'"); + assert.equal(shQuote('x$HOME'), "'x$HOME'"); + // The generated body never leaves an unquoted expansion, substitution or glob. + const body = HOOK_BODY(['weird bin', "quo'te", '$HOME', '`cmd`', '*']); + assert.match(body, /^'weird bin' 'quo'\\''te' '\$HOME' '`cmd`' '\*' validate \|\| exit 1$/m); + // The stale-index message keeps its own shell quoting: the backticks stay literal. + assert.match(body, /echo 'leji: stored index is stale; run `leji index` and stage the result\.' >&2/); +}); + +test('hook: the shim is gone; nothing reaches node_modules/.bin', () => { + for (const argv of [['leji'], ['pnpm', 'exec', 'leji'], ['go', 'tool', 'leji']]) { + const body = HOOK_BODY(argv); + assert.doesNotMatch(body, /node_modules/); + assert.doesNotMatch(body, /LEJI=/); + assert.doesNotMatch(body, /\$LEJI/); + assert.doesNotMatch(HUSKY_BLOCK(argv), /node_modules/); + } +}); + +test('hook: written into a repository, it carries that repository’s runner', () => { + const dir = plant(ROOTS.pnpm); + execFileSync('git', ['init', '-q'], { cwd: dir }); + const r = ensureLocalHook(dir); + assert.equal(r.action, 'created'); + const body = fs.readFileSync(path.join(dir, r.path), 'utf8'); + assert.equal(body, golden('hook-pnpm.sh')); + assert.equal(ensureLocalHook(dir).action, 'unchanged', 'idempotent'); +}); diff --git a/packages/sdk/test/coverage.test.ts b/packages/sdk/test/coverage.test.ts index c1ef317..2f13709 100644 --- a/packages/sdk/test/coverage.test.ts +++ b/packages/sdk/test/coverage.test.ts @@ -10,7 +10,7 @@ import { generateViewer, loadManifest, run, serveViewer, validateManifestObject import type { Manifest } from '../dist/index.js'; import { contentFindings } from '../dist/commands/validate.js'; import { finding, sortFindings } from '../dist/lib/findings.js'; -import { realpathWithin, walkMd } from '../dist/lib/fsx.js'; +import { resolvedWithinRoot, walkMd } from '../dist/lib/fsx.js'; import { schemaErrors } from '../dist/lib/schemas.js'; import { gitLastModified, gitShowHead, gitToplevel } from '../dist/lib/git.js'; import { readJsonArtifact } from '../dist/lib/layer.js'; @@ -65,11 +65,11 @@ test('sortFindings: undefined path sorts first, then rule, then message', () => ); }); -// --- lib/fsx: realpathWithin error branches --- -test('realpathWithin: unresolvable root is false; non-existent target is allowed', () => { - assert.equal(realpathWithin(path.join(os.tmpdir(), 'leji-no-such-root-zzz'), os.tmpdir()), false); - const d = tmpdir('leji-rpw-'); - assert.equal(realpathWithin(d, path.join(d, 'missing')), true); +// --- lib/fsx: resolvedWithinRoot error branches --- +test('resolvedWithinRoot: an unresolvable root is false; a not-yet-created target is contained', () => { + assert.equal(resolvedWithinRoot(path.join(os.tmpdir(), 'leji-no-such-root-zzz'), os.tmpdir()), false); + const d = tmpdir('leji-rwr-'); + assert.equal(resolvedWithinRoot(d, path.join(d, 'missing')), true); }); // --- lib/schemas: root-level violation label --- diff --git a/packages/sdk/test/create-classify.test.ts b/packages/sdk/test/create-classify.test.ts new file mode 100644 index 0000000..ffd670f --- /dev/null +++ b/packages/sdk/test/create-classify.test.ts @@ -0,0 +1,165 @@ +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 { after, test } from 'node:test'; +import { DOCS_CANDIDATES, KNOWN_VENDOR_FILES, classifyTarget, withinRoot } from '../dist/internal/create.js'; + +// One sandbox for the file. `realpathSync` on the temp root because macOS hands out +// /var, which is a symlink to /private/var: the classifier compares real paths, and a +// fixture built on the unresolved spelling would test the resolver, not the rule. +const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-classify-'))); +after(() => fs.rmSync(sandbox, { recursive: true, force: true })); + +/** Somewhere outside every target, to point escaping links at. */ +const outside = path.join(sandbox, 'outside'); +fs.mkdirSync(path.join(outside, 'docs'), { recursive: true }); +fs.mkdirSync(path.join(outside, '.github'), { recursive: true }); +fs.writeFileSync(path.join(outside, '.github', 'copilot-instructions.md'), '# elsewhere\n'); +fs.writeFileSync(path.join(outside, 'leji.json'), '{}\n'); + +let seq = 0; +/** A fresh target directory; a trailing `/` in `files` means a directory. */ +function target(files: string[] = []): string { + const dir = path.join(sandbox, `case-${seq++}`); + fs.mkdirSync(dir, { recursive: true }); + for (const rel of files) { + const abs = path.join(dir, rel); + if (rel.endsWith('/')) fs.mkdirSync(abs, { recursive: true }); + else { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, '# fixture\n'); + } + } + return dir; +} + +test('classifyTarget names each state of a plain directory', () => { + assert.equal(classifyTarget(path.join(sandbox, 'nothing-here')), 'missing'); + assert.equal(classifyTarget(target()), 'init'); + assert.equal(classifyTarget(target(['docs/'])), 'adopt'); + assert.equal(classifyTarget(target(['Docs/'])), 'adopt'); + assert.equal(classifyTarget(target(['leji.json'])), 'adopted'); + assert.equal(classifyTarget(target(['docs/', 'leji.json'])), 'adopted'); +}); + +test('every docs candidate and vendor entrypoint the SDK declares is recognized', () => { + for (const rel of DOCS_CANDIDATES) assert.equal(classifyTarget(target([rel])), 'adopt', rel); + for (const rel of KNOWN_VENDOR_FILES) assert.equal(classifyTarget(target([rel])), 'adopt', rel); +}); + +test('a target that cannot be listed is unreadable, never guessed at', () => { + const file = path.join(target(['README.md']), 'README.md'); + assert.equal(classifyTarget(file), 'unreadable', 'a file where a directory was named'); + + const dangling = path.join(sandbox, `dangling-${seq++}`); + fs.symlinkSync(path.join(sandbox, 'no-such-target'), dangling); + assert.equal(classifyTarget(dangling), 'unreadable', 'a dangling symlink is not a missing directory'); +}); + +test('a symlinked target is classified through its real path', () => { + const real = target(['docs/']); + const link = path.join(sandbox, `target-link-${seq++}`); + fs.symlinkSync(real, link); + assert.equal(classifyTarget(link), 'adopt'); +}); + +test('only the selected target is inspected, never its parent', () => { + const monorepo = target(['docs/', 'packages/app/']); + assert.equal(classifyTarget(path.join(monorepo, 'packages', 'app')), 'init'); +}); + +// --- nothing outside the target decides the answer --------------------------- + +test('a docs root symlinked out of the target is not an adopt trigger', () => { + const dir = target(); + fs.symlinkSync(path.join(outside, 'docs'), path.join(dir, 'docs')); + assert.equal(classifyTarget(dir), 'init'); +}); + +test('a leji.json symlinked out of the target does not make it adopted', () => { + const dir = target(); + fs.symlinkSync(path.join(outside, 'leji.json'), path.join(dir, 'leji.json')); + assert.equal(classifyTarget(dir), 'init'); +}); + +test('a vendor entrypoint reached through an escaping parent is not counted', () => { + const dir = target(); + fs.symlinkSync(path.join(outside, '.github'), path.join(dir, '.github')); + assert.equal(classifyTarget(dir), 'init', 'the entry itself is not a link; its parent is'); +}); + +test('a dangling entry inside the target counts as absent', () => { + const dir = target(); + fs.symlinkSync(path.join(dir, 'no-such-file'), path.join(dir, 'CLAUDE.md')); + assert.equal(classifyTarget(dir), 'init'); +}); + +test('symlinks that stay inside the target still count', () => { + const docsDir = target(['real-docs/']); + fs.symlinkSync(path.join(docsDir, 'real-docs'), path.join(docsDir, 'docs')); + assert.equal(classifyTarget(docsDir), 'adopt', 'an in-target docs link is a docs root'); + + const manifestDir = target(['real.json']); + fs.symlinkSync(path.join(manifestDir, 'real.json'), path.join(manifestDir, 'leji.json')); + assert.equal(classifyTarget(manifestDir), 'adopted', 'an in-target manifest link is the manifest'); + + const vendorDir = target(['real.md']); + fs.symlinkSync(path.join(vendorDir, 'real.md'), path.join(vendorDir, 'CLAUDE.md')); + assert.equal(classifyTarget(vendorDir), 'adopt', 'an in-target entrypoint link is an entrypoint'); +}); + +test('classifying writes nothing', () => { + const dir = target(['docs/', 'CLAUDE.md']); + const before = fs.readdirSync(dir).sort(); + assert.equal(classifyTarget(dir), 'adopt'); + assert.deepEqual(fs.readdirSync(dir).sort(), before); +}); + +// --- the containment rule itself -------------------------------------------- + +test('withinRoot accepts the target and its descendants, and nothing else', () => { + const root = path.join(sandbox, 'repo'); + assert.equal(withinRoot(root, root), true, 'the target is inside itself'); + assert.equal(withinRoot(root, path.join(root, 'docs')), true, 'a child'); + assert.equal(withinRoot(root, path.join(root, 'a', 'b', 'c.md')), true, 'a descendant'); + assert.equal(withinRoot(root, path.dirname(root)), false, 'the parent'); + assert.equal(withinRoot(root, path.join(sandbox, 'other')), false, 'a sibling'); + // The separator is what makes this a path comparison rather than a string one. + assert.equal(withinRoot(root, `${root}sitory`), false, 'a sibling sharing the prefix'); +}); + +test('withinRoot accepts children of a filesystem-root target', () => { + // The one target whose real path ends in a separator, so appending another would ask + // whether `/foo` starts with `//`. Asserted on the helper: classifying the real root + // would read the machine's filesystem, which is no business of a unit test. + const fsRoot = path.parse(sandbox).root; + assert.equal(path.parse(fsRoot).root, fsRoot, 'a root is its own root, which is what the branch keys on'); + assert.equal(withinRoot(fsRoot, fsRoot), true, 'the root itself'); + assert.equal(withinRoot(fsRoot, path.join(fsRoot, 'anything')), true, 'a child of the root'); + assert.equal(withinRoot(fsRoot, sandbox), true, 'the sandbox is under the root'); +}); + +test('withinRoot accepts children of a Windows drive root', { skip: path.sep !== '\\' }, () => { + assert.equal(withinRoot('C:\\', 'C:\\'), true); + assert.equal(withinRoot('C:\\', 'C:\\repo'), true); +}); + +test('a target given with a trailing separator classifies the same directory', () => { + const dir = target(['docs/']); + assert.equal(classifyTarget(dir + path.sep), 'adopt'); + assert.equal(classifyTarget(target(['leji.json']) + path.sep), 'adopted'); +}); + +test('a target given relative to the working directory is resolved first', () => { + const dir = target(['CLAUDE.md']); + const cwd = process.cwd(); + try { + process.chdir(sandbox); + assert.equal(classifyTarget(path.basename(dir)), 'adopt'); + assert.equal(classifyTarget(path.join('.', path.basename(dir))), 'adopt'); + assert.equal(classifyTarget('.'), 'init', 'the sandbox itself has nothing to adopt'); + } finally { + process.chdir(cwd); + } +}); diff --git a/packages/sdk/test/dependency.test.ts b/packages/sdk/test/dependency.test.ts new file mode 100644 index 0000000..c4a5b53 --- /dev/null +++ b/packages/sdk/test/dependency.test.ts @@ -0,0 +1,295 @@ +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 { type DependencyOffer, dependencyAddFailed, detectEcosystem, offerDependency } from '../dist/index.js'; +import { ECOSYSTEM_TEXT } from '../dist/lib/ecosystem.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const casesDir = path.join(repoRoot, 'fixtures', 'ecosystem'); + +type SpawnResult = { error?: Error; status?: number | null; signal?: NodeJS.Signals | null }; + +/** A fake `HandoffIo`. Its `run` is the ONLY way this suite could reach a package + * manager, and it records instead of spawning; `launch` throws, because the + * declaration offer has no business launching an agent. */ +function fakeIo(answer: string, result: SpawnResult = { status: 0 }) { + const runs: { bin: string; args: string[]; cwd?: string; quiet: boolean }[] = []; + const questions: string[] = []; + const io = { + async readLine(q: string) { + questions.push(q); + return answer; + }, + launch(): SpawnResult { + throw new Error('the dependency offer must never launch an agent'); + }, + run(bin: string, args: string[], cwd: string | undefined, opts: { quiet: boolean }): SpawnResult { + runs.push({ bin, args, cwd, quiet: opts.quiet }); + return result; + }, + }; + return { io, runs, questions }; +} + +/** Run the offer with console.log captured, so assertions read the printed block + * and the outcome record together. */ +async function offer( + fixture: string, + opts: { interactive: boolean; answer?: string; result?: SpawnResult }, +): Promise<{ outcome: DependencyOffer; out: string; runs: { bin: string; args: string[]; cwd?: string }[] }> { + const root = path.join(casesDir, fixture); + const f = fakeIo(opts.answer ?? '', opts.result); + const chunks: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => void chunks.push(a.map(String).join(' ') + '\n'); + try { + const outcome = await offerDependency({ + root, + report: detectEcosystem(root), + interactive: opts.interactive, + io: f.io, + }); + return { outcome, out: chunks.join(''), runs: f.runs }; + } finally { + console.log = log; + } +} + +test('offerDependency: yes runs the manager add with argv and cwd, never a shell', async () => { + const { outcome, out, runs } = await offer('node-pnpm-lock', { interactive: true, answer: 'y' }); + assert.equal(runs.length, 1); + assert.equal(runs[0].bin, 'pnpm'); + assert.deepEqual(runs[0].args, ['add', '-D', '@leji-org/leji']); + assert.equal(runs[0].cwd, path.join(casesDir, 'node-pnpm-lock')); + assert.match(out, /Detected pnpm \(pnpm-lock\.yaml\)\./); + assert.match(out, /Running: pnpm add -D @leji-org\/leji/); + assert.match(out, /Declared @leji-org\/leji; a clean install now brings leji\./); + assert.deepEqual(outcome, { + offered: true, + ran: true, + command: ['pnpm', 'add', '-D', '@leji-org/leji'], + exitCode: 0, + signal: null, + }); + assert.equal(dependencyAddFailed(outcome), false); +}); + +test('offerDependency: what the run will do is disclosed BEFORE the prompt', async () => { + // Consent that is asked before the consequence is stated is not consent. The + // line names the binary, that it runs here with this environment, and what that + // ordinarily means: a registry call and possibly install scripts. + const { out } = await offer('node-pnpm-lock', { interactive: true, answer: 'n' }); + const expected = + 'This runs pnpm here with your environment, as when you run it yourself: it will contact its registry and may run install scripts.'; + assert.ok(out.includes(expected), out); + assert.equal(ECOSYSTEM_TEXT.consent.disclosure('pnpm'), expected, 'one byte-stable string, in the table'); + // Before the prompt, and after the block that names the command. + assert.ok(out.indexOf('Detected pnpm') < out.indexOf(expected), 'the block comes first'); + + // Non-interactive prints the block and nothing else: there is no prompt to + // disclose for, and nothing can run. + const quiet = await offer('node-pnpm-lock', { interactive: false }); + assert.ok(!quiet.out.includes('This runs'), quiet.out); + assert.equal(quiet.runs.length, 0); + + // Every manager gets the line naming its own binary. + for (const [fixture, bin] of [ + ['python-uv', 'uv'], + ['go-1.24', 'go'], + ['node-npm-lock', 'npm'], + ] as const) { + const r = await offer(fixture, { interactive: true, answer: 'n' }); + assert.ok(r.out.includes(`This runs ${bin} here with your environment`), `${fixture}: ${r.out}`); + } +}); + +test('offerDependency: the prompt is the one the plan names, and Enter accepts', async () => { + const root = path.join(casesDir, 'node-pnpm-lock'); + const f = fakeIo(''); + const log = console.log; + console.log = () => {}; + try { + await offerDependency({ root, report: detectEcosystem(root), interactive: true, io: f.io }); + } finally { + console.log = log; + } + assert.deepEqual(f.questions, [ECOSYSTEM_TEXT.consent.prompt], 'one prompt, unchanged in wording'); + assert.equal(ECOSYSTEM_TEXT.consent.prompt, 'Run it now?'); + assert.equal(f.runs.length, 1, 'Enter is yes'); +}); + +test('offerDependency: declining runs nothing and leaves the exit alone', async () => { + for (const answer of ['n', 'no', 'q', 'N']) { + const { outcome, out, runs } = await offer('node-pnpm-lock', { interactive: true, answer }); + assert.equal(runs.length, 0, `"${answer}" must not run the manager`); + assert.equal(outcome.ran, false); + assert.equal(outcome.offered, true); + assert.equal(dependencyAddFailed(outcome), false); + assert.match(out, /Skipped; declare it later with:\n {3}pnpm add -D @leji-org\/leji/); + } +}); + +test('offerDependency: a non-zero add reports the exit and fails the run', async () => { + const { outcome, out } = await offer('python-uv', { + interactive: true, + answer: 'y', + result: { status: 1, signal: null }, + }); + assert.match(out, /^uv exited 1; run it yourself:$/m); + assert.match(out, /^ {3}uv add --dev leji$/m); + assert.equal(outcome.exitCode, 1); + assert.equal(dependencyAddFailed(outcome), true); +}); + +test('offerDependency: a signalled add is reported by signal, not by exit code', async () => { + // Node reports a signalled child as `status: null, signal: <name>`; the offer + // must not read that null as "exited 0". + const { outcome, out } = await offer('go-1.24', { + interactive: true, + answer: 'y', + result: { status: null, signal: 'SIGTERM' }, + }); + assert.match(out, /^go was terminated \(SIGTERM\); run it yourself:$/m); + assert.match(out, /^ {3}go get -tool github\.com\/leji-org\/leji\/packages\/sdk-go\/cmd\/leji@latest$/m); + assert.deepEqual({ exitCode: outcome.exitCode, signal: outcome.signal }, { exitCode: null, signal: 'SIGTERM' }); + assert.equal(dependencyAddFailed(outcome), true); +}); + +test('offerDependency: a spawn error is a missing binary, not a failed add', async () => { + // ENOENT surfaces on `error` with status AND signal null: reading it as an exit + // code would report "exited 0" and pass a run in which nothing happened. + const err = Object.assign(new Error('spawn npm ENOENT'), { code: 'ENOENT' }); + const { outcome, out } = await offer('node-npm-lock', { + interactive: true, + answer: 'y', + result: { error: err, status: null, signal: null }, + }); + assert.match(out, /^npm is not on your PATH; run it yourself once it is:$/m); + assert.match(out, /^ {3}npm i -D @leji-org\/leji$/m); + assert.deepEqual( + { ran: outcome.ran, exitCode: outcome.exitCode, signal: outcome.signal }, + { + ran: true, + exitCode: null, + signal: null, + }, + ); + assert.equal(dependencyAddFailed(outcome), true); +}); + +test('offerDependency: a declared repository is told so and never prompted', async () => { + const { outcome, out, runs } = await offer('node-declared', { interactive: true, answer: 'y' }); + assert.equal(runs.length, 0); + assert.equal(outcome.offered, false); + assert.equal(outcome.ran, false); + assert.equal(out.trim(), 'The Leji CLI is already declared in package.json.'); +}); + +test('offerDependency: non-interactive prints the block and runs nothing', async () => { + for (const fixture of ['node-pnpm-lock', 'python-uv', 'go-1.24', 'node-two-lockfiles']) { + const { outcome, out, runs } = await offer(fixture, { interactive: false, answer: 'y' }); + assert.equal(runs.length, 0, `${fixture}: nothing runs without a real terminal and a yes`); + assert.equal(outcome.ran, false); + assert.ok(out.trim().length > 0, `${fixture}: the block is printed in every mode`); + } +}); + +test('offerDependency: a print-only manager is never a prompt', async () => { + // pip and pre-1.24 Go have no add command leji could run, so there is nothing to + // consent to: the block carries the line to add instead. + for (const [fixture, needle] of [ + ['python-bare-pyproject', 'pip install --group dev'], + ['python-requirements-only', 'pip install -r requirements-dev.txt'], + ['go-1.23-legacy', 'go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest'], + ] as const) { + const { outcome, out, runs } = await offer(fixture, { interactive: true, answer: 'y' }); + assert.equal(runs.length, 0, `${fixture}: nothing to run`); + assert.equal(outcome.offered, false); + assert.equal(outcome.command, null); + assert.ok(out.includes(needle), `${fixture}: ${needle}`); + } +}); + +test('offerDependency: no manager, no prompt (ambiguous, refused, unreadable, none, multiple)', async () => { + for (const fixture of [ + 'node-two-lockfiles', + 'node-refused-evidence', + 'node-unreadable-manifest', + 'node-packagemanager-unknown', + 'none', + 'multiple-ecosystems', + 'composite-ambiguity', + ]) { + const { outcome, runs } = await offer(fixture, { interactive: true, answer: 'y' }); + assert.equal(runs.length, 0, `${fixture}: leji never guesses a manager to run`); + assert.equal(outcome.offered, false); + assert.equal(outcome.ran, false); + assert.equal(dependencyAddFailed(outcome), false); + } +}); + +test('offerDependency: the offer writes nothing itself', async () => { + // leji edits no manifest and no lockfile: the manager owns both formats. The + // fixture root is byte-identical after an offer that was accepted (the fake + // records the command instead of running it). + const root = path.join(casesDir, 'node-pnpm-lock'); + const before = fs.readdirSync(root).map((n) => [n, fs.readFileSync(path.join(root, n), 'utf8')] as const); + await offer('node-pnpm-lock', { interactive: true, answer: 'y' }); + const after = fs.readdirSync(root).map((n) => [n, fs.readFileSync(path.join(root, n), 'utf8')] as const); + assert.deepEqual(after, before); +}); + +test('offerDependency: every consent path is reachable only through the injected io', async () => { + // The guard behind every test above: with an io whose `run` throws, no path that + // must not spawn can silently spawn. A real package manager is never reachable + // from this suite, because `io` is always the fake. + const explode = { + async readLine() { + return 'y'; + }, + launch(): SpawnResult { + throw new Error('no launch'); + }, + run(): SpawnResult { + throw new Error('a real package manager was almost spawned'); + }, + }; + const log = console.log; + console.log = () => {}; + try { + for (const fixture of ['node-declared', 'python-bare-pyproject', 'none', 'node-two-lockfiles']) { + const root = path.join(casesDir, fixture); + const outcome = await offerDependency({ root, report: detectEcosystem(root), interactive: true, io: explode }); + assert.equal(outcome.ran, false, fixture); + } + // And non-interactively, for a fixture that DOES have a command to run. + const root = path.join(casesDir, 'node-pnpm-lock'); + const outcome = await offerDependency({ root, report: detectEcosystem(root), interactive: false, io: explode }); + assert.equal(outcome.ran, false); + } finally { + console.log = log; + } +}); + +test('offerDependency: the temp-root case, where the offer is the whole output', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-dep-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"demo"}\n'); + fs.writeFileSync(path.join(dir, 'yarn.lock'), ''); + const f = fakeIo('yes'); + const chunks: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => void chunks.push(a.map(String).join(' ') + '\n'); + let outcome: DependencyOffer; + try { + outcome = await offerDependency({ root: dir, report: detectEcosystem(dir), interactive: true, io: f.io }); + } finally { + console.log = log; + } + assert.deepEqual(f.runs[0].args, ['add', '-D', '@leji-org/leji']); + assert.equal(f.runs[0].bin, 'yarn'); + assert.equal(outcome.exitCode, 0); + assert.match(chunks.join(''), /Detected yarn \(yarn\.lock\)\./); +}); diff --git a/packages/sdk/test/ecosystem.test.ts b/packages/sdk/test/ecosystem.test.ts new file mode 100644 index 0000000..f597962 --- /dev/null +++ b/packages/sdk/test/ecosystem.test.ts @@ -0,0 +1,518 @@ +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 { ECOSYSTEM_TEXT } from '../dist/lib/ecosystem.js'; +import { + type EcosystemReport, + detectEcosystem, + renderEcosystemBlock, + renderEcosystemLine, + runnerArgv, +} from '../dist/index.js'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const casesDir = path.join(repoRoot, 'fixtures', 'ecosystem'); + +/** The one committed formatting of an ecosystem `expected.json`: the report under + * an `ecosystem` key, `JSON.stringify(…, null, 2)`, one trailing newline. Comparing + * the bytes is what pins KEY ORDER — a deep-equal comparison would pass however the + * three SDKs happened to order their fields, and the JSON is a public contract. */ +function serialize(report: EcosystemReport): string { + return JSON.stringify({ ecosystem: report }, null, 2) + '\n'; +} + +function tmpRoot(name: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), `leji-eco-${name}-`)); +} + +/** A root holding exactly the given files (contents may be empty: lockfiles are + * presence-only evidence). */ +function plant(name: string, files: Record<string, string>): string { + const dir = tmpRoot(name); + for (const [rel, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), body); + return dir; +} + +const caseNames = fs.readdirSync(casesDir).sort(); + +test('fixtures/ecosystem: the family is populated', () => { + assert.ok(caseNames.length >= 111, `expected the full ecosystem fixture family, found ${caseNames.length}`); +}); + +for (const name of caseNames) { + const dir = path.join(casesDir, name); + test(`ecosystem fixture ${name}`, () => { + const expected = fs.readFileSync(path.join(dir, 'expected.json'), 'utf8'); + const report = detectEcosystem(dir); + // Deep equality first: it names the field that diverged. + assert.deepEqual({ ecosystem: report }, JSON.parse(expected)); + // Then the bytes, which additionally pin key order and formatting. + assert.equal(serialize(report), expected); + }); +} + +/** + * The declaration scan's field-state matrix, as SHARED cases only. Every state + * this contract has is a fixture the three SDKs run: an assertion that lives only + * in TypeScript is a state the ports can diverge on in silence, which is how a + * cross-implementation suite stays green over a contract that is already wrong. + * + * Two mechanisms keep it that way, and neither can drift: + * - the families below must exactly PARTITION the `scan-*` fixtures on disk, so a + * new case must be classified and a deleted one fails here; + * - `no scanner input lives only in TypeScript` refuses any manifest literal in + * this file, so a new assertion cannot be written inline instead. + */ +interface ScannerFamily { + field: string; + positive: string[]; + negative: string[]; +} + +const SCANNER_FAMILIES: ScannerFamily[] = [ + { + field: 'project.dependencies', + positive: [ + 'scan-project-deps-inline', + 'scan-project-deps-multiline', + 'scan-project-deps-specifier', + 'scan-project-deps-spaced-header', + 'scan-project-deps-single-quoted', + ], + negative: ['scan-project-deps-absent', 'scan-project-deps-prefix-only', 'scan-project-deps-comment'], + }, + { + field: 'project.optional-dependencies', + positive: ['scan-optional-deps-declared'], + negative: ['scan-optional-deps-absent', 'scan-optional-deps-comment'], + }, + { + field: 'dependency-groups', + positive: ['scan-dependency-groups-declared'], + negative: [ + 'scan-dependency-groups-absent', + 'scan-dependency-groups-comment', + 'scan-dependency-groups-triple-quoted', + ], + }, + { + field: 'tool.uv dev-dependencies', + positive: ['scan-tool-uv-dev-declared', 'scan-tool-uv-dev-marker'], + negative: ['scan-tool-uv-dev-absent', 'scan-tool-uv-dev-comment', 'scan-tool-uv-other-field'], + }, + { + field: 'tool.poetry.dependencies', + positive: ['scan-poetry-deps-key', 'scan-poetry-deps-quoted-key'], + negative: ['scan-poetry-deps-absent', 'scan-poetry-deps-comment'], + }, + { + field: 'tool.poetry.dev-dependencies', + positive: ['scan-poetry-dev-deps-key', 'scan-poetry-dev-deps-quoted-key'], + negative: ['scan-poetry-dev-deps-absent', 'scan-poetry-dev-deps-comment'], + }, + { + field: 'tool.poetry.group.<x>.dependencies', + positive: ['scan-poetry-group-key', 'scan-poetry-group-inline-table'], + negative: ['scan-poetry-group-absent', 'scan-poetry-group-comment'], + }, + { + field: 'tool.pdm.dev-dependencies', + positive: ['scan-pdm-dev-array', 'scan-pdm-dev-key'], + negative: ['scan-pdm-dev-absent', 'scan-pdm-dev-comment'], + }, + { + field: 'Pipfile packages', + positive: ['scan-pipfile-packages', 'scan-pipfile-packages-quoted-key'], + negative: ['scan-pipfile-packages-absent', 'scan-pipfile-packages-comment'], + }, + { + field: 'Pipfile dev-packages', + positive: ['scan-pipfile-dev-packages', 'scan-pipfile-dev-packages-bare-key'], + negative: ['scan-pipfile-dev-packages-absent', 'scan-pipfile-dev-packages-comment'], + }, + { + field: 'requirements files', + positive: ['scan-requirements-declared', 'scan-requirements-bare', 'scan-requirements-extras'], + negative: [ + 'scan-requirements-indented', + 'scan-requirements-comment', + 'scan-requirements-prefix-only', + 'scan-requirements-include-line', + ], + }, + { + // A quoted element declares; the same text triple-quoted never does, on one + // line as across several, because nothing here parses TOML. + field: 'quoted and triple-quoted elements', + positive: ['scan-plain-quoted-element'], + negative: ['scan-triple-quoted-element', 'scan-triple-quoted-element-literal'], + }, + { + // A multi-line string is skipped whole, so neither its prose nor a table + // header inside it can reach the scan. There is no positive: it never declares. + field: 'multi-line strings', + positive: [], + negative: ['scan-multiline-basic-string', 'scan-multiline-literal-string', 'scan-multiline-string-hides-table'], + }, + { + // Uninspected fields of an inspected table, and tables that are not inspected + // at all: the scan is field-specific because a false positive suppresses the + // only offer the user gets. + field: 'uninspected fields and tables', + positive: [], + negative: [ + 'scan-project-description', + 'scan-project-keywords', + 'scan-project-classifiers', + 'scan-project-nested-array', + 'scan-unrelated-table-key', + 'scan-poetry-scripts-key', + 'scan-pipfile-scripts', + ], + }, + { + field: 'go.mod tool directive', + positive: ['scan-go-2.0'], + negative: ['scan-go-closed-block', 'scan-go-comment', 'scan-go-1.9', 'scan-go-1.25'], + }, +]; + +/** The verdict a shared case pins, read from its committed expectation. */ +function fixtureDeclared(name: string): boolean { + const expected = JSON.parse(fs.readFileSync(path.join(casesDir, name, 'expected.json'), 'utf8')); + assert.ok(expected.ecosystem.selected, `${name}: a scanner case always selects one manager`); + return expected.ecosystem.selected.directDeclared; +} + +test('fixtures/ecosystem: the scanner families partition every shared scan case', () => { + const onDisk = caseNames.filter((n) => n.startsWith('scan-')); + const claimed = SCANNER_FAMILIES.flatMap((f) => [...f.positive, ...f.negative]); + assert.equal(new Set(claimed).size, claimed.length, 'no case is claimed by two families'); + // Set equality both ways: a new fixture must be classified, and a classified + // case must exist. Neither list can quietly drift from the other. + assert.deepEqual(claimed.slice().sort(), onDisk.slice().sort()); +}); + +test('fixtures/ecosystem: every inspected field carries both verdicts as shared cases', () => { + for (const family of SCANNER_FAMILIES) { + for (const name of family.positive) { + assert.equal(fixtureDeclared(name), true, `${family.field}: ${name} must declare`); + } + for (const name of family.negative) { + assert.equal(fixtureDeclared(name), false, `${family.field}: ${name} must not declare`); + } + assert.ok(family.negative.length > 0, `${family.field}: a negative is what proves the scan is specific`); + } + // Every field that can be declared IN has a positive; the two families without + // one are the ones where declaring is impossible by construction. + const noPositive = SCANNER_FAMILIES.filter((f) => f.positive.length === 0).map((f) => f.field); + assert.deepEqual(noPositive, ['multi-line strings', 'uninspected fields and tables']); +}); + +test('fixtures/ecosystem: the go directive threshold is pinned by shared cases', () => { + const manager = (name: string): string => + JSON.parse(fs.readFileSync(path.join(casesDir, name, 'expected.json'), 'utf8')).ecosystem.selected.manager; + // Tool dependencies need Go 1.24; the fixtures pin both sides of the boundary. + assert.equal(manager('scan-go-1.9'), 'go-legacy'); + assert.equal(manager('go-1.23-legacy'), 'go-legacy'); + assert.equal(manager('go-no-directive'), 'go-legacy'); + assert.equal(manager('go-1.24'), 'go'); + assert.equal(manager('scan-go-1.25'), 'go'); + assert.equal(manager('scan-go-2.0'), 'go'); +}); + +/** + * The anti-drift guard, structural rather than a list of spellings. It reads its + * own source, extracts every string and template literal, and refuses any whose + * CONTENT reads as a manifest: a TOML table header at any spacing, a `go.mod` + * directive or block opener, a requirements line, or a dependency-array + * assignment. A curated list of substrings can always be spelled around + * (`[ project ]` with spaces, a `tool (` block, a dotted or quoted key); a rule + * about the SHAPE of the content cannot. + * + * Every bracket below is assembled from its character code, so no fragment of the + * guard is itself a manifest literal. + */ +const LB = String.fromCharCode(91); +const RB = String.fromCharCode(93); +/** One TOML key: bare, dotted, or quoted. */ +const TOML_KEY = LB + 'A-Za-z0-9_.' + String.fromCharCode(34) + "'-" + RB + '+'; + +const MANIFEST_SHAPES: { rule: string; re: RegExp }[] = [ + { + rule: 'a TOML table header', + re: new RegExp( + '^\\s*\\' + LB + '\\s*\\' + LB + '?\\s*' + TOML_KEY + '(?:\\s*\\.\\s*' + TOML_KEY + ')*\\s*\\' + RB, + 'm', + ), + }, + { rule: 'a go.mod module directive', re: new RegExp('^\\s*module\\s+\\S', 'm') }, + { rule: 'a go.mod go directive', re: new RegExp('^\\s*go\\s+' + LB + '0-9' + RB, 'm') }, + { rule: 'a go.mod tool directive', re: new RegExp('^\\s*tool\\s+\\S', 'm') }, + { rule: 'a go.mod tool block', re: new RegExp('^\\s*tool\\s*\\(', 'm') }, + { + rule: 'a requirement with a specifier or extras', + re: new RegExp('^\\s*leji\\s*' + LB + '<>=~!;,' + LB + RB, 'm'), + }, + { + rule: 'a dependency array assignment', + re: new RegExp('(?:^|\\s)(?:dependencies|dev-dependencies|dev)\\s*=\\s*\\' + LB, 'm'), + }, +]; + +/** A bare requirements BODY, which is a line that is only the name. Applied to + * literals carrying a newline, so a plain `leji` argv element stays legal. */ +const REQUIREMENT_LINE = new RegExp('^\\s*leji\\b', 'm'); + +/** + * Every string and template literal in the source, with escapes decoded, so a + * one-line manifest written with escaped newlines is judged by the lines it + * actually holds. Comments are skipped (their prose carries apostrophes), and the + * scan must end in code: ending inside a string would mean this reading of the + * file is wrong, and the guard says so rather than passing vacuously. + */ +function sourceLiterals(source: string): string[] { + const out: string[] = []; + let quote = ''; + let buf = ''; + let i = 0; + while (i < source.length) { + const c = source[i]; + if (quote === '') { + if (c === '/' && source[i + 1] === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (c === '/' && source[i + 1] === '*') { + const end = source.indexOf('*' + '/', i + 2); + i = end < 0 ? source.length : end + 2; + continue; + } + if (c === "'" || c === '"' || c === '`') { + quote = c; + buf = ''; + } + i++; + continue; + } + if (c === '\\') { + const next = source[i + 1] ?? ''; + buf += next === 'n' ? '\n' : next === 't' ? '\t' : next; + i += 2; + continue; + } + if (c === quote) { + out.push(buf); + quote = ''; + i++; + continue; + } + buf += c; + i++; + } + assert.equal(quote, '', 'the literal scan ended inside a string, so its reading of this file is not trustworthy'); + return out; +} + +test('fixtures/ecosystem: no scanner input lives only in TypeScript', () => { + const literals = sourceLiterals(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8')); + assert.ok(literals.length > 100, `the literal scan found ${literals.length} literals, so it proves nothing`); + for (const literal of literals) { + for (const shape of MANIFEST_SHAPES) { + assert.ok( + !shape.re.test(literal), + `${shape.rule} belongs in fixtures/ecosystem, not inline here: ${JSON.stringify(literal.slice(0, 60))}`, + ); + } + if (literal.includes('\n')) { + assert.ok( + !REQUIREMENT_LINE.test(literal), + `a requirements line belongs in fixtures/ecosystem, not inline: ${JSON.stringify(literal.slice(0, 60))}`, + ); + } + } +}); + +test('detectEcosystem: a report is the same object graph for selected and its element', () => { + const report = detectEcosystem(path.join(casesDir, 'node-pnpm-lock')); + assert.equal(report.all.length, 1); + assert.deepEqual(report.selected, report.all[0]); + assert.equal(report.reason, null); +}); + +// --- the runner a hook or CI job takes ------------------------------------- + +test('runnerArgv: the manager runner only when the repository declares the CLI', () => { + assert.deepEqual(runnerArgv(detectEcosystem(path.join(casesDir, 'node-declared'))), [ + 'npx', + '--no-install', + '@leji-org/leji', + ]); + // Detected but undeclared: the manager's runner would resolve nothing. + assert.deepEqual(runnerArgv(detectEcosystem(path.join(casesDir, 'node-pnpm-lock'))), ['leji']); + assert.deepEqual(runnerArgv(detectEcosystem(path.join(casesDir, 'go-declared-block'))), ['go', 'tool', 'leji']); + assert.deepEqual(runnerArgv(detectEcosystem(path.join(casesDir, 'python-declared-pyproject-groups'))), [ + 'uv', + 'run', + 'leji', + ]); + assert.deepEqual(runnerArgv(detectEcosystem(path.join(casesDir, 'none'))), ['leji']); + assert.deepEqual(runnerArgv(detectEcosystem(path.join(casesDir, 'node-two-lockfiles'))), ['leji']); +}); + +// --- the printed block ------------------------------------------------------ + +test('renderEcosystemBlock: the offer names the manager, its evidence and one command', () => { + assert.equal( + renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'node-pnpm-lock'))), + 'Detected pnpm (pnpm-lock.yaml). To declare the Leji CLI as a dev dependency so a clean install brings leji, run:\n pnpm add -D @leji-org/leji', + ); + assert.equal( + renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'node-declared'))), + 'The Leji CLI is already declared in package.json.', + ); + const ambiguous = renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'node-two-lockfiles'))); + assert.equal( + ambiguous, + 'Detected package.json with package-lock.json and yarn.lock; leji will not guess the package manager. Declare it with the one this repo uses:\n npm i -D @leji-org/leji\n yarn add -D @leji-org/leji', + ); + // Every no-manager outcome still prints a block, and none of them prints a + // command that would guess. + for (const name of ['node-packagemanager-unknown', 'node-unreadable-manifest', 'node-refused-evidence']) { + const block = renderEcosystemBlock(detectEcosystem(path.join(casesDir, name))); + assert.ok(block.length > 0, `${name} prints a block`); + assert.ok(!block.includes(' npm'), `${name} offers no guessed command`); + } + // A print-only manager prints what to add, never a command leji could run. + const pip = renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'python-bare-pyproject'))); + assert.equal(pip, ECOSYSTEM_TEXT.pipGroups('pyproject.toml').join('\n'), 'the print-only block is the table'); + const requirements = renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'python-requirements-only'))); + assert.ok(requirements.includes('pip install -r requirements-dev.txt'), requirements); + const legacy = renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'go-1.23-legacy'))); + assert.ok(legacy.includes('go install github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest'), legacy); + const none = renderEcosystemBlock(detectEcosystem(path.join(casesDir, 'none'))); + assert.ok(none.includes('https://leji.org/quickstart/'), none); +}); + +test('renderEcosystemLine: one line, whatever the outcome', () => { + const line = (name: string): string => renderEcosystemLine(detectEcosystem(path.join(casesDir, name))); + assert.equal(line('node-pnpm-lock'), 'Ecosystem: pnpm (pnpm-lock.yaml); Leji CLI not declared'); + assert.equal(line('node-declared'), 'Ecosystem: npm (package-lock.json); Leji CLI declared'); + assert.equal(line('python-pipfile-only'), 'Ecosystem: pipenv (Pipfile); Leji CLI declared'); + assert.equal(line('python-tool-uv-no-lock'), 'Ecosystem: uv (pyproject.toml); Leji CLI not declared'); + assert.equal(line('none'), 'Ecosystem: none detected'); + for (const name of caseNames) assert.ok(!line(name).includes('\n'), `${name} renders one line`); +}); + +// --- evidence eligibility --------------------------------------------------- + +test('detectEcosystem: a symlinked, dangling or non-regular manifest is refused, not read', () => { + const outside = plant('outside', { 'package.json': JSON.stringify({ dependencies: { '@leji-org/leji': '1' } }) }); + + const linked = tmpRoot('linked'); + fs.symlinkSync(path.join(outside, 'package.json'), path.join(linked, 'package.json')); + const viaLink = detectEcosystem(linked); + assert.equal(viaLink.reason, 'refused-evidence'); + assert.deepEqual(viaLink.all[0].evidence, ['package.json']); + assert.equal(viaLink.all[0].directDeclared, false, 'a refused manifest is never read for a declaration'); + + const dangling = tmpRoot('dangling'); + fs.symlinkSync(path.join(dangling, 'gone.json'), path.join(dangling, 'package.json')); + assert.equal(detectEcosystem(dangling).reason, 'refused-evidence'); + + // A directory standing where a lockfile belongs is a standing entry the run + // could not verify, exactly like a link. + const dirLock = plant('dirlock', { 'package.json': '{}' }); + fs.mkdirSync(path.join(dirLock, 'pnpm-lock.yaml')); + const asDir = detectEcosystem(dirLock); + assert.equal(asDir.reason, 'refused-evidence'); + assert.deepEqual(asDir.all[0].evidence, ['pnpm-lock.yaml']); + + // A symlink that stays inside the root is still not a regular file. + const inside = plant('inside', { 'package.json': '{}', 'other.json': '{}' }); + fs.symlinkSync('./other.json', path.join(inside, 'pnpm-lock.yaml')); + assert.equal(detectEcosystem(inside).reason, 'refused-evidence'); + + const pyLinked = tmpRoot('pylinked'); + fs.symlinkSync(path.join(outside, 'package.json'), path.join(pyLinked, 'requirements.txt')); + const py = detectEcosystem(pyLinked); + assert.equal(py.reason, 'refused-evidence'); + assert.equal(py.all[0].ecosystem, 'python'); +}); + +test('detectEcosystem: an unreadable manifest consults neither locks nor defaults', () => { + const broken = plant('broken', { 'package.json': '{ "name": ', 'package-lock.json': '' }); + const report = detectEcosystem(broken); + assert.equal(report.reason, 'unreadable-manifest'); + assert.equal(report.all[0].manager, null); + assert.deepEqual(report.all[0].evidence, []); + assert.equal(report.all[0].add, null); + + // Valid JSON that is not an object cannot carry a field either. + assert.equal(detectEcosystem(plant('array', { 'package.json': '[]' })).reason, 'unreadable-manifest'); + // A BOM is stripped, exactly once, before the strict parse. + const bom = plant('bom', { 'package.json': '{ "packageManager": "yarn@4.1.0" }' }); + assert.equal(detectEcosystem(bom).selected?.manager, 'yarn'); + // An unreadable pyproject refuses the ecosystem rather than falling back to pip. + const pyDir = tmpRoot('pyunreadable'); + fs.mkdirSync(path.join(pyDir, 'pyproject.toml')); + assert.equal(detectEcosystem(pyDir).reason, 'refused-evidence'); +}); + +test('detectEcosystem: no walk-up — a parent manifest never answers for the root', () => { + const parent = plant('parent', { 'package.json': '{}', 'package-lock.json': '' }); + const child = path.join(parent, 'child'); + fs.mkdirSync(child); + assert.deepEqual(detectEcosystem(child), { selected: null, all: [], reason: 'none' }); +}); + +// --- packageManager grammar -------------------------------------------------- + +test('detectEcosystem: packageManager wins, and a value it cannot parse never falls through', () => { + const pm = (value: string, extra: Record<string, string> = {}): EcosystemReport => + detectEcosystem(plant('pm', { 'package.json': JSON.stringify({ packageManager: value }), ...extra })); + assert.equal(pm('pnpm@9.12.0').selected?.manager, 'pnpm'); + assert.equal(pm('bun@1.1.30+e1f2a3b4c5').selected?.manager, 'bun'); + assert.equal(pm('yarn@4.1.0-rc.1').selected?.manager, 'yarn'); + assert.equal(pm('npm').selected?.manager, 'npm', 'the version is optional in the grammar'); + assert.equal(pm('pnpm@9.12.0', { 'yarn.lock': '' }).selected?.manager, 'pnpm'); + for (const bad of ['pnpm@@9', 'pnpm@', '@9.12.0', 'Pnpm@9.12.0', 'pnpm 9.12.0', '', 'hermit@1.0.0']) { + const report = pm(bad, { 'package-lock.json': '' }); + assert.equal(report.reason, 'unsupported-manager', `"${bad}" is not a manager leji knows`); + assert.equal(report.all[0].source, 'packageManager'); + assert.deepEqual(report.all[0].candidates, []); + assert.equal(report.all[0].add, null); + } + // A non-string value is a present value that does not parse. + const numeric = detectEcosystem(plant('pmnum', { 'package.json': '{ "packageManager": 9 }' })); + assert.equal(numeric.reason, 'unsupported-manager'); +}); + +// --- the declaration rules, only ever through the detector ------------------ + +test('node declaration: the same rules, reached only through the eligibility path', () => { + const declared = (pkg: string): boolean => { + const dir = plant('decl', { 'package.json': pkg, 'package-lock.json': '' }); + return detectEcosystem(dir).selected!.directDeclared; + }; + assert.equal(declared('{"devDependencies":{"@leji-org/leji":"^1"}}'), true); + assert.equal(declared('{"dependencies":{"@leji-org/leji":"^1"}}'), true); + // One BOM is stripped before the strict parse. + assert.equal(declared('\ufeff{"devDependencies":{"@leji-org/leji":"^1"}}'), true); + // A dependency map that is not a JSON object holds no key: absent, never an error. + assert.equal(declared('{"devDependencies":["@leji-org/leji"]}'), false); + assert.equal(declared('{"devDependencies":{"leji":"^1"}}'), false, 'the name is exact'); + // An unparseable manifest is unreadable, not undeclared: nothing is inferred. + const broken = plant('decl', { 'package.json': 'not json', 'package-lock.json': '' }); + assert.equal(detectEcosystem(broken).reason, 'unreadable-manifest'); + // And a manifest that fails eligibility is never read for a declaration at all. + const linked = tmpRoot('decl-link'); + const outside = plant('decl-outside', { 'package.json': '{"dependencies":{"@leji-org/leji":"1"}}' }); + fs.symlinkSync(path.join(outside, 'package.json'), path.join(linked, 'package.json')); + const report = detectEcosystem(linked); + assert.equal(report.reason, 'refused-evidence'); + assert.equal(report.all[0].directDeclared, false); +}); diff --git a/packages/sdk/test/export.test.ts b/packages/sdk/test/export.test.ts new file mode 100644 index 0000000..51d2582 --- /dev/null +++ b/packages/sdk/test/export.test.ts @@ -0,0 +1,508 @@ +import { strict as assert } from 'node:assert'; +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 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'; +// 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'; + +// `leji export` and `leji viewer build`: one operation, two permanently supported +// names. What this file pins is the part of that operation the other suites cannot +// see: that the pipeline carries no network dependency (statically or as a +// subprocess), that the two names really are one code path, that the exported tree +// answers every route the local server does, that no destination flag exists, that +// `--strict` is scoped to the lint class, and that a failed run leaves an existing +// export byte-untouched. + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const srcDir = path.join(repoRoot, 'packages', 'sdk', 'src'); +const exampleDir = path.join(repoRoot, 'examples', 'monorepo'); +const fixturesDir = path.join(repoRoot, 'fixtures'); + +/** realpath the temp dir: on macOS /tmp is a symlink, which the export resolves. */ +function tmpCopy(from: string, prefix: string): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + fs.cpSync(from, dir, { recursive: true }); + return dir; +} + +/** run() writes to the console; swallow it and hand back what it said. */ +async function quiet<T>(fn: () => T | Promise<T>): Promise<{ value: T; stdout: string }> { + const chunks: string[] = []; + const log = console.log; + const err = console.error; + console.log = (...a: unknown[]) => void chunks.push(a.map(String).join(' ') + '\n'); + console.error = () => {}; + try { + return { value: await fn(), stdout: chunks.join('') }; + } finally { + console.log = log; + console.error = err; + } +} + +/** Every path under `dir` as `rel -> digest` (directories as `rel/` -> ''), so a + * comparison covers appearance and disappearance as well as content. */ +function snapshot(dir: string, rel = '', acc = new Map<string, string>()): Map<string, string> { + 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; +} + +// --- module-graph ------------------------------------------------------------- +// The structural prong of the no-network guarantee: the export module's transitive +// STATIC import set contains no network module and no fetch call. It catches the +// static introduction of a network dependency and nothing else; the subprocess spy +// below and the offline CI leg cover dynamic loading and side doors. + +/** Every specifier `file` imports statically — `import` and `export … from` alike, + * since a re-export pulls a module in exactly as an import does — plus any dynamic + * `import()` it spells literally (a dynamic import of a network module would + * otherwise read as absent). */ +function importsOf(file: string): string[] { + const text = fs.readFileSync(file, 'utf8'); + const out: string[] = []; + for (const m of text.matchAll(/(?:^|[\s;}])import\s+(?:[^'"]*?\sfrom\s*)?['"]([^'"]+)['"]/g)) out.push(m[1]); + for (const m of text.matchAll(/(?:^|[\s;}])export\s+[^'"]*?\sfrom\s*['"]([^'"]+)['"]/g)) out.push(m[1]); + for (const m of text.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) out.push(m[1]); + for (const m of text.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) out.push(m[1]); + return out; +} + +/** The transitive closure of `entry` over relative specifiers, as absolute source + * paths, plus every bare/builtin specifier reached along the way. */ +function moduleGraph(entry: string): { files: string[]; external: Set<string> } { + const files: string[] = []; + const external = new Set<string>(); + const seen = new Set<string>(); + const stack = [entry]; + while (stack.length > 0) { + const file = stack.pop()!; + if (seen.has(file)) continue; + seen.add(file); + files.push(file); + for (const spec of importsOf(file)) { + if (!spec.startsWith('.')) { + external.add(spec); + continue; + } + // Authored TypeScript spells its own imports with the emitted `.js`. + const resolved = path.resolve(path.dirname(file), spec).replace(/\.js$/, '.ts'); + assert.ok(fs.existsSync(resolved), `${spec} from ${path.relative(srcDir, file)} resolves to a source file`); + stack.push(resolved); + } + } + return { files, external }; +} + +const NETWORK_MODULES = [ + 'http', + 'https', + 'net', + 'dgram', + 'tls', + 'dns', + 'http2', + 'node:http', + 'node:https', + 'node:net', + 'node:dgram', + 'node:tls', + 'node:dns', + 'node:http2', +]; + +test('module-graph: the export module reaches no network module and calls no fetch', () => { + // The parser reads every form a module can be pulled in by: `lib/layer.js` reaches + // index.ts through `export … from` alone, so a parser that knew only `import` + // would not see it — and a form the parser cannot see is how a network module + // walks into the graph unnoticed. + const barrel = importsOf(path.join(srcDir, 'index.ts')); + assert.ok(barrel.includes('./lib/layer.js'), `re-export forms are parsed: ${barrel.join(', ')}`); + + const graph = moduleGraph(path.join(srcDir, 'commands', 'export.ts')); + const reachable = graph.files.map((f) => path.relative(srcDir, f)).sort(); + // The graph is real: the export pulls in the chrome generation and the layer + // libraries, so an empty or truncated walk cannot pass this test by accident. + assert.ok(reachable.includes('commands/viewer.ts'), `the graph reaches the generator: ${reachable.join(', ')}`); + assert.ok(reachable.length >= 8, `the graph is not truncated: ${reachable.join(', ')}`); + assert.ok(!reachable.includes('commands/serve.ts'), 'the export never reaches the serve module'); + + for (const mod of NETWORK_MODULES) { + assert.ok(!graph.external.has(mod), `the export module graph imports ${mod}: ${[...graph.external].join(', ')}`); + } + for (const file of graph.files) { + const text = fs.readFileSync(file, 'utf8'); + assert.ok(!/\bfetch\s*\(/.test(text), `${path.relative(srcDir, file)} calls fetch()`); + assert.ok(!/\bnew\s+WebSocket\b/.test(text), `${path.relative(srcDir, file)} opens a WebSocket`); + } + + // Positive control: the serve module DOES import node:http, so the assertions + // above are testing a real property rather than a detector that sees nothing. + const serve = moduleGraph(path.join(srcDir, 'commands', 'serve.ts')); + assert.ok(serve.external.has('node:http'), 'the serve module graph imports node:http'); +}); + +// --- subprocess-spy ----------------------------------------------------------- + +test('subprocess-spy: an export run spawns git and nothing else', async () => { + const dir = tmpCopy(exampleDir, 'leji-export-spy-'); + const require = createRequire(import.meta.url); + const cp = require('node:child_process') as Record<string, unknown>; + const launchers = ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']; + const originals = new Map(launchers.map((n) => [n, cp[n]])); + const launched: string[] = []; + for (const name of launchers) { + const original = originals.get(name) as (...args: unknown[]) => unknown; + cp[name] = (...args: unknown[]): unknown => { + launched.push(String(args[0])); + return original(...args); + }; + } + // Builtin ESM bindings are snapshotted at link time; this republishes the + // patched CJS exports through them, so the SDK's own `import { execFileSync }` + // sees the spy. + Module.syncBuiltinESMExports(); + try { + // The whole command, not just the build: the CLI entry, the manifest load + // ahead of it, and the pipeline — so a spawn added anywhere on the export path + // is seen, not only one inside `buildViewer`. + const { value } = await quiet(() => run(['export', '--root', dir, '--json'])); + assert.equal(value, 0, 'the export ran to completion under the spy'); + } finally { + for (const name of launchers) cp[name] = originals.get(name); + Module.syncBuiltinESMExports(); + } + // The spy sees something (git, for the index dates and the mount status), so a + // silent no-op cannot pass; and everything it sees is git. + assert.ok(launched.length > 0, 'the spy observed the subprocesses the export path uses'); + assert.deepEqual([...new Set(launched)], ['git'], `only git is spawned: ${[...new Set(launched)].join(', ')}`); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- name-equivalence --------------------------------------------------------- + +test('name-equivalence: `export` and `viewer build` write byte-identical trees', async () => { + const a = tmpCopy(exampleDir, 'leji-export-name-a-'); + const b = tmpCopy(exampleDir, 'leji-export-name-b-'); + const first = await quiet(() => run(['export', '--root', a, '--json'])); + const second = await quiet(() => run(['viewer', 'build', '--root', b, '--json'])); + assert.equal(first.value, 0); + assert.equal(second.value, 0); + // The same JSON document under both names, `command` included: the second name + // is the same operation, not a second command that resembles it. + const docA = JSON.parse(first.stdout) as Record<string, unknown>; + const docB = JSON.parse(second.stdout) as Record<string, unknown>; + assert.equal(docA.command, 'export'); + assert.deepEqual(docB, docA); + assert.equal(docA.out, path.join('.leji', 'dist')); + + assert.deepEqual([...snapshot(b).entries()].sort(), [...snapshot(a).entries()].sort(), 'identical working trees'); + fs.rmSync(a, { recursive: true, force: true }); + fs.rmSync(b, { recursive: true, force: true }); +}); + +// --- route-equivalence -------------------------------------------------------- + +/** Issue one request with the path exactly as written. */ +function request(port: number, urlPath: string): Promise<{ status: number; body: Buffer }> { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: urlPath, method: 'GET' }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks) })); + }); + req.on('error', reject); + req.end(); + }); +} + +test('route-equivalence: every route the served layer names reads identically from the export', async () => { + const dir = tmpCopy(exampleDir, 'leji-export-routes-'); + // The export carries the COMMITTED index; the server answers that route from a + // live regeneration. They agree only when the stored index is current, which a + // copy outside git is not (document dates fall back to filesystem mtimes), so the + // layer is brought current first — the comparison below is then the real one. + assert.equal(await quiet(() => run(['index', '--root', dir])).then((r) => r.value), 0); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + const built = buildViewer(dir, manifest); + assert.ok(built.wrote); + const outContent = path.join(dir, built.out, 'content'); + + // 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<string>(['_sidebar.md', '_manifest.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'; + const index = JSON.parse(fs.readFileSync(path.join(dir, indexRel), 'utf8')) as { entries: { path: string }[] }; + const base = manifest.rootPath.replace(/\/$/, ''); + const route = (repoRel: string): string => (base === '' || base === '.' ? repoRel : repoRel.slice(base.length + 1)); + for (const e of index.entries) routes.add(route(e.path)); + // The index itself is a route like any other, and the one the normalization is + // for: the server answers it from a live regeneration, the export carries the + // committed snapshot, and `generatedAt` is the only field allowed to differ. + routes.add(route(indexRel)); + assert.ok(routes.size >= 6, `the corpus is not empty: ${[...routes].join(', ')}`); + + const server = await serveViewer(dir, 0, manifest.rootPath); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + for (const route of [...routes].sort()) { + const served = await request(port, `/content/${route}`); + assert.equal(served.status, 200, `the local server serves /content/${route}`); + const exported = path.join(outContent, route); + assert.ok(fs.existsSync(exported), `the export carries ${route}`); + // `generatedAt` is the one declared volatile field; nothing else may differ. + const normalize = (b: Buffer): string => + b.toString('utf8').replace(/"generatedAt":\s*"[^"]*"/g, '"generatedAt":"<normalized>"'); + assert.equal( + normalize(fs.readFileSync(exported)), + normalize(served.body), + `served and exported bytes differ for ${route}`, + ); + } + } finally { + server.close(); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- arg-rejection ------------------------------------------------------------ + +test('arg-rejection: export takes no destination flag, and its help names no network', async () => { + const dir = tmpCopy(exampleDir, 'leji-export-args-'); + for (const argv of [ + ['export', '--endpoint', 'x'], + ['export', '--url', 'https://example.invalid'], + ['export', '--host', 'example.invalid'], + ['export', '--token', 'secret'], + ['export', '--port', '8080'], + ['viewer', 'build', '--endpoint', 'x'], + ]) { + const { value } = await quiet(() => run([...argv, '--root', dir])); + assert.equal(value, 2, `${argv.join(' ')} is a usage error`); + } + // The accept side of the same guarantee, under BOTH names: the allow-list the + // rejection above consults is exactly the globals plus --out and --strict. Read + // from cli.json, which is what the CLI itself rejects against — so a destination + // flag cannot reach the surface without failing here. + const spec = loadCliSpec(); + for (const name of ['export', 'viewer build']) { + const cmd = spec.commands.find((c) => c.name === name); + assert.ok(cmd, `${name} is a documented command`); + const allowed = [...spec.globalOptions, ...cmd!.options] + .flatMap((o) => o.flags.split(',').map((s) => s.trim().split(/\s+/)[0])) + .sort(); + assert.deepEqual(allowed, ['--help', '--json', '--out', '--root', '--strict', '--version', '-h', '-v'], name); + // And the help bytes a person reads describe no network operation: this command + // writes files from files. The whole banned class against the real bytes, not a + // selected few of them. The flag surface itself is the cli.json assertion above, + // which holds whatever the help layout does; help only has to document it. + const help = renderCommandHelp(name); + assert.ok(help); + for (const o of cmd!.options) assert.ok(help!.includes(` ${o.flags}`), `${name} help documents ${o.flags}`); + assert.match(help!, /\nGlobal options: see leji --help\.\n/); + for (const word of [ + 'endpoint', + 'token', + 'upload', + 'api key', + 's3://', + 'host', + 'url', + 'server', + 'network', + 'browser', + 'publish', + 'remote', + ]) { + assert.ok(!help!.toLowerCase().includes(word), `the ${name} help text carries no "${word}"`); + } + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- strict-scope and the byte-untouched target -------------------------------- + +test('strict: the gate is the lint class, and a failed run leaves the target byte-untouched', async () => { + // A layer that reports a finding without failing generation: a viewer.homepage + // that resolves to nothing is a warning, exported anyway. + const dir = tmpCopy(path.join(fixturesDir, 'valid-unified-leji-fresh'), 'leji-export-strict-'); + const manifestPath = path.join(dir, 'leji.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>; + declared.viewer = { homepage: 'no-such-page.md' }; + fs.writeFileSync(manifestPath, JSON.stringify(declared, null, 2) + '\n'); + + const plain = await quiet(() => run(['export', '--root', dir, '--json'])); + assert.equal(plain.value, 0); + const plainDoc = JSON.parse(plain.stdout) as { ok: boolean; findings: { rule: string }[] }; + assert.equal(plainDoc.ok, true); + assert.ok( + plainDoc.findings.some((f) => f.rule === 'viewer-path-missing'), + `the layer reports a finding: ${JSON.stringify(plainDoc.findings)}`, + ); + + // `--strict` is scoped to the lint class, not to any finding: an ordinary viewer + // warning stays a warning, and the export is still written. + const strict = await quiet(() => run(['export', '--root', dir, '--strict', '--json'])); + assert.equal(strict.value, 0, 'an ordinary warning is not promoted by --strict'); + const strictDoc = JSON.parse(strict.stdout) as { ok: boolean; findings: unknown[] }; + assert.equal(strictDoc.ok, true); + assert.deepEqual(strictDoc.findings, plainDoc.findings, 'the same findings, and still written'); + // The class the gate does promote is the rendering lint's, so F4's findings fail + // a strict run the day they land. What that promotion DOES is pinned behaviorally + // by the test below; this only names the class the gate is scoped to. + assert.ok(STRICT_LINT_RULES.has('render-unsupported'), 'the lint class is what --strict promotes'); + + const distDir = path.join(dir, '.leji', 'dist'); + const before = [...snapshot(distDir).entries()].sort(); + assert.ok(before.length > 0, 'an export exists to be protected'); + + // An error finding fails the run through the same pre-clean gate: overview.md, + // seeded by the runs above, redirected into a private role. Generation reaches it + // after the chrome is written, so this run proves both halves of the pipeline + // promise at once — the internal chrome IS regenerated, the target is not touched. + fs.mkdirSync(path.join(dir, '.leji', 'mounts'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', 'mounts', 'stolen.md'), 'private\n'); + const overview = path.join(dir, 'docs', 'overview.md'); + assert.ok(fs.existsSync(overview), 'the seeded overview page is there to redirect'); + fs.rmSync(overview); + fs.symlinkSync(path.join(dir, '.leji', 'mounts', 'stolen.md'), overview); + const viewerDir = path.join(dir, '.leji', 'viewer'); + fs.rmSync(viewerDir, { recursive: true, force: true }); + + const failed = await quiet(() => run(['export', '--root', dir, '--json'])); + assert.equal(failed.value, 1, 'an error finding fails the run'); + const failedDoc = JSON.parse(failed.stdout) as { ok: boolean; findings: { severity: string }[] }; + assert.equal(failedDoc.ok, false); + assert.ok( + failedDoc.findings.some((f) => f.severity === 'error'), + `the run reports an error finding: ${JSON.stringify(failedDoc.findings)}`, + ); + assert.deepEqual([...snapshot(distDir).entries()].sort(), before, 'the existing export is byte-untouched'); + assert.ok(fs.existsSync(path.join(viewerDir, 'index.html')), 'the internal chrome was regenerated regardless'); + assert.ok(fs.existsSync(path.join(viewerDir, 'assets')), 'the internal chrome carries its assets'); + + // The same holds under the other name, and for a target that does not exist yet. + fs.rmSync(distDir, { recursive: true, force: true }); + const other = await quiet(() => run(['viewer', 'build', '--root', dir, '--strict'])); + assert.equal(other.value, 1); + assert.ok(!fs.existsSync(distDir), 'nothing was written at all'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- the strict gate, driven by a real lint finding ---------------------------- + +test('strict: a lint finding is a warning by default and fails the run under --strict', async () => { + const dir = tmpCopy(path.join(fixturesDir, 'valid-unified-leji-fresh'), 'leji-export-lint-'); + // A real unsupported construct in one of the layer's own documents: the rendering + // lint reads the source the export carries, so the exit codes below are the gate's + // answer to a finding the shipped pipeline produced and not to a planted one. + const doc = path.join(dir, 'docs', 'domain', 'overview.md'); + fs.appendFileSync(doc, '\nA raw <span>element</span> in the prose.\n'); + + // Default run: the lint finding is reported and the export is written anyway — + // the layer's build never breaks on prose. + const plain = await quiet(() => run(['export', '--root', dir, '--json'])); + assert.equal(plain.value, 0, 'an ordinary run exports despite the lint finding'); + const plainDoc = JSON.parse(plain.stdout) as { + ok: boolean; + findings: { rule: string; severity: string; path?: string; line?: number; construct?: string }[]; + }; + assert.equal(plainDoc.ok, true); + assert.ok( + plainDoc.findings.some( + (f) => + f.rule === 'render-unsupported' && + f.severity === 'warning' && + f.path === 'docs/domain/overview.md' && + f.line === 5 && + f.construct === 'raw-html', + ), + `the lint finding reached the pipeline: ${JSON.stringify(plainDoc.findings)}`, + ); + const distDir = path.join(dir, '.leji', 'dist'); + const before = [...snapshot(distDir).entries()].sort(); + assert.ok(before.length > 0, 'an export exists to be protected'); + + // Same layer, same finding, `--strict`: the run fails and the export it would have + // replaced is left exactly as it was. The chrome is removed first, so the assertion + // that it was regenerated can actually fail: after the default run above it exists + // already, and a strict gate moved ahead of regeneration would pass unnoticed. + const viewerDir = path.join(dir, '.leji', 'viewer'); + fs.rmSync(viewerDir, { recursive: true, force: true }); + const strict = await quiet(() => run(['export', '--root', dir, '--strict', '--json'])); + assert.equal(strict.value, 1, 'the lint class fails a strict run'); + const strictDoc = JSON.parse(strict.stdout) as { ok: boolean; findings: { rule: string }[] }; + assert.equal(strictDoc.ok, false); + assert.ok(strictDoc.findings.some((f) => f.rule === 'render-unsupported')); + assert.deepEqual([...snapshot(distDir).entries()].sort(), before, 'the existing export is byte-untouched'); + // The internal chrome is regenerated regardless: the no-write promise is the + // target's, per the pipeline order. + assert.ok(fs.existsSync(path.join(viewerDir, 'index.html')), 'the chrome was regenerated'); + assert.ok(fs.existsSync(path.join(viewerDir, 'assets')), 'the internal chrome carries its assets'); + + // One operation, two names: the gate answers the same under `viewer build`. + const other = await quiet(() => run(['viewer', 'build', '--root', dir, '--strict', '--json'])); + assert.equal(other.value, 1, 'the gate holds under the other name'); + assert.deepEqual([...snapshot(distDir).entries()].sort(), before, 'and still byte-untouched'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- canonical JSON on every path --------------------------------------------- + +test('canonical-json: a failure before the pipeline emits the export document, under both names', async () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-export-json-'))); + fs.writeFileSync(path.join(dir, 'leji.json'), '{ this is not a manifest\n'); + + const first = await quiet(() => run(['export', '--root', dir, '--json'])); + const second = await quiet(() => run(['viewer', 'build', '--root', dir, '--json'])); + assert.equal(first.value, 1); + assert.equal(second.value, 1); + // One document shape for every outcome of this command: the pre-pipeline failure + // is NOT reported in the generic `{command, ok, findings, summary}` envelope. + const doc = JSON.parse(first.stdout) as Record<string, unknown>; + assert.deepEqual(Object.keys(doc), ['command', 'ok', 'out', 'findings', 'warning']); + assert.equal(doc.command, 'export'); + assert.equal(doc.ok, false); + assert.equal(doc.out, path.join('.leji', 'dist')); + assert.ok( + (doc.findings as { severity: string }[]).some((f) => f.severity === 'error'), + `the unreadable manifest is reported: ${JSON.stringify(doc.findings)}`, + ); + assert.ok(typeof doc.warning === 'string' && (doc.warning as string).startsWith('This is your context layer')); + assert.equal(second.stdout, first.stdout, 'byte-identical under both names'); + + // A caller `--out` is reported as the caller wrote it, on the same shape. + const withOut = await quiet(() => run(['export', '--root', dir, '--out', 'site', '--json'])); + assert.equal(withOut.value, 1); + assert.equal((JSON.parse(withOut.stdout) as { out: string }).out, 'site'); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/packages/sdk/test/fsx.test.ts b/packages/sdk/test/fsx.test.ts new file mode 100644 index 0000000..b510f58 --- /dev/null +++ b/packages/sdk/test/fsx.test.ts @@ -0,0 +1,368 @@ +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { + guardRoot, + mkdirpGuarded, + openWriteGuarded, + renameGuarded, + resolvedWithinRoot, + rmGuarded, + verifiedTargetRead, + writeFileAtomicGuarded, + writeFileGuarded, +} from '../dist/lib/fsx.js'; +import { DIST_REL, WORK_REL } from '../dist/lib/layout.js'; + +// The write boundary at its own level: the strict within-root primitive, the rule +// `guardedWrite` applies through every convenience, and the verified read that +// decides what is standing at a target before anything acts on it. The canary suite +// pins the same rule through the commands; these pin the mechanism, so a port has a +// per-case oracle rather than an end-to-end one. + +/** A temp repository root, realpath-resolved (macOS hands out /var -> /private/var). */ +function repo(): string { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-fsx-'))); +} + +/** A destination outside any repository, for the escape cases. */ +function outside(): string { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-outside-'))); +} + +// --- the strict within-root primitive ---------------------------------------- + +test('resolvedWithinRoot: existing, absent, dangling, escaping, case-variant', () => { + const root = repo(); + fs.writeFileSync(path.join(root, 'file.md'), 'x\n'); + assert.equal(resolvedWithinRoot(root, path.join(root, 'file.md')), true, 'an existing file inside root'); + assert.equal(resolvedWithinRoot(root, path.join(root, 'not-yet', 'file.md')), true, 'a not-yet-created target'); + + const away = outside(); + fs.symlinkSync(path.join(away, 'gone.md'), path.join(root, 'dangling.md')); + assert.equal(resolvedWithinRoot(root, path.join(root, 'dangling.md')), false, 'a dangling link out of root'); + + fs.writeFileSync(path.join(away, 'real.md'), 'x\n'); + fs.symlinkSync(path.join(away, 'real.md'), path.join(root, 'escape.md')); + assert.equal(resolvedWithinRoot(root, path.join(root, 'escape.md')), false, 'a link resolving out of root'); + + fs.mkdirSync(path.join(root, 'dir')); + fs.symlinkSync(away, path.join(root, 'dir', 'up')); + assert.equal(resolvedWithinRoot(root, path.join(root, 'dir', 'up', 'new.md')), false, 'a symlinked ancestor'); + + // A `.LEJI/` spelling on a case-insensitive filesystem resolves to the directory + // the filesystem actually holds, which is what the `.leji/` rule then judges. + fs.mkdirSync(path.join(root, '.leji', 'dist'), { recursive: true }); + const variant = path.join(root, '.LEJI', 'dist', 'x.html'); + if (fs.existsSync(path.join(root, '.LEJI'))) { + const verdict = writeFileGuarded(root, variant, null, 'x'); + assert.equal(verdict.ok, false, 'a .LEJI/ spelling is judged as the .leji/ role it opens'); + assert.equal(verdict.role, 'dist'); + } + + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(away, { recursive: true, force: true }); +}); + +test('resolvedWithinRoot: an unreadable directory is unresolvable, and unresolvable is false', (t) => { + if (process.getuid?.() === 0) { + t.skip('running as root: a 0o000 directory is still traversable'); + return; + } + const root = repo(); + const closed = path.join(root, 'closed'); + fs.mkdirSync(closed); + fs.writeFileSync(path.join(closed, 'target.md'), 'x\n'); + fs.chmodSync(closed, 0o000); + try { + if (fs.existsSync(path.join(closed, 'target.md'))) { + t.skip('this platform allows traversal of a 0o000 directory'); + return; + } + assert.equal(resolvedWithinRoot(root, path.join(closed, 'target.md')), false, 'unresolvable fails closed'); + const verdict = writeFileGuarded(root, path.join(closed, 'target.md'), null, 'x'); + assert.equal(verdict.ok, false); + assert.equal(verdict.unresolvable, true, 'and the chokepoint refuses it as unresolvable'); + } finally { + fs.chmodSync(closed, 0o700); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +// --- the rule, through the conveniences --------------------------------------- + +test('the write rule: outside the repository is refused absolutely, whatever the role', () => { + const root = repo(); + const away = outside(); + fs.mkdirSync(path.join(root, '.leji'), { recursive: true }); + fs.symlinkSync(away, path.join(root, '.leji', 'dist')); + + const verdict = writeFileGuarded(root, path.join(root, DIST_REL, 'index.html'), DIST_REL, 'x'); + assert.equal(verdict.ok, false, 'an own-role target relocated out of the repository is refused'); + assert.equal(verdict.outsideRoot, true); + assert.deepEqual(fs.readdirSync(away), [], 'and nothing was written outside'); + + const cleared = rmGuarded(root, path.join(root, DIST_REL), DIST_REL); + assert.equal(cleared.outsideRoot, true, 'the clear is refused the same way'); + assert.ok(fs.existsSync(away), 'the out-of-tree directory still stands'); + + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(away, { recursive: true, force: true }); +}); + +test('the write rule: another role is refused, the own role passes, no role is content-only', () => { + const root = repo(); + fs.mkdirSync(path.join(root, WORK_REL), { recursive: true }); + + const crossed = writeFileGuarded(root, path.join(root, WORK_REL, 'stolen.md'), DIST_REL, 'x'); + assert.equal(crossed.ok, false, 'the export role may not write into the work role'); + assert.equal(crossed.role, 'work'); + assert.equal(fs.existsSync(path.join(root, WORK_REL, 'stolen.md')), false, 'nothing was written'); + + assert.equal(writeFileGuarded(root, path.join(root, DIST_REL, 'index.html'), DIST_REL, 'x').ok, true, 'own role'); + assert.equal(writeFileGuarded(root, path.join(root, 'overview.md'), null, 'x').ok, true, 'ordinary content'); + + const roleless = writeFileGuarded(root, path.join(root, DIST_REL, 'other.html'), null, 'x'); + assert.equal(roleless.ok, false, 'content has no legitimate .leji/ landing'); + assert.equal(roleless.role, 'dist'); + + const bare = writeFileGuarded(root, path.join(root, '.leji', 'loose.md'), DIST_REL, 'x'); + assert.equal(bare.ok, false, 'a file loose in .leji/ is not the export role'); + assert.equal(bare.role, 'loose.md', 'the role is the first segment under .leji/'); + const lejiItself = rmGuarded(root, path.join(root, '.leji'), DIST_REL); + assert.equal(lejiItself.ok, false, '.leji/ itself is never the export role'); + assert.equal(lejiItself.role, ''); + assert.equal(fs.existsSync(path.join(root, WORK_REL)), true, 'and the trust domain still stands'); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('the write rule: a parent symlinked out of root is caught before the file is created', () => { + const root = repo(); + const away = outside(); + fs.symlinkSync(away, path.join(root, 'redirect')); + const verdict = writeFileGuarded(root, path.join(root, 'redirect', 'planted.md'), null, 'x'); + assert.equal(verdict.ok, false); + assert.equal(verdict.outsideRoot, true); + assert.deepEqual(fs.readdirSync(away), [], 'the parent was not written through'); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(away, { recursive: true, force: true }); +}); + +test('the conveniences: exclusive create, mkdirp, rename, atomic write, guarded open', () => { + const root = repo(); + const away = outside(); + + const created = writeFileGuarded(root, path.join(root, 'leji.json'), null, '{}\n', { exclusive: true }); + assert.equal(created.ok, true); + const again = writeFileGuarded(root, path.join(root, 'leji.json'), null, '{"other":1}\n', { exclusive: true }); + assert.equal(again.ok, false); + assert.equal(again.exists, true, 'an existing target is its own verdict, never an overwrite'); + assert.equal(fs.readFileSync(path.join(root, 'leji.json'), 'utf8'), '{}\n', 'the bytes are untouched'); + + const made = mkdirpGuarded(root, path.join(root, DIST_REL, 'content'), DIST_REL); + assert.equal(made.ok, true); + assert.equal(made.ok && made.real, path.join(root, DIST_REL, 'content'), 'the checked resolved path comes back'); + + fs.symlinkSync(away, path.join(root, 'out')); + assert.equal(mkdirpGuarded(root, path.join(root, 'out', 'deep'), null).ok, false, 'mkdirp is guarded too'); + assert.deepEqual(fs.readdirSync(away), []); + + assert.equal( + renameGuarded(root, path.join(root, 'leji.json'), path.join(root, 'out', 'leji.json'), null).ok, + false, + 'a rename with an escaping destination is refused', + ); + assert.equal(fs.existsSync(path.join(root, 'leji.json')), true, 'and the source is still there'); + assert.equal(renameGuarded(root, path.join(root, 'leji.json'), path.join(root, 'moved.json'), null).ok, true); + + assert.equal(writeFileAtomicGuarded(root, path.join(root, 'ci.yml'), null, 'jobs:\n').ok, true); + assert.equal(fs.readFileSync(path.join(root, 'ci.yml'), 'utf8'), 'jobs:\n'); + assert.equal(fs.existsSync(path.join(root, 'ci.yml.leji-tmp')), false, 'the temp sibling is gone'); + assert.equal( + writeFileAtomicGuarded(root, path.join(root, 'out', 'ci.yml'), null, 'x').ok, + false, + 'an escaping atomic destination is refused', + ); + + const opened = openWriteGuarded(root, path.join(root, DIST_REL, 'assets', 'app.css'), DIST_REL, { mode: 0o644 }); + assert.equal(opened.ok, true); + if (opened.ok) { + fs.writeSync(opened.fd, 'body{}\n'); + fs.closeSync(opened.fd); + assert.equal(fs.readFileSync(opened.real, 'utf8'), 'body{}\n'); + } + const refusedOpen = openWriteGuarded(root, path.join(root, 'out', 'app.css'), null); + assert.equal(refusedOpen.ok, false); + assert.deepEqual(fs.readdirSync(away), [], 'nothing landed outside the repository'); + + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(away, { recursive: true, force: true }); +}); + +test('an exclusive create is decided on the standing entry, never on where it resolves', () => { + // O_EXCL on the RESOLVED path is not enough: a dangling symlink resolves to its + // missing destination, so resolving first would let `leji.json -> nowhere` create + // the file the link points at. ANY standing entry is `exists`, and nothing anywhere + // is created. + const root = repo(); + const away = outside(); + fs.mkdirSync(path.join(root, WORK_REL), { recursive: true }); + fs.writeFileSync(path.join(root, WORK_REL, 'private.json'), 'private\n'); + const target = path.join(root, 'leji.json'); + const bytes = '{"schemaVersion":"1.0"}\n'; + + const cases: { name: string; plant: () => void; landing: string }[] = [ + { + name: 'a dangling link to a contained path', + plant: () => fs.symlinkSync(path.join(root, 'missing.json'), target), + landing: path.join(root, 'missing.json'), + }, + { + name: 'a dangling link out of the repository', + plant: () => fs.symlinkSync(path.join(away, 'missing.json'), target), + landing: path.join(away, 'missing.json'), + }, + { + name: 'a link into another role', + plant: () => fs.symlinkSync(path.join(root, WORK_REL, 'planted.json'), target), + landing: path.join(root, WORK_REL, 'planted.json'), + }, + { + name: 'a link to a standing file in another role', + plant: () => fs.symlinkSync(path.join(root, WORK_REL, 'private.json'), target), + landing: target, // its destination stands already; the bytes are checked below + }, + { name: 'a directory', plant: () => fs.mkdirSync(target), landing: target }, + ]; + for (const c of cases) { + c.plant(); + const verdict = writeFileGuarded(root, target, null, bytes, { exclusive: true }); + assert.equal(verdict.ok, false, `${c.name}: refused`); + assert.equal(verdict.exists, true, `${c.name}: reported as an existing target`); + if (c.landing !== target) { + assert.equal(fs.existsSync(c.landing), false, `${c.name}: the link's destination was not created`); + } + fs.rmSync(target, { recursive: true, force: true }); + } + assert.equal( + fs.readFileSync(path.join(root, WORK_REL, 'private.json'), 'utf8'), + 'private\n', + "the other role's file was never written through", + ); + + // A standing regular file is the ordinary case, and its bytes stay as they were. + fs.writeFileSync(target, 'original\n'); + const overExisting = writeFileGuarded(root, target, null, bytes, { exclusive: true }); + assert.equal(overExisting.exists, true, 'an existing regular file is never overwritten'); + assert.equal(fs.readFileSync(target, 'utf8'), 'original\n'); + fs.rmSync(target); + + // Nothing standing: the resolved path is judged, its parents included, and created. + assert.equal(writeFileGuarded(root, target, null, bytes, { exclusive: true }).ok, true); + assert.equal(fs.readFileSync(target, 'utf8'), bytes); + assert.deepEqual(fs.readdirSync(away), [], 'nothing was created outside the repository at any point'); + assert.deepEqual(fs.readdirSync(path.join(root, WORK_REL)), ['private.json'], 'nor in another role'); + + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(away, { recursive: true, force: true }); +}); + +test('a refused write establishes no directory', () => { + const root = repo(); + fs.mkdirSync(path.join(root, WORK_REL), { recursive: true }); + const verdict = writeFileGuarded(root, path.join(root, WORK_REL, 'deep', 'nested', 'x.md'), DIST_REL, 'x'); + assert.equal(verdict.ok, false); + assert.equal(fs.existsSync(path.join(root, WORK_REL, 'deep')), false, 'no parent was created for a refused write'); + fs.rmSync(root, { recursive: true, force: true }); +}); + +// --- the verified read --------------------------------------------------------- + +test('verifiedTargetRead: absent, regular, dangling, socket, symlink to socket, directory', () => { + const root = repo(); + const target = path.join(root, 'leji-badge.svg'); + + assert.deepEqual(verifiedTargetRead(root, target, null).status, 'absent', 'nothing standing there'); + + fs.writeFileSync(target, 'svg\n'); + const regular = verifiedTargetRead(root, target, null); + assert.equal(regular.status, 'regular'); + assert.equal(regular.status === 'regular' && regular.bytes.toString('utf8'), 'svg\n'); + fs.rmSync(target); + + fs.symlinkSync(path.join(root, 'missing.svg'), target); + const dangling = verifiedTargetRead(root, target, null); + assert.equal(dangling.status, 'refused', 'a standing dangling link is never written through as absent'); + assert.equal(dangling.status === 'refused' && dangling.reason, 'unverifiable'); + fs.rmSync(target); + + const sock = path.join(root, 'sock'); + const server = net.createServer(); + server.listen(sock); + try { + const direct = verifiedTargetRead(root, sock, null); + assert.equal(direct.status, 'refused'); + assert.equal(direct.status === 'refused' && direct.reason, 'not-regular'); + fs.symlinkSync(sock, target); + const linked = verifiedTargetRead(root, target, null); + assert.equal(linked.status, 'refused', 'a link to a socket is settled on what it resolves to'); + assert.equal(linked.status === 'refused' && linked.reason, 'not-regular'); + fs.rmSync(target); + } finally { + server.close(); + fs.rmSync(sock, { force: true }); + } + + fs.mkdirSync(target); + const dir = verifiedTargetRead(root, target, null); + assert.equal(dir.status, 'refused'); + assert.equal(dir.status === 'refused' && dir.reason, 'not-regular'); + fs.rmSync(target, { recursive: true }); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('verifiedTargetRead: outside root, another role, and a parent symlinked out', () => { + const root = repo(); + const away = outside(); + fs.writeFileSync(path.join(away, 'real.svg'), 'svg\n'); + + const escaping = path.join(root, 'escape.svg'); + fs.symlinkSync(path.join(away, 'real.svg'), escaping); + const out = verifiedTargetRead(root, escaping, null); + assert.equal(out.status, 'refused'); + assert.equal(out.status === 'refused' && out.reason, 'outside-root'); + + fs.mkdirSync(path.join(root, WORK_REL), { recursive: true }); + fs.writeFileSync(path.join(root, WORK_REL, 'private.svg'), 'svg\n'); + const crossing = path.join(root, 'crossing.svg'); + fs.symlinkSync(path.join(root, WORK_REL, 'private.svg'), crossing); + const role = verifiedTargetRead(root, crossing, null); + assert.equal(role.status, 'refused'); + assert.equal(role.status === 'refused' && role.reason, 'other-role'); + assert.equal(verifiedTargetRead(root, crossing, WORK_REL).status, 'regular', 'its own role reads through'); + + fs.symlinkSync(away, path.join(root, 'redirect')); + const parent = verifiedTargetRead(root, path.join(root, 'redirect', 'real.svg'), null); + assert.equal(parent.status, 'refused'); + assert.equal(parent.status === 'refused' && parent.reason, 'outside-root'); + + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(away, { recursive: true, force: true }); +}); + +test('guardRoot resolves a root reached through a symlinked ancestor', () => { + const root = repo(); + const parent = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-link-'))); + const link = path.join(parent, 'repo'); + fs.symlinkSync(root, link); + assert.equal(guardRoot(link), root, 'both sides of the rule come through one resolver'); + assert.equal(writeFileGuarded(guardRoot(link), path.join(link, 'x.md'), null, 'x').ok, true); + assert.equal(fs.readFileSync(path.join(root, 'x.md'), 'utf8'), 'x'); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(parent, { recursive: true, force: true }); +}); diff --git a/packages/sdk/test/help.test.ts b/packages/sdk/test/help.test.ts new file mode 100644 index 0000000..5dbe472 --- /dev/null +++ b/packages/sdk/test/help.test.ts @@ -0,0 +1,247 @@ +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 { + type ConformanceResult, + SDK_VERSION, + buildWritePlan, + loadCliSpec, + renderCommandHelp, + renderDetect, + renderExplain, + renderUsage, + run, +} from '../dist/index.js'; +import { exitCodeColumn, helpRow, nameColumn, optionColumn, wrap } from '../dist/lib/text.js'; + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const goldensDir = path.join(repoRoot, 'fixtures', 'help-goldens'); +const golden = (name: string) => fs.readFileSync(path.join(goldensDir, name), 'utf8'); +const goldenName = (command: string) => command.replace(/ /g, '-') + '.txt'; +const exampleDir = path.join(repoRoot, 'examples', 'monorepo'); + +/** U+2013 and U+2014: the house rule is that no line the CLI prints carries either. */ +const DASHES = /[–—]/; + +async function stdoutOf<T>(fn: () => T | Promise<T>): Promise<string> { + const chunks: string[] = []; + const log = console.log; + const err = console.error; + console.log = (...a: unknown[]) => void chunks.push(a.map(String).join(' ') + '\n'); + console.error = () => {}; + try { + await fn(); + return chunks.join(''); + } finally { + console.log = log; + console.error = err; + } +} + +// --- cli.json integrity: the grouping four consumers read --------------------- + +test('cli.json: group ids are unique, every command is in one known group, aliases resolve', () => { + const spec = loadCliSpec(); + const ids = spec.groups.map((g) => g.id); + assert.deepEqual([...new Set(ids)], ids, 'group ids are unique'); + for (const g of spec.groups) assert.ok(g.title.length > 0, `${g.id} has a title`); + const names = new Set(spec.commands.map((c) => c.name)); + for (const c of spec.commands) { + assert.ok(typeof c.group === 'string' && ids.includes(c.group), `${c.name} names a declared group`); + if (c.aliasOf === undefined) continue; + assert.ok(names.has(c.aliasOf), `${c.name} aliases an existing command`); + const primary = spec.commands.find((p) => p.name === c.aliasOf); + assert.equal(primary!.aliasOf, undefined, `${c.name} aliases a primary, never another alias`); + } +}); + +// --- the wrapper -------------------------------------------------------------- + +test('wrap: collapses whitespace, hangs continuations, and never breaks an oversized token', () => { + assert.deepEqual(wrap(' one two ', 20, 0, 0), ['one two']); + assert.deepEqual(wrap('', 20, 0, 0), []); + assert.deepEqual(wrap('alpha beta gamma delta', 16, 0, 3), ['alpha beta gamma', ' delta']); + // The continuation indent counts against the width, not only the first line. + assert.deepEqual(wrap('alpha beta gamma delta', 16, 0, 8), ['alpha beta gamma', ' delta']); + // A token wider than the line takes a line of its own rather than being split: + // a URL or a flag spelling stays copyable. + assert.deepEqual(wrap('see https://leji.org/cli/#mounts-update-pin now', 20, 0, 3), [ + 'see', + ' https://leji.org/cli/#mounts-update-pin', + ' now', + ]); +}); + +test('the top-level Usage line goes through the wrapper (shared long-usage vector)', () => { + // No current command is long enough to wrap this line, so the contract is pinned on + // a vector instead: every emitted field passes the wrapper, never just the ones the + // data happens to overflow today. + const usage = + 'Usage: leji mounts update-pin <name> [--to <oid>] [--allow-non-fast-forward] [--fetch] [--dry-run] [--root <dir>] [--json]'; + assert.equal(wrap(usage, 80, 0, 7).join('\n') + '\n', golden('wrap-long-usage.txt')); + assert.match(renderUsage(), /\nUsage: leji <command> \[options\]\n/); +}); + +test('a label wider than its column takes the line alone (shared overlong-label vector)', () => { + const label = '--allow-non-fast-forward-with-a-very-long-spelling <oid>'; + const summary = + 'Permit a target that is not a descendant of the current pin, in the one spelling long enough to outgrow its column.'; + assert.equal(helpRow(label, 23, summary).join('\n') + '\n', golden('row-overlong-label.txt')); + // The clamp is what makes an overlong label reachable: past 27 characters the flag + // outgrows its own column. + assert.equal(optionColumn([label]), 33); + // A label that exactly fills the column would leave no gap, so it takes the line too. + assert.deepEqual(helpRow('--exactly-here', 17, 'summary'), [' --exactly-here', ' summary']); +}); + +test('a row pads by code points, not UTF-16 units (shared non-BMP row vector)', () => { + // Two U+1F600 in the label: padding by UTF-16 units leaves the row two columns short + // and misaligns every summary in the block. + const label = '--emoji-\u{1F600}\u{1F600} <value>'; + const summary = 'A flag carrying astral characters, so a column padded in UTF-16 units misaligns this row by two.'; + assert.equal(helpRow(label, 23, summary).join('\n') + '\n', golden('row-non-bmp.txt')); +}); + +test('every dynamic label class resolves a bounded, code-point column', () => { + assert.equal(optionColumn(['--json']), 23); // below the floor: [20, 30] + assert.equal(optionColumn(['--a-flag-of-thirty-plus-characters <value>']), 33); // above the ceiling + assert.equal(nameColumn(['leji']), 15); // below the floor: [12, 30] + assert.equal(nameColumn(['a-command-name-long-enough-to-outgrow-its-bounded-column']), 33); + assert.equal(exitCodeColumn(['0']), 6); // below the floor: [3, 8] + assert.equal(exitCodeColumn(['0', '127']), 8); + // Code points, not UTF-16 units: an astral label sizes its column by what it prints. + assert.equal(optionColumn(['--emoji-\u{1F600}\u{1F600} <value>']), 24); // 18 code points, not 20 UTF-16 units +}); + +test('the bounds hold through the renderers, on the shared synthetic spec', () => { + // Rendered, not just computed: a bound that the column helper honors and the renderer + // bypasses is exactly the defect this pins. The spec pushes every class past its + // bound at once. + const spec = JSON.parse(fs.readFileSync(path.join(goldensDir, 'bounds-spec.json'), 'utf8')); + assert.equal(renderUsage(spec) + '\n', golden('bounds-usage.txt').replace('{{version}}', SDK_VERSION)); + const long = 'a-command-name-long-enough-to-outgrow-its-bounded-column'; + assert.equal(renderCommandHelp(long, spec) + '\n', golden('bounds-command.txt')); +}); + +test('wrap: measures width in code points, not UTF-16 units (the shared non-BMP vector)', () => { + // Documented in fixtures/README.md: four U+1F600, two spaces, three ASCII words, + // width 20, first line indented 0 and continuations 3. Measuring the emoji run as + // 8 UTF-16 units instead of 4 code points breaks the line one word early. + const input = '\u{1F600}\u{1F600}\u{1F600}\u{1F600} alphabet six666 tail'; + assert.equal(wrap(input, 20, 0, 3).join('\n') + '\n', golden('wrap-non-bmp.txt')); +}); + +// --- the goldens -------------------------------------------------------------- + +test('help goldens: every rendered help matches its committed bytes', () => { + const spec = loadCliSpec(); + assert.equal(renderUsage() + '\n', golden('usage.txt').replace('{{version}}', SDK_VERSION)); + for (const c of spec.commands) { + assert.equal(renderCommandHelp(c.name) + '\n', golden(goldenName(c.name)), c.name); + } + // And nothing committed is orphaned: every golden is one of the surfaces above. + const expected = new Set([ + 'usage.txt', + 'wrap-non-bmp.txt', + 'wrap-long-usage.txt', + 'row-overlong-label.txt', + 'row-non-bmp.txt', + 'bounds-spec.json', + 'bounds-usage.txt', + 'bounds-command.txt', + ...spec.commands.map((c) => goldenName(c.name)), + ]); + assert.deepEqual(new Set(fs.readdirSync(goldensDir)), expected); +}); + +test("help goldens: per-command help lists the command's own options and points at the globals", () => { + const spec = loadCliSpec(); + const globals = spec.globalOptions.map((o) => o.flags); + // Examples are commands to copy, never prose: they are printed as authored, so + // the width contract covers everything above them. + const prose = (help: string) => help.split('\nExamples:\n')[0].split('\n'); + for (const c of spec.commands) { + const help = renderCommandHelp(c.name)!; + assert.match(help, /\nGlobal options: see leji --help\.\n/, c.name); + for (const g of globals) assert.ok(!help.includes(` ${g}`), `${c.name} does not repeat ${g}`); + for (const o of c.options) assert.ok(help.includes(` ${o.flags}`), `${c.name} lists ${o.flags}`); + assert.ok( + prose(help).every((l) => [...l].length <= 80), + `${c.name} wraps at 80 code points`, + ); + } + assert.ok( + renderUsage() + .split('\n') + .every((l) => [...l].length <= 80), + 'top-level help wraps at 80 code points', + ); +}); + +// --- the em-dash house rule, checked on the bytes the CLI prints -------------- + +test('help output carries no em or en dash', () => { + for (const file of fs.readdirSync(goldensDir)) { + assert.ok(!DASHES.test(golden(file)), `${file} carries an em or en dash`); + } +}); + +test('cli.json carries no em or en dash', () => { + const raw = fs.readFileSync(path.join(pkgRoot, 'cli.json'), 'utf8'); + assert.ok(!DASHES.test(raw)); +}); + +test('the CLI prose branches carry no em or en dash', async () => { + // detect's host lines: synthetic hosts, so the branch runs wherever the suite does. + const detect = renderDetect({ + hosts: [ + { + id: 'codex', + name: 'Codex CLI', + strength: 'confirmed', + onPath: true, + inRepo: true, + userConfig: false, + adapter: 'AGENTS.md', + }, + ], + ecosystem: { selected: null, all: [], reason: 'none' }, + }); + assert.match(detect, /Codex CLI: binary on PATH/); + assert.ok(!DASHES.test(detect)); + + // conformance --explain's blocker details, likewise: the detail branch needs a + // blocker that carries one, which a passing layer does not produce. + const result: ConformanceResult = { + claimedLevel: 'core', + verifiedLevel: 'core', + processAttested: 0, + items: [ + { + id: 'index-current', + level: 'indexed', + description: 'a generated context index, current with the tree', + status: 'fail', + detail: 'the stored index is stale', + }, + ], + findings: [], + }; + const explain = renderExplain(result); + assert.match(explain, /- a generated context index, current with the tree: the stored index is stale/); + assert.ok(!DASHES.test(explain)); + + // The conformance checklist's own detail column, from a real run. + const checklist = await stdoutOf(() => run(['conformance', '--root', exampleDir])); + assert.match(checklist, /freshness horizons are declared and checked \(report-only is acceptable\): /); + assert.ok(!DASHES.test(checklist)); + + // The write plan's read-only note is library data rather than a printed line, so + // it is asserted where it is produced. + const plan = buildWritePlan(exampleDir, [], ['README.md']); + assert.equal(plan[0].note, 'existing file, read-only input; Leji will not modify it'); + assert.ok(!DASHES.test(plan.map((e) => e.note ?? '').join('\n'))); +}); diff --git a/packages/sdk/test/inherits.test.ts b/packages/sdk/test/inherits.test.ts index a28774f..dbd0a2e 100644 --- a/packages/sdk/test/inherits.test.ts +++ b/packages/sdk/test/inherits.test.ts @@ -544,7 +544,7 @@ test('viewer: an inheriting profile renders resolved, naming both sources', () = assert.ok(page!.includes('docs/agents/core.md'), 'the base source is named'); assert.ok(page!.includes('docs/agents/thought-partner.md'), 'the derived source is named'); // Posture entries are labelled with the profile that supplied them. - assert.ok(page!.includes('`docs/system/invariants.md` — from `core`'), page!); + assert.ok(page!.includes('`docs/system/invariants.md` (from `core`)'), page!); assert.ok(page!.includes('from `thought-partner`'), page!); // A profile with no inherits is served from disk as authored. assert.equal(resolvedProfilePage(dir, manifest!, 'docs/agents/core.md'), null); diff --git a/packages/sdk/test/localcli.test.ts b/packages/sdk/test/localcli.test.ts new file mode 100644 index 0000000..80eb03b --- /dev/null +++ b/packages/sdk/test/localcli.test.ts @@ -0,0 +1,607 @@ +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 { effectiveRoot, run } from '../dist/index.js'; +import { + type LaunchIo, + type LaunchOutcome, + type LocalCliHandoff, + launchLocalCli, + resolveLocalCli, +} from '../dist/lib/localcli.js'; + +// The hand-off decision and the launch that follows it, unit level: the resolver over +// the committed `fixtures/handoff/` family with the installed state written here, and +// the launcher over an injected spawn, so every row of its result table is provable +// without ending this process. The end-to-end proof (real argv through a real child) +// is `test/proc/handoff.test.ts`. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(testDir, '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const fixturesDir = path.join(repoRoot, 'fixtures', 'handoff'); +const realCli = path.join(pkgRoot, 'dist', 'cli.js'); + +/** The platform this suite runs on, for the cases whose target is the POSIX shim. + * The Windows branch is exercised by passing `win32` explicitly, since it resolves a + * declared entry rather than asking the filesystem for an executable bit. */ +const HOST: NodeJS.Platform = process.platform; + +/** A self entry no fixture can ever be: the recursion guard is asserted with the + * real one in its own case. */ +const NOT_SELF = path.join(os.tmpdir(), 'leji-not-the-running-entry.js'); + +function tmpdir(prefix = 'leji-handoff-'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** + * The marker the hand-off must reach. It prints its own argv JSON-ENCODED, so + * argument boundaries are provable rather than inferred from a joined string, and + * exits 3, a status no leji command returns, so exit forwarding is observable. + */ +const MARKER = + "#!/usr/bin/env node\nprocess.stdout.write('handoff:node:' + JSON.stringify(process.argv.slice(2)) + '\\n');\nprocess.exit(3);\n"; + +/** + * The shapes a package manager's bin shim actually takes. npm, bun and Yarn's + * node-modules linker install a SYMLINK to the package entry; pnpm installs a small + * SCRIPT that runs it. The difference is the whole reason identity is resolved from + * the package's own entry rather than from what gets executed: only the symlink's + * realpath is the entry, so a guard comparing the shim leaves the script shape open + * to an unbounded loop. + */ +type ShimShape = 'symlink' | 'script'; + +interface InstallOptions { + /** The installed package's declared version. */ + version?: string; + /** Its declared identity. */ + name?: string; + /** Its `bin` field, as JSON. */ + bin?: unknown; + /** Raw metadata bytes, for the malformed cases. */ + metadata?: string; + /** The marker is a symlink to the RUNNING entry: the recursion case. */ + selfEntry?: boolean; + /** How the manager's shim reaches the entry. */ + shim?: ShimShape; +} + +/** The state a committed fixture cannot carry: `node_modules` is never committed, so + * the installed package, its entry, and the manager's bin shim are written here, + * statically, exactly as an install would leave them. */ +function install(dir: string, opts: InstallOptions = {}): string { + const pkgDir = path.join(dir, 'node_modules', '@leji-org', 'leji'); + const entry = path.join(pkgDir, 'dist', 'cli.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + if (opts.selfEntry === true) fs.symlinkSync(realCli, entry); + else fs.writeFileSync(entry, MARKER, { mode: 0o755 }); + const metadata = + opts.metadata ?? + JSON.stringify( + { + name: opts.name ?? '@leji-org/leji', + version: opts.version ?? '1.4.0', + bin: 'bin' in opts ? opts.bin : { leji: 'dist/cli.js' }, + }, + null, + 2, + ) + '\n'; + fs.writeFileSync(path.join(pkgDir, 'package.json'), metadata); + installShim(dir, entry, opts.shim ?? 'symlink'); + return pkgDir; +} + +/** The manager's shim, in the shape that manager installs. */ +function installShim(dir: string, entry: string, shape: ShimShape): string { + const binDir = path.join(dir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const shim = path.join(binDir, 'leji'); + fs.rmSync(shim, { force: true }); + if (shape === 'symlink') { + fs.symlinkSync(path.relative(binDir, entry), shim); + } else { + // pnpm's shape, reduced to what matters here: a real file that runs the entry. + fs.writeFileSync(shim, `#!/bin/sh\nexec node "$(dirname "$0")/${path.relative(binDir, entry)}" "$@"\n`, { + mode: 0o755, + }); + } + return shim; +} + +/** + * One case of the family: the committed miniature repository copied out, plus the + * installed state its name declares. Every fixture is seeded by a file copy and a + * write here; nothing is produced by running a CLI. + */ +function seed(name: string): string { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, name), dir, { recursive: true }); + switch (name) { + case 'node-eligible': + case 'node-undeclared': + case 'node-ambiguous-manager': + case 'node-unsupported-manager': + case 'node-unknown-spec-line': + case 'node-shim-symlink': + case 'node-shim-yarn': + case 'polyglot': + install(dir); + break; + case 'node-shim-script': + install(dir, { shim: 'script' }); + break; + case 'node-self': + install(dir); + break; + case 'node-declared-missing': + case 'go-tool': + break; // nothing is installed at all + case 'node-below-minimum': + install(dir, { version: '0.9.3' }); + break; + case 'node-malformed-version': + install(dir, { version: '1.x' }); + break; + case 'node-wrong-identity': + install(dir, { name: '@example/leji' }); + break; + case 'node-malformed-metadata': + install(dir, { metadata: '{ this is not json\n' }); + break; + case 'node-entry-not-regular': + // A declared entry that is a directory. The entry is the copy's identity on + // EVERY platform, so this is refused everywhere, not only where it is run. + install(dir, { bin: { leji: 'dist' } }); + break; + case 'node-metadata-not-regular': { + const pkgDir = install(dir); + fs.rmSync(path.join(pkgDir, 'package.json')); + fs.mkdirSync(path.join(pkgDir, 'package.json')); + break; + } + case 'node-escaped': { + // The whole package directory is a link out of the repository: a copy that + // is not installed HERE, whatever the spelling says. + const outside = tmpdir('leji-handoff-outside-'); + install(outside); + const scope = path.join(dir, 'node_modules', '@leji-org'); + fs.mkdirSync(scope, { recursive: true }); + fs.symlinkSync(path.join(outside, 'node_modules', '@leji-org', 'leji'), path.join(scope, 'leji')); + break; + } + case 'node-refused': { + // The manifest that gates the ecosystem resolves outside the repository, so + // the evidence is refused and nothing about this root is decided from it. + const outside = tmpdir('leji-handoff-outside-'); + fs.writeFileSync(path.join(outside, 'package.json'), '{ "devDependencies": { "@leji-org/leji": "^1" } }\n'); + fs.symlinkSync(path.join(outside, 'package.json'), path.join(dir, 'package.json')); + install(dir); + break; + } + default: + throw new Error(`unseeded fixture ${name}`); + } + return dir; +} + +/** The resolver as the executable calls it, with the root named explicitly (the + * `--root` path is what a nested cwd would otherwise decide). */ +function resolveIn(dir: string, extra: string[] = [], env: NodeJS.ProcessEnv = {}, platform = HOST) { + return resolveLocalCli(['--root', dir, ...extra], env, platform, NOT_SELF); +} + +// --- the decision table ------------------------------------------------------ + +/** Every fixture whose answer is the same on both platforms, with the reason the + * hand-off is or is not made. */ +const DECISIONS: ReadonlyArray<[string, boolean, string]> = [ + ['node-eligible', true, 'declared, installed inside the root, and at the minimum'], + ['node-ambiguous-manager', true, 'two lockfile families: the manager is not what is being run'], + ['node-unsupported-manager', true, 'an unknown packageManager: the target is the package, not the manager'], + ['polyglot', true, "a Python repository too, decided on Node's own record"], + ['node-undeclared', false, 'installed but not declared: the repository never asked for it'], + ['node-declared-missing', false, 'declared but nothing is installed'], + ['node-below-minimum', false, 'the installed major is under the layer minimum'], + ['node-escaped', false, 'the package directory resolves outside the repository'], + ['node-refused', false, 'the ecosystem evidence itself was refused'], + ['node-wrong-identity', false, 'a directory spelled like the package is not the package'], + ['node-malformed-metadata', false, 'the metadata does not parse'], + ['node-malformed-version', false, 'the version does not parse'], + ['node-metadata-not-regular', false, 'the metadata is not a regular file'], + ['node-unknown-spec-line', false, 'the layer declares a spec line this SDK has no minimum for'], + ['go-tool', false, 'a Go repository has no Node record to decide on'], + ['node-shim-symlink', true, "npm and bun's shape: the shim is a symlink to the entry"], + ['node-shim-script', true, "pnpm's shape: the shim is a script that runs the entry"], + ['node-shim-yarn', true, "Yarn's node-modules linker: a symlink, like npm's"], +]; + +for (const [name, handoff, why] of DECISIONS) { + test(`resolve: ${name} ${handoff ? 'hands off' : 'runs the global'} (${why})`, () => { + const dir = seed(name); + const resolved = resolveIn(dir); + assert.equal(resolved.kind, handoff ? 'handoff' : 'none'); + if (resolved.kind === 'handoff') { + assert.equal(resolved.display, 'node_modules/.bin/leji'); + assert.deepEqual(resolved.args, ['--root', dir]); + } + }); +} + +test('resolve: the package ENTRY is never the running entry (no recursion)', () => { + // A repository whose installed copy IS the executable now running: handing off + // would run it again, forever. Identity is the package's own entry, so the guard + // holds whichever shape the shim in front of it has. + for (const shim of ['symlink', 'script'] as const) { + const dir = seed('node-eligible'); + install(dir, { shim }); + const self = fs.realpathSync.native(path.join(dir, 'node_modules', '@leji-org', 'leji', 'dist', 'cli.js')); + assert.equal(resolveLocalCli(['--root', dir], {}, HOST, self).kind, 'none', shim); + assert.equal(resolveLocalCli(['--root', dir], {}, 'win32', self).kind, 'none', shim); + // The same tree with a different self hands off, so the refusal above is the + // recursion guard and not the fixture being ineligible for another reason. + assert.equal(resolveIn(dir).kind, 'handoff', shim); + } +}); + +test('resolve: a script shim is executed, and the entry behind it is what identity compares', () => { + const dir = seed('node-shim-script'); + const shim = path.join(dir, 'node_modules', '.bin', 'leji'); + // The shim is a regular file, NOT a link: its own realpath is itself, so a guard + // comparing what gets executed would never recognize the copy behind it. + assert.equal(fs.lstatSync(shim).isSymbolicLink(), false); + const entryReal = fs.realpathSync.native(path.join(dir, 'node_modules', '@leji-org', 'leji', 'dist', 'cli.js')); + assert.notEqual(fs.realpathSync.native(shim), entryReal); + const resolved = resolveIn(dir); + assert.equal(resolved.kind, 'handoff'); + if (resolved.kind === 'handoff') assert.equal(resolved.bin, shim); + assert.equal(resolveLocalCli(['--root', dir], {}, HOST, entryReal).kind, 'none'); +}); + +test('resolve: a package that declares no usable entry hands off nothing', () => { + // Identity is the entry, so a copy whose entry cannot be resolved cannot be + // proved distinct from this running one: fail closed, on every platform. + for (const bin of [null, { leji: 'dist' }, { leji: '../../../elsewhere.js' }, {}, 'dist']) { + const dir = seed('node-eligible'); + install(dir, { bin }); + assert.equal(resolveIn(dir).kind, 'none', JSON.stringify(bin)); + assert.equal(resolveLocalCli(['--root', dir], {}, 'win32', NOT_SELF).kind, 'none', JSON.stringify(bin)); + } + // The plain string form is an entry like any other, and it is accepted. + const ok = seed('node-eligible'); + install(ok, { bin: 'dist/cli.js' }); + assert.equal(resolveIn(ok).kind, 'handoff'); +}); + +test('resolve: an unresolvable running entry refuses the hand-off', () => { + const dir = seed('node-eligible'); + assert.equal(resolveLocalCli(['--root', dir], {}, HOST, null).kind, 'none'); +}); + +test('resolve: LEJI_NO_LOCAL at any value refuses, and only its presence counts', () => { + const dir = seed('node-eligible'); + for (const value of ['', '0', '1', 'no']) { + assert.equal(resolveIn(dir, [], { LEJI_NO_LOCAL: value }).kind, 'none', `LEJI_NO_LOCAL=${value}`); + } + assert.equal(resolveIn(dir, [], { LEJI_NO_LOCAL_OTHER: '1' }).kind, 'handoff'); +}); + +test('resolve: a nested cwd is not the root, and no scan walks up', () => { + const dir = seed('node-eligible'); + const nested = path.join(dir, 'docs', 'context'); + fs.mkdirSync(nested, { recursive: true }); + assert.equal(resolveLocalCli(['--root', nested], {}, HOST, NOT_SELF).kind, 'none'); +}); + +test('resolve: argv is forwarded verbatim, tokens after -- included', () => { + const dir = seed('node-eligible'); + const argv = ['start', '--root', dir, '--json', '--', '--root', 'ignored', '', ' ', 'ünïcødé']; + const resolved = resolveLocalCli(argv, {}, HOST, NOT_SELF); + assert.equal(resolved.kind, 'handoff'); + if (resolved.kind !== 'handoff') return; + assert.deepEqual(resolved.args, argv); + assert.notEqual(resolved.bin, process.execPath); // the shim, executed as a file +}); + +test('resolve: a malformed root selects nothing', () => { + const dir = seed('node-eligible'); + for (const argv of [['--root'], ['--root', '--json'], ['--root', ''], ['--name', '--root', dir]]) { + assert.equal(resolveLocalCli(argv, {}, HOST, NOT_SELF).kind, 'none', argv.join(' ')); + } +}); + +// --- the Windows branch ------------------------------------------------------ +// The `.cmd` shim cannot be executed without a shell, and this tool passes argv and +// never a command line, so the target there is the package's declared entry run by +// this Node. The resolution is pure path work, so it is provable on any platform. + +test('resolve (win32): the declared entry runs under this Node, inside the package', () => { + const dir = seed('node-eligible'); + const resolved = resolveLocalCli(['--root', dir], {}, 'win32', NOT_SELF); + assert.equal(resolved.kind, 'handoff'); + if (resolved.kind !== 'handoff') return; + assert.equal(resolved.bin, process.execPath); + assert.deepEqual(resolved.args, [ + path.join(dir, 'node_modules', '@leji-org', 'leji', 'dist', 'cli.js'), + '--root', + dir, + ]); + assert.equal(resolved.display, 'node_modules/@leji-org/leji/dist/cli.js'); +}); + +test('resolve (win32): a bin string, not only a map, names the entry', () => { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, 'node-eligible'), dir, { recursive: true }); + install(dir, { bin: 'dist/cli.js' }); + const resolved = resolveLocalCli(['--root', dir], {}, 'win32', NOT_SELF); + assert.equal(resolved.kind, 'handoff'); +}); + +test('an entry that is not a regular file is refused on every platform', () => { + // The entry is the identity of the copy now, so POSIX refuses it too: a copy whose + // entry cannot be resolved cannot be proved distinct from this running one. + const dir = seed('node-entry-not-regular'); + assert.equal(resolveLocalCli(['--root', dir], {}, 'win32', NOT_SELF).kind, 'none'); + assert.equal(resolveLocalCli(['--root', dir], {}, HOST, NOT_SELF).kind, 'none'); +}); + +test('resolve (win32): an entry escaping the package directory is refused', () => { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, 'node-eligible'), dir, { recursive: true }); + install(dir, { bin: { leji: '../../../elsewhere.js' } }); + fs.writeFileSync(path.join(dir, 'elsewhere.js'), MARKER, { mode: 0o755 }); + assert.equal(resolveLocalCli(['--root', dir], {}, 'win32', NOT_SELF).kind, 'none'); +}); + +test('resolve (win32): a missing bin field is refused', () => { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, 'node-eligible'), dir, { recursive: true }); + install(dir, { bin: null }); + assert.equal(resolveLocalCli(['--root', dir], {}, 'win32', NOT_SELF).kind, 'none'); +}); + +test('resolve: package metadata past the read bound is refused', () => { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, 'node-eligible'), dir, { recursive: true }); + const padded = JSON.stringify({ + name: '@leji-org/leji', + version: '1.4.0', + bin: { leji: 'dist/cli.js' }, + pad: 'x'.repeat(65 * 1024), + }); + install(dir, { metadata: padded }); + assert.equal(resolveIn(dir).kind, 'none'); +}); + +// --- unreadable eligibility state -------------------------------------------- +// Resolution runs BEFORE `run()` and outside its error handling, so anything that +// throws here would reach the user as a stack trace where the global CLI was meant +// to run. Every read is total: refusal and unreadability are both no hand-off. + +test('resolve: an unreadable installed package.json runs the global, silently', (t) => { + const dir = seed('node-eligible'); + const metadata = path.join(dir, 'node_modules', '@leji-org', 'leji', 'package.json'); + fs.chmodSync(metadata, 0o000); + try { + if (fs.readFileSync(metadata).length >= 0) { + t.skip('this user can read a 0o000 file (root); the permission case cannot be built here'); + return; + } + } catch { + /* expected: the file is unreadable, which is the case under test */ + } + assert.equal(resolveIn(dir).kind, 'none'); +}); + +test('resolve: an unreadable spec line runs the global, silently', (t) => { + const dir = seed('node-eligible'); + const manifest = path.join(dir, 'leji.json'); + fs.chmodSync(manifest, 0o000); + try { + if (fs.readFileSync(manifest).length >= 0) { + t.skip('this user can read a 0o000 file (root)'); + return; + } + } catch { + /* expected */ + } + assert.equal(resolveIn(dir).kind, 'none'); +}); + +test('resolve: a leji.json that is a directory runs the global, silently', () => { + const dir = seed('node-eligible'); + const manifest = path.join(dir, 'leji.json'); + fs.rmSync(manifest); + fs.mkdirSync(manifest); + assert.equal(resolveIn(dir).kind, 'none'); +}); + +test('resolve: a spec line read through a link out of the repository is refused', () => { + const dir = seed('node-eligible'); + const outside = tmpdir('leji-handoff-outside-'); + fs.writeFileSync(path.join(outside, 'leji.json'), '{"leji":"1.0"}\n'); + fs.rmSync(path.join(dir, 'leji.json')); + fs.symlinkSync(path.join(outside, 'leji.json'), path.join(dir, 'leji.json')); + assert.equal(resolveIn(dir).kind, 'none'); +}); + +test('resolve: a spec line past the read bound, or not a string, is refused', () => { + for (const body of [ + JSON.stringify({ leji: '1.0', pad: 'x'.repeat(65 * 1024) }), + JSON.stringify({ leji: 1 }), + JSON.stringify(['1.0']), + '{ not json', + ]) { + const dir = seed('node-eligible'); + fs.writeFileSync(path.join(dir, 'leji.json'), body); + assert.equal(resolveIn(dir).kind, 'none', body.slice(0, 24)); + } +}); + +test('resolve: the spec line is the only thing the wrapper asks of the manifest', () => { + // A manifest that would fail validation still names a spec line, and the hand-off + // is about which CLI answers, not about whether the layer is valid: the CLI that + // runs reports that, as it does today. + const dir = seed('node-eligible'); + fs.writeFileSync(path.join(dir, 'leji.json'), JSON.stringify({ leji: '1.0' }) + '\n'); + assert.equal(resolveIn(dir).kind, 'handoff'); +}); + +// --- the effective root ------------------------------------------------------ + +test('effectiveRoot: the pinned cases', () => { + assert.equal(effectiveRoot([]), '.'); + assert.equal(effectiveRoot(['validate']), '.'); + assert.equal(effectiveRoot(['--root', 'x']), 'x'); + assert.equal(effectiveRoot(['--root=x']), 'x'); + assert.equal(effectiveRoot(['--root', 'a', '--root', 'b']), 'b', 'last --root wins'); + assert.equal(effectiveRoot(['--root=a', '--root', 'b']), 'b'); + assert.equal(effectiveRoot(['--json', '--root', 'x', 'validate']), 'x'); + assert.equal(effectiveRoot(['--topics', 'a b', '--root', 'x']), 'x', 'a value flag consumes its own value'); + assert.equal(effectiveRoot(['--root', '-']), '-', 'a bare dash is a value, not a flag'); + assert.equal(effectiveRoot(['--', '--root', 'x']), '.', 'tokens after -- are not our flags'); + assert.equal(effectiveRoot(['--root', 'x', '--', '--root', 'y']), 'x'); + assert.equal(effectiveRoot(['--root']), null, 'a missing value decides nothing'); + assert.equal(effectiveRoot(['--root', '--json']), null, 'a flag-looking value decides nothing'); + assert.equal(effectiveRoot(['--root', '']), null, 'an empty value is the usage error'); + assert.equal(effectiveRoot(['--name', '--root', 'x']), null, 'a malformed sequence decides nothing'); + assert.equal(effectiveRoot(['--root=']), null); +}); + +/** + * The drift test. `parseFlags` is not exported, so the root it lands on is asserted + * where it is observable: WHICH repository the command answered about. One root + * carries a layer and the other does not, so a parse that landed on the other root + * cannot produce the same exit code. Every pinned case whose argv is a valid + * `validate` invocation is run this way; the ones that are usage errors assert the + * usage exit, which is what a null effective root means. + */ +test('effectiveRoot: the parser lands on the root the wrapper computed', async (t) => { + const layer = tmpdir('leji-handoff-layer-'); + fs.cpSync(path.join(repoRoot, 'fixtures', 'valid-minimal-core'), layer, { recursive: true }); + fs.rmSync(path.join(layer, 'expected.json'), { force: true }); + const empty = tmpdir('leji-handoff-empty-'); + const cases: string[][] = [ + ['validate', '--root', layer], + ['validate', `--root=${layer}`], + ['validate', '--root', empty, '--root', layer], + ['validate', '--json', '--root', layer], + ['validate', '--root', empty], + ['validate', '--root'], + ['validate', '--root', '--json'], + ['validate', '--name', '--root', layer], + ]; + const origLog = console.log; + const origError = console.error; + const origCwd = process.cwd(); + console.log = () => {}; + console.error = () => {}; + try { + process.chdir(empty); + for (const argv of cases) { + const root = effectiveRoot(argv.slice(1)); + const code = await run(argv); + // A root the wrapper could not decide is the usage error the parse reports; + // a decided root is answered about, and only the layer validates clean. + const expected = root === null ? 2 : path.resolve(root) === path.resolve(layer) ? 0 : 1; + assert.equal(code, expected, `${argv.join(' ')} (effective root ${root})`); + } + } finally { + process.chdir(origCwd); + console.log = origLog; + console.error = origError; + t.diagnostic(`${cases.length} argv forms compared`); + } +}); + +// --- the launcher's result table --------------------------------------------- + +interface Recorded { + stderr: string[]; + exits: number[]; + reraised: NodeJS.Signals[]; + spawned: Array<{ bin: string; args: string[] }>; +} + +class Exited extends Error {} + +/** An injected world: every effect is recorded, and `exit` throws so the launcher's + * "never returns" contract is observable without ending the test runner. */ +function recorder(outcome: LaunchOutcome, platform: NodeJS.Platform = 'linux'): { io: LaunchIo; log: Recorded } { + const log: Recorded = { stderr: [], exits: [], reraised: [], spawned: [] }; + const io: LaunchIo = { + platform, + spawn: (bin, args) => { + log.spawned.push({ bin, args }); + return outcome; + }, + reraise: (signal) => log.reraised.push(signal), + stderr: (line) => log.stderr.push(line), + exit: (code) => { + log.exits.push(code); + throw new Exited(`exit ${code}`); + }, + }; + return { io, log }; +} + +const HANDOFF: LocalCliHandoff = { + kind: 'handoff', + bin: '/repo/node_modules/.bin/leji', + args: ['validate', '--json'], + display: 'node_modules/.bin/leji', +}; + +function launched(outcome: LaunchOutcome, platform: NodeJS.Platform = 'linux'): Recorded { + const { io, log } = recorder(outcome, platform); + assert.throws(() => launchLocalCli(HANDOFF, io), Exited, 'the launcher must never return'); + return log; +} + +test('launch: an integer status is the status this process exits with', () => { + for (const status of [0, 1, 2, 3, 127]) { + const log = launched({ status, signal: null }); + assert.deepEqual(log.exits, [status]); + assert.deepEqual(log.stderr, []); + assert.deepEqual(log.spawned, [{ bin: HANDOFF.bin, args: HANDOFF.args }]); + } +}); + +test('launch: a signal is re-raised on POSIX so the shell sees the same end', () => { + const log = launched({ status: null, signal: 'SIGTERM' }); + assert.deepEqual(log.reraised, ['SIGTERM']); + assert.deepEqual(log.exits, [128 + os.constants.signals.SIGTERM]); + assert.deepEqual(log.stderr, []); +}); + +test('launch: a signal on Windows is named and exits 1, the documented limitation', () => { + const log = launched({ status: null, signal: 'SIGKILL' }, 'win32'); + assert.deepEqual(log.reraised, []); + assert.deepEqual(log.stderr, ["leji: the repository's Leji CLI ended by SIGKILL"]); + assert.deepEqual(log.exits, [1]); +}); + +test('launch: a failed spawn fails closed, never falling through to the global', () => { + const error = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }); + const log = launched({ status: null, signal: null, error }); + assert.deepEqual(log.stderr, ["leji: cannot run the repository's Leji CLI at node_modules/.bin/leji: ENOENT"]); + assert.deepEqual(log.exits, [2]); +}); + +test('launch: a spawn error without an errno code still names the failure', () => { + const log = launched({ status: null, signal: null, error: new Error('no code here') }); + assert.deepEqual(log.stderr, ["leji: cannot run the repository's Leji CLI at node_modules/.bin/leji: no code here"]); + assert.deepEqual(log.exits, [2]); +}); + +test('launch: neither a status nor a signal is never accidental success', () => { + const log = launched({ status: null, signal: null }); + assert.deepEqual(log.stderr, [ + "leji: cannot run the repository's Leji CLI at node_modules/.bin/leji: no exit status", + ]); + assert.deepEqual(log.exits, [2]); +}); diff --git a/packages/sdk/test/mcp-text.test.ts b/packages/sdk/test/mcp-text.test.ts new file mode 100644 index 0000000..b5d9682 --- /dev/null +++ b/packages/sdk/test/mcp-text.test.ts @@ -0,0 +1,74 @@ +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 { HOST_SPECS, MCP_JSON_CONFIG, mcpCommand, mcpJsonConfig } from '../dist/index.js'; + +// One instruction, three surfaces. The SDK owns the strings that register the local +// Leji MCP server: `leji start`'s preflight prints them, the MCP package README +// documents them, and the website publishes them. Anyone who follows one of the three +// must end up with the same registration, so the two documents QUOTE the SDK's bytes +// and this test is what keeps them from drifting apart word by word. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '..', '..', '..'); +const readme = path.join(repoRoot, 'packages', 'mcp', 'README.md'); +const sitePage = path.join(repoRoot, 'packages', 'site', 'src', 'pages', 'mcp.astro'); + +const claude = HOST_SPECS.find((s) => s.id === 'claude-code'); +const codex = HOST_SPECS.find((s) => s.id === 'codex'); + +/** The commands and configuration a person is told to run, derived from the host + * table rather than retyped: the shared project registration, the personal user-scope + * one, Codex's user-level one, and the standard JSON every other client takes. */ +function instructions(): { label: string; text: string }[] { + assert.ok(claude?.mcpAdd && claude.mcpAddUser, 'Claude Code declares both scopes'); + assert.ok(codex?.mcpAdd, 'Codex declares its user-level add'); + return [ + { label: 'project-scope add', text: mcpCommand(claude, claude.mcpAdd) }, + { label: 'user-scope add', text: mcpCommand(claude, claude.mcpAddUser) }, + { label: 'codex add', text: mcpCommand(codex, codex.mcpAdd) }, + { label: 'JSON config', text: MCP_JSON_CONFIG }, + ]; +} + +for (const file of [readme, sitePage]) { + const rel = path.relative(repoRoot, file); + test(`${rel} quotes the SDK's MCP instructions byte for byte`, () => { + const text = fs.readFileSync(file, 'utf8'); + for (const { label, text: want } of instructions()) { + assert.ok(text.includes(want), `${rel} does not carry the ${label}:\n${want}`); + } + }); +} + +test('the JSON config is the one the standard clients take, and it parses', () => { + const parsed = JSON.parse(MCP_JSON_CONFIG) as { + mcpServers: Record<string, { command: string; args: string[] }>; + }; + assert.deepEqual(Object.keys(parsed.mcpServers), ['leji']); + assert.equal(parsed.mcpServers.leji.command, 'npx'); + assert.deepEqual(parsed.mcpServers.leji.args, ['-y', '@leji-org/mcp']); +}); + +test('VS Code, which is how Copilot reads MCP servers, takes the `servers` shape', () => { + const copilot = HOST_SPECS.find((s) => s.id === 'copilot'); + assert.equal(copilot?.mcpConfig?.path, '.vscode/mcp.json'); + assert.equal(copilot?.mcpConfig?.shape, 'servers'); +}); + +test('every host Leji cannot register for names where its configuration lives', () => { + for (const spec of HOST_SPECS) { + if (spec.mcpAdd !== undefined) continue; + assert.ok(spec.mcpConfig, `${spec.id} has neither a registration command nor a config path`); + assert.ok(spec.mcpConfig.path.length > 0, `${spec.id} config path`); + assert.ok(['project', 'user'].includes(spec.mcpConfig.scope), `${spec.id} config scope`); + // The shape is not cosmetic: a block pasted under the wrong top-level key is a + // file the client silently ignores. + assert.ok(['mcpServers', 'servers'].includes(spec.mcpConfig.shape), `${spec.id} config shape`); + const block = mcpJsonConfig(spec.mcpConfig.shape); + assert.ok(block.includes(`"${spec.mcpConfig.shape}"`), `${spec.id} block key`); + assert.deepEqual(Object.keys(JSON.parse(block)), [spec.mcpConfig.shape], `${spec.id} block`); + } +}); diff --git a/packages/sdk/test/mounts.test.ts b/packages/sdk/test/mounts.test.ts index 5095ae6..8f48ca1 100644 --- a/packages/sdk/test/mounts.test.ts +++ b/packages/sdk/test/mounts.test.ts @@ -5,7 +5,8 @@ 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 { fileURLToPath, pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; import { type HydrateOutcome, cacheKeyFor, @@ -590,20 +591,27 @@ test('mounts: submodule ambiguity outranks a candidate that resolves only the pi assert.equal(hydrateMounts(host, manifest!, {}).outcomes[0].objectSource, 'hint'); }); -test('mounts: --fetch retains the pin by a resolver-owned ref, not just FETCH_HEAD', () => { +test('mounts: --fetch retains the pin by a resolver-owned ref, and writes no FETCH_HEAD at all', () => { const { host, sibling, pin } = mountedPair(); git(sibling, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); const { manifest } = loadManifest(host); - // main moves past the pin, so the witness fetch overwrites FETCH_HEAD with a - // different commit: only a ref of our own still retains the version of record. + // main moves past the pin, so neither fetch may leave the version of record to + // FETCH_HEAD: only a ref of our own retains it. commitFile(sibling, 'b.md'); withSourceRewrite(sibling, () => hydrateMounts(host, manifest!, { fetch: true })); const store = storeFor(host, ACME_IDENTITY); assert.equal(git(store, 'rev-parse', pinRefFor(ACME_IDENTITY, pin)), pin); - assert.notEqual(git(store, 'rev-parse', 'FETCH_HEAD'), pin); assert.ok( git(store, 'for-each-ref', '--format=%(refname)', 'refs/leji-pin').includes(pinRefFor(ACME_IDENTITY, pin)), ); + // Both fetches pass --no-write-fetch-head, so the managed store carries no + // per-run record of where the objects came from. + assert.equal(fs.existsSync(path.join(store, 'FETCH_HEAD')), false, 'no FETCH_HEAD in the managed store'); + // And a second --fetch, which refreshes the witness over an existing store, + // does not create one either. + commitFile(sibling, 'c.md'); + withSourceRewrite(sibling, () => hydrateMounts(host, manifest!, { fetch: true })); + assert.equal(fs.existsSync(path.join(store, 'FETCH_HEAD')), false, 'still none after a witness refresh'); }); test('mounts: git exit codes separate "no merge base" from a repository that cannot answer', () => { @@ -1618,3 +1626,394 @@ test('mounts: conformance reports all four mount items as n/a when none are decl ], ); }); + +// --- verification is read-only: it may not stage inside the tree it verifies --- + +/** Every directory at or under dir. */ +function allDirs(dir: string): string[] { + const out = [dir]; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) out.push(...allDirs(path.join(dir, e.name))); + } + return out; +} + +/** + * Can a write be refused to the account this suite runs as? + * + * POSIX: yes. 0o555 on the directory, and a non-root user is refused by the mode. + * + * Windows: no, and not for want of trying. An inheritable DENY ACE for the running + * SID does land — rc run three read it back on the mounts dir, explicit + * `(OI)(CI)(DENY)(W)` and `(I)(OI)(CI)(DENY)(W)` inherited — and the write succeeds + * anyway. The hosted runner is an elevated administrator whose token carries + * SeBackupPrivilege and SeRestorePrivilege ENABLED; libuv opens every file with + * FILE_FLAG_BACKUP_SEMANTICS (which is how Node opens a directory at all), and a + * backup-intent open by a holder of those privileges is granted without consulting + * the DACL. No ACL edit expresses denial to that token, so none is attempted. + * + * The platform that cannot host the precondition proves the property a different and + * stronger way: `treeSnapshot` equality across the command. "Without touching it" is + * what these tests are about, and an identical inventory measures that directly + * instead of inferring it from a permission the runner can bypass — a privilege can + * forge access, but not a byte that did not change. + */ +const CAN_DENY_WRITES = process.platform !== 'win32'; + +/** What a failed guard needs in the log to be readable without a second CI run: + * what actually stands at the path, the mode it carries, and who this process is + * against who owns it. Total — a message builder must never replace the failure it + * was called to explain. */ +function denialEvidence(target: string): string { + try { + const st = fs.statSync(target); + const kind = st.isDirectory() ? 'directory' : st.isFile() ? 'file' : 'other'; + const uid = typeof process.geteuid === 'function' ? process.geteuid() : 'n/a'; + return `evidence for ${target}: ${kind}, mode ${(st.mode & 0o777).toString(8)}, owner uid ${st.uid}, this euid ${uid}`; + } catch (e) { + return `evidence for ${target}: unreadable (${(e as NodeJS.ErrnoException).code})`; + } +} + +/** Strip write permission from every directory in the tree; returns the undo. A + * mode, and a real denial for a non-root user. On Windows this is a no-op that + * claims nothing: no ACL it could lay down would hold against the runner's token + * (see CAN_DENY_WRITES), and a mechanism that cannot deny must not pretend to. */ +function denyWrites(dir: string): () => void { + if (!CAN_DENY_WRITES) return () => {}; + const saved = allDirs(dir).map((d) => [d, fs.statSync(d).mode] as const); + for (const [d] of saved) fs.chmodSync(d, 0o555); + return () => { + for (const [d, mode] of saved) fs.chmodSync(d, mode); + }; +} + +/** Denial is asserted, never assumed: a mode a root-owned run ignores would make + * every "did not write" assertion below vacuous. Only a refusal counts — every other + * way the probe can fail (an absent directory, a sharing violation) is a defect in + * this harness, and says nothing about permission. POSIX only; see CAN_DENY_WRITES. + * + * Both failure paths carry the evidence with them, because this condition can only be + * built on the platform itself: a guard that only says "it was not denied" is a whole + * CI run spent asking the obvious next question. */ +function assertWriteDenied(dir: string, what: string): void { + const probe = path.join(dir, '.write-probe'); + try { + fs.writeFileSync(probe, 'x'); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code === 'EACCES' || err.code === 'EPERM') return; + assert.fail( + `${what} refused the probe with ${err.code}, which is not access denied: ${err.message}\n${denialEvidence(dir)}`, + ); + } + fs.rmSync(probe, { force: true }); + assert.fail(`${what} must really be write-denied, and the probe created ${probe} anyway\n${denialEvidence(dir)}`); +} + +/** + * Point the runtime's temp directory at something staging cannot be allocated in. + * Which variables decide it differs per platform (TMPDIR on POSIX; TEMP, then TMP, on + * Windows), and so does the way to make it unusable: POSIX takes the write permission + * away, which also walks the EACCES path a genuinely read-only temp would take; + * Windows cannot deny its own token (see CAN_DENY_WRITES), so there the variables + * name a regular FILE instead — `mkdtemp` beneath a file cannot succeed for any + * token, privileged or not, and a failed allocation is the same missing prerequisite + * reached by a route no privilege bypasses. + * + * `holder` is the directory the location lives in, and is a directory on both + * platforms: on POSIX it IS the temp location, on Windows it is the file's parent. + * It is what "nothing was staged here" is measured against. + */ +function unusableTempDir(): { tmp: string; holder: string; restore: () => void } { + const vars = process.platform === 'win32' ? ['TEMP', 'TMP'] : ['TMPDIR']; + const saved = vars.map((v) => [v, process.env[v]] as const); + const holder = tmpdir('leji-notmp-'); + let tmp = holder; + let restoreWrites = () => {}; + if (CAN_DENY_WRITES) { + restoreWrites = denyWrites(holder); + } else { + tmp = path.join(holder, 'not-a-directory'); + fs.writeFileSync(tmp, ''); + } + // Overridden last, so building the condition above still had a usable temp dir. + for (const v of vars) process.env[v] = tmp; + return { + tmp, + holder, + restore: () => { + for (const [v, value] of saved) { + if (value === undefined) delete process.env[v]; + else process.env[v] = value; + } + restoreWrites(); + }, + }; +} + +/** The errnos that mean "no staging area here": a directory that refuses the write, + * or a path whose parent is not a directory at all. */ +const UNUSABLE_TEMP_CODES: ReadonlySet<string> = new Set(['EACCES', 'EPERM', 'ENOTDIR', 'ENOENT']); + +/** The missing prerequisite is asserted, never assumed: a temp location the runtime + * can still allocate in would make every "unverifiable" assertion below vacuous, on + * either platform and by either mechanism. This probes what the product actually + * does — `mkdtemp` under `os.tmpdir()` — rather than something adjacent to it. */ +function assertNoStaging(tmp: string): void { + assert.equal(os.tmpdir(), tmp, 'the runtime must honor the temp variables for this to force the failure'); + let staged = ''; + try { + staged = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-staging-probe-')); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (UNUSABLE_TEMP_CODES.has(err.code ?? '')) return; + assert.fail( + `allocating staging failed with ${err.code}, which is not an unusable temp dir: ${err.message}\n${denialEvidence(tmp)}`, + ); + } + fs.rmSync(staged, { recursive: true, force: true }); + assert.fail( + `the temp dir must really be unusable, and staging was allocated at ${staged} anyway\n${denialEvidence(tmp)}`, + ); +} + +/** + * Paths, types, modes, symlink targets, content and directory mtimes: the whole of + * what "the tree is byte-for-byte what it was" has to mean here. Content alone would + * miss a directory created and removed between the two reads — its parent's mtime is + * the only trace that survives. + * + * The ROOT carries its own entry, in the same shape as every other directory, because + * the root is the parent of anything created directly beneath it: without that line, + * a staging directory allocated at the top of the tree and removed again leaves this + * snapshot completely unchanged, and on Windows — where equality is the whole proof + * (see CAN_DENY_WRITES) — that is precisely the write nothing else would catch. + */ +function treeSnapshot(dir: string): string[] { + const st = fs.lstatSync(dir); + return [`D . ${(st.mode & 0o777).toString(8)} ${st.mtimeMs}`, ...walkSnapshot(dir)]; +} + +/** Everything BELOW the root, recursively. Split out so the root's own entry is + * recorded once by `treeSnapshot` rather than once per level of the walk. */ +function walkSnapshot(dir: string, prefix = ''): string[] { + const out: string[] = []; + for (const name of fs.readdirSync(dir).sort()) { + const abs = path.join(dir, name); + const rel = prefix === '' ? name : `${prefix}/${name}`; + const st = fs.lstatSync(abs); + const mode = (st.mode & 0o777).toString(8); + if (st.isSymbolicLink()) { + out.push(`L ${rel} ${mode} ${fs.readlinkSync(abs, { encoding: 'buffer' }).toString('base64')}`); + } else if (st.isDirectory()) { + out.push(`D ${rel} ${mode} ${st.mtimeMs}`); + out.push(...walkSnapshot(abs, rel)); + } else { + out.push( + `F ${rel} ${mode} ${st.size} ${crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex')}`, + ); + } + } + return out; +} + +/** Staging directories left behind in the OS temp dir. Compared as a delta, since + * the suite's other files run in sibling processes against the same temp dir. */ +function verifyResidue(): string[] { + return fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('leji-verify-')); +} + +test('mounts: --check-integrity verifies a write-denied host tree, twice, without touching it', () => { + // `mounts status --check-integrity` staged its comparison tree inside the host's + // own .leji/mounts/, so the read-only diagnostic wrote into the tree it was + // diagnosing — and could not run at all where that tree is not writable. + const { host } = mountedPair(); + const { manifest } = loadManifest(host); + hydrateMounts(host, manifest!, {}); + const restore = denyWrites(host); + try { + // Where a write CAN be refused, refusing it proves the extra half the defect + // had: that the diagnostic runs at all against a tree it cannot write. Where it + // cannot (CAN_DENY_WRITES), the snapshot below carries the property on its own — + // it is the direct measurement, and the denial was only ever the setting. + if (CAN_DENY_WRITES) { + assertWriteDenied(path.join(host, '.leji', 'mounts'), 'the mounts dir'); + assertWriteDenied(host, 'the host root'); + } + const before = treeSnapshot(host); + const residueBefore = new Set(verifyResidue()); + // Twice: once proves it runs, twice proves the second run is not consuming + // residue the first left behind. + assert.equal(mountStatus(host, manifest!, { checkIntegrity: true })[0].verified, true); + assert.equal(mountStatus(host, manifest!, { checkIntegrity: true })[0].verified, true); + // The other two callers of the same verification, on the same denied tree. + const loc = locateMount(host, manifest!, 'acme-product-context'); + assert.equal(loc.present, true); + assert.equal(loc.verified, true); + assert.deepEqual(federationEnforcement(host, manifest!, 'available', null), []); + assert.deepEqual(treeSnapshot(host), before, 'verification wrote into the host tree'); + assert.deepEqual( + verifyResidue().filter((n) => !residueBefore.has(n)), + [], + 'staging outlived the verification that allocated it', + ); + } finally { + restore(); + } +}); + +/** One worker thread running `rounds` verifications of the host's only mount, every + * round entered through a two-thread rendezvous on the shared gate, with the lagging + * thread then held back to about half of its last round. + * + * Both halves earn their place. Without the rendezvous the threads drift into taking + * turns and never overlap; with the rendezvous alone they run identical work in + * lockstep, and two threads staging the same content into one shared directory at the + * same instant still agree — the interleaving that a shared staging directory cannot + * survive is one thread starting while the other is mid-verification. Worker threads, + * so "the same process" is literal: a staging name derived from the pid is one name + * for both of them. */ +function verifyInWorker(host: string, rounds: number, gate: SharedArrayBuffer, lag: boolean): Promise<unknown[]> { + const code = ` + const { parentPort, workerData } = require('node:worker_threads'); + const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + (async () => { + const { loadManifest } = await import(workerData.index); + const { verifyProjection } = await import(workerData.mounts); + const mount = loadManifest(workerData.host).manifest.federation.mounts[0]; + const gate = new Int32Array(workerData.gate); + const results = []; + let lastMs = 40; + for (let i = 0; i < workerData.rounds; i++) { + const bothArrived = (i + 1) * 2; + Atomics.add(gate, 0, 1); + Atomics.notify(gate, 0); + let seen; + while ((seen = Atomics.load(gate, 0)) < bothArrived) Atomics.wait(gate, 0, seen, 200); + if (workerData.lag) sleep(Math.max(5, Math.round(lastMs / 2))); + const startedAt = Date.now(); + try { + results.push(verifyProjection(workerData.host, mount)); + } catch (e) { + results.push('threw ' + String(e && e.code ? e.code : e)); + } + lastMs = Date.now() - startedAt; + } + parentPort.postMessage(results); + })(); + `; + return new Promise((resolve, reject) => { + const worker = new Worker(code, { + eval: true, + workerData: { + host, + rounds, + gate, + lag, + index: pathToFileURL(path.join(pkgRoot, 'dist', 'index.js')).href, + mounts: pathToFileURL(path.join(pkgRoot, 'dist', 'lib', 'mounts.js')).href, + }, + }); + let results: unknown[] | null = null; + worker.on('message', (m: unknown[]) => { + results = m; + }); + worker.on('error', reject); + worker.on('exit', () => resolve(results ?? ['no result'])); + }); +} + +test('mounts: two verifications running at once in one process do not collide', async () => { + const { host } = mountedPair(); + const { manifest } = loadManifest(host); + hydrateMounts(host, manifest!, {}); + const residueBefore = new Set(verifyResidue()); + const rounds = 8; + const gate = new SharedArrayBuffer(4); + const [a, b] = await Promise.all([ + verifyInWorker(host, rounds, gate, false), + verifyInWorker(host, rounds, gate, true), + ]); + // Every one of them verified: a shared staging path has one thread deleting or + // half-writing the tree the other is comparing, which surfaces as ENOENT, + // ENOTEMPTY, or a false verdict on content nobody tampered with. + assert.deepEqual(a, new Array(rounds).fill(true)); + assert.deepEqual(b, new Array(rounds).fill(true)); + assert.deepEqual( + verifyResidue().filter((n) => !residueBefore.has(n)), + [], + ); +}); + +test('mounts: an unusable temp directory makes verification unverifiable, never in-tree', () => { + const { host } = mountedPair(); + const { manifest } = loadManifest(host); + hydrateMounts(host, manifest!, {}); + const { tmp, holder, restore } = unusableTempDir(); + try { + assertNoStaging(tmp); + const before = treeSnapshot(host); + // The holder's own mtime is in this snapshot: staging is allocated directly in + // the temp dir and removed again in a `finally`, so nothing else would show it. + const tempBefore = treeSnapshot(holder); + // Unknown: no staging area is a missing prerequisite, exactly like no reachable + // object store. It is never a pass, never a failure, and never a reason to fall + // back into the host tree. + const row = mountStatus(host, manifest!, { checkIntegrity: true })[0]; + assert.equal(row.present, true); + assert.equal(row.verified, null); + const loc = locateMount(host, manifest!, 'acme-product-context'); + assert.equal(loc.present, true); + assert.equal(loc.verified, false); + assert.match(loc.detail!, /present but not verified/); + assert.match(loc.detail!, /verification prerequisites are unavailable/); + // The diagnostic names the prerequisite that was actually missing rather than + // blaming the object store, which is reachable here: a reader told to check + // their hint would be reading the wrong end of the failure. + const findings = federationEnforcement(host, manifest!, 'available', null); + assert.equal(findings.length, 1); + assert.match(findings[0].message, /cannot be verified/); + assert.match(findings[0].message, /verification prerequisites unavailable/); + assert.match(findings[0].message, /no writable temp dir/); + assert.deepEqual(treeSnapshot(host), before, 'verification fell back into the host tree'); + assert.deepEqual(treeSnapshot(holder), tempBefore, 'something was staged at the unusable temp location'); + } finally { + restore(); + } +}); + +test('mounts: a reachable store that does not hold the pin is unverifiable, and the diagnostic says which prerequisite', () => { + const { host } = mountedPair(); + const { manifest } = loadManifest(host); + hydrateMounts(host, manifest!, {}); + // A real repository, reachable, that simply does not contain this pin. The + // published projection stays published — its cache key comes from the + // declaration, not from whichever store happens to be reachable — so the only + // missing prerequisite is the commit the comparison would be made against. + const other = path.join(path.dirname(host), 'other'); + fs.mkdirSync(other, { recursive: true }); + git(other, 'init', '-q', '-b', 'main'); + commitFile(other, 'unrelated.md'); + fs.writeFileSync( + path.join(host, '.leji', 'mounts.local.json'), + JSON.stringify({ mounts: { 'acme-product-context': { repo: '../other' } } }) + '\n', + ); + const mount = manifest!.federation!.mounts![0]; + assert.equal(verifyProjection(host, mount), null); + const row = mountStatus(host, manifest!, { checkIntegrity: true })[0]; + assert.equal(row.present, true); + assert.equal(row.verified, null); + const loc = locateMount(host, manifest!, 'acme-product-context'); + assert.equal(loc.present, true); + assert.equal(loc.verified, false); + // The parenthetical is the whole of what makes a projection unverifiable. An + // exhaustive-looking list that omits this branch tells the reader their object + // store is unreachable when it is reachable and their pin is what is missing. + const findings = federationEnforcement(host, manifest!, 'available', null); + assert.equal(findings.length, 1); + assert.equal( + findings[0].message, + 'mount "acme-product-context" projection cannot be verified (verification prerequisites unavailable: no reachable object store, unresolvable pin, or no writable temp dir); an unverified cache is not evidence', + ); +}); diff --git a/packages/sdk/test/onboarding.test.ts b/packages/sdk/test/onboarding.test.ts index 9017dc0..6bb47db 100644 --- a/packages/sdk/test/onboarding.test.ts +++ b/packages/sdk/test/onboarding.test.ts @@ -37,17 +37,17 @@ test('init --dry-run writes nothing and reports the plan', async () => { const creates = result.plan.filter((e) => e.status === 'create').map((e) => e.rel); assert.ok(creates.includes('leji.json')); - assert.ok(creates.includes('docs/.leji/onboarding-brief.md')); + assert.ok(creates.includes('.leji/work/onboarding-brief.md')); // The existing vendor file is detected and explicitly left untouched. const untouched = result.plan.find((e) => e.rel === 'CLAUDE.md'); assert.equal(untouched?.status, 'wont-modify'); }); -test('init writes the onboarding brief under a dot-dir, excluded from the index', async () => { +test('init writes the onboarding brief in the workspace role, excluded from the index', async () => { const dir = tmpdir(); await initLayer({ dir, yes: true, level: 'indexed', name: 'acme-context' }); - const brief = path.join(dir, 'docs', '.leji', 'onboarding-brief.md'); + const brief = path.join(dir, '.leji', 'work', 'onboarding-brief.md'); assert.ok(fs.existsSync(brief), 'brief is written'); const { manifest } = loadManifest(dir); @@ -195,7 +195,7 @@ function fakeIo(answer: string | string[], launchResult?: SpawnResult, runResult return { io, launches, questions, cwds, runs, events }; } -const BRIEF_PROMPT = 'Read ./docs/.leji/onboarding-brief.md and follow it.'; +const BRIEF_PROMPT = 'Read ./.leji/work/onboarding-brief.md and follow it.'; test('handoffOffer never fires non-interactively, even with a launchable host on PATH', async () => { const f = fakeIo('y'); @@ -285,12 +285,12 @@ test('handoffOffer returns false when the agent is killed by a signal', async () assert.equal(await handoffOffer(manifestAt('docs/'), [CLAUDE], true, f.io), false); }); -test('handoffOffer threads the layer root into the brief prompt', async () => { +test('handoffOffer names the root-level workspace whatever the layer root is', async () => { + // The onboarding workspace is one tree at the repository root, so the prompt is + // the same for a layer rooted anywhere: it never carries a rootPath prefix. const f = fakeIo('y'); assert.equal(await handoffOffer(manifestAt('context/'), [CLAUDE], true, f.io), true); - assert.deepEqual(f.launches, [ - { bin: 'claude', promptArg: 'Read ./context/.leji/onboarding-brief.md and follow it.' }, - ]); + assert.deepEqual(f.launches, [{ bin: 'claude', promptArg: BRIEF_PROMPT }]); }); // --- enterLayer (leji start) --- @@ -627,11 +627,18 @@ test('ci --hooks: the stale-index message is literal text, not a command the hoo await initLayer({ dir, yes: true }); gitCommitAll(dir); ensureLocalHook(dir); - // A repo-local `leji` the hook prefers, so the run is hermetic. + // Nothing is declared here, so the generated hook runs the plain `leji` on PATH — + // which makes that PATH this test's to supply. A stub of our own, AHEAD of + // everything else, so the hook reaches it and never whatever the machine running + // the suite happens to have installed (a runner has nothing; a maintainer's box + // has a global copy, and the test would silently be measuring that one). The stub + // names this Node and this build absolutely: it cannot assume a PATH either. const cli = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'cli.js'); const binDir = path.join(dir, 'node_modules', '.bin'); fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(path.join(binDir, 'leji'), `#!/bin/sh\nexec node ${cli} "$@"\n`, { mode: 0o755 }); + fs.writeFileSync(path.join(binDir, 'leji'), `#!/bin/sh\nexec "${process.execPath}" "${cli}" "$@"\n`, { + mode: 0o755, + }); // Stale the stored index, the exact condition the message describes. const indexAbs = path.join(dir, 'docs', 'context-index.json'); const before = fs.readFileSync(indexAbs, 'utf8'); @@ -640,7 +647,11 @@ test('ci --hooks: the stale-index message is literal text, not a command the hoo '---\nsummary: An extra domain doc.\n---\n\n# Extra\n', ); - const run = spawnSync('sh', [path.join('.git', 'hooks', 'pre-commit')], { cwd: dir, encoding: 'utf8' }); + const run = spawnSync('sh', [path.join('.git', 'hooks', 'pre-commit')], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` }, + }); assert.equal(run.status, 1, 'the hook rejects the commit'); // The backticks reach the message as literal characters; a double-quoted echo // would have run `leji index` and spliced its stdout in here instead. @@ -898,29 +909,37 @@ test('renderWritePlan labels every status and summarizes counts', async () => { }); test('renderDetect handles the no-hosts case and the ranked case', async () => { - const { renderDetect } = await import('../dist/index.js'); - assert.match(renderDetect([]), /No coding-agent hosts detected/); - const ranked = renderDetect([ - { - id: 'claude-code', - name: 'Claude Code', - strength: 'confirmed', - onPath: true, - inRepo: false, - userConfig: false, - adapter: 'CLAUDE.md', - }, - { - id: 'cursor', - name: 'Cursor', - strength: 'project-present', - onPath: false, - inRepo: true, - userConfig: false, - adapter: '.cursor/rules/leji.md', - }, - ]); + const { renderDetect, detectEcosystem } = await import('../dist/index.js'); + // A root with no manifest: the ecosystem line is present in both shapes and + // says so, without changing what the host list reports. + const ecosystem = detectEcosystem(tmpdir()); + assert.match(renderDetect({ hosts: [], ecosystem }), /No coding-agent hosts detected/); + assert.match(renderDetect({ hosts: [], ecosystem }), /Ecosystem: none detected/); + const ranked = renderDetect({ + ecosystem, + hosts: [ + { + id: 'claude-code', + name: 'Claude Code', + strength: 'confirmed', + onPath: true, + inRepo: false, + userConfig: false, + adapter: 'CLAUDE.md', + }, + { + id: 'cursor', + name: 'Cursor', + strength: 'project-present', + onPath: false, + inRepo: true, + userConfig: false, + adapter: '.cursor/rules/leji.md', + }, + ], + }); assert.match(ranked, /confirmed.*Claude Code.*binary on PATH.*CLAUDE\.md/); + assert.match(ranked, /Ecosystem: none detected/); assert.match(ranked, /leji init --agent/); }); @@ -1086,6 +1105,226 @@ test('adopt --wire-adapters never loses vendor content when the migration name c assert.match(fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf8'), /docs\/boot-profile\.md/); }); +test('adopt --wire-adapters: a dangling archive candidate is occupied, never written through', async () => { + // `existsSync` follows symlinks, so a dangling candidate reads as a free name and the + // archive would be created at the link's missing destination. The candidate is judged + // by the verified read instead: a standing entry this run cannot verify is occupied. + const dir = tmpdir(); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'original instructions\n'); + gitCommitAll(dir); + await adoptLayer({ dir, yes: true }); + + const candidate = path.join(dir, 'docs', 'governance', 'imported-claude.md'); + fs.rmSync(candidate); + fs.symlinkSync('never-created.md', candidate); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'hand-written rules added after adoption\n'); + + const wired = await adoptLayer({ dir, yes: true, wireAdapters: true }); + + assert.deepEqual(wired.migrated, ['CLAUDE.md'], 'the newer content is still archived'); + assert.equal( + fs.existsSync(path.join(dir, 'docs', 'governance', 'never-created.md')), + false, + "the dangling link's destination is never created", + ); + assert.ok(fs.lstatSync(candidate).isSymbolicLink(), 'the planted link is left exactly as it was'); + const alt = path.join(dir, 'docs', 'governance', 'imported-claude-2.md'); + assert.match(fs.readFileSync(alt, 'utf8'), /hand-written rules added after adoption/, 'the next name is used'); +}); + +test('adopt --wire-adapters: an archive candidate resolving outside the repository is occupied', async () => { + const dir = tmpdir(); + const outsideFile = path.join(tmpdir(), 'outside.md'); + fs.writeFileSync(outsideFile, '# Outside the repository\n'); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'original instructions\n'); + gitCommitAll(dir); + await adoptLayer({ dir, yes: true }); + + const candidate = path.join(dir, 'docs', 'governance', 'imported-claude.md'); + fs.rmSync(candidate); + fs.symlinkSync(outsideFile, candidate); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'hand-written rules added after adoption\n'); + + await adoptLayer({ dir, yes: true, wireAdapters: true }); + + assert.equal(fs.readFileSync(outsideFile, 'utf8'), '# Outside the repository\n', 'the outside file is untouched'); + const alt = path.join(dir, 'docs', 'governance', 'imported-claude-2.md'); + assert.match(fs.readFileSync(alt, 'utf8'), /hand-written rules added after adoption/, 'the next name is used'); +}); + +test('init: a dangling symlink at a scaffold target is refused, never written through', async () => { + // `existsSync` follows symlinks, so a dangling target reads as absent and the + // guarded write lands at the link's destination — inside the root, but under a + // name init never planned. The verified read refuses the standing entry instead. + const dir = tmpdir(); + fs.mkdirSync(path.join(dir, 'docs')); + const target = path.join(dir, 'docs', 'boot-profile.md'); + fs.symlinkSync('never-created.md', target); + + await assert.rejects(() => initLayer({ dir, yes: true }), /escapes the target/); + + assert.equal( + fs.existsSync(path.join(dir, 'docs', 'never-created.md')), + false, + "the dangling link's destination is never created", + ); + assert.ok(fs.lstatSync(target).isSymbolicLink(), 'the planted link is left exactly as it was'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('init: a scaffold target symlinked outside the repository is refused, not skipped', async () => { + // A pathname check sees the link's outside target and reads the name as taken, so + // init would quietly skip the file it owns. The verified read judges where the + // entry resolves: outside the root is the same hard refusal a write to it is. + const dir = tmpdir(); + const outside = tmpdir(); + const outsideFile = path.join(outside, 'boot-profile.md'); + fs.writeFileSync(outsideFile, '# Outside the repository\n'); + fs.mkdirSync(path.join(dir, 'docs')); + fs.symlinkSync(outsideFile, path.join(dir, 'docs', 'boot-profile.md')); + + await assert.rejects(() => initLayer({ dir, yes: true }), /escapes the target/); + + assert.equal(fs.readFileSync(outsideFile, 'utf8'), '# Outside the repository\n', 'the outside file is untouched'); + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + +test('adopt: a dangling migration-doc name is occupied, never written through', async () => { + // The disambiguation loop picks the archive's name. A dangling candidate read by + // pathname is a free name, and the migrated content would land at the link's + // missing destination; the verified read makes any standing entry occupied. + const dir = tmpdir(); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'original instructions\n'); + fs.mkdirSync(path.join(dir, 'docs', 'governance'), { recursive: true }); + const candidate = path.join(dir, 'docs', 'governance', 'imported-claude.md'); + fs.symlinkSync('never-created.md', candidate); + gitCommitAll(dir); + + const res = await adoptLayer({ dir, yes: true }); + + assert.deepEqual(res.migrated, ['CLAUDE.md'], 'the vendor content is still migrated'); + assert.equal( + fs.existsSync(path.join(dir, 'docs', 'governance', 'never-created.md')), + false, + "the dangling link's destination is never created", + ); + assert.ok(fs.lstatSync(candidate).isSymbolicLink(), 'the planted link is left exactly as it was'); + const alt = path.join(dir, 'docs', 'governance', 'imported-claude-2.md'); + assert.match(fs.readFileSync(alt, 'utf8'), /original instructions/, 'the next name is used'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('adopt: a migration-doc name resolving outside the repository is occupied', async () => { + const dir = tmpdir(); + const outside = tmpdir(); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), 'original instructions\n'); + fs.mkdirSync(path.join(dir, 'docs', 'governance'), { recursive: true }); + const candidate = path.join(dir, 'docs', 'governance', 'imported-claude.md'); + fs.symlinkSync(path.join(outside, 'never-created.md'), candidate); + gitCommitAll(dir); + + const res = await adoptLayer({ dir, yes: true }); + + assert.deepEqual(res.migrated, ['CLAUDE.md'], 'the vendor content is still migrated'); + assert.deepEqual(fs.readdirSync(outside), [], 'nothing is written outside the repository'); + const alt = path.join(dir, 'docs', 'governance', 'imported-claude-2.md'); + assert.match(fs.readFileSync(alt, 'utf8'), /original instructions/, 'the next name is used'); + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + +test('adopt: a dangling scaffold name is occupied, and the alternate name is scaffolded', async () => { + // The scaffold names were picked with `existsSync`, which follows symlinks: a dangling + // boot-profile link read as a free name, and the scaffold would have been written at + // the link's missing destination. The verified read makes any standing entry occupied, + // so the alternate name is taken exactly as it is for an ordinary existing file. + const dir = tmpdir(); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.mkdirSync(path.join(dir, 'docs')); + fs.writeFileSync(path.join(dir, 'docs', 'notes.md'), '# Notes\n'); + const link = path.join(dir, 'docs', 'boot-profile.md'); + fs.symlinkSync('never-created.md', link); + gitCommitAll(dir); + + const res = await adoptLayer({ dir, yes: true }); + + assert.equal(res.manifest.bootProfilePath, 'docs/leji-boot-profile.md', 'the alternate name is scaffolded'); + assert.equal( + fs.existsSync(path.join(dir, 'docs', 'never-created.md')), + false, + "the dangling link's destination is never created", + ); + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'the planted link is left exactly as it was'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('adopt: a scaffold name resolving outside the repository is occupied', async () => { + const dir = tmpdir(); + const outside = tmpdir(); + const outsideFile = path.join(outside, 'boot-profile.md'); + fs.writeFileSync(outsideFile, '# Outside the repository\n'); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.mkdirSync(path.join(dir, 'docs')); + fs.writeFileSync(path.join(dir, 'docs', 'notes.md'), '# Notes\n'); + fs.symlinkSync(outsideFile, path.join(dir, 'docs', 'boot-profile.md')); + gitCommitAll(dir); + + const res = await adoptLayer({ dir, yes: true }); + + assert.equal(res.manifest.bootProfilePath, 'docs/leji-boot-profile.md', 'the alternate name is scaffolded'); + assert.equal(fs.readFileSync(outsideFile, 'utf8'), '# Outside the repository\n', 'the outside file is untouched'); + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + +test('leji agent: a dangling profile name refuses the command, writing neither half', async () => { + // `isFile` follows symlinks, so a dangling profile link read as absent and the profile + // was written at the link's destination. Both halves are judged before either is + // written, so a refused profile leaves the manifest binding unwritten too. + const dir = tmpdir(); + await initLayer({ dir, yes: true, name: 'demo' }); + const { manifest } = loadManifest(dir); + const link = path.join(dir, 'docs', 'agents', 'reviewer.md'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync('never-created.md', link); + const before = fs.readFileSync(path.join(dir, 'leji.json'), 'utf8'); + + assert.throws(() => addAgent(dir, manifest!, { host: 'codex', name: 'reviewer' }), /escapes the target/); + + assert.equal( + fs.existsSync(path.join(dir, 'docs', 'agents', 'never-created.md')), + false, + "the dangling link's destination is never created", + ); + assert.equal(fs.readFileSync(path.join(dir, 'leji.json'), 'utf8'), before, 'the manifest is not rewritten'); + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'the planted link is left exactly as it was'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('leji agent: a profile name resolving outside the repository refuses, writing neither half', async () => { + const dir = tmpdir(); + const outside = tmpdir(); + const outsideFile = path.join(outside, 'reviewer.md'); + fs.writeFileSync(outsideFile, '# Outside the repository\n'); + await initLayer({ dir, yes: true, name: 'demo' }); + const { manifest } = loadManifest(dir); + fs.mkdirSync(path.join(dir, 'docs', 'agents'), { recursive: true }); + fs.symlinkSync(outsideFile, path.join(dir, 'docs', 'agents', 'reviewer.md')); + const before = fs.readFileSync(path.join(dir, 'leji.json'), 'utf8'); + + assert.throws(() => addAgent(dir, manifest!, { host: 'codex', name: 'reviewer' }), /escapes the target/); + + assert.equal(fs.readFileSync(outsideFile, 'utf8'), '# Outside the repository\n', 'the outside file is untouched'); + assert.equal(fs.readFileSync(path.join(dir, 'leji.json'), 'utf8'), before, 'the manifest is not rewritten'); + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); +}); + const BROKEN_DOT_ROOT_PATHS = /\.boot-profile\.md|\.agents\/|\.governance\/|\.domain\/|\.decisions\/|\.context\/|\.\.leji\//; @@ -1124,7 +1363,7 @@ test('adopt summary under a "." root references governance/ and .leji/, never .g } as Parameters<typeof enteringAdopted>[0]); assert.doesNotMatch(summary, BROKEN_DOT_ROOT_PATHS, 'no .governance/ or ..leji/ in the summary'); assert.match(summary, /into governance\//, 'migrated into governance/'); - assert.match(summary, /\.leji\/onboarding-brief\.md/, 'brief path is .leji/onboarding-brief.md'); + assert.match(summary, /\.leji\/work\/onboarding-brief\.md/, 'brief path is .leji/work/onboarding-brief.md'); }); // --- MCP install offer (pre-handoff) --- @@ -1288,10 +1527,10 @@ test('solo boot profile routes identity and writing work by task, never preloade test('solo brief is mode-stamped and carries the interview and artifact rules', async () => { const dir = tmpdir(); await initLayer({ dir, yes: true, mode: 'solo' }); - const brief = fs.readFileSync(path.join(dir, 'docs/.leji/onboarding-brief.md'), 'utf8'); + const brief = fs.readFileSync(path.join(dir, '.leji/work/onboarding-brief.md'), 'utf8'); assert.ok(brief.includes('**Working mode:** solo')); - assert.ok(brief.includes('docs/.leji/onboarding-inputs/'), 'drop-folder path rewritten for the root'); + assert.ok(brief.includes('.leji/work/onboarding-inputs/'), 'drop folder sits in the workspace role'); assert.ok(brief.includes('untrusted data'), 'artifact consent rules present'); assert.ok(!brief.includes('<mode>'), 'no unreplaced mode marker'); assert.ok(!brief.includes('<root>/'), 'no unreplaced root marker'); @@ -1319,7 +1558,7 @@ test('omitted mode and explicit --mode team are byte-identical, with no solo sta ); } assert.ok(!fs.existsSync(path.join(a, 'docs/domain/identity.md')), 'team scaffolds no identity starter'); - const brief = fs.readFileSync(path.join(a, 'docs/.leji/onboarding-brief.md'), 'utf8'); + const brief = fs.readFileSync(path.join(a, '.leji/work/onboarding-brief.md'), 'utf8'); assert.ok(brief.includes('**Working mode:** team'), 'team brief carries a concrete stamp'); }); @@ -1405,8 +1644,8 @@ test('init refuses while files under .leji/ are tracked by git, leaving the tree git('init', '-q'); git('config', 'user.name', 'T'); git('config', 'user.email', 't@example.com'); - fs.mkdirSync(path.join(dir, 'docs/.leji'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'docs/.leji/stale.md'), 'tracked artifact\n'); + fs.mkdirSync(path.join(dir, '.leji'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji/stale.md'), 'tracked artifact\n'); git('add', '-A'); git('commit', '-qm', 'seed'); @@ -1435,13 +1674,13 @@ test('approval guard: installs idempotently and preserves existing settings', () assert.equal(settings.existing, true, 'unrelated settings preserved'); const matchers = settings.hooks.PreToolUse.map((e: { matcher: string }) => e.matcher); assert.deepEqual(matchers, ['Bash', 'AskUserQuestion']); - assert.ok(fs.existsSync(path.join(dir, 'docs', '.leji', 'hooks', 'approval-guard.mjs'))); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'work', 'hooks', 'approval-guard.mjs'))); }); test('approval guard: blocks until written and printed, inert after onboarding', () => { const dir = tmpdir(); ensureApprovalGuard(dir, 'docs/'); - const lejiDir = path.join(dir, 'docs', '.leji'); + const lejiDir = path.join(dir, '.leji', 'work'); const script = path.join(lejiDir, 'hooks', 'approval-guard.mjs'); fs.writeFileSync(path.join(lejiDir, 'onboarding-brief.md'), 'brief'); const run = (transcript: string): number => diff --git a/packages/sdk/test/preflight.test.ts b/packages/sdk/test/preflight.test.ts new file mode 100644 index 0000000..db6c642 --- /dev/null +++ b/packages/sdk/test/preflight.test.ts @@ -0,0 +1,888 @@ +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +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 { defaultHandoffIo } from '../dist/commands/init.js'; +import { checkDocument, colorDecision } from '../dist/commands/preflight.js'; +import { + type Check, + type PreflightResult, + detectEcosystem, + ensureLocalHook, + hookStatus, + offerPreflightFixes, + renderPreflight, + runPreflight, +} from '../dist/index.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const exampleDir = path.resolve(testDir, '..', '..', '..', 'examples', 'monorepo'); + +function tmpdir(prefix = 'leji-preflight-'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** The bin shim a Node package manager's install puts in the tree. The probe executes + * this file directly, so every Node case that expects a version has to have it. */ +function installNodeBin(dir: string): string { + const binDir = path.join(dir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const abs = path.join(binDir, 'leji'); + fs.writeFileSync(abs, '#!/bin/sh\necho 1.4.0\n', { mode: 0o755 }); + return abs; +} + +/** A committed example layer in its own git repository: the shape every hook class + * is derived from. */ +function gitLayer(prefix: string): string { + const dir = tmpdir(prefix); + execFileSync('git', ['init', '-q'], { cwd: dir }); + fs.cpSync(exampleDir, dir, { recursive: true }); + execFileSync('git', ['add', '-A'], { cwd: dir }); + execFileSync('git', ['-c', 'user.email=t@e.com', '-c', 'user.name=T', 'commit', '-qm', 'seed'], { cwd: dir }); + return dir; +} + +const MANIFEST = { leji: '1.0', bootProfilePath: 'docs/boot-profile.md' } as never; + +const CLAUDE_HOST = { id: 'claude-code', bin: 'claude', name: 'Claude Code' }; +const CODEX_HOST = { id: 'codex', bin: 'codex', name: 'Codex' }; +const host = (id: string, name: string): never => + ({ + id, + name, + strength: 'confirmed', + onPath: true, + inRepo: false, + userConfig: false, + adapter: null, + }) as never; +const DETECTED_CLAUDE = host('claude-code', 'Claude Code'); +const DETECTED_CODEX = host('codex', 'Codex'); +const DETECTED_CURSOR = host('cursor', 'Cursor'); +const DETECTED_COPILOT = host('copilot', 'GitHub Copilot'); + +type RunResult = { error?: Error; status?: number | null; signal?: NodeJS.Signals | null; stdout?: string }; +type RunOpts = { + quiet: boolean; + capture?: boolean; + timeoutMs?: number; + maxBytes?: number; + env?: Record<string, string>; +}; + +/** A scripted IO: `results` answers each `run` in order (the last one repeats), and + * every call is recorded so a probe's argv, cwd, and bounds can be asserted. */ +function fakeIo(results: RunResult[] = [{ status: 0, stdout: '1.4.0\n' }], answers: string[] = ['y']) { + const runs: { bin: string; args: string[]; cwd?: string; opts: RunOpts }[] = []; + const questions: string[] = []; + let i = 0; + const io = { + async readLine(q: string): Promise<string> { + questions.push(q); + return answers.length > 1 ? (answers.shift() as string) : answers[0]; + }, + launch(): RunResult { + throw new Error('the preflight never launches'); + }, + run(bin: string, args: string[], cwd: string | undefined, opts: RunOpts): RunResult { + runs.push({ bin, args, cwd, opts }); + const r = results[Math.min(i, results.length - 1)]; + i++; + return r; + }, + }; + return { io: io as never, runs, questions }; +} + +const byId = (checks: Check[], id: string): Check => { + const c = checks.find((x) => x.id === id); + assert.ok(c, `no ${id} check`); + return c; +}; + +function preflight(dir: string, io: never, opts: { host?: unknown; detected?: unknown[] } = {}): PreflightResult { + return runPreflight({ + root: dir, + manifest: MANIFEST, + host: (opts.host ?? null) as never, + detected: (opts.detected ?? []) as never, + report: detectEcosystem(dir), + io, + }); +} + +// --- hookStatus: one class per ownership ------------------------------------- + +test('hookStatus: an ordinary clone with no hook is personal and absent', () => { + const dir = gitLayer('leji-preflight-hook-'); + const s = hookStatus(dir, ['leji']); + assert.equal(s.ownership, 'personal'); + assert.equal(s.state, 'absent'); + assert.equal(s.path, '.git/hooks/pre-commit'); + assert.equal(s.managed, 'file'); +}); + +test('hookStatus: leji-managed hook reads current; a foreign one reads foreign', () => { + const dir = gitLayer('leji-preflight-hook-current-'); + ensureLocalHook(dir, ['leji']); + assert.equal(hookStatus(dir, ['leji']).state, 'current'); + fs.writeFileSync(path.join(dir, '.git', 'hooks', 'pre-commit'), '#!/bin/sh\necho mine\n'); + const foreign = hookStatus(dir, ['leji']); + assert.equal(foreign.state, 'foreign'); + assert.equal(foreign.ownership, 'personal', 'still per clone, whoever wrote it'); +}); + +test('hookStatus: husky is SHARED (committed), not personal', () => { + const dir = gitLayer('leji-preflight-hook-husky-'); + execFileSync('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: dir }); + const s = hookStatus(dir, ['leji']); + assert.equal(s.ownership, 'shared'); + assert.equal(s.state, 'absent'); + assert.equal(s.path, '.husky/pre-commit'); + assert.equal(s.managed, 'block'); +}); + +test('hookStatus: a core.hooksPath inside the worktree is shared', () => { + const dir = gitLayer('leji-preflight-hook-githooks-'); + execFileSync('git', ['config', 'core.hooksPath', 'githooks'], { cwd: dir }); + const s = hookStatus(dir, ['leji']); + assert.equal(s.ownership, 'shared'); + assert.equal(s.path, 'githooks/pre-commit'); +}); + +test('hookStatus: a hooks path under $HOME is external and reported only', () => { + const dir = gitLayer('leji-preflight-hook-global-'); + const outside = tmpdir('leji-preflight-globalhooks-'); + execFileSync('git', ['config', 'core.hooksPath', outside], { cwd: dir }); + const s = hookStatus(dir, ['leji']); + assert.equal(s.ownership, 'external'); + const c = byId(preflight(dir, fakeIo().io).checks, 'hook'); + assert.equal(c.status, 'missing'); + assert.ok(c.fix && c.fix.join('\n').includes('leji pre-commit (managed)'), 'the snippet is the fix'); + assert.equal(fs.existsSync(path.join(outside, 'pre-commit')), false, 'nothing was written there'); +}); + +test('hookStatus: a linked worktree resolves the shared hooks dir as personal', () => { + const dir = tmpdir('leji-preflight-worktree-'); + const main = path.join(dir, 'main'); + fs.mkdirSync(main, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: main }); + fs.cpSync(exampleDir, main, { recursive: true }); + execFileSync('git', ['add', '-A'], { cwd: main }); + execFileSync('git', ['-c', 'user.email=t@e.com', '-c', 'user.name=T', 'commit', '-qm', 'seed'], { cwd: main }); + execFileSync('git', ['worktree', 'add', '-q', path.join(dir, 'wt')], { cwd: main }); + const wt = path.join(dir, 'wt'); + const s = hookStatus(wt, ['leji']); + // The hooks git runs live in the COMMON dir, outside this worktree. It is still + // per-clone state, but the writer refuses everything outside the repository root, + // so it is reported rather than offered. + assert.equal(s.ownership, 'outside-root'); + assert.equal(s.state, 'absent'); + const row = byId(preflight(wt, fakeIo().io).checks, 'hook'); + assert.equal(row.status, 'missing'); + assert.match(row.detail, /hooks dir is outside this worktree/); + assert.ok(row.fix?.join('\n').includes('leji pre-commit (managed)'), 'the snippet is the fix'); +}); + +test('a linked worktree is never offered the hook, and nothing is written for it', async () => { + const dir = tmpdir('leji-preflight-worktree-offer-'); + const main = path.join(dir, 'main'); + fs.mkdirSync(main, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: main }); + fs.cpSync(exampleDir, main, { recursive: true }); + execFileSync('git', ['add', '-A'], { cwd: main }); + execFileSync('git', ['-c', 'user.email=t@e.com', '-c', 'user.name=T', 'commit', '-qm', 'seed'], { cwd: main }); + execFileSync('git', ['worktree', 'add', '-q', path.join(dir, 'wt')], { cwd: main }); + const wt = path.join(dir, 'wt'); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }], ['y']); + const result = preflight(wt, f.io); + assert.equal(result.hook.ownership, 'outside-root'); + await offerPreflightFixes({ root: wt, host: null, result, runner: ['leji'], interactive: true, io: f.io }); + assert.equal(f.questions.length, 0, 'a target outside the worktree is never offered'); + assert.equal(fs.existsSync(path.join(main, '.git', 'hooks', 'pre-commit')), false); +}); + +test('hookStatus: a directory that is not a git repository answers no-git', () => { + const s = hookStatus(tmpdir('leji-preflight-nogit-'), ['leji']); + assert.equal(s.ownership, 'no-git'); + assert.equal(s.path, ''); +}); + +// --- the version probe -------------------------------------------------------- + +test('cli: an undeclared repository is a SHARED gap carrying the declare command', () => { + const dir = gitLayer('leji-preflight-cli-undeclared-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","packageManager":"pnpm@9.0.0"}\n'); + const { checks, ready } = preflight(dir, fakeIo([{ error: new Error('spawn leji ENOENT') }]).io); + const cli = byId(checks, 'cli'); + assert.equal(cli.status, 'shared-gap'); + assert.deepEqual(cli.fix, ['pnpm add -D @leji-org/leji']); + assert.equal(ready, false, 'a shared cli gap still leaves the clone unready'); +}); + +test('cli: an undeclared repository names an ambient leji as your own install', () => { + const dir = gitLayer('leji-preflight-cli-ambient-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}\n'); + const { checks } = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }]).io); + const cli = byId(checks, 'cli'); + assert.equal(cli.status, 'shared-gap'); + assert.match(cli.detail, /not declared here \(PATH has your own 1\.4\.0\)/); +}); + +test('cli: a declared Node CLI is probed by executing the installed shim, never through a manager', () => { + const dir = gitLayer('leji-preflight-cli-ok-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n'); + const binAbs = installNodeBin(dir); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }]); + const { checks, ready } = preflight(dir, f.io); + const cli = byId(checks, 'cli'); + assert.equal(cli.status, 'ok'); + assert.equal(cli.detail, '1.4.0 (node_modules/.bin/leji)'); + assert.equal(cli.fix, null); + // The shim itself, by absolute path: no `pnpm exec`, no `npx`, no shell. + assert.equal(f.runs[0].bin, binAbs); + assert.deepEqual(f.runs[0].args, ['--version']); + assert.equal(f.runs[0].cwd, path.resolve(dir), 'the probe runs in the repository root'); + assert.equal(f.runs[0].opts.capture, true); + assert.equal(f.runs[0].opts.quiet, true); + assert.equal(f.runs[0].opts.timeoutMs, 10000); + assert.equal(f.runs[0].opts.maxBytes, 4096); + // The environment REPLACES this process's: no inherited PATH, no HOME of the user's. + const env = f.runs[0].opts.env as Record<string, string>; + assert.ok(env, 'the probe passes an environment'); + assert.equal(env.PATH, path.dirname(process.execPath)); + assert.notEqual(env.HOME, process.env.HOME); + for (const leaked of ['NODE_OPTIONS', 'npm_config_registry', 'LD_PRELOAD']) { + assert.equal(env[leaked], undefined, `${leaked} must not reach the probe`); + } + // The clone still has no hook, so one ok row is not readiness. + assert.equal(ready, false); + assert.equal(byId(checks, 'hook').status, 'missing'); +}); + +test('cli: a Node repository with no installed shim is missing, and no manager is ever run', () => { + const dir = gitLayer('leji-preflight-cli-nobin-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), '{"lockfileVersion":3}\n'); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }]); + const cli = byId(preflight(dir, f.io).checks, 'cli'); + assert.equal(cli.status, 'missing'); + assert.equal(cli.detail, 'not installed yet (node_modules/.bin/leji)'); + assert.deepEqual(cli.fix, ['npm install', 'npx --no-install @leji-org/leji --version']); + // Nothing was executed at all: a missing shim is answered from the filesystem. + assert.equal(f.runs.length, 0, 'no probe runs when the shim is absent'); +}); + +test('cli: a shim resolving outside the repository is refused rather than executed', () => { + const dir = gitLayer('leji-preflight-cli-escape-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), '{"lockfileVersion":3}\n'); + const outside = tmpdir('leji-preflight-outside-'); + const target = path.join(outside, 'leji'); + fs.writeFileSync(target, '#!/bin/sh\necho 9.9.9\n', { mode: 0o755 }); + const binDir = path.join(dir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + fs.symlinkSync(target, path.join(binDir, 'leji')); + const f = fakeIo([{ status: 0, stdout: '9.9.9\n' }]); + const cli = byId(preflight(dir, f.io).checks, 'cli'); + assert.equal(cli.status, 'missing', 'a shim pointing out of the repository is not the declared CLI'); + assert.equal(f.runs.length, 0); +}); + +test('cli: uv probes with --no-sync and go probes with the offline, sanitized environment', () => { + const uvDir = gitLayer('leji-preflight-cli-uv-'); + fs.writeFileSync(path.join(uvDir, 'pyproject.toml'), '[project]\nname = "app"\ndependencies = ["leji"]\n'); + fs.writeFileSync(path.join(uvDir, 'uv.lock'), 'version = 1\n'); + const uv = fakeIo([{ status: 0, stdout: '1.4.0\n' }]); + preflight(uvDir, uv.io); + assert.deepEqual(uv.runs[0].args, ['run', '--no-sync', 'leji', '--version'], 'uv never syncs to answer a probe'); + + const goDir = gitLayer('leji-preflight-cli-go-'); + fs.writeFileSync( + path.join(goDir, 'go.mod'), + 'module example.com/app\n\ngo 1.24\n\ntool github.com/leji-org/leji/packages/sdk-go/cmd/leji\n', + ); + const go = fakeIo([{ status: 0, stdout: '1.4.0\n' }]); + preflight(goDir, go.io); + assert.deepEqual(go.runs[0].args, ['tool', 'leji', '--version']); + const goEnv = go.runs[0].opts.env as Record<string, string>; + assert.equal(goEnv.GOFLAGS, '-mod=readonly'); + assert.equal(goEnv.GOTOOLCHAIN, 'local'); + assert.equal(goEnv.GOPROXY, 'off'); + assert.equal(goEnv.GOWORK, 'off', 'a workspace file must not redirect the probe'); + // A manager has to be found on PATH, so PATH and HOME survive; nothing else does. + assert.equal(goEnv.PATH, process.env.PATH); + for (const leaked of ['NODE_OPTIONS', 'LD_PRELOAD', 'GOPRIVATE']) { + assert.equal(goEnv[leaked], undefined, `${leaked} must not reach the probe`); + } +}); + +test('cli: every probe failure fails CLOSED, with the manager install line', () => { + const dir = gitLayer('leji-preflight-cli-closed-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + fs.writeFileSync(path.join(dir, 'package-lock.json'), '{"lockfileVersion":3}\n'); + installNodeBin(dir); + const failures: RunResult[] = [ + { error: new Error('spawn npx ENOENT') }, // never started + { error: new Error('ETIMEDOUT'), status: null, signal: 'SIGTERM' }, // timed out + { status: 1, stdout: '' }, // ran, failed + { status: 0, stdout: 'leji version one\n' }, // malformed + { status: 0, stdout: '\n' }, // empty + ]; + for (const outcome of failures) { + const cli = byId(preflight(dir, fakeIo([outcome]).io).checks, 'cli'); + assert.equal(cli.status, 'missing', JSON.stringify(outcome)); + assert.deepEqual(cli.fix, ['npm install', 'npx --no-install @leji-org/leji --version']); + } +}); + +test('cli: a resolved CLI below the spec line minimum is missing, not ok', () => { + const dir = gitLayer('leji-preflight-cli-old-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + installNodeBin(dir); + const cli = byId(preflight(dir, fakeIo([{ status: 0, stdout: '0.9.3\n' }]).io).checks, 'cli'); + assert.equal(cli.status, 'missing'); + assert.match(cli.detail, /is below 1\.0\.0 for spec 1\.0/); +}); + +// --- MCP rows ----------------------------------------------------------------- + +test('mcp: registered for the selected host is ok; unregistered offers the USER scope', () => { + const dir = gitLayer('leji-preflight-mcp-'); + const ok = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 0 }]).io, { + host: CLAUDE_HOST, + detected: [DETECTED_CLAUDE], + }); + assert.equal(byId(ok.checks, 'mcp').status, 'ok'); + + const missing = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 1 }]).io, { + host: CLAUDE_HOST, + detected: [DETECTED_CLAUDE], + }); + const row = byId(missing.checks, 'mcp'); + assert.equal(row.status, 'missing'); + assert.deepEqual(row.fix, ['claude mcp add leji --scope user -- npx -y @leji-org/mcp']); +}); + +test('mcp: Codex registers at user level, its only scope', () => { + const dir = gitLayer('leji-preflight-mcp-codex-'); + const { checks } = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 1 }]).io, { + host: CODEX_HOST, + detected: [DETECTED_CODEX], + }); + assert.deepEqual(byId(checks, 'mcp').fix, ['codex mcp add leji -- npx -y @leji-org/mcp']); + assert.equal(byId(checks, 'mcp-shared').status, 'n/a', 'Codex has no shared form'); +}); + +test('mcp: several hosts and no pick is unresolved, naming them and the flag', () => { + const dir = gitLayer('leji-preflight-mcp-unresolved-'); + const { checks } = preflight(dir, fakeIo().io, { detected: [DETECTED_CLAUDE, DETECTED_CODEX] }); + const row = byId(checks, 'mcp'); + assert.equal(row.status, 'unresolved'); + assert.match(row.detail, /Claude Code, Codex/); + assert.deepEqual(row.fix, ['leji start --agent <name>']); +}); + +test('mcp: a host leji cannot register for gets the standard config and its path', () => { + const dir = gitLayer('leji-preflight-mcp-other-'); + const { checks } = preflight(dir, fakeIo().io, { detected: [DETECTED_CURSOR] }); + const row = byId(checks, 'mcp'); + assert.equal(row.status, 'missing'); + assert.equal(row.fix?.[0], '.cursor/mcp.json (project scope)'); + assert.ok(row.fix?.join('\n').includes('"@leji-org/mcp"')); +}); + +test("mcp: the printed block takes the shape the host's own config file uses", () => { + const dir = gitLayer('leji-preflight-mcp-shape-'); + // VS Code, which is how GitHub Copilot reads MCP servers, spells the map + // `servers`; pasting the common `mcpServers` block into .vscode/mcp.json leaves + // the editor with a file it ignores. + const copilot = byId(preflight(dir, fakeIo().io, { detected: [DETECTED_COPILOT] }).checks, 'mcp'); + assert.equal(copilot.status, 'missing'); + assert.deepEqual(copilot.fix, [ + '.vscode/mcp.json (project scope)', + '{', + ' "servers": {', + ' "leji": { "command": "npx", "args": ["-y", "@leji-org/mcp"] }', + ' }', + '}', + ]); + // Every other host Leji cannot register for takes the common shape. + const cursor = byId(preflight(dir, fakeIo().io, { detected: [DETECTED_CURSOR] }).checks, 'mcp'); + assert.ok(cursor.fix?.includes(' "mcpServers": {'), cursor.fix?.join('\n')); +}); + +test('mcp: no detected host at all is skipped and never counts against ready', () => { + const dir = gitLayer('leji-preflight-mcp-none-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + installNodeBin(dir); + ensureLocalHook(dir, ['leji']); + const { checks, ready } = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }]).io); + assert.equal(byId(checks, 'mcp').status, 'skipped'); + assert.equal(ready, true); +}); + +test('mcp-shared: a committed .mcp.json is ok, its absence a shared gap for a maintainer', () => { + const dir = gitLayer('leji-preflight-mcp-shared-'); + const absent = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 0 }]).io, { + host: CLAUDE_HOST, + detected: [DETECTED_CLAUDE], + }); + const gap = byId(absent.checks, 'mcp-shared'); + assert.equal(gap.status, 'shared-gap'); + assert.deepEqual(gap.fix, ['claude mcp add leji --scope project -- npx -y @leji-org/mcp']); + assert.equal(absent.ready, false, 'ready is decided by cli, mcp and hook'); + + fs.writeFileSync(path.join(dir, '.mcp.json'), '{"mcpServers":{}}\n'); + const present = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 0 }]).io, { + host: CLAUDE_HOST, + detected: [DETECTED_CLAUDE], + }); + assert.equal(byId(present.checks, 'mcp-shared').status, 'ok'); +}); + +test('mcp-shared never decides ready on its own', () => { + const dir = gitLayer('leji-preflight-ready-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + installNodeBin(dir); + ensureLocalHook(dir, ['npx', '--no-install', '@leji-org/leji']); + const { checks, ready } = preflight(dir, fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 0 }]).io, { + host: CLAUDE_HOST, + detected: [DETECTED_CLAUDE], + }); + assert.equal(byId(checks, 'mcp-shared').status, 'shared-gap'); + assert.equal(ready, true, 'cli, mcp and hook are all ok'); +}); + +// --- the report --------------------------------------------------------------- + +test('the checks are always the same four ids, in the same order', () => { + const dir = gitLayer('leji-preflight-order-'); + const { checks } = preflight(dir, fakeIo().io, { host: CLAUDE_HOST, detected: [DETECTED_CLAUDE] }); + assert.deepEqual( + checks.map((c) => c.id), + ['cli', 'mcp', 'mcp-shared', 'hook'], + ); + // Every check carries the render-only fix kind; the document projection drops it. + for (const c of checks) assert.deepEqual(Object.keys(c), ['id', 'status', 'detail', 'fix', 'fixKind']); +}); + +test('the document projection publishes exactly the four keys, whatever the check carries', () => { + const dir = gitLayer('leji-preflight-json-'); + const { checks } = preflight(dir, fakeIo().io, { host: CLAUDE_HOST, detected: [DETECTED_CLAUDE] }); + for (const c of checks) { + const doc = checkDocument(c); + assert.deepEqual(Object.keys(doc), ['id', 'status', 'detail', 'fix']); + assert.deepEqual(doc, { id: c.id, status: c.status, detail: c.detail, fix: c.fix }); + assert.equal(JSON.stringify(doc).includes('fixKind'), false); + } +}); + +// --- the Setup block ------------------------------------------------------------ +// The three scenarios the layout was cut against, as exact bytes: every other SDK +// prints these same strings, so the render is pinned here rather than described. + +/** A check exactly as the internal constructors build it. */ +function checkRow( + id: Check['id'], + status: Check['status'], + detail: string, + fix: string[] | null = null, + fixKind: 'command' | 'snippet' = 'command', +): Check { + return { id, status, detail, fix, fixKind }; +} + +const MCP_USER = 'claude mcp add leji --scope user -- npx -y @leji-org/mcp'; +const MCP_PROJECT = 'claude mcp add leji --scope project -- npx -y @leji-org/mcp'; + +/** Nothing is this clone's to fix: the CLI, the shared server and the hook are all the + * repository's own state. */ +const ALL_TEAM: Check[] = [ + checkRow('cli', 'shared-gap', 'not declared in this repository', ['npm i -D @leji-org/leji']), + checkRow('mcp', 'ok', 'registered for Claude Code'), + checkRow('mcp-shared', 'shared-gap', 'no .mcp.json committed', [MCP_PROJECT]), + checkRow('hook', 'shared-gap', 'no leji block in .husky/pre-commit', ['leji ci --hooks']), +]; + +/** The CLI and the hook are the maintainer's; both MCP registrations are already there. */ +const TEAM_CLI_AND_HOOK: Check[] = [ + checkRow('cli', 'shared-gap', 'not declared here (PATH has your own 1.4.0)', ['npm i -D @leji-org/leji']), + checkRow('mcp', 'ok', 'registered for Claude Code'), + checkRow('mcp-shared', 'ok', '.mcp.json committed'), + checkRow('hook', 'shared-gap', 'no leji block in .husky/pre-commit', ['leji ci --hooks']), +]; + +/** One fix each: this user's own registration, and the one a maintainer commits. */ +const PERSONAL_AND_TEAM_MCP: Check[] = [ + checkRow('cli', 'ok', '1.4.0 (node_modules/.bin/leji)'), + checkRow('mcp', 'missing', 'not registered for Claude Code', [MCP_USER]), + checkRow('mcp-shared', 'shared-gap', 'no .mcp.json committed', [MCP_PROJECT]), + checkRow('hook', 'ok', 'runs leji checks before each commit: .git/hooks/pre-commit'), +]; + +const SCENARIOS: [string, Check[], string][] = [ + [ + 'every gap belongs to a maintainer', + ALL_TEAM, + [ + 'Setup for this clone', + '', + ' team Leji CLI not declared in this repository', + ' $ npm i -D @leji-org/leji', + ' ok MCP server registered for Claude Code', + ' team Team MCP no .mcp.json committed', + ` $ ${MCP_PROJECT}`, + ' team Git hook no leji block in .husky/pre-commit', + ' $ leji ci --hooks', + '', + ' 3 fixes for a maintainer. The agent starts either way.', + ].join('\n'), + ], + [ + "the CLI and the hook are a maintainer's, both MCP rows ok", + TEAM_CLI_AND_HOOK, + [ + 'Setup for this clone', + '', + ' team Leji CLI not declared here (PATH has your own 1.4.0)', + ' $ npm i -D @leji-org/leji', + ' ok MCP server registered for Claude Code', + ' ok Team MCP .mcp.json committed', + ' team Git hook no leji block in .husky/pre-commit', + ' $ leji ci --hooks', + '', + ' 2 fixes for a maintainer. The agent starts either way.', + ].join('\n'), + ], + [ + 'one fix for this user and one for a maintainer', + PERSONAL_AND_TEAM_MCP, + [ + 'Setup for this clone', + '', + ' ok Leji CLI 1.4.0 (node_modules/.bin/leji)', + ' you MCP server not registered for Claude Code', + ` $ ${MCP_USER}`, + ' team Team MCP no .mcp.json committed', + ` $ ${MCP_PROJECT}`, + ' ok Git hook runs leji checks before each commit: .git/hooks/pre-commit', + '', + ' 1 fix for you, 1 for a maintainer. The agent starts either way.', + ].join('\n'), + ], +]; + +for (const [name, checks, expected] of SCENARIOS) { + test(`renderPreflight: ${name}`, () => { + const block = renderPreflight(checks); + assert.equal(block, expected); + assert.doesNotMatch(block, /[–—]/, 'no en or em dash reaches the terminal'); + }); +} + +test('no row of any scenario wraps at 80 columns', () => { + for (const [name, checks] of SCENARIOS) { + for (const l of renderPreflight(checks).split('\n')) { + // Commands and snippets are exact and exempt: they are what a person pastes. + if (l.startsWith(' ')) continue; + assert.ok(l.length <= 80, `${name}: ${l.length} columns: ${l}`); + } + } +}); + +test('renderPreflight: a snippet fix is pasted as it stands, a command carries the prompt', () => { + const snippet = renderPreflight([ + checkRow('hook', 'missing', 'add it yourself; hooks run from /etc/hooks', ['#!/bin/sh', 'leji ci'], 'snippet'), + ]); + assert.ok(snippet.includes('\n #!/bin/sh\n leji ci\n'), snippet); + // A four-field check, the shape an older caller passes, still renders as a command. + const legacy = renderPreflight([ + { id: 'hook', status: 'missing', detail: 'none yet (per clone)', fix: ['leji ci --hooks'] }, + ]); + assert.ok(legacy.includes('\n $ leji ci --hooks\n'), legacy); +}); + +test('renderPreflight: nothing owed is one closing line', () => { + const block = renderPreflight([ + checkRow('cli', 'ok', '1.4.0 (node_modules/.bin/leji)'), + checkRow('mcp', 'skipped', 'no coding agent detected'), + ]); + assert.equal(block.split('\n').pop(), ' Setup complete.'); +}); + +// --- the color convention ------------------------------------------------------- + +test('color off is the default, and leaves not one escape byte', () => { + for (const [, checks] of SCENARIOS) { + assert.equal(renderPreflight(checks).includes('\x1b'), false); + assert.equal(renderPreflight(checks, {}).includes('\x1b'), false); + assert.equal(renderPreflight(checks, { color: false }).includes('\x1b'), false); + } +}); + +test('color on wraps the status word only, and the columns do not move', () => { + const block = renderPreflight( + [ + checkRow('cli', 'ok', '1.4.0 (node_modules/.bin/leji)'), + checkRow('mcp', 'missing', 'not registered for Claude Code'), + checkRow('mcp-shared', 'shared-gap', 'no .mcp.json committed'), + checkRow('hook', 'n/a', 'not a git repository'), + ], + { color: true }, + ); + assert.equal( + block, + [ + 'Setup for this clone', + '', + ' \x1b[32mok\x1b[0m Leji CLI 1.4.0 (node_modules/.bin/leji)', + ' \x1b[33myou\x1b[0m MCP server not registered for Claude Code', + ' \x1b[36mteam\x1b[0m Team MCP no .mcp.json committed', + ' \x1b[2mn/a\x1b[0m Git hook not a git repository', + '', + ' 1 fix for you, 1 for a maintainer. The agent starts either way.', + ].join('\n'), + ); +}); + +test('colorDecision: a terminal that has not asked for plain text, and nothing else', () => { + const cases: [boolean, NodeJS.ProcessEnv, boolean][] = [ + [true, {}, true], + [false, {}, false], + [true, { NO_COLOR: '1' }, false], + [true, { NO_COLOR: '' }, false], + [false, { NO_COLOR: '' }, false], + [true, { TERM: 'dumb' }, false], + [true, { TERM: 'xterm-256color' }, true], + [false, { TERM: 'xterm-256color' }, false], + ]; + for (const [isTTY, env, expected] of cases) { + assert.equal(colorDecision(isTTY, env), expected, `${isTTY} ${JSON.stringify(env)}`); + } +}); + +// --- the consented repairs ---------------------------------------------------- + +test('offerPreflightFixes writes nothing non-interactively, however many gaps there are', async () => { + const dir = gitLayer('leji-preflight-offer-none-'); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 1 }]); + const result = preflight(dir, f.io, { host: CLAUDE_HOST, detected: [DETECTED_CLAUDE] }); + const before = f.runs.length; + await offerPreflightFixes({ + root: dir, + host: CLAUDE_HOST as never, + result, + runner: ['leji'], + interactive: false, + io: f.io, + }); + assert.equal(f.questions.length, 0, 'nothing is asked'); + assert.equal(f.runs.length, before, 'nothing is run'); + assert.equal(fs.existsSync(path.join(dir, '.git', 'hooks', 'pre-commit')), false); +}); + +test('offerPreflightFixes registers the personal MCP scope and installs the clone hook, in that order', async () => { + const dir = gitLayer('leji-preflight-offer-yes-'); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 1 }, { status: 0 }], ['y']); + const result = preflight(dir, f.io, { host: CLAUDE_HOST, detected: [DETECTED_CLAUDE] }); + await offerPreflightFixes({ + root: dir, + host: CLAUDE_HOST as never, + result, + runner: ['leji'], + interactive: true, + io: f.io, + }); + assert.equal(f.questions.length, 2, 'the MCP registration, then the hook'); + assert.match(f.questions[0], /Register the Leji MCP server for Claude Code/); + assert.match(f.questions[1], /Install the pre-commit hook/); + const registration = f.runs[f.runs.length - 1]; + assert.deepEqual(registration.args, ['mcp', 'add', 'leji', '--scope', 'user', '--', 'npx', '-y', '@leji-org/mcp']); + assert.ok(fs.existsSync(path.join(dir, '.git', 'hooks', 'pre-commit')), 'the clone hook was written'); +}); + +test('offerPreflightFixes never offers a SHARED gap: a husky repo is reported only', async () => { + const dir = gitLayer('leji-preflight-offer-shared-'); + execFileSync('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: dir }); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }], ['y']); + const result = preflight(dir, f.io, { detected: [] }); + assert.equal(byId(result.checks, 'hook').status, 'shared-gap'); + await offerPreflightFixes({ root: dir, host: null, result, runner: ['leji'], interactive: true, io: f.io }); + assert.equal(f.questions.length, 0, 'a committed hook is a maintainer decision'); + assert.equal(fs.existsSync(path.join(dir, '.husky', 'pre-commit')), false); +}); + +test('offerPreflightFixes declines cleanly and writes nothing', async () => { + const dir = gitLayer('leji-preflight-offer-no-'); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 1 }], ['n']); + const result = preflight(dir, f.io, { host: CLAUDE_HOST, detected: [DETECTED_CLAUDE] }); + await offerPreflightFixes({ + root: dir, + host: CLAUDE_HOST as never, + result, + runner: ['leji'], + interactive: true, + io: f.io, + }); + assert.equal(f.questions.length, 2); + assert.equal(fs.existsSync(path.join(dir, '.git', 'hooks', 'pre-commit')), false); +}); + +test('a second run of an all-ok clone reports ok and offers nothing', async () => { + const dir = gitLayer('leji-preflight-idempotent-'); + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app","devDependencies":{"@leji-org/leji":"^1"}}\n'); + fs.writeFileSync(path.join(dir, '.mcp.json'), '{"mcpServers":{}}\n'); + installNodeBin(dir); + ensureLocalHook(dir, ['npx', '--no-install', '@leji-org/leji']); + const f = fakeIo([{ status: 0, stdout: '1.4.0\n' }, { status: 0 }], ['y']); + const result = preflight(dir, f.io, { host: CLAUDE_HOST, detected: [DETECTED_CLAUDE] }); + assert.deepEqual( + result.checks.map((c) => c.status), + ['ok', 'ok', 'ok', 'ok'], + ); + assert.equal(result.ready, true); + await offerPreflightFixes({ + root: dir, + host: CLAUDE_HOST as never, + result, + runner: ['leji'], + interactive: true, + io: f.io, + }); + assert.equal(f.questions.length, 0); +}); + +// --- the capture bounds, against a real child --------------------------------- +// The probe's two guarantees are about THIS process: nothing of its environment +// reaches the child, and nothing the child prints can grow past the cap or outlast +// the deadline. Both are exercised against real programs, not fakes. + +const CAPTURE_CANARY = 'LEJI_PROBE_CANARY'; + +/** An executable `/bin/sh` stub, and its path. */ +function captureStub(dir: string, name: string, body: string): string { + const abs = path.join(dir, name); + fs.writeFileSync(abs, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + return abs; +} + +const capture = (bin: string, cwd: string, maxBytes: number, env?: Record<string, string>, timeoutMs = 10_000) => + defaultHandoffIo().run(bin, [], cwd, { quiet: true, capture: true, timeoutMs, maxBytes, env }); + +/** + * The deadline a run that must NOT reach it is given: generous enough that a loaded + * runner cannot trip it, so finishing early can only mean the cap cut the child off. + */ +const OVERFLOW_TIMEOUT_MS = 30_000; +/** + * The bound a terminated run has to finish inside: far below the deadline above, and + * far above anything scheduling delay on a busy machine can add. What it proves is + * which mechanism ended the run, not how fast the machine is. + */ +const PROMPT_MS = 15_000; +/** + * 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 + * not because the child happened to exit. + */ +const STUB_HOLD = 'sleep 60'; + +test('capture replaces the environment rather than extending it', () => { + const dir = tmpdir('leji-capture-env-'); + const stub = captureStub(dir, 'echo-canary', `printf '%s' "\${${CAPTURE_CANARY}:-}"`); + const before = process.env[CAPTURE_CANARY]; + process.env[CAPTURE_CANARY] = 'leaked'; + try { + const res = capture(stub, dir, 4096, { PATH: '/usr/bin:/bin' }); + assert.equal(res.error, undefined); + assert.equal(res.stdout, '', `the parent's ${CAPTURE_CANARY} reached the probe`); + // The positive control: what the caller names IS present, so the empty result + // above is replacement rather than a stub that cannot see any environment. + const kept = capture(stub, dir, 4096, { PATH: '/usr/bin:/bin', [CAPTURE_CANARY]: 'named' }); + assert.equal(kept.stdout, 'named'); + } finally { + if (before === undefined) delete process.env[CAPTURE_CANARY]; + else process.env[CAPTURE_CANARY] = before; + } +}); + +test('capture kills a child that streams past the cap, well inside the timeout', () => { + const dir = tmpdir('leji-capture-cap-'); + // 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. + 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`, + ); +}); + +test('capture ends a sparse overflow promptly', () => { + // One byte past the cap, then a child that holds stdout open and does nothing. The + // overflow has to be decided from that single byte, not from a full buffer or from + // EOF, or the run would sit until the deadline. + const dir = tmpdir('leji-capture-sparse-'); + const cap = 64; + const stub = captureStub(dir, 'trickle', `printf '%${cap + 1}s' ''\n${STUB_HOLD}`); + const started = Date.now(); + const res = capture(stub, dir, cap, { PATH: '/usr/bin:/bin' }, OVERFLOW_TIMEOUT_MS); + const elapsed = Date.now() - started; + // What Node actually does here: ENOBUFS, the child terminated by signal, and the + // bytes it had already read kept. It stops at the read that crossed the cap, so the + // held output is bounded by the cap plus at most one read rather than by the cap + // exactly; the run is a failed probe either way and those bytes are never parsed. + assert.equal((res.error as NodeJS.ErrnoException | undefined)?.code, 'ENOBUFS'); + assert.notEqual(res.signal, null, 'the child is terminated, not left running'); + assert.ok((res.stdout ?? '').length <= cap + 4096, `held ${(res.stdout ?? '').length} bytes`); + assert.ok( + elapsed < PROMPT_MS, + `a sparse overflow waited for the deadline: ${elapsed}ms, against a ${OVERFLOW_TIMEOUT_MS}ms deadline`, + ); +}); + +test('capture cap boundary: exactly the cap is not overflow, one past it is', () => { + const dir = tmpdir('leji-capture-boundary-'); + const cap = 64; + for (const [size, capped] of [ + [cap - 1, false], + [cap, false], + [cap + 1, true], + ] as const) { + const stub = captureStub(dir, `size-${size}`, `printf '%${size}s' ''`); + const res = capture(stub, dir, cap, { PATH: '/usr/bin:/bin' }); + assert.equal(Boolean(res.error), capped, `${size} bytes: error=${res.error?.message}`); + if (!capped) assert.equal((res.stdout ?? '').length, size, `${size} bytes held`); + } +}); + +test('capture times out a child that never finishes', () => { + const dir = tmpdir('leji-capture-timeout-'); + const stub = captureStub(dir, 'hang', STUB_HOLD); + const started = Date.now(); + const res = capture(stub, dir, 4096, { PATH: '/usr/bin:/bin' }, 500); + const elapsed = Date.now() - started; + assert.ok(res.error || res.signal != null, 'the deadline ends the run'); + // Here the deadline IS the mechanism under test; the bound only has to separate it + // from the child's own 60s, with room for a loaded runner. + assert.ok(elapsed < PROMPT_MS, `the timeout did not end the run (${elapsed}ms)`); +}); diff --git a/packages/sdk/test/proc/cli.test.ts b/packages/sdk/test/proc/cli.test.ts index 515d10e..bb54264 100644 --- a/packages/sdk/test/proc/cli.test.ts +++ b/packages/sdk/test/proc/cli.test.ts @@ -214,6 +214,98 @@ test('cli index generate writes and reports entries', async () => { assert.equal(payload.entries, 3); }); +// The generate run's closing nudge. The line is spec-pinned byte for byte and +// identical in all three SDKs, so it is asserted as an exact string, never a +// pattern; the zero case is asserted as absence. +const UNINDEXED_LINE = (n: number): string => + `${n} file(s) unindexed: add to a category index or leave as reference deliberately`; + +test('cli index generate reports the unindexed count as its last line', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-cli-unindexed-')); + fs.cpSync(exampleDir, dir, { recursive: true }); + // Two markdown files under the governed root that no category index lists. + fs.mkdirSync(path.join(dir, 'docs', 'notes'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'docs', 'notes', 'loose.md'), '# Loose\n'); + fs.writeFileSync(path.join(dir, 'docs', 'stray.md'), '# Stray\n'); + const result = await runCli(['index', '--root', dir]); + // A nudge, never a gate: the count does not move the exit code. + assert.equal(result.code, 0); + const lines = result.stdout.split('\n'); + assert.equal(lines[lines.length - 1], UNINDEXED_LINE(2)); +}); + +test('cli index generate is quiet when nothing is unindexed', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-cli-indexed-')); + fs.cpSync(exampleDir, dir, { recursive: true }); + const result = await runCli(['index', '--root', dir]); + assert.equal(result.code, 0); + assert.ok(!result.stdout.includes('unindexed'), `no nudge at zero, got: ${result.stdout}`); + const lines = result.stdout.split('\n'); + assert.match(lines[lines.length - 1], /^ok \(/); +}); + +test('cli index --check is unaffected by the unindexed count', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-cli-unindexed-check-')); + fs.cpSync(exampleDir, dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'docs', 'stray.md'), '# Stray\n'); + const result = await runCli(['index', '--check', '--root', dir]); + assert.equal(result.code, 0); + assert.ok(!result.stdout.includes('unindexed'), `--check stays silent, got: ${result.stdout}`); +}); + +/** True when `key` appears anywhere in the document, at any depth. Checking the + * whole tree rather than the top level alone is what makes the JSON assertion + * below hold against a field added later inside summary or a future extra. */ +function hasKeyDeep(value: unknown, key: string): boolean { + if (Array.isArray(value)) return value.some((v) => hasKeyDeep(v, key)); + if (value !== null && typeof value === 'object') { + const obj = value as Record<string, unknown>; + return key in obj || Object.values(obj).some((v) => hasKeyDeep(v, key)); + } + return false; +} + +test('cli index --json carries no trailing nudge line', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-cli-unindexed-json-')); + fs.cpSync(exampleDir, dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'docs', 'stray.md'), '# Stray\n'); + const result = await runCli(['index', '--root', dir, '--json']); + assert.equal(result.code, 0); + // One document and nothing after it: the payload must still parse whole. + const payload = JSON.parse(result.stdout); + assert.equal(payload.written, 'docs/context-index.json'); + // The nudge is text-mode only. The count is not part of the index run's + // contract, so no consumer may start reading it off this document — not at + // the top level, not tucked into summary or a later extra. + assert.ok(!hasKeyDeep(payload, 'unindexed'), `--json must carry no unindexed field, got: ${result.stdout}`); +}); + +test('cli index generate prints no nudge when the index write fails', async () => { + // Root bypasses permission bits, so the write would succeed; skip there. + if (typeof process.getuid === 'function' && process.getuid() === 0) return; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-cli-unindexed-denied-')); + fs.cpSync(exampleDir, dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'docs', 'stray.md'), '# Stray\n'); + // The nudge would have something to say here: the count is nonzero, so the + // silence below is the operational failure's doing and not an empty set. + const before = await runCli(['status', '--root', dir, '--json']); + assert.ok(JSON.parse(before.stdout).unindexed.length > 0); + const target = path.join(dir, 'docs', 'context-index.json'); + fs.chmodSync(target, 0o444); + try { + const result = await runCli(['index', '--root', dir]); + // An operational failure surfaces its error and nothing else: generation + // never completed, so the layer has no count worth reporting. + assert.equal(result.code, 2); + assert.match(result.stderr, /^leji: /); + assert.match(result.stderr, /context-index\.json/); + assert.match(result.stderr, /permission denied/i); + assert.ok(!result.stdout.includes('unindexed'), `no nudge on a failed write, got: ${result.stdout}`); + } finally { + fs.chmodSync(target, 0o644); // restore so the temp tree can be cleaned up + } +}); + test('cli.json documents exactly the commands the CLI accepts', async () => { const cli = JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages', 'sdk', 'cli.json'), 'utf8')); const documented: string[] = cli.commands.map((c: { name: string }) => c.name).sort(); @@ -230,7 +322,7 @@ test('cli.json documents exactly the commands the CLI accepts', async () => { ? ['--keep', '1'] : name === 'agent' ? ['--host', 'codex', '--name', 'reviewer'] - : name === 'mounts locate' + : name === 'mounts locate' || name === 'mounts update-pin' ? ['some-mount'] : []; const result = await runCli([...argv, '--root', dir, ...extra]); @@ -244,17 +336,20 @@ test('cli.json documents exactly the commands the CLI accepts', async () => { assert.deepEqual(documented, [ 'adopt', 'agent', + 'badge', 'changelog check', 'changelog compact', 'ci', 'conformance', 'detect', + 'export', 'freshness', 'index', 'init', 'mounts hydrate', 'mounts locate', 'mounts status', + 'mounts update-pin', 'route', 'start', 'status', @@ -266,13 +361,22 @@ test('cli.json documents exactly the commands the CLI accepts', async () => { ]); }); -test('cli --help renders from cli.json (commands and the reference link)', async () => { +test('cli --help renders from cli.json (grouped commands, exit codes, and the reference link)', async () => { const help = await runCli(['--help']); assert.equal(help.code, 0); const cli = JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages', 'sdk', 'cli.json'), 'utf8')); for (const c of cli.commands) { assert.ok(help.stdout.includes(c.name), `help lists ${c.name}`); } + // Every group is a section of its own, and an alias sits under its primary + // instead of taking a row. + for (const g of cli.groups) assert.ok(help.stdout.includes(`\n${g.title}:\n`), `help sections ${g.title}`); + const nameCol = Math.max(...cli.commands.map((c: { name: string }) => c.name.length)) + 3; + for (const c of cli.commands.filter((c: { aliasOf?: string }) => c.aliasOf)) { + assert.ok(help.stdout.includes(` ${c.name.padEnd(nameCol)}(alias of ${c.aliasOf})`), `help folds ${c.name}`); + } + assert.match(help.stdout, /\nExit codes:\n/); + for (const e of cli.exitCodes) assert.ok(help.stdout.includes(` ${e.code} `), `help lists exit ${e.code}`); assert.match(help.stdout, /leji\.org\/cli/); }); @@ -289,13 +393,16 @@ test('cli --help is the trimmed top level: globals only, no per-command flags, p assert.match(help.stdout, /Run `leji <command> --help`/); }); -test('cli <command> --help renders that command: usage, its flags, and examples', async () => { +test('cli <command> --help renders that command: usage, its own flags, and examples', async () => { const help = await runCli(['adopt', '--help']); assert.equal(help.code, 0); assert.match(help.stdout, /^leji adopt: /); assert.match(help.stdout, /Usage: leji adopt /); assert.match(help.stdout, /--wire-adapters/); // command-specific flag - assert.match(help.stdout, /--root <dir>/); // globals included + // The globals are named once, at the top level: repeating them in every command + // was the bulk of the old per-command help. + assert.ok(!help.stdout.includes(' --root <dir>'), 'globals are not repeated per command'); + assert.match(help.stdout, /\nGlobal options: see leji --help\.\n/); assert.match(help.stdout, /Examples:/); assert.match(help.stdout, /leji\.org\/cli/); }); diff --git a/packages/sdk/test/proc/handoff.test.ts b/packages/sdk/test/proc/handoff.test.ts new file mode 100644 index 0000000..04771bc --- /dev/null +++ b/packages/sdk/test/proc/handoff.test.ts @@ -0,0 +1,322 @@ +import { strict as assert } from 'node:assert'; +import { type SpawnSyncReturns, spawnSync } from 'node:child_process'; +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 { SDK_VERSION } from '../../dist/index.js'; + +// The hand-off end to end, through the real bin: a seeded repository from +// `fixtures/handoff/`, the installed CLI a marker that prints the argv it received, +// and the assertion that the marker ran (or that the global did). The decision table +// itself is unit-level in `test/localcli.test.ts`; what is proved here is that the +// executable performs it, that a real argv survives the crossing byte for byte, and +// that the child's exit status and signal are what the shell sees. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(testDir, '..', '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const cli = path.join(pkgRoot, 'dist', 'cli.js'); +const fixturesDir = path.join(repoRoot, 'fixtures', 'handoff'); + +function tmpdir(prefix = 'leji-handoff-proc-'): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** The installed CLI these runs must reach: it prints its own argv JSON-encoded, so + * argument boundaries are proved rather than inferred, and exits 3, a status no leji + * command returns. */ +const MARKER = + "#!/usr/bin/env node\nprocess.stdout.write('handoff:node:' + JSON.stringify(process.argv.slice(2)) + '\\n');\nprocess.exit(3);\n"; + +/** The same marker, ending in a signal instead of a status. */ +const SIGNAL_MARKER = "#!/usr/bin/env node\nprocess.kill(process.pid, 'SIGTERM');\nsetTimeout(() => {}, 5000);\n"; + +/** A target that passes every check and still cannot be executed: its interpreter + * does not exist. This is what a target REMOVED or stripped of its executable bit + * between the check and the spawn leaves behind, deterministically, without a test + * having to win the race itself. */ +const UNRUNNABLE = '#!/nonexistent/interpreter\n'; + +/** + * The shapes a package manager's bin shim actually takes: npm, bun and Yarn's + * node-modules linker install a SYMLINK to the package entry, pnpm a small SCRIPT + * that runs it. Both are executed here as installed, so the hand-off is proved + * through the real thing rather than through one convenient shape. + */ +type ShimShape = 'symlink' | 'script'; + +/** The installed state a committed fixture cannot carry, written statically here. */ +function install(dir: string, body = MARKER, entryIsSelf = false, shim: ShimShape = 'symlink'): void { + const pkgDir = path.join(dir, 'node_modules', '@leji-org', 'leji'); + const entry = path.join(pkgDir, 'dist', 'cli.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + if (entryIsSelf) fs.symlinkSync(cli, entry); + else fs.writeFileSync(entry, body, { mode: 0o755 }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: '@leji-org/leji', version: '1.4.0', bin: { leji: 'dist/cli.js' } }, null, 2) + '\n', + ); + installShim(dir, entry, shim); +} + +function installShim(dir: string, entry: string, shape: ShimShape): void { + const binDir = path.join(dir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const target = path.join(binDir, 'leji'); + fs.rmSync(target, { force: true }); + if (shape === 'symlink') { + fs.symlinkSync(path.relative(binDir, entry), target); + } else { + fs.writeFileSync(target, `#!/bin/sh\nexec node "$(dirname "$0")/${path.relative(binDir, entry)}" "$@"\n`, { + mode: 0o755, + }); + } +} + +function seed(name: string, body = MARKER, entryIsSelf = false, shim: ShimShape = 'symlink'): string { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, name), dir, { recursive: true }); + if (name !== 'node-declared-missing' && name !== 'go-tool') install(dir, body, entryIsSelf, shim); + return dir; +} + +interface Result { + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +/** The real bin, in its own process, so the exit status and any signal are the + * process's own rather than a return value. A generous timeout, because a hand-off + * that recursed would otherwise hang the suite instead of failing it. */ +function leji(args: string[], opts: { cwd?: string; env?: Record<string, string> } = {}): Result { + const r: SpawnSyncReturns<string> = spawnSync(process.execPath, [cli, ...args], { + cwd: opts.cwd ?? repoRoot, + env: { ...process.env, ...opts.env }, + encoding: 'utf8', + timeout: 30_000, + }); + assert.equal(r.error, undefined, `spawning the CLI failed: ${r.error?.message}`); + return { status: r.status, signal: r.signal, stdout: r.stdout, stderr: r.stderr }; +} + +/** What the marker prints for one argv. */ +function marker(argv: string[]): string { + return `handoff:node:${JSON.stringify(argv)}\n`; +} + +test('an eligible repository runs its own CLI, with the argv it was given', () => { + const dir = seed('node-eligible'); + const argv = ['--root', dir, '--version']; + const r = leji(argv); + assert.equal(r.stdout, marker(argv)); + assert.equal(r.status, 3, 'the child status is the one that surfaces'); + assert.equal(r.stderr, ''); +}); + +test('the argv crosses verbatim: --json, tokens after --, empty, spaced and unicode', () => { + const dir = seed('node-eligible'); + const argv = ['start', '--root', dir, '--json', '--', '--root', 'not-a-root', '', ' ', 'ünïcødé', '--']; + assert.equal(leji(argv).stdout, marker(argv)); +}); + +test('the cwd decides the root when no --root is given, and a nested cwd does not', () => { + const dir = seed('node-eligible'); + assert.equal(leji(['--version'], { cwd: dir }).stdout, marker(['--version'])); + const nested = path.join(dir, 'docs', 'context'); + fs.mkdirSync(nested, { recursive: true }); + // No upward walk: the root is where the invocation points, never where a layer + // happens to be found above it. + assert.equal(leji(['--version'], { cwd: nested }).stdout, `${SDK_VERSION}\n`); +}); + +test('LEJI_NO_LOCAL runs the global at any value, and only when it is set', () => { + const dir = seed('node-eligible'); + for (const value of ['', '0', '1']) { + const r = leji(['--version'], { cwd: dir, env: { LEJI_NO_LOCAL: value } }); + assert.equal(r.stdout, `${SDK_VERSION}\n`, `LEJI_NO_LOCAL=${JSON.stringify(value)}`); + assert.equal(r.status, 0); + } + assert.equal(leji(['--version'], { cwd: dir }).stdout, marker(['--version']), 'unset hands off'); +}); + +/** The metadata that disqualifies an otherwise complete install, or null when the + * fixture's own tree is what disqualifies it. */ +function metadata(name: string, version: string): string { + return JSON.stringify({ name, version, bin: { leji: 'dist/cli.js' } }, null, 2) + '\n'; +} + +const DISQUALIFIED: ReadonlyArray<[string, string | null]> = [ + ['node-undeclared', null], + ['node-declared-missing', null], + ['node-unknown-spec-line', null], + ['go-tool', null], + ['node-below-minimum', metadata('@leji-org/leji', '0.9.3')], + ['node-wrong-identity', metadata('@example/leji', '1.4.0')], + ['node-malformed-version', metadata('@leji-org/leji', '1.x')], + ['node-malformed-metadata', '{ not json\n'], +]; + +test('every repository that does not qualify runs the global, silently', () => { + for (const [name, installed] of DISQUALIFIED) { + const dir = seed(name); + if (installed !== null) { + fs.writeFileSync(path.join(dir, 'node_modules', '@leji-org', 'leji', 'package.json'), installed); + } + const r = leji(['--version'], { cwd: dir }); + assert.equal(r.stdout, `${SDK_VERSION}\n`, name); + assert.equal(r.stderr, '', name); + assert.equal(r.status, 0, name); + } +}); + +// --- every shim shape a manager installs -------------------------------------- + +const SHIMS: ReadonlyArray<[string, ShimShape, string]> = [ + ['node-shim-symlink', 'symlink', "npm's shape, which bun installs too"], + ['node-shim-script', 'script', "pnpm's shape: a real file that runs the entry"], + ['node-shim-yarn', 'symlink', "Yarn's node-modules linker, a symlink like npm's"], +]; + +for (const [fixture, shape, why] of SHIMS) { + test(`the hand-off runs the installed shim as ${fixture} has it (${why})`, () => { + const dir = seed(fixture, MARKER, false, shape); + const argv = ['--root', dir, '--version']; + const r = leji(argv); + assert.equal(r.stdout, marker(argv)); + assert.equal(r.status, 3); + assert.equal(r.stderr, ''); + }); +} + +/** + * The re-entry test, which is the one that would have caught the loop: the installed + * package is the REAL built CLI, not a marker, reached through a SCRIPT shim whose + * realpath is itself. The global hands off once; the local copy then resolves the + * same repository, recognizes its own entry, and answers. Exactly one hand-off, and + * the version printed is the local one, so which copy answered is not in doubt. + * + * The package is assembled by copying the built artifacts rather than by running a + * package manager: the same tree an install produces, with no network in a unit + * suite. The real packed-install path is proved separately at build verification. + */ +function installRealCli(dir: string, shim: ShimShape): void { + const pkgDir = path.join(dir, 'node_modules', '@leji-org', 'leji'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.cpSync(path.join(pkgRoot, 'dist'), path.join(pkgDir, 'dist'), { recursive: true }); + // The copy carries the BUILD's mode, and `tsc` emits a plain 0644 file; installing + // is what makes an entry runnable (npm chmods the bin target executable when it + // links the shim). A symlink shim IS that file, so without this the shim points at + // something no POSIX exec will run, the eligibility check declines it, and the + // global answers — which is not this test's subject and looks like a passing + // suite anywhere the working tree still carries the bit from an earlier install. + fs.chmodSync(path.join(pkgDir, 'dist', 'cli.js'), 0o755); + fs.cpSync(path.join(pkgRoot, 'cli.json'), path.join(pkgDir, 'cli.json')); + // The installed copy declares a DIFFERENT version, which is also what it reports: + // `--version` reads the package's own metadata, so the answer names the copy. + const own = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ ...own, version: LOCAL_VERSION }, null, 2) + '\n', + ); + // Its runtime dependencies, resolved the way Node resolves them for a real + // install: from the tree the entry lives in. + const deps = path.join(dir, 'node_modules'); + for (const dep of ['ajv', 'yaml']) { + fs.symlinkSync(path.join(repoRoot, 'node_modules', dep), path.join(deps, dep)); + } + installShim(dir, path.join(pkgDir, 'dist', 'cli.js'), shim); +} + +const LOCAL_VERSION = '1.4.0-local'; + +test('the real local CLI, behind a script shim, answers exactly once', () => { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, 'node-shim-script'), dir, { recursive: true }); + installRealCli(dir, 'script'); + const r = leji(['--version'], { cwd: dir }); + // One line, from the local copy: a second hand-off would print it again, and an + // unbounded one would hit the runner's timeout instead of answering at all. + assert.equal(r.stdout, `${LOCAL_VERSION}\n`); + assert.equal(r.status, 0); + assert.equal(r.signal, null, 'a loop would be killed by the timeout, not exit cleanly'); +}); + +test('the real local CLI, behind a symlink shim, answers exactly once', () => { + const dir = tmpdir(); + fs.cpSync(path.join(fixturesDir, 'node-shim-symlink'), dir, { recursive: true }); + installRealCli(dir, 'symlink'); + const r = leji(['--version'], { cwd: dir }); + assert.equal(r.stdout, `${LOCAL_VERSION}\n`); + assert.equal(r.status, 0); + assert.equal(r.signal, null); +}); + +test('a repository whose installed CLI is this very executable runs once and stops', () => { + // The shim resolves to the running entry itself. Nothing hands off: the copy is + // not installed inside this repository at all (it resolves out of it), and the + // recursion guard stands behind that. What is proved here is the outcome a loop + // would break: ONE process, one version line, a normal exit. + const dir = seed('node-self', MARKER, true); + const r = leji(['--version'], { cwd: dir }); + assert.equal(r.stdout, `${SDK_VERSION}\n`); + assert.equal(r.status, 0); + assert.equal(r.signal, null); +}); + +test('unreadable eligibility state runs the global, with no stack trace', (t) => { + // The resolution happens before `run()` and outside its error handling, so a + // throw here would reach the user as a traceback instead of the CLI they typed. + const unreadable = (relative: string): string | null => { + const dir = seed('node-eligible'); + const target = path.join(dir, relative); + fs.chmodSync(target, 0o000); + try { + fs.readFileSync(target); + return null; // running as root: this case cannot be built here + } catch { + return dir; + } + }; + const directory = seed('node-eligible'); + fs.rmSync(path.join(directory, 'leji.json')); + fs.mkdirSync(path.join(directory, 'leji.json')); + const cases = [ + unreadable('node_modules/@leji-org/leji/package.json'), + unreadable('leji.json'), + unreadable('package.json'), + directory, + ]; + if (cases.some((dir) => dir === null)) { + t.diagnostic('some permission cases were skipped: this user can read a 0o000 file'); + } + for (const dir of cases) { + if (dir === null) continue; + const r = leji(['--version'], { cwd: dir }); + assert.equal(r.stdout, `${SDK_VERSION}\n`); + assert.equal(r.stderr, '', 'nothing is printed, and nothing throws'); + assert.equal(r.status, 0); + } +}); + +test('a selected target that cannot be executed fails closed: named on stderr, exit 2', () => { + const dir = seed('node-eligible', UNRUNNABLE); + const r = leji(['validate'], { cwd: dir }); + assert.equal(r.status, 2); + assert.equal(r.stdout, ''); + assert.match(r.stderr, /^leji: cannot run the repository's Leji CLI at node_modules\/\.bin\/leji: [A-Z]+\n$/); +}); + +test('a child that dies by signal ends this process the same way', (t) => { + if (process.platform === 'win32') { + t.skip('POSIX signal semantics; Windows names the signal and exits 1 instead'); + return; + } + const dir = seed('node-eligible', SIGNAL_MARKER); + const r = leji(['validate'], { cwd: dir }); + assert.equal(r.signal, 'SIGTERM', 'the shell must see the termination the child had'); + assert.equal(r.status, null); +}); diff --git a/packages/sdk/test/proc/run.test.ts b/packages/sdk/test/proc/run.test.ts index 50e84e5..da793b5 100644 --- a/packages/sdk/test/proc/run.test.ts +++ b/packages/sdk/test/proc/run.test.ts @@ -325,8 +325,8 @@ test('run viewer generates the viewer and prints the serve hint', async () => { const r = await runInProcess(['viewer', '--root', dir]); assert.equal(r.code, 0); assert.match(r.out, /serve: leji view/); - assert.ok(fs.existsSync(path.join(dir, 'docs', '.leji', 'viewer', 'index.html'))); - assert.ok(fs.existsSync(path.join(dir, 'docs', '.leji', 'viewer', '_sidebar.md'))); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'viewer', 'index.html'))); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'viewer', '_sidebar.md'))); }); test('run viewer against a missing manifest', async () => { @@ -442,7 +442,8 @@ leji-validate: # <<< leji ci (managed) <<< `; -const CIRCLE_CONFIG = `version: 2.1 +const CIRCLE_CONFIG = `# generated by leji ci (managed) v2 +version: 2.1 jobs: leji-validate: docker: @@ -471,7 +472,8 @@ workflows: - leji-validate `; -const AZURE_PIPELINE = `trigger: +const AZURE_PIPELINE = `# generated by leji ci (managed) v2 +trigger: - main pool: vmImage: ubuntu-latest @@ -547,21 +549,45 @@ test('ci --provider gitlab: replaces a stale managed block, preserving surroundi assert.ok(!out.includes('node:18'), 'stale block replaced'); }); -test('ci --provider circleci: creates when absent, prints a snippet (no edit) when present', async () => { +test('ci --provider gitlab: a standing entry that is not a regular file is refused, never merged', async () => { + // The merge reads the bytes it is about to rewrite through the verified read, so a + // target that is not a regular file is the same hard refusal a write to it would be, + // reported in the SDK's own words rather than as an OS read error. + const dir = await seededCiDir('leji-ci-gl-kind-'); + fs.mkdirSync(path.join(dir, 'inside-dir')); + fs.symlinkSync(path.join(dir, 'inside-dir'), path.join(dir, '.gitlab-ci.yml')); + const r = await runInProcess(['ci', '--root', dir, '--provider', 'gitlab']); + assert.equal(r.code, 2); + assert.match(r.err, /refusing to write through a symlink that escapes the target/); +}); + +test('ci --provider circleci: creates when absent, re-runs clean, hands a snippet for a foreign file', async () => { const dir = await seededCiDir('leji-ci-cc-'); const cc = path.join(dir, '.circleci', 'config.yml'); const created = await runInProcess(['ci', '--root', dir, '--provider', 'circleci', '--json']); assert.equal(created.code, 0); assert.equal(JSON.parse(created.out).action, 'created'); assert.equal(fs.readFileSync(cc, 'utf8'), CIRCLE_CONFIG, 'created config is byte-exact'); - const before = fs.readFileSync(cc, 'utf8'); - const manual = await runInProcess(['ci', '--root', dir, '--provider', 'circleci', '--json']); + // A file leji generated is leji's to keep current: the re-run recognizes its own + // bytes and reports unchanged rather than handing back a snippet for a file the + // user never wrote. + const again = await runInProcess(['ci', '--root', dir, '--provider', 'circleci', '--json']); + assert.equal(again.code, 0); + assert.equal(JSON.parse(again.out).action, 'unchanged'); + assert.equal(fs.readFileSync(cc, 'utf8'), CIRCLE_CONFIG, 'idempotent byte-for-byte'); + + // Someone else's config: never modified, and the snippet comes back to add by hand. + const foreign = await seededCiDir('leji-ci-cc-foreign-'); + const fcc = path.join(foreign, '.circleci', 'config.yml'); + fs.mkdirSync(path.dirname(fcc), { recursive: true }); + fs.writeFileSync(fcc, 'version: 2.1\njobs:\n mine: {}\n'); + const manual = await runInProcess(['ci', '--root', foreign, '--provider', 'circleci', '--json']); assert.equal(manual.code, 0); const j = JSON.parse(manual.out); assert.equal(j.action, 'manual'); assert.equal(j.created, false); assert.equal(j.snippet, CIRCLE_SNIPPET, 'manual snippet is byte-exact'); - assert.equal(fs.readFileSync(cc, 'utf8'), before, 'existing config left untouched'); + assert.equal(fs.readFileSync(fcc, 'utf8'), 'version: 2.1\njobs:\n mine: {}\n', 'foreign config untouched'); }); test('ci --provider azure: dedicated pipeline file + activation note (JSON and human), idempotent, byte-exact', async () => { @@ -652,6 +678,33 @@ test('ci: refuses to write through a symlink that escapes the root', async () => } }); +test('ci: a dangling target symlink is refused, never written through', async () => { + // Every arm decided presence with `existsSync`, which follows symlinks: a dangling + // workflow link read as absent and the create landed at the link's destination, a + // name inside the repository the tool never planned. The verified read refuses the + // standing entry instead, in the same words an escaping target gets. + for (const [provider, targetRel] of [ + ['github', '.github/workflows/leji.yml'], + ['gitlab', '.gitlab-ci.yml'], + ['circleci', '.circleci/config.yml'], + ['azure', '.azure-pipelines/leji.yml'], + ] as const) { + const dir = await seededCiDir('leji-ci-dangling-'); + const target = path.join(dir, targetRel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.symlinkSync('never-created.yml', target); + const r = await runInProcess(['ci', '--root', dir, '--provider', provider]); + assert.equal(r.code, 2, `${provider}: dangling target refused`); + assert.match(r.err, /refusing to write through a symlink that escapes the target/); + assert.equal( + fs.existsSync(path.join(path.dirname(target), 'never-created.yml')), + false, + `${provider}: the dangling link's destination is never created`, + ); + assert.ok(fs.lstatSync(target).isSymbolicLink(), `${provider}: the planted link is left exactly as it was`); + } +}); + test('ci: an unwritable target dir yields a normalized, OS-text-free error', async () => { // Root bypasses permission bits, so the write would succeed; skip there. if (typeof process.getuid === 'function' && process.getuid() === 0) return; @@ -695,6 +748,39 @@ test('start: no manifest exits 1; on a layer (non-TTY) it falls back to the boot assert.match(ok.out, /To enter this context layer/); }); +test('agent: writing the default binding prints the selects-vs-loads guidance (human and JSON); other keys and re-runs do not', async () => { + const guidance = + 'agents.default selects a role profile; it does not load it. If its instructions must apply before every task, fold them into the boot profile; otherwise keep the profile role-scoped and engage it through the relevant protocol.'; + const seed = async (prefix: string): Promise<string> => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + await runInProcess(['init', '--dir', dir, '--yes', '--name', 'demo']); + return dir; + }; + // human output: the guidance follows the success lines, byte-exact + const dir = await seed('leji-run-agent-'); + const human = await runInProcess(['agent', '--name', 'default', '--root', dir]); + assert.equal(human.code, 0, human.out + human.err); + assert.match(human.out, /Bound agent "default"/); + assert.equal(human.out.split('\n').at(-1), guidance); + // written-only: a re-run binds nothing and stays terse, in both modes + const again = await runInProcess(['agent', '--name', 'default', '--root', dir]); + assert.equal(again.code, 0, again.out + again.err); + assert.ok(!again.out.includes('selects a role profile'), 'no guidance when nothing was bound'); + const againJson = await runInProcess(['agent', '--name', 'default', '--json', '--root', dir]); + assert.equal(JSON.parse(againJson.out).note, undefined); + // JSON mode carries the same sentence in `note` (the CI activation-note pattern) + const json = await runInProcess(['agent', '--name', 'default', '--json', '--root', await seed('leji-run-agent-j-')]); + assert.equal(json.code, 0, json.out + json.err); + assert.equal(JSON.parse(json.out).note, guidance); + // any other binding stays quiet, in both modes + const other = await runInProcess(['agent', '--name', 'reviewer', '--root', dir]); + assert.equal(other.code, 0, other.out + other.err); + assert.ok(!other.out.includes('selects a role profile'), 'no guidance for a non-default key'); + const otherJson = await runInProcess(['agent', '--name', 'thought-partner', '--json', '--root', dir]); + assert.equal(otherJson.code, 0, otherJson.out + otherJson.err); + assert.equal(JSON.parse(otherJson.out).note, undefined); +}); + test('init interactive forces domain when both domain and system are declined', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-run-force-')); // name, description, root, owner name, owner contact, domain n, system n, @@ -754,9 +840,9 @@ test('init interactive with a "." context root yields context/ (not ./ reject, n const core = fs.readFileSync(path.join(dir, 'agents', 'core.md'), 'utf8'); assert.doesNotMatch(core, broken, 'core profile has no hidden paths'); assert.match(core, /\bboot-profile\.md/, 'core profile references boot-profile.md'); - const brief = fs.readFileSync(path.join(dir, '.leji', 'onboarding-brief.md'), 'utf8'); + const brief = fs.readFileSync(path.join(dir, '.leji', 'work', 'onboarding-brief.md'), 'utf8'); assert.doesNotMatch(brief, broken, 'brief has no ..leji/ or .context/'); - assert.match(brief, /\.leji\/onboarding-brief\.md/, 'brief references .leji/onboarding-brief.md'); + assert.match(brief, /\.leji\/work\/onboarding-brief\.md/, 'brief references .leji/work/onboarding-brief.md'); assert.match(brief, /context\/<id>\.md/, 'brief references context/<id>.md'); const v = await runInProcess(['validate', '--root', dir]); @@ -779,3 +865,114 @@ test('init/adopt --mode: invalid and missing values fail with usage exit 2; solo assert.match(dry.out, /create\s+docs\/practice\/writing-style\.md/); assert.equal(fs.readdirSync(dir).length, 0, 'dry-run writes nothing'); }); + +// --- the declaration step (init/adopt) -------------------------------------- +// The dependency offer is the one place the CLI can run a program that writes to +// the repository, so every mode is pinned here: what is printed, that nothing runs +// without a terminal and a yes, and what the JSON document carries. + +/** A planted dependency root: a manifest and its (empty) lockfiles, nothing else. */ +function plantRoot(files: Record<string, string>): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-dep-proc-')); + for (const [rel, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), body); + return dir; +} + +const PNPM_ROOT = { 'package.json': '{\n "name": "demo"\n}\n', 'pnpm-lock.yaml': '' }; + +test('init --yes prints the declaration block and runs no package manager', async () => { + const dir = plantRoot(PNPM_ROOT); + const before = fs.readFileSync(path.join(dir, 'package.json'), 'utf8'); + const r = await runInProcess(['init', '--dir', dir, '--yes', '--name', 'demo-context']); + assert.equal(r.code, 0); + assert.match(r.out, /Detected pnpm \(pnpm-lock\.yaml\)\. To declare the Leji CLI as a dev dependency/); + assert.match(r.out, /\n {3}pnpm add -D @leji-org\/leji/); + // --yes is not a terminal consent: nothing ran, so the manifest is untouched and + // no manager wrote a lockfile or a node_modules tree. + assert.equal(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'), before); + assert.equal(fs.readFileSync(path.join(dir, 'pnpm-lock.yaml'), 'utf8'), ''); + assert.equal(fs.existsSync(path.join(dir, 'node_modules')), false); + // The block lands after the write list and before the handoff text. + assert.ok(r.out.indexOf('Wrote ') < r.out.indexOf('Detected pnpm'), 'block follows the write list'); +}); + +test('init --yes --json is one document carrying the ecosystem, and no block', async () => { + const dir = plantRoot(PNPM_ROOT); + const r = await runInProcess(['init', '--dir', dir, '--yes', '--json', '--name', 'demo-context']); + assert.equal(r.code, 0); + const doc = JSON.parse(r.out); + assert.deepEqual(Object.keys(doc), ['command', 'ok', 'findings', 'summary', 'written', 'ecosystem']); + assert.equal(doc.command, 'init'); + assert.equal(doc.ok, true); + assert.ok(doc.written.includes('leji.json')); + assert.equal(doc.ecosystem.selected.manager, 'pnpm'); + assert.deepEqual(doc.ecosystem.selected.add, ['pnpm', 'add', '-D', '@leji-org/leji']); + assert.equal(doc.ecosystem.reason, null); + // Single document: the human block and the handoff prose stay out of it. + assert.doesNotMatch(r.out, /To declare the Leji CLI/); + assert.doesNotMatch(r.out, /The scaffold is in place/); +}); + +test('init --dry-run prints the block after the plan and writes nothing', async () => { + const dir = plantRoot({ 'pyproject.toml': '[project]\nname = "demo"\nversion = "0.1.0"\n', 'uv.lock': '' }); + const r = await runInProcess(['init', '--dir', dir, '--yes', '--dry-run', '--name', 'demo-context']); + assert.equal(r.code, 0); + assert.match(r.out, /No files written \(--dry-run\)/); + assert.match(r.out, /Detected uv \(uv\.lock\)\./); + assert.match(r.out, /\n {3}uv add --dev leji/); + assert.ok(r.out.indexOf('No files written') < r.out.indexOf('Detected uv'), 'block follows the plan render'); + assert.equal(fs.existsSync(path.join(dir, 'leji.json')), false); +}); + +test('init --dry-run --json is one document with the ecosystem and no writes', async () => { + const dir = plantRoot({ 'go.mod': 'module example.com/demo\n\ngo 1.24.0\n' }); + const r = await runInProcess(['init', '--dir', dir, '--yes', '--dry-run', '--json', '--name', 'demo-context']); + assert.equal(r.code, 0); + const doc = JSON.parse(r.out); + assert.equal(doc.dryRun, true); + assert.deepEqual(doc.written, []); + assert.equal(doc.ecosystem.selected.manager, 'go'); + assert.deepEqual(doc.ecosystem.selected.add, [ + 'go', + 'get', + '-tool', + 'github.com/leji-org/leji/packages/sdk-go/cmd/leji@latest', + ]); + assert.equal(fs.existsSync(path.join(dir, 'leji.json')), false); +}); + +test('init on a root with no manifest still prints the per-person install block', async () => { + const dir = plantRoot({}); + const r = await runInProcess(['init', '--dir', dir, '--yes', '--name', 'demo-context']); + assert.equal(r.code, 0); + assert.match(r.out, /No package\.json, pyproject\.toml or go\.mod here/); + assert.match(r.out, /https:\/\/leji\.org\/quickstart\//); +}); + +test('init on an ambiguous root offers one command per candidate and runs nothing', async () => { + const dir = plantRoot({ 'package.json': '{}\n', 'package-lock.json': '', 'yarn.lock': '' }); + const r = await runInProcess(['init', '--dir', dir, '--yes', '--name', 'demo-context']); + assert.equal(r.code, 0); + assert.match(r.out, /leji will not guess the package manager/); + assert.match(r.out, /\n {3}npm i -D @leji-org\/leji/); + assert.match(r.out, /\n {3}yarn add -D @leji-org\/leji/); +}); + +test('adopt --yes prints the block; adopt --json is one document', async () => { + const dir = plantRoot({ ...PNPM_ROOT }); + fs.mkdirSync(path.join(dir, 'docs')); + fs.writeFileSync(path.join(dir, 'docs', 'README.md'), '# Docs\n'); + const r = await runInProcess(['adopt', '--dir', dir, '--yes']); + assert.equal(r.code, 0); + assert.match(r.out, /Detected pnpm \(pnpm-lock\.yaml\)\./); + + const dir2 = plantRoot({ ...PNPM_ROOT }); + fs.mkdirSync(path.join(dir2, 'docs')); + fs.writeFileSync(path.join(dir2, 'docs', 'README.md'), '# Docs\n'); + const j = await runInProcess(['adopt', '--dir', dir2, '--yes', '--json']); + assert.equal(j.code, 0); + const doc = JSON.parse(j.out); + assert.equal(doc.command, 'adopt'); + assert.equal(doc.ecosystem.selected.manager, 'pnpm'); + assert.doesNotMatch(j.out, /To declare the Leji CLI/); +}); diff --git a/packages/sdk/test/proc/start.test.ts b/packages/sdk/test/proc/start.test.ts new file mode 100644 index 0000000..1ba4fec --- /dev/null +++ b/packages/sdk/test/proc/start.test.ts @@ -0,0 +1,215 @@ +import { strict as assert } from 'node:assert'; +import { execFile, execFileSync } from 'node:child_process'; +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'; + +// `leji start` end to end, through the real bin. Everything the command could reach +// outside the repository is a stub on a synthetic PATH: the CLI it probes, the agent +// hosts it detects, and the host commands it would run. Nothing real is launched or +// installed here, and the runs are non-interactive, so no prompt can fire either. + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const cli = path.join(pkgRoot, 'dist', 'cli.js'); + +function tmpdir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** The real `git` binary, the one program these runs cannot stub: the hook check asks + * git where hooks live. */ +function gitBin(): string { + try { + return execFileSync('bash', ['-lc', 'command -v git'], { encoding: 'utf8' }).trim(); + } catch { + return '/usr/bin/git'; + } +} + +/** + * A directory of executable stubs, plus a link to the real git. It is the WHOLE PATH + * of every run below, so what host detection finds is exactly what a case declares + * and never whatever the machine running the suite happens to have installed. + */ +function stubs(spec: Record<string, string>): string { + const dir = tmpdir('leji-start-stubs-'); + for (const [name, body] of Object.entries(spec)) { + fs.writeFileSync(path.join(dir, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + } + fs.symlinkSync(gitBin(), path.join(dir, 'git')); + return dir; +} + +const VERSION_STUB = 'echo 1.4.0'; + +interface CliResult { + code: number; + stdout: string; + stderr: string; +} + +async function runStart(args: string[], cwd: string, env: Record<string, string>): Promise<CliResult> { + return new Promise((resolve) => { + execFile(process.execPath, [cli, ...args], { cwd, env: { ...process.env, ...env } }, (error, stdout, stderr) => { + resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr }); + }); + }); +} + +/** A committed layer in its own repository, with the environment `start` sees. */ +function layer(opts: { declared?: boolean; hosts?: Record<string, string> } = {}): { + dir: string; + env: Record<string, string>; +} { + const dir = tmpdir('leji-start-'); + const home = tmpdir('leji-start-home-'); + const bare = { PATH: stubs({}), HOME: home }; + execFileSync(process.execPath, [cli, 'init', '--yes', '--name', 'demo-context'], { + cwd: dir, + env: { ...process.env, ...bare }, + stdio: 'ignore', + }); + if (opts.declared) { + fs.writeFileSync( + path.join(dir, 'package.json'), + '{\n "name": "demo",\n "devDependencies": { "@leji-org/leji": "^1" }\n}\n', + ); + fs.writeFileSync(path.join(dir, 'package-lock.json'), '{ "lockfileVersion": 3 }\n'); + // What the manager's own install would have produced. The probe executes this + // file directly; no `npx`/`pnpm exec` stub exists, and none is needed. + const binDir = path.join(dir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, 'leji'), `#!/bin/sh\n${VERSION_STUB}\n`, { mode: 0o755 }); + } + execFileSync('git', ['init', '-q'], { cwd: dir, env: { ...process.env, ...bare } }); + const stubDir = stubs({ leji: VERSION_STUB, ...(opts.hosts ?? {}) }); + return { dir, env: { PATH: stubDir, HOME: home } }; +} + +test('start prints the Setup block before the entry instructions, and launches nothing', async () => { + const { dir, env } = layer({ declared: true }); + const r = await runStart(['start'], dir, env); + assert.equal(r.code, 0, r.stderr); + const setup = r.stdout.indexOf('Setup for this clone'); + const entry = r.stdout.indexOf('No coding agent was launched.'); + assert.ok(setup >= 0, r.stdout); + assert.ok(entry > setup, 'the block prints above the entry instructions'); + assert.ok(!r.stdout.includes('Starting '), 'a non-interactive run never launches a host'); + assert.match(r.stdout, /^ {2}ok {4}Leji CLI {4}1\.4\.0 \(node_modules\/\.bin\/leji\)$/m); + assert.match(r.stdout, /^ {2}n\/a {3}MCP server {2}no coding agent detected$/m); + assert.match(r.stdout, /^ {2}you {3}Git hook {4}none yet \(per clone\)$/m); + assert.match(r.stdout, /^ {8}\$ leji ci --hooks$/m, 'the fix is printed as an exact command'); + assert.match(r.stdout, /^ {2}1 fix for you\. The agent starts either way\.$/m); + assert.equal(r.stdout.includes('\x1b'), false, 'a piped run carries no escape'); +}); + +test('start on a repository that declares nothing reports a shared gap, still exit 0', async () => { + const { dir, env } = layer(); + fs.writeFileSync(path.join(dir, 'package.json'), '{ "name": "demo" }\n'); + const r = await runStart(['start'], dir, env); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /^ {2}team {2}Leji CLI {4}not declared/m); + assert.match(r.stdout, /^ {8}\$ npm i -D @leji-org\/leji$/m); +}); + +test('start --json emits one report-only document and never launches', async () => { + const { dir, env } = layer({ declared: true, hosts: { claude: 'exit 1' } }); + const r = await runStart(['start', '--json'], dir, env); + assert.equal(r.code, 0, r.stderr); + const doc = JSON.parse(r.stdout) as { + command: string; + ok: boolean; + ready: boolean; + checks: { id: string; status: string; detail: string; fix: string[] | null }[]; + ecosystem: { selected: { manager: string } | null }; + }; + assert.deepEqual(Object.keys(doc), ['command', 'ok', 'ready', 'checks', 'ecosystem']); + assert.equal(doc.command, 'start'); + assert.equal(doc.ok, true); + assert.equal(doc.ready, false, 'the hook is missing'); + assert.deepEqual( + doc.checks.map((c) => c.id), + ['cli', 'mcp', 'mcp-shared', 'hook'], + ); + for (const c of doc.checks) assert.deepEqual(Object.keys(c), ['id', 'status', 'detail', 'fix']); + assert.equal(doc.checks[0].status, 'ok'); + // The one detected host is Claude Code (its stub is on PATH); `claude mcp get` + // exits 1, so the personal fix is the user-scope registration. + assert.equal(doc.checks[1].status, 'missing'); + assert.deepEqual(doc.checks[1].fix, ['claude mcp add leji --scope user -- npx -y @leji-org/mcp']); + assert.equal(doc.checks[2].status, 'shared-gap'); + assert.equal(doc.checks[3].status, 'missing'); + assert.equal(doc.ecosystem.selected?.manager, 'npm'); + assert.ok(!r.stdout.includes('Setup for this clone'), 'no human block in a document mode'); +}); + +test('start --json reports ready once the personal gaps are closed', async () => { + const { dir, env } = layer({ declared: true, hosts: { claude: 'exit 0' } }); + fs.writeFileSync(path.join(dir, '.mcp.json'), '{ "mcpServers": {} }\n'); + execFileSync(process.execPath, [cli, 'ci', '--hooks'], { + cwd: dir, + env: { ...process.env, ...env }, + stdio: 'ignore', + }); + const r = await runStart(['start', '--json'], dir, env); + assert.equal(r.code, 0, r.stderr); + const doc = JSON.parse(r.stdout) as { ready: boolean; checks: { status: string }[] }; + assert.equal(doc.ready, true); + assert.deepEqual( + doc.checks.map((c) => c.status), + ['ok', 'ok', 'ok', 'ok'], + ); +}); + +test('start --json with several hosts and no --agent leaves the MCP row unresolved', async () => { + const { dir, env } = layer({ declared: true, hosts: { claude: 'exit 1', codex: 'exit 1' } }); + const r = await runStart(['start', '--json'], dir, env); + assert.equal(r.code, 0, r.stderr); + const doc = JSON.parse(r.stdout) as { checks: { id: string; status: string; fix: string[] | null }[] }; + const mcp = doc.checks.find((c) => c.id === 'mcp'); + assert.equal(mcp?.status, 'unresolved'); + assert.deepEqual(mcp?.fix, ['leji start --agent <name>']); + assert.equal(doc.checks.find((c) => c.id === 'mcp-shared')?.status, 'n/a'); +}); + +test('start --agent claude-code --json answers for the named host', async () => { + const { dir, env } = layer({ declared: true, hosts: { claude: 'exit 1', codex: 'exit 1' } }); + const r = await runStart(['start', '--agent', 'claude-code', '--json'], dir, env); + assert.equal(r.code, 0, r.stderr); + const doc = JSON.parse(r.stdout) as { checks: { id: string; status: string }[] }; + assert.equal(doc.checks.find((c) => c.id === 'mcp')?.status, 'missing'); + assert.equal(doc.checks.find((c) => c.id === 'mcp-shared')?.status, 'shared-gap'); +}); + +test('start --agent bogus --json is a usage error, before any document is written', async () => { + const { dir, env } = layer({ declared: true }); + const r = await runStart(['start', '--agent', 'bogus', '--json'], dir, env); + assert.equal(r.code, 2); + assert.match(r.stderr, /--agent must be a launchable host/); + assert.equal(r.stdout.trim(), '', 'no document is emitted for a rejected argument'); +}); + +test('start --json on a missing boot profile is the boot-missing document, exit 1', async () => { + const { dir, env } = layer({ declared: true }); + fs.rmSync(path.join(dir, 'docs', 'boot-profile.md')); + const r = await runStart(['start', '--json'], dir, env); + assert.equal(r.code, 1); + const doc = JSON.parse(r.stdout) as Record<string, unknown>; + assert.deepEqual(Object.keys(doc), ['command', 'ok', 'ready', 'error', 'checks', 'ecosystem']); + assert.equal(doc.ok, false); + assert.equal(doc.ready, false); + assert.equal(doc.error, 'boot-missing'); + assert.deepEqual(doc.checks, [], 'nothing was checked, so nothing is claimed'); +}); + +test('start on a repository with no leji.json is the findings envelope, exit 1', async () => { + const dir = tmpdir('leji-start-bare-'); + const r = await runStart(['start', '--json'], dir, { PATH: stubs({}), HOME: tmpdir('leji-start-home-') }); + assert.equal(r.code, 1); + const doc = JSON.parse(r.stdout) as { command: string; ok: boolean; findings: unknown[] }; + assert.equal(doc.command, 'start'); + assert.equal(doc.ok, false); + assert.ok(Array.isArray(doc.findings)); +}); diff --git a/packages/sdk/test/renderlint.test.ts b/packages/sdk/test/renderlint.test.ts new file mode 100644 index 0000000..0fa558b --- /dev/null +++ b/packages/sdk/test/renderlint.test.ts @@ -0,0 +1,462 @@ +import { strict as assert } from 'node:assert'; +import * as crypto from 'node:crypto'; +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 { run } from '../dist/index.js'; +// The scan is the export command's own policy rather than SDK surface, so it stays +// inside the module an in-repo test reads directly. +import { scanRenderConstructs } from '../dist/lib/renderlint.js'; + +// Two halves of one contract. First the scan itself, family by family over the +// edges the fixtures state in prose: what it reports, and — the half a lint lives +// or dies on — what it stays quiet about. Then the shared render fixtures, driven +// through the real command: their pinned findings, their layout, and their golden +// export bytes. + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const fixturesDir = path.join(repoRoot, 'fixtures'); + +/** The scan as `line:construct` strings, which is what a family assertion reads. */ +function hits(text: string): string[] { + return scanRenderConstructs(text).map((h) => `${h.line}:${h.construct}`); +} + +// --- family: multi-line HTML blocks ------------------------------------------- + +test('family: an HTML block reports once, at the line it opens on', () => { + // A block runs to the next blank line, so the tags inside it — the closing one + // included — are block content and not a second construct. + assert.deepEqual(hits(['# Doc', '', '<div class="callout">', ' inner text', '</div>', '', 'after'].join('\n')), [ + '3:raw-html', + ]); + // Two blocks separated by a blank line are two constructs. + assert.deepEqual(hits(['<table>', '<tr><td>a</td></tr>', '</table>', '', '<div>', '</div>'].join('\n')), [ + '1:raw-html', + '5:raw-html', + ]); + // A raw-text block (type 1) ends at its closing tag rather than at a blank line, + // so the blank line inside it does not split it into two. + assert.deepEqual(hits(['<script>', '', 'let x = 1;', '', '</script>', '', 'prose'].join('\n')), ['1:raw-html']); + // Inline raw HTML mid-paragraph is the other form, reported on its own line, and + // a line carrying two tags is still one finding: the line is the unit. + assert.deepEqual(hits('A paragraph with <b>bold</b> and <i>italic</i> in it.\n'), ['1:raw-html']); + // Negative: a document with no HTML at all reports nothing. + assert.deepEqual(hits('# Title\n\nProse with a < less-than and an a > b comparison.\n'), []); +}); + +// --- family: excluded regions ------------------------------------------------- + +test('family: code spans, fences, and comments are excluded regions', () => { + // Code spans, including the multiple-backtick form. + assert.deepEqual(hits('The tag `<div>` and `[^ref]` and `$$x$$` are text.\n'), []); + assert.deepEqual(hits('A span with a backtick in it: ``a `<b>` span``.\n'), []); + // Fenced blocks, whatever the info string, and a longer fence carrying a shorter + // one: everything between the delimiters is code. + assert.deepEqual(hits(['```html', '<div>', '</div>', '```'].join('\n')), []); + assert.deepEqual(hits(['````markdown', '```html', '<span>x</span>', '```', '````'].join('\n')), []); + assert.deepEqual(hits(['~~~', '[^one]: definition', '$$', 'x', '$$', '~~~'].join('\n')), []); + // Comments are the excepted HTML form: nothing inside one is reported, on one + // line or many, at the start of a line or inside prose. + assert.deepEqual(hits(['<!--', ' <div> and [^ref] and $$x$$', '-->', '', 'prose'].join('\n')), []); + assert.deepEqual(hits('Prose with <!-- a <div> inside a comment --> and more prose.\n'), []); + // Positive controls: the same constructs outside a region are reported, so the + // assertions above are the exclusion working rather than a scan that sees nothing. + assert.deepEqual(hits('The tag <div> and [^ref] and $$x$$ are markup.\n'), [ + '1:footnote', + '1:math-block', + '1:raw-html', + ]); + // An unclosed fence excludes the rest of the document, as a renderer reads it. + assert.deepEqual(hits(['```', '<div>', '[^one]'].join('\n')), []); +}); + +// --- family: malformed and unpaired forms ------------------------------------- + +test('family: an unpaired or malformed construct is prose', () => { + // `$$` needs an open and a close; a lone delimiter is prose. + assert.deepEqual(hits('A lone delimiter:\n\n$$\n'), []); + assert.deepEqual(hits('$$\na^2 + b^2 = c^2\n$$\n'), ['1:math-block']); + assert.deepEqual(hits('An inline pair: $$e = mc^2$$ mid-sentence.\n'), ['1:math-block']); + // A single `$` is deliberately outside the closed token set. + assert.deepEqual(hits('An amount of $5 and a variable named $path.\n'), []); + // A footnote needs its closing bracket. + assert.deepEqual(hits('An open bracket [^ and nothing closing it.\n'), []); + assert.deepEqual(hits('An empty label [^] is not a footnote either.\n'), []); + assert.deepEqual(hits('A reference[^one] and its definition.\n\n[^one]: The text.\n'), ['1:footnote', '3:footnote']); + // A `<` that opens no valid tag is prose, and a bare tag name is not markup. + assert.deepEqual(hits('Compare a < b, and 3<4, and <-- an arrow.\n'), []); +}); + +// --- family: the YAML frontmatter boundary ------------------------------------ + +test('family: frontmatter is excluded, and only a leading block is frontmatter', () => { + const front = ['---', 'title: A value with <div> and [^ref] and $$x$$', '---', '', '# Doc', ''].join('\n'); + assert.deepEqual(hits(front), []); + // A `---` later in a document is a thematic break, so the text after it is + // scanned like any other prose. + assert.deepEqual(hits(['# Doc', '', '---', '', 'Prose with <div> in it.', ''].join('\n')), ['5:raw-html']); + // A block that never closes is not frontmatter, so its content is prose — and + // reported, which is the honest read of a document nothing will strip. + assert.deepEqual(hits(['---', 'title: <div>', '', '# Doc', ''].join('\n')), ['2:raw-html']); + // Frontmatter opens the FILE or it is not frontmatter: a block one line down is + // a thematic break followed by prose. + assert.deepEqual(hits(['', '---', 'title: <div>', '---', ''].join('\n')), ['3:raw-html']); +}); + +// --- family: overlaps and same-line ordering ---------------------------------- + +test('family: overlapping constructs resolve to the earliest start, one per line and construct', () => { + // Three constructs on one line, reported in the closed set's alphabetical order — + // the tie-breaker that keeps a same-line group deterministic across the SDKs. + assert.deepEqual(hits('All three: [^b], <i>italic</i>, and $$x + y$$ in one sentence.\n'), [ + '1:footnote', + '1:math-block', + '1:raw-html', + ]); + // A footnote-looking label inside a tag's attribute belongs to the tag: the + // earliest-starting match consumes it, so the line reports raw HTML only. + assert.deepEqual(hits('<span title="[^ref]">text</span>\n'), ['1:raw-html']); + // And the other way round: a tag inside a math pair belongs to the pair. + assert.deepEqual(hits('$$ a <b> c $$\n'), ['1:math-block']); + // A math pair spanning lines is attributed to its opening line, and the constructs + // between the delimiters are inside it. + assert.deepEqual(hits(['$$', 'a <b> c [^ref]', '$$', '', '<span>x</span>'].join('\n')), [ + '1:math-block', + '5:raw-html', + ]); + // Block structure outranks the inline pair, as a renderer reads it: a line + // OPENING with a block tag is an HTML block running to the blank line, so the + // second delimiter is inside it and the first never pairs. + assert.deepEqual(hits(['$$', '<div> [^ref]', '$$', '', '<span>x</span>'].join('\n')), ['2:raw-html', '5:raw-html']); + // Repeats on one line collapse; the same construct on the next line does not. + assert.deepEqual(hits('[^a] and [^b] together.\n[^c] alone.\n'), ['1:footnote', '2:footnote']); +}); + +// --- family: backslash escapes ------------------------------------------------ + +test('family: an escaped delimiter is a literal, and an entity is not markup', () => { + assert.deepEqual(hits('Escaped: \\<div> and \\<b>bold\\</b> are prose.\n'), []); + assert.deepEqual(hits('Escaped: \\[^one] in a sentence.\n\n\\[^one]: not a definition.\n'), []); + assert.deepEqual(hits('Escaped math: \\$\\$ a^2 \\$\\$ is prose about the notation.\n'), []); + // An HTML entity spells a character, not an element. + assert.deepEqual(hits('Entities: <div> and &lt; are text.\n'), []); + // Positive controls for each escape above. + assert.deepEqual(hits('Unescaped: <div> here.\n'), ['1:raw-html']); + assert.deepEqual(hits('Unescaped: [^one] here.\n'), ['1:footnote']); + assert.deepEqual(hits('Unescaped: $$ a^2 $$ here.\n'), ['1:math-block']); + // A backslash before a non-punctuation character is a literal backslash, so the + // construct after it still reports. + assert.deepEqual(hits('A backslash \\n then <div>.\n'), ['1:raw-html']); +}); + +// --- family: the HTML block forms that end mid-line --------------------------- + +test('family: a processing instruction, declaration, or CDATA block runs through its terminator line', () => { + // CommonMark type 3: the block ends on the line carrying `?>`, and the WHOLE of + // that line belongs to it — so what follows the terminator there is block content + // rather than a second construct, and the block reports once, at its opening line. + assert.deepEqual(hits(['<?php', '[^inside]', '?> [^after]'].join('\n')), ['1:raw-html']); + // Type 4 (a declaration) ends at the first `>`, type 5 (CDATA) at `]]>`; what + // follows the block, on a later line, is scanned normally. + assert.deepEqual(hits(['<!DOCTYPE html>', '', '[^after]'].join('\n')), ['1:raw-html', '3:footnote']); + assert.deepEqual(hits(['<![CDATA[', '[^x]', ']]> [^after]', '', 'prose [^real]'].join('\n')), [ + '1:raw-html', + '5:footnote', + ]); + // A block whose terminator never arrives runs to the end of the document, exactly + // as the comment form does. + assert.deepEqual(hits(['<?php', '[^inside]'].join('\n')), ['1:raw-html']); + // Negatives. The same forms mid-line are INLINE raw HTML, so the line's remainder + // is still scanned; an escaped opener is prose; one inside a fence is code. + assert.deepEqual(hits('Prose <?php echo 1; ?> and [^ref].\n'), ['1:footnote', '1:raw-html']); + assert.deepEqual(hits('Escaped \\<?php ?> here.\n'), []); + assert.deepEqual(hits(['```', '<?php ?>', '```', '[^after]'].join('\n')), ['4:footnote']); +}); + +// --- family: inline state never crosses a block boundary ---------------------- + +test('family: a code span or a math pair never bridges a block region', () => { + // The candidate closer lies beyond a block region, which ended the paragraph the + // run opened in: the backticks are literal at that boundary, so the footnote after + // the region is reported rather than swallowed. + assert.deepEqual(hits(['Text `open', '<!-- comment -->', '[^after] and a closer `here'].join('\n')), ['3:footnote']); + // The same for a `$$` whose apparent mate sits on the far side of the region: an + // unpaired delimiter is prose, and what follows it still reports. + assert.deepEqual(hits(['$$ open', '<!-- comment -->', '$$ and [^after]'].join('\n')), ['3:footnote']); + // Positive controls: inside ONE block, both forms still span lines. + assert.deepEqual(hits(['A span `over', 'two lines` and [^after]'].join('\n')), ['2:footnote']); + assert.deepEqual(hits(['$$', 'a^2 + b^2', '$$'].join('\n')), ['1:math-block']); +}); + +// --- family: a mate inside an excluded span, and straddling delimiters --------- + +test('family: a delimiter whose mate sits in a code span or a comment does not pair', () => { + // The apparent closer is inside an excluded region, so the open never pairs and + // the line is prose about the notation. + assert.deepEqual(hits('$$ open `$$` tail\n'), []); + assert.deepEqual(hits('$$ open <!-- $$ --> tail\n'), []); + // Positive controls: a readable mate pairs, and a real pair after an excluded one + // is still found. + assert.deepEqual(hits('$$ open $$ tail\n'), ['1:math-block']); + assert.deepEqual(hits('`$$` and then a real pair $$x$$\n'), ['1:math-block']); + // Straddling a span's edge, both ways: a footnote whose closing bracket is inside + // a code span still reports — the earliest start wins the overlap — while one that + // OPENS inside the span is span content. + assert.deepEqual(hits('[^one `] and text`\n'), ['1:footnote']); + assert.deepEqual(hits('`[^one` ] tail\n'), []); +}); + +// --- family: declaration case, split terminators, and indented openers -------- + +test('family: a declaration takes an ASCII letter of either case, and a terminator must be contiguous', () => { + // `<!` plus an ASCII letter of EITHER case is a declaration, at block and inline + // positions alike — the rendering the vendored renderer actually produces, and + // CommonMark's own character class. A block one runs to the next `>`, so what + // sits inside the consumed span and what trails the terminator on its line are + // block content rather than constructs of their own. + assert.deepEqual(hits(['<!foo', '[^inside]', '<!DOCTYPE html> [^tail]', '', '[^after]'].join('\n')), [ + '1:raw-html', + '5:footnote', + ]); + // Unterminated, the block runs to the end of the document, as the comment form does. + assert.deepEqual(hits(['<!foo', '[^after]'].join('\n')), ['1:raw-html']); + assert.deepEqual(hits('Prose <!foo bar> and [^ref].\n'), ['1:footnote', '1:raw-html']); + // The uppercase spellings, block form and inline form: identical treatment, so the + // assertions above are the grammar and not a case accident. + assert.deepEqual(hits(['<!DOCTYPE html>', '', '[^after]'].join('\n')), ['1:raw-html', '3:footnote']); + assert.deepEqual(hits('Prose <!ENTITY x "y"> and [^ref].\n'), ['1:footnote', '1:raw-html']); + // A terminator split across two lines is not a terminator: the CDATA block runs on + // to the contiguous `]]>`, and that whole line is block content. + assert.deepEqual(hits(['<![CDATA[', 'data ]]', '> still inside [^no]', ']]> [^after]', '', '[^real]'].join('\n')), [ + '1:raw-html', + '6:footnote', + ]); + // Indentation decides whether a line opens a block at all: a tab is one indent + // character, so a tab-indented opener still opens one, terminator line included. + assert.deepEqual(hits(['\t<?php', '[^inside]', '\t?> [^after]', '', '[^real]'].join('\n')), [ + '1:raw-html', + '5:footnote', + ]); + // Four leading spaces are indented code, which opens no block: an unterminated + // opener there swallows nothing, and the line after it still reports. + assert.deepEqual(hits([' <?php', '[^after]'].join('\n')), ['2:footnote']); +}); + +// --- the shared render fixtures ----------------------------------------------- + +interface ExpectedFinding { + rule: string; + severity: string; + path: string; + line: number; + construct: string; +} + +interface GoldenTree { + status: 'pending' | 'baked' | 'none'; + contentDir?: string; + manifest?: string; +} + +interface ExpectedExport { + args?: string[]; + exit: number; + findings: ExpectedFinding[]; + out: string; + layout?: { roles?: Record<string, string>; present?: string[]; absent?: string[]; preserved?: string[] }; + rerun?: { byteIdentical?: boolean }; + goldenTree: GoldenTree; +} + +/** The layout fixtures are driven by canary.test.ts, which asserts their trust + * corpus alongside the same export block; this harness takes the rest. */ +const CANARY_DRIVEN = new Set([ + 'valid-unified-leji-fresh', + 'valid-unified-leji-stale-tree', + 'valid-trust-canary-nested-root', + 'valid-trust-canary-dot-root', +]); + +/** run() writes to the console; swallow it and hand back what it said. */ +async function quiet<T>(fn: () => T | Promise<T>): Promise<{ value: T; stdout: string }> { + const chunks: string[] = []; + const log = console.log; + const err = console.error; + console.log = (...a: unknown[]) => void chunks.push(a.map(String).join(' ') + '\n'); + console.error = () => {}; + try { + return { value: await fn(), stdout: chunks.join('') }; + } finally { + console.log = log; + console.error = err; + } +} + +/** Every path under `dir` as `rel -> digest` (directories as `rel/` -> ''), so a + * comparison covers appearance and disappearance as well as content. */ +function snapshot(dir: string, rel = '', acc = new Map<string, string>()): Map<string, string> { + 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; +} + +/** A golden artifact at its declared name, or at the dot-prefixed name beside it: + * a `rootPath: "."` fixture exports its own root, so a plainly named golden would + * be exported into the next bake of itself (fixtures/README.md). */ +function goldenPath(fixtureRoot: string, declared: string): string { + const [head, ...rest] = declared.split('/'); + const plain = path.join(fixtureRoot, head, ...rest); + return fs.existsSync(plain) ? plain : path.join(fixtureRoot, '.' + head, ...rest); +} + +/** 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 })) { + const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; + if (entry.isDirectory()) filesUnder(dir, childRel, acc); + else acc.push(childRel); + } + return acc.sort(); +} + +for (const name of fs.readdirSync(fixturesDir).sort()) { + if (CANARY_DRIVEN.has(name)) continue; + const expectedFile = path.join(fixturesDir, name, 'expected.json'); + if (!fs.existsSync(expectedFile)) continue; + const expected = JSON.parse(fs.readFileSync(expectedFile, 'utf8')) as { export?: ExpectedExport }; + const block = expected.export; + if (!block) continue; + + test(`fixture ${name}: the export block, its findings, and its golden tree`, async () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'leji-render-'))); + fs.cpSync(path.join(fixturesDir, name), dir, { recursive: true }); + + const preservedBefore = new Map<string, string>(); + for (const rel of block.layout?.preserved ?? []) { + const abs = path.join(dir, ...rel.split('/')); + assert.ok(fs.existsSync(abs), `preserved path exists before the run: ${rel}`); + if (fs.statSync(abs).isFile()) preservedBefore.set(rel, fs.readFileSync(abs, 'utf8')); + } + + // The whole command, under the fixture's own argv: the exit code is the + // process's, and the findings are the ones a `--json` consumer reads. + const args = block.args ?? ['export']; + const { value: exit, stdout } = await quiet(() => run([...args, '--root', dir, '--json'])); + assert.equal(exit, block.exit, `exit code for ${name}: ${stdout}`); + const doc = JSON.parse(stdout) as { out: string; findings: ExpectedFinding[] }; + assert.equal(doc.out.split(path.sep).join('/'), block.out, 'the declared output directory'); + // Matched on (rule, severity, path, line, construct) IN ORDER — message text is + // never compared, and the order is the canonical one the three SDKs share. + assert.deepEqual( + doc.findings.map((f) => ({ + rule: f.rule, + severity: f.severity, + path: f.path, + line: f.line, + construct: f.construct, + })), + block.findings, + `findings for ${name}`, + ); + + // `roles` is the layout's role map — which directory each role NAMES — and + // `present`/`absent` say which of them a given run establishes: a `--strict` + // run names the export role and deliberately writes nothing at it. + const absent = new Set((block.layout?.absent ?? []).map((p) => p.replace(/\/+$/, ''))); + for (const [role, roleDir] of Object.entries(block.layout?.roles ?? {})) { + const rel = roleDir.replace(/\/+$/, ''); + const abs = path.join(dir, ...rel.split('/')); + if (absent.has(rel)) continue; + assert.ok(fs.existsSync(abs) && fs.statSync(abs).isDirectory(), `role ${role} established at ${roleDir}`); + } + for (const rel of block.layout?.present ?? []) { + assert.ok( + fs.existsSync(path.join(dir, ...rel.replace(/\/+$/, '').split('/'))), + `present after the run: ${rel}`, + ); + } + for (const rel of block.layout?.absent ?? []) { + assert.ok(!fs.existsSync(path.join(dir, ...rel.replace(/\/+$/, '').split('/'))), `never created: ${rel}`); + } + for (const [rel, before] of preservedBefore) { + assert.equal(fs.readFileSync(path.join(dir, ...rel.split('/')), 'utf8'), before, `byte-identical: ${rel}`); + } + + // --- the golden tree ----------------------------------------------------- + const out = path.join(dir, ...block.out.split('/')); + if (block.goldenTree.status === 'none') { + assert.ok(!fs.existsSync(out), 'a run that writes no export tree has nothing to bake'); + } + if (block.goldenTree.status === 'baked') { + const contentDir = goldenPath(path.join(fixturesDir, name), block.goldenTree.contentDir!); + const manifestFile = goldenPath(path.join(fixturesDir, name), block.goldenTree.manifest!); + const written = filesUnder(out); + const inContent = written.filter((f) => f.startsWith('content/')); + const outside = written.filter((f) => !f.startsWith('content/')); + + // The committed bytes ARE the export's content tree: same paths, same bytes, + // in both directions, so a file that appears or disappears fails here. + assert.deepEqual( + inContent.map((f) => f.slice('content/'.length)), + filesUnder(contentDir), + `${name}: the golden content tree lists exactly what the export wrote`, + ); + for (const rel of filesUnder(contentDir)) { + assert.deepEqual( + fs.readFileSync(path.join(out, 'content', ...rel.split('/'))), + fs.readFileSync(path.join(contentDir, ...rel.split('/'))), + `${name}: exported bytes differ from the golden for content/${rel}`, + ); + } + + // Everything else — chrome, vendored assets, fonts — by digest and size. The + // two sets are disjoint by construction and exhaustive by this comparison. + const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) as { + version: number; + files: Record<string, { sha256: string; size: number }>; + }; + assert.equal(manifest.version, 1, 'the manifest states its version'); + assert.deepEqual( + Object.keys(manifest.files), + outside, + `${name}: the manifest pins every file outside content/`, + ); + for (const rel of outside) { + const bytes = fs.readFileSync(path.join(out, ...rel.split('/'))); + assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), manifest.files[rel].sha256, rel); + assert.equal(bytes.length, manifest.files[rel].size, `${rel} size`); + } + } + + // --- idempotency --------------------------------------------------------- + if (block.rerun?.byteIdentical) { + const afterFirst = snapshot(dir); + await quiet(() => run([...args, '--root', dir, '--json'])); + assert.deepEqual( + [...snapshot(dir).entries()].sort(), + [...afterFirst.entries()].sort(), + 'a second run is a byte-level no-op across the whole working tree', + ); + } + fs.rmSync(dir, { recursive: true, force: true }); + }); +} diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index e7b80eb..ac49127 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -130,6 +130,22 @@ test('init emits no machine block (core), the minimal manifest', async () => { assert.ok(fs.existsSync(path.join(dir, 'docs', 'agents', 'core.md'))); }); +test('the scaffolded core profile fills its escalation placeholder', async () => { + const dir = gitTmpdir(); + const result = await initLayer({ dir, yes: true }); + const owner = result.manifest.owners.primary.name; + const core = fs.readFileSync(path.join(dir, 'docs', 'agents', 'core.md'), 'utf8'); + assert.ok( + core.includes(`Ask the primary owner (${owner}) whenever mustAskWhen applies`), + 'the escalation line names the owner', + ); + // Nothing angle-bracketed survives into the written profile: a `<...>` in an agent + // profile is exactly what `leji validate --content` flags as a placeholder. The owner + // name is removed first because git with no identity yields `<named owner>`, which is + // the manifest's own fallback rather than an unfilled template slot. + assert.equal(core.replaceAll(owner, '').includes('<'), false, core); +}); + test('indexed init: no machine key, yet the index and changelog are written at the defaults', async () => { const dir = gitTmpdir(); const result = await initLayer({ dir, yes: true, level: 'indexed', name: 'acme-context' }); diff --git a/packages/sdk/test/site-links.test.ts b/packages/sdk/test/site-links.test.ts new file mode 100644 index 0000000..a76ad3a --- /dev/null +++ b/packages/sdk/test/site-links.test.ts @@ -0,0 +1,112 @@ +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'; + +// Every leji.org URL this SDK hands a user must land on something this repository +// actually ships. The URLs are constants in the source (`leji badge`'s markdown +// wrapper, the help footers, the install hint, the `$schema` values written into a +// scaffolded layer), so nothing at runtime can catch a dead one: a page renamed or +// never written turns every emitted line into a 404 the adopter publishes. +// +// Two kinds of target, because there are two kinds of URL: +// /<path>/ a site page under packages/site/src/pages +// /schemas/v1.0/<f> a canonical schema, served from the repo-root schemas/ +// +// Single implementation, deliberately: the Python and Go ports mirror these exact +// strings and the parity suite pins them, so checking the TypeScript reference +// covers the class. Dependency-free (a directory walk and one regex) so it runs in +// `test:unit` with nothing installed beyond the package's own devDependencies. +// +// Known limit, stated rather than papered over: a page reached through a dynamic +// route (`pages/spec/[...slug].astro`) is not recognized as a target. No emitted +// URL uses one today; one that did would fail here loudly, and the rule below is +// where to widen it. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(testDir, '..'); +const repoRoot = path.resolve(packageDir, '..', '..'); +const srcDir = path.join(packageDir, 'src'); +const cliSpec = path.join(packageDir, 'cli.json'); +const pagesDir = path.join(repoRoot, 'packages', 'site', 'src', 'pages'); +const schemasDir = path.join(repoRoot, 'schemas'); + +/** The site origin, and the path that follows it, wherever either appears in a + * string literal. The character class stops at the closing quote, backtick or + * parenthesis that ends the literal it sits in. */ +const SITE_URL = /https:\/\/leji\.org([A-Za-z0-9._/-]*)/g; + +/** URLs under this prefix are schema files, not pages. */ +const SCHEMA_PREFIX = '/schemas/v1.0/'; + +interface SiteUrl { + /** The path part, `/` for the bare origin. */ + sitePath: string; + /** Where it is written, repository-relative, for the failure message. */ + sources: string[]; +} + +/** Every TypeScript file under `src/`, plus the CLI spec. */ +function scannedFiles(dir: string, out: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) scannedFiles(abs, out); + else if (entry.isFile() && entry.name.endsWith('.ts')) out.push(abs); + } + return out; +} + +function collect(): SiteUrl[] { + const found = new Map<string, Set<string>>(); + for (const abs of [...scannedFiles(srcDir), cliSpec]) { + const rel = path.relative(repoRoot, abs).split(path.sep).join('/'); + for (const match of fs.readFileSync(abs, 'utf8').matchAll(SITE_URL)) { + const sitePath = match[1] === '' ? '/' : match[1]; + const sources = found.get(sitePath) ?? new Set<string>(); + sources.add(rel); + found.set(sitePath, sources); + } + } + return [...found] + .map(([sitePath, sources]) => ({ sitePath, sources: [...sources].sort() })) + .sort((a, b) => a.sitePath.localeCompare(b.sitePath)); +} + +function isFile(abs: string): boolean { + return fs.existsSync(abs) && fs.statSync(abs).isFile(); +} + +/** `/` is the index; `/x/` is `x.astro` or `x/index.astro`, and a deeper path is + * the same rule over its segments. */ +function pageExists(sitePath: string): boolean { + const segments = sitePath.split('/').filter((s) => s !== ''); + if (segments.length === 0) return isFile(path.join(pagesDir, 'index.astro')); + const base = path.join(pagesDir, ...segments); + return isFile(`${base}.astro`) || isFile(path.join(base, 'index.astro')); +} + +/** A canonical schema URL resolves to the file at the repository root, which is the + * one the site serves at that `$id`. */ +function schemaExists(sitePath: string): boolean { + const rest = sitePath.slice(SCHEMA_PREFIX.length).split('/'); + return rest.every((s) => s !== '' && s !== '..') && isFile(path.join(schemasDir, ...rest)); +} + +function targetExists(sitePath: string): boolean { + return sitePath.startsWith(SCHEMA_PREFIX) ? schemaExists(sitePath) : pageExists(sitePath); +} + +test('every leji.org URL the SDK emits has a target in this repository', () => { + const urls = collect(); + // A scan that finds nothing would pass silently; these URLs exist, so an empty + // collection means the regex or the walk broke, not that the source is clean. + assert.ok(urls.length > 0, 'no https://leji.org URL was collected: the scan is broken, not the source'); + const missing = urls.filter((u) => !targetExists(u.sitePath)).map((u) => `${u.sitePath} (${u.sources.join(', ')})`); + assert.deepEqual( + missing, + [], + `leji.org URLs with no target in this repository: ${missing.join(', ')}\n` + + 'Add the page under packages/site/src/pages (or the schema under schemas/), or stop emitting the URL.', + ); +}); diff --git a/packages/sdk/test/source-audit.test.ts b/packages/sdk/test/source-audit.test.ts new file mode 100644 index 0000000..167bc17 --- /dev/null +++ b/packages/sdk/test/source-audit.test.ts @@ -0,0 +1,513 @@ +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 acceptance check for the write boundary: no production source file of this SDK +// reaches a raw filesystem mutation, or a subprocess that could perform one, except at +// a symbol named below. Every other write goes through `lib/fsx.ts` — the chokepoint +// and its guarded conveniences — so a new write site is contained by construction +// rather than by remembering to contain it, and a reviewer can read the exceptions +// instead of re-deriving them. `docs/practice/trust-boundary.md` mirrors both lists. +// +// The scan is TYPE-RESOLVED: the whole package is loaded as a program and every call is +// asked what it actually calls, so a mutator is recognized by the declaration it lands +// on rather than by how the call was spelled. `fs.rm(...)`, `fs['rm'](...)`, +// `fs.promises.rm(...)`, `import { rm as nuke }`, `const { rm } = fs`, a mutator +// destructured out of `await import('node:fs/promises')`, or one re-exported by a local +// module all resolve to the same `@types/node` declaration and are all caught. On top of +// that: the unmistakable synchronous names are still matched as identifiers (a +// belt-and-braces layer that needs no type at all), and `require()` or a dynamic +// `import()` of an fs or child_process module is banned outright — the SDK is ESM, so +// either is a laundering attempt rather than a style. + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(testDir, '..'); +const srcDir = path.join(packageDir, 'src'); + +const FS_MODULES: ReadonlySet<string> = new Set(['fs', 'node:fs', 'fs/promises', 'node:fs/promises']); +const CHILD_PROCESS_MODULES: ReadonlySet<string> = new Set(['child_process', 'node:child_process']); + +/** + * The synchronous mutation surface. These names belong to no other API a repository + * like this uses, so they are matched on the IDENTIFIER anywhere: an indirect alias + * (`const w = fs.rmSync`) is caught along with the direct call. + */ +const SYNC_MUTATORS: ReadonlySet<string> = new Set([ + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'copyFileSync', + 'cpSync', + 'createWriteStream', + 'fchmodSync', + 'fchownSync', + 'ftruncateSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lutimesSync', + 'mkdirSync', + 'mkdtempSync', + 'openSync', + 'renameSync', + 'rmdirSync', + 'rmSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', + 'writevSync', +]); + +/** + * The callback and promise forms. Their names are ordinary English words that any + * object may carry (`process.stderr.write`, `res.write`, a RegExp's `exec`), so they + * count only when the call RESOLVES to a declaration in Node's `fs` typings — which no + * amount of rebinding can hide, and which `process.stderr.write` never does. + */ +const FS_MUTATORS: ReadonlySet<string> = new Set([ + 'appendFile', + 'chmod', + 'chown', + 'copyFile', + 'cp', + 'fchmod', + 'fchown', + 'ftruncate', + 'futimes', + 'lchmod', + 'lchown', + 'link', + 'lutimes', + 'mkdir', + 'mkdtemp', + 'open', + 'rename', + 'rm', + 'rmdir', + 'symlink', + 'truncate', + 'unlink', + 'utimes', + 'write', + 'writeFile', + 'writev', +]); + +/** Every mutator name as `@types/node` declares it, both forms. */ +const MUTATORS: ReadonlySet<string> = new Set([...SYNC_MUTATORS, ...FS_MUTATORS]); + +/** Anything that hands work to another program, which can then write whatever it + * likes. Recognized the same type-resolved way, against `child_process`. */ +const SUBPROCESS: ReadonlySet<string> = new Set([ + 'exec', + 'execFile', + 'execFileSync', + 'execSync', + 'fork', + 'spawn', + 'spawnSync', +]); + +/** + * The write allow-list, by `file#symbol` — never by whole module, so a future raw + * mutation elsewhere in an allowed file still fails. An entry that matches nothing + * fails too: a stale exception is an exception nobody is checking. + */ +const ALLOWED_WRITES: Readonly<Record<string, string>> = { + 'lib/fsx.ts#writeFileGuarded': 'the chokepoint itself: the guarded write, judged before it acts', + 'lib/fsx.ts#mkdirpGuarded': 'the chokepoint itself: the guarded directory establishment', + 'lib/fsx.ts#rmGuarded': 'the chokepoint itself: the guarded clear', + 'lib/fsx.ts#renameGuarded': 'the chokepoint itself: the guarded rename, both ends judged', + 'lib/fsx.ts#chmodGuarded': 'the chokepoint itself: the guarded mode change', + 'lib/fsx.ts#openWriteGuarded': 'the chokepoint itself: the guarded destination descriptor', + 'lib/fsx.ts#writeFileAtomicGuarded': 'the chokepoint itself: temp sibling plus rename, both ends judged', + 'lib/fsx.ts#openVerifiedSource': 'the verified READ: opens a judged source read-only', + 'commands/export.ts#copyFromDescriptor': 'writes into the descriptor openWriteGuarded returned, never to a path', + 'lib/mounts.ts#extractProjection': 'mounts per-entry protocol, under a store root the chokepoint established', + 'lib/mounts.ts#publishCacheEntry': 'mounts per-entry protocol: sidecar, marker, publish-by-rename, staging clear', + 'lib/mounts.ts#hydrateMounts': 'mounts per-entry protocol: clears its own established staging directory', + 'lib/mounts.ts#verifyProjection': 'verification staging under the OS temp directory, outside the repository', + 'commands/init.ts#initLayer': 'root bootstrap: creates the selected root before any repository root exists', + 'commands/init.ts#adoptLayer': 'root bootstrap: creates the selected root before any repository root exists', +}; + +/** + * The subprocess allow-list. A child process is outside every guard this SDK can + * enforce, so each caller is named with what it runs and what it may write. + */ +const ALLOWED_SUBPROCESSES: Readonly<Record<string, string>> = { + 'lib/git.ts#git': 'read-only git queries (log, ls-files, status) in the host repository', + 'lib/mounts.ts#runGit': + 'the federation resolver: git init/fetch write ONLY into a store or cache root the chokepoint established, plus read-only queries', + 'commands/init.ts#gitConfig': 'read-only `git config --get`', + 'commands/init.ts#hooksPathConfig': 'read-only `git -C root config core.hooksPath`', + 'commands/init.ts#gitHooksDir': 'read-only `git rev-parse --git-path hooks`', + 'commands/init.ts#gitDirs': 'read-only `git rev-parse --git-dir --git-common-dir`', + 'commands/init.ts#launch': + 'the handoff IO: launches the agent host the user chose; its writes are that program, not this SDK', + 'commands/init.ts#run': + 'the handoff IO: runs the host command the user chose (same reasoning as `launch`), or, under `capture`, the preflight version probe: argv only, cwd-pinned to the repository root, stdin closed, stderr discarded, output and wall time capped while the child runs (passing either bound terminates it), and an environment that REPLACES this process\u2019s rather than extending it; it never invokes a package manager\u2019s script runner', + 'commands/serve.ts#openBrowser': 'opens the preview URL in the desktop browser; writes nothing', + 'lib/localcli.ts#spawnInherit': + "the hand-off: the installed executable runs the repository's OWN pinned Leji CLI, chosen only when the repository directly declares it and the copy is installed inside the repository with its package identity verified and meeting the layer's minimum; argv, never a shell, and the child inherits this terminal and environment because it IS this invocation, so its writes are that copy's rather than this one's", +}; + +/** The package's own compiler options, so the program the audit type-checks is the + * program `tsc` builds. */ +function compilerOptions(): ts.CompilerOptions { + const configPath = path.join(packageDir, 'tsconfig.json'); + const read = ts.readConfigFile(configPath, ts.sys.readFile); + assert.equal(read.error, undefined, `cannot read ${configPath}`); + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, packageDir); + // Nothing is emitted: the audit only asks the checker what each call resolves to. + return { ...parsed.options, noEmit: true, incremental: false, composite: false }; +} + +const OPTIONS = compilerOptions(); + +/** Where `@types/node` declares the fs and child_process modules, taken from the + * program's own ambient module declarations rather than guessed from a path — so the + * audit fails loudly if the typings ever move instead of silently seeing nothing. */ +function moduleDeclarationFiles(checker: ts.TypeChecker, names: ReadonlySet<string>): ReadonlySet<string> { + const files = new Set<string>(); + for (const module of checker.getAmbientModules()) { + if (!names.has(module.getName().replace(/^"|"$/g, ''))) continue; + for (const declaration of module.declarations ?? []) { + files.add(path.resolve(declaration.getSourceFile().fileName)); + } + } + return files; +} + +/** The declaration files one audit pass judges against. */ +interface Typings { + fs: ReadonlySet<string>; + childProcess: ReadonlySet<string>; +} + +/** The nearest named FUNCTION containing `node`: the declaration, method, or named + * arrow a reader would cite when arguing the exception. An anonymous callback is + * transparent — a raw primitive inside one belongs to the function that owns it, not + * to the variable the enclosing call happens to be assigned to. */ +function enclosingSymbol(node: ts.Node): string { + for (let n: ts.Node | undefined = node; n !== undefined; n = n.parent) { + if (!ts.isFunctionLike(n)) continue; + 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; + } + } + return '(top level)'; +} + +interface Hit { + key: string; + line: number; + name: string; +} + +/** One resolved callee: the name it was DECLARED under and the file declaring it. */ +interface Callee { + name: string; + file: string; +} + +/** Everything the checker can say about what this call calls: the signature it + * resolved to, and the symbol behind the callee with every alias followed (a named + * import, an `import x as y`, a local re-export chain). */ +function resolveCallee(node: ts.CallExpression | ts.NewExpression, checker: ts.TypeChecker): Callee[] { + const out: Callee[] = []; + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + if (declaration !== undefined) { + const named = (declaration as { name?: ts.Node }).name; + if (named !== undefined && ts.isIdentifier(named as ts.Node)) { + out.push({ name: (named as ts.Identifier).text, file: path.resolve(declaration.getSourceFile().fileName) }); + } + } + let symbol = checker.getSymbolAtLocation(node.expression); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = checker.getAliasedSymbol(symbol); + } + for (const decl of symbol?.declarations ?? []) { + out.push({ name: symbol!.getName(), file: path.resolve(decl.getSourceFile().fileName) }); + } + return out; +} + +/** `fs.open`/`openSync` count as mutations unless the flag is literally read-only: + * every other flag creates or truncates, and an absent one cannot be proven read-only. */ +function readOnlyOpen(node: ts.CallExpression | ts.NewExpression): boolean { + return (node.arguments ?? []).some((arg) => ts.isStringLiteral(arg) && arg.text === 'r'); +} + +/** The module specifier of a `require('…')` or a dynamic `import('…')`, else null. */ +function bannedRequireOrImport(node: ts.CallExpression): string | null { + const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require'; + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; + if (!isRequire && !isDynamicImport) return null; + const arg = node.arguments[0]; + if (arg === undefined || !ts.isStringLiteral(arg)) return null; + return arg.text; +} + +/** Every filesystem mutation and every subprocess call in one file. */ +function scan( + rel: string, + source: ts.SourceFile, + checker: ts.TypeChecker, + typings: Typings, +): { writes: Hit[]; subprocesses: Hit[] } { + const writes: Hit[] = []; + const subprocesses: Hit[] = []; + const seen = new Set<string>(); + const record = (into: Hit[], node: ts.Node, name: string): void => { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + const key = `${rel}#${enclosingSymbol(node)}`; + const dedupe = `${key}:${line}:${name}`; + if (seen.has(dedupe)) return; + seen.add(dedupe); + into.push({ key, line, name }); + }; + const visit = (node: ts.Node): void => { + // The import statement is where a binding is declared, not where it is used. + if (ts.isImportDeclaration(node)) return; + // Belt and braces, needing no type at all: the unmistakable synchronous names, + // matched wherever they appear, in either syntax. + const literal = ts.isIdentifier(node) + ? node.text + : ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) + ? node.text + : null; + if (literal !== null && SYNC_MUTATORS.has(literal)) record(writes, node, literal); + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + // What does this call actually call? However the binding was obtained — + // namespace, named, aliased, destructured, re-exported, `fs.promises.*`, + // element access — the declaration it lands on is the same one. + for (const callee of resolveCallee(node, checker)) { + if (typings.fs.has(callee.file) && MUTATORS.has(callee.name)) { + if ((callee.name === 'open' || callee.name === 'openSync') && readOnlyOpen(node)) continue; + record(writes, node, callee.name); + } + if (typings.childProcess.has(callee.file) && SUBPROCESS.has(callee.name)) { + record(subprocesses, node, callee.name); + } + } + } + // An ESM package has no business calling `require`, and a dynamic `import()` of + // these modules is a binding the checker would have to chase at runtime: both are + // refused outright rather than analyzed. + if (ts.isCallExpression(node)) { + const specifier = bannedRequireOrImport(node); + if (specifier !== null && FS_MODULES.has(specifier)) record(writes, node, `import of ${specifier}`); + if (specifier !== null && CHILD_PROCESS_MODULES.has(specifier)) { + record(subprocesses, node, `import of ${specifier}`); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return { writes, subprocesses }; +} + +/** The audited files of a program: production source only (no `.d.ts`, no tests). */ +function auditProgram(program: ts.Program, root: string): { writes: Hit[]; subprocesses: Hit[] } { + const checker = program.getTypeChecker(); + const typings: Typings = { + fs: moduleDeclarationFiles(checker, FS_MODULES), + childProcess: moduleDeclarationFiles(checker, CHILD_PROCESS_MODULES), + }; + assert.ok(typings.fs.size > 0, 'the fs typings are not in the program: the audit would recognize no fs call'); + assert.ok(typings.childProcess.size > 0, 'the child_process typings are not in the program'); + const writes: Hit[] = []; + const subprocesses: Hit[] = []; + const files = program + .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 audit loaded no source files'); + for (const source of files) { + const rel = path.relative(root, path.resolve(source.fileName)).split(path.sep).join('/'); + const found = scan(rel, source, checker, typings); + writes.push(...found.writes); + subprocesses.push(...found.subprocesses); + } + return { writes, subprocesses }; +} + +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); + } + return cached; +} + +/** Every production source file of this SDK (there are no tests under `src/`). */ +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) sourceFiles(abs, out); + else if (entry.isFile() && entry.name.endsWith('.ts')) out.push(abs); + } + return out; +} + +function unexpected(hits: Hit[], allowed: Readonly<Record<string, string>>): string[] { + return hits.filter((h) => !(h.key in allowed)).map((h) => `${h.key} (${h.name}) at line ${h.line}`); +} + +function stale(hits: Hit[], allowed: Readonly<Record<string, string>>): string[] { + const matched = new Set(hits.map((h) => h.key)); + return Object.keys(allowed).filter((key) => !matched.has(key)); +} + +test('no production source reaches a raw filesystem mutation outside the allow-list', () => { + const { writes } = audit(); + const outside = unexpected(writes, ALLOWED_WRITES); + assert.deepEqual( + outside, + [], + `raw filesystem mutations outside the chokepoint: ${outside.join(', ')}\n` + + 'Route the write through lib/fsx.ts (writeFileGuarded, mkdirpGuarded, rmGuarded, renameGuarded, ' + + 'chmodGuarded, openWriteGuarded, writeFileAtomicGuarded), or argue the exception into the allow-list.', + ); + const dead = stale(writes, ALLOWED_WRITES); + assert.deepEqual(dead, [], `write allow-list entries matching no symbol (delete them): ${dead.join(', ')}`); +}); + +test('every subprocess call is a named, reasoned exception', () => { + const { subprocesses } = audit(); + const outside = unexpected(subprocesses, ALLOWED_SUBPROCESSES); + assert.deepEqual( + outside, + [], + `subprocess calls outside the allow-list: ${outside.join(', ')}\n` + + 'A child process writes wherever it likes; name the caller and say what it runs and what it may write.', + ); + const dead = stale(subprocesses, ALLOWED_SUBPROCESSES); + assert.deepEqual(dead, [], `subprocess allow-list entries matching no symbol (delete them): ${dead.join(', ')}`); +}); + +/** + * 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 + * would be — same options, same `@types/node` — and fed to the same analyzer, so the + * audit's reach is asserted rather than assumed. The last file is the control: two + * ordinary `write`/`exec` calls that share their names with the mutator list and must + * never be flagged. + */ +const PROBES: ReadonlyArray<{ rel: string; source: string; flagged: boolean }> = [ + { + rel: '__probe_destructured.ts', + flagged: true, + source: [ + "import * as nodeFs from 'node:fs';", + 'const { rm } = nodeFs;', + 'export function launderDestructured(p: string): void {', + ' rm(p, () => {});', + '}', + ].join('\n'), + }, + { + rel: '__probe_dynamic_import.ts', + flagged: true, + source: [ + 'export async function launderDynamicImport(p: string): Promise<void> {', + " const { writeFile } = await import('node:fs/promises');", + " await writeFile(p, 'x');", + '}', + ].join('\n'), + }, + { + rel: '__probe_require.ts', + flagged: true, + source: [ + 'export function launderRequire(p: string): void {', + " const required = require('node:fs') as { rm: (p: string, cb: () => void) => void };", + ' required.rm(p, () => {});', + '}', + ].join('\n'), + }, + { + rel: '__probe_reexport_source.ts', + flagged: false, + source: "export { rm } from 'node:fs/promises';", + }, + { + rel: '__probe_reexport.ts', + flagged: true, + source: [ + "import { rm } from './__probe_reexport_source.js';", + 'export async function launderReexport(p: string): Promise<void> {', + ' await rm(p, { recursive: true });', + '}', + ].join('\n'), + }, + { + rel: '__probe_negative.ts', + flagged: false, + source: [ + 'export function notAMutation(line: string): boolean {', + ' process.stderr.write(`${line}\\n`);', + ' return /leji/.exec(line) !== null;', + '}', + ].join('\n'), + }, +]; + +/** 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 } { + const root = path.join(srcDir, '__audit_probes'); + const files = new Map<string, string>(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); + const fileExists = host.fileExists.bind(host); + const directoryExists = host.directoryExists?.bind(host); + host.readFile = (fileName) => files.get(path.resolve(fileName)) ?? readFile(fileName); + host.fileExists = (fileName) => files.has(path.resolve(fileName)) || fileExists(fileName); + host.directoryExists = (dir) => + path.resolve(dir) === root || (directoryExists === undefined ? true : directoryExists(dir)); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + const overlaid = files.get(path.resolve(fileName)); + return overlaid === undefined + ? getSourceFile(fileName, languageVersion, onError, shouldCreate) + : ts.createSourceFile(fileName, overlaid, languageVersion, true); + }; + return { program: ts.createProgram({ rootNames: [...files.keys()], options: OPTIONS, host }), root }; +} + +test('the analyzer sees through every known laundering of an fs binding', () => { + const { program, root } = probeProgram(); + const { writes, subprocesses } = auditProgram(program, root); + const flagged = new Set(writes.map((h) => h.key.split('#')[0])); + for (const probe of PROBES.filter((p) => p.flagged)) { + assert.ok( + flagged.has(probe.rel), + `${probe.rel} laundered an fs mutator past the audit (flagged: ${[...flagged].join(', ') || 'nothing'})`, + ); + } + assert.equal(flagged.has('__probe_negative.ts'), false, 'process.stderr.write / RegExp exec must not be flagged'); + assert.deepEqual( + subprocesses.filter((h) => h.key.startsWith('__probe_negative.ts')), + [], + 'the negative control must raise no subprocess hit either', + ); +}); diff --git a/packages/sdk/test/units.test.ts b/packages/sdk/test/units.test.ts index acab4b9..a9e1b5a 100644 --- a/packages/sdk/test/units.test.ts +++ b/packages/sdk/test/units.test.ts @@ -7,6 +7,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import * as vm from 'node:vm'; import { buildSidebar, buildManifestPage, @@ -42,6 +43,7 @@ import { joinUnderRoot, walkMd, underPath } from '../dist/lib/fsx.js'; import { templatesDir } from '../dist/lib/schemas.js'; import { excludedFromCategories, scanAgentProfiles, scanCategories } from '../dist/lib/layer.js'; import { route } from '../dist/lib/route.js'; +import { mermaidTextColor } from '../dist/commands/viewer.js'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const exampleDir = path.join(repoRoot, 'examples', 'monorepo'); @@ -50,6 +52,23 @@ function tmpdir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } +/** Every entry under `dir` as `path -> bytes` (symlinks by their target), so a run + * that must write nothing can be held to the whole tree rather than to one file. */ +function treeSnapshot(dir: string): Record<string, string> { + const out: Record<string, string> = {}; + const walk = (rel: string): void => { + for (const entry of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) { + const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`; + const abs = path.join(dir, childRel); + if (entry.isSymbolicLink()) out[childRel] = `link:${fs.readlinkSync(abs)}`; + else if (entry.isDirectory()) walk(childRel); + else if (entry.isFile()) out[childRel] = fs.readFileSync(abs).toString('base64'); + } + }; + walk(''); + return out; +} + // The example's git-tracked file list is invariant across a test-file run, so // resolve it once instead of shelling out to git on every copyExample(). let trackedExampleFiles: string[] | undefined; @@ -872,6 +891,48 @@ test('seedChangelogIfMissing does not re-seed when a changelog already exists', assert.equal(fs.readFileSync(abs, 'utf8'), sentinel, 'existing changelog left untouched'); }); +test('seedChangelogIfMissing treats a dangling changelog link as present, never seeding through it', () => { + // `existsSync` follows symlinks, so a dangling changelog link read as absent and the + // seed was created at the link's missing destination. The exclusive create judges the + // ORIGINAL entry, so any standing entry is the same no-op an existing changelog is. + const dir = tmpdir('leji-seed-dangling-'); + fs.cpSync(path.join(repoRoot, 'fixtures', 'valid-minimal-core'), dir, { recursive: true }); + const mp = path.join(dir, 'leji.json'); + const m = JSON.parse(fs.readFileSync(mp, 'utf8')); + m.conformance = { ...(m.conformance ?? {}), claimedLevel: 'indexed' }; + fs.writeFileSync(mp, JSON.stringify(m, null, 2) + '\n'); + const link = path.join(dir, 'docs', 'context-changelog.json'); + fs.symlinkSync('never-created.json', link); + const { manifest } = loadManifest(dir); + + const result = seedChangelogIfMissing(dir, manifest!); + + assert.equal(result, null, 'a standing entry is never seeded through'); + assert.equal( + fs.existsSync(path.join(dir, 'docs', 'never-created.json')), + false, + "the dangling link's destination is never created", + ); + assert.ok(fs.lstatSync(link).isSymbolicLink(), 'the planted link is left exactly as it was'); +}); + +test('seedChangelogIfMissing refuses a changelog link resolving outside the repository', () => { + const dir = tmpdir('leji-seed-outlink-'); + fs.cpSync(path.join(repoRoot, 'fixtures', 'valid-minimal-core'), dir, { recursive: true }); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'leji-seed-outside-link-')); + const mp = path.join(dir, 'leji.json'); + const m = JSON.parse(fs.readFileSync(mp, 'utf8')); + m.conformance = { ...(m.conformance ?? {}), claimedLevel: 'indexed' }; + fs.writeFileSync(mp, JSON.stringify(m, null, 2) + '\n'); + fs.symlinkSync(path.join(outside, 'context-changelog.json'), path.join(dir, 'docs', 'context-changelog.json')); + const { manifest } = loadManifest(dir); + + const result = seedChangelogIfMissing(dir, manifest!); + + assert.equal(result, null, 'nothing seeded through a link that leaves the repository'); + assert.equal(fs.existsSync(path.join(outside, 'context-changelog.json')), false, 'nothing written outside the root'); +}); + test('seedChangelogIfMissing refuses a path escaping the root via a symlinked ancestor', () => { const dir = tmpdir('leji-seed-symesc-'); fs.cpSync(path.join(repoRoot, 'fixtures', 'valid-minimal-core'), dir, { recursive: true }); @@ -977,7 +1038,7 @@ test('buildManifestPage: escapes hostile strings, covers drift states, byte-orde ); assert.ok(page.includes('unrelated'), 'the unrelated drift label is rendered'); assert.ok( - page.includes('**Roles**') && page.includes('- **alpha** — r'), + page.includes('**Roles**') && page.includes('- **alpha**: r'), 'role descriptions move below the table as a per-mount list', ); assert.ok( @@ -1009,65 +1070,89 @@ test('viewer: generates viewer + sidebar that reflect the layer', () => { const { manifest } = loadManifest(dir); const result = generateViewer(dir, manifest!); assert.deepEqual(result.written, [ - 'docs/.leji/viewer/index.html', - 'docs/.leji/viewer/_sidebar.md', - 'docs/.leji/viewer/assets/docsify-copy-code.min.js', - 'docs/.leji/viewer/assets/docsify-mermaid.js', - 'docs/.leji/viewer/assets/docsify-sidebar-collapse.min.css', - 'docs/.leji/viewer/assets/docsify-sidebar-collapse.min.js', - 'docs/.leji/viewer/assets/docsify.min.js', - 'docs/.leji/viewer/assets/fonts-licenses.txt', - 'docs/.leji/viewer/assets/leji-logo.svg', - 'docs/.leji/viewer/assets/mermaid.min.js', - 'docs/.leji/viewer/assets/prism-bash.min.js', - 'docs/.leji/viewer/assets/prism-json.min.js', - 'docs/.leji/viewer/assets/prism-markdown.min.js', - 'docs/.leji/viewer/assets/prism-typescript.min.js', - 'docs/.leji/viewer/assets/roboto-mono-400-latin-ext.woff2', - 'docs/.leji/viewer/assets/roboto-mono-400-latin.woff2', - 'docs/.leji/viewer/assets/roboto-mono-400-vietnamese.woff2', - 'docs/.leji/viewer/assets/search.min.js', - 'docs/.leji/viewer/assets/source-sans-pro-300-latin-ext.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-300-latin.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-300-vietnamese.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-400-latin-ext.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-400-latin.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-400-vietnamese.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-600-latin-ext.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-600-latin.woff2', - 'docs/.leji/viewer/assets/source-sans-pro-600-vietnamese.woff2', - 'docs/.leji/viewer/assets/viewer-boot.js', - 'docs/.leji/viewer/assets/vue.css', - 'docs/.leji/viewer/assets/zoom-image.min.js', + '.leji/viewer/index.html', + '.leji/viewer/_sidebar.md', + '.leji/viewer/assets/docsify-copy-code.min.js', + '.leji/viewer/assets/docsify-mermaid.js', + '.leji/viewer/assets/docsify-sidebar-collapse.min.css', + '.leji/viewer/assets/docsify-sidebar-collapse.min.js', + '.leji/viewer/assets/docsify.min.js', + '.leji/viewer/assets/leji-logo.svg', + '.leji/viewer/assets/mermaid.min.js', + '.leji/viewer/assets/prism-bash.min.js', + '.leji/viewer/assets/prism-json.min.js', + '.leji/viewer/assets/prism-markdown.min.js', + '.leji/viewer/assets/prism-typescript.min.js', + '.leji/viewer/assets/roboto-mono-400-latin-ext.woff2', + '.leji/viewer/assets/roboto-mono-400-latin.woff2', + '.leji/viewer/assets/roboto-mono-400-vietnamese.woff2', + '.leji/viewer/assets/search.min.js', + '.leji/viewer/assets/source-sans-pro-300-latin-ext.woff2', + '.leji/viewer/assets/source-sans-pro-300-latin.woff2', + '.leji/viewer/assets/source-sans-pro-300-vietnamese.woff2', + '.leji/viewer/assets/source-sans-pro-400-latin-ext.woff2', + '.leji/viewer/assets/source-sans-pro-400-latin.woff2', + '.leji/viewer/assets/source-sans-pro-400-vietnamese.woff2', + '.leji/viewer/assets/source-sans-pro-600-latin-ext.woff2', + '.leji/viewer/assets/source-sans-pro-600-latin.woff2', + '.leji/viewer/assets/source-sans-pro-600-vietnamese.woff2', + '.leji/viewer/assets/third-party-licenses.txt', + '.leji/viewer/assets/viewer-boot.js', + '.leji/viewer/assets/vue.css', + '.leji/viewer/assets/zoom-image.min.js', 'docs/overview.md', - 'docs/.leji/viewer/_manifest.md', + '.leji/viewer/_manifest.md', ]); - const viewer = path.join(dir, 'docs', '.leji', 'viewer'); + const viewer = path.join(dir, '.leji', 'viewer'); // The Manifest page is generated chrome in the viewer dir (reserved underscore // name, collision-free) and pinned, like the sidebar. const manifestPage = fs.readFileSync(path.join(viewer, '_manifest.md'), 'utf8'); assert.ok(manifestPage.startsWith('# '), 'manifest page has a title heading'); - assert.ok(/—\s*Manifest/.test(manifestPage), 'title ends with — Manifest'); + assert.ok(/:\s*Manifest/.test(manifestPage), 'title ends with ": Manifest"'); assert.ok(manifestPage.includes('## Identity') && manifestPage.includes('## Entrypoints'), 'core sections present'); const sidebarMd = fs.readFileSync(path.join(viewer, '_sidebar.md'), 'utf8'); - assert.ok(sidebarMd.includes('[📄 Manifest](_manifest.md)'), 'Manifest page is pinned in the sidebar'); + assert.ok(sidebarMd.includes('[📄 Manifest](/_manifest.md)'), 'Manifest page is pinned in the sidebar'); const html = fs.readFileSync(path.join(viewer, 'index.html'), 'utf8'); assert.ok(html.includes('acme-billing-context'), 'layer name baked into the JSON config'); assert.ok(html.includes('viewer-boot.js'), 'boot script (carrying the frontmatter hook) is wired'); const bootJs = fs.readFileSync(path.join(viewer, 'assets', 'viewer-boot.js'), 'utf8'); assert.ok(bootJs.includes('stripFrontmatter'), 'frontmatter hook present in the vendored boot script'); - assert.ok(bootJs.includes("basePath: '/content/'"), 'content mount configured in the boot script'); + // The content mount is the SDK's value, carried in the config block; the boot + // script routes from it instead of hardcoding a root, which is what lets the + // export flavor be relative. + assert.ok(bootJs.includes('basePath: lejiContentBase'), 'the boot script routes from the generated base'); + assert.ok(html.includes('"basePath":"/content/"'), 'the served flavor mounts content at the app root'); assert.ok(html.includes('"homepage":"overview.md"'), 'the overview is the homepage'); assert.ok(html.includes('<title>acme-billing-context'), 'escaped layer name in title'); // Default theming: the Leji mark (in the name HTML, served relative to the page so - // basePath does not break it) and the brand blue. + // basePath does not break it) and the brand green, with the mermaid node-text + // color the SDK computed for it (dark, at 5.14:1 against the accent). assert.ok(html.includes('/assets/leji-logo.svg'), 'default Leji logo wired into the name'); - assert.ok(html.includes('"themeColor":"#223F93"'), 'default brand color wired'); + assert.ok(html.includes('"themeColor":"#009F71"'), 'default brand color wired'); + assert.ok( + html.includes('"lejiMermaidTextColor":"#1a1a1a"'), + 'the computed mermaid text color travels in the config', + ); + // A configured accent is computed over too, not just the default: a dark accent + // flips the mermaid node text to white, end to end through the generator. + const darkDir = copyExample(); + const { manifest: darkManifest } = loadManifest(darkDir); + darkManifest!.viewer = { theme: { primary: '#164E42' } }; + generateViewer(darkDir, darkManifest!); + const darkHtml = fs.readFileSync(path.join(darkDir, '.leji', 'viewer', 'index.html'), 'utf8'); + assert.ok(darkHtml.includes('"themeColor":"#164E42"'), 'configured accent wired'); + assert.ok( + darkHtml.includes('"lejiMermaidTextColor":"#ffffff"'), + 'the mermaid text color is recomputed for the configured accent', + ); // Mermaid is on by default: the two scripts + their assets are present. assert.ok(html.includes('assets/mermaid.min.js'), 'mermaid script wired by default'); assert.ok(html.includes('assets/docsify-mermaid.js'), 'mermaid plugin wired by default'); assert.ok(fs.existsSync(path.join(viewer, 'assets', 'mermaid.min.js')), 'mermaid asset copied'); - assert.ok(fs.existsSync(path.join(viewer, 'assets', 'leji-logo.svg')), 'logo asset vendored'); + // The mark is vendored by bytes, so its color travels with it: the default logo + // wears the brand green, never the retired gold. + const logoSvg = fs.readFileSync(path.join(viewer, 'assets', 'leji-logo.svg'), 'utf8'); + assert.match(logoSvg, /fill="#009F71"/i, 'the vendored mark is Leji green'); // The vendored assets (core + theme + search/collapse plugins) land alongside // the page (no remote CDN). assert.ok(fs.existsSync(path.join(viewer, 'assets', 'docsify.min.js'))); @@ -1079,20 +1164,20 @@ test('viewer: generates viewer + sidebar that reflect the layer', () => { assert.equal( sidebar, [ - '- [🤖 Boot profile](boot-profile.md)', - '- [📄 Manifest](_manifest.md)', + '- [🤖 Boot profile](/boot-profile.md)', + '- [📄 Manifest](/_manifest.md)', '', '---', '', '- **🤖 Agents**', - ' - [Agent Core](agents/core.md)', - ' - [Thought Partner (Codex)](agents/thought-partner.md)', + ' - [Agent Core](/agents/core.md)', + ' - [Thought Partner (Codex)](/agents/thought-partner.md)', '- **📖 Domain**', - ' - [Glossary](domain/glossary.md)', + ' - [Glossary](/domain/glossary.md)', '- **⚙️ System**', - ' - [Invariants](system/invariants.md)', + ' - [Invariants](/system/invariants.md)', '- **🧭 Decisions**', - ' - [Adopt the Leji context layer](decisions/0001-adopt-leji.md)', + ' - [Adopt the Leji context layer](/decisions/0001-adopt-leji.md)', '', ].join('\n'), ); @@ -1112,7 +1197,7 @@ test('viewer: brand config (logo, primary color, title, favicon, pins) flows int pins: ['docs/domain/glossary.md', 'docs/nope.md'], }; const result = generateViewer(dir, manifest!); - const viewer = path.join(dir, 'docs', '.leji', 'viewer'); + const viewer = path.join(dir, '.leji', 'viewer'); const html = fs.readFileSync(path.join(viewer, 'index.html'), 'utf8'); // A relative logo path is served from the content mount; absolute/url is used as-is. assert.ok(html.includes('/content/assets/brand.svg'), 'configured logo resolved under /content/'); @@ -1121,7 +1206,7 @@ test('viewer: brand config (logo, primary color, title, favicon, pins) flows int assert.ok(html.includes('href="/content/assets/icon.svg"'), 'configured favicon resolved under /content/'); const sidebar = fs.readFileSync(path.join(viewer, '_sidebar.md'), 'utf8'); const top = sidebar.split('---')[0]; - assert.ok(top.includes('- [Glossary](domain/glossary.md)'), 'pinned page renders in the top zone'); + assert.ok(top.includes('- [Glossary](/domain/glossary.md)'), 'pinned page renders in the top zone'); assert.ok( result.findings.some((f) => f.rule === 'viewer-pin-missing' && f.path === 'docs/nope.md'), 'a missing pin is surfaced, not silently dropped', @@ -1201,6 +1286,140 @@ test('viewer build: exports a self-contained static folder carrying the protect assert.match(html, /Host the exported folder behind internal authentication/); }); +test('viewer build: the default output is the dist role of the unified tree', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + const { manifest } = loadManifest(dir); + const r = buildViewer(dir, manifest!); + assert.equal(r.out.split(path.sep).join('/'), '.leji/dist', 'the default output is root .leji/dist'); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'dist', 'index.html'))); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'dist', 'content', 'boot-profile.md'))); + // The pre-1.4 locations are never created, and nothing reads or writes a tree + // under the context root: a run leaves rootPath/.leji/ absent. + assert.ok(!fs.existsSync(path.join(dir, 'docs', '.leji')), 'no tree under the context root'); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'viewer-dist')), 'the old output name is not used'); +}); + +test('viewer build: --out never resolves inside .leji/ except exactly .leji/dist', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + const { manifest } = loadManifest(dir); + // The roles are the tool's own: an export target inside any of them is refused, + // including a role this version has never heard of, because the rule denies by + // name rather than listing what to protect. + for (const target of ['.leji', '.leji/mounts', '.leji/mounts/cache', '.leji/viewer', '.leji/work', '.leji/future']) { + assert.throws(() => buildViewer(dir, manifest!, target), /reserved for the tool's own roles/, target); + } + // The canary bytes a refusal must never have touched: the private roles are still + // exactly as planted. + fs.mkdirSync(path.join(dir, '.leji', 'mounts', 'store'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', 'mounts', 'store', 'keep'), 'private\n'); + assert.throws(() => buildViewer(dir, manifest!, '.leji/mounts'), /reserved for the tool's own roles/); + assert.equal(fs.readFileSync(path.join(dir, '.leji', 'mounts', 'store', 'keep'), 'utf8'), 'private\n'); + // The reservation is exact, not a subtree: `.leji/dist` is a target a caller may + // name, and everything under it is not — the export owns that directory whole. + for (const nested of ['.leji/dist/subdir', '.leji/dist/a/b']) { + assert.throws(() => buildViewer(dir, manifest!, nested), /never a path inside it/, nested); + } + // Spelling the same target absolutely is the same target: `--out` is resolved + // before it is judged, so an absolute path reaches the reservation exactly as a + // relative one does. + for (const nested of [path.join(dir, '.leji', 'dist', 'subdir'), path.join(dir, '.leji', 'dist', 'a', 'b')]) { + assert.throws(() => buildViewer(dir, manifest!, nested), /never a path inside it/, nested); + } + // The reserved role itself is the one accepted spelling. + assert.doesNotThrow(() => buildViewer(dir, manifest!, '.leji/dist')); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'dist', 'index.html'))); +}); + +/** Whether this directory sits on a filesystem that cannot tell `.leji` from + * `.LEJI`. Asked of the volume rather than inferred from the platform: a + * case-sensitive volume on macOS and a case-insensitive one on Linux both exist. */ +function caseInsensitiveFs(dir: string): boolean { + const probe = path.join(dir, 'leji-case-probe'); + fs.mkdirSync(probe, { recursive: true }); + try { + return fs.existsSync(path.join(dir, 'LEJI-CASE-PROBE')); + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } +} + +test('viewer build: --out is judged in resolved form, not as spelled', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + const { manifest } = loadManifest(dir); + fs.mkdirSync(path.join(dir, '.leji', 'mounts', 'store'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.leji', 'mounts', 'store', 'keep'), 'private\n'); + // A symlink is a spelling, not an exemption: what the write would land in is what + // the reservation judges, so an ordinary-looking --out that redirects into a + // private role is refused exactly as the literal path is. + fs.symlinkSync(path.join('.leji', 'mounts'), path.join(dir, 'redirect')); + assert.throws( + () => buildViewer(dir, manifest!, 'redirect/export'), + /reserved for the tool's own roles/, + 'a redirected --out is refused', + ); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'mounts', 'export')), 'and nothing was written through it'); + assert.equal(fs.readFileSync(path.join(dir, '.leji', 'mounts', 'store', 'keep'), 'utf8'), 'private\n'); + // Where the filesystem cannot tell the two spellings apart, `.LEJI/` names the + // reserved role and is refused as one. Where it can, `.LEJI/` is an ordinary + // directory name and there is nothing to assert, so the volume decides. + if (caseInsensitiveFs(dir)) { + assert.throws( + () => buildViewer(dir, manifest!, '.LEJI/mounts/export'), + /reserved for the tool's own roles/, + 'a case-variant spelling of a reserved role is the reserved role', + ); + assert.ok(!fs.existsSync(path.join(dir, '.leji', 'mounts', 'export')), 'and nothing was written under it'); + } + // The redirection rule is about the destination, not about symlinks: one that + // lands somewhere ordinary still exports. + fs.mkdirSync(path.join(dir, 'real-out')); + fs.symlinkSync('real-out', path.join(dir, 'link-out')); + assert.doesNotThrow(() => buildViewer(dir, manifest!, 'link-out')); + assert.ok(fs.existsSync(path.join(dir, 'real-out', 'index.html')), 'the export landed in the resolved target'); +}); + +test('viewer build: the export flavor is generated, and carries no root-absolute URL', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + const { manifest } = loadManifest(dir); + buildViewer(dir, manifest!); + const served = fs.readFileSync(path.join(dir, '.leji', 'viewer', 'index.html'), 'utf8'); + const exported = fs.readFileSync(path.join(dir, '.leji', 'dist', 'index.html'), 'utf8'); + // One code path, two flavors: the servable area holds the app-root base, the + // export holds the relative one. index.html is the only file that differs. + assert.ok(served.includes('"basePath":"/content/"'), 'the served flavor mounts content at the app root'); + assert.ok(served.includes('href="/assets/leji-logo.svg"'), 'the served favicon is app-root absolute'); + assert.ok(exported.includes('"basePath":"content/"'), 'the exported flavor mounts content relative to the page'); + assert.ok(!exported.includes('"basePath":"/content/"'), 'no export-flavored page keeps the app-root base'); + // The machine-checkable proxy gate for subpath hosting: nothing in the exported + // shell — attributes or config — addresses the server root. (Sidebar link + // destinations are route strings resolved against basePath, not fetch paths, and + // live in _sidebar.md, not here.) + const body = exported.slice(exported.indexOf('-->') + 3); + assert.equal( + (body.match(/(?:href|src)="\/[^"]*"/g) ?? []).join(', '), + '', + 'no root-absolute href/src in the exported shell', + ); + assert.equal( + (body.match(/\\"\/(?:content|assets)\/[^\\"]*\\"/g) ?? []).join(', '), + '', + 'no root-absolute URL inside the exported config block', + ); + // The servable area never holds export-flavored bytes, and the two trees agree on + // everything else the chrome ships. + for (const rel of ['assets/viewer-boot.js', 'assets/docsify.min.js']) { + assert.deepEqual( + fs.readFileSync(path.join(dir, '.leji', 'dist', rel)), + fs.readFileSync(path.join(dir, '.leji', 'viewer', rel)), + `${rel} is flavor-neutral`, + ); + } +}); + test('viewer build: refuses an --out inside the context root, leaving governed content intact', async () => { const dir = copyExample(); const { buildViewer } = await import('../dist/index.js'); @@ -1267,7 +1486,7 @@ test('viewer: a hostile manifest string cannot break out of its substitution sit // and breaking out of the favicon's href attribute. manifest!.viewer = { title: '{{MERMAID_SCRIPTS}}', favicon: '{{DOCSIFY_CONFIG}}' }; generateViewer(dir, manifest!); - const html = fs.readFileSync(path.join(dir, 'docs', '.leji', 'viewer', 'index.html'), 'utf8'); + const html = fs.readFileSync(path.join(dir, '.leji', 'viewer', 'index.html'), 'utf8'); assert.ok(html.includes('{{MERMAID_SCRIPTS}}'), 'the title stays a literal'); assert.ok(html.includes('href="/content/{{DOCSIFY_CONFIG}}"'), 'the favicon stays inside its attribute'); // The page keeps exactly the scripts the template declares: nothing injected. @@ -1279,30 +1498,128 @@ test('viewer: a hostile manifest string cannot break out of its substitution sit ); }); +/** The one message a rejected accent produces, spelled out here so a change to the + * contract's wording fails the suite rather than shipping. */ +function themeWarning(value: string): string { + return `viewer.theme.primary "${value}" is not a hex color (#RGB, #RGBA, #RRGGBB, or #RRGGBBAA); using #009F71`; +} + test('viewer: an unusable viewer.theme.primary is refused, not interpolated', () => { const dir = copyExample(); const { manifest } = loadManifest(dir); - manifest!.viewer = { theme: { primary: 'red; } body { display: none } /*' } }; + const injection = 'red; } body { display: none } /*'; + manifest!.viewer = { theme: { primary: injection } }; const result = generateViewer(dir, manifest!); - const html = fs.readFileSync(path.join(dir, 'docs', '.leji', 'viewer', 'index.html'), 'utf8'); - assert.ok(html.includes('"themeColor":"#223F93"'), 'the accent falls back to the default'); - assert.ok( - result.findings.some((f) => f.rule === 'viewer-theme-invalid' && f.severity === 'warning'), - 'the rejected accent is surfaced, never silently dropped', - ); + const html = fs.readFileSync(path.join(dir, '.leji', 'viewer', 'index.html'), 'utf8'); + assert.ok(html.includes('"themeColor":"#009F71"'), 'the accent falls back to the default'); + const warning = result.findings.find((f) => f.rule === 'viewer-theme-invalid' && f.severity === 'warning'); + assert.ok(warning, 'the rejected accent is surfaced, never silently dropped'); + assert.equal(warning!.message, themeWarning(injection)); // A plain color is kept as authored. manifest!.viewer = { theme: { primary: '#ff0000' } }; generateViewer(dir, manifest!); - const ok = fs.readFileSync(path.join(dir, 'docs', '.leji', 'viewer', 'index.html'), 'utf8'); + const ok = fs.readFileSync(path.join(dir, '.leji', 'viewer', 'index.html'), 'utf8'); assert.ok(ok.includes('"themeColor":"#ff0000"')); }); +test('viewer: the accent is hex and nothing else', () => { + const dir = copyExample(); + const { manifest } = loadManifest(dir); + const vectors: [string, boolean][] = [ + // The four lengths CSS defines, alpha forms included, case-insensitive. + ['#0f7', true], + ['#1234', true], + ['#009F71', true], + ['#AABBCCDD', true], + // 5 and 7 digits are no CSS color at all: they used to reach the page as an + // unusable accent with no warning, while the mermaid text color silently + // defaulted, leaving accent and text computed from different colors. + ['#12345', false], + ['#1234567', false], + // Keywords are not the contract, however real the name: acceptance used to + // fall out of the injection guard rather than any design. + ['navy', false], + ['notacolor', false], + ['transparent', false], + // A trailing newline does not sneak a hex past the predicate, in any SDK: + // the match is against the whole string, never up to a line end. + ['#009F71\n', false], + ]; + for (const [accent, accepted] of vectors) { + manifest!.viewer = { theme: { primary: accent } }; + const result = generateViewer(dir, manifest!); + const html = fs.readFileSync(path.join(dir, '.leji', 'viewer', 'index.html'), 'utf8'); + const warnings = result.findings.filter((f) => f.rule === 'viewer-theme-invalid' && f.severity === 'warning'); + if (accepted) { + assert.equal(warnings.length, 0, `${accent} is accepted silently`); + assert.ok(html.includes(`"themeColor":"${accent}"`), `${accent} is kept as authored`); + } else { + assert.equal(warnings.length, 1, `${JSON.stringify(accent)} warns exactly once`); + assert.equal(warnings[0].message, themeWarning(accent)); + assert.ok(html.includes('"themeColor":"#009F71"'), `${JSON.stringify(accent)} falls back to the default`); + } + } +}); + +/** WCAG contrast between two #rrggbb colors, computed here rather than imported: + * the numbers below are the assertion, so they are derived independently of the + * implementation under test. */ +function contrast(a: string, b: string): number { + const luminance = (hex: string): number => { + const channel = (i: number): number => { + const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2); + }; + const [x, y] = [luminance(a), luminance(b)]; + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} + +test('viewer: the mermaid text color is computed from the accent over every accepted form', () => { + // The generator resolves what the boot script cannot: the alpha forms, + // composited over the viewer's white content ground. + const vectors: [string, string][] = [ + // The two brand accents, and the mid-gray class where neither #1a1a1a nor + // #ffffff clears 4.5:1 and black buys the last half-stop. + ['#009F71', '#1a1a1a'], + ['#223F93', '#ffffff'], + ['#777777', '#000000'], + // #RGB expands like the boot script's fallback does. + ['#0f7', '#1a1a1a'], + // Alpha composites over white, which lightens: the same accent at half alpha + // takes dark text, and a black at 47% is light enough for it too. + ['#009F7180', '#1a1a1a'], + ['#0007', '#1a1a1a'], + // Named resolution is gone: navy would take white text if any keyword path + // survived, so the dark default here is the proof it does not. + ['navy', '#1a1a1a'], + // Unresolvable by nature or by typo: the dark default, never a guess. + ['currentColor', '#1a1a1a'], + ['notacolor', '#1a1a1a'], + ['#12345', '#1a1a1a'], + // A dark accent takes white; the case of the authored hex does not matter. + ['#1A1A1A', '#ffffff'], + ['#000080', '#ffffff'], + ]; + for (const [accent, want] of vectors) { + assert.equal(mermaidTextColor(accent), want, `${accent} takes ${want}`); + } + // The default accent's choice is not merely dark, it is accessible: the numeric + // ratio is what the rule is about, so it is asserted as a number. + assert.ok(contrast('#009F71', '#1a1a1a') >= 4.5, 'the default accent clears WCAG AA against its text color'); + assert.ok(contrast('#223F93', '#ffffff') >= 4.5, 'a dark accent clears it against white'); + // The #777777 class: black is chosen because both candidates miss, not because + // it wins outright over a passing option. + assert.ok(contrast('#777777', '#1a1a1a') < 4.5 && contrast('#777777', '#ffffff') < 4.5, 'both candidates miss'); +}); + test('viewer: a sidebar label carrying HTML is escaped, not rendered', () => { const dir = copyExample(); const { manifest } = loadManifest(dir); manifest!.viewer = { agentsLabel: '' }; generateViewer(dir, manifest!); - const sidebar = fs.readFileSync(path.join(dir, 'docs', '.leji', 'viewer', '_sidebar.md'), 'utf8'); + const sidebar = fs.readFileSync(path.join(dir, '.leji', 'viewer', '_sidebar.md'), 'utf8'); assert.ok(sidebar.includes('\\'), 'the angle brackets are escaped'); assert.ok(!/(^|[^\\]) { const { manifest } = loadManifest(dir); const result = generateViewer(dir, manifest!); assert.equal(result.entries, 3); - assert.ok(fs.existsSync(path.join(dir, 'docs', '.leji', 'viewer', 'index.html'))); + assert.ok(fs.existsSync(path.join(dir, '.leji', 'viewer', 'index.html'))); }); test('init: writes .gitignore with .leji/ (idempotent)', async () => { @@ -1425,6 +1742,51 @@ test('init: writes .gitignore with .leji/ (idempotent)', async () => { assert.ok(!result.written.includes('.gitignore'), '.gitignore is not in the written list'); }); +test('init: a .gitignore symlinked out of the repository is refused, and the target is untouched', async () => { + // Previously the one unguarded write in init: the `.leji/` ignore line went out + // through whatever `.gitignore` resolved to. It now goes through the chokepoint, + // so a planted link out of the tree is a refusal with nothing written through it. + const dir = fs.realpathSync(tmpdir('leji-ignore-escape-')); + const away = fs.realpathSync(tmpdir('leji-ignore-away-')); + const target = path.join(away, 'gitignore'); + fs.writeFileSync(target, 'node_modules/\n'); + fs.symlinkSync(target, path.join(dir, '.gitignore')); + const { initLayer: init } = await import('../dist/index.js'); + await assert.rejects( + () => init({ dir, yes: true, name: 'demo-context' }), + /refusing to write through a symlink that escapes the target/, + ); + assert.equal(fs.readFileSync(target, 'utf8'), 'node_modules/\n', 'the out-of-tree file is byte-untouched'); + assert.equal(fs.existsSync(path.join(dir, 'leji.json')), false, 'and the refusal came before any layer write'); +}); + +test('agent: a leji.json rewrite that would escape the repository is refused, and NOTHING is written', async () => { + // The other formerly unguarded write: the in-place manifest edit that binds the + // agent. Binding is two writes (a profile file and the manifest edit), so the + // manifest is judged through the verified read BEFORE either happens: a run that + // cannot finish must not half-finish. Nothing is written, anywhere. + const dir = fs.realpathSync(copyExample()); + const away = fs.realpathSync(tmpdir('leji-agent-away-')); + const { manifest } = loadManifest(dir); + assert.ok(manifest); + const manifestAbs = path.join(dir, 'leji.json'); + const target = path.join(away, 'leji.json'); + fs.renameSync(manifestAbs, target); + fs.symlinkSync(target, manifestAbs); + const before = fs.readFileSync(target, 'utf8'); + const profileAbs = path.join(dir, 'docs', 'agents', 'reviewer.md'); + assert.equal(fs.existsSync(profileAbs), false, 'the profile does not exist before the run'); + const snapshot = treeSnapshot(dir); + const { addAgent: bind } = await import('../dist/index.js'); + assert.throws( + () => bind(dir, manifest, { name: 'reviewer', role: 'reviewer' }), + /refusing to write through a symlink that escapes the target: "leji.json"/, + ); + assert.equal(fs.readFileSync(target, 'utf8'), before, 'the out-of-tree manifest is byte-untouched'); + assert.equal(fs.existsSync(profileAbs), false, 'the profile was never written'); + assert.deepEqual(treeSnapshot(dir), snapshot, 'the whole tree is byte-identical to the pre-run snapshot'); +}); + test('viewer: serve serves the scaffold on localhost', async () => { const dir = copyExample(); const { manifest } = loadManifest(dir); @@ -1457,6 +1819,456 @@ test('viewer: serve serves the scaffold on localhost', async () => { } }); +// --- 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 +// two halves: Docsify's relativePath routing (so a link resolves against the +// document carrying it, exactly as the same file reads on disk) and generated +// sidebar destinations emitted app-root absolute (exempt from that resolution). +// These pin both halves, plus the click paths and the not-found contract. + +/** Write `rel` (forward-slashed, repo-relative) under `dir`, creating its parents. */ +function writeUnder(dir: string, rel: string, text: string): void { + const abs = path.join(dir, ...rel.split('/')); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); +} + +/** Serve `dir`'s viewer on a free loopback port, returning the server and its port. */ +async function serveOnFreePort(dir: string, rootRel: string): Promise<{ server: http.Server; port: number }> { + const { serveViewer: serve } = await import('../dist/index.js'); + const server = await serve(dir, 0, rootRel); + const address = server.address(); + return { server, port: typeof address === 'object' && address ? address.port : 0 }; +} + +test('viewer: every sidebar destination, across all entry classes, is app-root absolute', () => { + const dir = copyExample(); + // One layer carrying every sidebar entry class at once: a pinned boot profile, + // the always-pinned Manifest chrome, a user pin, grouped index entries, and + // documents nested two directories deep in both the governed and browse zones. + writeUnder(dir, 'docs/domain/billing/settlement/netting.md', '# Netting\n'); + writeUnder(dir, 'docs/notes/team/onboarding/day-one.md', '# Day one\n'); + const { manifest } = loadManifest(dir); + manifest!.viewer = { pins: ['docs/boot-profile.md', 'docs/domain/glossary.md'] }; + const result = generateViewer(dir, manifest!); + assert.deepEqual( + result.findings.filter((f) => f.severity === 'error'), + [], + ); + const sidebar = fs.readFileSync(path.join(dir, '.leji', 'viewer', '_sidebar.md'), 'utf8'); + // Each class is present, so the sweep below is not vacuous. + for (const dest of [ + '/boot-profile.md', // the pinned boot profile + '/_manifest.md', // generated Manifest chrome + '/domain/glossary.md', // a user pin in the top zone + '/system/invariants.md', // a grouped index entry + '/domain/billing/settlement/netting.md', // grouped, nested two deep + '/notes/team/onboarding/day-one.md', // browse zone, nested two deep + ]) { + assert.ok(sidebar.includes(`](${dest})`), `${dest} is in the sidebar`); + } + // Every emitted destination, parsed rather than sampled: one bare rel anywhere + // in the sidebar re-resolves against whatever nested route is current. + const dests = [...sidebar.matchAll(/\]\(([^)]*)\)/g)].map((m) => m[1]); + assert.ok(dests.length >= 6, 'the matrix produced links to sweep'); + for (const dest of dests) assert.ok(dest.startsWith('/'), `sidebar destination ${dest} is app-root absolute`); +}); + +test('viewer: a sidebar destination is escaped, app-root absolute, and idempotent', () => { + const base = JSON.parse(fs.readFileSync(path.join(exampleDir, 'leji.json'), 'utf8')); + // Boot profile outside rootPath: no boot line, so the pin is the first line and + // buildSidebar's pins are the thinnest seam emitting one destination per input. + const manifest = { ...base, bootProfilePath: 'README.md', rootPath: 'docs/' }; + // These vectors are shared verbatim with the Go and Python SDKs + // (viewer_more_test.go, tests/test_units.py): the three must agree byte for byte. + const vectors: [string, string][] = [ + ['a.md', '/a.md'], + ['dir/b.md', '/dir/b.md'], + // Already absolute: `//…` would be a protocol-relative external URL to Docsify. + ['/a.md', '/a.md'], + ['//a.md', '/a.md'], + // Degenerate input passes through rather than becoming a bare `/`. + ['', ''], + ['a(b).md', '/a\\(b\\).md'], + ['(x).md', '/\\(x\\).md'], + ['a\\b.md', '/a\\\\b.md'], + ]; + for (const [input, want] of vectors) { + const sidebar = buildSidebar(manifest, [], [], [{ rel: input, title: 'x' }]); + assert.equal(sidebar.split('\n')[0], `- [x](${want})`, `destination for ${JSON.stringify(input)}`); + } +}); + +test('viewer: a document is served byte-identical, whatever link classes its body carries', async () => { + const dir = copyExample(); + // One instance of every link class a real document mixes. Routing is config plus + // the generated sidebar, never a transform over the author's markdown, so the + // served bytes are the file's. How an image path resolves under relativePath is + // a separate item and is deliberately not asserted here. + const body = [ + '# Links', + '', + '- [parent](../target.md)', + '- [sibling](sibling.md)', + '- [root](/root-target.md)', + '- [fragment](#fragment)', + '- [doc fragment](target.md#fragment)', + '- [query](target.md?q=1)', + '- [external](https://leji.org/spec)', + '', + '![x](assets/x.svg)', + '', + '', + '', + ].join('\n'); + writeUnder(dir, 'docs/notes/deep/links.md', body); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const res = await fetch(`http://127.0.0.1:${port}/content/notes/deep/links.md`); + assert.equal(res.status, 200); + assert.deepEqual( + Buffer.from(await res.arrayBuffer()), + fs.readFileSync(path.join(dir, 'docs', 'notes', 'deep', 'links.md')), + 'the viewer never rewrites document markdown', + ); + } finally { + server.close(); + } +}); + +test('viewer: the routing config ships in the served boot script and in the built one', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + // Both settings live in the boot script's static overlay, not the injected JSON + // config block, so the assertion is on the asset text. + const assertRouting = (boot: string, where: string): void => { + assert.match(boot, /relativePath:\s*true/, `relativePath is on in the ${where} boot script`); + assert.match(boot, /notFoundPage:\s*false/, `notFoundPage is off in the ${where} boot script`); + }; + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const asset = await fetch(`http://127.0.0.1:${port}/assets/viewer-boot.js`); + assert.equal(asset.status, 200); + assertRouting(await asset.text(), 'served'); + } finally { + server.close(); + } + buildViewer(dir, manifest!, 'out'); + assertRouting(fs.readFileSync(path.join(dir, 'out', 'assets', 'viewer-boot.js'), 'utf8'), 'built'); +}); + +/** Resolve a markdown destination the way Docsify's relativePath routing does: + * against the linking document's own directory, except a leading-slash + * destination, which is app-root (content-root) absolute. */ +function resolveRoute(fromRel: string, dest: string): string { + if (dest.startsWith('/')) return dest.slice(1); + return path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), dest)); +} + +test('viewer: the links a nested page carries resolve to documents the server actually has', async () => { + const dir = copyExample(); + writeUnder(dir, 'docs/practice/feature-workflow.md', '# Feature workflow\n'); + writeUnder(dir, 'docs/work/spec.md', '# Spec\n'); + writeUnder( + dir, + 'docs/work/README.md', + [ + '# Work', + '', + '- [workflow](../practice/feature-workflow.md)', + '- [spec](spec.md)', + '- [glossary](/domain/glossary.md)', + '', + ].join('\n'), + ); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + for (const dest of ['../practice/feature-workflow.md', 'spec.md', '/domain/glossary.md']) { + const target = resolveRoute('work/README.md', dest); + const res = await fetch(`http://127.0.0.1:${port}/content/${target}`); + assert.equal(res.status, 200, `${dest} routes to /content/${target}`); + } + // The pre-fix escape: the same `../` destination resolved against the server + // root instead of the router. The server has no such route, which is exactly + // why the link must stay in-app. + const escaped = await fetch(`http://127.0.0.1:${port}/practice/feature-workflow.md`); + assert.equal(escaped.status, 404, 'leaving the router lands on a URL the server cannot answer'); + } finally { + server.close(); + } +}); + +test('viewer: an unknown document route 404s, and there is no _404.md to chase', async () => { + const dir = copyExample(); + const { manifest } = loadManifest(dir); + const result = generateViewer(dir, manifest!); + const viewer = path.join(dir, '.leji', 'viewer'); + // The config disables Docsify's secondary _404.md fetch (pinned by the routing + // config test above) and the viewer generates no such page. That the browser + // therefore makes exactly one failing request is verified at the browser level, + // not here. + assert.ok(!fs.existsSync(path.join(viewer, '_404.md')), 'no _404.md in the generated viewer'); + assert.ok(!result.written.some((w) => w.endsWith('_404.md')), '_404.md is not written anywhere'); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const missing = await fetch(`http://127.0.0.1:${port}/content/does-not-exist.md`); + assert.equal(missing.status, 404, 'the missing document itself is the one 404'); + } finally { + server.close(); + } +}); + +// --- a raw-HTML image resolves against its document, like the markdown form --- +// Docsify's relativePath routing resolves the markdown image form against the +// document carrying it; a raw-HTML `` passed through +// untouched, so the browser resolved it against the server root and any nested +// page 404ed. The boot script now resolves it at render time, leaving the served +// document bytes alone. The rule is a pure function in the asset, pinned below by +// golden vectors; that it ships is pinned on both the served and the built script. + +/** The canonical viewer boot script's text — the asset both modes ship verbatim. */ +function bootAssetText(): string { + return fs.readFileSync(path.join(templatesDir(), 'viewer', 'assets', 'viewer-boot.js'), 'utf8'); +} + +/** Cut `function (…) {…}` out of asset source, terminated by its + * column-zero closing brace. Marker matching over source is fine here: this is + * test tooling reading a file the suite owns, at a shape the suite fixes. */ +function extractFunction(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} is declared in the boot asset`); + const end = source.indexOf('\n}\n', start); + assert.notEqual(end, -1, `${name}'s declaration terminates`); + return source.slice(start, end + 3); +} + +test('viewer: the boot asset rewrites exactly the document-relative image srcs', () => { + // The fail-without-the-change gate: before the fix no such function exists, and + // every rewrite vector below is a src the browser resolved against the server + // root. Evaluated in a vm rather than imported: the asset is browser code + // shipped verbatim, so the vectors run against the bytes that ship. + const context = vm.createContext({ URL }); + vm.runInContext(extractFunction(bootAssetText(), 'lejiResolveImgSrc'), context); + const resolve = (src: string, docDir: string, base = '/content/'): string | null => + vm.runInContext( + `lejiResolveImgSrc(${JSON.stringify(src)}, ${JSON.stringify(docDir)}, ${JSON.stringify(base)})`, + context, + ); + const vectors: [string, string, string | null][] = [ + // Rewritten: resolved under the document's own directory, suffixes kept. + ['assets/p.svg', 'notes/deep', '/content/notes/deep/assets/p.svg'], + ['./assets/p.svg', 'notes/deep', '/content/notes/deep/assets/p.svg'], + ['../shared/x.svg', 'notes/deep', '/content/notes/shared/x.svg'], + ['a.svg?v=1#f', 'notes/deep', '/content/notes/deep/a.svg?v=1#f'], + // Left as authored (null): empty, fragment-only, query-only, root-relative, + // backslash-led, protocol-relative, and any scheme reference whatever its case. + ['', 'notes/deep', null], + ['#f', 'notes/deep', null], + ['?q', 'notes/deep', null], + ['/x.svg', 'notes/deep', null], + ['\\x.svg', 'notes/deep', null], + ['//cdn/x.svg', 'notes/deep', null], + ['http://x/y.svg', 'notes/deep', null], + ['HTTPS://x/y.svg', 'notes/deep', null], + ['data:image/svg+xml,x', 'notes/deep', null], + ['blob:http://x/y', 'notes/deep', null], + // Traversal out of the content mount is refused, never clamped. + ['../../../../etc/x.svg', 'notes/deep', null], + // Containment vectors from the independent review: the disguises that pass a + // literal prefix check but not the server's own canonicalization — encoded + // traversal, malformed encoding, and a scheme hidden behind whitespace (the + // entry preprocessing makes classification see what the URL parser sees — + // edge trim plus tab/LF/CR removed anywhere — so both the padded scheme and + // one split by an interior tab, LF, or CR are caught as schemes, including + // when they name the synthetic origin the resolution base uses). Legitimate + // encoding still rewrites, the emitted src keeps its encoded form, and a + // padded relative path still resolves. + ['..%2f..%2f..%2fassets/viewer-boot.js', 'notes/deep', null], + ['a%5c..%5c..%5c..%5c..%5cx.svg', 'notes/deep', null], + ['%zz.svg', 'notes/deep', null], + ['\thttps://host/content/x.svg', 'notes/deep', null], + [' https://host/content/x.svg', 'notes/deep', null], + [' http://leji.invalid/content/x.svg', 'notes/deep', null], + ['\thttp://leji.invalid/content/x.svg', 'notes/deep', null], + ['h\tttp://leji.invalid/content/x.svg', 'notes/deep', null], + ['ht\ntp://leji.invalid/content/x.svg', 'notes/deep', null], + ['htt\rp://leji.invalid/content/x.svg', 'notes/deep', null], + [' assets/p.svg', 'notes/deep', '/content/notes/deep/assets/p.svg'], + ['my%20file.svg', 'notes/deep', '/content/notes/deep/my%20file.svg'], + ]; + for (const [src, docDir, want] of vectors) { + assert.equal(resolve(src, docDir), want, `${JSON.stringify(src)} from ${JSON.stringify(docDir)}`); + } + // The export flavor re-bases the same decisions onto a relative content mount, so + // a subpath-hosted page resolves the rewritten src against itself. Classification + // is unchanged: what was left as authored stays left as authored. + assert.equal(resolve('assets/p.svg', 'notes/deep', 'content/'), 'content/notes/deep/assets/p.svg'); + assert.equal(resolve('../shared/x.svg', 'notes/deep', 'content/'), 'content/notes/shared/x.svg'); + assert.equal(resolve('/x.svg', 'notes/deep', 'content/'), null); + assert.equal(resolve('../../../../etc/x.svg', 'notes/deep', 'content/'), null); +}); + +test('viewer: the boot asset falls back to a WCAG-correct text color, and yields to the config', () => { + // The fallback only runs for a viewer tree generated before the SDK computed the + // color; correctness still matters, because such a tree is the one nobody + // regenerates. Run against the shipped bytes, like the resolver vectors above. + const boot = bootAssetText(); + const context = vm.createContext({ Math }); + vm.runInContext(extractFunction(boot, 'lejiMermaidTextColor'), context); + const pick = (accent: unknown): string => + vm.runInContext(`lejiMermaidTextColor(${JSON.stringify(accent)})`, context); + const vectors: [unknown, string][] = [ + ['#009F71', '#1a1a1a'], + ['#223F93', '#ffffff'], + // The pre-fix brightness rule put white on this one; both candidates in fact + // miss 4.5:1, so black is the readable choice. + ['#777777', '#000000'], + ['#0f7', '#1a1a1a'], + ['#000', '#ffffff'], + // The alpha forms, which only the generator composites, and everything no + // accent can be — a keyword, malformed hex, nothing — keep the dark default. + ['navy', '#1a1a1a'], + ['#0007', '#1a1a1a'], + ['#12345', '#1a1a1a'], + ['', '#1a1a1a'], + [null, '#1a1a1a'], + ]; + for (const [accent, want] of vectors) { + assert.equal(pick(accent), want, `${JSON.stringify(accent)} takes ${want}`); + } + // Precedence: the generated field wins whenever the config carries one, so a + // freshly generated tree never recomputes a narrower answer in the browser. + assert.match( + boot, + /window\.\$docsify\.lejiMermaidTextColor \|\|\s*lejiMermaidTextColor\(window\.\$docsify\.themeColor\)/, + 'the config field takes precedence over the local fallback', + ); +}); + +test('viewer: the image resolver ships in the served boot script and in the built one', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + // The resolver and its render-time hook live in the boot script's static body, + // not the injected JSON config block, so the assertion is on the asset text. + const assertResolver = (boot: string, where: string): void => { + assert.match(boot, /function lejiResolveImgSrc\(/, `the resolver is in the ${where} boot script`); + assert.match(boot, /hook\.afterEach\(/, `the render-time hook is registered in the ${where} boot script`); + assert.match(boot, /querySelectorAll\('img\[src\]'\)/, `img[src] is walked in the ${where} boot script`); + }; + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const asset = await fetch(`http://127.0.0.1:${port}/assets/viewer-boot.js`); + assert.equal(asset.status, 200); + assertResolver(await asset.text(), 'served'); + } finally { + server.close(); + } + buildViewer(dir, manifest!, 'out'); + assertResolver(fs.readFileSync(path.join(dir, 'out', 'assets', 'viewer-boot.js'), 'utf8'), 'built'); +}); + +test('viewer: a nested document and the binary asset it links survive both modes verbatim', async () => { + const dir = copyExample(); + const { buildViewer } = await import('../dist/index.js'); + // A depth-2 document naming a sibling asset directory: the resolver rewrites + // that class at render time, in the browser, so the file on the way out — the + // document's markdown and the bytes behind the link alike — must be untouched. + // Real binary content (NUL and high bytes), so "identical" is a byte claim. + const pdf = Buffer.concat([ + Buffer.from('%PDF-1.4\n'), + Buffer.from([0x00, 0xff, 0xfe, 0x0a]), + Buffer.from('%%EOF\n'), + ]); + const doc = ['# Report', '', '[report](assets/r.pdf)', ''].join('\n'); + writeUnder(dir, 'docs/notes/deep/report.md', doc); + const assetAbs = path.join(dir, 'docs', 'notes', 'deep', 'assets', 'r.pdf'); + fs.mkdirSync(path.dirname(assetAbs), { recursive: true }); + fs.writeFileSync(assetAbs, pdf); + const { manifest } = loadManifest(dir); + generateViewer(dir, manifest!); + const { server, port } = await serveOnFreePort(dir, manifest!.rootPath); + try { + const asset = await fetch(`http://127.0.0.1:${port}/content/notes/deep/assets/r.pdf`); + assert.equal(asset.status, 200, 'the linked asset is served from under the content mount'); + assert.deepEqual(Buffer.from(await asset.arrayBuffer()), pdf, 'the served asset is byte-identical'); + const md = await fetch(`http://127.0.0.1:${port}/content/notes/deep/report.md`); + assert.equal(md.status, 200); + assert.deepEqual(Buffer.from(await md.arrayBuffer()), Buffer.from(doc), 'the document is served verbatim'); + } finally { + server.close(); + } + buildViewer(dir, manifest!, 'out'); + assert.deepEqual( + fs.readFileSync(path.join(dir, 'out', 'content', 'notes', 'deep', 'assets', 'r.pdf')), + pdf, + 'the exported asset is byte-identical', + ); +}); + +// --- the retired palette never returns to the shipped chrome --- +// The default viewer chrome and the schema's accent example wore the retired +// blue/gold before the Leji-green sweep. The canonical files and the copies each +// SDK vendors are separate bytes on disk (synced by scripts/sync-assets.ts), so a +// revert, a hand-edited copy, or a tint written in another encoding is a brand +// regression nothing else here would catch. Both encodings are scanned: the hex +// forms and the same values as rgb()/rgba() channels. +const RETIRED_PALETTE: RegExp[] = [ + /#223f93/i, + /#ffbd6e/i, + /#162960/i, + /#f8f9fa/i, + /34\s*,\s*63\s*,\s*147/, + /255\s*,\s*189\s*,\s*110/, + /22\s*,\s*41\s*,\s*96/, +]; + +/** Canonical chrome + schema, then every tree `npm run assets` syncs them into, + * plus the per-SDK generators that bake the default accent into their own source + * (unsynced bytes, so only a scan of all three catches one SDK reverting alone). */ +const BRANDED_FILES: string[] = [ + ...[ + 'templates', + 'packages/sdk/templates', + 'packages/sdk-py/src/leji/_assets/templates', + 'packages/sdk-go/internal/assets/templates', + ].flatMap((base) => + ['viewer/index.html', 'viewer/assets/vue.css', 'viewer/assets/viewer-boot.js', 'viewer/assets/leji-logo.svg'].map( + (rel) => `${base}/${rel}`, + ), + ), + ...[ + 'schemas', + 'packages/sdk/schemas', + 'packages/sdk-py/src/leji/_assets/schemas', + 'packages/sdk-go/internal/assets/schemas', + 'packages/mcp/assets/schemas', + ].map((base) => `${base}/context-manifest.schema.json`), + 'packages/sdk/src/commands/viewer.ts', + 'packages/sdk-go/internal/commands/viewer/viewer.go', + 'packages/sdk-py/src/leji/viewer_cmd.py', +]; + +test('viewer: no shipped chrome or schema copy carries the retired palette', () => { + for (const rel of BRANDED_FILES) { + const abs = path.join(repoRoot, rel); + // A moved or renamed copy fails here rather than passing by absence. + assert.ok(fs.existsSync(abs), `${rel} exists (the scan covers every synced copy)`); + const text = fs.readFileSync(abs, 'utf8'); + for (const pattern of RETIRED_PALETTE) { + assert.ok(!pattern.test(text), `${rel} carries no ${pattern.source}`); + } + } +}); + test('viewer: serve refuses a rootRel that escapes the layer root', async () => { const dir = copyExample(); const { serveViewer: serve } = await import('../dist/index.js'); @@ -1557,7 +2369,7 @@ test('viewer: buildSidebar skips an out-of-root boot profile and renders plain e ]); assert.ok(!sidebar.includes('Boot profile'), 'boot profile outside root is omitted'); assert.ok(sidebar.includes('- **💰 Finance**'), 'group label is the index-file H1, verbatim, bold'); - assert.ok(sidebar.includes(' - [Glossary](domain/glossary.md)'), 'entries render as plain links'); + assert.ok(sidebar.includes(' - [Glossary](/domain/glossary.md)'), 'entries render as plain links'); assert.ok(!sidebar.includes('lj-rec'), 'no record badges in the sidebar: kind and date are page-chip metadata now'); assert.ok(!sidebar.includes('Empty group'), 'empty groups are skipped'); }); @@ -1942,12 +2754,12 @@ test('viewer: homepage, favicon, and pins accept repo-relative and root-relative }; const result = generateViewer(dir, manifest!); assert.ok(!result.findings.some((f) => f.rule === 'viewer-path-missing')); - const html = fs.readFileSync(path.join(dir, 'docs', '.leji', 'viewer', 'index.html'), 'utf8'); + const html = fs.readFileSync(path.join(dir, '.leji', 'viewer', 'index.html'), 'utf8'); assert.ok(html.includes('"homepage":"HOME.md"'), 'repo-relative homepage normalized'); assert.ok(html.includes('/content/HOME.md'), 'favicon URL normalized under the content mount'); - const sidebar = fs.readFileSync(path.join(dir, 'docs', '.leji', 'viewer', '_sidebar.md'), 'utf8'); + const sidebar = fs.readFileSync(path.join(dir, '.leji', 'viewer', '_sidebar.md'), 'utf8'); assert.ok( - sidebar.split('---')[0].includes('](domain/glossary.md)'), + sidebar.split('---')[0].includes('](/domain/glossary.md)'), 'rootPath-relative pin resolves into the top zone', ); // An unresolvable homepage is kept as authored and warned about, never silent. @@ -1977,8 +2789,10 @@ test('ci --hooks: managed pre-commit hook is created, idempotent, and never clob const third = ensureLocalHook(dir); assert.equal(third.action, 'manual', 'unmanaged hook is never clobbered'); assert.equal(third.reason, 'foreign-hook'); - assert.match(third.snippet ?? '', /"\$LEJI" validate/); - assert.match(third.snippet ?? '', /\[ -x "node_modules\/\.bin\/leji" \]/, 'hook prefers the local bin'); + // The scalar shim is gone: the hook runs the repository's own runner argv, + // each element single-quoted for sh. This repo declares nothing, so it is `leji`. + assert.match(third.snippet ?? '', /^'leji' validate \|\| exit 1$/m); + assert.doesNotMatch(third.snippet ?? '', /node_modules/); assert.match(fs.readFileSync(hookPath, 'utf8'), /custom hook/, 'foreign hook untouched'); }); @@ -1995,7 +2809,7 @@ test('ci --hooks: husky (.husky/_) merges a managed block into .husky/pre-commit const merged = fs.readFileSync(huskyPre, 'utf8'); assert.match(merged, /npm test/, 'existing husky content untouched'); assert.match(merged, /# >>> leji hooks \(managed\) >>>/); - assert.match(merged, /"\$LEJI" validate \|\| exit 1/); + assert.match(merged, /^'leji' validate \|\| exit 1$/m); assert.ok(!fs.existsSync(path.join(dir, '.git', 'hooks', 'pre-commit')), '.git/hooks not written'); assert.equal(ensureLocalHook(dir).action, 'unchanged', 'rerun is idempotent'); }); @@ -2042,7 +2856,7 @@ test('ci --hooks: a custom core.hooksPath dir gets a managed hook file', () => { const custom = path.join(dir, 'githooks', 'pre-commit'); assert.ok((fs.statSync(custom).mode & 0o111) !== 0, 'custom hook is executable'); assert.match(fs.readFileSync(custom, 'utf8'), /# leji pre-commit \(managed\)/); - assert.match(fs.readFileSync(custom, 'utf8'), /\[ -x "node_modules\/\.bin\/leji" \]/, 'prefers the local bin'); + assert.match(fs.readFileSync(custom, 'utf8'), /^'leji' validate \|\| exit 1$/m, 'runs the repository runner'); assert.ok(!fs.existsSync(path.join(dir, '.git', 'hooks', 'pre-commit'))); }); @@ -2055,7 +2869,7 @@ test('ci --hooks: a core.hooksPath outside the repo is never written, reported m assert.equal(r.managed, 'file'); assert.equal(r.reason, 'outside-root'); assert.equal(r.path, `${outside}/pre-commit`, 'reports the computed target'); - assert.match(r.snippet ?? '', /"\$LEJI" validate/); + assert.match(r.snippet ?? '', /^'leji' validate \|\| exit 1$/m); assert.ok(!fs.existsSync(path.join(outside, 'pre-commit')), 'nothing written outside the repo'); assert.ok(!fs.existsSync(path.join(dir, '.git', 'hooks', 'pre-commit'))); }); @@ -2100,10 +2914,11 @@ test('ci: local-first CI variant when the repo declares @leji-org/leji', () => { assert.ok(!wf.includes('npx -y @leji-org/leji@1'), 'no floating fallback when the dep is local'); }); -test('ci: a declared dep without an npm lockfile falls back, rather than generating a job that fails', () => { - // pnpm, Yarn and Bun repositories can declare the dependency and have no - // package-lock.json. The generated `npm ci` would fail before Leji ran. - const dir = gitSeedExample('leji-ci-nolock-'); +test('ci: a pnpm repository gets pnpm, never `npm ci`, and an unlocked one falls back', () => { + // The generated job installs with the manager the repository actually uses: a + // pnpm repo that declares the CLI installs from ITS lockfile and runs the local + // binary through pnpm. `npm ci` here would fail before Leji ran. + const dir = gitSeedExample('leji-ci-pnpm-'); fs.writeFileSync( path.join(dir, 'package.json'), JSON.stringify({ devDependencies: { '@leji-org/leji': '^1.3.0' } }), @@ -2111,8 +2926,21 @@ test('ci: a declared dep without an npm lockfile falls back, rather than generat fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n'); ensureCiWorkflow(dir, 'github'); const wf = fs.readFileSync(path.join(dir, '.github', 'workflows', 'leji.yml'), 'utf8'); - assert.ok(!wf.includes('npm ci'), 'no npm ci without an npm lockfile'); - assert.match(wf, /npx -y @leji-org\/leji@1 validate/, 'falls back to the pinned npx form'); + assert.ok(!wf.includes('npm ci'), 'no npm ci in a pnpm repository'); + assert.match(wf, /- run: corepack enable && pnpm install --frozen-lockfile/); + assert.match(wf, /- run: pnpm exec leji validate/); + assert.ok(!wf.includes('npx -y @leji-org/leji@1'), 'declared + locked is never the fallback'); + + // Declared with no lockfile at all: nothing to install from, so the job that + // needs no manifest is the honest one. + const unlocked = gitSeedExample('leji-ci-nolock-'); + fs.writeFileSync( + path.join(unlocked, 'package.json'), + JSON.stringify({ devDependencies: { '@leji-org/leji': '^1.3.0' } }), + ); + ensureCiWorkflow(unlocked, 'github'); + const fallback = fs.readFileSync(path.join(unlocked, '.github', 'workflows', 'leji.yml'), 'utf8'); + assert.match(fallback, /npx -y @leji-org\/leji@1 validate/, 'falls back to the pinned npx form'); }); test('ci: npx @1 fallback when no package.json (or an unparseable one) declares the dep', () => { diff --git a/packages/sdk/test/update-pin.test.ts b/packages/sdk/test/update-pin.test.ts new file mode 100644 index 0000000..449bf7f --- /dev/null +++ b/packages/sdk/test/update-pin.test.ts @@ -0,0 +1,692 @@ +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 os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { loadManifest, replaceMountPinInManifestText, run, updatePinRun } from '../dist/index.js'; +import { + comparePins, + mountStatus, + normalizeSource, + pinRefFor, + retainPinInStore, + selectComparison, + witnessRefFor, +} from '../dist/lib/mounts.js'; + +// Three halves of one contract. First the pin-span scanner over its own byte +// fixtures — the only artifact here that needs no git at all. Then the two +// factorings out of `lib/mounts.ts`, checked against the callers they were taken +// from. Then the shared fixtures' `updatePin` block, driven through the real CLI as +// a process over a scaffold every SDK's harness builds identically +// (`fixtures/README.md` -> "The `updatePin` block"). + +const execFileAsync = promisify(execFile); +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pkgRoot, '..', '..'); +const fixturesDir = path.join(repoRoot, 'fixtures'); +const cli = path.join(pkgRoot, 'dist', 'cli.js'); + +const tmpdir = (prefix: string): string => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + +// --- the pin-span scanner ----------------------------------------------------- + +interface PinSpanCase { + note: string; + mount: string; + from: string; + to: string; + outcome: 'replaced' | 'error'; + error?: 'not-located' | 'not-from' | 'duplicate-key'; +} + +const pinSpanDir = path.join(fixturesDir, 'manifest-pin-span'); +for (const name of fs.readdirSync(pinSpanDir).sort()) { + const dir = path.join(pinSpanDir, name); + if (!fs.statSync(dir).isDirectory()) continue; + const spec = JSON.parse(fs.readFileSync(path.join(dir, 'case.json'), 'utf8')) as PinSpanCase; + + test(`manifest-pin-span ${name}: ${spec.note}`, () => { + const input = fs.readFileSync(path.join(dir, 'input.json'), 'utf8'); + if (spec.outcome === 'error') { + const pattern = + spec.error === 'not-located' + ? /cannot locate the pin of mount/ + : spec.error === 'not-from' + ? /pin of mount .* is not/ + : /duplicate key/; + assert.throws( + () => replaceMountPinInManifestText(input, spec.mount, spec.from, spec.to), + (e: Error) => pattern.test(e.message), + `${name}: the ${spec.error} refusal`, + ); + return; + } + const expected = fs.readFileSync(path.join(dir, 'expected.json'), 'utf8'); + const got = replaceMountPinInManifestText(input, spec.mount, spec.from, spec.to); + assert.equal(got.changed, true, `${name}: the span moved`); + assert.equal(got.text, expected, `${name}: byte-exact output`); + // Every case is a real manifest before and after: the edit never produces + // something a parser would reject. + assert.doesNotThrow(() => JSON.parse(got.text)); + // And the edit is confined: exactly the pin's own characters differ. + assert.equal(got.text.length, input.length + spec.to.length - spec.from.length); + }); +} + +test('a duplicate key on the path to the pin is refused, never resolved by picking one', () => { + // The two readers of this document disagree: a lexical scan takes the FIRST + // member, `JSON.parse` keeps the LAST. Rewriting the first span would report a + // change that every parser of the result still reads as the old pin. + const dir = path.join(pinSpanDir, 'error-duplicate-pin'); + const input = fs.readFileSync(path.join(dir, 'input.json'), 'utf8'); + const spec = JSON.parse(fs.readFileSync(path.join(dir, 'case.json'), 'utf8')) as PinSpanCase; + const parsedPin = (JSON.parse(input) as { federation: { mounts: { pin: string }[] } }).federation.mounts[0].pin; + assert.notEqual(parsedPin, spec.from, 'the parser reads the LAST pin, which is not the span a scan finds first'); + assert.throws( + () => replaceMountPinInManifestText(input, spec.mount, spec.from, spec.to), + /duplicate key "pin" in mount "product-context"/, + ); + // Every key the scanner reads on its way to the pin carries the same rule. + for (const [fixture, message] of [ + ['error-duplicate-federation', /duplicate key "federation" in the manifest root/], + ['error-duplicate-mounts', /duplicate key "mounts" in "federation"/], + ['error-duplicate-name', /duplicate key "name" in a federation mount/], + ] as const) { + const text = fs.readFileSync(path.join(pinSpanDir, fixture, 'input.json'), 'utf8'); + assert.throws(() => replaceMountPinInManifestText(text, 'product-context', spec.from, spec.to), message, fixture); + } +}); + +test('the pin span moves only for the addressed mount, and a no-op move writes nothing new', () => { + const input = fs.readFileSync(path.join(pinSpanDir, 'shared-prefix', 'input.json'), 'utf8'); + const spec = JSON.parse(fs.readFileSync(path.join(pinSpanDir, 'shared-prefix', 'case.json'), 'utf8')) as PinSpanCase; + // The neighbouring mount's pin is untouched by the move above it. + const moved = replaceMountPinInManifestText(input, spec.mount, spec.from, spec.to).text; + const before = JSON.parse(input) as { federation: { mounts: { name: string; pin: string }[] } }; + const after = JSON.parse(moved) as typeof before; + assert.equal(after.federation.mounts[0].pin, before.federation.mounts[0].pin); + assert.notEqual(after.federation.mounts[1].pin, before.federation.mounts[1].pin); + // `from === to` is a no-op the caller can rely on, not a rewrite of equal bytes. + const same = replaceMountPinInManifestText(input, spec.mount, spec.from, spec.from); + assert.equal(same.changed, false); + assert.equal(same.text, input); +}); + +// --- the acme-sibling scaffold ------------------------------------------------ + +/** The recipe's fixed commit ids. Every field a commit hashes is pinned by the + * recipe (`fixtures/README.md`), so these are constants, not observations. */ +const OID = { + a: '6b06fe51a323212156bb267842bf10187ed4c20e', + b: '3ff2a04361ca9d601180037bdfbc8b6c0a0a8723', + s: '50305153f1a107c6871ab3b3047cb4c225603b0c', + o: '0cb1fb59e73d78ff04cf41de7f177ea0fb940002', +}; +const ACME_SOURCE = 'https://github.com/acme/product-context'; +const ACME_IDENTITY = normalizeSource(ACME_SOURCE)!; + +/** Author, committer, date, message and content are all fixed, so every commit id + * the recipe produces is a constant an `expected.json` can carry. */ +const recipeEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_DIR: undefined, + GIT_AUTHOR_NAME: 'Leji Fixtures', + GIT_AUTHOR_EMAIL: 'fixtures@leji.org', + GIT_COMMITTER_NAME: 'Leji Fixtures', + GIT_COMMITTER_EMAIL: 'fixtures@leji.org', + GIT_AUTHOR_DATE: '2026-01-01T00:00:00 +0000', + GIT_COMMITTER_DATE: '2026-01-01T00:00:00 +0000', +}; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'commit.gpgsign=false', '-c', 'core.autocrlf=false', ...args], { + cwd, + env: recipeEnv, + encoding: 'utf8', + }).trim(); +} + +function commit(repo: string, file: string): string { + fs.writeFileSync(path.join(repo, file), `# ${file.replace(/\.md$/, '')}\n`); + git(repo, 'add', '-A'); + git(repo, 'commit', '-q', '-m', file.replace(/\.md$/, '')); + return git(repo, 'rev-parse', 'HEAD'); +} + +/** The `acme-sibling` recipe, normative in `fixtures/README.md`: a → b on main, a + * side branch off `a`, and an unrelated orphan branch. */ +function buildAcmeSibling(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); + git(dir, 'init', '-q', '-b', 'main', '.'); + assert.equal(commit(dir, 'a.md'), OID.a, 'recipe commit a'); + assert.equal(commit(dir, 'b.md'), OID.b, 'recipe commit b'); + git(dir, 'checkout', '-q', '-b', 'side', OID.a); + assert.equal(commit(dir, 's.md'), OID.s, 'recipe commit s'); + git(dir, 'checkout', '-q', '--orphan', 'other'); + git(dir, 'rm', '-q', '-rf', '.'); + assert.equal(commit(dir, 'o.md'), OID.o, 'recipe commit o'); + git(dir, 'checkout', '-q', 'main'); + // Fetching a commit by id is how the resolver retains a pin, so the recipe's + // repository must serve one the way a real host does. + git(dir, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); +} + +interface StoreSpec { + pin: string | null; + witnessRef: string | null; + witnessOid: string | null; + depth: number | null; +} + +/** Build the managed store exactly as a successful `--fetch` leaves it. */ +function buildStore(host: string, sibling: string, spec: StoreSpec): void { + const key = crypto.createHash('sha256').update(ACME_IDENTITY).digest('hex'); + const store = path.join(host, '.leji', 'mounts', 'store', key); + fs.mkdirSync(store, { recursive: true }); + git(host, 'init', '--bare', '-q', store); + const depth = spec.depth === null ? [] : ['--depth', String(spec.depth)]; + if (spec.pin !== null) { + git(store, 'fetch', '-q', ...depth, sibling, spec.pin); + git(store, 'update-ref', pinRefFor(ACME_IDENTITY, spec.pin), spec.pin); + } + if (spec.witnessRef !== null && spec.witnessOid !== null) { + git( + store, + 'fetch', + '-q', + ...depth, + sibling, + `+${spec.witnessOid}:${witnessRefFor(ACME_IDENTITY, spec.witnessRef)}`, + ); + } + fs.rmSync(path.join(store, 'FETCH_HEAD'), { force: true }); +} + +// --- the two factorings, against the callers they came out of ----------------- + +test('selectComparison and comparePins answer exactly what mountStatus reports', () => { + const dir = tmpdir('leji-updatepin-sel-'); + const sibling = path.join(dir, 'sibling'); + const host = path.join(dir, 'host'); + buildAcmeSibling(sibling); + for (const [pin, expected] of [ + [OID.a, 'behind'], + [OID.b, 'up-to-date'], + [OID.s, 'diverged'], + [OID.o, 'unrelated'], + ] as const) { + fs.rmSync(host, { recursive: true, force: true }); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), host, { recursive: true }); + repin(host, pin, 'keep'); + buildStore(host, sibling, { pin, witnessRef: 'refs/heads/main', witnessOid: OID.b, depth: null }); + const { manifest } = loadManifest(host); + const row = mountStatus(host, manifest!, {})[0]; + assert.equal(row.pinReport.state, expected, `status says ${expected}`); + const selection = selectComparison( + host, + { name: 'product-context', source: ACME_SOURCE, pin, trackingRef: 'refs/heads/main' }, + 'refs/heads/main', + ); + assert.ok(!('reason' in selection), 'the matrix selected a repository'); + if ('reason' in selection) return; + // The helper reports the same repository, provenance and ref status does… + assert.equal(selection.comparisonRepository, row.pinReport.comparisonRepository); + assert.equal(selection.witnessProvenance, row.pinReport.witnessProvenance); + assert.equal(selection.comparedRef, row.pinReport.comparedRef); + assert.equal(selection.tipOid, OID.b, 'the single witness snapshot'); + // …and comparing against that one snapshot reproduces the report exactly. + const cmp = comparePins(selection.repo, pin, selection.tipOid); + assert.ok(!('reason' in cmp)); + if ('reason' in cmp) return; + assert.equal(cmp.state, row.pinReport.state); + assert.equal(cmp.behind, row.pinReport.behind); + assert.equal(cmp.ahead, row.pinReport.ahead); + assert.equal(cmp.ancestryComplete, row.pinReport.ancestryComplete); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('selectComparison reports every degraded reason status reports, without selecting', () => { + const dir = tmpdir('leji-updatepin-degraded-'); + const mount = { name: 'product-context', source: ACME_SOURCE, pin: OID.a, trackingRef: 'refs/heads/main' }; + // Nothing holds the pin. + assert.deepEqual(selectComparison(dir, mount, 'refs/heads/main'), { reason: 'mount-pin-unavailable' }); + // A locator no resolver can normalize, and a ref the resolver refuses. + assert.deepEqual(selectComparison(dir, { ...mount, source: 'file:///srv/x' }, 'refs/heads/main'), { + reason: 'mount-source-unnormalizable', + }); + assert.deepEqual(selectComparison(dir, mount, 'refs/heads/main@{1}'), { reason: 'mount-tracking-ref-invalid' }); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('retainPinInStore establishes the store and retains one commit, without touching the witness', () => { + const dir = tmpdir('leji-updatepin-retain-'); + const sibling = path.join(dir, 'sibling'); + const host = path.join(dir, 'host'); + buildAcmeSibling(sibling); + fs.mkdirSync(host); + const mount = { name: 'product-context', source: sibling, pin: OID.a, trackingRef: 'refs/heads/main' }; + const first = retainPinInStore(host, mount, ACME_IDENTITY, OID.a); + assert.notEqual(first.repo, null, first.error); + assert.equal(git(first.repo!, 'rev-parse', pinRefFor(ACME_IDENTITY, OID.a)), OID.a); + // The witness namespace belongs to the refresh, which this primitive is not. + assert.equal(git(first.repo!, 'for-each-ref', '--format=%(refname)', 'refs/leji-witness'), ''); + // A second commit is retained beside the first, not instead of it. + const second = retainPinInStore(host, mount, ACME_IDENTITY, OID.b); + assert.notEqual(second.repo, null, second.error); + assert.equal(git(second.repo!, 'rev-parse', pinRefFor(ACME_IDENTITY, OID.a)), OID.a); + assert.equal(git(second.repo!, 'rev-parse', pinRefFor(ACME_IDENTITY, OID.b)), OID.b); + // A source that serves nothing is a stated failure, never a partial success. + const gone = retainPinInStore(host, { ...mount, source: path.join(dir, 'gone') }, ACME_IDENTITY, OID.s); + assert.equal(gone.repo, null); + assert.equal(gone.error, 'the pin could not be fetched from the source'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// --- the shared fixtures' `updatePin` block ----------------------------------- + +interface UpdatePinCase { + id: string; + note: string; + pin: string; + trackingRef?: string | null; + store: StoreSpec | null; + hint: boolean; + source: 'none' | 'local' | 'unreachable'; + args: string[]; + exit: number; + action: string | null; + from: string | null; + to: string | null; + reason: string | null; + override: boolean; + comparisonRepository?: string; + comparedRef?: string; + manifestGolden: string | null; + written: boolean; +} + +interface UpdatePinBlock { + sibling: string; + mount: string; + cases: UpdatePinCase[]; +} + +/** Apply a case's declaration rewrite: the pin it starts from, and whether the + * tracking ref is declared at all. A raw-text splice, as the fixture's own contract + * requires — the harness never reserializes a manifest either. */ +function repin(host: string, pin: string, trackingRef: string | null | 'keep'): void { + const mp = path.join(host, 'leji.json'); + let text = fs.readFileSync(mp, 'utf8'); + text = text.replace(/("pin": ")[0-9a-f]{40}(")/, `$1${pin}$2`); + if (trackingRef === null) text = text.replace(/\s*"trackingRef": "[^"]*",\n/, '\n'); + fs.writeFileSync(mp, text); +} + +interface CliResult { + code: number; + stdout: string; +} + +async function runCliProc(args: string[], env: NodeJS.ProcessEnv): Promise { + try { + const { stdout } = await execFileAsync('node', [cli, ...args], { cwd: repoRoot, env }); + return { code: 0, stdout }; + } catch (e) { + const err = e as { code?: number; stdout?: string }; + return { code: err.code ?? 1, stdout: err.stdout ?? '' }; + } +} + +interface UpdatePinDocument { + command: string; + ok: boolean; + findings: { rule: string; severity: string; path?: string; message: string }[]; + summary: { errors: number; warnings: number }; + mount: { + name: string; + sourceIdentity: string | null; + trackingRef: string | null; + from: string | null; + to: string | null; + }; + pinReport: Record | null; + action: string; + override: boolean; + reason?: string; +} + +/** Exactly the keys `--json` emits, under every outcome that emits a document. */ +const DOCUMENT_KEYS = ['command', 'ok', 'findings', 'summary', 'mount', 'pinReport', 'action', 'override']; + +for (const name of fs.readdirSync(fixturesDir).sort()) { + const expectedFile = path.join(fixturesDir, name, 'expected.json'); + if (!fs.existsSync(expectedFile)) continue; + const block = (JSON.parse(fs.readFileSync(expectedFile, 'utf8')) as { updatePin?: UpdatePinBlock }).updatePin; + if (!block) continue; + + for (const c of block.cases) { + test(`fixture ${name}: the updatePin block, ${c.id}`, async () => { + const dir = tmpdir('leji-updatepin-'); + try { + const sibling = path.join(dir, 'sibling'); + const host = path.join(dir, 'host'); + buildAcmeSibling(sibling); + fs.cpSync(path.join(fixturesDir, name), host, { recursive: true }); + repin(host, c.pin, c.trackingRef === undefined ? 'keep' : c.trackingRef); + if (c.store) buildStore(host, sibling, c.store); + if (c.hint) { + fs.mkdirSync(path.join(host, '.leji'), { recursive: true }); + fs.writeFileSync( + path.join(host, '.leji', 'mounts.local.json'), + JSON.stringify({ mounts: { [block.mount]: { repo: sibling } } }) + '\n', + ); + } + // The declared source is a locator no test may actually reach, so it is + // routed at git's own level: to the recipe repository for a run that + // must succeed, and to a path that does not exist for one that must fail. + const routed = + c.source === 'none' ? null : c.source === 'local' ? sibling : path.join(dir, 'never-created'); + const env: NodeJS.ProcessEnv = + routed === null + ? { ...process.env, GIT_DIR: undefined } + : { + ...process.env, + GIT_DIR: undefined, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `url.${routed}.insteadOf`, + GIT_CONFIG_VALUE_0: ACME_SOURCE, + }; + + const manifestPath = path.join(host, 'leji.json'); + const before = fs.readFileSync(manifestPath); + const r = await runCliProc([...c.args, '--root', host, '--json'], env); + assert.equal(r.code, c.exit, `${c.id}: exit code (${r.stdout})`); + + if (c.action === null) { + // A usage error reports no outcome at all, and touches nothing. + assert.equal(r.stdout.trim(), '', `${c.id}: no document`); + assert.deepEqual(fs.readFileSync(manifestPath), before, `${c.id}: nothing written`); + return; + } + const doc = JSON.parse(r.stdout) as UpdatePinDocument; + const keys = [...DOCUMENT_KEYS, ...(c.reason === null ? [] : ['reason'])].sort(); + assert.deepEqual(Object.keys(doc).sort(), keys, `${c.id}: the exact JSON key set`); + assert.equal(doc.command, 'mounts update-pin'); + assert.equal(doc.action, c.action, `${c.id}: action`); + assert.equal(doc.override, c.override, `${c.id}: override`); + assert.equal(doc.reason ?? null, c.reason, `${c.id}: reason`); + assert.equal(doc.mount.from, c.from, `${c.id}: from`); + assert.equal(doc.mount.to, c.to, `${c.id}: to`); + assert.equal(doc.ok, c.reason === null, `${c.id}: ok tracks the refusal`); + assert.deepEqual( + doc.summary, + { errors: c.reason === null ? 0 : 1, warnings: c.override ? 1 : 0 }, + `${c.id}: the literal summary`, + ); + // The findings are what the block pins, never read off the document: + // a refusal names its reason code, an override warns under its own. + assert.deepEqual( + doc.findings.map((f) => ({ rule: f.rule, severity: f.severity, path: f.path })), + [ + ...(c.reason === null ? [] : [{ rule: c.reason, severity: 'error', path: doc.mount.name }]), + ...(c.override + ? [{ rule: 'mount-pin-non-fast-forward-override', severity: 'warning', path: doc.mount.name }] + : []), + ].sort((a, b) => (a.rule < b.rule ? -1 : 1)), + `${c.id}: the exact findings`, + ); + if (c.comparisonRepository !== undefined) { + assert.equal( + doc.pinReport?.comparisonRepository, + c.comparisonRepository, + `${c.id}: comparisonRepository`, + ); + } + if (c.comparedRef !== undefined) { + assert.equal(doc.pinReport?.comparedRef, c.comparedRef, `${c.id}: comparedRef`); + } + + const after = fs.readFileSync(manifestPath); + if (c.manifestGolden !== null) { + const golden = fs.readFileSync(path.join(fixturesDir, ...c.manifestGolden.split('/'))); + assert.deepEqual(after, golden, `${c.id}: the written manifest bytes`); + } + // `written: false` is one claim: the manifest is byte-identical to the + // manifest this run started from. + if (!c.written) assert.deepEqual(after, before, `${c.id}: leji.json is byte-untouched`); + // A `--fetch` run does the store acts it was asked for even when the + // rewrite is suppressed: dry-run withholds the manifest, not the fetch. + if (c.id === 'dry-run-fetch') { + const key = crypto.createHash('sha256').update(ACME_IDENTITY).digest('hex'); + const store = path.join(host, '.leji', 'mounts', 'store', key); + assert.ok(fs.existsSync(store), 'the managed store was established'); + assert.equal(git(store, 'rev-parse', pinRefFor(ACME_IDENTITY, OID.a)), OID.a, 'the pin was retained'); + } + // Every fetch this command makes passes --no-write-fetch-head, so a run + // that reached the source leaves no per-run record inside the store. + if (c.source === 'local') { + const key = crypto.createHash('sha256').update(ACME_IDENTITY).digest('hex'); + const store = path.join(host, '.leji', 'mounts', 'store', key); + assert.equal(fs.existsSync(path.join(store, 'FETCH_HEAD')), false, `${c.id}: no FETCH_HEAD`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + } +} + +// --- the branches no fixture can construct ------------------------------------ + +test('a declaration that changes under the run is refused, not overwritten', () => { + const dir = tmpdir('leji-updatepin-race-'); + 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'); + buildStore(host, sibling, { pin: OID.a, witnessRef: 'refs/heads/main', witnessOid: OID.b, depth: null }); + const { manifest } = loadManifest(host); + // The comparison runs against the manifest object in hand; the file changes + // its `source` before the verified read the rewrite makes. + const mp = path.join(host, 'leji.json'); + const original = fs.readFileSync(mp, 'utf8'); + fs.writeFileSync(mp, original.replace(ACME_SOURCE, 'https://github.com/acme/moved-context')); + const r = updatePinRun(host, manifest!, { name: 'product-context' }); + assert.equal(r.action, 'refused'); + assert.equal(r.reason, 'mount-declaration-changed'); + assert.equal(fs.readFileSync(mp, 'utf8'), original.replace(ACME_SOURCE, 'https://github.com/acme/moved-context')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a target that cannot be retained under --fetch refuses the move, manifest untouched', async () => { + const dir = tmpdir('leji-updatepin-retain-fail-'); + 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'); + fs.mkdirSync(path.join(host, '.leji'), { recursive: true }); + fs.writeFileSync( + path.join(host, '.leji', 'mounts.local.json'), + JSON.stringify({ mounts: { 'product-context': { repo: sibling } } }) + '\n', + ); + const before = fs.readFileSync(path.join(host, 'leji.json')); + // By the time the TARGET is retained the store already holds it, so the fetch + // never runs and only the ref update can fail: the injection is the branch's + // 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, + LEJI_TEST_FAIL_PIN_REF: OID.b, + }); + assert.equal(r.code, 1, r.stdout); + const doc = JSON.parse(r.stdout) as UpdatePinDocument; + assert.equal(doc.action, 'refused'); + assert.equal(doc.reason, 'mount-store-fetch-failed'); + assert.equal(doc.mount.to, OID.b, 'the target it declined to retain is still reported'); + assert.deepEqual( + doc.findings.map((f) => f.rule), + ['mount-store-fetch-failed'], + ); + 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. + const key = crypto.createHash('sha256').update(ACME_IDENTITY).digest('hex'); + const store = path.join(host, '.leji', 'mounts', 'store', key); + assert.equal(git(store, 'rev-parse', pinRefFor(ACME_IDENTITY, OID.a)), OID.a); + } 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 { + 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'); + buildStore(host, sibling, { pin: OID.a, witnessRef: 'refs/heads/main', witnessOid: OID.b, depth: null }); + const { manifest } = loadManifest(host); + // The mount is renamed on disk after the comparison: the declaration check + // fires first, so the scanner's own refusal needs the name to still match + // while the pin does not. + const mp = path.join(host, 'leji.json'); + assert.throws( + () => replaceMountPinInManifestText(fs.readFileSync(mp, 'utf8'), 'product-context', OID.s, OID.b), + /pin of mount "product-context" is not/, + ); + // And the same refusal reaches the CLI as exit 2 with no document at all. + const r = await runCliProc( + ['mounts', 'update-pin', 'product-context', '--to', 'z'.repeat(40), '--root', host, '--json'], + { ...process.env, GIT_DIR: undefined }, + ); + assert.equal(r.code, 2, 'a malformed --to never reaches the scanner'); + assert.equal(r.stdout.trim(), ''); + assert.ok(manifest); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- the CLI surface ---------------------------------------------------------- + +async function quiet(fn: () => T | Promise): Promise { + const log = console.log; + const err = console.error; + console.log = () => {}; + console.error = () => {}; + try { + return await fn(); + } finally { + console.log = log; + console.error = err; + } +} + +test('run: the mounts sub-guard accepts update-pin and still rejects everything else', async () => { + const dir = tmpdir('leji-updatepin-cli-'); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), dir, { recursive: true }); + // Accepted spellings reach their command (never the sub-guard's exit 2)… + for (const sub of ['hydrate', 'status', 'locate', 'update-pin']) { + const argv = [ + 'mounts', + sub, + ...(sub === 'locate' || sub === 'update-pin' ? ['product-context'] : []), + '--root', + dir, + ]; + assert.notEqual(await quiet(() => run(argv)), 2, argv.join(' ')); + } + // …and every other spelling, including a bare `mounts`, is the guard. + for (const sub of [[], ['nope'], ['update'], ['updatepin'], ['update-pins'], ['Update-Pin']]) { + assert.equal(await quiet(() => run(['mounts', ...sub, '--root', dir])), 2, `mounts ${sub.join(' ')}`); + } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('run: update-pin takes exactly one positional, and only its declared flags', async () => { + const dir = tmpdir('leji-updatepin-flags-'); + fs.cpSync(path.join(fixturesDir, 'warn-update-pin'), dir, { recursive: true }); + // The positional budget gains this command's one name, as `mounts locate` has. + assert.notEqual(await quiet(() => run(['mounts', 'update-pin', 'product-context', '--root', dir])), 2); + assert.equal(await quiet(() => run(['mounts', 'update-pin', 'product-context', 'surplus', '--root', dir])), 2); + assert.equal(await quiet(() => run(['mounts', 'update-pin', '--root', dir])), 2, 'the name is required'); + // Flags declared on this command are accepted; a flag declared elsewhere is not, + // and neither is a destination parameter, which this command has none of. + assert.notEqual( + await quiet(() => run(['mounts', 'update-pin', 'product-context', '--dry-run', '--fetch', '--root', dir])), + 2, + ); + for (const argv of [ + ['mounts', 'update-pin', 'product-context', '--check-integrity'], + ['mounts', 'update-pin', 'product-context', '--strict'], + ['mounts', 'update-pin', 'product-context', '--endpoint', 'x'], + ['mounts', 'status', '--to', OID.b], + ['mounts', 'status', '--allow-non-fast-forward'], + ]) { + assert.equal(await quiet(() => run([...argv, '--root', dir])), 2, argv.join(' ')); + } + // `--to` takes a full lowercase hex commit id in either spelling, and nothing else. + assert.notEqual( + await quiet(() => run(['mounts', 'update-pin', 'product-context', `--to=${OID.b}`, '--root', dir])), + 2, + ); + assert.notEqual( + await quiet(() => run(['mounts', 'update-pin', 'product-context', '--to', '0'.repeat(64), '--root', dir])), + 2, + ); + for (const bad of ['xyz', OID.b.slice(0, 12), OID.b.toUpperCase(), '0'.repeat(41), '0'.repeat(63), '']) { + assert.equal( + await quiet(() => run(['mounts', 'update-pin', 'product-context', '--to', bad, '--root', dir])), + 2, + `--to ${bad}`, + ); + } + assert.equal( + await quiet(() => run(['mounts', 'update-pin', 'product-context', '--to', '--json', '--root', dir])), + 2, + ); + // The override is meaningless without a named target, and says so. + assert.equal( + await quiet(() => run(['mounts', 'update-pin', 'product-context', '--allow-non-fast-forward', '--root', dir])), + 2, + ); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('run: `mounts update-pin --help` exits 0 and names no network destination', async () => { + const lines: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => { + lines.push(a.join(' ')); + }; + try { + assert.equal(await run(['mounts', 'update-pin', '--help']), 0); + } finally { + console.log = log; + } + const help = lines.join('\n'); + assert.match(help, /leji mounts update-pin/); + // The only network vocabulary this command may carry is what `mounts hydrate` + // already documents: the declared source, and nothing addressable by the caller. + for (const banned of ['endpoint', 'token', 'upload', 'registry', 'api.', 'http://', 'account']) { + assert.ok(!help.toLowerCase().includes(banned), `help must not mention "${banned}"`); + } +}); diff --git a/packages/site/README.md b/packages/site/README.md index 811cb2b..8308ea9 100644 --- a/packages/site/README.md +++ b/packages/site/README.md @@ -1,3 +1,3 @@ # site -Source for leji.org: the spec rendered as a site, schema reference pages, adoption guides, and rationale. Astro; `npm run dev` serves on port 21200. +Source for leji.org: the spec rendered as a site, schema reference pages, adoption guides, and rationale. Astro; `npm run dev` serves on Astro's default port (4321). diff --git a/packages/site/astro.config.ts b/packages/site/astro.config.ts index 47909e0..3416df8 100644 --- a/packages/site/astro.config.ts +++ b/packages/site/astro.config.ts @@ -2,16 +2,19 @@ import { defineConfig } from 'astro/config'; import sitemap from '@astrojs/sitemap'; import { satteri } from '@astrojs/markdown-satteri'; import { markdownProcessorOptions } from './src/markdown-options'; +import lejiSyntaxTheme from './src/styles/shiki-leji.json'; export default defineConfig({ site: 'https://leji.org', integrations: [sitemap()], - server: { port: 21200 }, devToolbar: { enabled: false }, markdown: { // Astro 7's default Markdown processor. Our content transforms run as Sätteri // hast plugins (the unified/remark pipeline is no longer used). processor: satteri(markdownProcessorOptions), - shikiConfig: { theme: 'night-owl' }, + // Brand-family syntax theme: one voice with the terminal illustration and the + // dark-surface cast; strings mint, keywords luminous brand green, constants + // parchment, comments muted — no off-system hues on brand grounds. + shikiConfig: { theme: lejiSyntaxTheme }, }, }); diff --git a/packages/site/public/favicon.svg b/packages/site/public/favicon.svg index a634b48..d0613c0 100644 --- a/packages/site/public/favicon.svg +++ b/packages/site/public/favicon.svg @@ -1,6 +1,5 @@ - - + diff --git a/packages/site/public/llms.txt b/packages/site/public/llms.txt index 46fbbe3..780561e 100644 --- a/packages/site/public/llms.txt +++ b/packages/site/public/llms.txt @@ -52,5 +52,6 @@ JSON Schemas served at their canonical $id URLs: - [Quickstart](https://leji.org/quickstart/): install the reference CLI (npm, PyPI, and Go), bootstrap or map a context layer, validate, score conformance - [Adoption guides](https://leji.org/adoption/): monorepo, multi-repo, and federation adoption paths - [Rationale](https://leji.org/rationale/): why the specification exists and the design bets -- [Trust](https://leji.org/trust/): stewardship, the versioning promise, and what continues without the steward +- [Trust](https://leji.org/trust/): who stewards Leji, how the steward's commercial products relate to the specification, the versioning promise, and what continues without the steward +- [Trademark & usage policy](https://leji.org/trademark/): how the Leji name may be used, what forks need, and what the logo requires - [Source repository](https://github.com/leji-org/leji): spec prose (CC-BY-4.0), code and schemas (Apache-2.0) diff --git a/packages/site/public/og.png b/packages/site/public/og.png index b359e68..4fce391 100644 Binary files a/packages/site/public/og.png and b/packages/site/public/og.png differ diff --git a/packages/site/src/assets/leji-icon.svg b/packages/site/src/assets/leji-icon.svg index 490b6b6..33944b0 100644 --- a/packages/site/src/assets/leji-icon.svg +++ b/packages/site/src/assets/leji-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/site/src/components/AnimatedTerminal.astro b/packages/site/src/components/AnimatedTerminal.astro index 889701d..b96fd1c 100644 --- a/packages/site/src/components/AnimatedTerminal.astro +++ b/packages/site/src/components/AnimatedTerminal.astro @@ -58,10 +58,10 @@ const bodyHtml = lines .term { position: relative; border-radius: 12px; - background: var(--blue-deep); - box-shadow: 0 18px 50px rgba(22, 41, 96, 0.22); + background: var(--leji-deep); + box-shadow: 0 18px 50px rgba(24, 61, 59, 0.24); /* no overflow:hidden: the brand mark pokes past the top-right corner */ - border: 1px solid rgba(255, 189, 110, 0.18); + border: 1px solid rgba(112, 216, 194, 0.24); } .term-bar { border-radius: 12px 12px 0 0; @@ -123,7 +123,7 @@ const bodyHtml = lines z-index: 3; display: inline-flex; align-items: center; - filter: drop-shadow(0 2px 10px rgba(255, 189, 110, 0.35)); + filter: drop-shadow(0 2px 10px rgba(112, 216, 194, 0.4)); } .term-brand :global(svg) { display: block; @@ -169,31 +169,32 @@ const bodyHtml = lines .term[data-state='split'] .term-glimpse { clip-path: polygon(0 0, 100% 0, 0 100%, 0 100%); /* layered drop-shadow so the panel reads as casting onto the terminal */ - filter: drop-shadow(1px 7px 20px rgba(3, 7, 22, 0.72)) drop-shadow(0 2px 5px rgba(3, 7, 22, 0.55)); + filter: drop-shadow(1px 7px 20px rgba(16, 42, 41, 0.72)) drop-shadow(0 2px 5px rgba(16, 42, 41, 0.55)); } .line { display: inline; } .prompt { - color: var(--gold); + color: var(--leji-accent); user-select: none; } .cmd-text { color: var(--white-strong); } - .out.pass { - color: #6fc7a6; + /* Success reads in the brand's own accent; the failure tone stays outside the + palette because the OSS system defines no error colour and this is a terminal + state, not identity. */ + .out.pass, + .out.ok { + color: var(--leji-accent); } .out.fail { color: #ff8f8f; } .out.manual { - color: var(--gold-bright); + color: var(--leji-accent); opacity: 0.85; } - .out.ok { - color: #8fd4b8; - } .out.dim { color: var(--white-faint); } @@ -207,7 +208,7 @@ const bodyHtml = lines width: 0.55ch; height: 1.05em; vertical-align: -0.18em; - background: var(--gold-bright); + background: var(--leji-accent); margin-left: 1px; animation: term-blink 1s steps(1) infinite; } diff --git a/packages/site/src/components/CircleChart.astro b/packages/site/src/components/CircleChart.astro index 210fcc8..e3ad713 100644 --- a/packages/site/src/components/CircleChart.astro +++ b/packages/site/src/components/CircleChart.astro @@ -20,7 +20,7 @@ markerHeight="11" orient="auto-start-reverse" > - + @@ -135,23 +135,23 @@ font-size: 13.5px; letter-spacing: 0.16em; text-transform: uppercase; - fill: var(--blue); + fill: var(--leji-link); text-anchor: middle; } .spoke { - stroke: var(--blue); + stroke: var(--leji-brand); stroke-width: 1.7; } .ring { fill: none; - stroke: rgba(34, 63, 147, 0.4); + stroke: rgba(0, 159, 113, 0.45); stroke-width: 1.3; stroke-dasharray: 5 6; animation: cc-ring 4.5s linear infinite; } - /* gold particles drift along the solid spokes, each at its own tempo */ + /* brand-green particles drift along the solid spokes, each at its own tempo */ .spoke-dot { - fill: var(--gold); + fill: var(--leji-brand); animation: cc-dot 3s ease-in-out infinite; } .spokes circle:nth-of-type(1) { @@ -171,7 +171,7 @@ animation-delay: -1.7s; } .core { - fill: var(--gold); + fill: var(--leji-accent); } @keyframes cc-dot { 0%, @@ -199,23 +199,25 @@ font-family: var(--mono); font-size: 14px; font-weight: 500; - fill: var(--blue-deep); + fill: var(--leji-text); text-anchor: middle; } .node { - fill: var(--blue-deep); + fill: var(--leji-deep); stroke-width: 1.5; } + /* Two rings of the same hue keep people and agents apart on one node fill: + brand green for the people, the lighter mint for the agents. */ .node.human { - stroke: var(--blue-soft); + stroke: var(--leji-brand); } .node.agent { - stroke: var(--gold); + stroke: var(--leji-accent); } .node-label { font-family: var(--mono); font-size: 12.5px; - fill: var(--white); + fill: var(--leji-surface); text-anchor: middle; } diff --git a/packages/site/src/components/CopyableCode.astro b/packages/site/src/components/CopyableCode.astro index 387dd99..722551c 100644 --- a/packages/site/src/components/CopyableCode.astro +++ b/packages/site/src/components/CopyableCode.astro @@ -4,6 +4,7 @@ import { Code } from 'astro:components'; import copyIcon from '../assets/copy.svg?raw'; import checkIcon from '../assets/check.svg?raw'; +import lejiSyntaxTheme from '../styles/shiki-leji.json'; interface Props { code: string; @@ -15,7 +16,7 @@ const { code, lang = 'text', label = 'Copy to clipboard' } = Astro.props; ---
- +