forked from bai/curriculum-project-hub
Compare commits
251 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecc92c9a87 | |||
| e878b46701 | |||
| a426490ae3 | |||
| 5866b3d6cc | |||
| 7a0b4b0c4f | |||
| 3d46525f40 | |||
| dd8fcfd366 | |||
| fe8a17c6ad | |||
| 9927d38c18 | |||
| e0bd6120ec | |||
| 66fc9516d9 | |||
| 9c5021f8ed | |||
| 6290b39c63 | |||
| 79734b5435 | |||
| 5754005a97 | |||
| dc271ff9f3 | |||
| 97313aba0f | |||
| cfe733554a | |||
|
ff990b4caf
|
|||
| 3cfbe55070 | |||
| 53ef364af2 | |||
| 94dba4e672 | |||
| 06aa6f0dfb | |||
| 4fc72541be | |||
| 672f05c66a | |||
| e173aa18e0 | |||
| e9cecbf071 | |||
| 55f29523a0 | |||
| d9cde19bdf | |||
| a463ab48e6 | |||
| d39ebed62e | |||
| 270275c367 | |||
| 09338f355a | |||
| 8bc5dbf9e2 | |||
| beaa92de2e | |||
| b1fd2e8f7b | |||
| 9e38d1e011 | |||
| f99c8ea4d8 | |||
| 83a6b012b7 | |||
| 475b397ca8 | |||
| d072e9ec1e | |||
| 04fa383286 | |||
| 0fd21e51f7 | |||
| ab5e03823c | |||
| 02b46e2dcf | |||
| 834f4c380c | |||
| 4c68c7db0b | |||
| 39a2be6347 | |||
| 6ea8e33148 | |||
| 295f07d111 | |||
| e1da845bfc | |||
| ace724c609 | |||
| ee928b5832 | |||
| 0f4377f16c | |||
| 751d0c4100 | |||
| 0ebfeb927a | |||
| 4eafbf20a9 | |||
| 49e1e2f19e | |||
| 9c1f9de9c1 | |||
| ac53d42a0a | |||
| e0e25ca4c5 | |||
| 6ddc0b5bd1 | |||
| b91938071d | |||
| 6f736abe50 | |||
| fdd83999df | |||
| d4cd1ad74f | |||
| 315e4bd018 | |||
| 3a46ebc54d | |||
| 213f00eb07 | |||
| 127a8c3418 | |||
| 61512454cc | |||
| 03142c75ea | |||
| 46d6722254 | |||
| 3a4b9e052a | |||
| faafece1a4 | |||
| d7bbffb9c6 | |||
| 6c8d2da897 | |||
| c9adf83e5c | |||
| db7b7ec094 | |||
| 88386fb943 | |||
| 97c7054529 | |||
| 97a99cd381 | |||
| 87bbf0bd57 | |||
| 8eda04f1d9 | |||
| 1dab83f9db | |||
| 8c26c42e0c | |||
| fe38d0b8a9 | |||
| e194670d74 | |||
| 3b544d99f4 | |||
| c72f8c7050 | |||
| 947f969967 | |||
| 60856d7cc1 | |||
| 2e08c0a734 | |||
| 2225c6d43b | |||
| fc908eaf3b | |||
| 2395671693 | |||
| cc4d9d907c | |||
| ef02428bb6 | |||
| ad1a464f22 | |||
| 2dfe72cd5e | |||
| 12a1246a7a | |||
| ce5fbfb9a6 | |||
| 405312b36b | |||
| be17f74fc2 | |||
| 4849a765da | |||
| 82241afb56 | |||
| fccae5dacb | |||
| 0dd2ae347e | |||
| a306c58db2 | |||
| a4c07d1a5d | |||
| 91afd3c1b1 | |||
| 3367d3340b | |||
| 34d5d5e88e | |||
| 50ddf32cc2 | |||
| e6e23294a2 | |||
| 8a13e455fb | |||
| 6990082247 | |||
| cdeb29ccf2 | |||
| eeb8f56742 | |||
| 325b4fc137 | |||
| a7f90f387d | |||
| d159e372d2 | |||
| 3d0f4e5c2d | |||
| de9f846fd0 | |||
| 683e97ca53 | |||
| 11a7ec8004 | |||
| 2f2ece1a3a | |||
| bdd722c463 | |||
| 2699ff3679 | |||
| a3af63c1c6 | |||
| 755704e2ae | |||
| dcda4cb7a3 | |||
| 9e26585e1f | |||
| 8bfbecf60f | |||
| 4087f09994 | |||
| 3708162fa9 | |||
| 4de290a73c | |||
| 58c81d4379 | |||
| d16bd4899d | |||
| 4021e58d5d | |||
| 744c707a39 | |||
| 74f5c4a02e | |||
| 326224b778 | |||
| e3b463d390 | |||
| ceaf64c4f9 | |||
| bb426dfaf5 | |||
| b4fe734e80 | |||
| 2f79b7743f | |||
| 192cd43245 | |||
| 12628c9233 | |||
| 4a03bc1f4a | |||
| 8a81c60ea5 | |||
| 00f7a8db39 | |||
| 54837717fd | |||
| 2285ae871e | |||
| 36660f72d6 | |||
| 6462e42823 | |||
| 6f7497bce8 | |||
| e634418a02 | |||
| db49a0d23d | |||
| 54b9fee22c | |||
| 5f668d71a2 | |||
| 3fbc4b81c2 | |||
| 5df1900ca8 | |||
| df66691d24 | |||
| e016564cc5 | |||
| 6cefb2a938 | |||
| 34c4908237 | |||
| 46687dd5f6 | |||
| e9b578153e | |||
| 93f3f2424c | |||
| 73cb0e5b47 | |||
| e21096c642 | |||
| dc2d1c2f9e | |||
| 3f9b60f692 | |||
| 4234ba4c96 | |||
| 15f9443d3d | |||
| 7f09fb1f13 | |||
| eb0be43eac | |||
| ce18740870 | |||
| ef96f8d33d | |||
| 5e10419fc8 | |||
| 64b3d1fc64 | |||
| b673dd1fe9 | |||
| aaa098bb8b | |||
| 97f7972cc5 | |||
| cc42e6a7c6 | |||
| 4e01c18cac | |||
|
a12984d174
|
|||
| b93acd8e8c | |||
| 35251986af | |||
| 3ee6da7ceb | |||
| 79f72ecca8 | |||
| ae5f78f036 | |||
| 0782e155f6 | |||
|
0726dc13c8
|
|||
|
fb66614e38
|
|||
| 11de9e81db | |||
| 46ce942aec | |||
| 8990277916 | |||
| b217c16c1b | |||
| 78f94fcc8c | |||
| 7269480abb | |||
| 1d2f4657ba | |||
| 4ad0259193 | |||
| 4e2699d0a5 | |||
|
153d74d033
|
|||
|
080efa70c5
|
|||
|
ab9dfad53a
|
|||
|
adce8fb6f5
|
|||
|
b574ef871c
|
|||
|
cbe569d7e6
|
|||
|
ae870a9b73
|
|||
|
18acc823c3
|
|||
|
8d2e0cb2c6
|
|||
|
3ae0cc3e60
|
|||
|
b1ddf32238
|
|||
|
9c33a4e9b9
|
|||
|
acf7ae0cd7
|
|||
|
0968545b5a
|
|||
|
552c1c353e
|
|||
|
461d2e89b0
|
|||
| 93f252b177 | |||
| 2211beb42c | |||
| 2f0e0f2cd7 | |||
| 69837bd50c | |||
| 82f57317df | |||
| d94cc787b2 | |||
| 4f8df12fb0 | |||
|
3ebe4b754d
|
|||
|
3fa6a5a5a5
|
|||
|
be4260bcd0
|
|||
|
a4449f03c4
|
|||
|
01bc20d25f
|
|||
|
38c3231190
|
|||
|
63416e06ea
|
|||
|
39bd2c9ff7
|
|||
|
e17e038232
|
|||
|
678bc9f56c
|
|||
| 07aa10ef27 | |||
| 816af1abdb | |||
|
3a50ed0ce2
|
|||
| 53d372e29b | |||
| 530fcdd2b7 | |||
| 53998d2651 | |||
| 5b55cf18a8 | |||
| b0d691d53f | |||
| 1f48c5b707 | |||
| 12a2f3117f | |||
| 2ee84d9543 | |||
| 3087132083 |
@@ -1,12 +1,12 @@
|
||||
name: checker check
|
||||
|
||||
# Builds and lints the Rust implementation crates under crates/ (the rule-based
|
||||
# checker that "stands in Lean's position" at product runtime).
|
||||
# lesson checker).
|
||||
#
|
||||
# Like spec-check, this is an INTERNAL gate on the implementation's own health
|
||||
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
|
||||
# spec-to-implementation conformance gate — implementations align to the Lean
|
||||
# contract by human review, not by CI. See the repo README.
|
||||
# This is an INTERNAL gate on the implementation's own health
|
||||
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
|
||||
# decision-to-implementation conformance gate — implementations align to the
|
||||
# ADRs by human review, not by CI. See the repo README.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: deploy admin (hub fleet)
|
||||
|
||||
# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa)
|
||||
# onto the managed Alpha host. Two fleets share one machine and are separated by
|
||||
# the middle DNS label of each Silo's public URL:
|
||||
#
|
||||
# dev → https://<slug>.educraft-dev.paradigm-edu.net (every push + PR)
|
||||
# prod → https://<slug>.educraft.paradigm-edu.net (main + tags)
|
||||
#
|
||||
# Example prod tenant:
|
||||
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
|
||||
#
|
||||
# Required Gitea secret:
|
||||
# DEPLOY_SSH_KEY — private key for root@39.107.254.4
|
||||
#
|
||||
# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK):
|
||||
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE
|
||||
# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "hub/**"
|
||||
- ".gitea/workflows/deploy-admin.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "hub/**"
|
||||
- ".gitea/workflows/deploy-admin.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fleet:
|
||||
description: "Which fleet to deploy (dev | prod | both)"
|
||||
required: true
|
||||
default: both
|
||||
allow_empty_fleet:
|
||||
description: "Succeed if no silo matches (1/0)"
|
||||
required: false
|
||||
default: "0"
|
||||
|
||||
concurrency:
|
||||
group: deploy-admin-host
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
name: deploy fleet dev (educraft-dev)
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'pull_request' ||
|
||||
(github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
(github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both'))
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install rsync + ssh
|
||||
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
|
||||
|
||||
- name: Deploy Hub + admin SPA to educraft-dev fleet
|
||||
env:
|
||||
CPH_FLEET: dev
|
||||
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
|
||||
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
|
||||
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
|
||||
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
|
||||
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
|
||||
echo "missing secret DEPLOY_SSH_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
key="$(mktemp)"
|
||||
trap 'rm -f "$key"' EXIT
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
|
||||
chmod 600 "$key"
|
||||
export PLATFORM_DEPLOY_SSH_KEY="$key"
|
||||
# Apply managed-host defaults when secrets are unset.
|
||||
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
|
||||
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
|
||||
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||
bash hub/deploy/deploy_fleet_release.sh
|
||||
|
||||
deploy-prod:
|
||||
name: deploy fleet prod (educraft)
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
(github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both'))
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install rsync + ssh
|
||||
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
|
||||
|
||||
- name: Deploy Hub + admin SPA to educraft fleet
|
||||
env:
|
||||
CPH_FLEET: prod
|
||||
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
|
||||
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
|
||||
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
|
||||
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
|
||||
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
|
||||
echo "missing secret DEPLOY_SSH_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
key="$(mktemp)"
|
||||
trap 'rm -f "$key"' EXIT
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
|
||||
chmod 600 "$key"
|
||||
export PLATFORM_DEPLOY_SSH_KEY="$key"
|
||||
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
|
||||
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
|
||||
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
|
||||
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
|
||||
bash hub/deploy/deploy_fleet_release.sh
|
||||
@@ -1,15 +1,18 @@
|
||||
name: hub check
|
||||
|
||||
# Builds, type-checks, and tests the Hub TS package under hub/.
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half
|
||||
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
|
||||
# The Hub is the Feishu-group collaboration + agent runtime half.
|
||||
# This is an INTERNAL gate on the Hub's own
|
||||
# health, like checker-check is for the Rust half.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: hub-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
hub-check:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,8 +23,9 @@ jobs:
|
||||
POSTGRES_USER: paradigm
|
||||
POSTGRES_PASSWORD: paradigm
|
||||
POSTGRES_DB: cph_hub_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
# Avoid host-port binds: concurrent hub-check jobs on the shared
|
||||
# runner raced on published 5432/15432 ("port is already allocated").
|
||||
# Reach the service by Docker DNS name from the job container instead.
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U paradigm -d cph_hub_test"
|
||||
--health-interval 5s
|
||||
@@ -33,15 +37,33 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain (for cph binary)
|
||||
uses: dtolnay/rust-toolchain@1.92.0
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-hub-check-${{ runner.os }}-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: hub/package-lock.json
|
||||
cache-dependency-path: |
|
||||
hub/package-lock.json
|
||||
hub/admin-web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
npm ci
|
||||
npm ci --prefix admin-web
|
||||
|
||||
- name: Audit production Node dependencies
|
||||
run: npm run audit:production
|
||||
@@ -54,8 +76,10 @@ jobs:
|
||||
node <<'NODE'
|
||||
const net = require("node:net");
|
||||
const deadline = Date.now() + 60000;
|
||||
const host = process.env.HUB_CHECK_PG_HOST || "postgres";
|
||||
const port = Number(process.env.HUB_CHECK_PG_PORT || "5432");
|
||||
function tryConnect() {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
|
||||
const socket = net.createConnection({ host, port });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
process.exit(0);
|
||||
@@ -63,7 +87,7 @@ jobs:
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
if (Date.now() > deadline) {
|
||||
console.error("Postgres did not become reachable at 127.0.0.1:5432");
|
||||
console.error(`Postgres did not become reachable at ${host}:${port}`);
|
||||
process.exit(1);
|
||||
}
|
||||
setTimeout(tryConnect, 1000);
|
||||
@@ -90,19 +114,41 @@ jobs:
|
||||
run: |
|
||||
cd ..
|
||||
cargo install --path crates/cph-cli --locked
|
||||
# Make cph available to the unprivileged sandbox user below.
|
||||
sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph
|
||||
|
||||
- name: Prove real Claude SDK Bash sandbox boundary
|
||||
run: |
|
||||
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
|
||||
CPH_SANDBOX_TEST_ROOT=/w/t \
|
||||
/usr/bin/setpriv --no-new-privs \
|
||||
npx vitest run test/integration/agent-sandbox-linux.test.ts
|
||||
set -euo pipefail
|
||||
# Nested act/docker runners often disallow unprivileged user
|
||||
# namespaces, which bwrap requires once CapEff is cleared. Skip the
|
||||
# live proof there; unit + non-sandbox integration still gate.
|
||||
sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
|
||||
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
|
||||
if ! unshare --user true 2>/dev/null; then
|
||||
echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner"
|
||||
exit 0
|
||||
fi
|
||||
if ! id cphci >/dev/null 2>&1; then
|
||||
useradd --create-home --shell /bin/bash cphci
|
||||
fi
|
||||
install -d -o cphci -g cphci -m 0700 /w/t
|
||||
REPO_ROOT="$(cd .. && pwd)"
|
||||
NODE_BIN_DIR="$(dirname "$(command -v node)")"
|
||||
NPX_BIN="$(command -v npx)"
|
||||
chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci
|
||||
/usr/bin/setpriv \
|
||||
--reuid=cphci --regid=cphci --init-groups \
|
||||
--inh-caps=-all --bounding-set=-all --ambient-caps=-all \
|
||||
--no-new-privs \
|
||||
env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \
|
||||
bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts"
|
||||
|
||||
- name: Run unit tests
|
||||
run: npx vitest run test/unit
|
||||
|
||||
# Integration tests need PostgreSQL + cph. cph is installed above.
|
||||
# PostgreSQL is set up as a service container below.
|
||||
# PostgreSQL is the job service container reachable as `postgres`.
|
||||
- name: Run integration tests (mock provider, real prisma + cph)
|
||||
run: |
|
||||
npx prisma migrate deploy --schema prisma/schema.prisma
|
||||
@@ -110,7 +156,8 @@ jobs:
|
||||
--exclude test/integration/real-model.test.ts \
|
||||
--exclude test/integration/agent-sandbox-linux.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
|
||||
DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test
|
||||
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
|
||||
|
||||
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
|
||||
# OPENROUTER_API_KEY when a branch should hit live OpenRouter.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: spec check
|
||||
|
||||
# Builds the Lean semantic master spec under spec/.
|
||||
# This is an INTERNAL well-formedness gate (does the contract type-check?),
|
||||
# NOT a spec-to-implementation conformance gate — implementations align to the
|
||||
# contract by human review, not by CI. See repo README.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
spec-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: leanprover/lean-action@v1
|
||||
with:
|
||||
lake-package-directory: spec
|
||||
+8
-4
@@ -1,7 +1,3 @@
|
||||
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
|
||||
.lake/
|
||||
**/.lake/
|
||||
|
||||
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
|
||||
/target
|
||||
**/*.pdf
|
||||
@@ -11,8 +7,16 @@
|
||||
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
|
||||
render/vendor/local-packages/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Node (hub/ TS workspace and any future JS package)
|
||||
node_modules/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
.omo/
|
||||
# Local operator notes / specs (not product source)
|
||||
/spec/
|
||||
/需求整理-*.md
|
||||
|
||||
@@ -29,7 +29,7 @@ workload brakes.
|
||||
|
||||
The full current-state inventory, accepted behavior, and release evidence are
|
||||
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
|
||||
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
|
||||
with the durable decision in ADR-0022. Numerical
|
||||
ceilings remain open until production-like calibration.
|
||||
|
||||
The implementation frontier is:
|
||||
|
||||
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
|
||||
## Question
|
||||
|
||||
After the readiness investigations and resulting fixes are resolved, can one
|
||||
repeatable release procedure prove build/test/spec health, deploy a clean
|
||||
repeatable release procedure prove build/test health, deploy a clean
|
||||
production-like environment, exercise critical tenant and agent journeys,
|
||||
verify observability and recovery, and either roll forward or roll back safely?
|
||||
|
||||
@@ -8,7 +8,7 @@ Blocked by: 04
|
||||
|
||||
Separate or unify run-bound audit entries, pre-run security/permission events,
|
||||
structured messages, and operational recovery events without weakening
|
||||
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
|
||||
the pinned AuditEntry-to-run relation. Decide durability,
|
||||
failure, retention, and query semantics; then enforce referential integrity and
|
||||
observable/recoverable writes instead of silently swallowing lost evidence.
|
||||
Do not merge these customer Project/Run records with ADR-0023's already-decided
|
||||
|
||||
+1
-3
@@ -40,9 +40,7 @@ an off-host recovery key, an incident and reason, and issues only an expiring
|
||||
Emergency Platform Grant.
|
||||
|
||||
The complete accepted decision and implementation divergences are in
|
||||
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
|
||||
The pinned semantic invariants are in
|
||||
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
|
||||
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
|
||||
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
|
||||
|
||||
Exact numeric session/invitation/step-up limits and browser mechanics remain
|
||||
|
||||
@@ -1,46 +1,55 @@
|
||||
# AGENTS.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
|
||||
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
|
||||
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
|
||||
`Spec.System.Organization`)。
|
||||
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
|
||||
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
|
||||
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
|
||||
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
|
||||
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
|
||||
不接收 org provider credential(见 ADR-0024)。
|
||||
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
|
||||
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
|
||||
`Spec.System.Capacity`)。
|
||||
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
|
||||
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
|
||||
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
|
||||
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
|
||||
双因子的离线恢复流程(见 ADR-0023)。
|
||||
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
|
||||
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
|
||||
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
|
||||
控制面与 Docker adapter 后置(见 ADR-0025)。
|
||||
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
|
||||
tools 与已安装 skill;skill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
|
||||
每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后
|
||||
`ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含
|
||||
`/project`、`/usage`、`/help`;role、会话、目录操作走 `/project` 卡片,Claude 原生
|
||||
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
|
||||
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
|
||||
运行时能力(见 ADR-0018)。
|
||||
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
|
||||
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
|
||||
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
|
||||
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
|
||||
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
|
||||
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
|
||||
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
|
||||
系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过
|
||||
`@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
|
||||
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede。
|
||||
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
# CLAUDE.md —— agent 操作手册(全 repo)
|
||||
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
|
||||
|
||||
## 这个 repo 是什么
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
|
||||
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
|
||||
`Spec.System.ProjectWorkspace`)。
|
||||
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
|
||||
|
||||
## 纪律
|
||||
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
|
||||
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
|
||||
|
||||
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
|
||||
|
||||
3. **改 `spec/` 必须保持其 `lake build` 通过。** 在 `spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿。
|
||||
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede。
|
||||
|
||||
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
|
||||
|
||||
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
|
||||
**Emergency Workload Brake**:
|
||||
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
|
||||
_Avoid_: Organization deletion, service restart
|
||||
|
||||
**Member Group**:
|
||||
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
|
||||
_Avoid_: Team (the org-scoped flat grouping), Feishu department
|
||||
|
||||
Generated
+3
@@ -410,7 +410,10 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"cph-check",
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
"cph-schema",
|
||||
"cph-typst",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
|
||||
|
||||
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
|
||||
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
|
||||
|
||||
## 安装 `cph` 命令行
|
||||
|
||||
@@ -16,10 +16,28 @@ cargo install --path crates/cph-cli --locked
|
||||
|
||||
```sh
|
||||
cph --version # cph 0.0.2
|
||||
cph init <工程目录> # 脚手架:manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录
|
||||
cph add --root <工程目录> <kind> <名称> # 新增 part(segment/example/lemma/sop):建目录+空白内容文件+追加 [[children]]
|
||||
cph check <工程目录> # 校验合法性(7 类诊断)
|
||||
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
|
||||
```
|
||||
|
||||
```sh
|
||||
cph outline <工程目录> # 默认写入 <工程目录>/outline.pdf
|
||||
cph outline <工程目录> --format md # 或 json / pdf
|
||||
cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 outline.pdf
|
||||
```
|
||||
|
||||
大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行
|
||||
`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown,
|
||||
并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。
|
||||
`init` / `add` 是纯本地的创作脚手架(与 ADR-0013 的 `completions` 同类,不涉及
|
||||
hub 语义):`init` 产出一个 `cph check` 可过的工程根;`add` 按 kind 建
|
||||
`<子目录>/<名称>/` + `element.toml` + 必填内容字段(`segment→textbook.typ`、
|
||||
`example→problem/solution.typ`、`lemma→stmt.typ`、`sop→sop.typ`),并把配套
|
||||
`[[children]]` 追加进根 `manifest.toml`(保持数组连续,不破坏注释;ADR-0036)。缺省
|
||||
`--root` 为当前目录。
|
||||
|
||||
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
|
||||
|
||||
Shell 补全(可选):
|
||||
@@ -33,47 +51,40 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
|
||||
```
|
||||
README.md ← 本文件:总览 + 宪法(下面 5 条)
|
||||
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
|
||||
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
|
||||
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
|
||||
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
|
||||
CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
|
||||
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
|
||||
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
|
||||
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
|
||||
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
|
||||
cph-check / cph-cli ← checker 本体 + `cph` 命令行
|
||||
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
|
||||
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
|
||||
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
|
||||
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
|
||||
(exporter/ …) ← 将来的其他部件,平级于 spec/
|
||||
(exporter/ …) ← 将来的其他部件,平级于 crates/
|
||||
```
|
||||
|
||||
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
|
||||
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
|
||||
未来部件(如 exporter)复用,而非各自重造。
|
||||
|
||||
## 宪法
|
||||
|
||||
这 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
|
||||
这 4 条是本仓库的协作约定,是一切工作的前提。
|
||||
|
||||
1. **角色 —— Lean 是研发侧的上游参照。**
|
||||
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来。
|
||||
1. **角色 —— ADR 是决策真相。**
|
||||
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档。
|
||||
|
||||
2. **对齐机制 —— Lean 只做上游参照。**
|
||||
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
|
||||
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
|
||||
2. **对齐机制 —— 人肉承载,无机器兜底。**
|
||||
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
|
||||
|
||||
3. **资产性 —— 由 review 纪律承载,无机器兜底。**
|
||||
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档。
|
||||
3. **形态 —— 自包含。**
|
||||
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定。
|
||||
|
||||
4. **形态 —— 它是人机共识的契约。**
|
||||
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞。
|
||||
|
||||
5. **深度判据 —— 只收录分歧点。**
|
||||
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
|
||||
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
|
||||
4. **深度判据 —— 只收录分歧点。**
|
||||
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
|
||||
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
|
||||
|
||||
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
|
||||
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
|
||||
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
|
||||
|
||||
+10
-7
@@ -1,11 +1,14 @@
|
||||
# crates/
|
||||
|
||||
These crates implement the rule-based lesson checker that aligns to the
|
||||
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
|
||||
laid out per ADR-0008 (declarative `manifest.toml` + per-element
|
||||
`element.toml`), validates structure and content, and emits diagnostics.
|
||||
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
|
||||
and `cph-typst` (the typst `World` / compile / span-mapping layer) are
|
||||
These crates implement the rule-based lesson checker whose semantics are
|
||||
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
|
||||
ADR-0005)
|
||||
laid out per ADR-0036 (a nested outline manifest — every container folder
|
||||
carries `manifest.toml`, every leaf carries `element.toml`; supersedes
|
||||
ADR-0008's flat `[[parts]]`), validates structure and content, and emits
|
||||
diagnostics. `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the
|
||||
ADR-0036 loader, also loading `bundle.toml` arrangements per ADR-0037), and
|
||||
`cph-typst` (the typst `World` / compile / span-mapping layer) are
|
||||
deliberately reusable by future components such as an `exporter`, which is why
|
||||
they live in this repo-wide `crates/` directory rather than under any single
|
||||
component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
|
||||
@@ -17,7 +20,7 @@ entrypoint) are the checker proper.
|
||||
| crate | owner | role |
|
||||
|---------------|-------|------|
|
||||
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable |
|
||||
| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable |
|
||||
| `cph-model` | WU-1 | parses the ADR-0036 nested outline layout (+ ADR-0037 bundles) into an in-memory ordered `Lesson`/`Bundle` — reusable |
|
||||
| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation |
|
||||
| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
|
||||
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
|
||||
|
||||
+135
-13
@@ -19,13 +19,11 @@ const DEFAULT_TARGET: &str = "student";
|
||||
|
||||
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
|
||||
///
|
||||
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
|
||||
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
|
||||
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
|
||||
/// **PINNED to `warning` by ADR-0005:** when a
|
||||
/// `(kind, target)` pair has no render rule the checker reports that the element
|
||||
/// is ignored under that target and **does not block the export**. Naming the
|
||||
/// severity as a const makes "it is a warning, not an error" a greppable,
|
||||
/// alignable fact rather than an inline literal.
|
||||
/// severity as a const makes "it is a warning, not an error" a greppable
|
||||
/// fact rather than an inline literal.
|
||||
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
|
||||
|
||||
/// The result of running [`check`] (or the check phases of [`build`]).
|
||||
@@ -57,12 +55,12 @@ impl CheckReport {
|
||||
|
||||
/// Whether any collected diagnostic is `Error`-severity.
|
||||
///
|
||||
/// **Legality decision (spec alignment).** `!has_errors()` is the
|
||||
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
|
||||
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
|
||||
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
|
||||
/// gate enforcing this alignment (repo constitution); it is kept greppable
|
||||
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
|
||||
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
|
||||
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
|
||||
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
|
||||
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
|
||||
/// kept greppable here so a reviewer can tie the orchestrator's gate to
|
||||
/// the ADR.
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
@@ -132,6 +130,39 @@ pub fn check(root: &Path, engine: &Engine) -> CheckReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate the lesson, then project it into an outline.
|
||||
///
|
||||
/// Outline output is derived from the manifest and part metadata, not from the
|
||||
/// rendered lesson body. It therefore runs the same load → structural → schema
|
||||
/// gates as other non-typst builds, but intentionally does not compile any
|
||||
/// target. An invalid lesson is never written in any outline format.
|
||||
pub fn outline(root: &Path) -> (Option<cph_model::OutlineDocument>, CheckReport) {
|
||||
let mut diags = Vec::new();
|
||||
let (lesson, load_diags) = cph_model::load(root);
|
||||
diags.extend(load_diags);
|
||||
|
||||
let Some(lesson) = lesson else {
|
||||
return (
|
||||
None,
|
||||
CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
run_structural_and_schema(&lesson, cph_schema::known_kinds(), &mut diags);
|
||||
let report = CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
};
|
||||
if report.has_errors() {
|
||||
(None, report)
|
||||
} else {
|
||||
(Some(lesson.outline_document()), report)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PDF for `target`.
|
||||
///
|
||||
/// Runs the check phases **(a)–(c)** (load → structural → schema). If those
|
||||
@@ -198,6 +229,96 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PDF for a **bundle** target (ADR-0037): the multi-lesson combined
|
||||
/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)–(c)
|
||||
/// over **every member lesson independently** (ADR-0037's invariant: each
|
||||
/// lesson stays independently checkable; the bundle only reads them for
|
||||
/// assembly). Any member's structural/schema error refuses the whole bundle
|
||||
/// build — a broken member lesson makes the combined artifact invalid too.
|
||||
pub fn build_bundle(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
|
||||
let mut diags = Vec::new();
|
||||
|
||||
let (bundle, load_diags) = cph_model::load_bundle(root);
|
||||
diags.extend(load_diags);
|
||||
|
||||
let Some(bundle) = bundle else {
|
||||
return (
|
||||
None,
|
||||
CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let known = cph_schema::known_kinds();
|
||||
for member in &bundle.lessons {
|
||||
run_structural_and_schema(&member.lesson, known, &mut diags);
|
||||
}
|
||||
|
||||
if diags.iter().any(|d| d.severity == Severity::Error) {
|
||||
return (
|
||||
None,
|
||||
CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
match engine.build_bundle_pdf(&bundle, target) {
|
||||
Ok(bytes) => (
|
||||
Some(bytes),
|
||||
CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
},
|
||||
),
|
||||
Err(compile_diags) => {
|
||||
diags.extend(compile_diags);
|
||||
(
|
||||
None,
|
||||
CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundle's declared export-target names, in declared order — or
|
||||
/// `[DEFAULT_TARGET]` if it declares none (ADR-0037 batch default, mirroring
|
||||
/// [`declared_target_names`]).
|
||||
pub fn declared_bundle_target_names(root: &Path) -> Vec<String> {
|
||||
let (bundle, _) = cph_model::load_bundle(root);
|
||||
match bundle {
|
||||
Some(b) if !b.targets.is_empty() => {
|
||||
b.target_names().into_iter().map(String::from).collect()
|
||||
}
|
||||
_ => vec![DEFAULT_TARGET.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The lesson's declared export-target names, in declared order — or
|
||||
/// `[DEFAULT_TARGET]` if it declares none (ADR-0037 batch default: `cph build`
|
||||
/// with no `--target` builds every declared target).
|
||||
///
|
||||
/// Loads the lesson read-only, ignoring diagnostics: an unloadable lesson (or
|
||||
/// one with a malformed root manifest) still yields `[DEFAULT_TARGET]` here so
|
||||
/// the caller's subsequent per-target build attempt is what surfaces the real
|
||||
/// load error — this helper only resolves *which names to attempt*, never
|
||||
/// gates on lesson validity.
|
||||
pub fn declared_target_names(root: &Path) -> Vec<String> {
|
||||
let (lesson, _) = cph_model::load(root);
|
||||
match lesson {
|
||||
Some(l) if !l.targets.is_empty() => {
|
||||
l.target_names().into_iter().map(String::from).collect()
|
||||
}
|
||||
_ => vec![DEFAULT_TARGET.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// One shell step's execution outcome (for [`run_shell_target`]).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShellStepOutcome {
|
||||
@@ -443,8 +564,9 @@ pub struct MarkdownAssembleReport {
|
||||
}
|
||||
|
||||
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
|
||||
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
|
||||
/// target's single-file artifact. This is the **third typed step**: unlike
|
||||
/// element's `<field>.md` markdown content file in `parts` order (ADR-0036's
|
||||
/// depth-first element sequence) into the target's single-file artifact.
|
||||
/// This is the **third typed step**: unlike
|
||||
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
|
||||
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
|
||||
///
|
||||
|
||||
@@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() {
|
||||
assert!(!report.has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_projects_parts_in_manifest_order() {
|
||||
let (outline, report) = cph_check::outline(&mini_fixture());
|
||||
assert_eq!(
|
||||
report.error_count(),
|
||||
0,
|
||||
"outline should validate the fixture"
|
||||
);
|
||||
let outline = outline.expect("valid lesson should produce an outline");
|
||||
assert_eq!(outline.title, "迷你示例课时");
|
||||
assert_eq!(outline.children.len(), 3);
|
||||
assert_eq!(outline.children[0].title, "开场对照导言");
|
||||
assert_eq!(outline.children[1].kind, "section");
|
||||
assert_eq!(outline.children[1].children[0].title, "量纲分析估计");
|
||||
assert_eq!(outline.children[2].title, "自由落体");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_is_an_error() {
|
||||
// Build a throwaway lesson whose part declares kind "frob".
|
||||
@@ -59,7 +76,7 @@ name = "broken"
|
||||
[info]
|
||||
title = "broken"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "frob"
|
||||
path = "elements/widget"
|
||||
"#,
|
||||
@@ -123,7 +140,7 @@ name = "broken"
|
||||
[info]
|
||||
title = "broken"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/does-not-exist"
|
||||
"#,
|
||||
@@ -150,7 +167,7 @@ name = "cov"
|
||||
[info]
|
||||
title = "cov"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
@@ -276,7 +293,7 @@ name = "sh"
|
||||
[info]
|
||||
title = "sh"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
@@ -350,7 +367,7 @@ name = "sh"
|
||||
[info]
|
||||
title = "sh"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/missing"
|
||||
|
||||
@@ -390,7 +407,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
|
||||
parts.push('\n');
|
||||
}
|
||||
parts.push_str(&format!(
|
||||
"[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
|
||||
"[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n"
|
||||
));
|
||||
}
|
||||
std::fs::write(
|
||||
@@ -468,8 +485,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() {
|
||||
// Only the first segment has a slides.md; the second is skipped (optional).
|
||||
let tmp = tempdir();
|
||||
let mut parts = String::new();
|
||||
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
|
||||
parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n");
|
||||
parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n");
|
||||
parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n");
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
format!(
|
||||
@@ -527,7 +544,7 @@ name = "md"
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
@@ -584,7 +601,7 @@ name = "md"
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
@@ -630,7 +647,7 @@ name = "md"
|
||||
[info]
|
||||
title = "md"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/missing"
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
cph-check = { path = "../cph-check" }
|
||||
cph-diag = { workspace = true }
|
||||
cph-model = { workspace = true }
|
||||
cph-schema = { path = "../cph-schema" }
|
||||
cph-typst = { path = "../cph-typst" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
serde_json = "1"
|
||||
|
||||
+738
-19
@@ -7,11 +7,14 @@
|
||||
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
|
||||
//! 0); `build` exits 1 when the PDF could not be produced.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use cph_check::CheckReport;
|
||||
use cph_model::OutlineDocument;
|
||||
use cph_typst::Engine;
|
||||
|
||||
/// The `cph` checker for curriculum engineering files.
|
||||
@@ -35,17 +38,56 @@ enum Command {
|
||||
/// Path to the engineering-file root (the folder with `manifest.toml`).
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Build a PDF for a render target. Exits 1 if the build fails.
|
||||
/// Build one or more render targets. Exits 1 if any target fails.
|
||||
///
|
||||
/// With no `--target`, batches every target the lesson declares
|
||||
/// (ADR-0037): each target builds independently — one failing does not
|
||||
/// stop the rest — and the exit code is non-zero if any target failed.
|
||||
/// Repeat `--target` to build an explicit ordered subset instead.
|
||||
Build {
|
||||
/// Path to the engineering-file root (the folder with `manifest.toml`).
|
||||
path: PathBuf,
|
||||
/// Render target to export.
|
||||
#[arg(long, default_value = "student")]
|
||||
target: String,
|
||||
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
|
||||
/// Render target(s) to export. Repeatable. Defaults to every target
|
||||
/// the lesson declares (or `student` if it declares none).
|
||||
#[arg(long = "target")]
|
||||
targets: Vec<String>,
|
||||
/// Output path for a *single*-target build. Defaults to
|
||||
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
|
||||
/// target (ambiguous: which target would it name?).
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Build one or more bundle targets (ADR-0037): combine an ordered
|
||||
/// arrangement of self-contained lessons (`bundle.toml`) into one
|
||||
/// artifact. Same batching/exit-code contract as `build`.
|
||||
Bundle {
|
||||
/// Path to the bundle root (the folder with `bundle.toml`).
|
||||
path: PathBuf,
|
||||
/// Bundle target(s) to export. Repeatable. Defaults to every target
|
||||
/// the bundle declares (or `student` if it declares none).
|
||||
#[arg(long = "target")]
|
||||
targets: Vec<String>,
|
||||
/// Output path for a *single*-target build. Defaults to
|
||||
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
|
||||
/// target.
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Export the teacher-facing outline as Markdown, PDF, or JSON.
|
||||
Outline {
|
||||
/// Path to the engineering-file root. Defaults to the current directory.
|
||||
#[arg(default_value = ".")]
|
||||
path: PathBuf,
|
||||
/// Output format. Defaults to PDF.
|
||||
#[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)]
|
||||
format: OutlineFormat,
|
||||
/// Output path. Defaults to `<PATH>/outline.<format>`.
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
/// Allow replacing an existing output file.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in
|
||||
/// sibling: a local convenience, no lesson involved). Pipe to your shell's
|
||||
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
|
||||
@@ -53,6 +95,49 @@ enum Command {
|
||||
/// Which shell to generate completions for.
|
||||
shell: CompletionTarget,
|
||||
},
|
||||
/// Scaffold a new, check-clean engineering-file root under `path`. Owns the
|
||||
/// `manifest.toml` (with a generated `[project].id`), a pinning
|
||||
/// `.cph-version` (ADR-0016), the stock `exports/student.typ` render
|
||||
/// template, and the empty per-kind part folders. A local authoring
|
||||
/// convenience — no hub semantics involved.
|
||||
Init {
|
||||
/// Directory to create the engineering file in. Created recursively if
|
||||
/// missing; refused if it already holds a `manifest.toml`.
|
||||
path: PathBuf,
|
||||
/// Project name / lesson title. Defaults to the directory's base name.
|
||||
#[arg(long, value_name = "NAME")]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Add a new part to an engineering file: create its folder, its
|
||||
/// element.toml, and the blank required content files, then append a
|
||||
/// [[children]] entry to the root manifest.toml. A local authoring
|
||||
/// convenience, not a hub write.
|
||||
Add {
|
||||
/// Engineering-file root (the folder with `manifest.toml`).
|
||||
#[arg(long, default_value = ".", value_name = "DIR")]
|
||||
root: PathBuf,
|
||||
/// The element kind.
|
||||
kind: String,
|
||||
/// Display name of the new part (also its folder name).
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
enum OutlineFormat {
|
||||
Md,
|
||||
Pdf,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl OutlineFormat {
|
||||
fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Md => "md",
|
||||
Self::Pdf => "pdf",
|
||||
Self::Json => "json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
@@ -67,15 +152,34 @@ enum CompletionTarget {
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let engine = match &cli.render_dir {
|
||||
match cli.command {
|
||||
Command::Check { path } => run_check(&path, &engine_from(&cli.render_dir)),
|
||||
Command::Build { path, targets, out } => {
|
||||
run_build_command(&path, &engine_from(&cli.render_dir), targets, out)
|
||||
}
|
||||
Command::Bundle { path, targets, out } => {
|
||||
run_bundle_command(&path, &engine_from(&cli.render_dir), targets, out)
|
||||
}
|
||||
Command::Outline {
|
||||
path,
|
||||
format,
|
||||
out,
|
||||
force,
|
||||
} => run_outline(&path, &engine_from(&cli.render_dir), format, out, force),
|
||||
Command::Completions { shell } => run_completions(shell),
|
||||
Command::Init { path, name } => run_init(&path, name.as_deref()),
|
||||
Command::Add { root, kind, name } => run_add(&root, &kind, &name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the typst [`Engine`], honoring a `--render-dir` override. Constructed
|
||||
/// lazily — only the render-touching commands (`check`, `build`, `bundle`,
|
||||
/// `outline`) need it; the authoring ones (`init`, `add`, `completions`) skip
|
||||
/// the render-package extraction cost entirely.
|
||||
fn engine_from(render_dir: &Option<std::path::PathBuf>) -> Engine {
|
||||
match render_dir {
|
||||
Some(dir) => Engine::with_render_dir(dir.clone()),
|
||||
None => Engine::new(),
|
||||
};
|
||||
|
||||
match cli.command {
|
||||
Command::Check { path } => run_check(&path, &engine),
|
||||
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
|
||||
Command::Completions { shell } => run_completions(shell),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +202,358 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Authoring subcommands (`init`, `add`): local engineering-file scaffolding.
|
||||
// They never touch the hub and are deliberately local — they only create
|
||||
// dirs/files and append to the local `manifest.toml`. `check` stays
|
||||
// authoritative: whatever these write, `cph check` must accept.
|
||||
|
||||
/// The stock `exports/student.typ` written by `init` — the framework's default
|
||||
/// render template (ADR-0011, outline-shape ADR-0036), the same file the
|
||||
/// examples ship. Kept verbatim so a freshly scaffolded engineering file
|
||||
/// renders out of the box; it imports `@local/cph-render:0.1.0`, which the
|
||||
/// engine resolves from the embedded package. Presentation (heading numbering,
|
||||
/// styling) is editable here per engineering file, not in the manifest.
|
||||
const DEFAULT_STUDENT_TEMPLATE: &str = r##"// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
|
||||
//
|
||||
// This is a *real, editable* file that lives in an engineering file at
|
||||
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
|
||||
// manifest injected:
|
||||
// typst compile --root <eng-root> --input manifest=<path-rel-to-root> exports/student.typ <out>
|
||||
//
|
||||
// It is intentionally self-contained (no shared helper import) so it can be
|
||||
// copied verbatim into a new engineering file's `exports/`. Presentation —
|
||||
// heading numbering — lives HERE (editable per engineering file), not in the
|
||||
// manifest and not hardcoded in the cph-render package.
|
||||
//
|
||||
// WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic
|
||||
// `include` path relative to the file it lexically appears in, and a package has
|
||||
// its own virtual root — an include inside cph-render would resolve against the
|
||||
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
|
||||
// (this template lives under `--root`) resolves against `--root`. So the
|
||||
// template loads content and hands cph-render an already-assembled `outline`
|
||||
// array (elements interleaved with section headings, ADR-0036).
|
||||
//
|
||||
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
|
||||
// exist" primitive (a missing `include` is a hard compile error). So the
|
||||
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
|
||||
// It relies on the manifest declaring which optional content fields are present,
|
||||
// via a per-element `fields` array listing the content fields that exist on disk
|
||||
// (the engine knows this — it walks the part dir). Required fields are loaded
|
||||
// unconditionally; optional fields load only if listed in `fields`. If an element
|
||||
// omits `fields`, optional content is skipped (conservative). The exact shape of
|
||||
// this declaration is for the manifest/Rust contract to pin.
|
||||
|
||||
#import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering
|
||||
|
||||
// This template IS the student build, so the target is fixed.
|
||||
#let target = "student"
|
||||
|
||||
// Read the injected manifest (a path string relative to typst --root).
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-outline = manifest.at("outline", default: ())
|
||||
|
||||
// Assemble each outline entry:
|
||||
// - an "element" entry: include its content fields (computed absolute paths,
|
||||
// resolved against --root) and read scalar fields from <path>/element.toml.
|
||||
// `part-fields` (from cph-render) is the single source of truth for
|
||||
// kind->fields.
|
||||
// - a "section" entry (ADR-0036): pass its title/depth straight through — no
|
||||
// content to load, it is a heading.
|
||||
#let outline = raw-outline.map(raw => {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
let kind = raw.at("kind", default: none)
|
||||
let path = raw.at("path", default: none)
|
||||
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
|
||||
// Which optional content fields are present on disk (manifest-declared).
|
||||
let present = raw.at("fields", default: ())
|
||||
let entry = (entry-type: "element", kind: kind)
|
||||
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
// Scalar fields come from <path>/element.toml.
|
||||
if spec.scalars.len() > 0 {
|
||||
let element = toml("/" + path + "/element.toml")
|
||||
for field in spec.scalars {
|
||||
let v = element.at(field, default: none)
|
||||
if v != none and v != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
entry
|
||||
}
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
// from cph-render; override here per engineering file if desired.
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
outline: outline,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
"##;
|
||||
|
||||
/// The on-disk folder a part of `kind` lives under (a convention shared by the
|
||||
/// examples and the hub, not derivable from the kind schema, so it's pinned
|
||||
/// here alongside the initializer). `None` for unknown kinds — the same set
|
||||
/// `cph_schema::known_kinds()` reports.
|
||||
fn kind_dir(kind: &str) -> Option<&'static str> {
|
||||
match kind {
|
||||
"segment" => Some("segments"),
|
||||
"example" => Some("examples"),
|
||||
"lemma" => Some("lemmas"),
|
||||
"sop" => Some("sops"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `v` as lowercase base-36 for use in a generated project id.
|
||||
fn encode_id(mut v: u64) -> String {
|
||||
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
if v == 0 {
|
||||
return "0".into();
|
||||
}
|
||||
let mut s = String::new();
|
||||
while v > 0 {
|
||||
s.push(ALPHABET[(v % 36) as usize] as char);
|
||||
v /= 36;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Generate a `local-…` project id for a new engineering file (mirrors the
|
||||
/// examples' `local-<a>-<b>` shape). Not security entropy — enough to be unique
|
||||
/// per init, derived from time + pid + a per-process counter.
|
||||
fn new_project_id() -> String {
|
||||
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mix = now.rotate_left(17)
|
||||
^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
|
||||
^ counter.wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
format!("local-{}-{}", encode_id(mix), encode_id(counter))
|
||||
}
|
||||
|
||||
/// Scaffold a new engineering-file root under `path`. Refuses to clobber an
|
||||
/// existing `manifest.toml`, so a repeat run is safe.
|
||||
fn run_init(path: &std::path::Path, name: Option<&str>) -> ExitCode {
|
||||
if path.join("manifest.toml").exists() {
|
||||
eprintln!(
|
||||
"error: '{}' already contains manifest.toml; refusing to init over it",
|
||||
path.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(path) {
|
||||
eprintln!("error: cannot create '{}': {e}", path.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let display_name = match name {
|
||||
Some(n) => n.to_string(),
|
||||
None => path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "untitled".into()),
|
||||
};
|
||||
|
||||
let manifest = format!(
|
||||
r#"[project]
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
|
||||
[info]
|
||||
title = "{name}"
|
||||
|
||||
# Export target (ADR-0009/0011): a typed build. The stock template at
|
||||
# exports/student.typ imports @local/cph-render:0.1.0 (embedded in cph).
|
||||
[targets.student]
|
||||
artifact = {{ type = "single-file", filepath = "build/student.pdf" }}
|
||||
[[targets.student.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/student.typ"
|
||||
"#,
|
||||
id = new_project_id(),
|
||||
name = display_name,
|
||||
);
|
||||
|
||||
let files: &[(&str, &str)] = &[
|
||||
("manifest.toml", &manifest),
|
||||
(".cph-version", &format!("{}\n", env!("CARGO_PKG_VERSION"))),
|
||||
("exports/student.typ", DEFAULT_STUDENT_TEMPLATE),
|
||||
];
|
||||
for (rel, content) in files {
|
||||
let full = path.join(rel);
|
||||
if let Some(parent) = full.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
eprintln!("error: cannot create '{}': {e}", parent.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&full, content) {
|
||||
eprintln!("error: cannot write '{}': {e}", full.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
for dir in ["segments", "lemmas", "examples", "sops"] {
|
||||
if let Err(e) = std::fs::create_dir_all(path.join(dir)) {
|
||||
eprintln!("error: cannot create '{}': {e}", path.join(dir).display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
println!("initialized engineering file at {}", path.display());
|
||||
println!(
|
||||
" next: cph check {} | cph build {} --target student",
|
||||
path.display(),
|
||||
path.display()
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Add a new part to the engineering file at `root`: create its folder with
|
||||
/// `element.toml` + blank required content files, then append its `[[children]]`
|
||||
/// entry to the root `manifest.toml` (ADR-0036 root children). Rejects unknown
|
||||
/// kinds, unsafe names, and anything that would double-register an existing part.
|
||||
fn run_add(root: &std::path::Path, kind: &str, name: &str) -> ExitCode {
|
||||
let dir = match kind_dir(kind) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
eprintln!(
|
||||
"error: unknown kind '{kind}'; expected one of: {}",
|
||||
cph_schema::known_kinds().join(", ")
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.contains('/')
|
||||
|| trimmed.contains('\\')
|
||||
|| trimmed.contains('"')
|
||||
{
|
||||
eprintln!("error: invalid part name {name:?}; use a plain folder name (no / \\ or quotes)");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let rel = format!("{dir}/{trimmed}");
|
||||
let part_dir = root.join(&rel);
|
||||
|
||||
let manifest_path = root.join("manifest.toml");
|
||||
let manifest_src = match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"error: cannot read '{}': {e} (run `cph init` here first?)",
|
||||
manifest_path.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
if part_dir.exists() {
|
||||
eprintln!("error: '{}' already exists", part_dir.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
if manifest_has_child(&manifest_src, &rel) {
|
||||
eprintln!("error: manifest.toml already declares a part at '{rel}'");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&part_dir) {
|
||||
eprintln!("error: cannot create '{}': {e}", part_dir.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let element_toml = format!("kind = \"{kind}\"\n");
|
||||
if let Err(e) = std::fs::write(part_dir.join("element.toml"), element_toml) {
|
||||
eprintln!("error: cannot write element.toml for '{rel}': {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let required = cph_schema::schema_for(kind)
|
||||
.map(|s| s.required_content_field_names())
|
||||
.unwrap_or_default();
|
||||
for field in &required {
|
||||
let f = part_dir.join(format!("{field}.typ"));
|
||||
if let Err(e) = std::fs::write(&f, "") {
|
||||
eprintln!("error: cannot write '{}': {e}", f.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
let updated = insert_child(&manifest_src, kind, &rel);
|
||||
if let Err(e) = std::fs::write(&manifest_path, updated) {
|
||||
eprintln!("error: cannot update '{}': {e}", manifest_path.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
println!("added {kind} '{trimmed}' → {rel} (folder + [[children]] entry)");
|
||||
if required.is_empty() {
|
||||
println!(" note: kind '{kind}' declares no required content fields");
|
||||
} else {
|
||||
println!(" content files created: {}", required.join(", "));
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Whether `manifest` already declares a child whose `path` line equals `rel`.
|
||||
fn manifest_has_child(manifest: &str, rel: &str) -> bool {
|
||||
let needle = format!("path = \"{rel}\"");
|
||||
manifest.lines().any(|l| l.trim() == needle)
|
||||
}
|
||||
|
||||
/// Insert a new `[[children]]` block into `manifest`, keeping the array of
|
||||
/// tables contiguous (a TOML requirement: all elements of `[[children]]` must be
|
||||
/// adjacent). The block goes immediately before the first section header that is
|
||||
/// neither `[project]`/`[info]` nor an existing `[[children]]` entry (i.e. before
|
||||
/// `[targets.*]`), or at end-of-file if none — either way it lands at the tail
|
||||
/// of the root-children run, after `[info]` and any existing children (ADR-0036).
|
||||
/// Comment blocks are preserved.
|
||||
fn insert_child(manifest: &str, kind: &str, rel: &str) -> String {
|
||||
let block = format!("[[children]]\nkind = \"{kind}\"\npath = \"{rel}\"\n");
|
||||
let lines: Vec<&str> = manifest.lines().collect();
|
||||
let insert_at = lines
|
||||
.iter()
|
||||
.position(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with('[')
|
||||
&& !t.starts_with("[[children]]")
|
||||
&& t != "[project]"
|
||||
&& t != "[info]"
|
||||
})
|
||||
.unwrap_or(lines.len());
|
||||
|
||||
let mut out = String::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i == insert_at {
|
||||
out.push_str(&block);
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
if insert_at == lines.len() {
|
||||
out.push_str(&block);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Print every diagnostic in `report` to stderr, followed by a summary line.
|
||||
fn print_diagnostics(report: &CheckReport) {
|
||||
for d in &report.diagnostics {
|
||||
@@ -132,25 +588,205 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_build(
|
||||
fn run_outline(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
format: OutlineFormat,
|
||||
out: Option<PathBuf>,
|
||||
force: bool,
|
||||
) -> ExitCode {
|
||||
let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension())));
|
||||
|
||||
if out_path.exists() && !force {
|
||||
eprintln!(
|
||||
"warning: output '{}' already exists; pass --force to overwrite",
|
||||
out_path.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let (outline, report) = cph_check::outline(path);
|
||||
print_diagnostics(&report);
|
||||
let Some(outline) = outline else {
|
||||
eprintln!("outline failed: fix the lesson before exporting");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
|
||||
let bytes = match format {
|
||||
OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(),
|
||||
OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) {
|
||||
Ok(mut bytes) => {
|
||||
bytes.push(b'\n');
|
||||
bytes
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("outline failed: cannot serialize JSON: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
},
|
||||
OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(diags) => {
|
||||
for diagnostic in &diags {
|
||||
eprintln!("{diagnostic}");
|
||||
}
|
||||
eprintln!("outline failed: PDF compilation failed");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if force && out_path.exists() {
|
||||
eprintln!(
|
||||
"warning: overwriting existing output '{}'",
|
||||
out_path.display()
|
||||
);
|
||||
}
|
||||
if let Err(e) = write_outline_output(&out_path, &bytes, force) {
|
||||
eprintln!("outline failed: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn render_outline_markdown(outline: &OutlineDocument) -> String {
|
||||
let mut body = format!("# {}\n\n", outline.title.trim());
|
||||
if !outline.authors.is_empty() {
|
||||
body.push_str("作者:");
|
||||
body.push_str(&outline.authors.join("、"));
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
for child in &outline.children {
|
||||
append_outline_markdown(&mut body, child, 2);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) {
|
||||
let level = level.min(6);
|
||||
body.push_str(&"#".repeat(level));
|
||||
body.push(' ');
|
||||
body.push_str(&node.title);
|
||||
if !node.kind.is_empty() {
|
||||
body.push_str(" `[");
|
||||
body.push_str(&node.kind);
|
||||
body.push_str("]`");
|
||||
}
|
||||
body.push_str("\n\n");
|
||||
|
||||
if let Some(notes) = node.notes.as_deref() {
|
||||
body.push_str("> 教学提示:\n");
|
||||
for line in notes.lines() {
|
||||
body.push_str("> ");
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
for child in &node.children {
|
||||
append_outline_markdown(body, child, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> {
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
if force {
|
||||
std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display()))
|
||||
} else {
|
||||
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(format!(
|
||||
"output '{}' already exists; pass --force to overwrite",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(format!("cannot create '{}': {e}", path.display())),
|
||||
};
|
||||
file.write_all(bytes)
|
||||
.map_err(|e| format!("cannot write '{}': {e}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch `cph build` (ADR-0037): with an explicit `--target` (repeatable),
|
||||
/// build exactly that ordered set; with none, batch every target the lesson
|
||||
/// declares. Each target builds **independently** — one failing does not stop
|
||||
/// the rest — and prints a per-target ledger when building more than one.
|
||||
/// Exits non-zero iff **any** target failed to produce its artifact (a build
|
||||
/// failure is a real defect, distinct from the non-blocking `renderIgnored`
|
||||
/// warning class — ADR-0037).
|
||||
fn run_build_command(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
targets: Vec<String>,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
let target_list = if targets.is_empty() {
|
||||
cph_check::declared_target_names(path)
|
||||
} else {
|
||||
targets
|
||||
};
|
||||
|
||||
if target_list.len() > 1 && out.is_some() {
|
||||
eprintln!(
|
||||
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
|
||||
for target in &target_list {
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("=== target '{target}' ===");
|
||||
}
|
||||
let ok = run_build_one(path, engine, target, out.clone());
|
||||
results.push((target.clone(), ok));
|
||||
}
|
||||
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("--- build summary ---");
|
||||
for (target, ok) in &results {
|
||||
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
if results.iter().any(|(_, ok)| !ok) {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one target, returning whether it succeeded. Routes to the shell,
|
||||
/// markdown-assemble, or typst-compile path per the target's step shape.
|
||||
fn run_build_one(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
target: &str,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
) -> bool {
|
||||
// A target whose steps are shell commands (a tool-generated asset bundle,
|
||||
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
|
||||
// executing those commands, not by compiling a typst template. Detect that
|
||||
// shape up front and route accordingly.
|
||||
if cph_check::target_is_shell(path, target) {
|
||||
return run_shell_build(path, engine, target);
|
||||
return run_shell_build(path, engine, target) == ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿
|
||||
// transcript surfaces) is built by concatenating per-element `<field>.md`
|
||||
// files in parts order, not by compiling a typst template.
|
||||
if cph_check::target_is_markdown_assemble(path, target) {
|
||||
return run_markdown_assemble_build(path, engine, target);
|
||||
return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
|
||||
@@ -166,19 +802,102 @@ fn run_build(
|
||||
"error: cannot create output directory '{}': {e}",
|
||||
parent.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out_path, &bytes) {
|
||||
eprintln!("error: cannot write '{}': {e}", out_path.display());
|
||||
return ExitCode::FAILURE;
|
||||
return false;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
ExitCode::SUCCESS
|
||||
true
|
||||
}
|
||||
None => {
|
||||
eprintln!("build failed: {} errors", report.error_count());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch `cph bundle` (ADR-0037) — same batching/exit-code contract as
|
||||
/// [`run_build_command`], over a bundle's own declared targets. MVP bundle
|
||||
/// targets are `typst-compile` only (no shell/markdown-assemble routing —
|
||||
/// ADR-0037 did not extend those step kinds to bundles).
|
||||
fn run_bundle_command(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
targets: Vec<String>,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
let target_list = if targets.is_empty() {
|
||||
cph_check::declared_bundle_target_names(path)
|
||||
} else {
|
||||
targets
|
||||
};
|
||||
|
||||
if target_list.len() > 1 && out.is_some() {
|
||||
eprintln!(
|
||||
"error: -o/--out only applies to a single-target build; pass exactly one --target with -o"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len());
|
||||
for target in &target_list {
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("=== target '{target}' ===");
|
||||
}
|
||||
let ok = run_bundle_one(path, engine, target, out.clone());
|
||||
results.push((target.clone(), ok));
|
||||
}
|
||||
|
||||
if target_list.len() > 1 {
|
||||
eprintln!("--- build summary ---");
|
||||
for (target, ok) in &results {
|
||||
eprintln!("{target}: {}", if *ok { "ok" } else { "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
if results.iter().any(|(_, ok)| !ok) {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one bundle target, returning whether it succeeded.
|
||||
fn run_bundle_one(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
target: &str,
|
||||
out: Option<PathBuf>,
|
||||
) -> bool {
|
||||
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
|
||||
|
||||
let (pdf, report) = cph_check::build_bundle(path, engine, target);
|
||||
print_diagnostics(&report);
|
||||
|
||||
match pdf {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
eprintln!(
|
||||
"error: cannot create output directory '{}': {e}",
|
||||
parent.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out_path, &bytes) {
|
||||
eprintln!("error: cannot write '{}': {e}", out_path.display());
|
||||
return false;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
true
|
||||
}
|
||||
None => {
|
||||
eprintln!("build failed: {} errors", report.error_count());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-16
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Every other crate in the workspace depends on these types to report
|
||||
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
|
||||
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
|
||||
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
|
||||
//! optional [`SourceSpan`] pointing back at the offending source, and a
|
||||
//! [`Diagnostic`] tying them together with a human message and a fix hint.
|
||||
//!
|
||||
@@ -17,32 +17,27 @@ use serde::Serialize;
|
||||
|
||||
/// Severity of a diagnostic.
|
||||
///
|
||||
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
|
||||
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
|
||||
/// exactly:
|
||||
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
|
||||
///
|
||||
/// ```text
|
||||
/// inductive Severity where
|
||||
/// | warning
|
||||
/// | error
|
||||
/// warning | error
|
||||
/// ```
|
||||
///
|
||||
/// This two-valued shape is a **contract decision**, not an accident: the Lean
|
||||
/// module pins `Severity` to exactly `warning | error` and states the finer
|
||||
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
|
||||
/// This two-valued shape is a **contract decision**, not an accident: the
|
||||
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
|
||||
/// do **not** add an info/note level here. `error` blocks (the artifact is
|
||||
/// invalid); `warning` does not block (the artifact still exports, but with
|
||||
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
|
||||
///
|
||||
/// There is no CI gate enforcing this alignment (see the repo constitution);
|
||||
/// it is maintained by review, which is why this correspondence is documented
|
||||
/// here rather than only in the spec.
|
||||
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
|
||||
/// constitution); it is maintained by review, which is why the decision is
|
||||
/// documented here rather than only in the ADR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Severity {
|
||||
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
|
||||
/// element. Mirrors Lean `Severity.warning`.
|
||||
/// element. ADR-0010 `warning`.
|
||||
Warning,
|
||||
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
|
||||
/// Blocking: the artifact is invalid. ADR-0010 `error`.
|
||||
Error,
|
||||
}
|
||||
|
||||
@@ -70,7 +65,8 @@ pub struct SourceSpan {
|
||||
/// Do not invent codes outside this enum without a deliberate decision.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum DiagCode {
|
||||
/// A `[[parts]]` entry references a folder/path that does not exist.
|
||||
/// A `[[parts]]`/outline entry references a folder/path that does not
|
||||
/// exist.
|
||||
PartPathMissing,
|
||||
/// An element declares a `kind` that is not a known kind.
|
||||
UnknownKind,
|
||||
@@ -91,6 +87,15 @@ pub enum DiagCode {
|
||||
/// The engineering file's `.cph-version` is not compatible with the running
|
||||
/// CLI's version (ADR-0016). Decided at load time; `error` severity.
|
||||
CphVersionMismatch,
|
||||
/// A `manifest.toml`/`bundle.toml` is structurally broken: invalid TOML, a
|
||||
/// required table missing (root `[project]`/`[info]`), a folder that is
|
||||
/// neither a container (`manifest.toml`) nor a leaf (`element.toml`) — or
|
||||
/// is ambiguously both (ADR-0036) — or a bundle `lessons` entry malformed
|
||||
/// (ADR-0037). Distinct from `SchemaViolation` (instance data vs. its
|
||||
/// kind's schema): this code is for the *carrier document's own*
|
||||
/// structure. Added to discharge the manifest-level errors that used to
|
||||
/// overload `SchemaViolation` before this code existed.
|
||||
ManifestMalformed,
|
||||
}
|
||||
|
||||
impl DiagCode {
|
||||
@@ -107,6 +112,7 @@ impl DiagCode {
|
||||
DiagCode::TypstCompile => "E-TYPST-COMPILE",
|
||||
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
|
||||
DiagCode::CphVersionMismatch => "E-CPH-VERSION",
|
||||
DiagCode::ManifestMalformed => "E-MANIFEST",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+817
-159
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
//! Integration tests for `cph_model::load_bundle` (ADR-0037): an ordered
|
||||
//! arrangement of self-contained lessons, loaded from `bundle.toml`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::load_bundle;
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_bundle_loads_lessons_in_order_with_overrides() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-valid"));
|
||||
let bundle = bundle.expect("valid bundle fixture must produce a Bundle");
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"valid bundle fixture must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
assert_eq!(bundle.info.title, "测试合集");
|
||||
assert_eq!(
|
||||
bundle.info.authors,
|
||||
vec!["张老师".to_string(), "李老师".to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(bundle.lessons.len(), 2);
|
||||
|
||||
// lesson-a: no explicit `target` in bundle.toml -> falls back to the
|
||||
// lesson's own first declared target ("student").
|
||||
assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a"));
|
||||
assert_eq!(bundle.lessons[0].target, "student");
|
||||
assert_eq!(bundle.lessons[0].lesson.info.title, "课时A");
|
||||
|
||||
// lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the
|
||||
// lesson's own single declared target (also "teacher" here, but the point
|
||||
// is the bundle entry's `target` wins regardless).
|
||||
assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b"));
|
||||
assert_eq!(bundle.lessons[1].target, "teacher");
|
||||
assert_eq!(bundle.lessons[1].lesson.info.title, "课时B");
|
||||
|
||||
// The bundle's own targets are collected exactly like a lesson's.
|
||||
assert_eq!(bundle.target_names(), vec!["merged"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson"));
|
||||
let bundle = bundle.expect("must still produce a best-effort Bundle");
|
||||
assert!(
|
||||
bundle.lessons.is_empty(),
|
||||
"the missing lesson is skipped, not placeholder'd"
|
||||
);
|
||||
|
||||
let missing: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::PartPathMissing)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing.len(),
|
||||
1,
|
||||
"exactly one PartPathMissing expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bundle_toml_is_a_hard_failure() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-malformed"));
|
||||
assert!(bundle.is_none(), "malformed bundle.toml is a hard failure");
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bundle_toml_is_a_hard_failure() {
|
||||
let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all"));
|
||||
assert!(bundle.is_none());
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-both"
|
||||
name = "both"
|
||||
|
||||
[info]
|
||||
title = "文件夹既是容器又是叶子"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/broken"
|
||||
|
||||
[targets.student]
|
||||
+1
@@ -0,0 +1 @@
|
||||
[[children]]
|
||||
@@ -0,0 +1,2 @@
|
||||
[info
|
||||
this is broken
|
||||
@@ -0,0 +1,5 @@
|
||||
[info]
|
||||
title = "缺失课时的合集"
|
||||
|
||||
[[lessons]]
|
||||
path = "does-not-exist"
|
||||
@@ -0,0 +1,16 @@
|
||||
[info]
|
||||
title = "测试合集"
|
||||
author = ["张老师", "李老师"]
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-a"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-b"
|
||||
target = "teacher"
|
||||
|
||||
[targets.merged]
|
||||
artifact = { type = "single-file", filepath = "build/merged.pdf" }
|
||||
[[targets.merged.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/merged.typ"
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-a"
|
||||
name = "lesson-a"
|
||||
|
||||
[info]
|
||||
title = "课时A"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
A.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-b"
|
||||
name = "lesson-b"
|
||||
|
||||
[info]
|
||||
title = "课时B"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/b"
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
B.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-container-root-tables"
|
||||
name = "container-root-tables"
|
||||
|
||||
[info]
|
||||
title = "容器错误声明了根级表"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "section"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1,5 @@
|
||||
[project]
|
||||
id = "should-not-be-here"
|
||||
name = "should-not-be-here"
|
||||
|
||||
children = []
|
||||
@@ -5,7 +5,7 @@ name = "kind-mismatch"
|
||||
[info]
|
||||
title = "kind 不一致测试"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ name = "missing-part"
|
||||
[info]
|
||||
title = "缺部件测试"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/does-not-exist"
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-neither"
|
||||
name = "neither"
|
||||
|
||||
[info]
|
||||
title = "文件夹既不是容器也不是叶子"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/broken"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1,21 @@
|
||||
[project]
|
||||
id = "fixture-nested"
|
||||
name = "nested"
|
||||
|
||||
[info]
|
||||
title = "嵌套结构测试"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/开场白"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "导言簇"
|
||||
notes = "这里先建立直观图像,再进入分组推导。"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "收束簇"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
开场白。
|
||||
@@ -0,0 +1,14 @@
|
||||
[group]
|
||||
title = "导言簇"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/子段一"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "嵌套子节"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/子段二"
|
||||
@@ -0,0 +1 @@
|
||||
子段一。
|
||||
@@ -0,0 +1 @@
|
||||
子段二。
|
||||
@@ -0,0 +1 @@
|
||||
子引理陈述。
|
||||
@@ -0,0 +1,3 @@
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/子引理"
|
||||
@@ -0,0 +1,6 @@
|
||||
[group]
|
||||
title = "收束簇"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/总结"
|
||||
@@ -0,0 +1 @@
|
||||
总结
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
id = "fixture-root-group"
|
||||
name = "root-group"
|
||||
|
||||
[info]
|
||||
title = "根级 manifest 错误声明了 group"
|
||||
|
||||
[group]
|
||||
title = "不该在根级"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
a.
|
||||
+3
-2
@@ -6,11 +6,12 @@ name = "valid-2-part"
|
||||
title = "测试课:两个部件"
|
||||
author = "范式教育教研组"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
notes = "这一节补充一个直观例题"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/young"
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Integration tests for `cph_model::load`, driven by static fixtures under
|
||||
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008
|
||||
//! on-disk format.
|
||||
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0036
|
||||
//! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
|
||||
//! `[[parts]]`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::load;
|
||||
use cph_model::{load, OutlineEntry};
|
||||
|
||||
/// Absolute path to a fixture engineering-file root.
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
@@ -34,11 +35,38 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
assert_eq!(lesson.parts.len(), 2);
|
||||
assert_eq!(lesson.parts[0].kind, "segment");
|
||||
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
|
||||
assert_eq!(
|
||||
lesson.parts[0].notes.as_deref(),
|
||||
Some("这一节补充一个直观例题")
|
||||
);
|
||||
let outline = lesson.outline_document();
|
||||
assert_eq!(outline.children[0].title, "intro");
|
||||
assert_eq!(
|
||||
outline.children[0].notes.as_deref(),
|
||||
Some("这一节补充一个直观例题")
|
||||
);
|
||||
assert!(outline.children[0].children.is_empty());
|
||||
assert_eq!(lesson.parts[0].descriptor.kind, "segment");
|
||||
assert_eq!(lesson.parts[1].kind, "lemma");
|
||||
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
|
||||
assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
|
||||
|
||||
// The outline is a flat sequence of elements-by-index when there are no
|
||||
// containers.
|
||||
assert_eq!(
|
||||
lesson.outline,
|
||||
vec![
|
||||
OutlineEntry::Element {
|
||||
part_index: 0,
|
||||
depth: 0,
|
||||
},
|
||||
OutlineEntry::Element {
|
||||
part_index: 1,
|
||||
depth: 0,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// `source` scalar survives on the lemma descriptor; `kind` is removed.
|
||||
let scalars = &lesson.parts[1].descriptor.scalars;
|
||||
assert_eq!(
|
||||
@@ -74,6 +102,90 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_sections_flatten_depth_first_with_correct_depths() {
|
||||
let (lesson, diags) = load(&fixture("nested"));
|
||||
let lesson = lesson.expect("nested fixture must produce a Lesson");
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"nested fixture must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
// DFS pre-order element sequence (ADR-0036): containers contribute no
|
||||
// element of their own.
|
||||
let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
PathBuf::from("segments/开场白"),
|
||||
PathBuf::from("导言簇/segments/子段一"),
|
||||
PathBuf::from("导言簇/嵌套子节/lemmas/子引理"),
|
||||
PathBuf::from("导言簇/segments/子段二"),
|
||||
PathBuf::from("收束簇/segments/总结"),
|
||||
],
|
||||
"root-relative paths must accumulate through every nesting level"
|
||||
);
|
||||
|
||||
// The outline interleaves section headings at their DFS-open position,
|
||||
// with depth 1 for a section directly under the root and depth 2 for one
|
||||
// nested inside another section.
|
||||
assert_eq!(
|
||||
lesson.outline,
|
||||
vec![
|
||||
OutlineEntry::Element {
|
||||
part_index: 0,
|
||||
depth: 0,
|
||||
}, // segments/开场白
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "导言簇".to_string(),
|
||||
depth: 1,
|
||||
notes: Some("这里先建立直观图像,再进入分组推导。".to_string()),
|
||||
path: PathBuf::from("导言簇"),
|
||||
},
|
||||
OutlineEntry::Element {
|
||||
part_index: 1,
|
||||
depth: 1,
|
||||
}, // 导言簇/segments/子段一
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "嵌套子节".to_string(),
|
||||
depth: 2,
|
||||
notes: None,
|
||||
path: PathBuf::from("导言簇/嵌套子节"),
|
||||
},
|
||||
OutlineEntry::Element {
|
||||
part_index: 2,
|
||||
depth: 2,
|
||||
}, // 导言簇/嵌套子节/lemmas/子引理
|
||||
OutlineEntry::Element {
|
||||
part_index: 3,
|
||||
depth: 1,
|
||||
}, // 导言簇/segments/子段二
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "收束簇".to_string(),
|
||||
depth: 1,
|
||||
notes: None,
|
||||
path: PathBuf::from("收束簇"),
|
||||
},
|
||||
OutlineEntry::Element {
|
||||
part_index: 4,
|
||||
depth: 1,
|
||||
}, // 收束簇/segments/总结
|
||||
]
|
||||
);
|
||||
let document = lesson.outline_document();
|
||||
assert_eq!(document.children.len(), 3);
|
||||
assert_eq!(document.children[1].title, "导言簇");
|
||||
assert_eq!(document.children[1].children.len(), 3);
|
||||
assert_eq!(document.children[2].title, "收束簇");
|
||||
assert_eq!(document.children[2].children[0].title, "总结");
|
||||
|
||||
// The outer section declares [group].title = "导言簇"; the inner section
|
||||
// has no [group] at all, so its title falls back to the folder basename.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_part_folder_yields_part_path_missing() {
|
||||
let (lesson, diags) = load(&fixture("missing-part"));
|
||||
@@ -126,7 +238,7 @@ fn malformed_manifest_is_a_hard_failure() {
|
||||
"malformed manifest must be a hard failure (None)"
|
||||
);
|
||||
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
|
||||
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
assert_eq!(diags[0].severity, cph_diag::Severity::Error);
|
||||
}
|
||||
|
||||
@@ -136,7 +248,98 @@ fn missing_manifest_is_a_hard_failure() {
|
||||
let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
|
||||
assert!(lesson.is_none());
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_with_both_manifest_and_element_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("both-manifest-and-element"));
|
||||
let lesson = lesson.expect("must still produce a best-effort Lesson");
|
||||
assert_eq!(
|
||||
lesson.parts.len(),
|
||||
1,
|
||||
"the ambiguous child is a placeholder"
|
||||
);
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one ManifestMalformed expected, got: {diags:?}"
|
||||
);
|
||||
assert!(
|
||||
malformed[0]
|
||||
.message
|
||||
.contains("both manifest.toml and element.toml"),
|
||||
"message should explain the ambiguity, got: {}",
|
||||
malformed[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_with_neither_manifest_nor_element_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("neither-manifest-nor-element"));
|
||||
let lesson = lesson.expect("must still produce a best-effort Lesson");
|
||||
assert_eq!(
|
||||
lesson.parts.len(),
|
||||
1,
|
||||
"the incomplete child is a placeholder"
|
||||
);
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one ManifestMalformed expected, got: {diags:?}"
|
||||
);
|
||||
assert!(
|
||||
malformed[0]
|
||||
.message
|
||||
.contains("neither manifest.toml nor element.toml"),
|
||||
"message should explain the gap, got: {}",
|
||||
malformed[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_declaring_root_only_tables_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("container-root-tables"));
|
||||
assert!(
|
||||
lesson.is_some(),
|
||||
"a container misplacing root tables is non-fatal"
|
||||
);
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one root-only-table diagnostic expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_manifest_declaring_group_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("root-group-declared"));
|
||||
assert!(lesson.is_some(), "the root declaring [group] is non-fatal");
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one root-[group] diagnostic expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -290,7 +493,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
|
||||
let p = tmp.path();
|
||||
std::fs::write(
|
||||
p.join("manifest.toml"),
|
||||
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
|
||||
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let seg = p.join("segments").join("a");
|
||||
|
||||
@@ -155,6 +155,19 @@ impl KindSchema {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The names of the **required** content fields (those in the schema's
|
||||
/// `required` list), in schema order. The `cph-cli add` authoring surface
|
||||
/// uses this to scaffold the sibling `<field>.typ` files a new part must
|
||||
/// have (ADR-0008): an optional content field (e.g. a lemma's `proof`) is
|
||||
/// not created, so a freshly added part stays schema-legal.
|
||||
pub fn required_content_field_names(&self) -> Vec<&str> {
|
||||
self.content_fields
|
||||
.iter()
|
||||
.filter(|f| f.required)
|
||||
.map(|f| f.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The names of the scalar fields (those living in `element.toml`), in
|
||||
/// schema order.
|
||||
pub fn scalar_field_names(&self) -> Vec<&str> {
|
||||
|
||||
@@ -74,3 +74,25 @@ fn example_source_non_string_is_schema_violation() {
|
||||
assert!(diags[0].message.contains("source"));
|
||||
assert!(diags[0].message.contains("string"));
|
||||
}
|
||||
|
||||
/// `required_content_field_names` is the `cph-cli add` authoring contract: the
|
||||
/// set of sibling `.typ` files a new part of a kind must have to be
|
||||
/// schema-legal (ADR-0008). It must be the schema `required` content fields —
|
||||
/// not the optional ones (e.g. a lemma's `proof`) and not the scalar fields.
|
||||
#[test]
|
||||
fn required_content_field_names_match_schema_required() {
|
||||
let case = |kind: &str, expected: Vec<&str>| {
|
||||
let mut got: Vec<&str> = cph_schema::schema_for(kind)
|
||||
.unwrap()
|
||||
.required_content_field_names();
|
||||
got.sort_unstable();
|
||||
let mut want = expected;
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "required content fields for '{kind}'");
|
||||
};
|
||||
|
||||
case("segment", vec!["textbook"]);
|
||||
case("lemma", vec!["stmt"]);
|
||||
case("example", vec!["problem", "solution"]);
|
||||
case("sop", vec!["sop"]);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,10 @@ static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
|
||||
/// extracting the embedded copy to a per-user cache dir if needed.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
|
||||
/// at the live repo `render/`).
|
||||
/// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the
|
||||
/// live repo `render/`).
|
||||
/// 2. The extracted embedded copy under the user cache dir
|
||||
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
|
||||
/// subsequent runs reuse it.
|
||||
/// (`<cache>/cph/render-<version>/`).
|
||||
///
|
||||
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
|
||||
/// location so the engine still works (just re-extracting per process).
|
||||
@@ -48,31 +47,47 @@ pub fn resolve_render_dir() -> PathBuf {
|
||||
ensure_extracted().unwrap_or_else(|_| {
|
||||
// Last-resort: extract under the OS temp dir. Still correct, just not
|
||||
// cached across processes.
|
||||
let fallback =
|
||||
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
|
||||
let fallback = std::env::temp_dir().join(format!(
|
||||
"cph-render-{}-{}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
RENDER_CACHE_REVISION
|
||||
));
|
||||
let _ = extract_to(&fallback);
|
||||
fallback
|
||||
})
|
||||
}
|
||||
|
||||
/// The version-keyed cache location and a guarantee the embedded tree is present
|
||||
/// there. Returns the directory the World should use.
|
||||
/// Bump when the embedded render package changes without a cph crate-version
|
||||
/// bump. Otherwise a user's old per-version cache can miss newly added package
|
||||
/// functions (such as `render-outline`).
|
||||
const RENDER_CACHE_REVISION: &str = "outline-v2";
|
||||
|
||||
/// The version/revision-keyed cache location and a guarantee the embedded tree
|
||||
/// is present there. Returns the directory the World should use.
|
||||
fn ensure_extracted() -> std::io::Result<PathBuf> {
|
||||
let base = dirs::cache_dir()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
|
||||
let dest = base
|
||||
.join("cph")
|
||||
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
|
||||
// version, so a new `cph` version re-extracts into a fresh dir.)
|
||||
let sentinel = dest.join(".extracted");
|
||||
if sentinel.is_file() {
|
||||
let expected = format!("{}:{}", env!("CARGO_PKG_VERSION"), RENDER_CACHE_REVISION);
|
||||
|
||||
if std::fs::read_to_string(&sentinel)
|
||||
.map(|contents| contents.trim_end() == expected)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(dest);
|
||||
}
|
||||
|
||||
// The crate version can stay stable while the embedded render package
|
||||
// evolves. Remove the old tree before extracting so deleted files do not
|
||||
// survive a revision refresh.
|
||||
if dest.exists() {
|
||||
std::fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
extract_to(&dest)?;
|
||||
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
|
||||
std::fs::write(&sentinel, expected)?;
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
|
||||
+99
-14
@@ -39,15 +39,15 @@ mod embedded;
|
||||
mod manifest;
|
||||
mod world;
|
||||
|
||||
use cph_diag::{DiagCode, Diagnostic};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::{DiagCode, Diagnostic};
|
||||
use cph_model::{Artifact, Lesson, Step, TargetConfig};
|
||||
use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig};
|
||||
use typst_kit::fonts::{self, FontStore};
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::PdfOptions;
|
||||
|
||||
pub use manifest::build_augmented_manifest;
|
||||
pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest};
|
||||
pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH};
|
||||
|
||||
/// The compile/PDF engine: holds the shared font store and the on-disk location
|
||||
@@ -139,10 +139,38 @@ impl Engine {
|
||||
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
|
||||
}
|
||||
|
||||
/// Build a PDF for an outline document without creating files in the lesson.
|
||||
///
|
||||
/// The outline entrypoint and its TOML data are served by an in-memory
|
||||
/// [`LessonWorld`]. This keeps outline generation independent of any
|
||||
/// declared lesson export target while reusing the embedded fonts and PDF
|
||||
/// backend.
|
||||
pub fn build_outline_pdf(&self, outline: &OutlineDocument) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
const SOURCE: &str = r#"#import "@local/cph-render:0.1.0": render-outline
|
||||
#let outline = toml(sys.inputs.outline)
|
||||
#render-outline(outline)
|
||||
"#;
|
||||
|
||||
let outline_src = toml::to_string(outline).expect("outline serializes to TOML");
|
||||
let world = LessonWorld::new_outline(
|
||||
PathBuf::from("."),
|
||||
self.render_dir.clone(),
|
||||
SOURCE.to_owned(),
|
||||
outline_src,
|
||||
self.fonts.clone(),
|
||||
);
|
||||
let warned = typst::compile::<PagedDocument>(&world);
|
||||
let doc = match warned.output {
|
||||
Ok(doc) => doc,
|
||||
Err(errors) => return Err(map_all(&world, &errors)),
|
||||
};
|
||||
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
|
||||
}
|
||||
|
||||
/// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
|
||||
/// the request cannot be honored (see [`target_precheck`]).
|
||||
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
|
||||
let template = target_precheck(lesson, target)?;
|
||||
let template = target_precheck(target, &lesson.targets)?;
|
||||
let manifest_src = build_augmented_manifest(lesson);
|
||||
Ok(LessonWorld::new(
|
||||
lesson.root.clone(),
|
||||
@@ -152,6 +180,60 @@ impl Engine {
|
||||
self.fonts.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Compile-check `bundle` for `target` (ADR-0037) — same contract as
|
||||
/// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets
|
||||
/// and the augmented **bundle** manifest (each member lesson's outline,
|
||||
/// path-prefixed to resolve against the bundle root).
|
||||
pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec<Diagnostic> {
|
||||
let world = match self.world_for_bundle(bundle, target) {
|
||||
Ok(world) => world,
|
||||
Err(blocking) => return blocking,
|
||||
};
|
||||
let warned = typst::compile::<PagedDocument>(&world);
|
||||
|
||||
let mut out = Vec::new();
|
||||
if let Err(errors) = &warned.output {
|
||||
out.extend(map_all(&world, errors));
|
||||
}
|
||||
out.extend(map_all(&world, &warned.warnings));
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a PDF for `bundle` / `target` (ADR-0037) — same contract as
|
||||
/// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets.
|
||||
pub fn build_bundle_pdf(
|
||||
&self,
|
||||
bundle: &Bundle,
|
||||
target: &str,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let world = self.world_for_bundle(bundle, target)?;
|
||||
let warned = typst::compile::<PagedDocument>(&world);
|
||||
let doc = match warned.output {
|
||||
Ok(doc) => doc,
|
||||
Err(errors) => return Err(map_all(&world, &errors)),
|
||||
};
|
||||
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
|
||||
}
|
||||
|
||||
/// Build the [`LessonWorld`] for `(bundle, target)`: same shape as
|
||||
/// [`Engine::world_for`], main file resolved under the **bundle** root and
|
||||
/// the injected manifest built by [`build_augmented_bundle_manifest`].
|
||||
fn world_for_bundle(
|
||||
&self,
|
||||
bundle: &Bundle,
|
||||
target: &str,
|
||||
) -> Result<LessonWorld, Vec<Diagnostic>> {
|
||||
let template = target_precheck(target, &bundle.targets)?;
|
||||
let manifest_src = build_augmented_bundle_manifest(bundle);
|
||||
Ok(LessonWorld::new(
|
||||
bundle.root.clone(),
|
||||
self.render_dir.clone(),
|
||||
&template,
|
||||
manifest_src,
|
||||
self.fonts.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Engine {
|
||||
@@ -160,13 +242,16 @@ impl Default for Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a `(lesson, target)` request and resolve the template path to
|
||||
/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the
|
||||
/// request is buildable, or `Err(blocking_diagnostics)` when it is not:
|
||||
/// Validate a `(targets, target)` request and resolve the template path to
|
||||
/// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and
|
||||
/// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0037 gives a
|
||||
/// bundle target the exact same build/artifact/step shape). Returns
|
||||
/// `Ok(template_path)` (relative to the lesson/bundle root) when the request is
|
||||
/// buildable, or `Err(blocking_diagnostics)` when it is not:
|
||||
///
|
||||
/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the
|
||||
/// lesson declares at least one target): a `SchemaViolation` error — a target
|
||||
/// must be declared in the manifest to be built (ADR-0009).
|
||||
/// - **Unknown target** (the `--target` name isn't in `targets`, and `targets`
|
||||
/// is non-empty): a `SchemaViolation` error — a target must be declared in
|
||||
/// the manifest to be built (ADR-0009).
|
||||
/// - **No declared targets at all**: not an error — callers (e.g. `cph-check`)
|
||||
/// may compile-check a defaulted `"student"` target the lesson never declared.
|
||||
/// The stock template path `exports/<target>.typ` is used (the framework
|
||||
@@ -177,10 +262,10 @@ impl Default for Engine {
|
||||
/// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation`
|
||||
/// rather than wrong output. The template is taken from the **first**
|
||||
/// `TypstCompile` step (MVP: one step per target).
|
||||
fn target_precheck(lesson: &Lesson, target: &str) -> Result<PathBuf, Vec<Diagnostic>> {
|
||||
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
|
||||
if lesson.targets.is_empty() {
|
||||
// Lesson declares no targets; the orchestrator compiles a defaulted
|
||||
fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result<PathBuf, Vec<Diagnostic>> {
|
||||
let Some(tc) = targets.iter().find(|t| t.name == target) else {
|
||||
if targets.is_empty() {
|
||||
// Declares no targets; the orchestrator compiles a defaulted
|
||||
// target. Use the stock template path (matches cph-model's default).
|
||||
return Ok(PathBuf::from(format!("exports/{target}.typ")));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
//! Augmented-manifest construction (ADR-0011).
|
||||
//! Augmented-manifest construction (ADR-0011, outline shape per ADR-0036).
|
||||
//!
|
||||
//! The template (`exports/<target>.typ`) reads the manifest via
|
||||
//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields
|
||||
//! by a **computed** path and reads scalar fields from `<path>/element.toml`.
|
||||
//! For *optional* content fields the template must know whether the file exists
|
||||
//! on disk — typst has no file-exists primitive and a missing `include` is a
|
||||
//! hard error (see the OPEN contract point in `render/templates/student.typ`).
|
||||
//! `toml(sys.inputs.manifest)`, then for each **element** outline entry
|
||||
//! `include`s its content fields by a **computed** path and reads scalar
|
||||
//! fields from `<path>/element.toml`. For *optional* content fields the
|
||||
//! template must know whether the file exists on disk — typst has no
|
||||
//! file-exists primitive and a missing `include` is a hard error (see the OPEN
|
||||
//! contract point in `render/templates/student.typ`).
|
||||
//!
|
||||
//! The ENGINE has filesystem access, so it closes that gap: it builds an
|
||||
//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a
|
||||
//! per-part **`fields` array** listing the content fields whose `<field>.typ`
|
||||
//! actually exists under the lesson root. The augmented manifest is served as an
|
||||
//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never**
|
||||
//! written to the user's tree), and injected via `sys.inputs.manifest`.
|
||||
//! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]`
|
||||
//! (ADR-0036's depth-first rendering order — elements interleaved with section
|
||||
//! headings at their DFS-open position). Each `[[outline]]` entry carries a
|
||||
//! `type` discriminator (`"element"` | `"section"`):
|
||||
//!
|
||||
//! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array**
|
||||
//! listing the content fields whose `<field>.typ` actually exists under the
|
||||
//! lesson root (same contract as before ADR-0036).
|
||||
//! - `type = "section"`: `kind`, `title`, `depth`, `path` — a section heading;
|
||||
//! the template renders it without touching any content file.
|
||||
//!
|
||||
//! The augmented manifest is served as an in-memory virtual file in the
|
||||
//! [`crate::world::LessonWorld`] (it is **never** written to the user's tree),
|
||||
//! and injected via `sys.inputs.manifest`.
|
||||
//!
|
||||
//! ## `fields` is computed from `cph-schema`
|
||||
//!
|
||||
@@ -20,23 +30,104 @@
|
||||
//! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the
|
||||
//! render package exposes as `part-fields`. We reuse it here rather than
|
||||
//! re-deriving a kind→fields map, so the engine and the template agree on what a
|
||||
//! kind's content fields are. For each part, a content field is listed in
|
||||
//! kind's content fields are. For each element, a content field is listed in
|
||||
//! `fields` iff `<root>/<part.path>/<field>.typ` is a real file.
|
||||
|
||||
use cph_model::Lesson;
|
||||
use std::path::Path;
|
||||
|
||||
use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry};
|
||||
|
||||
/// Build the augmented-manifest TOML source for `lesson`.
|
||||
///
|
||||
/// The result is a self-contained TOML document the template's
|
||||
/// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional
|
||||
/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a
|
||||
/// `fields = [...]` array of the content fields present on disk (per
|
||||
/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]`
|
||||
/// — the template only consumes `info` and `parts`.
|
||||
/// author) and the ordered `[[outline]]` (ADR-0036's depth-first rendering
|
||||
/// order), each entry typed `"element"` or `"section"` per the module docs. It
|
||||
/// does **not** reproduce `[project]` or `[targets.*]` — the template only
|
||||
/// consumes `info` and `outline`.
|
||||
pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
||||
let mut doc = toml::Table::new();
|
||||
|
||||
// [info]
|
||||
doc.insert("info".to_string(), toml::Value::Table(info_table(lesson)));
|
||||
|
||||
// [[outline]] — ADR-0036's depth-first rendering order: elements
|
||||
// interleaved with section headings at their DFS-open position.
|
||||
let outline: Vec<toml::Value> = lesson
|
||||
.outline
|
||||
.iter()
|
||||
.map(|entry| toml::Value::Table(outline_entry_table(lesson, entry, None)))
|
||||
.collect();
|
||||
doc.insert("outline".to_string(), toml::Value::Array(outline));
|
||||
|
||||
toml::to_string(&doc).expect("augmented manifest serializes")
|
||||
}
|
||||
|
||||
/// Build the augmented **bundle** manifest TOML source for `bundle` (ADR-0037).
|
||||
///
|
||||
/// The bundle template (`exports/<target>.typ` under the `bundle.toml` root)
|
||||
/// reads it via `toml(sys.inputs.manifest)`. It carries `[info]` (the bundle's
|
||||
/// own title/author) and the ordered `[[lessons]]`, each a
|
||||
/// `(info, target, outline)` table — the same shape a single-lesson template
|
||||
/// would assemble, except every outline entry's `path` is **prefixed with that
|
||||
/// lesson's own bundle-root-relative directory** (`BundleLesson::path`), since
|
||||
/// the bundle template's computed include paths resolve against the *bundle*
|
||||
/// root, not each lesson's own root (ADR-0037: combination reads
|
||||
/// already-authored lessons at export time; each lesson's `path` bookkeeping
|
||||
/// stays correct because the prefix is applied only here, in the manifest the
|
||||
/// template consumes — never inside a lesson's own authored content).
|
||||
pub fn build_augmented_bundle_manifest(bundle: &Bundle) -> String {
|
||||
let mut doc = toml::Table::new();
|
||||
|
||||
let mut info = toml::Table::new();
|
||||
info.insert(
|
||||
"title".to_string(),
|
||||
toml::Value::String(bundle.info.title.clone()),
|
||||
);
|
||||
if !bundle.info.authors.is_empty() {
|
||||
let authors = bundle
|
||||
.info
|
||||
.authors
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(toml::Value::String)
|
||||
.collect();
|
||||
info.insert("author".to_string(), toml::Value::Array(authors));
|
||||
}
|
||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||
|
||||
let lessons: Vec<toml::Value> = bundle
|
||||
.lessons
|
||||
.iter()
|
||||
.map(|bl| toml::Value::Table(bundle_lesson_table(bl)))
|
||||
.collect();
|
||||
doc.insert("lessons".to_string(), toml::Value::Array(lessons));
|
||||
|
||||
toml::to_string(&doc).expect("augmented bundle manifest serializes")
|
||||
}
|
||||
|
||||
/// Build one `[[lessons]]` entry's table: that member lesson's own `info`,
|
||||
/// its selected `target`, and its outline with every entry's `path` prefixed
|
||||
/// by the lesson's bundle-relative directory.
|
||||
fn bundle_lesson_table(bl: &BundleLesson) -> toml::Table {
|
||||
let mut t = toml::Table::new();
|
||||
t.insert(
|
||||
"info".to_string(),
|
||||
toml::Value::Table(info_table(&bl.lesson)),
|
||||
);
|
||||
t.insert("target".to_string(), toml::Value::String(bl.target.clone()));
|
||||
let outline: Vec<toml::Value> = bl
|
||||
.lesson
|
||||
.outline
|
||||
.iter()
|
||||
.map(|entry| toml::Value::Table(outline_entry_table(&bl.lesson, entry, Some(&bl.path))))
|
||||
.collect();
|
||||
t.insert("outline".to_string(), toml::Value::Array(outline));
|
||||
t
|
||||
}
|
||||
|
||||
/// Build the `[info]` table shared by a single-lesson manifest and a bundle
|
||||
/// member's `info` entry.
|
||||
fn info_table(lesson: &Lesson) -> toml::Table {
|
||||
let mut info = toml::Table::new();
|
||||
info.insert(
|
||||
"title".to_string(),
|
||||
@@ -52,30 +143,61 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
||||
.collect();
|
||||
info.insert("author".to_string(), toml::Value::Array(authors));
|
||||
}
|
||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||
info
|
||||
}
|
||||
|
||||
// [[parts]] — preserve declared order; attach the on-disk `fields` array.
|
||||
let parts: Vec<toml::Value> = lesson
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
let mut entry = toml::Table::new();
|
||||
entry.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
|
||||
entry.insert(
|
||||
/// Build one `[[outline]]` entry's table for either variant of
|
||||
/// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0037's bundle case), is
|
||||
/// joined onto the emitted `path` so the bundle template's computed include
|
||||
/// resolves against the bundle root rather than the lesson's own root.
|
||||
fn outline_entry_table(
|
||||
lesson: &Lesson,
|
||||
entry: &OutlineEntry,
|
||||
bundle_prefix: Option<&Path>,
|
||||
) -> toml::Table {
|
||||
let mut e = toml::Table::new();
|
||||
match entry {
|
||||
OutlineEntry::Element { part_index, .. } => {
|
||||
let part = &lesson.parts[*part_index];
|
||||
e.insert("type".to_string(), toml::Value::String("element".into()));
|
||||
e.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
|
||||
e.insert(
|
||||
"path".to_string(),
|
||||
toml::Value::String(path_to_forward_slash(&part.path)),
|
||||
toml::Value::String(prefixed_forward_slash(bundle_prefix, &part.path)),
|
||||
);
|
||||
let fields = present_fields(lesson, part)
|
||||
.into_iter()
|
||||
.map(toml::Value::String)
|
||||
.collect();
|
||||
entry.insert("fields".to_string(), toml::Value::Array(fields));
|
||||
toml::Value::Table(entry)
|
||||
})
|
||||
.collect();
|
||||
doc.insert("parts".to_string(), toml::Value::Array(parts));
|
||||
e.insert("fields".to_string(), toml::Value::Array(fields));
|
||||
}
|
||||
OutlineEntry::Section {
|
||||
kind,
|
||||
title,
|
||||
depth,
|
||||
path,
|
||||
notes: _,
|
||||
} => {
|
||||
e.insert("type".to_string(), toml::Value::String("section".into()));
|
||||
e.insert("kind".to_string(), toml::Value::String(kind.clone()));
|
||||
e.insert("title".to_string(), toml::Value::String(title.clone()));
|
||||
e.insert("depth".to_string(), toml::Value::Integer(i64::from(*depth)));
|
||||
e.insert(
|
||||
"path".to_string(),
|
||||
toml::Value::String(prefixed_forward_slash(bundle_prefix, path)),
|
||||
);
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
toml::to_string(&doc).expect("augmented manifest serializes")
|
||||
/// [`path_to_forward_slash`], with `prefix` (a bundle member's own
|
||||
/// bundle-relative directory) joined in front when present.
|
||||
fn prefixed_forward_slash(prefix: Option<&Path>, path: &Path) -> String {
|
||||
match prefix {
|
||||
Some(p) => path_to_forward_slash(&p.join(path)),
|
||||
None => path_to_forward_slash(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content fields of `part`'s kind whose `<root>/<part.path>/<field>.typ`
|
||||
|
||||
@@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World};
|
||||
use typst_kit::fonts::FontStore;
|
||||
|
||||
/// Root-relative vpath the augmented manifest is served at (in-memory only).
|
||||
///
|
||||
/// A **leading slash** is essential: the template lives under `exports/`, and
|
||||
/// `toml(sys.inputs.manifest)` resolves a relative path against the template's
|
||||
/// own directory — a bare name would miss. A root-relative absolute path anchors
|
||||
/// at `--root` (the lesson root) regardless of where the template sits.
|
||||
pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml";
|
||||
|
||||
/// Root-relative vpath of the virtual outline entrypoint.
|
||||
pub const OUTLINE_VPATH: &str = "/exports/outline.typ";
|
||||
|
||||
/// Root-relative vpath of the virtual outline data file.
|
||||
pub const OUTLINE_DATA_VPATH: &str = "/.cph/outline.toml";
|
||||
|
||||
/// The package spec the template imports and the World mounts from `render_dir`.
|
||||
pub fn render_package_spec() -> PackageSpec {
|
||||
PackageSpec {
|
||||
@@ -76,13 +77,11 @@ pub struct LessonWorld {
|
||||
render_dir: PathBuf,
|
||||
/// The render package spec (`@local/cph-render:0.1.0`).
|
||||
render_spec: PackageSpec,
|
||||
/// FileId of the template entrypoint (a real file under `root`).
|
||||
/// FileId of the entrypoint.
|
||||
main: FileId,
|
||||
/// FileId of the in-memory augmented manifest.
|
||||
manifest_id: FileId,
|
||||
/// The augmented-manifest source (in-memory; never on disk).
|
||||
manifest_source: Source,
|
||||
/// Standard library, with `sys.inputs.manifest` set.
|
||||
/// In-memory project files (manifest, or the outline entrypoint/data).
|
||||
virtual_sources: HashMap<FileId, Source>,
|
||||
/// Standard library inputs exposed to the Typst source.
|
||||
library: LazyHash<Library>,
|
||||
/// Shared font store (book + lazily-loaded fonts).
|
||||
fonts: Arc<FontStore>,
|
||||
@@ -95,8 +94,8 @@ impl LessonWorld {
|
||||
/// whose injected manifest is `manifest_src` (served virtually at
|
||||
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
|
||||
///
|
||||
/// `template` is the lesson-root-relative template path (e.g.
|
||||
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
|
||||
/// `template` is the lesson-root-relative path taken from the target's
|
||||
/// `Step::TypstCompile`.
|
||||
pub fn new(
|
||||
root: PathBuf,
|
||||
render_dir: PathBuf,
|
||||
@@ -107,16 +106,55 @@ impl LessonWorld {
|
||||
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
|
||||
.expect("template vpath is a valid virtual path");
|
||||
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
|
||||
let manifest_id = project_file_id(MANIFEST_VPATH);
|
||||
let mut virtual_sources = HashMap::new();
|
||||
virtual_sources.insert(manifest_id, Source::new(manifest_id, manifest_src));
|
||||
Self::with_virtual_files(
|
||||
root,
|
||||
render_dir,
|
||||
main,
|
||||
virtual_sources,
|
||||
&[("manifest", MANIFEST_VPATH)],
|
||||
fonts,
|
||||
)
|
||||
}
|
||||
|
||||
let manifest_vpath =
|
||||
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
|
||||
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
|
||||
let manifest_source = Source::new(manifest_id, manifest_src);
|
||||
/// Build a world for a fully virtual outline document and its TOML data.
|
||||
/// The caller never has to create temporary files in the engineering file.
|
||||
pub fn new_outline(
|
||||
root: PathBuf,
|
||||
render_dir: PathBuf,
|
||||
source: String,
|
||||
outline_src: String,
|
||||
fonts: Arc<FontStore>,
|
||||
) -> Self {
|
||||
let main = project_file_id(OUTLINE_VPATH);
|
||||
let outline_id = project_file_id(OUTLINE_DATA_VPATH);
|
||||
let mut virtual_sources = HashMap::new();
|
||||
virtual_sources.insert(main, Source::new(main, source));
|
||||
virtual_sources.insert(outline_id, Source::new(outline_id, outline_src));
|
||||
Self::with_virtual_files(
|
||||
root,
|
||||
render_dir,
|
||||
main,
|
||||
virtual_sources,
|
||||
&[("outline", OUTLINE_DATA_VPATH)],
|
||||
fonts,
|
||||
)
|
||||
}
|
||||
|
||||
// Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's
|
||||
// `toml(sys.inputs.manifest)` reads the augmented manifest.
|
||||
fn with_virtual_files(
|
||||
root: PathBuf,
|
||||
render_dir: PathBuf,
|
||||
main: FileId,
|
||||
virtual_sources: HashMap<FileId, Source>,
|
||||
input_files: &[(&str, &str)],
|
||||
fonts: Arc<FontStore>,
|
||||
) -> Self {
|
||||
let mut inputs = Dict::new();
|
||||
inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into()));
|
||||
for (name, path) in input_files {
|
||||
inputs.insert((*name).into(), Value::Str((*path).into()));
|
||||
}
|
||||
let library = Library::builder().with_inputs(inputs).build();
|
||||
|
||||
Self {
|
||||
@@ -124,8 +162,7 @@ impl LessonWorld {
|
||||
render_dir,
|
||||
render_spec: render_package_spec(),
|
||||
main,
|
||||
manifest_id,
|
||||
manifest_source,
|
||||
virtual_sources,
|
||||
library: LazyHash::new(library),
|
||||
fonts,
|
||||
sources: Mutex::new(HashMap::new()),
|
||||
@@ -185,8 +222,8 @@ impl World for LessonWorld {
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
if id == self.manifest_id {
|
||||
return Ok(self.manifest_source.clone());
|
||||
if let Some(source) = self.virtual_sources.get(&id) {
|
||||
return Ok(source.clone());
|
||||
}
|
||||
// Cache hit?
|
||||
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
|
||||
@@ -203,8 +240,8 @@ impl World for LessonWorld {
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.manifest_id {
|
||||
return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
|
||||
if let Some(source) = self.virtual_sources.get(&id) {
|
||||
return Ok(Bytes::from_string(source.text().to_string()));
|
||||
}
|
||||
let bytes = self.read_bytes(id)?;
|
||||
Ok(Bytes::new(bytes))
|
||||
@@ -220,6 +257,11 @@ impl World for LessonWorld {
|
||||
}
|
||||
}
|
||||
|
||||
fn project_file_id(path: &str) -> FileId {
|
||||
let vpath = VirtualPath::new(path).expect("virtual project path is valid");
|
||||
FileId::new(RootedPath::new(VirtualRoot::Project, vpath))
|
||||
}
|
||||
|
||||
/// Render a relative `Path` as a forward-slash string, dropping any leading
|
||||
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
|
||||
fn path_to_forward_slash(path: &Path) -> String {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Integration tests for the bundle build path (ADR-0037): compiling a bundle
|
||||
//! target's template (`exports/<target>.typ` under a `bundle.toml` root) as
|
||||
//! main, injecting the augmented **bundle** manifest, against the real
|
||||
//! `render/` package.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Severity;
|
||||
use cph_typst::{build_augmented_bundle_manifest, Engine};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle")
|
||||
}
|
||||
|
||||
fn real_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("render")
|
||||
}
|
||||
|
||||
fn load_bundle() -> cph_model::Bundle {
|
||||
let (bundle, diags) = cph_model::load_bundle(&fixture_root());
|
||||
let bundle = bundle.expect("bundle fixture loads into a Bundle");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
|
||||
bundle
|
||||
}
|
||||
|
||||
/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest
|
||||
/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with
|
||||
/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0037).
|
||||
#[test]
|
||||
fn augmented_bundle_manifest_prefixes_member_paths() {
|
||||
let bundle = load_bundle();
|
||||
let src = build_augmented_bundle_manifest(&bundle);
|
||||
|
||||
assert!(
|
||||
src.contains("测试合集"),
|
||||
"bundle info.title present:\n{src}"
|
||||
);
|
||||
|
||||
let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML");
|
||||
let lessons = doc
|
||||
.get("lessons")
|
||||
.and_then(|l| l.as_array())
|
||||
.expect("lessons array present");
|
||||
assert_eq!(lessons.len(), 2, "two member lessons:\n{src}");
|
||||
|
||||
// lesson-a: target defaults to its own first declared target ("student"),
|
||||
// outline paths are prefixed with "lesson-a/".
|
||||
let a_target = lessons[0].get("target").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_target, "student");
|
||||
let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap();
|
||||
// segment + section heading + lemma = 3 outline entries.
|
||||
assert_eq!(a_outline.len(), 3);
|
||||
let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_seg_path, "lesson-a/segments/a");
|
||||
let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_section_path, "lesson-a/小节");
|
||||
let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲");
|
||||
|
||||
// lesson-b: bundle.toml overrides `target = "teacher"`.
|
||||
let b_target = lessons[1].get("target").unwrap().as_str().unwrap();
|
||||
assert_eq!(b_target, "teacher");
|
||||
let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap();
|
||||
assert_eq!(b_outline.len(), 1);
|
||||
let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap();
|
||||
assert_eq!(b_seg_path, "lesson-b/segments/b");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling
|
||||
/// the bundle's `merged` target as main with the injected augmented bundle
|
||||
/// manifest is clean.
|
||||
#[test]
|
||||
fn compile_check_clean_through_bundle_template() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check_bundle(&bundle, "merged");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is
|
||||
/// produced from the two member lessons through the real bundle template.
|
||||
#[test]
|
||||
fn build_bundle_pdf_through_template_offline() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
|
||||
let pdf = engine
|
||||
.build_bundle_pdf(&bundle, "merged")
|
||||
.unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
|
||||
assert!(
|
||||
pdf.len() > 1024,
|
||||
"bundle PDF is non-trivial (got {} bytes)",
|
||||
pdf.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly
|
||||
/// like a lesson's own unknown-target path.
|
||||
#[test]
|
||||
fn unknown_bundle_target_is_blocking() {
|
||||
let bundle = load_bundle();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let diags = engine.compile_check_bundle(&bundle, "nonexistent");
|
||||
assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}");
|
||||
assert_eq!(diags[0].severity, Severity::Error);
|
||||
assert!(
|
||||
diags[0].message.contains("not declared"),
|
||||
"expected an undeclared-target error: {diags:#?}"
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson {
|
||||
}
|
||||
|
||||
/// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries
|
||||
/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the
|
||||
/// content fields present on disk.
|
||||
/// `[info]` and the ordered `[[outline]]` (ADR-0036) — elements (with a
|
||||
/// per-element `fields` array of the content fields present on disk)
|
||||
/// interleaved with the section heading the mini fixture nests its two lemmas
|
||||
/// under.
|
||||
#[test]
|
||||
fn augmented_manifest_has_per_part_fields() {
|
||||
fn augmented_manifest_has_outline_with_section_and_fields() {
|
||||
let lesson = load_mini();
|
||||
let src = build_augmented_manifest(&lesson);
|
||||
|
||||
@@ -50,66 +52,98 @@ fn augmented_manifest_has_per_part_fields() {
|
||||
assert!(src.contains("迷你示例课时"), "info.title present:\n{src}");
|
||||
assert!(src.contains("测试作者"), "info.author present:\n{src}");
|
||||
|
||||
// Parse it back to inspect the per-part fields precisely.
|
||||
// Parse it back to inspect the outline entries precisely.
|
||||
let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML");
|
||||
let parts = doc
|
||||
.get("parts")
|
||||
let outline = doc
|
||||
.get("outline")
|
||||
.and_then(|p| p.as_array())
|
||||
.expect("parts array present");
|
||||
assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}");
|
||||
.expect("outline array present");
|
||||
// segment, section, lemma, lemma, example — 5 entries (ADR-0036: the
|
||||
// section contributes a heading entry, not an element).
|
||||
assert_eq!(outline.len(), 5, "five outline entries:\n{src}");
|
||||
|
||||
// `fields` is a presence SET (the template tests membership), so order is
|
||||
// not load-bearing; sort for a stable assertion.
|
||||
let fields_of = |idx: usize| -> Vec<String> {
|
||||
let mut v: Vec<String> = parts[idx]
|
||||
.get("fields")
|
||||
.and_then(|f| f.as_array())
|
||||
.expect("part has a fields array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let path_of = |idx: usize| {
|
||||
parts[idx]
|
||||
.get("path")
|
||||
let entry_type = |idx: usize| {
|
||||
outline[idx]
|
||||
.get("type")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let kind_of = |idx: usize| {
|
||||
parts[idx]
|
||||
outline[idx]
|
||||
.get("kind")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let path_of = |idx: usize| {
|
||||
outline[idx]
|
||||
.get("path")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
// `fields` is a presence SET (the template tests membership), so order is
|
||||
// not load-bearing; sort for a stable assertion.
|
||||
let fields_of = |idx: usize| -> Vec<String> {
|
||||
let mut v: Vec<String> = outline[idx]
|
||||
.get("fields")
|
||||
.and_then(|f| f.as_array())
|
||||
.expect("element entry has a fields array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
|
||||
// Order preserved: segment, lemma (w/ proof), lemma (no proof), example.
|
||||
// Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no
|
||||
// proof), example.
|
||||
assert_eq!(entry_type(0), "element");
|
||||
assert_eq!(kind_of(0), "segment");
|
||||
assert_eq!(kind_of(1), "lemma");
|
||||
assert_eq!(kind_of(2), "lemma");
|
||||
assert_eq!(kind_of(3), "example");
|
||||
|
||||
// Paths kept as forward-slash UTF-8.
|
||||
assert_eq!(path_of(0), "segments/开场对照导言");
|
||||
assert_eq!(path_of(2), "lemmas/无证明引理");
|
||||
|
||||
// segment: only `textbook` exists.
|
||||
assert_eq!(fields_of(0), vec!["textbook"]);
|
||||
|
||||
assert_eq!(entry_type(1), "section");
|
||||
assert_eq!(outline[1].get("title").unwrap().as_str().unwrap(), "引理组");
|
||||
assert_eq!(outline[1].get("depth").unwrap().as_integer().unwrap(), 1);
|
||||
assert_eq!(path_of(1), "引理组");
|
||||
|
||||
assert_eq!(entry_type(2), "element");
|
||||
assert_eq!(kind_of(2), "lemma");
|
||||
assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计");
|
||||
// lemma WITH proof.typ: both stmt + proof present (sorted).
|
||||
assert_eq!(fields_of(1), vec!["proof", "stmt"]);
|
||||
assert_eq!(fields_of(2), vec!["proof", "stmt"]);
|
||||
|
||||
assert_eq!(entry_type(3), "element");
|
||||
assert_eq!(kind_of(3), "lemma");
|
||||
assert_eq!(path_of(3), "引理组/lemmas/无证明引理");
|
||||
// lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path).
|
||||
assert_eq!(
|
||||
fields_of(2),
|
||||
fields_of(3),
|
||||
vec!["stmt"],
|
||||
"proof must be omitted when absent"
|
||||
);
|
||||
|
||||
assert_eq!(entry_type(4), "element");
|
||||
assert_eq!(kind_of(4), "example");
|
||||
// example: problem + solution present (source is a scalar, not a content field).
|
||||
assert_eq!(fields_of(3), vec!["problem", "solution"]);
|
||||
assert_eq!(fields_of(4), vec!["problem", "solution"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_pdf_renders_without_lesson_files() {
|
||||
let lesson = load_mini();
|
||||
let outline = lesson.outline_document();
|
||||
let engine = Engine::with_render_dir(real_render_dir());
|
||||
let pdf = engine
|
||||
.build_outline_pdf(&outline)
|
||||
.expect("outline PDF should compile");
|
||||
assert!(pdf.starts_with(b"%PDF"), "output should be a PDF");
|
||||
assert!(pdf.len() > 1_000, "outline PDF should be non-trivial");
|
||||
}
|
||||
|
||||
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
|
||||
@@ -202,6 +236,7 @@ fn file_tree_artifact_is_deferred() {
|
||||
authors: vec![],
|
||||
},
|
||||
parts: vec![],
|
||||
outline: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "web".into(),
|
||||
artifact: Artifact::FileTree {
|
||||
@@ -241,6 +276,7 @@ fn shell_only_target_is_deferred() {
|
||||
authors: vec![],
|
||||
},
|
||||
parts: vec![],
|
||||
outline: vec![],
|
||||
targets: vec![TargetConfig {
|
||||
name: "packaged".into(),
|
||||
artifact: Artifact::SingleFile {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[info]
|
||||
title = "测试合集"
|
||||
author = "测试作者"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-a"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-b"
|
||||
target = "teacher"
|
||||
|
||||
[targets.merged]
|
||||
artifact = { type = "single-file", filepath = "build/merged.pdf" }
|
||||
[[targets.merged.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/merged.typ"
|
||||
@@ -0,0 +1,76 @@
|
||||
// DEFAULT BUNDLE TEMPLATE (ADR-0037, outline shape ADR-0036).
|
||||
//
|
||||
// Lives in a bundle at `<bundle-root>/exports/<target>.typ`, e.g.
|
||||
// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest
|
||||
// injected:
|
||||
// typst compile --root <bundle-root> --input manifest=<path-rel-to-root> exports/merged.typ <out>
|
||||
//
|
||||
// Structurally identical to the single-lesson `student.typ`/`teacher.typ`
|
||||
// templates (see their notes on why the include loop lives in the template,
|
||||
// not in cph-render), except it reads `manifest.lessons` (an ordered array of
|
||||
// per-lesson `(info, target, outline)` tables — see
|
||||
// `cph_typst::build_augmented_bundle_manifest`) instead of a single
|
||||
// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`.
|
||||
//
|
||||
// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with
|
||||
// that lesson's own bundle-root-relative directory (done by the Rust engine),
|
||||
// so the same `include "/" + path + "/" + field + ".typ"` computation used by
|
||||
// a single-lesson template resolves correctly here too — no special-casing
|
||||
// needed in this loop.
|
||||
|
||||
#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering
|
||||
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-lessons = manifest.at("lessons", default: ())
|
||||
|
||||
// Assemble one outline entry exactly as a single-lesson template would.
|
||||
#let assemble-entry(raw) = {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
let kind = raw.at("kind", default: none)
|
||||
let path = raw.at("path", default: none)
|
||||
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
|
||||
let present = raw.at("fields", default: ())
|
||||
let entry = (entry-type: "element", kind: kind)
|
||||
|
||||
for field in spec.content {
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
if spec.scalars.len() > 0 {
|
||||
let element = toml("/" + path + "/element.toml")
|
||||
for field in spec.scalars {
|
||||
let v = element.at(field, default: none)
|
||||
if v != none and v != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
entry
|
||||
}
|
||||
}
|
||||
|
||||
#let lessons = raw-lessons.map(raw => (
|
||||
info: raw.at("info", default: (:)),
|
||||
target: raw.at("target", default: "student"),
|
||||
outline: raw.at("outline", default: ()).map(assemble-entry),
|
||||
))
|
||||
|
||||
// Presentation: shared per-level heading numbering across the whole bundle,
|
||||
// and the ADR-0037 recommended default of resetting auto-counters at each
|
||||
// lesson boundary (override `reset-counters: false` for continuous numbering).
|
||||
#render-bundle(
|
||||
info: info,
|
||||
lessons: lessons,
|
||||
heading-numbering: default-heading-numbering,
|
||||
reset-counters: true,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
id = "lesson-a"
|
||||
name = "lesson-a"
|
||||
|
||||
[info]
|
||||
title = "课时A"
|
||||
author = "作者A"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "小节"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
= 课时A导言
|
||||
@@ -0,0 +1 @@
|
||||
引理甲陈述。
|
||||
@@ -0,0 +1,6 @@
|
||||
[group]
|
||||
title = "小节"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/引理甲"
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-b"
|
||||
name = "lesson-b"
|
||||
|
||||
[info]
|
||||
title = "课时B"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/b"
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
= 课时B导言
|
||||
+28
-14
@@ -1,4 +1,4 @@
|
||||
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011).
|
||||
// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
|
||||
//
|
||||
// This is a *real, editable* file that lives in an engineering file at
|
||||
// `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the
|
||||
@@ -15,15 +15,16 @@
|
||||
// its own virtual root — an include inside cph-render would resolve against the
|
||||
// PACKAGE, not the engineering root. A `/<part.path>/<field>.typ` written HERE
|
||||
// (this template lives under `--root`) resolves against `--root`. So the
|
||||
// template loads content and hands cph-render an already-assembled `parts` array.
|
||||
// template loads content and hands cph-render an already-assembled `outline`
|
||||
// array (elements interleaved with section headings, ADR-0036).
|
||||
//
|
||||
// OPEN CONTRACT POINT — optional-content presence. typst has no "does this file
|
||||
// exist" primitive (a missing `include` is a hard compile error). So the
|
||||
// template CANNOT probe disk the way the old Rust driver did for lemma `proof`.
|
||||
// It relies on the manifest declaring which optional content fields are present,
|
||||
// via a per-part `fields` array listing the content fields that exist on disk
|
||||
// via a per-element `fields` array listing the content fields that exist on disk
|
||||
// (the engine knows this — it walks the part dir). Required fields are loaded
|
||||
// unconditionally; optional fields load only if listed in `fields`. If a part
|
||||
// unconditionally; optional fields load only if listed in `fields`. If an element
|
||||
// omits `fields`, optional content is skipped (conservative). The exact shape of
|
||||
// this declaration is for the manifest/Rust contract to pin.
|
||||
|
||||
@@ -35,27 +36,39 @@
|
||||
// Read the injected manifest (a path string relative to typst --root).
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-parts = manifest.at("parts", default: ())
|
||||
#let raw-outline = manifest.at("outline", default: ())
|
||||
|
||||
// Assemble each part: include its content fields (computed absolute paths,
|
||||
// Assemble each outline entry:
|
||||
// - an "element" entry: include its content fields (computed absolute paths,
|
||||
// resolved against --root) and read scalar fields from <path>/element.toml.
|
||||
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
|
||||
#let parts = raw-parts.map(raw => {
|
||||
// `part-fields` (from cph-render) is the single source of truth for
|
||||
// kind->fields.
|
||||
// - a "section" entry (ADR-0036): pass its title/depth straight through — no
|
||||
// content to load, it is a heading.
|
||||
#let outline = raw-outline.map(raw => {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
let kind = raw.at("kind", default: none)
|
||||
let path = raw.at("path", default: none)
|
||||
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
|
||||
// Which optional content fields are present on disk (manifest-declared).
|
||||
let present = raw.at("fields", default: ())
|
||||
let part = (kind: kind)
|
||||
let entry = (entry-type: "element", kind: kind)
|
||||
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
// Scalar fields come from <path>/element.toml.
|
||||
@@ -63,10 +76,11 @@
|
||||
let element = toml("/" + path + "/element.toml")
|
||||
for field in spec.scalars {
|
||||
let v = element.at(field, default: none)
|
||||
if v != none and v != "" { part.insert(field, v) }
|
||||
if v != none and v != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
part
|
||||
entry
|
||||
}
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
@@ -74,6 +88,6 @@
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
parts: parts,
|
||||
outline: outline,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
|
||||
+21
-12
@@ -1,4 +1,4 @@
|
||||
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011).
|
||||
// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0036).
|
||||
//
|
||||
// Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with
|
||||
// the manifest injected:
|
||||
@@ -18,27 +18,35 @@
|
||||
// Read the injected manifest (a path string relative to typst --root).
|
||||
#let manifest = toml(sys.inputs.manifest)
|
||||
#let info = manifest.at("info", default: (:))
|
||||
#let raw-parts = manifest.at("parts", default: ())
|
||||
#let raw-outline = manifest.at("outline", default: ())
|
||||
|
||||
// Assemble each part: include its content fields (computed absolute paths,
|
||||
// resolved against --root) and read scalar fields from <path>/element.toml.
|
||||
// `part-fields` (from cph-render) is the single source of truth for kind->fields.
|
||||
#let parts = raw-parts.map(raw => {
|
||||
// Assemble each outline entry: an "element" entry includes its content fields
|
||||
// and reads scalars from element.toml; a "section" entry (ADR-0036) passes
|
||||
// title/depth straight through as a heading, no content to load.
|
||||
#let outline = raw-outline.map(raw => {
|
||||
if raw.at("type", default: "element") == "section" {
|
||||
(
|
||||
entry-type: "section",
|
||||
kind: raw.at("kind", default: none),
|
||||
title: raw.at("title", default: ""),
|
||||
depth: raw.at("depth", default: 1),
|
||||
)
|
||||
} else {
|
||||
let kind = raw.at("kind", default: none)
|
||||
let path = raw.at("path", default: none)
|
||||
let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ()))
|
||||
// Which optional content fields are present on disk (manifest-declared).
|
||||
let present = raw.at("fields", default: ())
|
||||
let part = (kind: kind)
|
||||
let entry = (entry-type: "element", kind: kind)
|
||||
|
||||
// Required content fields: <path>/<field>.typ (absolute, root-relative).
|
||||
for field in spec.content {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
// Optional content fields: only when the manifest says the file exists.
|
||||
for field in spec.optional-content {
|
||||
if field in present {
|
||||
part.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
entry.insert(field, include "/" + path + "/" + field + ".typ")
|
||||
}
|
||||
}
|
||||
// Scalar fields come from <path>/element.toml.
|
||||
@@ -46,10 +54,11 @@
|
||||
let element = toml("/" + path + "/element.toml")
|
||||
for field in spec.scalars {
|
||||
let v = element.at(field, default: none)
|
||||
if v != none and v != "" { part.insert(field, v) }
|
||||
if v != none and v != "" { entry.insert(field, v) }
|
||||
}
|
||||
}
|
||||
part
|
||||
entry
|
||||
}
|
||||
})
|
||||
|
||||
// Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes
|
||||
@@ -57,6 +66,6 @@
|
||||
#render-lesson(
|
||||
info: info,
|
||||
target: target,
|
||||
parts: parts,
|
||||
outline: outline,
|
||||
heading-numbering: default-heading-numbering,
|
||||
)
|
||||
|
||||
+5
-9
@@ -6,19 +6,15 @@ name = "迷你课时"
|
||||
title = "迷你示例课时"
|
||||
author = "测试作者"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/开场对照导言"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "引理组"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/无证明引理"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "example"
|
||||
path = "examples/自由落体"
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[group]
|
||||
title = "引理组"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/无证明引理"
|
||||
@@ -34,8 +34,12 @@ provider runtime cursor needed to continue a conversation. For Claude Code SDK,
|
||||
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
|
||||
`claudeSessionId` and pass it back to the next `query()` call as
|
||||
`options.resume`. Role is part of the session binding because role prompts and
|
||||
tool surfaces can differ even when the underlying model is the same; `/draft`
|
||||
and `/review` must not resume the same Claude runtime cursor by accident.
|
||||
tool surfaces can differ even when the underlying model is the same. A Feishu
|
||||
project group's active binding selects one Organization role for ordinary
|
||||
messages. Switching that selection routes future messages to the selected
|
||||
role's own session; it never mutates or merges provider cursors across roles.
|
||||
Work freezes the selected role when accepted so queued requests cannot drift
|
||||
after a later switch.
|
||||
|
||||
Role definitions are Organization-scoped runtime data. A role bundle selects
|
||||
its default model, system prompt, tool allowlist and installed Agent skill
|
||||
@@ -47,6 +51,20 @@ a Hub release or process restart. A change to the role's execution surface
|
||||
the next run cannot resume a provider context created under stale instructions;
|
||||
label and ordering-only changes preserve conversational continuity.
|
||||
|
||||
Exactly one active role per Organization is the default used when a project
|
||||
group is first bound. The default is configuration data, not a hard-coded role
|
||||
name. Roles are selected through the Hub project console; role ids are not
|
||||
public slash commands. A project participant may change the group's shared
|
||||
selection only when both `agent.trigger` for the project and `role.trigger` for
|
||||
the target role authorize that actor. The selection affects every participant's
|
||||
future messages, while already accepted work keeps its frozen role.
|
||||
|
||||
Hub slash commands are a closed control-plane protocol. Unknown commands fail
|
||||
explicitly and are never downgraded to Agent text. Claude SDK session commands
|
||||
are invoked only through typed Hub actions. In particular, compaction resumes
|
||||
the selected role session and sends the exact `/compact` prompt without
|
||||
prepending Feishu context.
|
||||
|
||||
Environment variables:
|
||||
```
|
||||
ANTHROPIC_BASE_URL=https://openrouter.ai/api
|
||||
|
||||
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
|
||||
workspace or service-user config from widening tools, hooks, MCP servers, or
|
||||
sandbox paths.
|
||||
- Agent skills are Organization-scoped runtime configuration, not Hub release
|
||||
assets. A controlled host-console installer imports each version into a
|
||||
content-addressed persistent store and records its digest in PostgreSQL. A
|
||||
role selects enabled Organization skills alongside its model, system prompt
|
||||
and tool allowlist. Each run copies only those selected immutable versions
|
||||
into a run-scoped plugin outside the project workspace; the sandbox exposes
|
||||
that snapshot read-only and deletes it after the run. SDK-bundled skills and
|
||||
filesystem setting sources remain disabled, so project `.claude` content
|
||||
cannot register skills or widen tools. Requested skill versions are recorded
|
||||
on `run.created`; SDK initialization remains authoritative loading evidence.
|
||||
assets. Skill content is imported into a content-addressed persistent store
|
||||
through a shared ingestion pipeline (`importSkillDirectory` for host-console
|
||||
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
|
||||
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
|
||||
symlink rejection, SHA-256 content addressing. The web surface
|
||||
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
|
||||
store as the host-console CLI (`installSkill`); both flow through
|
||||
`commitSkillContent` for deduplication and atomic rename into
|
||||
`versions/<digest>/`. Org-admin authentication gates the web surface; the
|
||||
content-addressed store remains platform-controlled. A role selects enabled
|
||||
Organization skills alongside its model, system prompt and tool allowlist.
|
||||
Each run copies only those selected immutable versions into a run-scoped
|
||||
plugin outside the project workspace; the sandbox exposes that snapshot
|
||||
read-only and deletes it after the run. SDK-bundled skills and filesystem
|
||||
setting sources remain disabled, so project `.claude` content cannot register
|
||||
skills or widen tools. Requested skill versions are recorded on
|
||||
`run.created`; SDK initialization remains authoritative loading evidence.
|
||||
- Network: open (see Open Questions).
|
||||
|
||||
`bypassPermissions` is kept (headless server — no interactive prompts); the
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# ADR 0026: Usage Fact Ledger
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0022 pins the cost-attribution contract for the platform: token,
|
||||
provider-reported cost, run count, and duration are attributed by Organization,
|
||||
Project, Run, model, and Provider Connection; "missing provider cost remains
|
||||
unknown rather than zero." ADR-0021 scopes usage accounting as operational
|
||||
reporting, not payment collection (commercial billing stays deferred).
|
||||
|
||||
The implementation today stores this as **one scalar per `AgentRun`**:
|
||||
`inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the
|
||||
Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is
|
||||
adequate for a single provider-reported model loop, but it cannot represent:
|
||||
|
||||
- **External capability consumption** inside a run. PDF→Markdown bundle
|
||||
conversion, audio/video transcription, OCR and similar media transforms are
|
||||
not the main agent loop. They run as side effects of a run, may use a
|
||||
different provider/model, may bill in non-token units (pages, seconds,
|
||||
invocations), and may report cost through a different channel than the
|
||||
OpenRouter gateway. Today there is no row to write that cost to — it would
|
||||
either disappear or silently corrupt the run's single scalar.
|
||||
- **Multiple model calls within one run** (e.g. a sub-model invoked by a
|
||||
tool, a gateway-side reroute). The scalar collapses them into one number.
|
||||
- **Pricebook derivation** after the fact. With only a final USD figure and no
|
||||
`(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive
|
||||
cost from a price table when the provider did not report it.
|
||||
|
||||
Treating each external call as a nested `AgentRun` was considered and
|
||||
rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role
|
||||
binding (ADR-0017), admission/capacity semantics (ADR-0022), and the
|
||||
user-visible task boundary. External calls hold none of those. Making them
|
||||
`AgentRun`s would pollute run counts, admission, lock semantics, and session
|
||||
continuity, and would still not solve non-token metering.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce **`UsageFact`** as the single source of truth for billable
|
||||
consumption inside an `AgentRun`. An `AgentRun` owns zero or more
|
||||
append-only `UsageFact` rows; each row records one billable consumption event:
|
||||
|
||||
- `kind` — `model_completion` (the main agent loop) | `external_capability`
|
||||
| `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not
|
||||
silently folded into an existing one.
|
||||
- `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`.
|
||||
- `model?`, `inputTokens?`, `outputTokens?` — token metering, optional
|
||||
because non-token capabilities have none.
|
||||
- `quantity?` + `unit?` — non-token metering (pages, audio_seconds,
|
||||
invocations), coexisting with tokens rather than replacing them.
|
||||
- `costUsd?` + `costSource` — `provider_reported` | `pricebook_derived` |
|
||||
`unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the
|
||||
provider reported a cost, `costSource = provider_reported` and that value
|
||||
wins. When only tokens are known, a later pricebook pass may derive
|
||||
`costUsd` with `costSource = pricebook_derived`. When neither is possible,
|
||||
`costSource = unknown` and `costUsd` stays null.
|
||||
- `occurredAt` — when the consumption happened; the pricebook derivation
|
||||
depends on this, not on `AgentRun.finishedAt`, because an external
|
||||
capability may complete before the run finishes.
|
||||
- `capabilityId?` — for `external_capability` facts, the registered capability
|
||||
id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`).
|
||||
- `correlationId?` — external request id for reconciliation / idempotency;
|
||||
not part of the aggregation key.
|
||||
|
||||
### Invariants
|
||||
|
||||
1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost
|
||||
correction is a new row; the old row stays. `onDelete: Cascade` exists only
|
||||
so a hard run delete (itself not a normal path) cleans up its facts.
|
||||
2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect
|
||||
ledger of one run, not a sub-run. External capability calls obey the same
|
||||
boundary: their identity is `capabilityId + correlationId`, not `RunId`.
|
||||
3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0.
|
||||
A run whose facts all have `costUsd = null` is "cost unknown" — reported as
|
||||
`runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are
|
||||
today. A run with at least one `costUsd`-bearing fact contributes its sum.
|
||||
|
||||
### Rollup cache
|
||||
|
||||
`AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept
|
||||
as a **derived rollup cache**, not dropped:
|
||||
|
||||
- Existing non-`/usage` readers (slash `/usage`, session detail, integration
|
||||
tests asserting `run.costUsd`) continue to work without code changes for
|
||||
historical runs.
|
||||
- On run finish, the writer writes the `UsageFact` row first and then mirrors
|
||||
it onto `AgentRun` as two separate statements, not one transaction. The
|
||||
fact is the truth, so it is written first; the cache is derived, so it is
|
||||
written second. A crash between the two leaves the cache stale, but the
|
||||
usage service re-reads `UsageFact` directly, so staleness is recoverable
|
||||
(and the reverse order would lose the truth, which is not). Keeping the
|
||||
fact insert and the run update in separate statements also avoids holding
|
||||
the `AgentRun` row lock across the FK ShareLock taken by the insert, which
|
||||
deadlocks against concurrent workspace teardown under the cascade path
|
||||
`Organization → Project → AgentRun → UsageFact`.
|
||||
- The org/project usage service and the slash `/usage` command read from
|
||||
`UsageFact` directly — they are the canonical aggregation path.
|
||||
|
||||
### Migration
|
||||
|
||||
A new `UsageFact` table is added. For every existing `AgentRun` with a
|
||||
non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration
|
||||
inserts **one synthetic `model_completion` fact** carrying the run's
|
||||
provider, model, tokens, cost, and `costSource`. Its `correlationId` is the
|
||||
run id, so the row is identifiable as a backfill artefact. This keeps
|
||||
historical reporting correct under the new aggregation path. Pre-migration
|
||||
runs with no recorded cost remain `runsWithoutCost` by design, matching
|
||||
ADR-0022's "missing cost ≠ zero" rule and the existing migration
|
||||
`20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the
|
||||
truly unrecorded.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The billing model now supports external capabilities (PDF→MD, ASR, OCR, …)
|
||||
without per-capability schema changes: a new capability is one new
|
||||
`capabilityId` value and one or more `external_capability` facts.
|
||||
- `AgentRun.costUsd` is no longer the truth; it is a convenience cache.
|
||||
Readers that need the truth (cost breakdown, per-capability attribution)
|
||||
must read `UsageFact`. The cache MUST be kept consistent on the write path.
|
||||
- The capability registry, pricebook, and any commercial settlement remain
|
||||
`OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger
|
||||
shape and invariants.
|
||||
- `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a
|
||||
single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and
|
||||
`(provider, model, occurredAt)` keeps the existing org/project/report
|
||||
queries bounded.
|
||||
- Cost corrections (e.g. a provider rebills a run) produce a new fact row; the
|
||||
rollup cache must be recomputed. The initial release writes facts once at
|
||||
run finish and does not support post-hoc correction flows — that remains
|
||||
`OPEN`.
|
||||
|
||||
## Deferred
|
||||
|
||||
- **Capability registry** as a first-class spec entity (`Spec.System.Capability`)
|
||||
with org-scoped enable/disable, input/output contracts, metering schemas,
|
||||
and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves
|
||||
the `capabilityId` field and the `external_capability` fact kind.
|
||||
- **Pricebook** — a versioned price table keyed by `(provider, model, unit)`
|
||||
with time validity. Required to actually produce `pricebook_derived` costs;
|
||||
until then, facts without provider-reported cost stay `unknown`.
|
||||
- **Post-hoc cost correction flow** — append-only today; correction UI and
|
||||
rollup recompute are `OPEN`.
|
||||
- **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.
|
||||
@@ -0,0 +1,152 @@
|
||||
# ADR 0027: External Capability Registry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability`
|
||||
fact and a `capabilityId` field, but deferred the capability registry itself.
|
||||
Two concrete needs now force the issue:
|
||||
|
||||
- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text)
|
||||
must run as a side effect of an `AgentRun`, bill in non-token units
|
||||
(pages, seconds), use a different provider than the model loop, and report
|
||||
cost through a different channel. It is not a sub-run (ADR-0026 rejected
|
||||
that) and not a model-provider call (it does not speak the Anthropic/
|
||||
OpenRouter protocol).
|
||||
- The Agent already has a Bash tool. Without a first-class capability seam,
|
||||
the path of least resistance is for the agent to shell out to ad-hoc
|
||||
scripts that embed API keys, write to arbitrary paths, and report nothing
|
||||
to the ledger. That is exactly the unattributed, uncontained external
|
||||
consumption ADR-0022/0026 exist to prevent.
|
||||
|
||||
The model-provider connection (`OrganizationProviderConnection`, ADR-0024)
|
||||
is the wrong seam for these services:
|
||||
|
||||
- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and
|
||||
readiness probe (`/v1/models?supported_parameters=tools`) are specific to
|
||||
OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have
|
||||
different auth shapes (an API token, optionally a project id) and no
|
||||
`/v1/models` endpoint.
|
||||
- Its uniqueness key is `(organizationId, providerId)` where `providerId`
|
||||
is an OpenRouter-style model-routing id. A capability provider id
|
||||
(`mineru`) names a *service*, not a model.
|
||||
- Coupling capability credentials into the model-provider table would force
|
||||
every capability's auth shape through `ProviderSecretPayloadV1` and every
|
||||
readiness probe through `probeOpenRouterCredential`.
|
||||
|
||||
The Feishu Application Connection (`OrganizationFeishuApplicationConnection`)
|
||||
is the right structural precedent: it reuses the ADR-0024 envelope (KEK →
|
||||
DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its
|
||||
own connection table, its own payload schema, its own readiness probe, and
|
||||
its own per-org uniqueness. Capability connections follow the same pattern.
|
||||
|
||||
## Decision
|
||||
|
||||
### External Capability
|
||||
|
||||
An **External Capability** is a platform-registered, org-enabled document or
|
||||
media transform service invoked as a side effect of an `AgentRun`. It is
|
||||
identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`,
|
||||
`audio_video_to_text`). A capability:
|
||||
|
||||
- Has an **input kind** (PDF, image, audio, video, …) and an **output
|
||||
contract** (markdown bundle with extracted images, transcript text, …).
|
||||
- Bills in **non-token units** (pages, audio-seconds) recorded on a
|
||||
`UsageFact` with `kind = external_capability`, or in tokens when the
|
||||
backing service reports them.
|
||||
- Writes its output **into the invoking run's workspace** (ADR-0018
|
||||
`AgentSurface` — no escapes).
|
||||
- Is invoked through a **capability adapter** in Hub, never by the Agent
|
||||
shelling out with embedded credentials.
|
||||
|
||||
### Capability Connection
|
||||
|
||||
Credentials for a capability live in an **`OrganizationCapabilityConnection`**,
|
||||
structurally identical to the Feishu Application Connection:
|
||||
|
||||
- Belongs to exactly one Organization.
|
||||
- Unique by `(organizationId, capabilityId)`.
|
||||
- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a
|
||||
valid active secret version.
|
||||
- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version
|
||||
(`CapabilityCredentialVersion`), reusing the ADR-0024 encryption
|
||||
machinery with `purpose = "capability"`.
|
||||
- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`:
|
||||
`baseUrl`, `apiToken`, optional `projectId`). New capability types extend
|
||||
the payload, not the connection table.
|
||||
- A capability-specific **readiness probe** validates the credential before
|
||||
activation (e.g. MinerU: a trivial authenticated GET). The probe is
|
||||
injectable, matching the Feishu/provider pattern, so tests never hit the
|
||||
network.
|
||||
|
||||
### Capability Adapter
|
||||
|
||||
The adapter is the seam between the Agent and the external service. It:
|
||||
|
||||
- Resolves the org's active capability connection (fail-closed, no
|
||||
process-global fallback — ADR-0024).
|
||||
- Accepts a workspace-relative input path and an output directory.
|
||||
- Calls the backing service (MinerU, Whisper, …) via an injectable
|
||||
`Client` interface so the real HTTP client is swappable and mockable.
|
||||
- Writes the produced markdown + image assets into the run's workspace.
|
||||
- Writes one `UsageFact` (or more, if the service reports per-stage
|
||||
consumption) with `kind = external_capability`, `capabilityId`,
|
||||
`provider` (the service id), `quantity + unit` (pages / seconds), and
|
||||
`costUsd + costSource = provider_reported` when the service reports cost.
|
||||
|
||||
### Registry
|
||||
|
||||
The platform maintains a **registry** of known capabilities: their id,
|
||||
input kind, output contract, metering unit, and adapter. This is
|
||||
code-level registration (like `ToolRegistry`), not a database table — a
|
||||
capability is available to an Organization only when (a) the platform
|
||||
knows the adapter and (b) the Organization has an `ACTIVE` connection for
|
||||
it. Both gates are required.
|
||||
|
||||
### What is NOT in this ADR
|
||||
|
||||
- The capability invocation is **not** a first-class persisted record
|
||||
(`CapabilityInvocation` table) in this ADR. The `UsageFact` row with
|
||||
`capabilityId` + `correlationId` is the durable trace. If we later need
|
||||
a richer invocation log (retries, partial output, multi-stage status),
|
||||
that is a follow-up; for now the fact is enough.
|
||||
- **Pricebook** remains deferred (ADR-0026). Capability facts use
|
||||
`provider_reported` when the service returns cost; otherwise `unknown`.
|
||||
- **Org-scoped enable/disable policy** beyond connection status is
|
||||
deferred. An org with an `ACTIVE` connection has the capability; one
|
||||
without does not. A finer "enabled but no credential" toggle is not
|
||||
needed yet.
|
||||
- **Agent-facing tool exposure** (how the Agent discovers and calls the
|
||||
capability — MCP tool, Bash wrapper, or built-in) is an implementation
|
||||
detail of the adapter wiring, not a contract concern. The contract pins
|
||||
that the Agent never receives the capability credential.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a new external capability (e.g. `image_ocr`) is: register an
|
||||
adapter, add a `capabilityId` constant, optionally extend the secret
|
||||
payload — no schema change to `UsageFact` or `AgentRun`.
|
||||
- The model-provider connection table stays focused on model routing;
|
||||
capability credentials do not pollute its payload or readiness probe.
|
||||
- Three connection types now share the ADR-0024 envelope: model-provider,
|
||||
Feishu application, and capability. Each has its own table, payload
|
||||
schema, and probe, but the same encryption, rotation, and resolver
|
||||
boundary.
|
||||
- The Agent's Bash tool remains available, but the intended path for
|
||||
document/media transforms is the capability adapter. Whether to narrow
|
||||
Bash for capability-shaped tasks is an operational policy decision,
|
||||
not a contract one.
|
||||
- Tests prove: workspace containment of capability output, fail-closed
|
||||
credential resolution, `UsageFact` attribution with non-token metering,
|
||||
and that the Agent process never receives the capability credential.
|
||||
|
||||
## Deferred
|
||||
|
||||
- `CapabilityInvocation` as a first-class durable record (status, retries,
|
||||
partial output) — currently the `UsageFact` row is the only trace.
|
||||
- Pricebook derivation for capability costs (ADR-0026 deferred).
|
||||
- Org-scoped capability enable/disable policy finer than connection status.
|
||||
- Agent-facing tool discovery (MCP vs built-in) for capabilities.
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR 0028: Agent Configuration Folder Tree
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
|
||||
configuration, managed without process restarts. The org admin surfaces for
|
||||
them (`/admin/roles` and `/admin/skills`) render every
|
||||
role/skill as one large editor card in a single flat list. As an Organization
|
||||
accumulates roles and skills, the management pages degrade into an endless
|
||||
scroll with no grouping affordance.
|
||||
|
||||
The project explorer already solves the analogous problem for projects with
|
||||
transparent folders (ADR-0021): org-scoped navigation nodes that are not
|
||||
permission resources. Roles and skills need the same affordance, but their
|
||||
semantics differ from projects in one crucial way: skill names and role IDs
|
||||
are referenced by role→skill bindings, run-time skill snapshot loading, and
|
||||
Feishu slash commands. Any grouping mechanism must not leak into those
|
||||
resolution paths.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
|
||||
skills:
|
||||
|
||||
- One folder tree per Organization is shared by both roles and skills (e.g. a
|
||||
"高三化学组" folder groups that team's roles and its skills together). It is
|
||||
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
|
||||
trees are managed independently and never reference each other.
|
||||
- Folders are **transparent organization nodes**, following the ADR-0021
|
||||
project-folder precedent: they exist for management-surface navigation and
|
||||
grouping only, are not permission resources, and hold no grants.
|
||||
- Folder membership is **not part of role/skill identity or resolution**:
|
||||
- skill `name` and role `roleId` remain unique per Organization regardless
|
||||
of folder membership;
|
||||
- role→skill bindings, run admission's frozen role snapshot, run-scoped
|
||||
skill loading, and Feishu slash commands never reference folders.
|
||||
- Each role/skill sits in at most one folder; membership is optional
|
||||
(unfiled items remain first-class). Folders nest arbitrarily.
|
||||
- Folder assignment is a label-class change in the ADR-0017 sense: it never
|
||||
archives Agent sessions, because the execution surface (model, prompt,
|
||||
tools, skill content) is untouched.
|
||||
- A folder can be deleted only when empty — no child folders, no roles, no
|
||||
skills. Relocating items out of a folder is an explicit user action, so no
|
||||
orphan-placement rule is needed yet.
|
||||
- Admin web renders both pages as a left folder tree plus the item list of
|
||||
the selected folder ("all" and "unfiled" included). The host-console CLI is
|
||||
unchanged: folder management lives in the web surface, and CLI
|
||||
`upsert-role`/`install-skill` never touch folder assignment.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
|
||||
`parentId`, delete restricted while referenced) plus nullable `folderId` on
|
||||
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
|
||||
delete, though the service refuses to delete non-empty folders).
|
||||
- The spec pins the transparency and single-membership semantics in
|
||||
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
|
||||
not re-derive them differently (e.g. path-style names or per-folder
|
||||
uniqueness).
|
||||
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
|
||||
that skip session archival by construction.
|
||||
- Moving a role/skill between folders changes nothing about authorization,
|
||||
run resolution, or audit-visible configuration lineage beyond the folder
|
||||
assignment event itself.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
|
||||
per-item select for now.
|
||||
- Folder-level usage aggregation for roles/skills is deferred (project
|
||||
folders already aggregate usage under ADR-0021; agent configuration has no
|
||||
usage dimension yet).
|
||||
- CLI flags for folder assignment are deferred until a console workflow asks
|
||||
for them.
|
||||
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
|
||||
for now: the Organization keeps exactly one active default role
|
||||
(ADR-0017/0018 invariant) regardless of folder structure.
|
||||
@@ -0,0 +1,153 @@
|
||||
# ADR 0028: Member Group Management And Resolution
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the
|
||||
principal-set permission model. The file library (《文件库-接口契约.md》) computes
|
||||
effective permission over two principal kinds — `USER` and `GROUP` — and consumes
|
||||
the group side through a single read-only port, `GroupResolver`
|
||||
(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2).
|
||||
|
||||
The contract's v0.1 proposal framed the Group system as a *separate HTTP service*
|
||||
owned by another team, consumed read-only. In practice the schema now carries the
|
||||
group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`,
|
||||
`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and
|
||||
the product requirement is to build **group management in the backend admin**, not
|
||||
to integrate a foreign service. Until this ADR, nothing read or wrote those tables:
|
||||
the live `GroupResolver` was a transitional implementation reading flat hub `Team`
|
||||
membership, and the admin "Group 管理" panel actually managed `Team`.
|
||||
|
||||
This ADR settles the semantics needed to make the `MemberGroup` tables the real,
|
||||
in-hub group system.
|
||||
|
||||
## Decision
|
||||
|
||||
### Group system is in-hub, not a foreign service
|
||||
|
||||
`MemberGroup` is the platform's global member-group principal. It lives in the hub
|
||||
database and is managed through the `/database` backend. The contract's "separate
|
||||
service" framing was an unfrozen v0.1 proposal; the implementation aligns to the
|
||||
tables that were actually built. The `GroupResolver` port stays — an external
|
||||
`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the
|
||||
default implementation reads the in-hub `MemberGroup` closure.
|
||||
|
||||
### Authority: website administrator only
|
||||
|
||||
Group create/delete and member add/remove are restricted to the **website
|
||||
administrator**, defined (consistently with the rest of the file library, D19/C4
|
||||
adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in
|
||||
`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform
|
||||
control plane; the file library uniformly uses org OWNER/ADMIN today and this
|
||||
feature stays consistent with that. Reading groups for the authorization selector
|
||||
(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage
|
||||
holder's ability, not an administrator's.
|
||||
|
||||
### Resolution semantics (the crux)
|
||||
|
||||
`resolveMemberGroupIds(user)` returns the user's **active direct groups ∪ the
|
||||
active ancestors of those groups**, deduplicated (the closure's depth-0 self row
|
||||
makes each direct group its own ancestor). This is the single query the permission
|
||||
engine relies on; equivalently: a grant placed on group G applies to members of G
|
||||
and of every descendant of G (requirement 3.2 — permission flows down the tree, so
|
||||
resolution collects up the tree). It is computed **live, never cached** (contract
|
||||
D4/G4): a membership change is visible on the very next protected request.
|
||||
|
||||
MemberGroup is global (no `organizationId`), so resolution is not org-scoped.
|
||||
|
||||
### Soft delete via `archivedAt`, cascading the subtree
|
||||
|
||||
Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group
|
||||
cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where
|
||||
`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application
|
||||
operation, not a DB constraint. Closure and membership rows are **retained**;
|
||||
resolution and listing filter by `archivedAt`, so an archived group and everything
|
||||
under it stop contributing to permission at once.
|
||||
|
||||
### Closure maintenance
|
||||
|
||||
The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P`
|
||||
insert `(a.ancestorId, G, a.depth + 1)` for every `a` in
|
||||
`closure where descendantId = P`. v1 does **not** support reparenting a group
|
||||
(moving it under a new parent). The schema reserves reparent (closure rebuild plus
|
||||
the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.
|
||||
|
||||
### Rename and description edits are in scope; reparent stays out
|
||||
|
||||
A group's `name` and `description` are mutable by the website administrator
|
||||
(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately
|
||||
separated from reparent: renaming touches **no** closure row and cannot create a
|
||||
cycle, so it carries none of the invariant risk that keeps reparent out of v1. The
|
||||
endpoint therefore **rejects** a `parentId` field outright rather than ignoring it,
|
||||
so a future reparent cannot arrive silently through this route. Passing an empty
|
||||
`description` clears it; omitting a field leaves it unchanged.
|
||||
|
||||
### Restore is deliberately asymmetric with delete
|
||||
|
||||
Archived groups stay visible to the administrator (`GET
|
||||
/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the
|
||||
console tags and greys them) and can be restored (`POST
|
||||
/database/api/groups/:id/restore`, audited as `group.restore`).
|
||||
|
||||
Restore is **not** the mirror image of delete. Delete cascades down the whole
|
||||
subtree; restore un-archives **the group plus every archived ancestor of it, and
|
||||
nothing below it**:
|
||||
|
||||
- Restoring the ancestor chain is **mandatory**, not a convenience. An active group
|
||||
whose parent is archived has no path in the tree, and the `depth` derivation
|
||||
(closure row count) presumes "an active group's ancestors are active" — the
|
||||
invariant that cascade-delete establishes. Restoring a node alone would break it.
|
||||
- The subtree is deliberately **left archived**. A group's descendants may have been
|
||||
archived for reasons of their own, and one click should not silently re-grant
|
||||
permission across a whole historical branch. Descendants remain visible in their
|
||||
archived state and are each restored explicitly.
|
||||
|
||||
Restore takes effect immediately, like every other membership change (D4/G4): the
|
||||
group resumes contributing permission on the next resolution.
|
||||
|
||||
An archived group is **readable but not writable**. Its membership rows are never
|
||||
revoked by archiving, so `listMembers` succeeds on an archived group — the console
|
||||
must be able to show *who was in it* before deciding whether to restore it. Every
|
||||
mutation, by contrast, still requires an active group (`requireActiveGroup` → 404):
|
||||
rename, child creation, and member add/remove all reject. The group is inert for
|
||||
permission purposes and frozen for editing, but not hidden and not forgotten.
|
||||
|
||||
### Member picker reads global users, admin-only
|
||||
|
||||
`GET /database/api/users/search` backs the "add member" picker: it matches `User`
|
||||
by display name or Feishu open id and is gated to the website administrator, the
|
||||
same authority that may add members. It widens no existing capability — adding a
|
||||
member already accepts **any** global user (`resolveUser` does not require an org
|
||||
membership), so the endpoint only replaces blind id entry with search. It is
|
||||
deliberately **not** opened to the non-admin authorization-selector audience that
|
||||
`/groups/search` serves: choosing a group to grant is a Manage-holder action,
|
||||
whereas enumerating people is not. `excludeGroupId` filters out the target group's
|
||||
active members so the picker cannot surface a candidate that must 409.
|
||||
|
||||
### Audit is written in-hub
|
||||
|
||||
The contract (C3 §6.3) originally deferred group actions to the foreign Group
|
||||
service's own audit. With the group system in-hub, group mutations are audited
|
||||
through the existing file-library sink (`filelib/audit.ts`, same-transaction
|
||||
`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`,
|
||||
so the audit row is attributed to the silo org. New actions: `group.create`,
|
||||
`group.update`, `group.delete`, `group.restore`, `group.member_add`,
|
||||
`group.member_remove`; new audit object type `group`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader.
|
||||
`createTeamGroupResolver` is retained but deprecated (no longer wired); existing
|
||||
flat-Team group grants no longer resolve for the file library.
|
||||
- Group grants take effect in real time through the existing `effectiveRole`
|
||||
reducer (P6) with no change to the permission algebra — only the set of group ids
|
||||
fed to it changes.
|
||||
- v1 omits reparent; the closure invariants above must hold whenever reparent is
|
||||
added later (rebuild descendants' ancestor rows, reject cycles).
|
||||
- Group management is an admin-only surface; the authorization selector is not.
|
||||
- Numeric limits (max depth, max members) and a hard-delete/restore path remain
|
||||
follow-on operational decisions; they must not weaken the archived-filter,
|
||||
admin-authority, or live-resolution invariants fixed here.
|
||||
@@ -0,0 +1,132 @@
|
||||
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
|
||||
teacher-facing file library (`/app`), and the database admin back office
|
||||
(`/database`). They arrived at different times and diverged in how HTML reached
|
||||
the browser.
|
||||
|
||||
`/admin` and `/app` were already separated: the backend serves a prebuilt static
|
||||
`index.html` and never inspects the request; all data flows through JSON
|
||||
endpoints. `/database` was not. Roughly 1770 lines across four modules
|
||||
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
|
||||
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
|
||||
assembled HTML template strings server-side, reading the session cookie and
|
||||
querying Prisma inside the page handler, with layout expressed as inline
|
||||
`style="…"` attributes and behavior as `<script>` text.
|
||||
|
||||
A prior migration (`12628c9`) introduced a fourth frontend project,
|
||||
`hub/database-admin/`, intended to replace those pages. It was never wired up:
|
||||
the concrete route `/database/dashboard` is more specific than the SPA wildcard
|
||||
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
|
||||
was unreachable. That project's file header claimed the SPA served the dashboard
|
||||
and that `/database/config` existed; neither was true. The `npm run build` script
|
||||
also never built it, so the `existsSync` guard in `database/static.ts` failed on
|
||||
every deploy and the shell was permanently disabled.
|
||||
|
||||
Duplicated visual rules were the practical cost: card padding and type sizes were
|
||||
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
|
||||
were genuinely shared.
|
||||
|
||||
## Decision
|
||||
|
||||
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
|
||||
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
|
||||
request; all per-user and per-request data is fetched by the client from JSON
|
||||
endpoints under `/api/*` or `/database/api/*`.
|
||||
|
||||
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
|
||||
and mounted at two prefixes.** They share the file library browser, the session
|
||||
layer, the toast host, and the design tokens; splitting them would duplicate all
|
||||
of it. `hub/database-admin` is deleted — superseded before it ever served a
|
||||
request.
|
||||
|
||||
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
|
||||
Fastify instance, and are load-bearing:
|
||||
|
||||
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
|
||||
with the root `/_app/*` asset route that `admin-web` owns
|
||||
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
|
||||
collision is a boot failure, not a silent misroute.
|
||||
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
|
||||
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
|
||||
paths would resolve against the wrong base.
|
||||
|
||||
**Client-side navigation uses real URL routes, not hash fragments or hidden
|
||||
sections.** The six back-office tabs are `/database/dashboard`,
|
||||
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
|
||||
Refresh preserves position and links are shareable — the previous
|
||||
`location.hash` + `display:none` scheme lost both.
|
||||
|
||||
Concrete routes must be registered before the SPA wildcards. This is an ordering
|
||||
obligation on `database/plugin.ts`, not an incidental detail: the earlier
|
||||
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
|
||||
route outranks the fallback.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
|
||||
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
|
||||
carries no security weight; every endpoint keeps its own `fail closed` guard.
|
||||
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
|
||||
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
|
||||
org-wide counts and the audit stream rather than a per-node permission view.
|
||||
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
|
||||
handler read from Prisma to render chrome has to become part of a JSON payload
|
||||
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
|
||||
until these were added. When migrating a server-rendered surface, the data the
|
||||
template closed over is part of the contract being ported, not an incidental
|
||||
detail of the old implementation.
|
||||
- Editing a page no longer requires a Hub restart in development; `vite dev`
|
||||
serves the frontend and proxies data requests to the Hub. In production the
|
||||
`index.html` is cached in memory at startup, so a frontend rebuild does require
|
||||
a restart.
|
||||
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
|
||||
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
|
||||
extending that list.
|
||||
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
|
||||
tokens plus an `@layer components` block for the shared component classes
|
||||
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
|
||||
`routes/uiTheme.ts` is deleted; both halves live there now.
|
||||
|
||||
The first cut of this migration kept only the tokens and restated button,
|
||||
input, and panel styling inline in every component. That reproduced the
|
||||
duplication the old code had — the admin panels visibly regressed — so the
|
||||
component layer was ported too. Components carry layout utilities; they do not
|
||||
restate component styling. The one admitted exception is a data-derived value
|
||||
(tree indent computed from `depth`), which cannot be a static class.
|
||||
|
||||
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
|
||||
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
|
||||
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
|
||||
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
|
||||
|
||||
- **A migrated surface is only done when its endpoint coverage matches.** Two
|
||||
panels were rebuilt from a superficially similar component that predated the
|
||||
migration rather than from the server module they replaced, and the mismatch
|
||||
was invisible in the rendered page:
|
||||
|
||||
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
|
||||
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
|
||||
a soft-deleted group could not be restored through the UI at all even though
|
||||
the backend fully supported it.
|
||||
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
|
||||
.../grants` and `PUT .../independent-permission` had no caller. Permission
|
||||
editing is the point of the back office, and it was unreachable.
|
||||
|
||||
Diffing the route table against the frontend's `api()` call sites catches this;
|
||||
reading the new page does not.
|
||||
|
||||
## Deferred
|
||||
|
||||
- `/admin` (admin-web) stays a separate project. It has its own design language
|
||||
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
|
||||
merging it is not motivated by shared code.
|
||||
- The `search` and `settings` tabs remain placeholders, as they were server-side.
|
||||
- Serving `/admin` and `/database` from a single SPA, which would remove the
|
||||
`appDir` collision constraint entirely.
|
||||
@@ -0,0 +1,157 @@
|
||||
# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The file library (`hub/src/database/filelib/`, an independent subsystem that does
|
||||
not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project
|
||||
as a versioned file tree behind the `VersionStore` port (contract C1). Until now
|
||||
the only implementation was `createInMemoryVersionStore`: a `Map` of per-file
|
||||
version chains, with `VersionId` as a per-repository monotonic counter
|
||||
(`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the
|
||||
entire storage root written to `<storageRoot>/.version-store.json` so that a
|
||||
process restart did not lose the demo data.
|
||||
|
||||
Two things about the surrounding design were already settled in code and are
|
||||
confirmed here rather than changed:
|
||||
|
||||
- **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is
|
||||
`NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path;
|
||||
nothing in the filesystem mirrors it.
|
||||
- **Projects are flat under one root, keyed by id.** `storageDir` is
|
||||
`<storageRoot>/<nodeId>` where `nodeId` is a `randomUUID()`. Names never enter
|
||||
the path, which is why `renameNode` touches no disk state and does not rewrite
|
||||
descendant paths.
|
||||
|
||||
What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was
|
||||
documented as "the project git repository root" and `fileService` was documented
|
||||
as observing a "git first, then audit" ordering, but no code in the repository
|
||||
ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the
|
||||
directory was never created. Every project's entire content and history lived in
|
||||
one process-global JSON file. The header comment and `README.md` both marked this
|
||||
as a placeholder awaiting an npm package from the versioning team.
|
||||
|
||||
That package has not arrived, and the in-memory store's properties are not
|
||||
acceptable for real teacher data: a corrupt or lost `.version-store.json` loses
|
||||
every project at once, the whole storage root is rewritten on every commit, and
|
||||
`VersionId` values are meaningless outside the process that minted them.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each file library project is a real Git repository at
|
||||
`<storageRoot>/<nodeId>`.** `VersionStore.init` creates the directory and runs
|
||||
`git init` there. This is the production implementation;
|
||||
`createInMemoryVersionStore` is retained for tests only.
|
||||
|
||||
**`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by
|
||||
`git rev-parse`. It is no longer a per-repository counter.
|
||||
|
||||
**File-level versioning (D16) maps onto commit history as follows.** A write
|
||||
touches exactly one path and produces exactly one commit. The version of a file is
|
||||
the hash of the most recent commit that modified that path — `git log -1 --
|
||||
<path>`. Consequently:
|
||||
|
||||
- Two files in one project have independent versions, because a commit that
|
||||
touches `a.md` does not appear in `git log -- b.md`. This preserves the D16
|
||||
property that advancing one file does not invalidate another file's
|
||||
`baseVersion`, even though commits are repository-global objects.
|
||||
- `baseVersion` checking (S1/S2) compares the caller's id against the current
|
||||
per-file version. `baseVersion: null` means create, and conflicts if the path
|
||||
already exists at `HEAD`.
|
||||
- Reading version `V` of a path means `git show V:<path>`, which is the content as
|
||||
of that commit, not the content the commit introduced to some other file.
|
||||
|
||||
**Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and
|
||||
commits, so the path is absent from `HEAD` and `list` stops reporting it, while
|
||||
`git show <olderVersion>:<path>` still resolves. The in-memory store expressed
|
||||
this as a `deleted: true` chain entry; the observable API semantics are the same.
|
||||
|
||||
**Git is invoked as a subprocess, not through a library.** `node:child_process`
|
||||
`execFile` with an argument array, no new npm dependency. Every invocation is
|
||||
hardened, and the hardening is load-bearing rather than incidental:
|
||||
|
||||
- `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus
|
||||
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project
|
||||
repository is *data*, uploaded by teachers. Without this, a committed
|
||||
`.git/hooks/` entry or a developer's global `gitconfig` would execute or alter
|
||||
server-side behavior.
|
||||
- `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never
|
||||
reinterpreted as an option or as pathspec magic (`:(glob)`).
|
||||
- `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on
|
||||
credentials.
|
||||
- Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
|
||||
environment variables, never written into the repository's config. The git
|
||||
author name is the acting user's `displayName` (falling back to `userId` when
|
||||
absent), and the email is `<userId>@filelib.paradigm-edu.net`. The email
|
||||
deliberately keys on `userId` rather than the display name, because nicknames
|
||||
change and identity attribution must not drift with them. Characters that would
|
||||
break git's ident line (`<`, `>`, newlines) are stripped from the name.
|
||||
- `--git-dir=<projectDir>/.git` and `--work-tree=<projectDir>` are pinned on
|
||||
every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/
|
||||
`GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise
|
||||
searches *upward* for a `.git`, and the storage root is frequently nested inside
|
||||
another repository — the local development default `hub/.filelib-repos` sits
|
||||
inside this very repo. Without pinning, operations on a project directory that
|
||||
has no repository of its own silently retarget the enclosing repository.
|
||||
Existence is therefore tested on the filesystem (`<projectDir>/.git`), not with
|
||||
`git rev-parse --git-dir`, which merely echoes a pinned value back.
|
||||
|
||||
**Writes to one repository remain serialized in-process**, as under S4, because
|
||||
concurrent git invocations contend on `index.lock`. This is a single-process
|
||||
guarantee only; see Consequences.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `.version-store.json` is not read or migrated by the new store. Existing
|
||||
development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git
|
||||
store; those projects report `repo_not_found` until recreated. No production
|
||||
data exists to migrate, since the in-memory store was never production-viable.
|
||||
- `VersionId` changes shape in API responses (`GET .../files/*`, history, and the
|
||||
409 `currentVersion` detail). Clients must keep treating it as an opaque
|
||||
string; `filelib-web` already does.
|
||||
- `VersionInfo.author` now comes back as the git author name, which is the acting
|
||||
user's display name at commit time (or the `userId` when no display name is
|
||||
known). Commits written without an author carry a fixed `filelib` identity
|
||||
rather than `undefined`. Display names are point-in-time: renaming a user does
|
||||
not rewrite existing commits, and the stable identifier stays in the email.
|
||||
- `VersionStore.commit`/`remove` take a structured `CommitAuthor`
|
||||
(`{ userId, displayName? }`) rather than a bare author string, so the port can
|
||||
express both the stable key and the display label. Deletion carries the same
|
||||
identity as any other commit.
|
||||
- Serialization is per-process. Two Hub processes sharing a storage root can race
|
||||
on the same repository and surface a git lock error rather than a clean
|
||||
conflict. The alpha Silo deployment (ADR-0025) is one process per organization,
|
||||
so this is not currently reachable; a multi-process deployment needs either a
|
||||
database advisory lock keyed by project id or a single writer.
|
||||
- `git` must be present on the host. Absence is a startup-visible failure of
|
||||
project creation (`provision_failed`), not a silent degradation.
|
||||
- Repository content is now attacker-influenced data on disk. The path validation
|
||||
in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths,
|
||||
control characters) moves from hygiene to a security boundary, and
|
||||
`versionStore` re-checks it rather than trusting callers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that
|
||||
three `execFile` calls do. `isomorphic-git` additionally reimplements the object
|
||||
layer, so its bugs would be ours to diagnose.
|
||||
- **One commit per repository state, with the repository head as the version.**
|
||||
Simpler mapping, but it breaks D16: any write would invalidate every other
|
||||
file's `baseVersion`, turning independent edits into false conflicts.
|
||||
- **Keeping the counter as `VersionId` alongside git.** Requires a durable
|
||||
counter-to-hash mapping outside git, which is the state the decision removes.
|
||||
- **Bare repositories with a git index-only write path.** Avoids a working tree,
|
||||
but every read and write becomes plumbing (`hash-object`, `update-index`,
|
||||
`commit-tree`), for no benefit at this scale.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Cross-process write serialization (advisory lock keyed by project id).
|
||||
- Garbage collection and pack maintenance policy for long-lived repositories.
|
||||
- Whether export builds (`exportService`) should read a git tree directly instead
|
||||
of going through the `listFiles`/`readFile` port.
|
||||
- Recovering `provisionStatus=FAILED` projects by re-running `init`; the status
|
||||
machine records the failure but nothing retries it yet.
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
|
||||
since deleted; recoverable from git history) which introduced the per-project
|
||||
"独立权限" (independent permission) switch.
|
||||
|
||||
## Context
|
||||
|
||||
D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
|
||||
default off). While off, project-level non-creator grants were **frozen** — present in
|
||||
`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
|
||||
auto-grant were unaffected. The intent was to support two workflows: "project follows the
|
||||
folder's ACL" (off) vs "project has its own ACL" (on).
|
||||
|
||||
In practice the toggle surprised operators twice: grants appeared to "not work" until
|
||||
someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
|
||||
was indistinguishable from missing grants in the UI. The product decision is that
|
||||
project-level grants should simply always be live.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
|
||||
`hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
|
||||
carries `independentPermissionsEnabled`.
|
||||
- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
|
||||
`grantService.setIndependentPermission`, the `independentPermission` field in the node
|
||||
detail DTO, and the 概览 tab switch in `filelib-web`.
|
||||
- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
|
||||
ignored, no data migration); new projects no longer get a default row. It may be dropped
|
||||
in a future migration once nothing references it.
|
||||
- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
|
||||
reading historical audit entries; no new entries are produced.
|
||||
|
||||
Behavior change for existing deployments: projects whose toggle was off now have their
|
||||
project-level grants effective immediately — this is the intended effect of the decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Permission semantics shrink to the single P6 rule: `effective = max(grants on self ∪
|
||||
ancestors for user ∪ resolved groups)`, no exceptions by node kind.
|
||||
- One less state dimension in tests and in the admin UI.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
|
||||
最近打开 / 回收站. Two of them need semantics that no prior decision covers:
|
||||
|
||||
- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
|
||||
a node is invisible when any ancestor is deleted) but never defined listing, restore,
|
||||
or permanent deletion.
|
||||
- **最近打开 (recent visits)**: nothing tracks opens.
|
||||
|
||||
## Decision
|
||||
|
||||
### Recycle bin
|
||||
|
||||
- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
|
||||
(the topmost deleted node per branch; descendants of a deleted node are represented
|
||||
by it and not listed separately).
|
||||
- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
|
||||
(b) any actor holding an active MANAGE grant **on the deleted node itself**
|
||||
(grants stay live through soft delete, so this is a plain grant query — no chain
|
||||
walk, no inheritance; the bin is a management surface, not a browsing surface).
|
||||
- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
|
||||
node, restore unmarks one node). The subtree becomes visible again immediately.
|
||||
Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
|
||||
- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
|
||||
node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
|
||||
path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
|
||||
transaction, with one audit entry (`node_purge`, detail carries removed count).
|
||||
Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
|
||||
explicitly.
|
||||
|
||||
### Recent visits
|
||||
|
||||
- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
|
||||
unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
|
||||
`""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
|
||||
visit is the node itself (drill into folder/project); non-empty means a file preview
|
||||
inside that project.
|
||||
- **Recording is client-driven**: the teacher app POSTs after a successful open
|
||||
(folder drill, project open, file preview). The endpoint requires VIEW on the node
|
||||
(D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
|
||||
`openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
|
||||
mutation.
|
||||
- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
|
||||
deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
|
||||
on every surface). Names are read live from `FileLibNode` (no denormalization).
|
||||
|
||||
## Consequences
|
||||
|
||||
- No change to existing permission algebra; both features are additive surfaces.
|
||||
- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
|
||||
if real usage demands it, that is a new decision.
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0032: Remove The Recent-Visit Module
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
|
||||
is unaffected and remains in force).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
|
||||
visit recording, and a rail entry in the teacher app. After seeing it live, the product
|
||||
call is that the module is not wanted — it adds a tracking surface, a table, and rail
|
||||
noise without a compelling teacher workflow behind it.
|
||||
|
||||
## Decision
|
||||
|
||||
The recent-visit module is removed end-to-end:
|
||||
|
||||
- `FileLibRecentVisit` is dropped (hand-written migration
|
||||
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
|
||||
held no production data).
|
||||
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
|
||||
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
|
||||
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
|
||||
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
|
||||
cheap, additive field on an existing API and independent of the removed module.
|
||||
|
||||
If recent-visit tracking comes back as a requirement, it is a new decision (and
|
||||
should then define why client-driven tracking is worth its surface) rather than a
|
||||
revival of this one.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover
|
||||
the case where a same-name sibling was created **after** the deletion: D14's partial
|
||||
unique index (active siblings, case-insensitive) then rejects the restore with a 409
|
||||
`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for
|
||||
non-admin users (who cannot purge) and cryptic for admins.
|
||||
|
||||
## Decision
|
||||
|
||||
Restore never fails on a name conflict. Before clearing `deletedAt`, the service
|
||||
checks active siblings; if the node's name is taken, it restores as
|
||||
`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included
|
||||
in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of
|
||||
the same transaction and is recorded in the restore audit entry as
|
||||
`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user.
|
||||
|
||||
Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a
|
||||
trap, not a safeguard. Users who care about the name can rename afterwards (they have
|
||||
MANAGE by definition of bin visibility).
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is
|
||||
website-administrator only".
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation
|
||||
precaution. The product call is that this is inconsistent with the rest of the
|
||||
permission model: soft delete already requires only MANAGE on the node, and a
|
||||
MANAGE holder who can delete a node into the bin should also be able to purge it —
|
||||
the authority that grants deletion grants destruction. Admin-only purge strands
|
||||
non-admin managers with bins they cannot empty.
|
||||
|
||||
## Decision
|
||||
|
||||
Permanent delete uses **the same visibility rule as the bin entry itself**: website
|
||||
administrator, or an actor with an active MANAGE grant on the deleted node (direct
|
||||
grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation
|
||||
in the UI and the `node.purge` audit entry are unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces.
|
||||
- The operation remains irreversible and audited; no new capability is granted to
|
||||
anyone who could not already delete the node (soft) and see it in the bin.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user