feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Trellis: append-only developer journals should merge cleanly across
|
||||
# parallel sessions/worktrees — each session only appends a new block, so
|
||||
# there is nothing to actually conflict on.
|
||||
#
|
||||
# Do NOT add a rule for workspace/*/index.md here — it is fully regenerated
|
||||
# every session, so a real conflict there is expected and safe to resolve by
|
||||
# picking either side (task state lives in task.json, not index.md). See
|
||||
# .trellis/spec/cli/backend/directory-structure.md for details.
|
||||
.trellis/workspace/*/journal-*.md merge=union
|
||||
@@ -0,0 +1,8 @@
|
||||
/target/
|
||||
/config/setting.json
|
||||
/.release-key.*/
|
||||
/.trellis/workspace/
|
||||
/.agents/
|
||||
/.codex/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "skills"]
|
||||
path = skills
|
||||
url = https://github.com/shemic/dever-main-skills.git
|
||||
@@ -0,0 +1,32 @@
|
||||
# Developer identity (local only)
|
||||
.developer
|
||||
|
||||
# Current task pointer (each dev works on different task)
|
||||
.current-task
|
||||
|
||||
# Session/window scoped runtime state
|
||||
.runtime/
|
||||
|
||||
# Ralph Loop state file
|
||||
.ralph-state.json
|
||||
|
||||
# Agent runtime files
|
||||
.agents/
|
||||
.agent-log
|
||||
.session-id
|
||||
|
||||
# Task directory runtime files
|
||||
.plan-log
|
||||
|
||||
# Atomic update temp files
|
||||
*.tmp
|
||||
|
||||
# Update backup directories
|
||||
.backup-*
|
||||
|
||||
# Conflict resolution temp files
|
||||
*.new
|
||||
|
||||
# Python cache
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"__version": 2,
|
||||
"hashes": {
|
||||
".agents/skills/trellis-continue/SKILL.md": "2b948434883636a1f1e2a4ad5b8a3d7dc2edc93ed05da6bca82882e8c024e22b",
|
||||
".agents/skills/trellis-finish-work/SKILL.md": "161060fbcd44f787440d3a5c297a9f5223ea7774bb3021a50e376875a9ac5b2d",
|
||||
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
||||
".agents/skills/trellis-before-dev/SKILL.md": "00c9d1c83bc318e91b27a019bc954c2957cfad907ff20d854b596f16cad2c0f3",
|
||||
".agents/skills/trellis-brainstorm/SKILL.md": "17d9bf209730c14f584eb97f38198d0683ad3f8da982d9a6e6d4e84c853f9a94",
|
||||
".agents/skills/trellis-break-loop/SKILL.md": "f5a93699832f29dee443b53c135a7459519b371f689af191d15c29e8ee5c7bde",
|
||||
".agents/skills/trellis-check/SKILL.md": "dfb0600e95c19c7a83200465b6a92a5514ec9778ba4083efa881bed541cc74a8",
|
||||
".agents/skills/trellis-update-spec/SKILL.md": "003ce08a3404aeb50998029392c4d4e57b626edf526d3ebd585032bb92dcbb96",
|
||||
".agents/skills/trellis-channel/references/command-reference.md": "c4df3d940d89814310bfdaded20e84dd1d6b96786f6772688de7a123338d3c8e",
|
||||
".agents/skills/trellis-channel/references/forum.md": "407db39e8ebde47cea8637e3069bdb3d76ba42b43ed1d08ba97a56f26dbcfcb4",
|
||||
".agents/skills/trellis-channel/references/progress-debugging.md": "93b021fd8d5d3d4ee54952c40424ab91ca57c2ab8032bfbfc3b5c5e22f1464cd",
|
||||
".agents/skills/trellis-channel/references/workers.md": "d4481b2858bca82050cb8d131916da8a2ccc3857f199cdb9ced8bb41d592e2a7",
|
||||
".agents/skills/trellis-channel/references/workflows.md": "0782d602efde6e94a7d7d7950fd605eedc9a1f5a523908284e76b628fd618fd4",
|
||||
".agents/skills/trellis-channel/SKILL.md": "ff84801c511a736da018b4b1149348ebc9a16b7fc18d905ec10cfa7acbf269a3",
|
||||
".agents/skills/trellis-meta/references/customize-local/add-project-local-conventions.md": "86009ccb5d0373f399582da0bc570c4e5c6053c3c764857424ff93384f0e04e5",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-agents.md": "4216ac3cc570038fd8ce3319a85932bb537fea9e0a7f4d11fa315cfa645c9c85",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-context-loading.md": "350d319dc1ab99609ddbf52cf8c06c71bd97ba1a29ce2eac8b97d0bb192938bf",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-hooks.md": "4c18b134f05d5ba1609c517ec94c97c9442b5f0670aa2211c487c31ed2a4f358",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-skills-or-commands.md": "5b942e9f512e049a75e8dd9e8cc4d49786f6da61b22afc82bb447acf808f9fe2",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-spec-structure.md": "31eccaad7097d96e66a45c1b4caea1ba4f2e54b7814184c3ebf82c87dafc4841",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
|
||||
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
|
||||
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
|
||||
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
|
||||
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "b1ebaac467bf0195e2a21f2f8492a8b7c91ff3e0f9312f853468cda0255df1c7",
|
||||
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
|
||||
".agents/skills/trellis-meta/references/local-architecture/overview.md": "50638fd9eaaba2e0edf2f2a84d920578d5b2fb7934031b174e417d3dc510b2a6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/spec-system.md": "b8d8a6a0888b44a232c8f50161b9e20e903cf621ad7be4021715ab6fab226f47",
|
||||
".agents/skills/trellis-meta/references/local-architecture/task-system.md": "b154568e74f1c738a51633df9f2503f416b3afa5f309325224422162e0b707e4",
|
||||
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
|
||||
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
|
||||
".agents/skills/trellis-meta/references/platform-files/agents.md": "b806b1f0de6dfc74720014aee15ede767a58925b2b3b964f2e557ed39a65bbc0",
|
||||
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "91e9855e418673bcd7770411e24e28390738b123532eef000beba0a29cf154e1",
|
||||
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
|
||||
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "3c0d4546461c06d1aaf006276e34e250e0ca7bbb5b853fb45c58114cebca4042",
|
||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||
".agents/skills/trellis-meta/SKILL.md": "ee7bcfd0023def62688339c4b63c50f2fbeea12a34eb89e8bb63a992b58cb168",
|
||||
".agents/skills/trellis-session-insight/references/cli-quick-reference.md": "c520353fe3fc00b9702ed4f780647c8fbd6d342e66527a03647f533a9fe09779",
|
||||
".agents/skills/trellis-session-insight/references/triggering-patterns.md": "121ecd23be83d1567e8ce15c366a81073d7a2b1d3ad616fce235c07ca1f1cc20",
|
||||
".agents/skills/trellis-session-insight/SKILL.md": "d20f1d20c6946e26ba6f848b9a016d9a16c803d87501462e5b11a2ced6f56643",
|
||||
".agents/skills/trellis-spec-bootstrap/references/mcp-setup.md": "df542fc8f279edd38046d26a7c8151804b708f57b24d4aa2733cea587a88c65e",
|
||||
".agents/skills/trellis-spec-bootstrap/references/repository-analysis.md": "0dae98d774f6e34559b9f3442888ac43e3a8af110c37cbefc49ce256986858b6",
|
||||
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".codex/agents/trellis-check.toml": "206fd96a8aa17e95ed344cc435661d597093ece571671f298ca64490422fffe0",
|
||||
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||
".codex/hooks/session-start.py": "efdd015c7c4687227ce34a84ac6249d5554c704f737e00d4844c9284ddcf696f",
|
||||
".codex/hooks/inject-subagent-context.py": "9b68be8d08f85e824e39d901a29f14d38346dbeeda15880e4cba03076f2a8caa",
|
||||
".codex/hooks/inject-workflow-state.py": "71ef1aa1ed1051a697cae3c9ebffa518958a3bbd4d147434f382348829915f8c",
|
||||
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
||||
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
|
||||
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
|
||||
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
|
||||
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
|
||||
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa",
|
||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||
".trellis/scripts/add_session.py": "f61a452ec5f7eece8d7e0f0dd56ce5851166cbc83ca4822e30acbb36eeb5896f",
|
||||
".trellis/scripts/common/__init__.py": "f258cd873681dc5a4d71db0dd71d10fd78656de55bec96418d6702503c16e87d",
|
||||
".trellis/scripts/common/active_task.py": "f52715bbcc0677607055903e799674d7d50d6fcbab413a8f453c4e4efc954726",
|
||||
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
|
||||
".trellis/scripts/common/config.py": "9a042d6f0e25a33a567510760e87c1e389af14a41faf1daeb1190a00f3df7f5e",
|
||||
".trellis/scripts/common/developer.py": "a0f61bb58063e2f4cb357bcb2c22fd5a6f1241286398beaa7130b2a8994a4afa",
|
||||
".trellis/scripts/common/git.py": "5605b71662e524b47632030d9db9245121a5cba85b5521eeb524ed464d840ff5",
|
||||
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
||||
".trellis/scripts/common/io.py": "5dd8b9e297125ff58543a9bd4790c03ae1aca1d37b9e2ca60c4ffb47601c1cbb",
|
||||
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
||||
".trellis/scripts/common/paths.py": "d8586d4d3f9bb2dd46b7f11b0f038b5340d283f41b6fa2c76bb8235c13bbb0b7",
|
||||
".trellis/scripts/common/safe_commit.py": "262ad740552279fe6ec6a2a9865b2965ce2448577cf973a4d702dfe2f5f91f04",
|
||||
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
|
||||
".trellis/scripts/common/task_context.py": "245e654713e6030f9e7b5cee474efc06343ecc0d7f66f6455098874239355ce3",
|
||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||
".trellis/scripts/common/task_store.py": "5712b0405e1fd0c2b7be5e5fbd420fa6eec2c49c8e4cf2b8fc69b7d2e4aeb016",
|
||||
".trellis/scripts/common/task_utils.py": "35f544317a95e98d05fe089794071482175739e8c1f3b3b0228dae48602ffe90",
|
||||
".trellis/scripts/common/tasks.py": "87aca6b73ec4524450cfff8e2ae561b1f2e53bd62a185f036a7a2d484d90c8a2",
|
||||
".trellis/scripts/common/trellis_config.py": "f1fc705ec463b0b9c163672dc9e50f10d29da967775a1900429205f7c4fcb51c",
|
||||
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
|
||||
".trellis/scripts/common/workflow_phase.py": "146b69c1ec0e628aeb720d3e34d8333f16050225037fe4233da9bbc4d4ac99c1",
|
||||
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
|
||||
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
|
||||
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
|
||||
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
|
||||
".trellis/scripts/task.py": "b415e0a2dbc9e8cba1ada0c7997999aaaaea0eec96d3419db640830e88e84cfe",
|
||||
".trellis/workflow.md": "eb4508a4f6792f3d53705c1f9e7d21807dabd774543bd7b01f56372a704f6ad0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0.6.16
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: check
|
||||
description: |
|
||||
Code quality auditor for the Trellis channel runtime. Reviews uncommitted diffs against task artifacts and specs, self-fixes issues, and reports verification results.
|
||||
provider: claude
|
||||
labels: [trellis, check]
|
||||
---
|
||||
|
||||
# Check Agent (channel runtime)
|
||||
|
||||
You are the Check Agent spawned by `trellis channel spawn --agent check` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||
|
||||
## Context
|
||||
|
||||
Before reviewing, read in this order:
|
||||
|
||||
1. `<task-path>/check.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||
2. `<task-path>/prd.md` — requirements
|
||||
3. `<task-path>/design.md` if present — technical design
|
||||
4. `<task-path>/implement.md` if present — execution plan
|
||||
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff under review)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Get the diff** — `git diff` / `git diff --staged` for uncommitted changes
|
||||
2. **Review against task artifacts** — does the diff satisfy `prd.md` (and `design.md` / `implement.md` if present)?
|
||||
3. **Review against specs** — naming, structure, type safety, error handling, conventions in `.trellis/spec/`
|
||||
4. **Self-fix** — when an issue is mechanical and small, fix it directly with the editing tools you have
|
||||
5. **Run verification** — project lint and typecheck on the changed scope
|
||||
6. **Report** — concrete findings with `file:line` citations and what was fixed vs. what is open
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
- `git commit`
|
||||
- `git push`
|
||||
- `git merge`
|
||||
|
||||
The supervising main session owns commits. Report the post-fix state; do not commit on its behalf.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run `git diff --name-only` and `git diff` to scope the changes
|
||||
2. Read the task artifacts and relevant spec files
|
||||
3. For each issue:
|
||||
- If mechanical (lint nit, missing type, wrong import, dead branch) → fix in-place
|
||||
- If a design/judgment issue → record and report, do not silently rewrite
|
||||
4. Run the project's lint and typecheck on the changed scope after self-fixes
|
||||
5. Report
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
## Self-Check Complete
|
||||
|
||||
### Files Checked
|
||||
- <path>
|
||||
|
||||
### Issues Found and Fixed
|
||||
1. `<file>:<line>` — <what was wrong> → <what you changed>
|
||||
|
||||
### Issues Not Fixed
|
||||
- `<file>:<line>` — <issue> — <why deferred to the main session>
|
||||
|
||||
### Verification Results
|
||||
- TypeCheck: <pass|fail|skipped + reason>
|
||||
- Lint: <pass|fail|skipped + reason>
|
||||
|
||||
### Summary
|
||||
Checked <N> files, found <X> issues, fixed <Y>, <X-Y> open.
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: implement
|
||||
description: |
|
||||
Code implementation expert for the Trellis channel runtime. Understands specs and task artifacts, then implements features. No git commit allowed.
|
||||
provider: claude
|
||||
labels: [trellis, implement]
|
||||
---
|
||||
|
||||
# Implement Agent (channel runtime)
|
||||
|
||||
You are the Implement Agent spawned by `trellis channel spawn --agent implement` inside the Trellis channel runtime. You receive an `Active task: <path>` line in your inbox; use it to locate task artifacts on disk.
|
||||
|
||||
## Context
|
||||
|
||||
Before implementing, read in this order:
|
||||
|
||||
1. `<task-path>/implement.jsonl` if present — spec manifest curated for this turn; read every listed file
|
||||
2. `<task-path>/prd.md` — requirements
|
||||
3. `<task-path>/design.md` if present — technical design
|
||||
4. `<task-path>/implement.md` if present — execution plan
|
||||
5. `.trellis/spec/` — project-wide guidelines (load only what is relevant to the diff you are about to write)
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Understand specs** — read relevant spec files in `.trellis/spec/`
|
||||
2. **Understand task artifacts** — read the artifacts listed above
|
||||
3. **Implement features** — write code that follows specs and existing patterns
|
||||
4. **Self-check** — run lint and typecheck on the changed scope before reporting
|
||||
|
||||
## Forbidden Operations
|
||||
|
||||
- `git commit`
|
||||
- `git push`
|
||||
- `git merge`
|
||||
|
||||
The supervising main session owns commits. Report what changed; do not commit on its behalf.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read relevant specs based on task type and the files in `implement.jsonl` if present
|
||||
2. Read the task's `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
3. Implement features following specs and existing patterns
|
||||
4. Run the project's lint and typecheck commands on the changed scope
|
||||
5. Report files touched, key decisions, and verification results back to the channel
|
||||
|
||||
## Code Standards
|
||||
|
||||
- Follow existing code patterns
|
||||
- Don't add unnecessary abstractions
|
||||
- Only do what the PRD asks for; no speculative scope expansion
|
||||
- Surface uncertainty back to the channel rather than guessing
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
### Files Modified
|
||||
- <path> — <one-line description>
|
||||
|
||||
### Implementation Summary
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
### Verification Results
|
||||
- Lint: <pass|fail|skipped + reason>
|
||||
- TypeCheck: <pass|fail|skipped + reason>
|
||||
|
||||
### Open Questions
|
||||
- <if any, otherwise omit>
|
||||
```
|
||||
@@ -0,0 +1,161 @@
|
||||
# Trellis Configuration
|
||||
# Project-level settings for the Trellis workflow system
|
||||
#
|
||||
# All values have sensible defaults. Only override what you need.
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Session Recording
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Commit message used when auto-committing journal/index changes
|
||||
# after running add_session.py
|
||||
session_commit_message: "chore: record journal"
|
||||
|
||||
# Maximum lines per journal file before rotating to a new one
|
||||
max_journal_lines: 2000
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Session Auto-Commit
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Auto-commit behavior for session journal + task archive operations.
|
||||
# - true (default): scripts auto-stage and auto-commit journal / task changes
|
||||
# after add_session.py / task.py archive runs.
|
||||
# - false: scripts do not touch git. Files (journal-*.md, task archive moves)
|
||||
# are still written to disk; you decide whether to git add / commit.
|
||||
#
|
||||
# Use `false` if your project's .gitignore intentionally excludes `.trellis/`
|
||||
# and you want session data kept local-only, or if you prefer to review
|
||||
# staged changes manually before each commit.
|
||||
#
|
||||
# Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive).
|
||||
#
|
||||
# session_auto_commit: true
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Task Lifecycle Hooks
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Shell commands to run after task lifecycle events.
|
||||
# Each hook receives TASK_JSON_PATH environment variable pointing to task.json.
|
||||
# Hook failures print a warning but do not block the main operation.
|
||||
#
|
||||
# hooks:
|
||||
# after_create:
|
||||
# - "echo 'Task created'"
|
||||
# after_start:
|
||||
# - "echo 'Task started'"
|
||||
# after_finish:
|
||||
# - "echo 'Task finished'"
|
||||
# after_archive:
|
||||
# - "echo 'Task archived'"
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Monorepo / Packages
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
# Declare packages for monorepo projects.
|
||||
# Trellis auto-detects workspaces during `trellis init`, but you can also
|
||||
# configure them manually here.
|
||||
#
|
||||
# packages:
|
||||
# frontend:
|
||||
# path: packages/frontend
|
||||
# backend:
|
||||
# path: packages/backend
|
||||
# docs:
|
||||
# path: docs-site
|
||||
# type: submodule
|
||||
# # For polyrepo / meta-repo layouts (independent .git in each subdir),
|
||||
# # mark the package with `git: true`. The runtime treats it as an
|
||||
# # independent repository for things like git-context display.
|
||||
# webapp:
|
||||
# path: ./webapp
|
||||
# git: true
|
||||
|
||||
# Default package used when --package is not specified.
|
||||
# default_package: frontend
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Channel worker OOM guard
|
||||
#-------------------------------------------------------------------------------
|
||||
# Default safeguards for `trellis channel spawn` workers. The guard runs
|
||||
# at spawn time (cleans expired idle workers, then enforces the live-worker
|
||||
# budget) and inside each supervisor (self-terminates a worker that stays
|
||||
# continuously idle past `idle_timeout`).
|
||||
#
|
||||
# Precedence: CLI flag > env var (TRELLIS_CHANNEL_WORKER_IDLE_TIMEOUT /
|
||||
# TRELLIS_CHANNEL_MAX_LIVE_WORKERS) > this config > built-in default.
|
||||
#
|
||||
# `idle_timeout: 0` disables idle cleanup (workers can sit idle forever
|
||||
# unless explicitly killed or given `--timeout`).
|
||||
# `max_live_workers: 0` disables the spawn-time budget check.
|
||||
#
|
||||
# `trusted_context_dirs` extends the `--file`/`--jsonl`/`--agent` containment
|
||||
# check beyond the worker cwd — useful when `.trellis/tasks` or
|
||||
# `.trellis/workspace` is a symlink to an external directory. Realpaths under
|
||||
# any listed dir are accepted in addition to cwd.
|
||||
# `auto_trust_trellis_symlinks: false` disables the narrow auto-trust of
|
||||
# `.trellis/tasks` / `.trellis/workspace` when either is itself a top-level
|
||||
# symlink (auto-trust is on by default).
|
||||
#
|
||||
channel:
|
||||
worker_guard:
|
||||
idle_timeout: 5m
|
||||
max_live_workers: 6
|
||||
# trusted_context_dirs:
|
||||
# - /work/user/trellis_workspace
|
||||
# auto_trust_trellis_symlinks: false
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Codex (dispatch behavior)
|
||||
#-------------------------------------------------------------------------------
|
||||
# Codex-only knob; other platforms ignore it. Default ("auto") dispatches
|
||||
# trellis-implement / trellis-check / trellis-research sub-agents. This does
|
||||
# not rely on inherited parent transcripts: `fork_turns` remains
|
||||
# caller-controlled, while Codex's native SubagentStart hook injects task
|
||||
# context when trusted and child-side loading remains the fallback when it is
|
||||
# unavailable. Set to "inline" only to keep implementation and checks in the
|
||||
# main session. "sub-agent" remains a backwards-compatible alias for "auto".
|
||||
# Invalid explicit values safely use inline mode.
|
||||
#
|
||||
# In "auto" mode, dispatched sub-agents inherit the main session's model
|
||||
# unless you pin one. To use a cheaper/faster model for implement/check/
|
||||
# research sub-agent work, edit `model` / `model_reasoning_effort` directly
|
||||
# on the generated `.codex/agents/trellis-*.toml` files (see the commented
|
||||
# hint lines in those files) — there is no config.yaml knob for this,
|
||||
# `trellis update` preserves your edits across regeneration.
|
||||
#
|
||||
# codex:
|
||||
# dispatch_mode: auto # or "inline"; legacy alias: "sub-agent"
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Sub-agent context injection limits
|
||||
#-------------------------------------------------------------------------------
|
||||
# Caps how much task context (implement.jsonl / check.jsonl referenced files,
|
||||
# plus prd.md / design.md / implement.md) gets inlined into a sub-agent's
|
||||
# first prompt. Oversized files are truncated with a notice; once the total
|
||||
# payload cap is reached, remaining files degrade to index lines (path +
|
||||
# reason + size) instead of being inlined.
|
||||
#
|
||||
# All values are byte counts. `0` disables the corresponding limit.
|
||||
#
|
||||
# context_injection:
|
||||
# max_file_bytes: 32768 # per implement.jsonl / check.jsonl referenced file
|
||||
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
|
||||
# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Per-turn prompt injection
|
||||
#-------------------------------------------------------------------------------
|
||||
# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user
|
||||
# prompt contains the skip keyword as a standalone word (case-insensitive,
|
||||
# word-boundary match — "no-trellisfoo" does NOT count), the breadcrumb is
|
||||
# skipped for that turn only. Does not affect SessionStart or sub-agent
|
||||
# context injection.
|
||||
#
|
||||
# prompt_injection:
|
||||
# skip_keyword: "no-trellis" # "" disables the escape hatch entirely
|
||||
|
||||
codex:
|
||||
dispatch_mode: auto
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Trellis Python Scripts
|
||||
|
||||
This module provides Python implementations of Trellis workflow scripts.
|
||||
"""
|
||||
Executable
+1545
File diff suppressed because it is too large.
Load diff
Executable
+94
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Common utilities for Trellis workflow scripts.
|
||||
|
||||
This module provides shared functionality used by other Trellis scripts.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
# =============================================================================
|
||||
# Windows Encoding Fix (MUST be at top, before any other output)
|
||||
# =============================================================================
|
||||
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
||||
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
||||
#
|
||||
# Any script that imports from common will automatically get this fix.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _configure_stream(stream: object) -> object:
|
||||
"""Configure a stream for UTF-8 encoding on Windows."""
|
||||
# Try reconfigure() first (Python 3.7+, more reliable)
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||||
return stream
|
||||
# Fallback: detach and rewrap with TextIOWrapper
|
||||
elif hasattr(stream, "detach"):
|
||||
return io.TextIOWrapper(
|
||||
stream.detach(), # type: ignore[union-attr]
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return stream
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||
|
||||
|
||||
def configure_encoding() -> None:
|
||||
"""
|
||||
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
||||
|
||||
This is automatically called when importing from common,
|
||||
but can be called manually for scripts that don't import common.
|
||||
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
global sys
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
||||
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
||||
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
||||
|
||||
|
||||
from .paths import (
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
DIR_TASKS,
|
||||
DIR_ARCHIVE,
|
||||
DIR_SPEC,
|
||||
DIR_SCRIPTS,
|
||||
FILE_DEVELOPER,
|
||||
FILE_CURRENT_TASK,
|
||||
FILE_TASK_JSON,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
ENV_DEVELOPER,
|
||||
DEVELOPER_HINT,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
check_developer,
|
||||
get_tasks_dir,
|
||||
get_workspace_dir,
|
||||
get_active_journal_file,
|
||||
count_lines,
|
||||
get_current_task,
|
||||
get_current_task_abs,
|
||||
normalize_task_ref,
|
||||
resolve_task_ref,
|
||||
set_current_task,
|
||||
clear_current_task,
|
||||
has_current_task,
|
||||
generate_task_date_prefix,
|
||||
)
|
||||
|
||||
from .active_task import (
|
||||
ActiveTask,
|
||||
clear_active_task,
|
||||
resolve_active_task,
|
||||
resolve_context_key,
|
||||
set_active_task,
|
||||
)
|
||||
Executable
+857
@@ -0,0 +1,857 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Session-scoped active task resolution.
|
||||
|
||||
The user-facing concept is a single "active task". Trellis stores that pointer
|
||||
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
|
||||
session key there is no active task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .io import read_json as _io_read_json, write_json as _io_write_json
|
||||
|
||||
DIR_WORKFLOW = ".trellis"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_RUNTIME = ".runtime"
|
||||
DIR_SESSIONS = "sessions"
|
||||
DIR_SHELL_TICKETS = "shell-tickets"
|
||||
# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
|
||||
# was mid-command across an upgrade does not silently degrade; never written.
|
||||
# Tickets are 30-second ephemera, so the old directory ages out by itself —
|
||||
# there is nothing to migrate, only a glob on a directory that is normally
|
||||
# absent. The alternative (ignore it) would land its one lost command on the
|
||||
# platform that works today.
|
||||
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
|
||||
SHELL_TICKET_TTL_SECONDS = 30
|
||||
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
||||
|
||||
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
||||
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
||||
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
|
||||
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
|
||||
_KNOWN_PLATFORMS = {
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"opencode",
|
||||
"gemini",
|
||||
"droid",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"kiro",
|
||||
"copilot",
|
||||
"pi",
|
||||
"trae",
|
||||
"grok",
|
||||
"kimi",
|
||||
"zcode",
|
||||
"snow",
|
||||
"dsh",
|
||||
}
|
||||
|
||||
# Every name below records how it was checked. Do NOT add a name by analogy
|
||||
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
|
||||
# declared names had never existed anywhere — they were pattern-guessed from a
|
||||
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
|
||||
# only "evidence" behind them. A platform with no verified name belongs in no
|
||||
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
|
||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
# REAL (reported 2026-08-13 against DSH 0.1.0-rc.6 by @SajoLuo, from a live
|
||||
# run: DSH exports DSH_SESSION_ID plus DSH_SHELL=1 into its managed shell).
|
||||
# MUST STAY FIRST. A DSH session can inherit an outer host's identity — a
|
||||
# DSH launched from Codex still carries CODEX_THREAD_ID — and the untargeted
|
||||
# lookup below walks this table in order, so any earlier entry would claim
|
||||
# the session and write a foreign `codex_<thread>` pointer for DSH work.
|
||||
# DSH_SESSION_ID is the only name here no other vendor sets, so first place
|
||||
# is safe: it cannot mis-claim a non-DSH session.
|
||||
("dsh", ("DSH_SESSION_ID",)),
|
||||
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
|
||||
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
|
||||
# was removed here — verified absent from that same live environment.
|
||||
("claude", ("CLAUDE_CODE_SESSION_ID",)),
|
||||
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
|
||||
# into shell children, absent from the parent env; openai/codex#19937).
|
||||
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
|
||||
("codex", ("CODEX_THREAD_ID",)),
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
|
||||
# hookRunner.ts. Its shell tool builds the child env in
|
||||
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
|
||||
# this never reaches a bash child — it resolves only inside a hook process.
|
||||
("gemini", ("GEMINI_SESSION_ID",)),
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
|
||||
# extensions/hooks documents it as injected during hook execution by the
|
||||
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
|
||||
("qoder", ("QODER_SESSION_ID",)),
|
||||
# UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
|
||||
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
|
||||
# it is "set in both interactive and --no-interactive". Kept because that is
|
||||
# absence of evidence, not evidence of absence. To settle: run
|
||||
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
|
||||
("kiro", ("KIRO_SESSION_ID",)),
|
||||
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
|
||||
# hooks-reference and from the CLI programmatic reference. To settle: run
|
||||
# `copilot help environment` (the authoritative list per those docs) — not
|
||||
# runnable here, the CLI is not installed and copilot-cli ships no source.
|
||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
|
||||
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
|
||||
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
|
||||
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
|
||||
# name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
|
||||
# keep the historical name as a fallback: if neither exists nothing changes.
|
||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
|
||||
# entry only fires once the resolver detected "zcode" — no collision with
|
||||
# the claude entry above.
|
||||
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
|
||||
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
|
||||
# exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
|
||||
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
|
||||
# override — Snow sets that too.
|
||||
("snow", ("SNOW_SESSION_ID",)),
|
||||
)
|
||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
|
||||
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
|
||||
# 2026-05 forum request for it drew no staff reply. The invented
|
||||
# CURSOR_SESSION_ID was removed from the session table: empty in a live
|
||||
# cursor-agent shell. Cursor's other path is the shell ticket below
|
||||
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
|
||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||
)
|
||||
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
|
||||
# scripts; empty in the agent's own shell env.
|
||||
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
|
||||
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
|
||||
# table only, so do not infer these are real *or* fake from that work
|
||||
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
|
||||
# checked: absent from docs and from live envs). To settle each: run
|
||||
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
|
||||
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
|
||||
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
|
||||
("qoder", ("QODER_TRANSCRIPT_PATH",)),
|
||||
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
|
||||
)
|
||||
_ENV_PLATFORM_ALIASES = {
|
||||
"claude-code": "claude",
|
||||
"factory": "droid",
|
||||
"factory-ai": "droid",
|
||||
"github-copilot": "copilot",
|
||||
}
|
||||
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
|
||||
# is ZCode, while later shell commands see only the shared env name and resolve
|
||||
# it through the claude entry. Canonicalize both paths to one runtime filename.
|
||||
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||
"zcode": "claude",
|
||||
# Factory Droid's config directory is `.factory/`, so a hook that names its
|
||||
# platform after the directory it was installed in reports "factory". Its
|
||||
# sibling hooks report "droid". One runtime filename either way.
|
||||
"factory": "droid",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveTask:
|
||||
"""Resolved active task state."""
|
||||
|
||||
task_path: str | None
|
||||
source_type: str
|
||||
context_key: str | None = None
|
||||
stale: bool = False
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
"""Human-readable source label."""
|
||||
if self.source_type == "session" and self.context_key:
|
||||
return f"session:{self.context_key}"
|
||||
if self.source_type == "session-fallback" and self.context_key:
|
||||
return f"session-fallback:{self.context_key}"
|
||||
return self.source_type
|
||||
|
||||
|
||||
def normalize_task_ref(task_ref: str) -> str:
|
||||
"""Normalize a task ref for stable storage and comparison."""
|
||||
normalized = task_ref.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||
return f"{DIR_WORKFLOW}/{normalized}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve a task ref to an absolute task directory inside the repo.
|
||||
|
||||
Mirrors `paths.resolve_task_ref` (same containment check). Duplicated
|
||||
rather than imported because this module is loaded standalone — hooks add
|
||||
it to `sys.path` directly — so it stays zero-relative-import on purpose.
|
||||
"""
|
||||
normalized = normalize_task_ref(task_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
try:
|
||||
root = repo_root.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
candidate = path_obj
|
||||
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
candidate = root / path_obj
|
||||
else:
|
||||
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
# Both sides are resolved because repo_root itself may sit behind a
|
||||
# symlink (/tmp on macOS does), and resolve() is what collapses `..`
|
||||
# instead of leaving it for a lexical relative_to() to wave through.
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
workflow_real = (root / DIR_WORKFLOW).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
return resolved
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# `.trellis` may itself be a symlink into a store outside the repo (#567).
|
||||
# The workflow dir's own real location is then a second legitimate
|
||||
# containment base; a ref that escapes BOTH bases is still refused. Map
|
||||
# back to the in-repo (lexical) form so callers store a repo-relative ref.
|
||||
try:
|
||||
rel = resolved.relative_to(workflow_real)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return root / DIR_WORKFLOW / rel
|
||||
|
||||
|
||||
def _runtime_sessions_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
|
||||
|
||||
|
||||
def _sanitize_key(raw: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
|
||||
safe = safe.strip("._-")
|
||||
return safe[:160] if safe else ""
|
||||
|
||||
|
||||
def _hash_value(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _string_value(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||||
for key in keys:
|
||||
value = _string_value(data.get(key))
|
||||
if value:
|
||||
return value
|
||||
|
||||
for nested_key in _NESTED_KEYS:
|
||||
nested = _as_dict(data.get(nested_key))
|
||||
if not nested:
|
||||
continue
|
||||
value = _lookup_string(nested, keys)
|
||||
if value:
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
|
||||
if platform:
|
||||
return _sanitize_key(platform) or "session"
|
||||
if platform_input:
|
||||
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
|
||||
value = _string_value(platform_input.get(key))
|
||||
if value:
|
||||
return _sanitize_key(value) or "session"
|
||||
if _string_value(platform_input.get("cursor_version")):
|
||||
return "cursor"
|
||||
return "session"
|
||||
|
||||
|
||||
def _context_key(platform_name: str, kind: str, value: str) -> str:
|
||||
platform_name = _CONTEXT_KEY_PLATFORM_ALIASES.get(platform_name, platform_name)
|
||||
if kind == "transcript":
|
||||
return f"{platform_name}_transcript_{_hash_value(value)}"
|
||||
safe_value = _sanitize_key(value)
|
||||
if safe_value:
|
||||
return f"{platform_name}_{safe_value}"
|
||||
return f"{platform_name}_{_hash_value(value)}"
|
||||
|
||||
|
||||
def _iter_env_keys(
|
||||
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
|
||||
platform_name: str | None,
|
||||
) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
||||
"""Narrow an env-key table to one platform, or return all of it.
|
||||
|
||||
A platform with no entry yields an empty tuple, and the caller's `for` loop
|
||||
simply does not run. That is the normal case, not an error: platforms with
|
||||
no verified env var name are deliberately absent from these tables.
|
||||
"""
|
||||
if not platform_name:
|
||||
return env_keys
|
||||
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
|
||||
return matched
|
||||
|
||||
|
||||
def _env_platform_name(platform_name: str | None) -> str | None:
|
||||
if not platform_name or platform_name == "session":
|
||||
return None
|
||||
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
|
||||
|
||||
|
||||
def _lookup_env_context_key(platform_name: str | None) -> str | None:
|
||||
"""Resolve a context key from platform-provided environment variables.
|
||||
|
||||
Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
|
||||
shell command can only see session identity if the host platform exports it
|
||||
in the command environment. These names are best-effort adapters; if none
|
||||
are present, there is no session-scoped active task.
|
||||
"""
|
||||
env_platform_name = _env_platform_name(platform_name)
|
||||
|
||||
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
|
||||
for key in keys:
|
||||
value = _string_value(os.environ.get(key))
|
||||
if value:
|
||||
return _context_key(name, "session", value)
|
||||
|
||||
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
|
||||
for key in keys:
|
||||
value = _string_value(os.environ.get(key))
|
||||
if value:
|
||||
return _context_key(name, "conversation", value)
|
||||
|
||||
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
|
||||
for key in keys:
|
||||
value = _string_value(os.environ.get(key))
|
||||
if value:
|
||||
return _context_key(name, "transcript", value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_repo_root_from_cwd() -> Path | None:
|
||||
current = Path.cwd().resolve()
|
||||
while True:
|
||||
if (current / DIR_WORKFLOW).is_dir():
|
||||
return current
|
||||
if current == current.parent:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
|
||||
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
|
||||
return (
|
||||
runtime_dir / DIR_SHELL_TICKETS,
|
||||
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
|
||||
)
|
||||
|
||||
|
||||
def _remove_file(path: Path) -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
left_path = resolve_task_ref(left, repo_root)
|
||||
right_path = resolve_task_ref(right, repo_root)
|
||||
if left_path is not None and right_path is not None:
|
||||
return left_path == right_path
|
||||
return normalize_task_ref(left) == normalize_task_ref(right)
|
||||
|
||||
|
||||
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
if Path(sys.argv[0]).name != "task.py":
|
||||
return False
|
||||
args = tuple(sys.argv[1:])
|
||||
if not args:
|
||||
return False
|
||||
|
||||
command_name = args[0]
|
||||
if command_name not in TASK_SESSION_COMMANDS:
|
||||
return False
|
||||
|
||||
subcommands = ticket.get("subcommands")
|
||||
if not isinstance(subcommands, list):
|
||||
return False
|
||||
|
||||
for subcommand in subcommands:
|
||||
if not isinstance(subcommand, dict):
|
||||
continue
|
||||
if _string_value(subcommand.get("name")) != command_name:
|
||||
continue
|
||||
if command_name != "start":
|
||||
return True
|
||||
task_ref = args[1] if len(args) > 1 else None
|
||||
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
|
||||
expires_at = ticket.get("expires_at_epoch")
|
||||
if isinstance(expires_at, (int, float)) and expires_at < now:
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
|
||||
created_at = ticket.get("created_at_epoch")
|
||||
if isinstance(created_at, (int, float)):
|
||||
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
|
||||
return True
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
cwd = _string_value(ticket.get("cwd"))
|
||||
if not cwd:
|
||||
return True
|
||||
try:
|
||||
Path(cwd).resolve().relative_to(repo_root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _matching_ticket_context_key(
|
||||
ticket_path: Path,
|
||||
repo_root: Path,
|
||||
now: float,
|
||||
) -> str | None:
|
||||
"""Accept a ticket on its merits, never on which platform wrote it.
|
||||
|
||||
The `platform` field a ticket carries is debugging metadata; gating on it
|
||||
was what kept this bridge invisible to every platform but Cursor.
|
||||
"""
|
||||
ticket = _read_json(ticket_path)
|
||||
if ticket is None:
|
||||
return None
|
||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||
return None
|
||||
if not _ticket_cwd_matches_repo(ticket, repo_root):
|
||||
return None
|
||||
if not _pending_ticket_matches_args(ticket, repo_root):
|
||||
return None
|
||||
return _string_value(ticket.get("context_key"))
|
||||
|
||||
|
||||
def _lookup_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve session identity from a short-lived shell ticket.
|
||||
|
||||
No researched platform exports its session id into a shell child, but every
|
||||
hook-capable one hands that id to a hook. So the hook that runs just before
|
||||
a shell command writes a ticket, and this reads it back. A ticket counts
|
||||
only when it is fresh, was written for this repo, and matches the `task.py`
|
||||
subcommand now running — and only when exactly one fresh context key
|
||||
matches. Two concurrent windows therefore both degrade rather than one
|
||||
inheriting the other's pointer.
|
||||
"""
|
||||
repo_root = _find_repo_root_from_cwd()
|
||||
if repo_root is None:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
candidates: set[str] = set()
|
||||
for ticket_dir in _shell_ticket_dirs(repo_root):
|
||||
if not ticket_dir.is_dir():
|
||||
continue
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return next(iter(candidates))
|
||||
return None
|
||||
|
||||
|
||||
def resolve_context_key(
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
*,
|
||||
allow_environment_context: bool = True,
|
||||
) -> str | None:
|
||||
"""Resolve a stable session/window context key, if one is available.
|
||||
|
||||
`TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
|
||||
scripts and subprocesses. It does not store the task itself.
|
||||
"""
|
||||
if allow_environment_context:
|
||||
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
||||
if override:
|
||||
return _sanitize_key(override) or _hash_value(override)
|
||||
|
||||
data = _as_dict(platform_input)
|
||||
platform_name = _detect_platform(data, platform) if data or platform else None
|
||||
|
||||
if data:
|
||||
session_id = _lookup_string(data, _SESSION_KEYS)
|
||||
if session_id:
|
||||
return _context_key(platform_name or "session", "session", session_id)
|
||||
|
||||
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
|
||||
if conversation_id:
|
||||
return _context_key(platform_name or "session", "conversation", conversation_id)
|
||||
|
||||
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
|
||||
if transcript_path:
|
||||
return _context_key(platform_name or "session", "transcript", transcript_path)
|
||||
|
||||
if allow_environment_context:
|
||||
env_context_key = _lookup_env_context_key(platform_name)
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
|
||||
# Last in the chain on purpose: a platform that genuinely exports identity
|
||||
# into the shell outranks a ticket, and no platform name gates the lookup.
|
||||
if allow_environment_context:
|
||||
return _lookup_shell_ticket_context_key()
|
||||
return None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
"""Tolerant read of a session runtime file, non-objects included."""
|
||||
data = _io_read_json(path)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> bool:
|
||||
"""Write a session runtime file atomically, creating the runtime dir.
|
||||
|
||||
Routes through io.write_json so session pointers get the same
|
||||
temp-file-then-rename treatment as task.json (#429). A plain write_text
|
||||
truncates the target first, so a crash mid-write would leave a session
|
||||
file that reads back as no active task.
|
||||
"""
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return False
|
||||
return _io_write_json(path, data)
|
||||
|
||||
|
||||
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
|
||||
normalized = normalize_task_ref(task_path)
|
||||
if not normalized:
|
||||
return None
|
||||
full_path = resolve_task_ref(normalized, repo_root)
|
||||
if full_path is None or not full_path.is_dir():
|
||||
return None
|
||||
try:
|
||||
return full_path.relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
# resolve_task_ref already refused everything outside the repo, so this
|
||||
# is unreachable. Refuse rather than fall back to an absolute path —
|
||||
# that fallback is how an out-of-repo ref used to reach the session
|
||||
# pointer and get replayed on every later turn.
|
||||
return None
|
||||
|
||||
|
||||
def _relative_task_ref(task_path: str, repo_root: Path) -> str:
|
||||
"""Repo-relative posix ref for a task path that need not exist.
|
||||
|
||||
`_canonical_task_ref` resolves through the filesystem and so refuses a task
|
||||
directory that has been moved away. Rename needs to name both sides of the
|
||||
move, one of which is always absent.
|
||||
"""
|
||||
normalized = normalize_task_ref(task_path)
|
||||
if not normalized:
|
||||
return ""
|
||||
candidate = Path(normalized)
|
||||
if not candidate.is_absolute():
|
||||
return normalized
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
root = repo_root.resolve()
|
||||
workflow_real = (root / DIR_WORKFLOW).resolve()
|
||||
except OSError:
|
||||
return ""
|
||||
try:
|
||||
return resolved.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
# Same dual-base containment as resolve_task_ref: a path through a
|
||||
# symlinked `.trellis` (#567) maps back to its in-repo form; anything
|
||||
# outside both bases is refused rather than stored as an absolute pointer.
|
||||
try:
|
||||
rel = resolved.relative_to(workflow_real)
|
||||
except ValueError:
|
||||
return ""
|
||||
return (Path(DIR_WORKFLOW) / rel).as_posix()
|
||||
|
||||
|
||||
def _active_from_ref(
|
||||
task_ref: str | None,
|
||||
repo_root: Path,
|
||||
source_type: str,
|
||||
context_key: str | None = None,
|
||||
) -> ActiveTask | None:
|
||||
if not task_ref:
|
||||
return None
|
||||
resolved = resolve_task_ref(task_ref, repo_root)
|
||||
stale = resolved is None or not resolved.is_dir()
|
||||
return ActiveTask(task_ref, source_type, context_key, stale)
|
||||
|
||||
|
||||
def _context_path(repo_root: Path, context_key: str) -> Path:
|
||||
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
|
||||
|
||||
|
||||
def resolve_active_task(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
*,
|
||||
allow_single_session_fallback: bool = True,
|
||||
allow_environment_context: bool = True,
|
||||
) -> ActiveTask:
|
||||
"""Resolve the active task from session runtime state only.
|
||||
|
||||
A stale session task is returned as stale. Missing context identity or a
|
||||
missing/empty session context falls back to single-session inference: if
|
||||
exactly one session file exists in the runtime, return its task with
|
||||
source_type="session-fallback" — covers pull-based platform sub-agents
|
||||
(copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
|
||||
files or 0 files yield ActiveTask(None) — refuses to guess across windows.
|
||||
"""
|
||||
context_key = resolve_context_key(
|
||||
platform_input,
|
||||
platform,
|
||||
allow_environment_context=allow_environment_context,
|
||||
)
|
||||
if context_key:
|
||||
context = _read_json(_context_path(repo_root, context_key)) or {}
|
||||
task_ref = _string_value(context.get("current_task"))
|
||||
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
||||
if active:
|
||||
return active
|
||||
|
||||
if allow_single_session_fallback:
|
||||
fallback = _resolve_single_session_fallback(repo_root)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
return ActiveTask(None, "none", context_key)
|
||||
|
||||
|
||||
def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
|
||||
"""Return the task pointed at by the sole session file, if exactly one exists.
|
||||
|
||||
Used when context-key resolution fails (typical for class-2 platform
|
||||
sub-agents). Returns None if 0 or ≥2 session files are present — refuses
|
||||
to pick across windows so 04-21's multi-session isolation contract holds.
|
||||
"""
|
||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||
if not sessions_dir.is_dir():
|
||||
return None
|
||||
|
||||
session_files = sorted(sessions_dir.glob("*.json"))
|
||||
if len(session_files) != 1:
|
||||
return None
|
||||
|
||||
session_file = session_files[0]
|
||||
context = _read_json(session_file) or {}
|
||||
task_ref = _string_value(context.get("current_task"))
|
||||
if not task_ref:
|
||||
return None
|
||||
|
||||
fallback_key = session_file.stem
|
||||
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _context_metadata(
|
||||
platform_input: dict[str, Any] | None,
|
||||
platform: str | None,
|
||||
context_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = _as_dict(platform_input) or {}
|
||||
platform_name = _detect_platform(data, platform)
|
||||
if platform_name == "session" and context_key:
|
||||
prefix = context_key.split("_", 1)[0]
|
||||
if prefix in _KNOWN_PLATFORMS:
|
||||
platform_name = prefix
|
||||
metadata: dict[str, Any] = {
|
||||
"platform": platform_name,
|
||||
"last_seen_at": _utc_now(),
|
||||
}
|
||||
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
|
||||
value = _lookup_string(data, (key,))
|
||||
if value:
|
||||
metadata[key] = value
|
||||
return metadata
|
||||
|
||||
|
||||
def set_active_task(
|
||||
task_path: str,
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> ActiveTask | None:
|
||||
"""Set the active task in session scope.
|
||||
|
||||
Returns None when no context key is available; callers should surface a
|
||||
user-facing error that explains how to provide session identity.
|
||||
"""
|
||||
canonical = _canonical_task_ref(task_path, repo_root)
|
||||
if canonical is None:
|
||||
return None
|
||||
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
if not context_key:
|
||||
return None
|
||||
|
||||
context_path = _context_path(repo_root, context_key)
|
||||
context = _read_json(context_path) or {}
|
||||
context.update(_context_metadata(platform_input, platform, context_key))
|
||||
context["current_task"] = canonical
|
||||
context.setdefault("current_run", None)
|
||||
if not _write_json(context_path, context):
|
||||
return None
|
||||
return ActiveTask(canonical, "session", context_key)
|
||||
|
||||
|
||||
def clear_active_task(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> ActiveTask:
|
||||
"""Clear the active task by deleting its resolved session context file."""
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
if not context_key:
|
||||
return ActiveTask(None, "none")
|
||||
|
||||
previous = resolve_active_task(repo_root, platform_input, platform)
|
||||
if not previous.task_path or not previous.context_key:
|
||||
return previous
|
||||
|
||||
context_path = _context_path(repo_root, previous.context_key)
|
||||
if context_path.is_file():
|
||||
_remove_file(context_path)
|
||||
return previous
|
||||
|
||||
|
||||
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
|
||||
"""Delete all session runtime files that point at a task."""
|
||||
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
|
||||
if not target:
|
||||
return 0
|
||||
|
||||
cleared = 0
|
||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||
if not sessions_dir.is_dir():
|
||||
return cleared
|
||||
|
||||
for session_path in sessions_dir.glob("*.json"):
|
||||
context = _read_json(session_path) or {}
|
||||
current = _string_value(context.get("current_task"))
|
||||
if not current:
|
||||
continue
|
||||
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
|
||||
if current_ref != target:
|
||||
continue
|
||||
if session_path.is_file() and _remove_file(session_path):
|
||||
cleared += 1
|
||||
|
||||
return cleared
|
||||
|
||||
|
||||
def repoint_task_in_sessions(old_path: str, new_path: str, repo_root: Path) -> int:
|
||||
"""Move every session pointer from `old_path` to `new_path`.
|
||||
|
||||
Rename is the one lifecycle step where the task survives under a different
|
||||
name, so clearing the pointers (what archive does) would be wrong: the user
|
||||
would silently lose their active task and have to run `task.py start`
|
||||
again to get context injection back. Repointing keeps the session valid
|
||||
across the rename.
|
||||
"""
|
||||
# Not `_canonical_task_ref`: the caller repoints *after* moving the
|
||||
# directory, so `old_path` no longer exists and canonicalization — which
|
||||
# requires an existing directory — would return None for exactly the ref we
|
||||
# need to match.
|
||||
target = _relative_task_ref(old_path, repo_root)
|
||||
replacement = _relative_task_ref(new_path, repo_root)
|
||||
if not target or not replacement:
|
||||
return 0
|
||||
|
||||
moved = 0
|
||||
sessions_dir = _runtime_sessions_dir(repo_root)
|
||||
if not sessions_dir.is_dir():
|
||||
return moved
|
||||
|
||||
for session_path in sorted(sessions_dir.glob("*.json")):
|
||||
context = _read_json(session_path)
|
||||
if not context:
|
||||
continue
|
||||
current = _string_value(context.get("current_task"))
|
||||
if not current:
|
||||
continue
|
||||
current_ref = _canonical_task_ref(current, repo_root) or _relative_task_ref(
|
||||
current, repo_root
|
||||
)
|
||||
if current_ref != target:
|
||||
continue
|
||||
context["current_task"] = replacement
|
||||
if _write_json(session_path, context):
|
||||
moved += 1
|
||||
|
||||
return moved
|
||||
|
||||
|
||||
def get_current_task_source(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Return (`source_type`, `context_key`, `task_path`) for compatibility."""
|
||||
active = resolve_active_task(repo_root, platform_input, platform)
|
||||
return active.source_type, active.context_key, active.task_path
|
||||
Executable
+950
@@ -0,0 +1,950 @@
|
||||
"""
|
||||
CLI Adapter for Multi-Platform Support.
|
||||
|
||||
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||
|
||||
Supported platforms:
|
||||
- claude: Claude Code (default)
|
||||
- opencode: OpenCode
|
||||
- cursor: Cursor IDE
|
||||
- iflow: iFlow CLI
|
||||
- codex: Codex CLI (skills-based)
|
||||
- kilo: Kilo CLI
|
||||
- kiro: Kiro Code (skills-based)
|
||||
- gemini: Gemini CLI
|
||||
- antigravity: Antigravity (workflow-based)
|
||||
- devin: Devin (formerly Windsurf; workflow-based)
|
||||
- qoder: Qoder
|
||||
- codebuddy: CodeBuddy
|
||||
- copilot: GitHub Copilot (VS Code)
|
||||
- droid: Factory Droid (commands-based)
|
||||
- pi: Pi Agent (extension-backed)
|
||||
- trae: Trae IDE (IDE-only, hooks-based)
|
||||
- omp: Oh My Pi
|
||||
- grok: Grok Build (pull-based skills/agents; no hook context injection)
|
||||
- kimi: Kimi Code (pull-based skills; commands delivered as skills; no hook context injection)
|
||||
|
||||
Usage:
|
||||
from common.cli_adapter import CLIAdapter
|
||||
|
||||
adapter = CLIAdapter("opencode")
|
||||
cmd = adapter.build_run_command(
|
||||
agent="dispatch",
|
||||
session_id="abc123",
|
||||
prompt="Start the pipeline"
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
Platform = Literal[
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
"omp",
|
||||
"grok",
|
||||
"kimi",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CLIAdapter:
|
||||
"""Adapter for different AI coding CLI tools."""
|
||||
|
||||
platform: Platform
|
||||
|
||||
# =========================================================================
|
||||
# Agent Name Mapping
|
||||
# =========================================================================
|
||||
|
||||
# OpenCode has built-in agents that cannot be overridden
|
||||
# See: https://github.com/sst/opencode/issues/4271
|
||||
# Note: Class-level constant, not a dataclass field
|
||||
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
|
||||
"claude": {}, # No mapping needed
|
||||
"opencode": {
|
||||
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
|
||||
},
|
||||
}
|
||||
|
||||
def get_agent_name(self, agent: str) -> str:
|
||||
"""Get platform-specific agent name.
|
||||
|
||||
Args:
|
||||
agent: Original agent name (e.g., 'plan', 'dispatch')
|
||||
|
||||
Returns:
|
||||
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
|
||||
"""
|
||||
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
|
||||
return mapping.get(agent, agent)
|
||||
|
||||
# =========================================================================
|
||||
# Agent Path
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def config_dir_name(self) -> str:
|
||||
"""Get platform-specific config directory name.
|
||||
|
||||
Returns:
|
||||
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', '.pi', or '.trae')
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ".opencode"
|
||||
elif self.platform == "cursor":
|
||||
return ".cursor"
|
||||
elif self.platform == "iflow":
|
||||
return ".iflow"
|
||||
elif self.platform == "codex":
|
||||
return ".codex"
|
||||
elif self.platform == "kilo":
|
||||
return ".kilocode"
|
||||
elif self.platform == "kiro":
|
||||
return ".kiro"
|
||||
elif self.platform == "gemini":
|
||||
return ".gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return ".agent"
|
||||
elif self.platform == "devin":
|
||||
return ".devin"
|
||||
elif self.platform == "qoder":
|
||||
return ".qoder"
|
||||
elif self.platform == "codebuddy":
|
||||
return ".codebuddy"
|
||||
elif self.platform == "copilot":
|
||||
return ".github/copilot"
|
||||
elif self.platform == "droid":
|
||||
return ".factory"
|
||||
elif self.platform == "pi":
|
||||
return ".pi"
|
||||
elif self.platform == "trae":
|
||||
return ".trae"
|
||||
elif self.platform == "omp":
|
||||
return ".omp"
|
||||
elif self.platform == "grok":
|
||||
return ".grok"
|
||||
elif self.platform == "kimi":
|
||||
return ".kimi-code"
|
||||
else:
|
||||
return ".claude"
|
||||
|
||||
def get_config_dir(self, project_root: Path) -> Path:
|
||||
"""Get platform-specific config directory.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, .pi, or .trae)
|
||||
"""
|
||||
return project_root / self.config_dir_name
|
||||
|
||||
def get_agent_path(self, agent: str, project_root: Path) -> Path:
|
||||
"""Get path to agent definition file.
|
||||
|
||||
Args:
|
||||
agent: Agent name (original, before mapping)
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to agent definition file (.md for most platforms, .toml for Codex)
|
||||
"""
|
||||
mapped_name = self.get_agent_name(agent)
|
||||
if self.platform == "codex":
|
||||
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml"
|
||||
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
|
||||
|
||||
def get_commands_path(self, project_root: Path, *parts: str) -> Path:
|
||||
"""Get path to commands directory or specific command file.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
*parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
|
||||
|
||||
Returns:
|
||||
Path to commands directory or file
|
||||
|
||||
Note:
|
||||
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
|
||||
Antigravity uses workflow directory: .agent/workflows/<name>.md
|
||||
Devin uses workflow directory: .devin/workflows/trellis-<name>.md
|
||||
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "pi":
|
||||
prompts_dir = self.get_config_dir(project_root) / "prompts"
|
||||
if not parts:
|
||||
return prompts_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"trellis-{filename}.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
# OMP and Grok: flat slash commands under .{platform}/commands/trellis-<name>.md
|
||||
if self.platform in ("omp", "grok"):
|
||||
commands_dir = self.get_config_dir(project_root) / "commands"
|
||||
if not parts:
|
||||
return commands_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return commands_dir / f"trellis-{filename}.md"
|
||||
return commands_dir / Path(*parts)
|
||||
|
||||
# Kimi: commands are skills under .kimi-code/skills/trellis-<name>/SKILL.md
|
||||
if self.platform == "kimi":
|
||||
skills_dir = self.get_config_dir(project_root) / "skills"
|
||||
if not parts:
|
||||
return skills_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return skills_dir / f"trellis-{filename}" / "SKILL.md"
|
||||
return skills_dir / Path(*parts)
|
||||
|
||||
if self.platform == "devin":
|
||||
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||
if not parts:
|
||||
return workflow_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
return workflow_dir / f"trellis-{filename}"
|
||||
return workflow_dir / Path(*parts)
|
||||
|
||||
if self.platform in ("antigravity", "kilo"):
|
||||
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||
if not parts:
|
||||
return workflow_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
return workflow_dir / filename
|
||||
return workflow_dir / Path(*parts)
|
||||
|
||||
if self.platform == "copilot":
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
if not parts:
|
||||
return prompts_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"{filename}.prompt.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
|
||||
if not parts:
|
||||
return self.get_config_dir(project_root) / "commands"
|
||||
|
||||
# Cursor uses prefix naming instead of subdirectory
|
||||
if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
|
||||
# Convert trellis/<name>.md to trellis-<name>.md
|
||||
filename = parts[-1]
|
||||
return (
|
||||
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
|
||||
)
|
||||
|
||||
return self.get_config_dir(project_root) / "commands" / Path(*parts)
|
||||
|
||||
def get_trellis_command_path(self, name: str) -> str:
|
||||
"""Get relative path to a trellis command file.
|
||||
|
||||
Args:
|
||||
name: Command name without extension (e.g., 'finish-work', 'check')
|
||||
|
||||
Returns:
|
||||
Relative path string for use in JSONL entries
|
||||
|
||||
Note:
|
||||
Cursor: .cursor/commands/trellis-<name>.md
|
||||
Codex: .agents/skills/trellis-<name>/SKILL.md
|
||||
Kiro: .kiro/skills/trellis-<name>/SKILL.md
|
||||
Gemini: .gemini/commands/trellis/<name>.toml
|
||||
Antigravity: .agent/workflows/<name>.md
|
||||
Devin: .devin/workflows/trellis-<name>.md
|
||||
Pi: .pi/prompts/trellis-<name>.md
|
||||
Others: .{platform}/commands/trellis/<name>.md
|
||||
"""
|
||||
if self.platform == "cursor":
|
||||
return f".cursor/commands/trellis-{name}.md"
|
||||
elif self.platform == "codex":
|
||||
# 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
|
||||
# (see that release's manifest for the 60+ rename entries).
|
||||
return f".agents/skills/trellis-{name}/SKILL.md"
|
||||
elif self.platform == "kiro":
|
||||
return f".kiro/skills/trellis-{name}/SKILL.md"
|
||||
elif self.platform == "gemini":
|
||||
return f".gemini/commands/trellis/{name}.toml"
|
||||
elif self.platform == "antigravity":
|
||||
return f".agent/workflows/{name}.md"
|
||||
elif self.platform == "devin":
|
||||
return f".devin/workflows/trellis-{name}.md"
|
||||
elif self.platform == "kilo":
|
||||
return f".kilocode/workflows/{name}.md"
|
||||
elif self.platform == "copilot":
|
||||
return f".github/prompts/{name}.prompt.md"
|
||||
elif self.platform == "droid":
|
||||
return f".factory/commands/trellis/{name}.md"
|
||||
elif self.platform == "pi":
|
||||
return f".pi/prompts/trellis-{name}.md"
|
||||
elif self.platform in ("omp", "grok"):
|
||||
return f"{self.config_dir_name}/commands/trellis-{name}.md"
|
||||
elif self.platform == "kimi":
|
||||
return f".kimi-code/skills/trellis-{name}/SKILL.md"
|
||||
else:
|
||||
return f"{self.config_dir_name}/commands/trellis/{name}.md"
|
||||
|
||||
# =========================================================================
|
||||
# Environment Variables
|
||||
# =========================================================================
|
||||
|
||||
def get_non_interactive_env(self) -> dict[str, str]:
|
||||
"""Get environment variables for non-interactive mode.
|
||||
|
||||
Returns:
|
||||
Dict of environment variables to set
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return {"OPENCODE_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "iflow":
|
||||
return {"IFLOW_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "codex":
|
||||
return {"CODEX_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "kiro":
|
||||
return {"KIRO_NON_INTERACTIVE": "1"}
|
||||
elif self.platform == "gemini":
|
||||
return {} # Gemini CLI doesn't have a non-interactive env var
|
||||
elif self.platform == "antigravity":
|
||||
return {}
|
||||
elif self.platform == "devin":
|
||||
return {}
|
||||
elif self.platform == "qoder":
|
||||
return {}
|
||||
elif self.platform == "codebuddy":
|
||||
return {}
|
||||
elif self.platform == "copilot":
|
||||
return {}
|
||||
elif self.platform == "droid":
|
||||
return {}
|
||||
elif self.platform == "pi":
|
||||
return {}
|
||||
elif self.platform == "trae":
|
||||
return {}
|
||||
elif self.platform == "omp":
|
||||
return {}
|
||||
elif self.platform == "grok":
|
||||
return {}
|
||||
elif self.platform == "kimi":
|
||||
return {}
|
||||
else:
|
||||
return {"CLAUDE_NON_INTERACTIVE": "1"}
|
||||
|
||||
# =========================================================================
|
||||
# CLI Command Building
|
||||
# =========================================================================
|
||||
|
||||
def build_run_command(
|
||||
self,
|
||||
agent: str,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
skip_permissions: bool = True,
|
||||
verbose: bool = True,
|
||||
json_output: bool = True,
|
||||
) -> list[str]:
|
||||
"""Build CLI command for running an agent.
|
||||
|
||||
Args:
|
||||
agent: Agent name (will be mapped if needed)
|
||||
prompt: Prompt to send to the agent
|
||||
session_id: Optional session ID (Claude Code only for creation)
|
||||
skip_permissions: Whether to skip permission prompts
|
||||
verbose: Whether to enable verbose output
|
||||
json_output: Whether to use JSON output format
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
mapped_agent = self.get_agent_name(agent)
|
||||
|
||||
if self.platform == "opencode":
|
||||
cmd = ["opencode", "run"]
|
||||
cmd.extend(["--agent", mapped_agent])
|
||||
|
||||
# Note: OpenCode 'run' mode is non-interactive by default
|
||||
# No equivalent to Claude Code's --dangerously-skip-permissions
|
||||
# See: https://github.com/anomalyco/opencode/issues/9070
|
||||
|
||||
if json_output:
|
||||
cmd.extend(["--format", "json"])
|
||||
|
||||
if verbose:
|
||||
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
|
||||
|
||||
# Note: OpenCode doesn't support --session-id on creation
|
||||
# Session ID must be extracted from logs after startup
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
elif self.platform == "iflow":
|
||||
cmd = ["iflow", "-y", "-p"]
|
||||
cmd.append(f"${mapped_agent} {prompt}")
|
||||
elif self.platform == "codex":
|
||||
cmd = ["codex", "exec"]
|
||||
cmd.append(prompt)
|
||||
elif self.platform == "kiro":
|
||||
cmd = ["kiro", "run", prompt]
|
||||
elif self.platform == "gemini":
|
||||
cmd = ["gemini"]
|
||||
cmd.append(prompt)
|
||||
elif self.platform == "antigravity":
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Devin workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
cmd = ["qodercli", "-p", prompt]
|
||||
elif self.platform == "codebuddy":
|
||||
raise ValueError(
|
||||
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||
)
|
||||
elif self.platform == "copilot":
|
||||
raise ValueError(
|
||||
"GitHub Copilot is IDE-only; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "droid":
|
||||
raise ValueError(
|
||||
"Factory Droid CLI agent run is not yet supported."
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
cmd = ["pi", "-p", prompt]
|
||||
elif self.platform == "trae":
|
||||
raise ValueError(
|
||||
"Trae is IDE-only; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "omp":
|
||||
raise ValueError(
|
||||
"OMP uses native task tool for agent runs; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "grok":
|
||||
# Headless single-prompt; sub-agents use in-process spawn_subagent.
|
||||
cmd = ["grok", "-p", prompt, "--yolo"]
|
||||
elif self.platform == "kimi":
|
||||
# Headless single-prompt with auto-approval; sub-agents are the
|
||||
# built-in coder/explore/plan agents dispatched in-session.
|
||||
cmd = ["kimi", "-p", prompt, "--yolo"]
|
||||
|
||||
else: # claude
|
||||
cmd = ["claude", "-p"]
|
||||
cmd.extend(["--agent", mapped_agent])
|
||||
|
||||
if session_id:
|
||||
cmd.extend(["--session-id", session_id])
|
||||
|
||||
if skip_permissions:
|
||||
cmd.append("--dangerously-skip-permissions")
|
||||
|
||||
if json_output:
|
||||
cmd.extend(["--output-format", "stream-json"])
|
||||
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
return cmd
|
||||
|
||||
def build_resume_command(self, session_id: str) -> list[str]:
|
||||
"""Build CLI command for resuming a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to resume (ignored for iFlow)
|
||||
|
||||
Returns:
|
||||
List of command arguments
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ["opencode", "run", "--session", session_id]
|
||||
elif self.platform == "iflow":
|
||||
# iFlow uses -c to continue most recent conversation
|
||||
# session_id is ignored as iFlow doesn't support session IDs
|
||||
return ["iflow", "-c"]
|
||||
elif self.platform == "codex":
|
||||
return ["codex", "resume", session_id]
|
||||
elif self.platform == "kiro":
|
||||
return ["kiro", "resume", session_id]
|
||||
elif self.platform == "gemini":
|
||||
return ["gemini", "--resume", session_id]
|
||||
elif self.platform == "antigravity":
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Devin workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
return ["qodercli", "--resume", session_id]
|
||||
elif self.platform == "codebuddy":
|
||||
raise ValueError(
|
||||
"CodeBuddy does not support non-interactive mode (no CLI agent)"
|
||||
)
|
||||
elif self.platform == "copilot":
|
||||
raise ValueError(
|
||||
"GitHub Copilot is IDE-only; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "droid":
|
||||
raise ValueError(
|
||||
"Factory Droid CLI resume is not yet supported."
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
return ["pi", "-c", session_id]
|
||||
elif self.platform == "trae":
|
||||
raise ValueError(
|
||||
"Trae is IDE-only; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "omp":
|
||||
raise ValueError(
|
||||
"OMP uses native task tool for agent runs; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "grok":
|
||||
return ["grok", "-c"]
|
||||
elif self.platform == "kimi":
|
||||
return ["kimi", "--session", session_id]
|
||||
else:
|
||||
return ["claude", "--resume", session_id]
|
||||
|
||||
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
|
||||
"""Get human-readable resume command string.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to resume
|
||||
cwd: Optional working directory to cd into
|
||||
|
||||
Returns:
|
||||
Command string for display
|
||||
"""
|
||||
cmd = self.build_resume_command(session_id)
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if cwd:
|
||||
return f"cd {cwd} && {cmd_str}"
|
||||
return cmd_str
|
||||
|
||||
# =========================================================================
|
||||
# Platform Detection Helpers
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def is_opencode(self) -> bool:
|
||||
"""Check if platform is OpenCode."""
|
||||
return self.platform == "opencode"
|
||||
|
||||
@property
|
||||
def is_claude(self) -> bool:
|
||||
"""Check if platform is Claude Code."""
|
||||
return self.platform == "claude"
|
||||
|
||||
@property
|
||||
def is_cursor(self) -> bool:
|
||||
"""Check if platform is Cursor."""
|
||||
return self.platform == "cursor"
|
||||
|
||||
@property
|
||||
def is_iflow(self) -> bool:
|
||||
"""Check if platform is iFlow CLI."""
|
||||
return self.platform == "iflow"
|
||||
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
"""Get CLI executable name.
|
||||
|
||||
Note: Cursor doesn't have a CLI tool, returns None-like value.
|
||||
"""
|
||||
if self.is_opencode:
|
||||
return "opencode"
|
||||
elif self.is_cursor:
|
||||
return "cursor" # Note: Cursor is IDE-only, no CLI
|
||||
elif self.platform == "iflow":
|
||||
return "iflow"
|
||||
elif self.platform == "kiro":
|
||||
return "kiro"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return "agy"
|
||||
elif self.platform == "devin":
|
||||
return "devin"
|
||||
elif self.platform == "qoder":
|
||||
return "qodercli"
|
||||
elif self.platform == "codebuddy":
|
||||
return "codebuddy"
|
||||
elif self.platform == "copilot":
|
||||
return "copilot"
|
||||
elif self.platform == "droid":
|
||||
return "droid"
|
||||
elif self.platform == "pi":
|
||||
return "pi"
|
||||
elif self.platform == "trae":
|
||||
return "trae"
|
||||
elif self.platform == "omp":
|
||||
return "omp"
|
||||
elif self.platform == "grok":
|
||||
return "grok"
|
||||
elif self.platform == "kimi":
|
||||
return "kimi"
|
||||
else:
|
||||
return "claude"
|
||||
|
||||
@property
|
||||
def supports_cli_agents(self) -> bool:
|
||||
"""Check if platform supports running agents via CLI.
|
||||
|
||||
Claude Code, OpenCode, iFlow, and Codex support CLI agent execution.
|
||||
Cursor is IDE-only and doesn't support CLI agents.
|
||||
"""
|
||||
return self.platform in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"iflow",
|
||||
"codex",
|
||||
"pi",
|
||||
"grok",
|
||||
"kimi",
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_agent_definition_file(self) -> bool:
|
||||
"""Check if platform requires an agent definition file (.md/.toml) to run.
|
||||
|
||||
Claude Code, OpenCode, iFlow: require agent .md files (--agent flag).
|
||||
Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag.
|
||||
"""
|
||||
return self.platform in ("claude", "opencode", "iflow")
|
||||
|
||||
# =========================================================================
|
||||
# Session ID Handling
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def supports_session_id_on_create(self) -> bool:
|
||||
"""Check if platform supports specifying session ID on creation.
|
||||
|
||||
Claude Code: Yes (--session-id)
|
||||
OpenCode: No (auto-generated, extract from logs)
|
||||
iFlow: No (no session ID support)
|
||||
"""
|
||||
return self.platform == "claude"
|
||||
|
||||
def extract_session_id_from_log(self, log_content: str) -> str | None:
|
||||
"""Extract session ID from log output (OpenCode only).
|
||||
|
||||
OpenCode generates session IDs in format: ses_xxx
|
||||
|
||||
Args:
|
||||
log_content: Log file content
|
||||
|
||||
Returns:
|
||||
Session ID if found, None otherwise
|
||||
"""
|
||||
import re
|
||||
|
||||
# OpenCode session ID pattern
|
||||
match = re.search(r"ses_[a-zA-Z0-9]+", log_content)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Factory Function
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
|
||||
"""Get CLI adapter for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae')
|
||||
|
||||
Returns:
|
||||
CLIAdapter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If platform is not supported
|
||||
|
||||
Note:
|
||||
'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was
|
||||
renamed to Devin) and normalized before validation.
|
||||
"""
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if platform == "windsurf":
|
||||
platform = "devin"
|
||||
if platform not in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
"omp",
|
||||
"grok",
|
||||
"kimi",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', 'omp', 'grok', or 'kimi')"
|
||||
)
|
||||
|
||||
return CLIAdapter(platform=platform) # type: ignore
|
||||
|
||||
|
||||
_ALL_PLATFORM_CONFIG_DIRS = (
|
||||
".claude",
|
||||
".cursor",
|
||||
".iflow",
|
||||
".opencode",
|
||||
".codex",
|
||||
".kilocode",
|
||||
".kiro",
|
||||
".gemini",
|
||||
".agent",
|
||||
".devin",
|
||||
".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal)
|
||||
".qoder",
|
||||
".codebuddy",
|
||||
".github/copilot",
|
||||
".factory",
|
||||
".pi",
|
||||
".trae",
|
||||
".omp",
|
||||
".grok",
|
||||
".kimi-code",
|
||||
)
|
||||
"""Platform-specific config directory names used by detect_platform exclusion
|
||||
checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
|
||||
layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the
|
||||
agentskills.io standard), not a single-platform signal. Its presence must not
|
||||
block detection of Kiro, Antigravity, Devin, or other platforms."""
|
||||
|
||||
|
||||
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
|
||||
"""Check if any platform config dir exists besides those in *exclude*."""
|
||||
return any(
|
||||
(project_root / d).is_dir()
|
||||
for d in _ALL_PLATFORM_CONFIG_DIRS
|
||||
if d not in exclude
|
||||
)
|
||||
|
||||
|
||||
def detect_platform(project_root: Path) -> Platform:
|
||||
"""Auto-detect platform based on existing config directories.
|
||||
|
||||
Detection order:
|
||||
1. TRELLIS_PLATFORM environment variable (if set)
|
||||
2. .opencode directory exists → opencode
|
||||
3. .iflow directory exists → iflow
|
||||
4. .cursor directory exists (without .claude) → cursor
|
||||
5. .gemini directory exists → gemini
|
||||
6. .codex exists and no other platform dirs → codex
|
||||
7. .kilocode directory exists → kilo
|
||||
8. .kiro/skills exists and no other platform dirs → kiro
|
||||
9. .agent/workflows exists and no other platform dirs → antigravity
|
||||
10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
|
||||
11. .codebuddy directory exists → codebuddy
|
||||
12. .qoder directory exists → qoder
|
||||
13. .github/copilot directory exists → copilot
|
||||
14. .factory directory exists → droid
|
||||
15. .pi directory exists → pi
|
||||
16. .trae directory exists → trae
|
||||
17. Default → claude
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or default 'claude')
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check environment variable first
|
||||
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if env_platform == "windsurf":
|
||||
env_platform = "devin"
|
||||
if env_platform in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"cursor",
|
||||
"iflow",
|
||||
"codex",
|
||||
"kilo",
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
"omp",
|
||||
"grok",
|
||||
"kimi",
|
||||
):
|
||||
return env_platform # type: ignore
|
||||
|
||||
# Check for .opencode directory (OpenCode-specific)
|
||||
if (project_root / ".opencode").is_dir():
|
||||
return "opencode"
|
||||
|
||||
# Check for .iflow directory (iFlow-specific)
|
||||
if (project_root / ".iflow").is_dir():
|
||||
return "iflow"
|
||||
|
||||
# Check for .cursor directory (Cursor-specific)
|
||||
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
|
||||
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
|
||||
return "cursor"
|
||||
|
||||
# Check for .gemini directory (Gemini CLI-specific)
|
||||
if (project_root / ".gemini").is_dir():
|
||||
return "gemini"
|
||||
|
||||
# Check for .codex directory (Codex-specific)
|
||||
# .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
|
||||
if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".codex", ".agents"}
|
||||
):
|
||||
return "codex"
|
||||
|
||||
# Check for .kilocode directory (Kilo-specific)
|
||||
if (project_root / ".kilocode").is_dir():
|
||||
return "kilo"
|
||||
|
||||
# Check for Kiro skills directory only when no other platform config exists
|
||||
if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".kiro"}
|
||||
):
|
||||
return "kiro"
|
||||
|
||||
# Check for Antigravity workflow directory only when no other platform config exists
|
||||
if (
|
||||
project_root / ".agent" / "workflows"
|
||||
).is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".agent", ".gemini"}
|
||||
):
|
||||
return "antigravity"
|
||||
|
||||
# Check for Devin workflow directory only when no other platform config
|
||||
# exists. `.windsurf/workflows` is the legacy pre-rename path (still detected
|
||||
# as devin for back-compat until users migrate via `trellis update --migrate`).
|
||||
if (
|
||||
(project_root / ".devin" / "workflows").is_dir()
|
||||
or (project_root / ".windsurf" / "workflows").is_dir()
|
||||
) and not _has_other_platform_dir(
|
||||
project_root, {".devin", ".windsurf"}
|
||||
):
|
||||
return "devin"
|
||||
|
||||
# Check for .codebuddy directory (CodeBuddy-specific)
|
||||
if (project_root / ".codebuddy").is_dir():
|
||||
return "codebuddy"
|
||||
|
||||
# Check for .qoder directory (Qoder-specific)
|
||||
if (project_root / ".qoder").is_dir():
|
||||
return "qoder"
|
||||
|
||||
# Check for .github/copilot directory (GitHub Copilot-specific)
|
||||
if (project_root / ".github" / "copilot").is_dir():
|
||||
return "copilot"
|
||||
|
||||
# Check for .factory directory (Factory Droid-specific)
|
||||
if (project_root / ".factory").is_dir():
|
||||
return "droid"
|
||||
|
||||
# Check for .pi directory (Pi Agent-specific)
|
||||
if (project_root / ".pi").is_dir():
|
||||
return "pi"
|
||||
|
||||
# Check for .trae directory (Trae IDE-specific)
|
||||
if (project_root / ".trae").is_dir():
|
||||
return "trae"
|
||||
|
||||
# Check for .omp directory (OMP-specific)
|
||||
if (project_root / ".omp").is_dir():
|
||||
return "omp"
|
||||
|
||||
# Check for .grok directory (Grok Build-specific)
|
||||
if (project_root / ".grok").is_dir():
|
||||
return "grok"
|
||||
|
||||
# Check for .kimi-code directory (Kimi Code-specific)
|
||||
if (project_root / ".kimi-code").is_dir():
|
||||
return "kimi"
|
||||
|
||||
# Fallback: checkout only has the Codex shared-skills layer
|
||||
# (.agents/skills/trellis-* dirs) and no explicit platform config dir.
|
||||
# Happens on fresh clones where .codex/ is gitignored/absent but the
|
||||
# shared skills were committed to git. Must guard against the case
|
||||
# where .claude/ or any other platform dir also exists — .agents/skills/
|
||||
# can legitimately coexist with any platform as a shared consumption
|
||||
# layer for Amp/Cline/Warp/etc.
|
||||
agents_skills = project_root / ".agents" / "skills"
|
||||
if agents_skills.is_dir() and not _has_other_platform_dir(
|
||||
project_root, set()
|
||||
):
|
||||
try:
|
||||
for entry in agents_skills.iterdir():
|
||||
if entry.is_dir() and entry.name.startswith("trellis-"):
|
||||
return "codex"
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return "claude"
|
||||
|
||||
|
||||
def get_cli_adapter_auto(project_root: Path) -> CLIAdapter:
|
||||
"""Get CLI adapter with auto-detected platform.
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
CLIAdapter instance for detected platform
|
||||
"""
|
||||
platform = detect_platform(project_root)
|
||||
return CLIAdapter(platform=platform)
|
||||
Executable
+482
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Trellis configuration reader.
|
||||
|
||||
Reads settings from .trellis/config.yaml with sensible defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
from .trellis_config import parse_simple_yaml
|
||||
|
||||
# The YAML subset parser lives in trellis_config.py — it imports nothing from
|
||||
# this package, so hooks can load it as a single standalone file. Two byte-
|
||||
# equivalent copies is a drift hazard, not a feature.
|
||||
|
||||
|
||||
# Defaults
|
||||
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
||||
DEFAULT_MAX_JOURNAL_LINES = 2000
|
||||
DEFAULT_SESSION_AUTO_COMMIT = True
|
||||
DEFAULT_CODEX_DISPATCH_MODE = "auto"
|
||||
|
||||
CONFIG_FILE = "config.yaml"
|
||||
|
||||
|
||||
TRUE_CONFIG_VALUES = ("true", "yes", "1", "on")
|
||||
FALSE_CONFIG_VALUES = ("false", "no", "0", "off")
|
||||
|
||||
|
||||
def coerce_config_bool(
|
||||
value: object,
|
||||
default: bool,
|
||||
label: str,
|
||||
) -> bool:
|
||||
"""Coerce a config value to a bool, warning on anything unrecognized.
|
||||
|
||||
The parser stores every value as a string, so ``git: yes`` arrives as
|
||||
``"yes"``. Every boolean config key goes through this one helper: an
|
||||
accepted-here/rejected-there split means a user writing a perfectly
|
||||
reasonable YAML boolean silently gets the opposite branch.
|
||||
|
||||
Args:
|
||||
value: Raw value from the parsed config.
|
||||
default: Returned when the value is unrecognized.
|
||||
label: Config key name, used in the warning.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
s = str(value).strip().lower()
|
||||
if s in TRUE_CONFIG_VALUES:
|
||||
return True
|
||||
if s in FALSE_CONFIG_VALUES:
|
||||
return False
|
||||
print(
|
||||
f"[WARN] invalid {label} value: {value!r}; using {str(default).lower()} (default)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
def _is_true_config_value(value: object, label: str = "config flag") -> bool:
|
||||
"""Return True when a config value represents an enabled flag."""
|
||||
if value is None:
|
||||
return False
|
||||
return coerce_config_bool(value, False, label)
|
||||
|
||||
|
||||
def _get_config_path(repo_root: Path | None = None) -> Path:
|
||||
"""Get path to config.yaml."""
|
||||
root = repo_root or get_repo_root()
|
||||
return root / DIR_WORKFLOW / CONFIG_FILE
|
||||
|
||||
|
||||
def _load_config(repo_root: Path | None = None) -> dict:
|
||||
"""Load and parse config.yaml. Returns empty dict on any error.
|
||||
|
||||
Fail-open, matching ``trellis_config.read_trellis_config``: a malformed
|
||||
config must not take down ``task.py create``. A parse failure is reported
|
||||
once on stderr so it is not invisible.
|
||||
"""
|
||||
config_file = _get_config_path(repo_root)
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except (OSError, IOError):
|
||||
return {}
|
||||
try:
|
||||
parsed = parse_simple_yaml(content, source=str(config_file))
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[WARN] could not parse {config_file}: {type(e).__name__}: {e}; "
|
||||
"using defaults",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
||||
"""Get the commit message for auto-committing session records."""
|
||||
config = _load_config(repo_root)
|
||||
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
||||
|
||||
|
||||
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
||||
"""Get the maximum lines per journal file."""
|
||||
config = _load_config(repo_root)
|
||||
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_MAX_JOURNAL_LINES
|
||||
|
||||
|
||||
def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
||||
"""Whether scripts should auto-stage + auto-commit session/task changes.
|
||||
|
||||
Governs both ``add_session.py:_auto_commit_workspace`` and
|
||||
``task_store.py:_auto_commit_archive``.
|
||||
|
||||
Default: ``True`` (existing behavior — auto-stage + auto-commit).
|
||||
Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
|
||||
auto-staging entirely; the journal/archive files are still written to
|
||||
disk, but the user manages ``git add`` / ``git commit`` themselves.
|
||||
|
||||
Accepts native YAML booleans (``true`` / ``false``) and the string
|
||||
aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
|
||||
Invalid values fall back to ``True`` with a stderr warning.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
|
||||
return coerce_config_bool(
|
||||
raw, DEFAULT_SESSION_AUTO_COMMIT, "session_auto_commit"
|
||||
)
|
||||
|
||||
|
||||
def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||
"""Return Codex dispatch mode.
|
||||
|
||||
Default is ``auto``, which dispatches Trellis sub-agents and uses native
|
||||
context injection with a child-side fallback. ``inline`` is an explicit
|
||||
opt-out. ``sub-agent`` remains a backwards-compatible alias for ``auto``.
|
||||
|
||||
Invalid explicit configuration falls back to ``inline`` rather than
|
||||
unexpectedly dispatching a sub-agent. This CLI-facing parser is the only
|
||||
place that emits a warning for invalid values; hook readers fail safely
|
||||
without producing per-turn warning noise.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
codex = config.get("codex")
|
||||
if codex is None:
|
||||
return DEFAULT_CODEX_DISPATCH_MODE
|
||||
if not isinstance(codex, dict):
|
||||
print(
|
||||
f"[WARN] invalid codex config: {codex!r}; using inline",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return "inline"
|
||||
|
||||
raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE)
|
||||
mode = str(raw).strip().lower()
|
||||
if mode in ("auto", "inline"):
|
||||
return mode
|
||||
if mode == "sub-agent":
|
||||
return "auto"
|
||||
print(
|
||||
f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using inline",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return "inline"
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||
|
||||
|
||||
def get_context_injection_limits(repo_root: Path | None = None) -> dict[str, int]:
|
||||
"""Return sub-agent context injection byte limits.
|
||||
|
||||
Reads the ``context_injection:`` section of ``.trellis/config.yaml``:
|
||||
|
||||
context_injection:
|
||||
max_file_bytes: 32768
|
||||
max_artifact_bytes: 65536
|
||||
max_total_bytes: 131072
|
||||
|
||||
``0`` disables the corresponding limit. Missing keys use their default;
|
||||
invalid (non-int or negative) values fall back to the default for that
|
||||
key with a stderr warning.
|
||||
"""
|
||||
defaults = {
|
||||
"max_file_bytes": DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES,
|
||||
"max_artifact_bytes": DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES,
|
||||
"max_total_bytes": DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES,
|
||||
}
|
||||
|
||||
config = _load_config(repo_root)
|
||||
section = config.get("context_injection")
|
||||
if not isinstance(section, dict):
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
for key, default_value in defaults.items():
|
||||
if key not in section:
|
||||
continue
|
||||
raw = section[key]
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
print(
|
||||
f"[WARN] invalid context_injection.{key} value: {raw!r}; "
|
||||
f"using default {default_value}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
if value < 0:
|
||||
print(
|
||||
f"[WARN] invalid context_injection.{key} value: {raw!r}; "
|
||||
f"using default {default_value}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
|
||||
|
||||
|
||||
def get_prompt_injection_config(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Return per-turn prompt injection config.
|
||||
|
||||
Reads the ``prompt_injection:`` section of ``.trellis/config.yaml``:
|
||||
|
||||
prompt_injection:
|
||||
skip_keyword: "no-trellis" # "" disables the escape hatch entirely
|
||||
|
||||
``skip_keyword`` is the word-boundary, case-insensitive keyword that, when
|
||||
present in the user's prompt, makes the per-turn workflow-state injection
|
||||
emit nothing for that turn. Defaults to ``"no-trellis"``. A non-string
|
||||
value falls back to the default.
|
||||
"""
|
||||
defaults = {"skip_keyword": DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD}
|
||||
|
||||
config = _load_config(repo_root)
|
||||
section = config.get("prompt_injection")
|
||||
if not isinstance(section, dict):
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD)
|
||||
if isinstance(raw, str):
|
||||
result["skip_keyword"] = raw
|
||||
return result
|
||||
|
||||
|
||||
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
||||
"""Get hook commands for a lifecycle event.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create", "after_archive").
|
||||
repo_root: Repository root path.
|
||||
|
||||
A hook the user believes is installed and which silently never runs is the
|
||||
worst outcome for this feature, so a declared-but-unusable shape warns
|
||||
instead of returning an empty list quietly.
|
||||
|
||||
Returns:
|
||||
List of shell commands to execute, empty if none configured.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
hooks = config.get("hooks")
|
||||
if hooks is None:
|
||||
return []
|
||||
if not isinstance(hooks, dict):
|
||||
print(
|
||||
f"[WARN] ignoring `hooks` in config.yaml: expected a mapping of "
|
||||
f"event -> list of commands, got {hooks!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
commands = hooks.get(event)
|
||||
if commands is None:
|
||||
return []
|
||||
if isinstance(commands, list):
|
||||
return [str(c) for c in commands]
|
||||
# `after_create: echo hi` instead of a `- ` list — parses fine, registers
|
||||
# nothing.
|
||||
print(
|
||||
f"[WARN] ignoring hook `{event}` in config.yaml: expected a list of "
|
||||
f"commands, got {commands!r}. Write it as:\n"
|
||||
f" hooks:\n {event}:\n - {commands}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monorepo / Packages
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
|
||||
"""Get monorepo package declarations.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its config (path, type, etc.),
|
||||
or None if not configured (single-repo mode).
|
||||
|
||||
Example return:
|
||||
{"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
packages = config.get("packages")
|
||||
if not isinstance(packages, dict):
|
||||
return None
|
||||
# Ensure each value is a dict (filter out scalar entries)
|
||||
filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
|
||||
if not filtered:
|
||||
return None
|
||||
return filtered
|
||||
|
||||
|
||||
def get_default_package(repo_root: Path | None = None) -> str | None:
|
||||
"""Get the default package name from config.
|
||||
|
||||
Returns:
|
||||
Package name string, or None if not configured.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
value = config.get("default_package")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Get packages that are git submodules.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its path for submodule-type packages.
|
||||
Empty dict if none configured.
|
||||
|
||||
Example return:
|
||||
{"docs-site": "docs-site"}
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return {}
|
||||
return {
|
||||
name: cfg.get("path", name)
|
||||
for name, cfg in packages.items()
|
||||
if cfg.get("type") == "submodule"
|
||||
}
|
||||
|
||||
|
||||
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Get packages that have their own independent git repository.
|
||||
|
||||
These are sub-directories with their own .git (not submodules),
|
||||
marked with ``git: true`` in config.yaml.
|
||||
|
||||
Returns:
|
||||
Dict mapping package name to its path for git-repo packages.
|
||||
Empty dict if none configured.
|
||||
|
||||
Example config::
|
||||
|
||||
packages:
|
||||
backend:
|
||||
path: iqs
|
||||
git: true
|
||||
|
||||
Example return::
|
||||
|
||||
{"backend": "iqs"}
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return {}
|
||||
return {
|
||||
name: cfg.get("path", name)
|
||||
for name, cfg in packages.items()
|
||||
if _is_true_config_value(cfg.get("git"), f"packages.{name}.git")
|
||||
}
|
||||
|
||||
|
||||
def is_monorepo(repo_root: Path | None = None) -> bool:
|
||||
"""Check if the project is configured as a monorepo (has packages in config)."""
|
||||
return get_packages(repo_root) is not None
|
||||
|
||||
|
||||
def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
|
||||
"""Get the spec directory base path relative to .trellis/.
|
||||
|
||||
Single-repo: returns "spec"
|
||||
Monorepo with package: returns "spec/<package>"
|
||||
Monorepo without package: returns "spec" (caller should specify package)
|
||||
"""
|
||||
if package and is_monorepo(repo_root):
|
||||
return f"spec/{package}"
|
||||
return "spec"
|
||||
|
||||
|
||||
def validate_package(package: str, repo_root: Path | None = None) -> bool:
|
||||
"""Check if a package name is valid in this project.
|
||||
|
||||
Single-repo (no packages configured): always returns True.
|
||||
Monorepo: returns True only if package exists in config.yaml packages.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return True # Single-repo, no validation needed
|
||||
return package in packages
|
||||
|
||||
|
||||
def resolve_package(
|
||||
task_package: str | None = None,
|
||||
repo_root: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve package from inferred sources with validation.
|
||||
|
||||
Checks in order: task_package → default_package.
|
||||
Invalid inferred values print a warning to stderr and are skipped.
|
||||
|
||||
Returns:
|
||||
Resolved package name, or None if no valid package found.
|
||||
|
||||
Note:
|
||||
CLI --package should be validated separately by the caller
|
||||
(fail-fast with available packages list on error).
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if packages is None:
|
||||
return None # Single-repo, no package needed
|
||||
|
||||
# Try task_package (guard against non-string values from malformed JSON)
|
||||
if task_package and isinstance(task_package, str):
|
||||
if task_package in packages:
|
||||
return task_package
|
||||
print(
|
||||
f"Warning: task.json package '{task_package}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Try default_package
|
||||
default = get_default_package(repo_root)
|
||||
if default:
|
||||
if default in packages:
|
||||
return default
|
||||
print(
|
||||
f"Warning: default_package '{default}' not found in config, skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
|
||||
"""Get session.spec_scope configuration.
|
||||
|
||||
Returns:
|
||||
list[str]: Package names to include in spec scanning.
|
||||
str: "active_task" to use current task's package.
|
||||
None: No scope configured (scan all packages).
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
session = config.get("session")
|
||||
if not isinstance(session, dict):
|
||||
return None
|
||||
|
||||
scope = session.get("spec_scope")
|
||||
if scope is None:
|
||||
return None
|
||||
if isinstance(scope, str):
|
||||
return scope # e.g. "active_task"
|
||||
if isinstance(scope, list):
|
||||
return [str(s) for s in scope]
|
||||
return None
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Developer management utilities.
|
||||
|
||||
Provides:
|
||||
init_developer - Initialize developer
|
||||
ensure_developer - Ensure developer is initialized (exit if not)
|
||||
show_developer_info - Show developer information
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import (
|
||||
DEVELOPER_HINT,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
DIR_TASKS,
|
||||
FILE_DEVELOPER,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
check_developer,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Developer Initialization
|
||||
# =============================================================================
|
||||
|
||||
def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
||||
"""Initialize developer.
|
||||
|
||||
Creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
- Initial journal file and index.md
|
||||
|
||||
Args:
|
||||
name: Developer name.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if not name:
|
||||
print("Error: developer name is required", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
|
||||
|
||||
# Create .developer file
|
||||
initialized_at = datetime.now().isoformat()
|
||||
try:
|
||||
dev_file.write_text(
|
||||
f"name={name}\ninitialized_at={initialized_at}\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create workspace directory structure
|
||||
try:
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create initial journal file
|
||||
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
||||
if not journal_file.exists():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
journal_content = f"""# Journal - {name} (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: {today}
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
try:
|
||||
journal_file.write_text(journal_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Create index.md with markers for auto-update
|
||||
index_file = workspace_dir / "index.md"
|
||||
if not index_file.exists():
|
||||
index_content = f"""# Workspace Index - {name}
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
"""
|
||||
try:
|
||||
index_file.write_text(index_content, encoding="utf-8")
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print(f"Developer initialized: {name}")
|
||||
print(f" .developer file: {dev_file}")
|
||||
print(f" Workspace dir: {workspace_dir}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_developer(repo_root: Path | None = None) -> None:
|
||||
"""Ensure developer is initialized, exit if not.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
if not check_developer(repo_root):
|
||||
print("Error: Developer not initialized.", file=sys.stderr)
|
||||
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
|
||||
print(DEVELOPER_HINT, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def show_developer_info(repo_root: Path | None = None) -> None:
|
||||
"""Show developer information.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
if not developer:
|
||||
print("Developer: (not initialized)")
|
||||
else:
|
||||
print(f"Developer: {developer}")
|
||||
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_developer_info()
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Git command execution utility.
|
||||
|
||||
Single source of truth for running git commands across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Bounded retry for transient `.git/index.lock` contention. Another process
|
||||
# (IDE git integration, status daemon, a concurrent Trellis session) can hold
|
||||
# the lock for a fraction of a second; three attempts spread over ~1.5s ride
|
||||
# that out without making a genuinely stuck lock hang the command. One sleep
|
||||
# per retry, so the attempt count follows the backoff tuple.
|
||||
INDEX_LOCK_RETRY_BACKOFF = (0.5, 1.0)
|
||||
INDEX_LOCK_RETRY_ATTEMPTS = len(INDEX_LOCK_RETRY_BACKOFF) + 1
|
||||
|
||||
# Whether a checkout is a linked worktree cannot change while a script runs, and
|
||||
# the answer costs two subprocesses. Developer-identity resolution asks several
|
||||
# times per command whenever the local `.developer` file is absent, so memoize.
|
||||
_CACHE_MISS = object()
|
||||
_MAIN_WORKTREE_CACHE: dict[Path, Path | None] = {}
|
||||
|
||||
|
||||
def run_git(
|
||||
args: list[str],
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr).
|
||||
|
||||
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
|
||||
consistent output across all platforms (Windows, macOS, Linux). Callers
|
||||
may provide a timeout for best-effort probes; normal Git operations remain
|
||||
unbounded by default.
|
||||
"""
|
||||
try:
|
||||
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
|
||||
result = subprocess.run(
|
||||
git_args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
|
||||
|
||||
def stderr_indicates_index_lock(stderr: str) -> bool:
|
||||
"""git failed because another process holds `.git/index.lock`."""
|
||||
if not stderr:
|
||||
return False
|
||||
return "index.lock" in stderr.lower()
|
||||
|
||||
|
||||
def run_git_retry_index_lock(
|
||||
args: list[str],
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run a git command, retrying only while `.git/index.lock` is held.
|
||||
|
||||
Any other non-zero exit returns immediately — a retry loop around real
|
||||
failures (bad path, nothing to commit, hook rejection) just delays the
|
||||
error. Returns the last (returncode, stdout, stderr).
|
||||
"""
|
||||
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
|
||||
attempt = 1
|
||||
while (
|
||||
rc != 0
|
||||
and attempt < INDEX_LOCK_RETRY_ATTEMPTS
|
||||
and stderr_indicates_index_lock(err)
|
||||
):
|
||||
time.sleep(INDEX_LOCK_RETRY_BACKOFF[attempt - 1])
|
||||
attempt += 1
|
||||
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
|
||||
return rc, out, err
|
||||
|
||||
|
||||
def index_lock_path(repo_root: Path) -> str:
|
||||
"""Path of the lock file git is contending on, for diagnostics.
|
||||
|
||||
Asks git so worktrees and `GIT_DIR` setups name the real file rather
|
||||
than a `.git/` guess that does not exist there.
|
||||
"""
|
||||
rc, out, _ = run_git(["rev-parse", "--git-path", "index.lock"], cwd=repo_root)
|
||||
if rc == 0 and out.strip():
|
||||
return out.strip()
|
||||
return ".git/index.lock"
|
||||
|
||||
|
||||
def resolve_default_branch(repo_root: Path) -> str | None:
|
||||
"""Resolve the repository's default branch (origin/HEAD target).
|
||||
|
||||
Tries the local `refs/remotes/origin/HEAD` symbolic ref first (no
|
||||
network access), then falls back to `git remote show origin` (which
|
||||
may hit the network but also repairs a missing/stale symbolic-ref).
|
||||
Returns None when neither resolves, so callers can fall back to their
|
||||
own pre-existing behavior.
|
||||
"""
|
||||
rc, out, _ = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root)
|
||||
if rc == 0 and out.strip():
|
||||
return out.strip().rsplit("/", 1)[-1]
|
||||
|
||||
rc, out, _ = run_git(["remote", "show", "origin"], cwd=repo_root)
|
||||
if rc == 0:
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("HEAD branch:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
if branch and branch != "(unknown)":
|
||||
return branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def current_branch_name(repo_root: Path) -> str | None:
|
||||
"""Return the checked-out branch name, or None when there isn't one.
|
||||
|
||||
Empty output covers detached HEAD and "not a git repository" alike, and
|
||||
callers treat both the same way: there is no branch worth recording.
|
||||
"""
|
||||
rc, out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
if rc != 0:
|
||||
return None
|
||||
return out.strip() or None
|
||||
|
||||
|
||||
def has_git_remote(repo_root: Path) -> bool:
|
||||
"""Whether the repository has at least one configured remote."""
|
||||
rc, out, _ = run_git(["remote"], cwd=repo_root)
|
||||
return rc == 0 and bool(out.strip())
|
||||
|
||||
|
||||
def main_worktree_root(repo_root: Path) -> Path | None:
|
||||
"""Root of the main working tree when `repo_root` is a linked worktree.
|
||||
|
||||
Returns None in the main working tree itself, outside a git repository, and
|
||||
for a linked worktree of a bare repository (no main checkout to point at).
|
||||
|
||||
`git worktree list --porcelain` reports the main working tree as its first
|
||||
record, so git identifies it rather than this code deriving it from the
|
||||
`.git` layout. Deriving it — taking the parent of `--git-common-dir` — is
|
||||
wrong for a bare repository that happens to sit inside an unrelated
|
||||
checkout (`~/repos/project.git` under a `~/repos` that is itself a repo):
|
||||
the parent is a real checkout with a real `.developer`, so the guess is
|
||||
indistinguishable from a hit and identity leaks across repositories.
|
||||
"""
|
||||
cached = _MAIN_WORKTREE_CACHE.get(repo_root, _CACHE_MISS)
|
||||
if cached is not _CACHE_MISS:
|
||||
return cached # type: ignore[return-value]
|
||||
|
||||
result = _probe_main_worktree_root(repo_root)
|
||||
_MAIN_WORKTREE_CACHE[repo_root] = result
|
||||
return result
|
||||
|
||||
|
||||
def _probe_main_worktree_root(repo_root: Path) -> Path | None:
|
||||
rc_list, listing, _ = run_git(["worktree", "list", "--porcelain"], cwd=repo_root)
|
||||
rc_top, toplevel, _ = run_git(["rev-parse", "--show-toplevel"], cwd=repo_root)
|
||||
if rc_list != 0 or rc_top != 0:
|
||||
return None
|
||||
|
||||
lines = listing.splitlines()
|
||||
if not lines or not lines[0].startswith("worktree "):
|
||||
return None
|
||||
|
||||
# Records are blank-line separated; a `bare` attribute on the first one
|
||||
# means the "main working tree" is a bare repo with nothing to inherit.
|
||||
for line in lines[1:]:
|
||||
if not line.strip():
|
||||
break
|
||||
if line.strip() == "bare":
|
||||
return None
|
||||
|
||||
try:
|
||||
main_root = Path(lines[0][len("worktree ") :].strip()).resolve()
|
||||
current_root = Path(toplevel.strip()).resolve()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
if main_root == current_root:
|
||||
return None
|
||||
return main_root
|
||||
|
||||
|
||||
def branch_exists_locally(branch: str, repo_root: Path) -> bool:
|
||||
"""Check whether a local branch ref exists in the repository."""
|
||||
if not branch:
|
||||
return False
|
||||
rc, _, _ = run_git(
|
||||
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
return rc == 0
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Git and Session Context utilities.
|
||||
|
||||
Entry shim — delegates to session_context and packages_context.
|
||||
|
||||
Provides:
|
||||
output_json - Output context in JSON format
|
||||
output_text - Output context in text format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .git import run_git
|
||||
from .session_context import (
|
||||
get_context_json,
|
||||
get_context_text,
|
||||
get_context_record_json,
|
||||
get_context_text_record,
|
||||
output_json,
|
||||
output_text,
|
||||
)
|
||||
from .packages_context import (
|
||||
get_context_packages_text,
|
||||
get_context_packages_json,
|
||||
)
|
||||
from .trellis_config import read_trellis_config
|
||||
from .workflow_phase import (
|
||||
filter_platform,
|
||||
get_phase_index,
|
||||
get_step,
|
||||
resolve_effective_platform,
|
||||
)
|
||||
|
||||
# Backward-compatible alias — external modules import this name
|
||||
_run_git_command = run_git
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
"-j",
|
||||
action="store_true",
|
||||
help="Output in JSON format (works with any --mode)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
"-m",
|
||||
choices=["default", "record", "packages", "phase"],
|
||||
default="default",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == "record":
|
||||
if args.json:
|
||||
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(get_context_text_record())
|
||||
elif args.mode == "packages":
|
||||
if args.json:
|
||||
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(get_context_packages_text())
|
||||
elif args.mode == "phase":
|
||||
content = get_step(args.step) if args.step else get_phase_index()
|
||||
if not content.strip():
|
||||
if args.step:
|
||||
parser.exit(2, f"Step not found: {args.step}\n")
|
||||
else:
|
||||
parser.exit(2, "Phase Index section not found in workflow.md\n")
|
||||
if args.platform:
|
||||
effective = resolve_effective_platform(
|
||||
args.platform, read_trellis_config()
|
||||
)
|
||||
content = filter_platform(content, effective)
|
||||
print(content, end="")
|
||||
else:
|
||||
if args.json:
|
||||
output_json()
|
||||
else:
|
||||
output_text()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
File I/O utilities.
|
||||
|
||||
Provides read_json / write_json as the single source of truth for JSON file
|
||||
operations, plus write_text_atomic for the Markdown state files (journal,
|
||||
index.md) that carry durable session state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
JSON_READ_MISSING = "missing"
|
||||
JSON_READ_INVALID = "invalid"
|
||||
JSON_READ_UNREADABLE = "unreadable"
|
||||
JSON_READ_NOT_OBJECT = "not-object"
|
||||
JSON_READ_EMPTY = "empty"
|
||||
JSON_READ_UNDECODABLE = "undecodable"
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict | None:
|
||||
"""Read and parse a JSON file.
|
||||
|
||||
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
|
||||
Use this for optional reads only — a caller that is about to overwrite the
|
||||
file, or that must tell a parse error from a permissions error, wants
|
||||
read_json_checked instead.
|
||||
"""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
# UnicodeDecodeError is not an OSError. Without it here a non-UTF-8
|
||||
# session file raises out of a tolerant read, so the hook path fails
|
||||
# instead of degrading to "no active task".
|
||||
return None
|
||||
|
||||
|
||||
def read_json_checked(path: Path) -> tuple[dict | None, str | None]:
|
||||
"""Read a JSON object, keeping the ways it can fail distinguishable.
|
||||
|
||||
Returns ``(data, None)`` on success, or ``(None, reason)`` where reason is
|
||||
one of the ``JSON_READ_*`` constants. An empty object counts as a failure:
|
||||
a state file that parses to ``{}`` carries none of the fields callers read,
|
||||
and treating it as success would silently rebuild it from defaults.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None, JSON_READ_MISSING
|
||||
except UnicodeDecodeError:
|
||||
# Not an OSError, so it escaped both handlers and surfaced as a
|
||||
# traceback. The point of this reader is that every failure mode stays
|
||||
# nameable, and "not valid UTF-8" is a different repair from
|
||||
# "not valid JSON".
|
||||
return None, JSON_READ_UNDECODABLE
|
||||
except OSError:
|
||||
return None, JSON_READ_UNREADABLE
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None, JSON_READ_INVALID
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None, JSON_READ_NOT_OBJECT
|
||||
if not data:
|
||||
return None, JSON_READ_EMPTY
|
||||
return data, None
|
||||
|
||||
|
||||
def describe_json_read_failure(path: Path, reason: str | None) -> tuple[str, str]:
|
||||
"""Return ``(what happened, what to do)`` for a read_json_checked reason."""
|
||||
if reason == JSON_READ_MISSING:
|
||||
return (f"{path}: file not found", "Pass an existing task directory, or create the task first.")
|
||||
if reason == JSON_READ_UNREADABLE:
|
||||
return (
|
||||
f"{path}: could not be read (permission denied or I/O error)",
|
||||
"Check the file and directory permissions, then retry.",
|
||||
)
|
||||
if reason == JSON_READ_INVALID:
|
||||
return (
|
||||
f"{path}: not valid JSON",
|
||||
f"Fix the syntax (e.g. `python3 -m json.tool {path}`), then retry.",
|
||||
)
|
||||
if reason == JSON_READ_NOT_OBJECT:
|
||||
return (
|
||||
f"{path}: top level is not a JSON object",
|
||||
"Restore the file to a JSON object ({ ... }), then retry.",
|
||||
)
|
||||
if reason == JSON_READ_EMPTY:
|
||||
return (
|
||||
f"{path}: contains an empty JSON object",
|
||||
"Restore the task fields (or recreate the task), then retry.",
|
||||
)
|
||||
if reason == JSON_READ_UNDECODABLE:
|
||||
return (
|
||||
f"{path}: not valid UTF-8 text",
|
||||
"Re-save the file as UTF-8 (or restore it from git), then retry.",
|
||||
)
|
||||
return (f"{path}: could not be loaded", "Inspect the file, then retry.")
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file with pretty formatting.
|
||||
|
||||
The write is atomic: content goes to a temp file in the same directory
|
||||
and is then renamed over the target. A crash or Ctrl-C mid-write leaves
|
||||
the existing file intact rather than truncated, so a corrupted task.json
|
||||
can never make a task silently vanish from `task.py list`.
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
return write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, text: str) -> bool:
|
||||
"""Write text to a file atomically (temp in same dir, then replace).
|
||||
|
||||
The same never-truncate-in-place guarantee as :func:`write_json`, for the
|
||||
Markdown state files that hold durable session state (journal files,
|
||||
index.md). A crash or Ctrl-C mid-write leaves the previous content intact
|
||||
instead of a half-written record that no retry can classify.
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
try:
|
||||
try:
|
||||
f = os.fdopen(fd, "w", encoding="utf-8")
|
||||
except OSError:
|
||||
# fdopen never took ownership of fd; close it ourselves.
|
||||
os.close(fd)
|
||||
raise
|
||||
with f:
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except OSError:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
except BaseException:
|
||||
# Ctrl-C mid-write: drop the temp file, then let the interrupt through.
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Terminal output utilities: colors and structured logging.
|
||||
|
||||
Single source of truth for Colors and log_* functions
|
||||
used across all Trellis scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Colors:
|
||||
"""ANSI color codes for terminal output."""
|
||||
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
CYAN = "\033[0;36m"
|
||||
DIM = "\033[2m"
|
||||
NC = "\033[0m" # No Color / Reset
|
||||
|
||||
|
||||
def colored(text: str, color: str) -> str:
|
||||
"""Apply ANSI color to text."""
|
||||
return f"{color}{text}{Colors.NC}"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
"""Print info-level message with [INFO] prefix."""
|
||||
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
"""Print success message with [SUCCESS] prefix."""
|
||||
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str) -> None:
|
||||
"""Print warning message with [WARN] prefix."""
|
||||
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
"""Print error message with [ERROR] prefix."""
|
||||
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Package discovery and context output.
|
||||
|
||||
Provides:
|
||||
get_packages_info - Get structured package info
|
||||
get_packages_section - Build PACKAGES text section
|
||||
get_context_packages_text - Full packages text output (--mode packages)
|
||||
get_context_packages_json - Full packages JSON output (--mode packages --json)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
|
||||
from .paths import (
|
||||
DIR_SPEC,
|
||||
DIR_WORKFLOW,
|
||||
get_current_task,
|
||||
get_repo_root,
|
||||
)
|
||||
from .tasks import load_task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal Helpers
|
||||
# =============================================================================
|
||||
|
||||
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
|
||||
"""Scan spec directory for available layers (subdirectories).
|
||||
|
||||
For monorepo: scans spec/<package>/
|
||||
For single-repo: scans spec/
|
||||
"""
|
||||
target = spec_dir / package if package else spec_dir
|
||||
if not target.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
|
||||
)
|
||||
|
||||
|
||||
def _get_active_task_package(repo_root: Path) -> str | None:
|
||||
"""Get the package field from the active task's task.json."""
|
||||
current = get_current_task(repo_root)
|
||||
if not current:
|
||||
return None
|
||||
ct = load_task(repo_root / current)
|
||||
return ct.package if ct and ct.package else None
|
||||
|
||||
|
||||
def _resolve_scope_set(
|
||||
packages: dict,
|
||||
spec_scope,
|
||||
task_pkg: str | None,
|
||||
default_pkg: str | None,
|
||||
) -> set | None:
|
||||
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
if spec_scope is None:
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, str) and spec_scope == "active_task":
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
if isinstance(spec_scope, list):
|
||||
valid = {e for e in spec_scope if e in packages}
|
||||
if valid:
|
||||
return valid
|
||||
# All invalid: fallback
|
||||
if task_pkg and task_pkg in packages:
|
||||
return {task_pkg}
|
||||
if default_pkg and default_pkg in packages:
|
||||
return {default_pkg}
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def get_packages_info(repo_root: Path) -> list[dict]:
|
||||
"""Get structured package info for monorepo projects.
|
||||
|
||||
Returns list of dicts with keys: name, path, type, default, specLayers,
|
||||
isSubmodule, isGitRepo.
|
||||
Returns empty list for single-repo projects.
|
||||
"""
|
||||
packages = get_packages(repo_root)
|
||||
if not packages:
|
||||
return []
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
result = []
|
||||
|
||||
for pkg_name, pkg_config in packages.items():
|
||||
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
|
||||
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
|
||||
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
|
||||
layers = _scan_spec_layers(spec_dir, pkg_name)
|
||||
|
||||
result.append({
|
||||
"name": pkg_name,
|
||||
"path": pkg_path,
|
||||
"type": pkg_type,
|
||||
"default": pkg_name == default_pkg,
|
||||
"specLayers": layers,
|
||||
"isSubmodule": pkg_type == "submodule",
|
||||
"isGitRepo": _is_true_config_value(pkg_git),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_packages_section(repo_root: Path) -> str:
|
||||
"""Build the PACKAGES section for text output."""
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("## PACKAGES")
|
||||
|
||||
if not pkg_info:
|
||||
lines.append("(single-repo mode)")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
|
||||
for pkg in pkg_info:
|
||||
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
|
||||
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
|
||||
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
|
||||
default_tag = " *" if pkg["default"] else ""
|
||||
lines.append(
|
||||
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
|
||||
)
|
||||
|
||||
if default_pkg:
|
||||
lines.append(f"Default package: {default_pkg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_text(repo_root: Path | None = None) -> str:
|
||||
"""Get packages context as formatted text (for --mode packages)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
lines: list[str] = []
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
lines.append("Single-repo project (no packages configured)")
|
||||
lines.append("")
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
if layers:
|
||||
lines.append(f"Spec layers: {', '.join(layers)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Resolve scope for annotations
|
||||
packages_dict = get_packages(repo_root) or {}
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
|
||||
|
||||
lines.append("## PACKAGES")
|
||||
lines.append("")
|
||||
for pkg in pkg_info:
|
||||
default_tag = " (default)" if pkg["default"] else ""
|
||||
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
|
||||
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
|
||||
|
||||
# Scope annotation
|
||||
scope_tag = ""
|
||||
if scope_set is not None and pkg["name"] not in scope_set:
|
||||
scope_tag = " (out of scope)"
|
||||
|
||||
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
|
||||
lines.append(f"Path: {pkg['path']}")
|
||||
if pkg["specLayers"]:
|
||||
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
|
||||
for layer in pkg["specLayers"]:
|
||||
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
|
||||
else:
|
||||
lines.append("Spec: not configured")
|
||||
lines.append("")
|
||||
|
||||
# Also show shared guides
|
||||
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
|
||||
if guides_dir.is_dir():
|
||||
lines.append("### Shared Guides (always included)")
|
||||
lines.append("Path: .trellis/spec/guides/index.md")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_context_packages_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get packages context as a dictionary (for --mode packages --json)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
pkg_info = get_packages_info(repo_root)
|
||||
|
||||
if not pkg_info:
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
layers = _scan_spec_layers(spec_dir)
|
||||
return {
|
||||
"mode": "single-repo",
|
||||
"specLayers": layers,
|
||||
}
|
||||
|
||||
default_pkg = get_default_package(repo_root)
|
||||
spec_scope = get_spec_scope(repo_root)
|
||||
task_pkg = _get_active_task_package(repo_root)
|
||||
|
||||
return {
|
||||
"mode": "monorepo",
|
||||
"packages": pkg_info,
|
||||
"defaultPackage": default_pkg,
|
||||
"specScope": spec_scope,
|
||||
"activeTaskPackage": task_pkg,
|
||||
}
|
||||
Executable
+543
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common path utilities for Trellis workflow.
|
||||
|
||||
Provides:
|
||||
get_repo_root - Get repository root directory
|
||||
get_developer - Get developer name
|
||||
get_workspace_dir - Get developer workspace directory
|
||||
get_tasks_dir - Get tasks directory
|
||||
get_active_journal_file - Get current journal file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .git import main_worktree_root
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Constants (change here to rename directories)
|
||||
# =============================================================================
|
||||
|
||||
# Directory names
|
||||
DIR_WORKFLOW = ".trellis"
|
||||
DIR_WORKSPACE = "workspace"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_ARCHIVE = "archive"
|
||||
DIR_SPEC = "spec"
|
||||
DIR_SCRIPTS = "scripts"
|
||||
|
||||
# File names
|
||||
FILE_DEVELOPER = ".developer"
|
||||
FILE_CURRENT_TASK = ".current-task"
|
||||
FILE_TASK_JSON = "task.json"
|
||||
FILE_JOURNAL_PREFIX = "journal-"
|
||||
|
||||
# Environment override for the developer identity, ahead of the .developer file.
|
||||
ENV_DEVELOPER = "TRELLIS_DEVELOPER"
|
||||
|
||||
# Appended to every "no developer set" error so the two non-obvious sources are
|
||||
# discoverable from the failure itself.
|
||||
DEVELOPER_HINT = (
|
||||
f" Or set {ENV_DEVELOPER}=<your-name> in the environment.\n"
|
||||
f" A linked git worktree inherits {DIR_WORKFLOW}/{FILE_DEVELOPER} from its "
|
||||
f"main checkout — run init_developer.py there to cover every worktree."
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Repository Root
|
||||
# =============================================================================
|
||||
|
||||
def get_repo_root(start_path: Path | None = None) -> Path:
|
||||
"""Find the nearest directory containing .trellis/ folder.
|
||||
|
||||
This handles nested git repos correctly (e.g., test project inside another repo).
|
||||
|
||||
Args:
|
||||
start_path: Starting directory to search from. Defaults to current directory.
|
||||
|
||||
Returns:
|
||||
Path to repository root, or current directory if no .trellis/ found.
|
||||
"""
|
||||
current = (start_path or Path.cwd()).resolve()
|
||||
|
||||
while current != current.parent:
|
||||
if (current / DIR_WORKFLOW).is_dir():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
# Fallback to current directory if no .trellis/ found
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Developer
|
||||
# =============================================================================
|
||||
|
||||
def _read_developer_file(dev_file: Path) -> str | None:
|
||||
"""Read the `name=` field out of a .developer file, or None."""
|
||||
if not dev_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = dev_file.read_text(encoding="utf-8")
|
||||
except (OSError, IOError):
|
||||
return None
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.startswith("name="):
|
||||
return line.split("=", 1)[1].strip() or None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_developer(repo_root: Path | None = None) -> str | None:
|
||||
"""Get the developer name for this checkout.
|
||||
|
||||
Resolution order, first hit wins (a CLI `--assignee` flag overrides all of
|
||||
it, before this function is ever called):
|
||||
|
||||
1. The ``TRELLIS_DEVELOPER`` environment variable.
|
||||
2. ``.trellis/.developer`` in this checkout.
|
||||
3. ``.trellis/.developer`` in the main checkout, when this checkout is a
|
||||
linked git worktree.
|
||||
|
||||
Step 3 exists because `.developer` is gitignored on purpose — it carries a
|
||||
personal identity and no tracked file should. A fresh `git worktree add`
|
||||
therefore starts with no identity file of its own, which used to make every
|
||||
task.py command fail until init_developer.py was re-run per worktree. The
|
||||
main checkout's file is read, never copied: a copy would go stale and shadow
|
||||
later changes made in the main checkout.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Developer name or None if not initialized.
|
||||
"""
|
||||
env_name = os.environ.get(ENV_DEVELOPER, "").strip()
|
||||
if env_name:
|
||||
return env_name
|
||||
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
local = _read_developer_file(repo_root / DIR_WORKFLOW / FILE_DEVELOPER)
|
||||
if local:
|
||||
return local
|
||||
|
||||
main_root = main_worktree_root(repo_root)
|
||||
if main_root is None:
|
||||
return None
|
||||
|
||||
return _read_developer_file(main_root / DIR_WORKFLOW / FILE_DEVELOPER)
|
||||
|
||||
|
||||
def check_developer(repo_root: Path | None = None) -> bool:
|
||||
"""Check if developer is initialized.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if developer is initialized.
|
||||
"""
|
||||
return get_developer(repo_root) is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tasks Directory
|
||||
# =============================================================================
|
||||
|
||||
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
||||
"""Get tasks directory path.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to tasks directory.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Workspace Directory
|
||||
# =============================================================================
|
||||
|
||||
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
||||
"""Get developer workspace directory.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to workspace directory or None if developer not set.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if developer:
|
||||
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Journal File
|
||||
# =============================================================================
|
||||
|
||||
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
||||
"""Get the current active journal file.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to active journal file or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
workspace_dir = get_workspace_dir(repo_root)
|
||||
if workspace_dir is None or not workspace_dir.is_dir():
|
||||
return None
|
||||
|
||||
latest: Path | None = None
|
||||
highest = 0
|
||||
|
||||
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Extract number from filename
|
||||
name = f.stem # e.g., "journal-1"
|
||||
match = re.search(r"(\d+)$", name)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if num > highest:
|
||||
highest = num
|
||||
latest = f
|
||||
|
||||
return latest
|
||||
|
||||
|
||||
def count_lines(file_path: Path) -> int:
|
||||
"""Count lines in a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file.
|
||||
|
||||
Returns:
|
||||
Number of lines, or 0 if file doesn't exist.
|
||||
"""
|
||||
if not file_path.is_file():
|
||||
return 0
|
||||
|
||||
try:
|
||||
return len(file_path.read_text(encoding="utf-8").splitlines())
|
||||
except (OSError, IOError):
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Current Task Management
|
||||
# =============================================================================
|
||||
|
||||
def normalize_task_ref(task_ref: str) -> str:
|
||||
"""Normalize a task ref for stable runtime storage.
|
||||
|
||||
Stored refs should prefer repo-relative POSIX paths like
|
||||
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
||||
unless they can later be converted back to repo-relative form by callers.
|
||||
"""
|
||||
normalized = task_ref.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
if normalized.startswith(f"{DIR_TASKS}/"):
|
||||
return f"{DIR_WORKFLOW}/{normalized}"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Resolve a task ref to an absolute task directory path inside the repo.
|
||||
|
||||
Returns None when the ref resolves outside `repo_root`. Every reader of the
|
||||
active task — `task.py`, the shared hooks, the platform extensions — comes
|
||||
through here, so containment is enforced at this one point rather than at
|
||||
each call site.
|
||||
|
||||
It matters because a ref is not always something the user typed. It round
|
||||
trips through the session pointer under `.trellis/.runtime/sessions/`, and
|
||||
`..` segments used to survive that trip intact: `_canonical_task_ref`
|
||||
compares lexically, and a lexical `relative_to` accepts
|
||||
`<root>/.trellis/tasks/../../../elsewhere` because the string does start
|
||||
with the root. The ref was then stored verbatim and replayed on every later
|
||||
turn, so `task.py start .trellis/tasks/../../../elsewhere` both rewrote that
|
||||
directory's `task.json` and fed its files to the model.
|
||||
|
||||
Resolving here also normalises the path, so callers get a ref without `..`
|
||||
to store.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
normalized = normalize_task_ref(task_ref)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
try:
|
||||
root = repo_root.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
path_obj = Path(normalized)
|
||||
if path_obj.is_absolute():
|
||||
candidate = path_obj
|
||||
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
|
||||
candidate = root / path_obj
|
||||
else:
|
||||
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
||||
|
||||
# resolve() collapses `..` and follows symlinks, so a task directory that
|
||||
# links outside the repo is refused too. Both sides are resolved because
|
||||
# repo_root itself may sit behind a symlink (/tmp on macOS does).
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
workflow_real = (root / DIR_WORKFLOW).resolve()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
return resolved
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# `.trellis` may itself be a symlink into a store outside the repo (#567).
|
||||
# The workflow dir's own real location is then a second legitimate
|
||||
# containment base: a ref through that link never left the workflow tree.
|
||||
# A ref that escapes BOTH bases (traversal, absolute path elsewhere, a
|
||||
# task dir symlinked out of the tree) is still refused.
|
||||
try:
|
||||
rel = resolved.relative_to(workflow_real)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Map back to the in-repo (lexical) form so callers store the same
|
||||
# repo-relative ref as in the non-symlinked layout.
|
||||
return root / DIR_WORKFLOW / rel
|
||||
|
||||
|
||||
def get_current_task(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> str | None:
|
||||
"""Get current task directory path (relative to repo_root).
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Relative path to current task directory or None.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import resolve_active_task
|
||||
|
||||
return resolve_active_task(repo_root, platform_input, platform).task_path
|
||||
|
||||
|
||||
def get_current_task_abs(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> Path | None:
|
||||
"""Get current task directory absolute path.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Absolute path to current task directory or None.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
relative = get_current_task(repo_root, platform_input, platform)
|
||||
if relative:
|
||||
return resolve_task_ref(relative, repo_root)
|
||||
return None
|
||||
|
||||
|
||||
def get_current_task_source(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import get_current_task_source as _get_source
|
||||
|
||||
return _get_source(repo_root, platform_input, platform)
|
||||
|
||||
|
||||
def set_current_task(
|
||||
task_path: str,
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> bool:
|
||||
"""Set current task in session scope.
|
||||
|
||||
Args:
|
||||
task_path: Task directory path (relative to repo_root).
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import set_active_task
|
||||
|
||||
return set_active_task(
|
||||
task_path,
|
||||
repo_root,
|
||||
platform_input=platform_input,
|
||||
platform=platform,
|
||||
) is not None
|
||||
|
||||
|
||||
def clear_current_task(
|
||||
repo_root: Path | None = None,
|
||||
platform_input: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> bool:
|
||||
"""Clear current task in session scope.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .active_task import clear_active_task
|
||||
|
||||
clear_active_task(
|
||||
repo_root,
|
||||
platform_input=platform_input,
|
||||
platform=platform,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def has_current_task(repo_root: Path | None = None) -> bool:
|
||||
"""Check if has current task.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
True if current task is set.
|
||||
"""
|
||||
return get_current_task(repo_root) is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task ID Generation
|
||||
# =============================================================================
|
||||
|
||||
def generate_task_date_prefix() -> str:
|
||||
"""Generate task ID based on date (MM-DD format).
|
||||
|
||||
Returns:
|
||||
Date prefix string (e.g., "01-21").
|
||||
"""
|
||||
return datetime.now().strftime("%m-%d")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Monorepo / Package Paths
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
||||
"""Get the spec directory path.
|
||||
|
||||
Single-repo: .trellis/spec
|
||||
Monorepo with package: .trellis/spec/<package>
|
||||
|
||||
Uses lazy import to avoid circular dependency with config.py.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_spec_base
|
||||
|
||||
base = get_spec_base(package, repo_root)
|
||||
return repo_root / DIR_WORKFLOW / base
|
||||
|
||||
|
||||
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
||||
"""Get a package's source directory absolute path from config.
|
||||
|
||||
Returns:
|
||||
Absolute path to the package directory, or None if not found.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
from .config import get_packages
|
||||
|
||||
packages = get_packages(repo_root)
|
||||
if not packages or package not in packages:
|
||||
return None
|
||||
|
||||
info = packages[package]
|
||||
if isinstance(info, dict):
|
||||
rel_path = info.get("path", package)
|
||||
else:
|
||||
rel_path = str(info)
|
||||
|
||||
return repo_root / rel_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
print(f"Repository root: {repo}")
|
||||
print(f"Developer: {get_developer(repo)}")
|
||||
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
||||
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
||||
print(f"Journal file: {get_active_journal_file(repo)}")
|
||||
print(f"Current task: {get_current_task(repo)}")
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Safe git-add helpers for Trellis-owned paths.
|
||||
|
||||
Why this module exists
|
||||
----------------------
|
||||
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
|
||||
template / personal habit). When `add_session.py` and `task.py archive` ran
|
||||
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
|
||||
agent driving the workflow "fixed" it by retrying with
|
||||
`git add -f .trellis/` — which fan-out-included every ignored subtree
|
||||
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
|
||||
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
|
||||
|
||||
Design
|
||||
------
|
||||
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
|
||||
current task dir, the archive dir). Never the whole `.trellis/` tree.
|
||||
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
|
||||
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
|
||||
intent ("keep .trellis/ local-only"). The script warns and skips the
|
||||
auto-commit; users who want auto-staging can either fix their `.gitignore`
|
||||
or set ``session_auto_commit: false`` and manage git themselves.
|
||||
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
|
||||
so any AI rereading the log doesn't reinvent the bug.
|
||||
|
||||
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
|
||||
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
|
||||
user had gitignored violates user intent even when the path list is narrow.
|
||||
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
|
||||
``-f`` is gone too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .git import run_git, run_git_retry_index_lock
|
||||
from .paths import (
|
||||
DIR_ARCHIVE,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_developer,
|
||||
)
|
||||
|
||||
|
||||
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
|
||||
# warning to the user can show concrete subpaths to ignore individually
|
||||
# instead of ignoring the whole `.trellis/` tree.
|
||||
TRELLIS_IGNORED_SUBPATHS = (
|
||||
".trellis/.backup-*",
|
||||
".trellis/worktrees/",
|
||||
".trellis/.template-hashes.json",
|
||||
".trellis/.runtime/",
|
||||
".trellis/.cache/",
|
||||
)
|
||||
|
||||
|
||||
def safe_trellis_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||
|
||||
Only includes paths that exist on disk so callers don't pass non-existent
|
||||
arguments to git. The caller is responsible for `git diff --cached`
|
||||
checking afterwards.
|
||||
|
||||
Included:
|
||||
- .trellis/workspace/<developer>/journal-*.md
|
||||
- .trellis/workspace/<developer>/index.md
|
||||
- .trellis/tasks/<task_name>/ (ONLY the current task dir when
|
||||
``task_name`` is passed; plus its archive location if the task
|
||||
already lives under archive/)
|
||||
|
||||
Excluded (intentionally — these must not be staged):
|
||||
- .trellis/.backup-*, .trellis/worktrees/,
|
||||
.trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
|
||||
|
||||
Scope contract (see #303 / break-loop analysis): when ``task_name`` is
|
||||
passed, the task segment stages ONLY that task directory — it never walks
|
||||
``tasks_dir.iterdir()`` over all active tasks. This mirrors
|
||||
:func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER
|
||||
parallel-window task dirs from being bundled into the session auto-commit.
|
||||
|
||||
Backwards-compat: with no ``task_name``, the function walks every active
|
||||
task directory (+ the archive subtree) the old wide way. New callers
|
||||
should always pass ``task_name``.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
|
||||
# Workspace journal files + index.md
|
||||
developer = get_developer(repo_root)
|
||||
if developer:
|
||||
ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
||||
if ws.is_dir():
|
||||
for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
|
||||
if f.is_file():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}"
|
||||
)
|
||||
index_md = ws / "index.md"
|
||||
if index_md.is_file():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
|
||||
)
|
||||
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
if task_name is not None:
|
||||
# Narrow scope — ONLY the current task directory (active or archived).
|
||||
# Never iterdir() all tasks: parallel-window dirty task dirs must not
|
||||
# leak into the session auto-commit.
|
||||
active_task = tasks_dir / task_name
|
||||
if active_task.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
|
||||
archived_task = tasks_dir / DIR_ARCHIVE / task_name
|
||||
if archived_task.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}"
|
||||
)
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): each direct child of tasks/ that is a
|
||||
# directory and not the archive root, plus the whole archive subtree.
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def safe_archive_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
modified_children: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Return paths to stage after `task.py archive`.
|
||||
|
||||
Scoped to ONLY the paths the archive operation actually touched:
|
||||
|
||||
- the archive subtree (where the freshly-moved task lives)
|
||||
- the source task directory (for source-side deletes; caller pairs
|
||||
this with `git rm --cached` since `git add` won't stage deletes
|
||||
for a path that no longer exists in the working tree)
|
||||
- any child task directories whose `task.json` was edited to drop
|
||||
the archived parent (parent-children relationship update)
|
||||
|
||||
This narrow scope avoids "scope creep" — dirty changes in OTHER
|
||||
active task dirs (parallel-window edits) are NOT bundled into the
|
||||
archive commit. Callers handle each kind of change in its own
|
||||
commit boundary.
|
||||
|
||||
Backwards-compat: with no arguments, the function walks the whole
|
||||
`.trellis/tasks/` subtree the old way (active tasks + archive). New
|
||||
callers should always pass `task_name`.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
|
||||
if task_name is not None:
|
||||
# Narrow scope — only paths that still exist on disk (so
|
||||
# `git add` doesn't choke on the moved-away source). The caller
|
||||
# handles the source-side deletes via `git rm --cached`
|
||||
# explicitly.
|
||||
if archive_dir.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
|
||||
)
|
||||
for child_name in modified_children or []:
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): preserve old behavior so callers
|
||||
# that have not been updated keep working.
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
return paths
|
||||
|
||||
|
||||
def _stderr_indicates_ignored(stderr: str) -> bool:
|
||||
"""git add error indicates the path is excluded by .gitignore."""
|
||||
if not stderr:
|
||||
return False
|
||||
lowered = stderr.lower()
|
||||
return "ignored by" in lowered
|
||||
|
||||
|
||||
def safe_git_add(
|
||||
paths: list[str], repo_root: Path, retry_on_index_lock: bool = False
|
||||
) -> tuple[bool, bool, str]:
|
||||
"""Run `git add` on specific paths; never retry with -f.
|
||||
|
||||
Returns ``(success, used_force, stderr)``. The ``used_force`` field is
|
||||
kept for signature compatibility with the 0.5.10 implementation but is
|
||||
always ``False`` — we never auto-force.
|
||||
|
||||
Behavior:
|
||||
- No paths passed → success, no force, empty stderr.
|
||||
- Plain ``git add -- <paths>`` succeeds → return success.
|
||||
- Plain fails (any reason — ignored or otherwise) → return failure with
|
||||
the stderr. Callers should inspect the stderr (see
|
||||
:func:`print_gitignore_warning`) and skip the auto-commit.
|
||||
|
||||
``retry_on_index_lock`` opts into the bounded backoff-retry for a held
|
||||
``.git/index.lock`` (see :func:`~.git.run_git_retry_index_lock`). It is
|
||||
off by default: only the archive path, which has already moved the task
|
||||
directory on disk by the time it stages, needs to wait out a transient
|
||||
lock rather than fail.
|
||||
"""
|
||||
if not paths:
|
||||
return True, False, ""
|
||||
|
||||
runner = run_git_retry_index_lock if retry_on_index_lock else run_git
|
||||
rc, _, err = runner(["add", "--", *paths], cwd=repo_root)
|
||||
if rc == 0:
|
||||
return True, False, ""
|
||||
return False, False, err
|
||||
|
||||
|
||||
def print_gitignore_warning(paths: list[str]) -> None:
|
||||
"""Explain to the user (and any AI reading the log) what to do.
|
||||
|
||||
CRITICAL: includes the negative example
|
||||
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
|
||||
known to invent that command, which fans out to ignored caches/backups.
|
||||
"""
|
||||
print(
|
||||
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] git was not touched.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Trellis manages these specific paths and they should be tracked:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if paths:
|
||||
for p in paths:
|
||||
print(f"[WARN] {p}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/<task-dir>/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/tasks/archive/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] subpaths that should remain ignored, e.g.:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for sub in TRELLIS_IGNORED_SUBPATHS:
|
||||
print(f"[WARN] {sub}", file=sys.stderr)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] .trellis/config.yaml:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] session_auto_commit: false",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] so the scripts skip git entirely and you can review / commit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] manually with `git status` / `git add` / `git commit`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("[WARN]", file=sys.stderr)
|
||||
print(
|
||||
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"[WARN] and runtime caches that should never be committed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
Executable
+892
@@ -0,0 +1,892 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Session context generation (default + record modes).
|
||||
|
||||
Provides:
|
||||
get_context_json - JSON output for default mode
|
||||
get_context_text - Text output for default mode
|
||||
get_context_record_json - JSON for record mode
|
||||
get_context_text_record - Text for record mode
|
||||
output_json - Print JSON
|
||||
output_text - Print text
|
||||
get_update_hint - Once-per-session "update available" line
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .active_task import resolve_context_key
|
||||
from .config import get_git_packages
|
||||
from .git import run_git
|
||||
from .packages_context import get_packages_section
|
||||
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
|
||||
from .paths import (
|
||||
DIR_SCRIPTS,
|
||||
DIR_SPEC,
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
DIR_WORKSPACE,
|
||||
count_lines,
|
||||
get_active_journal_file,
|
||||
get_current_task,
|
||||
get_current_task_source,
|
||||
get_developer,
|
||||
get_repo_root,
|
||||
get_tasks_dir,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
_PACKAGE_NAME = "@mindfoldhq/trellis"
|
||||
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
|
||||
_VERSION_RE = re.compile(
|
||||
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
||||
)
|
||||
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
||||
_POLYREPO_IGNORED_DIRS = {
|
||||
"node_modules",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
"bin",
|
||||
"obj",
|
||||
"vendor",
|
||||
"coverage",
|
||||
"tmp",
|
||||
"__pycache__",
|
||||
}
|
||||
_POLYREPO_SCAN_MAX_DEPTH = 2
|
||||
_POLYREPO_SCAN_MAX_REPOS = 8
|
||||
_GIT_PROBE_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
def _is_git_worktree(path: Path) -> bool:
|
||||
"""Return True when path is inside a Git worktree."""
|
||||
rc, out, _ = run_git(
|
||||
["rev-parse", "--is-inside-work-tree"],
|
||||
cwd=path,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return rc == 0 and out.strip().lower() == "true"
|
||||
|
||||
|
||||
def _parse_recent_commits(log_output: str) -> list[dict]:
|
||||
"""Parse `git log --oneline` output into structured commit entries."""
|
||||
commits = []
|
||||
for line in log_output.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) >= 2:
|
||||
commits.append({"hash": parts[0], "message": parts[1]})
|
||||
elif len(parts) == 1:
|
||||
commits.append({"hash": parts[0], "message": ""})
|
||||
return commits
|
||||
|
||||
|
||||
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
||||
"""Collect Git status for one known repository directory."""
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
status_rc, status_out, _ = run_git(
|
||||
["status", "--porcelain"],
|
||||
cwd=repo_dir,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
if status_rc != 0:
|
||||
return None
|
||||
changes = len([line for line in status_out.splitlines() if line.strip()])
|
||||
|
||||
_, branch_out, _ = run_git(
|
||||
["branch", "--show-current"],
|
||||
cwd=repo_dir,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, log_out, _ = run_git(
|
||||
["log", "--oneline", "-5"],
|
||||
cwd=repo_dir,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"path": rel_path,
|
||||
"branch": branch,
|
||||
"isClean": changes == 0,
|
||||
"uncommittedChanges": changes,
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
}
|
||||
|
||||
|
||||
def _collect_root_git_info(repo_root: Path) -> dict:
|
||||
"""Collect root Git info without pretending a non-Git root is clean."""
|
||||
if not _is_git_worktree(repo_root):
|
||||
return {
|
||||
"isRepo": False,
|
||||
"branch": "",
|
||||
"isClean": False,
|
||||
"uncommittedChanges": 0,
|
||||
"recentCommits": [],
|
||||
}
|
||||
|
||||
_, branch_out, _ = run_git(
|
||||
["branch", "--show-current"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
status_rc, status_out, _ = run_git(
|
||||
["status", "--porcelain"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
||||
|
||||
_, short_out, _ = run_git(
|
||||
["status", "--short"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
_, log_out, _ = run_git(
|
||||
["log", "--oneline", "-5"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
return {
|
||||
"isRepo": True,
|
||||
"branch": branch,
|
||||
"isClean": status_rc == 0 and len(status_lines) == 0,
|
||||
"uncommittedChanges": len(status_lines),
|
||||
"statusShort": short_out.splitlines(),
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
}
|
||||
|
||||
|
||||
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
||||
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
||||
found: list[str] = []
|
||||
overflow = False
|
||||
|
||||
def is_candidate_dir(path: Path) -> bool:
|
||||
name = path.name
|
||||
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
||||
|
||||
def scan(rel_dir: Path, depth: int) -> None:
|
||||
nonlocal overflow
|
||||
if overflow:
|
||||
return
|
||||
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
||||
return
|
||||
abs_dir = repo_root / rel_dir
|
||||
try:
|
||||
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
for child in children:
|
||||
if not child.is_dir() or not is_candidate_dir(child):
|
||||
continue
|
||||
|
||||
child_rel = (
|
||||
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
||||
)
|
||||
if (child / ".git").exists():
|
||||
if len(found) >= _POLYREPO_SCAN_MAX_REPOS:
|
||||
overflow = True
|
||||
return
|
||||
found.append(child_rel.as_posix())
|
||||
continue
|
||||
scan(child_rel, depth + 1)
|
||||
|
||||
scan(Path("."), 0)
|
||||
if overflow:
|
||||
print(
|
||||
"warning: found more than "
|
||||
f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; "
|
||||
"skipping automatic Git status collection. Configure explicit "
|
||||
"packages entries with path and git: true in .trellis/config.yaml.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
if len(found) < 2:
|
||||
return []
|
||||
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
||||
|
||||
|
||||
def _collect_package_git_info(
|
||||
repo_root: Path,
|
||||
discover_unconfigured: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Collect Git status for independent package repositories.
|
||||
|
||||
Packages marked with ``git: true`` in config.yaml are authoritative.
|
||||
When the Trellis root is not a Git repo and no configured package repos are
|
||||
available, optionally fall back to the bounded polyrepo child scan.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: name, path, branch, isClean,
|
||||
uncommittedChanges, recentCommits.
|
||||
Empty list if no git-repo packages are configured.
|
||||
"""
|
||||
git_pkgs = get_git_packages(repo_root)
|
||||
result = []
|
||||
for pkg_name, pkg_path in git_pkgs.items():
|
||||
pkg_dir = repo_root / pkg_path
|
||||
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
||||
if info is not None:
|
||||
result.append(info)
|
||||
|
||||
if result or not discover_unconfigured:
|
||||
return result
|
||||
|
||||
discovered = []
|
||||
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
||||
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
||||
if info is not None:
|
||||
discovered.append(info)
|
||||
return discovered
|
||||
|
||||
|
||||
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
||||
"""Append root Git status without misleading non-Git roots."""
|
||||
lines.append("## GIT STATUS")
|
||||
if not root_git_info["isRepo"]:
|
||||
lines.append("Root is not a Git repository.")
|
||||
lines.append("Run Git commands from the package repository paths listed below.")
|
||||
else:
|
||||
lines.append(f"Branch: {root_git_info['branch']}")
|
||||
if root_git_info["isClean"]:
|
||||
lines.append("Working directory: Clean")
|
||||
else:
|
||||
lines.append(
|
||||
f"Working directory: {root_git_info['uncommittedChanges']} "
|
||||
"uncommitted change(s)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("Changes:")
|
||||
for line in root_git_info.get("statusShort", [])[:10]:
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## RECENT COMMITS")
|
||||
if not root_git_info["isRepo"]:
|
||||
lines.append(
|
||||
"Root has no Git commit history because it is not a Git repository."
|
||||
)
|
||||
elif root_git_info["recentCommits"]:
|
||||
for commit in root_git_info["recentCommits"]:
|
||||
lines.append(f"{commit['hash']} {commit['message']}")
|
||||
else:
|
||||
lines.append("(no commits)")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
||||
"""Append Git status and recent commits for package repositories."""
|
||||
for pkg in package_git_info:
|
||||
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
|
||||
lines.append(f"Branch: {pkg['branch']}")
|
||||
if pkg["isClean"]:
|
||||
lines.append("Working directory: Clean")
|
||||
else:
|
||||
lines.append(
|
||||
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
|
||||
if pkg["recentCommits"]:
|
||||
for commit in pkg["recentCommits"]:
|
||||
lines.append(f"{commit['hash']} {commit['message']}")
|
||||
else:
|
||||
lines.append("(no commits)")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def _read_project_version(repo_root: Path) -> str | None:
|
||||
try:
|
||||
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
except OSError:
|
||||
return None
|
||||
return version or None
|
||||
|
||||
|
||||
def _fetch_trellis_version_output() -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["trellis", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = f"{result.stdout}\n{result.stderr}".strip()
|
||||
return output or None
|
||||
|
||||
|
||||
def _extract_available_update_version(output: str) -> str | None:
|
||||
update_match = re.search(
|
||||
r"Trellis update available:\s*"
|
||||
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
|
||||
output,
|
||||
)
|
||||
if update_match:
|
||||
return update_match.group("latest").strip()
|
||||
candidates = _VERSION_TOKEN_RE.findall(output)
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
def _resolve_available_update_version() -> str | None:
|
||||
output = _fetch_trellis_version_output()
|
||||
if not output:
|
||||
return None
|
||||
return _extract_available_update_version(output)
|
||||
|
||||
|
||||
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
|
||||
match = _VERSION_RE.match(version)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, prerelease = match.groups()
|
||||
numbers = (int(major), int(minor or "0"), int(patch or "0"))
|
||||
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
|
||||
return numbers, prerelease_parts
|
||||
|
||||
|
||||
def _compare_prerelease(
|
||||
left: tuple[str, ...] | None,
|
||||
right: tuple[str, ...] | None,
|
||||
) -> int:
|
||||
if left is None and right is None:
|
||||
return 0
|
||||
if left is None:
|
||||
return 1
|
||||
if right is None:
|
||||
return -1
|
||||
|
||||
for left_part, right_part in zip(left, right):
|
||||
if left_part == right_part:
|
||||
continue
|
||||
left_numeric = left_part.isdigit()
|
||||
right_numeric = right_part.isdigit()
|
||||
if left_numeric and right_numeric:
|
||||
left_int = int(left_part)
|
||||
right_int = int(right_part)
|
||||
return (left_int > right_int) - (left_int < right_int)
|
||||
if left_numeric:
|
||||
return -1
|
||||
if right_numeric:
|
||||
return 1
|
||||
return (left_part > right_part) - (left_part < right_part)
|
||||
|
||||
return (len(left) > len(right)) - (len(left) < len(right))
|
||||
|
||||
|
||||
def _compare_versions(left: str, right: str) -> int | None:
|
||||
parsed_left = _parse_version(left)
|
||||
parsed_right = _parse_version(right)
|
||||
if parsed_left is None or parsed_right is None:
|
||||
return None
|
||||
|
||||
left_numbers, left_prerelease = parsed_left
|
||||
right_numbers, right_prerelease = parsed_right
|
||||
if left_numbers != right_numbers:
|
||||
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
|
||||
"""Path of the once-per-session marker that throttles the update check.
|
||||
|
||||
`context_key` lets a caller that already resolved session identity pass it
|
||||
in — the SessionStart hook reads the session id from hook stdin, which is
|
||||
more reliable than this function's environment-only fallback chain. Shell
|
||||
entry points leave it None and keep the previous behavior.
|
||||
"""
|
||||
if not context_key:
|
||||
context_key = resolve_context_key()
|
||||
if not context_key:
|
||||
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
|
||||
context_key = terminal_key or f"ppid-{os.getppid()}"
|
||||
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
|
||||
if not safe_key:
|
||||
safe_key = "session"
|
||||
return (
|
||||
repo_root
|
||||
/ DIR_WORKFLOW
|
||||
/ ".runtime"
|
||||
/ f"update-check-{safe_key[:160]}.marker"
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(
|
||||
repo_root: Path,
|
||||
context_key: str | None = None,
|
||||
) -> bool:
|
||||
marker_path = _update_marker_path(repo_root, context_key)
|
||||
if marker_path.exists():
|
||||
return False
|
||||
try:
|
||||
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker_path.write_text("checked\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
|
||||
"""Return the "update available" line for this session, at most once.
|
||||
|
||||
Public because the SessionStart hook imports it: the text-mode CLI path
|
||||
(`get_context.py`) used to be the only caller, so hook-driven platforms —
|
||||
Claude Code included — never saw the reminder at all.
|
||||
"""
|
||||
marker_path = _update_marker_path(repo_root, context_key)
|
||||
if marker_path.exists():
|
||||
return None
|
||||
|
||||
current_version = _read_project_version(repo_root)
|
||||
if not current_version:
|
||||
return None
|
||||
|
||||
latest_version = _resolve_available_update_version()
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root, context_key)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
|
||||
return (
|
||||
f"Trellis update available: {current_version} -> {latest_version}, "
|
||||
"run trellis update"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON Output
|
||||
# =============================================================================
|
||||
|
||||
def get_context_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get context as a dictionary.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Context dictionary.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
|
||||
journal_lines = 0
|
||||
journal_relative = ""
|
||||
if journal_file and developer:
|
||||
journal_lines = count_lines(journal_file)
|
||||
journal_relative = (
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# Tasks
|
||||
tasks = [
|
||||
{
|
||||
"dir": t.dir_name,
|
||||
"name": t.name,
|
||||
"status": t.status,
|
||||
"children": list(t.children),
|
||||
"parent": t.parent,
|
||||
}
|
||||
for t in iter_active_tasks(tasks_dir)
|
||||
]
|
||||
|
||||
# Package git repos (independent sub-repositories)
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"tasks": {
|
||||
"active": tasks,
|
||||
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
|
||||
},
|
||||
"journal": {
|
||||
"file": journal_relative,
|
||||
"lines": journal_lines,
|
||||
"nearLimit": journal_lines > 1800,
|
||||
},
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def output_json(repo_root: Path | None = None) -> None:
|
||||
"""Output context in JSON format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
context = get_context_json(repo_root)
|
||||
print(json.dumps(context, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Text Output
|
||||
# =============================================================================
|
||||
|
||||
def get_context_text(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Formatted text output.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
|
||||
# Developer section
|
||||
lines.append("## DEVELOPER")
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append(f"Name: {developer}")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# Current task
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
current_task_dir = repo_root / current_task
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
|
||||
ct = load_task(current_task_dir)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
|
||||
if ct.description:
|
||||
lines.append(f"Description: {ct.description}")
|
||||
|
||||
# Check for prd.md
|
||||
prd_file = current_task_dir / "prd.md"
|
||||
if prd_file.is_file():
|
||||
lines.append("")
|
||||
lines.append("[!] This task has prd.md - read it for task details")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
# Active tasks
|
||||
lines.append("## ACTIVE TASKS")
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
task_count = 0
|
||||
|
||||
# Collect all task data for hierarchy display
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
def _print_task_tree(name: str, indent: int = 0) -> None:
|
||||
nonlocal task_count
|
||||
t = all_tasks[name]
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
prefix = " " * indent
|
||||
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
|
||||
task_count += 1
|
||||
for child in t.children:
|
||||
if child in all_tasks:
|
||||
_print_task_tree(child, indent + 1)
|
||||
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
_print_task_tree(dir_name)
|
||||
|
||||
if task_count == 0:
|
||||
lines.append("(no active tasks)")
|
||||
lines.append(f"Total: {task_count} active task(s)")
|
||||
lines.append("")
|
||||
|
||||
# My tasks
|
||||
lines.append("## MY TASKS (Assigned to me)")
|
||||
my_task_count = 0
|
||||
|
||||
for t in all_tasks.values():
|
||||
if t.assignee == developer and t.status != "done":
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
# Journal file
|
||||
lines.append("## JOURNAL FILE")
|
||||
journal_file = get_active_journal_file(repo_root)
|
||||
if journal_file:
|
||||
journal_lines = count_lines(journal_file)
|
||||
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
||||
lines.append(f"Active file: {relative}")
|
||||
lines.append(f"Line count: {journal_lines} / 2000")
|
||||
if journal_lines > 1800:
|
||||
lines.append("[!] WARNING: Approaching 2000 line limit!")
|
||||
else:
|
||||
lines.append("No journal file found")
|
||||
lines.append("")
|
||||
|
||||
# Packages
|
||||
packages_text = get_packages_section(repo_root)
|
||||
if packages_text:
|
||||
lines.append(packages_text)
|
||||
lines.append("")
|
||||
|
||||
# Paths
|
||||
lines.append("## PATHS")
|
||||
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
|
||||
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Record Mode
|
||||
# =============================================================================
|
||||
|
||||
def get_context_record_json(repo_root: Path | None = None) -> dict:
|
||||
"""Get record-mode context as a dictionary.
|
||||
|
||||
Focused on: my active tasks, git status, current task.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
|
||||
# My tasks (single pass — collect statuses and filter by assignee)
|
||||
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
||||
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
|
||||
|
||||
my_tasks = []
|
||||
for t in all_tasks_list:
|
||||
if t.assignee == developer:
|
||||
done = sum(
|
||||
1 for c in t.children
|
||||
if all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
my_tasks.append({
|
||||
"dir": t.dir_name,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"children": list(t.children),
|
||||
"childrenDone": done,
|
||||
"parent": t.parent,
|
||||
"meta": t.meta,
|
||||
})
|
||||
|
||||
# Current task
|
||||
current_task_info = None
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
current_task_info = {
|
||||
"path": current_task,
|
||||
"name": ct.name,
|
||||
"status": ct.status,
|
||||
"source": source_type,
|
||||
"contextKey": context_key,
|
||||
}
|
||||
|
||||
# Package git repos
|
||||
pkg_git_info = _collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
)
|
||||
|
||||
result = {
|
||||
"developer": developer or "",
|
||||
"git": {
|
||||
"isRepo": root_git_info["isRepo"],
|
||||
"branch": root_git_info["branch"],
|
||||
"isClean": root_git_info["isClean"],
|
||||
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
||||
"recentCommits": root_git_info["recentCommits"],
|
||||
},
|
||||
"myTasks": my_tasks,
|
||||
"currentTask": current_task_info,
|
||||
}
|
||||
|
||||
if pkg_git_info:
|
||||
result["packageGit"] = pkg_git_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_context_text_record(repo_root: Path | None = None) -> str:
|
||||
"""Get context as formatted text for record-session mode.
|
||||
|
||||
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
|
||||
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("========================================")
|
||||
lines.append("SESSION CONTEXT (RECORD MODE)")
|
||||
lines.append("========================================")
|
||||
lines.append("")
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
lines.append(
|
||||
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# MY ACTIVE TASKS — first and prominent
|
||||
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
|
||||
lines.append("[!] Review whether any should be archived before recording this session.")
|
||||
lines.append("")
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
my_task_count = 0
|
||||
|
||||
# Single pass — collect all tasks and filter by assignee
|
||||
all_statuses = get_all_statuses(tasks_dir)
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.assignee == developer:
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
|
||||
my_task_count += 1
|
||||
|
||||
if my_task_count == 0:
|
||||
lines.append("(no active tasks assigned to you)")
|
||||
lines.append("")
|
||||
|
||||
root_git_info = _collect_root_git_info(repo_root)
|
||||
_append_root_git_context(lines, root_git_info)
|
||||
|
||||
# Package git repos — independent sub-repositories
|
||||
_append_package_git_context(
|
||||
lines,
|
||||
_collect_package_git_info(
|
||||
repo_root,
|
||||
discover_unconfigured=not root_git_info["isRepo"],
|
||||
),
|
||||
)
|
||||
|
||||
# CURRENT TASK
|
||||
lines.append("## CURRENT TASK")
|
||||
current_task = get_current_task(repo_root)
|
||||
if current_task:
|
||||
source_type, context_key, _ = get_current_task_source(repo_root)
|
||||
lines.append(f"Path: {current_task}")
|
||||
lines.append(
|
||||
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
|
||||
)
|
||||
ct = load_task(repo_root / current_task)
|
||||
if ct:
|
||||
lines.append(f"Name: {ct.name}")
|
||||
lines.append(f"Status: {ct.status}")
|
||||
else:
|
||||
lines.append("(none)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("========================================")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def output_text(repo_root: Path | None = None) -> None:
|
||||
"""Output context in text format.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
print(get_context_text(repo_root))
|
||||
Executable
+470
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task JSONL context management.
|
||||
|
||||
Provides:
|
||||
cmd_add_context - Add entry to JSONL context file
|
||||
cmd_validate - Validate JSONL context files
|
||||
cmd_list_context - List JSONL context entries
|
||||
|
||||
Note:
|
||||
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
||||
are created empty at ``task.py create`` time; the AI agent curates real
|
||||
entries during planning when the task needs sub-agent/spec context. See
|
||||
``.trellis/workflow.md`` for the current planning artifact contract.
|
||||
|
||||
Older Trellis versions seeded those files with a ``{"_example": ...}``
|
||||
placeholder row. ``cmd_validate`` now rejects that row so a task cannot
|
||||
validate locally and then fail PR preflight, which treats it as
|
||||
unresolved scaffolding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_context_injection_limits
|
||||
from .git import branch_exists_locally
|
||||
from .io import read_json
|
||||
from .log import Colors, colored
|
||||
from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
|
||||
from .task_utils import resolve_task_dir
|
||||
|
||||
# Extensions that look like code rather than spec/research docs. Entries with
|
||||
# one of these extensions outside .trellis/spec/, docs/docs-site, or the
|
||||
# task's own directory get a hygiene warning in `task.py validate` — the
|
||||
# reader is a sub-agent, not a human, so code paths belong in the diff the
|
||||
# agent reads itself, not in implement.jsonl / check.jsonl.
|
||||
_CODE_FILE_EXTENSIONS = {
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".py",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".rb",
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".h",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: add-context
|
||||
# =============================================================================
|
||||
|
||||
def cmd_add_context(args: argparse.Namespace) -> int:
|
||||
"""Add entry to JSONL context file."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
if target_dir is None:
|
||||
return 1
|
||||
|
||||
jsonl_name = args.file
|
||||
path = args.path
|
||||
reason = args.reason or "Added manually"
|
||||
|
||||
if not target_dir or not target_dir.is_dir():
|
||||
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
# The JSONL name is user input joined onto the task dir — keep it a plain
|
||||
# filename so it cannot create files elsewhere.
|
||||
if "/" in jsonl_name or "\\" in jsonl_name or jsonl_name in (".", ".."):
|
||||
print(colored(
|
||||
f"Error: context file must be a plain name (e.g. implement, check): {jsonl_name}",
|
||||
Colors.RED,
|
||||
))
|
||||
return 1
|
||||
|
||||
# Support shorthand
|
||||
if not jsonl_name.endswith(".jsonl"):
|
||||
jsonl_name = f"{jsonl_name}.jsonl"
|
||||
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
full_path = repo_root / path
|
||||
|
||||
entry_type = "file"
|
||||
if full_path.is_dir():
|
||||
entry_type = "directory"
|
||||
if not path.endswith("/"):
|
||||
path = f"{path}/"
|
||||
elif not full_path.is_file():
|
||||
print(colored(f"Error: Path not found: {path}", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Check if already exists
|
||||
if jsonl_file.is_file():
|
||||
content = jsonl_file.read_text(encoding="utf-8")
|
||||
if f'"{path}"' in content:
|
||||
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
|
||||
return 0
|
||||
|
||||
# Add entry
|
||||
entry: dict
|
||||
if entry_type == "directory":
|
||||
entry = {"file": path, "type": "directory", "reason": reason}
|
||||
else:
|
||||
entry = {"file": path, "reason": reason}
|
||||
|
||||
with jsonl_file.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: validate
|
||||
# =============================================================================
|
||||
|
||||
def curated_entry_count(jsonl_file: Path) -> int | None:
|
||||
"""Count curated entries in a jsonl context manifest.
|
||||
|
||||
Returns None when the file does not exist — `task.py create` seeds the
|
||||
manifests only on sub-agent-capable platforms, so an absent file means no
|
||||
sub-agent will ever read it and callers should not gate on it. A curated
|
||||
entry is a JSON object row carrying a truthy ``file`` (or legacy ``path``)
|
||||
value: the same rows the sub-agent injection hook materializes.
|
||||
"""
|
||||
if not jsonl_file.is_file():
|
||||
return None
|
||||
try:
|
||||
lines = jsonl_file.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return 0
|
||||
count = 0
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(data, dict) and (data.get("file") or data.get("path")):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
"""Validate JSONL context files."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
if target_dir is None or not target_dir.is_dir():
|
||||
print(colored("Error: task directory required", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(colored("=== Validating Context Files ===", Colors.BLUE))
|
||||
print(f"Target dir: {target_dir}")
|
||||
print()
|
||||
|
||||
# Warn (don't fail validation) when the recorded branch is stale — it
|
||||
# was likely already merged and deleted (#399 item 2).
|
||||
task_json_path = target_dir / FILE_TASK_JSON
|
||||
if task_json_path.is_file():
|
||||
task_data = read_json(task_json_path)
|
||||
stored_branch = task_data.get("branch") if task_data else None
|
||||
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
|
||||
print(
|
||||
colored(
|
||||
f"Warning: recorded branch '{stored_branch}' no longer exists locally "
|
||||
"(likely merged and deleted).",
|
||||
Colors.YELLOW,
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
total_errors = 0
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
errors = _validate_jsonl(jsonl_file, repo_root, target_dir)
|
||||
total_errors += errors
|
||||
|
||||
print()
|
||||
if total_errors == 0:
|
||||
print(colored("✓ All validations passed", Colors.GREEN))
|
||||
return 0
|
||||
else:
|
||||
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
||||
"""Whether a jsonl entry path is exempt from the code-file hygiene warning.
|
||||
|
||||
Exempt: spec docs (``.trellis/spec/``), documentation (``docs``,
|
||||
``docs-site``), and the task's own directory (execution plans, generated
|
||||
artifacts, etc. legitimately live there).
|
||||
"""
|
||||
posix_path = file_path.replace("\\", "/").lstrip("/")
|
||||
exempt_prefixes = (".trellis/spec/", "docs/", "docs-site/")
|
||||
if posix_path.startswith(exempt_prefixes):
|
||||
return True
|
||||
if task_rel and (posix_path == task_rel or posix_path.startswith(f"{task_rel}/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_context_entry_path(
|
||||
file_path: str, repo_root: Path, task_dir: Path | None
|
||||
) -> Path | None:
|
||||
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
|
||||
|
||||
Exact historical self-references are remapped only for archived tasks.
|
||||
``None`` means the remapped path traversed or resolved outside that archive.
|
||||
"""
|
||||
repo_path = repo_root / file_path
|
||||
if task_dir is None:
|
||||
return repo_path
|
||||
|
||||
try:
|
||||
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
|
||||
except ValueError:
|
||||
return repo_path
|
||||
|
||||
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
|
||||
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
|
||||
return repo_path
|
||||
|
||||
year_month = task_parts[3]
|
||||
if (
|
||||
len(year_month) != 7
|
||||
or year_month[4] != "-"
|
||||
or not year_month[:4].isdigit()
|
||||
or not year_month[5:].isdigit()
|
||||
):
|
||||
return repo_path
|
||||
|
||||
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
|
||||
posix_path = file_path.replace("\\", "/")
|
||||
if posix_path == historical_root:
|
||||
relative_parts: tuple[str, ...] = ()
|
||||
elif posix_path.startswith(f"{historical_root}/"):
|
||||
relative_path = posix_path[len(historical_root) + 1 :]
|
||||
if relative_path.endswith("/"):
|
||||
relative_path = relative_path[:-1]
|
||||
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
|
||||
if any(part in ("", ".", "..") for part in relative_parts):
|
||||
return None
|
||||
else:
|
||||
return repo_path
|
||||
|
||||
try:
|
||||
archive_root = task_dir.resolve()
|
||||
resolved_path = task_dir.joinpath(*relative_parts).resolve()
|
||||
resolved_path.relative_to(archive_root)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return resolved_path
|
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
||||
"""Validate a single JSONL file.
|
||||
|
||||
``{"_example": ...}`` placeholder rows written by older Trellis versions
|
||||
are hard errors: PR preflight rejects them as unresolved scaffolding, so
|
||||
accepting them here would pass locally and fail later. Other rows without
|
||||
a ``file`` field are skipped silently, matching what consumers do.
|
||||
|
||||
Beyond hard errors (missing file/dir, invalid JSON), this also prints
|
||||
non-blocking hygiene warnings (never counted in ``errors``, never change
|
||||
the exit code): entries that look like code files rather than
|
||||
spec/research docs, and entries whose file size exceeds the configured
|
||||
sub-agent context injection cap (``context_injection.max_file_bytes``).
|
||||
"""
|
||||
file_name = jsonl_file.name
|
||||
errors = 0
|
||||
|
||||
if not jsonl_file.is_file():
|
||||
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||||
return 0
|
||||
|
||||
task_rel = ""
|
||||
if task_dir is not None:
|
||||
try:
|
||||
task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
task_rel = ""
|
||||
|
||||
max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 0)
|
||||
|
||||
line_num = 0
|
||||
real_entries = 0
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
line_num += 1
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f" {colored(f'{file_name}:{line_num}: Expected a JSON object', Colors.RED)}"
|
||||
)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if "_example" in data:
|
||||
error_message = (
|
||||
f"{file_name}:{line_num}: Placeholder `_example` row left by an older "
|
||||
"task.py create — delete this line, or replace it with "
|
||||
'{"file": "<path>", "reason": "<why>"}'
|
||||
)
|
||||
print(f" {colored(error_message, Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
file_path = data.get("file")
|
||||
entry_type = data.get("type", "file")
|
||||
|
||||
if not file_path:
|
||||
# Comment / unknown row without a path — skip silently
|
||||
continue
|
||||
|
||||
if not isinstance(file_path, str):
|
||||
# A truthy non-string (e.g. {"file": 1}) reached path joining and
|
||||
# raised TypeError, so validation crashed on the row it exists to
|
||||
# report.
|
||||
print(
|
||||
f" {colored(f'{file_name}:{line_num}: `file` must be a string path', Colors.RED)}"
|
||||
)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
real_entries += 1
|
||||
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
|
||||
if entry_type == "directory":
|
||||
if full_path is None or not full_path.is_dir():
|
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if full_path is None or not full_path.is_file():
|
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
extension = Path(file_path).suffix.lower()
|
||||
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
|
||||
file_path, task_rel
|
||||
):
|
||||
warning_message = (
|
||||
f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
|
||||
"implement/check.jsonl should reference spec/research docs; "
|
||||
"agents read code themselves"
|
||||
)
|
||||
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||
|
||||
if max_file_bytes:
|
||||
# Advisory hygiene warning, so it must never be what fails
|
||||
# `validate`. `stat()` can still raise after the `is_file()` check
|
||||
# above — a permission change, or the file removed in between.
|
||||
try:
|
||||
size: int | None = full_path.stat().st_size
|
||||
except OSError:
|
||||
size = None
|
||||
if size is not None and size > max_file_bytes:
|
||||
warning_message = (
|
||||
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
|
||||
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
|
||||
"injection will truncate it"
|
||||
)
|
||||
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||
|
||||
if errors == 0 and real_entries == 0:
|
||||
# Seed-only / empty manifest: sub-agents dispatched for this task
|
||||
# would run with zero spec context (#573). Silent-green here is how
|
||||
# more than half the tasks in the report ended up uncurated.
|
||||
action = file_name.split(".", 1)[0]
|
||||
print(
|
||||
f" {colored(f'{file_name}: ✗ (0 curated entries — sub-agents would get zero spec context)', Colors.RED)}"
|
||||
)
|
||||
print(
|
||||
f" Curate it: python3 .trellis/scripts/task.py add-context <task> {action} <path> \"<why>\""
|
||||
)
|
||||
print(
|
||||
" Intentionally empty? Bypass at start: task.py start <task> --allow-empty-context"
|
||||
)
|
||||
return 1
|
||||
|
||||
if errors == 0:
|
||||
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||
else:
|
||||
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list-context
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list_context(args: argparse.Namespace) -> int:
|
||||
"""List JSONL context entries."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
|
||||
if target_dir is None or not target_dir.is_dir():
|
||||
print(colored("Error: task directory required", Colors.RED))
|
||||
return 1
|
||||
|
||||
print(colored("=== Context Files ===", Colors.BLUE))
|
||||
print()
|
||||
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
if not jsonl_file.is_file():
|
||||
continue
|
||||
|
||||
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||||
|
||||
count = 0
|
||||
curated = False
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
file_path = data.get("file")
|
||||
if not file_path:
|
||||
# Placeholder / comment row — don't count as a real entry
|
||||
continue
|
||||
curated = True
|
||||
|
||||
count += 1
|
||||
entry_type = data.get("type", "file")
|
||||
reason = data.get("reason", "-")
|
||||
|
||||
if entry_type == "directory":
|
||||
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
|
||||
else:
|
||||
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
|
||||
print(f" {colored('→', Colors.YELLOW)} {reason}")
|
||||
|
||||
if not curated:
|
||||
print(f" {colored('(no curated entries yet)', Colors.YELLOW)}")
|
||||
|
||||
print()
|
||||
|
||||
return 0
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task queue utility functions.
|
||||
|
||||
Provides:
|
||||
list_tasks_by_status - List tasks by status
|
||||
list_pending_tasks - List tasks with pending status
|
||||
list_tasks_by_assignee - List tasks by assignee
|
||||
list_my_tasks - List tasks assigned to current developer
|
||||
get_task_stats - Get P0/P1/P2/P3 counts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import (
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
get_tasks_dir,
|
||||
)
|
||||
from .tasks import iter_active_tasks
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal helper
|
||||
# =============================================================================
|
||||
|
||||
def _task_to_dict(t) -> dict:
|
||||
"""Convert TaskInfo to the dict format callers expect."""
|
||||
return {
|
||||
"priority": t.priority,
|
||||
"id": t.raw.get("id", ""),
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"assignee": t.assignee or "-",
|
||||
"dir": t.dir_name,
|
||||
"children": list(t.children),
|
||||
"parent": t.parent,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Functions
|
||||
# =============================================================================
|
||||
|
||||
def list_tasks_by_status(
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks by status.
|
||||
|
||||
Args:
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts with keys: priority, id, title, status, assignee.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
results = []
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
results.append(_task_to_dict(t))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
|
||||
"""List pending tasks.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
"""
|
||||
return list_tasks_by_status("planning", repo_root)
|
||||
|
||||
|
||||
def list_tasks_by_assignee(
|
||||
assignee: str,
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks assigned to a specific developer.
|
||||
|
||||
Args:
|
||||
assignee: Developer name.
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
results = []
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if (t.assignee or "-") != assignee:
|
||||
continue
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
results.append(_task_to_dict(t))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_my_tasks(
|
||||
filter_status: str | None = None,
|
||||
repo_root: Path | None = None
|
||||
) -> list[dict]:
|
||||
"""List tasks assigned to current developer.
|
||||
|
||||
Args:
|
||||
filter_status: Optional status filter.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
List of task info dicts.
|
||||
|
||||
Raises:
|
||||
ValueError: If developer not set.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
if not developer:
|
||||
raise ValueError("Developer not set")
|
||||
|
||||
return list_tasks_by_assignee(developer, filter_status, repo_root)
|
||||
|
||||
|
||||
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
|
||||
"""Get task statistics.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Dict with keys: P0, P1, P2, P3, Total.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
|
||||
|
||||
for t in iter_active_tasks(tasks_dir):
|
||||
if t.priority in stats:
|
||||
stats[t.priority] += 1
|
||||
stats["Total"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def format_task_stats(stats: dict[str, int]) -> str:
|
||||
"""Format task stats as string.
|
||||
|
||||
Args:
|
||||
stats: Stats dict from get_task_stats.
|
||||
|
||||
Returns:
|
||||
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
|
||||
"""
|
||||
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
stats = get_task_stats()
|
||||
print(format_task_stats(stats))
|
||||
print()
|
||||
print("Pending tasks:")
|
||||
for task in list_pending_tasks():
|
||||
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
|
||||
Executable
+1874
File diff suppressed because it is too large.
Load diff
Executable
+498
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Task utility functions.
|
||||
|
||||
Provides:
|
||||
is_within_tasks_dir - Check a resolved path is a task directly under tasks/
|
||||
find_task_by_name - Find task directory by name
|
||||
resolve_task_dir - Resolve task directory from name, relative, or absolute path
|
||||
archive_destination_for - Path a task would be archived to
|
||||
archive_task_dir - Archive task to monthly directory
|
||||
run_task_hooks - Run lifecycle hooks for task events
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .paths import get_repo_root, get_tasks_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import subprocess
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path Safety
|
||||
# =============================================================================
|
||||
|
||||
def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
|
||||
"""Check that a resolved task directory really is a task under the tasks dir.
|
||||
|
||||
A real task lives directly at ``.trellis/tasks/<name>``. This returns True
|
||||
only when ``task_dir_abs`` is an immediate child of the tasks directory.
|
||||
|
||||
Narrows ``resolve_task_dir``'s containment for archive: that chokepoint
|
||||
accepts anything under the tasks dir, including a task already in
|
||||
``archive/<YYYY-MM>/``. This rejects those, plus the tasks dir itself, so
|
||||
``shutil.move`` never re-archives an archived task into a nested copy.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
try:
|
||||
resolved = task_dir_abs.resolve()
|
||||
tasks_resolved = get_tasks_dir(repo_root).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
if resolved.parent != tasks_resolved:
|
||||
return False
|
||||
return resolved.name != "archive"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Lookup
|
||||
# =============================================================================
|
||||
|
||||
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
|
||||
"""Find task directory by name (exact or suffix match).
|
||||
|
||||
A task name is a single directory name under ``tasks_dir``, never a path:
|
||||
names carrying a separator or a dot segment are rejected before the join,
|
||||
so ``".."`` cannot hand back the tasks dir's own parent.
|
||||
|
||||
An ambiguous suffix (two tasks created on different days with the same
|
||||
slug) is a failure, not a coin flip — ``iterdir()`` order is filesystem
|
||||
order, so silently picking the first match picks a different task on a
|
||||
different machine.
|
||||
|
||||
Args:
|
||||
task_name: Task name to find.
|
||||
tasks_dir: Tasks directory path.
|
||||
|
||||
Returns:
|
||||
Absolute path to task directory, or None if not found or ambiguous.
|
||||
"""
|
||||
if not task_name or not tasks_dir or not tasks_dir.is_dir():
|
||||
return None
|
||||
|
||||
if "/" in task_name or "\\" in task_name or task_name in (".", ".."):
|
||||
print(f"Error: invalid task name: {task_name}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
exact_match = tasks_dir / task_name
|
||||
if exact_match.is_dir():
|
||||
return exact_match
|
||||
|
||||
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
|
||||
matches = sorted(
|
||||
d for d in tasks_dir.iterdir()
|
||||
if d.is_dir() and d.name.endswith(f"-{task_name}")
|
||||
)
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if matches:
|
||||
print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
|
||||
for match in matches:
|
||||
print(f" - {match.name}", file=sys.stderr)
|
||||
print("Pass the full task directory name.", file=sys.stderr)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Archive Operations
|
||||
# =============================================================================
|
||||
|
||||
def archive_destination_for(task_dir_abs: Path) -> Path:
|
||||
"""Path a task would be archived to: <tasks>/archive/<YYYY-MM>/<name>."""
|
||||
tasks_dir = task_dir_abs.parent
|
||||
year_month = datetime.now().strftime("%Y-%m")
|
||||
return tasks_dir / "archive" / year_month / task_dir_abs.name
|
||||
|
||||
|
||||
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
|
||||
"""Archive a task directory to archive/{YYYY-MM}/.
|
||||
|
||||
Args:
|
||||
task_dir_abs: Absolute path to task directory.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Path to archived directory, or None on error.
|
||||
"""
|
||||
if not task_dir_abs.is_dir():
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
dest = archive_destination_for(task_dir_abs)
|
||||
month_dir = dest.parent
|
||||
|
||||
# Create archive directory
|
||||
try:
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
except (OSError, IOError) as e:
|
||||
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# shutil.move into an existing directory moves the source *inside* it,
|
||||
# producing archive/<month>/<task>/<task>/ and returning a path that is not
|
||||
# where the task actually landed. That wrong path then flows into the
|
||||
# printed result, the after_archive hook's TASK_JSON_PATH, and auto-commit
|
||||
# staging, so refuse before the move instead.
|
||||
if dest.exists():
|
||||
print(
|
||||
f"Error: refusing to archive {task_dir_abs}: "
|
||||
f"archive destination already exists: {dest}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Move or rename the existing archived task, then retry.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
shutil.move(str(task_dir_abs), str(dest))
|
||||
except (OSError, IOError, shutil.Error) as e:
|
||||
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def archive_task_complete(
|
||||
task_dir_abs: Path,
|
||||
repo_root: Path | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Complete archive workflow: archive directory.
|
||||
|
||||
Args:
|
||||
task_dir_abs: Absolute path to task directory.
|
||||
repo_root: Repository root path. Defaults to auto-detected.
|
||||
|
||||
Returns:
|
||||
Dict with archive result info.
|
||||
"""
|
||||
if not task_dir_abs.is_dir():
|
||||
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
archive_dest = archive_task_dir(task_dir_abs, repo_root)
|
||||
if archive_dest:
|
||||
return {"archived_to": str(archive_dest)}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Directory Resolution
|
||||
# =============================================================================
|
||||
|
||||
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve task directory to an absolute path inside the tasks directory.
|
||||
|
||||
Supports:
|
||||
- Absolute path: /path/to/task
|
||||
- Relative path: .trellis/tasks/01-31-my-task
|
||||
- Task name: my-task (uses find_task_by_name for lookup)
|
||||
|
||||
This is the containment chokepoint for every command that accepts a task
|
||||
directory argument. The candidate is resolved (following symlinks) and must
|
||||
land strictly under ``.trellis/tasks/``; archived tasks under
|
||||
``archive/<YYYY-MM>/`` qualify. Traversal (``../victim``), absolute paths
|
||||
outside the repo, a task dir symlinked out of the tasks tree, and the
|
||||
tasks directory itself are all rejected here so no caller has to re-check.
|
||||
The containment base is the tasks dir's own real location, so a
|
||||
``.trellis`` that is itself a symlink into a shared store (#567) works.
|
||||
|
||||
Args:
|
||||
target_dir: Task directory specification.
|
||||
repo_root: Repository root path.
|
||||
|
||||
Returns:
|
||||
Absolute path spelled through the repo's own tasks dir (in-repo
|
||||
lexical form), or None when it is not a location inside the tasks
|
||||
directory (an error naming the path is printed to stderr).
|
||||
"""
|
||||
if not target_dir:
|
||||
print("Error: task directory is required", file=sys.stderr)
|
||||
return None
|
||||
|
||||
normalized = target_dir.replace("\\", "/")
|
||||
while normalized.startswith("./"):
|
||||
normalized = normalized[2:]
|
||||
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
|
||||
if Path(target_dir).is_absolute():
|
||||
candidate = Path(target_dir)
|
||||
elif "/" in normalized or normalized.startswith(".trellis"):
|
||||
# Relative path (contains path separator or starts with .trellis)
|
||||
candidate = repo_root / Path(normalized)
|
||||
else:
|
||||
# Task name - must resolve inside the tasks directory. The historical
|
||||
# fallback to repo_root/<name> only ever produced a path the check
|
||||
# below rejects, so a miss ends here instead.
|
||||
candidate = find_task_by_name(target_dir, tasks_dir)
|
||||
if candidate is None:
|
||||
# find_task_by_name reports invalid names and ambiguity itself.
|
||||
print(
|
||||
f"Error: could not resolve task '{target_dir}' under {tasks_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
tasks_lexical = get_tasks_dir(repo_root.resolve())
|
||||
tasks_resolved = tasks_lexical.resolve()
|
||||
except (OSError, RuntimeError) as e:
|
||||
print(f"Error: could not resolve task directory '{target_dir}': {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if resolved == tasks_resolved:
|
||||
print(
|
||||
f"Error: refusing to use '{target_dir}': {tasks_resolved} is the tasks "
|
||||
"directory itself, not a task",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if tasks_resolved not in resolved.parents:
|
||||
print(
|
||||
f"Error: refusing to use '{target_dir}': {resolved} is outside {tasks_resolved}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# Hand back the path spelled through the repo's own tasks dir rather than
|
||||
# `resolved`: `.trellis` may be a symlink into a store outside the repo
|
||||
# (#567), in which case `resolved` sits outside the repo even though the
|
||||
# task is legitimate, and callers converting to a repo-relative ref for
|
||||
# storage would refuse it.
|
||||
return tasks_lexical / resolved.relative_to(tasks_resolved)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle Hooks
|
||||
# =============================================================================
|
||||
|
||||
HOOK_TIMEOUT_SECONDS = 60
|
||||
HOOK_KILL_GRACE_SECONDS = 5
|
||||
HOOK_OUTPUT_LIMIT = 2000
|
||||
|
||||
|
||||
def _kill_hook_tree(proc: subprocess.Popen[str]) -> None:
|
||||
"""Kill a timed-out hook's whole process tree, not just the shell.
|
||||
|
||||
With ``shell=True`` the direct child is the shell; the actual work runs in
|
||||
its children. Killing only the shell leaves grandchildren alive holding the
|
||||
inherited stdout/stderr pipes, and collecting output afterwards then blocks
|
||||
until those orphans exit — the exact "command that never returns" the
|
||||
timeout exists to prevent.
|
||||
|
||||
POSIX: the hook is started with ``start_new_session=True``, so the shell and
|
||||
every descendant share one fresh process group; SIGKILL the group.
|
||||
Windows: ``taskkill /F /T`` walks the tree (best effort).
|
||||
Either way ``proc.kill()`` is the fallback.
|
||||
|
||||
Limitation: a hook that calls ``setsid`` itself leaves the group and
|
||||
survives this. Accepted and out of scope — a hook is arbitrary code, and the
|
||||
timeout is a liveness guarantee, not a containment boundary.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
if os.name == "posix":
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
return
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _release_hook_process(proc: subprocess.Popen[str]) -> None:
|
||||
"""Close our pipe ends and reap a finished hook under a bound.
|
||||
|
||||
``Popen`` used as a context manager calls ``wait()`` with no timeout when
|
||||
the block exits. That is the one place the liveness guarantee could still
|
||||
be lost: if ``_kill_hook_tree`` failed outright — both the process-group
|
||||
kill and the direct kill raising — the lifecycle command would block there
|
||||
forever, which is the exact hang the timeout exists to prevent.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
for stream in (proc.stdout, proc.stderr, proc.stdin):
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=HOOK_KILL_GRACE_SECONDS)
|
||||
except (subprocess.TimeoutExpired, OSError, ValueError):
|
||||
# Leaves a zombie until this process exits. Fail-open and bounded beats
|
||||
# a task command that never returns.
|
||||
pass
|
||||
|
||||
|
||||
def _decode_hook_output(raw: object) -> str:
|
||||
"""TimeoutExpired carries bytes or str depending on the platform."""
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _print_hook_stream(name: str, text: str) -> None:
|
||||
"""Print a captured hook stream to stderr, truncated."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
if len(text) > HOOK_OUTPUT_LIMIT:
|
||||
text = (
|
||||
text[:HOOK_OUTPUT_LIMIT]
|
||||
+ f"\n… ({len(text) - HOOK_OUTPUT_LIMIT} more characters truncated)"
|
||||
)
|
||||
print(f" {name}:", file=sys.stderr)
|
||||
for line in text.splitlines():
|
||||
print(f" {line}", file=sys.stderr)
|
||||
|
||||
|
||||
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
|
||||
"""Run lifecycle hooks for a task event.
|
||||
|
||||
Hooks are shell commands read from ``.trellis/config.yaml`` and executed
|
||||
with ``shell=True`` from the repo root — see the trust boundary note in
|
||||
``.trellis/spec/cli/backend/script-conventions.md``.
|
||||
|
||||
Fail-open by design: a broken hook warns and the lifecycle command
|
||||
continues. The warning names the event, command, exit status, and both
|
||||
captured streams, because a hook whose only symptom is "nothing happened"
|
||||
is undebuggable. A hook that hangs is bounded by
|
||||
``HOOK_TIMEOUT_SECONDS``; output is captured, so without the timeout the
|
||||
user sees a task command that never returns and prints nothing. On timeout
|
||||
the hook's entire process tree is killed (see ``_kill_hook_tree``) and
|
||||
output is collected under a bounded grace, so a surviving grandchild
|
||||
holding the pipes cannot re-create that same hang. Cleanup is bounded for
|
||||
the same reason — see ``_release_hook_process``.
|
||||
|
||||
Args:
|
||||
event: Event name (e.g. "after_create").
|
||||
task_json_path: Absolute path to the task's task.json.
|
||||
repo_root: Repository root for cwd and config lookup.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from .config import get_hooks
|
||||
from .log import Colors, colored
|
||||
|
||||
commands = get_hooks(event, repo_root)
|
||||
if not commands:
|
||||
return
|
||||
|
||||
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
|
||||
|
||||
# POSIX only: a fresh session puts the shell and every descendant in one
|
||||
# process group, which is what makes the timeout kill the whole tree.
|
||||
popen_kwargs: dict = {}
|
||||
if os.name == "posix":
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
for cmd in commands:
|
||||
proc = None
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=repo_root,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
**popen_kwargs,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
_kill_hook_tree(proc)
|
||||
stdout = _decode_hook_output(e.stdout)
|
||||
stderr = _decode_hook_output(e.stderr)
|
||||
try:
|
||||
# Bounded: an orphan that escaped the kill still holds
|
||||
# the pipes, and waiting on it forever here would be
|
||||
# the very hang the timeout prevents.
|
||||
rest_out, rest_err = proc.communicate(
|
||||
timeout=HOOK_KILL_GRACE_SECONDS
|
||||
)
|
||||
stdout = rest_out or stdout
|
||||
stderr = rest_err or stderr
|
||||
except (subprocess.TimeoutExpired, OSError, ValueError):
|
||||
pass
|
||||
print(
|
||||
colored(
|
||||
f"[WARN] Hook timed out ({event}) after "
|
||||
f"{HOOK_TIMEOUT_SECONDS}s: {cmd}",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f" cwd: {repo_root}", file=sys.stderr)
|
||||
_print_hook_stream("stdout", stdout)
|
||||
_print_hook_stream("stderr", stderr)
|
||||
continue
|
||||
|
||||
if proc.returncode != 0:
|
||||
print(
|
||||
colored(
|
||||
f"[WARN] Hook failed ({event}): exit {proc.returncode}: {cmd}",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f" cwd: {repo_root}", file=sys.stderr)
|
||||
_print_hook_stream("stdout", stdout or "")
|
||||
_print_hook_stream("stderr", stderr or "")
|
||||
except Exception as e:
|
||||
print(
|
||||
colored(
|
||||
f"[WARN] Hook error ({event}): {cmd} — {type(e).__name__}: {e}",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry (for testing)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
repo = get_repo_root()
|
||||
tasks = get_tasks_dir(repo)
|
||||
|
||||
print(f"Tasks dir: {tasks}")
|
||||
print(f"resolve_task_dir('.trellis/tasks/test'): {resolve_task_dir('.trellis/tasks/test', repo)}")
|
||||
print(f"resolve_task_dir('../test'): {resolve_task_dir('../test', repo)}")
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Task data access layer.
|
||||
|
||||
Single source of truth for loading and iterating task directories.
|
||||
Replaces scattered task.json parsing across 9+ files.
|
||||
|
||||
Provides:
|
||||
load_task — Load a single task by directory path
|
||||
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
||||
get_all_statuses — Get {dir_name: status} map for children progress
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from .io import describe_json_read_failure, read_json_checked
|
||||
from .paths import FILE_TASK_JSON
|
||||
from .types import TaskInfo
|
||||
|
||||
|
||||
def load_task(task_dir: Path) -> TaskInfo | None:
|
||||
"""Load task from a directory containing task.json.
|
||||
|
||||
Args:
|
||||
task_dir: Absolute path to the task directory.
|
||||
|
||||
Returns:
|
||||
TaskInfo if task.json exists and is valid, None otherwise.
|
||||
|
||||
A directory without task.json is not a task, so it is skipped silently.
|
||||
A task.json that exists but cannot be loaded is different: the task
|
||||
disappears from `task.py list` and from every context the iterator feeds.
|
||||
Callers stay tolerant, but the skip is announced on stderr so a task
|
||||
cannot vanish from the workflow with no diagnostic anywhere.
|
||||
"""
|
||||
task_json = task_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
return None
|
||||
|
||||
data, reason = read_json_checked(task_json)
|
||||
if data is None:
|
||||
problem, hint = describe_json_read_failure(task_json, reason)
|
||||
print(f"[WARN] Skipping task '{task_dir.name}': {problem}", file=sys.stderr)
|
||||
print(f" {hint}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return TaskInfo(
|
||||
dir_name=task_dir.name,
|
||||
directory=task_dir,
|
||||
title=data.get("title") or data.get("name") or "unknown",
|
||||
status=data.get("status", "unknown"),
|
||||
assignee=data.get("assignee", ""),
|
||||
priority=data.get("priority", "P2"),
|
||||
children=tuple(data.get("children", [])),
|
||||
parent=data.get("parent"),
|
||||
package=data.get("package"),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
||||
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
||||
|
||||
Skips the "archive" directory and directories without valid task.json.
|
||||
|
||||
Args:
|
||||
tasks_dir: Path to the tasks directory.
|
||||
|
||||
Yields:
|
||||
TaskInfo for each valid task.
|
||||
"""
|
||||
if not tasks_dir.is_dir():
|
||||
return
|
||||
|
||||
for d in sorted(tasks_dir.iterdir()):
|
||||
if not d.is_dir() or d.name == "archive":
|
||||
continue
|
||||
info = load_task(d)
|
||||
if info is not None:
|
||||
yield info
|
||||
|
||||
|
||||
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
||||
"""Get a {dir_name: status} mapping for all active tasks.
|
||||
|
||||
Useful for computing children progress without loading full TaskInfo.
|
||||
|
||||
Args:
|
||||
tasks_dir: Path to the tasks directory.
|
||||
|
||||
Returns:
|
||||
Dict mapping directory names to status strings.
|
||||
"""
|
||||
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
||||
|
||||
|
||||
def children_progress(
|
||||
children: tuple[str, ...] | list[str],
|
||||
all_statuses: dict[str, str],
|
||||
) -> str:
|
||||
"""Format children progress string like " [2/3 done]".
|
||||
|
||||
Args:
|
||||
children: List of child directory names.
|
||||
all_statuses: Status map from get_all_statuses().
|
||||
|
||||
Returns:
|
||||
Formatted string, or "" if no children.
|
||||
"""
|
||||
if not children:
|
||||
return ""
|
||||
# A child missing from active statuses has been archived (cmd_archive
|
||||
# sets status=completed before moving the dir). Count it as done so
|
||||
# parent progress doesn't regress when children are archived.
|
||||
done = sum(
|
||||
1 for c in children
|
||||
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
||||
)
|
||||
return f" [{done}/{len(children)} done]"
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone reader for .trellis/config.yaml.
|
||||
|
||||
Owns the minimal YAML parser used across Trellis. ``common.config`` imports
|
||||
``parse_simple_yaml`` from here rather than keeping its own copy: this module
|
||||
imports nothing from the package, so hooks can load it as a single file, and
|
||||
one parser cannot drift from another. Returns an empty dict on
|
||||
missing/malformed files so callers stay simple.
|
||||
|
||||
Supported subset: ``key: value`` scalars (everything is a string), nested
|
||||
mappings by indentation, ``- `` lists of scalars, ``#`` comments (whole-line
|
||||
and inline outside quotes), and one layer of matching surrounding quotes.
|
||||
Constructs outside that subset — block scalars, anchors, aliases, merge keys,
|
||||
flow collections, and mappings nested inside a list — are reported on stderr
|
||||
and skipped rather than parsed into a plausible-looking wrong value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
CONFIG_REL_PATH = ".trellis/config.yaml"
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _strip_inline_comment(value: str) -> str:
|
||||
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
|
||||
|
||||
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
|
||||
is part of the value. Quoted strings are immune.
|
||||
"""
|
||||
in_quote: str | None = None
|
||||
for idx, ch in enumerate(value):
|
||||
if in_quote:
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_quote = ch
|
||||
continue
|
||||
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
|
||||
return value[:idx]
|
||||
return value
|
||||
|
||||
|
||||
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
return i, lines[i]
|
||||
i += 1
|
||||
return i, ""
|
||||
|
||||
|
||||
def _warn_unsupported(source: str, lineno: int, line: str, reason: str) -> None:
|
||||
"""Report a YAML construct this parser cannot represent, and move on."""
|
||||
print(
|
||||
f"[WARN] {source}:{lineno}: {reason}; ignoring: {line.strip()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _is_block_scalar(value: str) -> bool:
|
||||
"""True for ``|``, ``>`` and their chomping/indent indicators (``|-``, ``>2``)."""
|
||||
if not value or value[0] not in ("|", ">"):
|
||||
return False
|
||||
return all(ch in "+-0123456789" for ch in value[1:])
|
||||
|
||||
|
||||
def _unsupported_value(key: str, value: str) -> str | None:
|
||||
"""Name the unsupported construct in an unquoted scalar value, else None.
|
||||
|
||||
Only unquoted values are inspected: ``cmd: "[a] | b"`` is a string the user
|
||||
wrote deliberately, while a bare ``notes: |`` or ``base: *anchor`` would
|
||||
otherwise be stored as the literal marker with the real content dropped.
|
||||
"""
|
||||
if key == "<<":
|
||||
return "YAML merge keys are not supported"
|
||||
if _is_block_scalar(value):
|
||||
return "block scalars are not supported"
|
||||
if value.startswith("&"):
|
||||
return "YAML anchors are not supported"
|
||||
if value.startswith("*"):
|
||||
return "YAML aliases are not supported"
|
||||
if value.startswith("["):
|
||||
return "flow sequences are not supported (use `- ` list items)"
|
||||
if value.startswith("{"):
|
||||
return "flow mappings are not supported (use an indented mapping)"
|
||||
return None
|
||||
|
||||
|
||||
def _skip_indented_body(lines: list[str], start: int, indent: int) -> int:
|
||||
"""Skip the continuation lines of a rejected key (block scalar body etc.)."""
|
||||
i = start
|
||||
while i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
if stripped and len(lines[i]) - len(lines[i].lstrip()) <= indent:
|
||||
break
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _parse_yaml_block(
|
||||
lines: list[str], start: int, min_indent: int, target: dict, source: str
|
||||
) -> int:
|
||||
i = start
|
||||
current_list: list | None = None
|
||||
# Indent of the key that opened current_list, so a deeper `key: value`
|
||||
# can be recognized as a mapping inside that list.
|
||||
list_owner_indent = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if indent < min_indent:
|
||||
break
|
||||
|
||||
if stripped.startswith("- "):
|
||||
if current_list is not None:
|
||||
current_list.append(_unquote(stripped[2:].strip()))
|
||||
i += 1
|
||||
elif ":" in stripped:
|
||||
if current_list is not None and indent > list_owner_indent:
|
||||
# `- name: cli` / ` path: x`: the second key belongs to a
|
||||
# mapping inside the list. Storing it would hoist it into the
|
||||
# parent dict as a sibling of the list — a nested key silently
|
||||
# becoming a root key.
|
||||
_warn_unsupported(
|
||||
source,
|
||||
i + 1,
|
||||
line,
|
||||
"mappings inside a list are not supported",
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
|
||||
|
||||
if not was_quoted:
|
||||
reason = _unsupported_value(key, value)
|
||||
if reason is not None:
|
||||
_warn_unsupported(source, i + 1, line, reason)
|
||||
current_list = None
|
||||
i = _skip_indented_body(lines, i + 1, indent)
|
||||
continue
|
||||
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value or was_quoted:
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
next_i, next_line = _next_content_line(lines, i + 1)
|
||||
if next_i >= len(lines):
|
||||
target[key] = {}
|
||||
i = next_i
|
||||
elif next_line.strip().startswith("- "):
|
||||
current_list = []
|
||||
list_owner_indent = indent
|
||||
target[key] = current_list
|
||||
i += 1
|
||||
else:
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent:
|
||||
nested: dict = {}
|
||||
target[key] = nested
|
||||
i = _parse_yaml_block(lines, i + 1, next_indent, nested, source)
|
||||
else:
|
||||
target[key] = {}
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return i
|
||||
|
||||
|
||||
def parse_simple_yaml(content: str, source: str = "config.yaml") -> dict:
|
||||
"""Parse simple YAML with nested dict support (no dependencies).
|
||||
|
||||
Supports:
|
||||
- key: value (string)
|
||||
- key: (followed by list items)
|
||||
- item1
|
||||
- item2
|
||||
- key: (followed by nested dict)
|
||||
nested_key: value
|
||||
nested_key2:
|
||||
- item
|
||||
|
||||
Uses indentation to detect nesting (2+ spaces deeper = child). Every value
|
||||
is a string; consumers coerce. Unsupported constructs are reported on
|
||||
stderr against ``source`` and skipped — see the module docstring.
|
||||
|
||||
Args:
|
||||
content: YAML content string.
|
||||
source: Label used in warnings, normally the config file path.
|
||||
|
||||
Returns:
|
||||
Parsed dict (values can be str, list[str], or dict).
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
result: dict = {}
|
||||
_parse_yaml_block(lines, 0, 0, result, source)
|
||||
return result
|
||||
|
||||
|
||||
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
|
||||
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
|
||||
root = repo_root or Path.cwd()
|
||||
config_file = root / CONFIG_REL_PATH
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
try:
|
||||
parsed = parse_simple_yaml(content, source=str(config_file))
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Core type definitions for Trellis task data.
|
||||
|
||||
Provides:
|
||||
TaskData — TypedDict for task.json shape (read-path type hints only)
|
||||
TaskInfo — Frozen dataclass for loaded task (the public API type)
|
||||
AgentRecord — TypedDict for registry.json agent entries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# task.json shape (TypedDict — used only for read-path type hints)
|
||||
# =============================================================================
|
||||
|
||||
class TaskData(TypedDict, total=False):
|
||||
"""Shape of task.json on disk.
|
||||
|
||||
Used only for type annotations when reading task.json.
|
||||
Writes must use the original dict to avoid losing unknown fields.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
dev_type: str
|
||||
scope: str | None
|
||||
package: str | None
|
||||
priority: str
|
||||
creator: str
|
||||
assignee: str
|
||||
createdAt: str
|
||||
completedAt: str | None
|
||||
branch: str | None
|
||||
base_branch: str | None
|
||||
worktree_path: str | None
|
||||
commit: str | None
|
||||
pr_url: str | None
|
||||
subtasks: list[str]
|
||||
children: list[str]
|
||||
parent: str | None
|
||||
relatedFiles: list[str]
|
||||
notes: str
|
||||
meta: dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Loaded task object (frozen dataclass — the public API type)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskInfo:
|
||||
"""Immutable view of a loaded task.
|
||||
|
||||
Created by load_task() / iter_active_tasks().
|
||||
Contains the commonly accessed fields; the original dict
|
||||
is preserved in `raw` for write-back and uncommon field access.
|
||||
"""
|
||||
|
||||
dir_name: str
|
||||
directory: Path
|
||||
title: str
|
||||
status: str
|
||||
assignee: str
|
||||
priority: str
|
||||
children: tuple[str, ...]
|
||||
parent: str | None
|
||||
package: str | None
|
||||
raw: dict # original dict — use for writes and uncommon fields
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Task name (id or name field)."""
|
||||
return self.raw.get("name") or self.raw.get("id") or self.dir_name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.raw.get("description", "")
|
||||
|
||||
@property
|
||||
def branch(self) -> str | None:
|
||||
return self.raw.get("branch")
|
||||
|
||||
@property
|
||||
def meta(self) -> dict:
|
||||
return self.raw.get("meta", {})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# registry.json agent entry
|
||||
# =============================================================================
|
||||
|
||||
class AgentRecord(TypedDict, total=False):
|
||||
"""Shape of an agent entry in registry.json."""
|
||||
|
||||
id: str
|
||||
pid: int
|
||||
task_dir: str
|
||||
worktree_path: str
|
||||
branch: str
|
||||
platform: str
|
||||
started_at: str
|
||||
status: str
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Workflow Phase Extraction.
|
||||
|
||||
Extracts step-level content from .trellis/workflow.md and optionally filters
|
||||
platform-specific blocks.
|
||||
|
||||
Platform marker syntax in workflow.md:
|
||||
|
||||
[Claude Code, Cursor, ...]
|
||||
agent-capable content
|
||||
[/Claude Code, Cursor, ...]
|
||||
|
||||
Provides:
|
||||
get_phase_index - Extract the Phase Index section (no --step)
|
||||
get_step - Extract a single step (#### X.X) section
|
||||
filter_platform - Strip platform blocks that don't include the given name
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
|
||||
|
||||
def _workflow_md_path():
|
||||
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||
|
||||
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||
|
||||
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
|
||||
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
|
||||
|
||||
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
|
||||
_PHASE_INDEX_HEADING = "## Phase Index"
|
||||
|
||||
|
||||
def _read_workflow() -> str:
|
||||
path = _workflow_md_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"workflow.md not found: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
||||
"""Parse a platform marker line.
|
||||
|
||||
Returns:
|
||||
(is_closing, [platform_names]) if line is a marker, else None.
|
||||
"""
|
||||
m = _MARKER_RE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
is_closing = m.group(1) == "/"
|
||||
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
|
||||
return is_closing, names
|
||||
|
||||
|
||||
def get_phase_index() -> str:
|
||||
"""Return the compact Phase Index summary from workflow.md.
|
||||
|
||||
SessionStart and no-step phase context use this small summary as their
|
||||
orientation payload. Detailed Phase 1/2/3 instructions are loaded with
|
||||
``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
|
||||
consumed by the per-turn hook, so they're stripped from this output.
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if start is None and stripped == _PHASE_INDEX_HEADING:
|
||||
start = i
|
||||
continue
|
||||
if start is not None and stripped == "## Phase 1: Plan":
|
||||
end = i
|
||||
break
|
||||
|
||||
if start is None:
|
||||
return ""
|
||||
if end is None:
|
||||
end = len(lines)
|
||||
|
||||
section = "\n".join(lines[start:end]).rstrip()
|
||||
# Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
|
||||
# they're injected separately by inject-workflow-state.py per-turn.
|
||||
import re as _re
|
||||
tag_re = _re.compile(
|
||||
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
|
||||
_re.DOTALL,
|
||||
)
|
||||
return tag_re.sub("", section).rstrip() + "\n"
|
||||
|
||||
|
||||
def get_step(step_id: str) -> str:
|
||||
"""Return the `#### X.X` section matching step_id (header + body).
|
||||
|
||||
Body ends at the next `####` or `---` or `##` heading (whichever comes first).
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
|
||||
start: int | None = None
|
||||
for i, line in enumerate(lines):
|
||||
m = _STEP_HEADING_RE.match(line)
|
||||
if m and m.group(1) == step_id:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
return ""
|
||||
|
||||
end: int = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
line = lines[j]
|
||||
if line.startswith("#### "):
|
||||
end = j
|
||||
break
|
||||
if line.startswith("## "):
|
||||
end = j
|
||||
break
|
||||
# Horizontal rule at column 0
|
||||
if line.strip() == "---":
|
||||
end = j
|
||||
break
|
||||
|
||||
return "\n".join(lines[start:end]).rstrip() + "\n"
|
||||
|
||||
|
||||
def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
||||
"""Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
|
||||
needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
for name in block_names:
|
||||
hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
|
||||
if needle == hay:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_PLATFORM_MARKER_LABELS: dict[str, str] = {
|
||||
# workflow.md marker blocks label platforms with their product names, but
|
||||
# every caller passes the stable id instead (`--platform {{CLI_FLAG}}` in
|
||||
# the start / continue commands). `_platform_matches` only strips
|
||||
# punctuation, so an id that is not its label-minus-spaces never matches and
|
||||
# `filter_platform` drops the block WITHOUT error — the section just comes
|
||||
# back empty. Four platforms shipped that way before this table existed.
|
||||
#
|
||||
# Add an entry whenever a platform's id is not its marker label with the
|
||||
# separators removed. `test/registry-invariants.test.ts` asserts every
|
||||
# registry id keeps a non-empty routing section, so a missing entry fails
|
||||
# there rather than silently blanking that platform's routing.
|
||||
"claude": "Claude Code",
|
||||
"kimi": "Kimi Code",
|
||||
"omp": "Oh My Pi",
|
||||
"dsh": "DeepSeek Harness",
|
||||
}
|
||||
|
||||
|
||||
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||
|
||||
When ``--platform codex`` is passed, return ``"codex-sub-agent"`` by
|
||||
default or ``"codex-inline"`` when explicitly configured in
|
||||
``.trellis/config.yaml``. ``sub-agent`` remains an alias for ``auto``.
|
||||
``filter_platform`` then surfaces blocks whose marker lists include the
|
||||
namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
|
||||
Antigravity, Devin]``).
|
||||
|
||||
Native Codex context injection supports the ``auto`` default. Invalid
|
||||
explicit values fall back to ``inline`` safely; this renderer deliberately
|
||||
does not warn because it can run in normal CLI output flows.
|
||||
|
||||
Platforms whose marker label differs from their id resolve through
|
||||
``_PLATFORM_MARKER_LABELS``. Everything else is returned unchanged.
|
||||
"""
|
||||
label = _PLATFORM_MARKER_LABELS.get(platform.strip().lower())
|
||||
if label:
|
||||
return label
|
||||
if platform == "codex":
|
||||
mode = "auto"
|
||||
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
||||
if codex_cfg is not None:
|
||||
if not isinstance(codex_cfg, dict):
|
||||
mode = "inline"
|
||||
else:
|
||||
cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower()
|
||||
if cfg_mode == "inline":
|
||||
mode = "inline"
|
||||
elif cfg_mode in ("auto", "sub-agent"):
|
||||
mode = "auto"
|
||||
else:
|
||||
mode = "inline"
|
||||
return "codex-sub-agent" if mode == "auto" else "codex-inline"
|
||||
return platform
|
||||
|
||||
|
||||
def filter_platform(content: str, platform: str) -> str:
|
||||
"""Keep lines outside any `[...]` block + lines inside blocks that include platform.
|
||||
|
||||
Marker lines themselves are dropped from the output.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
out: list[str] = []
|
||||
|
||||
in_block = False
|
||||
keep_block = False
|
||||
|
||||
for line in lines:
|
||||
marker = _parse_marker(line)
|
||||
if marker is not None:
|
||||
is_closing, names = marker
|
||||
if not is_closing:
|
||||
in_block = True
|
||||
keep_block = _platform_matches(platform, names)
|
||||
else:
|
||||
in_block = False
|
||||
keep_block = False
|
||||
continue # drop the marker line itself
|
||||
|
||||
if in_block:
|
||||
if keep_block:
|
||||
out.append(line)
|
||||
continue
|
||||
out.append(line)
|
||||
|
||||
# Collapse runs of 3+ blank lines that may arise from dropped markers
|
||||
collapsed: list[str] = []
|
||||
blank_run = 0
|
||||
for line in out:
|
||||
if line.strip() == "":
|
||||
blank_run += 1
|
||||
if blank_run <= 2:
|
||||
collapsed.append(line)
|
||||
else:
|
||||
blank_run = 0
|
||||
collapsed.append(line)
|
||||
|
||||
return "\n".join(collapsed).rstrip() + "\n"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get Session Context for AI Agent.
|
||||
|
||||
Usage:
|
||||
python3 get_context.py Output context in text format
|
||||
python3 get_context.py --json Output context in JSON format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from common.git_context import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Get current developer name.
|
||||
|
||||
This is a wrapper that uses common/paths.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import get_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
developer = get_developer()
|
||||
if developer:
|
||||
print(developer)
|
||||
else:
|
||||
print("Developer not initialized", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Linear sync hook for Trellis task lifecycle.
|
||||
|
||||
Syncs task events to Linear via the `linearis` CLI.
|
||||
|
||||
Usage (called automatically by task.py hooks):
|
||||
python3 .trellis/scripts/hooks/linear_sync.py create
|
||||
python3 .trellis/scripts/hooks/linear_sync.py start
|
||||
python3 .trellis/scripts/hooks/linear_sync.py archive
|
||||
|
||||
Manual usage:
|
||||
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
|
||||
|
||||
Environment:
|
||||
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
|
||||
|
||||
Configuration:
|
||||
.trellis/hooks.local.json - Local config (gitignored), example:
|
||||
{
|
||||
"linear": {
|
||||
"team": "TEAM_KEY",
|
||||
"project": "Project Name",
|
||||
"assignees": {
|
||||
"dev-name": "linear-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
|
||||
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
|
||||
|
||||
# Linear status names (must match your team's workflow)
|
||||
STATUS_IN_PROGRESS = "In Progress"
|
||||
STATUS_DONE = "Done"
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load local hook config from .trellis/hooks.local.json."""
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if task_json_path:
|
||||
# Walk up from task.json to find .trellis/
|
||||
trellis_dir = Path(task_json_path).parent.parent.parent
|
||||
else:
|
||||
trellis_dir = Path(".trellis")
|
||||
|
||||
config_path = trellis_dir / "hooks.local.json"
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
CONFIG = _load_config()
|
||||
LINEAR_CFG = CONFIG.get("linear", {})
|
||||
|
||||
TEAM = LINEAR_CFG.get("team", "")
|
||||
PROJECT = LINEAR_CFG.get("project", "")
|
||||
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_task() -> tuple[dict, str]:
|
||||
path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if not path:
|
||||
print("TASK_JSON_PATH not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f), path
|
||||
|
||||
|
||||
def _write_task(data: dict, path: str) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _linearis(*args: str) -> dict | None:
|
||||
result = subprocess.run(
|
||||
["linearis", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
stdout = result.stdout.strip()
|
||||
if stdout:
|
||||
return json.loads(stdout)
|
||||
return None
|
||||
|
||||
|
||||
def _get_linear_issue(task: dict) -> str | None:
|
||||
meta = task.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
return meta.get("linear_issue")
|
||||
return None
|
||||
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_create() -> None:
|
||||
if not TEAM:
|
||||
print("No linear.team configured in hooks.local.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
task, path = _read_task()
|
||||
|
||||
# Skip if already linked
|
||||
if _get_linear_issue(task):
|
||||
print(f"Already linked: {_get_linear_issue(task)}")
|
||||
return
|
||||
|
||||
title = task.get("title") or task.get("name") or "Untitled"
|
||||
args = ["issues", "create", title, "--team", TEAM]
|
||||
|
||||
# Map priority
|
||||
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
|
||||
if priority:
|
||||
args.extend(["-p", str(priority)])
|
||||
|
||||
# Set project
|
||||
if PROJECT:
|
||||
args.extend(["--project", PROJECT])
|
||||
|
||||
# Assign to Linear user
|
||||
assignee = task.get("assignee", "")
|
||||
linear_user_id = ASSIGNEE_MAP.get(assignee)
|
||||
if linear_user_id:
|
||||
args.extend(["--assignee", linear_user_id])
|
||||
|
||||
# Link to parent's Linear issue if available
|
||||
parent_issue = _resolve_parent_linear_issue(task)
|
||||
if parent_issue:
|
||||
args.extend(["--parent-ticket", parent_issue])
|
||||
|
||||
result = _linearis(*args)
|
||||
if result and "identifier" in result:
|
||||
if not isinstance(task.get("meta"), dict):
|
||||
task["meta"] = {}
|
||||
task["meta"]["linear_issue"] = result["identifier"]
|
||||
_write_task(task, path)
|
||||
print(f"Created Linear issue: {result['identifier']}")
|
||||
|
||||
|
||||
def cmd_start() -> None:
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
return
|
||||
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
|
||||
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
|
||||
cmd_sync()
|
||||
|
||||
|
||||
def cmd_archive() -> None:
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
return
|
||||
_linearis("issues", "update", issue, "-s", STATUS_DONE)
|
||||
print(f"Updated {issue} -> {STATUS_DONE}")
|
||||
|
||||
|
||||
def cmd_sync() -> None:
|
||||
"""Sync prd.md content to Linear issue description."""
|
||||
task, _ = _read_task()
|
||||
issue = _get_linear_issue(task)
|
||||
if not issue:
|
||||
print("No linear_issue in meta, run create first", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find prd.md next to task.json
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
prd_path = Path(task_json_path).parent / "prd.md"
|
||||
if not prd_path.is_file():
|
||||
print(f"No prd.md found at {prd_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
description = prd_path.read_text(encoding="utf-8").strip()
|
||||
_linearis("issues", "update", issue, "-d", description)
|
||||
print(f"Synced prd.md to {issue} description")
|
||||
|
||||
|
||||
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_parent_linear_issue(task: dict) -> str | None:
|
||||
"""Find parent task's Linear issue identifier."""
|
||||
parent_name = task.get("parent")
|
||||
if not parent_name:
|
||||
return None
|
||||
|
||||
task_json_path = os.environ.get("TASK_JSON_PATH", "")
|
||||
if not task_json_path:
|
||||
return None
|
||||
|
||||
current_task_dir = Path(task_json_path).parent
|
||||
tasks_dir = current_task_dir.parent
|
||||
parent_json = tasks_dir / parent_name / "task.json"
|
||||
|
||||
if parent_json.exists():
|
||||
try:
|
||||
with open(parent_json, encoding="utf-8") as f:
|
||||
parent_task = json.load(f)
|
||||
return _get_linear_issue(parent_task)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
actions = {
|
||||
"create": cmd_create,
|
||||
"start": cmd_start,
|
||||
"archive": cmd_archive,
|
||||
"sync": cmd_sync,
|
||||
}
|
||||
fn = actions.get(action)
|
||||
if fn:
|
||||
fn()
|
||||
else:
|
||||
print(f"Unknown action: {action}", file=sys.stderr)
|
||||
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize developer for workflow.
|
||||
|
||||
Usage:
|
||||
python3 init_developer.py <developer-name>
|
||||
|
||||
This creates:
|
||||
- .trellis/.developer file with developer info
|
||||
- .trellis/workspace/<name>/ directory structure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from common.paths import (
|
||||
DIR_WORKFLOW,
|
||||
FILE_DEVELOPER,
|
||||
get_developer,
|
||||
)
|
||||
from common.developer import init_developer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <developer-name>")
|
||||
print()
|
||||
print("Example:")
|
||||
print(f" {sys.argv[0]} john")
|
||||
sys.exit(1)
|
||||
|
||||
name = sys.argv[1]
|
||||
|
||||
# Check if already initialized
|
||||
existing = get_developer()
|
||||
if existing:
|
||||
print(f"Developer already initialized: {existing}")
|
||||
print()
|
||||
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
|
||||
sys.exit(0)
|
||||
|
||||
if init_developer(name):
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+801
@@ -0,0 +1,801 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Task Management Script.
|
||||
|
||||
Usage:
|
||||
python3 task.py create "<title>" --description "<desc>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] [--force]
|
||||
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
|
||||
python3 task.py validate <dir> # Validate jsonl files
|
||||
python3 task.py list-context <dir> # List jsonl entries
|
||||
python3 task.py start <dir> # Set active task, record current branch
|
||||
python3 task.py current [--source] [--json] # Show active task
|
||||
python3 task.py finish # Clear active task
|
||||
python3 task.py set-branch <dir> <branch> # Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> # Set scope for PR title
|
||||
python3 task.py set-meta <dir> <key> <value> # Set a task metadata key
|
||||
python3 task.py rename <dir> <new-slug> [--dry-run] # Rename task + references
|
||||
python3 task.py archive <task-dir> [--skip-branch-validation] # Archive completed task
|
||||
python3 task.py list # List active tasks
|
||||
python3 task.py list-archive [month] # List archived tasks
|
||||
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
|
||||
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from common.log import Colors, colored
|
||||
from common.paths import (
|
||||
DEVELOPER_HINT,
|
||||
DIR_WORKFLOW,
|
||||
DIR_TASKS,
|
||||
FILE_TASK_JSON,
|
||||
get_repo_root,
|
||||
get_developer,
|
||||
get_tasks_dir,
|
||||
get_current_task,
|
||||
)
|
||||
from common.active_task import (
|
||||
clear_active_task,
|
||||
resolve_active_task,
|
||||
resolve_context_key,
|
||||
set_active_task,
|
||||
)
|
||||
from common.git import current_branch_name
|
||||
from common.io import (
|
||||
describe_json_read_failure,
|
||||
read_json_checked,
|
||||
write_json,
|
||||
)
|
||||
from common.task_utils import resolve_task_dir, run_task_hooks
|
||||
from common.tasks import iter_active_tasks, children_progress
|
||||
|
||||
# Import command handlers from split modules (also re-exports for plan.py compatibility)
|
||||
from common.task_store import (
|
||||
cmd_create,
|
||||
cmd_rename,
|
||||
cmd_archive,
|
||||
cmd_set_branch,
|
||||
cmd_set_base_branch,
|
||||
cmd_set_scope,
|
||||
cmd_set_meta,
|
||||
cmd_add_subtask,
|
||||
cmd_remove_subtask,
|
||||
)
|
||||
from common.task_context import (
|
||||
cmd_add_context,
|
||||
cmd_validate,
|
||||
cmd_list_context,
|
||||
curated_entry_count,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: start / finish
|
||||
# =============================================================================
|
||||
|
||||
def _record_start_state(
|
||||
task_json_path: Path,
|
||||
repo_root: Path,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Move a freshly started task to in_progress and record its branch.
|
||||
|
||||
Both updates share one read/write: the status flip from planning, and the
|
||||
checked-out branch when `branch` is still empty. Recording at start is what
|
||||
keeps `branch` trustworthy at archive time — a task whose branch is only
|
||||
ever set by hand tends to reach archive with `branch: null`.
|
||||
|
||||
Tolerant on purpose — a broken task.json does not fail `start`, because the
|
||||
session pointer is the point of the command. But the read overwrites the
|
||||
file it just read, so no failure may be silent: without a message the
|
||||
absent status line looks like the task simply was not in planning.
|
||||
"""
|
||||
data, reason = read_json_checked(task_json_path)
|
||||
if data is None:
|
||||
problem, hint = describe_json_read_failure(task_json_path, reason)
|
||||
print(
|
||||
colored(f"Warning: {problem}; task.json not updated.", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(hint, file=sys.stderr)
|
||||
return
|
||||
|
||||
applied: list[str] = []
|
||||
|
||||
if data.get("status") == "planning":
|
||||
data["status"] = "in_progress"
|
||||
applied.append(f"✓ Status: planning → in_progress{label}")
|
||||
|
||||
# Only fill an empty field: an explicit `set-branch` must survive a later
|
||||
# `start` (re-starting a task after a checkout is a normal thing to do).
|
||||
base_branch_conflict: str | None = None
|
||||
if not data.get("branch"):
|
||||
branch = current_branch_name(repo_root)
|
||||
if branch:
|
||||
data["branch"] = branch
|
||||
applied.append(f"✓ Branch recorded: {branch}{label}")
|
||||
if branch == data.get("base_branch"):
|
||||
base_branch_conflict = branch
|
||||
else:
|
||||
print(
|
||||
colored(
|
||||
"Note: no checked-out branch (detached HEAD, or not a git "
|
||||
"repository); task branch not recorded.",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if not applied:
|
||||
return
|
||||
|
||||
if not write_json(task_json_path, data):
|
||||
print(
|
||||
colored(
|
||||
f"Warning: Failed to write {task_json_path}; "
|
||||
"status and branch are unchanged.",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
for line in applied:
|
||||
print(colored(line, Colors.GREEN))
|
||||
|
||||
if base_branch_conflict:
|
||||
# Recorded anyway — the value is true, it just cannot describe a PR.
|
||||
# Archive refuses this shape, so say so now rather than at the gate.
|
||||
print(
|
||||
colored(
|
||||
f"Warning: '{base_branch_conflict}' is also this task's base_branch; "
|
||||
"a PR cannot target its own branch, and archive will refuse it.",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"Once you branch off, run: python3 {DIR_WORKFLOW}/scripts/task.py "
|
||||
"set-branch <task> <feature-branch>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> int:
|
||||
"""Set active task."""
|
||||
repo_root = get_repo_root()
|
||||
task_input = args.dir
|
||||
|
||||
if not task_input:
|
||||
print(colored("Error: task directory or name required", Colors.RED))
|
||||
return 1
|
||||
|
||||
# Resolve task directory (supports task name, relative path, or absolute path)
|
||||
full_path = resolve_task_dir(task_input, repo_root)
|
||||
|
||||
if full_path is None:
|
||||
# resolve_task_dir already named the exact reason on stderr. A second,
|
||||
# generic line on stdout would split one diagnosis across two streams
|
||||
# and bury the specific message.
|
||||
return 1
|
||||
|
||||
if not full_path.is_dir():
|
||||
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
|
||||
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
||||
return 1
|
||||
|
||||
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
|
||||
# manifest means every sub-agent dispatched for this task runs with zero
|
||||
# spec context, and nothing downstream surfaces that to the main session.
|
||||
# An absent manifest is not gated — create seeds the files only on
|
||||
# sub-agent-capable platforms, so absence means no sub-agent reads them.
|
||||
if not getattr(args, "allow_empty_context", False):
|
||||
empty_manifests = [
|
||||
name
|
||||
for name in ("implement.jsonl", "check.jsonl")
|
||||
if curated_entry_count(full_path / name) == 0
|
||||
]
|
||||
if empty_manifests:
|
||||
print(colored(
|
||||
f"Error: {' and '.join(empty_manifests)} "
|
||||
f"{'has' if len(empty_manifests) == 1 else 'have'} no curated entries",
|
||||
Colors.RED,
|
||||
))
|
||||
print("Sub-agents (implement/check) would run with zero spec context.")
|
||||
print(f" Curate: python3 .trellis/scripts/task.py add-context {task_input} implement <path> \"<why>\"")
|
||||
print(f" Verify: python3 .trellis/scripts/task.py validate {task_input}")
|
||||
print(" Intentionally empty? Re-run start with --allow-empty-context")
|
||||
return 1
|
||||
|
||||
# Convert to relative path for storage. repo_root is resolved because
|
||||
# full_path already is (resolve_task_dir only returns paths inside the
|
||||
# resolved root), so an unresolved repo_root would mismatch under a
|
||||
# symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
|
||||
try:
|
||||
task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
# resolve_task_dir already refused everything outside the repo, so
|
||||
# this is unreachable in practice. Refuse rather than fall back to
|
||||
# str(full_path) — that fallback (a lexical relative_to() paired with
|
||||
# an absolute-path fallback) is exactly the pattern that let a `..`
|
||||
# ref escape into storage before this fix.
|
||||
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
|
||||
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
|
||||
return 1
|
||||
|
||||
task_json_path = full_path / FILE_TASK_JSON
|
||||
|
||||
if not resolve_context_key():
|
||||
# Degraded mode: no session identity available.
|
||||
# Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
|
||||
# --continue resume path, fork distribution, hooks disabled, etc.). Skip
|
||||
# per-session pointer write; AI continues based on conversation context.
|
||||
print(colored(
|
||||
"ℹ Session identity not available; active-task pointer not persisted "
|
||||
"this session (degraded mode). AI continues based on conversation context.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
print(colored(
|
||||
"Hint: run inside an AI IDE/session that exposes session identity, "
|
||||
"or set TRELLIS_CONTEXT_ID before running task.py start.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
|
||||
# Still flip task.json status: planning → in_progress so downstream phases proceed.
|
||||
if task_json_path.is_file():
|
||||
_record_start_state(task_json_path, repo_root, " (degraded)")
|
||||
run_task_hooks("after_start", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
active = set_active_task(task_dir, repo_root)
|
||||
if active:
|
||||
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
|
||||
print(f"Source: {active.source}")
|
||||
|
||||
if task_json_path.is_file():
|
||||
_record_start_state(task_json_path, repo_root)
|
||||
|
||||
print()
|
||||
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
|
||||
|
||||
run_task_hooks("after_start", task_json_path, repo_root)
|
||||
return 0
|
||||
else:
|
||||
print(colored("Error: Failed to set current task", Colors.RED))
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_finish(args: argparse.Namespace) -> int:
|
||||
"""Clear active task."""
|
||||
repo_root = get_repo_root()
|
||||
active = clear_active_task(repo_root)
|
||||
current = active.task_path
|
||||
|
||||
if not current:
|
||||
print(colored("No current task set", Colors.YELLOW))
|
||||
return 0
|
||||
|
||||
# Resolve task.json path before clearing
|
||||
task_json_path = repo_root / current / FILE_TASK_JSON
|
||||
|
||||
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
|
||||
print(f"Source: {active.source}")
|
||||
|
||||
if task_json_path.is_file():
|
||||
run_task_hooks("after_finish", task_json_path, repo_root)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_current(args: argparse.Namespace) -> int:
|
||||
"""Show active task."""
|
||||
repo_root = get_repo_root()
|
||||
active = resolve_active_task(repo_root)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
task_obj = None
|
||||
read_error = None
|
||||
if active.task_path:
|
||||
task_json_path = repo_root / active.task_path / FILE_TASK_JSON
|
||||
data, reason = read_json_checked(task_json_path)
|
||||
if data is None:
|
||||
# Without this, a corrupt task.json emits null for every field
|
||||
# — indistinguishable from a task whose fields really are null.
|
||||
problem, hint = describe_json_read_failure(task_json_path, reason)
|
||||
read_error = {
|
||||
"file": str(task_json_path),
|
||||
"reason": reason,
|
||||
"message": f"{problem}. {hint}",
|
||||
}
|
||||
data = {}
|
||||
task_obj = {
|
||||
"dir": active.task_path,
|
||||
"id": data.get("id") or data.get("name"),
|
||||
"title": data.get("title"),
|
||||
"status": data.get("status"),
|
||||
"parent": data.get("parent"),
|
||||
"children": data.get("children", []),
|
||||
"branch": data.get("branch"),
|
||||
"base_branch": data.get("base_branch"),
|
||||
}
|
||||
payload = {
|
||||
"current_task": task_obj,
|
||||
"source": active.source,
|
||||
"stale": active.stale,
|
||||
}
|
||||
# Only present when the read failed, so the healthy shape is unchanged.
|
||||
if read_error:
|
||||
payload["error"] = read_error
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return 0 if active.task_path else 1
|
||||
|
||||
if args.source:
|
||||
print(f"Current task: {active.task_path or '(none)'}")
|
||||
print(f"Source: {active.source}")
|
||||
if active.stale:
|
||||
print("State: stale")
|
||||
return 0 if active.task_path else 1
|
||||
|
||||
if active.task_path:
|
||||
print(active.task_path)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list
|
||||
# =============================================================================
|
||||
|
||||
def _display_status(t, all_statuses: dict) -> str:
|
||||
"""Return the status label to show for a task in `list` output.
|
||||
|
||||
A parent task's stored status stays "planning" until someone runs
|
||||
`task.py start` on the parent directly, even while its children are
|
||||
actively being worked — a misleading label for anyone scanning the
|
||||
list (#399 item 3). Show "active" instead when at least one child is
|
||||
past planning; the stored status.json value is left untouched.
|
||||
"""
|
||||
if t.status == "planning" and t.children:
|
||||
child_in_flight = any(
|
||||
all_statuses.get(c) not in (None, "planning") for c in t.children
|
||||
)
|
||||
if child_in_flight:
|
||||
return "active"
|
||||
return t.status
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List active tasks."""
|
||||
repo_root = get_repo_root()
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
current_task = get_current_task(repo_root)
|
||||
developer = get_developer(repo_root)
|
||||
filter_mine = args.mine
|
||||
filter_status = args.status
|
||||
as_json = getattr(args, "json", False)
|
||||
|
||||
# Single pass: collect all tasks via shared iterator
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
if as_json:
|
||||
if filter_mine and not developer:
|
||||
print(
|
||||
json.dumps({"error": "No developer set", "hint": DEVELOPER_HINT}),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
items = []
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
t = all_tasks[dir_name]
|
||||
if filter_mine and (t.assignee or "-") != developer:
|
||||
continue
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
items.append({
|
||||
"dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}",
|
||||
"id": t.raw.get("id") or dir_name,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"display_status": _display_status(t, all_statuses),
|
||||
"priority": t.priority,
|
||||
"assignee": t.assignee or None,
|
||||
"parent": t.parent,
|
||||
"children": list(t.children),
|
||||
"package": t.package,
|
||||
})
|
||||
print(json.dumps({"tasks": items}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
if filter_mine:
|
||||
if not developer:
|
||||
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
|
||||
print(DEVELOPER_HINT, file=sys.stderr)
|
||||
return 1
|
||||
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
|
||||
else:
|
||||
print(colored("All active tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
# Display tasks hierarchically
|
||||
count = 0
|
||||
|
||||
def _print_task(dir_name: str, indent: int = 0) -> None:
|
||||
nonlocal count
|
||||
t = all_tasks[dir_name]
|
||||
|
||||
# Apply --mine filter
|
||||
if filter_mine and (t.assignee or "-") != developer:
|
||||
return
|
||||
|
||||
# Apply --status filter
|
||||
if filter_status and t.status != filter_status:
|
||||
return
|
||||
|
||||
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
|
||||
marker = ""
|
||||
if relative_path == current_task:
|
||||
marker = f" {colored('<- current', Colors.GREEN)}"
|
||||
|
||||
# Children progress
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
status_label = _display_status(t, all_statuses)
|
||||
|
||||
# Package tag
|
||||
pkg_tag = f" @{t.package}" if t.package else ""
|
||||
|
||||
prefix = " " * indent + " - "
|
||||
|
||||
if filter_mine:
|
||||
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}")
|
||||
else:
|
||||
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
|
||||
count += 1
|
||||
|
||||
# Print children indented
|
||||
for child_name in t.children:
|
||||
if child_name in all_tasks:
|
||||
_print_task(child_name, indent + 1)
|
||||
|
||||
# Display only top-level tasks: those without a parent, plus orphans
|
||||
# whose recorded parent is not (or no longer) in the active set — a
|
||||
# dangling parent ref must still render flat instead of disappearing.
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
parent = all_tasks[dir_name].parent
|
||||
if not parent or parent not in all_tasks:
|
||||
_print_task(dir_name)
|
||||
|
||||
if count == 0:
|
||||
if filter_mine:
|
||||
print(" (no tasks assigned to you)")
|
||||
else:
|
||||
print(" (no active tasks)")
|
||||
|
||||
print()
|
||||
print(f"Total: {count} task(s)")
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: list-archive
|
||||
# =============================================================================
|
||||
|
||||
def cmd_list_archive(args: argparse.Namespace) -> int:
|
||||
"""List archived tasks."""
|
||||
repo_root = get_repo_root()
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
archive_dir = tasks_dir / "archive"
|
||||
month = args.month
|
||||
|
||||
print(colored("Archived tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
if month:
|
||||
month_dir = archive_dir / month
|
||||
if month_dir.is_dir():
|
||||
print(f"[{month}]")
|
||||
for d in sorted(month_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
print(f" - {d.name}/")
|
||||
else:
|
||||
print(f" No archives for {month}")
|
||||
else:
|
||||
if archive_dir.is_dir():
|
||||
for month_dir in sorted(archive_dir.iterdir()):
|
||||
if month_dir.is_dir():
|
||||
month_name = month_dir.name
|
||||
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
|
||||
print(f"[{month_name}] - {count} task(s)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Help
|
||||
# =============================================================================
|
||||
|
||||
def show_usage() -> None:
|
||||
"""Show usage help."""
|
||||
print("""Task Management Script
|
||||
|
||||
Usage:
|
||||
python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty)
|
||||
python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
|
||||
python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
|
||||
python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
|
||||
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
|
||||
python3 task.py validate <dir> Validate jsonl files
|
||||
python3 task.py list-context <dir> List jsonl entries
|
||||
python3 task.py start <dir> Set active task; records the checked-out branch when unset
|
||||
python3 task.py current [--source] Show active task
|
||||
python3 task.py finish Clear active task
|
||||
python3 task.py set-branch <dir> <branch> Set git branch
|
||||
python3 task.py set-base-branch <dir> <branch> Set PR target branch
|
||||
python3 task.py set-scope <dir> <scope> Set scope for PR title
|
||||
python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key
|
||||
python3 task.py rename <dir> <new-slug> Rename task, identity fields and references
|
||||
python3 task.py archive <task-dir> Archive completed task
|
||||
python3 task.py add-subtask <parent> <child> Link child task to parent
|
||||
python3 task.py remove-subtask <parent> <child> Unlink child from parent
|
||||
python3 task.py list [--mine] [--status <status>] [--json] List tasks
|
||||
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||||
|
||||
Monorepo options:
|
||||
--package <pkg> Package name (validated against config.yaml packages)
|
||||
|
||||
Rename options:
|
||||
--dry-run Print the change set without writing anything
|
||||
|
||||
Archive options:
|
||||
--no-commit Skip the auto git commit after archiving
|
||||
--skip-branch-validation Archive despite missing or self-referential branch metadata.
|
||||
Archive normally refuses a task with no `branch` when it has a
|
||||
`base_branch` and the repo has a remote, or with
|
||||
`branch == base_branch`; repair those with `set-branch` /
|
||||
`set-base-branch` instead. Use this flag only for tasks that
|
||||
were never PR-backed. A recorded branch that was merged and
|
||||
deleted is only a warning and needs no flag.
|
||||
|
||||
List options:
|
||||
--mine, -m Show only tasks assigned to current developer
|
||||
--status, -s <s> Filter by status (planning, in_progress, review, completed)
|
||||
--json Output machine-readable JSON (also available on `current`)
|
||||
|
||||
Examples:
|
||||
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login
|
||||
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli
|
||||
python3 task.py create "Add login feature" --description "Email + password sign-in" --meta linear=ENG-123 --meta epic=auth
|
||||
python3 task.py create "Child task" --description "Session cookie handling" --slug child --parent .trellis/tasks/01-21-parent
|
||||
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
|
||||
python3 task.py set-branch <dir> task/add-login
|
||||
python3 task.py start .trellis/tasks/01-21-add-login
|
||||
python3 task.py current --source
|
||||
python3 task.py finish
|
||||
python3 task.py rename add-login add-sso --dry-run # Preview the change set
|
||||
python3 task.py rename add-login add-sso
|
||||
python3 task.py archive add-login
|
||||
python3 task.py archive add-login --skip-branch-validation # Task never had a branch of its own
|
||||
python3 task.py add-subtask parent-task child-task # Link existing tasks
|
||||
python3 task.py remove-subtask parent-task child-task
|
||||
python3 task.py list # List all active tasks
|
||||
python3 task.py list --mine # List my tasks only
|
||||
python3 task.py list --mine --status in_progress # List my in-progress tasks
|
||||
""")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry
|
||||
# =============================================================================
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point."""
|
||||
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
|
||||
# Detect early so argparse doesn't mask the real reason with a generic
|
||||
# "invalid choice" error.
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
|
||||
print(
|
||||
colored(
|
||||
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"sub-agent-capable platforms and curated by the AI during planning when needed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
|
||||
print(
|
||||
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Task Management Script",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
||||
|
||||
# create
|
||||
p_create = subparsers.add_parser("create", help="Create new task")
|
||||
p_create.add_argument("title", help="Task title (required, non-empty)")
|
||||
p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix")
|
||||
p_create.add_argument("--assignee", "-a", help="Assignee developer")
|
||||
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
|
||||
p_create.add_argument(
|
||||
"--description",
|
||||
"-d",
|
||||
help="Task description (required, non-empty — an empty one is refused at archive)",
|
||||
)
|
||||
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
|
||||
p_create.add_argument("--package", help="Package name for monorepo projects")
|
||||
p_create.add_argument(
|
||||
"--base-branch",
|
||||
help="PR target branch (overrides origin/HEAD detection and the checked-out-branch fallback)",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--meta",
|
||||
action="append",
|
||||
help="Task metadata key=value (repeatable)",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--no-start",
|
||||
action="store_true",
|
||||
help="Create the task without making it active in this session",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite task.json when the task directory already exists",
|
||||
)
|
||||
|
||||
# add-context
|
||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||
p_add.add_argument("dir", help="Task directory")
|
||||
p_add.add_argument("file", help="JSONL file (implement|check)")
|
||||
p_add.add_argument("path", help="File path to add")
|
||||
p_add.add_argument("reason", nargs="?", help="Reason for adding")
|
||||
|
||||
# validate
|
||||
p_validate = subparsers.add_parser("validate", help="Validate context files")
|
||||
p_validate.add_argument("dir", help="Task directory")
|
||||
|
||||
# list-context
|
||||
p_listctx = subparsers.add_parser("list-context", help="List context entries")
|
||||
p_listctx.add_argument("dir", help="Task directory")
|
||||
|
||||
# start
|
||||
p_start = subparsers.add_parser("start", help="Set active task")
|
||||
p_start.add_argument("dir", help="Task directory")
|
||||
p_start.add_argument(
|
||||
"--allow-empty-context",
|
||||
action="store_true",
|
||||
help="Start even when implement.jsonl / check.jsonl have no curated entries",
|
||||
)
|
||||
|
||||
# current
|
||||
p_current = subparsers.add_parser("current", help="Show active task")
|
||||
p_current.add_argument("--source", action="store_true",
|
||||
help="Show active task source")
|
||||
p_current.add_argument("--json", action="store_true",
|
||||
help="Output machine-readable JSON")
|
||||
|
||||
# finish
|
||||
subparsers.add_parser("finish", help="Clear active task")
|
||||
|
||||
# set-branch
|
||||
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
|
||||
p_branch.add_argument("dir", help="Task directory")
|
||||
p_branch.add_argument("branch", help="Branch name")
|
||||
|
||||
# set-base-branch
|
||||
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
|
||||
p_base.add_argument("dir", help="Task directory")
|
||||
p_base.add_argument("base_branch", help="Base branch name (PR target)")
|
||||
|
||||
# set-scope
|
||||
p_scope = subparsers.add_parser("set-scope", help="Set scope")
|
||||
p_scope.add_argument("dir", help="Task directory")
|
||||
p_scope.add_argument("scope", help="Scope name")
|
||||
|
||||
# set-meta
|
||||
p_setmeta = subparsers.add_parser("set-meta", help="Set/overwrite a task metadata key")
|
||||
p_setmeta.add_argument("dir", help="Task directory")
|
||||
p_setmeta.add_argument("key", help="Metadata key")
|
||||
p_setmeta.add_argument("value", help="Metadata value")
|
||||
|
||||
# rename
|
||||
p_rename = subparsers.add_parser("rename", help="Rename task and its references")
|
||||
p_rename.add_argument("name", help="Task directory or name")
|
||||
p_rename.add_argument("new_slug", help="New slug without the MM-DD date prefix")
|
||||
p_rename.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the change set without writing anything",
|
||||
)
|
||||
|
||||
# archive
|
||||
p_archive = subparsers.add_parser("archive", help="Archive task")
|
||||
p_archive.add_argument("name", help="Task directory or name")
|
||||
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
|
||||
p_archive.add_argument(
|
||||
"--skip-branch-validation",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Archive even when branch metadata is missing or self-referential "
|
||||
"(for tasks that were never PR-backed)"
|
||||
),
|
||||
)
|
||||
|
||||
# list
|
||||
p_list = subparsers.add_parser("list", help="List tasks")
|
||||
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
|
||||
p_list.add_argument("--status", "-s", help="Filter by status")
|
||||
p_list.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
||||
|
||||
# add-subtask
|
||||
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
|
||||
p_addsub.add_argument("parent_dir", help="Parent task directory")
|
||||
p_addsub.add_argument("child_dir", help="Child task directory")
|
||||
|
||||
# remove-subtask
|
||||
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
|
||||
p_rmsub.add_argument("parent_dir", help="Parent task directory")
|
||||
p_rmsub.add_argument("child_dir", help="Child task directory")
|
||||
|
||||
# list-archive
|
||||
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
|
||||
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
show_usage()
|
||||
return 1
|
||||
|
||||
commands = {
|
||||
"create": cmd_create,
|
||||
"add-context": cmd_add_context,
|
||||
"validate": cmd_validate,
|
||||
"list-context": cmd_list_context,
|
||||
"start": cmd_start,
|
||||
"current": cmd_current,
|
||||
"finish": cmd_finish,
|
||||
"set-branch": cmd_set_branch,
|
||||
"set-base-branch": cmd_set_base_branch,
|
||||
"set-scope": cmd_set_scope,
|
||||
"set-meta": cmd_set_meta,
|
||||
"rename": cmd_rename,
|
||||
"archive": cmd_archive,
|
||||
"add-subtask": cmd_add_subtask,
|
||||
"remove-subtask": cmd_remove_subtask,
|
||||
"list": cmd_list,
|
||||
"list-archive": cmd_list_archive,
|
||||
}
|
||||
|
||||
if args.command in commands:
|
||||
return commands[args.command](args)
|
||||
else:
|
||||
show_usage()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,194 @@
|
||||
# Compiler Contracts
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
Applies to AST/HIR contract metadata, typed LLVM/resource/coroutine lowering, private fields, range/failure/effect analysis, structural warnings and public API baseline enforcement. Keep clause-only control flow and ordinary value semantics. Official sources receive the same hard checks; no blanket recovery annotation or bundled exception.
|
||||
|
||||
## 2. Signatures
|
||||
|
||||
- `llvm::emit_executable[_with_resources]` and `emit_test_executable` wrap the existing callable roots in C `main`. Process startup owns SIGPIPE initialization; callable roots preserve host policy. Success/failure paths release outputs, typed faults, rendered Text and diagnostic Buffer, then flush the existing logger. Test main validates one decimal index and uses the same index for execution and all fault/output owners. Private CLI defaults to these entries and never invokes a host compiler or Rust fallback.
|
||||
- `llvm::emit_kernel(&Program, &SourceMap, entry) -> Result<String, String>` lowers typed ordinary function kernels and inferred-suspending coroutines, not the default application backend. It consumes only checked HIR and existing specialization/Domain metadata; no Rust source, generic Value or interpreter dispatch is emitted. Keep direct typed arguments, explicit output/fault pointers, clause projections, value-copy mutation, static handlers, source evaluation order, short-circuiting and checked numeric semantics. Fault origin and each caller reference the emitted source-span table; frame capacity is reachable specialization count plus one, so acyclic calls never truncate a trace. Collect nominal types from nested reachable expressions as well as locals/signatures. Validate and collect every reachable `specialize::failures` type and its recursive owned fields, including Port-declared errors unused in implementation bodies: fault layout/release still references them. Reject unsupported reachable types/effects/application roots at their source location before returning IR; never silently substitute a value or fallback backend.
|
||||
- `llvm::emit_application(&Program, &SourceMap) -> Result<String, String>` emits callable compiler-owned CMD, HTTP/REST and Job roots. `dever_application_entry(Unit*, fault*) -> i32` uses existing output/fault release exports. CMD reads a qualified name and one JSON object, decodes checked inputs, invokes App/selected Dever or external Adapter and prints the existing success envelope; a mixed HTTP/CMD application must not bind HTTP when executing CMD. HTTP/REST uses static route/auth/permission descriptors and concrete callbacks, reusing the existing API, JWT/business verify, site, tenant, RBAC, Upload and log owners. No dynamic Value enters business code. Model/SQL/Related/migration/Seed/DbError, Job resources and hidden transactions share one invocation-owned application Session. `emit_application_with_resources` additionally consumes the packager's verified `EmbeddedResource` table; private CLI adds an executable process entry; managed Linux compilation rechecks portable sources in a trusted daemon worker. Formal target packs remain unfinished.
|
||||
- LLVM external lowering reuses checked Port names, declared failures and wire field codecs. Decode selected Setting before starting a Worker. Named result/error objects require every top-level field, including nullable fields; unknown/missing fields, invalid types and undeclared error identities remain source-located runtime faults. Move fully decoded outputs or exact nominal Choice payloads into their owner once. JSON exists only at the process boundary. Embed immutable bytes and metadata with the native backend's shared digest/dedupe helpers. Production resources/startup never enter a fake-only Test graph.
|
||||
- `llvm::emit_test_suite(&Program, &SourceMap) -> Result<String, String>` emits one module/executable with stable case indices from case-local checked `test_program` metadata. It exports `dever_test_count`, `dever_test_entry`, `dever_test_fault_size`, `dever_test_fault_release`, `dever_test_fault_render` and `dever_test_text_release`. Allocate the selected fault size with eight-byte alignment; render/release with the same index. Cases retain their own effects, fakes, faults, private symbols, database and virtual clock. Only declared internal LLVM symbol/type tokens are qualified; never rewrite strings, comments or shared ABI declarations. Production routes/auth are removed and unreachable Jobs pruned before lowering; reachable Job payloads still use the shared typed wire contract. No deployment config, real Lib or service startup is allowed in ordinary tests.
|
||||
- LLVM application Dever Port dispatch reuses checked production Adapter candidates and `Settings` selection/validation. Decode only the selected typed Setting; a single unconfigured Adapter without Model needs no settings file. Concrete selection/Setting/database binding slots are module-private. A nonblocking atomic entry gate rejects overlapping/reentrant roots that own Port, database, application or clock slots. The root Scope drains before same-runtime Session close, selected Setting drop, slot reset and gate release, including partial initialization and faults. Child Tasks read immutable selected slots but detach transaction context; they neither reload config nor own independent application roots. Writing CMD begins after input decode and commits only after output encoding, before stdout. Nested/sequential calls share one transaction without savepoints; cancelled unconfirmed operations discard the connection. Database ABI/storage/error contracts and live acceptance are owned by `database-guidelines.md` §9, not a second ORM implementation.
|
||||
- LLVM inferred-suspending functions are `ptr @f(...typed inputs, ptr out, ptr fault, ptr state) presplitcoroutine`; synchronous functions remain `i32 @f(...typed inputs, ptr out, ptr fault)`. Both share clause/binding/statement emission, TypeOwner guards and specialized typed invocation. Coroutine/runtime descriptors and fixed ABI signatures live in `toolchain-and-library.md` and the canonical C header.
|
||||
- Application source identity is `module/<component>/<domain>/<role>.dever[.md]` or a cohesive role topic file; role is app/domain/model/port/adapter/api/job. Generated API/CMD/Job roots replace application `main.dever`. Model/API root files may coexist with topic directories; other roles keep root/directory exclusivity and non-API topics are flat. Reject generic common/shared/utils/helper/base/service buckets. Application source has no package/exposes/import declarations; `public` is limited to an individual HTTP declaration.
|
||||
- App functions use logical `<component>.<domain>.<function>` names; role/topic never enter the call. Main calls App; API calls its own App; App calls App or same-domain Domain/Port; Domain calls same-domain Domain; Adapter calls only same-file helpers and standard capabilities. Model operations remain owned by their domain App. API handlers and qualified Adapter/fake implementations are not source-callable.
|
||||
- Port operations are one bodyless ordinary signature with typed value inputs, unbounded outputs and required `fails app.ErrorChoice` (or public standard error choice). The choice contains an explicit error variant and belongs to the same-domain public App contract. Port/Adapter may reference the App types used by this contract, never call App. Each production Adapter completely implements one Port identity; a test file can fake multiple Ports. Input names/types and ordered output names/types agree exactly. Every implementation failure set is a subset of the declaration.
|
||||
- An external Adapter is `external <exec|pip|npm|go> "relative/entry" { ... }` in an Adapter-role file. Body clauses are newline-separated `lib "name@version"` and `allow <capability>`; `lib` derives its ecosystem from the declaration, while exec uses explicit ecosystem-prefixed Lib specs. It may coexist with one `setting` record, but not with Dever functions. The compiler synthesizes every operation of the domain's single Port identity; App code still calls the Port and never receives Worker, process or JSON values. `ExternalWorkerContract::sdk_manifest()` exposes the same checked setting/input/output/error wire DAG and fixed identity for SDK generation, never a separately maintained type registry.
|
||||
- App parameter, output and inferred failure types are public contract. Reject an App capability that leaks a private Domain/Adapter type; define externally visible business error choices in App and let same-domain private roles refer to them through `app.<Type>`.
|
||||
- Fields: `private name: Type` for records and Models; ordinary record privacy is package-owned, Model privacy belongs only to the owning domain App. `name: Int >= low and <= high` (also Decimal) applies to record/payload/output/handler fields.
|
||||
- Choice: `error Failed(message: Text)`; markers are explicit, never inferred from variant names.
|
||||
- `fail(Error.Variant(...))` terminates a clause; ordinary calls propagate inferred errors. `result(named_call(...))` requires a choice with one success variant matching the call outputs and an exact recoverable error set.
|
||||
- Functions: `name(inputs) (outputs) pure recover("reason")`; modifiers optional, identical across all clauses of one signature. Source has no `async` modifier or `await(...)` form.
|
||||
- Transaction functions use `transaction name(inputs) (outputs)` and infer a single physical database effect through their complete call graph; source never receives a transaction handle. Sequential `blocking` is allowed only through its existing synchronous/no-database/no-concurrency contract; detached, parallel, stream and channel work remain forbidden.
|
||||
- Model files synthesize opaque `model.id`, generated `id/created_at`, a record Page type and static short CRUD symbols. Anonymous Model blocks are contextual syntax, not general Map values.
|
||||
- `Program::api_snapshot() -> String`, `check_api(&str) -> Result<(), String>`, `application_warnings(entry) -> Result<Vec<Diagnostic>, String>`.
|
||||
- Application tests use `test/<component>/<domain>/<topic>.dever[.md]`; the filename selects the one ordinary zero-input, zero-output `<topic>() ()` entry. Test helpers/types remain private to that file. Tests call same-domain App/Domain and cross-domain App only; they cannot call Model operations, Port, Adapter, API or another test, and production cannot call tests.
|
||||
- Test assertions are compiler-owned `assert(Bool)` and `assert_eq(actual, expected)`. Equality requires the same comparable type; only `null`/empty expressions that already require context receive the left type, so numeric promotion does not make unequal source types valid. Business failures continue through `result(call)` and explicit recovery.
|
||||
- CLI: `check <root>`, `test <root>`, `run <root> [--tenant <id>] [-- <cmd> <json>]`, `build <root> --output <new-file>`, `tenant migrate <root> <id>`, `tenant owner <root> <id> <site> <user-id>` and `tenant component enable|disable <root> <id> <component>`. Run/build collect checked API/CMD/Job declarations into one generated entry and reject application `main.dever`. Test checks `module/ + test/` before running stable-sorted cases; check/run/build load only module/. Deployment and database settings come only from `config/setting.json`.
|
||||
- Module API files contain declarations such as `get profile = app.profile`, `post publish = app.publish`, `cmd bootstrap = app.bootstrap` and `rest model`; they do not contain handler function bodies. HTTP routes inherit the App signature and return the fixed envelope. `public` is valid only on one HTTP declaration. Recursive `api/<site-path>/<topic>.dever` supplies configured site ownership without source `site` statements.
|
||||
- Authentication providers, sites, external HTTP origins, Cookie names/lifetimes and tenant routing come only from `config/setting.json`. Verify is one read-only App function from `Claims` to an App-owned record with exactly `id: Text`, `user_id: ModelId?`, and `tenant_id: ModelId?`; it cannot return permissions. A protected site's generated entry verifies its configured provider, enters trusted tenant scope, checks every reachable tenant component and authorizes the exact permission before reading business input or dispatching App. A `public` declaration ignores an ambient browser Cookie, but an explicitly supplied invalid `Authorization` credential remains an authentication error.
|
||||
|
||||
- Trusted `auth.user_id()` / `tenant_id()` keep their default `Int?` result. An explicit nullable ModelId context selects the same identity intrinsic with that nominal type; absence remains nullable and arbitrary Int casts remain forbidden. Configured validation walks every protected API's reachable functions and each generated REST field binding, matching the selected ID against its actual verify identity field; `owns_user(ModelId)` follows the same user-ID rule. Source-only checking defers provider matching, never configured builds. Native wraps the existing nullable i64; LLVM retains its nullable ModelId ABI. Public/CMD/Job/Test capability rules still apply. Ordinary tests must reject reachable identity effects instead of synthesizing a trusted user.
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
The current LLVM kernel accepts ordinary checked functions with supported value types, including Decimal, Text/Id, Bytes standard operations, List/Map and nested Record/Choice/Nullable, File/Stream, Task/Group/Channel, AsyncStream, TCP Socket/Listener and HTTP/TLS/WebSocket resources. Time/calendar, process arguments, stdout/logging, Uuid and Secret/crypto use the same typed runtime bridge. List/Bytes each/filter/find/reduce/reduce_until/sum and Stream/AsyncStream traversal use static typed handlers, including the existing checked suspending-handler contracts, never a generic Value interpreter. `dever_entry(ptr out, ptr fault) -> i32` initializes both slots, returns 0 on success and 1 on checked runtime or business failure; outputs are read only after success. Fault is `{i32 code, i32 origin, i32 depth, [capacity x i32] caller_locations, {ptr, i64} message, i32 business_type, i32 business_variant, [words x i64] business_payload}` with separate source-span metadata. Keep fields 0..4 stable; the aligned payload holds the complete concrete Choice and is sized for the largest reachable error type. Named fault codes (overflow=1, division-by-zero=2, RuntimeAbi=3, Business=4) are not the runtime C ABI status. RuntimeAbi retains original owned diagnostic bytes; Business retains nominal identity (`type_id+1`, 0 means absent), variant and typed payload. Callers release root outputs and both fault payloads through `dever_outputs_release` and `dever_fault_release`, including zero-initialized outputs after failure. Application entries and unsupported application intrinsics remain source-located rejections.
|
||||
|
||||
File create uses create_new and never truncates an existing path; clone/close preserve shared owner semantics. File chunks yield a shared synchronous Stream cursor, with one terminal Failed before End. Source traversal uses only its existing each/reduce/reduce_until contracts; stream_pull is internal ABI, not a new source pull/first(Stream). Preserve aliases and unvisited rows on early stop, and release resources on both typed business and runtime faults.
|
||||
|
||||
AsyncStream constructors use List/Bytes/Channel/ticks or the existing TCP producer; aliases share progress and closure. Register its consumption-close guard only after source, initial state and context expressions finish evaluating. Early stop, normal return, fault and cancellation close the shared producer; unlike synchronous Stream, another alias cannot continue. Release pending operations before closing the producer. Terminal ReadEvent/ConnectResult Failed remains one actual typed row before End. TCP operations reuse runtime net deadlines, direction locks and partial-write cancellation rather than blocking wrappers. Map async statuses explicitly before decoding standard resource choices.
|
||||
|
||||
HTTP values cross the ABI as fixed named fields plus opaque resource handles; clone/register every owned field before fallible header conversion. Static sync/suspending handlers and optional context share the existing coroutine descriptors; borrowed request/context inputs become owned before initial suspend. Handler faults retain typed ownership until terminal request-boundary rendering, log and produce 500/abort as the existing runtime requires, then permit later requests. Do not turn a handler fault into a server-wide fault or serialize intermediate values through JSON. Uuid is an actual UUID owner, not Text; Secret stays non-observable. Password operations preserve checked blocking boundaries. Reachable Log/HTTP-server roots flush after scope drain on success and failure; pure kernels do not initialize a logger.
|
||||
|
||||
Timeout consumes a Task and uses an independent presence flag with the full output row: successful null and zero-output success are not timeouts. Only an actual timeout invokes its statically specialized fallback after runtime cleanup; numeric, business and RuntimeAbi faults propagate with their original kind and spans. Race uses canonical output descriptors shared by same checked signatures, consumes tasks only after non-null operation creation and drains all losers on every completion path. ParallelEach uses the existing scoped concurrent, bounded blocking and async stream owners for each checked sequence/handler combination; a plain Group loop is not equivalent to capacity-before-pull and failure observation while a producer is idle.
|
||||
|
||||
Coroutines clone borrowed inputs before initial suspend; all live guards, operation owners, values, output slots and typed faults survive real Pending. Initial/pending destroy runs reverse cleanup once; normal completion runs cleanup then complete/final suspend, and final destroy frees only the frame. Generated wrappers reuse the existing single task Scope/supervisor and bounded blocking pool, never per-function block_on. Validate cross-thread Send before returning IR against checked inputs/outputs, inferred error-variant fields and all reachable suspending frames including expression temporaries. Task/Group affine locals are frame-safe, not source-transferable. Non-suspending blocking functions may create/drop a Stream locally without transferring it. Internal Unit/Outputs ABI rows derive transferability field-wise (Unit=true); never copy their source `Type::transferable=false` property or change the language property to fix ABI metadata.
|
||||
|
||||
LLVM `fail` transfers the concrete Choice into the business tail and disarms its temporary owner; normal propagation appends the call's source frame. `result(named_call)` shares ordinary invocation and uses only `specialize::failures` plus `capture::targets`, including handler specialization. Success packs zero/one/multiple ordered outputs into the checked success variant. A matching business failure transfers directly or wraps one error-only Choice according to that mapping, then clears the entire fault; explicitly raised outer variants must not be unwrapped. Numeric and RuntimeAbi faults remain uncaptured and propagate with their original origin and caller frames. Root fault release statically dispatches nominal error types through the existing typed drop owner, including nonzero variants. Do not add runtime generic Value, parser-based error matching or a parallel failure registry.
|
||||
|
||||
BytesToText/BytesFromInts/BytesSlice use the existing runtime handle ABI: status 0 transfers a success value; only status 1 creates the standard Failed(Text) from the original diagnostic Buffer, freeing that Buffer after Text construction; status 2 stays RuntimeAbi. Standard Bytes wrappers then use the same `fail`/capture channel, not a separate error-return shortcut. Never inspect a success output on a failing status.
|
||||
|
||||
LLVM ordinary function inputs are borrowed; clause bindings, locals and temporaries own typed copies. Track ownership with entry-initialized guards, recursively retain/drop only present Nullable payloads and active Choice variants, and release on normal and fault exits. Success transfers outputs and disarms the corresponding guards. Reuse native last-use/field-update analyses rather than duplicating them; a final field move clears that field while preserving its containing record's owner. An ABI `_take` consumes its receiver even on failure, so disarm before calling it. First/Get allocate output storage for the actual List element or Map value type, not just the inner type of the nullable result. The ABI's outer presence flag only determines whether this row may be read: an already-nullable row is transferred intact, not wrapped again or overwritten with Some.
|
||||
|
||||
Reuse clause interval normalization and facts/domain containment at every constrained construction, write, output and handler boundary. Concrete handlers may establish outputs by inference; forwarded signatures require input contravariance and output covariance. Unknown operations establish no new bounds.
|
||||
|
||||
Facts preserve value snapshots and invalidate the modified path. Use shared immutable field graphs and bounded proof work, not recursive copying of a type/value DAG. `contracts::MAX_PROOF_WORK` is 16,384; exceeding proof work reports C015 and never grants a guarantee. Before analysis, nominal type depths are computed iteratively and must not exceed the shared 128-level syntax depth limit. Failure-free nominal type graphs are memoized and skipped by failure expansion.
|
||||
|
||||
Failure origins survive aliases, aggregates and projections. An unknown List may be empty and Nullable may be null, so possible error payloads do not establish guaranteed failure. A short-circuited handler or empty traversal cannot consume a pre-existing obligation. Empty reduce returns the original state's obligation unchanged. Replacing a Map entry cannot retain the replaced failure's identity in the new Map. Partial selection is conservative about the unselected remainder.
|
||||
|
||||
A capture error variant may contain one error-only choice payload, e.g. `error Database(error: dever.database.Error)`. It wraps that choice's inferred failures without renaming them or parsing messages. Explicitly raised outer variants retain their nominal identity. Success shapes, complete error sets and non-overlapping wrappers are checked before emission; captured failures still carry the ordinary handling obligation. Native, LLVM and reference execution share the same capture mapping, including specialized handler error sets.
|
||||
|
||||
Effects and effective recovery reasons propagate through the existing call/handler substitution flow. An unused handler argument adds no effective recovery. Recovery is not an external effect and is allowed in pure functions, but its reason belongs in reachable public API summaries even when declared in a private helper. Keep private helper names and private record representation out of snapshots.
|
||||
|
||||
Port remains a Function ID with compiler-owned successors. Shared dependencies, effect worklists and specialization include every production implementation; failure inference seeds the declared error set, independent of selection. Resolve the complete signature type graph before type-shape access checks, including App DTOs nested in Port-local DTOs; unrelated App types remain inaccessible to Port/Adapter. Unmediated file/network I/O propagates through functions and static handlers and is cleared only at the Port boundary. App/Domain reject it; only the compiler-generated application root may start API/Job service loops. Other effects retain existing policy. Production summaries exclude Test bodies. Checking and suite emission reuse one case-specific closed graph with that case's fakes, including failure-subset checks for unused fake operations and declaration checks for uninstantiated handler helpers. Each case gets an independent native module and sync/async entry; one rustc invocation still produces one suite binary. Never union different cases' fake effects or run a synchronous case under another case's async runtime. Production settings and fallback implementations are absent from the reachable suite.
|
||||
|
||||
An Adapter-role external declaration synthesizes exactly one complete implementation of the domain's single Port identity. Its schema hash is derived deterministically from the optional setting wire schema and every operation's ordered input/output wire shape, including top-level field names and declared business error payload fields. The synthetic functions enter the existing dependency, effect, failure and specialization graphs; do not add a parallel external-call registry. Only fixed `network`, `file`, `process` and `gpu` capabilities are accepted, and they seed conservative effects. An external declaration may coexist only with one setting record and never with Dever implementation/helper functions. Test graphs replace production implementations with case-local fakes and therefore never start an external Worker. Ecological declarations do not weaken these checks or imply their target packager is implemented.
|
||||
|
||||
Database effects carry a typed `DatabaseOwner::Model` or `DatabaseOwner::Job`, resolved centrally by Program to connection plus global/tenant scope. A transaction must reach at least one database operation (including enqueue), may resolve to only one physical database and cannot mix global/tenant scopes. It cannot start detached, parallel, streaming or channel work; a validated sequential `BlockingCall` does not escape the transaction. Ordinary database helpers accept a generated hidden transaction context so nested transaction calls and enqueue reuse the outer physical transaction. Never synthesize a Model to represent a queue.
|
||||
|
||||
Job declares an explicit database and a private `job name(payload?) () retry(n) timeout(ms)` entry. One input must be a same-domain App record; no outputs, overloading, multiple clauses, pure/recover/fails or direct non-App calls. App enqueue uses a checked static same-domain target and lowers to a dedicated HIR node. Enqueue propagates only its queue connection/failures, not handler effects, failure or synchronous recursion. Native execution reachability adds handlers as independent roots; application test closures apply the case's Port fakes to those roots too. UTC cron checking and runtime scheduling share one bounded parser.
|
||||
|
||||
Every Job enqueue requires a compiler/runtime-established execution identity. Generated CMD, cron and Test roots establish `System`; a protected API establishes `User` only after provider verification, component checks and automatic authorization. Missing identity never falls back to `System`, and a `public` API call graph that can enqueue a Job is rejected. User enqueue persists a re-verifiable provider/site/subject/session/tenant reference plus the one exact permission key of its HTTP entry; it never persists credentials, roles or a permission collection. Every attempt invokes verify again, validates the restored tenant, confirms the key still belongs to the current compiled permission catalog, checks reachable component state and performs the current role query in that order before opening the business transaction. Chained enqueue inherits the restored identity and permission key.
|
||||
|
||||
Protected custom API permissions are `component.domain.site.action`; REST reserves `read/create/replace/delete`. The HTTP method is catalog metadata, not part of the key. Two declarations cannot own the same key even if their method or URL differs. `public` is the only anonymous form and emits no permission; its complete reachable graph cannot touch a tenant component. The service atomically synchronizes compiled permissions into the global control catalog and marks removed rows inactive. Site and tenant are hidden context for role management; source never writes `auth.require`, an `access` clause or a permission list in Identity.
|
||||
|
||||
Suspension is an internal monotone effect inferred from async runtime intrinsics, ordinary calls, bound static handlers and collection handlers. HIR records `suspends`; API snapshots do not expose it. Direct source calls preserve sequential completion, while `run` is the explicit concurrency boundary and `wait` consumes Task/Group values. A fully non-suspending entry emits no Tokio runtime.
|
||||
|
||||
Facts, effects and failures share `contracts/dependencies.rs` for function/static-handler dependencies and initial order. Effects/failures enqueue reverse callers only when a summary changes; conservative dependency cycles still converge without discarding obligations. Type comparison/move/transfer properties are computed once per nominal definition after type-cycle validation, avoiding repeated expansion of shared type graphs.
|
||||
|
||||
Domain dependency edges come only from resolved type, function and handler references. Physical role files in one domain share one dependency boundary; cross-domain cycles remain invalid and function recursion remains independently invalid. Source code does not duplicate edges in a header, and dependency inference does not install or download packages.
|
||||
|
||||
API snapshots sort semantic names and contracts, not source paths/IDs. A baseline is optional, explicit and create-new; checks never update it. Protect its edits outside the language. CLI suppresses bundled-source suggestions only; Program retains diagnostics and bundled hard errors remain visible. Entry-specific export advice must not infer unknown library consumers.
|
||||
|
||||
Test authority comes from compiler-assigned source origin, never a user-controlled path string. A checked `TestCase` holds the private function ID and database-effect metadata; native compilation accepts only that checked identity and does not relax normal entry visibility. Each case runs serially in its own native process and temporary project. Successful child output stays captured; failed stdout/stderr is reported in labelled sections, later tests continue, and any failed case makes the command fail. Tests cannot reach `dever.api.serve()`.
|
||||
|
||||
For `module/<component>/<domain>/api.dever`, the route is `/<component>/<domain>/<action>`; recursive `api/<scopes>/<file>.dever` inserts scopes and file before action. `get/post/put/delete` bind same-domain App functions and inherit their wire-safe input/output shapes; GET/DELETE read query, POST/PUT read a unique-key JSON object. `rest` generates Model CRUD and can use checked owner/create/replace/search field bindings. Each successful request receives JSON `{code:0,message:"ok",data:<value>}` with HTTP 200; standard API errors map to fixed 400/401/403/404/409/429 envelopes and unknown failures become a redacted 500. A top-level POST `Upload` switches the statically known route to bounded multipart parsing and affine ownership. No environment-variable config or implicit database fallback is introduced.
|
||||
|
||||
Response Header/Cookie mutations are staged in request-local pending metadata. Generated dispatch serializes the response value, commits the database transaction, and only then promotes pending metadata for the server response; serialization, business, commit or rollback failure discards it. Cookie issue/clear uses the configured name, lifetime and attributes. Credential-changing requests require Origin and Host to exactly match the site's configured external HTTP/HTTPS scheme, host and effective port; a missing or cross-origin value is rejected without trusting forwarding headers. A write API call graph containing password verification or hashing does not receive an entry-wide transaction. Every reachable write must instead cross an explicit short `transaction` function, and password crypto is forbidden inside that transaction. Other write entries retain their generated transaction.
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
`wire.rs` is the sole Type-to-wire eligibility owner; API checking and `native/wire.rs` consume its interned DAG. Policy (Output, ApiInput, SettingInput, Job) participates in the versioned fingerprint and specialization cache. Records reject private or bounded fields until concrete bound validation exists; Choice, Map, Related and resources are rejected. ModelId encodes as i64 while retaining nominal Model name in the fingerprint, never unstable compiler IDs. Secret is decode-only for ApiInput/SettingInput. `Program::wire_schema` exposes checked nominal schemas; no consumer may add a parallel Type allow-list. Concrete helpers retain field order and map missing nullable fields to None, while unknown/missing required fields fail. Persisted Model fingerprints never include wire schemas.
|
||||
|
||||
Sensitivity belongs to the cached type graph in `types.rs`: Secret and private Model data propagate through nominal records/choices and every container, including Related association cycles via a monotone reverse-edge worklist. Ordinary record private fields retain their existing package access contract. App inputs reject private Models; App/entry outputs and error payloads reject both sensitive categories. API JSON and assertion rendering reuse the same observable property. Generate Render only for observable nominal types; Debug for runtime Secret is always redacted. Test-only `secret(Text)` is resolved from compiler-assigned source origin and reserved against user declarations.
|
||||
|
||||
| Condition | Result |
|
||||
| --- | --- |
|
||||
| Reachable LLVM kernel type/effect/application root is not implemented | Source-located Err before IR is returned; no Rust fallback or successful stub |
|
||||
| Dynamic LLVM Int add/sub/mul/neg overflow or div/rem invalid operation | Entry status 1 with original source-span index and every caller frame; never unchecked poison/trap arithmetic |
|
||||
| Runtime value error during a managed LLVM call other than a recoverable Bytes operation | RuntimeAbi fault with the original diagnostic Buffer and source/caller spans; clean all guards and release message through the caller boundary |
|
||||
| Explicit business fail or its ordinary-call propagation | Business fault with concrete nominal type/variant/payload and original source/caller spans; release through the caller boundary unless captured |
|
||||
| Result matches a declared business failure | Transfer direct/wrapped typed payload and clear fault before any following call; do not swallow numeric/RuntimeAbi faults |
|
||||
| Bytes conversion/range value error; invalid ABI | Value error becomes the standard Failed(Text); invalid ABI remains an uncaptured RuntimeAbi fault |
|
||||
| File I/O value error; negative time/task sleep | Standard original Failed(Text); only the operation's recoverable status is captured |
|
||||
| Pending, optional receive End, or Channel element null | Pending initializes no row/fault; receive reads a row only when present; preserve the element's full Nullable state |
|
||||
| Async initial/pending/final destroy | Drop live guards/operation owners once at initial/pending; final destroy frees frame only after normal cleanup |
|
||||
| Non-Send task/blocking frame or inferred failure payload | Source-located lowering Err before IR; no unsafe Send shortcut or erased fault |
|
||||
| First/Get has no row, or finds a row whose value is null | No row: return zero Nullable without reading untouched output; nullable row: preserve its own null flag and full concrete layout |
|
||||
| Invalid role layout, generic bucket, file/directory conflict or one-file role directory | C003 |
|
||||
| `package`/`exposes`/`public` application syntax or forbidden role call | P001 / C006 |
|
||||
| App signature or propagated failure exposes a private type | C006 |
|
||||
| Duplicate API method/path | C002 |
|
||||
| API non-prefixed/unsupported input or output | C005 |
|
||||
| API missing/duplicate/extra or malformed request inputs | JSON 400, code=400 |
|
||||
| Unknown route / unsupported method on known route / handler failure | JSON 404 / 405 (Allow header) / 500 |
|
||||
| Unproved record/payload/write/output/handler bound | C011 |
|
||||
| Discarded, overwritten or merely logged failure | C012 |
|
||||
| Capture error set mismatch or overlapping nested error wrappers | C012 |
|
||||
| Effect in pure function or inconsistent clause contract | C013 |
|
||||
| Non-transferable value live across an inferred suspension point | C005 |
|
||||
| Blocking or parallel effect executed inline on a suspending path, including a concretely bound handler | C005 |
|
||||
| Invalid Model or transaction database contract | C014 |
|
||||
| Contract analysis exceeds bounded work | C015, no executable Program |
|
||||
| Proven redundant check / duplicate private implementation / repeated pure call / trivial private forwarding | W002 / W003 / W004 / W005 |
|
||||
| Unused or overwritten write | Existing W001, no duplicate W006 |
|
||||
| Unneeded exposure for a selected application | W007, not a hard error |
|
||||
| API drift or unreadable baseline | CLI failure before native compilation |
|
||||
| Existing snapshot output | CLI failure, existing bytes preserved |
|
||||
| Invalid test path, missing/overloaded/non-ordinary entry or duplicate `.dever`/`.dever.md` identity | C002/C003/C005 before any test runs |
|
||||
| Test crosses a private role, calls Model/API/another test, or production calls a test | C006 before native compilation |
|
||||
| `assert` receives non-Bool or `assert_eq` receives unequal/non-comparable types | C005 at the assertion |
|
||||
| Test assertion, uncaptured business error or runtime fault | Current case fails; later cases run; final status is nonzero |
|
||||
| `external` outside Adapter, mixed with a Dever function, or unable to select exactly one domain Port identity | P001/C005 before native generation |
|
||||
| External entry is absolute, escapes with `.`/`..`, contains empty/Windows-only segments, or names an unknown/duplicate capability | C002/C005 before native generation |
|
||||
| External input/output/error payload is not eligible for the shared wire policy | C005 before native generation |
|
||||
| `public` API can reach Job enqueue, or enqueue has no trusted execution identity | C006 / runtime rejection before persistence; never synthesize System |
|
||||
| `public` API reaches a tenant component | C014 before native generation |
|
||||
| `public` API reaches authorization administration, or GET reaches role mutation | C006 / C013 before native generation |
|
||||
| Duplicate `component.domain.site.action` across methods or paths | C002 before native generation |
|
||||
| Protected API has no current role grant | JSON 403 before business input/App dispatch |
|
||||
| User Job provider/site/reference, compiled permission, component or current role fails | Block with a redacted identity/component category; do not enter the business transaction |
|
||||
| Credential-changing request has missing or non-matching Origin | JSON authentication error; no Cookie/Header is committed |
|
||||
| Response serialization, business transaction or commit fails after metadata was staged | Discard staged response metadata |
|
||||
| Password crypto API reaches a write outside an explicit transaction, or a transaction reaches password crypto | C014 before native generation |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
LLVM base: lower checked Int 40+2, emit and link real objects, execute on the native host. Good: a nested nominal value that exists only inside a comparison still declares all required LLVM types; a short-circuited failing RHS never runs; managed aliases survive COW updates and fault cleanup. First/Get/Find on List<Text?> or Map<Text,Text?> preserve null and present values without double wrapping. List/Map Float values containing NaN remain non-reflexive under equality. Resource/async good: a second create_new fails without overwriting distinct content; a large owned multi-output survives Pending; stopping pending tasks drains descendants and releases frames; internal Unit and Outputs rows transfer through typed sync Task/blocking descriptors. Bad: consider IR substring or hand-written coroutine smoke sufficient checked-source evidence, clone parameters only after initial resume, omit temporary nominal/Send types, shrink a nullable row output slot or treat foreign-target object generation as successful execution.
|
||||
|
||||
Base: direct `Port { value = 8080 }` satisfies `Int >= 1 and <= 65535`; a project without `test/` reports zero tests. Good: `test/user/account/register.dever` calls `app.register`, asserts returned values and uses `result(call)` for expected business failures. A protected `post publish = app.publish` automatically owns `news.article.admin.publish`; an enqueued User Job stores that one key and rechecks the current catalog and role on every attempt. A login reads credentials and verifies the password outside a transaction, then calls one short transaction that rechecks mutable account/tenant/membership state and creates the session. An external Adapter declares one owned entry and the minimum fixed capability set while its tests replace the production Worker with case-local Port fakes. Bad: call `model.create` or an API handler from a test, share test helpers across files, expose test entries, configure databases through an environment variable, enqueue from `public`, put permissions in Identity, write `auth.require`, copy a token/role/permission snapshot into Job storage, let password work overlap a database transaction, guess that every public POST NotFound means Unauthorized, write response Cookie/Header before commit, or let an external declaration coexist with helper functions.
|
||||
|
||||
## 6. Required Tests
|
||||
|
||||
LLVM resource/async changes select `llvm_resource_native`, `llvm_async_native` and `llvm_async_source` plus existing managed/result/kernel regressions. Use explicitly built runtime-only archives, actual checked source -> object -> archive execution and an atomic allocator ledger (one warm-up + 64 repeated calls). Cover create_new's second creation failing within the same run and verify distinct existing content; aliases, EOF/terminal error, early shared cursor stop, each business failure and runtime unwind. Async cases assert real Pending, initial/final destroy, large typed Text/List/Map/record and multi-output transfer, async static handlers, sync Task and Group, bounded backpressure, nullable Channel row/End, wait/stop/nested descendants, typed business failure/capture/refail, division code2 with exact origin and runtime ABI code3 with original bytes. Six-target object generation is not six-OS execution; bounded allocation balance is not RSS/performance acceptance.
|
||||
|
||||
AsyncStream/TCP/composition changes additionally select `llvm_stream_network_source`: nullable rows, Channel backpressure, ticks and alias closure, async/sync handlers and all ParallelEach dispatch categories, owned multi-output/nullable/Unit timeout and race, original business/numeric/runtime faults, TCP short reads/EOF/shared close, exact creation errors and one terminal failure. An owned loopback cancellation fixture observes peer EOF after timeout returns to prove descendant Socket release. Keep the hand-written C smoke's invalid status assertion separate: async invalid input is 4, not typed-fault 2. Repeat actual native allocations through the same atomic ledger; do not claim performance or six-platform execution.
|
||||
|
||||
LLVM business/Bytes changes select `llvm_lowering` plus opt-in `llvm_result_native`, reusing the runtime-linked runner in `test/dever-tests/tests/support/llvm_managed.rs`. Assert independent expected success/failure branches (especially zero-output capture), ordered multi-output payloads, direct/wrapped/explicit-outer identity, specialized each/reduce failures, capture-success-recapture, nested owned COW, root ownership transfer and nonzero-variant root release. Root faults must retain exact original source/caller spans. Dynamic division and duplicate Map-key diagnostics must remain uncaptured. Bytes cases cover Unicode, empty buffers, aliases, invalid UTF-8, -1/256 byte values and invalid/reversed ranges with original messages. Repeat native ownership checks through the same bounded archive/allocator fixture as managed tests; no recursive archive build or default database/service checks.
|
||||
|
||||
LLVM changes select root `llvm_lowering` and embedded-bridge `llvm_native`: real checked sources to IR/object/LLD/native execution, static handlers and aligned nested Record/Choice/Nullable, multiple outputs, short-circuit faulting RHS, checked Int extrema, IEEE Float and original fault/caller spans. Managed changes also select `llvm_managed_native` with embedded/runtime-abi: Unicode and Decimal, collection COW/order/key types, nullable First/Get/Find, Unit handlers, owned reduce state/early stop, nested field replacement, original runtime diagnostic and failure/root-output cleanup. Its opt-in native runs link the explicitly prepared runtime-only archive, initialize outputs through the real entry and check allocation balance across 64 repetitions after warmup; this is ownership regression evidence, not throughput/RSS/64-or-128-MiB acceptance. Simple kernels link on all six targets; complex/managed aggregates emit on all six but only native host execution is evidence of execution. Windows CRT/stack-probe dependencies require a real target runtime pack, not fixture symbols. Runtime ABI changes select root `native_runtime_abi` plus the explicitly prepared `native_runtime_link` C/LLVM acceptance fixture. Prepare only an offline `runtime-abi` staticlib; author C/Rust tools are not product runtime dependencies or formal release assets. Tests remain under root `test/` and run no database or service by default.
|
||||
|
||||
Select `structured_concurrency`, `async_native`, `contract_syntax`, `contract_facts`, `contract_semantics`, `contract_api`, `contract_redundancy`, `contract_execution`, `error_effects` according to changes. Assert suspension propagation through ordinary calls and concrete handler substitution, non-transferable liveness, worker boundaries, API omission, alias mutation, handler variance, short-circuit/empty traversal, overwrite/projection obligations, bounded shared graphs and unchanged native results. Error capture tests cover native/reference wrapping, explicit outer identity, ambiguity and discarded captures. Parser tests must reject the removed package-header dependency clause; formatter round trips must prove it cannot be emitted. Do not run the entire suite or service tests by default.
|
||||
|
||||
For this source/API contract select `source_architecture`, `source_visibility`, `contract_execution`, `markdown_source`, `api_declarations`, `rest_model`, `native_entry`, `api_routes`, `auth_runtime`, `authorization_runtime`, `secure_contracts`, `tenant_database`, `durable_jobs` and `upload_boundary` as needed. Assert App aliases, topic hiding, role call direction, Model ownership, layout rejection, generated roots, strict inputs, JSON envelopes, Markdown parity, site/auth ownership, exact permission keys, duplicate-key rejection, public-to-tenant rejection, REST row scope and Upload cleanup. Authentication coverage includes public Cookie isolation, explicit invalid Authorization, exact Origin, staged metadata ordering, generated System roots, site-scoped multi-role union, Owner isolation and User Job current-role checks. Actual loopback HTTP verification requires an explicitly scoped service check.
|
||||
|
||||
Application-test changes select `application_testing`, `formatter` and `cms_project`. Assert stable discovery, plain/Markdown duplicates, filename entry validation, both visibility directions, assertion typing/rendered source locations, existing result-choice recovery, failure continuation/output capture, zero-test success, per-case SQLite Seed isolation, ignored deployment settings, production commands ignoring test sources and module/test formatting preflight.
|
||||
|
||||
External Adapter changes select `port_adapter`, `external_component` and `application_testing`. Assert Adapter-only contextual syntax and formatting, setting/top-level wire names in the schema hash, complete Port effect/failure propagation, reference rejection, case-local fakes, strict framed messages and request IDs, business-error decoding, bounded cancel/timeout/crash/restart/shutdown, queued-call admission closure, explicit child reap and native end-to-end dispatch.
|
||||
|
||||
`external command` reuses the external Port boundary but not the Worker protocol. Require exactly one operation with named `args: List<Text>` and optional second `stdin: Bytes?`, one named `output: dever.process.Output`, and `fails dever.process.Error`; reject setting/lib declarations. The checked `Command` wire policy alone admits Bytes as strict padded base64; API, Job and protocol Worker policies retain their previous allowed types. HIR Worker contract/entry queries exclude commands, while `external_command_contracts()` shares their contract construction. Both typed backends use the same external async ABI and Bytes wire bridge. Test plain/Markdown formatting, wrong signatures, fake isolation and six-target object emission separately from actual runtime execution.
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
LLVM application wrong: emit an unbound auth descriptor, accept a nonpublic route without its exact permission, clone a borrowed handle but return the original SSA value, or give DELETE's encoded `null` two owners. Correct: validate auth metadata at source and again at the private ABI; return the actual guard-held clone before releasing its source; register one REST response owner through encoding/commit and transfer it once. Channel/Task/Group wrapper clones can change address even though Text Arc retain does not.
|
||||
|
||||
LLVM request scopes must receive one boxed compiled-handler Future. Inline embedding the same large Future in every auth/tenant/job task-local branch amplifies debug poll stacks and caused a real nonpublic HTTP SIGSEGV. Keep one request-level indirection with the original scope/cancellation ownership; do not enlarge thread stacks, relax the allocation oracle or box every database operation. Permanent probes are `llvm_api_source` (private REST including DELETE, concurrent identities, rollback and 64 repeated roots) and `llvm_async_source::cloned_channel_fields_survive_their_source_scope`.
|
||||
|
||||
LLVM wrong: collect types only from signatures/locals or assume every object needs no platform runtime. Correct: visit all reachable nested expressions, verify actual typed IR and native behavior, and distinguish cross-target object/link evidence from formal target runtime and OS execution acceptance.
|
||||
|
||||
Managed LLVM wrong: bit-copy an owned handle, overwrite an owned field without dropping it, disarm a whole record when moving one field, or use the nullable result payload as First/Get's physical row slot. Correct: use typed lifecycle callbacks/guards, reuse final-read analyses, transfer only the selected owner, and allocate the full concrete row before interpreting outer presence. Preserve internal null rather than constructing Some(null).
|
||||
|
||||
LLVM Result wrong: stringify business payloads, remap errors by variant name/message, catch numeric faults, leave a captured fault live, or accept either success or failure in every test expectation. Correct: share checked capture targets, preserve typed nominal payload and exact source frames, transfer then clear business ownership, propagate non-business faults, and assert the expected branch for each input.
|
||||
|
||||
LLVM async wrong: use source Unit/Outputs transferability for internal ABI rows, clone arguments only on first resume, mark Send from parameters alone, drop PreparedSync input inside invoke, or use a RuntimeAbi-string oracle for division. Correct: field-wise ABI metadata, initial-frame ownership, transitive suspend-frame checks including temporaries, runtime-owned synchronous input cleanup, and exact fault-kind/source oracles. Resource tests must make erroneous overwrite observable with different contents and a mutually exclusive result.
|
||||
|
||||
LLVM protocol wrong: round-trip source records through JSON, render a sensitive failure variant, make one failed request stop the server, or call all six targets runtime-tested after generating objects. Correct: fixed field ABI, typed resource/handler ownership, checked observable terminal errors, original request-boundary behavior, independent wire peers and explicit host-execution evidence.
|
||||
|
||||
Wrong: preserve application package/import/main syntax, accept arbitrary run/build entries, call private roles across domains, make topic filenames part of App calls, turn API declarations into handler functions, trust client tenant/user fields, infer System from an absent identity, put permissions in verify Identity, write manual permission strings, persist a credential/role/permission snapshot, publish pending response metadata, or count a possibly-empty List as proof of returned failure.
|
||||
|
||||
Correct: derive one source-role identity from each path, publish only logical App capabilities, keep API declarations and private roles narrow, generate application roots and exact permissions from checked API/CMD/Job contracts, establish System only at compiler-owned roots, and re-verify persisted User references plus the one entry permission on every Job attempt. Preserve conservative HIR facts and require explicit recovery where the program intentionally degrades. Derive Test identity from a compiler-owned source root, retain private checked entries and reuse the normal HIR/native/error pipeline; expose response metadata only after serialization and transaction commit.
|
||||
|
||||
External wrong: expose JSON/process handles to App, hand-write a second operation registry, omit setting or top-level wire names from schema identity, or start the production Worker from a Test. External correct: synthesize the complete implementation from one checked Port, reuse shared wire/effect/failure/specialization owners, and bind tests only to case-local Dever fakes.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Database Guidelines
|
||||
|
||||
## 1. Scope
|
||||
|
||||
Dever owns a static ORM. Application code declares persistence in `module/<component>/<domain>/model.dever` or, for multiple cohesive models, `model/<topic>.dever`; Markdown uses the same identity and schema rules. It does not add CRUD App/API wrappers or pass database/transaction handles. SQLite and PostgreSQL consume the same checked `ModelSchema`, `QueryPlan`, bind values, row shapes and error propagation.
|
||||
|
||||
## 2. Model Contract
|
||||
|
||||
- A role-file Model has one automatically visible record matching the domain in UpperCamelCase; a topic Model record matches its topic. Source does not write `public`.
|
||||
- `id` and `created_at` are synthesized, generated and cannot be assigned by source code.
|
||||
- Persistent fields use lower_snake_case and the checked types `Bool/Int/Float/Decimal/Text/Bytes/Uuid/DateTime/Date/Time/Duration/Json`, Model choices or `<model-package>.id`.
|
||||
- Persistent metadata includes `generated`, constant `default`, `index`, `unique` and `from <old_field>`. REST-only `owner`, `create = expr`, `replace = expr` and `search = expr` are checked API contracts and do not alter ordinary ORM semantics or storage fingerprints.
|
||||
- `private` is language visibility metadata: only the owning domain App accesses that field, including generated create/update blocks. It does not enter ModelSchema, SQL column order or migration fingerprints. Private Model snapshots and containers cannot cross App contracts; expose a View. Secret is never a persistent type; password hashes use private Text with enough room for PHC encoding.
|
||||
- Default and Seed literals share storage validation: integer/date values fit i64 without a fractional part, Float is finite, Decimal satisfies exact precision/scale and Text lengths count Unicode scalars. Invalid values fail checking rather than Rust emission or database startup.
|
||||
- Composite `index(...)`, `unique(...)`, reverse `relation`, one `seed` block and stable named `migrate` blocks live in the Model file.
|
||||
- Model IDs are opaque nominal values. A foreign Model ID is not interchangeable with `Int` or another Model ID.
|
||||
|
||||
## 3. Runtime And Query Contract
|
||||
|
||||
Use compiler-synthesized short Model functions: `create/create_many/get/first/list/cursor/count/exists/stream/update/delete/upsert`. Only the owning domain App may call them, as `model.<operation>` or `model.<topic>.<operation>`. Main, API and other domains call the owning App instead. Anonymous create/update/query blocks are contextual syntax and never become runtime maps or reusable dynamic query objects.
|
||||
|
||||
`where` builds a static condition tree; values are always bind parameters. Equality on nullable values is null-safe (`IS`/`IS NOT` on SQLite, `IS [NOT] DISTINCT FROM` on PostgreSQL). `order` is static: an omitted order defaults to `id DESC`, while an explicit order receives a stable `id` tie-breaker when needed. `list` defaults to page 1 and size 20, and runtime rejects a size above `database.<name>.max_page_size`. Do not add an Active Record type, chainable query object, reflection decoder or duplicated backend CRUD implementation.
|
||||
|
||||
`cursor` uses the same ordered Model fields as a static composite keyset. Its nullable `next` value is the last returned Model snapshot and is passed back as nullable `after`; the compiler binds the ordered field values in declaration order and rejects nullable cursor-order fields. SQLite uses `IS` and PostgreSQL uses `IS NOT DISTINCT FROM` for equal key prefixes. The nullable guard must cast the first PostgreSQL placeholder to the ordered field's SQL type before `IS NULL`; an untyped `$n IS NULL` leaves PostgreSQL unable to infer the parameter type. `stream` remains the existing `AsyncStream<Model>` source surface and uses a bounded runtime producer. Its database worker is registered in the current structured task scope; full consumption, `close`, drop, cancellation and decoder/driver failure all release or discard the owned connection according to its known state.
|
||||
|
||||
Connection selection order is explicit `database <name>`, root package name, then required `database.default`. `config/setting.json` is strict and external to the binary. SQLite paths remain below the executable directory; invalid or missing settings fail startup.
|
||||
|
||||
Repository PostgreSQL acceptance tests and performance tools use the repository-root `config/setting.json` entry `database.postgres_test`. They do not accept connection URLs or TLS modes from environment variables or command-line flags. The local file is ignored by Git; tests stage only the selected connection as `database.default` beside their owned executable.
|
||||
|
||||
`test/dever-tests/tests/support/postgres.rs` owns both schema-level and physical-tenant-database fixtures. The latter requires an explicitly isolated test connection with `{case}` in the database name and permission to create/drop test databases. It creates one random control database and a fresh tenant prefix, rejects a pre-existing namespace, and only drops that exact control name plus the generated prefix with canonical positive tenant IDs. Cleanup failures fail acceptance, including when the test itself panics. No configured URL means no connection; these real-service tests remain ignored by default.
|
||||
|
||||
`postgres_api` runs stock CMS from a test-owned project copy and validates Owner HTTP requests plus two physical tenant databases. Its explicit test-only member fixture also exercises real non-Owner sessions, two admin roles, a same-named front role and immediate grant/revoke effects; never change reserved Owner mutability or production CMS to manufacture this evidence. Permission catalogs are site-filtered: look up each site's keys through that site's authenticated API. GET/DELETE arguments use query, not JSON bodies. Its `cms.py postgres-case` helper stages its own temporary copy: private ports, secrets, config changes and compiled artifacts must never modify the supplied source project, and persistent reports must omit credentials. A standalone Owner-only run without `--rbac-fixture` still does not prove non-Owner authorization.
|
||||
|
||||
The PostgreSQL driver receives named `ConnectionOptions`, shared by initial and tenant connections. These are internal constructor arguments, not an additional configuration layer or environment-variable path.
|
||||
|
||||
Application tests never read or copy the project's deployment database settings. A test with reachable Model effects receives a runner-generated `config/setting.json` in its per-case temporary project, containing SQLite `database.default` plus SQLite entries for every explicit Model connection. It uses the normal settings validation, complete schema initialization, migrations, Seed, transactions and shutdown. A suite containing only tests without Model effects uses the base runtime. A mixed suite links the SQLite-capable runtime once, but a selected non-database case receives no generated setting and does not load settings or initialize Models. Neither path accepts database environment variables, PostgreSQL URLs or writes project `data/`.
|
||||
|
||||
Global Models resolve their connection once during startup into a `OnceLock<Database>`. When top-level `tenant.database` is configured, ordinary Models resolve through the hidden trusted tenant scope to a bounded, idle-evicted physical pool; there is no mode switch and they never fall back to the global connection. SQLite and PostgreSQL are independent optional runtime features; native build profiles contain only configured drivers.
|
||||
|
||||
`global type` marks platform data. With top-level tenant settings, every other persistent Model and its Job queue is tenant scoped. SQLite derives a private database file below `tenant_directory`; PostgreSQL derives a database name from `tenant_database_prefix` and the positive internal tenant id. Only explicit `dever tenant migrate <project-root> <id>` creates/migrates storage and records matching local/control readiness markers. For PostgreSQL that command connects through the configured control URL, creates the derived database when absent, treats SQLSTATE `42P04` as the idempotent already-exists case, and then migrates it; ordinary requests/workers never create or fall back to another database. Requests, Jobs and tenant CMD execution require a ready marker and current schema fingerprint. Table/field modes are not accepted until their entire query, relation, unique, migration and native-SQL contracts exist.
|
||||
|
||||
`tenant` accepts only `database`, `max_pools` and `idle_timeout_ms`; `database` selects the control connection. The control database owns `_dever_tenant_storage`, `_dever_tenant_component` and the global `_dever_permission` catalog. Tenant components are the sorted component names owning a tenant Model or tenant Job; platform/global-only and unknown names cannot be toggled. A mixed component is wholly tenant scoped: every Job it owns uses tenant storage even when that Job's own body touches only global data. After migration, `dever tenant component disable|enable <root> <id> <component>` writes only the control table. API, CMD and Job check all tenant components in their reachable execution graph before business work. A `public` API reaching any tenant component is rejected statically.
|
||||
|
||||
Authorization role storage is framework-private: `_dever_auth_role`, `_dever_auth_role_permission` and `_dever_auth_user_role`, versioned by `_dever_auth_version`. Platform identities use the control database; tenant identities use their tenant database. Role identity and both relation tables use the composite `(site, role_id)` boundary, so sites may reuse role names without collision and grants cannot cross sites. The v1 single-ID schema migrates transactionally by copying each relation's site from its role before replacing the three tables; an incomplete or unknown private schema fails startup. Disabled roles do not authorize, multiple roles form a union and an `all_permissions` role covers only its site. `_dever_owner:<site>` is reserved and can be provisioned only by `dever tenant owner` after tenant migration; ordinary save, grant, revoke and disable operations must reject it. Applications may expose thin App/API adapters over the core role operations but must not create duplicate business permission tables.
|
||||
|
||||
SQLite uses `deadpool-sqlite 0.13` with bundled SQLite, WAL, foreign keys, normal synchronous mode, a busy timeout and a bounded pool. PostgreSQL uses `deadpool-postgres 0.14.2` with `tokio-postgres 0.7.18`, cached static statements and rustls system roots; `min_connections` is prepared before Model initialization. Pool create/recycle and PostgreSQL socket connection have fixed runtime bounds. `wait_timeout_ms` bounds pool wait, while independent `io_timeout_ms` bounds SQL, transaction and migration I/O; both accept 1–300000 milliseconds.
|
||||
|
||||
PostgreSQL parameter types must match the runtime's binary bind codec. Dever `Int` binds as i64 and therefore requires a `BIGINT` context in typed native SQL; `Uuid` binds as the native PostgreSQL UUID type and generated placeholders use `$n::uuid`, never `$n::text::uuid`. Casts resolve PostgreSQL parameter types and must not change the runtime value representation.
|
||||
|
||||
Model operations, row-stream pulls and transaction begin/commit propagate `dever.database.Error` categories (Pool, PoolExhausted, Connection, Timeout, Cancelled, Database, Constraint, NotFound, InvalidData, Migration). Generated code converts runtime ErrorKind directly to source variants with message payloads; it never flattens recoverable errors into Fault or guesses their category from text. A service capture can use `type ReadResult { Found(value: User) error Failed(error: dever.database.Error) }`. The inferred set conservatively includes every database category. Startup failures remain process failures before application code begins.
|
||||
|
||||
## 4. Validation And Error Matrix
|
||||
|
||||
| Condition | Required result |
|
||||
| --- | --- |
|
||||
| Missing/invalid `setting.json`, unresolved database, conflicting tables or cross-database FK | Startup or compile failure before serving work |
|
||||
| Transaction has no database effect, spans physical connections/scopes, or starts escaping concurrent work | Compile failure; validated sequential `blocking` remains allowed |
|
||||
| Bound handler specializes to database work | Specialized effect set and hidden transaction context are retained |
|
||||
| `run`/thread/parallel/HTTP callback performs database work | Detached context; it cannot inherit an ambient transaction |
|
||||
| Operation, `BEGIN`, migration or transaction finish is cancelled before confirmed completion | Detach the pool object; never recycle an unknown connection state |
|
||||
| Actual catalog differs from recorded catalog | Migration failure before schema mutation |
|
||||
| Constraint tightening conflicts with stored rows | Database error and full transaction rollback |
|
||||
| Business body fails and rollback also fails | Preserve the business error and append rollback failure as a cause |
|
||||
| Missing get row or duplicate unique key | Recoverable NotFound / Constraint, including through task wait and transaction rollback |
|
||||
| PostgreSQL cursor nullable guard leaves `$n` untyped | Generate `CAST($n AS <field SQL type>) IS NULL` |
|
||||
| PostgreSQL reports admin/crash shutdown, cannot-connect-now or database-dropped SQLSTATE | Classify as a connection error so pool recovery semantics apply |
|
||||
| Typed native SQL uses an `Int` parameter where PostgreSQL infers int4 | Give the placeholder a `BIGINT` context; do not change the runtime codec |
|
||||
| Job claim/renew/complete waits for a database write lock | Read the injected Clock only after the lock is owned; never extend or complete an already expired lease with a cached timestamp |
|
||||
| SQLite `job.lease_ms <= Job timeout_ms` | Fail startup before listen/claim; a persisted incompatible timeout becomes `blocked/invalid_policy` before App dispatch |
|
||||
| An expired Job already consumed `max_attempts` | Move it to dead with bounded cleanup; it must not occupy an execution slot or hide a later eligible Job |
|
||||
| Concurrent enqueue repeats an active `(target, key)` | Return the same Job Id from the atomic upsert; never create a second pending/running row |
|
||||
| Job enqueue has no trusted execution identity | Reject before persistence; absence never means System |
|
||||
| User Job identity cannot be re-verified or changes tenant | Block with a redacted identity category before business dispatch |
|
||||
| User Job permission is absent from the current compiled catalog or current role grant | Block as `identity_rejected` before business dispatch |
|
||||
| A reachable tenant component is disabled | API 403, CMD failure or Job `blocked/component_disabled`; no business dispatch |
|
||||
| Component name is global-only or absent from the compiled tenant manifest | Reject the control operation; do not create an arbitrary row |
|
||||
| Legacy active Job lacks identity or crosses the v4 permission-contract upgrade | Block it as `missing_execution_identity` / `authorization_contract_changed`; never upgrade it to System |
|
||||
| PostgreSQL tenant database is absent during explicit tenant migration | Create the derived database through the configured control connection, then migrate; concurrent already-exists is success |
|
||||
|
||||
## 5. Required Patterns
|
||||
|
||||
Declare a business function with `transaction name(...) (...)`. The compiler propagates its database effects through ordinary calls and specialized handler bindings, then validates the concrete call graph. The generated hidden context owns one pool object at the outer boundary; nested transaction calls reuse it without savepoints.
|
||||
|
||||
Success commits. Error/fault rolls back before propagation. A rollback failure is appended as a cause and does not replace the original error. Commit and rollback consume the transaction, immediately returning a one-connection SQLite pool object after completion. SQLite write transactions use `BEGIN IMMEDIATE`. An API call graph containing password verification or hashing receives no entry-wide transaction; every write in that graph must be owned by an explicit short transaction reached after the expensive work, and that transaction must recheck the mutable state on which the write depends.
|
||||
|
||||
Use one checked query planner and shared value model for both database executors. Backend-specific code is limited to SQL dialect, bind/row codecs, pool operations and catalog migration. Keep business transactions in the owning domain App; Model CRUD remains compiler-generated.
|
||||
|
||||
Typed Model SQL may return its private result record or an explicit owning-domain App View (`app.ArticleView`, including App topic files), as one row, optional row or list. Reuse ordinary App alias resolution and existing scalar row decoding. App Views must contain only visible supported storage scalar fields; Secret, nested Model/record values and cross-domain or Domain-owned result records are rejected. All regular SQL result records reject field bounds: row decoding does not prove source range refinements. This supports bounded list projections without exporting private Model snapshots or duplicating View types; existing App/API sensitivity checks remain mandatory.
|
||||
|
||||
GET may reach typed SQL only when both dialects are proved to be a complete single-own-table field projection: SELECT fields FROM own_table, optional WHERE field = numbered-bind joined by AND, optional ORDER BY own fields, optional LIMIT numbered-bind. `check/sql_read.rs` owns this conservative proof. Unknown syntax, functions, CTEs, subqueries, other tables or extra statements retain the existing write effect; a SELECT prefix alone never establishes safety. Test both dialects independently and preserve POST SQL write behavior.
|
||||
|
||||
## 6. Schema, Migration, Seed And Tests
|
||||
|
||||
Every database has `_dever_history` rows for schema, named migrations and Seed revisions. Initialization takes a backend lock and updates schema, indexes, Seed and history in one transaction.
|
||||
|
||||
- A missing table is created directly at the final schema; old `from` and drop history are not replayed.
|
||||
- The normalized actual catalog must match recorded history before any update. SQLite fingerprints table DDL and managed index DDL. PostgreSQL fingerprints columns, types, nullability, identity/default metadata, constraints and managed indexes.
|
||||
- `from` maps one old field to one new field. Name similarity is never inferred.
|
||||
- Removed fields require a new named `migrate ... { drop field }`; applied migrations cannot be removed or modified.
|
||||
- Named migrations may contain repeated `before` / `after` blocks with explicit `sqlite`, `postgres` and literal `parameters`. Reuse checked placeholder scanning and existing bind codecs; accept one INSERT/UPDATE/DELETE only, reject reserved internal identifiers, DDL and transaction control. Unclosed quoted regions are diagnostics, including Unicode input. Context-free parameters support Bool/Int/Text/null and finite exponent-form Float; do not silently reinterpret Decimal as Float.
|
||||
- PostgreSQL dollar-quote recognition must respect identifier boundaries and validate both delimiter and body termination. Do not eagerly construct an EOF slice inside `then_some`; malformed SQL must produce C014, never panic. The same scanner serves typed SQL and migration SQL.
|
||||
- For an existing table, validate history/order/catalog first, run pending before blocks in source order, apply automatic DDL, then pending after blocks, Seed and history in the same Model transaction. Fresh databases skip old data transforms and record the final migrations as applied. SQL revisions cover phase, source order, both dialects and parameter types/values. Keep drop-only revision calculation stable. Applied migration order is immutable.
|
||||
- The migration boundary is one Model on one database. Data transforms may reference tables already present at that point; do not promise cross-Model atomicity or arbitrary table initialization order.
|
||||
- Text/Bytes constraint changes and nullable/unique/FK/choice tightening rely on database validation and roll back without truncating or inventing values.
|
||||
- Cross-family or lossy type changes are rejected. The currently automatic widening is `Int -> Float`; Text and Bytes bound changes stay within their storage family.
|
||||
- SQLite rebuilds the table transactionally when fields change and synchronizes managed indexes directly for index-only changes.
|
||||
- PostgreSQL uses transactional `ALTER TABLE`, named `_dever_` CHECK/FK constraints, advisory locks and managed index replacement. Fresh schemas create tables first, then add foreign keys in a second phase so source order and cyclic references do not break initialization.
|
||||
- Seed requires a covered unique key and uses parameterized `ON CONFLICT DO NOTHING`. It inserts missing initialization rows and never overwrites existing business data.
|
||||
|
||||
Date/time values use integer milliseconds: DateTime is Unix epoch milliseconds, Date is UTC midnight epoch milliseconds, Time is milliseconds since midnight in [0, 86400000), and Duration is signed milliseconds. External wire strings do not change the storage codec, logical Model type or schema fingerprint. JSON uses checked text. Decimal uses a fixed-width sortable text key on SQLite and native `NUMERIC(P,S)` on PostgreSQL. UUID uses a 16-byte SQLite BLOB and native PostgreSQL `UUID`; generated UUID fields receive UUIDv7 values from the runtime.
|
||||
|
||||
Long-term tests live under repository-root `test/`. Focused verification must cover database-effect propagation through helpers, nested transactions and handler specialization; rejection of concurrent transaction work; nullable equality with both present and absent values; typed cursor placeholder casts; Decimal/UUID round trips; cancellation isolation; migration catalog drift; settings and Model syntax. PostgreSQL catalog, migration, TLS, native codecs, disconnect classification and cancellation require an explicitly supplied isolated test database; never probe or mutate a local/development PostgreSQL service by assumption.
|
||||
|
||||
Durable Job changes select `durable_jobs`. Assert atomic business-write/enqueue commit and rollback, active-key concurrency, claim-time attempt, fresh fencing tokens, old-token CAS rejection, expired-lease recovery, bounded exhausted-row cleanup, blocked target/schema/payload/policy/identity/component, cursor rollback/restart and completed-occurrence dedupe. Cover System/User persistence, missing-identity rejection, v2 identity migration, v4 User authorization-contract blocking, physical/execution tenant dedupe isolation, chained inheritance and generated User re-verification/exact-permission checks. The SQLite regression must hold `BEGIN IMMEDIATE` across a lease renewal window and advance the injected Clock while claim/renew/complete waits, proving timestamps are sampled after lock ownership. Mode/signal tests use only test-owned processes, loopback listeners and temporary SQLite databases.
|
||||
|
||||
`model_syntax` covers invalid default/Seed values; `markdown_source` covers Model metadata and schema parity; `sqlite_orm` verifies captured error identity and zero persisted rows after rollback. `orm_data_migration` covers bound SQL, revisions, catalog checks, source order, once-only execution, fresh databases, rollback and native upgrades. `contract_execution` compares both CMS source declarations, Model/API snapshots and settings; `cms_project` checks both source formats and isolated packaged executables. Native row decoding must return its ORM Result inside its own closure; a bare generated block lets `?`/`return` escape into the outer application error type, breaking `first()`.
|
||||
|
||||
## Durable Job Store
|
||||
|
||||
`job/store.rs` owns versioned private `_dever_jobs`/cursor schema, separate from Model history. Use the existing Database/Executor/Transaction and pool. Private schema v5 stores physical `tenant_id`, execution `scope_tenant_id`, `execution_kind`, provider/site/subject/session/tenant references and one `auth_permission` key inherited from the protected HTTP entry. It never stores a token, Cookie, Header, role or permission collection. SQLite initialization/claims use the ordinary write transaction; PostgreSQL schema installation uses a transaction advisory lock and claims use bounded `FOR UPDATE SKIP LOCKED` rows.
|
||||
|
||||
An active partial unique index is authoritative for physical tenant + execution tenant + target + business key. Atomic upsert with RETURNING retains the active row lock until commit and avoids an insert/select completion race. Ambient enqueue shares the caller's physical transaction; standalone enqueue owns a short transaction. Every claim increments attempt and creates a fresh token. Renew and every completion require id/token/running/unexpired lease. Unknown target, wire fingerprint mismatch and decode failure become blocked with fixed redacted categories; retry and timeout use bounded backoff, then dead.
|
||||
|
||||
CMD, cron and compiler-owned Test entries explicitly establish System identity. Protected API and restored User Jobs establish User identity from verified Claims; nested enqueue inherits it. On every User attempt generated dispatch selects the configured provider/site verify App, reconstructs the trusted Claims reference, validates the returned subject and tenant, confirms the persisted key still belongs to the compiled site catalog, checks current tenant components and performs the latest role query, and only then begins the business transaction. Invalid identity is blocked; transient verify database failures retain normal retry behavior. A missing task-local identity is always an enqueue error.
|
||||
|
||||
Private migration v2/v3 preserves the existing fail-closed identity behavior. Migration v4 to v5 adds `auth_permission`, blocks every active User row as `authorization_contract_changed`, and drops the obsolete `auth_capabilities` column because a previous capability list cannot be guessed into one entry permission. Active System rows and terminal history remain unchanged. No migration guesses System or reconstructs authorization from payload data.
|
||||
|
||||
Scheduler identity/fingerprint/cursor are durable. Occurrence insert and cursor advance share a transaction; first enable records the current minute, each pass considers at most 256 elapsed minutes. Completed occurrences cannot be recreated. Job database effects participate in settings-aware transaction validation and per-case SQLite test isolation without manufacturing Model metadata or a second storage stack.
|
||||
|
||||
Read the injected Clock after obtaining the write lock: claims establish their deadline after row selection, and renew/completion lock the claim row before checking expiry. A timestamp captured before pool/lock wait cannot prove a lease is still valid. SQLite worker preflight requires `lease_ms > Job timeout_ms`, because a business write transaction blocks heartbeat writes even with a spare connection; PostgreSQL retains shorter renewable leases.
|
||||
|
||||
Validate persisted claim policy before dispatch too: older timeout policies incompatible with the current SQLite lease become blocked with `invalid_policy`, without running App. Clean exhausted rows in a separate bounded statement within the claim transaction; they must not occupy execution slots or make test drain mistake an eligible queue for an empty one.
|
||||
|
||||
## 7. Good / Base / Bad
|
||||
|
||||
Base: a System CMD enqueues a tenant Job and persists its controlled execution tenant. Good: protected `post publish` automatically authorizes and binds `news.article.admin.publish`; each attempt re-verifies the session/membership, compiled permission, component state and current role. Bad: derive actor/tenant from payload fields, treat a missing identity as System, persist JWT/Cookie/roles/current permissions, write manual authorization strings, allow two execution tenants to share a global-queue dedupe key, or create a tenant database during an ordinary request.
|
||||
|
||||
## 8. Wrong Versus Correct
|
||||
|
||||
- Wrong: hand-written CRUD Service/API wrappers, dynamic query maps, reflection decoders or database handles in source code. Correct: Model methods lower to a checked `QueryPlan` and thin backend executors.
|
||||
- Wrong: infer a renamed column from similarity or silently accept catalog drift. Correct: require `from <old_field>` and compare recorded actual catalog before migration.
|
||||
- Wrong: carry an ambient transaction into `run` or another concurrent boundary. Correct: concurrent callbacks use detached database context; only sequential calls inherit the transaction.
|
||||
- Wrong: release a connection whose async/blocking operation did not report completion. Correct: detach it so the pool replaces it.
|
||||
- Wrong: make PostgreSQL accept an i64/UUID bind by converting the runtime value to text. Correct: keep the native binary codec and emit the exact `BIGINT`/`UUID` placeholder context.
|
||||
- Wrong: claim cursor/stream, bulk create/upsert, relation loading or live PostgreSQL behavior from AOT compilation. Correct: claim each only after its dedicated runtime tests exist.
|
||||
- Wrong: assume an extra SQLite pool connection can renew a lease while a handler holds `BEGIN IMMEDIATE`, or capture `now` before waiting for that lock. Correct: require the SQLite lease to exceed the bounded handler timeout and read Clock after acquiring the write/claim lock.
|
||||
- Wrong: let exhausted rows consume `LIMIT` and return an empty claim batch while eligible work remains. Correct: clean exhausted rows with a separate bounded statement and query execution slots only from `attempt < max_attempts` rows.
|
||||
- Wrong: snapshot authorization into payload/queue columns or let legacy rows become System. Correct: persist only a trusted re-verifiable identity reference and the protected entry's exact permission key, then re-verify the current catalog, components and role before each attempt.
|
||||
- Wrong: let password hashing hold SQLite's writer reservation or switch the whole flow to a deferred snapshot that later upgrades to a write. Correct: perform password work outside a transaction, then enter an explicit short `BEGIN IMMEDIATE` transaction that rechecks mutable state and writes.
|
||||
- Wrong: silently connect to the control/global database when a tenant database is missing. Correct: create/migrate it only through explicit `tenant migrate`, require readiness/fingerprint markers, and fail ordinary work closed.
|
||||
|
||||
AOT compilation proves feature and generated-code compatibility, not live PostgreSQL semantics.
|
||||
|
||||
## 9. LLVM Model Application Boundary
|
||||
|
||||
### Scope / Trigger
|
||||
|
||||
Checked Model/SQL/transaction lowering serves LLVM CMD, API, Job and case-local Test roots. The API Tenant Session section below defines identity, authorization and tenant-pool ownership. Missing tenant identity/readiness fails closed; it never silently selects the platform database. External Worker and default CLI integration remain separate work.
|
||||
|
||||
### Signatures
|
||||
|
||||
- Core `llvm::emit_application(&Program, &SourceMap)` emits typed Model functions and the owned database application root. Pure SQL/DDL builders remain owned by `native/orm.rs` and shared by both backends.
|
||||
- Runtime `database::Session::load(profile, bindings, transactions)`, `prepare()`, `database(explicit, root)` and `close()` own only this invocation's Settings and database pools.
|
||||
- `task::run_entry_with_typed_cleanup(config, future, cleanup)` returns the original typed result after descendants drain and cleanup completes inside the same runtime.
|
||||
- Canonical `dever_rt_v1_db_*` declarations live only in `crates/dever-backend-bridge/include/runtime.h`; `runtime-sqlite` / `runtime-postgres` add actual drivers independently of `embedded`.
|
||||
|
||||
### Contracts
|
||||
|
||||
Deployment settings still come only from executable-local `config/setting.json`; no connection environment variable or PATH language runtime is introduced. Ordinary resolution is explicit connection → component root → required default. Persistence ABI accepts only fixed storage scalar rows; business Model records, nullable fields, Related and DbError are concrete typed owners, not dynamic business values. Related ToMany groups at most 998 parent keys per SQL batch and retains the existing per-parent row limit. Migrations group by resolved database, create all tables before foreign keys and preserve existing history/Seed validation.
|
||||
|
||||
Writing CMD order is input decode → begin → sequential App/handler calls → output encode → commit → stdout. Nested transaction shares the outer owner, without savepoints; Task and parallel boundaries detach. Failed or cancelled unconfirmed operations discard the transaction connection. Rollback must move the original fault, retain its type/source/payload and only append cleanup cause; commit failure must release the already-initialized output. Drain children, stop every pool, await pool drivers, then destroy the runtime. Never reuse the process-global Settings/pools for repeated embedded roots.
|
||||
|
||||
### Validation & Error Matrix
|
||||
|
||||
| Condition | Required result |
|
||||
|---|---|
|
||||
| Invalid/misaligned output or typed callback slot | ABI fault before I/O, receiver advancement or fault consumption |
|
||||
| Missing/invalid settings or missing tenant identity/readiness | Explicit root failure, no default connection fallback |
|
||||
| Database error | Exact one of the ten checked DbError variants with owned Text payload |
|
||||
| Output encoding or numeric fault after a write | Original runtime fault; rollback; no stdout |
|
||||
| Captured PostgreSQL SQL constraint error | Current transaction remains aborted; no implicit savepoint/continued-query promise |
|
||||
| Partial decode, stream stop/cancel or root exit | Paired typed cleanup and connection release/discard in its known state |
|
||||
|
||||
### Good / Base / Bad Cases
|
||||
|
||||
Base: a CMD creates, reads and deletes a row in its owned SQLite database. Good: real PostgreSQL round-trips storage scalars, captures NotFound without losing identity, and rolls back an uncaught unique violation before the next command inspects persisted state. Bad: use SQLite's ability to continue after a SQL constraint error as a PostgreSQL contract, close a pool after its runtime disappears or reuse a closed OnceLock pool on the next entry.
|
||||
|
||||
### Tests Required
|
||||
|
||||
Select root `llvm_database_source`, `runtime_entry_cleanup` and the canonical C database driver in `native_runtime_link`. Static cases must pass the existing checker and emit objects for all six targets. Opt-in native cases link the explicitly prepared runtime-only archive, execute with a cleared environment, and check output/fault plus allocation balance over warmup and 64 repeats. Cover CRUD/bulk/upsert, nullable/codec values, cursor/offset, stream cancellation with one connection, ToOne/ToMany, typed SQL, nested/sequential transactions, uncaught constraint/numeric/encoding rollback and successive schema/Seed revisions. Live PostgreSQL requires only the shared `database.postgres_test` isolated fixture, not an environment URL. These checks do not prove target runtime packs, throughput/RSS, tenant/API equivalence or injected commit/close failures.
|
||||
|
||||
### Wrong Versus Correct
|
||||
|
||||
Wrong: publish Session settings into global OnceLock, run pool cleanup in a second runtime, copy SQL/driver code for LLVM or add savepoints to make a SQLite test pass on PostgreSQL. Correct: invocation-owned Session, original shared SQL/drivers, same-runtime drain/close ordering and tests respecting each driver's existing transaction semantics.
|
||||
|
||||
### LLVM API Tenant Session
|
||||
|
||||
Job bindings and clock belong to the same invocation Session. Enqueue uses the caller's physical database and ambient transaction; handler transactions and timeout rollback reuse the existing store/worker and transaction owners. Tenant migrations prepare the queue in each selected tenant database before readiness. Component membership alone never enables tenant isolation: Job component checks apply only when deployment settings configure tenant storage. Without tenant configuration, ordinary SQLite/System Jobs must run without a tenant ID. Protected API authorization must bind its exact permission to the trusted Job execution context before enqueue; workers recheck the current subject/session, tenant, catalog, component and role rather than trusting a stored grant.
|
||||
|
||||
HTTP/REST and log-enabled CMD use `application::Session` as the single config,
|
||||
lifecycle, database, tenant and migration-pool owner. The fixed route descriptors
|
||||
only reference checked Models and auth contracts; SQL/DDL/role storage remain
|
||||
shared with the original backend. Configured database-only tenant storage is
|
||||
supported, not a reason to reject every root. Missing tenant identity/readiness
|
||||
still fails closed; `global` Models use control storage, other Models require the
|
||||
tenant physical database. A disabled component also denies Tenant Owner.
|
||||
|
||||
Tenant pool creation registers a manager entry with `ready=false` before awaiting
|
||||
schema preparation/marker validation. Interrupted preparation never becomes a
|
||||
usable pool, and retirement remains owned through `close_pool().await`. Migration
|
||||
pools register with the Session before their first prepare await. After child
|
||||
drain, close migration pools, tenant manager and platform pools on the same
|
||||
runtime; preserve the original fault and append cleanup causes rather than
|
||||
replacing it. Do not cache a failed or incomplete pool as ready.
|
||||
|
||||
`llvm_api_source` uses 65 SQLite roots with two tenant files and the unchanged
|
||||
allocation ledger; it asserts exact site roles, component denial, owner row
|
||||
filtering, concurrent identity isolation and POST rollback. Its opt-in PostgreSQL
|
||||
case shares `support/postgres::run_in_isolated_databases`, supplies only
|
||||
`config/setting.json.database.postgres_test`, and cleans one random control and
|
||||
two tenant databases. Neither cross-target objects nor prior Model-only PG
|
||||
success proves API/tenant execution. Commit/close failure injection and
|
||||
throughput/RSS remain separate acceptance work.
|
||||
|
||||
REST SQL snapshots may inspect HIR without deployment settings, but executable
|
||||
REST fixtures must use `check_with_settings` with a real site/provider binding
|
||||
and a checked identity App. Reuse one owned fixture for this setup, as in
|
||||
`rest_model`; missing auth metadata or generated authorization helpers must not
|
||||
be worked around by disabling permissions.
|
||||
|
||||
### Dependency Lock-Order Regression
|
||||
|
||||
The old bundled SQLite 3.51.1 had an ABBA VFS lock inversion: replacement connection creation (`findReusableFd`) acquired global → inode, while WAL connection close (`unixLock` → `unixIsSharingShmNode`) acquired inode → global. A cancelled stream correctly discarded its unknown connection; concurrent background close/replacement then hung runtime shutdown. Native stdout already contained a successful CMD response, so checking only SQL success or Scope completion missed this dependency defect. Repeated opt-in process-bounded stream/root execution and both blocking-worker stacks exposed the exact cycle; no timeout extension, reusable-unknown connection or checkpoint suppression is an acceptable workaround.
|
||||
|
||||
Keep the fixed dependency chain (`deadpool-sqlite` 0.14.0, locked `rusqlite` 0.40.2 / `libsqlite3-sys` 0.38.2, bundled SQLite 3.53.2), without editing registry cache or shipping a private SQLite fork. The author workspace requires Rust 1.95. `sqlite::value(column, ValueRef)` receives the real index in ordinary, bounded, owned-SQL and stream decoders; malformed Text retains `ErrorKind::Database` and the column diagnostic. Select the native early-close/repeated-root case and `sqlite_runtime` invalid-Text/replacement tests on dependency changes. An earlier successful run is not sufficient evidence against an observed intermittent shutdown hang.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Directory Structure
|
||||
|
||||
- `crates/dever-core/src/lib.rs`: separate syntax `parse` and semantic `check` entry points.
|
||||
- `source.rs`: UTF-8 original-source ownership, sorted `.dever`/`.dever.md` loading, the single main/component/domain/role/topic classifier and byte-to-line/column conversion.
|
||||
- `markdown.rs`: CommonMark top-level literal `dever` and `typescript dever` fence extraction, explicit closure and complete-declaration validation.
|
||||
- `markdown/contract/`: `.dever.md` heading/list model, one-section-per-declaration mapping and checked package/type/function documentation contracts.
|
||||
- `diagnostic.rs`: source-located errors, related labels and rendering.
|
||||
- `lexer.rs` and `token.rs`: lexical rules and preserved numeric spellings.
|
||||
- `syntax.rs`: spanned syntax structures and operator precedence.
|
||||
- `parser.rs`: declarations, types, patterns and linear statements.
|
||||
- `parser/expression.rs`: expressions, postfix operations and collection construction, sharing the same parser cursor.
|
||||
- `check.rs` and `check/`: register all declarations before bodies; `check/layout.rs` owns application layout validation, `check/symbols.rs` owns logical App aliases and role access, and `check/api.rs` validates HTTP routes after signatures resolve. Names, types, linear flow, expressions/operators/value construction, collection handlers and symbolic clause coverage retain separate owners. Cycle checks include nested expressions, handler calls and type references, collapsing physical files in one domain for dependency-cycle purposes.
|
||||
- `types.rs` and `hir.rs`: nominal types, opaque Stream values, static handler signatures/targets, concrete typed expressions, normalized value-only clause domains and local slots. Only checking constructs executable HIR.
|
||||
- `wire.rs`: shared checked wire schema, policy identity and interned concrete type graph. `native/wire.rs` and `llvm/wire.rs` emit concrete backend-specific encoders/decoders; runtime `wire.rs` owns bounded duplicate-aware JSON parsing and exact raw JSON output. API dispatch, Adapter settings and Job codecs consume this owner, never maintain independent allowed-type registries.
|
||||
- `specialize.rs`: the single function-plus-handler specialization graph used by checking and native emission. It resolves forwarded handlers and rejects recursion after substitution.
|
||||
- `llvm.rs` and `llvm/`: typed HIR function/coroutine kernels with shared ownership/fault helpers. `network.rs`, `concurrency.rs` and `parallel.rs` own streams/TCP, task composition and worker packs. `http*.rs` lower protocol capabilities; `system.rs` owns system intrinsics and `render.rs` terminal faults. `application.rs` owns compiler-generated CMD/HTTP/Job roots; `api.rs` static route/auth/permission callbacks; `api_context.rs` auth/request/Cookie/Upload capabilities; `rest.rs` generated Model REST; `application_commands.rs` typed tenant management/CMD normalization. `jobs.rs` owns typed payload dispatch, enqueue, binding and clock adapters; `testing.rs` links case-local checked programs into one suite with indexed fault ownership. `wire.rs` owns concrete checked codecs, `ports.rs` selected Dever Adapter dispatch and Setting lifetime. `database*.rs`, `orm*.rs` and `transaction.rs` own persistence codecs, shared SQL/DDL and hidden transactions. Runtime `application::Session` owns config/lifecycle/database/tenant/migration/Job resources and closes after Scope drain within the same runtime. `external.rs` owns checked Worker calls and embedded resources; `process_entry.rs` wraps callable roots in process main. Private and managed Linux CLI compilation are connected; formal six-target delivery remains unfinished.
|
||||
- `contracts.rs` and `contracts/`: post-HIR facts/domain proofs, failure obligations, transitive effects/dependencies and structural suggestions. `api.rs` owns public snapshots and application exposure analysis; CLI `api.rs` owns explicit baseline file I/O.
|
||||
- `intrinsic.rs`: the single name-to-operation mapping for system primitives; checker and emitter exhaustively match this enum. Official result choice shapes are checked before emission and variants resolve by name, not declaration index.
|
||||
- `native.rs` and `native/`: concrete record/choice generation, direct functions and collection loops, source-located failures, generated API/CMD/Job application roots and owned temporary build artifacts. `native/build.rs` owns native compilation and the single shared Cargo runtime target; `native/build/cache.rs` owns exact immutable inputs and executable cache identity; `native/build/cleanup.rs` owns marker-protected temporary directories and bounded local cleanup. CLI code must not reproduce those filesystem rules. `native/api.rs` emits checked static route dispatch, auth scope and response conversion. No generic runtime Value or interpreter dispatch.
|
||||
- `check/jobs.rs` owns Job declarations, App payload contracts and static enqueue checking. `native/jobs.rs` owns concrete payload dispatch and queue initialization, reusing the shared wire and database owners. `specialize::execution_reachable` adds Job handlers as independent execution roots without adding synchronous call edges.
|
||||
- `crates/dever-runtime/src/`: shared Decimal128/checked Int rules, ordered copy-on-write collections, Bytes, resource handles, pull-based Stream ownership, rendering and stdout. Native programs link this same runtime library; generated Rust is not a duplicate runtime implementation.
|
||||
- `crates/dever-backend-bridge/`: private LLVM/LLD object/link owner. Its safe API uses explicit six-target selection; only private `ffi.rs` may contain authorized unsafe calls. LLD calls are serialized; failed crash recovery terminates the private compiler worker before any cleanup/re-entry. Outputs are exclusively claimed before linking. The opt-in `embedded` authoring build requires a private LLVM 18 SDK; independent `runtime-abi` exports the canonical C runtime boundary without that SDK. HIR lowering remains in core, and neither build mode is a formal released compiler/target pack.
|
||||
- Runtime `async_stream.rs` owns the shared async producer/cursor and closure; `net.rs` owns Tokio Socket/Listener, duplex I/O, deadlines and resource cancellation. `task.rs` owns the single runtime, structured tasks/Group and bounded async stream dispatch. File resources remain in `resource.rs`.
|
||||
- Runtime `job.rs` owns transactional enqueue and trusted execution/component checks, `job/store.rs` owns private queue/cursor schema and fenced state changes, and `job/worker.rs` owns bounded production/test dispatch. `job/resources.rs` owns invocation-local bindings, clock, initialization and tenant queue migrations. `auth::authorize_permission` binds the checked permission after authorization for both backends. `cron.rs` supplies the shared UTC parser. `lifecycle.rs` owns service-mode gating and signal-driven stop/drain; all work remains under the existing structured task owner.
|
||||
- Runtime `component.rs` owns external Adapter resolution, bounded admission, Worker protocol, restart and shutdown. `component/command.rs` reuses that owner for one ordinary process per call, with binary I/O and caller-scope completion; `dever.process.Output` is its checked result, never an OS process handle. `check/ports.rs` synthesizes external implementations from the existing Port owner; `native/ports.rs` emits concrete codecs and dispatch. Do not add per-language runtime supervisors or expose protocol JSON to App code. CLI `workers/command.rs` packages domain-owned ELF/resources and reuses `workers/elf.rs` with native Python library validation.
|
||||
- Runtime `external.rs` owns verified embedded external-resource extraction below the executable's private `data/cache/lib/<digest>` root. It is a packager primitive, not a registry client or shared-cache writer.
|
||||
- Runtime `http/` owns Hyper HTTP/1, bounded messages, streaming reply/session ownership and connection deadlines. Runtime `api.rs` adapts typed inputs/JSON envelopes and reuses HTTP serving. `sse.rs` owns event encoding; `websocket.rs` reuses tokio-tungstenite. TCP and WebSocket share private `endpoint.rs` for close/wakeup ownership. Native `http.rs` converts checked source records and statically bound handlers; no application business code belongs in the runtime.
|
||||
- Runtime `http/client.rs` owns fixed-origin pooled requests and leases; `client_driver.rs` owns physical/request capacity, idle expiry and the one structured pool driver; `client_body.rs` owns pull-driven upload/download. `tls.rs` owns shared rustls configurations; the private `transport.rs` enum delegates TCP/TLS I/O for HTTP and WebSocket.
|
||||
- `library/**/*.dever`: bundled official packages processed by the same parser/checker as user source. Compiler-assigned source origins, not filenames, authorize reserved namespaces; `SourceMap::files()` remains user-only while `get()` also resolves bundled diagnostic spans.
|
||||
- `format.rs` and `format/`: shared AST-driven declaration layout, preserved per-block comment attachment and syntax-only canonical formatting. Markdown replaces only program-block interiors.
|
||||
- `reference.rs` and `reference/`: opt-in HIR semantic evaluator and differential output/fault representation. Never link into production native programs.
|
||||
- `native/liveness.rs` and `native/updates.rs`: clause-local last-use analysis, safe record alias coalescing and scalar snapshots before aggregate consumption. `types.rs` caches nominal comparison/move/transfer properties after cycle validation.
|
||||
- `native/sequences.rs`: conservative total-handler proof and eligible collection fusion; collection emission shares lazy Text splitting with the runtime.
|
||||
- `contracts/dependencies.rs`: shared dependency order and reverse-caller work scheduling for contract summaries.
|
||||
- `check/cache.rs`: per-thread last-result cache with complete source identity; bundled AST caching belongs to `lib.rs`/`source.rs`.
|
||||
- `native/build/cache.rs`: native artifact identity, integrity validation and atomic publication. `native/build.rs` owns current runtime freshness, snapshots and isolated executable lifetimes.
|
||||
- `crates/dever-cli/src/main.rs` and `format.rs`: argument parsing, source loading, direct execution of the owned native program, local clean command adaptation and prepared/atomic source formatting writes. `test_runner.rs` is the single owner of application-test ordering, isolated native processes, captured output and temporary project cleanup.
|
||||
- CLI binary-private `compile.rs` owns LLVM/object/LLD orchestration; `worker.rs` is the service-owned source compiler. Shared `toolchain/runtime_pack.rs` validates packs/snapshots without bridge imports; `compilation.rs` owns canonical input and staging validation, while `service/` owns authenticated transport and child supervision. Reuse core `native::compile_artifact` for private builds and `NativeProgram` for caller-owned received executables. `toolchain/packaging.rs` owns deterministic native release assembly/signing from explicit author inputs; `sdk/native-release.rs` only adapts its private author CLI. Root `runtime-pack` profile and `sdk/native-release.md` own the four-archive build recipe. `test/dever-cli-tests/support/llvm_inputs.rs` holds explicit fixture SDK inputs, and `support/daemon.rs` holds the shared owned-daemon lifecycle; neither is a production input discovery mechanism.
|
||||
- `library/dever/json/`: ordinary Dever JSON algorithm. `library/dever/http.dever`, `sse.dever`, `websocket.dever` and `tls.dever`: public protocol types/helpers and the native bridge; no duplicate source protocol stack. `task.dever` exposes runtime configuration, sleep and periodic streams.
|
||||
- `examples/cms/dever/` and `examples/cms/md/`: maintained CMS projects with equal declarations, schema, settings, business behavior and application tests. Generated API/CMD roots replace `module/main.dever`; `api/admin/**` and `api/front/**` are assigned by `sites.<key>.path`. Both projects keep matching `test/<component>/<domain>/<topic>` cases and `config/setting.json`.
|
||||
- `examples/old/`: archived pre-role-layout language and regression material. It is not a current application root and must not be presented as a directory or visibility template. Formal tests remain under root `test/`.
|
||||
- `examples/README.md`: current CMS commands and the archive boundary. Historical rejection fixtures keep their original diagnostics only when invoked by dedicated compiler-level tests, not as current application projects.
|
||||
- `test/dever-tests/tests/`: permanent focused tests. The canonical full-language syntax example is read directly from the PRD so a copied fixture cannot drift.
|
||||
|
||||
Core foundations, formatting, optional reference evaluation, official library follow-through and the SQLite/PostgreSQL static ORM have concrete implementations. Compiler distribution and self-hosting remain unshipped; validation evidence belongs to the active task, not this directory map. Compiler phase APIs are bootstrap internals, not an alternative program source format.
|
||||
|
||||
Use one owning implementation for each language rule. Do not introduce a generic parser framework, service layer or backend registry for a single concrete implementation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Error Handling
|
||||
|
||||
Source loading returns path-specific `LoadError` values because unavailable or invalid UTF-8 files do not have a valid source span. Parsed-source errors use `Diagnostic` with a stable code, primary span and optional related labels.
|
||||
|
||||
- `Span` is a half-open UTF-8 byte range owned by one `SourceId`.
|
||||
- Display positions are one-based lines and Unicode scalar columns. LF, CRLF and CR line endings use the same source model.
|
||||
- Render the original source line with its explicit line/column location. Do not guess terminal display width with one ASCII space per Unicode scalar.
|
||||
- Preserve source literal spelling through parsing; numeric representability belongs to checking.
|
||||
- Reject invalid source with a diagnostic. Never turn an unsupported construct into a successful default AST.
|
||||
- Parser recovery synchronizes only at balanced body boundaries or top-level newlines.
|
||||
- Both parser call depth and resulting expression tree depth are bounded by `MAX_SYNTAX_DEPTH`. Long iterative operator/postfix chains must not bypass the limit and overflow during recursive AST destruction.
|
||||
- I/O and syntax errors are explicit results. `expect` is reserved for compiler-owned invariants or infallible String formatting, not malformed source.
|
||||
- Contract structural-depth rejection is fatal to that analysis pass: `contracts::check` returns `Err(C015)` before producing summaries. Both the main checker and Port fake reanalysis must stop before consumers read those summaries; ordinary contract diagnostics still aggregate after summaries are populated. The 256-layer nominal-type regression must report C015 rather than panic in a later phase.
|
||||
|
||||
Current codes: `L001` unexpected character, `L003` excluded keyword, `L004` malformed number, `L005` unterminated Text, `L006` invalid escape, `P001` expected syntax, `P002` invalid statement/assignment, `P009` excessive syntax nesting.
|
||||
|
||||
Markdown source codes: M001 no top-level program block, M002 unclosed program fence, M003 declaration spans multiple blocks, M004 invalid package documentation structure, M005 invalid declaration documentation structure, M006 documented package/API/declaration name mismatch, M007 function input mismatch, M008 function output or type-shape mismatch. Semantic documentation mismatches use the Markdown value as the primary span and add the corresponding Dever declaration as a related label. Tokens and diagnostics refer to original `.dever.md` byte positions, including Unicode columns and source newline conventions. Markdown cannot introduce a synthetic filename or bypass normal language diagnostics.
|
||||
|
||||
Semantic codes: `C002` duplicate declarations/bindings/known Map keys, `C003` package path or reserved namespace, `C004` unresolved names/signatures or uninitialized locals, `C005` type/arity/construction/primitive-contract errors, `C006` private cross-package access, `C007` function/type/package cycles, `C008` statically known numeric faults, `C009` clause overlap/coverage/reachability, `C010` naming format. `W001` reports unused/overwritten writes without rejecting the program. Diagnostics sort by source ID and byte span; check unused declarations before native reachability pruning.
|
||||
|
||||
Standard output errors propagate to the native entry as a failure containing the `.dever` call location. The process exits nonzero even if stderr itself cannot be written. CLI/toolchain failures are explicit command errors; `check` never invokes rustc. Native temporary directories have exclusive ownership and are cleaned on compilation failure or after use. Cleanup failure cannot mask the primary result; explicit build output never overwrites an existing file.
|
||||
|
||||
External Adapter business errors are decoded only when their exact identity and payload match a Port-declared error variant. Startup, schema/handshake mismatch, invalid or out-of-order protocol messages, timeout, child crash and undeclared error identities are runtime faults at the Port call site, never synthetic business variants. Protocol diagnostics contain metadata only and must not echo setting secrets or call payloads. A protocol fault terminates the Worker channel and closes pending calls. Generated entry cleanup runs after success, business failure and runtime fault; shutdown/database cleanup augments rather than replaces the primary error.
|
||||
|
||||
Embedded external resources are never executed before their relative path, regular-file type and SHA-256 digest verify. Extraction rejects symlinks, path escape, duplicate entries, truncated staging and invalid bundle identity; an existing or concurrently published directory is reusable only after a full recheck. Missing artifacts remain an explicit offline preparation error with the corresponding `dever lib add/update` command.
|
||||
|
||||
## Core Execution Contract
|
||||
|
||||
Compiler contract diagnostics add C011 (unproved range), C012 (unhandled failure), C013 (pure/clause contract), C014 (Model/transaction contract) and C015 (bounded proof work). W002–W005 and W007 are suggestions; W001 remains the owner of unused/overwritten writes. Details and counterexamples are in [Compiler Contracts](./compiler-contracts.md).
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
Applies when changing checker-to-HIR-to-native contracts, bundled packages or runtime operations.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- `check(&SourceMap) -> Result<Program, Vec<Diagnostic>>`; successful programs expose `warnings()` separately.
|
||||
- `native::compile_project(&Program, &SourceMap, rustc, profile) -> Result<NativeProgram, String>` generates the application entry from API/CMD/Job declarations; compiler-internal library fixtures may still use `native::compile` with an explicitly selected zero-input entry.
|
||||
- `dever.io.read(file, limit) -> ReadState`; `dever.net.read(socket, limit)` returns the same state. ReadResult is the capture choice.
|
||||
- `dever.io.chunks(file, limit) -> Stream<ReadEvent>`; `dever.net.chunks(socket, limit) -> AsyncStream<ReadEvent>`, and `dever.net.connections(listener) -> AsyncStream<ConnectResult>`. ReadStreamResult choices capture creation failures.
|
||||
- `dever.http.send -> Response`; ResponseResult captures recoverable errors. `serve` has zero outputs and propagates configuration/accept faults. Handler faults are logged at the request boundary and return 500 or abort an already-started response; later requests remain available. Peer protocol/I/O errors close only the affected connection; body/handler timeouts return 408/504 before closing, incomplete-header timeout closes directly.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
`ReadState` is `Read(bytes: Bytes)` or `End`; `ReadResult` is `Done(state: ReadState)` or `Failed(message: Text)`. Ordinary calls return success values; explicit result captures convert the inferred failure set into choice values. Stream reads yield source-defined `ReadEvent` values and produce at most one terminal `Failed`; normal EOF produces no synthetic item. File creation uses create-new; resources share identity and close state across aliases, unlike ordinary value-semantic records/collections. Read limits and socket timeouts are positive Ints; reads may be short. Stream creation rejects a non-positive limit or already-closed source before returning a Stream. Core `close(stream)` is idempotent.
|
||||
|
||||
Handler parameters are compile-time bindings, never runtime values or clause axes. Every value type nested in a handler signature uses the same generic arity and Map-key validation as an ordinary function boundary. Handler signature, output-name and specialization-cycle errors are source diagnostics before build. `reduce_until` applies the current item, then stops without pulling another item.
|
||||
|
||||
`result(call)` changes business-failure representation, never scheduling. Captured calls share ordinary-call suspension/liveness and inline-blocking checks. A synchronous capture must remain legal with synchronous resources; capturing a suspending call must diagnose non-transferable live locals at the capture span, and capturing a blocking call must not bypass an async caller's worker boundary. Keep both negative paths and the synchronous positive control in `structured_concurrency`.
|
||||
|
||||
Build/run use the local bootstrap toolchain (`RUSTC`, optional `CARGO`) and locked cached dependencies with `--offline`; missing tools/dependencies fail explicitly. Generated binaries link the runtime, not the checker or a generic evaluator, and execute without the build toolchain.
|
||||
|
||||
All runtime feature combinations build through one Cargo target. Only artifacts reported for the current Cargo graph enter an immutable input view; a missing, symbolic-link, changed or incomplete input fails before generated code is linked. Native temporary directories carry a versioned owner marker and are removed immediately by RAII. Recovery cleanup deletes only current-user, real directories with an exact Dever name/marker older than 24 hours; unknown, fresh, active or symbolic-link paths are skipped. Cleanup failure may be returned by explicit `clean`, but automatic cleanup never replaces the current compile or run result.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| Condition | Result |
|
||||
| --- | --- |
|
||||
| Incomplete record/output, type drift, illegal key | Source diagnostic before build |
|
||||
| Invalid generic or Map key nested in a handler signature | C005 before build |
|
||||
| Clause overlap, gap or empty complement | C009; overlap labels both clauses |
|
||||
| Known Int/Decimal overflow or zero divisor | C008 before build |
|
||||
| Dynamic Int/Decimal fault or stdout failure | Source-located native failure and nonzero exit |
|
||||
| Invalid bytes, file/socket error, closed resource | Explicit Failed choice |
|
||||
| Invalid stream limit or closed source at creation | Explicit Failed choice; no Stream returned |
|
||||
| Stream producer terminal error | One Failed item, then end; producer released immediately |
|
||||
| timeout/race receives a program fault | Propagate after structured cleanup; never invoke timeout fallback for a fault |
|
||||
| timeout expires | Stop and drain the task before its signature-matched fallback runs |
|
||||
| Pool closed, request/stream deadline or TLS verification failure | Explicit Failed; failed/incomplete connections are not reused |
|
||||
| Stream(Channel/List/Bytes) contains error choices | Preserve their failure obligations; constructing or closing the stream does not handle them |
|
||||
| HTTP listener explicitly closed | Stop accepting, drain active responses; application timeout may stop remaining work |
|
||||
| Invalid/reused build output path | Command error; existing file preserved |
|
||||
| Runtime input view is incomplete or changed | Build error; never scan unrelated historical artifacts |
|
||||
| Clean candidate has no valid owner marker, is fresh, active or a symlink | Preserve it unchanged |
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
Good: `Text` plus `null` clauses infer `Text?`; `first([null])` in an `Int?` output context remains `Int?`. Base: hello prints one UTF-8 line. Bad: `Bool` plus `true` clauses overlap; repeated constant or zero-payload choice Map keys are rejected.
|
||||
|
||||
### 6. Required Tests
|
||||
|
||||
Use `core_semantics` for checking/rejection, `core_native` for exact outputs/evaluation order/native failure locations, `runtime_foundations` for decimal precision/COW/resource closure, and `hello_native` for CLI-backend regressions. Stream tests must assert that mapping preserves a terminal item as terminal, releases its producer on that pull, and that official wrappers map invalid creation to `ReadStreamResult.Failed`. Run only affected targets and explicitly announce owned temporary-file/loopback checks.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
Wrong: select the first matching source clause, append anonymous standard files to a cloned SourceMap, emit an untyped Value interpreter, reorder record expressions by field name, or implement Stream mapping through an API that erases `Pull::Last`.
|
||||
|
||||
Correct: prove disjoint exhaustive domains before emission, keep bundled source origins retrievable by the caller's SourceMap, emit concrete types/direct handlers, evaluate constructor fields in source order, and preserve `Item`/`Last`/`End` through Stream adapters.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Compiler Development Guidelines
|
||||
|
||||
This repository implements the Dever language compiler in Rust. It is not a Go Dever application or a database-backed service.
|
||||
|
||||
## Pre-Development Checklist
|
||||
|
||||
1. Read the current task PRD, design and implementation progress.
|
||||
2. Read [Directory Structure](./directory-structure.md), [Error Handling](./error-handling.md) and [Quality Guidelines](./quality-guidelines.md).
|
||||
For CLI, formatter, native optimization or official packages, also read [Toolchain and Library Contracts](./toolchain-and-library.md), including signed ecosystem packs and bounded binary resource attachments.
|
||||
For language contracts and API boundaries, read [Compiler Contracts](./compiler-contracts.md).
|
||||
For LLVM typed lowering or runtime C ABI work, read both contracts; distinguish kernel/object evidence from complete application semantics and formal target runtime packs.
|
||||
For Model, ORM, database configuration or runtime connections, also read [Database Guidelines](./database-guidelines.md).
|
||||
For Dever logging or HTTP request-boundary records, read [Logging Guidelines](./logging-guidelines.md).
|
||||
3. Inspect affected compiler phases and existing tests before adding another rule or helper.
|
||||
|
||||
## Quality Check
|
||||
|
||||
- Check the implemented milestone only; syntax success must not imply semantic validity.
|
||||
- Run the focused checks listed in the quality guide.
|
||||
- Keep all tests under repository-root `test/`.
|
||||
- Preserve unrelated user changes and do not create Git commits without authorization.
|
||||
|
||||
Database behavior is a compiler/runtime contract only within the static ORM boundary documented in [Database Guidelines](./database-guidelines.md). Shared logging and native exit-flush contracts are documented in [Logging Guidelines](./logging-guidelines.md).
|
||||
@@ -0,0 +1,46 @@
|
||||
# Logging Guidelines
|
||||
|
||||
`library/dever/log.dever` provides `debug`, `info`, `warn` and `error` with a
|
||||
`Text` message and `Map<Text, Text>` fields. Native emission routes these calls
|
||||
to `crates/dever-runtime/src/log.rs`; do not add a second logger in the CLI or
|
||||
generated program.
|
||||
|
||||
The runtime writes one JSON record per line to stderr, with `level`, `message`
|
||||
and `fields`. The bounded writer may drop debug/info events under pressure and
|
||||
reports the count in `dropped_events` on a later record; warn/error events wait
|
||||
for room, and error records flush immediately. Native program exit flushes the
|
||||
writer. Keep log messages and fields bounded instead of logging unbounded
|
||||
payloads.
|
||||
|
||||
LLVM applications with reachable log calls use the same invocation-owned
|
||||
`application::Session` and load `config/setting.json` once, even for log-only CMD.
|
||||
Configured level filtering and request identity fields come from that owner;
|
||||
Scope drain precedes pool close and log flush. Pure scalar CMD/kernel entries
|
||||
do not acquire an API owner. `llvm_api_source` tests warn-level filtering and
|
||||
65 flush-before-return invocations; do not infer configured logging support
|
||||
from the standalone kernel logger alone.
|
||||
|
||||
The single writer uses one preallocated 128-command queue; debug/info overflow
|
||||
and warn/error backpressure remain distinct. Flush callers are serialized onto
|
||||
one persistent acknowledgement state. Release preceding records and flush the
|
||||
output before acknowledgement; writer exit/panic clears queued owners and wakes
|
||||
all waiters. Do not add per-flush channels or a second queue. A pure-flush native
|
||||
ledger exposed lazy mpsc receiver waiter allocations after acknowledgement, so
|
||||
ordinary first-flush success alone is not a stable exit/allocation boundary.
|
||||
Keep the original strict ledger, not sleeps, extra warmup or relaxed counts.
|
||||
Root test/runtime_logging.rs covers FIFO/drop counts, backpressure, concurrent
|
||||
flushes and writer failure; native_runtime_link covers the actual ABI barrier.
|
||||
|
||||
`crates/dever-runtime/src/http/mod.rs` records an HTTP request-boundary error
|
||||
with `boundary`, `request_id`, `protocol` and `target`. Do not include secrets,
|
||||
credentials or full request/response bodies in fields. The source-to-stderr
|
||||
contract is covered by `test/dever-tests/tests/logging.rs`; extend that focused
|
||||
test when changing the public logging surface or output format.
|
||||
|
||||
Secret and complete Models containing private fields are non-observable types,
|
||||
including nested containers and related Models. Compiler entry/error/API/assertion
|
||||
render boundaries must reject them before native emission; log arguments remain
|
||||
Text and Map<Text, Text>. Runtime Secret Debug emits one fixed redacted marker and
|
||||
never reveals bytes. Do not add a generic conversion or derive Render for a
|
||||
sensitive nominal type. Ordinary record private fields remain an access rule,
|
||||
not a promise of cryptographic secrecy.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Quality Guidelines
|
||||
|
||||
Workspace-owned Rust forbids unsafe code. Decimal128 and insertion-ordered Map reuse the pinned `dec` and `indexmap` dependencies in `Cargo.lock`; do not reimplement these domains. Keep source loading, parsing, semantic checking, native emission, runtime and CLI separate. Parser success is not semantic success.
|
||||
|
||||
Root dev/test profiles keep `debug = 0` and `incremental = false` so independent focused test binaries do not recreate multi-gigabyte debuginfo and incremental object trees. Release keeps fat LTO and one codegen unit. Native runtime feature combinations share `target/native-runtime/cargo`; exact per-build inputs live under `target/native-runtime/inputs`. Do not restore one complete Cargo target per feature combination.
|
||||
|
||||
Native ABI release archives use the root `runtime-pack` profile and one shared `target/native-runtime-abi` Cargo target for all four driver combinations. Save each completed archive before the next feature build. Preserve link symbols, keep `embedded` disabled, and distinguish deterministic pack assembly from cross-host source reproduction; the author workflow and assertion points live in toolchain-and-library.md and sdk/native-release.md.
|
||||
|
||||
Tests exercise source contracts and observable AST structure, not private helper implementations. Include the PRD's complete package, precedence and associativity, Unicode and line positions, lexical failures, excluded syntax, recovery, structural limits and directory loading. Filesystem tests create and remove only their own temporary directories; source loading never follows symbolic links.
|
||||
|
||||
Explicit LLVM Worker acceptance consumes the prepared immutable `target/native-release-inputs/component-fixture`, with recorded source and binary digests. Cargo's `target/debug/component-fixture` is a mutable build output: feature unification can change its bytes while running the workspace matrix. Ordinary Worker tests use their Cargo-provided executable; never bypass the CI input-change gate to treat a mutable output as a fixed author input.
|
||||
|
||||
Explicit single-libtest acceptance uses `run_command(..., single_harness=True)` and requires the final parent summary to contain exactly one passed test, no failures and no ignored tests. Nested probe summaries must not be added to that parent count; ordinary Cargo multi-harness commands retain aggregate counting. A zero-test parent, failed parent or missing summary is not a pass even if a child probe succeeded. `test/ci/test_gate.py` owns these rejection cases.
|
||||
|
||||
The shared `test/native-runtime-abi/managed-driver.c` runs one warmup and 64 repeated entries on joined host threads, then checks strict allocation balance after each join/TLS destruction. Rust's bounded-channel producer may first allocate its 48-byte thread-local Context only after warmup when the queue fills; measuring before the host thread exits misreports that cache as a leak. Preserve process initialization before any thread, original result checks and strict zero allocation delta; do not add retries, random warmups or an allowed-leak threshold. This measures entry-plus-host-thread cleanup, not unbounded TLS growth on a long-lived embedding thread. `test/ci/test_driver.py` compiles the shared driver with the explicit existing author C tool and verifies late TLS cleanup succeeds while intentional per-entry leaks and changed results fail. Suite probes include the same driver instead of copying its ledger.
|
||||
|
||||
Markdown source uses pinned pulldown-cmark in the compiler only. Preserve the `.dever` path and existing semantic/contract checks; shared Markdown extraction/formatting rules and the focused `markdown_source` target are specified in [Toolchain And Official Library Contracts](toolchain-and-library.md).
|
||||
|
||||
Focused checks for the current milestone:
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check
|
||||
cargo clippy --offline -p dever-core -p dever-tests --test syntax_and_format -- -D warnings
|
||||
cargo test --offline -p dever-tests --test syntax_and_format
|
||||
git diff --check
|
||||
```
|
||||
|
||||
For core language changes, select the affected targets and announce bounded native/resource checks before running:
|
||||
|
||||
```bash
|
||||
cargo test --offline -p dever-tests --test hello_native
|
||||
cargo test --offline -p dever-tests --test core_semantics
|
||||
cargo test --offline -p dever-tests --test core_native
|
||||
cargo test --offline -p dever-tests --test runtime_foundations
|
||||
cargo test --offline -p dever-tests --test syntax_and_format
|
||||
cargo clippy --offline -p dever-core -p dever-runtime -p dever-tests --test hello_native -- -D warnings
|
||||
cargo clippy --offline -p dever-cli --bin dever -- -D warnings
|
||||
```
|
||||
|
||||
The hello target retains stdout, value mutation, package boundaries, missing toolchain and artifact cleanup regressions. Core targets cover the full PRD sample, static rejection, clause permutation, concrete native output, COW ordering, nullable flattening and source-located faults. Foundation-extension cases additionally cover contextual handler syntax, forwarding/specialization recursion, value-only clause domains, List/Bytes/Stream traversal, early stop/resume and one terminal stream failure. Resource tests create only their own temporary files and loopback sockets on port 0 with bounded reads/timeouts. CLI changes also require the actual check/run/build command. These are focused compiler checks, not an integration environment or workspace-wide test run.
|
||||
|
||||
Parentheses must not affect context inference: use the shared Group-unwrapping helper for nulls, empty literals and named handlers. Handler signature types must reuse ordinary boundary validation, including generic arity and nested Map-key rules. Stream adapters must preserve `Pull::Last` rather than turning it into an ordinary item. Never retype an existing Decimal/Int local as Float, even after constant propagation. Float zero patterns match both signs, so substituting the pattern constant for the actual input changes division semantics. Preserve source field and Map pair evaluation order in generated code; declaration order controls layout/display only.
|
||||
|
||||
Use an explicit installed toolchain or a command-scoped PATH if Cargo is not discoverable. Do not change global shell configuration or replace the existing `dever` command.
|
||||
|
||||
Do not run workspace-wide tests, npm builds, service checks, native compilation benchmarks or integration environments by default. Focused syntax tests do not establish type correctness, formatting round trips or runtime equivalence. Record those later-phase checks as not run.
|
||||
|
||||
Explicit Linux no-host release checks share `native_release::acceptance::isolated_os_command`: a real mount-namespace root with only declared OS files, proc and synthetic devices. Plain chroot blocks the nested user namespace required by Worker sandboxing. The explicitly built static `native-acceptance-init` replaces bwrap's masked proc with a complete private proc inside the already pivoted outer namespace; otherwise the kernel rejects the nested proc mount. Preserve the outer fixture's original capabilities and the product's inner capability removal; do not substitute a host proc bind. Verify the final standalone executable after removing source and machine stores; an earlier pre-sandbox artifact or managed-only pass is insufficient.
|
||||
|
||||
Installed daemon and launcher checks must share that owned PID/user/mount namespace so kernel peer credentials remain visible. Use the owned bwrap PID report, verify its live process and root inode, then enter it explicitly for clients; never relax production peer authentication to accommodate sibling PID namespaces. Pin the namespace init with a pidfd and wait for namespace exit and daemon-lock release before starting the next daemon. Run real distinct-UID checks separately because a single-UID test user namespace cannot represent multiple host identities.
|
||||
|
||||
Toolchain/library changes select `formatter`, `standard_library`, `native_ownership`, `concurrency`, `json_library` or `http_library`. The optional `reference` feature enables only `differential`; production CLI feature resolution must exclude it. HTTP tests independently assert wire bytes and malformed framing, with bounded loopback peers. Formatter changes also exercise the actual `fmt`/`--check` CLI and failure-before-rewrite behavior.
|
||||
|
||||
Configured concurrency and reusable TLS clients select `async_composition`, `pooled_network` and `pooled_library`. Assert task cleanup even before first poll, complete-body connection reuse versus abandoned-body close, bounded physical connections including idle entries, stream deadlines, verified CA/name failures, WSS and graceful shutdown. Check real native source conversion alongside runtime peers; include compile rejection for mismatched context/output signatures and unhandled stream failures. Certificates and test private keys belong under root `test/`; peers use only owned loopback ports.
|
||||
|
||||
Runtime root entries pin the caller Future once before forwarding it through cleanup adapters and task-local wrappers. Job execution scopes also pin before constructing their async wrapper; identity initialization and task-local entry remain lazy. Preserve borrowed and non-Send roots, concrete errors, component-close/child-drain/cleanup order, and nested-entry rejection. Exercise `runtime_entry_cleanup` and all-feature `durable_jobs`, including signal/transaction shutdown: default-feature success alone misses the larger combined-driver Future layout. Do not increase thread stacks to mask wrapper-induced stack overflow.
|
||||
|
||||
Standalone C fixture drivers must acquire the existing process initialization before starting kernel/runtime threads, just like generated process main. The shared `managed-driver.c` also owns initialization for the indexed suite driver. Callable roots deliberately preserve an embedding host's signal policy; do not modify runtime network leaves or test subprocess inheritance to hide a missing driver initialization. Keep the direct SIGPIPE ABI assertion and real TLS upload/download allocation-ledger regression.
|
||||
|
||||
The ignored `performance` target runs only for an explicitly announced bounded performance check. Match Decimal initial representation as well as numeric value; prepare parsing outside the timed region. Five native computation kernels cover Int/Float/List/Decimal/Map with exact results and a 1.15 baseline ratio budget. Alternate a fixed 25 independent processes per side, discard each process's warmup, retain every process's nine raw samples, then compare the medians of all 225 samples per side. The same ELF has exhibited clustered timing modes even with CPU affinity; five process groups can accidentally favor one side. More process groups improve coverage without proving host noise is eliminated. Never selectively omit groups or add samples until a failing ratio passes. Production and baseline share `native::OPTIMIZATION_ARGS`, including one codegen unit so generic collection inlining does not depend on generated program size. Record compilation separately and report CPU/environment without inferring HTTP throughput or whole-language performance.
|
||||
|
||||
## Standalone Runtime Benchmarks
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
Changes to root `test/performance/` and the `network_bench`, `async_bench`, `live_bench` Rust examples. These explicit opt-in benchmarks measure owned binaries/loopback peers, never existing services or default test execution. Operational commands and limitations live in `test/performance/README.md`.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
`python3 test/performance/run.py build --output <new-dir>` compiles fixtures and records binary fingerprints/configuration. `--selection protocols` selects runtime/HTTP/live and three explicitly prepared Rust peers; `profiles` selects four runtime profiles; `cms-profiles` also builds both CMS sources. Every Dever fixture uses the public LLVM project build and CMD dispatch, never the removed native Rust fixture builder. `run --artifacts <dir> --output <new-dir> --suite memory|async|http|live|http2|http2-recovery|all` executes only prepared binaries. The HTTP peer CLI is `network_bench load h1|h2 URL PATH RATE SECONDS PHYSICAL_CONNECTIONS STREAMS_PER_CONNECTION TIMEOUT_MS REUSE CA`; H1 requires one stream per connection, while H2 requires connection reuse. Service modes are runtime-http/runtime-https/hyper-http/hyper-https plus runtime-http2/runtime-https2/hyper-http2/hyper-https2. Async comparison uses `async_bench root|worker WORKERS TASK_CAPACITY ITERATIONS`. Long connections use `live_bench PROTOCOL URL CONNECTIONS CYCLES HOLD_MS TIMEOUT_MS`, selected by `--connection-tiers`, `--cycles` and `--duration` in the runner.
|
||||
|
||||
For `run`, `--server-cpu <id>` optionally binds the server to one CPU; `--client-cpus <id,id,...>` optionally binds HTTP/1, HTTP/2 and live clients to a CPU set, and `--client-workers <positive-int>` defaults to 1. `Process(cpus=...)` changes only its owned child before exec. An omitted client set inherits the runner's allowed CPUs. The report records `arguments.client_cpus`, `arguments.client_workers` and the effective `environment.client_cpus`/`client_workers`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
Servers publish `READY|port` after preparing shared payloads. JSON counters satisfy `scheduled=sent+dropped`, `sent=completed=succeeded+errors`, and `dropped=dropped_capacity+dispatch_expired`. `latency_ms` measures planned-to-successful-completion; `request_latency_ms` measures dispatched-to-successful-completion; dispatch lag includes completed failures. Empty histograms are null and success histograms have exactly succeeded samples. Record raw rounds, successful throughput, load-generator CPU/lag, config and limits; never average p99 values into a global p99. HTTP/1, HTTP/2 and live clients share explicit `--client-workers`/`--client-cpus` configuration; record both in the report. Python stages each peer beside an owned config/setting.json with a benchmark object; peers read it relative to their executable and never use application environment variables. The manifest records setting-json-v1 and build checks the peer's check-config output before accepting it. Server affinity remains one CPU, distinct from the specified client CPU set.
|
||||
|
||||
HTTP/2 records `physical_connections`, `streams_per_connection`, their `request_slots` product and `successful_handshakes`. Preconnect the exact physical connection count before timing, share cloned senders across stream slots, and do not retry, replace or expand connections during measurement. H2 cleartext is prior knowledge; TLS advertises and requires only ALPN `h2`. Runtime and raw Hyper baselines use the same stream/window/header/frame/send-buffer limits. After each warmup, case or recovery cycle, close clients, wait the fixed settle interval and require service FDs to return to the READY baseline; preserve the RSS sequence without treating allocator retention alone as a leak. Any HTTP/2 request error or cgroup OOM event fails the run after completed case data is persisted; capacity drops remain saturation evidence rather than a pass/fail threshold.
|
||||
|
||||
Manifest `application_configuration=llvm-cmd-v1` is mandatory alongside `setting-json-v1`; reject older application artifacts before launch. Each runtime/http/live project contains its group's CMD declarations, so per-command aliases share the same multi-command binary and recorded identity. `fixture_command` derives the qualified CMD from the same `ENTRIES` used for building. Live settings belong beside each owned executable; ORM keeps its separate `benchmark.command.run` entry. LLVM O2/LLD and the exact prebuilt runtime archives determine Dever output. Rust peers are separately prepared with Cargo release, copied and fingerprinted. Do not claim identical optimizers or scheduling: Dever uses production defaults, while explicit workers/capacity affect Rust peers. Both retain checked Int arithmetic and structured run/wait; root/worker modes differ by an untimed outer structured task. Sampled /proc context-switch totals count currently alive threads and can decrease as threads exit; use perf for complete process/thread totals.
|
||||
|
||||
Live clients flush zero-based `PHASE|cycle|connecting/connected/closing/disconnected` in order. Connected holds perform validated interactions; disconnected holds last 300 ms before the next connecting marker. Compare progress before and after sampling; discard ambiguous transition observations from phase reports. Success requires attempted=connected=disconnected=connections*cycles, errors=0 and at least one validated message per connection (SSE also requires a heartbeat during hold). Counters alone do not prove server cleanup: preserve per-phase FD/RSS and their post-disconnect deltas. TCP/WS/SSE payload validation, finite limits and driver ownership apply on errors as well as success.
|
||||
|
||||
CMS concurrent reads close the control client before the baseline. `Process.descriptors()` records FD-to-target maps; `validate_read_descriptor_recovery(load)` is shared by the runner and CI. Its input includes `descriptors_before`, `descriptors_after`, and fixed `pooled_descriptor_limits`: each existing SQLite database/main WAL target is bounded by configured `max_connections`, SHM by one. All remaining descriptor targets and multiplicities must match (`Counter(non_pooled_before) == Counter(non_pooled_after)`). Preserve total FD change as observation. Persist each case's `report.json`, including partial completed data, raw samples, final budget and errors; aggregate failures as well as successes.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
Wrong status/type/body -> counted error, no successful latency sample. Exhausted load capacity -> dropped_capacity. Missed measurement window -> dispatch_expired; one late timer wake is not itself a drop. Incomplete samples/checksum mismatch -> runner failure. Missing cgroup permission/controller -> explicit failure and owned-group cleanup, never unlimited fallback. Interrupted run -> terminate/reap owned processes and remove owned empty groups, preserving completed case records.
|
||||
|
||||
An empty, duplicate, negative or non-integer `--client-cpus` set fails at argument parsing; an unavailable CPU or overlap with the specified server CPU fails before any output directory or process is created. A nonpositive `--client-workers` value fails at argument parsing. Do not claim independent client/server cores when affinity was omitted.
|
||||
|
||||
CMS extra/replaced/duplicated sockets, unexpected files or SQLite handles over the configured bounds fail even if total FD count is unchanged. Record completed publication fields and baseline/limits before starting the read load, so an early read or sampling error cannot erase existing evidence.
|
||||
|
||||
Process memory may disappear before waitpid reports exit: skip that unavailable observation and let the process exit check own failure reporting. Every owned server context rejects cgroup OOM after teardown without masking an earlier failure. HTTP warmup and measurement require at least one success and zero request errors; capacity drops remain explicit measurements. Live missing/out-of-order phases or incomplete connection cleanup fail the run. A valid partial SSE frame at the intentional measurement deadline is not a malformed peer frame; never require TCP chunk boundaries to coincide with events.
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
Base: single-worker runtime, 32 server connections, 16 client slots. Good: each implementation prepares and shares the 64 KiB body once, validates exact response bytes and waits the entire measurement window. Bad: only one side reallocates the body per request, failed responses improve p99, or sub-window completion inflates QPS.
|
||||
|
||||
CPU placement good: `--server-cpu 0 --client-cpus 1,2,3,4 --client-workers 4` on a host allowing CPUs 0–4. Base: omit client flags for one worker inheriting allowed CPUs, with no isolation claim. Bad: repeat CPU 1 or place CPU 0 in the client set, or infer multi-core use solely from a requested worker count.
|
||||
|
||||
CMS FD base: one main/WAL/SHM handle per database. Good: a configured four-connection pool expands to four main and WAL handles, one SHM, and all non-database descriptors recover. Bad: a fifth database handle, an extra socket or a replacement socket masked by a closed control connection.
|
||||
|
||||
### 6. Required Tests
|
||||
|
||||
Run `python3 -m unittest discover -s test/performance -p test_runner.py -v` for counting/warmup/phase ordering/process teardown, HTTP/2 shape/summary validation, isolated JSON staging and missing-controller cleanup. Assert staged public projects declare the CMD used by every runner call and reject an obsolete application manifest. Configure test binaries in root config/setting.json at performance.network_peer and performance.live_peer. `python3 test/performance/test_network_bench.py -v` uses independent owned Python H1 peers and owned h2c/TLS services for exact-body success, one-connection multi-stream behavior and deliberate wrong-body failure. `python3 test/performance/test_live_bench.py -v` verifies TCP reconnect and rejects wrong TCP/SSE bodies or an SSE hold without heartbeats. Both also provide a no-network check-config test. Announce owned loopback checks before running them; select bounded protocol validation after driver changes, never the full suite by default. Network examples build offline with the workspace release profile; Dever archive/source provenance is recorded separately from Rust reference builds.
|
||||
|
||||
After changing CPU placement, assert the child reports the requested affinity without changing the parent, reject empty/duplicate/out-of-range/overlapping IDs, and verify serialized report workers/CPU sets. Run a bounded h2c/TLS+h2 smoke and minimal HTTP/1/live smoke against fingerprinted artifacts. To establish that the client actually exceeded one core, inspect `load.client_resources.cpu_cores_sampled` in high-rate raw cases, alongside service CPU and slot/dispatch drops.
|
||||
|
||||
CMS recovery changes require `test_runner.py` and `test/ci/test_gate.py`: bounded pool growth passes, socket replacement/duplication and excessive pool handles fail, and read/recovery failures preserve case and aggregate evidence after cleanup. A real read check longer than the default 30-second HTTP idle timeout exercises the closed-control baseline; it does not replace the 1800-second soak.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
Wrong: call warmed cgroup current the application's cold-start RAM; subtract two p99 values to infer request latency; equate a completed report with zero errors/OOM. Correct: report RSS/PSS alongside charged cgroup current/peak and OOM/throttling counters, distinguish warmed file pages and pre-exec Python peaks, record request latency directly, and inspect all failure counters before drawing conclusions. No global cache drops. Raw Hyper is a normal-GET baseline, not an equivalent implementation of production slow-peer/fault/structured-cleanup semantics.
|
||||
|
||||
Wrong: require CMS total FD delta zero across idle control-client expiry and lazy SQLite pool expansion. Correct: close that client first, preserve exact non-pooled descriptor identity/count and independently enforce the database pool's descriptor bounds.
|
||||
|
||||
Wrong: equate a 5-second saturation platform or a complete report with a sustainable 60-second rate. Correct: pick a rate below the observed platform, require at least 99% planned-request success over 60 seconds in both transport modes and memory budgets, and preserve nonpassing calibration data separately.
|
||||
|
||||
For the 2026-09-18 HTTP/2 ceiling check, `--client-workers 4 --client-cpus 1,2,3,4` removed the former single-core client constraint while the Dever server stayed on CPU 0. A 16x16 `/plain` scan at 30k/45k/60k/90k observed roughly 36k–40k successful req/s at the upper rates, with the service near one CPU and request-slot drops dominant; this is not an isolated server-only ceiling. Short scans cannot select a reliable steady rate by themselves: 28k failed the 99% planned-request success gate over 60 seconds, while 20k passed for both h2c and TLS+h2 at 128 and 64 MiB. `test/performance/README.md` owns exact commands, counts, RSS/latency, failure and calibration reports, and the limits of the 100-cycle recovery evidence. Do not extrapolate loopback or warm-cache results to production.
|
||||
@@ -0,0 +1,417 @@
|
||||
# Toolchain And Official Library Contracts
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
Application deployment targets belong to `toolchain::BuildTarget`, independently of the compiler host. Public `build --target linux-aarch64` may run on Linux x86_64; omitted targets and `run/test` stay native. Propagate the selected target through Lib preparation, CompileRequest, runtime pack selection, signature closure verification and both caches. Verify ELF machine/type for archive members and ordinary objects; a matching manifest label/hash alone does not prove architecture. A warm shared cache must still validate the selected target pack. Do not globally replace `platform_identity` or require an ARM compiler core for cross builds.
|
||||
|
||||
Target runtime/interpreter/extension/exec/sandbox assets must match the deployment target. Go build tools explicitly declare `build.host`, while Go output and stdlib headers match `build.goos/goarch` and version. Their sandbox uses host assets; emitted Workers use target assets. Product processes keep cleared environments; author-only cross linker settings or Go overlays must not become application configuration/environment knobs. Python sdist/npm hook execution requires a matching execution environment or a previously prepared complete target receipt; never relabel host native output. The canonical author maps and preparation commands are in `sdk/native-release.md`.
|
||||
|
||||
Executable modes are also part of the cross-target resource contract. Sandbox packaging and application embedding share `asset_executable`; classify both supported target loaders independently of the author's host, while retaining target ELF validation. Regressions must check actual packaged modes from non-executable source inputs and execute the embedded Worker. Host-only loader assertions and successful linking cannot establish target bootstrap behavior. Full-system sandbox fixtures must switch from initramfs rootfs to a normal root mount before bubblewrap uses `pivot_root`; do not loosen the production sandbox to accommodate a fixture mount error.
|
||||
|
||||
Applies to formatter/CLI, reference execution, native ownership and clause optimization, concurrent sequence actions and official source packages. JSON algorithms belong to `.dever`; HTTP reuses Hyper in the runtime, with source-owned public types and static business handlers. `intrinsic.rs` remains the exhaustive bridge for checker, native emission and reference evaluation.
|
||||
|
||||
The compiler author workspace requires Rust 1.95 or newer. SQLite is pinned to `deadpool-sqlite` 0.14.0 and its locked bundled SQLite dependency chain, which removes the observed WAL-close/replacement-open VFS lock inversion. Do not lower this requirement by reverting to the defective 3.51.1 engine, changing global toolchains or mutating Cargo registry sources. Initial developer dependency preparation may fetch explicitly; product run/build still consume prepared artifacts offline. See database-guidelines §9 for the root/connection lifecycle and regression evidence.
|
||||
|
||||
Compiler bridge exception (explicitly authorized 2026-09-30): workspace Rust remains `unsafe_code = forbid`. Only `dever-backend-bridge` may override this with `deny`, and only its private `ffi.rs` may allow necessary foreign calls, runtime C exports and their raw-memory operations. The rest of that crate, its build script, core, runtime, CLI and tests remain safe Rust; no public unsafe Rust API escapes the private boundary. Each foreign call and output transfer documents pointer lifetime, buffer length, allocation ownership and serialized LLD state. This is not a Dever-language FFI feature. LLVM/LLD object/link tests do not establish complete HIR semantic equivalence or a shipped runtime pack.
|
||||
|
||||
Keep `embedded` (LLVM/LLD compiler bridge) and `runtime-abi` (application staticlib) independently buildable. Runtime-only builds never link the LLVM/C++ SDK. Versioned signatures belong to `include/runtime.h`: fixed-width scalars/status, explicit output pointers, borrowed slices and matching owner releases. Decimal uses explicit little-endian low/high u64 words, not aggregate-by-value or Rust layout. Numeric success leaves Buffer null/0; errors preserve the original diagnostic and use only `dever_rt_v1_buffer_free`; compiler Reply has its separate C++ allocator. Reuse existing runtime numeric/collection/resource/protocol/system implementations. Application slices include CMD/wire/Dever/external Adapter, Model/SQL/transactions, HTTP/REST/auth/tenant/permission/Cookie/Upload/log and Job/Test. `runtime-api` adds owned application resources independently of `embedded`; `runtime-sqlite` and `runtime-postgres` select real drivers; `runtime-external` adds verified Worker resources. Private and managed Linux compilation use LLVM; formal six-target packs remain unfinished.
|
||||
|
||||
Each task root owns a component Session and its prepared bundle, propagated into child Tasks, blocking work and API requests. Copy external metadata into owned strings/vectors; never leak C name arrays or reset a process-global registry between entries. Register a Worker before awaiting startup and attach its supervisor to the root Scope. Explicit shutdown can be followed by a new start; terminal close permanently rejects admission. Retain task ownership while awaiting shutdown so cancellation can resume cleanup. Close Workers before cancelling/draining descendants, then close databases and other application owners in the same runtime. Generated typed roots use `run_entry_with_typed_resource_cleanup` to append resource failure to an existing typed fault; the original Rust backend preserves its explicit shutdown/error-cause path.
|
||||
|
||||
Private task constructors without suspension remain ordinary functions. Scope adapters should directly return the existing Tokio scoped future when no additional await is needed. An extra async wrapper can store both its input future and the child future, multiplying debug-build stack usage through nested callers; do not compensate by increasing thread stacks or boxing individual test phases.
|
||||
|
||||
Private `dever run/build/test` uses checked HIR → LLVM → object → in-process LLD, without Rust/Cargo/cc lookup or fallback. Keep LLVM calls binary-private; shared `toolchain/runtime_pack` validates compiler-relative `runtime/<platform>/manifest.json`, normalized paths, version/v1 ABI/target, four profiles and exact CRT/archive digests. Shared code must not reference bridge target methods: actual linkage pulls LLVM into launcher/daemon even when the call appears to be metadata only. Reject symlinks, scripts, thin archives and incomplete references; snapshot/recheck selected inputs before linking. Private compilation reuses `native::compile_artifact`; managed compilation requires authenticated daemon IPC and never falls back. Ephemeral signed Linux fixtures do not establish trimmed or formal six-target release assets.
|
||||
|
||||
Native authoring now uses the independent four-profile `runtime-pack` build recipe and `sdk/native-release.md`; the old all-features debug archive remains a test fixture only. Signing prepared inputs and compiling source are separate operations. Do not move Cargo/tool discovery into the maker or application run/build.
|
||||
|
||||
Linux first installation reuses the release signature and machine version owner. Author `bootstrap` inputs provide launcher/daemon and their own complete private ELF library closure. The independently authenticated installer reads only its absolute root-owned `config/setting.json`; its public key must be supplied outside the downloaded release, then pinned atomically without key replacement. A bounded journal may name only the seven fixed bootstrap destinations, including the sandbox helper tree and dedicated AppArmor policy. Persist final file modes and child directories before publication; recover uncommitted changes, but never roll back a committed publication because cleanup failed. Stop a newly started service before removing its unit; restore previous service state after restoring files. Offline image installation never invokes systemctl. Incoming packages must match the supported service template; existing signed versions retain their original template and remain usable by pinned projects. Actual global activation and public release require separate operational authorization and evidence.
|
||||
|
||||
Process main is separate from callable roots. Initialize Unix SIGPIPE policy before application threads, preserving stdout EPIPE as the existing located runtime error. Do not change an embedding host's signals from callable roots. Reuse typed fault render, indexed Test fault size/render/release, output cleanup and logger flush; Test main accepts exactly one nonempty unsigned decimal index below case count. Verify actual env-cleared CLI execution, relocated embedded Worker, selected-pack tampering on cache hits, broken stdout, and launcher/daemon dynamic dependencies. Six-target objects do not establish cross-platform execution.
|
||||
|
||||
## 2. Signatures
|
||||
|
||||
- Project creation: `dever new <new-directory> [--markdown]`; `project::create(&Path, bool)` consumes the single template source under the pinned `skills/assets/template` submodule. The machine launcher selects the active core without reading nonexistent target configuration.
|
||||
- AI guidance: `dever skill path [project-root]` resolves and verifies the active or project-pinned release's `skills/dever-language`; `dever skill install <new-directory>` atomically publishes a stable loader that invokes the absolute machine launcher. Skill resolution is a public read-only operation and must not open the root-only install lock.
|
||||
|
||||
- Application target: `dever build <root> --output <new-file> [--target linux-x86_64|linux-aarch64]`; explicit dependency preparation accepts the same target on `dever lib add/update/doctor`. Omission selects `BuildTarget::host()`. `CompileRequest.target: BuildTarget` is required and serialized canonically; test requests must use the host.
|
||||
|
||||
- Private author entry: `native-release <author-root> --output <new-dir>` calls `toolchain::packaging::create(&Path, &Path) -> Result<ReleaseManifest, String>`. It reads only `<author-root>/config/setting.json`; this is not an application configuration section or another public `dever` command.
|
||||
|
||||
- Runtime ABI examples: `uint32_t dever_rt_v1_int_add(int64_t left, int64_t right, int64_t *out, DeverRtBuffer *buffer)` and `void dever_rt_v1_buffer_free(uint8_t *ptr, uint64_t len)`. Buffer is `{uint8_t *ptr; uint64_t len;}`; Decimal output is `{uint64_t low; uint64_t high;}` and input words are separate scalar parameters. Full declarations live in the canonical C header, not independently handwritten per consumer.
|
||||
- Managed ABI: Text/Bytes/List/Map use opaque owner pointers and paired retain/release; Id shares Text representation. A process-static immutable `DeverRtType` holds size/align and clone/drop/equal/hash callbacks for the actual compiled type. `_take` consumes the non-null receiver on success, value error or invalid output; other inputs are borrowed. List/Map cursors consume their receiver and release unvisited rows on early release. Optional output uses a separate u8 presence flag, and the value slot holds the complete element/value type, including its own Nullable flag when applicable.
|
||||
- Application wire ABI uses the same process-static `DeverRtType` and synchronous typed encode/decode callbacks. Borrowed runtime wire Nodes and CMD Inputs never escape a callback or survive suspension. Validate aligned writable slots before parsing, consuming a field, selecting an Adapter or calling user callbacks. A successful decode initializes a complete typed row; if subsequent CMD `Inputs.finish()` or error-buffer validation fails, drop that row once. Partial failed rows remain the compiled callback's guarded responsibility. Keep CMD top-level `Inputs` policy distinct from nested checked wire parsing; required/unknown field wording and field-error precedence must match the Rust backend. Runtime `wire` feature supplies these owners without enabling a second protocol engine or LLVM SDK.
|
||||
- Adapter settings ABI reuses `Settings::load_adapter_settings`, name validation and selection. Runtime owns only the temporary opaque config handle; generated concrete Setting slots retain decoded fields after that handle is released. Decode errors never render Secret content. Scope drain precedes typed Setting release and the module-local entry gate reset; repeated success, partial init failure and child Task execution must preserve allocation balance.
|
||||
- API ABI: `dever_rt_v1_api_serve(session, routes, count, tenant_components, component_count, error) -> DeverRtAsyncOp*`; process-static `DeverRtApiRoute` references concrete callable/input/failure/auth/permission metadata. Claims pack borrows subject/session/optional tenant/site Text handles and initializes one concrete input row. Identity unpack borrows the returned concrete row, retains one id Text, and writes separate i64/u8 user/tenant value/presence slots. Release both unpacked id and the original output once. Runtime dispatches checked callbacks; source App is never looked up by string or called through dynamic business Value.
|
||||
- Resource ABI: File retain/release shares the runtime File; open/create/read/write/close/chunks borrow inputs, and create uses create_new. File read has an independent presence byte. `DeverRtReadEvent` supplies a concrete descriptor and Chunk/Failed callbacks, retaining borrowed Bytes/Text into the full original Choice. Stream retain/close/pull shares the runtime cursor; internal pull has typed row/presence outputs, emits a terminal Failed once and then End, and validates writable outputs before advancing the cursor or doing I/O.
|
||||
- Async owned ABI: `DeverRtOwnedType {size, align, move_init, drop, transferable}` has process-static callbacks; move initializes matching aligned storage and deinitializes the source owner. `DeverRtAsyncFunction` carries output/fault descriptors and create/resume/destroy/done/send_safe; create clones borrowed input before initial suspend. `DeverRtSyncFunction` carries input/output/fault descriptors and invoke/send_safe; invoke borrows runtime-owned input, returns 0 initializing output or 1 initializing fault, and never drops the input itself. Internal Unit rows are transferable, and internal Outputs rows derive transferability from fields; the source type properties are unchanged.
|
||||
- Async polling ABI: `dever_rt_v1_async_root(function, input, output, fault, error) -> u32` starts one task Scope; `dever_rt_v1_async_op_poll(state, operation, output, present, fault, error) -> u32` polls under only the current Context; `async_op_release` cancels/drops one operation. Statuses are Pending=0, ReadyOK=1, ReadyTypedFault=2, RuntimeError=3, InvalidInput=4, distinct from regular ABI 0/1/2 and kernel 0/1. Pending initializes no result, presence or fault. Ready moves only the appropriate typed owner; numeric faults and owned RuntimeAbi buffers remain typed faults, not business failures. Initial/pending/final coroutine destruction uses paired frame_alloc/free and does not retain poll Context.
|
||||
- Async concurrency ABI: task/group run accepts checked async descriptors or checked sync descriptors routed through the bounded blocking pool. Task/Group retain clones a wrapper owner, not the affine task/group; wait/stop consumes one wrapper and takes its inner value once. Group run and Channel operations borrow handles. Successful non-null blocking/parallel/sync-run/send operation creation consumes its typed input even if later pending, failed or cancelled; null leaves the input caller-owned. Channel receive's separate presence flag distinguishes End from Some(null) before language-level flattening. SendForeignFuture is constructed only after checked transitive frame/output/fault transferability; local root/call polling never gains Send by blanket unsafe implementation.
|
||||
- AsyncStream/TCP ABI: constructors borrow source handles and return a shared opaque producer. Rows use concrete managed clone or OwnedType move callbacks, never padding copies; separate presence distinguishes End from a nullable row. A consumption scope closes aliases on drop. Socket/Listener retain/release preserves runtime identity; async operations clone borrowed handles into their operation owner. TCP standard choices retain original value errors. Timeout returns a complete Task row plus presence, race validates matching canonical output descriptors before consuming its task wrappers, and both reuse structured runtime drain. ParallelFunction packs an element/context into one checked input row for the existing runtime dispatch owner, validating frame/input/output/fault transferability.
|
||||
- `List::into_values()` consumes unique elements and lazily clones shared elements. Emit consuming loops only for ordinary movable values; resource-containing lists retain lexical ownership.
|
||||
- `List::into_first()`, `Map::into_get()` and `Map::into_entries()` transfer uniquely owned values and clone only through a live alias. `Bytes::from_string()`/`into_text()` transfer compatible Text/Bytes storage.
|
||||
- `Render::render_to(&mut String)` recursively appends composite values into one caller-owned buffer; `render()` remains the allocating convenience boundary.
|
||||
- `concurrent::each_slice(&[T], workers, apply)` batches memory sequences; `each(iterator, workers, apply)` retains producer-owned Stream pulls. Both share cancellation/join logic.
|
||||
- `dever.http.serve(handler, Listener, Limits) -> ()` owns bounded HTTP/1 or HTTP/2 connections; `send(address, port, Request, Limits) -> Response` owns a single request and driver without a client pool. `result(send(...))` captures ResponseResult. Handler suspension is inferred. `Limits.http2: Http2Limits?` selects HTTP/2 explicitly; null selects HTTP/1.
|
||||
- `http.serve_live(handler(Request, HttpReply) -> (), Listener, Limits, LiveLimits)` owns streaming/SSE/WebSocket handlers. `respond/start/write/finish` share checked headers and a one-slot body channel. SSE Event has Text event/data and nullable Text id/Int retry_ms. WebSocket accept/connect/send/receive/messages/close reuse tokio-tungstenite; public source owns records/results/Message.
|
||||
- `http.client(origin, ClientTls, Limits, PoolLimits) -> HttpClient` creates a fixed-origin shared resource in the caller's task scope. `request -> Response` buffers; `open` and `open_stream` return StreamResponse with `AsyncStream<dever.io.ReadEvent>`. StreamRequest uses the same stream body. `close_client -> ()` closes aliases and drains its driver. Result choices describe explicit captures, not ordinary output signatures.
|
||||
- `tls.system/client/server` own public roots, explicit custom CA and server PEM configuration. `http.serve_tls/serve_live_tls` and `websocket.open(ws/wss URL, ClientTls, Limits)` reuse the same verified TCP/TLS transport. All four system http_serve intrinsics accept an optional final transferable Context, appended to the static handler signature.
|
||||
- `native::compile_with_cache(..., cache_directory) -> NativeProgram`; `cache_hit()` reports reuse. This explicit development/test path uses the workspace `target/native-artifacts`; a released `dever` must request the protected machine `deverd` service and must not expose that directory as a fallback.
|
||||
- `native::compile_test_suite(&Program, &SourceMap, rustc) -> NativeProgram`; every suite entry must come from successful checking and remains private. The suite selects one stable checked case index per process.
|
||||
- `toolchain::artifact_put(&Layout, &[u8]) -> ArtifactReceipt` and `artifact_get(&Layout, sha256, bytes) -> Vec<u8>` use authenticated daemon IPC. Receipts carry lowercase 64-hex SHA-256 and exact length, not a client-selected path. `CacheStore::summary()` returns metadata only with `verified:false`; administrator `status()` verifies bytes and returns `verified:true`.
|
||||
- `toolchain::compile(&Layout, &CompileRequest) -> Result<Vec<u8>, String>` submits canonical JSON `{version, kind, target, sources, bindings, resources}`. Sources are logical path/test origin/text; bindings contain only database names/drivers, provider verify names and site paths/auth keys. Resources are normalized paths, base64 bytes, digests and executable flags. No project root, secret Settings, IR, output path or client cache key is accepted. `SourceMap::from_compilation_sources` forces strict layout and compiler-owned standard sources; `check_with_bindings` and `validate_database_bindings` share the ordinary configuration rules.
|
||||
|
||||
- `format::format(&SourceFile) -> Result<String, Vec<Diagnostic>>`; `dever fmt <project-root> [--check]` prepares both `module/` and optional `test/` before committing either source set.
|
||||
- `SourceMap::load(root)` accepts `.dever` and `.dever.md`; plain `.md` is not program source. It derives package identity from the relative filename, and both source formats use the same `parse/check/api/run/build` contracts.
|
||||
- `SourceMap::load_project(module_root, test_root)` preserves separate display/logical paths and assigns Test origin internally. A missing test root is empty; an existing non-directory or unreadable root is an explicit load error.
|
||||
- Static API declarations are generated into the async application entry; application source does not call `dever.api.serve()`. Runtime `api::Inputs` binds GET/DELETE query parameters, POST/PUT JSON objects or a statically selected POST multipart shape, and runtime response helpers build the fixed JSON envelope. `config/setting.json` alone supplies HTTP, site/auth and upload limits; application config never uses environment variables.
|
||||
- Optional feature `reference`: `reference::evaluate(&Program, entry) -> Result<Evaluation, String>`, with captured stdout and structured source-span fault. No interpreter production CLI path.
|
||||
- `parallel_each(handler, List|Bytes|Stream, workers[, context]) -> ()`; zero-output synchronous handler, 1–256 workers, no recursively nested Stream in worker input/context. In async code the handler must be a named function with no transitive concurrency effect.
|
||||
- Synchronous and asynchronous parallel dispatch use typed failure paths. Generated handlers keep `AppError` and business-error identity across worker boundaries; String-only runtime helpers delegate to the same generic implementation and are not a compiler fallback.
|
||||
- List/Bytes accept both non-suspending and inferred-suspending handlers. Sequential collection operations await internally when required; suspending `parallel_each` reuses the AsyncStream Group path with capacity 1–65536 and the global task cap. Synchronous Stream remains synchronous.
|
||||
- `AsyncStream<T>` shares one Send producer/cursor and close signal. Traversal accepts effect-checked handlers and infers suspension; stream `parallel_each` requires a zero-output handler and a Group limit of 1–65536. Both `AsyncStream<T>` and `Channel<T>` require transferable T in every declaration position.
|
||||
- TCP connect/listen/accept/read/write infer suspension; metadata, close and stream construction do not suspend. `net.chunks` returns AsyncStream; file chunks return Stream. Explicit captures use their respective ReadStreamResult choices. No synchronous TCP backend or compatibility aliases.
|
||||
- `run`/`wait`/`stop`, bounded Group and bounded Channel are structured under one task-local Scope. Direct calls complete sequentially; `run` is the only call-level explicit concurrency operation and accepts both suspending and non-suspending static calls. `receive(Channel<T>) -> T?`.
|
||||
- Source code cannot start a runtime. A fully non-suspending entry executes directly; an inferred-suspending entry starts the single bounded runtime. `timeout(Task, millis, zero-input fallback)` and `race(Task, Task, ...)` consume affine tasks with identical output signatures. `stream(Channel|List|Bytes)` and `task.ticks(millis)` are pull-driven AsyncStreams.
|
||||
- `text.at(Text, Int) -> Text?`, `slice(Text, start, end) -> Text?`, `codepoint(Text) -> Int?`, `from_codepoint(Int) -> Text?`; positions count Unicode scalars, slice is half-open. Empty-separator split yields scalars without synthetic empty edges.
|
||||
- `int.parse`, `decimal.parse`, `float.parse` return nullable values. Decimal remains finite/exact at parse; Float supports its rendered IEEE special spellings. `to_text` uses runtime rendering.
|
||||
- `dever.process.arguments -> List<Text>` excludes argv[0] and rejects non-UTF-8 arguments. There is no application environment-reading capability. Application and benchmark configuration uses config/setting.json only.
|
||||
- `dever.time` owns UTC now, strict DateTime/Date/Time parse/format, millisecond Duration construction and checked DateTime add/subtract/difference. Deterministic operations are pure; now has a time effect. Result choices expose Read(value)/Failed(message). Existing Int clocks and zero-output sleep remain. Chrono 0.4.45 (std only) validates calendars without a local timezone dependency. `dever.net.connect_timeout(address, port, milliseconds)` requires a numeric IP and positive timeout.
|
||||
- JSON: `dever.json.parse(Text) -> dever.json.value.Document`, `stringify(Document) -> Text`; ParseResult/WriteResult apply to explicit result captures. node/property/element access returns nullable values or node IDs.
|
||||
- HTTP: source-defined Header/Request/Response/Limits. Headers are a List of Text names and Bytes values; preserve duplicate values. Bodies are bounded Bytes. Incoming names normalize to lowercase; no promise of ordering across distinct names.
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
Developer onboarding has two official repositories: `shemic/dever-main` and `shemic/dever-main-skills`. The skill directory is an independent Git repository, pinned by the compiler's submodule; it distinguishes this language from Go Dever. Author `skill` is a required list of `{source,path,sha256}` entries published as signed `skills/dever-language/<path>` artifacts, including SKILL.md; at most 256 files, 1 MiB each and 8 MiB total. New-project source bytes come from the same skill templates; Markdown documentation wraps them without a second semantic copy. Keep the generated configuration CMD-only and free of database/auth prerequisites.
|
||||
|
||||
Official `install` and `update` explicitly download from the compiled GitHub Releases origin; neither project setting.json nor environment variables choose that origin. Each platform publishes `dever-<platform>.manifest.json`, `.manifest.sig`, `.tar.gz`, with immutable version tags `v<major.minor.patch>`. Verify the pinned signature before unpacking, require a matching skill, reject unsigned/duplicate/nonregular/escaping archive entries, and bound both metadata and payload. USTAR contains only signed artifact files; the two metadata files are separate assets. Redirects remain on the enumerated official HTTPS asset hosts. Privately staged publication never overwrites an existing release; malformed downloads leave the active version unchanged. The existing active-version journal is the sole commit point for both core and skill. The stable skill entry resolves it on each task; do not maintain a second independently switched skill pointer or rewrite every user's home directory.
|
||||
|
||||
The skill's first-install helper uses its independently distributed public key, verifies the full package before executing bootstrap, and generates only an owned temporary installer config. It never reads business configuration. Real-root installation explicitly starts the machine service unless --no-start is selected; image-root verification must not touch host services. Python/OpenSSL are installer prerequisites, not application runtime dependencies. `sdk/release-assets.py` publishes the three deterministic assets with Linux NOREPLACE directory rename and cannot clean another writer's public output.
|
||||
|
||||
Native author settings use `dever-native-release-input-v1`: version/target/signing_key, core and `core_libraries` inputs, required base/sqlite/postgres/both archive inputs, and ordered start/libraries/end. Input `source` is a normalized author-relative path and `sha256` is the exact lowercase digest; link inputs also specify the destination `path`. Core libraries use plain SONAME filenames and publish below `lib/`; runtime link inputs remain runtime-relative. Reuse the runtime v1 manifest types and validator. Copy into exclusively owned private staging, verify both source and copied digest, sort manifest artifacts, and sign those exact bytes with the supplied owner-private Ed25519 PKCS#8 key. Reject key content as payload, symlinks, path escape and reserved/duplicate destinations. Keep source paths, key material and timestamps out of release metadata. Linux publication uses atomic NOREPLACE; failures clean only owned staging. No tool invocation, environment configuration, downloads, input hard links or implicit trust-key installation belongs to this owner.
|
||||
|
||||
`toolchain::core_libraries` validates the same complete ELF closure during authoring and verification of signed native releases (identified by the signed native runtime manifest). Require matching GNU target/class/endian, exact SONAMEs and core `DT_RPATH=$ORIGIN/lib`, with no core RUNPATH. Libraries may resolve only their own private directory; every non-OS dependency must be declared and every supplied library used. Reject loader audit/filter hooks, host `/etc/ld.so.preload`, unsigned flat leaves, symlinks and subdirectories including glibc-hwcaps. The installed directory must exactly match the signed regular files. GNU libc-family/loader are the explicitly declared OS ABI exception; LLVM and its transitive non-OS dependencies are not host prerequisites. Generic signed payload fixtures without a native manifest do not become compilable releases. Verify a real signed compiler can check/build in an owned root containing only declared OS ABI libraries, then run the output after deleting its source.
|
||||
|
||||
Build archives with the root `runtime-pack` profile, one shared Cargo target, and independent features: runtime-api/runtime-external for every profile, then runtime-sqlite/runtime-postgres for the selected drivers. Never enable embedded or strip linkable symbols. Deterministic packaging of fixed inputs does not establish cross-host reproducible source builds. Assert actual driver separation and run the produced programs; a file named base.a is not evidence of trimming.
|
||||
|
||||
Runtime ABI status is u32: 0 success, 1 original runtime value error, 2 invalid ABI input. Output slots must be live, writable, aligned and disjoint; borrowed inputs stay live and immutable for the whole call. Null input is valid only at zero length; reject lengths outside usize/isize range before constructing a slice. Caller releases any previous Buffer allocation before reuse, and releases each returned non-null allocation once through the matching runtime ABI. Error paths do not initialize numeric output. These are compiler-owned internal interfaces, not a language-level unsafe capability; application configuration gains no environment variables.
|
||||
|
||||
Managed element storage is checked size/alignment with 8-byte-aligned `MaybeUninit<u64>` backing; descriptors and callbacks stay valid for the entire collection lifetime. Clone/drop/equality/hash act only through the concrete typed callbacks, never by reading/copying raw bytes or padding. Reuse runtime `List<Element>` and `Map<MapKey, Element>` COW/cursors. Element implements PartialEq, preserving NaN semantics in List and Map values; only MapKey wraps an eligible key with Eq/Hash. A non-null receiver passed to `_take` is owned by the call even if output validation fails. Successful optional output with presence=0 leaves its row untouched: never read it. Presence=1 initializes the entire row; its own Nullable flag remains authoritative. Text split receives the same Text descriptor used by ordinary List<Text> construction, not an independent runtime type identity.
|
||||
|
||||
The runtime C ABI's value-error status does not by itself define a recoverable language failure. LLVM BytesToText/BytesFromInts/BytesSlice explicitly map status 1 to their checked standard Failed(Text), then use ordinary library fail/result propagation; other value faults (such as duplicate Map keys) remain RuntimeAbi. Status 2 always propagates as an ABI fault. Convert the original error Buffer to an owned Text handle, free the Buffer exactly once, and read value output only on status 0. LLVM business payloads belong to its typed fault tail and reuse compiler capture/drop owners, not a new C runtime error registry; details live in compiler-contracts.md.
|
||||
|
||||
Protocol async operations validate output shape/alignment and required presence before polling, including operations whose successful output is Unit. Invalid metadata returns async ABI status 4 without changing the operation or output/fault sentinels; a corrected call may retry. Do not perform network I/O and only then reject the result destination. Protocol getters validate destination slots before allocating owned Text/Bytes/stream fields. Their fixed records and handles live in runtime.h; this is not a generic serializer ABI.
|
||||
|
||||
Nominal type properties are computed once after cycle validation. Contract analyses reuse a shared dependency graph; monotone summaries enqueue only affected callers. Bundled AST is cached in process; each thread retains only its preceding exact path/text checked Program or diagnostics. Separate CLI processes still check all source and the API baseline.
|
||||
|
||||
Native optimization works on a cloned checked clause: coalesce record aliases only while old fields remain equivalent; snapshot only ordinary scalar future reads with no intervening host writes; move only the final overlapping read of a replaced field. Direct split consumers reuse runtime text::pieces Unicode/empty-separator semantics. Fuse only non-resource stages with structurally proven total, synchronous, effect-free handlers; pure alone never proves totality. Preserve argument evaluation, numerical faults and left-to-right Float sums.
|
||||
|
||||
Native argument bindings are disposable owned temporaries: a final movable read transfers the source, while a still-live or resource-containing source first clones according to value/resource semantics. Collection lookup/extraction and compatible Text/Bytes conversions consume that temporary so uniquely owned payloads are not copied. Composite rendering recursively writes into one String, including generated records/choices and named entry outputs; numeric `to_text` keeps its direct `to_string()` hot path.
|
||||
|
||||
Memory parallel batches are capped at 256 elements and check the shared cancellation state between elements. Stream dispatch remains item-by-item. HTTP uses Hyper's incremental protocol state. Bytes shares engine buffers and slices; multi-frame body collection merges incrementally instead of retaining metadata per tiny frame. Empty concat reuses storage without altering aliases.
|
||||
|
||||
Native cache keys use SHA-256 and include emitted input, compiler/toolchain identity, exact runtime/dependency bytes, options/platform/environment. A compiler-owned path/size/mtime memo avoids rehashing unchanged runtime files on every cache hit; any metadata change recomputes the exact content identity, and a cache miss verifies copied runtime bytes against that identity before compilation. Publish complete private entries atomically; restore verifies identity and executable integrity, copies into per-call temp ownership, and never skips CLI API checking. Generated Rust is not persisted. Cache digests are content identities, not publisher signatures.
|
||||
|
||||
The machine toolchain has one stable `dever` launcher and a signed version store. A project may select one exact `major.minor.patch` only through `config/setting.json` at `dever.version`; otherwise the machine active version is used. The launcher verifies the signed manifest and selected core before reading project source, then preserves arguments and exit status. Version publication uses staging and an atomic directory rename. Active-version state is a checksum-valid append journal so an interrupted write leaves the preceding complete record selectable on Unix and Windows. Cargo's default `dever` binary is the development compiler and becomes `dever-core` in a release. Cargo's `dever-launcher` binary is the machine entry installed as `dever`; it must stay independent of LLVM. This is one public command name, with no legacy command alias. Builds do not replace global commands.
|
||||
|
||||
`dever clean <project-root>` is a version-core project command. It removes only current-user Dever temporary build directories and legacy `.dever-run-*` files that pass exact name/type/owner/age checks and whose recorded process is no longer active, then reports directory, file and byte counts. It never touches explicit build outputs, project data, Lib artifacts, Cargo caches or the machine store. `dever cache clean` remains an authenticated `deverd` machine-cache operation with no local fallback; compiler contributors use `cargo clean` for the repository `target/`.
|
||||
|
||||
`deverd` is the only writer of released cross-user cache entries. Its storage engine uses complete length-delimited build inputs, SHA-256 identities, immutable atomic entries, integrity checks, active-operation/read leases and maintenance locking. Released clients require platform-authenticated IPC; service unavailability fails without a private or uncached fallback. OS service installation and remaining platform peer adapters belong to distribution, not a caller-writable cache directory.
|
||||
|
||||
Linux cross-user IPC uses kernel `SO_PEERCRED`, verifies endpoint/server UID against the machine state owner and keeps the administrator token owner-only. Foreign users may transfer Lib artifacts and query metadata, never clean or trigger whole-cache hashing. Eight slots admit at most two foreign connections per UID and six total. Artifact transfer accepts at most 64 MiB and stores at most 2 GiB/4096 entries. Compiled entries have separate equal quotas and are inaccessible through ArtifactGet/Put: retrieving a program requires its complete canonical compilation request.
|
||||
|
||||
Compilation admission is two requests, with 384 MiB wire input, 4096/16 MiB sources and 4096/256 MiB resources (128 MiB each). The service verifies the installed release and complete runtime reference closure, including its native manifest and LLVM library, on every request before cache lookup. Identity binds canonical input and signed manifest; a persistent private install lock is shared through compilation/transfer and exclusive for install/uninstall, never unlinked. The whole request holds a cache operation; transmission retains its read lease. Only the signed core's hidden compile worker may produce a publishable program. It reads a service-owned 0600 request inside 0700 staging; trusted state/cache ancestors remain traversable and non-writable by other users. The worker reconstructs/checks sources and links, without loading deployment Settings, preparing Libs, executing App/Test or writing shared cache. Caller retains dependency preparation, baseline checks, program execution and per-case Test settings. Worker CPU/address-space/file/FD/core limits do not constitute an OS sandbox.
|
||||
|
||||
Compilation has a 180-second absolute service deadline, including output transfer, and a 190-second client download deadline. EOF or any byte following the exact request body cancels it. Every failure kills/waits for the owned child and removes staging; diagnostics are capped at 64 KiB and output at 320 MiB. This output budget applies only to trusted compilation receipts, never opaque ArtifactGet/Put. Apply remaining time before each write/read; a per-call socket timeout alone permits slow peers to retain version and cache locks indefinitely.
|
||||
|
||||
Binary resources cross the compiler bridge separately from bounded textual LLVM IR (8 MiB). The shared resource index verifies digests and binds path, length and executable status into IR/cache identity; declarations are initialized from borrowed bytes before LLVM verification and optimization. Reject duplicate, unused, defined or incorrectly typed/sized attachment symbols; six targets preserve the same byte order. Do not hex-escape interpreter payloads into IR or duplicate large byte buffers merely to sort canonical requests. Attachment limits match the managed resource count/byte budgets, and bridge object output is bounded at 320 MiB.
|
||||
|
||||
### Signed ecosystem pack contract
|
||||
|
||||
#### Scope / trigger
|
||||
|
||||
Native release authoring also packages Python/Node interpreters and Go build tools; Worker preparation consumes the same runtime descriptor validator. The author recipe lives in [sdk/native-release.md](../../../sdk/native-release.md).
|
||||
|
||||
#### Signatures
|
||||
|
||||
`packaging::create(author_root: &Path, output: &Path) -> Result<ReleaseManifest, String>` and the private `native-release <author-root> --output <new-directory>` command retain their signatures. `workers::runtime::validate(files, ecosystem, target)` owns descriptor/file-closure validation for both maker and consumer.
|
||||
|
||||
#### Contracts
|
||||
|
||||
Author `config/setting.json.runtimes` optionally contains `pip`, `npm`, `go`; each has `name`, `version`, `files[{source,path,sha256}]`, with Python `python_markers` and `python_wheel_tags`. Configuration is bounded at 1 MiB. Sources are explicit regular files below the author root, never env/PATH lookup. The deterministic archive and registry manifest are both signed artifacts; neither an upload nor a filename can substitute for their verified identity. Prepared trees have their own 256 MiB/65,536 file bound; final embedded applications must also fit the stricter compilation limits above.
|
||||
|
||||
#### Validation and error matrix
|
||||
|
||||
| Input | Result |
|
||||
| --- | --- |
|
||||
| Missing interpreter, Go tool or importcfg archive | Reject incomplete pack before publication |
|
||||
| Wrong ecosystem/target, Python flags or registry metadata | Reject runtime identity/descriptor |
|
||||
| Duplicate/escaping paths, file/directory conflict, symlink, wrong digest or signing-key alias | Reject author input; remove owned staging |
|
||||
| Existing output or concurrent second publisher | Preserve published output; reject replacement |
|
||||
|
||||
#### Good / base / bad cases
|
||||
|
||||
Good: complete relocatable interpreter plus stdlib, or Go tools plus every referenced stdlib archive. Base: omit `runtimes` for an otherwise complete native-only release. Bad: copy only host Python and assume its host stdlib will exist on the destination; a descriptor alone cannot prove portability.
|
||||
|
||||
#### Tests required
|
||||
|
||||
`native_release` verifies deterministic archive/signature bytes, computed runtime identities, bad inputs and rollback. Explicit ecosystem acceptance must lock from a signed install, execute all three managed check/run/build paths, then run programs after removing source and machine stores inside a root with no system language installations. OS baseline libraries, test key, target and untested third-party registry behavior remain explicit.
|
||||
|
||||
Linux no-host acceptance must use a genuine mount-namespace root, shared by core, Worker and bootstrap fixtures. A plain `chroot` leaves the process root different from the namespace root; Linux then rejects the Worker's nested `CLONE_NEWUSER` with EPERM, even for root. Use the explicitly prepared bwrap/loader assets to bind only the owned OS root, private proc and synthetic devices. Do not remove the product's `--unshare-all` or add host library/tool mounts to make the fixture pass. A successful managed run does not replace source-free standalone execution under this boundary.
|
||||
|
||||
Managed Go compilation removes the invocation-owned stage prefix from recorded source paths with `compile -trimpath`. This applies to the SDK, Adapter and dependency packages through their single `Builder::compile` path. Preserve logical filenames/line numbers; do not strip diagnostics to hide path drift. The real `external_workers` Go fixture must prepare twice in fresh stages and compare every emitted resource identity, then execute after source removal. The signed three-ecosystem run/build test must publish exactly three compiled entries. A single successful Go execution does not prove reusable cache identity.
|
||||
|
||||
#### Wrong versus correct
|
||||
|
||||
Wrong: encode a 118 MiB Node binary as LLVM text, raise the IR cap, or relax opaque artifact upload limits. Correct: pass checked bytes through `emit_object_with_resources(ir, target, resources)`, keep the IR cap, and apply the separate trusted compile/output budgets. Test author compression deadlines do not change the service's compile deadline.
|
||||
|
||||
Formatting is syntax-only, stable and idempotent. It preserves comments, literal values, explicit groups and source record-expression order. Prepare every source before rewriting any; snapshot changes, syntax errors, symlinks and staging failures do not modify originals. Each final replacement is atomic, not a multi-file OS transaction.
|
||||
|
||||
Application-test execution reuses checked HIR, specialization, runtime feature trimming and native cache; it does not link the reference evaluator or add an interpreter. Each case owns its temporary executable/project directory. Production check/api/run/build source sets and cache inputs exclude `test/`; fmt intentionally includes it.
|
||||
|
||||
Port native dispatch is a direct call for one implementation or a closed match for several. `adapter.<port identity>.use` in setting.json selects an immutable startup binding; unknown choices/fields, duplicate keys and missing required settings fail before user code. A selected Adapter's contextual `setting` is a private record decoded with shared SettingInput wire helpers into a OnceLock; only that implementation requires values. Secret errors never include input values. Port-only binaries enable the wire feature without API/database runtime; a no-setting single implementation may omit setting.json. Runtime configuration uses the bounded duplicate-aware wire parser before typed config parsing.
|
||||
|
||||
External exec and managed dispatch remain behind the same selected Port binding. All executions require a verified embedded launch manifest, selected Worker tree and signed sandbox assets; no adjacent-file or bare-process fallback exists. CLI preparation reads sandbox leaves from the authenticated installed release, including for exec. Runtime clears the environment, maps only verified launch arguments, and uses length-framed stdio. Native and LLVM dispatch reuse one invocation settings snapshot for Adapter selection, business settings and deployment grants. Component tasks reuse the structured application Scope and bounded queues. `hello/ready/health` must finish before admission; request ids are sequential and exact; timeout/cancellation sends `cancel` and drains one matching `result` or `error`; application cleanup sends `shutdown`, joins, then uses a bounded kill only when the child misses the deadline.
|
||||
|
||||
Python SDK input uses an owned asyncio pipe transport and bounded message queue. Cancel and join its reader on normal shutdown and protocol failure, then close the transport. A daemon thread blocked on `sys.stdin.buffer` can abort CPython finalization even after a successful reply. The SDK regression must wait for successful process exit after the shutdown reply while the parent's stdin writer remains open; closing it before waiting masks this lifecycle defect. SDK source changes require re-embedding the source through the host compiler before deployed application verification.
|
||||
|
||||
`dever-sandbox` owns the Linux namespace/seccomp launch boundary for Workers and Go build tools. The signed release supplies bwrap, the safe Rust guard and a bounded, complete ELF library closure; the loader cannot select libraries through environment or cache, and a host preload configuration is explicitly unsupported. Mounts are opened with no-symlink `openat2` and passed as owned descriptors to bwrap bind-fd, preserving the authorized inode across rename/replacement. Never replace this with a path check followed by a path-based bind. Input trees are read-only; output grants cannot overlap executable inputs. The runtime additionally rejects writable grants overlapping `data/cache/lib`, including its ancestors. Sandbox descendants inherit the syscall filter; namespace termination stops even children that changed process groups.
|
||||
|
||||
The signed bwrap must be a bounded static target ELF without an interpreter or dynamic dependencies and is executed directly. On Linux with restricted unprivileged user namespaces, a non-root launch requires the matching digest-addressed `/opt/dever/sandbox/<sha256>/bwrap`, root ownership of every ancestor, no shared writes/symlinks/setuid/setgid/file capabilities, and both dedicated AppArmor profiles loaded in enforce mode. The helper bytes must match the embedded signed asset. The runtime and real-host bootstrap require `kernel.apparmor_restrict_unprivileged_unconfined=1` before using/publishing this policy: at zero, unconfined callers can enter a capability-granting profile through `aa_change_profile`, independently of its executable attachment. Missing or invalid strict-policy state fails closed. The product only reads these flags; administrator-approved installation/loading owns host changes. Root or unrestricted-host execution uses the same embedded static helper, never an unsandboxed fallback. Test normal and immediate profile changes, with and without no-new-privileges, using the same-process negative probe; wrapper failure or unavailable setup is not a successful isolation check.
|
||||
|
||||
With `process=true`, POSIX spawn may reset only the effective UID/GID to the guard's captured real identity: `setresuid(-1, uid, -1)` and `setresgid(-1, gid, -1)`. Compare all three arguments as 32-bit kernel IDs; changing real/saved IDs or choosing another effective ID remains denied. Without the process capability both calls remain denied. Keep clone3 returning ENOSYS so libc falls back to the flag-checked clone syscall. The actual C probe must cover both capability states, high-bit-equivalent IDs and rejected identity changes; an unrestricted setuid allowlist is not an acceptable build compatibility fix.
|
||||
|
||||
Deployment file grants live only at `adapter.<port>.files.<name> = {"path":"data/upload", "write":true}` in setting.json. Checked `allow file` is required, names are bounded lowercase identifiers, paths stay below the application, and write defaults to false. Workers see `/data/<name>`; grant configuration is not business `setting`. Network capability permits IP sockets and supplies only bounded resolver files; host Unix sockets and raw sockets stay denied. Process capability controls fork/process clones separately from threads. GPU and unsupported OS backends fail explicitly. Linux additionally requires host permission for user/mount/PID/network namespaces and openat2; root fixture success does not establish non-root deployment support under AppArmor.
|
||||
|
||||
Go tools consume a private read-only input tree, including parent-generated import/embed configuration, and a separate writable output tree. Parent reads of generated programs must use nofollow, regular-file and single-link checks with a byte limit; never follow a tool-created output symlink on the host. Runtime/SDK/build assets and source remain explicit, without host compiler discovery or implicit networking.
|
||||
|
||||
When a build carries compiler-owned external resources, the native entry may include a deterministic resource table. `dever-runtime::external` releases it only below the executable's private `data/cache/lib/<digest>` directory, rejects symlinks/path escape and digest mismatches, and publishes a complete staging directory atomically. Existing entries are reverified; a concurrent publisher is reusable only after the same manifest verifies. This extraction path never downloads, consults `PATH`, or accepts runtime paths from environment variables.
|
||||
|
||||
Resource SHA-256 must match its bytes before native cache lookup. The bootstrap emitter references private, fixed-name binary compiler inputs with `include_bytes!`, not decimal arrays; identical checked digests share one static payload/input while each resource retains its own path and executable bit. Emitter and build staging reuse one input-index owner so they cannot diverge. The build owner stages each unique input exclusively and removes it with the owned build directory. Runtime integrity checks compare exact length, every 64 KiB chunk and digest against embedded bytes without allocating another whole interpreter image.
|
||||
|
||||
Markdown Port function metadata adds `- 允许失败:` after outputs, exactly matching the fails type. Qualified Adapter/fake names remain complete in their function sections and private in public-method lists. The setting declaration uses a Type section named Setting with its ordered field metadata.
|
||||
|
||||
Markdown selects only top-level fenced blocks with literal info `dever` or `typescript dever`, using pulldown-cmark offset events rather than scanning fence-like lines. Quotes/lists/raw HTML/outer showcase blocks are presentation. Fences use backticks or tildes with CommonMark 0–3-space indentation and require explicit closure. Both formats own one package each; duplicates are C002, never merged or selected by priority.
|
||||
|
||||
Every `.dever.md` has exactly one natural-language H1 source section. Its ordered metadata is path-derived source identity, role-derived public types/functions and logical App call examples; H1 has no executable program block. Every type or logical `(function name, arity)` group has one natural-language H2 section and one program block. All clauses in a logical function share that block and input names. Type fields/variants and checked function parameter/output names, types, counts and order must equal their documented inline-code values. Empty collections use explicit `无`; per-field/variant/input/output descriptions are nonempty. H3 and deeper supplemental content may follow the section's program block.
|
||||
|
||||
Closing-fence trailing whitespace is spaces only; a tab is rejected as an unclosed program fence (M002). The Markdown parser receives a same-byte-length view with bare CR changed to LF because it does not recognize bare CR itself; source text, code lexing and diagnostic positions still use original bytes. CRLF is kept intact. Do not normalize entire files or introduce an offset-changing source transformation.
|
||||
|
||||
SourceFile text, cache keys and CLI snapshots retain original Markdown. Lex each block in its original byte range so Text/comment scanning cannot enter prose. Parsing and formatting share extraction, structural documentation parsing and block validation. After function signature resolution, semantic checking compares documentation with syntax/HIR before checking bodies. Formatting reuses the AST emitter per block, keeps comments in their block, preserves external/fence bytes and the opening fence's newline style. Runtime faults keep original positions through HIR. The Markdown dependency belongs only to the compiler.
|
||||
|
||||
Native last-use analysis walks actual HIR read order backwards, kills full assignments, retains field hosts and outputs, and identifies concrete expression nodes. It may move only ordinary values: resource aliases have observable lexical scope. Share recursive type-property traversal for comparability, transferability and movability. Full-domain Int/finite Decimal guards need no runtime comparison; actual clause bounds must remain.
|
||||
|
||||
`native::OPTIMIZATION_ARGS` owns production and baseline optimization flags: level 3, one codegen unit, fat LTO and stripped symbols. The workspace release profile applies matching fat LTO/one-unit settings so the runtime rlib retains bitcode for the final native link. Multiple units caused larger generated programs to miss the same generic hash-table inlining performed in a small equivalent baseline. These settings preserve that optimization without weakening overflow, hashing or panic semantics, at the cost of compilation parallelism and higher compile time/memory. Stripping removes release-only debug/symbol payload without removing source-location strings used by Dever failures.
|
||||
|
||||
Application test suites use level 1, 16 codegen units and stripped symbols without LTO. Tests validate observable semantics and source locations rather than production optimization quality; `run`, `build`, performance fixtures and baselines continue using `OPTIMIZATION_ARGS`.
|
||||
|
||||
Concurrent actions use a caller-owned producer, bounded queue and joined workers. Exact-size finite iterators create at most one worker per item/batch; unknown-length Stream iterators retain the explicitly requested limit. Faults stop further dispatch at cooperative pull boundaries and already-running work drains. Pending blocking operations require explicit OS timeouts; there is no forced cancellation or detached task. The reference evaluator chooses a documented sequential valid schedule; concurrent stdout ordering is not a differential invariant.
|
||||
|
||||
Inferred-suspending entry points own one bounded Tokio runtime. Child Scopes share task and blocking semaphores but keep independent work registries; body cancellation cannot cancel the supervisor responsible for recursively draining descendants and started blocking jobs. Transitive effect checks, including concrete handler substitution at wrapper calls, keep blocking system operations, stdout and hidden collection-handler concurrency off Tokio scheduling workers unless they use an explicit bounded boundary.
|
||||
|
||||
Async stream consumers reserve capacity before pulling and observe child failures while the producer is idle. Close releases the producer's resource alias, not independently held Socket/Listener aliases. TCP has independent direction locks and a shared close signal. Read buffers allocate after readiness. Operation deadlines include direction-lock wait; partial writes close the socket on abandonment before releasing the write lock, with no whole-message retry. DNS uses the shared bounded blocking path.
|
||||
|
||||
Single `read(resource, limit)` uses the caller-selected maximum buffer and must not silently clamp a valid limit: regular files may satisfy the whole request in one operation, and existing programs rely on that result. Reads may still be short according to the OS. Memory-constrained programs choose a smaller limit or use the pull-based `chunks` interface; the runtime cannot both return a `limit`-sized value and avoid storing that value.
|
||||
|
||||
JSON uses an acyclic flat document: consecutive postorder IDs, child IDs smaller than parents, Map insertion order independent of ID order. Numbers retain raw JSON spelling, object names are unique, Unicode surrogate escapes must pair. Maximum depth is defined once in `dever.json.value`; stringifier checks references and output expansion before concatenating child graphs.
|
||||
|
||||
Typed wire boundaries are separate from the ordinary source Document API. Runtime `wire` (enabled by api) owns a borrowed RawValue parser with duplicate checks at every object, total byte/value/depth budgets (16 MiB / 65536 / 64 containers), exact raw Json and bounded output writing. A missing object value must retain its incomplete frame; a byte-limit failure after a partial write permanently prevents `finish` from publishing Encoded JSON. Generated concrete decoders reject unknown/missing fields and never pass Node/Value to business signatures. API envelopes embed only validated Encoded JSON, preserving raw numeric spelling. Decimal/Id/Uuid use strings; DateTime normalizes offsets to UTC Z with optional three-digit milliseconds, Date is YYYY-MM-DD and Time is HH:MM:SS[.sss]. Reject leap seconds, submillisecond precision and calendar years outside 0000–9999. Duration remains a JSON i64. Select root `time_codec` with api/reference for the concrete native decoder, policy, parser budgets, native/reference time, Markdown parity and in-process API dispatch checks; it opens no listener.
|
||||
|
||||
HTTP/1 server connections reuse AsyncStream/Group and the same task cap, with capacity reserved before accept. Hyper owns framing/keep-alive/pipelining; source handlers specialize directly. Preserve exact record-field contracts at the intrinsic boundary and include callbacks in effects, dependencies and specialization. Header/body/handler/stalled-write deadlines release peers. Configuration, accept and handler faults propagate through serve; ordinary peer failures end only that connection. send errors return Failed, with driver and request polled together. No old wire/transport API, detached driver, fallback parser, TLS/HTTP2, nonempty trailers, upgrades or streaming response in N2. Tests compare with independent wire peers.
|
||||
|
||||
N3 live HTTP adds one structured handler per connection, using a Group of one under the same global cap. Response body Drop cancels even HEAD/buffered handlers; a Session guard ends the stream or upgraded socket and invalidates escaped aliases. Ordinary stream idleness is a peer failure; handler faults retain their original error and cannot become a successful chunked EOF. An upgraded Hyper driver finishes before its WebSocket handler: the connection owner must wait for that handler and keep its capacity reservation. Take the concrete TCP/TLS Transport and pre-read bytes out of Hyper on upgrade so the HTTP write timeout does not leak into WebSocket policy.
|
||||
|
||||
LiveLimits owns maximum chunk bytes, stream idle deadline and shorter SSE heartbeat interval. A one-slot channel bounds pending chunks; business values, current writes, engine and OS buffers are additional memory. SSE normalizes CR/LF/CRLF without discarding trailing data lines, rejects event/id field injection and checks encoded length before allocation. Heartbeats are three-byte comments generated by polling the body; replay/history is application-owned through Last-Event-ID.
|
||||
|
||||
WebSocket data message and write-buffer sizes are bounded, including fragmented messages; read buffer is 4 KiB. Independent direction locks permit simultaneous receive/send. Acquire the writer lock before arming the partial-frame cancellation guard: cancelling a lock waiter cannot close another task's active frame. Endpoint.take wakes ordinary operations while transferring the final transport owner to close-handshake cleanup. Control frames, masking and UTF-8 belong to tungstenite. receive/messages must be polled to process Ping/Close; no detached reader or automatic Ping task. Limits are per operation. N4 adds WSS; subprotocol negotiation, compression and close metadata remain deferred.
|
||||
|
||||
N4 pool ownership uses separate request and physical-connection permits, each bounded by Limits.connections; idle and queued drivers count as physical connections. One structured task polls bounded queued/running Hyper drivers and idle expiry. Construct its owner guard before task creation so dropping an unpolled child closes escaped pool aliases. Never detach a connector/driver or retry partially sent requests. Return a connection only after complete EOF; body drop/fault/cancellation aborts that entry. Streamed transfer bounds chunks/read waits, buffered request additionally bounds total body/time. TLS shares trust/session caches with full certificate/name verification and small explicit cache capacities; public roots are bundled, custom roots replace them.
|
||||
|
||||
N5 HTTP/2 reuses Hyper/h2 with fixed receive windows, 16 KiB frame/send-buffer settings and decompressed header budgets. TLS must negotiate h2 through HTTP-specific derived configs; do not mutate shared WSS trust/config or downgrade. Cleartext uses prior knowledge. Source targets remain path/query; normalize authority to Host when Host is absent and reject disagreement. Connection-specific fields are rejected rather than silently rewritten. HTTP/1 upgrades remain the WebSocket path; no extended CONNECT, h2c Upgrade, push or nonempty trailers.
|
||||
|
||||
Hyper's synchronous Executor must spawn registered Dever child Scopes. A scoped connection owner drains these tasks and their started blocking descendants before releasing its physical guard. Serialize closing admission and child registration; task completion must include fault reporting and registry removal. Cleanup belongs to an uncancellable registered supervisor, even when the caller awaiting stop/finish is cancelled. Never take a Scope work registry in a cancellable caller and lose the captured completions on Drop. Each live HTTP/2 body owns its handler Group under that stream's Scope, so reset before/after response head cancels only that handler; report handler errors explicitly because Hyper consumes HTTP/2 stream errors internally.
|
||||
|
||||
HTTP/2 clients lease streams independently from physical connections, with total requests bounded by connections × streams (at most 65536). Upload and download share the stream lease; neither idle expiry nor another stream's cancellation may abort an active neighbor. Create connection scopes when handshake jobs are polled by the persistent pool driver, never under a temporary request Scope. Queued, idle and draining connections share the physical budget. GOAWAY blocks new dispatch while accepted streams drain. Only Hyper TrySendError returning the unsent request permits requeueing within the original deadline; release the old upload lease first. Never replay a sent request or infer dispatch safety from an error string. Single-use send has one deadline spanning TCP, handshake and exchange, followed by mandatory cleanup. Server first-request and protocol liveness deadlines prevent silent peers from holding connections indefinitely.
|
||||
|
||||
Listener close ends HTTP accept normally and triggers Hyper graceful shutdown while active response/upgrade owners drain. Public net.connections retains a terminal Failed on explicit listener close. Applications use timeout(server, grace_ms, zero-output fallback) for bounded drain. timeout/race borrow supervisor handles while waiting, cancel and drain losers/expired work before returning, and preserve runtime faults. Source stream adapters retain failure obligations and nullable Channel items; closing the adapter does not close independent Channel aliases.
|
||||
|
||||
Create a response-body cancellation owner only after the pending-to-committed state transition is accepted. Dropping an uncommitted replacement body must not close a previously committed response. Rejected duplicate start/respond/SSE/upgrade calls leave the existing stream usable; head delivery failure still cancels the committed body.
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
- New target already exists (including empty directory or dangling symlink), parent is missing, or two creators race: explicit error for the loser; preserve the existing target and remove only owned staging.
|
||||
- Signature/payload/skill corruption, incomplete archive or absent official asset: explicit failure, no active-version switch or fallback. Publishing source code is not evidence that a downloadable binary release exists.
|
||||
- Project has a pinned version: `skill path <project>` resolves that exact installed version. Foreign users must read matching guidance without obtaining the administrator install lock.
|
||||
|
||||
Cross build: unknown/duplicate/missing target arguments fail before execution; absent target packs, wrong ELF/archive machine, unsigned target leaves, incompatible lock/runtime/build receipt and changed target inputs fail before cache reuse or linking. Sandbox loader mode must survive both release packing and embedding; a hash-valid non-executable loader is not a usable Worker resource.
|
||||
|
||||
Native maker: wrong format/target/version, unknown or duplicate fields, missing profile, non-regular or escaping source, SHA mismatch, exposed/invalid signing key or payload alias to key → explicit failure, no output, owned staging removed. Existing or concurrently created destination → no overwrite. Correct bytes → complete signed v1 directory, without private key/configuration. Unsupported host publication → explicit failure, never a weaker rename fallback.
|
||||
|
||||
Managed input with duplicate fields/noncanonical encoding, unsafe paths or forged resource digest -> reject before publication; invalid source -> original checker diagnostic from worker; unavailable service -> explicit failure; signature/runtime omission or tampering even on a cache hit -> reject; full admission/quota -> bounded error; cancellation/expiry -> reap child and release staging/locks without publishing a partial executable.
|
||||
|
||||
External Lib declarations live only in the root `lib` array of `config/setting.json`; reject old `config/lib.json` rather than silently combining it. Canonical v2 locks bind checked Port/Adapter/schema/operations/capabilities and independent Worker roots. Doctor rejects a stale binding, missing/cyclic closure or within-environment version conflict; independent Workers may select different versions. Embedding compares complete normalized provider metadata, including dependencies, runtime, schema and artifacts, not just an artifact subset.
|
||||
|
||||
Production registry access derives from a verified installed version core, not host tools or development fixtures. Signed assets `runtime/<ecosystem>/<target>/manifest.json` (`dever-registry-runtime-v1`) bind a RuntimePack identity and the adjacent `runtime.pack`; Python metadata additionally supplies target markers/wheel tags. Resolver owners implement PyPI PEP 508/440, npm semver/SRI and Go proxy/MVS with fixed HTTPS origins, disabled proxies and bounded work. Redirects are rejected except for Go `/@v/*.zip`: manually follow at most two hops into the exact HTTPS `storage.googleapis.com/proxy-golang-org-prod/` bucket. Validate normalized host/port/path and reject userinfo, fragments, missing/duplicate Location and loops before issuing a subsequent request; all hops share one 30-second deadline and diagnostics must not echo signed URLs. Loopback mirrors are explicit test transport, never project configuration. Unsupported extras/sdist, install hooks and Go sumdb are not silently approximated. Only explicit Lib commands resolve/download; run/build consume locked cached bytes.
|
||||
|
||||
`workers::prepare(root, Program, resources)` expands verified runtime packs (`dever-worker-runtime-v1`: ecosystem, target, executable, arguments; Go additionally has `build`) and owned Adapter sources into a per-contract `workers/<digest>` tree. Python uses exactly `-I -S -B`, installs the packaged SDK path and imports that trusted SDK before exposing business source/dependency paths, and reserves stdout for protocol; JavaScript binds ESM/CommonJS exports. Both bind each checked Port operation by name using `sdk_manifest()`. Go consumes fixed-target compiler/linker/source selector and stdlib importcfg from the same exact locked pack, compiles the SDK and checked-operation runner alongside target-selected Adapter files and locked module ZIPs, then embeds only the executable and checked contract. Go source exports one exact `func(context.Context, any, any) (any, error)` per operation; `send` maps to `Send`, dotted segments to PascalCase. Cgo, assembly and unlisted imports fail explicitly. Build tools run by absolute path with an empty environment and bounded lifetime; product compilation does not invoke host `go build`, `PATH`, `GOOS`, `GOARCH`, `GOCACHE` or `GOPATH`. `<entry>.dever-worker.json` (`dever-worker-launch-v1`: ecosystem, entry, executable, arguments, working_directory) accepts only manifest-bound resource arguments in that same tree and bounded literal flags. Runtime revalidates the entire private bundle before every launch/restart and clears the inherited environment. Discard consumed raw runtime/archive resources after expansion unless config roots or exec Workers still need them. A private fixed-target pack fixture proves the local compile/link path; signed release packs, Go sumdb and cross-platform distribution remain separate acceptance work.
|
||||
|
||||
Packaged exec Workers resolve only against the entry's embedded manifest. Bundle identity includes path, byte digest, length and executable bit. Validate all path segments, Unix owner/private permissions and file bytes on both cache reuse and every Worker restart; do not scan other bundles or fall back to source workers. Publish owner-only files/directories atomically before exposure. These protocol and integrity checks do not establish OS capability isolation; verify the sandbox launch boundary above separately.
|
||||
|
||||
`dever.crypto` uses the existing source Result choices and exhaustive Intrinsic bridge. Secret storage lives in the base runtime with zeroize; Argon2id, SHA/HMAC and OS randomness are in the optional crypto feature. Native runtime feature selection inspects already-pruned emitted crypto calls so direct system intrinsics, API roots and Test roots are covered. Password hashing uses RustCrypto Argon2 0.5.3, Argon2id v19 and default Params; PHC verification bounds algorithm/version/memory/time/parallelism/output before computing. Failure messages contain no input. Hash/verify use the existing blocking-worker effect boundary. `dever.process` exposes arguments only; application settings never come from environment variables.
|
||||
|
||||
Cache miss -> compile and publish; changed inputs -> separate key; incomplete/modified published artifact -> explicit error; unchanged sources with modified API baseline -> reject before native reuse. Concurrent writers may reuse only a complete same-identity entry.
|
||||
|
||||
| Condition | Result |
|
||||
| --- | --- |
|
||||
| Async op Pending; ready typed fault; runtime error | No output initialization; move checked fault with original kind/source; move original error bytes into RuntimeAbi fault respectively |
|
||||
| Consuming async operation creation returns null | Keep caller-owned input live for cleanup; a non-null operation owns the input through completion/cancel |
|
||||
| Invalid File/Stream writable slot | ABI invalid-input status before I/O or shared cursor progress |
|
||||
| Null/misaligned runtime ABI output, null nonempty input or unrepresentable length | ABI status 2; never dereference that pointer or read an uninitialized value output |
|
||||
| Int overflow, numeric division by zero, nonfinite Decimal bits or invalid UTF-8 | ABI status 1 with owned original runtime diagnostic; caller frees Buffer exactly once |
|
||||
| Invalid element descriptor/layout, or missing Map-key hash callback | ABI status 2 before constructing element storage; callback pointers and concrete row layouts remain compiler-owned lifetime/type obligations |
|
||||
| Optional extraction succeeds with presence=0 | Do not read the uninitialized row slot; return zero Nullable and release consumed receiver/unvisited cursor rows |
|
||||
| Recoverable Bytes operation returns status 1, or any returns status 2 | Bytes status 1 becomes its original Failed(Text); status 2 remains RuntimeAbi and is not caught by result |
|
||||
| LLVM-generated Windows aggregate object linked without target runtime support | Explicit missing `__chkstk`/`_fltused` diagnostic; no successful output or fixture replacement symbols |
|
||||
| Invalid format input anywhere in source root | Diagnostics, zero source rewrites |
|
||||
| Markdown source has no program blocks | M001 at original source |
|
||||
| Program fence is not explicitly closed | M002 at opening fence |
|
||||
| Package header/type/function crosses program blocks | M003; no format writes |
|
||||
| Package or declaration section is absent, duplicated or contains the wrong declarations | M004/M005; no format writes |
|
||||
| Documented package/API/declaration name differs | M006 with documentation and code locations |
|
||||
| Documented function input differs | M007 with documentation and code locations |
|
||||
| Documented output/type field/variant differs | M008 with documentation and code locations |
|
||||
| Same package in `.dever` and `.dever.md` | Existing C002 duplicate-package check |
|
||||
| `fmt --check` finds changes | Exit 1, no writes |
|
||||
| Ordinary alias remains live | Preserve independent value semantics |
|
||||
| Resource-containing local reaches last read | Retain lexical lifetime; no earlier socket EOF |
|
||||
| Invalid parallel count | Source-located failure before pulling or invoking handlers |
|
||||
| Stream-containing worker input | C005 before native compilation |
|
||||
| Invalid scalar index/number input | Null, never a default zero or lossy truncation |
|
||||
| Exact finite input shorter than requested workers | Same visits/results with no more workers than items/batches |
|
||||
| Valid one-shot read limit | Caller-selected maximum is passed to the OS read; no runtime clamp |
|
||||
| Composite/named-output rendering | Exact existing bytes, appended through one result buffer |
|
||||
| JSON grammar/reference/depth/output violation | Explicit Failed with offset/node and message |
|
||||
| Component hello/ready/schema/operation/capability mismatch | Startup fault before request admission; child is killed and reaped |
|
||||
| Component duplicate/unknown/out-of-order/truncated/oversized message | Fail the current and queued calls, close the generation and reap the child |
|
||||
| Component call timeout or caller cancellation | Send one matching cancel, drain one matching result/error within a deadline, otherwise terminate the Worker |
|
||||
| Component shutdown with active or queued calls | Close admission permanently, cancel the active call, drop queued calls, then bounded shutdown/kill/wait |
|
||||
| Managed launch identity/path/argv/runtime drift | Reject before executing any interpreter; no host fallback |
|
||||
| Shared artifact bad hash/length, overflow, truncation or quota exhaustion | Fail without publication; clean the owned staging and release operation/admission guards |
|
||||
| Foreign UID requests cache clean | Reject by kernel caller identity, regardless of supplied token |
|
||||
| HTTP peer framing/body violation | Hyper rejection or explicit error response/connection close, no handler invocation |
|
||||
| HTTP invalid outgoing message | send Failed or serve task fault; never silently rewrite framing |
|
||||
| HTTP/2 TLS peer cannot negotiate h2 | Explicit TLS/ALPN failure; no HTTP/1 downgrade |
|
||||
| HTTP/2 connection-specific header, or response TE header | Explicit error; request TE allows only trailers |
|
||||
| HTTP/2 reset before/after response head, including a blocked write | Cancel that handler and drain its descendants; neighboring streams survive |
|
||||
| HTTP/2 handler program fault | Original error propagates to serve; never treat it as normal EOF |
|
||||
| HTTP/2 GOAWAY while old streams remain | Stop admission and keep the physical connection permit until cleanup |
|
||||
| HTTP/2 stream/connection budget or window outside its bounds | Explicit failure before accepting or connecting |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
Onboarding base: `dever new app` followed by `run app -- hello.greeting.greet '{"name":"Dever"}'` returns the standard envelope with `data:"Hello, Dever"`. Good: --markdown generates equivalent checked declarations; two projects/users share core and skill. Bad: manual main.dever scaffolding, Go service/Page JSON templates, update URLs in project setting.json, or a global skill copied once and allowed to drift from the installed compiler.
|
||||
|
||||
Cross build good: an x86 core uses ARM profiles, ARM Worker assets and x86 Go tools emitting ARM objects. Base: omitted target retains native behavior. Bad: replacing global host identity, executing an ARM build tool on x86, labeling x86 native extensions ARM, or deriving target loader permissions from the host.
|
||||
|
||||
Native author base: fixed inputs and temporary key produce a verifiable manifest. Good: two different output names have identical manifests/signatures and payload digests, source mutation does not affect either published copy, and optimized profile programs run through the managed compiler. Bad: hard-link output to build caches, accept a debug all-features archive as four trimmed profiles, or call an unused PostgreSQL configuration a real database test.
|
||||
|
||||
Managed base: a signed Package project completes check/test/run/build and its built program runs without daemon. Good: different UIDs and project directories submit the same logical inputs and reuse one entry, while wrong source, unsigned CRT or modified pack fails. Bad: accept client IR, fetch compiled output through artifact digest, upload full Settings, run the application under the daemon UID, or make state/socket directories private to hide a worker permission bug.
|
||||
|
||||
Resource/async base: create/read a private fixture file and lower sleep to a real switched-resume frame. Good: aliases share Stream progress; stop releases a pending child and Scope drains descendants; large typed records and errors survive Pending; sync Task inputs/outputs transfer through the same bounded blocking owner. Bad: declare async metadata safe from parameters alone, clone borrowed arguments after initial suspend, reinterpret a typed error as String, or let a same-content overwrite pass the test.
|
||||
|
||||
Runtime ABI base: Int 40+2 yields status 0/value 42/null Buffer. Good: explicitly link a runtime-abi-only archive from a C or LLVM caller, read output only on status 0 and free an error/text buffer via the matching symbol. Bad: cast Rust DecimalValue into a C layout, pass aggregates by value, reuse a live owned Buffer slot, free compiler Reply via runtime buffer_free or treat author fixtures as signed release packs.
|
||||
|
||||
Managed ABI good: typed C callbacks retain/drop a nested Text record; aliased List/Map updates preserve the original; early cursor release drops remaining rows; duplicate Map keys retain the exact original diagnostic. Bad: byte-copy a managed payload or padding, make ordinary Float elements implement Eq, or confuse outer row existence with a nullable element's null state.
|
||||
|
||||
Base: format the hello sample twice; bytes are unchanged on the second pass. Good: move an unaliased Map payload into `get`, render a nested value into one buffer, or parse and stringify nested JSON with an exact large number. Bad: clamp a valid one-shot read behind the caller's back, move the last Socket alias into a short-lived function, accept conflicting Content-Length values, or compare concurrent stdout as an ordered trace.
|
||||
|
||||
Markdown base: an application file documents its path-derived component/domain/role identity in H1 metadata; API sections document declarations and contain no handler bodies. Good: keep every type/function/declaration description synchronized with its checked signature and format without changing prose. Bad: add application `main.dever.md`, put code under H1, group two functions under one H2, split clauses across H2 sections, execute a quoted example or move a comment across prose during formatting.
|
||||
|
||||
HTTP/2 base: null Limits.http2 retains HTTP/1; default_http2_limits() selects 16 streams and fixed 65535/262144-byte receive windows. Good: a TLS proxy makes a nested request through the same one-connection pool while its outer response remains open. Bad: expire that active connection using the idle timer, or reuse a cancelled stream's lease on a replacement connection.
|
||||
|
||||
Component base: one selected external Adapter completes hello/ready/health, serves typed Port calls and acknowledges shutdown. Good: a protocol fault closes the old receiver before a later call creates a rate-limited new generation; shutdown rejects every racing or queued call and explicitly reaps the child. Bad: drain a full request queue during shutdown, restart after admission closed, rely only on `kill_on_drop`, or report handshake capability equality as an OS sandbox.
|
||||
|
||||
Managed base: a zero-Lib Python/JavaScript Adapter still locks its signed runtime and generates a typed runner. Good: preserve raw archives shared with exec while removing managed-only copies. Bad: import the Python SDK before restoring the packaged path under `-I`, use a peer-supplied cache path, or treat `verified:false` counts as byte-integrity evidence.
|
||||
|
||||
## 6. Required Tests
|
||||
|
||||
For onboarding select CLI `new_project`, lib `release_source`, shared-toolchain version/skill tests and native-release skill authoring tests, plus `python3 -B -m unittest discover -s test/onboarding -v`. Assert both formats check/format, no-overwrite concurrency, real CMD/Test/build output, exact signed extraction, corrupt-download retention, project-pinned guidance and foreign-user reads. The foreign-user test lowers only an owned client UID. Bootstrap script tests mock final installation; report that separately from real native-template execution. Do not activate host services or run full release/CI suites as an onboarding check.
|
||||
|
||||
Cross-target changes cover `build_arguments`, native-release target/ELF/mode cases, CompileRequest validation, cache isolation and Lib/Worker target selection. The opt-in `signed_host_compiler_builds_arm_profiles_and_runs_standalone_under_qemu` and `signed_arm_ecosystems_build_offline_standalone_apps_for_system_guest` require explicit author inputs. Run exported Workers/HTTP with `test/ecosystem-release/arm64/simulate.py` under a complete ARM kernel; assert each successful output, source-located expected failure and child cleanup. Do not substitute QEMU user emulation for namespace/seccomp execution or infer physical ARM performance.
|
||||
|
||||
Native release changes select `native_release` plus affected private pack/installation checks. Default cases cover deterministic bytes/signature, private-copy ownership, malformed configuration/path/digest/key rejection, cleanup, and concurrent no-overwrite publication. Explicit `native_release::acceptance` consumes prepared optimized archives and author executable; inspect SQLite/PostgreSQL symbols, then repeat packaging, verify signed installation, compile/run all four profiles and run independent outputs after source removal/daemon stop. Report PG-server and cross-host source rebuild evidence separately. Keep native checks and Cargo serial under constrained disk space.
|
||||
|
||||
Managed compilation selects `packages::managed_compile`, the installed Package execution fixture, affected `shared_toolchain` cache/install/IPC cases, and private `llvm_cli`. Cover secret-free snapshots, reserved/strict source checks, cross-UID reuse, changed sources, failure recovery, fake-only Test execution, signature reference omissions, cache-hit tampering, limits, real cancellation/disconnect/deadline reaping, and shared install lock lifetime. Tests use owned daemons and temporary signing keys. Check final `dever`/`deverd` DT_NEEDED independently: empty-environment success on an LLVM-equipped host cannot establish absence of a dependency.
|
||||
|
||||
Runtime ABI changes select `native_runtime_abi` and opt-in `native_runtime_link`: exact Decimal word round-trip, checked errors/IEEE floats, borrowed UTF-8/Bytes including null-empty and invalid input, real C-header/staticlib linkage, LLVM scalar/out-pointer calls and matching buffer release. Managed changes include canonical C typed lifecycle callbacks, nested rows, COW aliases, cursors/early release, duplicate Map keys, split descriptor identity and Bytes conversion/slice. Build the offline runtime-only archive explicitly in `target/native-runtime-abi`; do not recursively build it from tests or silently use an embedded-compiler archive. Opt-in `llvm_managed_native` and `llvm_result_native` reuse the shared runtime-linked runner to verify real checked-source execution and repeated allocation balance; this is not a general memory profiler or capacity test. Typed lowering/bridge tests and target-pack limitations are specified in `compiler-contracts.md`.
|
||||
|
||||
File/Stream/coroutine ABI changes additionally select `llvm_resource_native`, `llvm_async_native` and `llvm_async_source`. Keep hand-written LLVM/C ABI smoke separate from checked-source lowering evidence; execute both against the actual archive. Assert all initial/pending/final destroy paths, actual Pending/backpressure, typed error transfer, sync input borrowing, non-null creation consumption, nullable row/End, Scope drain and repeat allocation balance. Use an atomic allocation ledger for concurrent workers. Do not run broad database/service/performance checks or claim complete application/target-pack acceptance from these tests.
|
||||
|
||||
HTTP/system lowering selects `llvm_http_system_source` plus the above affected ABI/async targets: repeated headers, independent chunked wire, pooled H1/H2 uploads/downloads, verified TLS, live/SSE, WebSocket typed variants/end, context ownership, failure-500-next-request, original diagnostics, calendar/UUID/Secret/crypto/argv and exact stdout/log JSON. The canonical C protocol fixture must reject null/misaligned destinations before poll, preserve sentinels, allow corrected retry, and reject completed repoll. The pure-log C fixture uses the same strict allocation ledger; logger concurrency/backpressure/panic tests stay under root test/runtime_logging.rs. Six-target object generation is separate from the actual Linux archive execution.
|
||||
|
||||
AsyncStream/TCP/timeout/race/ParallelEach changes also select `llvm_stream_network_source` and the existing async composition/network reference fixtures as appropriate. Cover successful null/Unit versus genuine timeout, exact typed faults without fallback, shared stream close, terminal rows, all dispatch categories and descendant peer EOF after cleanup. C polling invalid input returns `DEVER_RT_ASYNC_INVALID_INPUT=4`; it must never look like a typed fault. Canonical signatures remain only in runtime.h.
|
||||
|
||||
Performance changes select the affected native_ownership, runtime_foundations, concurrency or native_cache cases. HTTP changes select http_engine (independent wire, repeated headers, framing, body limits, deadlines, connection backpressure and task cleanup) and http_library (static handlers/effects, native bridging, examples). Bounded ignored performance tests require explicit scope; correctness checks do not establish throughput or RSS improvements.
|
||||
|
||||
Choose relevant root targets: `formatter`, `standard_library`, `native_ownership`, `concurrency`, `json_library`, `http_library`, and opt-in `differential`. Assert independent expected results, diagnostics, source field/Map order, scope lifetime, actual worker overlap, limits, malformed protocols and wire bytes. Native/resource tests own all temporary files and loopback sockets.
|
||||
|
||||
Markdown changes select `markdown_source` and affected `formatter`/`syntax_and_format` regressions. Cover mixed formats/duplicates and unchanged language rejection; H1 metadata without a block and H2 one-block structure; public names/use examples; fields/variants; resolved clause inputs and outputs; explicit empty lists and natural descriptions; original UTF-8/LF/CRLF/CR positions; hidden/nested/nonprogram content; closure and cross-block rejection; comments, prose preservation and idempotence. Verify actual CLI check/fmt/api/run/build with isolated small programs and failure-before-rewrite cases, without a full or service integration suite.
|
||||
|
||||
`performance` is explicitly ignored by default. It measures bounded hot paths after setup against the same representation and runtime, with exact output checks and repeated median measurements. Keep the 1.15 ratio gate meaningful; never replace a baseline by a slower different representation to conceal a compiler regression. Report environment and actual scope, not generic speed claims.
|
||||
|
||||
N3 selects `live_network` and `live_library`: independent HTTP chunks and WebSocket frame bytes, preread upgrades, masking/fragmentation/control frames/UTF-8, exact SSE field/line/size rules, backpressure, phase vs idle deadlines, task/connection limits, close/cancellation and native static bridging. Existing `http_engine`, `http_library` and affected `async_network` cases cover shared owners. Service examples are checked/formatted without starting their persistent entry point.
|
||||
|
||||
N4 selects `async_composition`, `pooled_network` and `pooled_library`: source configuration, affine timeout/race, async memory fusion boundaries, Channel/ticks, exact typed context/effects/failure obligations; independent connection reuse, upload framing, stream backpressure, queue/body/idle deadlines, incomplete-body cancellation, unpolled pool cleanup, CA/name rejection, WSS and graceful draining. Native programs must exercise the complete checked source bridge. The self-contained backend example may run on its owned random loopback port; persistent examples remain check-only.
|
||||
|
||||
N5 selects `http2_client`, `http2_server`, affected `http_library`/`pooled_library`, HTTP/1 pool/live regressions and structured task lifecycle cases. Independent h2 peers must prove actual multiplexing, stream reset isolation, flow-control recovery, stream/physical budgets, active-body idle behavior, ALPN success/rejection, GOAWAY draining and handler error propagation. Native source must cover HTTP/2 single-use send and a TLS proxy that issues a nested request through the same one-connection client, plus streamed upload, SSE and run/wait inside handlers. Functional checks do not establish HTTP/2 throughput or memory improvements.
|
||||
|
||||
External Component Protocol changes select `external_component`, `port_adapter` and `application_testing`. The fixture must be an owned test executable, not Python/Node/Go or a service dependency. Assert two calls, declared business error, health, caller cancel, timeout cancel, crash/restart, malformed frames, queued shutdown, schema drift and native Worker execution. These tests establish protocol/lifecycle correctness only; they do not establish ecosystem resolver, packaging, OS sandbox, throughput or RSS.
|
||||
|
||||
Resource and Lib changes also select `external_resources`, CLI `external_libs`, `application_config` and `shared_toolchain`. Include concurrent extraction, execute-bit identity, symlink/permission rejection, no-source/env-cleared packaged exec with repeat runs and tamper rejection, full provider metadata drift and independent Worker lock bindings. The opt-in `test/external-sdk/check.py` uses explicitly supplied host interpreters and checks all three SDKs, i64 extremes, JSON budgets, health/errors and cooperative/noncooperative cancellation; it does not prove managed runtimes or no-host distribution.
|
||||
|
||||
Registry tests exercise real provider metadata through an injected bounded transport, candidate/dependency/version conflicts and runtime identity; owned loopback HTTP tests also cover the Go ZIP redirect and rejection-before-request boundary. They are not public-network or signed-release acceptance. `external_workers` tests cover Python isolation, ESM/CommonJS binding, wheel/npm rules, path/file conflicts and no-host lookup. Explicit host-copy subprocess cases remain ignored by default. Shared service tests cover real UID separation (opt-in root-owned fixture), bad/truncated concurrent uploads, entry/byte quotas, summary-vs-integrity semantics and owner maintenance. Native tests assert resource-independent generated source size, forged-digest rejection and actual packaged execution; tests derived solely from emit strings do not prove launch.
|
||||
|
||||
## Durable Worker Lifecycle
|
||||
|
||||
The compiler owns Job service startup. Both application backends validate settings and selected `runtime.mode` before preparing service databases or listening/claiming. API and worker roots stay in one binary; api/worker/all gate only the services, not source reachability. Starting a Job service requires runtime settings; CMD and management commands do not validate or start unrelated services. An enqueue-only CMD may use one database connection without HTTP/worker configuration. API-only applications use api/30000 ms defaults when omitted. Worker pools reserve at least one connection beyond worker slots for queue control and lease renewals. SQLite additionally requires `lease_ms > Job timeout_ms` before database preparation, because a spare connection does not bypass its single-writer lock.
|
||||
|
||||
Tokio's existing signal feature owns SIGTERM/Ctrl-C registration; do not install handlers with unsafe code or detach signal tasks. Lifecycle supervises the application root with the existing task owner. Shutdown stops API accept and Job claim, drains within one deadline, then cancellation joins descendants before database shutdown and log flush. Generated normal/test entries close databases on success and application/startup failure. Worker work and heartbeat use registered tasks, bounded active slots and fencing tokens; cancellation leaves the lease for recovery.
|
||||
|
||||
Test-only `dever.test.advance_clock` and bounded `drain_jobs` share production clock/store/dispatch logic. Each case has an independent clock/database/fake closure; empty drain needs no database. No test may start API/worker services, select a production Adapter or use deployment configuration. Job test roots must include enqueued handlers even though enqueue is not a synchronous dependency edge.
|
||||
|
||||
LLVM Job/Test validation selects `llvm_job_test_source` and the Job cases in `llvm_api_source`, plus affected original `durable_jobs` contracts. Check six-target object emission separately from actual archive execution. Exercise single-suite case isolation, structured failure locations, fake-only external calls, virtual delay/retry/cron, atomic enqueue/handler rollback, timeout child drain and repeated-root allocation balance. Real owned API/worker cases verify tenant queue placement and current permissions after enqueue. Revocation fixtures keep stored references structurally valid; malformed storage ownership is an earlier fail-closed store error, not a successful dispatch-time authorization test.
|
||||
|
||||
LLVM external validation selects `llvm_external_source`, the original `external_component`/`external_resources` tests and the existing fake-only Job/Test case. Verify six-target metadata/object layouts separately from actual runtime execution. Run real fixture processes for typed values/errors, malformed payloads, bad Setting/handshake, cancellation, and strict warmup + 64 repeated roots. Relocate only the linked executable and settings before checking embedded exec and managed launch manifests with an empty environment; synthetic managed launch fixtures are not real Python/npm/Go SDK or formal release acceptance. Retain resource SHA verification on every root/restart. Linux allocation ledgers must wrap `realpath(NULL)` as an owned allocation: glibc's internal malloc bypasses executable linker wrapping, while Rust's later free is wrapped. Account for that owner without relaxing balance or increasing warmups.
|
||||
|
||||
Runtime config owns the shared Job bounds: timeout <= 3,600,000 ms and lease in 100..7,200,000 ms. Tests use the maximum lease so every legal timeout fits SQLite without bypassing production policy validation.
|
||||
|
||||
Ordinary Linux commands use `workers/command.rs` with the shared `workers/elf.rs` dependency-closure checker. Prepare the domain-owned ELF and flat adjacent `lib/` from either the project or locked Package; never scan host libraries or require a Lib lock for command-only projects. Validate target, GNU interpreter, recursive DT_NEEDED, SONAME, symbol versions and loader hooks. All command RPATH/RUNPATH expansions must remain inside the logical `program` tree, which maps to `/worker`; accepting an escape such as `$ORIGIN/../data/grant` would allow a file grant to replace a validated dependency at execution time. Private libraries cannot shadow signed sandbox libraries. Dynamic launch uses the signed target loader with `--inhibit-cache --library-path lib:/lib`; user argv is appended literally after mapping only compiler-owned resource arguments. Commands use a distinct `dever-command-launch-v1` manifest and `commands/` tree under the existing verified bundle/extraction owner.
|
||||
|
||||
Command limits are `adapter.<port>.command.timeout_ms` (default 30000, 1..3600000) and `output_limit` (default 1048576, 1..8388608 combined stdout/stderr); stdin is bounded at 8388608 bytes and argv at 65536 including NUL terminators, with embedded NUL rejected. A call owns one process, closes absent stdin, drains both output pipes concurrently and never replays. Nonzero program exit is output; failure before target exec must remain a runtime fault and cannot be inferred from stderr or a reserved exit code. Invocation shutdown and caller cancellation must wait for actual sandbox completion. Queued cancellation has no child to drain and must not wait behind an unrelated active call. Ordinary application tests remain fake-only. Focused evidence belongs to `external_command`, command cases in `external_workers`/`llvm_external_source`, and the opt-in signed native-release command acceptance; physical ARM execution must not be inferred from cross-target metadata tests.
|
||||
|
||||
Command execution observes guard exec through a private CLOEXEC pipe. Bind the PID namespace init through bwrap `--info-fd`, keep it blocked with `--block-fd` until pidfd acquisition and PPid verification, and wait for that pinned pidfd before releasing caller-scope completion. Pipe EOF (`--sync-fd`) is not sufficient: Linux closes init descriptors before tearing down descendants. Cleanup always reaps the owned launcher even if namespace observation fails, retaining both errors. Old Worker launches keep their existing handshake path.
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
Wrong: update a skill symlink separately after changing the active core, treat a cached older version as a successful failed update, or delete a public output tree after a publication race. Correct: resolve core and signed skill from one active journal, return the original download failure, and publish exclusively owned staging with NOREPLACE.
|
||||
|
||||
Cross-target wrong: check ELF headers but use the host loader name to assign executable mode. Correct: validate target architecture and independently preserve each declared executable's mode across signed packaging, embedding and extraction; exercise the resulting Worker bootstrap under the target kernel.
|
||||
|
||||
Native authoring wrong: discover a compiler in PATH while signing, overwrite a completed release, or strip a hard-linked archive in place. Correct: separate the recorded source-build recipe from deterministic copy/verify/sign, preserve link symbols, and publish a wholly new directory with the existing signed installation contract.
|
||||
|
||||
Wrong: reuse shared-file validation for an owner-only lock, require 0700 on public state ancestors, unlink a held advisory lock, or trust a successful test on a host with LLVM installed. Correct: validate each owner/permission contract separately, retain the lock inode, keep source staging private, and inspect final dynamic dependencies.
|
||||
|
||||
Wrong: export a Rust aggregate directly, use C++ free on Rust ABI buffers or ship the LLVM author SDK inside the application runtime. Correct: fixed scalar arguments and explicit output pointers, one canonical header, private allocator-matched exports and separate runtime-abi/embedded features; validate formal target packs separately.
|
||||
|
||||
Managed ABI wrong: trust a copied byte buffer as an owned typed value, create per-caller descriptor identities or read an optional out row regardless of presence. Correct: one process-static descriptor per concrete type, typed lifecycle callbacks over aligned uninitialized storage, explicit receiver consumption and full-row nullable extraction.
|
||||
|
||||
Async ABI wrong: use regular 0/1/2 status checks for Pending/Ready, raw-copy fault payload padding, free normal guards twice during final destroy, or drop the runtime's input inside SyncFunction.invoke. Correct: explicit async status mapping, typed nominal move/drop with source zeroing, initial/pending reverse cleanup versus final frame-only free, and runtime-owned input release after invoke or cancellation.
|
||||
|
||||
HTTP/2 wrong: spawn Hyper futures outside CURRENT_SCOPE, let a body-reset write error stop the whole server, or retry every GOAWAY error. Correct: register protocol tasks under a connection owner, distinguish transport cancellation before propagating handler faults, and only requeue a structurally returned unsent request within its original deadline.
|
||||
|
||||
Wrong: formatter reorders effectful constructors; code generation moves resource aliases early; composite rendering first collects every child String; the runtime caps one-shot reads to an arbitrary internal chunk; a benchmark times unequal Decimal initial representations.
|
||||
|
||||
Correct: reuse AST/HIR/runtime owners, preserve source ordering and lifetime, consume only disposable native bindings, stream rendering into one output String, keep read size under caller control, and match both semantics and representation before measuring.
|
||||
|
||||
Markdown wrong: regex-extract every fence or format all extracted code as one document. Correct: identify program ranges through Markdown parsing, preserve original positions, and reuse the existing Dever pipeline and formatter within those ranges.
|
||||
|
||||
Component wrong: use shell command strings, `PATH`, application environment variables, detached tasks, automatic call replay, a second JSON parser, or a per-ecosystem supervisor. Component correct: resolve one compiler-owned relative entry, clear inherited environment, reuse bounded wire and Scope owners, preserve the first runtime fault, and explicitly kill/wait on every abnormal exit.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Code Reuse Thinking Guide
|
||||
|
||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
||||
|
||||
When you copy-paste or rewrite existing logic:
|
||||
- Bug fixes don't propagate
|
||||
- Behavior diverges over time
|
||||
- Codebase becomes harder to understand
|
||||
|
||||
---
|
||||
|
||||
## Before Writing New Code
|
||||
|
||||
### Step 1: Search First
|
||||
|
||||
```bash
|
||||
# Search for similar function names
|
||||
grep -r "functionName" .
|
||||
|
||||
# Search for similar logic
|
||||
grep -r "keyword" .
|
||||
```
|
||||
|
||||
### Step 2: Ask These Questions
|
||||
|
||||
| Question | If Yes... |
|
||||
|----------|-----------|
|
||||
| Does a similar function exist? | Use or extend it |
|
||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
||||
| Could this be a shared utility? | Create it in the right place |
|
||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
||||
|
||||
---
|
||||
|
||||
## Common Duplication Patterns
|
||||
|
||||
### Pattern 1: Copy-Paste Functions
|
||||
|
||||
**Bad**: Copying a validation function to another file
|
||||
|
||||
**Good**: Extract to shared utilities, import where needed
|
||||
|
||||
### Pattern 2: Similar Components
|
||||
|
||||
**Bad**: Creating a new component that's 80% similar to existing
|
||||
|
||||
**Good**: Extend existing component with props/variants
|
||||
|
||||
### Pattern 3: Repeated Constants
|
||||
|
||||
**Bad**: Defining the same constant in multiple files
|
||||
|
||||
**Good**: Single source of truth, import everywhere
|
||||
|
||||
### Pattern 4: Repeated Payload Field Extraction
|
||||
|
||||
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
||||
|
||||
```typescript
|
||||
const description = (ev as { description?: string }).description;
|
||||
const context = (ev as { context?: ContextEntry[] }).context;
|
||||
```
|
||||
|
||||
This is duplicated contract logic even when the code is only two lines. Each
|
||||
consumer now has its own definition of what a valid payload means.
|
||||
|
||||
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
||||
|
||||
```typescript
|
||||
if (isThreadEvent(ev)) {
|
||||
renderThreadEvent(ev);
|
||||
}
|
||||
```
|
||||
|
||||
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
||||
shared type guard / normalizer / projection before adding a third reader.
|
||||
|
||||
---
|
||||
|
||||
## When to Abstract
|
||||
|
||||
**Abstract when**:
|
||||
- Same code appears 3+ times
|
||||
- Logic is complex enough to have bugs
|
||||
- Multiple people might need this
|
||||
|
||||
**Don't abstract when**:
|
||||
- Only used once
|
||||
- Trivial one-liner
|
||||
- Abstraction would be more complex than duplication
|
||||
|
||||
---
|
||||
|
||||
## After Batch Modifications
|
||||
|
||||
When you've made similar changes to multiple files:
|
||||
|
||||
1. **Review**: Did you catch all instances?
|
||||
2. **Search**: Run grep to find any missed
|
||||
3. **Consider**: Should this be abstracted?
|
||||
|
||||
### Reducers Should Use Exhaustive Structure
|
||||
|
||||
When state is derived from action-like values (`action`, `kind`, `status`,
|
||||
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
||||
|
||||
```typescript
|
||||
// BAD - action-specific state transitions are hard to audit
|
||||
if (action === "opened") { ... }
|
||||
else if (action === "comment") { ... }
|
||||
else if (action === "status") { ... }
|
||||
|
||||
// GOOD - one reducer owns the transition table
|
||||
switch (event.action) {
|
||||
case "opened":
|
||||
...
|
||||
return;
|
||||
case "comment":
|
||||
...
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
This matters when the event log is the source of truth. A reducer is the
|
||||
documented replay model; display code and commands should not duplicate pieces
|
||||
of that replay model.
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Commit
|
||||
|
||||
- [ ] Searched for existing similar code
|
||||
- [ ] No copy-pasted logic that should be shared
|
||||
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
||||
- [ ] Constants defined in one place
|
||||
- [ ] Similar patterns follow same structure
|
||||
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Python if/elif/else Exhaustive Check
|
||||
|
||||
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
||||
|
||||
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
||||
|
||||
**Example** (`cli_adapter.py`):
|
||||
```python
|
||||
# BAD: "gemini" falls through to else, returns "claude"
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
else:
|
||||
return "claude" # gemini silently gets "claude"!
|
||||
|
||||
# GOOD: explicit branch for every platform
|
||||
@property
|
||||
def cli_name(self) -> str:
|
||||
if self.platform == "opencode":
|
||||
return "opencode"
|
||||
elif self.platform == "gemini":
|
||||
return "gemini"
|
||||
else:
|
||||
return "claude"
|
||||
```
|
||||
|
||||
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
||||
|
||||
---
|
||||
|
||||
## Gotcha: Asymmetric Mechanisms Producing Same Output
|
||||
|
||||
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
|
||||
|
||||
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
|
||||
|
||||
**Prevention**:
|
||||
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
||||
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
||||
- When migrating directory structures, search for ALL code paths that reference the old structure
|
||||
|
||||
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
||||
|
||||
---
|
||||
|
||||
## Template File Registration (Trellis-specific)
|
||||
|
||||
When adding new files to `src/templates/trellis/scripts/`:
|
||||
|
||||
**Single registration point**: `src/templates/trellis/index.ts`
|
||||
|
||||
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
||||
2. Add to `getAllScripts()` Map
|
||||
|
||||
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
||||
|
||||
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
||||
|
||||
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
||||
|
||||
### Quick Checklist for New Scripts
|
||||
|
||||
```bash
|
||||
# After adding a new .py file, verify it's in getAllScripts():
|
||||
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
||||
```
|
||||
|
||||
### Template Sync Convention
|
||||
|
||||
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
||||
|
||||
```bash
|
||||
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
||||
```
|
||||
|
||||
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
||||
@@ -0,0 +1,327 @@
|
||||
# Cross-Layer Thinking Guide
|
||||
|
||||
> **Purpose**: Think through data flow across layers before implementing.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
**Most bugs happen at layer boundaries**, not within layers.
|
||||
|
||||
Common cross-layer bugs:
|
||||
|
||||
- API returns format A, frontend expects format B
|
||||
- Database stores X, service transforms to Y, but loses data
|
||||
- Multiple layers implement the same logic differently
|
||||
|
||||
---
|
||||
|
||||
## Before Implementing Cross-Layer Features
|
||||
|
||||
### Step 1: Map the Data Flow
|
||||
|
||||
Draw out how data moves:
|
||||
|
||||
```
|
||||
Source → Transform → Store → Retrieve → Transform → Display
|
||||
```
|
||||
|
||||
For each arrow, ask:
|
||||
|
||||
- What format is the data in?
|
||||
- What could go wrong?
|
||||
- Who is responsible for validation?
|
||||
|
||||
### Step 2: Identify Boundaries
|
||||
|
||||
| Boundary | Common Issues |
|
||||
| --------------------- | --------------------------------- |
|
||||
| API ↔ Service | Type mismatches, missing fields |
|
||||
| Service ↔ Database | Format conversions, null handling |
|
||||
| Backend ↔ Frontend | Serialization, date formats |
|
||||
| Component ↔ Component | Props shape changes |
|
||||
|
||||
### Step 3: Define Contracts
|
||||
|
||||
For each boundary:
|
||||
|
||||
- What is the exact input format?
|
||||
- What is the exact output format?
|
||||
- What errors can occur?
|
||||
|
||||
---
|
||||
|
||||
## Common Cross-Layer Mistakes
|
||||
|
||||
### Mistake 1: Implicit Format Assumptions
|
||||
|
||||
**Bad**: Assuming date format without checking
|
||||
|
||||
**Good**: Explicit format conversion at boundaries
|
||||
|
||||
### Mistake 2: Scattered Validation
|
||||
|
||||
**Bad**: Validating the same thing in multiple layers
|
||||
|
||||
**Good**: Validate once at the entry point
|
||||
|
||||
### Mistake 3: Leaky Abstractions
|
||||
|
||||
**Bad**: Component knows about database schema
|
||||
|
||||
**Good**: Each layer only knows its neighbors
|
||||
|
||||
### Mistake 4: Every Consumer Parses The Same Payload
|
||||
|
||||
**Bad**: A command reads JSONL events and casts fields inline:
|
||||
|
||||
```typescript
|
||||
const thread = (ev as { thread?: string }).thread;
|
||||
const labels = (ev as { labels?: string[] }).labels;
|
||||
```
|
||||
|
||||
This looks local, but it means every consumer owns a private version of the
|
||||
event contract. The next field change will update one command and miss another.
|
||||
|
||||
**Good**: Decode once at the event boundary, then export typed projections:
|
||||
|
||||
```typescript
|
||||
if (!isThreadEvent(ev)) return false;
|
||||
return ev.thread === filter.thread;
|
||||
```
|
||||
|
||||
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
|
||||
create one owner for:
|
||||
|
||||
- event / payload type definitions
|
||||
- type guards and normalization from `unknown`
|
||||
- metadata projections used by UI commands
|
||||
- reducers that replay state from the source of truth
|
||||
|
||||
Rendering code may format fields, but it must not redefine the payload contract.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Cross-Layer Features
|
||||
|
||||
Before implementation:
|
||||
|
||||
- [ ] Mapped the complete data flow
|
||||
- [ ] Identified all layer boundaries
|
||||
- [ ] Defined format at each boundary
|
||||
- [ ] Decided where validation happens
|
||||
|
||||
After implementation:
|
||||
|
||||
- [ ] Tested with edge cases (null, empty, invalid)
|
||||
- [ ] Verified error handling at each boundary
|
||||
- [ ] Checked data survives round-trip
|
||||
- [ ] Checked that consumers import shared decoders / projections instead of
|
||||
casting payload fields locally
|
||||
- [ ] Checked that derived state points back to the source event identifier
|
||||
(`seq`, `id`, `version`) instead of inventing a second cursor
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Template Consistency
|
||||
|
||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
||||
|
||||
### Checklist: After Modifying Any Command Template
|
||||
|
||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
||||
|
||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
||||
|
||||
---
|
||||
|
||||
## Generated Runtime Template Upgrade Consistency
|
||||
|
||||
Some generated files are both documentation and runtime input. In Trellis,
|
||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
||||
against both fresh init and upgrade paths.
|
||||
|
||||
### Checklist: After Modifying A Runtime-Parsed Template
|
||||
|
||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
||||
writer that installs it
|
||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
||||
such as tag blocks
|
||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
||||
the older `.trellis/.version`
|
||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
||||
assert the installed file reaches the current packaged shape
|
||||
- [ ] Update the backend spec that owns the runtime contract
|
||||
|
||||
---
|
||||
|
||||
## Versioned Documentation Boundary
|
||||
|
||||
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
|
||||
version routing, and the rendered version selector must all describe the same
|
||||
release line.
|
||||
|
||||
### Checklist: Before Editing Versioned Docs
|
||||
|
||||
- [ ] Identify the target release line: stable, beta, or RC
|
||||
- [ ] Verify the edited MDX path matches that line:
|
||||
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
|
||||
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
|
||||
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
|
||||
- [ ] Verify `docs.json` navigation points the version label to the same paths
|
||||
- [ ] Grep the opposite tree for release-line-specific terms before committing
|
||||
- [ ] Treat beta content appearing under root release paths as a source-path bug,
|
||||
not a rendering bug
|
||||
|
||||
**Real-world example**: A beta-only task workflow change documented
|
||||
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
|
||||
mode banners under root `start/` and `advanced/` paths. The docs site then
|
||||
served 0.6 beta behavior under the Release selector. The fix was to restore root
|
||||
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
|
||||
audit for beta markers against the root release tree.
|
||||
|
||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
||||
could return empty Phase 2.1 detail.
|
||||
|
||||
---
|
||||
|
||||
## Mode-Detection Probe Checklist
|
||||
|
||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
||||
|
||||
### Before implementing:
|
||||
|
||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
||||
|
||||
### After implementing:
|
||||
|
||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
||||
|
||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
||||
|
||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Template Consistency
|
||||
|
||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
||||
|
||||
### Checklist: After Modifying Any Command Template
|
||||
|
||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
||||
|
||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
||||
|
||||
---
|
||||
|
||||
## Generated Runtime Template Upgrade Consistency
|
||||
|
||||
Some generated files are both documentation and runtime input. In Trellis,
|
||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
||||
against both fresh init and upgrade paths.
|
||||
|
||||
### Checklist: After Modifying A Runtime-Parsed Template
|
||||
|
||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
||||
writer that installs it
|
||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
||||
such as tag blocks
|
||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
||||
the older `.trellis/.version`
|
||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
||||
assert the installed file reaches the current packaged shape
|
||||
- [ ] Update the backend spec that owns the runtime contract
|
||||
|
||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
||||
could return empty Phase 2.1 detail.
|
||||
|
||||
---
|
||||
|
||||
## Mode-Detection Probe Checklist
|
||||
|
||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
||||
|
||||
### Before implementing:
|
||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
||||
|
||||
### After implementing:
|
||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
||||
|
||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
||||
|
||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
||||
|
||||
---
|
||||
|
||||
## When to Create Flow Documentation
|
||||
|
||||
Create detailed flow docs when:
|
||||
|
||||
- Feature spans 3+ layers
|
||||
- Multiple teams are involved
|
||||
- Data format is complex
|
||||
- Feature has caused bugs before
|
||||
|
||||
---
|
||||
|
||||
## Event Log / Projection Boundary
|
||||
|
||||
Append-only logs are cross-layer contracts. A single event travels through:
|
||||
|
||||
```
|
||||
CLI input → event writer → events.jsonl → reader → filter → reducer → display
|
||||
```
|
||||
|
||||
### Checklist: After Adding A New Event Kind Or Field
|
||||
|
||||
- [ ] Add the event kind to the central event taxonomy
|
||||
- [ ] Add a typed event variant or type guard at the event layer
|
||||
- [ ] Add normalization helpers for array/object fields that come from
|
||||
user input or JSON
|
||||
- [ ] Keep `seq` / `id` assignment in the event writer only
|
||||
- [ ] Make filters and reducers consume the typed event guard, not local casts
|
||||
- [ ] Make display code consume reducer output or typed events, not raw JSON
|
||||
- [ ] Add at least one regression that proves history replay and live filtering
|
||||
use the same filter model
|
||||
|
||||
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
|
||||
`context`, labels, and `lastSeq`. The first implementation replayed thread
|
||||
state correctly, but several commands still re-parsed event payload fields with
|
||||
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
|
||||
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
|
||||
projection, and make `reduceThreads` the only thread replay reducer.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Thinking Guides
|
||||
|
||||
> **Purpose**: Expand your thinking to catch things you might not have considered.
|
||||
|
||||
---
|
||||
|
||||
## Why Thinking Guides?
|
||||
|
||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
||||
|
||||
- Didn't think about what happens at layer boundaries → cross-layer bugs
|
||||
- Didn't think about code patterns repeating → duplicated code everywhere
|
||||
- Didn't think about edge cases → runtime errors
|
||||
- Didn't think about future maintainers → unreadable code
|
||||
|
||||
These guides help you **ask the right questions before coding**.
|
||||
|
||||
---
|
||||
|
||||
## Available Guides
|
||||
|
||||
| Guide | Purpose | When to Use |
|
||||
|-------|---------|-------------|
|
||||
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
|
||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Thinking Triggers
|
||||
|
||||
### When to Think About Cross-Layer Issues
|
||||
|
||||
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
|
||||
- [ ] Data format changes between layers
|
||||
- [ ] Multiple consumers need the same data
|
||||
- [ ] You're not sure where to put some logic
|
||||
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
|
||||
- [ ] UI / command code starts casting raw payload fields directly
|
||||
|
||||
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
|
||||
|
||||
### When to Think About Code Reuse
|
||||
|
||||
- [ ] You're writing similar code to something that exists
|
||||
- [ ] You see the same pattern repeated 3+ times
|
||||
- [ ] You're adding a new field to multiple places
|
||||
- [ ] **You're modifying any constant or config**
|
||||
- [ ] **You're creating a new utility/helper function** ← Search first!
|
||||
- [ ] Two files read the same untyped payload field with local casts
|
||||
- [ ] Multiple branches update the same derived state from `kind` / `action`
|
||||
|
||||
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
||||
|
||||
### When Verifying AI Cross-Review Results
|
||||
|
||||
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
|
||||
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
|
||||
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
|
||||
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
|
||||
|
||||
**Common AI reviewer false-positive patterns**:
|
||||
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
|
||||
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
|
||||
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
|
||||
|
||||
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Modification Rule (CRITICAL)
|
||||
|
||||
> **Before changing ANY value, ALWAYS search first!**
|
||||
|
||||
```bash
|
||||
# Search for the value you're about to change
|
||||
grep -r "value_to_change" .
|
||||
```
|
||||
|
||||
This single habit prevents most "forgot to update X" bugs.
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Directory
|
||||
|
||||
1. **Before coding**: Skim the relevant thinking guide
|
||||
2. **During coding**: If something feels repetitive or complex, check the guides
|
||||
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a new "didn't think of that" moment? Add it to the relevant guide.
|
||||
|
||||
---
|
||||
|
||||
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
@@ -0,0 +1,793 @@
|
||||
# Dever Minimal Core 0.1 Design
|
||||
|
||||
## N4 Concurrent Composition And Reusable Clients (2026-09-12, Complete)
|
||||
|
||||
Source `dever.task.start` establishes the one runtime from a synchronous entry with an explicit configuration and static async handler. HTTP system primitives infer an optional final transferable context and pass it to a statically checked handler; applications can wrap this in their own concrete context signature. Ordinary records retain value semantics; Channel/client/resource fields share their existing resource identity.
|
||||
|
||||
`timeout(task, millis, fallback)` consumes a Task, waits for cancellation cleanup on expiry, then invokes a zero-input async fallback with the identical output signature. `race(tasks...)` consumes equally typed tasks and drains losers before returning the first result. Neither operation suppresses program faults. `stream(Channel|List|Bytes)` and periodic `dever.task.ticks` are pull-driven; async memory traversal reuses collection handlers and bounded Group dispatch.
|
||||
|
||||
Network clients own bounded reusable HTTP/1 connections and stream bodies. TLS uses tokio-rustls/ring, with certificate verification, bundled public roots and explicit custom roots/server PEM material. TCP/TLS share the concrete I/O owner used by HTTP and WebSocket upgrades. Protocol parsing stays in Hyper/tungstenite. Idle connections, pending requests, body chunks and cancellation have explicit owners. No HTTP/2, implicit retries, redirects or detached connection drivers.
|
||||
|
||||
The implemented pool fixes one origin per shared HttpClient and uses Hyper's low-level HTTP/1 API. Separate request and physical-connection permits include idle/queued drivers in the same limit. One structured task drives the bounded queue and FuturesUnordered, expiring idle entries without a future request. Its owner guard is constructed before task creation, including the unpolled-child cancellation case. Response EOF returns its lease; early close/fault/cancellation aborts the connection. Listener.close triggers graceful HTTP shutdown; timeout(server, grace_ms, fallback) bounds draining active responses and upgrades.
|
||||
|
||||
Locked additions are tokio-rustls 0.26.5, rustls 0.23.44 with ring/std/tls12 and webpki-roots 1.0.8. Shared TLS configuration uses small explicit session-cache capacities. Transport boxes only the large TLS variant; ordinary TCP stays direct. The backend example combines configured source entry, concrete Context with Channel, a pooled request and graceful shutdown on an owned random loopback port.
|
||||
|
||||
## N3 Streaming HTTP, SSE And WebSocket (2026-09-12, Approved)
|
||||
|
||||
Keep ordinary `serve` direct; `serve_live` binds an async zero-output `(Request, HttpReply)` handler. A Group of one owns each connection's live handler. Shared request decoding, checked response encoding and Hyper configuration avoid a parallel HTTP implementation. A one-slot body channel provides backpressure. Response-body Drop signals cancellation for streamed, buffered and HEAD responses; Session cleanup closes the producer or upgraded socket. Handler faults propagate and cannot be emitted as a successful truncated body.
|
||||
|
||||
On upgrade, wait for Hyper's driver, extract its known TcpStream and preread bytes, then keep the connection reservation while awaiting the handler Group. This removes the HTTP write deadline and the need for an extra transport abstraction. WebSocket uses independent reader/writer locks around tokio-tungstenite split halves, the shared Endpoint close signal and bounded message/write buffers with a 4 KiB read buffer. Acquire the writer lock before arming cancellation cleanup. close takes final transport ownership, wakes other operations and completes a bounded handshake.
|
||||
|
||||
SSE is a small field encoder plus response-body timer; no routing framework or heartbeat task is needed. Preserve CR/LF/CRLF and trailing data lines, compute final size before allocation and reserve Content-Type for the protocol. Request headers carry Last-Event-ID; replay belongs to application code. WebSocket control frames are processed while receive/messages is polled; no automatic reader or Ping task. Public exact limits and lifecycle are defined once in LANGUAGE.md.
|
||||
|
||||
References checked: [tokio-tungstenite 0.30.0](https://docs.rs/tokio-tungstenite/0.30.0/tokio_tungstenite/), [WebSocketConfig](https://docs.rs/tungstenite/0.30.0/tungstenite/protocol/struct.WebSocketConfig.html), [EventSource parsing](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream). No TLS, HTTP/2, pooling, protocol extensions or compatibility layer in N3.
|
||||
|
||||
## N2 HTTP Engine (2026-09-12, Approved)
|
||||
|
||||
Hyper HTTP/1 owns parsing/framing/persistence, using the existing Tokio runtime and only hyper-util's Tokio I/O/timer adapter. No Axum dependency is needed for the current single static route contract. This supersedes earlier source-only HTTP and per-request fault-isolation proposals below: handler program faults propagate through the structured server task, while peer protocol/I/O failures end only that connection.
|
||||
|
||||
Official source owns Header/Request/Response/Limits, header helpers, send and serve. Header is Text name plus Bytes value; List preserves repeated values. Native conversions resolve fields by name after exact shape checks. Intrinsic carries an optional static callback; callback extraction is shared with Collection for dependency/cycle/specialization traversal, and handler effects/recovery propagate through the existing summaries.
|
||||
|
||||
HTTP accepts unique Tokio streams through the existing Listener owner before constructing any aliased public Socket. Existing AsyncStream/Group reserves capacity before accept, observes connection faults while idle, and drains descendants on stop. Hyper never spawns a detached connection driver. Client send polls driver and response in the same bounded future and releases both on completion/cancellation; pooling is deferred.
|
||||
|
||||
Bytes uses bytes::Bytes for shared engine storage, slicing and compatible Text ownership transfer. Bodies are buffered within explicit limits, merging fragments incrementally without retaining one metadata entry per tiny frame. Header, body, handler and stalled-write deadlines have separate owners. The public contract and exact protocol exclusions are in LANGUAGE.md; TLS/HTTP2, streaming responses, client pooling and WS/SSE are not N2 deliverables.
|
||||
|
||||
## N1 Async Stream And TCP (2026-09-12, Approved)
|
||||
|
||||
This new-language phase supersedes the historical compatibility baseline and network pause below. Migrate callers directly; no synchronous TCP backend, aliases or fallback paths.
|
||||
|
||||
Use one existing Tokio runtime with its I/O driver enabled. AsyncStream owns one Send stream producer, serializes pulls, and shares a close signal across aliases. Closing or finishing a consumer releases its producer; there is no unbounded buffer or per-item boxed future. Stream remains the distinct synchronous file-sequence type.
|
||||
|
||||
Socket and Listener share explicit resource closure. Socket read and write have independent async locks; closure wakes both. A write tracks partial progress and closes the socket if abandoned after writing any bytes, so subsequent operations cannot append to a truncated message. Operation timeout includes waiting for its direction lock. No retry of an entire interrupted write.
|
||||
|
||||
AsyncStream traversal may call synchronous effect-checked handlers or async handlers. parallel_each on AsyncStream requires an async zero-output handler and uses the existing structured Group. It checks completed tasks and acquires group capacity before pulling, and observes handler failures even while the producer is idle. No new detached task model or private thread pool.
|
||||
|
||||
Compiler types, effects, liveness, native specialization, official network API and existing HTTP transport move together. The synchronous reference evaluator explicitly excludes async execution; no second network implementation is retained for differential tests.
|
||||
|
||||
## Structured Concurrency Design And Implementation Contract (2026-09-12)
|
||||
|
||||
This preceding phase is implemented through the compiler frontend, checked HIR, native backend and bounded runtime, with evidence in `implement.md`. It superseded the first-stage no-async model. The current N1 decision permits direct source-contract changes without a compatibility baseline.
|
||||
|
||||
### One Task Model
|
||||
|
||||
Dever has one concurrency model rather than separate async, coroutine and thread abstractions:
|
||||
|
||||
- an async function compiles to a stackless coroutine;
|
||||
- `await` suspends that coroutine without occupying a worker thread;
|
||||
- `run` schedules the coroutine as a child Task;
|
||||
- the runtime may move transferable tasks across worker threads;
|
||||
- `parallel` and `blocking` cross from async scheduling into bounded synchronous execution.
|
||||
|
||||
The program creates one Tokio runtime at an async entry point. A program whose reachable entry graph is entirely synchronous retains the current direct native entry and does not initialize Tokio. The runtime configuration owns async worker count, blocking capacity and task limits once; packages do not create private runtimes or pools.
|
||||
|
||||
### Source Surface
|
||||
|
||||
`async` prefixes a function clause. Every clause in one logical function must agree on this modifier:
|
||||
|
||||
```dever
|
||||
async load_user(id: Id) (user: User) {
|
||||
user = await(fetch_user(id))
|
||||
}
|
||||
```
|
||||
|
||||
An async call is context-only, not an ordinary storable future. It may appear directly under `await`, `run` or the matching compiler-owned task operation. This avoids adding a general `Awaitable<T>` value and preserves the current static-call model.
|
||||
|
||||
```dever
|
||||
first = run(load_user(first_id))
|
||||
second = run(load_user(second_id))
|
||||
first_user = await(first)
|
||||
second_user = await(second)
|
||||
```
|
||||
|
||||
`await(async_call(...))` invokes and waits without creating a separately owned Task. `run(async_call(...))` starts first and returns an affine Task whose associated output signature remains in checked HIR. `await(task)` consumes it and yields the same zero, single or named multi-output shape as the function. Task operations consistently use the language's existing call appearance; no second prefix spelling is accepted.
|
||||
|
||||
Static handler syntax places `async` before `handler`:
|
||||
|
||||
```dever
|
||||
async serve(
|
||||
route: async handler(request: Request) (response: Response)
|
||||
) () {
|
||||
response = await(route(request))
|
||||
}
|
||||
```
|
||||
|
||||
`async` has one canonical declaration position immediately before the function name. Existing `pure` and `recover` contracts retain their post-signature positions. Parser, formatter, Markdown signatures and examples must change atomically; no postfix async spelling is accepted.
|
||||
|
||||
### Task Ownership
|
||||
|
||||
Task is a compiler-owned affine local type, not a new user-defined generic facility. It may be held only in a local slot of the async function that created it. It cannot be copied, compared, stored in an aggregate, passed as an ordinary value, returned, captured by a handler or left live at any clause exit.
|
||||
|
||||
Each async entry and spawned task has an internal runtime Scope; the compiler treats every async function as a lexical ownership scope. Directly awaited calls inherit the current Scope because runtime faults cannot be caught, while `run` and Group create a supervised child Scope. Every Task must be consumed exactly once by `await` or `stop`. On abnormal runtime exit, the root or child supervisor requests cancellation and waits for descendant and blocking-work cleanup before returning the failure. There is no detached task in the first release.
|
||||
|
||||
`stop` means cancel-and-wait, not fire-and-forget cancellation. To keep failure and output obligations explicit, the first release accepts only zero-output Task values. Result-bearing tasks must be awaited. Cancellation is cooperative at await/yield boundaries; it neither kills a running synchronous operation nor rolls back effects that already occurred.
|
||||
|
||||
### Dynamic Task Groups
|
||||
|
||||
`group(limit)` creates an affine Group for a dynamic number of zero-output async calls. It is needed for later connection/session ownership without allowing detached children:
|
||||
|
||||
```dever
|
||||
workers = group(128)
|
||||
run(workers, serve_connection(connection))
|
||||
await(workers)
|
||||
```
|
||||
|
||||
`run(group, async_call(...))` transfers the child into the Group and returns no Task. It may suspend while the positive capacity limit is full, providing backpressure before starting more work. Ungrouped `run(async_call(...))` is likewise subject to the owning function scope's task limit. `await(group)` closes admission, waits for all children and consumes the Group. `stop(group)` closes admission, requests cancellation, waits for every child and consumes the Group. Group cannot be copied, stored or escape its creating async function.
|
||||
|
||||
Only zero-output calls enter a Group, so the runtime cannot accumulate unbounded result values and expected failures cannot be silently discarded. A later result-collecting combinator must define ordering and memory limits separately.
|
||||
|
||||
### Thread Transfer And Execution Classes
|
||||
|
||||
The type model derives a `transferable` property recursively, alongside the existing comparison/move/transfer properties. Bool, numeric values, Text, Id and Bytes are transferable. Records, choices, nullable values, Lists and Maps are transferable only when every nested value is transferable. Each opaque resource is opted in only when its runtime representation and lifecycle are thread-safe; the current pull-based Stream is initially non-transferable.
|
||||
|
||||
Values live across an `await`, captured by `run`, or passed through channel/parallel boundaries must be transferable. Last-use analysis may move uniquely owned values into a task; otherwise ordinary value semantics require a copy. This rule is checked before native emission and is not delegated to a Rust compiler error.
|
||||
|
||||
Execution classes remain distinct:
|
||||
|
||||
- ordinary synchronous calls execute inline;
|
||||
- `run` schedules an async call on the shared runtime;
|
||||
- `parallel(sync_call(...))` accepts only a pure synchronous target with transferable inputs/outputs and runs it through bounded CPU execution;
|
||||
- `blocking(sync_call(...))` accepts a synchronous target whose transitive effect summary contains a compiler-known blocking system boundary and uses bounded blocking capacity.
|
||||
|
||||
`parallel` and `blocking` are async-context operations in the first release: they suspend their caller until the synchronous call completes. Synchronous code continues to use `parallel_each`; it does not initialize Tokio solely to access these operators.
|
||||
|
||||
Existing synchronous `parallel_each` keeps its public behavior and joined scoped workers. Async List/Bytes lowering requires a named synchronous function with no transitive concurrency effect, then submits bounded batches directly to the shared Tokio blocking executor, so it neither blocks a Tokio scheduling worker nor creates a nested thread pool. Other collection operations inspect the complete handler effect and cannot hide blocking, concurrency or an unconstrained handler effect on a Tokio scheduling worker. The first release has no handler effect bounds, so an async function cannot invoke a synchronous handler parameter inline. Both parallel paths share worker-limit and batch-size validation. Synchronous Stream remains unavailable in async functions until async Stream is designed.
|
||||
|
||||
### Channels
|
||||
|
||||
`channel(Type, capacity)` is compiler-contextual syntax: its first operand is a TypeRef, not a runtime type value. It creates a bounded `Channel<Type>` resource. Capacity is required, positive and capped. This deliberately avoids adding generic function invocation syntax solely for one constructor.
|
||||
|
||||
Channel aliases share one state. `send(channel, value)` asynchronously waits for capacity; `receive(channel)` asynchronously waits for an item and returns `Item?`, where a value is an item and `null` means the Channel is closed. Because nullable types are idempotent, receiving from `Channel<Item?>` also returns `Item?`; native emission flattens the runtime's nested option. A runtime scheduler or Channel fault remains a located fatal execution failure rather than a domain value. `close(channel)` is idempotent and wakes blocked senders and receivers. No unbounded constructor exists. Values must be transferable. Channel itself cannot be a Map key or comparable value, and its close/alias behavior follows explicit resource semantics rather than ordinary value semantics.
|
||||
|
||||
### Effects, Failures And Static Handlers
|
||||
|
||||
Async is part of a function and handler signature. Sync callers cannot invoke async functions or async handlers. Async callers may invoke sync code directly and async code only through an explicit async operation. Collection handlers remain synchronous because collection operations do not await handler futures; async handlers are called only through `await` or `run`. Handler specialization keys include the async signature bit, and cycle detection continues across await/run/forwarding edges; async does not legalize recursion.
|
||||
|
||||
`await`, `run`, `stop`, group operations, channel waits, timers, `parallel` and `blocking` are effects. The existing transitive effect graph owns these facts, so the first release rejects `async` together with `pure`. Task boundaries do not discharge error-bearing outputs: awaiting restores the original output obligations, while Group and stop accept only zero-output work. Runtime scheduler failure is a located fatal execution failure; expected domain failures remain explicit source choice values.
|
||||
|
||||
### HIR And Native Boundaries
|
||||
|
||||
The syntax tree records the async modifier and contextual concurrency expressions. Checked HIR owns explicit `AwaitCall`, `RunCall`, `AwaitTask`, `StopTask`, Group and Channel operations. It never represents a generic user-visible Future. Function metadata and HandlerSignature carry the async bit; Task stores its concrete checked output fields.
|
||||
|
||||
Native emission generates concrete Rust futures and direct calls. Static handlers remain specialized direct targets; no boxed dynamic callback, generic runtime Value, reflection registry or garbage collector is introduced. The runtime owns scheduler construction, task/group/channel state, limits and cancellation primitives. Compiler checks own source legality, transferability, affine consumption and diagnostic locations.
|
||||
|
||||
Runtime cancellation separates a business future from its supervisor. Task/Group stop aborts the business future; the supervisor itself remains alive, drains its child Scope, and only then publishes completion to the parent Scope. Started `spawn_blocking` jobs register a sticky completion signal because Tokio cannot abort them safely. Drop paths request cancellation and detach only supervisor handles whose completion is still owned and awaited by the parent Scope; no Drop implementation calls `block_on`.
|
||||
|
||||
### Compatibility And Deferred Work
|
||||
|
||||
Existing synchronous source and `parallel_each` behavior stay valid. `LANGUAGE.md`, Markdown contracts and API snapshots are updated with the implementation. The `run` source operator is distinct from the CLI command by grammar context.
|
||||
|
||||
The task-foundation phase deferred networking; N1 above resumes AsyncStream and TCP on these primitives. The HTTP engine and WebSocket/SSE remain later work. Detached tasks, arbitrary shared mutation, locks, user thread creation, unbounded channels and forceful cancellation remain out of scope.
|
||||
|
||||
## Markdown Source Follow-through (2026-09-09)
|
||||
|
||||
`.dever.md` is an additional source container alongside `.dever`. Top-level literal `dever` and `typescript dever` fences contain ordinary Dever declarations; the latter supplies a TypeScript highlighting hint while retaining an explicit Dever marker. A dedicated Markdown contract parser maps one H1 to the package header and one H2 to each type or logical function group before formatting. After shared signature resolution, semantic validation compares package/API/use metadata, type fields/variants and function inputs/outputs with syntax and HIR, retaining both documentation and declaration spans in diagnostics. Shared fence extraction keeps original spans for parsing, diagnostics and per-block formatting. `.dever` and language/runtime semantics are unchanged. The authoritative source-format contract is `LANGUAGE.md` and `.trellis/spec/backend/toolchain-and-library.md`; earlier `.dever`-only and unconstrained-heading limits below are superseded.
|
||||
|
||||
## Performance Implementation Boundary (2026-09-08)
|
||||
|
||||
- Compiler analysis worker owns types.rs, check.rs integration and contracts dependency scheduling: memoized type properties and monotone reverse-dependency work queues, retaining handler substitution and proof limits. Existing graph traversal is reused.
|
||||
- Cache worker owns source/parse caching, native/build.rs and its cache helper plus CLI cache integration if necessary. Cache identities include effective source, compiler/toolchain/runtime and options; checked API baselines stay outside cached native execution. No new external dependency or source format.
|
||||
- Main owns native liveness/emission, runtime collections, JSON/HTTP source algorithms, performance fixtures and integration. Record updates preserve value aliases and evaluation order; consuming iteration retains resource scope; direct split consumption avoids eager character lists; HTTP parsing tracks incremental boundaries and framing in Dever.
|
||||
- Root tests own semantic regressions and bounded before/after measurements; no services/full suite/commit. Workers are not alone and must preserve unrelated edits. Shared check.rs edits require coordination with the analysis owner. Independent final review checks values, failure order, cache invalidation and protocol bounds.
|
||||
|
||||
## Follow-through Implementation Design (2026-09-07)
|
||||
|
||||
This section owns the current follow-through; older extension boundaries below describe completed milestones.
|
||||
|
||||
- Formatter: add an AST writer using existing spans, preserved comments and precedence. Preserve record initializer expression order. CLI owns filesystem mutation and prepares every result before any rewrite. Expose syntax-only formatting separately from semantic checking.
|
||||
- Reference evaluator: add an opt-in `reference` compiler feature, not a runtime dependency. Consume checked HIR, normalized domains and static handler bindings; reuse runtime number/resource primitives. Frames hold value locals and preserve copy semantics. Capture stdout and named results for differential tests. Do not add an evaluator production CLI path.
|
||||
- Standard library: extend the single intrinsic/checker/native catalog for atomic representation/OS operations only. Public result choices live in `library/`; share runtime implementations with the evaluator. Text indexes are Unicode scalar positions; Bytes indexes remain byte offsets. Number parse failures are nullable results, never a silent zero; finite Decimal rules remain unchanged.
|
||||
- Optimization: analyze actual read order and writes per clause, retaining outputs and assignment hosts. Move dead values, preserve live aliases and short-circuit paths; transfer reduction state between iterations. Verify value semantics before performance comparison.
|
||||
- Parallel ownership: formatter worker owns formatter modules, formatting CLI and root format tests; reference worker owns evaluator modules and root differential tests. Main agent owns libraries, primitive integration, native optimization, task documents and integration. Coordinate shared module registration edits.
|
||||
- Verification uses offline focused targets and test-owned files/processes. Performance checks are bounded local computation, exclude compiler startup and use matching numeric/collection wrappers. Existing user sources are never formatter test fixtures.
|
||||
- Production native compilation and matching performance baselines share one optimization-argument owner (`opt-level=3`, `codegen-units=1`). The single generated source compiles as one unit to keep generic collection inlining independent of source size; compilation time is measured separately from hot paths.
|
||||
|
||||
- Status: Core foundations and foundation extension implemented and verified on 2026-09-07
|
||||
- Date: 2026-09-04
|
||||
- Product source of truth: `prd.md`
|
||||
|
||||
## Completed Foundation Boundary (Historical)
|
||||
|
||||
Extend the implemented core with the approved sequence and resource foundation: `reduce`, `reduce_until`, Bytes traversal, `Stream<Item>`, restricted static handler inputs, and file/TCP stream wrappers. Existing clause-only branching, linear bodies and the ban on source loops and user recursion remain permanent. Compiler distribution, cross-platform work and full HTTP/JSON/CRUD packages remain deferred.
|
||||
|
||||
Extend the existing checker and HIR in place. Represent user types nominally, resolve every function signature before bodies, prove clause coverage over symbolic input domains, and emit concrete native types/functions. Keep source expression order, including record construction; formatting must not reorder effectful field expressions. Use one runtime implementation for numeric operations, ordered collections and resource primitives. Bundled `.dever` sources define official wrappers and result types through the same frontend as user packages; only fixed primitive signatures have native implementations.
|
||||
|
||||
## Foundation Extension Design
|
||||
|
||||
This section records the completed foundation-extension design. The Follow-through Implementation Design above owns subsequent additions; later numbered sections retain the original Core roadmap.
|
||||
|
||||
### Sequence Contract And Reuse
|
||||
|
||||
The checker extends the existing collection-operation owner rather than adding parallel validators. One internal sequence classifier returns the element type and source kind for `List<Item>`, Bytes and `Stream<Item>`; `each`, `reduce` and `reduce_until` reuse it for handler checking and lowering.
|
||||
|
||||
- `reduce(handler, sequence, initial_state)` requires a two-input handler `(Item, State) -> State` and returns `State`.
|
||||
- `reduce_until(handler, sequence, initial_state)` requires `(Item, State) -> (State, stop: Bool)`, processes the current item before stopping, and returns only `State`.
|
||||
- List and Bytes use finite direct loops. Bytes yields Int values from 0 through 255 without allocating `List<Int>`.
|
||||
- Stream consumption calls its pull boundary only when the next item is needed. `reduce_until` leaves the next item unread; `reduce` consumes through natural exhaustion.
|
||||
- `each(Stream)` accepts only a zero-output handler. List/Bytes retain their existing zero-output action and one-output mapping behavior. `filter`, `find` and `sum` remain List-only.
|
||||
- Core `close(stream)` is a zero-output, idempotent Stream operation. It shares the same sequence operation registry but is checked as resource termination, not traversal.
|
||||
|
||||
Handler signature diagnostics, sequence classification and output-shape validation each have one semantic owner. Native lowering receives already classified sequence and handler facts; it does not repeat source-level type decisions.
|
||||
|
||||
### Static Handler Representation
|
||||
|
||||
`handler(inputs) (named_outputs)` is contextual syntax allowed only after a function input colon. The syntax tree represents value patterns and handler parameter declarations as separate input kinds; it does not disguise a handler as an ordinary `TypeRef`. The semantic model likewise keeps `HandlerSignature` separate from runtime `Type`, because a handler is a compile-time binding rather than a value.
|
||||
|
||||
`HandlerSignature` stores positional input types and ordered named output fields. Input names do not participate in compatibility; output name, position and type all do. The checker accepts only a matching named function or a handler parameter already in scope. Handler references cannot enter local slots, aggregates, outputs, comparisons or clause domains.
|
||||
|
||||
HIR uses explicit call and argument forms:
|
||||
|
||||
```text
|
||||
HandlerTarget = NamedFunction(FunctionId) | Parameter(HandlerParameterId)
|
||||
CallTarget = NamedFunction(FunctionId) | Handler(HandlerTarget)
|
||||
CallArgument = Value(Expression) | Handler(HandlerTarget)
|
||||
```
|
||||
|
||||
Collection/sequence HIR stores the same `HandlerTarget`; it no longer carries a collection-specific optional function ID. This is the reusable representation for direct handler calls, sequence operations and multi-level forwarding.
|
||||
|
||||
Native compilation treats a function with handler inputs as a template. A specialization key is the function ID plus its ordered concrete named-function bindings. Forwarding substitutes bindings until every handler call has a concrete target, then emits an ordinary direct call. The specialization graph rejects a repeated active key as direct or indirect recursion before any private Rust source is compiled. Generated handler plumbing contains no function pointers, closures, virtual dispatch or reflective registry; the opaque Stream producer described below is the only dynamic call boundary.
|
||||
|
||||
### Stream Representation And Lifecycle
|
||||
|
||||
The semantic type model adds `Stream(Box<Type>)`. It is the only new compiler-provided generic type; it is a valid parameter, local, field and choice payload but is not comparable and cannot be a Map key. Copying it preserves opaque resource semantics: every alias shares one cursor, exhausted flag and explicit-close flag.
|
||||
|
||||
`dever-runtime` adds one generic pull-stream owner backed by shared synchronized state. Its producer returns either one item or natural end. The only dynamic producer abstraction is inside this opaque I/O boundary, where the PRD explicitly permits it; Dever handlers remain statically specialized. Pulling and closing are serialized, with no prefetch, worker thread or async task.
|
||||
|
||||
The terminal rules are:
|
||||
|
||||
1. Normal EOF marks the Stream exhausted and releases its producer without yielding an element.
|
||||
2. A terminal file/socket read error is converted once to `ReadEvent.Failed`, then the Stream becomes exhausted.
|
||||
3. A terminal accept error is converted once to `ConnectResult.Failed`, then the Stream becomes exhausted.
|
||||
4. `close(stream)` marks all aliases closed, drops the producer and is a no-op when repeated; later traversal sees ordinary exhaustion.
|
||||
5. Closing a separately held File, Socket or Listener invalidates the shared underlying resource. The dependent Stream then yields its one typed Failed item and ends.
|
||||
6. Closing only the Stream drops its source alias but does not close a separately held source handle. Native destruction remains the final cleanup fallback.
|
||||
|
||||
`reduce_until` never pre-pulls, so its stop item is consumed and the following item remains available to every Stream alias. Concurrent consumption is outside the language contract; the synchronized state protects resource integrity but does not introduce concurrency semantics.
|
||||
|
||||
### Official Package And Runtime Boundary
|
||||
|
||||
The public stream API remains ordinary bundled `.dever` source:
|
||||
|
||||
```dever
|
||||
type ReadEvent {
|
||||
Chunk(bytes: Bytes)
|
||||
Failed(message: Text)
|
||||
}
|
||||
|
||||
type ReadStreamResult {
|
||||
Streaming(stream: Stream<ReadEvent>)
|
||||
Failed(message: Text)
|
||||
}
|
||||
```
|
||||
|
||||
`dever.io.chunks(file, limit)` and `dever.net.chunks(socket, limit)` return `dever.io.ReadStreamResult`; `dever.net.connections(listener)` returns `Stream<dever.net.ConnectResult>`. Existing one-shot `read` remains unchanged. Invalid limits and already-closed sources fail during stream creation; later source failures become one terminal stream item.
|
||||
|
||||
The ownership flow remains:
|
||||
|
||||
```text
|
||||
bundled .dever function and result choices
|
||||
-> fixed dever.system intrinsic signature
|
||||
-> generated typed adapter
|
||||
-> dever-runtime Stream/resource primitive
|
||||
```
|
||||
|
||||
The generated adapter maps runtime pull outcomes (item, natural end or error) into the source-defined concrete choices. Runtime code therefore owns pulling, locking and OS resources, while `.dever` owns public names and recoverable result shapes. No HTTP parser, route table, JSON codec or user model enters the runtime.
|
||||
|
||||
### Implementation And Rollback Boundaries
|
||||
|
||||
Implementation proceeds through coherent compiler boundaries: syntax/semantic handler declarations; shared handler and sequence HIR; List/Bytes reduce lowering; runtime Stream; file/TCP adapters; then native specialization and end-to-end fixtures. Each boundary must compile and pass its focused tests before the next. Unsupported handler or Stream positions remain compile diagnostics; there is no compatibility fallback to runtime values or eager Stream collection.
|
||||
|
||||
The existing one-shot file/TCP APIs, List operations and generated-binary boundary remain compatible. No stored data or public artifact format needs migration. Reverting the extension removes its syntax, standard source declarations, HIR forms and runtime module together while leaving the completed core foundations intact.
|
||||
|
||||
## Historical Hello Milestone (2026-09-06)
|
||||
|
||||
The completed deliverable is a native `user.dever` whose `main() ()` prints `hello`. A future user API may start with in-memory fixtures; database selection is no longer a prerequisite. Existing parser and surface syntax are preserved.
|
||||
|
||||
The implemented Text/action subset covers package and function resolution, Text parameters and local assignment, zero-output calls, checked HIR, native Rust emission and local check/build/run. It checks all source declarations and rejects unsupported constructs, duplicate symbols, invalid exposures, private cross-package calls, source/package mismatches and cycles. This is not full Core checking.
|
||||
|
||||
The standard `dever.io.println(Text)` signature resolves to a typed intrinsic. Its implementation belongs to a small dependency-free `dever-runtime`; the native backend embeds that same runtime source in disposable generated Rust. Text uses owned Rust Strings to preserve Dever value semantics. User function and local identifiers lower to numeric IDs, not raw Rust identifiers. Output failures propagate internally to a nonzero process exit with the Dever call location; no new source exception syntax is introduced.
|
||||
|
||||
The CLI owns argument parsing and process execution; the core owns checked programs and native compilation. Invoke rustc directly without a shell, using RUSTC or PATH. Compile into an exclusively created temporary directory, retain it through execution and remove owned artifacts afterwards. Explicit build output must not overwrite an existing file. No external dependency, package distribution, interpreter or service is needed.
|
||||
|
||||
The following sections describe the wider roadmap. Implemented foundations are recorded in the Active Core Checklist and Core Foundation Evidence in `implement.md`; formatter, reference evaluator, performance gates and application packages are not part of this delivery.
|
||||
|
||||
## Deferred Application Architecture
|
||||
|
||||
This is a later HTTP/JSON/CRUD direction, not part of the approved foundation extension. It must be reconsidered against the implemented Stream and static-handler contracts before application work begins.
|
||||
|
||||
The native path remains `.dever -> parser -> resolver/checker -> typed HIR -> private Rust -> native executable`. HTTP requests must execute the compiled business functions, not an interpreter or a handwritten Rust user service.
|
||||
|
||||
| Owner | Required responsibility |
|
||||
| --- | --- |
|
||||
| `.dever` application packages | User/input types, validation, CRUD orchestration, parameterized queries, routes and business error mapping |
|
||||
| `dever-core` | Symbol/type/clause/initialization checks, standard signatures, I/O effects, typed handler checks and native code generation |
|
||||
| `dever-runtime` | Mature HTTP and JSON implementations, the selected database driver, connection/resource lifecycle and per-request fault boundary |
|
||||
| CLI | Configuration inputs, `check`, native `build/run` and diagnostics |
|
||||
|
||||
Necessary semantic adaptations, with no new surface keywords:
|
||||
|
||||
- Allow named function references in fixed HTTP registration positions, extending the existing collection-handler mechanism. They remain statically resolved, not general first-class functions.
|
||||
- Add opaque standard resource types for database pools and server resources. Ordinary records/collections keep value semantics; resource handles explicitly identify external state and cannot be serialized or mutated as records.
|
||||
- Give standard I/O operations fixed effect metadata and propagate effects through the call graph. Preserve statement/expression evaluation order; internal async lowering does not introduce `async` or `await` into `.dever`.
|
||||
- Reuse choice results for recoverable HTTP/JSON/database failures. Program faults are isolated to the failing request and retain Dever source locations.
|
||||
- Bundle only the required standard HTTP/JSON/database contracts with the toolchain. External package distribution remains deferred.
|
||||
|
||||
The current `TypeRef` AST can represent parameterized standard types, but `parser.rs::type_ref_inner` presently recognizes only List/Map/MapEntry generic spellings. Any required qualified standard generic type must be supported through the standard-type contract, without a User-specific parser branch. User-defined generics remain deferred.
|
||||
|
||||
Persistence selection is pending. The initial design candidate uses parameterized SQL and static type-driven row decoding, with schema/query definitions owned by one application persistence package. Do not build an ORM or multi-database framework speculatively. JSON and row decoding should share resolved type facts, rather than duplicate user field definitions in Rust.
|
||||
|
||||
Verification must show that changing `.dever` handlers, types or routes changes the compiled API. A second small schema/route fixture should prove that compiler/runtime code contains no user-API special case. Report actual CRUD throughput, latency percentiles, errors and memory against equivalent native code under identical database/durability settings; finalize the performance budget after storage and deployment scope are selected.
|
||||
|
||||
## 1. Architecture Decision
|
||||
|
||||
Core 0.1 is a textual language toolchain, not a semantic-graph workbench. It has one checked frontend and two deliberately different consumers of the same typed HIR:
|
||||
|
||||
```text
|
||||
.dever source root
|
||||
|
|
||||
v
|
||||
source loader -> lexer -> parser -> syntax tree
|
||||
| |
|
||||
| +-> formatter
|
||||
v
|
||||
package/name resolver
|
||||
v
|
||||
type + clause + flow checks
|
||||
v
|
||||
typed HIR
|
||||
/ \
|
||||
v v
|
||||
reference evaluator native specialization
|
||||
v
|
||||
private Rust emission
|
||||
v
|
||||
rustc / LLVM -> binary
|
||||
|
||||
dever CLI -> load / check / format / build / run
|
||||
```
|
||||
|
||||
Dever text in `.dever` files or program blocks of `.dever.md` files is the program source. The syntax tree and typed HIR are in-memory compiler representations; they are not editable JSON formats or public compatibility contracts. The evaluator defines a simple executable reference for conformance tests. Production `build` and `run` use ahead-of-time native compilation and never route through the evaluator.
|
||||
|
||||
The bootstrap compiler is implemented in Rust because rustc provides a mature LLVM path. The repository starts without an existing workspace or implementation. This is an implementation choice, not a surface-language dependency: diagnostics, type identities and runtime semantics are owned by Dever, and generated Rust is a disposable private build artifact.
|
||||
|
||||
## 2. Scope Boundaries
|
||||
|
||||
The implementation contains three production crates:
|
||||
|
||||
```text
|
||||
crates/
|
||||
dever-core/
|
||||
src/
|
||||
source.rs
|
||||
diagnostic.rs
|
||||
token.rs
|
||||
lexer.rs
|
||||
syntax.rs
|
||||
parser.rs
|
||||
format.rs
|
||||
package.rs
|
||||
resolve.rs
|
||||
types.rs
|
||||
clauses.rs
|
||||
check.rs
|
||||
hir.rs
|
||||
value.rs
|
||||
stdlib.rs
|
||||
eval.rs
|
||||
native.rs
|
||||
lib.rs
|
||||
dever-runtime/
|
||||
src/
|
||||
number.rs
|
||||
collections.rs
|
||||
fault.rs
|
||||
lib.rs
|
||||
dever-cli/
|
||||
src/
|
||||
main.rs
|
||||
```
|
||||
|
||||
Compiler phases remain in one `dever-core` library rather than separate syntax, semantic and IR crates. They share source spans and language contracts and will evolve together during 0.1. `dever-runtime` is the only justified split because generated native programs consume it without depending on parser, checker, evaluator or CLI code. It contains only language-owned numeric, ordered-collection and runtime-fault primitives; it does not contain a dynamic interpreter or plugin layer.
|
||||
|
||||
`dever-cli` owns argument parsing, filesystem invocation, exit codes and rendering. It calls `dever-core` APIs and contains no parser, type or evaluation rules.
|
||||
|
||||
Permanent integration tests stay under repository-root `test/`, matching project policy. Plain-source examples live under `examples/dever/`; Markdown examples live under `examples/markdown/`. Both may contain small fixture data when needed.
|
||||
|
||||
## 3. Reuse And Replacement
|
||||
|
||||
### Reuse
|
||||
|
||||
- Pinned dependency discipline in the new Rust workspace.
|
||||
- Small modules and explicit data structures.
|
||||
- The idea that every diagnostic maps back to a source location.
|
||||
- Root-level integration tests and deterministic fixtures.
|
||||
|
||||
### Replace
|
||||
|
||||
- `SemanticGraph`, canonical JSON loading and semantic hashing.
|
||||
- Refiner candidates, approval, scrap and quarantine workflows.
|
||||
- Dever IR designed around the old graph.
|
||||
- Old target-facing Rust and React source generators and their public backend contracts.
|
||||
- Workbench presentation and temperature-alarm example.
|
||||
- Old child-task plan and old repository rules that call the semantic graph trusted source.
|
||||
|
||||
No adapter translates old JSON into new `.dever`. Such a bridge would preserve the rejected architecture, expand the change surface and create two sources of truth.
|
||||
|
||||
The replacement list describes rejected concepts, not files present at kickoff. No old implementation exists in the working tree or HEAD. Initialize `dever-core` and the root test crate first; add the runtime and CLI when their real consumers exist, without empty crates or partially working public commands.
|
||||
|
||||
## 4. Source Model
|
||||
|
||||
### 4.1 Source Files And Spans
|
||||
|
||||
`SourceFile` owns:
|
||||
|
||||
- canonical source-root-relative path;
|
||||
- UTF-8 text;
|
||||
- precomputed line-start offsets.
|
||||
|
||||
`Span` contains a file identifier and half-open byte range. Line and column are calculated only when rendering a diagnostic, keeping compiler nodes small. Every token, syntax node, resolved reference and HIR instruction retains a span.
|
||||
|
||||
The loader:
|
||||
|
||||
1. walks the supplied source root for `.dever` files;
|
||||
2. sorts paths lexically before parsing;
|
||||
3. rejects invalid UTF-8 and duplicate canonical paths;
|
||||
4. derives the expected package name from the path;
|
||||
5. returns all diagnostics in deterministic file/span order.
|
||||
|
||||
### 4.2 Tokens And Trivia
|
||||
|
||||
The lexer emits semantic tokens plus newline and `#` comment trivia. Newlines matter for separating linear statements; whitespace otherwise does not. Comments attach to the following syntax item, or to the preceding item when trailing on the same line, so the formatter can preserve them. `//` is always one integer-division token and is never considered comment syntax.
|
||||
|
||||
Numeric text remains unconverted in tokens. Parsing a very large literal directly into i64 or f64 would lose the ability to issue precise range and rounding diagnostics; conversion happens during checking with the expected type. An inexact Decimal literal is rejected rather than silently rounded, while checked Decimal arithmetic may produce the specified rounded result.
|
||||
|
||||
### 4.3 Syntax Tree
|
||||
|
||||
The parser produces a lossless-enough typed AST:
|
||||
|
||||
- `PackageDecl` and exposed names;
|
||||
- record/choice `TypeDecl`;
|
||||
- `FunctionClause`;
|
||||
- input patterns and output declarations;
|
||||
- assignment/call statements;
|
||||
- value construction, call, field, literal, unary and binary expressions.
|
||||
|
||||
The parser does not resolve names or infer types. It supports bounded recovery at a newline, closing delimiter or next top-level declaration and returns a partial tree plus diagnostics. Later phases run only for packages without syntax errors.
|
||||
|
||||
## 5. Formatter
|
||||
|
||||
The formatter consumes the syntax tree and attached comments, never performs string replacements over raw source, and owns one layout:
|
||||
|
||||
- two-space indentation;
|
||||
- multiline package exposure and type members, one item per line;
|
||||
- commas only where grammar requires positional separation;
|
||||
- no semicolons;
|
||||
- stable spaces around operators;
|
||||
- record fields in declaration order after successful resolution;
|
||||
- parentheses inserted only when required by precedence;
|
||||
- one blank line between top-level definitions and no trailing whitespace.
|
||||
|
||||
`format(format(source)) == format(source)` is an acceptance invariant. Invalid files are not rewritten in Core 0.1; the CLI reports diagnostics and leaves them untouched.
|
||||
|
||||
## 6. Resolution And Symbols
|
||||
|
||||
Resolution runs in three passes:
|
||||
|
||||
1. Register every package, type, function name and function arity.
|
||||
2. Register record fields, choice variants and full function signatures.
|
||||
3. Resolve bodies, patterns, exposed names and cross-package references.
|
||||
|
||||
Symbols use compiler-assigned integer IDs internally. User-visible identity remains the package-qualified source name, not an opaque semantic graph ID.
|
||||
|
||||
The resolver enforces:
|
||||
|
||||
- one file per package and exact path/package match;
|
||||
- local source plus the fixed compiler-owned standard catalog as the only Core 0.1 package origins;
|
||||
- reserved core single-segment names and the `dever.*` namespace, which local source cannot declare;
|
||||
- unique names inside a package;
|
||||
- one function signature per name/arity;
|
||||
- contiguous clauses for the same signature;
|
||||
- public access only through `exposes`;
|
||||
- full qualification across packages;
|
||||
- acyclic package, type and function-call graphs.
|
||||
|
||||
Resolution never performs network access or searches outside the supplied source root in Core 0.1. An unavailable official or third-party package is an ordinary unknown-package diagnostic. Future lockfile-pinned components will be registered as an additional origin before these same resolution passes; this does not require imports, aliases or version text in `.dever` calls.
|
||||
|
||||
### 6.1 Distribution Compatibility Contract
|
||||
|
||||
External distribution is deferred to 0.2, but Core 0.1 must not close off its confirmed identity model:
|
||||
|
||||
- a stable component ID such as `shemic.contact` owns the root of package and type identity;
|
||||
- registry, Git and local paths are replaceable source transports and never appear in `.dever` references;
|
||||
- Git additions require an explicit tag or revision, then lock the full commit and normalized SHA-256 content hash;
|
||||
- CLI-maintained `dever.mod` records direct dependency intent while `dever.lock` records the complete exact graph;
|
||||
- verified sources live in a global read-only content-addressed store shared across projects;
|
||||
- only explicit `add`, `update` and `fetch` operations may access the network;
|
||||
- every package supplied by a component must remain under that component ID, and duplicate providers are errors.
|
||||
|
||||
No 0.2 parser, Git client, resolver, store or command is implemented speculatively in Core 0.1. The current resolver needs only reserved namespaces and origin-neutral internal symbol IDs so adding a locked package origin later does not change source names.
|
||||
|
||||
The ordinary call graph is computed after all bodies resolve. Sequence handlers and handler-input forwarding are explicit HIR edges; native specialization checks the fully substituted instance graph, so indirect recursion cannot hide behind `each`, `reduce`, `reduce_until` or a handler parameter.
|
||||
|
||||
## 7. Type System
|
||||
|
||||
`TypeId` points to interned compiler types:
|
||||
|
||||
- the six scalar types;
|
||||
- user record and choice types;
|
||||
- `Nullable(TypeId)`;
|
||||
- `List(TypeId)`;
|
||||
- `Map(KeyTypeId, ValueTypeId)`;
|
||||
- compiler-owned `MapEntry(KeyTypeId, ValueTypeId)`;
|
||||
- compiler-owned `Stream(ItemTypeId)` opaque resources;
|
||||
- ephemeral named multi-output result types.
|
||||
|
||||
There are no user-defined generic parameters in 0.1. List, Map, MapEntry, Stream and sequence functions are compiler-provided generic contracts. Static `HandlerSignature` is a separate function-parameter contract and is not a runtime `Type`.
|
||||
|
||||
The type interner owns idempotent nullable construction for standard generic returns; explicit doubled `?` syntax remains invalid. Structural equality follows the PRD, including ordered Map pairs and IEEE Float comparisons inside aggregates. Checking, reference evaluation and native lowering must share these contracts.
|
||||
|
||||
Checking is expression-directed:
|
||||
|
||||
- function inputs and outputs provide declared boundary types;
|
||||
- assignment provides an expected type once the local is initialized;
|
||||
- an uninitialized local takes the inferred type of its first complete assignment;
|
||||
- empty List/Map and context-sensitive numeric literals require an expected type;
|
||||
- the only implicit numeric conversion is Int to Decimal;
|
||||
- function output, static handler and sequence-operation contracts are checked before lowering.
|
||||
|
||||
Linear bodies make definite assignment a forward pass. The checker tracks each local as uninitialized or initialized with one stable type. Record field writes require an initialized record and preserve its type.
|
||||
|
||||
## 8. Clause Analysis
|
||||
|
||||
Clause-based conditional dispatch is a permanent source-language constraint, not a Core 0.1 deferral. Native decision trees are its internal implementation and do not introduce another source-level branching form.
|
||||
|
||||
Each same-name/same-arity group becomes one `FunctionDef` with multiple clauses. Analysis separates base input types from clause patterns.
|
||||
|
||||
Patterns normalize into symbolic sets:
|
||||
|
||||
- Bool and no-payload choice types become finite atoms.
|
||||
- Nullable adds a distinct null atom.
|
||||
- Payload choices keep the variant atom and bind payload fields after selection.
|
||||
- Text/numeric constants become singleton sets.
|
||||
- Int and Decimal comparisons become normalized intervals with open or closed endpoints.
|
||||
- `other` is computed as the complement of all explicit patterns in that argument position and group.
|
||||
|
||||
A multi-input clause is a product of its per-input sets. The analyzer incrementally subtracts each product from the function input universe:
|
||||
|
||||
- intersection with prior coverage means overlap;
|
||||
- an empty clause domain means unreachable;
|
||||
- remaining universe after all clauses means non-exhaustive.
|
||||
|
||||
The algorithm works on finite atoms and interval boundaries, not enumerated i64 or Decimal values. Float range patterns are rejected before this phase. The source order is never part of selection.
|
||||
|
||||
At runtime, HIR stores a compiler-chosen decision tree built from the normalized domains. It can optimize tests while preserving order-independent semantics; no runtime “first matching clause” rule exists.
|
||||
|
||||
## 9. HIR And Reference Evaluation
|
||||
|
||||
The typed HIR contains only resolved IDs and typed operations:
|
||||
|
||||
- constants and value construction;
|
||||
- local/field load and store;
|
||||
- checked numeric/text/boolean operations;
|
||||
- direct function and statically bound handler calls;
|
||||
- standard collection/sequence calls with explicit handler targets;
|
||||
- clause decision nodes;
|
||||
- named outputs.
|
||||
|
||||
The evaluator uses an explicit call frame with owned locals and a source span for every operation. There is no global mutable program state.
|
||||
|
||||
Runtime values are:
|
||||
|
||||
```text
|
||||
Bool
|
||||
Int(i64)
|
||||
Decimal(DecimalValue)
|
||||
Float(f64)
|
||||
Text
|
||||
Id
|
||||
Record
|
||||
Choice
|
||||
Nullable
|
||||
List
|
||||
Map
|
||||
Stream
|
||||
MultiOutput
|
||||
```
|
||||
|
||||
Aggregate values use shared immutable storage with copy-on-write mutation. This implements source-level value semantics without eagerly cloning every nested List, Map or record. The optimization is internal and must pass alias-observation tests.
|
||||
|
||||
Map storage uses an insertion-ordered map. In Rust, `indexmap::IndexMap` is the planned implementation; replacement keeps the same wrapper contract. Removal must use order-preserving removal, and overwrite must retain the existing position.
|
||||
|
||||
Evaluation returns either named outputs or a `RuntimeFault`. A fault carries category, message and originating `Span` and unwinds only to the current CLI execution boundary. There is no language-level catch path.
|
||||
|
||||
The evaluator is intentionally not optimized as the production runtime. Its job is to make language semantics easy to inspect, to evaluate focused tests and to serve as a differential oracle for native output.
|
||||
|
||||
## 10. Native AOT Compilation
|
||||
|
||||
The native path consumes only fully checked typed HIR. It performs whole-program specialization from the selected zero-input entry function and emits one transient Rust crate:
|
||||
|
||||
```text
|
||||
reachable typed HIR
|
||||
|
|
||||
v
|
||||
specialize concrete List/Map/Stream and handler-binding combinations
|
||||
|
|
||||
v
|
||||
emit static Rust records, enums, functions and loops
|
||||
|
|
||||
v
|
||||
cargo build --release -> native executable
|
||||
```
|
||||
|
||||
There is no generic `Value` in generated function signatures. Dever records lower to concrete Rust structs, choices to concrete enums, nullable values to an internal option representation, and calls to direct functions. Clause domains lower to compiler-selected decision trees. Because sequence handlers are concrete after specialization, `each`, `reduce`, `reduce_until`, `filter`, `find` and `sum` lower to direct calls and loops. List/Bytes loops have no virtual dispatch; only opaque Stream pulling may use the internal producer abstraction at the I/O boundary.
|
||||
|
||||
Source-level value semantics do not require eager deep copies. A last-use/liveness pass may move a value when the source is dead; when both aliases remain live, generated aggregates use a language-owned clone or copy-on-write representation. This is invisible to `.dever` and is verified against the evaluator. Locals and non-escaping records remain eligible for normal Rust/LLVM stack and register optimization. The AOT runtime has no garbage collector and no reflection registry.
|
||||
|
||||
Checked Int and Decimal behavior is emitted explicitly, so the release profile cannot remove language-required overflow or invalid-operation faults. Runtime helpers carry compact source-location IDs; the executable maps a fault back to the original `.dever` path and span. Internal Rust compiler errors are toolchain failures, not Dever diagnostics, and generated Rust names must never appear as user symbols.
|
||||
|
||||
The transient crate depends only on `dever-runtime`. Its generated source and Cargo metadata live in a compiler-managed build directory, are not emitted by a public command and may be deleted at any time. The optimized profile starts with `opt-level = 3`, thin LTO and one codegen unit; benchmark evidence may change those internal settings without changing the language.
|
||||
|
||||
The first backend deliberately uses stable Rust as a bootstrap bridge to LLVM. A later direct LLVM or Cranelift backend can consume the same checked HIR and runtime contracts without changing `.dever` source. No `dever-backend-rust` public crate or target-specific surface syntax is introduced.
|
||||
|
||||
Self-hosting is a staged replacement, not a Core 0.1 syntax promise:
|
||||
|
||||
1. The Rust bootstrap compiler establishes parser, checker, HIR, native semantics and conformance fixtures.
|
||||
2. Later language phases add the explicit file/process/memory capabilities required to write compilers.
|
||||
3. The frontend and standard library are reimplemented in `.dever` against the same fixtures and HIR contracts.
|
||||
4. A stage-1 compiler builds stage 2; stage 2 builds stage 3; stage-2/stage-3 behavior and reproducible artifacts are compared before the Rust frontend becomes only a seed.
|
||||
|
||||
## 11. Numeric Implementation
|
||||
|
||||
`dever-runtime::number` is the only module allowed to depend on the decimal implementation. It exposes a language-owned `DecimalValue` and operations returning:
|
||||
|
||||
```text
|
||||
Result<DecimalValue, NumericFault>
|
||||
```
|
||||
|
||||
Core 0.1 uses the Rust `dec` crate's `Decimal128` plus a reusable Context configured for 34 digits and round-half-even. Its status flags are cleared and checked around every operation: inexact/rounded is an accepted rounded result, while overflow, underflow, division by zero and invalid operation become Dever runtime faults. Decimal NaN/Infinity never enter a normal Dever `Value`. Compile-time constant evaluation and the reference evaluator call this same runtime wrapper, so native and reference behavior cannot acquire separate Decimal implementations.
|
||||
|
||||
This boundary is necessary because `dec` uses libdecnumber internally and exposes more IEEE decimal states than the language permits. Conformance tests, not the dependency's default operators, define Dever behavior.
|
||||
|
||||
Int operations use Rust checked arithmetic. Division and remainder explicitly handle zero and `i64::MIN / -1`. Int-to-Decimal conversion is exact.
|
||||
|
||||
Float uses Rust f64 operations without fast-math transformations. List summation is left-to-right. NaN/Infinity classification is exposed through `math` functions.
|
||||
|
||||
## 12. Standard Library Boundary
|
||||
|
||||
Core names such as `each`, `reduce`, `reduce_until`, `close` and `get` are registered from a static table containing:
|
||||
|
||||
- accepted arities;
|
||||
- generic input/output constraints;
|
||||
- whether an argument is a sequence, value or statically bound handler;
|
||||
- evaluator function.
|
||||
|
||||
Qualified standard packages (`text`, `decimal`, `float`, `math`) use the same table with qualified names. This is a fixed compiler-owned catalog, not a plugin registry.
|
||||
|
||||
Every implemented standard operation has one checking contract and one native lowering rule. Sequence source classification and handler validation are shared so List, Bytes and Stream forms cannot drift. The deferred evaluator must later consume these same checked contracts rather than define a second interpretation.
|
||||
|
||||
## 13. CLI Contract
|
||||
|
||||
`dever-cli` builds one `dever` binary:
|
||||
|
||||
```text
|
||||
dever check <source-root>
|
||||
dever fmt <source-root>
|
||||
dever build <source-root> <package.function> --output <path>
|
||||
dever run <source-root> <package.function>
|
||||
```
|
||||
|
||||
- `check` loads every package and runs all static phases.
|
||||
- `fmt` prepares every file before writing; if any source file is invalid, no file is modified. Record-field reordering requires successful resolution.
|
||||
- `build` checks the complete source root, specializes one exposed zero-input function as the entry point and writes an optimized native executable to the requested path.
|
||||
- `run` uses the same native compilation path in a compiler-managed directory and then invokes the executable; it does not run the reference evaluator.
|
||||
- Outputs print in declaration order using deterministic Dever value formatting.
|
||||
- Diagnostics go to stderr; program output goes to stdout.
|
||||
- Exit 0 means success, exit 1 means source/runtime diagnostics, exit 2 means invalid CLI usage.
|
||||
|
||||
The CLI is deliberately not a project/package manager. Source-root selection avoids introducing a manifest before dependency versions or executable targets are designed.
|
||||
|
||||
Core 0.1 therefore has no `add`, `update`, registry, signature or component-cache commands. Those are a 0.2 distribution concern; `check`, `build` and `run` remain offline and deterministic.
|
||||
|
||||
### 13.1 Command Identity And Coexistence
|
||||
|
||||
The Cargo binary and canonical product command remain `dever`. Command examples, help semantics and the public CLI contract use that name.
|
||||
|
||||
During Core 0.1 coexistence with the existing Dever framework CLI, a machine may expose this compiler on `PATH` as `deverc`. That entry is a symlink or launcher for the same compiler binary, not a second CLI implementation. It must not replace, shadow or modify the existing `dever` executable.
|
||||
|
||||
Repository builds and automated tests invoke the built binary directly and never mutate global executables or shell configuration. Installing, updating or removing the temporary `deverc` entry is an explicitly authorized machine-level operation. Moving the compiler onto the global `dever` name requires a separate migration plan.
|
||||
|
||||
## 14. Diagnostics
|
||||
|
||||
All compile diagnostics use one structure:
|
||||
|
||||
```text
|
||||
severity
|
||||
code
|
||||
message
|
||||
primary span
|
||||
zero or more labeled related spans
|
||||
optional help
|
||||
```
|
||||
|
||||
Examples include both conflicting clause spans, declaration and bad-reference spans, or first initialization and later type-changing assignment. Rendering uses `path:line:column` and a source excerpt. Messages describe the violated Dever rule and never expose Rust type names, crate names or implementation stack traces.
|
||||
|
||||
Runtime faults use the same renderer with a runtime category. The CLI may include a Dever call trace, but the first diagnostic always points to the failing `.dever` operation.
|
||||
|
||||
## 15. Migration And Repository Cleanup
|
||||
|
||||
Implementation starts from the documentation-only repository verified on 2026-09-05:
|
||||
|
||||
1. Initialize `dever-core` and `dever-tests`; add `dever-runtime` and `dever-cli` at their implementation stages.
|
||||
2. Implement `dever-core` around textual language phases.
|
||||
3. Do not recreate old Refiner, IR, Workbench, generated backend or semantic JSON architecture.
|
||||
4. Preserve the existing deletion of superseded planning documents; no old child links exist.
|
||||
5. Update repository `AGENTS.md` and Trellis package specs so future work treats `.dever` as the trusted source.
|
||||
6. Add the Core 0.1 examples and tests.
|
||||
|
||||
No database, persisted user data or old implementation exists, so there is no migration. Git history retains the old planning documents.
|
||||
|
||||
## 16. Verification Strategy
|
||||
|
||||
Tests are organized by behavior rather than compiler module:
|
||||
|
||||
```text
|
||||
test/dever-tests/tests/
|
||||
syntax_and_format.rs
|
||||
package_resolution.rs
|
||||
type_checking.rs
|
||||
clause_analysis.rs
|
||||
value_semantics.rs
|
||||
collections.rs
|
||||
numbers.rs
|
||||
failures.rs
|
||||
native.rs
|
||||
differential.rs
|
||||
performance.rs
|
||||
cli.rs
|
||||
```
|
||||
|
||||
Fixtures include valid programs plus one focused invalid case per diagnostic. Tests assert diagnostic code and relevant spans, not full prose, except dedicated rendering tests.
|
||||
|
||||
The highest-risk property tests are:
|
||||
|
||||
- parse/format/parse semantic equivalence and formatter idempotence;
|
||||
- clause order permutation does not change coverage or result;
|
||||
- Int division/remainder identity across boundary samples;
|
||||
- Decimal conformance vectors for precision, rounding and fault flags;
|
||||
- copy-on-write values do not expose alias mutation;
|
||||
- Map overwrite/remove/reinsert order;
|
||||
- Float sum remains left-to-right.
|
||||
- reference evaluator and native executable produce identical outputs/fault categories for the conformance corpus;
|
||||
- generated executables do not link the evaluator or perform per-element dynamic dispatch;
|
||||
- representative native hot paths stay within the PRD performance budget against equivalent safe Rust baselines.
|
||||
|
||||
The foundation extension adds focused cases to the existing `syntax_and_format`, `core_semantics`, `core_native`, `runtime_foundations` and `hello_native` targets. They cover handler syntax and forbidden positions, signature matching and forwarding, specialization-cycle rejection, List/Bytes reduce behavior, Stream alias/EOF/stop/close/error behavior, official package compilation, and actual CLI check/run/build of a chunked state parser. File fixtures use test-owned temporary directories; TCP fixtures use only loopback port 0, bounded data and explicit timeouts.
|
||||
|
||||
No existing service, browser, npm build, full workspace test or external integration environment is involved in this extension.
|
||||
|
||||
## 17. Key Trade-offs
|
||||
|
||||
- One compiler crate is less independently reusable than many compiler crates, but avoids unstable public boundaries during the first grammar; the native runtime is separate only because generated programs are a real independent consumer.
|
||||
- No recursion/effects limits programs to finite pure computation, but makes the first language deterministic and prevents hidden control flow.
|
||||
- Full clause checking is more compiler work than ordered pattern matching, but it directly enforces the user's requirement that AI-written code cannot hide behavior in ordering or nested branches.
|
||||
- A decimal dependency is safer than hand-written decimal128 arithmetic; the wrapper and conformance suite prevent it from defining public language behavior.
|
||||
- A small fixed standard catalog is less extensible than plugins, but keeps the first implementation inspectable and removes speculative architecture.
|
||||
- Rust emission gets optimized native code much earlier than implementing an LLVM backend, while typed HIR, differential tests and a private artifact boundary prevent Rust from becoming the language definition.
|
||||
- Keeping a reference evaluator adds implementation work, but it localizes semantic debugging and gives native code generation a deterministic oracle; it is excluded from production binaries.
|
||||
- Separating component identity from Git/registry location adds two CLI-managed metadata files in 0.2, but prevents repository moves from becoming source-breaking type renames and enables one verified global source store.
|
||||
# Compiler Contract Extension (2026-09-08)
|
||||
|
||||
Extend existing AST fields and clause modifiers, normalize field bounds with the same interval owner as patterns, then analyze checked HIR before publishing Program. Keep private checks in field access/construction owners. Function/package metadata feeds effect/dependency checks and deterministic API snapshots. Failure obligations follow values across aggregates and calls, with explicit source recovery declarations as the only normal-value discharge boundary. Static handlers share existing specialization bindings. Facts must be conservative on mutation and unknown operations; no runtime interpreter, speculative fallback, new dependencies or source keyword for every domain.
|
||||
|
||||
Independent syntax/formatter work owns syntax.rs/parser.rs/format.rs; effect/API work owns contracts/effects.rs/api.rs and CLI; main owns semantic types, HIR integration, fact/failure analysis, standard-source migration, tests and docs. All workers preserve shared changes. Core check is the single enforcement entry used by CLI and native execution.
|
||||
|
||||
# AI-Oriented Source Architecture Design (2026-09-19)
|
||||
|
||||
Source classification is one compiler-owned value derived from the relative source path. It records main or component/domain/role/topic identity and is reused by parsing, Model registration, symbol resolution, API route construction and architecture diagnostics. Those layers must not rediscover roles with independent string searches.
|
||||
|
||||
Every physical source still has a unique internal owner name so diagnostics, HIR ownership and native symbol generation remain deterministic. App declarations additionally receive one stable logical alias, `<component>.<domain>.<name>`, regardless of whether they live in `app.dever` or `app/<topic>.dever`. Alias collisions are duplicate declarations. Topic paths remain an implementation detail and never leak into API snapshots or call sites.
|
||||
|
||||
Name lookup tries the current physical owner first, then role-relative names in the current domain, same-component App aliases, and finally full App aliases. A resolved reference is accepted only when the role dependency matrix permits it. Main and API may call App; App may call private roles in its own domain and App aliases elsewhere; private roles may collaborate only inside their own domain. API functions are registered for HTTP routing but rejected as source call targets. Model type identity stays path-derived, while generated operations are restricted to their owning domain.
|
||||
|
||||
Application `public` modifiers are rejected. App functions and App-declared types form the external contract automatically; Model record/choice/ID types remain type-visible where relations or App contracts require them. After failure inference, every App function is also checked so a private Domain/Adapter error type cannot leak through its effective error set. Bundled standard sources keep compiler-internal visibility metadata because they are not application architecture. The existing API baseline and unused-export checks consume logical App names.
|
||||
|
||||
Architecture validation runs before semantic bodies: legal layouts, reserved names, file/directory exclusivity, flat non-API topic directories and the no-single-file-role-directory rule. Declaration-role validation then keeps API thin, Model storage-only and App as the sole cross-domain capability boundary. Port syntax itself is not expanded in this change; `port` is a reserved architectural role until a separately approved replaceable-boundary declaration contract exists.
|
||||
@@ -0,0 +1,819 @@
|
||||
# Dever Minimal Core 0.1 Implementation Plan
|
||||
|
||||
## HTTP/2 Ceiling And Soak (2026-09-18, Complete)
|
||||
|
||||
子任务 `09-13-http2-ceiling-soak` 复用既有负载器,将客户端 worker/CPU 集合参数贯通 HTTP/1、HTTP/2 和长连接入口。34 项运行器定向测试及最小 loopback 回归通过;生产运行时未改。1 CPU、128 MiB 下 h2c/TLS+h2 的 16x16、30k/45k/60k/90k 双次扫描表明本机吞吐约 36k-40k req/s 时服务端接近 1 核,客户端仍占 1.4-2 核;请求槽丢弃使它不是纯服务端上限。28k 的首轮稳态未达 99% 成功率,故改用 20k:128/64 MiB 各 60 秒的四组成功率为 99.77%/99.92%/99.94%/99.80%,无请求错误、OOM 或 FD 残留。h2c/TLS+h2 各 100 轮重连无错误或 FD 残留;短时 RSS 数据不证明小时级无泄漏。原始报告、命令和限制见 `test/performance/README.md`。
|
||||
|
||||
HTTP/2 有界验收和 typed ORM 子任务均已完成。根任务保持进行中:历史计划的编译器发行/跨平台与自举仍未交付,小时级稳定性等扩展也没有在本轮获批或验证;不得凭子任务完成数宣称整个语言路线图结束。
|
||||
|
||||
## N5 HTTP/2 (2026-09-12, Complete)
|
||||
|
||||
子任务 `09-12-http2` 完成:Hyper/h2 buffered/live 服务端、single-use/pooled 客户端、TLS ALPN、固定流控窗口与每连接流配额、逐流 handler/上传/下载/SSE、RST 隔离及 GOAWAY 排空。官方 `Limits.http2: Http2Limits?` 与检查器一起直接演进,静态原生桥接复用既有 Nullable/Record;没有新 intrinsic 或兼容层。
|
||||
|
||||
Hyper Executor 的协议任务接入 Dever 子 Scope,关闭 admission 与登记串行,故障上报/注销在 completion 之前;不可取消 supervisor 清理协议任务和已启动 blocking 后代后才释放物理许可。池连接归持久 driver;上传/下载共享流租约。只有 Hyper 结构化交回的未发送请求可在原期限重排,已发请求不重放。
|
||||
|
||||
88 个不同定向用例通过:HTTP/2 client/server 各6,HTTP engine12、HTTP library7、live16、pooled network14、pooled library6、async runtime10、structured concurrency11。独立 h2 对端覆盖流控、取消、容量、ALPN、语义与故障;原生程序覆盖单连接 TLS 嵌套代理、流式上传、SSE、run/await 和原有 HTTP/1/WSS。修复传输取消误升为整服故障、响应发布锁重入、GOAWAY 未发请求卡住旧租约和 Response TE 静默删除;测试端句柄和 WSS 独立连接预算也已修正。
|
||||
|
||||
最终 CLI release 构建、backend check/fmt-check、network_bench/reference 编译及源码检查通过;独立审查闭环。Rustfmt/Clippy 不可用,官方库 fmt-check 的原有 net.dever 差异未改。临时构建目录和暂存约378 MiB可再生缓存已清理,旧编译器与性能报告保留。不提交、不自动归档;N5 实现没有运行 HTTP/2 性能矩阵。后续仍有 HTTP/2 压测、小时级稳定性、入口调度优化、RFC 8441/协议扩展、数据库、发行和自举。
|
||||
|
||||
## P2 Async Comparison And Long Connections (2026-09-12, Complete)
|
||||
|
||||
子任务 `09-12-runtime-performance-p2` 继续复用 P1 工具,完成同语义 Rust run/wait 对照、perf 调度定位、HTTP/HTTPS 高负载和 TCP/WS/SSE 分阶段回收测量。12 个运行器检查、4 个独立对端检查、11 个原生入口及新 Rust examples 的离线构建通过;未修改生产编译器/运行时。
|
||||
|
||||
1 CPU/128 MiB 下,Dever run/await root约15.50 µs、实验worker约2.003 µs,与Rust15.59/1.984 µs接近;任务入口跨线程切换是明确优化方向,根任务配额、Send和取消语义需保留。54组HTTP高负载发出1500830请求全部成功、299170计划请求未发出;压测槽位已限制结果,不是服务上限。
|
||||
|
||||
最终27组长连接覆盖32/128/512条,每组3轮;18144连接、377061消息均成功,81次FD回基线。另各协议512连接十轮,共15360连接、471207消息,无错误/OOM且30次FD回基线;SSE RSS在约25.3 MiB趋稳。修正采样退出与阶段交接竞态、SSE无心跳误通过和客户端清理;完整证据与限制集中在 `test/performance/README.md`。
|
||||
|
||||
P2 结束时尚未启用http2/h2依赖;当时规划的TLS ALPN、独立流/连接预算、流控、取消隔离和GOAWAY已由后续N5交付。小时级soak与入口调度优化另行落地,总语言路线图保持未完成。
|
||||
|
||||
## P1 Standalone Runtime Performance (2026-09-12, Complete)
|
||||
|
||||
用户批准建立常驻内存、异步调度和 HTTP/HTTPS 三组基准,取代旧网络实施阶段的局部“不压测”限制。子任务 `09-12-runtime-performance` 完成:真实原生二进制、Rust runtime/Hyper 对照、有界固定速率负载、Linux RSS/PSS/CPU 和专属 cgroup 预算、原始 JSON 报告。生产语言/运行时未改动。
|
||||
|
||||
7 个运行器与 2 个独立 HTTP 对端检查通过;离线原生/release 构建和格式检查通过,Rustfmt/Clippy 不可用。最终 78 组主测、18 组新连接、15 组 64 MiB 空闲基线均完成且无 OOM;主测网络发出 107962 请求全部成功,38 个计划请求未发出并按原因记录。当前只是热缓存、短时、指定负载结果,不是吞吐极限、冷启动整机内存或长期稳定性结论。
|
||||
|
||||
运行命令、确切统计口径和首轮数据以 `test/performance/README.md` 为准,原始产物/报告保存在 `target/performance/`。后续候选:同语义 Rust 异步对照与调度剖析、高负载扫描、TCP/WS/SSE 长连接;其余语言/后端路线图仍未完成。
|
||||
|
||||
## N4 Concurrent Composition And Reusable Clients (2026-09-12, Complete)
|
||||
|
||||
- [x] Implement source runtime configuration, typed handler context, timeout/race and pull-driven stream adapters; extend async memory traversal.
|
||||
- [x] Add verified TLS transport and bounded reusable HTTP clients with streamed uploads/downloads; integrate HTTPS/WSS and TLS servers.
|
||||
- [x] Wire compiler contracts, official source, native conversion and examples together.
|
||||
- [x] Run focused compiler/native and independent loopback protocol/lifecycle tests; review final ownership and limits.
|
||||
- [x] Update canonical language/spec docs and record evidence without a commit or archive.
|
||||
|
||||
### N4 Evidence
|
||||
|
||||
83 distinct focused cases passed: async_composition 7, pooled_network 14, pooled_library 5, structured_concurrency 11, async_network 9, http_engine 12, http_library 6 and live_network 16. Reruns are not counted twice. The old structured-concurrency case that rejected async List handlers was migrated to the newly accepted contract and checked for a concurrency effect.
|
||||
|
||||
New coverage includes source runtime bounds/start-once, timeout cleanup of started blocking children, race loser cleanup/fault propagation, Channel null/drain/close and periodic stream timing, static handler/output checks, C012 stream failure obligations, async memory traversal/fusion and concrete HTTP Context transfer. Native programs exercise HTTPS, pooled proxy calls through shared Context, streamed uploads/downloads, SSE, WSS and graceful shutdown.
|
||||
|
||||
Independent peers verify physical connection reuse, repeated headers, early chunks beyond the response-head deadline, chunked upload, slow/abandoned/truncated bodies, upload failure/cancellation, queue/body/idle deadlines, pool close wakeups, unpolled-task cleanup, certificate CA/name rejection and WSS frames. A reproduced lifetime defect allowed a pool's unpolled driver to escape its creator; constructing the driver owner guard before task creation fixes it. Native checks also exposed missing Channel Debug/Render for shared records and async handlers entering synchronous fusion; both are corrected. Final bounded cleanup removed an infallible Result from the private lease-completion path; all 14 pool cases passed afterward.
|
||||
|
||||
Actual CLI backend example check and fmt-check passed without warnings. Offline build produced a standalone binary; running with an empty environment and PATH restricted to /usr/bin:/bin printed exactly `Hello /Dever\nrequests = 1\n` and exited successfully. Official source formatting, optional-reference core/runtime/CLI cargo check, whitespace/conflict-marker and final ownership/contract inspection passed. Rustfmt/Clippy are not installed, so their checks were unavailable; Rust layout was reviewed manually.
|
||||
|
||||
Only initial dependency preparation fetched locked packages; validation/builds were offline. All network peers used owned loopback random ports and static test certificates under test/. No full suite, prior performance baseline, existing service, persistent example launch, global install or commit. No throughput/RSS improvement is inferred from functional checks. Temporary build, certificate-generation, formatting and demo directories are removed at handoff; workspace native caches remain. N4 is complete; HTTP2, protocol extensions/close metadata, database, distribution/cross-platform and self-hosting keep the umbrella task active.
|
||||
|
||||
## N3 Streaming HTTP, SSE And WebSocket (2026-09-12, Complete)
|
||||
|
||||
- [x] Add `serve_live`, HttpReply and LiveLimits while keeping ordinary serve direct. Reuse request/response codecs and connection configuration; one-slot body channel and one-handler Group per live connection.
|
||||
- [x] Add SSE field encoding, bounded chunks, Last-Event-ID access and body-driven heartbeat timers; no event history or extra heartbeat task.
|
||||
- [x] Reuse tokio-tungstenite 0.30.0 handshake/protocol for server/client Text/Binary/Ping/Pong, AsyncStream messages and close handshakes. Shared Endpoint lifecycle, 4 KiB read buffer, bounded messages/write buffers and independent directions.
|
||||
- [x] Integrate resource types, exact nominal records/choices, async effects, static handlers, native conversion, API/Markdown names and Render. No new syntax, compatibility layer, dynamic interpreter or generic transport abstraction.
|
||||
- [x] Verify owned wire peers and real native programs; update canonical docs/example and manually record the session without a commit or archive.
|
||||
|
||||
### N3 Evidence
|
||||
|
||||
46 distinct focused cases passed: live_network 16, live_library 3, http_engine 12, http_library 6, async_network 9. Repeated checks after corrections are not counted again. All final checks passed; the earlier slow-peer test assumed normal return and was corrected to observe cleanup on either return or cancellation.
|
||||
|
||||
Wire evidence covers early chunk delivery, bounded slow-peer output, SSE exact encoding/heartbeats past the ordinary deadline, idle failure isolation, HEAD cleanup, handler faults, upgrade capacity/preread bytes, independent client masks, fragmentation/UTF-8/control frames, close, partial writes, waiting-writer cancellation and started blocking descendant drain. A reproduced regression showed duplicate response publication closed the existing body; cancellation ownership now starts only after publication is accepted, and the regression passes.
|
||||
|
||||
The native source program exercised all four routes (buffered, stream, SSE, WebSocket), including client connect/send/receive/close and static async message-stream handlers. Existing native HTTP clients also matched independent wire peers. Final optional-reference cargo check, actual CLI example check (no warnings), CLI fmt-check, source format roundtrips and explicit whitespace/owner inspection passed. Rustfmt/Clippy are not installed and were not run. No throughput/RSS claims or old baseline.
|
||||
|
||||
Only a one-time dependency fetch accessed the registry; checks/builds ran offline. No full suite, existing service, persistent example launch, global install or commit. N3 is complete; TLS/WSS, HTTP2, client pools, protocol extensions, close metadata, database/distribution and other roadmap work keep the umbrella task active.
|
||||
|
||||
## N2 HTTP Engine (2026-09-12, Complete)
|
||||
|
||||
The user approved continuing after N1. Execute inline; no further planning approval. Reuse Hyper HTTP/1 on the shared Tokio runtime. No legacy compatibility, old baseline, full suite, service checks or global installation.
|
||||
|
||||
- [x] Replace the custom HTTP parser/transport with Hyper server/client primitives and migrate all source callers.
|
||||
- [x] Preserve repeated headers as `List<Header>` with byte values; buffer bodies within explicit limits. Reuse byte storage across the engine boundary.
|
||||
- [x] Add `serve(async handler, Listener, Limits) -> ()` with bounded structured connections, server keep-alive, chunked input and cancellation cleanup. Configuration/accept/handler faults propagate; peer protocol failures terminate only that connection.
|
||||
- [x] Add one-shot `send(address, port, Request, Limits) -> ResponseResult`. Poll its driver in the request future; no detached tasks, implicit retries or client pool.
|
||||
- [x] Check static handler contracts/effects and native record conversion. Replace obsolete parser tests with independent loopback wire cases and focused compiler/native tests.
|
||||
- [x] Update canonical docs and record evidence. TLS, HTTP/2, client pooling, streaming response bodies, WebSocket and SSE remain later work.
|
||||
|
||||
Limits: header buffer at least 8192 bytes (Hyper requirement), nonnegative body bytes, positive timeout, bounded connections. Defaults: 8192 / 1048576 / 5000 ms / 32. Runtime owns HTTP framing; outgoing Content-Length/Transfer-Encoding are rejected. Request header/body/handler and stalled write deadlines prevent one idle peer retaining a connection indefinitely. Static Dever handlers specialize directly; no dynamic interpreter dispatch.
|
||||
|
||||
### N2 Evidence
|
||||
|
||||
- 44 distinct focused cases passed: http_engine 12, http_library 6, async_network 9, structured_concurrency 11, native_ownership byte cases 3, runtime_foundations bytes case 1, contract_execution selected example checks 2. Repeats after local corrections are not counted again.
|
||||
- Independent wire cases cover persistence/pipelining, HEAD and 204/205/304, fragmented/chunked/binary bodies, repeated and non-UTF8 headers, conflicting lengths, header/body limits, unsupported trailers/upgrades, truncated responses, all deadlines, connection capacity and descendant cleanup. Header timeout closes directly; body/handler timeouts produce 408/504. Native tests execute real compiled send/serve and compare client bytes to an independent peer.
|
||||
- Bytes tests retain unique Text storage transfer, shared-alias behavior, shared slices and consuming iteration. Connection setup now configures TCP_NODELAY once through one helper. No old-performance baseline, throughput or RSS claims were derived from correctness tests.
|
||||
- Core compiled offline with the optional reference feature. Actual CLI HTTP example check/fmt-check and formatting roundtrips passed; the persistent example was not started. Final owning-layer, dependency, obsolete-reference and whitespace review passed. Rustfmt and Clippy were unavailable (components not installed), not reported as passing.
|
||||
- Hyper 1.11.1, hyper-util 0.1.20, http-body-util 0.1.5 and bytes 1.12.1 are pinned. Missing transitive development dependencies were fetched once; builds/tests remained offline. No Axum, TLS or HTTP2 features were added.
|
||||
- No full suite, existing service check, load test, global install, commit or task archive. N2 is complete; the umbrella task remains active for N3 and other unshipped roadmap work.
|
||||
|
||||
## N1 Async Stream And TCP (2026-09-12, Complete)
|
||||
|
||||
- [x] Persist the approved new-language/no-compatibility policy; use existing inline execution routing.
|
||||
- [x] Add bounded, cancellable async stream consumption and Tokio TCP resources to the shared runtime.
|
||||
- [x] Integrate AsyncStream types, async handlers, effects/liveness and concrete native code generation.
|
||||
- [x] Replace synchronous TCP source contracts and migrate official HTTP transport, examples and tests.
|
||||
- [x] Verify stream backpressure, close/stop, duplex I/O, timeout and compiler/native behavior with focused tests.
|
||||
- [x] Update public contracts, inspect the final changed flows and record evidence. N2 HTTP engine and N3 WS/SSE remain deferred.
|
||||
|
||||
### N1 Evidence
|
||||
|
||||
- 62 distinct focused cases passed: async_network 9, async_stream 6, structured_concurrency 11, async_native 6, async_runtime 10, http_library 11, core_native `official_` 5, runtime_foundations `stream` 3, and the reference/native file-stream differential case 1. The HTTP benchmark stayed ignored. Repeated individual checks after local fixes are not counted again.
|
||||
- Network tests owned only loopback port-0 sockets. A one-worker runtime proved duplex progress, pending close wakeups, read timeout reuse, partial-write cancellation closure, pre-pull backpressure, failure while the producer is idle, child cleanup and listener release. The persistent server example was checked without starting it.
|
||||
- Native execution exposed missing Render implementations for new resource types; those were fixed at the runtime owner and the affected native checks passed. Migrated TCP differential cases now assert independent native expectations; the synchronous evaluator no longer carries a duplicate TCP implementation.
|
||||
- Compiler/default and optional reference builds passed offline. Source formatting round trips, explicit whitespace checks and final owning-layer inspection passed. Rustfmt/Clippy were unavailable because those components are not installed; no claim of passing them.
|
||||
- No workspace-wide tests, service checks, old-performance baseline, network load test, global install or commit. The task remains active for N2 HTTP engine and N3 WebSocket/SSE, not for unfinished N1 work.
|
||||
|
||||
## Structured Concurrency Implementation (2026-09-12, Complete)
|
||||
|
||||
The user approved this preceding implementation. Networking was paused during it; N1 above resumes AsyncStream/TCP. Each phase uses only focused offline tests under root `test/`; do not run the workspace-wide suite, service checks, old-performance baselines or network load tests.
|
||||
|
||||
### Phase A: Freeze Syntax And Semantic Model
|
||||
|
||||
- [x] Add the canonical `async name(...)` modifier while retaining post-signature `pure`/`recover`; update lexer/parser/formatter and Markdown signature model together.
|
||||
- [x] Add async to function identity metadata and HandlerSignature compatibility without changing package/name/arity lookup.
|
||||
- [x] Reject `async` together with `pure` in the first release and classify every concurrency operator through the existing transitive effect owner.
|
||||
- [x] Add contextual `await`, `run`, `stop`, `group`, `parallel`, `blocking` and `channel` syntax; reject async calls as ordinary stored values.
|
||||
- [x] Add HIR task/group/channel operations and compiler-owned affine local metadata; do not add a user-visible Future or generic evaluator value.
|
||||
- [x] Extend recursive type properties with thread transferability and explicit resource opt-in.
|
||||
- [x] Add focused syntax/format/check cases for clause agreement, sync-to-async rejection, handler mismatch, forbidden Task escape and transfer failures.
|
||||
|
||||
Rollback point: syntax/HIR-only changes must leave all existing synchronous HIR and formatted output unchanged.
|
||||
|
||||
### Phase B: Await And One Shared Runtime
|
||||
|
||||
- [x] Add Tokio with the smallest required features and one runtime owner in `dever-runtime`.
|
||||
- [x] Emit async functions as concrete Rust futures while retaining direct sync emission for wholly synchronous entry graphs.
|
||||
- [x] Implement direct `await(async_call(...))` and async entry execution; preserve zero/single/named multi-output and source-located failures.
|
||||
- [x] Implement async timer sleep as the first real suspension boundary; do not use TCP/HTTP as the proof fixture.
|
||||
- [x] Verify multiple sleeping tasks can make progress without one OS thread per task and sync binaries do not initialize the runtime.
|
||||
|
||||
Rollback point: runtime dependency and native entry changes are isolated from Task ownership and can be removed while keeping Phase A diagnostics.
|
||||
|
||||
### Phase C: Structured Task And Group Ownership
|
||||
|
||||
- [x] Implement `run(async_call(...)) -> Task`, `await(task)`, and exactly-once affine consumption across local writes and every clause exit.
|
||||
- [x] Implement zero-output `stop(task)` as cooperative cancel-and-wait; reject result-bearing stop and repeated consume.
|
||||
- [x] Implement positive bounded `group(limit)`, backpressured `run(group, call)`, `await(group)` and `stop(group)`.
|
||||
- [x] Ensure normal and abnormal parent completion cleans all children; no detached path or swallowed expected failure.
|
||||
- [x] Verify start-before-await concurrency, output equivalence, cancellation cleanup, capacity backpressure and bounded task ownership.
|
||||
|
||||
Rollback point: Task and Group runtime modules remain separate from scheduler construction and do not change direct await.
|
||||
|
||||
### Phase D: Multi-thread And Blocking Work
|
||||
|
||||
- [x] Implement `parallel(sync_call(...))` for pure transferable calls using bounded execution; preserve existing numerical/error semantics.
|
||||
- [x] Implement the compiler-owned `blocking` boundary only for calls with a transitive compiler-known blocking effect; bound active workers plus queued submissions.
|
||||
- [x] Route async use of `parallel_each` away from Tokio workers while retaining the current synchronous contract and shared worker validation.
|
||||
- [x] Configure conservative defaults from available parallelism with explicit hard maxima; do not create per-call runtimes or unconstrained pools.
|
||||
- [x] Verify multi-core execution, no scheduler starvation under bounded blocking work, exact output/error behavior and small-worker configurations.
|
||||
|
||||
Rollback point: retain the existing synchronous `parallel_each` implementation until the common executor passes its focused compatibility cases.
|
||||
|
||||
### Phase E: Bounded Channels And Contract Integration
|
||||
|
||||
- [x] Add compiler-contextual `channel(Type, capacity)` and the runtime Channel resource.
|
||||
- [x] Implement async `send`/`receive`, idempotent `close`, wakeup behavior and explicit source-defined result choices.
|
||||
- [x] Extend effect, failure, dependency, redundancy, API snapshot and Markdown checks for every new signature/operation.
|
||||
- [x] Add documentation only for implemented behavior and a runnable non-network producer/consumer example.
|
||||
- [x] Run the available focused syntax/semantic/native/runtime/concurrency, reference-feature and whitespace checks; record unavailable rustfmt/Clippy components explicitly.
|
||||
|
||||
Final gate: inspect generated native code for concrete futures/direct static handlers and absence of generic Value/handler dispatch. Record passed, failed and unrun checks without claiming network readiness.
|
||||
|
||||
### Final Evidence
|
||||
|
||||
- `structured_concurrency`: 11 passed; syntax, formatter shape, affine ownership, transferability, effects/failures, handler boundaries and Channel contracts.
|
||||
- `async_runtime`: 10 passed; recursive cleanup, cancel-and-wait, Group backpressure, bounded channels, shared blocking limits and async `parallel_each` peak concurrency.
|
||||
- `async_native`: 6 passed; real native execution of await/Task/Group/Channel/parallel/blocking/timer, sync-entry isolation and nullable Channel flattening.
|
||||
- Existing synchronous `concurrency`: 5 passed and its explicit benchmark remained ignored; the async example and nullable-result native regressions each passed their exact case.
|
||||
- `dever-core` with the optional `reference` feature compiled offline. `git diff --check` and explicit trailing-whitespace scans passed. Dependency inspection confirmed Tokio uses only runtime, sync and time features in this phase.
|
||||
- The installed Rust toolchain has no rustfmt or Clippy component, so those two checks were unavailable rather than passed. No full suite, build, service, network, old-performance baseline, global installation or commit was run.
|
||||
- Independent final review found no remaining blocking defect after nullable Channel emission, synchronous collection-handler enforcement and transitive async effect boundaries were corrected.
|
||||
|
||||
### Deferred Follow-through
|
||||
|
||||
- [x] Design and implement AsyncStream/Tokio TCP on the shared runtime in N1. HTTP engine and WebSocket/SSE integration remain later phases.
|
||||
- [ ] Measure the new packaged binary and live network behavior only after network integration; the user explicitly waived an old-runtime baseline.
|
||||
|
||||
## Active Performance Checklist (2026-09-08)
|
||||
|
||||
- [x] Capture bounded compiler, record/text/list/map/JSON/HTTP and concurrency baselines.
|
||||
- [x] Memoize type properties and schedule effect/failure summaries by dependencies.
|
||||
- [x] Eliminate redundant record field copies and consume unique list elements safely.
|
||||
- [x] Optimize direct character-list consumption and eligible collection fusion without changing fault order.
|
||||
- [x] Implement incremental HTTP framing and test in-memory fragments/limits/malformed inputs.
|
||||
- [x] Cache bundled frontend work and native artifacts with complete invalidation and API checks.
|
||||
- [x] Measure concurrent task granularity; batch only where justified and preserve bounded Stream consumption.
|
||||
- [x] Run affected offline tests, native/reference regressions, strict lint, formatting and independent final review; record measurements and update owning docs.
|
||||
|
||||
User has authorized the complete implementation. This extends the active task and does not require another planning approval. Two bounded implement workers own analysis and cache respectively; main owns emission/runtime/protocols and benchmark integration. All permanent tests stay in root test/. Never run workspace-wide tests, existing services or global installation; use test-owned temporary inputs/processes only.
|
||||
|
||||
## Packaged Runtime Footprint Follow-through (2026-09-09)
|
||||
|
||||
- Composite runtime values and generated records/choices now append recursively through `Render::render_to`; named entry outputs reuse that same String instead of building a rendered value and then copying it into a second formatted String. Numeric `to_text` retains its direct `to_string()` path after the bounded hot-path gate exposed a slower generic formatting attempt.
|
||||
- Native binding temporaries now transfer uniquely owned Text/Bytes storage and List/Map values through consuming operations. Live aliases retain copy-on-write behavior, and resource-containing values remain non-movable under the existing type-property contract.
|
||||
- Exact-size finite parallel iterators create no more workers than items/batches. Unknown-length Stream dispatch keeps the caller-requested worker limit, bounded queue, cancellation and joined lifecycle.
|
||||
- Production optimization arguments now use fat LTO and strip symbols; the workspace release runtime uses matching fat LTO/one-unit settings. The same allocation-performance fixture decreased from 4,680,864 to 452,352 bytes (90.3%) and measured 3,468 KiB peak RSS, versus 3,608 KiB without LTO.
|
||||
- Thin LTO was rejected because ordered Map reached a 1.255 native/baseline ratio. Fat LTO passed all five gates: Int 0.713, Float 1.001, List 0.668, Decimal 0.985 and ordered Map 1.030. It trades substantially longer cold compilation for the user-approved packaged-runtime priority.
|
||||
- An attempted fixed 64 KiB clamp for one-shot reads was rejected and removed: it changed a 1 MiB regular-file result to 64 KiB and failed the exact performance result. `read(limit)` remains caller-sized; low-memory programs use smaller limits or `chunks`.
|
||||
- Focused runtime/concurrency/native ownership/core-native checks and both explicit performance scenarios cover the final contracts. No workspace-wide suite, service, external network, global installation or commit was used.
|
||||
|
||||
## Performance Evidence (2026-09-08)
|
||||
|
||||
- Nominal type properties are memoized after cycle validation. Facts, effects and failures share one dependency graph; summary changes reschedule callers instead of rescanning every function. The same 24-level shared-type debug regression went from 11.56 s to 0.47 s across the combined analysis/frontend changes.
|
||||
- Native emission reuses checked HIR and last-use analysis. Safe record aliases are coalesced, scalar reads can be snapshotted before consuming large records, and exact field overwrites can move their old value. Unique Lists move elements; shared Lists clone only consumed elements. Resources retain their original lifetime and aliases retain value semantics. Empty Bytes concatenation reuses storage without weakening copy-on-write.
|
||||
- Text splitting has one iterator implementation. Direct consumers avoid an intermediate List; eligible each/filter stages fuse only when handlers are proven free of observable effects and faults. A pure Int handler alone is insufficient because overflow can change fault order. Generated-code, alias, mutation and native/reference cases cover these boundaries.
|
||||
- HTTP wire decoding now retains its header boundary and parsed frame between feeds. Transport uses that state; legacy one-shot scan delegates to the same implementation. Three new in-memory cases cover fragmentation, binary bodies, EOF, limits, duplicate headers and private/aliased state; five existing pure protocol tests passed. No live HTTP service was used.
|
||||
- Bundled AST and the last complete frontend result are cached within a process. Native artifacts persist across processes, keyed by source-generated code, compiler/toolchain/runtime/dependency identities and native options. Atomic publication, fingerprint validation, isolated executable copies and create-new saves preserve correctness. All six cache tests and the explicit actual-CLI API-baseline regression passed. Independent CLI processes still check current sources and API baselines; no persistent HIR cache was introduced.
|
||||
- Finite parallel inputs use bounded batches through the existing cancellation/join owner; Stream keeps bounded producer-driven pulls. With 32,768 inputs and four workers, median light-handler queue/batch/serial times were 140.945/3.894/0.247 ms; 512-round handlers measured 178.267/20.033/60.185 ms. Batching reduces dispatch cost but does not make tiny tasks preferable to serial execution.
|
||||
- Main-agent final ownership/JSON/reference verification passed 9/7/10 tests respectively; only three network-dependent differential cases were skipped. Concurrency passed five functional cases, contracts passed 38 affected cases, and strict Clippy/type checks passed for core/runtime/CLI and affected test targets. Actual CLI library formatting and contracts/library checks passed. Independent final review found no blocking issue.
|
||||
- After the final Bytes optimization, the main agent reran all three incremental HTTP cases and six default native-cache cases successfully. The actual-CLI cache test is ignored by default and had already passed when explicitly selected. Final targeted Rust formatting and new-file whitespace checks passed.
|
||||
|
||||
The same source was compiled before and after this change. Linux x86-64, Rust 1.98.0, CPU affinity 4; five alternating processes per side, native internal timing excluding compilation/startup. Text/List/JSON each retain five samples per process after discarding warmup; Map retains one per process. All expected results were asserted. Medians:
|
||||
|
||||
| Source workload | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 16,384 record Text appends | 14.093 ms | 0.279 ms |
|
||||
| 16,384 Text List elements | 4.786 ms | 1.342 ms |
|
||||
| Long JSON string parsing | 28.072 ms | 6.977 ms |
|
||||
| Record accumulation of 16,384 distinct Map keys | 548.061 ms | 1.999 ms |
|
||||
| Process peak resident memory | 4,648 KiB | 3,604 KiB |
|
||||
|
||||
- Separate HTTP strategy measurement used identical 16,947-byte input in 64-byte fragments, both paths on the new compiler, seven alternating samples after warmup: repeated one-shot scan 4.541 ms, incremental decode 0.366 ms. This is parser work, not server throughput or an old/new compiler comparison.
|
||||
- Same release CLI hello: cold native miss 0.84 s; subsequent independent-process hits 0.17/0.17/0.16 s, including source/API checks and runtime freshness. In-process frontend cold/hit measured 171.273/3.742 ms.
|
||||
- The existing five-kernel matching-runtime gate passed again with 45 effective samples per side: native/baseline Int 0.989, Float 0.998, List 0.578, Decimal 0.979, ordered Map 1.006 (limit 1.15). This checks code-generation overhead against the same runtime, not against the old compiler. Build times were measured separately: native 2,137–2,693 ms, baseline 252–680 ms.
|
||||
- These are bounded local workloads, not whole-language speed guarantees. README, IMPLEMENTATION and owning specs describe the optimization boundaries. No full suite, service integration, global installation or commit; the performance scope is complete while the umbrella language roadmap remains active.
|
||||
|
||||
## Active Follow-through Checklist (2026-09-07)
|
||||
|
||||
- [x] AST formatter, comments/precedence/idempotence, all-file preparation and CLI fmt/check.
|
||||
- [x] Text/number library expansion and explicit argument/environment/clock primitives.
|
||||
- [x] Opt-in reference evaluator, core semantics and native differential cases.
|
||||
- [x] Last-use and reduction-state ownership optimization with alias/order regressions.
|
||||
- [x] Bounded reproducible native performance baseline and evidence.
|
||||
- [x] JSON/HTTP source packages and focused protocol fixtures.
|
||||
- [x] Controlled concurrent execution with explicit limits/lifecycle and focused verification.
|
||||
- [ ] Resolve database choice from pending user answer; implement or record explicit deferral.
|
||||
- [x] Integrate delivered flows, review, update canonical specs and actual validation evidence.
|
||||
|
||||
Workers implement formatter and reference evaluator independently; the main agent owns libraries and integration. Checks are targeted, offline and bounded; no full suite, live service, global install, commit or cross-platform distribution. Later checklists are historical.
|
||||
|
||||
## Follow-through Evidence (2026-09-07)
|
||||
|
||||
- Implemented syntax-only AST formatting and CLI `fmt`/`--check`. Source snapshots are reloaded before staging and before replacement, comparing both the complete path set and file contents. Independent review found the initial missing path-set check; the shared source loader now closes that gap without a second traversal implementation. Per-file replacement remains atomic, not a multi-file OS transaction.
|
||||
- Added Unicode Text operations, numeric parsing/rendering, process arguments/environment and explicit clocks/sleep through the existing intrinsic catalog and source-defined result choices. JSON and HTTP algorithms are ordinary `.dever` packages; no runtime JSON/HTTP parser or new dependency was added.
|
||||
- JSON validates grammar, precise numeric spelling, duplicate keys, Unicode, graph references, nesting and expansion limits. HTTP supports a declared HTTP/1.1 single-message subset with binary bodies, Content-Length, strict ASCII headers and typed failures; independent peers check canonical request/response bytes, fragmentation, invalid requests/400, EOF and no-body statuses. Blocking read/write syscalls have explicit timeouts; no strict overall message deadline is claimed.
|
||||
- `parallel_each` reuses static handlers/sequence classification and adds a bounded producer/worker owner with explicit worker limits, fault cancellation at pull boundaries and joined completion. Stream remains producer-owned; worker inputs/context cannot recursively contain Stream.
|
||||
- Ordinary last-use moves and transferred reduction state preserve live aliases and output/field order. Resource-containing locals retain lexical lifetime; a differential socket regression verifies no premature peer EOF. Full-domain Int/Decimal guards omit redundant comparisons without altering bounded clauses.
|
||||
- Passed 25 targeted core/library tests: `standard_library` 5, `native_ownership` 3, `concurrency` 4 and opt-in `differential` 13. `formatter` passed 11 tests including added/renamed source snapshots and all current official packages/examples. JSON passed 7 and HTTP passed 8 in worker validation; the main agent independently reran Unicode/exact-number JSON and the real HTTP server wire/400 case. Production CLI feature resolution excludes `reference`.
|
||||
- Actual CLI `check`, `fmt`, `run` and `build` succeeded for `examples/library/src`; normal run and a standalone executable under `env -i PATH=/nonexistent` printed `json = {"message":"你好 Dever","total":6}` and `http_roundtrip = true`.
|
||||
- Independent final review found no remaining blocker in formatter, reference, ownership, numeric domain optimization, concurrency and JSON after the snapshot fix. Main-agent HTTP review checked framing/status/timeout contracts. Database selection remains pending rather than implicitly chosen.
|
||||
- Passed the explicitly ignored performance target on Linux x86-64, CPU affinity 4, Rust 1.98.0, cached runtime, optimization level 3 and one codegen unit. Each side used five alternating processes and 45 effective samples. Native/baseline median ratios: checked Int 0.952, Float 1.000, List each/filter/sum 0.207, Decimal 1.001, ordered Map 1.145; all met 1.15. Native build times were 1,206–1,793 ms, baseline compile times 255–668 ms, recorded separately from execution. These figures cover the selected kernels only.
|
||||
- Decimal comparison was corrected to use identical lexical `0.0` representation with baseline parsing outside timing. Map diagnostics identified missed generic hash-table inlining under multiple codegen units; production and baseline now share one optimization-argument constant. This changes compiler optimization scope without changing hashing, panic, arithmetic or value semantics; it trades compilation parallelism for optimization consistency.
|
||||
- After the final native compiler setting, all 13 differential cases passed again, including exact fault traces, signed zero, Map semantics and resource lifetime. Strict Clippy/type checks passed for core/runtime/CLI and every new test target; targeted rustfmt and explicit whitespace checks passed. The complete follow-through has 51 functional tests plus one five-kernel performance gate, not a full workspace test run.
|
||||
- No full suite, existing service, external database, global toolchain/command change, Git commit or archive. Distribution/cross-platform remains deferred; self-hosting is still unshipped.
|
||||
|
||||
- Status: Approved core foundations and foundation extension implemented and verified on 2026-09-07
|
||||
- Requirement source: `prd.md`
|
||||
- Architecture source: `design.md`
|
||||
- Execution mode: one tightly coupled task; no obsolete child-task dispatch
|
||||
|
||||
## Completed Foundation Delivery Rule (Historical)
|
||||
|
||||
Implement the foundation extension as one bounded delivery: `reduce`, `reduce_until`, Bytes traversal, `Stream<Item>`, restricted static handler inputs, file/TCP stream wrappers and focused verification. Reuse the existing collection checker, typed HIR, intrinsic catalog, native emitter and resource owner; do not build a parallel execution path. Compiler distribution, cross-platform work, formatter/evaluator work and full HTTP/JSON/CRUD/database packages remain deferred. The completed core and hello milestones are regression baselines; the old API and original phase checklists are historical.
|
||||
|
||||
## Foundation Extension Checklist (Authoritative, Complete)
|
||||
|
||||
### 1. Syntax And Semantic Contracts
|
||||
|
||||
- [x] Parse contextual `handler(inputs) (named_outputs)` only in function input declarations, using a dedicated AST input kind rather than an ordinary value type.
|
||||
- [x] Parse `Stream<Item>` through the standard generic-type path; reject wrong arity, nullable handler signatures and handler syntax in fields, outputs or local/value positions.
|
||||
- [x] Add semantic `HandlerSignature` and handler-parameter symbols separately from runtime `Type`; add opaque `Stream(Item)` to the type model with non-comparable, non-Map-key resource semantics.
|
||||
- [x] Centralize handler compatibility, allowed reference positions and sequence element classification so direct calls, forwarding, `each`, `reduce` and `reduce_until` cannot drift.
|
||||
|
||||
### 2. Typed HIR And Static Specialization
|
||||
|
||||
- [x] Replace collection-only handler IDs with shared `HandlerTarget`, `CallTarget` and handler/value call arguments.
|
||||
- [x] Lower direct handler calls and multi-level forwarding without storing handlers in value locals or aggregates.
|
||||
- [x] Key native specializations by function plus ordered concrete handler bindings, substitute every forwarded target, and emit only direct calls.
|
||||
- [x] Detect direct and indirect cycles in the substituted specialization graph before private Rust compilation.
|
||||
|
||||
### 3. Sequence Operations
|
||||
|
||||
- [x] Add `reduce` and `reduce_until` to the single standard-operation registry and HIR, with exact state and `stop: Bool` output contracts.
|
||||
- [x] Lower List and Bytes traversal as left-to-right direct loops; emit Bytes elements as Int values without an intermediate List.
|
||||
- [x] Extend `each` to Bytes and zero-output Stream consumption while preserving existing List mapping/action behavior.
|
||||
- [x] Leave `filter`, `find` and `sum` List-only and reject implicit Stream collection.
|
||||
- [x] Add idempotent zero-output `close(stream)` and reject it for non-Stream values.
|
||||
|
||||
### 4. Stream Runtime And Official Packages
|
||||
|
||||
- [x] Add one generic runtime Stream owner with shared cursor/end/close state and serialized pull/close operations; do not add prefetch, threads or async machinery.
|
||||
- [x] Implement natural EOF, one terminal typed failure item, idempotent close, alias continuation after `reduce_until`, and release of only the Stream-held source alias.
|
||||
- [x] Add `ReadEvent`, `ReadStreamResult` and `chunks` to bundled `dever.io`; add socket `chunks` and listener `connections` to bundled `dever.net`.
|
||||
- [x] Extend fixed `dever.system` intrinsic signatures and generated typed adapters only for Stream creation/pulling; keep public result choices in `.dever` source and OS/resource mechanics in runtime.
|
||||
- [x] Preserve existing one-shot read/write/accept/close contracts without compatibility aliases or behavior changes.
|
||||
|
||||
### 5. Focused Verification And Cleanup
|
||||
|
||||
- [x] Add parser/semantic negatives for handler placement, type/arity/output mismatch, forbidden storage/return/comparison/clause use and indirect recursion.
|
||||
- [x] Add native List/Bytes cases for empty input, order, state typing, stop behavior, zero-output each and absence of intermediate Bytes allocation.
|
||||
- [x] Add runtime/native Stream cases for shared aliases, natural EOF, early stop/resume, repeated close, source close, invalid limit and one terminal failure.
|
||||
- [x] Compile official wrappers through the normal source loader and test file chunks plus loopback socket chunks/connections with owned resources, bounded data and timeouts.
|
||||
- [x] Exercise actual CLI `check`, `run`, `build` and the resulting standalone executable with a chunked state-parser fixture whose behavior is defined in `.dever`.
|
||||
- [x] Run focused formatting/type/lint/test checks only, inspect generated code/artifacts for direct handler calls, validate Trellis context and run whitespace checks.
|
||||
- [x] Review the final diff for duplicate contracts, runtime handler dispatch, eager Stream collection, HTTP/JSON leakage and unrelated changes; update the canonical specs and implementation evidence.
|
||||
|
||||
Planned focused validation targets:
|
||||
|
||||
```bash
|
||||
cargo test --offline -p dever-tests --test syntax_and_format <handler-or-stream-filter>
|
||||
cargo test --offline -p dever-tests --test core_semantics <reduce-or-handler-filter>
|
||||
cargo test --offline -p dever-tests --test core_native <sequence-or-specialization-filter>
|
||||
cargo test --offline -p dever-tests --test runtime_foundations <stream-or-loopback-filter>
|
||||
cargo test --offline -p dever-tests --test hello_native <regression-filter>
|
||||
cargo check --offline -p dever-core -p dever-runtime -p dever-cli
|
||||
python3 ./.trellis/scripts/task.py validate 09-04-dever-language-mvp
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Filters will be replaced by the concrete test names introduced during implementation. No full suite, workspace-wide test, performance gate, service start, external network call or global command mutation is authorized. File tests use test-owned temporary directories; TCP tests bind only `127.0.0.1:0` and terminate within explicit bounds.
|
||||
|
||||
Rollback boundaries are syntax/semantic representation, HIR/specialization, finite sequences, runtime Stream and official wrappers. A boundary is not accepted while it relies on a runtime function value, duplicated checker rule, eager collection fallback or partially exposed public API.
|
||||
|
||||
## Foundation Extension Evidence (2026-09-07)
|
||||
|
||||
- Handler inputs use dedicated syntax, semantic symbols and HIR arguments. Clause coverage and runtime locals traverse value parameters only. One specialization graph resolves direct and multi-level handler forwarding, records real cross-package handler references and rejects substituted recursion; generated functions contain direct calls and no user-handler function values.
|
||||
- `reduce`, `reduce_until` and `each` reuse one sequence classifier. List and Bytes lower to direct left-to-right loops, Stream pulls on demand, and `reduce_until` does not pull past the stopping item. Bytes traversal yields Int directly without constructing a List.
|
||||
- Runtime `Stream<T>` shares cursor/end/close state across aliases and serializes pull/close. File/socket chunks validate creation, release their producer at EOF or after one terminal failure, and support early-stop continuation. Bundled `dever.io` and `dever.net` expose source-defined stream choices through the normal loader.
|
||||
- Passed `cargo check --offline -p dever-core -p dever-runtime -p dever-cli` and strict Clippy for core/runtime/CLI plus the root test crate. `cargo fmt --all --check` passed.
|
||||
- Passed 74 focused tests: 22 `syntax_and_format`, 16 `core_semantics`, 16 `core_native`, 8 `runtime_foundations` and 12 `hello_native`. File tests owned their temporary directories; TCP tests used `127.0.0.1:0`, explicit timeouts and bounded transfers.
|
||||
- Final review made handler signatures reuse ordinary nested Map validation, preserved `Pull::Last` through mapped official streams, and added generated-code plus official creation-failure regressions. An independent read-only review then found no blocking correctness, safety or contract issue.
|
||||
- Actual CLI `check`, `run` and `build` succeeded for `examples/stream/src`; run and the standalone executable both printed `total = 208`. The executable also ran with an empty environment and no Cargo/Rust path.
|
||||
- No formatter/evaluator, async/concurrency system, HTTP/JSON/database package, external service, full workspace test, benchmark, distribution change, global command change or commit was included.
|
||||
|
||||
## Active Core Checklist
|
||||
|
||||
- [x] Record clause-only conditional dispatch as a permanent language rule and remove conditional blocks from the deferral list.
|
||||
- [x] Retain settled repetition rules and remove the redundant planning blocker.
|
||||
- [x] Record the user's approval and map the implementation boundary to core language and system interfaces.
|
||||
- [x] Implement scalar/aggregate types, complete symbols, expressions, outputs and value-semantic assignments.
|
||||
- [x] Implement clause type inference, normalization, overlap/exhaustiveness analysis and typed native dispatch.
|
||||
- [x] Implement numeric and collection runtime operations plus shared intrinsic contracts.
|
||||
- [x] Compile bundled official source and implement Bytes/file/TCP resource foundations.
|
||||
- [x] Verify focused semantic/native/runtime cases and review the final cross-layer changes.
|
||||
|
||||
## Core Foundation Evidence (2026-09-07)
|
||||
|
||||
- Implemented six scalars, records/choices/nullable/List/Map/MapEntry, typed expressions, named outputs, stable assignments and value semantics through check/HIR/native execution. Added naming and unused-write diagnostics.
|
||||
- Function groups infer one signature, use symbolic per-axis domains and rectangle subtraction to prove disjoint coverage, and lower into concrete native dispatch. No conditional blocks, source loops or user recursion were introduced.
|
||||
- Official source in `library/` shares the same frontend; one intrinsic enum bridges the small system boundary. Decimal128 and ordered maps reuse pinned `dec`/`indexmap` runtime wrappers. Added Bytes, create-new/read-only files, TCP and shared-close opaque handles with explicit source-defined result choices.
|
||||
- Passed 46 focused tests: 13 `core_semantics`, 9 `core_native`, 5 `runtime_foundations`, 12 existing `hello_native`, and 7 relevant `syntax_and_format` source/diagnostic regressions. Runtime tests used owned temporary files and loopback port 0 only. No full suite or integration environment was run.
|
||||
- Passed targeted rustfmt and strict Clippy/type checks for compiler/runtime/CLI and affected test targets. Existing tool archives were invoked with command-scoped paths; a local ignored verification wrapper supplies the toolchain library path after Cargo sanitizes its environment. No global installation or environment changes.
|
||||
- Actual CLI `run examples/core/src app.main` produced all 22 expected named outputs. `check` succeeded with nonexistent RUSTC. `build` produced `target/dever-core-example`, confirmed as a native Linux x86-64 ELF, and it produced the same outputs with `env -i PATH=/nonexistent`.
|
||||
- Independent read-only review found and closed duplicate choice Map keys, Group-sensitive inference, null-only `first`, and Float signed-zero constant propagation. Added semantic/native regressions; Float literal contextual typing does not retype existing Decimal/Int locals.
|
||||
- The initial wrapper/fixture errors and one stale numeric rejection fixture were corrected; final affected checks passed. Trellis context validation passed with implementation/check JSONL absent and skipped for inline work. Ordinary `git diff --check` passes but does not inspect the untracked compiler files; targeted formatting and explicit new-file whitespace checks cover those files.
|
||||
- Formatter, reference evaluator/differential checks, performance gates, full HTTP/JSON/database packages, self-hosting and distribution/cross-compilation were not implemented or claimed by this delivery. No commit/archive; the wider language task remains active.
|
||||
|
||||
## Completed Hello Checklist
|
||||
|
||||
- [x] Correct the superseded API-first requirements and record the approved Text/action subset.
|
||||
- [x] Add package/function resolution, semantic diagnostics and checked HIR.
|
||||
- [x] Add standard output runtime and native compilation.
|
||||
- [x] Add local check/build/run CLI and `examples/hello/src/app/user.dever`.
|
||||
- [x] Run focused semantic/native/runtime tests and the actual hello command; announce local process execution first.
|
||||
- [x] Review the final changes, update implementation evidence and record the session.
|
||||
|
||||
## Native Hello Evidence (2026-09-06)
|
||||
|
||||
- Implemented the approved Text/action subset, checked HIR, shared stdout runtime, native Rust generation and local `deverc` CLI. Example: `examples/hello/src/app/user.dever`.
|
||||
- Passed 12 `hello_native` tests and the existing 20 `syntax_and_format` tests. After the final emitter readability change, reran all 12 native tests successfully.
|
||||
- Passed rustfmt and strict Clippy for core/runtime, both targeted test binaries and the actual CLI binary.
|
||||
- Actual CLI `check` succeeds even with a nonexistent RUSTC, proving it does not invoke compilation. `run` prints exactly `hello\n`; an unknown entry returns exit 1. `build` produced `target/hello-user`, identified as a native x86-64 ELF, which prints `hello\n` with PATH containing no Rust tools.
|
||||
- Independent read-only reviewer found no blocking issue; the noted CLI verification gap was closed by the actual commands above. Tests exercise Unicode/escaping, source locations, value semantics and write failures, not a User/hello-specific execution branch.
|
||||
- Added README run/build instructions and updated owning compiler specs. No global command replacement, external dependency, commit, service, database or load test.
|
||||
- This bounded milestone is complete. The overall language roadmap remains open; HTTP/JSON/CRUD and persistence are deferred pending the user's next step.
|
||||
|
||||
Preserve unrelated changes and do not create a commit without explicit authorization. Verification for the new API must use its own local process and test storage, with functional checks and bounded performance checks described before execution. Do not test against, restart or benchmark existing services. No runtime or load check has been run during this planning turn.
|
||||
|
||||
## Historical API Replanning Checklist (Deferred)
|
||||
|
||||
- [x] Record the real user API goal and supersede the pure-computation-only boundary.
|
||||
- [x] Keep `.dever` source and the existing surface syntax; defer the speculative contract-declaration system.
|
||||
- [ ] Confirm persistence selection.
|
||||
- [ ] Specify typed HTTP/JSON/storage boundaries and necessary effect/concurrency semantics.
|
||||
- [ ] Finalize user API behavior and meaningful performance acceptance.
|
||||
- [ ] Replace the next execution steps with a bounded native application delivery plan.
|
||||
- [ ] Review the concrete plan with the user before implementation.
|
||||
|
||||
Proposed delivery order, to finalize after the open product decisions:
|
||||
|
||||
1. Implement name/type/clause/flow checking and HIR for the language features the API actually uses.
|
||||
2. Establish concrete native business-function execution and source-located failures.
|
||||
3. Add typed HTTP/JSON boundaries, explicit resource/effect semantics and the selected persistent database adapter.
|
||||
4. Implement `examples/user-api` entirely in `.dever`, with real CRUD and configuration supplied by the runtime environment.
|
||||
5. Run announced, isolated functional/persistence/concurrency checks and a bounded native performance comparison.
|
||||
|
||||
Complete Decimal/Float/Map libraries, the full reference evaluator, formatter, component distribution and AI indexing remain on the roadmap; they are not all prerequisites for the first user API. Preserve unsupported-source diagnostics rather than silently approximating unimplemented language behavior.
|
||||
|
||||
## Initial Frontend Evidence (2026-09-05)
|
||||
|
||||
- Implemented: dependency-free Rust workspace, source directory loading, UTF-8 and line/column handling, deterministic diagnostics, lexer, spanned AST and parser.
|
||||
- Passed: 20 focused `syntax_and_format` tests, including the complete PRD package; Rust formatting check; focused Clippy with `-D warnings`; diff whitespace check.
|
||||
- Independent review: fixed iterative expression-tree depth bypass and removed inaccurate terminal-width caret rendering. Follow-up review confirmed both fixes.
|
||||
- Validation tools: rustc/Cargo 1.98.0 are installed but absent from the default PATH. Rustfmt and Clippy were missing, so their exact 1.98.0 official archives were downloaded to ignored `target/verification-tools/`, checked against the installed Rust manifest SHA-256 values and invoked directly with command-scoped library/tool paths. No global toolchain installation or command replacement occurred.
|
||||
- At that checkpoint, native/differential/performance checks had not run. The Native Hello Evidence above records the subsequently implemented and verified native subset.
|
||||
- The previous API-first next step was superseded by the approved hello-world milestone; formatter and other language work remain on the roadmap.
|
||||
|
||||
## Historical Original Roadmap Notice
|
||||
|
||||
The phase checklist below records the original repository bootstrap plan. Its unchecked boxes are not the current implementation status and do not expand the authoritative extension above. The Active Core Checklist and dated evidence describe completed work.
|
||||
|
||||
## Phase 1: Initialize The Repository
|
||||
|
||||
### 1.1 Preserve and classify current changes
|
||||
|
||||
- [x] Record `git status --short`; current changes contain planning and agent metadata, plus two deleted old documents.
|
||||
- [x] Confirm no old Rust workspace, implementation or test files exist in the working tree or HEAD.
|
||||
- [x] Preserve existing user changes; no old implementation needs removal.
|
||||
|
||||
### 1.2 Establish the new workspace
|
||||
|
||||
- [x] Create `crates/dever-core` as the single language library.
|
||||
- [ ] Add `crates/dever-runtime` in Phase 7 when numeric/fault consumers exist; keep parser, checker, HIR and evaluator code out of it.
|
||||
- [ ] Add `crates/dever-cli` in Phase 10 for the `dever` binary; do not expose syntax-only success as the public semantic `check` command.
|
||||
- [x] Create `test/dever-tests` as the only permanent test crate.
|
||||
- [x] Create root `Cargo.toml`; pin external dependencies when they are first consumed.
|
||||
- [ ] Add a decimal128 dependency behind `dever-runtime::number` and reuse that wrapper from the evaluator.
|
||||
- [ ] Add an insertion-ordered map dependency behind `dever-runtime::collections` and reuse that wrapper from the evaluator.
|
||||
- [x] Do not add parser generators, async runtimes, plugin systems or CLI frameworks.
|
||||
|
||||
### 1.3 Establish current rules
|
||||
|
||||
- [x] Confirm the rejected prototype and its fixtures/tests are absent; do not add migration scaffolding.
|
||||
- [x] Preserve existing deletion of superseded plan/spec documents.
|
||||
- [x] State in `AGENTS.md` that `.dever` source is the trusted source.
|
||||
- [x] Replace relevant Trellis spec placeholders with the actual frontend ownership and verification rules.
|
||||
|
||||
Rollback point: kickoff adds compiler files and updates canonical planning only; existing user deletions and unrelated metadata are preserved.
|
||||
|
||||
## Phase 2: Source, Diagnostics And Lexer
|
||||
|
||||
### 2.1 Source ownership
|
||||
|
||||
- [x] Implement `SourceId`, `SourceFile`, `SourceMap` and half-open `Span`.
|
||||
- [x] Precompute line starts and render stable one-based line/column positions.
|
||||
- [x] Load `.dever` paths in lexical order and reject invalid UTF-8.
|
||||
|
||||
### 2.2 Diagnostics
|
||||
|
||||
- [x] Implement the shared diagnostic model; semantic phases will consume it when implemented.
|
||||
- [x] Support primary and related spans plus stable diagnostic codes.
|
||||
- [x] Sort diagnostics deterministically.
|
||||
- [x] Add renderer tests for the syntax-phase messages, related source files and Unicode locations.
|
||||
|
||||
### 2.3 Lexer
|
||||
|
||||
- [x] Tokenize identifiers, keywords, delimiters, operators, newlines and comments.
|
||||
- [x] Preserve numeric spelling and comment trivia.
|
||||
- [x] Decode Text escapes with precise bad-escape diagnostics.
|
||||
- [x] Reject non-ASCII identifiers while allowing UTF-8 strings/comments.
|
||||
|
||||
Focused checks:
|
||||
|
||||
```bash
|
||||
cargo test --offline -p dever-tests --test syntax_and_format
|
||||
```
|
||||
|
||||
## Phase 3: Parser And Formatter
|
||||
|
||||
### 3.1 Syntax tree
|
||||
|
||||
- [x] Define focused AST nodes for package, type, function clauses, patterns, statements and expressions.
|
||||
- [x] Keep every node spanned; do not attach resolved IDs or runtime values.
|
||||
- [x] Preserve ordered comments with source spans; formatter attachment remains part of Phase 3.3.
|
||||
|
||||
### 3.2 Recursive-descent parser
|
||||
|
||||
- [x] Parse the package header and exposure list.
|
||||
- [x] Parse record and choice type declarations.
|
||||
- [x] Parse function signatures, clause patterns and linear bodies.
|
||||
- [x] Parse record/List/Map values, calls, field access and operators with fixed precedence.
|
||||
- [x] Recover only at bounded declaration/statement delimiters.
|
||||
- [x] Reject excluded constructs rather than accepting compatibility syntax.
|
||||
|
||||
### 3.3 Canonical formatter
|
||||
|
||||
- [ ] Format every valid syntax node from structure.
|
||||
- [ ] Preserve comments and normalize whitespace, blank lines and indentation.
|
||||
- [ ] Add parse-format-parse and twice-format idempotence tests.
|
||||
- [ ] Refuse to rewrite invalid files.
|
||||
|
||||
Focused check:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test syntax_and_format
|
||||
```
|
||||
|
||||
Milestone boundary: the parser is currently an internal library for focused tests. Do not expose a public checked-source or formatting command until its complete contracts are implemented.
|
||||
|
||||
## Phase 4: Package And Name Resolution
|
||||
|
||||
- [ ] Register package/type/function headers before resolving bodies.
|
||||
- [ ] Validate path/package equality and one-file ownership.
|
||||
- [ ] Load only the supplied source root and fixed standard catalog; do not search external paths or perform network access.
|
||||
- [ ] Reserve core single-segment package names and `dever.*` against local declarations.
|
||||
- [ ] Validate `exposes` against locally declared names.
|
||||
- [ ] Resolve local short names and cross-package fully qualified names.
|
||||
- [ ] Enforce visibility without import tables.
|
||||
- [ ] Group same-name/same-arity clauses and enforce contiguity.
|
||||
- [ ] Reject package, recursive type and direct/indirect function cycles.
|
||||
- [ ] Include standard core and qualified package names through one fixed catalog.
|
||||
- [ ] Report unavailable future official/third-party references as unknown packages without an implicit install fallback.
|
||||
|
||||
Focused check:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test package_resolution
|
||||
```
|
||||
|
||||
## Phase 5: Type Checking And Linear Flow
|
||||
|
||||
### 5.1 Type interning and declarations
|
||||
|
||||
- [ ] Implement scalar, record, choice, nullable, List, Map, MapEntry, Stream and multi-output types, with static handler inputs represented separately from values.
|
||||
- [ ] Reject record/choice member mixing and recursive user types.
|
||||
- [ ] Validate choice payload arity and record completeness.
|
||||
- [ ] Validate Map key eligibility.
|
||||
|
||||
### 5.2 Expressions and calls
|
||||
|
||||
- [ ] Check literals using expected type without premature numeric conversion.
|
||||
- [ ] Implement operator tables exactly as specified in the PRD.
|
||||
- [ ] Permit only lossless Int-to-Decimal implicit promotion.
|
||||
- [ ] Resolve positional function calls and named multi-output access.
|
||||
- [ ] Restrict named handler references to sequence operations, direct static invocation and approved handler-input forwarding.
|
||||
|
||||
### 5.3 Definite assignment and mutation
|
||||
|
||||
- [ ] Track uninitialized/initialized locals in one forward pass.
|
||||
- [ ] Reject reads and field writes before complete initialization.
|
||||
- [ ] Require every output on every clause path.
|
||||
- [ ] Keep a local's inferred type stable across reassignment.
|
||||
- [ ] Report unused writes without adding defensive runtime behavior.
|
||||
|
||||
Focused check:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test type_checking
|
||||
```
|
||||
|
||||
## Phase 6: Clause Analysis And Typed HIR
|
||||
|
||||
### 6.1 Normalize patterns
|
||||
|
||||
- [ ] Normalize Bool, null, choice, scalar constant and `other` patterns.
|
||||
- [ ] Normalize Int/Decimal comparisons into open/closed intervals.
|
||||
- [ ] Support at most one lower and one upper bound joined by `and`.
|
||||
- [ ] Reject Float ranges and dynamic/function-based guards.
|
||||
|
||||
### 6.2 Prove clause validity
|
||||
|
||||
- [ ] Compute overlap, reachability and exhaustiveness over symbolic domains.
|
||||
- [ ] Support products of multiple input domains without enumerating numeric values.
|
||||
- [ ] Emit diagnostics that label both conflicting clauses or the uncovered domain.
|
||||
- [ ] Prove that permuting source clauses leaves the normalized decision model unchanged.
|
||||
|
||||
### 6.3 Lower typed HIR
|
||||
|
||||
- [ ] Lower only fully checked packages.
|
||||
- [ ] Replace names with resolved IDs and attach static types/spans.
|
||||
- [ ] Build decision nodes from normalized clause domains.
|
||||
- [ ] Keep HIR internal to `dever-core`; do not serialize it as a second source format.
|
||||
|
||||
Focused check:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test clause_analysis
|
||||
```
|
||||
|
||||
Rollback point: do not add an ordered first-match fallback if coverage analysis is incomplete. Unsupported patterns must fail compilation.
|
||||
|
||||
## Phase 7: Values, Numbers And Reference Evaluator
|
||||
|
||||
### 7.1 Runtime values
|
||||
|
||||
- [ ] Implement the language-owned `Value` variants.
|
||||
- [ ] Use shared immutable aggregate storage with copy-on-write updates.
|
||||
- [ ] Wrap insertion-ordered Map behavior and use order-preserving removal.
|
||||
- [ ] Implement deterministic value rendering and named output order.
|
||||
|
||||
### 7.2 Numeric layer
|
||||
|
||||
- [ ] Implement checked i64 arithmetic, including negative division/remainder edges.
|
||||
- [ ] Wrap Decimal128 Context with round-half-even and per-operation status clearing/checks; accept inexact/rounded and fault on overflow, underflow, division by zero or invalid operation.
|
||||
- [ ] Reject Decimal special values at the `DecimalValue` boundary.
|
||||
- [ ] Implement exact Int-to-Decimal and explicit Int-to-Float conversion.
|
||||
- [ ] Reject inexact Decimal literals while allowing specified inexact/rounded arithmetic results.
|
||||
- [ ] Implement f64 arithmetic and IEEE classification functions without fast math.
|
||||
- [ ] Add Decimal conformance and numeric fault fixtures.
|
||||
|
||||
### 7.3 Evaluator
|
||||
|
||||
- [ ] Execute HIR with explicit frames and no mutable global state.
|
||||
- [ ] Select clauses through compiled decision nodes.
|
||||
- [ ] Preserve source-level value semantics during local and field mutation.
|
||||
- [ ] Return named outputs or an uncatchable `RuntimeFault` with source span.
|
||||
- [ ] Add a Dever call trace only as diagnostic context.
|
||||
- [ ] Keep evaluator entry points internal to compiler tests; production `build` and `run` must not depend on them.
|
||||
|
||||
Focused checks:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test value_semantics
|
||||
cargo test -p dever-tests --test numbers
|
||||
cargo test -p dever-tests --test failures runtime
|
||||
```
|
||||
|
||||
## Phase 8: Standard Functions
|
||||
|
||||
- [ ] Define one static signature/evaluator catalog for core and qualified standard names.
|
||||
- [ ] Implement `append`, `first`, `get`, `put`, `remove` and `entries`.
|
||||
- [ ] Implement `each`, `reduce`, `reduce_until`, `filter`, `find` and `sum` with shared sequence/handler helpers.
|
||||
- [ ] Enforce one optional context and element-first handler inputs.
|
||||
- [ ] Preserve order and short-circuit `find`.
|
||||
- [ ] Keep Float sum left-to-right.
|
||||
- [ ] Implement minimum `text`, `decimal`, `float` and `math` functions used by the PRD.
|
||||
- [ ] Do not add unrequested collection aliases, lazy Stream transforms or reduction names besides `reduce` and `reduce_until`.
|
||||
|
||||
Focused check:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test collections
|
||||
cargo test -p dever-tests --test numbers standard_functions
|
||||
```
|
||||
|
||||
## Phase 9: Native AOT Compilation
|
||||
|
||||
### 9.1 Whole-program specialization
|
||||
|
||||
- [ ] Start from one checked, exposed, zero-input entry function and collect its reachable HIR.
|
||||
- [ ] Specialize every concrete record, choice, nullable, List, Map, Stream and static handler-binding combination.
|
||||
- [ ] Lower clauses to deterministic native decision trees and collection functions to direct loops.
|
||||
- [ ] Perform last-use/liveness analysis so dead sources move instead of clone while observable value semantics remain unchanged.
|
||||
- [ ] Reject any native path that would require a generic runtime `Value`, reflective lookup or virtual per-element dispatch.
|
||||
|
||||
### 9.2 Private Rust emission
|
||||
|
||||
- [ ] Emit concrete Rust types and direct functions from typed HIR through one structured writer with centralized identifier/literal escaping.
|
||||
- [ ] Emit explicit checked numeric and source-located runtime-fault operations.
|
||||
- [ ] Make generated programs depend only on `dever-runtime`, never `dever-core` or the evaluator.
|
||||
- [ ] Keep generated Rust and Cargo metadata in a compiler-owned transient directory with no public emit command.
|
||||
- [ ] Compile with an optimized release profile starting at opt-level 3, thin LTO and one codegen unit.
|
||||
- [ ] Translate toolchain failures into stable compiler diagnostics without leaking generated identifiers as Dever names.
|
||||
|
||||
### 9.3 Native conformance and performance
|
||||
|
||||
- [ ] Differentially compare evaluator and native outputs/fault categories across the language conformance corpus.
|
||||
- [ ] Inspect native artifacts to prove the evaluator is not linked.
|
||||
- [ ] Benchmark scalar arithmetic and List `each`/`filter`/`sum` against equivalent safe Rust using the same algorithm, data layout and toolchain.
|
||||
- [ ] Benchmark Decimal and ordered Map separately against Rust baselines using the same underlying semantic wrappers.
|
||||
- [ ] Fail the performance gate when representative median native hot-path time exceeds 1.15 times the matching Rust baseline.
|
||||
|
||||
Focused checks:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test native
|
||||
cargo test -p dever-tests --test differential
|
||||
cargo test -p dever-tests --test performance -- --ignored
|
||||
```
|
||||
|
||||
Rollback point: the evaluator remains the semantic oracle, but an incomplete native backend cannot silently become the production `run` path. Unsupported lowering is a compile diagnostic.
|
||||
|
||||
## Phase 10: CLI And End-To-End Example
|
||||
|
||||
### 10.1 CLI
|
||||
|
||||
- [ ] Implement `check`, `fmt`, `build` and `run` as thin adapters.
|
||||
- [ ] Do not add registry, `add`, `update`, signature or component-cache commands in Core 0.1.
|
||||
- [ ] Validate exact argument counts and stable exit codes.
|
||||
- [ ] Keep diagnostics on stderr and results on stdout.
|
||||
- [ ] Make `fmt` all-or-nothing when any source file is invalid.
|
||||
- [ ] Restrict `build` and `run` to exposed zero-input functions.
|
||||
- [ ] Make `build` write an optimized native executable to `--output` and make `run` compile/execute the same native path.
|
||||
- [ ] Keep the Cargo binary and canonical command name as `dever`; do not rename compiler internals or public command semantics to `deverc`.
|
||||
- [ ] Treat `deverc`, when explicitly installed for coexistence, as a thin link or launcher to the same compiler binary with no divergent behavior.
|
||||
- [ ] Do not replace the existing framework `dever`, modify system `PATH` or create global executables during repository implementation or automated tests.
|
||||
- [ ] If global `deverc` setup is later authorized, resolve and record both command targets and verify that the existing global `dever` remains unchanged.
|
||||
|
||||
### 10.2 Example and integration
|
||||
|
||||
- [ ] Add the canonical account/user package from the PRD.
|
||||
- [ ] Add a zero-input executable example covering types, clauses, collections and numbers.
|
||||
- [ ] Verify multiple source files and full qualification.
|
||||
- [ ] Verify the zero-input example produces identical evaluator and native results.
|
||||
- [ ] Ensure no example source depends on Rust, JSON graph, React or a hidden host fallback.
|
||||
|
||||
Focused check:
|
||||
|
||||
```bash
|
||||
cargo test -p dever-tests --test cli
|
||||
```
|
||||
|
||||
## Phase 11: Cleanup And Final Verification
|
||||
|
||||
### 11.1 Structural review
|
||||
|
||||
- [ ] Search for duplicate operator, standard-function, diagnostic and type rules.
|
||||
- [ ] Keep each language rule owned by one table/module.
|
||||
- [ ] Remove obsolete compatibility code, commented code, placeholders and old architecture references.
|
||||
- [ ] Confirm the CLI contains no language semantics.
|
||||
- [ ] Confirm no public Rust API leaks into `.dever` diagnostics or docs.
|
||||
- [ ] Confirm generated binaries depend on the minimal runtime but not compiler/evaluator modules.
|
||||
- [ ] Confirm private generated Rust is not documented or tested as a public compatibility format.
|
||||
|
||||
### 11.2 Acceptance mapping
|
||||
|
||||
- [ ] Map all 20 PRD acceptance criteria to named test cases.
|
||||
- [ ] Run each relevant focused test target.
|
||||
- [ ] Run static formatting and diff checks.
|
||||
- [ ] Record passed, failed, skipped and not-run checks separately.
|
||||
|
||||
Permitted final checks, announced before execution:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo check -p dever-core -p dever-runtime -p dever-cli
|
||||
cargo test -p dever-tests --test syntax_and_format
|
||||
cargo test -p dever-tests --test package_resolution
|
||||
cargo test -p dever-tests --test type_checking
|
||||
cargo test -p dever-tests --test clause_analysis
|
||||
cargo test -p dever-tests --test value_semantics
|
||||
cargo test -p dever-tests --test collections
|
||||
cargo test -p dever-tests --test numbers
|
||||
cargo test -p dever-tests --test failures
|
||||
cargo test -p dever-tests --test native
|
||||
cargo test -p dever-tests --test differential
|
||||
cargo test -p dever-tests --test cli
|
||||
python3 ./.trellis/scripts/task.py validate 09-04-dever-language-mvp
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The performance test is an explicit, separately announced gate and is not part of the default test list. Do not substitute `cargo test --workspace`, `npm run build` or service/integration tests. If a focused check reveals a shared compiler defect, fix the owning implementation once and rerun only affected targets.
|
||||
|
||||
## Finalization
|
||||
|
||||
- [ ] Run `trellis-check` after implementation.
|
||||
- [ ] Update canonical Trellis specs for the new crate ownership and language rules.
|
||||
- [ ] Review the final diff against PRD scope and remove accidental abstractions.
|
||||
- [ ] Do not commit until the user explicitly authorizes a commit.
|
||||
- [x] Confirm there are no obsolete planning children to archive.
|
||||
# Active Compiler Contract Checklist (2026-09-08)
|
||||
|
||||
- [x] Contract syntax, formatter and semantic metadata.
|
||||
- [x] Field privacy, numeric facts, construction/write/output constraints.
|
||||
- [x] Failure obligations and explicit recovery, including nested values and static handlers.
|
||||
- [x] Transitive effects, package dependencies and API snapshot/CLI enforcement.
|
||||
- [x] Evidence-backed redundancy diagnostics and application export analysis.
|
||||
- [x] Official source/examples migration, targeted tests, native equivalence, docs/specs and final review.
|
||||
|
||||
User approved the full contract implementation. Complete the list before claiming delivery. Keep earlier evidence below as historical; no full test suite, external services, distribution work or commit.
|
||||
|
||||
## Compiler Contract Evidence (2026-09-08)
|
||||
|
||||
- Implemented private record fields, normalized Int/Decimal field/payload/output/handler bounds, explicit error/recover and pure/depends. HIR facts reuse existing clause domains; handler boundaries support safe variance and concrete output inference without forcing wrapper annotations.
|
||||
- Failure obligations follow copies, nested fields, calls and collections. Official Failed variants are explicitly marked; HTTP transport preserves earlier failure state and returns the inspected result with its stop decision. The Stream example now propagates a CountResult failure instead of silently returning zero. Presentation/test recovery reasons are explicit and consistent across clauses.
|
||||
- Effects and recovery policies propagate through invoked static handlers. API generation is create-new; check/run/build compare an existing baseline. Adding a private recovering helper changes the effective public snapshot without exposing that helper's name. Depends includes normalized system resource types. W001 is reused; W002–W005 and entry-scoped W007 are advisory, not arbitrary abstraction requirements.
|
||||
- Final six contract targets passed 47 tests: syntax 4, facts 16, semantics 10, API 9, redundancy 6, execution 2. Existing core semantics passed 16 (15 in the first run, W001 assertion narrowed to its diagnostic then rerun); existing syntax passed 22, formatter passed 11 and the new contracts example roundtrip passed. The independent expected-output core differential case passed. Forty-one migrated source fixtures were checked without executing their network/resource scenarios; the temporary probe was removed.
|
||||
- Actual CLI api/check/run/build and standalone execution with `env -i PATH=/nonexistent` passed. A temporary source's new exposure was rejected by its unchanged baseline. `examples/contracts/src/dever.api` records the example's contract. Strict installed-driver Clippy/type checks passed for core/CLI and focused contract tests; formatting and explicit whitespace checks cover untracked files as well.
|
||||
- Independent review found and closed empty-reduce seed consumption, primitive resource dependency omission, and exponential/deep proof graphs. Added positive/negative regressions; immutable shared fact/value graphs preserve aliases, proof work is capped at 16,384 and iterative nominal depth validation caps 128 before recursive analysis. Exhaustion reports C015; no incomplete proof becomes a successful Program.
|
||||
- Updated LANGUAGE.md, IMPLEMENTATION.md, README and the owning compiler-contract spec. No database/distribution expansion, full suite, integration environment, existing service, global installation or Git commit. The umbrella task stays active for its previously deferred roadmap; this approved contract extension is complete.
|
||||
|
||||
## Direct Example Roots And Coverage (2026-09-08)
|
||||
|
||||
- Moved all five original source roots from `examples/<name>/src` to `examples/<name>`, including the unchanged contracts API baseline. Removed empty src directories; package-relative nesting, such as hello/app/user.dever, remains. Updated executable test references, current guides and owning specs; earlier dated evidence retains its historical paths.
|
||||
- Added sequences, text, privacy, parallel and system examples, bringing runnable roots to ten. Added six separately checkable rejected roots for C006/C009/C011/C012/C013/C014. The examples index documents commands, outputs, nondeterministic ordering, runtime input paths and explicit recovery boundaries.
|
||||
- Reused compiler/check/native APIs in one example runner. Automatic discovery checks every runnable root and any baseline; formatter coverage discovers all examples, including rejection fixtures. No compiler or runtime behavior changed.
|
||||
- Passed seven example-target tests covering all ten successful roots, nine native executions and six rejection cases; the existing core native test separately verified its 22 expected outputs. The targeted formatter roundtrip passed over every official/example source. Actual CLI contract check and expected C012 rejection passed; example fmt --check passed. Strict Clippy/type checks compiled all affected test targets, including feature-gated differential references.
|
||||
- No full suite, network/service integration, compiler feature expansion, global installation, commit or archive.
|
||||
|
||||
## Isolated PostgreSQL And Dual-source CMS Measurements (2026-09-18)
|
||||
|
||||
- In a nonprivileged, owned PostgreSQL 16 instance with a private Unix socket and no TCP listener, the ignored `postgres_orm_uses_the_configured_isolated_database` test passed 1/1. Its shared schema cleanup left no test schemas. Added a focused live named-migration probe for bound before/after SQL, atomic rollback on after failure, successful DDL and once-only re-execution. Test connection came only from a temporary root `config/setting.json`; no database URL environment variable or existing PostgreSQL service was used. After verification, the temporary setting was deleted, the owned instance stopped and its test data removed.
|
||||
- Focused `cms-profiles` build compiles both current `examples/cms/{dever,md}` source formats using one shared benchmark entry and separately builds base/SQLite/PostgreSQL/both runtime profiles. Artifacts: `target/performance/cms-dual-build-09-18-v2/manifest.json` and `profile-report.json`. Profile bytes: 411,352 / 2,558,200 / 2,708,112 / 4,672,888. CMS Dever/Markdown bytes: 3,151,352 / 3,151,416.
|
||||
- `target/performance/cms-dual-rss-09-18/report.json` completed all six runs (three per source, READY=3 throughout). Dever/Markdown median readiness 19.87/19.69 ms, idle RSS 5.37/5.37 MiB, PSS 3.28/3.33 MiB, VmHWM 5.37/5.37 MiB. This is warm-cache initialization and bounded idle sampling, not throughput. No hard 64/128 MiB CMS budget or cgroup peak: the local controller is not writable. No broad/full suite, existing service check, or global installation.
|
||||
- Benchmark runner unit tests pass 40/40; focused test compiled with `--features postgres`; Trellis task context validation passed. Rustfmt and Clippy are unavailable in the installed toolchain. This root roadmap remains in progress; API stays deferred pending its redesign, and hard-budget CMS stress requires a delegated cgroup environment.
|
||||
|
||||
# Source Visibility And Module API Implementation (2026-09-18)
|
||||
|
||||
- Replaced `package/exposes` syntax with path-derived package identity and explicit `public` for cross-package type/function visibility; fixed private `main.main` as the only run/build entry. Updated bundled sources, CMS Dever/Markdown roots, old runnable examples and Markdown metadata. The hello example now delegates through a public library function to its own `main.dever`.
|
||||
- Added statically checked module `api/` routes with GET/POST/DELETE naming, recursive paths, private handlers and one `response` output. `dever.api.serve()` opts the application into routing and the existing bounded HTTP engine; typed inputs, JSON serialization and `{code,message,data}` envelopes reuse a single runtime adapter. Listener settings are read from `config/setting.json`, not environment variables; no CMS business API or authentication policy was added.
|
||||
- Focused checks: core/CLI/runtime/tests cargo check passed; `source_visibility` 10/10, `syntax_and_format` 22/22, `core_semantics` 16/16, `formatter` 11/11 and `markdown_source` 20/20 passed. `api_routes` passed 7/7 before the final test adjustment, then its application-mode native compilation passed 1/1 with config bootstrap and no listener. An isolated temporary-project CLI `check/run/build` and API-baseline rejection test passed. Maintained examples, CMS Dever/Markdown source parity and rejection fixtures passed selected contract tests. Two ORM fixture static-check tests passed without database connections. `deverc check` passed for hello and both CMS formats (CMS retains W001 unused-write warnings).
|
||||
- Not run: full suite, live HTTP/API service check, real PostgreSQL, CMS runtime/build or benchmarks. Rustfmt and Clippy binaries are unavailable in the installed toolchain. The umbrella task remains in progress for separately deferred roadmap work; historical entries above retain their original date/context.
|
||||
|
||||
# AI-Oriented Source Architecture Implementation (2026-09-19, Complete)
|
||||
|
||||
- [x] Add one shared source-role classifier and project-layout validation.
|
||||
- [x] Replace application `public` visibility with automatic App capabilities and logical App aliases.
|
||||
- [x] Enforce role dependency direction, API non-callability and Model-operation ownership.
|
||||
- [x] Migrate the Dever and Markdown CMS sources from `service/` to component/domain roles without function-per-file fragmentation.
|
||||
- [x] Update the language, Markdown and backend compiler/database contracts.
|
||||
- [x] Add focused layout, visibility, API, Model and CMS parity tests; run only the affected checks.
|
||||
|
||||
The implementation does not add environment variables, implicit HTTP startup, authentication policy, a new Port declaration syntax, or backward compatibility for the superseded application layout.
|
||||
|
||||
## AI-Oriented Source Architecture Evidence (2026-09-19)
|
||||
|
||||
- `SourceLayout` is the single main/component/domain/role/topic classifier. Strict project loading rejects loose application paths, generic buckets, role file/directory conflicts, nested non-API topics and one-file role directories. The check cache includes strict-layout identity; in-memory compiler fragments remain isolated from project-layout enforcement and cannot weaken CLI projects.
|
||||
- App declarations receive stable `<component>.<domain>.<name>` identities without exposing topic filenames. Role-aware lookup enforces Main/API/App/private-role direction, API non-callability and owning-domain Model operations. App signatures and inferred failures reject private type leakage. Port remains a reserved, rejected declaration role.
|
||||
- Plain and Markdown CMS now contain only `main`, `user/account/{app,domain,model}` and `news/article/{app,model}`. Both forms have equal normalized declarations, Model/API snapshots and settings. Current examples contain no package/exposes/public syntax, old service/model roots or environment-variable configuration.
|
||||
- The current performance packager now stages `benchmark/<domain>/app.dever` plus generated `main.dever`; it no longer generates package/exposes headers or passes arbitrary entries to `deverc build`. ORM, profile, Dever CMS and Markdown CMS staged projects all passed actual CLI check. The CLI CMS regression test compiles against the fixed main-only interface and uses a test-owned App probe instead of injecting a loose root source.
|
||||
- Focused verification passed: core/runtime/CLI Cargo check; `source_architecture` 8/8; `source_visibility` 10/10; `orm_performance_fixture` 3/3; `concurrency` 5/5 with its explicit benchmark ignored; CMS source/config parity 1/1; archived parallel and source-root native regressions passed after the synchronous typed-error fix. Earlier in this same phase `api_routes` passed 7/7. Actual CLI `check` and `fmt --check` passed for both CMS formats, and Python syntax checking passed for the performance builder.
|
||||
- Not run: full workspace suite, live CMS `run/build`, `cms_project` integration execution, HTTP listener, real PostgreSQL, performance/size/RSS builds or 64/128 MiB stress. Rustfmt and Clippy components remain unavailable. The umbrella language roadmap stays in progress for separately deferred work.
|
||||
@@ -0,0 +1,990 @@
|
||||
# Dever Language Definition
|
||||
|
||||
## N5:HTTP/2(已完成)
|
||||
|
||||
用户在 P2 性能验证结束后批准继续接入 HTTP/2。使用 Hyper 实现 buffered/live 服务端和 single-use/pooled 客户端;复用既有请求/响应、TLS、AsyncStream 与 SSE 接口。Limits.http2 显式配置协议和每连接流数、接收窗口,TLS 必须协商 h2,明文使用 prior knowledge。每流取消隔离,Hyper 内部任务保留 Dever Scope,GOAWAY 排空不自动重试已发请求。详细验收以 `../09-12-http2/prd.md` 为准。
|
||||
|
||||
N5 已由 88 项定向测试、原生执行和独立审查验收。后续 HTTP/2 性能矩阵与有界稳态/恢复压测已由子任务完成;小时级稳定性、编译器发行/跨平台、自举及其余路线图仍未完成。
|
||||
|
||||
仅定向协议、生命周期与原生桥接验证;不重跑 P1/P2 性能矩阵,不改根任务调度,不接 RFC 8441、h2c Upgrade、推送、trailer、数据库或发行工具。下文各轮排除范围仅约束相应历史阶段。
|
||||
|
||||
## 已完成阶段 N4:并发组合与可复用网络客户端(2026-09-12)
|
||||
|
||||
用户批准完成上一轮清单的 1 和 2。交付源码运行参数、静态 HTTP handler 的类型化上下文、任务超时/多路竞争等待、周期与 Channel 流、异步内存序列遍历;有界 HTTP 连接池、域名、流式请求/响应、HTTPS/WSS 客户端和 TLS 服务端。保留 N3 SSE/WS 会话、取消和背压契约。HTTP/2、数据库、协议扩展和分发工具不属于本轮。
|
||||
|
||||
任务、连接及正文队列有界;取消和关闭回收所属工作,已开始的阻塞工作必须排空。复用 Tokio、Hyper、tungstenite 和 Rustls,不维护兼容接口或自写协议栈。只运行定向编译/测试及自有 loopback 随机端口,不测旧性能基线,不启动现有服务,不提交。
|
||||
|
||||
以上范围已接通 checked source、原生编译和运行时。83 项定向用例、实际 CLI check/fmt/build 与无 Rust 工具链环境中的独立后端示例通过;具体证据见 implement.md 的 N4 Evidence,公开用法以 LANGUAGE.md 为准。
|
||||
|
||||
## 已完成阶段 N3:流式响应、SSE 与 WebSocket(2026-09-12)
|
||||
|
||||
用户已授权“做下一阶段”,沿用新语言直接演进和低内存运行目标。保留普通 HTTP 的直接 handler 路径,增加 `serve_live` 和 HttpReply:同端口处理完整响应、逐块正文、SSE 和 WebSocket。HTTP 请求仍有界缓冲;响应通道只有 1 个槽位,长连接配置与普通请求期限分开。
|
||||
|
||||
WebSocket 服务端/客户端复用 tokio-tungstenite 0.30.0(handshake feature),提供 Text/Binary/Ping/Pong、AsyncStream 消息消费和关闭握手。SSE 提供字段编码、Last-Event-ID 访问和空闲注释心跳,历史重放归应用所有。HttpReply/WebSocket 进入编译器资源、类型、effects、API、Markdown 和 native 契约;不引入新语法、旧 API 兼容或自写 WebSocket 协议栈。
|
||||
|
||||
连接、handler 及升级后会话共用 Scope/Group 上限与取消排空;等待写锁时取消不能破坏另一任务的帧。仅执行根 test/ 下的定向编译器/运行时验证和自有回环端口,不测试旧性能基线、常驻示例或已有服务。TLS/WSS、HTTP/2、客户端池、子协议/压缩扩展、关闭帧元数据和数据库不属于 N3。
|
||||
|
||||
## 前一阶段 N2:HTTP 引擎(2026-09-12,已完成)
|
||||
|
||||
用户在 N1 完成后批准继续。采用 Hyper 1.x HTTP/1 引擎、hyper-util TokioIo/Timer 和共享 bytes 缓冲区;不额外引入路由框架。此阶段取代历史“HTTP 全部由 Dever 自写”的实现决定。官方类型与静态业务 handler 仍由 Dever 定义,不保留旧协议 API 或备用解析器。
|
||||
|
||||
- `serve(async handler, Listener, Limits) -> ()`:持久连接、流水线、chunked 输入,复用现有有界任务组、背压、超时和取消清理。accept/配置/程序故障沿任务传播,对端协议错误只结束对应连接。
|
||||
- `send(address, port, Request, Limits) -> ResponseResult`:单次请求,无客户端池和重试,driver 与请求同生命周期。
|
||||
- Header 列表保留重复头和字节值;消息体在显式上限内缓冲,单帧共享,多帧增量合并。
|
||||
- 直接迁移示例、检查器、native bridge 和测试。只用离线定向检查、自有 loopback 随机端口,不测旧基线、不跑全量或现有服务。
|
||||
- TLS、HTTP/2、流式响应、客户端池、非空 trailer、WebSocket/SSE 留待后续;N3 优先处理长连接协议。
|
||||
|
||||
## 前一阶段 N1:异步 Stream 与 TCP(2026-09-12,已完成)
|
||||
|
||||
用户批准继续,并明确这是全新的语言:无需旧源码兼容,不增加兼容别名、回退实现或推测性的防御分支。仓库调用方直接迁移。此决定取代下文历史阶段中的“网络暂停”和“兼容基线”要求。
|
||||
|
||||
- 增加 `AsyncStream<Item>`,支持在 async function 中按需 `each`、`reduce`、`reduce_until`,以及有界 `parallel_each`;handler 可为 async,关闭/提前结束释放生产端。
|
||||
- `dever.net` 直接采用 Tokio 异步 TCP;connect、listen、accept、read、write 为 async。资源名称仍为 Socket、Listener;无 sync/aio 两套 API。
|
||||
- 连接流和字节流不预取;并发消费先等待容量再拉取。复用现有结构化 Task/Group,停止后等待子任务清理,连接支持同时读写。
|
||||
- 保留协议必须的输入约束、失败输出、超时、关闭和取消语义;写入中断不得自动重发已写字节。
|
||||
- 现有 HTTP 传输调用链随 TCP 改为 async;成熟 HTTP 引擎接入属于 N2,WebSocket/SSE 属于 N3,本轮不实施。
|
||||
- 只做根 test/ 下定向检查及自己创建的 loopback 随机端口验证;不测旧性能基线,不运行全量测试、业务服务、全量 build 或全局安装。
|
||||
|
||||
## 前一阶段:并发基础(2026-09-12,已完成)
|
||||
|
||||
前一阶段先完成同步、异步和多线程(含协程/任务)基础,网络当时暂停;当前 N1 恢复异步 Stream/TCP。下述并发契约已经进入编译器前端、HIR、原生后端和 runtime;最终状态以本任务的定向验证和 `LANGUAGE.md` 公开契约为准。不测旧实现性能基线。
|
||||
|
||||
### 暂存的网络方案
|
||||
|
||||
- 后续需要 TCP、HTTP、WebSocket、SSE 长连接,复用成熟 Rust 库。
|
||||
- 候选栈:Tokio 统一网络调度,Hyper/hyper-util 处理 HTTP,Axum 复用服务路由、升级和 SSE,WebSocket 复用其 tungstenite 生态,bytes 管理共享缓冲区。实施前核对版本、工具链和 feature 兼容性。
|
||||
- 共用连接限额、有界队列、背压、超时、取消与关闭。长连接保活独立于普通请求超时;SSE 事件历史保存/重放属于应用责任。
|
||||
- 不在每次 I/O 上 block_on,也不把同步阻塞 handler 直接放入异步调度线程。HTTPS/WSS、HTTP/2 的首轮范围尚未确定。
|
||||
- 取消旧性能基线工作,不取消新能力必要的定向正确性验证;不启动业务服务或压测。
|
||||
|
||||
### 并发语言契约(已批准实施)
|
||||
|
||||
#### Goal
|
||||
|
||||
增加可明确推理的同步、异步协程和多线程能力,为 TCP、HTTP、WebSocket 与 SSE 提供统一运行基础。小机器只运行打包后的二进制;空闲任务不占用一个系统线程,任务、线程和队列都有明确上限。契约可以直接演进,维护值/资源语义的清晰性,不承担旧源码兼容义务。
|
||||
|
||||
#### In Scope
|
||||
|
||||
- 普通 function 保持同步;`async` 声明异步 function,`await` 等待异步调用或已启动任务。
|
||||
- `run` 启动异步调用并返回受限 Task;`stop` 采用“请求协作取消并等待清理完成”的安全语义,而不是仅发送信号。
|
||||
- 每个 async function 是默认结构化任务域;动态数量的零输出任务可由有界 `group` 统一拥有、等待和停止。
|
||||
- `parallel` 将 pure 同步计算交给有界多线程执行;`blocking` 供明确的阻塞边界使用。`parallel_each` 按序列类型复用相应的有界执行器。
|
||||
- 有界 `channel` 以及 `send`、`receive`、`close` 用于任务通信;禁止无界队列。
|
||||
- async 静态 handler、跨线程可传递性、任务消费、effects/failures、API 快照、Markdown 契约、格式化和原生代码生成均由编译器检查。
|
||||
- 运行时复用 Tokio 的协程与多线程调度;整个程序最多建立一个异步运行时,CPU/阻塞任务使用受限执行通道。
|
||||
|
||||
#### Out of Scope
|
||||
|
||||
- 本阶段不实现 TCP、HTTP、WebSocket、SSE、TLS 或数据库;网络方案按上节暂存。
|
||||
- 不加入裸线程、共享可变全局对象、用户锁、分离后无人管理的后台任务、无界 channel、强杀线程或副作用回滚。
|
||||
- 不同时加入 lambda、闭包、一等 function、用户泛型、用户递归、循环块或异常机制。
|
||||
- 同步 `Stream<Item>` 保持当前阻塞拉取契约;异步流在网络恢复前单独设计,不做隐式转换。
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. 完全同步的入口不启动 Tokio runtime;接口发生变化时直接迁移仓库源码。
|
||||
2. async function 和 async 静态 handler 的调用都必须直接位于 `await(...)` 或 `run(...)` 上下文;同步 function 中的异步调用在原生构建前拒绝。
|
||||
3. `await(call(...))` 顺序执行并保持原函数的命名输出;`task = run(call(...))` 先启动,`await(task)` 恰好消费一次任务并取得相同输出。
|
||||
4. Task 不能复制、存入 record/List/Map/Stream、作为普通参数/输出逃逸、重复等待或在 function 返回时仍未消费;必须 `await` 或 `stop`。
|
||||
5. `stop(...)` 第一阶段只接受零输出 Task 或 Group,协作取消并等待清理;已发生的副作用不回滚,长时间同步计算不承诺立即停止。
|
||||
6. `group(limit)` 的 limit 必须为正且有实现上限;只拥有零输出异步任务。容量已满时 `run(group, call(...))` 施加背压,不无限排队;`await(group)` 或 `stop(group)` 消费该 Group。
|
||||
7. `parallel(call(...))` 只接受 pure 同步 function,参数和输出必须可跨线程传递;`blocking(call(...))` 只接受编译器传递 effect 分析确认包含阻塞系统边界的同步调用。二者第一阶段只能用于 async function,并使用明确的有界容量;同步代码继续使用现有 `parallel_each`。
|
||||
8. `channel(Type, capacity)` 容量必须为正且有实现上限;send 在满时等待,receive 在空时等待并返回 `Item?`(值表示 Item,`null` 表示 Closed),close 唤醒等待方;调度器或 Channel 内部故障作为带源码位置的 fatal error 传播。
|
||||
9. async handler 的同步/异步属性是签名的一部分;所有同签名分句必须一致,专门化和递归检查不能因异步边界失效。
|
||||
10. 任务启动、等待、停止、channel、时间等待和 blocking 均进入传递 effect 分析;pure function 不能隐藏这些效果,失败义务不能因任务边界被吞掉。
|
||||
11. 异步入口只创建一次运行时;多个挂起任务不按任务创建线程。工作线程、阻塞线程、group、channel 与待处理任务均有保守默认上限及显式配置边界。
|
||||
12. 仅运行根 `test/` 下针对新增语法、检查、原生执行、取消/关闭和有界调度的离线定向测试;不跑全量测试、服务测试、旧实现基线或网络压测。
|
||||
|
||||
#### Key Naming Decisions
|
||||
|
||||
- 保留 `async`、`await`、`parallel`、`blocking`、`channel`、`send`、`receive`、`close`。
|
||||
- 使用 `run` 代替 spawn,使用 `stop` 代替 cancel,使用 `group` 表示动态任务所有者;不再增加 join,统一由 `await` 等待。任务操作沿用函数式调用外观,不提供第二套前缀语法。
|
||||
- 协程是 async Task 的运行时实现,不增加 coroutine 关键字。
|
||||
|
||||
## Markdown Source Follow-through (2026-09-09)
|
||||
|
||||
用户批准保留 `.dever`,新增 `.dever.md` 源码容器,并进一步要求编译器强制说明与代码一致。顶层 `dever` 或 `typescript dever` 围栏内写正常 Dever 声明,共用原有语法、契约、API 和原生路径。一个 H1 说明 package;每个 H2 只说明一个 type 或一个 `(函数名, 参数数量)` 及其全部分句。编译器核对 package 名、公开类型/方法/使用方式、类型字段/分支、函数输入输出的名称、类型、数量和顺序,空项显式写 `无`。`typescript dever` 的首个标记供编辑器尝试 TypeScript 语法高亮。跨格式同包重复拒绝;格式化保留说明,诊断定位原文并关联代码。此决定替代下方历史阶段“只加载 `.dever` 文件”及“标题说明无约束”的范围限制,不开放其它语言或 HIR/JSON 作为源码。正式写法见 `LANGUAGE.md` 的 Markdown 小节。
|
||||
|
||||
## Approved Performance Follow-through (2026-09-08)
|
||||
|
||||
用户批准实施本轮性能方案。保持现有语法、值/资源语义、错误位置、数值顺序和编译器契约。交付类型属性缓存、依赖驱动契约分析、记录更新复制消除、独占列表消费、直接消费字符列表的遍历优化、HTTP 增量扫描、标准库及原生产物缓存。循环融合必须证明副作用和失败顺序等价;并发分批以有限测量为依据,不要求线程池。补编译/运行/内存定向基准并记录优化前后证据。只运行离线定向测试、自有临时文件/进程和内存协议输入,不运行全量或服务压测,不提交或安装全局命令。
|
||||
|
||||
## Current Follow-through (2026-09-07)
|
||||
|
||||
用户在剩余项盘点后要求“实施剩下的”。继续完成已确定语言的工具、标准库和验证工作,并推进后续运行能力;既有语法、分句、静态 handler、值/资源语义保持兼容。下方历史里程碑的暂缓项不阻止本次明确要求的工作。
|
||||
|
||||
- 提供 `.dever` 的规范格式化及 CLI `fmt`/只读检查,保留注释、表达式含义和求值顺序;任一输入语法错误时不改写文件。
|
||||
- 补常用 Text、数值解析/格式化、进程参数/环境和时钟接口。公开接口与组合逻辑仍由普通 `.dever` package 定义。
|
||||
- 实现仅用于验证的 HIR 基准解释器,覆盖已实现核心语义并与原生结果、故障位置对照;生产 build/run 始终使用原生编译。
|
||||
- 实现保持值语义的最后使用优化,提供可复现、有限工作量的同语义性能基线,不对现有服务压测。
|
||||
- 继续推进 JSON、HTTP 与受控并发等官方包所需能力;协议与应用逻辑使用 Dever 基础语法,系统边界保持最小。
|
||||
- 数据库引擎此前未定,已向用户异步询问;这一选择不阻塞其余工作。
|
||||
- 编译器发行、跨平台沿用此前暂缓安排;自举仍是尚未交付的长期项。本次不安装或替换全局命令,不提交,不访问既有服务。
|
||||
|
||||
验收按每项真实完成的行为和定向证据记录,不将已完成的基础语法测试当作新增工具和库的验收。
|
||||
|
||||
## Goal
|
||||
|
||||
当前目标是实现已经确定的基础语言,使官方 package 与应用能够用同一套 `.dever` 语法编写。2026-09-07 用户批准实现基础类型、表达式、函数输出、分句、赋值和值语义、集合与处理函数、统一 package 编译及最小系统接口。原生 Hello World 里程碑已于 2026-09-06 完成,作为回归基线。
|
||||
|
||||
长期目标仍是改善 AI 开发中的硬编码、重复实现、无依据兜底和大项目上下文问题。表面语法保留 package、type、function、分句和值语义;用户所说的“用户类”用已有 `type User` 表达,不引入 class 或继承。Rust 等引导技术不进入用户源码或语言兼容契约。
|
||||
|
||||
基础阶段曾将 HTTP、JSON 和性能验证后移;当前 Follow-through 已推进这些能力。编译器发行与跨平台仍暂缓,数据库引擎未定;用户 CRUD 示例不是基础语言的额外交付。保留原生执行方向和既有表面语法,不增加契约 DSL。
|
||||
|
||||
## Approved Core Implementation
|
||||
|
||||
- 沿用既定条件分句、线性 body 和集合函数;不再把循环语法作为待确认项,不增加条件块、循环块或用户递归。
|
||||
- 每项语言能力贯通源码检查、typed HIR 与原生执行;不能以 AST 已解析代替支持。
|
||||
- 官方 `.dever` package 与本地 package 共用语言规则。官方命名空间由编译器随附源码提供,本地源码不能冒充官方来源。
|
||||
- 系统接口提供 Bytes、opaque 文件/TCP 资源、读写和显式关闭;普通值保持值语义,资源别名标识同一外部资源,关闭后操作得到显式失败。
|
||||
- 可恢复系统错误以官方源码声明的选择型结果返回;程序故障保持源码定位。系统原语不包含 HTTP 路由或应用业务。
|
||||
- 验收覆盖完整语言样例、负例诊断、分句顺序变化、集合顺序与值语义,以及隔离临时文件和本地回环连接。使用必要的最小定向检查,不运行全量测试、既有服务或压测。
|
||||
|
||||
## Approved Foundation Extension
|
||||
|
||||
Status: implemented and verified on 2026-09-07.
|
||||
|
||||
### Goal And Scope
|
||||
|
||||
补齐官方 `.dever` package 编写协议解析、持续 I/O 组合和宿主回调所需的最小表达能力,同时保留分句条件、线性 body、无循环 block 和无用户递归的长期规则。本阶段一次完成 `reduce`、`reduce_until`、Bytes 遍历、`Stream<Item>`、受限静态 handler 参数,以及文件/TCP 的流式包装;不在 runtime 中加入 HTTP 或业务特例。
|
||||
|
||||
### Sequence Operations
|
||||
|
||||
- 带状态遍历对外名称固定为 `reduce`;此前讨论中的 `fold` 和 `accumulate` 名称作废。
|
||||
- `reduce(handler, sequence, initial_state)` 固定从左到右执行,始终显式提供初始状态;空序列直接返回初始状态。
|
||||
- handler 依次接收当前元素和上一步状态,必须输出一个与状态类型相同的下一步状态:
|
||||
|
||||
```dever
|
||||
add(value: Int, total: Int) (next_total: Int) {
|
||||
next_total = total + value
|
||||
}
|
||||
|
||||
total(values: List<Int>) (result: Int) {
|
||||
result = reduce(add, values, 0)
|
||||
}
|
||||
```
|
||||
|
||||
- `reduce_until(handler, sequence, initial_state)` 使用相同顺序,但 handler 必须有两个输出:第一项是下一步状态,第二项必须命名为 `stop` 且类型为 Bool。处理完当前元素后,`stop` 为 true 就立即返回,不再请求下一个元素。
|
||||
- `reduce` 和 `reduce_until` 不提供额外 context 参数;共享配置应组合进状态,避免同一调用同时维护两套跨元素数据。
|
||||
- sequence 只接受 `List<Item>`、Bytes 或 `Stream<Item>`。Bytes 的元素静态类型为 Int,运行时值保证在 0 至 255,不先复制为 `List<Int>`。Text 如需按 UTF-8 遍历,必须显式使用 `dever.bytes.from_text`。
|
||||
- `each(List/Bytes)` 保留零输出动作和单输出 List 映射;`each(Stream)` 只接受零输出 handler,不把未知长度或无限输出隐式收集到内存。Stream 的最终状态由 `reduce`/`reduce_until` 返回。
|
||||
- `filter`、`find`、`sum` 本阶段仍只处理 List;不增加 Stream 到 Stream 的惰性 map/filter 管道。
|
||||
|
||||
### Static Handler Inputs
|
||||
|
||||
function 输入可以声明受限 handler 签名:
|
||||
|
||||
```dever
|
||||
serve(
|
||||
route: handler(request: Request) (response: Response),
|
||||
config: Config
|
||||
) (result: ServeResult) {
|
||||
result = serve_with(route, config)
|
||||
}
|
||||
```
|
||||
|
||||
- handler 类型只允许出现在 function 输入中;输入按位置匹配类型,命名输出同时匹配名称、顺序和类型。
|
||||
- 调用方只能传入签名匹配的具名 function,或转发当前 function 的 handler 参数。
|
||||
- handler 参数可以直接调用,也可以传给 `each`、`reduce`、`reduce_until` 或另一个 handler 输入;不能参与分句匹配,不能赋给局部值、存入 record/List/Map/Stream、比较或作为输出返回。
|
||||
- handler 不是运行时值。typed HIR 保留静态绑定,AOT 以 function 和实际 handler 绑定组成专门化实例,生成直接调用;不产生函数指针、闭包、虚调用或反射注册表。
|
||||
- 递归检查覆盖 handler 调用与转发后的专门化调用图,不能通过 handler 绕过无递归规则。
|
||||
|
||||
### Stream And Resource Contract
|
||||
|
||||
- `Stream<Item>` 是编译器提供的唯一新增标准泛型类型,用户仍不能声明泛型 type 或泛型 function。
|
||||
- Stream 是拉取式、顺序、一次消费的 opaque 资源;复制 Stream 只产生同一资源的别名,所有别名共享读取位置、结束状态和显式关闭状态。Stream 不可比较,也不能成为 Map key。
|
||||
- `reduce_until` 提前停止时 Stream 保持可用,调用方可从下一个未消费元素继续处理。正常耗尽后继续消费只得到结束,不会重新执行生产者。
|
||||
- 正常 EOF 使用 Stream 自然结束表示,不作为普通元素。可恢复 I/O 错误必须作为官方 choice 的 `Failed(message: Text)` 元素交给 handler;终止性错误产生一个 Failed 后结束 Stream,不能转换成默认值或程序故障。
|
||||
- 流式读取使用 `dever.io.ReadEvent`,其分支固定为 `Chunk(bytes: Bytes)` 和 `Failed(message: Text)`;现有单次 `read` 继续返回 `ReadResult.Read/End/Failed`,两种接口不混用结束语义。
|
||||
- 流创建结果固定为 `dever.io.ReadStreamResult`,其分支为 `Streaming(stream: Stream<ReadEvent>)` 和 `Failed(message: Text)`。`dever.io.chunks(file, limit)` 与 `dever.net.chunks(socket, limit)` 都返回该类型;非正数、无法表示的 limit 或创建时已经关闭的源立即返回 Failed。
|
||||
- `dever.net.connections(listener)` 直接产生 `Stream<ConnectResult>`;创建后关闭 listener 或终止性 accept 错误产生一个 `ConnectResult.Failed` 后结束。
|
||||
- 核心操作 `close(stream)` 没有输出并且幂等。它停止该 Stream 及其别名并释放 Stream 持有的底层资源引用,但不主动关闭调用方仍单独持有的 File、Socket 或 Listener;关闭后的 Stream 与正常耗尽一样不再产生元素。调用方先关闭底层资源时,依赖它的未关闭 Stream 产生一个 Failed 后结束;最后一个资源别名离开作用域时仍由原生资源析构兜底。
|
||||
- 首阶段只有阻塞、顺序执行;Socket 延续显式 timeout。没有预取、后台线程、async、await、并发消费或共享可变业务全局状态。
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
1. List 和 Bytes 的 `reduce` 按源顺序传递状态,空序列返回初始状态;Bytes 不经过中间 List。
|
||||
2. `reduce_until` 在 stop 元素之后不调用 handler 或拉取 Stream;同一 Stream 随后从未消费位置继续。
|
||||
3. `each(Stream)` 接受零输出 handler,拒绝单输出或多输出 handler;List/Bytes 的既有映射和动作语义不回归。
|
||||
4. handler 输入支持具名 function、跨 package function 和多层静态转发;签名不匹配、存储、返回、比较和 handler 间接递归均在原生构建前拒绝。
|
||||
5. 生成程序中的 handler 是专门化直接调用;不引入通用 runtime Value、运行时函数指针或逐元素虚拟 handler 分派。
|
||||
6. Stream 别名共享游标、结束和关闭状态;自然 EOF、提前停止、幂等 `close`、底层关闭与终止性错误均有确定测试。
|
||||
7. 官方 `dever.io`/`dever.net` 流式接口作为普通 `.dever` 源码经过同一 parser、resolver、checker 和 HIR;runtime 只拥有系统读取和 Stream 拉取原语。
|
||||
8. 文件验证只使用测试自有临时目录,TCP 验证只使用 `127.0.0.1` 随机端口、有限数据和超时;不连接、启动或修改现有服务。
|
||||
9. 实际 `check`、`run`、`build` 与独立原生程序覆盖一个分块状态解析样例,证明结果来自 `.dever` handler 和状态,而非编译器特判。
|
||||
|
||||
### Out Of Scope
|
||||
|
||||
- HTTP、JSON、CRUD、数据库、并发服务器和异步语法;本阶段交付的是这些 package 可复用的底层表达能力。
|
||||
- 一般化用户泛型、lambda、闭包、一等 function、Stream 惰性转换、循环 block、用户递归和异常机制。
|
||||
- formatter、语义基准解释器、性能门槛、自举、package 分发、跨平台构建和全局命令迁移。
|
||||
|
||||
### Known Risks And Controls
|
||||
|
||||
- 静态 handler 转发可能产生重复原生实例;专门化键按 function 与完整 handler 绑定去重,并且只生成入口可达实例,不退化为运行时分派。
|
||||
- Stream 的内部生产者调用有固定边界成本;文件和 Socket 以 Bytes chunk 为单位拉取,不按单字节跨越动态边界,有限 List/Bytes 热路径仍是直接循环。
|
||||
- 首阶段的阻塞 Stream 不能承担并发服务器;这是明确范围限制,不能在 HTTP package 中用隐藏线程或全局队列规避。
|
||||
- 把 I/O 失败作为最后一个元素要求 handler 穷尽处理官方 choice;类型检查和终止状态测试共同防止错误被静默丢弃或无限重复。
|
||||
|
||||
## Completed Hello Milestone
|
||||
|
||||
- 示例位于 `examples/dever/hello/app/user.dever`,与 `package app.user` 路径一致。
|
||||
- 支持 Text 字面量、Text 参数、局部赋值、普通零输出函数调用和公开零输入入口。
|
||||
- 内置标准签名为 `dever.io.println(text: Text) ()`,输出 UTF-8 文本和一个换行;写入失败报告 Dever 调用位置并以非零状态退出。
|
||||
- 修改文本、调用其他自定义函数、跨包调用公开函数都必须由实际源码驱动;不能特判示例名称或内容。
|
||||
- 名称、参数、可见性、包路径和重复定义检查在原生编译前完成;递归及包依赖循环按既有契约拒绝。
|
||||
- 未实现的类型、表达式、分句和输出签名明确报不支持,不静默生成近似代码。
|
||||
- 提供本地 `check <source-root>`、`run <source-root> <package.function>`、`build <source-root> <package.function> --output <path>`;不替换全局命令。
|
||||
- 验收真实 stdout、错误定位、原生可执行文件和输出失败;不启动任何服务或执行全量测试。
|
||||
|
||||
## Deferred User API Proposal
|
||||
|
||||
以下保留之前的 API 讨论作为后续参考;其中持久化、并发与性能要求尚未重新确认,不属于当前验收或实施前置条件。
|
||||
|
||||
### Confirmed Requirements
|
||||
|
||||
- 应用业务使用 Dever 源码(`.dever` 或 `.dever.md` 的程序块);不能手写 Rust 用户 CRUD 再把 Dever 当作装饰。
|
||||
- 用户模型只有 ID、姓名、手机号,CRUD 和 HTTP JSON 交互必须实际可用。
|
||||
- 保留已经讨论的表面语法;只增加实际应用所需的标准 package 能力和必要语义。
|
||||
- 编译器与通用运行时不能特判 `User`、`name`、`phone` 或 `/users`;这些信息来自应用源码。修改业务类型和路由不能要求修改编译器。
|
||||
- 数据需要真实持久化;重启后仍可查询,不能用进程内 Map 冒充实际存储。
|
||||
- 性能验证覆盖真实读写、序列化和持久化,不用空接口成绩替代 CRUD 性能。
|
||||
|
||||
业务模型沿用现有语法:
|
||||
|
||||
```dever
|
||||
type User {
|
||||
id: Id
|
||||
name: Text
|
||||
phone: Text
|
||||
}
|
||||
```
|
||||
|
||||
### Proposed API Acceptance
|
||||
|
||||
以下为待最终收敛的最小 API 行为,不增加登录、管理页面或其它业务模块:
|
||||
|
||||
| Request | Behavior |
|
||||
| --- | --- |
|
||||
| `POST /users` | 提交姓名和手机号;服务端生成 ID,返回创建的用户 |
|
||||
| `GET /users/{id}` | 查询单个用户;不存在时返回明确的未找到结果 |
|
||||
| `GET /users` | 按稳定顺序、有上限地分页查询 |
|
||||
| `PUT /users/{id}` | 完整更新姓名和手机号,ID 不可变 |
|
||||
| `DELETE /users/{id}` | 删除指定用户,删除后无法查询到 |
|
||||
|
||||
- `name`、`phone` 使用 Text;不把手机号当数值,也不擅自添加特定国家的手机号规则或手机号唯一约束。
|
||||
- 缺少字段、非法 JSON、无效 ID、未知用户和存储失败需要明确响应;不能静默填值或伪装成功。
|
||||
- 并发访问不能产生重复 ID、半写入或未说明的丢失更新;具体并发写入契约随存储设计确定。
|
||||
- 吞吐、p50/p95/p99 延迟、错误率和资源使用需要在固定数据量、读写比例、并发和持久化配置下报告,并与同环境、同功能的原生基线比较。数值性能门槛仍待收敛,现有计算内核的 1.15 倍预算不能直接替代 API 指标。
|
||||
|
||||
### Open Decisions
|
||||
|
||||
- 后续 API 先允许内存测试数据;需要持久化时再讨论数据库、并发更新和性能预算。
|
||||
|
||||
### Deferred From This Milestone
|
||||
|
||||
- 契约 DSL、AI 生成器、全面重复语义检测和完整的大项目上下文工具。
|
||||
- 登录/RBAC、前端页面、多租户、分布式部署及其它没有被请求的用户业务。
|
||||
- 与用户 API 无关的完整数值标准库、全部集合组合、自举和组件分发系统;既有语言契约作为后续路线保留。
|
||||
|
||||
## Language Roadmap
|
||||
|
||||
后续章节保留原 Minimal Core 的语言契约,作为本轮定义讨论的基础。已确认的长期规则与首版能力限制分别标明;讨论中的功能不代表已经实现,编译器仍须明确拒绝尚未支持的源码。
|
||||
|
||||
## Repository Evidence
|
||||
|
||||
- 仓库当前没有已发布的 `.dever` parser、formatter 或兼容格式。
|
||||
- 2026-09-05 开工检查确认:当前仓库只有规划资料,没有 Cargo workspace、Rust 源码或测试。HEAD 也只包含两份旧规划文档。
|
||||
- 新语言没有兼容旧语义图、旧 CLI、旧生成结果或旧测试的义务。旧规划由 Git 文档历史保留,不在新实现中增加迁移层。
|
||||
- 从零建立 Rust workspace;复用源码位置诊断思路和仓库根目录 `test/` 约束。旧领域模型和旧后端契约不能决定新语法。
|
||||
|
||||
## Core Language Roadmap Scope
|
||||
|
||||
1. `.dever` 源文件、UTF-8 文本、注释、literal 和唯一 formatter。
|
||||
2. package 路径、公开名称、完整限定名和源码根目录内的自动依赖解析。
|
||||
3. 被动 type、字段型值、选择型值、可空值、List、Map、Bytes 和 opaque Stream 资源。
|
||||
4. 显式 function 边界、线性 body、局部类型推断和值语义修改。
|
||||
5. 通过同名 function 分句表达的穷尽、互斥条件规则。
|
||||
6. 算术、比较、布尔和文本连接表达式。
|
||||
7. `each`、`reduce`、`reduce_until`、`filter`、`find`、`sum` 以及最小 List/Map 标准函数。
|
||||
8. Int、Decimal、Float 的确定数值语义和最小 `math`/`decimal` 标准 package。
|
||||
9. parser、formatter、resolver、type checker、clause analyzer、语义基准解释器、原生 AOT 编译和 CLI。
|
||||
10. Rust 编写的首代引导编译器,以及不依赖 Rust 表面语法、可被未来 Dever 自举编译器复用的 typed HIR 和运行语义。
|
||||
|
||||
## Language Contract
|
||||
|
||||
### 1. Source And Package
|
||||
|
||||
- 源文件后缀固定为 `.dever`,编码为 UTF-8。
|
||||
- 代码标识符只使用 ASCII 英文;注释、字符串和文档可以使用任意 UTF-8。
|
||||
- `#` 开始单行注释;Core 0.1 不提供块注释。`//` 只表示整数除法,不具有第二种词法含义。
|
||||
- 语句和声明按换行或结构边界分隔,不写分号。
|
||||
- 一个文件完整拥有一个 package;同一 package 不能拆到多个文件。
|
||||
- 源文件相对 `src/` 的路径必须与 package 点分名称一致。
|
||||
|
||||
```text
|
||||
src/account/user.dever -> package account.user
|
||||
```
|
||||
|
||||
文件第一项必须是 package 声明:
|
||||
|
||||
```dever
|
||||
package account.user exposes (
|
||||
User
|
||||
create
|
||||
)
|
||||
```
|
||||
|
||||
`exposes` 只能列出当前 package 已声明的 type 或 function。未列出的名称只在当前 package 可见;公开 function 名称会公开该名称的全部签名和分句。
|
||||
|
||||
同 package 引用使用短名称,跨 package 引用必须使用完整限定名:
|
||||
|
||||
```dever
|
||||
normalized = normalize_email(email)
|
||||
user = account.user.create(id, name, email)
|
||||
```
|
||||
|
||||
Core 0.1 不提供 `import`、`use`、通配导入或别名。编译器从完整限定引用建立依赖图,并拒绝 package 循环依赖。`app`、`domain`、`feature`、`flow`、`policy`、`view` 和 `scenario` 都不是关键字,需要时只是普通 package 路径段。
|
||||
|
||||
#### Package Origins And Distributed Components
|
||||
|
||||
语言层只有 package,不新增 `component` 关键字。package 是源码和 API 命名空间;“组件”只是 registry 中用于安装、签名和版本管理的发布单元,可以包含一个根 package 及若干子 package。组件不能绕过各 package 的 `exposes` 边界。
|
||||
|
||||
package 来源固定分为三类:
|
||||
|
||||
| 来源 | 名称规则 | 示例 | 获取方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| 核心标准 package | 编译器保留的单段名称 | `text`、`math`、`decimal` | 随编译器提供 |
|
||||
| Dever 官方组件 | `dever.<component>` 根路径 | `dever.http`、`dever.sql` | 显式安装 |
|
||||
| 公共第三方组件 | `<publisher>.<component>` 根路径 | `stripe.payment`、`shemic.robot` | 显式安装 |
|
||||
|
||||
本地业务 package 继续使用项目内路径,例如 `account.user`、`order.checkout`。本地源码不能声明核心标准名称或 `dever.*` 官方命名空间。同一个构建中如果本地源码、标准库或未来已安装组件提供了相同 package 名称,编译器直接报告重复定义,不进行覆盖或静默择优。
|
||||
|
||||
调用形式对所有来源一致,版本不会进入业务源码:
|
||||
|
||||
```dever
|
||||
user = account.user.create(input)
|
||||
name = text.trim(raw_name)
|
||||
response = dever.http.send(request)
|
||||
payment = stripe.payment.create(order)
|
||||
```
|
||||
|
||||
从 0.2 起,组件的稳定身份与下载来源相互独立。`shemic.contact` 是进入 package 名称和类型身份的 component id;GitHub、其他 Git 服务或 Dever registry 只是可以替换的 source。仓库迁移不会迫使业务源码改名。
|
||||
|
||||
公共组件必须通过命令显式加入,不能因为遇到未知名称就在 `check` 或 `build` 时自动联网:
|
||||
|
||||
```text
|
||||
dever add dever.http@1
|
||||
dever add stripe.payment@3
|
||||
dever add shemic.contact --git https://github.com/shemic/contact.git --tag v1.2.0
|
||||
dever add shemic.contact --git https://github.com/shemic/contact.git --rev 8f34c2a
|
||||
dever update stripe.payment
|
||||
dever fetch
|
||||
```
|
||||
|
||||
Git source 必须同时给出 tag 或 revision,不接受未固定的默认分支 HEAD。tag 在加入时解析为完整 commit;`dever.lock` 同时记录 source URL、完整 commit 和规范化源码内容的 SHA-256,后续 tag 被移动也不会改变构建。组件内全部 package 必须位于 component id 根路径下,例如 `shemic.contact` 可以提供 `shemic.contact.phone`,不能同时冒充 `account.user`。
|
||||
|
||||
0.2 的项目根目录增加两个由 CLI 维护、需要提交的元数据文件:
|
||||
|
||||
```text
|
||||
dever.mod # component id、直接依赖及其 registry/git/path 来源要求
|
||||
dever.lock # 完整依赖图、精确版本/commit、内容摘要和实际提供的 package
|
||||
```
|
||||
|
||||
开发者仍只手写 `.dever`;`dever add`、`dever update` 和未来的本地 link 命令负责规范化更新元数据。分开保存意图和精确结果,使直接依赖保持可读,同时保证所有机器解析出同一份源码。
|
||||
|
||||
下载源码不复制到每个项目。Dever 使用平台级、可由 `DEVER_HOME` 改址的全局只读内容寻址仓库:
|
||||
|
||||
```text
|
||||
$DEVER_HOME/
|
||||
git/<source-url-hash>/ # Git bare mirror,只负责取对象
|
||||
store/sha256/<content-hash>/ # 已校验、不可修改的组件源码
|
||||
build/<target>/<artifact-hash>/ # 可删除的编译缓存
|
||||
```
|
||||
|
||||
项目目录只保留 `src/`、`dever.mod`、`dever.lock` 和可删除的 `target/dever/` 构建结果。多个项目依赖相同 commit 时共享同一份 store 内容。`dever add`、`dever update`、`dever fetch` 是仅有的默认联网入口;普通 `check`、`build` 和 `run` 只读取本地源码、内置标准 package、lock 与 store,缺失时明确要求执行 `dever fetch`,不隐式下载。
|
||||
|
||||
Core 0.1 只实现本地 package 与随编译器提供的核心标准 package,同时锁定上述命名和解析边界。registry、`dever add`、签名验证和外部组件缓存确定延后到 0.2,不影响现有 `.dever` 调用形式。
|
||||
|
||||
### 2. Canonical Naming
|
||||
|
||||
| 对象 | 唯一格式 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| package 路径段 | `lower_snake_case`,优先单数领域名 | `account.user`、`sales_order` |
|
||||
| 文件名 | `lower_snake_case` | `user.dever` |
|
||||
| type | `UpperCamelCase` | `User`、`CreateResult` |
|
||||
| 选择项 | `UpperCamelCase` | `Pending`、`Created` |
|
||||
| function | `lower_snake_case` | `create`、`can_sign_in` |
|
||||
| 输入、输出、局部值、字段 | `lower_snake_case` | `user_id`、`created_at` |
|
||||
| 标准泛型参数 | 有含义的 `UpperCamelCase` | `Item`、`Key`、`Value` |
|
||||
|
||||
大小写不控制公开性。缩写按普通单词处理:`Id`、`Url`、`HttpClient`、`user_id`、`callback_url`。编译器强制可机械判断的大小写、ASCII 和路径格式;“是否是好名称”只产生规范诊断,不依赖英语词典阻止编译。
|
||||
|
||||
### 3. Types And Literals
|
||||
|
||||
Core 0.1 的基础标量只有:
|
||||
|
||||
| 类型 | 语义 |
|
||||
| --- | --- |
|
||||
| `Bool` | `true` 或 `false` |
|
||||
| `Int` | 有符号 64 位整数,范围 `-9223372036854775808` 至 `9223372036854775807` |
|
||||
| `Decimal` | decimal128,最多 34 位有效十进制数字,round half to even |
|
||||
| `Float` | IEEE 754 binary64 近似数 |
|
||||
| `Text` | UTF-8 文本;相等按实际字符序列判断,不做隐式 Unicode 规范化 |
|
||||
| `Id` | 不透明稳定标识,只支持相等比较、传递和序列化 |
|
||||
|
||||
Core 0.1 不提供 `UInt`、`BigInt`、`Percent`、字符类型或隐式动态 `Json` 类型。
|
||||
|
||||
- 整数 literal 默认是 Int。
|
||||
- 带小数点的 literal 默认是 Decimal;在明确要求 Float 的静态上下文中直接成为 Float。
|
||||
- 科学计数 literal,例如 `1.25e6`,是 Float。
|
||||
- Decimal literal 必须能被 34 位 decimal128 精确表示;源码常量不会被静默舍入,超出精度或范围时由编译器拒绝。运行时算术仍按已确定的银行家舍入产生结果。
|
||||
- Text 使用双引号,支持 `\\`、`\"`、`\n`、`\r`、`\t` 和 `\u{HEX}` Unicode 转义。
|
||||
- `true`、`false` 和 `null` 是保留 literal。
|
||||
- 源码不提供 NaN 或 Infinity literal;Float 运算可以产生这些值。
|
||||
|
||||
用户定义 type 只有两种形状,不能混写:
|
||||
|
||||
```dever
|
||||
type User {
|
||||
id: Id
|
||||
name: Text
|
||||
phone: Text?
|
||||
}
|
||||
|
||||
type CreateResult {
|
||||
Created(user: User)
|
||||
InvalidPhone(reason: Text)
|
||||
EmailAlreadyUsed(email: Text)
|
||||
}
|
||||
```
|
||||
|
||||
字段型 type 是固定字段数据;选择型 type 表示同一时间只能是其中一种选择。type 没有方法、构造器、hook、继承或行为,也不能递归包含自身。
|
||||
|
||||
选择项必须通过所属 type 限定:
|
||||
|
||||
```dever
|
||||
result = CreateResult.Created(user)
|
||||
```
|
||||
|
||||
选择项携带的数据在声明中必须有名称和类型,构造时按声明位置传入。字段型值必须一次完整创建:
|
||||
|
||||
```dever
|
||||
user = User {
|
||||
id = id
|
||||
name = name
|
||||
phone = null
|
||||
}
|
||||
```
|
||||
|
||||
未知、重复或遗漏字段都是编译错误,包括值为 `null` 的可空字段。formatter 按 type 声明顺序输出字段。点号只访问或修改已经完整存在的字段型值,不能逐字段创建半初始化对象。
|
||||
|
||||
`Value?` 表示 `Value` 或 `null`。普通类型永远不能为 `null`,`Value??` 非法,也没有 `?.`、`??`、强制解包或隐式默认值。可空值必须交给穷尽的 function 分句处理。
|
||||
|
||||
标准泛型函数产生可空返回值时,可空化是幂等的:`first(List<Int?>)`、`find` 的同类调用及 `get(Map<Text, Int?>, key)` 都返回 `Int?`,不产生嵌套可空类型。返回 `null` 不区分“没有元素/键”和“已有值为 null”;需要区分时,业务使用带显式选择项的 Value 类型。源码仍禁止显式书写 `Int??`。
|
||||
|
||||
### 4. List And Map Values
|
||||
|
||||
List 使用方括号创建:
|
||||
|
||||
```dever
|
||||
users = [first_user, second_user]
|
||||
empty_users() (users: List<User>) {
|
||||
users = []
|
||||
}
|
||||
```
|
||||
|
||||
List 元素必须是同一静态类型。空 List 必须能从 output、参数或其他静态上下文推断元素类型,否则编译错误。
|
||||
|
||||
Map 是 `Map<Key, Value>`,使用裸大括号创建:
|
||||
|
||||
```dever
|
||||
default_labels() (labels: Map<Text, Text>) {
|
||||
labels = {
|
||||
"env" = "production"
|
||||
"region" = "china"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同一 Map literal 的 key 类型一致、value 类型一致,重复 key 是编译错误;空 Map 也必须能推断完整类型。`Type { ... }` 是固定字段型值,裸 `{ ... }` 是动态 Map,二者不会合并成万能对象。
|
||||
|
||||
Map key 只允许 Text、Int、Bool、Id 和不携带数据的选择型 type。Decimal、Float、可空值、字段型值、List 和 Map 不能成为 key。
|
||||
|
||||
Map 保证插入顺序。literal 保留源码顺序;`put` 新 key 时追加,覆盖已有 key 不移动位置;`remove` 后重新写入视为新的末尾项。`entries`、显示和未来的 JSON 边界都必须遵守这一顺序。
|
||||
|
||||
### 5. Function Definitions And Calls
|
||||
|
||||
所有行为只有一种定义形式:
|
||||
|
||||
```dever
|
||||
line_total(line: Line) (total: Decimal) {
|
||||
total = line.price * line.quantity
|
||||
}
|
||||
```
|
||||
|
||||
- 第一组括号是输入,第二组是命名输出。
|
||||
- 普通输入和输出必须写成 `name: Type`;body 内局部值可以推断。
|
||||
- 没有输入或输出时仍写空括号。
|
||||
- 不使用 `function` 或 `return` 关键字。
|
||||
- 输入名称是当前分句的局部绑定;输出名称和输入输出类型属于稳定签名。
|
||||
- 调用只按位置传参,不提供命名参数调用。
|
||||
- 用户 function 以“package、名称、输入数量”确定签名;同名同参数数量的全部分句必须具有相同的基础输入类型和输出契约。不同参数数量可以形成独立签名。
|
||||
- 同一签名的全部分句必须在同一文件中连续书写。
|
||||
- Core 0.1 禁止直接或间接递归;重复执行只能通过 `each`、`reduce` 和 `reduce_until` 这些受检查的具名 handler 组合器进入。
|
||||
|
||||
选择项会提供所属选择型 type,普通 type 会提供自身类型;`null` 和 `other` 则从同组其他分句推断基础输入类型。只写 `null`/`other` 而没有任何可推断类型的分句组是编译错误。`Text` 与 `null` 两个分句共同形成 `Text?` 输入,而不是两个运行时重载。
|
||||
|
||||
单输出调用直接得到该值,零输出调用作为一条动作语句,多输出调用得到只能按输出名称访问的结果:
|
||||
|
||||
```dever
|
||||
allowed = can_sign_in(user.status)
|
||||
parts = split_name(name)
|
||||
display = parts.first + parts.last
|
||||
```
|
||||
|
||||
不提供按位置拆包。命名 function 只能在普通调用位置、作为标准序列/集合操作的 handler,或传给受限静态 handler 输入;Core 0.1 不提供 lambda、闭包,也不能把 function 存入局部值、字段、List、Map 或 Stream。
|
||||
|
||||
### 6. Function Clauses Replace Conditional Blocks
|
||||
|
||||
function 分句是 Dever 唯一的条件分派机制。这是 2026-09-07 用户确认的长期语言规则;条件 block 和条件表达式不属于后续待增加能力。Bool 表达式及其短路规则仍按第 8 节定义,业务分支通过调用具名 function 的分句选择。
|
||||
|
||||
示例:
|
||||
|
||||
```dever
|
||||
can_pay(status: OrderStatus.Draft) (allowed: Bool) {
|
||||
allowed = true
|
||||
}
|
||||
|
||||
can_pay(status: other) (allowed: Bool) {
|
||||
allowed = false
|
||||
}
|
||||
```
|
||||
|
||||
冒号左侧始终是当前分句的局部输入名称;不同分句可以使用不同名称。右侧允许:
|
||||
|
||||
- 普通类型,表示该类型的任意值;
|
||||
- `true`、`false`、`null`、Text 或数值常量;
|
||||
- 完整限定的选择项及其数据绑定,例如 `CreateResult.Created(user)`;
|
||||
- 选择项中的 `_`,表示忽略该携带值;
|
||||
- Int 或 Decimal 的静态常量比较;
|
||||
- 上述数值比较的一个上下界组合;
|
||||
- 上下文关键词 `other`。
|
||||
|
||||
数值范围直接放在输入中:
|
||||
|
||||
```dever
|
||||
absolute(value: Int < 0) (result: Int) {
|
||||
result = -value
|
||||
}
|
||||
|
||||
absolute(value: Int >= 0) (result: Int) {
|
||||
result = value
|
||||
}
|
||||
|
||||
grade(score: Int >= 60 and < 90) (name: Text) {
|
||||
name = "pass"
|
||||
}
|
||||
|
||||
grade(score: Int < 60) (name: Text) {
|
||||
name = "fail"
|
||||
}
|
||||
|
||||
grade(score: Int >= 90) (name: Text) {
|
||||
name = "excellent"
|
||||
}
|
||||
```
|
||||
|
||||
Float 不进入静态范围分句,因为 NaN 不具有普通全序;先通过 `math.is_nan` 等 function 得到 Bool,再对 Bool 写分句。
|
||||
|
||||
分句没有源码顺序优先级。编译器把同一签名的全部分句作为一组规则,拒绝类型不一致、重叠、遗漏和不可达情况。多输入 function 中,每个 `other` 是该输入位置全部明确模式的补集,编译器再检查各位置组合是否覆盖完整输入空间。`other` 只在分句输入冒号右侧具有该含义,不能作为普通值存储、传递或组合。
|
||||
|
||||
分句输入中不能调用任意 function 或读取运行时状态。更深一层的业务选择必须调用另一个有名称的 function,源码不提供 `if`、`else`、`switch`、`match`、`when` 或 `by` block。
|
||||
|
||||
### 7. Linear Body And Value Semantics
|
||||
|
||||
body 只包含顺序赋值和 function 调用:
|
||||
|
||||
```dever
|
||||
final_price(price: Decimal, rate: Decimal) (result: Decimal) {
|
||||
discounted = price * (1 - rate)
|
||||
result = decimal.round(discounted, 2)
|
||||
}
|
||||
```
|
||||
|
||||
表达式由 literal、名称、字段访问、值构造、function 调用、一元运算和二元运算组成。没有条件表达式、循环 block、匿名 function、复合赋值或隐藏控制流。
|
||||
|
||||
`=` 既用于初始化,也用于修改当前 function 的 input、局部值或 output。编译器强制:
|
||||
|
||||
- 读取前已经完整初始化;
|
||||
- 字段修改的宿主值已经完整初始化;
|
||||
- function 结束时全部命名输出都已赋值;
|
||||
- 赋值类型稳定,不通过再次赋值改变局部类型;
|
||||
- 无意义覆盖和写入后未读取至少产生诊断。
|
||||
|
||||
普通值的赋值、传参和输出采用值语义。修改只属于当前局部名称,调用方或其他名称不会观察到共享别名变化;变化只能通过命名 output 或未来的显式 effect function 离开当前调用。实现可以用唯一所有权或写时复制消除真实复制,但源码不暴露借用、生命周期或 `clone`。File、Socket、Listener 和 Stream 是显式 opaque 资源例外:复制只建立同一资源状态的别名,不复制外部资源。
|
||||
|
||||
### 8. Expressions And Operators
|
||||
|
||||
普通算术直接使用符号:
|
||||
|
||||
```dever
|
||||
subtotal = price * quantity
|
||||
ratio = 5 / 2
|
||||
page = 5 // 2
|
||||
remainder = 5 % 2
|
||||
eligible = user.active and user.age >= 18
|
||||
```
|
||||
|
||||
| 运算 | 规则 |
|
||||
| --- | --- |
|
||||
| `+ - *` | Int、Decimal 或 Float 的同类运算;Int 与 Decimal 混合时 Int 无损提升为 Decimal |
|
||||
| `/` | Int/Int 返回 Decimal;Decimal 与 Int 返回 Decimal;Float/Float 返回 Float |
|
||||
| `//` | 只接受 Int,结果向零截断 |
|
||||
| `%` | 只接受 Int,余数符号跟随被除数,并满足 `a = (a // b) * b + (a % b)` |
|
||||
| `+` | 也可连接两个 Text |
|
||||
| `== !=` | 相同可比较类型 |
|
||||
| `< <= > >=` | Int、Decimal 或 Float 的同类比较;Int 可提升为 Decimal |
|
||||
| `and or not` | 只接受 Bool,并具有短路语义 |
|
||||
|
||||
可比较类型包括上述标量,以及成员递归可比较的字段型、选择型、可空、List、Map 和 MapEntry。字段型按声明字段逐项比较;选择型比较选择项和携带值;List 按位置比较;Map 按插入顺序比较 key/value 对,因此顺序不同的 Map 不相等;可空值中只有两个 null 相等,两个非 null 值继续比较其内容。Float 保持下文的 NaN 规则,包括嵌套在集合或字段中时。命名多输出结果只能访问输出字段,不能直接比较。相等运算不做 Int/Decimal 提升。
|
||||
|
||||
Decimal 与 Float 不隐式混合,实际 Int 值转为 Float 也必须显式调用 `float.from_int`。Core 0.1 不提供 Float 到 Decimal 的隐式或有损转换。
|
||||
|
||||
运算优先级从高到低固定为:调用和字段访问、一元 `-`/`not`、`* / // %`、`+ -`、比较、`and`、`or`。同级二元运算从左到右;括号可以显式改变顺序。formatter 不依赖空白表达优先级。
|
||||
|
||||
### 9. Collection And Sequence Functions
|
||||
|
||||
Core 0.1 只保留以下集合行为,不增加循环语法:
|
||||
|
||||
```dever
|
||||
views = each(to_view, users)
|
||||
views = each(to_view, users, context)
|
||||
|
||||
active = filter(is_active, users)
|
||||
active = filter(is_active, users, context)
|
||||
|
||||
user = find(is_target, users)
|
||||
user = find(is_target, users, context)
|
||||
|
||||
total = sum(prices)
|
||||
total = sum(line_total, lines)
|
||||
total = sum(line_total, lines, context)
|
||||
|
||||
state = reduce(update_state, values, initial_state)
|
||||
state = reduce_until(update_until_done, values, initial_state)
|
||||
|
||||
parallel_each(consume, values, 4)
|
||||
parallel_each(consume, values, 4, context)
|
||||
```
|
||||
|
||||
- `each`、`filter`、`find` 和投影式 `sum` 最多有一个末尾 context;需要多项共享数据时先组合成字段型值,其 handler 先接收元素、再接收 context。
|
||||
- `reduce` 和 `reduce_until` 的第三项是必需的初始状态,不是可选 context;handler 先接收元素、再接收当前状态,且不接受第四项参数。
|
||||
- `each` 的处理 function 有一个输出时按输入顺序产生 `List<Output>`;没有输出时只按顺序执行动作;多个输出不合法。
|
||||
- `filter` 的处理 function 必须输出 Bool,并保留原列表顺序。
|
||||
- `find` 找到第一个 Bool 为 true 的元素后停止,返回 `Item?`;没有结果返回 `null`。
|
||||
- `sum(List<Number>)` 直接求和;投影形式要求处理 function 输出 Int、Decimal 或 Float。
|
||||
- 空 List 的 `sum` 返回相应数值类型的零。
|
||||
- 除显式 `parallel_each` 外,List 操作按列表顺序执行,Bytes 按字节位置执行,Stream 按拉取顺序执行;Float 求和不得为了优化而重排。
|
||||
- `reduce` 和 `reduce_until` 的完整 List/Bytes/Stream、状态和停止契约由 Approved Foundation Extension 定义;它们不开放循环 block 或用户递归。
|
||||
- `parallel_each` 接受 List、Bytes 或 Stream、1–256 个工作线程和可选 context;handler 必须零输出,输入依次是元素和 context。复用既有静态 handler 与专门化规则,不产生用户可存储的 function 值。
|
||||
- 生产端顺序拉取且队列有界,工作端的副作用顺序不保证。handler 输入与 context 不能递归包含 Stream,源 Stream 只在生产端拉取;文件与 Socket 等别名仍遵守共享资源规则。
|
||||
- `parallel_each` 返回前等待全部已启动动作;程序故障在后续拉取边界停止分派,保留第一个已记录故障并传播源码位置。已经阻塞的拉取或系统调用由显式系统超时约束,不提供强制取消或后台遗留任务。非法线程数在拉取前产生程序故障。
|
||||
|
||||
其他最小标准函数:
|
||||
|
||||
```dever
|
||||
users = append(users, user)
|
||||
user = first(users)
|
||||
|
||||
value = get(labels, "env")
|
||||
labels = put(labels, "env", "development")
|
||||
labels = remove(labels, "env")
|
||||
pairs = entries(labels)
|
||||
```
|
||||
|
||||
`append` 返回新 List;`first(List<Item>)` 返回 `Item?`。`get` 返回 `Value?`;`put` 和 `remove` 返回新 Map;`entries` 返回按插入顺序排列的 `List<MapEntry<Key, Value>>`。没有 `list.add`、数字点号索引、`map.key` 或 `map[key]`。
|
||||
|
||||
`any`、`all`、`min`、`max` 不进入首版;真实重复需求出现后再增加普通标准 function。
|
||||
|
||||
### 10. Recoverable Failures And Runtime Faults
|
||||
|
||||
可恢复业务失败使用选择型输出:
|
||||
|
||||
```dever
|
||||
create_message(result: CreateResult.Created(user)) (message: Text) {
|
||||
message = user.name
|
||||
}
|
||||
|
||||
create_message(result: CreateResult.InvalidPhone(reason)) (message: Text) {
|
||||
message = reason
|
||||
}
|
||||
|
||||
create_message(result: CreateResult.EmailAlreadyUsed(email)) (message: Text) {
|
||||
message = email + " already exists"
|
||||
}
|
||||
```
|
||||
|
||||
这同时替代 `throw` 和 `catch`。调用方接收完整选择值,再通过命名分句显式转换。Core 0.1 不提供 exception、自动错误传播、隐藏提前退出或类似 Rust `?` 的语法。
|
||||
|
||||
编译器可以证明的不变量错误直接拒绝编译。只能在运行时发现的程序故障终止当前执行边界,并至少报告故障类别、文件、行和列;普通 function 不能捕获、忽略或转换程序故障。执行边界由宿主确定,例如一次 CLI 调用,未来可以是一次请求或任务。
|
||||
|
||||
数据库、网络等只要存在恢复可能,就必须在未来以显式选择型结果返回,不能伪装成程序故障。
|
||||
|
||||
### 11. Numeric Semantics
|
||||
|
||||
- Int 是固定 i64;不环绕、不饱和。静态可证明的溢出是编译错误,运行时溢出是程序故障。
|
||||
- `Int / Int` 先无损提升为 Decimal 再除,因此 `5 / 2` 是 Decimal `2.5`。
|
||||
- `Int // Int` 向零截断;`Int % Int` 与商保持代数恒等式。除数为零以及 `-9223372036854775808 // -1` 属于溢出故障。
|
||||
- Decimal 使用 decimal128 的 34 位有效数字和银行家舍入;算术中的正常 inexact/rounded 状态产生舍入结果,指数上溢、下溢、除零或无效运算产生程序故障,不向源码暴露 Decimal NaN/Infinity。
|
||||
- 金额位数由 `decimal.round(value, places)` 显式处理;核心算术不根据变量名、字段名或业务场景猜测两位小数。
|
||||
- Float 遵守 IEEE 754 binary64 非捕获行为:溢出产生正负 Infinity,无效运算产生 NaN,除零产生 Infinity 或 NaN。
|
||||
- Float 的 NaN 与任何值的 `==` 都为 false,`!=` 为 true,有序比较均为 false;使用 `math.is_finite`、`math.is_nan` 和 `math.is_infinite` 分类。
|
||||
- 常用科学函数通过 `math.sqrt`、`math.sin`、`math.cos`、`math.log` 和 `math.pow` 等普通标准 package function 提供。
|
||||
- Decimal 与 Float 不隐式混合。任意精度整数/小数、向量、矩阵、复数、统计与单位系统以后由独立 package 提供。
|
||||
|
||||
## Standard Names
|
||||
|
||||
无需 import 即可使用的核心名称只有:
|
||||
|
||||
- Bool、Int、Decimal、Float、Text、Id、Bytes、List、Map、MapEntry、Stream;
|
||||
- true、false、null、other;
|
||||
- each、parallel_each、reduce、reduce_until、filter、find、sum、append、first、get、put、remove、entries、length、用于 Stream 的 close;
|
||||
- 仅用于 function 输入签名的上下文关键词 handler;
|
||||
- 算术、比较和布尔运算符。
|
||||
|
||||
其他标准行为使用完整 package 名,例如 `text.trim`、`text.lower`、`decimal.round`、`float.from_int` 和 `math.sqrt`。这避免大量隐式全局名称,也让标准库与业务 package 遵守同一调用模型。
|
||||
|
||||
## CLI Identity And Coexistence
|
||||
|
||||
- 语言、编译器产品和正式命令名保持为 `dever`,文档与命令语义以 `dever` 为准。
|
||||
- Core 0.1 与现有 Dever 框架 CLI 共存期间,系统 `PATH` 中可以暂时用 `deverc` 暴露语言编译器;不得覆盖、替换或遮蔽现有的 `dever` 命令。
|
||||
- `deverc` 只是指向同一编译器二进制的安装入口,不形成第二套命令、配置或行为契约。
|
||||
- 仓库构建与自动测试直接调用构建产物,不修改系统 `PATH` 或全局可执行文件;创建、更新或移除全局 `deverc` 入口必须单独获得用户授权。
|
||||
- 未来是否让语言编译器接管全局 `dever` 命令属于独立迁移决策,不在 Core 0.1 默认实施范围内。
|
||||
|
||||
## Complete Package Example
|
||||
|
||||
```dever
|
||||
public type UserStatus {
|
||||
Pending
|
||||
Active
|
||||
Disabled(reason: Text)
|
||||
}
|
||||
|
||||
public type CreateInput {
|
||||
id: Id
|
||||
name: Text
|
||||
email: Text
|
||||
phone: Text?
|
||||
score: Int
|
||||
labels: Map<Text, Text>
|
||||
}
|
||||
|
||||
public type CreateResult {
|
||||
Created(user: User)
|
||||
InvalidName(reason: Text)
|
||||
InvalidEmail(reason: Text)
|
||||
}
|
||||
|
||||
public type User {
|
||||
id: Id
|
||||
name: Text
|
||||
email: Text
|
||||
phone: Text?
|
||||
score: Int
|
||||
labels: Map<Text, Text>
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
public type UserView {
|
||||
id: Id
|
||||
name: Text
|
||||
email: Text
|
||||
phone: Text
|
||||
score: Int
|
||||
can_sign_in: Bool
|
||||
}
|
||||
|
||||
public create(input: CreateInput) (result: CreateResult) {
|
||||
normalized = normalize(input)
|
||||
result = create_name(normalized, normalized.name)
|
||||
}
|
||||
|
||||
normalize(input: CreateInput) (result: CreateInput) {
|
||||
result = input
|
||||
result.name = text.trim(input.name)
|
||||
result.email = text.lower(text.trim(input.email))
|
||||
}
|
||||
|
||||
create_name(input: CreateInput, name: "") (result: CreateResult) {
|
||||
result = CreateResult.InvalidName("name is required")
|
||||
}
|
||||
|
||||
create_name(input: CreateInput, name: other) (result: CreateResult) {
|
||||
result = create_email(input, name, input.email)
|
||||
}
|
||||
|
||||
create_email(input: CreateInput, name: Text, email: "") (result: CreateResult) {
|
||||
result = CreateResult.InvalidEmail("email is required")
|
||||
}
|
||||
|
||||
create_email(input: CreateInput, name: Text, email: other) (result: CreateResult) {
|
||||
user = User {
|
||||
id = input.id
|
||||
name = name
|
||||
email = email
|
||||
phone = input.phone
|
||||
score = input.score
|
||||
labels = input.labels
|
||||
status = UserStatus.Pending
|
||||
}
|
||||
result = CreateResult.Created(user)
|
||||
}
|
||||
|
||||
public activate(user: User) (result: User) {
|
||||
result = user
|
||||
result.status = UserStatus.Active
|
||||
}
|
||||
|
||||
public disable(user: User, reason: Text) (result: User) {
|
||||
result = user
|
||||
result.status = UserStatus.Disabled(reason)
|
||||
}
|
||||
|
||||
public can_sign_in(status: UserStatus.Active) (allowed: Bool) {
|
||||
allowed = true
|
||||
}
|
||||
|
||||
public can_sign_in(status: other) (allowed: Bool) {
|
||||
allowed = false
|
||||
}
|
||||
|
||||
phone_text(phone: Text) (result: Text) {
|
||||
result = phone
|
||||
}
|
||||
|
||||
phone_text(phone: null) (result: Text) {
|
||||
result = "Not provided"
|
||||
}
|
||||
|
||||
public to_view(user: User) (view: UserView) {
|
||||
view = UserView {
|
||||
id = user.id
|
||||
name = user.name
|
||||
email = user.email
|
||||
phone = phone_text(user.phone)
|
||||
score = user.score
|
||||
can_sign_in = can_sign_in(user.status)
|
||||
}
|
||||
}
|
||||
|
||||
public to_views(users: List<User>) (views: List<UserView>) {
|
||||
views = each(to_view, users)
|
||||
}
|
||||
|
||||
is_active(user: User) (result: Bool) {
|
||||
result = can_sign_in(user.status)
|
||||
}
|
||||
|
||||
public active_views(users: List<User>) (views: List<UserView>) {
|
||||
active = filter(is_active, users)
|
||||
views = each(to_view, active)
|
||||
}
|
||||
|
||||
has_id(user: User, expected: Id) (matches: Bool) {
|
||||
matches = user.id == expected
|
||||
}
|
||||
|
||||
public find_user(users: List<User>, id: Id) (user: User?) {
|
||||
user = find(has_id, users, id)
|
||||
}
|
||||
|
||||
public put_label(user: User, key: Text, value: Text) (result: User) {
|
||||
result = user
|
||||
result.labels = put(user.labels, key, value)
|
||||
}
|
||||
|
||||
public label(user: User, key: Text) (value: Text?) {
|
||||
value = get(user.labels, key)
|
||||
}
|
||||
|
||||
score_bonus(user: User, rate: Decimal) (amount: Decimal) {
|
||||
amount = user.score * rate
|
||||
}
|
||||
|
||||
public total_score_bonus(users: List<User>, rate: Decimal) (total: Decimal) {
|
||||
total = sum(score_bonus, users, rate)
|
||||
}
|
||||
```
|
||||
|
||||
该源码只要求开发者理解 package、type、function、值和普通表达式。它同时覆盖显式业务结果、局部修改、条件分句、可空处理、列表转换、筛选、查找、Map 更新和 Decimal 聚合,所有路径保持平铺且可由编译器完整检查。
|
||||
|
||||
## Original Core Deferrals
|
||||
|
||||
以下保留原 Core 0.1 的暂缓能力,具体版本安排在后续需求中确定。HTTP、JSON、持久化以及异步/并发 I/O 的应用扩展仍属暂缓 API 提案。
|
||||
|
||||
- AI、自然语言转译、画布和业务 DSL;
|
||||
- Web 页面、React,以及超出首个 API 需要的数据库、网络和部署能力;
|
||||
- 超出首个 API 需要的 effect/capability、异步、并发和事务能力;不开放任意共享可变业务全局变量;
|
||||
- package registry、版本解析和项目清单;
|
||||
- `for/while/break/continue/iterate/recur/repeat`、用户递归和其它通用循环结构;
|
||||
- class、继承、interface、annotation、macro、unsafe、FFI;
|
||||
- import、别名、命名参数、lambda、闭包和一般化一等 function;
|
||||
- 用户泛型 type、递归 type、Set 和动态 Json;首个 API 的 JSON 边界应由静态类型驱动;
|
||||
- UInt、BigInt、任意精度 Decimal、Percent、日期时间和单位类型;
|
||||
- 直接 LLVM/Cranelift backend、完成自举、所有权或生命周期表面语法。
|
||||
|
||||
这些内容保留为 Core 0.1 暂缓项,不改变 Dever 最终成为通用编程语言的方向。条件 block 已从暂缓清单移除,条件分派始终遵守第 6 节的长期规则。
|
||||
|
||||
## Core Roadmap Acceptance Criteria
|
||||
|
||||
这些是完整语言路线的验收项。2026-09-07 的基础语法、最小系统接口和后续工具/库分别验收,实际状态与证据见 `implement.md`;发行和自举不因基础里程碑完成而视为已交付。
|
||||
|
||||
1. 一个源码根目录中的多个 `.dever` 文件可以被加载;Core 0.1 只读取该根目录和内置标准 package,不隐式联网或搜索外部目录;路径与 package 不一致、保留命名空间、重复 package、未知公开名、未知引用和循环依赖均得到带源码位置的确定诊断。
|
||||
2. parser 覆盖本 PRD 的 package、两种 type、function、分句、value、statement 和 expression;拒绝已排除的关键字或语法。
|
||||
3. formatter 对合法程序产生唯一布局,连续执行两次结果不变,并保持注释和程序含义。
|
||||
4. 编译器强制命名格式、显式 function 边界、位置调用、一文件一 package、连续分句和无递归调用图;同 package 使用短名称,跨 package 必须使用完整限定名,并且只能访问对方 `exposes` 中的名称。
|
||||
5. type checker 拒绝未知/重复/遗漏字段、半初始化字段写入、未初始化读取、遗漏 output、非法 null、类型漂移、非法 Map key 和未授权隐式转换。
|
||||
6. clause analyzer 对选择型、可空、Bool、Text 常量、Int/Decimal 范围和多输入组合检查互斥、穷尽与可达性;分句移动顺序不改变执行结果。
|
||||
7. 本 PRD 的完整 package 示例可以通过解析、格式化、名称解析和类型检查;独立的零输入示例在语义基准解释器和原生程序中产生相同的确定命名输出。
|
||||
8. 局部赋值和字段修改不会改变调用方或其他名称持有的原值;编译器实现可以优化复制但不能改变可观察语义。
|
||||
9. `each`、`reduce`、`reduce_until`、`filter`、`find` 和 `sum` 的顺序、handler、context、初始/空结果、停止和类型规则全部有定向测试。
|
||||
10. List、Map、Bytes 和 Stream,以及 `append`、`first`、`get`、`put`、`remove`、`entries` 遵守可空返回、值/资源语义和稳定顺序。
|
||||
11. `5 / 2` 得到 Decimal `2.5`,`-5 // 2` 得到 Int `-2`,`-5 % 2` 得到 Int `-1`,并满足商余恒等式。
|
||||
12. Decimal 能证明 `0.1 + 0.2 == 0.3`,`1 / 3` 使用 34 位有效数字和 round half to even;`decimal.round` 显式控制业务位数。
|
||||
13. 静态 Int/Decimal 溢出或零除数被拒绝编译;动态故障终止当前执行边界并定位 `.dever` 源码,不环绕、饱和或返回默认值。
|
||||
14. Float 使用 binary64 和科学计数法,能够产生并通过 `math.is_nan`、`math.is_finite`、`math.is_infinite` 判断 NaN/Infinity;Decimal 与 Float 混用被拒绝。
|
||||
15. 可恢复失败只能通过选择型输出和穷尽分句传播;源码中不存在 exception、`throw`、`catch` 或自动传播语法。
|
||||
16. 正式 CLI 名为 `dever`,至少提供 `dever check <source-root>`、`dever fmt <source-root>`、`dever build <source-root> <package.function> --output <path>` 和 `dever run <source-root> <package.function>`;与现有框架 CLI 共存时,临时全局入口 `deverc` 必须调用同一编译器且不得覆盖现有 `dever`;build/run 首版只接受公开的零输入 function,run 执行与 build 相同的原生路径并确定性打印命名输出。
|
||||
17. 新实现不依赖旧 canonical JSON、SemanticGraph、Refiner、Workbench、温度示例或 Rust/React 生成后端,仓库文档不再把旧路线描述为当前架构。
|
||||
18. 生产产物不链接通用解释器,不携带运行时 type tag 分派或垃圾回收器;record、choice、List、Bytes 和静态 handler 在 AOT 路径中具有具体原生表示,热循环不发生逐元素动态 handler 分派或无必要堆分配;opaque Stream 只在 I/O 资源拉取边界保留内部生产者抽象。
|
||||
19. 在同一算法、数据布局、工具链和机器上的 release 基准中,Int/Float 算术及 List `each`/`filter`/`sum` 代表性热路径的中位执行时间不得超过等价 safe Rust 基线的 1.15 倍;Decimal 与有序 Map 分别对使用相同底层语义的 Rust 基线比较,编译时间单独报告。
|
||||
20. Rust 生成文件只存在于编译器管理的临时构建目录,不是用户源码、公共输出格式或兼容接口;删除这些文件不影响 `.dever` 工程。
|
||||
|
||||
## Risks And Deferred Items
|
||||
|
||||
- 条件分句的穷尽分析是首版最复杂的静态检查,应先支持有限选择域与一维 Int/Decimal 区间,再组合多个输入;不引入通用定理证明器。
|
||||
- decimal128 需要成熟实现与一致状态标志。Core 0.1 采用 Rust `dec` crate 的 Decimal128 和可配置 Context,并将其封装在内部数值适配层;语言级测试锁定行为,依赖 API 不得泄漏到 `.dever`。
|
||||
- 当前仍禁止用户递归,已按批准范围提供显式文件/TCP 系统操作和结构化异步并发;异步网络、一般化 effect 和事务模型尚未实现,因此不是最终完整运行时。
|
||||
- Float 运算遵守平台 IEEE 754 语义,但任何未来的快速数学优化都必须显式设计,首版不得重排计算。
|
||||
|
||||
# Current Source And Module API Contract (2026-09-18)
|
||||
|
||||
This user-approved contract supersedes historical `package/exposes`, arbitrary entry arguments and "API deferred" wording above. Source filenames relative to `module/` define package names in both `.dever` and `.dever.md`; top-level type/function declarations default private and may opt into cross-package access with `public`. `deverc run/build <project>` always invokes private `main()` from `module/main.dever` (or `.dever.md`) and accepts no entry argument. Markdown H1 documents path-derived package and public declarations with no executable H1 block.
|
||||
|
||||
Each module may own recursive `api/` source containing only private `get_`/`post_`/`delete_` ordinary functions with one `response` output. Routes are statically validated and compiled only when reachable `main()` calls `dever.api.serve()`. GET/DELETE query inputs and POST JSON-object inputs are typed Text/Int/Bool (nullable allowed), and HTTP responses have JSON `code/message/data` envelopes. Listener settings are read only from `config/setting.json`. No application-config environment variable, implicit listener, new auth policy or CMS HTTP business endpoint is part of this milestone. Current canonical contracts are in `LANGUAGE.md`, `MARKDOWN-SYNTAX.md` and `.trellis/spec/backend/`.
|
||||
|
||||
# Current AI-Oriented Source Architecture Contract (2026-09-19)
|
||||
|
||||
This section supersedes the 2026-09-18 application visibility and package rules above. An application starts only at `module/main.dever` or `module/main.dever.md`; `main()` is implicit and private, and HTTP starts only when it calls `dever.api.serve()`. Application source contains no `package`, `exposes`, `public`, `internal` or import declarations.
|
||||
|
||||
Business source uses `module/<component>/<domain>/<role>.dever` or `module/<component>/<domain>/<role>/<topic>.dever`, where role is exactly `app`, `domain`, `model`, `port`, `adapter` or `api`. A role file and its same-name directory are mutually exclusive. Non-API role directories are flat; only `api/` may add nested URL scopes. A role directory containing only one source file is rejected because it creates structure without separation; keep that implementation in the role file instead.
|
||||
|
||||
Files group cohesive behavior rather than individual functions. One role file may contain multiple related functions, and one topic file may contain multiple related functions. The topic filename never becomes part of an App call. All App functions are cross-domain capabilities and are addressed as `<component>.<domain>.<function>`; a same-component caller may use `<domain>.<function>`, and source in the same domain may use `app.<function>`. Every other role is domain-private. Cross-domain and cross-component calls may target only App. Model types and ID types may appear in relations and signatures across domains, but generated Model operations remain callable only inside their owning domain. API handlers are HTTP entry adapters and are never source-callable.
|
||||
|
||||
App inputs, outputs and inferred failure sets are one public contract. They cannot expose a private Domain/Adapter type; a business error that may leave the domain is declared in App and referenced from same-domain private source as `app.<ErrorType>`.
|
||||
|
||||
The dependency direction is `api -> app`, `app -> domain/model/port/adapter` plus another domain's App, while domain/model/port/adapter remain implementation details. Generic dumping grounds such as `common`, `shared`, `utils`, `helper`, `base` and physical `service/` are invalid application architecture. Keep one-use logic inline, same-topic reuse beside its callers, cross-topic domain rules in `domain`, persistence in `model`, and external integrations in `adapter`; introduce `port` only for a real replaceable boundary.
|
||||
|
||||
API route prefixes and JSON `code/message/data` envelopes remain as approved on 2026-09-18. Runtime settings still come only from `config/setting.json`; environment-variable configuration remains prohibited. Authentication remains business composition through App/domain capabilities rather than a compiler-owned HTTP policy.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Package Distribution Research
|
||||
|
||||
## Question
|
||||
|
||||
How should a Dever project consume a component downloaded from GitHub or another Git host without leaking transport details into `.dever` source or losing reproducible builds?
|
||||
|
||||
## Primary Evidence
|
||||
|
||||
- Go defines a module as a versioned collection of packages. The module path is also the prefix of package import paths and usually identifies where the module can be downloaded. Downloaded source is shared through a module cache, and hashes are verified against `go.sum`.
|
||||
- https://go.dev/ref/mod
|
||||
- https://go.dev/doc/modules/gomod-ref
|
||||
- Cargo permits registry, Git and local path sources. Git dependencies may select a tag or revision, and Cargo locks the resolved commit in `Cargo.lock` until an explicit update.
|
||||
- https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html
|
||||
- Cargo and Go both separate downloaded source caches from project build artifacts. Go also supports optional vendoring for a fully project-local source tree.
|
||||
- https://go.dev/ref/mod#module-cache
|
||||
- https://go.dev/ref/mod#vendoring
|
||||
- https://doc.rust-lang.org/cargo/reference/config.html#cache
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Git URL as package identity
|
||||
|
||||
Example: `github.com.shemic.contact.phone.parse(raw)`.
|
||||
|
||||
This is easy to locate but couples every source reference and type identity to the current hosting provider. Repository transfer, self-hosting or registry mirroring then becomes a source-breaking rename. It also creates long names that obscure the business API.
|
||||
|
||||
### Project-local copied dependencies
|
||||
|
||||
Example: clone every dependency under `vendor/`.
|
||||
|
||||
This is transparent and offline-friendly but duplicates source across projects, increases repository size and makes accidental dependency edits likely. It is useful as an explicit export mode, not the default store.
|
||||
|
||||
### Stable identity plus independent source
|
||||
|
||||
Example identity: `shemic.contact`; example source: `https://github.com/shemic/contact.git` at an exact resolved commit.
|
||||
|
||||
This preserves short, stable `.dever` names while allowing registry, Git, mirrors and local development to use the same package identity. It requires dependency metadata and identity verification, but those responsibilities belong to the package manager rather than business source.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Use the third model:
|
||||
|
||||
1. `component id` is the stable namespace and type-identity root.
|
||||
2. `source` is a replaceable registry, Git or local transport selected outside `.dever`.
|
||||
3. `dever.mod` records direct dependency intent and source requirements.
|
||||
4. `dever.lock` records the complete graph, full Git commits and normalized content hashes.
|
||||
5. A global immutable content-addressed store deduplicates verified source across projects.
|
||||
6. `check`, `build` and `run` never perform implicit network access; explicit `add`, `update` and `fetch` do.
|
||||
7. Package declarations must remain beneath the component root, preventing one dependency from supplying another component's namespace.
|
||||
|
||||
This deliberately combines Go's small module/package model and shared verified cache with Cargo's source flexibility and exact Git locking, while avoiding Go's coupling between source-level identity and repository location.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Core 0.1 Runtime Dependency Research
|
||||
|
||||
- Checked: 2026-09-03
|
||||
- Scope: dependencies required by already-approved Decimal and Map semantics
|
||||
|
||||
## Decimal128
|
||||
|
||||
Selected for the MVP: `dec = "=0.4.11"`.
|
||||
|
||||
Evidence:
|
||||
|
||||
- The [dec crate documentation](https://docs.rs/dec/latest/dec/) describes `Decimal128` as a 128-bit decimal representation with 34 digits of precision and exposes a configurable `Context` with rounding and exceptional-condition status.
|
||||
- The [Decimal128 API](https://docs.rs/dec/latest/dec/struct.Decimal128.html) supports the required arithmetic and classification operations.
|
||||
|
||||
Reason:
|
||||
|
||||
- It directly implements the approved decimal128 contract instead of approximating it with a lower-precision financial decimal.
|
||||
- Context status lets Dever convert overflow, division by zero and invalid Decimal states into language-level runtime faults.
|
||||
- A reusable Context avoids the per-operation default-context overhead documented by the crate.
|
||||
|
||||
Constraint:
|
||||
|
||||
- `dec` is a safe Rust wrapper over libdecnumber and therefore introduces a native C build dependency. It must remain isolated behind `dever_runtime::number::DecimalValue`.
|
||||
- Dever conformance tests, not the crate's operator defaults or special-value surface, define public behavior.
|
||||
- A future pure-Rust replacement is allowed only when it passes the same conformance suite; this does not change `.dever` semantics.
|
||||
|
||||
Rejected for Core 0.1:
|
||||
|
||||
- `rust_decimal`: pure Rust, but its 96-bit coefficient does not provide the approved 34-digit decimal128 contract.
|
||||
- Hand-written decimal arithmetic: correct rounding, exponent handling and status behavior are too risky for the language MVP.
|
||||
- Newly published decimal128 implementations: potentially useful later, but less established than the selected reference implementation.
|
||||
|
||||
## Insertion-Ordered Map
|
||||
|
||||
Selected for the MVP: `indexmap = "=2.14.0"`.
|
||||
|
||||
Evidence:
|
||||
|
||||
- The [IndexMap project](https://github.com/indexmap-rs/indexmap) documents hash-based lookup with iteration independent of hash order.
|
||||
- The [IndexMap API](https://docs.rs/indexmap/latest/indexmap/map/struct.IndexMap.html) documents that insertion appends new keys, overwriting preserves the existing position, and order-preserving removal is available.
|
||||
|
||||
Reason:
|
||||
|
||||
- Those operations match the approved Dever Map contract without maintaining a duplicate vector-plus-index implementation.
|
||||
- Lookup remains hash based while iteration is deterministic.
|
||||
|
||||
Constraint:
|
||||
|
||||
- Dever wraps IndexMap and exposes only `get`, `put`, `remove` and `entries`.
|
||||
- Removal uses the order-preserving operation rather than swap removal.
|
||||
- Dependency-specific index access never appears in `.dever` or public diagnostics.
|
||||
|
||||
## Dependency Discipline
|
||||
|
||||
These are planned pins; the initial parser milestone has no external dependencies. Verify availability and pin each version when the runtime first consumes it. No dependency becomes a public language contract. Upgrading either dependency requires running the focused Decimal/Map conformance tests and confirming that serialized/displayed Dever values remain unchanged.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "dever-language-mvp",
|
||||
"name": "dever-language-mvp",
|
||||
"title": "Dever Language Compiler Contracts And Library Follow-through",
|
||||
"description": "N5 HTTP/2、定向性能矩阵与有界稳态/恢复压测、typed ORM 及双源码 CMS 合同修正已完成;业务 API 等用户重新设计,编译器发行/跨平台与自举仍未交付,根语言路线图保持进行中。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": ".",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-04",
|
||||
"completedAt": null,
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"09-12-runtime-performance",
|
||||
"09-12-runtime-performance-p2",
|
||||
"09-12-http2",
|
||||
"09-12-http2-performance",
|
||||
"09-13-http2-ceiling-soak",
|
||||
"09-13-typed-orm",
|
||||
"09-18-cms-dual-source-contracts",
|
||||
"09-19-application-testing"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "2026-09-18: HTTP/2 有界饱和扫描、128/64 MiB 各 60 秒稳态与各 100 轮重连验收完成;typed ORM 子任务也已完成。原始结果见 test/performance/README.md,历史阶段证据见 implement.md。根任务仍需单独界定发行/跨平台、自举等后续路线;未运行全量测试、操作现有服务、安装全局命令或提交。",
|
||||
"meta": {
|
||||
"planning_workflow": "trellis",
|
||||
"execution_mode": "inline"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,72 @@
|
||||
# Dever 私有编译器与公开发行边界
|
||||
|
||||
## Goal
|
||||
|
||||
让用户只接触 Dever 语言、工具链和发行物,不接触或依赖 Rust/Cargo/rustc;Rust 仅作为私有仓库中的首代引导实现,不进入公开源码、安装流程、命令输出或用户运行环境。
|
||||
|
||||
## Background
|
||||
|
||||
- 当前 `/data/project/dever` 没有配置 Git 远端,Cargo workspace、Rust 源码、测试和 README 尚未提交,因此没有证据表明实现已经对外发布。
|
||||
- 当前仓库及 Git 历史包含 Rust 实现信息,不能直接转换为公开仓库。
|
||||
- 当前 `deverc run/build` 调用用户环境中的 `rustc`,并在临时目录生成 Rust 源码;这不符合对外隐藏实现和免 Rust 工具链的要求。
|
||||
- 二进制实现无法可靠抵抗专业逆向识别。本任务隐藏正常产品表面和源码实现,不承诺不可逆向。
|
||||
- 当前 Rust 编译器及计划使用的 LLVM 后端都有必须合规审查的第三方许可证;不能通过删除或伪造法定声明隐藏实现。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Repository Boundary
|
||||
|
||||
- 当前编译器仓库保持私有,不向公开仓库推送现有 Git 历史、Cargo 文件、Rust 源码或内部设计资料。
|
||||
- 对外发行使用一个没有当前仓库历史的新仓库或等价的独立公开渠道。
|
||||
- 公开内容只包含 Dever 用户需要的文档、`.dever` 示例、安装入口和构建后的发行物,不包含编译器实现源码。
|
||||
|
||||
### Product Surface
|
||||
|
||||
- 用户只使用正式的 `dever` 命令和 `.dever` 源码,不需要了解 Cargo、Rust、rustc 或内部生成语言。
|
||||
- 公开文档、安装器、帮助文本、普通诊断、日志和临时文件不得暴露 Rust/Cargo/rustc 实现细节。
|
||||
- `dever check/run/build` 不要求用户安装 Rust 工具链,也不得调用用户 PATH 中的 `rustc` 或 `cargo`。
|
||||
- `dever build` 生成的程序可独立运行,不依赖 Cargo、Rust 工具链或私有编译器源码。
|
||||
- 更换公开发行边界和原生后端不得改变已经确认的 `.dever` 表面语法、诊断位置或当前可执行语义。
|
||||
|
||||
### Native Backend
|
||||
|
||||
- Rust 可以继续用于私有编译器本身,但用户侧原生编译路径必须由随 `dever` 交付的内置后端完成。
|
||||
- 不采用“把 rustc 改名或静默捆绑”来满足隐藏要求;底层工具链不能作为可观察的用户侧子进程或安装前置条件。
|
||||
- 编译必须在用户本机离线完成,不上传 `.dever` 源码或中间表示,也不依赖远程构建节点。
|
||||
- 不采用云端编译来回避本地后端,因为这会额外引入源码上传、网络依赖和服务可用性边界。
|
||||
|
||||
### Initial Platform Boundary
|
||||
|
||||
- 首版支持 Linux、Windows 和 macOS;所有编译均在用户本机离线完成。
|
||||
- Linux、Windows 和 macOS 均提供 x86_64 与 ARM64 版 `dever`。
|
||||
- 任一受支持宿主上的 `dever build` 都必须能够离线生成 Linux、Windows 和 macOS 的 x86_64/ARM64 程序,形成 6 个宿主发行物到 6 个目标的完全对称矩阵。
|
||||
- 生成跨平台程序不能依赖远程构建节点或上传源码及中间表示。
|
||||
- 目标后端、链接能力和 Dever 平台运行时随发行物提供,不能要求用户另行安装目标平台编译器或 SDK。
|
||||
- `dever build` 为 macOS 目标生成平台运行所需的离线 ad-hoc 链接签名;它不代表开发者身份,也不能通过 Gatekeeper 分发验证。
|
||||
- Developer ID 身份签名和 Apple 公证是独立的可选发布阶段,不属于 `dever build`;Developer ID 安全时间戳和 Apple 公证均允许并必然需要联网。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 从一台没有 `cargo` 和 `rustc` 的目标机器安装公开发行物后,可以执行当前里程碑对应的 `dever check/run/build`。
|
||||
- [ ] 安装、帮助、正常运行、普通错误和构建临时目录中均不出现 Rust、Cargo、rustc 或 `.rs` 源码。
|
||||
- [ ] `dever build` 的输出是可独立运行的目标平台程序,并保持当前 `.dever` 示例的可观察行为。
|
||||
- [ ] 公开仓库或公开发行包不包含当前私有仓库的 Git 历史、Cargo metadata、Rust 源码或私有 Trellis 资料。
|
||||
- [ ] 自动检查能够证明公开构建路径没有启动 `cargo` 或 `rustc`,并覆盖没有 Rust 工具链的干净环境。
|
||||
- [ ] 断网且未安装 Windows 编译工具链的 Linux 环境可以生成 Windows 程序,并在真实 Windows 环境验证其行为。
|
||||
- [ ] 断网且未安装 Xcode 或 Apple SDK 的受支持环境可以生成 macOS 程序,并在真实 Intel Mac 和 Apple Silicon Mac 上验证其行为。
|
||||
- [ ] 六个宿主发行物分别能够生成六个目标程序,36 种宿主到目标组合使用同一命令合同并通过确定性产物检查;每个目标程序均在对应真实系统与架构上执行验证。
|
||||
- [ ] macOS ARM64 产物包含可离线生成的有效 ad-hoc 链接签名;编译不访问开发者身份或公证服务。
|
||||
- [ ] 单独的发布流程明确区分 ad-hoc 链接签名、需要联网安全时间戳的 Developer ID 身份签名和需要联网的 Apple 公证。
|
||||
- [ ] 私有仓库仍可维护和验证 Rust 引导编译器,公开边界不迫使内部源码改名或制造重复实现。
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- 阻止专业人员通过反汇编、二进制指纹或运行时特征推断实现语言。
|
||||
- 为隐藏实现而混淆、加壳或伪造二进制来源。
|
||||
- 在本任务中实现 HTTP、JSON、数据库、CRUD 或完整 Dever 语言路线。
|
||||
- 公开编译器源代码或建立外部贡献者的编译器开发环境。
|
||||
- 在 `dever build` 中管理用户的 Developer ID 凭据、执行身份签名或隐式提交 Apple 公证。
|
||||
|
||||
## Open Question
|
||||
|
||||
- 是否允许依法必须提供的第三方许可证清单提及 Rust;如果不允许,则 Rust 引导编译器只能作为私有 seed,Dever 自举编译器必须成为首次公开发行的前置条件?
|
||||
@@ -0,0 +1,35 @@
|
||||
# Linux Host Cross-Compilation
|
||||
|
||||
## Confirmed Toolchain Facts
|
||||
|
||||
- LLVM is designed as a cross-compiler, but target code generation alone is insufficient. A complete build also needs target-specific headers, libraries, linker behavior and usually a sysroot: <https://clang.llvm.org/docs/CrossCompilation.html>.
|
||||
- LLD supports ELF, Windows PE/COFF and macOS Mach-O. Its PE/COFF port is described as complete; Mach-O support exists but is listed after ELF and PE/COFF in completeness: <https://lld.llvm.org/>.
|
||||
- Microsoft publishes the PE/COFF executable and object format specification, so a Linux-hosted backend can produce Windows artifacts without invoking a Windows compiler: <https://learn.microsoft.com/en-us/windows/win32/debug/pe-format>.
|
||||
- Apple's documented universal-binary workflow requires Xcode on a Mac and uses Apple target tooling. Distributed macOS software also has Developer ID signing and notarization requirements: <https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary> and <https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution>.
|
||||
- Apple exposes a Notary REST API, so submission need not use `notarytool`, but this does not remove SDK, linking, signing or real-device validation concerns: <https://developer.apple.com/documentation/NotaryAPI>.
|
||||
|
||||
## Product Consequences
|
||||
|
||||
### Linux to Windows
|
||||
|
||||
This is a practical offline target. A target-neutral Dever frontend can feed an embedded native backend, then a PE/COFF linker can combine generated code with a versioned Dever Windows runtime and required import libraries. The resulting `.exe` still needs validation on real x86_64 and ARM64 Windows systems.
|
||||
|
||||
### Linux to macOS
|
||||
|
||||
Generating x86_64 or ARM64 instructions and a Mach-O file on Linux is technically possible. Treating that file as a supported macOS product is harder: platform libraries, minimum OS versions, Mach-O signing details, Developer ID distribution, notarization and testing remain Apple-platform concerns. Redistributing an Apple SDK inside the Linux compiler also introduces a licensing boundary and must not be assumed acceptable.
|
||||
|
||||
For the current Text/println subset, a self-contained Dever runtime and purpose-built Mach-O path could avoid most SDK use. That shortcut does not establish a maintainable path for the planned HTTP, TLS, files, database and other system integrations.
|
||||
|
||||
Local compilation and public distribution are separate contracts. Once the target backend and versioned Dever macOS runtime are installed, `dever build` can remain offline. Apple Silicon requires every executable to be signed, so the linker must create an identity-free ad-hoc signature as part of offline output: <https://developer.apple.com/documentation/macos-release-notes/macos-big-sur-11_0_1-universal-apps-release-notes>. Developer ID distribution signing requires the developer's identity and an online secure timestamp: <https://developer.apple.com/documentation/technotes/tn3161-inside-code-signing-certificates>. Apple's notarization service then requires uploading the signed artifact. Apple exposes a REST API for non-macOS notarization clients, but the service is necessarily online: <https://developer.apple.com/documentation/NotaryAPI>.
|
||||
|
||||
## Recommended Boundary
|
||||
|
||||
- Keep parsing, checking, HIR and target selection shared.
|
||||
- Support offline Linux-to-Windows cross-build as a normal target once the embedded backend exists.
|
||||
- Keep all first-release compilation local and offline; do not transfer source or intermediate representations to a build service.
|
||||
- Include macOS as an offline compilation target without introducing controlled Mac build infrastructure. Treat Developer ID signing and Apple notarization as separate optional publishing stages, and require real Intel and Apple Silicon validation for supported Mach-O output.
|
||||
- Implement each target through small ABI/runtime/object-format adapters rather than duplicating compiler flows.
|
||||
|
||||
## Open Product Decision
|
||||
|
||||
Resolved: the first release uses a fully symmetric Linux, Windows and macOS x86_64/ARM64 host-to-target matrix. Six host distributions must each produce all six targets locally and offline.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Distribution Licensing And Implementation Disclosure
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- The Rust project and standard library are distributed under `MIT OR Apache-2.0`: <https://rust-lang.org/policies/licenses/> and <https://github.com/rust-lang/rust/blob/main/COPYRIGHT>.
|
||||
- A public compiler binary built from the current Rust implementation can contain Rust standard-library code. Its exact notice obligations require a release-license audit; the project must not assume that deleting all Rust attribution is permitted.
|
||||
- LLVM is `Apache-2.0 WITH LLVM-exception`. LLVM states that binaries including LLVM must reproduce its copyright notice, while permitting closed-source and commercial use: <https://llvm.org/docs/DeveloperPolicy.html#copyright-license-and-patents>.
|
||||
- Apple licenses prohibit separately using Apple SDKs or running Apple Software on non-Apple-branded hardware, and generally prohibit redistributing Apple Software in whole or part without permission: <https://www.apple.com/legal/sla/docs/xcode.pdf>.
|
||||
|
||||
## Consequences
|
||||
|
||||
Hiding Cargo files, symbols and implementation-specific diagnostics is a product-surface concern. Required third-party notices are a separate compliance concern and must remain truthful.
|
||||
|
||||
There are two viable disclosure boundaries:
|
||||
|
||||
1. Ship the Rust bootstrap compiler initially. Normal product surfaces mention only Dever, but a compliant third-party notice may identify Rust and LLVM. Professional binary identification is already outside the task scope.
|
||||
2. Never ship the Rust bootstrap compiler publicly. Use it only as the private stage-1 seed, complete enough Dever language and runtime capabilities to build a stage-2 compiler, and publicly distribute the verified stage-2/stage-3 Dever compiler. LLVM attribution can remain if LLVM is embedded, but Rust is absent from the public executable and notices.
|
||||
|
||||
The second boundary matches a strict “no public Rust trace” requirement but makes self-hosting a release prerequisite rather than a later roadmap item. It materially expands the first public release.
|
||||
|
||||
## Rule
|
||||
|
||||
Do not use renamed tools, stripped license files, misleading notices, obfuscation or packaging tricks as a substitute for the chosen disclosure boundary.
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Native Backend Options
|
||||
|
||||
## Required Properties
|
||||
|
||||
- Consume checked Dever HIR without changing `.dever` syntax or semantics.
|
||||
- Generate x86_64 and ARM64 ELF, PE/COFF and Mach-O on every supported host.
|
||||
- Run fully offline without Cargo, rustc, a target SDK or a remote build service.
|
||||
- Preserve direct native calls, concrete value layouts and the existing native performance direction.
|
||||
- Keep generated IR, objects and link details private, owned temporary artifacts.
|
||||
- Avoid six target-specific compiler implementations.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Strengths | Blocking weaknesses | Decision |
|
||||
| --- | --- | --- | --- |
|
||||
| Pinned LLVM code generation plus LLD | Mature optimization; x86_64/ARM64; ELF, PE/COFF and Mach-O; supports in-process use | Large host binaries, native integration boundary, mandatory LLVM notices, target runtimes still required | Recommended |
|
||||
| Cranelift plus LLD | Safe Rust-facing library, fast compilation, x86_64/ARM64 | Still needs a cross-format linker and target runtimes; optimization quality creates risk against the native performance contract | Keep as fallback only if measured LLVM integration cost is unacceptable |
|
||||
| Custom machine-code and executable writers | Full control and small artifacts for the current subset | Reimplements two ISAs, three formats, relocation, unwind and linking; duplicates platform logic and does not scale to the language roadmap | Reject |
|
||||
| WebAssembly or a bytecode VM inside native launchers | Simple portable execution model | Changes the AOT/runtime architecture, adds a production VM and jeopardizes native performance requirements | Reject for production build/run |
|
||||
| Generated Rust plus bundled or renamed rustc | Reuses current implementation | Exposes the forbidden toolchain, produces Rust temporary source and makes offline target support depend on Rust sysroots/linkers | Replace completely |
|
||||
|
||||
LLVM's cross-compilation documentation still requires target-specific sysroots/libraries beyond machine-code generation: <https://clang.llvm.org/docs/CrossCompilation.html>. LLD supports the three required executable formats and can be embedded for compiler-owned trusted objects: <https://lld.llvm.org/> and <https://lld.llvm.org/NewLLD.html>.
|
||||
|
||||
## Proposed Ownership
|
||||
|
||||
```text
|
||||
dever-core
|
||||
checked, target-neutral HIR and source diagnostics
|
||||
|
||||
dever-native
|
||||
fixed target catalog
|
||||
HIR -> LLVM lowering
|
||||
object emission
|
||||
LLD integration
|
||||
target-pack selection
|
||||
owned temporary artifacts
|
||||
|
||||
dever-runtime
|
||||
Dever-owned ABI and runtime behavior
|
||||
six release-built target packs
|
||||
|
||||
dever-cli
|
||||
--target parsing, host/default selection, output and process adaptation
|
||||
```
|
||||
|
||||
`dever-native` is one justified private workspace boundary: it isolates the large native dependency and target mechanics from parsing/checking. It is not a public backend plugin API. Target variation uses one explicit catalog and small data/config adapters, not inheritance or a registry.
|
||||
|
||||
## Build Data Flow
|
||||
|
||||
```text
|
||||
.dever -> check -> typed HIR -> specialize reachable program
|
||||
-> shared LLVM lowering -> target object in memory
|
||||
-> fixed target specification + versioned Dever runtime pack
|
||||
-> embedded LLD driver -> new executable output
|
||||
```
|
||||
|
||||
- LLVM IR and object files are never public output formats.
|
||||
- `dever build` creates a new output and keeps the current no-overwrite contract.
|
||||
- `dever run` always builds the host target and runs it; cross-target execution/emulation is not added.
|
||||
- A cross-target `build` only generates an artifact. Real target runners validate it in release CI.
|
||||
|
||||
## Target Catalog
|
||||
|
||||
The public catalog has exactly six initial targets:
|
||||
|
||||
```text
|
||||
linux-x86_64
|
||||
linux-arm64
|
||||
windows-x86_64
|
||||
windows-arm64
|
||||
macos-x86_64
|
||||
macos-arm64
|
||||
```
|
||||
|
||||
Each target definition owns its LLVM triple, CPU baseline, object format, calling convention, executable suffix, runtime pack and deterministic linker settings. Shared OS and architecture helpers remove repetition; target entries remain explicit and reviewable.
|
||||
|
||||
## Runtime Packs
|
||||
|
||||
Generated code must use a Dever-owned ABI rather than Rust's unstable ABI or Rust `String` layout. Each target pack contains only Dever-owned startup/runtime objects and permitted platform import metadata. It must not contain Rust standard-library objects, Apple SDK files or target compiler binaries.
|
||||
|
||||
The current `dever-runtime` source is a behavior reference, not a stable native ABI. The first backend milestone defines the minimal Text slice, result/fault and `println` ABI once, then supplies six compiled implementations or target-specific adapters behind that contract. Future collections, numbers, HTTP, TLS and storage extend this ABI deliberately rather than reaching into host-language layouts.
|
||||
|
||||
## Determinism And Matrix Verification
|
||||
|
||||
- Pin one LLVM/LLD source revision and runtime-pack version for all six host builds.
|
||||
- Remove timestamps, absolute build paths, random UUID inputs and host-dependent ordering.
|
||||
- Each of six host compilers builds all six targets, producing 36 artifacts.
|
||||
- Artifacts for the same target and source should be byte-identical across hosts. Hash equality reduces execution validation to one artifact per target; all six target artifacts still run on real matching systems.
|
||||
- Offline checks deny network access and remove Cargo, rustc, Xcode, Visual Studio, GCC and target SDKs from PATH.
|
||||
|
||||
## Release And License Gates
|
||||
|
||||
- LLVM permits proprietary distribution but requires its copyright notice in binaries that include it: <https://llvm.org/docs/DeveloperPolicy.html#copyright-license-and-patents>.
|
||||
- No Apple SDK file may enter a target pack. Apple's agreement prohibits separately using SDKs on non-Apple hardware and redistributing Apple Software in part: <https://www.apple.com/legal/sla/docs/xcode.pdf>.
|
||||
- Before implementation commits to the macOS target, prove that both Mach-O targets can link the minimal Dever runtime without redistributed Apple SDK material and pass real-machine execution.
|
||||
- The public-compiler Rust attribution decision in `distribution-licensing.md` remains a product gate.
|
||||
|
||||
## Rejected Shortcuts
|
||||
|
||||
- Do not rename or conceal rustc, Cargo, LLD or license files.
|
||||
- Do not special-case the hello example or patch prebuilt executable templates.
|
||||
- Do not implement one backend per OS.
|
||||
- Do not upload source or HIR when local target support is missing.
|
||||
- Do not silently fall back to a VM, host execution or another target.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "private-compiler-distribution",
|
||||
"name": "private-compiler-distribution",
|
||||
"title": "Dever 私有编译器与公开发行边界",
|
||||
"description": "隐藏 Rust 引导实现并建立不依赖 Rust 工具链的公开发行路径",
|
||||
"status": "planning",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-06",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"09-28-shared-toolchain"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/backend/toolchain-and-library.md","reason":"检查 HTTP/2 配置与运行时公开合同一致"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查测量公平性、计数完整性、资源预算和验证范围"}
|
||||
{"file":".trellis/tasks/09-12-http2-performance/research/current-benchmark-and-h2-api.md","reason":"核对实现是否真正拆分物理连接和并发流"}
|
||||
@@ -0,0 +1,84 @@
|
||||
# 设计
|
||||
|
||||
## 1. 边界与复用
|
||||
|
||||
继续使用 `test/performance` 的“构建与测量分离”结构:`build.py` 只生成并指纹化产物,`run.py` 只启动清单内的二进制并采样。进程、cgroup、直方图、逐 case 落盘和失败保留逻辑全部复用。新增的唯一核心抽象是 HTTP 连接拓扑:
|
||||
|
||||
```text
|
||||
ConnectionShape
|
||||
physical_connections
|
||||
streams_per_connection
|
||||
request_slots = physical_connections * streams_per_connection
|
||||
```
|
||||
|
||||
HTTP/1.1 要求 `streams_per_connection = 1`;HTTP/2 要求复用连接。这个模型避免继续把并发请求与 TCP 连接混为一个 `connections` 参数,也让报告能解释吞吐和内存差异来自哪里。
|
||||
|
||||
## 2. Rust 基准对端与负载端
|
||||
|
||||
`network_bench` 的统一负载入口调整为:
|
||||
|
||||
```text
|
||||
network_bench load h1|h2 URL PATH RATE SECONDS PHYSICAL_CONNECTIONS STREAMS_PER_CONNECTION TIMEOUT_MS REUSE CA
|
||||
```
|
||||
|
||||
所有仓库调用者一次性迁移,不保留第二套旧参数解析。
|
||||
|
||||
- HTTP/1.1 保留现有“一名 worker 独占一条连接”的实现,并要求 streams 为 1。
|
||||
- HTTP/2 在计时前主动建立指定数量的物理连接;每条连接由 Hyper HTTP/2 client driver 持有,`SendRequest` 按该连接的请求槽克隆给 worker。总 worker 数为 shape 的乘积,worker 固定映射到连接,不额外建立连接。
|
||||
- HTTP/2 连接中断后不重试请求或静默补建连接。失败必须进入错误计数,避免恢复逻辑污染吞吐数据。
|
||||
- 两种协议共享现有速率调度、队列、响应校验、延迟直方图和结果守恒逻辑,不复制 metrics/validation。
|
||||
- 结果新增 `http_version`、`physical_connections`、`streams_per_connection`、`request_slots` 和 `successful_handshakes`。H2 的握手数必须等于物理连接数。
|
||||
|
||||
H2 请求使用完整 scheme/authority/path 和 `Version::HTTP_2`。明文使用 prior knowledge;TLS client/server 配置按协议派生 ALPN,H2 只允许 `h2`,不改变运行时用于 WSS 的共享 TLS 配置。
|
||||
|
||||
服务模式新增 `runtime-http2`、`runtime-https2`、`hyper-http2`、`hyper-https2`。`ServiceKind` 集中派生 runtime/TLS/H2 三个维度,避免模式分支散落。直接 runtime 使用 `Http2Limits`;原生 Hyper 使用 `http2::Builder<TokioExecutor>`,显式配置与 N5 相同的 streams、窗口、header/frame/send-buffer 上限。两者继续复用同一响应路由和连接生命周期。
|
||||
|
||||
## 3. Dever 夹具与构建清单
|
||||
|
||||
HTTP 夹具暴露 `http`、`https`、`http2`、`https2` 四个入口。`serve` 接收 `dever.http.Limits` 参数,四个 worker 只负责选择 TLS 和协议配置,避免复制路由与关闭流程。
|
||||
|
||||
`build.py` 增加 HTTP/2 构建参数并写入 manifest:
|
||||
|
||||
- `http2_streams = 16`
|
||||
- `http2_stream_window_bytes = 65535`
|
||||
- `http2_connection_window_bytes = 262144`
|
||||
|
||||
`config.dever` 分别生成 HTTP/1.1 和 HTTP/2 limits;`task_capacity` 继续按有界并发计算,并校验正式拓扑的总请求槽不超过语言上限。旧产物目录和报告不修改,新构建必须使用新的输出目录。
|
||||
|
||||
## 4. Python 运行器
|
||||
|
||||
新增两个显式 suite:
|
||||
|
||||
- `http2`:吞吐、延迟与活跃资源矩阵。
|
||||
- `http2-recovery`:同一服务进程上的多轮连接建立、请求和断开。
|
||||
|
||||
`--http2-connections` 接受物理连接档位,默认 `1,4`;每连接流数从 manifest 读取。`--implementations` 可将 smoke 或 64 MiB 场景限制为 `dever`,正式矩阵默认为 `dever,runtime,hyper`。已有 `http` suite 的 `--concurrency` 和连接新建/复用语义不变。
|
||||
|
||||
吞吐 case 的流程为:启动服务并取得 READY,运行独立 warmup,等待连接释放并采样基线,运行正式负载,同时采样服务端与客户端;负载退出后再次采样以记录 FD/RSS 回落。case 名和 JSON 主键包含实现、传输、路径、速率、物理连接、每连接流和 repeat,避免结果覆盖或混淆。
|
||||
|
||||
恢复 case 使用独立参数 `--recovery-connections`、`--recovery-rate`、`--recovery-path`,复用 `--cycles` 与 `--duration`。每轮启动一次负载进程,在同一服务上建立固定 shape,完成请求后关闭连接;运行器等待稳定窗口并保存该轮末样本。FD 不回到 READY 基线、请求错误、握手数不符或服务提前退出均使 case 失败。RSS 只报告趋势,不以“没有立即回到启动值”单独判断泄漏。
|
||||
|
||||
报告继续保留 `cases.jsonl` 和 `report.json`,并在完整或部分结果上生成结构化 `summary`。摘要按实现、协议、TLS、路径、速率和 topology 分组,对成功 QPS、p99、RSS/PSS/FD 峰值取各 repeat 中位数;不把不同 offered rate 或饱和/未饱和 case 混合。
|
||||
|
||||
## 5. 测量矩阵
|
||||
|
||||
正式测量固定服务端 1 worker、1 CPU、128 MiB cgroup、swap=0;客户端绑定另一 CPU且位于服务预算之外。每组 0.5 秒预热、2 秒正式负载、3 个独立服务进程 repeat:
|
||||
|
||||
| 正文 | offered rate | topology | 实现 | 传输 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `/plain` 5 B | 5k / 15k / 30k req/s | 1x16、4x16 | Dever/runtime/Hyper | h2c、TLS+h2 |
|
||||
| `/bytes` 64 KiB | 100 / 500 / 1k req/s | 1x16、4x16 | Dever/runtime/Hyper | h2c、TLS+h2 |
|
||||
|
||||
另运行 10 轮、1x16、`/plain` 的恢复矩阵,以及 64 MiB 下 Dever 的 1x16 明文/TLS 活跃代表场景。若当前机器不能创建指定 cgroup,工具必须明确失败;不得回退为无预算结果后仍标记完成。
|
||||
|
||||
## 6. 解释与决策
|
||||
|
||||
- 主要比较同一 H2 topology 下 Dever、runtime 和 Hyper 的差异,定位语言桥接、运行时结构化任务和底层协议栈各自成本。
|
||||
- 1x16 与 4x16 比较多路复用和多物理连接扩展;它们不是“连接数相同”的横向比较。
|
||||
- `/bytes` 的速率按响应字节量降低,避免只测客户端或 loopback 带宽耗尽;65536 字节正文会越过 65535 字节默认单流窗口,可观察 WINDOW_UPDATE 路径。
|
||||
- `dropped_capacity` 表示负载端无法在计划时间取得请求槽,不算服务错误;分析时必须同时看 actual QPS、请求延迟和服务 CPU,不能只看 offered rate。
|
||||
- 不设预定胜负线。若 Dever 相对 direct runtime 出现稳定的大幅差距,或内存随 recovery 周期持续增长,则把证据和复现命令交给后续独立优化任务。
|
||||
|
||||
## 7. 回退形态
|
||||
|
||||
本阶段不迁移生产 API。若 H2 基准实现无法通过独立 smoke,删除新增 suite、服务模式和夹具入口即可回到原有 H1 基准;历史产物目录不受影响。任何正式测量都写入新的 `target/performance/*` 目录,失败结果保留且不覆盖旧报告。
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/backend/toolchain-and-library.md","reason":"HTTP/2 streams、窗口、ALPN、结构化任务和资源预算合同"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"独立二进制基准、cgroup、报告和定向验证规则"}
|
||||
{"file":".trellis/tasks/09-12-http2-performance/research/current-benchmark-and-h2-api.md","reason":"现有 H1 基准限制、锁定 Hyper API 和 H2 负载模型结论"}
|
||||
@@ -0,0 +1,50 @@
|
||||
# 执行计划
|
||||
|
||||
## 1. 任务启动
|
||||
|
||||
- [x] 用户审核本 PRD、设计和执行计划后,运行 `task.py start`;启动前不改产品或基准源码。
|
||||
- [x] 使用 `trellis-before-dev` 重新加载 backend 规范,核对工作区状态和已有性能产物,不清理用户文件。
|
||||
|
||||
## 2. 基准协议模型
|
||||
|
||||
- [x] 在 `network_bench` 中加入 `h1|h2` 与 `ConnectionShape` 参数校验;H1 限制每连接一槽,H2 限制复用且乘积有界。
|
||||
- [x] 保留一个速率调度和 metrics 路径;把 H1 独占连接与 H2 共享 sender 封装为两个小的协议执行分支,不引入通用 trait 层级。
|
||||
- [x] H2 计时前建立固定数量物理连接,按连接分配 sender clone,关闭时排空/终止 driver;不重试、不自动扩容。
|
||||
- [x] 在结果中输出并校验协议、shape 和握手计数,继续保证全部请求计数守恒和完整正文校验。
|
||||
|
||||
## 3. 三类服务实现
|
||||
|
||||
- [x] 扩展 `ServiceKind` 和 TLS ALPN 派生,新增 runtime/raw Hyper 的 h2c 与 TLS+h2 模式。
|
||||
- [x] direct runtime 与 raw Hyper 使用相同的 streams、窗口、header/frame/send-buffer 约束;复用响应路由、accept 限额和关闭流程。
|
||||
- [x] 重构 Dever HTTP fixture,让四个入口共享 serve/route/lifecycle,只在入口选择协议和 TLS。
|
||||
- [x] 扩展构建配置与 manifest,生成并指纹化 `http2`、`https2`,保留 H1 产物。
|
||||
|
||||
## 4. 运行器与摘要
|
||||
|
||||
- [x] 增加 `http2`、`http2-recovery` suite、物理连接档位、实现筛选及恢复参数,并按 manifest 校验 capacity、streams 和窗口。
|
||||
- [x] HTTP/2 吞吐 case 记录 warmup、正式负载、断开后样本、服务/客户端资源和 cgroup 状态;case 标识包含完整维度。
|
||||
- [x] 恢复 case 在一个服务进程内运行多轮固定 shape,逐轮校验请求和握手并记录 FD/RSS 回落。
|
||||
- [x] 从逐 case 结果生成分组中位数 summary;失败时仍保存已完成 case 和可诊断日志。
|
||||
|
||||
## 5. 定向验证
|
||||
|
||||
- [x] 运行 `python3 -m unittest discover -s test/performance -p 'test_runner.py' -v`,覆盖参数、计数、summary、进程回收和错误路径。
|
||||
- [x] 离线 release 构建 `network_bench`,运行现有独立 Python H1 对端测试,确认一次性 CLI 迁移没有改变 H1 测量语义。
|
||||
- [x] 运行 H2 loopback smoke,覆盖 1 条物理连接、至少 16 个请求槽、明文/TLS、`/plain`/`/bytes`、三类服务模式和错误正文拒绝。
|
||||
- [x] 用新的输出目录构建 Dever 性能夹具,核对 manifest 指纹、HTTP/2 limits 和四个 HTTP 入口。
|
||||
- [x] 运行最小 `http2`/`http2-recovery` runner smoke,检查失败报告、FD 回落和原始计数完整性。
|
||||
|
||||
## 6. 有界正式测量
|
||||
|
||||
- [x] 先确认可用 CPU affinity、cgroup v2 cpu/memory 控制器、磁盘和已有服务边界,再说明将执行的有界命令。
|
||||
- [x] 运行 128 MiB `/plain` 矩阵:5k/15k/30k,1x16/4x16,三实现、h2c/TLS、3 repeats。
|
||||
- [x] 运行 128 MiB `/bytes` 矩阵:100/500/1000,1x16/4x16,三实现、h2c/TLS、3 repeats。
|
||||
- [x] 运行 128 MiB 10 轮恢复矩阵,并运行 64 MiB Dever 1x16 明文/TLS 活跃场景。
|
||||
- [x] 核对全部报告 status、errors、dropped、OOM、FD、RSS/PSS、CPU 和摘要分组;不以单次异常样本下结论。
|
||||
|
||||
## 7. 收尾审查
|
||||
|
||||
- [x] 更新 `test/performance/README.md` 和 `.trellis/spec/backend/quality-guidelines.md` 的 CLI、矩阵、实测结果和边界说明。
|
||||
- [x] 检查最终改动是否仍有重复协议分支、混合职责或无必要依赖;确认未修改生产运行时、旧报告和用户文件。
|
||||
- [x] 运行任务 JSONL 校验、Python 语法检查及受影响的最小回归;不运行全量测试。
|
||||
- [x] 记录实际通过、失败、跳过和不可用项;完成 Trellis 检查与会话记录,不提交 Git。
|
||||
@@ -0,0 +1,40 @@
|
||||
# HTTP/2 性能基准
|
||||
|
||||
N5 已完成 HTTP/2 服务端、客户端和连接池的功能接入,但现有独立二进制基准仍只测 HTTP/1.1。当前阶段为 HTTP/2 建立可重复的小机器性能证据,不凭功能测试或理论值宣称性能提升。
|
||||
|
||||
## 需求
|
||||
|
||||
- 扩展现有显式运行的独立二进制基准,新增 HTTP/2 suite;保留已有 HTTP/1.1、异步和长连接基准的语义及历史报告。
|
||||
- 覆盖明文 prior knowledge 和 TLS ALPN 两种 HTTP/2 传输,统一比较原生 Dever 程序、直接 `dever-runtime` 和原生 Hyper 三种实现。
|
||||
- 负载模型必须把物理 TCP 连接数与每连接并发流数分开。每个报告明确记录协议、TLS、物理连接数、每连接请求槽、总请求槽、服务端流/窗口配置和正文大小。
|
||||
- 至少覆盖单连接多流和多连接多流;请求必须校验状态码、Content-Type 和完整正文,所有计划、发送、完成、失败及丢弃请求均可核对。
|
||||
- 记录实际成功吞吐、总延迟/排队延迟/请求延迟的 p50/p95/p99,以及服务端和客户端 RSS、PSS、峰值、线程、FD、CPU 和 cgroup 内存/OOM 数据。
|
||||
- 同一服务进程上重复建立、使用和关闭 HTTP/2 连接,记录每轮断开后的 FD/RSS 差值,验证物理连接能够回收且内存不按轮持续增长。
|
||||
- 生成按实现、传输、路径、速率和连接拓扑分组的中位数摘要,同时保留逐 case 原始样本和失败时已完成的数据。
|
||||
- 完成 1 worker、1 服务 CPU、128 MiB、无 swap 的正式矩阵,并完成 64 MiB 下的 Dever 活跃 HTTP/2 代表场景;负载端使用独立 CPU且不计入服务预算。
|
||||
|
||||
## 约束
|
||||
|
||||
- 本任务只改基准夹具、基准对端、运行器、定向测试和对应性能文档;不修改 `crates/dever-runtime`、编译器或语言公共 API。基准揭示的生产瓶颈另建优化任务,避免边测边改导致基线失效。
|
||||
- 复用仓库锁定的 Hyper 1.11.1、h2 0.4.19、rustls 和现有进程/cgroup 采样器,不引入新的协议栈或压测依赖。
|
||||
- HTTP/2 明文只使用 prior knowledge;TLS 只协商 `h2`,不降级 HTTP/1.1。HTTP/1.1 每连接请求槽固定为 1,HTTP/2 只测复用连接,不测每请求新建连接。
|
||||
- 服务端与客户端固定使用 N5 默认值:每连接 16 流、65535 字节单流接收窗口、262144 字节连接接收窗口,以及运行时已有的 16 KiB frame/send-buffer 上限。
|
||||
- `/plain` 用于小响应调度和协议开销,`/bytes` 使用 65536 字节正文并跨越默认单流窗口;`/json` 不进入正式矩阵,因为当前夹具只返回预制 JSON,信息与小响应重复。
|
||||
- 不设与机器无关的绝对 QPS 或内存门槛。错误、OOM、计数不守恒和 FD 未回落属于失败;容量丢弃与相对性能只作为结果,不伪装成通过或失败阈值。
|
||||
- 仅运行本任务的 Python 单元测试、独立 loopback smoke、必要的离线 release 构建和显式有界性能命令;不运行全量测试,不替换全局二进制,不接触已有服务,不提交 Git。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] HTTP/1.1 旧入口仍通过现有独立 Python 对端测试;HTTP/2 smoke 证明一条实际物理连接承载至少 16 个并发请求槽,明文和 TLS 均完整成功。
|
||||
- [x] 构建清单包含 `http2`/`https2` Dever 产物、HTTP/2 配置及所有二进制指纹;运行阶段只使用清单中的已构建产物。
|
||||
- [x] `http2` suite 覆盖三种实现、两种传输、`/plain` 与 `/bytes`、`1 x 16` 与 `4 x 16` 两种拓扑,并保存可核对的逐 case 数据与聚合摘要。
|
||||
- [x] `http2-recovery` suite 在同一服务进程上完成至少 10 轮连接周期;每轮请求正确,断开后 FD 回到 READY 基线,且报告保留 RSS 变化序列。
|
||||
- [x] 128 MiB 正式矩阵中无请求错误、无 OOM;丢弃请求如实报告。64 MiB 代表场景无 OOM,并报告峰值而非只报告进程 RSS。
|
||||
- [x] 文档写明运行命令、环境、矩阵、限制和实测结论;不得将 HTTP/2 结果外推为所有后端负载或真实网络的性能保证。
|
||||
- [x] 定向测试覆盖参数校验、计数守恒、连接拓扑字段、摘要分组、H2 明文/TLS 正文校验、进程失败和连接回收;相关检查全部通过。
|
||||
|
||||
## 排除项
|
||||
|
||||
- RFC 8441 WebSocket-over-HTTP/2、h2c Upgrade、HTTP/3、服务器推送、非空 trailers、HTTP/2 客户端自动重试。
|
||||
- 小时级 soak、跨主机网络、丢包/限速故障注入、真实公网站点和多平台硬件矩阵。
|
||||
- 本阶段根据一次测量直接改写运行时热路径或承诺固定的 Fiber/Go 对比数字。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 现有基准与 HTTP/2 API 调研
|
||||
|
||||
## 现有结构
|
||||
|
||||
- `test/performance/build.py` 负责生成 Dever fixtures、复制 Rust peers、记录配置和 SHA-256;`run.py` 只执行已指纹化产物。这一分离可直接复用。
|
||||
- 当前 HTTP suite 覆盖 Dever、direct runtime、raw Hyper,但三者都只使用 HTTP/1.1。`config.dever` 明确写入 `http2 = null`。
|
||||
- 当前 loader 的 `connections` 同时决定 worker 数和连接数,每个 worker 串行持有一条 HTTP/1.1 连接;它无法证明 HTTP/2 多路复用。
|
||||
- 现有 runner 已有固定速率调度、三类延迟直方图、进程 RSS/PSS/FD/CPU 采样、cgroup memory/peak/OOM 采样、逐 case 落盘和异常清理,不应重写。
|
||||
- `live` suite 已有多轮连接后 FD/RSS 差值的报告方式,可复用其 phase 后采样语义,但 HTTP/2 负载和连接创建应继续由 `network_bench` 负责。
|
||||
|
||||
## N5 已有合同
|
||||
|
||||
- `Http2Limits` 的公开维度为 streams、stream window 和 connection window;默认分别为 16、65535、262144。
|
||||
- cleartext HTTP/2 使用 prior knowledge;TLS 配置必须协商 `h2`,不能降级或影响 WSS 的 HTTP/1.1 TLS 配置。
|
||||
- 客户端按流租约复用物理连接,服务端连接和流均受限;固定 16 KiB frame/send-buffer,关闭自适应窗口。
|
||||
- `/bytes` fixture 为 65536 字节,恰好跨过默认 65535 字节单流窗口,适合覆盖 flow-control 热路径。
|
||||
|
||||
## 锁定依赖可用能力
|
||||
|
||||
- Cargo.lock 固定 Hyper 1.11.1、h2 0.4.19、rustls 0.23.44 和 tokio-rustls 0.26.5,不需要新依赖。
|
||||
- Hyper HTTP/2 `SendRequest` 实现 `Clone`,可让多个请求 worker 共享同一物理连接的 driver。
|
||||
- Hyper HTTP/2 client/server builder 可显式设置 initial stream/connection window、adaptive window、max concurrent streams、max frame、max send buffer 和 header list size。
|
||||
- Hyper HTTP/2 request future 的取消会对单流发送 RST_STREAM;基准不需要自己实现帧协议。
|
||||
|
||||
## 设计结论
|
||||
|
||||
1. 物理连接与请求槽必须拆开;只给现有 loader 换 HTTP/2 handshake 会得到错误的“一 worker 一连接”模型。
|
||||
2. H2 连接应在计时前主动建立,worker 固定映射到已建 sender;这样报告的物理连接数由 loader 自己控制和证明。
|
||||
3. H1 和 H2 共享调度、计数和响应校验,但连接执行策略分开。不存在足够稳定的公共行为值得引入 trait 或继承式抽象。
|
||||
4. raw Hyper 对照必须显式复制 N5 的协议参数;使用 Hyper 默认值会把配置差异误判为 Dever/runtime 开销。
|
||||
5. 性能任务不同时修改生产 runtime。测量发现的瓶颈需要在固定基线后另立任务验证,否则优化前后结果不可复现。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "http2-performance",
|
||||
"name": "http2-performance",
|
||||
"title": "HTTP/2 performance benchmarks",
|
||||
"description": "扩展现有独立二进制基准,测量小内存下 HTTP/2 多路复用的吞吐、尾延迟、内存和连接回收。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": "test/performance + test/dever-tests/examples/network_bench* + performance docs/spec",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-12",
|
||||
"completedAt": "2026-09-13",
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-04-dever-language-mvp",
|
||||
"relatedFiles": [],
|
||||
"notes": "HTTP/2 benchmark complete: 224 formal cases plus 60 recovery cycles, fixed 1x16/4x16 topology, h2c/TLS and Dever/runtime/Hyper comparisons. All request errors, OOM and FD deltas were zero; 64 MiB active Dever cases passed. Sixteen runner tests, three loopback tests, release cargo check, Dever fmt-check and independent review passed. No full suite, Rustfmt/Clippy, commit or archive.",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/backend/toolchain-and-library.md","reason":"检查 HTTP2 的结构化归属、资源容量和桥接"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"定向测试和无全量测试约束"}
|
||||
{"file":".trellis/tasks/09-12-http2/prd.md","reason":"完整范围和验收"}
|
||||
{"file":".trellis/tasks/09-12-http2/design.md","reason":"协议配置与生命周期"}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 设计
|
||||
|
||||
## API 与复用
|
||||
|
||||
在官方 `dever.http.Limits` 增加 `http2: Http2Limits?`;`Http2Limits` 三个 Int 字段为 `streams`、`stream_window_bytes`、`connection_window_bytes`。默认关闭 HTTP/2,`default_http2_limits()` 返回 16 流、65535 字节单流窗口、262144 字节连接窗口。沿用现有 serve/serve_tls/serve_live/client/open/open_stream/send,不增加平行 API。
|
||||
|
||||
`Limits.check` 统一校验;HTTP/2 streams 和 connections 的乘积不得超过 65536,窗口遵循 HTTP/2 31 位限制(连接窗口至少 65535)。关闭自适应窗口,保持帧和发送缓存有界。源语言 exact-record 检查与原生结构转换同步修改。
|
||||
|
||||
## 运行时边界
|
||||
|
||||
- 协议配置、Hyper builder 和 HTTP 专用 TLS ALPN 配置集中在 HTTP 模块,原有 TLS trust/session cache 和 Transport 复用。不能修改共享 TLS 配置使 WSS 错误协商 h2。
|
||||
- 编解码复用 message;按协议校验头字段,HTTP/2 使用路径查询而不是绝对 URI 作为 source Request.target,authority 与 Host 一致。
|
||||
- HTTP/1 仍独占连接 lease;HTTP/2 持有共享连接与独立流 lease。EOF 或取消释放流,只有连接 owner 结束才释放物理连接许可。空闲回收不能中止活跃流。
|
||||
- Hyper Executor 必须接入已有结构化任务 Scope,不能直接用脱离作用域的 TokioExecutor。每流清理独立,程序故障明确传给服务任务,peer reset 只结束该流。
|
||||
- GOAWAY 排空与业务取消分开;应用已有 timeout(server, grace_ms, fallback) 控制最长排空时间。
|
||||
|
||||
## 验证
|
||||
|
||||
仅运行本次相关 Rust 定向测试、编译器官方库检查和隔离 loopback 原生程序。独立 HTTP/2 对端验证真正单连接多流、SETTINGS/流控、RST_STREAM、GOAWAY、ALPN 和请求语义。保留已有性能报告;缓存可重建部分暂存到专用 /dev/shm 目录以腾出构建空间。
|
||||
|
||||
协议依据:[RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html) 与当前锁定 Hyper 1.11.1 源码。
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file":".trellis/spec/backend/directory-structure.md","reason":"Rust 语言分层与 root test 布局"}
|
||||
{"file":".trellis/spec/backend/toolchain-and-library.md","reason":"HTTP、TLS、流、结构化任务及原生桥接合同"}
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"程序故障与 peer 失败边界"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"有界定向验证与原生测试"}
|
||||
{"file":".trellis/tasks/09-12-http2/design.md","reason":"本阶段已批准接口与生命周期设计"}
|
||||
@@ -0,0 +1,49 @@
|
||||
# 执行记录
|
||||
|
||||
## 实现
|
||||
|
||||
- [x] 用户批准方案后实施,复用 Hyper 1.11.1;启用 http2 feature,锁定 h2 0.4.19 和四个传递依赖,不引入第二套协议栈。
|
||||
- [x] 官方 Limits.http2/Http2Limits、精确记录检查和仓库调用者一起迁移;原生桥接复用 Nullable/Record 转换,无新增 intrinsic。
|
||||
- [x] protocol/message 集中拥有流控预算、ALPN 派生、authority/Host 和方向相关的头校验。
|
||||
- [x] buffered/live 服务端和 single-use/pooled 客户端接通;流式上传、下载、SSE、GOAWAY 和取消沿用现有 API。
|
||||
- [x] ScopedOwner/ScopedSpawner 接入已有 Task Scope、全局容量和 blocking 清理;每流独立 Group,物理许可持有到协议后代清理完成。
|
||||
- [x] 按工作流完成服务端/客户端实现分工及独立并发、全范围检查;主代理复查关键所有权和运行证据。
|
||||
- [x] 更新现有 LANGUAGE、IMPLEMENTATION、README、toolchain-and-library 和性能说明;P1/P2 报告仍保留为 HTTP/1 数据。
|
||||
- [x] 最终定向验证与检查闭环。
|
||||
- [x] 完成任务状态、会话记录和自有临时目录清理,不提交。
|
||||
|
||||
## 复现及修正
|
||||
|
||||
- Hyper Executor 不携带 Dever CURRENT_SCOPE:协议 future 改由连接 owner 创建子 Scope;关闭 admission 与登记使用同一把锁。故障报告/注销先于完成信号;不可取消 supervisor 排空后代,等待者被取消也不能遗失 completion。
|
||||
- 池握手的连接 owner 必须在持久 driver 首次 poll 时创建,不能归临时请求 Scope。
|
||||
- RST/正文 idle 超时使背压 write 同时失败:先记录传输取消,避免把该流结束当成全局 handler 故障;真正程序错误仍保留原消息。发布失败时先释放 state 锁再析构响应体,避免取消标记重入锁。
|
||||
- GOAWAY 后 Hyper dispatch ready 不等于能建新流:仅 TrySendError 结构化返回的未发送请求可在原期限内重排;先释放旧上传/流租约,不重放已发送请求。
|
||||
- HTTP/2 Response TE 必须显式拒绝,不能交给 Hyper 静默删除;请求 TE 只接受 trailers。
|
||||
- 测试修正:Dever 子任务用 await;静默 h2 peer 可能先收到 SETTINGS,需读到 EOF;独立 h2 的 FlowControl/RecvStream/SendStream 在等 driver 结束前释放。共享原生 fixture 的 HTTP/1 服务端保留三条连接容量,两条供池内嵌套代理,一条供独立 WSS;HTTP/2 客户端仍只允许一条物理连接。
|
||||
|
||||
## 验证约束
|
||||
|
||||
使用 PATH 指向已有 stable 工具链、CARGO_TARGET_DIR=target/native-runtime、TMPDIR=/dev/shm/dever-http2-native.fW3PNS,Cargo 均 --offline --locked --release。一轮 fetch 后不再联网。仅自有随机 loopback 端口和 test/ 下测试证书,不运行全量测试、既有服务、性能矩阵或全局安装。
|
||||
|
||||
首轮 CLI release 构建和 backend 示例 check 通过。官方库 fmt-check 仅报告原有 net.dever 格式差异;修改的 http.dever 无差异。Rustfmt/Clippy 未安装,不作为通过项。
|
||||
|
||||
低磁盘处理仅将可再生 target/debug/{incremental,deps,build} 暂存到 /dev/shm/dever-http2-cache.wOoaI9;现有 debug/deverc、源码和 target/performance 报告保留。最后统一记录验证结果与缓存清理。
|
||||
|
||||
## 最终测试证据
|
||||
|
||||
88 个不同定向用例全部通过(修复后重跑不重复计数):http2_client 6、http2_server 6、http_engine 12、http_library 7、live_network 16、pooled_network 14、pooled_library 6、async_runtime 10、structured_concurrency 11。
|
||||
|
||||
- 独立 h2 对端证明一条物理连接的并发请求、流式上传/下载、RST 前后隔离、背压写取消、WINDOW_UPDATE 恢复、流上限与活跃正文不会被 idle 回收、GOAWAY 的物理预算与连接替换、TLS ALPN 成功/拒绝、Host/target、413 与原始 handler 故障。
|
||||
- 原生 Dever 程序证明 single-use HTTP/2、TLS 同池嵌套代理(客户端只有一个物理名额)、上传、SSE 和 handler 内 run/await。共享 HTTP/1 变体另覆盖 WSS 与优雅停机;测试服务端三连接容量的修正经实际运行确认。
|
||||
- 最新响应发布取消/锁序改动已重新编译,并由 http2_server 6 项和 live_network 16 项覆盖;已有结构化任务、HTTP/1 连接池与源码语义检查通过。
|
||||
- 源码空白/冲突标记扫描、性能构建器 Python 语法和任务 context JSONL 验证通过。Rustfmt/Clippy 未安装;未运行性能矩阵、全量测试或既有服务验证。
|
||||
|
||||
复现上述范围:
|
||||
|
||||
```bash
|
||||
cargo test --offline --locked --release -p dever-tests --test http2_client --test http2_server --test http_engine --test http_library --test live_network --test pooled_network --test pooled_library --test async_runtime --test structured_concurrency
|
||||
```
|
||||
|
||||
最终 CLI release 构建通过,产物为 `target/native-runtime/release/deverc`;backend 示例的实际 CLI check/fmt-check 均无输出并退出 0。迁移后的 network_bench example 及启用 reference 的 core/runtime 离线 release check 通过。未替换全局命令。
|
||||
|
||||
独立审查与主代理最终所有权、调用链、规范和改动范围核对闭环,无剩余生产阻断。清理专属 shm 构建目录,并删除暂存的约 378 MiB 可重建 debug 中间缓存;源码、现有编译器和全部性能报告保留。按用户约束手动更新状态/日志,不提交、不调用自动提交归档流程。N5 完成,HTTP/2 性能测量、RFC 8441、数据库与发行等仍在后续路线图。
|
||||
@@ -0,0 +1,16 @@
|
||||
# N5 HTTP/2
|
||||
|
||||
用户已批准上一阶段提出的 HTTP/2 接入方案,并要求继续实现。复用 Hyper,面向小机器控制运行内存;不实现旧源码兼容层,不改运行时根任务调度策略。
|
||||
|
||||
## 范围与验收
|
||||
|
||||
- buffered/live 服务端和 single-use/pooled 客户端支持 HTTP/2;HTTPS 必须通过 ALPN 协商 h2,明文为 prior knowledge。
|
||||
- `Limits.http2: Http2Limits?` 显式选择协议;null 为 HTTP/1,非空为 HTTP/2。配置包含 streams、stream_window_bytes、connection_window_bytes,连接限额仍由 connections 持有。仓库调用者直接迁移。
|
||||
- 连接内复用请求,流式上传/下载与 SSE 复用现有正文接口;并发流、窗口、发送缓存有界,单流中止不取消相邻流。
|
||||
- Hyper 内部任务保留 Dever 的作用域归属;子任务、已启动 blocking、取消及故障完整排空,容量持有到清理完成。
|
||||
- 监听器关闭发送 GOAWAY 并排空在途流;客户端不向已 GOAWAY 连接派发新请求,不自动重试已发送请求。
|
||||
- 使用独立线协议对端和原生 Dever 程序验证多路复用、TLS ALPN、流控、取消隔离和生命周期,选取 HTTP/1 与 WS 回归。
|
||||
|
||||
## 排除
|
||||
|
||||
本阶段不接 RFC 8441 WebSocket-over-HTTP/2、h2c Upgrade、服务器推送、非空 trailers、自动协议降级,不跑全量测试或重新做性能矩阵,不替换全局二进制,不提交 Git。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "http2",
|
||||
"name": "http2",
|
||||
"title": "HTTP/2 server and multiplexed client",
|
||||
"description": "复用 Hyper 接入 HTTP/2 服务端、客户端多路复用、TLS ALPN、流控和结构化取消;验证流式正文与 SSE,保留 HTTP/1 和普通 WebSocket。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-12",
|
||||
"completedAt": "2026-09-12",
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-04-dever-language-mvp",
|
||||
"relatedFiles": [],
|
||||
"notes": "N5 complete: 88 focused cases passed, native HTTP/2/TLS proxy/upload/SSE and HTTP/1/WSS verified; CLI build/check and independent review passed. No performance matrix, global install or commit.",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Benchmark verification contracts"}
|
||||
{"file":".trellis/tasks/09-12-runtime-performance-p2/prd.md","reason":"Scope and acceptance"}
|
||||
{"file":".trellis/tasks/09-12-runtime-performance-p2/design.md","reason":"Semantic comparison and lifecycle"}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Design
|
||||
|
||||
现有 test/performance 负责产物和采样,Rust examples 负责压力对端。保留该分工,不增加第二套预算与报告框架。
|
||||
|
||||
异步对照使用独立 async_bench example:相同 run/wait、checked Int 加法、输入与批次;runtime-root 与 runtime-worker 仅改变批次所在任务。worker 模式增加一个结构化外层任务,不修改生产调度。对照程序输出 P1 SAMPLE 协议,Python 复用校验和与样本汇总。
|
||||
|
||||
长连接使用新增 Dever fixture 与 Rust 对端。Rust 对端有固定连接上限、阶段标记、消息校验和超时;Python 根据阶段采样同一个服务进程,复用 Process/Budget。每轮连接、保持、断开后等待采样,最后重连探测服务可用。TCP/WS 做小正文交互;SSE 校验有限事件,保留流直到断开。
|
||||
|
||||
高负载扫描复用现有 HTTP load,不重写压测器。使用独立 CPU 亲和性和 128 MiB 服务预算,合理短窗口与重复次数;保留客户端瓶颈和短窗口限制。
|
||||
|
||||
HTTP/2 只做本地依赖/池/协议边界核查:Hyper h2、TLS ALPN、流与连接预算、复用客户端、GOAWAY、取消隔离;HTTP/1 WebSocket 升级保持独立。
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/backend/index.md","reason":"Rust language owning layers"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Bounded standalone benchmark contracts"}
|
||||
{"file":".trellis/tasks/09-12-runtime-performance-p2/prd.md","reason":"Approved scope and acceptance"}
|
||||
{"file":".trellis/tasks/09-12-runtime-performance-p2/design.md","reason":"Reuse and ownership"}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Execution
|
||||
|
||||
- [x] 主代理:async_bench、Python 集成、HTTP 扫描、采样、结果分析与 HTTP/2 就绪评估。
|
||||
- [x] trellis-implement:long connection Rust 对端及 Dever fixture,独立文件所有权,不修改生产代码。
|
||||
- [x] 定向 offline release 构建、生成原生 fixture、校验工具错误路径、执行有界矩阵。
|
||||
- [x] trellis-check:复查统计、语义、连接清理和最终结果;主代理复核关键证据。
|
||||
- [x] 更新现有性能 README、任务与日志,不提交。
|
||||
|
||||
磁盘空间有限;复用 target/native-runtime 的 release 依赖。原生构建 TMPDIR 使用任务独有 /dev/shm 临时目录,结束移除自有目录。不并行执行 Cargo 构建。
|
||||
|
||||
## 已取得证据
|
||||
|
||||
- HTTP/HTTPS 高负载完整 54 组,report: target/performance/http-load-p2-final/report.json。1800000 计划、1500830 实际成功、0 错误、299170 未发出;三实现均有压测槽位丢弃,不能推断服务端吞吐上限。无 OOM。
|
||||
- 首次 http-load-p2 在子进程退出采样竞态中断:/proc memory teardown 早于 waitpid 状态。已修复采样拥有层,补定向回归,保留中断报告。
|
||||
- run/wait Rust 对照用生产 task runtime、Bytes 和 checked Int;build 工具使用与 native 相同优化参数和 runtime rlib。async-p2 已完成 18 组:Dever root/worker 15.50/2.003 µs,Rust 15.59/1.984 µs,三轮校验均通过且无 OOM。独立 perf 确认 root/worker 线程切换为 465149/56 次;生产运行时未修改。
|
||||
- 独立 review 修正了 live 总超时遗漏关闭阶段预算、SSE 保持期无心跳仍通过的问题;静态发现并修正 SSE driver 早期错误所有权和任意截止点半帧误判。所有修改均在 benchmark 工具/fixture 内。
|
||||
- 首次 live-p2 消息校验通过,但发现阶段读/采样间跨轮竞态:下一轮 FD 混入上轮 disconnected。修复为采样前后 progress 必须一致,丢弃过渡阶段观察,并新增回归。最终 live-p2-final 完整重跑 27 组、18144 连接、377061 消息;错误/OOM 为零,81 次断开 FD 差值均为零。
|
||||
- live-cycles-p2 对三个协议各做 512 连接十轮重连、每轮 3 秒;15360 连接、471207 消息,错误/OOM 为零,30 次 FD 均回基线。SSE RSS 在前三轮升到约25 MiB,后七轮在25.24–25.36 MiB;TCP约8.8、WS约17 MiB。短时证据不宣称小时级稳定性。
|
||||
- 12 个 Python 运行器定向检查、4 个独立 TCP/SSE 对端检查通过;11 个 Dever 入口生产优化构建和两个新 Rust example 离线 release/原生优化构建通过。Dever fmt-check、Python解析、源码空白/冲突和范围检查通过;Rustfmt/Clippy 不可用,未运行。
|
||||
- /dev/shm/dever-perf-p2.E84QV8 已空并移除。保留原始构建/剖析/中断与最终报告以供复核;不提交,不运行自动归档或自动日志提交脚本。
|
||||
- 最终独立检查无剩余阻断;主代理复核四份最终报告、全部 OOM/FD 计数及无残留预算组。P2 完成,可进入 HTTP/2 阶段,未将实验调度变化当作已实现的生产优化。
|
||||
@@ -0,0 +1,12 @@
|
||||
# Runtime performance P2
|
||||
|
||||
用户已批准继续上一阶段;本阶段完成同语义异步对照、高负载 HTTP/HTTPS 扫描和 TCP/WebSocket/SSE 有界长连接测试,并回答 HTTP/2 的接入条件。HTTP/2 实现留到下一阶段。
|
||||
|
||||
## 验收
|
||||
|
||||
- Rust 对照调用现有 Dever task runtime,保留 run/wait 的任务额度、结构化取消和清理;单独标注工作线程调度实验的边界。
|
||||
- 复用 P1 构建、进程采样、cgroup 和报告逻辑,记录校验和、样本、CPU、上下文切换、RSS/PSS 与环境;不以不同语义的裸 Tokio 数字宣称语言损耗。
|
||||
- HTTP/HTTPS 固定正文在更高输入速率下记录成功、错误、容量丢弃、调度落后及服务端/客户端 CPU;限速结果不宣称极限吞吐。
|
||||
- TCP、WebSocket、SSE 支持有限数量的长连接保持、正文验证和多轮断开重连;记录服务端连接前、保持中、断开后的内存和 FD。
|
||||
- 运行仅限自有 loopback 服务和独立预算组;所有任务、连接和进程有明确停止边界,结束无残留。执行最小定向测试,不运行全量测试、不修改全局安装、不提交。
|
||||
- 不添加兼容分支、无证据的运行时重构或新依赖;发现实际阻断后再定位归属。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "runtime-performance-p2",
|
||||
"name": "runtime-performance-p2",
|
||||
"title": "Runtime performance and long connections",
|
||||
"description": "Continue approved runtime benchmarks: equivalent async Rust comparison, bounded HTTP load scan and TCP/WebSocket/SSE connection recovery; assess HTTP/2 readiness without implementing it.",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-12",
|
||||
"completedAt": "2026-09-12",
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-04-dever-language-mvp",
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Benchmark validation and evidence limits"}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 测量边界
|
||||
|
||||
现有 performance.rs 拥有计算热路径基准,保持原有语义与 1.15 门槛。新增根 test/performance/ 的 Python 标准库驱动,分离构建、进程采样、场景执行和报告。Dever fixtures 经过现有 CLI build 成为独立二进制。Rust 辅助程序作为 dever-tests 的显式 example,复用 workspace 已锁定的 Tokio/Hyper/Rustls。
|
||||
|
||||
网络辅助程序同时提供等价 Rust runtime / 原生 Hyper 对照服务和独立负载端;不自行解析 HTTP。固定速率调度以计划发送时间计算延迟,并记录有界并发容量不足或调度逾期导致的丢弃。TLS 校验证书和主机名,复用已有测试 CA;新连接与连接复用分别测量。
|
||||
|
||||
进程通过 READY|port(空闲/计算用 READY|0)报告可采样状态。配置统一使用 BENCH_WORKERS、BENCH_CONNECTIONS、BENCH_TIMEOUT_MS、BENCH_LIFETIME_MS、BENCH_CERT、BENCH_KEY 等环境变量。负载 CLI 为 network_bench load URL PATH RATE SECONDS CONNECTIONS TIMEOUT_MS REUSE CA,JSON 标准输出。服务 CLI 为 network_bench runtime-http/runtime-https/hyper-http/hyper-https。各服务 /plain 返回 hello,/json 返回 {"ok":true},/bytes 返回 65536 个 x;带对应 Content-Type,不启用日志。
|
||||
|
||||
Linux /proc 采样独立于子程序;cgroup 为可选且显式启用的进程预算,使用新建的专属子组,不调整宿主或已有服务的 cgroup。负载发生器不进入服务预算。默认不声称隔离或峰值吞吐;报告采样间隔、负载端 CPU 和限额状态。汇总保留每轮原始数据,不建立未经校准的绝对性能门槛。
|
||||
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Focused owned benchmarks and unchanged runtime semantics"}
|
||||
{"file":".trellis/spec/backend/toolchain-and-library.md","reason":"Native optimization and HTTP/TLS/task contracts"}
|
||||
@@ -0,0 +1,18 @@
|
||||
# 执行顺序
|
||||
|
||||
- [x] 主代理实现 Dever 场景和 Python 构建/采样/报告入口。
|
||||
- [x] 独立 implement 代理实现 test/dever-tests/examples/network_bench.rs 及其私有模块;复用现有网络依赖。
|
||||
- [x] 编译定向 fixtures/example,最小验证测量统计和清理,短时 loopback HTTP/HTTPS 对照。
|
||||
- [x] 检查最终文件、响应校验、延迟分母、丢弃/失败计数、资源预算和退出清理;记录初始结果与限制。
|
||||
- [x] 更新 test/performance/README.md、现有质量规范及任务证据;不提交。
|
||||
|
||||
工具链使用 /root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin 的命令级 PATH;所有 Cargo 构建 offline。只运行本任务的最小定向检查,不执行旧 performance target 或全量测试。
|
||||
|
||||
## 最终证据
|
||||
|
||||
- 七个 Python 运行器定向测试和两个独立 Python HTTP 对端测试通过;负例响应正文必须记 error、成功延迟为空。覆盖热身去除、正确结果/数量、完整窗口、计数守恒、已退出进程和缺控制器时清理。
|
||||
- Rust example 离线 release check/build 通过;七个 Dever 入口经 CLI 格式化、语义检查和生产优化原生构建。Rustfmt/Clippy 未安装,未报告为通过;显式源码空白/冲突标记检查与 Dever fmt-check 通过。
|
||||
- 最终基线 78 组:15 内存、9 异步、54 HTTP/HTTPS;另 18 组新连接和 15 组 64 MiB 空闲基线。各组实际调用环境见 target/performance/*/report.json,结果解释以 test/performance/README.md 首轮结果为准。
|
||||
- 54 组正式网络共计划 108000 请求,发出并成功 107962,错误 0;未发出 38(窗口过期 32、容量不足 6)。预热单独记录。新连接 900 请求全部成功,无丢弃;全部自有预算组无 OOM,结束后没有残留 dever-perf-* cgroup。
|
||||
- 对照复查修正了大正文逐请求重复分配、短窗口吞吐分母、定时器抖动错误计丢弃、请求 driver 取消所有权、负载线程环境污染、缺控制器清理以及实际请求延迟校验。独立只读 review 最终没有剩余阻断。
|
||||
- 仅构建/测试工具、fixtures、Cargo 测试依赖和文档发生变化;生产编译器/运行时未修改。保留产物和原始报告,不提交、不运行自动归档脚本。更高负载、长连接/持续稳定性和运行时优化是后续工作。
|
||||
@@ -0,0 +1,14 @@
|
||||
# 独立二进制运行性能基准
|
||||
|
||||
用户批准实施上一轮提出的第一阶段:常驻内存、异步调度、HTTP/HTTPS。小机器只运行已编译程序;构建资源不是本阶段优化目标。此批准取代历史网络开发阶段不运行压测的局部限制,仅限本任务拥有的有界基准。
|
||||
|
||||
验收:
|
||||
- 根 test/ 内提供显式运行的构建与测量入口,默认测试不启动压测。
|
||||
- 测量真实 Dever 编译产物,复用现有优化设置;编译不进入运行计时。
|
||||
- 覆盖同步/异步/HTTP/HTTPS 空闲内存,任务启动/Channel/取消,以及 HTTP/HTTPS 固定速率请求。
|
||||
- HTTP 对照使用相同 Dever runtime 的 Rust 服务和原生 Tokio/Hyper 服务;校验响应内容,记录成功、失败、未发出请求和尾延迟。
|
||||
- 输出机器可读结果、环境/配置/二进制身份、CPU/RSS/PSS/线程/FD;可选独立 cgroup 限额,缺少权限时报错,不虚构限额。
|
||||
- 自有随机 loopback 端口、测试证书、有界时间和负载,退出清理子进程;不访问已有服务,不跑全量测试,不提交。
|
||||
- 完成短时定向验证和至少一组可解释的初始测量;长时间、多机器/多档压力矩阵明确留待显式运行。
|
||||
|
||||
不包含运行时优化、数据库、服务端流式请求、信号 API、TCP/WS/SSE 长连接稳定性和发行工具链。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "runtime-performance",
|
||||
"name": "runtime-performance",
|
||||
"title": "Runtime performance baselines",
|
||||
"description": "Measure standalone Dever memory, async scheduling and HTTP/HTTPS with reproducible bounded workloads and equivalent Rust baselines.",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-12",
|
||||
"completedAt": "2026-09-12",
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-04-dever-language-mvp",
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"数据库错误、事务失败、取消和资源回收必须显式传播"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查数据库测试边界、内存限制、无重复驱动逻辑和最小验证"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/architecture-options.md","reason":"复核实现只暴露 Model 包短函数,没有混入实例 method、链式查询器或动态 Value 路径"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/prd.md","reason":"逐项核对已确认的 Model、查询、配置、事务、错误、迁移和小内存验收合同"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/design.md","reason":"复核编译器/runtime/dialect 边界、driver profile、单连接事务与错误根边界没有重复实现"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/schema-change-matrix.md","reason":"确认所有自动 schema 变化均无静默数据损失,危险变化必须由命名 migrate 表达"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/driver-selection.md","reason":"确认 SQLite/PostgreSQL 只复用驱动和连接池,不引入第二套 ORM 或无界池"}
|
||||
@@ -0,0 +1,409 @@
|
||||
# 类型安全 ORM 技术设计
|
||||
|
||||
## 设计目标
|
||||
|
||||
实现一个静态、无反射、默认有界的 ORM,并让数据库能力自然接入 Dever 的顺序调用、结构化并发、错误传播和 AOT 产物裁剪。Model 只描述持久化事实和数据访问;数据库驱动不拥有语言层查询语义。
|
||||
|
||||
## 总体边界
|
||||
|
||||
```text
|
||||
Dever source
|
||||
-> parser / formatter
|
||||
-> model + function semantic checking
|
||||
-> effect / error / database binding inference
|
||||
-> typed QueryPlan + normalized ModelSchema
|
||||
-> native emitter
|
||||
-> shared ORM runtime
|
||||
-> SQLite dialect/executor
|
||||
-> PostgreSQL dialect/executor
|
||||
-> packaged binary + external config/setting.json
|
||||
```
|
||||
|
||||
- 编译器拥有 Model 识别、字段类型、查询表达式、静态关系、错误集合和 transaction 合法性。
|
||||
- ORM runtime 拥有配置、连接生命周期、绑定值、行流、事务、schema 执行和统一错误分类。
|
||||
- dialect 只拥有 SQL/DDL 差异;driver executor 只拥有具体连接、参数编码和行解码入口。
|
||||
- 生成代码拥有每个 Model 的具体创建输入、更新输入、查询返回、字段绑定和行 decoder。
|
||||
- 不建立通用 Repository/BaseModel 层,也不让 SQLite/PostgreSQL 分别复制 CRUD 流程。
|
||||
|
||||
## 源码合同
|
||||
|
||||
### Model 文件
|
||||
|
||||
```dever
|
||||
package app.model.user exposes (
|
||||
User
|
||||
)
|
||||
|
||||
type UserStatus {
|
||||
Active = "启用"
|
||||
Disabled = "禁用"
|
||||
}
|
||||
|
||||
type User {
|
||||
uuid: Uuid generated unique
|
||||
email: Text(254) unique
|
||||
display_name: Text(1, 64) from nickname
|
||||
status: UserStatus default UserStatus.Active index
|
||||
organization_id: app.model.organization.id?
|
||||
}
|
||||
|
||||
index(status, created_at)
|
||||
|
||||
seed {
|
||||
{
|
||||
email = "admin@example.com"
|
||||
display_name = "Admin"
|
||||
status = UserStatus.Active
|
||||
}
|
||||
}
|
||||
|
||||
migrate remove_legacy_code {
|
||||
drop legacy_code
|
||||
}
|
||||
```
|
||||
|
||||
`database <name>`、`index`、`unique`、`relation`、`seed` 和 `migrate` 只在 Model 文件中合法。需要显式使用报告库时,在 package header 后增加 `database report`;未写时按根包/default 规则绑定。普通文件继续只有普通 type/function 语义。
|
||||
|
||||
### 查询与事务
|
||||
|
||||
```dever
|
||||
users = user.list({
|
||||
where = status == UserStatus.Active and organization_id == organization_id
|
||||
order = [created_at.desc, id.desc]
|
||||
page = 1
|
||||
size = 20
|
||||
with = [organization]
|
||||
})
|
||||
|
||||
transaction disable_organization(
|
||||
target_organization_id: app.model.organization.id
|
||||
) () {
|
||||
user.update({
|
||||
where = organization_id == target_organization_id
|
||||
}, {
|
||||
status = UserStatus.Disabled
|
||||
})
|
||||
organization.update(target_organization_id, {
|
||||
active = false
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
匿名创建/更新/查询块只在对应参数位置出现,由目标 Model 提供字段命名空间。它们不会成为可传递的动态 Map 或持久查询对象。
|
||||
|
||||
### 类型化原生 SQL
|
||||
|
||||
复杂查询通过 Model 文件内的专用 `sql` 声明提供,不引入通用泛型或 type-as-value:
|
||||
|
||||
```dever
|
||||
type UserSummary {
|
||||
status: UserStatus
|
||||
total: Int
|
||||
}
|
||||
|
||||
sql active(status: UserStatus) (users: List<User>) {
|
||||
sqlite = "SELECT id, created_at, email, status FROM user WHERE status = ?1"
|
||||
postgres = "SELECT id, created_at, email, status FROM user WHERE status = $1"
|
||||
}
|
||||
|
||||
sql summary(status: UserStatus) (summary: UserSummary?) {
|
||||
sqlite = "SELECT status, COUNT(*) FROM user WHERE status = ?1 GROUP BY status"
|
||||
postgres = "SELECT status, COUNT(*) FROM user WHERE status = $1 GROUP BY status"
|
||||
}
|
||||
```
|
||||
|
||||
- 两种方言都必须是静态 Text 字面量。SQLite 显式使用 `?N`,PostgreSQL 显式使用 `$N`;编译器不重写 SQL。
|
||||
- 参数按声明顺序和具体字段类型绑定;禁止插值、动态列名及动态行 `Map`。
|
||||
- 结果只能是一个具体 `Model`、`Model?`、`List<Model>`,或同文件私有辅助 record 的对应形状。
|
||||
- Model 文件仍只有文件名对应且 exposed 的 record 注册为 Model。辅助 record 必须被 SQL 结果使用,不生成表,也不能 exposed。
|
||||
- Model 结果复用生成的 Model decoder;辅助 record 生成具体字段顺序 decoder。缺列、多列、类型不匹配,以及单行结果的零行/多行都作为运行时数据库数据错误传播。
|
||||
- 调用形式与内置 CRUD 一致,例如 `user.active(status)`。执行复用既有 statement cache、值绑定和隐藏 transaction executor;`List<Record>` 只物化该静态 SQL 实际返回的行,不建立额外动态缓存。
|
||||
|
||||
## 编译器设计
|
||||
|
||||
### 1. 语法与格式化
|
||||
|
||||
- 根据源文件相对路径判定是否属于 `model/`;Model 文件仍解析一个普通 record type,不新增通用 type 种类。
|
||||
- 在 AST 中增加目的明确的 Model 元数据节点:数据库绑定、字段修饰、复合索引、关系、Seed 和命名迁移。不要把它们编码为字符串或普通函数调用后再反解析。
|
||||
- 函数声明使用 `FunctionKind::Ordinary | Transaction`,取代继续叠加多个布尔修饰位。
|
||||
- 删除源码 `async` 和 `await` 节点;`run`、`wait`、`stop` 保留为明确的并发上下文表达式。
|
||||
- `result(call)` 和 `fail(error_variant)` 作为两个目的单一的核心表达式进入 AST/HIR,不借此加入 try/catch、annotation 或 macro。
|
||||
- formatter、Markdown 合同解析和 API snapshot 与新语法同步更新;不保留旧语法分支。
|
||||
|
||||
### 2. Model 注册与静态元数据
|
||||
|
||||
- SourceMap 加载后先建立 `ModelId`,验证文件名、package 路径、唯一公开 record 和 Model 表名。
|
||||
- 为每个 Model 合成:
|
||||
- 不透明 `<model-package>.id`;
|
||||
- 完整 record 中的 `id`/`created_at`;
|
||||
- 创建和更新输入 shape;
|
||||
- CRUD/query/relationship 符号;
|
||||
- choice options 常量;
|
||||
- 具体 row decoder 与 bind encoder 描述。
|
||||
- 同一逻辑连接中发生表名、索引名或内部对象名冲突时,在生成 SQL 前报告源码诊断。
|
||||
- Model CRUD 是编译器合成的包能力,不要求开发者在 `exposes` 中逐个列出,也不扩展成通用 type-as-value。
|
||||
|
||||
### 3. 规范化 Schema
|
||||
|
||||
新增与方言无关的 `ModelSchema`:
|
||||
|
||||
- 稳定 Model/package/table identity;
|
||||
- 有序字段、逻辑类型、可空性、默认值、generated、旧字段来源;
|
||||
- 主键、外键、choice CHECK、单列/复合索引;
|
||||
- relation、Seed 和命名迁移规范化表示。
|
||||
|
||||
revision 只对逻辑 schema 和迁移意图计算,不包含 SQLite/PostgreSQL SQL 文本、连接串、文件绝对路径或源码格式。这样两个方言完成后记录相同逻辑身份。
|
||||
|
||||
### 4. QueryPlan
|
||||
|
||||
所有短函数共用一个静态 IR:
|
||||
|
||||
- operation:select/insert/update/delete/upsert/count/exists;
|
||||
- projection 与具体结果 shape;
|
||||
- condition tree、稳定 order、page/cursor、limit;
|
||||
- relation load plan;
|
||||
- 参数槽位及 Dever 类型;
|
||||
- transaction requirement 与 Model binding。
|
||||
|
||||
查询块在 checker 中解析为 Model 字段 ID 和类型化运算,不把字段名字符串留到 runtime。native emitter 将固定 SQL shape 和参数提取代码写入生成程序。
|
||||
|
||||
类型化原生 SQL 作为同一 Model operation HIR 的专用分支:HIR 只保存已校验的 operation 索引和类型化参数,方言文本保留在 `ModelSchema`。native emitter 选择 runtime profile 对应文本,并复用普通 ORM 的 bind encoder、database executor 与 row decoder;不会把 SQL 或结果 shape 推迟到 runtime 解释。`Record`/`Record?` 最多拉取两行以判定基数错误;`List<Record>` 最多保留 `database.max_page_size` 行并额外探测一行,超限失败而不静默截断,因此漏写 SQL `LIMIT` 也不会形成无界进程内结果集。
|
||||
|
||||
#### Relation 结果与加载计划
|
||||
|
||||
- 编译器公开内置 `Related<T>`,只有 `Unloaded` 和 `Loaded(value)` 两态。ORM 负责构造值;业务代码可以用 `Related.Unloaded<T>()` 和 `Related.Loaded<T>(value)` 分句穷尽匹配。
|
||||
- `Related` 使用间接 native 布局 `Loaded(Box<T>)`。Model 类型环只有穿过 `Related` 才合法,基础 row decoder 始终把合成关系字段初始化为 `Unloaded`。
|
||||
- 持久化字段 `<name>_id: target.id` 自动合成 `<name>: Related<Target>`;可空外键合成 `Related<Target?>`。显式 `relation children = target.parent_id` 合成 `children: Related<List<Target>>`。持久化 schema、bind encoder 和基础 decoder 不包含这些合成字段。
|
||||
- `with = [organization, children]` 在 checker 中解析为 `QueryPlan` 的静态 relation 索引,只允许一层裸 relation 名称;未知、重复和嵌套关系均拒绝。`with` 只允许 `first`、`list`、`cursor`,`stream` 明确拒绝。
|
||||
- to-one 使用受控 `LEFT JOIN` 或 `INNER JOIN`,支持同一父查询的多个关系;`list` 的 count 查询保持只访问父表,`cursor` 不生成 count。
|
||||
- to-many 在父结果确定后按父 ID 批量加载并归组,不逐父查询。子记录按 `id ASC` 稳定排序,每个父记录最多加载 `database.max_page_size` 条;父 ID 每 998 个一批,为 SQLite 的第 999 个参数保留 relation limit。空父列表不会生成 `IN ()`。
|
||||
- 关系查询数量由生成代码 shape 测试验证:每个 relation 只有一个按固定参数批次循环的 `query_owned` 调用点,不在生产热路径加入计数原子。
|
||||
|
||||
### 5. 挂起 Effect
|
||||
|
||||
- intrinsic 和官方库函数标记基础挂起 effect;调用、静态 handler 和 collection handler 通过现有依赖图传递。
|
||||
- AST 不再存 `is_async`,HIR 在 effect 收敛后记录内部 `suspends`。native emitter 仅对可达挂起函数生成 Rust `async fn` 和内部 `.await`。
|
||||
- 普通调用根据目标 effect 降低为同步 call 或内部 await call;源码语义始终是顺序完成。
|
||||
- 同步入口的完整可达图没有挂起 effect 时沿用直接 `main`;否则生成唯一 Tokio runtime 入口。
|
||||
|
||||
### 6. Error Effect
|
||||
|
||||
- `error` choice 变体仍是命名、带类型 payload 的领域失败;`fail(...)` 把对应变体加入当前函数错误集合。
|
||||
- 调用图为每个函数推导封闭错误集合。普通调用在 HIR 中自动传播失败,native 使用具体 Rust `Result<Success, AppError>`;源码不写 `?`。
|
||||
- 对存在多个正常状态的操作,把正常状态保留为非 error choice,例如 `ReadState.Read/End`,错误集合与成功类型分开。
|
||||
- `result(call)` 阻止当前调用自动传播,并生成编译器内部 `CapturedResult<SuccessShape, ErrorSet>`。它不引入用户泛型;checker 只允许通过现有穷尽函数分句消费、继续返回或传递,且必须覆盖成功形状和全部 error 变体。
|
||||
- native 为当前可达程序生成一个具体的错误 enum,并为函数记录精确子集;API/docs/editor 显示每个函数的静态错误集合。没有动态错误对象、字符串类型分派或按变体名猜 HTTP 状态。
|
||||
- 现有 C012 改为检查 error effect 和 captured result 的消费,继续拒绝丢弃、覆盖、仅打印或仅记录。
|
||||
|
||||
### 7. Database Effect 与 Transaction
|
||||
|
||||
- 每个 Model operation 在 HIR 标记 `DatabaseEffect(ModelId)`;普通 helper 通过调用图传递 Model 集合。
|
||||
- 每个 Model 保存声明式 `ConnectionSelector::Explicit(name) | PackageRoot(root)`。source check 可拒绝显然不同的显式绑定;native build 读取 setting 后做最终连接解析和 transaction 单连接验证。
|
||||
- Transaction 函数必须具有至少一个数据库 effect,且所有 effect 在构建配置中解析为同一连接;并发子调用中的数据库 effect 直接诊断。
|
||||
- HIR 为 transaction 调用链增加隐藏 `DatabaseContext` 参数。普通路径使用 registry slot,事务路径使用同一 connection 的 transaction handle;不查询线程局部或任务局部状态。
|
||||
|
||||
## 配置与构建能力
|
||||
|
||||
### 项目根与源码根
|
||||
|
||||
- 应用命令接收项目根;项目根内的 `module/` 是唯一 SourceMap 根,package 按文件相对 `module/` 的路径校验和派生。
|
||||
- `module/user/model/user.dever` 因而对应 `user.model.user`,根包数据库绑定解析为 `database.user`;不存在时才使用 `database.default`。
|
||||
- `config/`、`data/` 与输出二进制都在项目根下,但不参与 Dever 源码扫描。编译器内部只传递规范化后的项目根和源码根,不在 parser/checker 中重复猜目录。
|
||||
- 旧的单目录语言 fixture 由测试工具显式传入源码根;应用 CLI 不增加自动回退或第二套模糊目录探测。
|
||||
|
||||
### setting.json
|
||||
|
||||
配置路径固定为 `<executable-dir>/config/setting.json`。数据库对象直接按连接名组织:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {},
|
||||
"http": {},
|
||||
"database": {
|
||||
"default": {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/app.db",
|
||||
"max_connections": 1,
|
||||
"max_page_size": 100
|
||||
},
|
||||
"report": {
|
||||
"type": "postgres",
|
||||
"url": "postgres://user:password@db.example.com/report",
|
||||
"tls": "system",
|
||||
"min_connections": 0,
|
||||
"max_connections": 4,
|
||||
"max_page_size": 100,
|
||||
"wait_timeout_ms": 5000,
|
||||
"io_timeout_ms": 30000
|
||||
}
|
||||
},
|
||||
"log": {}
|
||||
}
|
||||
```
|
||||
|
||||
- 使用严格的结构化 JSON 解析;未知数据库 type、缺失 default、无效数值或被引用连接不存在均失败。
|
||||
- PostgreSQL 的 `tls` 必须显式为 `system` 或 `disabled`;`system` 使用现有 rustls 系统根并校验证书与主机名,不提供从 TLS 静默回落明文的 `prefer` 模式。
|
||||
- PostgreSQL 的 `wait_timeout_ms` 限制池等待,`io_timeout_ms` 独立限制 SQL、事务和迁移 I/O;二者范围均为 1–300000 毫秒。
|
||||
- 相对数据库/日志/upload/cache/tmp 路径都相对可执行文件目录,不相对 cwd。
|
||||
- 启动时创建 `data/db`、`data/upload`、`data/log`、`data/cache`、`data/tmp`;不创建或修复 setting。
|
||||
- 连接 selector 按“显式名称 -> 根包同名键 -> default”解析一次,Model 保存连接 slot。
|
||||
|
||||
### RuntimeProfile
|
||||
|
||||
发布构建读取 setting 中全部数据库 `type`,生成 `RuntimeProfile { sqlite, postgres, async, log }`:
|
||||
|
||||
- 只读取能力类型,不把 URL、密码、路径或池参数嵌入二进制或缓存 key。
|
||||
- runtime 的 SQLite/PostgreSQL 依赖使用 optional Cargo features。
|
||||
- profile、runtime rlib/dependency 快照和生成源码共同进入 native cache identity。
|
||||
- 编译器 parse/check 不依赖 setting;生成可部署原生程序时需要 setting。程序启动会再次严格解析实际 setting。
|
||||
- 运行时配置可以在已打包驱动集合内修改。若改成未打包的数据库类型,启动明确失败并要求重新构建,不做动态下载或回退。
|
||||
|
||||
### CMS 纵向样例
|
||||
|
||||
```text
|
||||
examples/dever/cms/
|
||||
config/setting.json
|
||||
data/
|
||||
module/
|
||||
cms.dever
|
||||
user/model/user.dever
|
||||
user/service/register.dever
|
||||
news/model/news.dever
|
||||
news/service/publish.dever
|
||||
```
|
||||
|
||||
- `user.model.user` 保存账户及初始化管理员,`news.model.news` 通过 `author_id: user.model.user.id` 建立静态外键。
|
||||
- `user.service.register` 与 `news.service.publish` 使用 transaction 函数表达真实业务边界,直接复用编译器生成的 Model 操作。
|
||||
- `cms.main` 只编排可观察的纵向流程,不复制 CRUD、迁移或连接选择逻辑。
|
||||
- 默认 setting 只启用 bundled SQLite,既是新项目模板也是 SQLite-only 原生产物和小内存基准的真实用例。
|
||||
|
||||
## Runtime 设计
|
||||
|
||||
### 连接注册表
|
||||
|
||||
`DatabaseRegistry` 在应用入口启动 HTTP/任务前完成:
|
||||
|
||||
1. 定位并解析 setting;
|
||||
2. 创建数据目录;
|
||||
3. 构造所有被 Model 引用的连接池;
|
||||
4. 解析 Model -> connection slot;
|
||||
5. 验证 transaction 单连接不变量;
|
||||
6. 获取每个连接的 schema 锁,执行 migration 和 Seed;
|
||||
7. 成功后才进入用户入口。
|
||||
|
||||
热路径通过整数 slot 取连接,不执行字符串查找。连接实现使用小枚举静态分派,不使用每行/每值 trait object。
|
||||
|
||||
### SQLite
|
||||
|
||||
- 使用 `deadpool-sqlite` + `rusqlite bundled`。
|
||||
- pool 上限不超过 Dever 全局 blocking 容量;每次同步交互都通过既有 blocking permit 和 pool object 生命周期。
|
||||
- 默认一个连接,开启 foreign keys,设置 busy timeout;WAL 是否启用由连接配置明确决定,不在 runtime 猜测。
|
||||
- transaction 独占同一个 pool object 直到 commit/rollback;RowStream 不能在释放连接后继续读取。
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
- 使用 `deadpool-postgres` + `tokio-postgres`;TLS 连接通过 `tokio-postgres-rustls` 复用当前 rustls/webpki roots。
|
||||
- pool 获取、连接建立、查询和 row stream 都使用 Tokio,且有独立的连接上限、获取等待和 I/O 超时。
|
||||
- driver future 归属连接/pool 的结构化 scope;关闭 registry 时停止接收、等待借出连接归还并排空 driver task。
|
||||
- 对固定 QueryPlan 使用 statement cache;迁移 DDL 与一次性动态片段不进入无界缓存。
|
||||
|
||||
### 统一错误
|
||||
|
||||
driver 错误在所属 executor 边界映射为稳定 `DatabaseError`:
|
||||
|
||||
- Connection、Timeout、Cancelled;
|
||||
- Constraint(含 unique/foreign-key/check/not-null 分类);
|
||||
- NotFound;
|
||||
- Decode/Encode;
|
||||
- Migration/SchemaConflict;
|
||||
- Busy/PoolExhausted;
|
||||
- Driver(保留可记录的内部 code/cause,但不直接暴露到 HTTP)。
|
||||
|
||||
非幂等写入不自动重试。原始 driver cause 进入诊断链,公开 error 只暴露稳定字段。
|
||||
|
||||
## Schema 与 Seed 执行
|
||||
|
||||
### 内部状态
|
||||
|
||||
每个数据库使用一个保留前缀的内部 history 表记录:
|
||||
|
||||
- Model identity;
|
||||
- kind:schema/migration/seed;
|
||||
- logical name/revision;
|
||||
- normalized checksum;
|
||||
- applied_at。
|
||||
|
||||
history 不是目标 schema 的替代品。每次启动仍核对数据库 catalog;history 与实际 schema 冲突时拒绝继续,不猜测修复。
|
||||
|
||||
### 启动流程
|
||||
|
||||
- PostgreSQL 使用数据库级 advisory lock;SQLite 使用写事务锁,防止多个进程同时迁移同一 schema。
|
||||
- 表不存在:直接创建当前最终表、索引与约束,不回放 `from` 或历史 migrate。
|
||||
- 表存在:catalog -> normalized actual schema -> diff -> validation plan -> DDL plan。
|
||||
- 收紧变化先运行只读验证 SQL,任一失败都不执行 DDL。
|
||||
- 命名 migrate 校验未执行/内容未改,再与 DDL/history 在一个连接事务中提交。
|
||||
- Seed 在 schema 成功后执行,按唯一键 conflict-do-nothing;只忽略目标唯一冲突。
|
||||
- 多连接无法拥有全局原子迁移。各连接独立加锁和提交,按连接名稳定顺序执行;后续连接失败时应用不启动,已完成连接依靠 checksum 幂等重跑。
|
||||
|
||||
## 查询执行数据流
|
||||
|
||||
```text
|
||||
generated Model function
|
||||
-> evaluate typed arguments
|
||||
-> acquire connection or reuse hidden transaction
|
||||
-> dialect renders fixed QueryPlan
|
||||
-> prepare/cache statement
|
||||
-> bind concrete values
|
||||
-> execute / pull rows
|
||||
-> generated row decoder
|
||||
-> Model/List/Page/Cursor result
|
||||
-> release connection or retain for bounded RowStream
|
||||
```
|
||||
|
||||
- to-one include 与主查询共享受控 join 计划。
|
||||
- to-many include 在获得父页后用一个有界 `IN (...)` 批次查询并按父 ID 归组;超过参数上限时按固定上限分批,不按父记录逐条查询。
|
||||
- list 的 count 与 data 查询按同一条件树生成;cursor 只执行 data 查询。
|
||||
- stream 持有连接和 statement 生命周期,消费者停止、Task 取消或 fault 时通过 Drop/scope 清理归还。
|
||||
|
||||
## Transaction 执行
|
||||
|
||||
- 最外层 transaction 从已验证 slot 获取一个连接并 begin。
|
||||
- 生成的 `DatabaseContext` 携带 `ConnectionSlot + TransactionHandle`,只沿隐藏参数传递。
|
||||
- 普通 Model call 检测到上下文时验证 slot 相同并直接使用 handle;无上下文时从 pool 获取。
|
||||
- 同连接嵌套 transaction 只增加逻辑作用域,不 begin/commit。
|
||||
- 函数成功时最外层 commit;error/fault/取消路径统一进入 rollback guard,rollback 完成后再传播原始失败。
|
||||
- rollback 自身失败追加到诊断 cause,不覆盖原始业务 error/fault。
|
||||
- transaction context 不能进入 `run`、Group、Channel、Stream producer 或返回值,因此不存在逃逸连接。
|
||||
|
||||
## 日志与执行边界
|
||||
|
||||
- runtime 维护一个结构化 `DiagnosticContext`,包含 fault/error code、source span、request/task id 和 cause chain。
|
||||
- lower layer 只附加 cause/context,不记录根日志;HTTP、ownerless Task、CLI 等根边界决定是否记录。
|
||||
- HTTP 未映射失败在 rollback 后返回通用 500;显式 handler 可把预期 error 映射为 4xx。
|
||||
- 日志 front-end 先检查 level,再构造字段。队列容量固定;debug/info 满时丢弃并累计,warn/error 等待容量。
|
||||
- 文件 sink 的编码、轮转和压缩策略不在本任务冻结;先完成有界队列、stderr emergency path 和一次记录合同。
|
||||
|
||||
## 性能设计
|
||||
|
||||
- 生成代码只保留可达 Model、字段 decoder、QueryPlan 和 driver profile。
|
||||
- 相同静态 SQL shape 使用每连接 statement cache;值变化不创建新 SQL 字符串或缓存项。PostgreSQL 每连接缓存暂定上限为 64;to-many 参数批次生成的动态 SQL 不进入缓存,最终上限由阶段 10 基准收敛。
|
||||
- page size、to-many 子项、IN 批次、批量写入、pool、blocking、RowStream、日志全部由一个明确上限约束。
|
||||
- SQLite 默认一连接,PostgreSQL 默认零预热小池;最终默认值由 64/128 MiB 定向基准确定,并固化为命名常量和文档。
|
||||
- 性能基准实测 RSS/PSS/VmHWM、cgroup、线程/FD、吞吐、p50/p95/p99 和二进制体积;配置的 pool capacity 单独记录。生产 runtime 尚无低成本观测点的 pool wait、SQL 数量和数据库连接数必须标记为 `unavailable`,不能用 FD 或配置值推算冒充实测。
|
||||
- 构建产物以 manifest 的 SHA-256 和字节数校验后才运行;每个 ORM case 使用独立 bundle。PostgreSQL URL 的唯一 `{case}` 必须位于数据库名,敏感 URL 只进入权限为 `0600` 的 case setting,不进入 manifest/report。
|
||||
- transaction cancel 使用单连接 pool:先证明连接可用,再证明长事务占用 pool、客户端在响应前断开、连接在 handler timeout 前归还且事务行数为零,避免把 handler timeout 回滚误报为请求取消。
|
||||
- 先验证稳定内存和正确回收,再优化 SQL 生成、statement 命中或 decoder;不以缓存未绑定 SQL 或扩大队列换吞吐。
|
||||
|
||||
## 风险与处理
|
||||
|
||||
- 隐式挂起和错误传播会触及编译器、官方库和全部异步 fixture,应先单独完成语义迁移并保持 Rust runtime 行为,再叠加 ORM。
|
||||
- SQLite DDL 经常需要重建表;schema planner 必须以真实 catalog fixture 验证,不能只比较生成 SQL 字符串。
|
||||
- 外部 setting 与驱动裁剪可能不一致;构建记录 capability,启动严格校验,错误要求重建,不加载动态插件。
|
||||
- 多数据库迁移无法跨连接原子提交;稳定顺序、单连接事务和 checksum 幂等是唯一可维护恢复路径。
|
||||
- PostgreSQL 测试依赖真实服务;实施阶段只运行隔离的定向 fixture,不连接开发或生产数据库。
|
||||
|
||||
## 回滚边界
|
||||
|
||||
- 每一实施阶段必须在独立语义门槛下完成;不在半迁移状态同时保留新旧源码语法。
|
||||
- 自动 schema 操作在提交前不改 history;失败回滚。跨连接已有成功迁移不反向回滚,由幂等重启继续。
|
||||
- 发布二进制始终保留上一个版本;危险命名 migrate 需要先有数据库备份,自动回滚程序版本不能逆转已提交的数据迁移。
|
||||
@@ -0,0 +1,10 @@
|
||||
{"file":".trellis/spec/backend/compiler-contracts.md","reason":"model、查询计划、资源和公开 API 必须在 checker/HIR/native 中保持静态合同"}
|
||||
{"file":".trellis/spec/backend/toolchain-and-library.md","reason":"数据库公共包、驱动选择、runtime 构建与缓存必须复用现有工具链边界"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/architecture-options.md","reason":"依据现有无反射 AOT 和资源语义选择 ORM 表面"}
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"实现默认错误传播、事务回滚、根边界处理和 C012 合同"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查数据库测试边界、内存限制、无重复驱动逻辑和最小验证"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/implicit-suspension.md","reason":"统一删除 Dever async/await,并保留编译器推导协程、run/wait/stop 和同步入口裁剪合同"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/field-types.md","reason":"实现隐式字段、常用数据库类型、Uuid、choice、关联 ID、Seed 和索引语义"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/schema-change-matrix.md","reason":"按变更类别实现自动 diff、数据验证、from、命名 migrate 和两种方言的等价结果"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/query-api.md","reason":"实现已确认的 Model 短函数、块式查询、分页、关联、事务和连接解析表面"}
|
||||
{"file":".trellis/tasks/09-13-typed-orm/research/driver-selection.md","reason":"接入 rusqlite/deadpool-sqlite 与 tokio-postgres/deadpool-postgres,并按 runtime profile 裁剪驱动"}
|
||||
@@ -0,0 +1,297 @@
|
||||
# 类型安全 ORM 实施清单
|
||||
|
||||
## 开始前门槛
|
||||
|
||||
- [x] 用户在最终规划摘要之后明确批准进入实现,并要求增加 `examples/dever/cms` 项目。
|
||||
- [x] 运行 `python3 ./.trellis/scripts/task.py start 09-13-typed-orm`,加载 `trellis-before-dev` 上下文。
|
||||
- [x] 核对 `Cargo.lock` 中待加入驱动与当前 Rust 1.85/MSRV、license 和 feature;只锁定实际使用的最小 feature。
|
||||
- [x] 为每个阶段建立对应的根目录 `test/` 定向测试目标;不运行 workspace 全量测试或集成压测。
|
||||
|
||||
## 阶段 1:隐式挂起语言表面
|
||||
|
||||
目标:删除 Dever 源码 `async`/`await`,保留 Rust runtime 的真实异步执行。
|
||||
|
||||
- [x] 调整 lexer/parser/syntax/formatter,删除 function/handler async 与 `await(...)`,保留 `run`/`wait`/`stop`。
|
||||
- [x] 将 `FunctionClause.is_async` 拆为编译器推导的 HIR `suspends`,复用现有调用依赖图传递基础挂起 effect。
|
||||
- [x] 更新 checker:普通调用自动按目标 effect 顺序等待;`run` 接受可启动调用,Task/Group 仍保持结构化所有权。
|
||||
- [x] 更新 native emitter:同步可达图生成直接入口,挂起可达图生成唯一 Tokio 入口和内部 await。
|
||||
- [x] 更新 API snapshot、Markdown 合同、官方 `dever.task/net/http/websocket/sse` 源码、examples 与相关 fixture,不保留旧语法。
|
||||
- [x] 清理旧 `AwaitCall`、`TaskStart`、公开 async 专属诊断和死分支,保留 runtime Rust async 节点。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test structured_concurrency
|
||||
cargo test -p dever-tests --test async_native
|
||||
cargo test -p dever-tests --test async_network
|
||||
cargo test -p dever-tests --test http_library
|
||||
cargo test -p dever-tests --test live_library
|
||||
```
|
||||
|
||||
完成条件:HTTP/TCP/WS/SSE 的 Dever fixture 不含源码 async/await,协议、背压、取消和同步程序无 Tokio 入口的定向测试通过。
|
||||
|
||||
## 阶段 2:默认错误传播与执行边界
|
||||
|
||||
目标:建立 `fail`、`result(call)`、推导错误集合和运行时统一兜底。
|
||||
|
||||
- [x] 在 AST/HIR 增加 `Fail`、`CaptureResult` 和静态 `FailureSet`,通过现有 dependency worklist 传递。
|
||||
- [x] 将普通调用降低为自动传播,`result(call)` 降低为编译器内部 captured result;不增加用户泛型或每函数 error 声明。
|
||||
- [x] 拆分官方库中“正常状态 choice”与 error effect,迁移现有 Result choice 的调用方。
|
||||
- [x] 扩展 C012,覆盖默认传播、captured result 穷尽消费、覆盖、丢弃、仅日志和 `recover`。
|
||||
- [x] 生成可达应用的具体 Rust error enum、cause chain 和 source span;API snapshot 显示每函数精确错误集合。
|
||||
- [x] 建立 HTTP、Task、CLI 根边界:事务回滚后分别返回 500/Task failure/非零退出,且只记录一次。
|
||||
- [x] 增加 `dever.log` level API、共享诊断上下文和有界队列;文件 sink 格式/轮转仍不在本阶段冻结。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test contract_syntax
|
||||
cargo test -p dever-tests --test contract_semantics
|
||||
cargo test -p dever-tests --test contract_execution
|
||||
cargo test -p dever-tests --test hello_native
|
||||
cargo test -p dever-tests --test http_engine
|
||||
```
|
||||
|
||||
完成条件:默认传播、主动捕获、自定义 fail、C012、HTTP/Task/CLI 边界及一次日志都有正反 fixture。
|
||||
|
||||
## 阶段 3:Model 语法与静态 Schema
|
||||
|
||||
目标:编译器能从一个 Model 文件得到完整静态持久化合同。
|
||||
|
||||
- [x] 增加 Model 路径识别和 Model 专属 declaration/field metadata AST。
|
||||
- [x] 实现文件名/package/唯一公开 record 校验,合成 `id`、`created_at`、创建输入和更新输入。
|
||||
- [x] 实现 `Bool/Int/Float/Decimal/Text/Bytes/Uuid/DateTime/Date/Time/Duration/Json/choice/model.id` 及 nullable/static bounds。
|
||||
- [x] 实现 `generated/default/index/unique/from`、复合 index/unique、relation、seed、migrate 的解析、格式化和源码诊断。
|
||||
- [x] 实现不透明 Model ID 的解析与类型检查、隐式 FK/index、choice labels 与 `Choice.options`。
|
||||
- [x] 建立与方言无关的 `ModelSchema` 规范化和稳定 checksum;检查同连接内表/对象名冲突。
|
||||
- [x] API/Markdown 输出 Model、隐式字段、choice options 和持久化元数据,但不暴露内部输入类型噪音。
|
||||
|
||||
建议新增测试目标:`model_syntax`、`model_semantics`、`model_schema`。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test model_syntax
|
||||
cargo test -p dever-tests --test model_semantics
|
||||
cargo test -p dever-tests --test model_schema
|
||||
```
|
||||
|
||||
完成条件:字段/索引/关联/Seed/migrate 的合法和非法边界在 native build 前有稳定诊断,schema checksum 不受格式或方言影响。
|
||||
|
||||
## 阶段 4:配置、目录与 RuntimeProfile
|
||||
|
||||
目标:严格加载外部 setting,并按实际能力构建最小 runtime。
|
||||
|
||||
- [x] 增加严格 `setting.json` 结构化解析和可执行文件目录定位。
|
||||
- [x] CLI 应用命令以项目根为输入,统一把 `<project>/module` 交给 SourceMap;fixture 的显式源码根入口保持内部测试用途,不做应用级自动回退。
|
||||
- [x] 创建固定 `data/db|upload|log|cache|tmp` 目录;setting 缺失/无效直接失败。
|
||||
- [x] 实现显式连接、根包连接、default 的一次性解析和生成 Model 句柄缓存。
|
||||
- [x] 将 SQLite/PostgreSQL/config 依赖拆为 runtime optional features。
|
||||
- [x] native build 从 setting 提取驱动 capability,构建对应 runtime rlib;capability 进入 cache identity,敏感配置不进入生成源码或 identity。
|
||||
- [x] 启动时复核实际 setting 的驱动集合和 transaction binding;未打包驱动明确失败。
|
||||
- [x] 增加 base/sqlite/postgres/both 四种产物的依赖和二进制体积报告。
|
||||
|
||||
建议新增测试目标:`application_config`、`native_profiles`。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test application_config
|
||||
cargo test -p dever-tests --test native_cache
|
||||
cargo test -p dever-tests --test native_profiles
|
||||
```
|
||||
|
||||
完成条件:cwd 改变不影响定位,缺失配置无回退;SQLite-only/PostgreSQL-only 产物不含另一驱动。
|
||||
|
||||
## 阶段 5:共享 QueryPlan 与 SQLite 纵向闭环
|
||||
|
||||
目标:先用一个真实数据库打通静态 CRUD 和有界查询。
|
||||
|
||||
- [x] 实现 Model 短函数符号与匿名 create/update/query 块的上下文类型检查。
|
||||
- [x] 建立共享 QueryPlan、条件树、参数槽、稳定排序、page/cursor/stream 和结果 shape。
|
||||
- [x] 接入 `deadpool-sqlite` + `rusqlite bundled` 和有界 pool。
|
||||
- [x] 实现当前 CRUD 所需 SQLite dialect:标识符、占位符、类型/值编码、RETURNING、CHECK/FK/index。
|
||||
- [x] 生成 Model 专属 bind/row decoder;所有值参数化,不使用反射或动态 Map。
|
||||
- [x] 实现 create/get/first/list/cursor/count/exists/stream/update/delete/upsert 与有界 create_many。
|
||||
- [x] 验证 stream/取消/超时/pool exhaustion 后连接归还。
|
||||
|
||||
建议新增测试目标:`orm_query`、`sqlite_orm`。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test orm_query
|
||||
cargo test -p dever-tests --test sqlite_orm
|
||||
```
|
||||
|
||||
完成条件:临时 SQLite 数据库完成 CRUD、分页、游标、stream、约束错误和清理;目标机器路径不需要系统 SQLite。
|
||||
|
||||
## 阶段 6:Schema Diff、迁移与 Seed
|
||||
|
||||
目标:SQLite 先完成空库、升级、危险变化拒绝和幂等初始化。
|
||||
|
||||
- [x] 建立 catalog 列 introspection 与 history 中的规范化 actual schema。
|
||||
- [x] 实现内部 history 表、schema/seed checksum 和并发启动锁。
|
||||
- [x] 实现空库最终 schema 创建,不回放 `from`/历史 migrate。
|
||||
- [x] 实现安全字段 diff、约束数据库验证和 SQLite 表重建。
|
||||
- [x] 实现字段 `from`、稳定命名 migrate、内容不可改和事务性 history。
|
||||
- [x] 实现 Seed 唯一身份检查、缺失插入、非唯一冲突失败和 checksum 快速跳过。
|
||||
- [x] 对每一种自动/拒绝/显式变化保留真实 catalog 和数据断言,不只断言 SQL 字符串。
|
||||
|
||||
建议新增测试目标:`orm_migration`、`orm_seed`。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test orm_migration
|
||||
cargo test -p dever-tests --test orm_seed
|
||||
```
|
||||
|
||||
完成条件:新库、旧库 rename、长度放宽/收紧、唯一/FK、重建失败回滚、重复启动和 Seed 追加全部通过。
|
||||
|
||||
## 阶段 7:PostgreSQL 复用接入
|
||||
|
||||
目标:只增加方言和 executor,不复制 Model/QueryPlan/schema 语义。
|
||||
|
||||
- [x] 接入 `deadpool-postgres` + `tokio-postgres`;使用 `tokio-postgres-rustls` 支持系统根 TLS 校验和显式禁用。
|
||||
- [x] 实现当前 CRUD 所需 PostgreSQL dialect、值编码/解码、RETURNING、catalog introspection 和 advisory migration lock。
|
||||
- [x] 对固定 QueryPlan 使用 statement cache;缓存 key 只来自编译器生成的静态 SQL shape。
|
||||
- [x] 复用 SQLite 同一 CRUD/schema/Seed fixture 数据集和断言,仅替换隔离数据库适配。
|
||||
- [x] 验证 pool wait、连接断开、取消、timeout、constraint code 映射、TLS 主机名/证书拒绝和 shutdown drain。
|
||||
|
||||
建议新增测试目标:`postgres_orm`,默认标记为 ignored;仅在仓库根 `config/setting.json` 的 `database.postgres_test` 明确配置隔离测试 PostgreSQL 时手动运行,不接触现有服务。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test --offline -p dever-tests --features postgres --test postgres_orm -- --ignored --exact postgres_orm_uses_the_configured_isolated_database
|
||||
```
|
||||
|
||||
完成条件:PostgreSQL 与 SQLite 对共享 fixture 返回相同逻辑结果和 schema revision,差异仅存在于 dialect/executor。
|
||||
|
||||
## 阶段 8:Transaction 与多数据库
|
||||
|
||||
目标:实现无显式句柄、单连接静态约束的 transaction 函数。
|
||||
|
||||
- [x] 实现 `FunctionKind::Transaction` 检查和 `DatabaseEffect(ModelId)` 传播。
|
||||
- [x] build/runtime 分别解析 transaction Model 集合到唯一 connection。
|
||||
- [x] native 生成隐藏数据库上下文参数和最外层 begin/commit/rollback guard。
|
||||
- [x] 同连接嵌套 transaction 复用 handle;不创建 savepoint。
|
||||
- [x] 拒绝无数据库 effect、跨连接、动态切库、transaction context 逃逸和并发数据库子任务。
|
||||
- [x] 验证 error/fault 时先 rollback,再传播原始失败;rollback failure 只追加 cause。
|
||||
- [x] 验证 default、根包、显式 report 三种连接绑定和多连接启动顺序/幂等恢复。
|
||||
|
||||
建议新增测试目标:`orm_transaction`、`orm_connections`。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test orm_transaction
|
||||
cargo test -p dever-tests --test orm_connections
|
||||
cargo test --offline -p dever-tests --features postgres --test postgres_orm -- --ignored --exact postgres_orm_uses_the_configured_isolated_database
|
||||
```
|
||||
|
||||
完成条件:两种数据库都验证提交、回滚、嵌套复用、取消和连接归还,跨数据库在执行前被拒绝。
|
||||
|
||||
## 阶段 9:Relation 与原生 SQL 逃生口
|
||||
|
||||
目标:完成后端常用关联读取和复杂查询能力。
|
||||
|
||||
- [x] 实现 to-one relation 推导、显式反向 to-many relation 和 `with` 计划。
|
||||
- [x] to-one 使用受控 JOIN;to-many 按父页批量加载、稳定排序、数量和参数批次上限。
|
||||
- [x] 加入生成代码 query-shape 探针,断言父记录数增长不会产生 N+1。
|
||||
- [x] 实现参数化 typed native SQL,显式 SQLite/PostgreSQL 方言和具体 Model/record decoder。
|
||||
- [x] 拒绝 SQL 插值、动态列 Map、结果列缺失和类型不匹配。
|
||||
- [x] 建立 `examples/dever/cms/module/{user,news}/{model,service}`,用用户注册、新闻发布和 author 外键覆盖 Model/Service/transaction 纵向流程。
|
||||
|
||||
建议新增测试目标:`orm_relations`、`orm_native_sql`。
|
||||
|
||||
定向验证:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test -p dever-tests --test orm_relations
|
||||
cargo test -p dever-tests --test orm_native_sql
|
||||
```
|
||||
|
||||
完成条件:两种数据库返回相同关系结果,to-many SQL 数量有固定上界,原生 SQL 保持参数化和静态解码。
|
||||
|
||||
## 阶段 10:小内存与压力验证
|
||||
|
||||
目标:在正确性完成后冻结小机器默认值并找出真实瓶颈。
|
||||
|
||||
- [x] 扩展 `test/performance` 构建器,产出 sync/base、SQLite-only、PostgreSQL-only 和 HTTP+JSON+DB fixture。
|
||||
- [x] 构建和测量分离;运行阶段只启动已打包二进制。
|
||||
- [x] 添加 SQLite/PG idle、CRUD、list/cursor/stream、pool saturation、transaction cancel 场景;PG 仅在根 `config/setting.json` 显式配置 `database.postgres_test` 时运行。
|
||||
- [x] 报告 binary size、RSS/PSS/VmHWM、cgroup current/peak/OOM、线程/FD、配置 pool capacity、吞吐和 p50/p95/p99;当前无法直接观测的连接数、pool wait、SQL 数明确标记 unavailable。
|
||||
- [x] 先跑 1 核/128 MiB,再跑 64 MiB;不设置假 QPS 门槛,以无 OOM、无错误、内存有界、连接回收和相对对照为硬条件。
|
||||
- [x] 根据报告收敛 SQLite pool、PG min/max、page size、row buffer、IN batch 和 statement cache 上限;两档最大 RSS 高水位均约 7 MiB、OOM 为 0,现有有界默认值有充分余量,因此不做无数据依据的调参。
|
||||
- [x] 最后做 cleanup:删除临时 fixture、重复驱动分支、旧 async/await 代码和未使用 feature。
|
||||
- [x] 以 `examples/dever/cms` 验证项目级 check/run/build 和 SQLite-only 打包产物。
|
||||
- [x] 以 `examples/dever/cms` 验证真实业务内存基线。
|
||||
|
||||
最小基准检查:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s test/performance -p 'test_runner.py' -v
|
||||
python3 test/performance/run.py run \
|
||||
--artifacts target/performance/orm-current \
|
||||
--output target/performance/orm-128m \
|
||||
--suite orm --duration 10 --warmup 2 --repeats 3 \
|
||||
--cgroup-parent /sys/fs/cgroup/system.slice \
|
||||
--memory-mib 128 --cpu-quota 1
|
||||
```
|
||||
|
||||
该运行会启动隔离的本地测试进程并使用 cgroup;执行前必须说明,确认不会影响现有服务。PostgreSQL 场景只使用专门测试实例。
|
||||
|
||||
## 最终自检
|
||||
|
||||
- [x] `rg` 确认 Dever 官方库、examples、fixture 不再出现源码 `async`/`await`。
|
||||
- [x] `rg` 确认没有旧 ORM、链式 Query、显式 Database/Transaction 或双 CRUD 表面。
|
||||
- [x] 检查生成 SQL 的所有值都通过 bind 参数,只有编译器生成的标识符进入 SQL。
|
||||
- [x] 检查 SQLite/PostgreSQL 共享 QueryPlan/schema/error 流程,没有复制业务算法。
|
||||
- [x] 检查所有队列、池、page、batch、stream、日志和 blocking 路径有明确上限。
|
||||
- [x] 检查最终改动没有调试输出、TODO、临时测试、无用依赖或未使用兼容分支。
|
||||
- [x] 只报告实际运行的定向验证;未运行 PostgreSQL、压测或受权限限制的 cgroup 检查必须明确标注。
|
||||
|
||||
## 高风险文件与回滚点
|
||||
|
||||
- 编译器语法/HIR:`crates/dever-core/src/{lexer,parser,syntax,hir,format}.rs`。
|
||||
- 效果与错误:`crates/dever-core/src/contracts/*`、`check/*`、`native/*`、`api.rs`。
|
||||
- native profile/cache:`crates/dever-core/src/native/build.rs`、`native/build/cache.rs`、workspace/runtime Cargo 配置。
|
||||
- Runtime:新增 config/database/orm/dialect 模块与 `crates/dever-runtime/src/task.rs` 的 bounded blocking 接口。
|
||||
- 官方库和示例:`library/dever/*`、`examples/dever/*`。
|
||||
- 所有长期测试只能放在仓库根 `test/`。
|
||||
|
||||
阶段 1、2 属于语言合同迁移,完成后不回退旧语法;阶段 3-9 每阶段保持编译器与 runtime 同步可用。schema 变更只靠数据库事务/history 回滚,已成功提交的命名迁移不由程序版本回退。
|
||||
|
||||
## 2026-09-17 收口验证
|
||||
|
||||
- `cargo check --offline -p dever-runtime --features postgres` 通过。
|
||||
- `application_config` 3/3 通过;PostgreSQL 配置默认值和超时范围已覆盖。
|
||||
- 原 `postgres_orm` 无环境变量时提前返回却显示 1/1 通过,该证据无效。测试现已改为默认 ignored,并只从根 `config/setting.json` 读取 `database.postgres_test`;真实 PostgreSQL 验收仍未运行。
|
||||
- PostgreSQL 配置入口回归:performance runner 27/27 定向单测通过,`cargo check --offline -p dever-tests --features postgres --test postgres_orm` 通过;未连接数据库。
|
||||
- handler effect 替换回归通过;全部维护中示例按 `<project>/module` 统一源码根通过静态检查。
|
||||
- `cms_project` 已完成 1/1 check/run/build/打包程序验证;最终复验因根文件系统空间不足在链接测试二进制前中止,不是测试逻辑失败。
|
||||
- rustfmt、clippy 组件未安装;全量测试、真实 PostgreSQL、二进制体积、64/128 MiB 和 cgroup 压测未运行。
|
||||
|
||||
## 2026-09-17 最终验收
|
||||
|
||||
- 共享 fixture 已落在 `test/dever-tests/tests/support/orm_fixture.rs`;SQLite 与 PostgreSQL 使用同一 schema/Seed、CRUD、create_many、upsert、list/cursor/stream、transaction 和 catalog 断言。SQLite 1/1、隔离 PostgreSQL 1/1 最终复验通过。
|
||||
- PostgreSQL 真实验收修复并覆盖三处仅靠编译无法发现的问题:cursor 可空参数显式 SQL 类型 cast、UUID 原生二进制参数直接使用 `::uuid`、shutdown SQLSTATE 映射为 connection error。pool wait、断连、timeout、constraint、TLS 拒绝和 drain 均通过。
|
||||
- PostgreSQL 测试与性能工具只从仓库根 `config/setting.json` 读取 `database.postgres_test`;没有数据库 URL/TLS 环境变量或命令行覆盖入口。测试实例为独立临时 PostgreSQL,未接触现有 5432/5433 服务。
|
||||
- 四 profile 使用同一最小源码:base 413,880 B、sqlite 2,562,808 B、postgres 2,711,632 B、both 4,676,472 B。依赖报告确认单驱动产物不包含另一驱动,证据见 `target/performance/typed-orm-build-v2/profile-report.json`。
|
||||
- 1 CPU、无 swap、128 MiB 与 64 MiB 各完成 48/48 ORM case(SQLite/PostgreSQL × 8 场景 × 3 重复),两档 OOM/OOM kill 均为 0,最大 RSS 高水位分别 7,184,384 B 与 7,102,464 B;所有 cancel case 均确认池饱和、零提交和超时前连接回收。报告见 `target/performance/typed-orm-128m-v2/report.json` 与 `target/performance/typed-orm-64m/report.json`。
|
||||
- 现有 SQLite pool=1、PostgreSQL min/max=0/4、page size=100 以及有界 row/IN/statement 路径均有充分内存余量;本轮数据不支持进一步缩减,保持默认值。
|
||||
- CMS 128 MiB 与 64 MiB 各 3/3 完成,业务基线每次为 2 条记录,OOM 为 0,RSS 高水位中位数分别 5,513,216 B 与 5,562,368 B。报告见 `target/performance/cms-128m/report.json` 与 `target/performance/cms-64m/report.json`。
|
||||
- 定向回归:performance runner 32/32、`application_config` 3/3、`orm_query` 8/8、`cms_project` 1/1(实际 check/run/build/打包程序)通过;`cargo check --offline -p dever-runtime --features postgres` 通过。
|
||||
- 最终独立复核修正了两个收口问题:普通 SQLite `setting.json` 缺少 `database.postgres_test` 时现在按未启用 PostgreSQL 处理,只有显式但非法的测试连接才失败;真实 PostgreSQL fixture 使用 PID+纳秒+计数隔离 schema,并在断言失败/panic 后清理再恢复原始失败。修正后 runner 32/32、PostgreSQL cargo check 与真实验收 1/1 通过,结束后隔离 schema=0、活动连接=0。
|
||||
- rustfmt、clippy 组件未安装,未运行全量 workspace 测试;以上限制不影响本任务已列出的定向验收。
|
||||
@@ -0,0 +1,211 @@
|
||||
# SQLite 与 PostgreSQL 类型安全 ORM
|
||||
|
||||
## 目标
|
||||
|
||||
为 Dever 增加编译期类型检查、AOT 生成具体代码且无需反射的 ORM。第一轮完整支持 SQLite 和 PostgreSQL,共用 Model、查询计划、事务、迁移和错误合同;小内存机器只运行打包后的二进制,不承担 Rust 编译成本。
|
||||
|
||||
## 背景与约束
|
||||
|
||||
- Dever 是全新语言,不保留旧 ORM、旧 `async`/`await` 或其它源码兼容层,也不增加防御性回退路径。
|
||||
- 当前编译器没有用户泛型、反射、动态 `Value`、method 或 type-as-value;公开 ORM 必须保持静态、直接和可 AOT 降低。
|
||||
- Rust runtime 继续使用 Tokio 和真实异步 I/O。删除 Dever 源码中的 `async`/`await` 只改变语言表面,不把 HTTP、TCP、WebSocket、SSE 或数据库驱动改成阻塞线程模型。
|
||||
- SQLite 面向单机、小内存程序;PostgreSQL 面向并发后端。二者共享语义,不强行共享执行机制。
|
||||
- Model、Transaction、RowStream 等数据库资源沿用现有所有权、结构化任务、背压和取消清理原则。
|
||||
|
||||
## Model 与字段
|
||||
|
||||
- `model/` 下一个源文件定义一个 Model,继续使用普通 record `type`,不增加 `model` 关键字。
|
||||
- 文件只公开一个与文件名对应的 Model record;表名和列名按文件名、字段名精确派生,不做复数化猜测。
|
||||
- 同一文件集中字段、索引、关联、choice、Seed、迁移和紧贴持久化的数据访问操作,不增加外部 schema 映射文件。
|
||||
- 每个 Model 隐式拥有只读 `id` 和 `created_at: DateTime`。二者出现在完整查询结果中,但不出现在创建输入中;`updated_at` 不隐式生成。
|
||||
- `<model-package>.id` 是数据库生成的有符号 64 位正整数,也是 Model 专属不透明类型。PostgreSQL 使用 64 位 identity,SQLite 使用 `INTEGER PRIMARY KEY`;不同 Model 的 ID 不能混用,也不能按普通 `Int` 运算。
|
||||
- 关联字段直接引用目标主键,例如 `owner_id: app.model.user.id`、`parent_id: app.model.category.id?`。作为 Model 字段时自动生成外键和普通索引,但不触发隐式加载。
|
||||
- 第一轮字段类型包括 `Bool`、`Int`、`Float`、`Decimal(P, S)`、`Text`、`Bytes`、Model `.id`、`Uuid`、`DateTime`、`Date`、`Time`、`Duration`、`Json`、无载荷 choice 及其可空形式。
|
||||
- `List`、`Map` 和普通 record 不隐式序列化为列;结构化持久化必须显式使用 `Json`。
|
||||
- 字段元数据使用可解析、可格式化、可静态检查的语法,不使用字符串 tag。
|
||||
|
||||
### 字符串、字节与数值
|
||||
|
||||
- `Text` 表示无界 UTF-8 文本,`Text(max)` 表示最多 max 个 Unicode codepoint,`Text(min, max)` 表示 min 至 max 个 Unicode codepoint;不增加语义重复的 `Char`。
|
||||
- `Bytes`、`Bytes(max)`、`Bytes(min, max)` 使用相同边界形式,但长度单位是字节。
|
||||
- PostgreSQL 有界 Text 使用 `VARCHAR` 与必要的 CHECK;SQLite 使用 `TEXT` 与等价 CHECK,不能依赖 SQLite 忽略 VARCHAR 长度。
|
||||
- `Decimal(P, S)` 必须满足 decimal128 的精度边界;两种数据库往返后保持精确值,不静默舍入。
|
||||
|
||||
### UUID 与时间
|
||||
|
||||
- `Uuid` 是固定 128 位值,运行时不使用堆分配 Text。PostgreSQL 使用原生 UUID,SQLite 使用 16-byte BLOB;API/JSON/文本统一为小写、带连字符的 36 字符格式。
|
||||
- 普通 `Uuid` 由调用方提供;`Uuid generated` 由 ORM 创建 UUIDv7 并从创建输入省略;`generated` 与可空不能同时使用。
|
||||
- `Uuid` 不隐式表示关系,关系仍使用 Model `.id`。
|
||||
- `DateTime` 表示 UTC 毫秒时间点;`Date` 表示公历日期;`Time` 表示日内毫秒时间;`Duration` 表示固定毫秒时长,不使用包含月份换算的数据库 interval。
|
||||
|
||||
### Choice
|
||||
|
||||
- Model 文件可声明供字段使用的无载荷 choice;只有文件名对应的 record 生成表。
|
||||
- 可选项写成 `Pending = "待处理"`。数据库和 API 存储左侧稳定变体名,右侧显示名称不入库。
|
||||
- 编译器生成保持声明顺序的只读 `Choice.options: Map<Choice, Text>`;一个 choice 的变体必须全部带显示名或全部不带。
|
||||
- 第一轮不支持 choice payload,也不创建 PostgreSQL 专有 enum。
|
||||
|
||||
## 索引、Schema 与迁移
|
||||
|
||||
- 单字段索引写为字段后的 `index` 或 `unique`;复合索引写为 `index(a, b)` 或 `unique(a, b)`。
|
||||
- 索引描述当前目标 schema,不写版本号或手工名称;编译器稳定生成数据库对象名并静态检查字段引用和重复列。
|
||||
- 当前 Model schema 是唯一目标。ORM 比较数据库系统目录、内部 history 与二进制嵌入的规范化 schema,不生成源码级 `table/` 状态目录。
|
||||
- 新表直接按当前最终 schema 创建;新增可空字段、有合法常量默认值的非空字段、普通索引等意图唯一的变化自动应用。
|
||||
- 长度、精度、可空性、默认值和约束直接修改目标字段。放宽变化自动应用;收紧变化先验证现有数据,数据不满足时整次迁移失败并要求命名 migrate,绝不截断、舍入、猜测补值或删除数据。
|
||||
- 简单字段重命名写为 `display_name: Text(64) from nickname`。已有旧表执行 rename;全新数据库忽略历史来源,只创建最终字段。
|
||||
- `from` 只供 schema 规划,不进入运行时字段、CRUD 输入、查询 SQL 或最终约束。
|
||||
- 开发者不维护数字 schema 版本。编译器根据规范化 schema 和变更计划生成 revision 校验值,嵌入二进制并记录到数据库内部 history。
|
||||
- 复杂数据变化使用 Model 文件内稳定命名的 `migrate <name> { ... }`,包括拆分、合并、跨行转换、删除前归档和显式 drop。
|
||||
- 一个命名迁移只成功执行一次;已执行迁移的规范化内容改变时拒绝启动。迁移和 history 写入在同一数据库事务内完成。
|
||||
- SQLite 无法直接 ALTER 的变化由方言层在事务内重建表;PostgreSQL 使用对应 DDL。两者完成后必须得到相同逻辑 schema revision。
|
||||
- 只有结构化迁移无法表达时,命名 migrate 才可包含明确方言的参数化原生 SQL;不承诺该 SQL 跨数据库复用。
|
||||
|
||||
## Seed
|
||||
|
||||
- 每个 Model 文件最多一个无版本 `seed { ... }` 块,块内直接写多个匿名创建数据块,不重复 Model 名或 `create`。
|
||||
- Seed 数据按 Model 创建输入静态检查,不能填写 `id`、`created_at` 或其它 generated 字段。
|
||||
- 每条 Seed 必须完整提供至少一个单字段或复合唯一键,用于稳定识别初始化记录。
|
||||
- 编译器为规范化 Seed 生成校验值。内容未变化时启动不执行 Seed SQL;变化时在 schema 迁移成功后,以一个事务重新执行“缺失则插入”。
|
||||
- 唯一键冲突保留数据库现值;其它非空、CHECK、外键或解码错误必须失败。Seed 不覆盖或删除线上数据,修改/删除内置数据使用命名 migrate。
|
||||
|
||||
## 查询与内置操作
|
||||
|
||||
- Model 包生成唯一一套短函数:`create`、`create_many`、`get`、`first`、`list`、`cursor`、`count`、`exists`、`stream`、`update`、`delete`、`upsert`。
|
||||
- 业务代码直接调用 `user.list(...)`、`user.update(...)`,不暴露 `User.Query`、`User.List`、Active Record 实例方法或第二套 CRUD。
|
||||
- 创建、更新和查询配置使用上下文推导的匿名块;底层生成具体静态输入和结果类型,不引入动态字段 Map。
|
||||
- 查询只采用一次性配置块:
|
||||
|
||||
```dever
|
||||
users = user.list({
|
||||
where = status == UserStatus.Active
|
||||
and age >= 18
|
||||
and (city == "北京" or city == "上海")
|
||||
order = [created_at.desc, id.desc]
|
||||
page = 1
|
||||
size = 20
|
||||
with = [owner]
|
||||
})
|
||||
```
|
||||
|
||||
- 不提供 `user.where(...).order(...).list()` 链式查询器;链式查询不会提升数据库性能,却会要求新增 method、泛型查询对象和所有权语义。
|
||||
- 多个条件使用现有 `and`、`or`、`not` 和括号;支持比较、`in`、`between`、`contains`、`starts_with`、`ends_with`、null 与关联字段路径。
|
||||
- 查询表达式在编译期降低为类型检查后的 `QueryPlan`。字段、排序、关系和 SQL 结构不能来自运行时字符串;所有值通过参数绑定。
|
||||
- `list()` 默认返回第一页,默认 size 为 20,并受 `setting.json` 的统一上限约束;排序不是唯一键时自动追加 `id` 保证稳定分页。
|
||||
- `list` 返回 `items/page/size/total/pages`;`cursor` 返回 `items/next/has_more` 且不执行 count;`stream` 全量遍历但保持有界内存,不提供无界 `all()`。
|
||||
- 正向 to-one 由关联 ID 推导;反向 to-many 在 Model 中显式声明 `relation tasks = app.model.task.owner_id`。
|
||||
- `with` 才加载关系。to-one 使用受控 JOIN 或等价单次查询;to-many 按当前父页 ID 批量加载并归组,必须有排序和数量上限;禁止 lazy loading 和 N+1。
|
||||
- 多对多使用显式中间 Model,不生成隐藏表。
|
||||
- 复杂查询保留参数化、类型化的原生 SQL 逃生口。调用必须声明具体 Model/record 结果和方言;不返回动态行 Map,不允许 SQL 字符串插值。
|
||||
|
||||
## 数据库配置与绑定
|
||||
|
||||
- 应用项目根采用固定布局:`module/` 是唯一 Dever 源码根,`config/` 保存 `setting.json`,`data/` 保存运行数据。编译器从项目根加载时按 `module/` 的相对路径派生逻辑 package,`module` 本身不进入 package 名;`config/` 和 `data/` 不参与源码扫描。
|
||||
- 可执行文件按自身所在目录解析固定布局,不使用进程当前目录,也不提供环境变量覆盖:
|
||||
|
||||
```text
|
||||
app/
|
||||
app
|
||||
config/
|
||||
setting.json
|
||||
data/
|
||||
db/
|
||||
upload/
|
||||
log/
|
||||
cache/
|
||||
tmp/
|
||||
```
|
||||
|
||||
- `config/setting.json` 是唯一配置文件;缺失、无法解析或数据库配置无效时启动失败。运行时按需创建 `data/` 子目录,不生成默认 setting。
|
||||
- `setting.json` 顶层使用 `app`、`http`、`database`、`log` 等对象;`database` 直接映射连接名,不增加 `connections` 包装层。
|
||||
- `database.default` 必须存在;其它名称由应用定义,例如 `database.report`。
|
||||
- Model 连接按以下顺序在启动时解析并缓存:
|
||||
1. Model 文件显式 `database report`;
|
||||
2. 否则按根包名查找,例如 `app.model.user` 查 `database.app`;
|
||||
3. 根包连接键不存在时使用 `database.default`。
|
||||
- 只有“根包连接键不存在”允许回落 default。显式连接不存在、default 不存在或被选连接无效都直接启动失败。
|
||||
- 普通 CRUD 不接收 Database 或 Transaction 参数,也不允许按运行时字符串切换连接。
|
||||
- SQLite 使用 bundled `rusqlite`,同步调用进入现有有界 blocking 通道,默认小连接数并明确单写者约束。
|
||||
- PostgreSQL 使用 `tokio-postgres` 与有界异步连接池;池容量、等待和超时全部受配置上限约束。TLS 必须显式选择系统根验证或禁用,不允许验证失败后静默回落明文。
|
||||
- 编译器按可达数据库能力选择 runtime profile。SQLite-only 二进制不链接 PostgreSQL 驱动,PostgreSQL-only 二进制不链接 SQLite;驱动集合进入原生产物缓存身份。
|
||||
|
||||
## CMS 示例项目
|
||||
|
||||
- 在 `examples/dever/cms/` 建立可检查、可运行、可构建的完整项目,而不是只展示孤立语法片段。
|
||||
- 固定目录为 `config/setting.json`、`data/`、`module/cms.dever`、`module/user/model/`、`module/user/service/`、`module/news/model/`、`module/news/service/`。
|
||||
- `module/user` 和 `module/news` 分别是组件根;逻辑 package 使用 `user.model.*`、`user.service.*`、`news.model.*`、`news.service.*`,入口为 `cms.main`。
|
||||
- Model 展示隐式 `id`/`created_at`、Uuid、choice、Seed、索引和跨组件关联;Service 只承载注册、发布等事务业务,不包装 Model 已生成的普通 CRUD。
|
||||
- 示例默认使用 SQLite 和相对路径 `data/db/cms.db`,因此打包二进制可在无外部数据库服务的机器上直接运行;PostgreSQL 通过独立定向 fixture 验证,不让 CMS 示例依赖现有服务。
|
||||
|
||||
## 顺序调用、并发与事务
|
||||
|
||||
- Dever 源码删除 function/handler 的 `async` 标记和直接调用外层的 `await`。普通调用表示顺序等待完成,编译器根据传递挂起 effect 自动降低为 Rust coroutine。
|
||||
- 只有 `run(call)` 显式开始并发,`wait(task/group)` 消耗并等待结果,`stop(task/group)` 请求取消并等待结构化清理。
|
||||
- 同步入口的可达图没有挂起 effect 时不启动异步 runtime;包含网络、数据库、定时器、Channel 或异步 Stream 时只启动一个有界 Tokio runtime。
|
||||
- 事务使用专用函数声明:
|
||||
|
||||
```dever
|
||||
transaction create_order(...) (...) {
|
||||
order = order.create({...})
|
||||
inventory.update({...})
|
||||
}
|
||||
```
|
||||
|
||||
- 源码不出现 `@transaction`、Database、Transaction、begin、commit、rollback、`async` 或 `await`。
|
||||
- 编译器分析完整受控调用链中的 Model 数据库 effect,静态推导唯一连接,并通过隐藏参数传递事务上下文;不使用线程局部、任务局部或全局“当前事务”。
|
||||
- 最外层 transaction 函数开启物理事务;正常完成提交,error、fault 或取消先回滚再传播原始失败。
|
||||
- 同连接的嵌套 transaction 函数复用外层事务,不开启 savepoint,也不提前提交。
|
||||
- 无数据库 effect、跨连接、动态无法确定连接,或在 `run`/并发子任务中执行数据库 effect 的 transaction 函数在编译期拒绝。第一轮不支持分布式事务。
|
||||
|
||||
## Error、Fault 与日志边界
|
||||
|
||||
- choice 变体继续用 `error` 标记可恢复失败;编译器保留并扩展 C012,禁止丢弃、覆盖或仅用日志观察失败。
|
||||
- 普通可失败调用直接返回成功值,失败自动向当前边界传播,不写 `?`:
|
||||
|
||||
```dever
|
||||
user = user.get(id)
|
||||
```
|
||||
|
||||
- 需要主动处理完整结果时使用一个核心操作 `result(call)`,再由现有穷尽函数分句处理成功和 error 变体。
|
||||
- 自定义业务失败使用 `fail(OrderError.OutOfStock(id))`。错误集合由编译器沿调用图静态推导并显示在 API、文档和编辑器信息中,不要求每个函数重复声明错误类型。
|
||||
- `error` 表示可恢复业务/外部失败;`fault` 表示程序不变量、编译器已知资源或 runtime 故障。普通业务函数第一轮不能捕获 fault。
|
||||
- HTTP 未处理 error/fault 只终止当前请求,回滚事务后返回不泄露内部细节的 500,并在服务端根边界记录一次;预期 4xx 必须由 handler 显式映射。
|
||||
- Task 失败由 `wait`/`result` 返回;无 owner 的任务边界记录一次。CLI 输出 stderr 并返回非零退出码。
|
||||
- 日志是观察,不是错误处理;`log.error` 不满足 C012。底层驱动、事务和 Service 不重复记录同一失败。
|
||||
- 正式日志 API 采用 `dever.log.debug/info/warn/error(message, fields)` 与共享结构化诊断上下文。队列有界:debug/info 满时丢弃当前事件并累计数量,warn/error 施加背压;logger 自身失败或最终 fault 直接写 stderr。
|
||||
|
||||
## 性能与资源边界
|
||||
|
||||
- ORM 只生成具体 Model 输入、行解码和静态查询计划;共享查询规划、schema diff 和方言实现,不为每个 Model 复制算法。
|
||||
- 查询使用绑定参数和可复用预编译语句;结果按行拉取,有界缓冲,不默认收集全表。
|
||||
- 连接池、SQLite blocking、批量写入、分页、关系子列表、stream 和日志队列全部有明确上限。
|
||||
- 不静默重试非幂等写入;约束冲突、连接失败、超时、取消和解码失败保持可区分。
|
||||
- 小机器验收以打包二进制的稳定 RSS、空闲连接成本、并发请求内存斜率和取消后的资源回收为准,不以构建机编译耗时或峰值内存为主要目标。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 合法 Model 在原生构建前生成确定 schema、创建输入、查询结果和 CRUD;字段、索引、Seed、关系或查询中的非法引用产生源码定位诊断。
|
||||
- 未声明 `id`/`created_at` 的 Model 仍能读取这两个字段,创建时无需填写;不同 Model ID 无法误传。
|
||||
- SQLite 与 PostgreSQL 对全部首批字段类型、默认值、长度、精度、choice、外键和索引产生等价逻辑约束并正确往返;PostgreSQL 系统根 TLS 校验和显式明文模式分别通过定向验证。
|
||||
- 同一个查询配置块在两种数据库生成正确的参数化 SQL,支持分页、游标、stream、复合条件和显式关联加载;to-many 不产生 N+1。
|
||||
- `list` 默认有界并返回 total/pages,`cursor` 不执行 count,`stream` 在慢消费者下保持有界内存。
|
||||
- 从空数据库启动时直接创建最终 schema 并执行缺失 Seed;重复启动无重复数据。已存在数据库能安全自动升级,危险变化在没有有效 migrate 时保持原 schema/数据并启动失败。
|
||||
- 带 `from` 的稳定二进制既能初始化新数据库,也能保留旧列数据完成 rename;自动 revision 和命名迁移 history 可重复验证且不可静默改写。
|
||||
- 默认、根包和显式连接按既定优先级绑定;显式缺失连接和无效配置启动失败。事务调用链只能使用一个已解析连接。
|
||||
- create/get/update/delete/list、事务提交、事务回滚和取消清理分别通过 SQLite 与 PostgreSQL 的定向真实数据库 fixture。
|
||||
- 普通 Dever 网络和数据库调用无需 `async`/`await` 仍是非阻塞 I/O;只有 `run` 建立业务并发,同步程序不启动 Tokio。
|
||||
- 可恢复 error 不能被丢弃或用日志代替处理;默认传播、`result(call)`、`fail(...)`、HTTP/Task/CLI 边界和事务回滚具有定向编译与运行验证。
|
||||
- SQLite-only 与 PostgreSQL-only 发布二进制只包含所用驱动;目标机器不需要 Cargo、Rust、SQLite 动态库或 PostgreSQL 客户端 SDK。
|
||||
- 在 64 MiB 与 128 MiB 约束下分别测量 SQLite、PostgreSQL 的空闲基线、稳定请求负载、流式读取和连接池上限;持续运行时 RSS 不无界增长,取消后连接与事务槽位回到基线。
|
||||
- `deverc check examples/dever/cms`、`deverc run examples/dever/cms cms.main` 和以同一项目根执行的原生构建均使用 `module/` 作为源码根,并能由 CMS Service 完成初始化数据、注册和发布的纵向流程。
|
||||
|
||||
## 暂不纳入第一轮
|
||||
|
||||
- 旧源码兼容、第二套 CRUD、链式查询、动态 Model、反射字段访问或运行时动态 SQL 结构。
|
||||
- MySQL/MariaDB、SQL Server、Oracle 驱动;只保留真实共享边界,不提前增加空适配器。
|
||||
- 非默认主键、自定义主键类型、以任意 UUID 唯一键建立关系、主键策略迁移。
|
||||
- choice 稳定变体改名和表/Model 重命名的通用简写;第一轮使用命名 migrate。
|
||||
- lazy loading、自动 N+1、隐藏多对多表、跨数据库 join、分布式事务、savepoint、读写分离和自动故障转移。
|
||||
- 数据库专有数组/enum/interval、分区表、表空间、物化视图、触发器、行级安全和在线无锁迁移。
|
||||
- 通用 annotation、decorator、macro、AST 改写和用户编写的编译期函数处理器。
|
||||
- 前端、管理页面、package registry、跨平台发行和编译器自举。
|
||||
- 日志文件 sink 的最终格式、轮转默认值和压缩策略;该项在错误基础设施子阶段收敛,不阻塞 ORM 合同。
|
||||
@@ -0,0 +1,76 @@
|
||||
# ORM 架构选项
|
||||
|
||||
## 现有语言约束
|
||||
|
||||
- 一个源文件完整拥有一个 package,源码相对路径必须与 package 名匹配,因此可用 `model/` 路径承载 Model 身份,不需要再增加 `model` 声明。
|
||||
- `crates/dever-core/src/native/records.rs` 为用户 record/choice 生成具体类型;生产路径没有通用运行时 Value 或反射分派。
|
||||
- 系统资源由 `types.rs`、`check/symbols.rs`、`intrinsic.rs`、native emitter 和 runtime 共同定义,错误由官方 `.dever` choice 暴露。
|
||||
- static handler 是编译期绑定而非运行时函数值,可复用于行映射与查询回调,但不能单独解决表名、主键、索引和关联元数据。
|
||||
- 当前 native build 复用一个预编译 runtime rlib;数据库驱动选择必须进入可达性、runtime build 和缓存身份,不能只依赖链接器偶然消除代码。
|
||||
|
||||
## 表面模型比较
|
||||
|
||||
### 显式 Data Mapper(已否决)
|
||||
|
||||
概念形态:
|
||||
|
||||
```text
|
||||
find(User, database, id)
|
||||
insert(database, user)
|
||||
update(database, user)
|
||||
```
|
||||
|
||||
Model 是数据/schema,Database 和 Transaction 都由调用方显式传递。虽然实现直接,但业务表面重复且容易把同一事务漏传给某次操作;用户已明确否决,不作为公开或兼容 API 保留。
|
||||
|
||||
### Active Record(已否决)
|
||||
|
||||
概念形态:
|
||||
|
||||
```text
|
||||
User.find(database, id)
|
||||
user.save(database)
|
||||
```
|
||||
|
||||
该方案会把生成类型和成员暴露给业务代码,用户已明确否决,不作为兼容表面保留。链式查询同样需要新增 method 和中间查询对象语义,且不改善生成 SQL;首版已经确定不采用。
|
||||
|
||||
### SQL-first typed mapper
|
||||
|
||||
概念形态:参数化 SQL + 静态 row decoder。它不单独承担 ORM,只作为模型包短函数之外的高级逃生口保留:参数必须绑定,调用方必须声明具体 Model/record 结果,数据库专有语句明确选择对应 dialect。
|
||||
|
||||
## Model 文件约定
|
||||
|
||||
推荐形态:
|
||||
|
||||
```dever
|
||||
package app.model.user exposes (
|
||||
User
|
||||
)
|
||||
|
||||
type User {
|
||||
email: Text(254) unique
|
||||
name: Text(1, 64)
|
||||
bio: Text?
|
||||
}
|
||||
```
|
||||
|
||||
- 路径 `app/model/user.dever` 对应 `package app.model.user`,文件名 `user` 精确对应公开 record `User` 和表名 `user`;不做复数化猜测。
|
||||
- 文件只能公开一个 Model record。私有辅助声明是否允许由实现阶段按真实需要决定,不能形成第二个持久化类型。
|
||||
- 主键 `id` 和 `created_at` 由 Model 文件约定隐式生成,不进入创建输入;查询结果仍可读取 `user.id` 与 `user.created_at`。其它 Model 通过 `app.model.user.id` 引用该强类型主键。
|
||||
- 编译器可合成 `User.New` 创建输入和固定 CRUD 成员;这属于 Model 专用静态语义,不意味着加入通用嵌套 type、反射或 type-as-value。
|
||||
- 简单字段属性紧邻字段;复合唯一约束、复合索引、初始化数据和有序迁移使用同文件的独立声明,避免把复杂结构压进字段行或外部配置。
|
||||
- 安全且意图唯一的 schema 变化自动生成;索引和 Seed 不使用版本号。简单重命名通过新字段的 `from <old_field>` 明确,编译器自动生成 revision;删除、类型收窄和数据转换等复杂操作才使用 Model 文件底部的命名 migrate。revision/迁移校验值记录在 history 中,SQLite 表重建由 dialect 隐藏。
|
||||
- Model 文件可以声明自定义持久化操作,但优先组合生成的查询/事务能力;不能覆盖通用 CRUD 的事务、错误和资源合同。
|
||||
|
||||
## 驱动边界
|
||||
|
||||
- SQLite:成熟 Rust 驱动支持 bundled SQLite,适合产生无需目标系统 SQLite 的单文件程序;同步 API 必须通过 Dever 已有 bounded blocking 资源执行。
|
||||
- PostgreSQL:成熟 Tokio 驱动原生异步并支持流水线;连接 driver 必须属于 Database/Pool 的结构化作用域。
|
||||
- 两者共享 QueryPlan、绑定值、行 schema、错误分类和迁移意图;SQL 编码与执行机制保持独立。
|
||||
|
||||
## 结论
|
||||
|
||||
采用 Model 包短函数作为唯一首版表面,例如 `user.list(...)`、`user.update(...)`。Model 用一文件一普通 `type` 的约定识别,不增加 `model` 关键字;创建、更新和查询块由调用上下文推导,不暴露 `User.Query` 等生成类型。复杂查询使用参数化、类型化的原生 SQL,不引入动态行对象。
|
||||
|
||||
跨操作事务采用 `transaction <function>` 专用函数声明。普通调用按顺序等待完成,编译器根据传递挂起 effect 自动降低为协程;Dever 源码不出现 `async`、`await`、Database、Transaction、begin、commit 或 rollback。编译器从 Model 数据库效果推导唯一连接,并把内部事务上下文沿受控调用链传递;正常完成自动提交,error、fault 或取消自动回滚。首版不允许事务中的数据库效果进入并发子任务,也不提供跨库事务、嵌套物理事务或 savepoint。
|
||||
|
||||
不引入 `@` 或通用函数装饰器。注解本身并不能减少事务实现,只会额外增加解析、注解解析、顺序、参数、扩展权限和源码映射合同;用户自定义处理还需要通用函数泛型、闭包捕获、async 包装与错误转发。完全自动推导事务和调用点 `transaction(handler)` 也分别存在边界隐蔽与包装遗漏问题,因此都不作为公开表面。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 数据库驱动与连接池选择
|
||||
|
||||
## 结论
|
||||
|
||||
- SQLite 使用 [rusqlite](https://github.com/rusqlite/rusqlite) 并启用 `bundled`,目标机器不依赖系统 SQLite。
|
||||
- SQLite 池与同步任务适配使用 [deadpool-sqlite](https://docs.rs/crate/deadpool-sqlite/latest),它直接基于 rusqlite,并能把 `bundled` feature 传递给 rusqlite。
|
||||
- PostgreSQL 使用 [tokio-postgres](https://github.com/sfackler/rust-postgres),保留原生异步连接与流水线能力;TLS 使用 [tokio-postgres-rustls](https://docs.rs/tokio-postgres-rustls/latest/tokio_postgres_rustls/) 复用项目现有 rustls 根证书和主机名校验。
|
||||
- PostgreSQL 连接池使用 [deadpool-postgres](https://docs.rs/deadpool-postgres/latest/deadpool_postgres/),复用其连接回收和静态语句缓存,不自行实现通用连接池。
|
||||
|
||||
## 选择理由
|
||||
|
||||
- 四个 crate 属于两组相互配套的驱动/池实现,不需要引入完整 ORM 或第二套查询语义。
|
||||
- rusqlite 的 bundled 模式静态编译 SQLite,符合打包后二进制直接运行的要求。
|
||||
- tokio-postgres 与当前 Dever Tokio runtime 一致;它的连接 driver 可以纳入现有结构化任务生命周期。
|
||||
- deadpool-postgres 已包装 `tokio_postgres::Client`/`Transaction` 并提供 statement cache,适合 Dever 已静态确定 SQL 形状的查询。
|
||||
- deadpool-sqlite 负责连接池和同步连接的安全交互;Dever 仍在外层施加全局 blocking 上限、取消和超时合同。
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
- 不采用 SQLx、Diesel 或 SeaORM:Dever 自己拥有 Model、QueryPlan、迁移和类型检查,再引入完整 ORM 会形成重复语义与额外二进制负担。
|
||||
- 不直接手写 PostgreSQL/SQLite 连接池:池的回收、等待、关闭与失效连接处理不是 Dever 的业务差异点。
|
||||
- 不让 SQLite 和 PostgreSQL 共用同一个执行 trait object 热路径;共享的是静态 QueryPlan 和错误分类,具体执行通过编译时 profile 与连接枚举分派。
|
||||
|
||||
## 二进制裁剪
|
||||
|
||||
`dever-runtime` 将数据库依赖设为 optional features:
|
||||
|
||||
- `sqlite`:deadpool-sqlite + rusqlite bundled
|
||||
- `postgres`:deadpool-postgres + tokio-postgres + tokio-postgres-rustls
|
||||
- `database`:共享 config、ORM 值和错误基础
|
||||
|
||||
构建阶段从 `config/setting.json` 读取所有连接的数据库类型,只把驱动能力集合加入 runtime profile 和原生产物缓存身份,不嵌入账号、连接串或其它配置值。运行时设置可以在同一能力集合内改变;改用未打包的数据库类型必须重新构建。
|
||||
|
||||
## 验证重点
|
||||
|
||||
- 分别比较 base、SQLite-only、PostgreSQL-only 和双驱动二进制大小。
|
||||
- 64/128 MiB 环境测量空池、最大池、稳定负载和连接归还后的 RSS。
|
||||
- 验证连接获取超时、取消、driver task 失败、SQLite busy 和 shutdown drain。
|
||||
- 对静态 SQL 验证 statement cache 命中;迁移 DDL 不进入无界 statement cache。
|
||||
@@ -0,0 +1,192 @@
|
||||
# ORM 字段类型与隐式字段
|
||||
|
||||
## 默认字段
|
||||
|
||||
`model/` 中的每个 Model 即使源码不写,也具有:
|
||||
|
||||
```dever
|
||||
id: <model-package>.id
|
||||
created_at: DateTime
|
||||
```
|
||||
|
||||
- `id` 是编译器合成、数据库生成的 64 位正整数主键字段;它的公开静态类型就是当前 Model 的 `<model-package>.id`,不能当作普通 Int 运算,不同 Model 之间也不能混用。
|
||||
- `created_at` 由 ORM 在插入时写入当前 UTC DateTime。
|
||||
- 两个字段都存在于完整 Model 和查询结果中,不存在于 `Model.New` 创建输入中。
|
||||
- `updated_at` 不默认生成。Go Dever 现有 Model 中 created_at 明显比 updated_at 普遍;只有确实需要跟踪修改时间的 Model 才显式声明自动更新字段。
|
||||
- `deleted_at`、version、tenant_id 和操作者字段都具有业务含义,不作为所有 Model 的隐式字段。
|
||||
|
||||
因此最小 Model 可以写成:
|
||||
|
||||
```dever
|
||||
type User {
|
||||
name: Text(1, 64)
|
||||
email: Text(254) unique
|
||||
}
|
||||
```
|
||||
|
||||
读取 `user.id`、`user.created_at` 合法,但创建时只提供 name/email。
|
||||
|
||||
## 通用类型矩阵
|
||||
|
||||
| Dever 类型 | 逻辑语义 | PostgreSQL | SQLite |
|
||||
| --- | --- | --- | --- |
|
||||
| Bool | 布尔值 | BOOLEAN | INTEGER + CHECK |
|
||||
| Int | 有符号 64 位整数 | BIGINT | INTEGER |
|
||||
| Float | binary64 | DOUBLE PRECISION | REAL |
|
||||
| Decimal(P, S) | 最多 34 位十进制定点数 | NUMERIC(P,S) | 精确定点编码与约束 |
|
||||
| Text | 无界 UTF-8 文本 | TEXT | TEXT |
|
||||
| Text(N) | 0 至 N 个 Unicode codepoint | VARCHAR(N) | TEXT + CHECK |
|
||||
| Text(M, N) | M 至 N 个 Unicode codepoint | VARCHAR(N) + CHECK | TEXT + CHECK |
|
||||
| Bytes | 无界字节 | BYTEA | BLOB |
|
||||
| Bytes(N) / Bytes(M, N) | 有界字节 | BYTEA + CHECK | BLOB + CHECK |
|
||||
| `<model-package>.id` | 具体 Model 的不透明 64 位主键/外键投影 | BIGINT identity | INTEGER PRIMARY KEY |
|
||||
| Uuid | 128 位 UUID | UUID | 16-byte BLOB |
|
||||
| DateTime | UTC 时间点,毫秒精度 | TIMESTAMPTZ(3) | epoch-millis INTEGER |
|
||||
| Date | 公历日期 | DATE | epoch-day INTEGER |
|
||||
| Time | 一天内时间,毫秒精度 | TIME(3) | millis-of-day INTEGER |
|
||||
| Duration | 固定毫秒时长 | BIGINT | INTEGER |
|
||||
| Json | 已验证 JSON 值 | JSONB | UTF-8 TEXT |
|
||||
| 无载荷 choice | 封闭枚举 | TEXT + CHECK | TEXT + CHECK |
|
||||
|
||||
List、Map、普通 record 不自动变成 JSON 列。调用方必须显式选择 Json,避免字段重构在不知情时改变持久化格式。
|
||||
|
||||
## UUID 字段
|
||||
|
||||
UUID 不是每个 Model 的隐式字段。只有业务需要公开不可预测标识、跨系统交换标识或在写入数据库前生成标识时才显式声明:
|
||||
|
||||
```dever
|
||||
type User {
|
||||
uuid: Uuid generated unique
|
||||
name: Text(1, 64)
|
||||
}
|
||||
```
|
||||
|
||||
- `uuid: Uuid` 由调用方提供,存在于创建输入中。
|
||||
- `uuid: Uuid generated` 由 ORM 在创建时生成 UUIDv7,不存在于创建输入中。
|
||||
- `uuid: Uuid?` 接受空值;`generated` 与可空不能同时使用。
|
||||
- 运行时使用固定 16 字节值,不用堆分配的 Text 保存 UUID。
|
||||
- PostgreSQL 使用原生 UUID,SQLite 使用 16-byte BLOB,索引不会保存 36 字符文本。
|
||||
- API、JSON、日志及显式文本转换统一输出小写、带连字符的 36 字符形式;解析只接受语言标准库定义的 UUID 文本格式。
|
||||
- Uuid 字段不会自动成为关联。关联仍写成 `app.model.user.id`,以保持外键类型检查和紧凑索引。
|
||||
|
||||
## 初始化数据写法
|
||||
|
||||
Seed 是当前 Model 的可重复初始化数据。一个块可以包含多条数据,值使用 Model 创建输入进行静态检查:
|
||||
|
||||
```dever
|
||||
type Status {
|
||||
code: Text(32) unique
|
||||
name: Text(64)
|
||||
enabled: Bool default true
|
||||
}
|
||||
|
||||
seed {
|
||||
{
|
||||
code = "pending"
|
||||
name = "待处理"
|
||||
}
|
||||
|
||||
{
|
||||
code = "done"
|
||||
name = "已完成"
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以后增加初始化记录时直接追加数据块,不维护 Seed 版本号。编译器按 Seed 内容校验值判断是否需要执行;执行时整体使用事务,唯一冲突保留已有记录。源码移除 Seed 也不删除数据,需要修改或删除时使用显式命名迁移。
|
||||
|
||||
## 可选项
|
||||
|
||||
固定可选项复用语言现有的无载荷 choice `type`,不另造 ORM enum:
|
||||
|
||||
```dever
|
||||
type TaskStatus {
|
||||
Pending = "待处理"
|
||||
Running = "进行中"
|
||||
Done = "已完成"
|
||||
}
|
||||
|
||||
type Task {
|
||||
title: Text(1, 160)
|
||||
status: TaskStatus default TaskStatus.Pending
|
||||
}
|
||||
|
||||
seed {
|
||||
{
|
||||
title = "初始化任务"
|
||||
status = TaskStatus.Pending
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Task` 是文件对应的 Model record;`TaskStatus` 是同文件的辅助 choice,不生成表。ORM 第一轮只接受不带 payload 的 choice,并将左侧变体名映射为带 CHECK 约束的 Text。右侧显示名称不存入业务列。
|
||||
|
||||
推荐由编译器把显示名称提供为保持声明顺序的只读 Map,不再重复定义映射:
|
||||
|
||||
```dever
|
||||
task_status_options() (options: Map<TaskStatus, Text>) {
|
||||
options = TaskStatus.options
|
||||
}
|
||||
```
|
||||
|
||||
需要选项子集或自定义顺序时再返回 `List<TaskStatus>` 或组合 `TaskStatus.options`。choice 负责限制合法值,生成的 Map 负责名称和默认顺序。
|
||||
|
||||
## 索引与迁移写法
|
||||
|
||||
单字段索引直接跟在字段后,复合索引放在 Model 后;它们都描述当前目标 schema,不使用版本号或手工数据库名称:
|
||||
|
||||
```dever
|
||||
type Task {
|
||||
tenant_id: app.model.tenant.id
|
||||
code: Text(32)
|
||||
status: TaskStatus index
|
||||
title: Text(1, 160)
|
||||
}
|
||||
|
||||
unique(tenant_id, code)
|
||||
index(status, created_at)
|
||||
```
|
||||
|
||||
简单字段重命名直接写在新字段旁边:
|
||||
|
||||
```dever
|
||||
type Task {
|
||||
summary: Text from description
|
||||
}
|
||||
```
|
||||
|
||||
编译器生成 `RENAME COLUMN`,不会把它误判成删旧列再加新列。revision 由编译器根据规范化 schema 和计划自动生成并写入数据库 history,源码不维护版本号。
|
||||
|
||||
拆分、合并、删除前归档等无法附着在单个新字段上的数据变化,才保留一次性命名迁移:
|
||||
|
||||
```dever
|
||||
migrate remove_legacy_code {
|
||||
drop legacy_code
|
||||
}
|
||||
```
|
||||
|
||||
迁移名称是复杂操作的稳定身份,内容校验值用于阻止已经执行的迁移被悄悄修改。`from` 需要保留到所有可能升级的数据库都已越过该变更。
|
||||
|
||||
## 关联 ID
|
||||
|
||||
```dever
|
||||
type Article {
|
||||
author_id: app.model.user.id
|
||||
category_id: app.model.category.id?
|
||||
title: Text(1, 160)
|
||||
}
|
||||
```
|
||||
|
||||
- `app.model.user.id` 与 `app.model.category.id` 是不同静态类型,不能误传。
|
||||
- 点分路径的最后一段 `id` 是目标 Model 的隐式字段投影,不是新增的普通小写 type;它也能用于 function 输入、输出和局部类型位置。
|
||||
- 非空和可空关联第一轮都使用 `ON DELETE RESTRICT`;SET NULL 或 CASCADE 的通用关系选项不在第一轮,必要时使用明确方言的命名 migrate。
|
||||
- 每个关联 ID 自动创建普通索引和外键约束。它只表达持久化关系,不自动查询目标对象。
|
||||
- 关系预加载必须显式请求并保持有界,不能形成隐藏 N+1。
|
||||
|
||||
## 时间 API 方向
|
||||
|
||||
- DateTime 值始终代表 UTC 时间点,不在值内携带可变时区数据库。
|
||||
- 时区只参与解析、格式化和日历换算;数据库比较、排序和相减使用统一时间点。
|
||||
- `created_at` 自动取当前 DateTime。`updated_at` 不隐式生成;第一轮需要该字段时按普通 `DateTime` 明确写入,自动更新修饰词后续单独设计。
|
||||
- DateTime/Date/Time 的 parse、format、比较和加减由标准 package 提供;数据库函数通过 ORM 查询表达式或明确的原生 SQL 使用。
|
||||
@@ -0,0 +1,45 @@
|
||||
# Go Dever ORM 参考结论
|
||||
|
||||
## 已核对实现
|
||||
|
||||
- `/data/project/shemic/backend/dever/orm/schema_registry.go` 通过 Go 反射读取 `dorm` 字符串 tag;Text 默认 `VARCHAR(255)`,`size:N` 生成 `VARCHAR(N)`,也允许直接写 `type:varchar(N)` 或 `type:text`。
|
||||
- `ModelConfig.Index`/`Indexes` 使用额外 Go struct 表达单列或复合索引;`ModelConfig.Seeds` 使用 `[]map[string]any` 表达初始化数据。
|
||||
- `/data/project/shemic/backend/dever/orm/schema_apply.go` 能建表、加字段、修改部分数据库的字段类型、删旧字段,并同步新增、变化和删除的索引。
|
||||
- 当前 Seeds 只在表首次创建时插入;已有表后来新增 Seed 不会自动补入。Seed 使用动态 Map,字段名和值类型不能在编译期完整检查。
|
||||
- 当前自动迁移直接对数据库执行,再可选记录 SQL;它会删除不在目标 schema 中的字段和索引。字段重命名只对大小写或下划线差异做有限推断,无法可靠判断任意重命名。
|
||||
- `database.persist=true` 会把目标 schema 写入 `data/table/*.json`,`dever migrate` 再读取这些文件应用到目标数据库。该目录是 schema 传递/发布产物,不包含某个数据库实例是否成功执行的可靠状态;同一文件可以对应多个状态不同的数据库。
|
||||
- Go Model wrapper 可以嵌入通用 `orm.Model[T]` 并增加 `AfterSave`、`AfterDelete` 或自定义查询方法。普通 Model 直接复用通用 CRUD,不需要 wrapper。
|
||||
|
||||
## 新语言应复用的设计
|
||||
|
||||
- 一个 Model 文件集中字段、索引、默认排序、初始化数据和少量紧贴持久化生命周期的扩展。
|
||||
- 通用 CRUD、过滤、排序、分页、事务和批量操作由 ORM 提供,不为每个 Model 重写。
|
||||
- 单列与复合索引都从 Model 目标 schema 自动同步。
|
||||
- Model 声明生成确定的数据库 schema,SQLite/PostgreSQL 只负责方言差异。
|
||||
|
||||
## 不应照搬的部分
|
||||
|
||||
- 不使用字符串 tag、反射、`any`、动态字段 Map 或运行时猜测字段名。
|
||||
- 不把无界 Text 默认截成 `VARCHAR(255)`;`Text` 表示无界文本,只有显式长度上限才产生 VARCHAR/CHECK。
|
||||
- 不只在首次建表时处理初始化数据。每条 Seed 必须有稳定身份并可重复应用。
|
||||
- 不把“字段从目标结构中消失”直接等同于可以删列,也不把任意新增/缺失列猜成重命名。
|
||||
- 不把本地 `table/` 文件是否存在当作数据库建表或迁移完成标记。新语言将目标 schema 嵌入二进制,实际状态记录在数据库并通过系统目录核对。
|
||||
- 不允许自定义方法覆盖并改变通用 CRUD 的基本事务、错误和资源合同;自定义操作组合通用查询或参数化 SQL。
|
||||
|
||||
## 推荐字段形态
|
||||
|
||||
```dever
|
||||
type User {
|
||||
name: Text(1, 64)
|
||||
email: Text(254) unique
|
||||
bio: Text?
|
||||
credit: Decimal(18, 2) default 0
|
||||
status: Int >= 0 and <= 2 default 1
|
||||
}
|
||||
```
|
||||
|
||||
- `Text(N)` 表示最多 N 个 Unicode codepoint,`Text(M, N)` 表示 M 至 N 个;无参数 Text 是无界文本。
|
||||
- `Bytes(N)` 表示最多 N 个字节,`Bytes(M, N)` 表示 M 至 N 个字节。
|
||||
- `Decimal(P, S)` 描述数据库精度与小数位,必须满足 `1 <= P <= 34`、`0 <= S <= P`,与 Dever decimal128 的 34 位有效数字上限一致。
|
||||
- `?` 已经表达可空,不再增加 `null/not null` 两套写法。
|
||||
- `id` 与 `created_at` 由 Model 隐式生成;关联主键用 `app.model.user.id` 这种字段投影引用。默认值和简单 unique 靠字段元数据;复合索引、无版本 Seed 和命名迁移使用同文件的独立声明,避免把复杂结构塞进一行字段。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 隐式挂起的顺序调用
|
||||
|
||||
## 决定
|
||||
|
||||
Dever 源码删除 function/handler 的 `async` 标记和直接异步调用外层的 `await(...)`。普通调用始终表示调用完成后才执行下一条语句;如果调用链包含数据库、网络、定时器、Channel 或异步 Stream 等挂起效果,编译器自动把对应函数降低为协程。
|
||||
|
||||
```dever
|
||||
route(request: dever.http.Request) (response: dever.http.Response) {
|
||||
user = user.get(request.user_id)
|
||||
response = render(user)
|
||||
}
|
||||
```
|
||||
|
||||
等待 I/O 时只挂起当前协程,Tokio 工作线程可以执行其它连接和任务。语法简化不意味着阻塞线程,也不改变 HTTP/TCP/WS/SSE runtime、连接池、背压、超时与取消清理。
|
||||
|
||||
## 并发边界
|
||||
|
||||
普通调用严格顺序;只有 `run` 显式启动并发:
|
||||
|
||||
```dever
|
||||
first = run(load_user(first_id))
|
||||
second = run(load_user(second_id))
|
||||
first_user = wait(first)
|
||||
second_user = wait(second)
|
||||
```
|
||||
|
||||
- `run(call(...))` 创建受限 Task。
|
||||
- `wait(task_or_group)` 消耗任务或任务组并取得结果/等待排空。
|
||||
- `stop(task_or_group)` 请求取消并等待结构化清理。
|
||||
- CPU 同步计算继续使用 `parallel`,明确阻塞调用继续使用 `blocking`。
|
||||
- 普通调用不产生 Task,也不允许把可挂起调用结果当 Future 保存。
|
||||
|
||||
## 编译器边界
|
||||
|
||||
- 系统 intrinsic 和官方库入口提供基础挂起效果;调用图、静态 handler 专门化和 collection handler 传播效果。
|
||||
- checker 在效果推导后区分顺序调用、`run` 和 Task `wait`;HIR/native 仍可保留内部 coroutine/await 节点,但不把它们暴露到源码。
|
||||
- 同步入口的完整可达图没有挂起效果时沿用直接原生入口,不初始化 Tokio。
|
||||
- handler 是否允许挂起由其使用位置和实际效果静态检查,不再由源码 `async handler` 标记。
|
||||
- API snapshot、Markdown 合同和格式化输出的是新语法;旧 `async`/`await` 不保留兼容分支。
|
||||
|
||||
## 迁移范围
|
||||
|
||||
- lexer、parser、syntax AST、checker、effect/cycle/failure 分析、HIR 与 native emitter。
|
||||
- `dever.task`、`dever.net`、`dever.http`、`dever.websocket`、`dever.sse` 的官方 Dever 声明。
|
||||
- HTTP/1、HTTP/2、TCP、TLS、WebSocket、SSE、AsyncStream、Channel 和结构化并发测试中的嵌入源码。
|
||||
- backend/http/live/async 示例、性能 fixtures、LANGUAGE 与 Trellis backend 规范。
|
||||
|
||||
Rust runtime 内的 Rust `async/.await` 不在迁移范围;删除它们会把高并发 I/O 退化为阻塞或线程堆叠,与目标相反。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 字段重命名与迁移版本调研
|
||||
|
||||
## 官方实现结论
|
||||
|
||||
- [Alembic Autogenerate](https://alembic.sqlalchemy.org/en/latest/autogenerate.html) 明确说明无法检测表名和列名变化;它会生成 add/drop 候选,要求开发者审查并改为 rename。
|
||||
- [Prisma 自定义迁移](https://docs.prisma.io/docs/orm/prisma-migrate/workflows/customizing-migrations) 的字段重命名示例同样默认得到 drop/add,需要先生成草稿迁移,再人工改成 `RENAME COLUMN`。Prisma 用迁移目录和数据库 `_prisma_migrations` 表维护历史。
|
||||
- [EF Core 管理迁移](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/managing) 说明框架无法判断“删除并新增”和“重命名”的真实意图;生成代码需要改为 `RenameColumn`,否则会丢失旧列数据。
|
||||
- [Django 迁移操作](https://docs.djangoproject.com/en/5.2/ref/migration-operations/) 提供显式 `RenameField(old_name, new_name)`;[Django 迁移流程](https://docs.djangoproject.com/en/6.0/topics/migrations/) 把迁移文件视为数据库 schema 的版本控制,并在数据库记录应用历史。
|
||||
- [Atlas 声明式重命名](https://atlasgo.io/changelog/declarative-schema-renames) 使用紧邻目标列的 `renamed_from` 显式表达意图。Atlas 早期依赖交互式询问,但非交互部署无法回答,因此把 rename 意图写回声明式 schema。
|
||||
- [Atlas 声明式与版本式对比](https://atlasgo.io/concepts/declarative-vs-versioned) 表明声明式流程可以从目标 schema 规划实际数据库差异;版本式流程则保留完整迁移文件历史。两者都不能依靠一个数字版本推导 rename 意图。
|
||||
|
||||
## 对 Dever 的结论
|
||||
|
||||
- 只增加 Model 数字版本不能解决字段重命名;版本只标识状态先后,不包含旧列与新列的对应关系。
|
||||
- 不采用字段名相似度自动猜测。相同类型的删一列、加一列既可能是 rename,也可能是两个独立业务变化。
|
||||
- 简单 rename 采用声明式字段来源:`new_field: Type from old_field`。它比单独 migrate 块短,并把意图放在所属字段旁。
|
||||
- 编译器为规范化目标 schema 与迁移计划自动产生 revision id/校验值,嵌入二进制;数据库内部 history 记录实际应用状态。开发者不写数字版本。
|
||||
- `from` 必须保留到所有需要升级的数据库越过该变化;这与 Atlas 把 `renamed_from` 放进 schema 的原则一致。
|
||||
- `from` 只描述已有表的升级路径。目标表不存在时直接创建当前最终 schema,只创建新字段,不要求旧字段存在,也不先创建旧结构再回放 rename。
|
||||
- 拆分、合并、跨行回填、删除前归档等没有单字段对应关系的变化,仍需要命名 migrate。该复杂度来自数据转换本身,不能由版本号或启发式推断消除。
|
||||
@@ -0,0 +1,199 @@
|
||||
# ORM 查询与内置操作方案
|
||||
|
||||
## 结论
|
||||
|
||||
公开表面只使用 Model 包短函数和一次性查询配置块:
|
||||
|
||||
```dever
|
||||
users = user.list({
|
||||
where = status == UserStatus.Active and age >= 18
|
||||
order = [created_at.desc, id.desc]
|
||||
page = 1
|
||||
size = 20
|
||||
with = [owner]
|
||||
})
|
||||
```
|
||||
|
||||
不提供 `User.Query`、`User.List`、Active Record 实例方法或 `user.where(...).order(...).list()` 链式查询器。编译器把匿名块直接静态降低为 `QueryPlan`;源码没有 `async`/`await`,普通调用顺序等待完成。
|
||||
|
||||
## 为什么选择配置块
|
||||
|
||||
块式和链式可以生成相同 SQL,链式本身没有数据库性能优势。Dever 当前没有通用 method、lambda、用户泛型或 type-as-value,链式方案会额外引入查询对象类型、所有权、复用和执行时机合同。配置块则有以下特点:
|
||||
|
||||
- `create`、`update` 和 `list` 保持同一种上下文推导形式。
|
||||
- 一次调用完整展示条件、排序、分页和关系加载。
|
||||
- 编译器直接生成一个静态计划,不产生运行时中间查询对象。
|
||||
- 公开 API 只有一套写法,不需要兼容或维护等价表面。
|
||||
|
||||
将来若动态 HTTP 参数确实需要逐步组合条件,应增加受限的类型化 where 值,而不是改造全部 CRUD 为链式查询。
|
||||
|
||||
## 基本 CRUD
|
||||
|
||||
```dever
|
||||
users = user.list()
|
||||
current = user.get(user_id)
|
||||
created = user.create({
|
||||
name = "Dever"
|
||||
status = UserStatus.Active
|
||||
})
|
||||
updated = user.update(user_id, {
|
||||
name = "New name"
|
||||
})
|
||||
deleted = user.delete(user_id)
|
||||
```
|
||||
|
||||
`create` 和 `update` 的匿名数据块由调用上下文推导字段。不能写入的 generated 字段、拼错的字段和不兼容值在编译期拒绝;底层仍是具体输入 record,不是动态 Map。
|
||||
|
||||
普通可失败调用成功时直接产生成功值,失败自动传播。需要主动处理完整结果时写:
|
||||
|
||||
```dever
|
||||
result = result(user.get(user_id))
|
||||
```
|
||||
|
||||
随后使用现有穷尽函数分句处理成功和 error 变体,不为 ORM 增加 try/catch 或 `?`。
|
||||
|
||||
## List、Cursor 与 Stream
|
||||
|
||||
`user.list()` 返回有界默认第一页。完整配置为:
|
||||
|
||||
```dever
|
||||
users = user.list({
|
||||
where = status == UserStatus.Active and created_at >= start
|
||||
order = [created_at.desc, id.desc]
|
||||
page = 1
|
||||
size = 20
|
||||
with = [owner]
|
||||
})
|
||||
```
|
||||
|
||||
查询块处于当前 Model 上下文,所以字段直接写 `status`、`created_at`、`owner`,不重复 Model 名。它不是动态 Map,字段、运算符、值、排序和关系都由编译器检查。
|
||||
|
||||
- `page` 默认 1,`size` 默认 20,并受统一配置上限约束。
|
||||
- 默认排序为 `id.desc`;自定义排序不是唯一键时自动追加 `id`,保证页边界稳定。
|
||||
- 返回 `items`、`page`、`size`、`total`、`pages`。
|
||||
- 页码查询执行数据查询和 count;不需要 total 时使用 cursor。
|
||||
|
||||
深翻页使用同一个配置块:
|
||||
|
||||
```dever
|
||||
users = user.cursor({
|
||||
after = next
|
||||
size = 100
|
||||
where = status == UserStatus.Active
|
||||
order = [created_at.desc, id.desc]
|
||||
})
|
||||
```
|
||||
|
||||
`cursor` 返回 `items`、`next`、`has_more`,不执行 count。全表处理使用 `user.stream(query?)`,按行拉取并保持有界缓冲;不提供无界 `all()`。
|
||||
|
||||
## Where
|
||||
|
||||
多个条件直接组成一个布尔表达式,不增加条件数组:
|
||||
|
||||
```dever
|
||||
users = user.list({
|
||||
where = status == UserStatus.Active
|
||||
and age >= 18
|
||||
and age < 60
|
||||
and (city == "北京" or city == "上海")
|
||||
})
|
||||
```
|
||||
|
||||
- 比较支持 `==`、`!=`、`<`、`<=`、`>`、`>=`。
|
||||
- 逻辑组合使用既有 `and`、`or`、`not` 和括号。
|
||||
- 集合与区间使用 `in`、`between`。
|
||||
- Text 匹配使用 `contains`、`starts_with`、`ends_with`。
|
||||
- 可空字段可直接与 `null` 比较。
|
||||
- 关系条件使用字段路径,例如 `owner.status == UserStatus.Active`;筛选关系不会自动把关系放进结果。
|
||||
|
||||
ORM 将表达式规范化为静态条件树,按源码顺序收集绑定值。相同字段可重复出现,矛盾条件由数据库返回空结果;不引入运行时条件解释器。
|
||||
|
||||
## 关联
|
||||
|
||||
正向 to-one 从关联 ID 推导:
|
||||
|
||||
```dever
|
||||
type Task {
|
||||
owner_id: app.model.user.id
|
||||
}
|
||||
```
|
||||
|
||||
查询时使用 `with = [owner]`。反向 to-many 无法可靠推断名称,因为目标 Model 可能有多个外键,因此在当前 Model 文件显式命名:
|
||||
|
||||
```dever
|
||||
relation tasks = app.model.task.owner_id
|
||||
```
|
||||
|
||||
- to-one 使用受控 JOIN 或等价单次查询。
|
||||
- to-many 按当前父页 ID 批量加载并归组,不逐父记录查询。
|
||||
- to-many 子列表必须有稳定排序和数量上限。
|
||||
- 多对多使用显式中间 Model,不生成隐藏表。
|
||||
- 不提供 lazy loading,读取普通字段不会在背后访问数据库。
|
||||
|
||||
## 内置函数
|
||||
|
||||
| 函数 | 用途 |
|
||||
| --- | --- |
|
||||
| `user.create(data)` | 创建一条并返回完整 Model |
|
||||
| `user.create_many(list)` | 有界批量创建 |
|
||||
| `user.get(id)` | 按主键获取一条 |
|
||||
| `user.first(query?)` | 按条件获取第一条 |
|
||||
| `user.list(query?)` | 自动页码分页 |
|
||||
| `user.cursor(query?)` | 稳定游标分页 |
|
||||
| `user.count(where?)` | 统计数量 |
|
||||
| `user.exists(where?)` | 判断是否存在 |
|
||||
| `user.stream(query?)` | 有界流式遍历 |
|
||||
| `user.update(id_or_query, changes)` | 单条或条件批量更新 |
|
||||
| `user.delete(id_or_query)` | 单条或条件批量删除 |
|
||||
| `user.upsert(key, create, update)` | 按唯一键新增或更新 |
|
||||
|
||||
`update`/`delete` 根据首个参数静态区分主键和条件,不增加重复的 `update_many`/`delete_many` 名称。批量写入返回影响行数;业务状态流转、跨表原子编排和外部调用由 transaction 函数组合。
|
||||
|
||||
## 数据库绑定
|
||||
|
||||
普通调用不传 `Database`:
|
||||
|
||||
```dever
|
||||
user.list({...})
|
||||
user.update(user_id, {...})
|
||||
```
|
||||
|
||||
Model 的连接在程序启动时解析并缓存:
|
||||
|
||||
1. Model 文件显式 `database report`;
|
||||
2. 否则按根包名查找,例如 `app.model.user` 查 `database.app`;
|
||||
3. 根包键不存在时使用必需的 `database.default`。
|
||||
|
||||
显式连接不存在、default 不存在或选中配置无效时启动失败。生成代码访问固定连接槽位,不按字符串进行每请求查找,也不允许 CRUD 临时切库。
|
||||
|
||||
## 自动事务
|
||||
|
||||
事务不属于 Model API,源码不取得或传递 Transaction:
|
||||
|
||||
```dever
|
||||
transaction assign_task(
|
||||
user_id: app.model.user.id,
|
||||
title: Text
|
||||
) (result: AssignResult) {
|
||||
user.update(user_id, {
|
||||
active = true
|
||||
})
|
||||
task.create({
|
||||
owner_id = user_id
|
||||
title = title
|
||||
})
|
||||
result = AssignResult.Assigned
|
||||
}
|
||||
```
|
||||
|
||||
编译器分析完整调用链,从 Model 数据库 effect 推导唯一连接。进入最外层函数时自动开启物理事务,直接或经普通顺序 helper 执行的操作都使用隐藏事务上下文;正常完成提交,error、fault 或取消先回滚再离开。
|
||||
|
||||
同连接嵌套 transaction 复用外层事务,不使用 savepoint。跨连接事务和事务中的并发数据库子任务在编译期拒绝。该设计不引入 `@`、decorator、显式 begin/commit/rollback 或动态当前事务。
|
||||
|
||||
## 实现与性能
|
||||
|
||||
- 所有 Model 复用一个编译期 `QueryPlan`、schema 引擎和方言执行层,只生成字段专属的薄代码。
|
||||
- 参数绑定、预编译语句和具体行解码不经过反射或动态 Map。
|
||||
- to-many 批量加载避免 N+1,也避免多个 to-many JOIN 产生笛卡尔膨胀。
|
||||
- 分页、关联子列表、批量写入、行流、连接池和 SQLite blocking 全部有界。
|
||||
- SQLite 和 PostgreSQL 可以用不同执行器,但必须消费同一逻辑查询计划并返回同一 Dever 类型。
|
||||
@@ -0,0 +1,90 @@
|
||||
# Model schema 变更分类
|
||||
|
||||
## 设计原则
|
||||
|
||||
Model 始终声明当前目标 schema。ORM 只保留三种演进机制:
|
||||
|
||||
1. 目标 schema diff:处理意图唯一且不会静默丢失数据的变化。
|
||||
2. `from`:补充 diff 无法推断的旧身份,例如字段重命名。
|
||||
3. 命名 `migrate`:处理需要业务决定的数据转换、修复和删除。
|
||||
|
||||
不为长度、默认值、可空性、索引等每类变化增加单独版本或迁移关键字。SQLite 和 PostgreSQL 的 DDL 差异由 dialect 处理。
|
||||
|
||||
## 变更矩阵
|
||||
|
||||
| 变化 | Model 写法 | 默认处理 |
|
||||
| --- | --- | --- |
|
||||
| 新建表 | 新增 Model 文件 | 直接创建当前最终 schema |
|
||||
| 新增可空字段 | 新增 `field: Type?` | 自动添加 |
|
||||
| 新增必填字段并有常量默认值 | 新增 `field: Type default value` | 自动添加并由数据库填充已有行 |
|
||||
| 新增必填字段但无默认值 | 新增 `field: Type` | 空表可自动添加;有数据时要求 migrate 回填 |
|
||||
| 字段重命名 | `new_field: Type from old_field` | 旧表执行 rename;新表只创建新字段 |
|
||||
| 字段重命名并放宽类型约束 | `new_field: WiderType from old_field` | rename 后自动修改类型/约束 |
|
||||
| 字段重命名并收紧类型约束 | `new_field: NarrowerType from old_field` | 先验证数据;不满足时要求 migrate |
|
||||
| Text/Bytes 最大长度增加 | 直接修改类型参数 | 自动修改 |
|
||||
| Text/Bytes 最大长度减小 | 直接修改类型参数 | 先验证现有值长度;不截断,失败时要求 migrate |
|
||||
| Text/Bytes 最小长度增加 | 直接修改类型参数 | 先验证现有值;失败时要求 migrate |
|
||||
| Text/Bytes 最小长度减小 | 直接修改类型参数 | 自动放宽约束 |
|
||||
| Decimal 精度/小数位变化 | 直接修改类型参数 | 仅可精确往返时自动;可能溢出或舍入时要求 migrate |
|
||||
| 同族无损类型扩大 | 直接修改字段类型 | 按编译器固定无损转换表自动执行 |
|
||||
| 跨族或可能有损类型转换 | 直接修改字段类型并提供 migrate | 必须明确转换表达式,禁止依赖数据库隐式强转 |
|
||||
| 必填改可空 | `Type` 改为 `Type?` | 自动移除 NOT NULL |
|
||||
| 可空改必填 | `Type?` 改为 `Type` | 验证无 NULL;否则要求 migrate 回填 |
|
||||
| 增加默认值 | 增加 `default value` | 自动修改,只影响后续写入 |
|
||||
| 修改默认值 | 修改 `default value` | 自动修改,只影响后续写入 |
|
||||
| 删除默认值 | 删除 `default value` | 自动修改,不改已有数据 |
|
||||
| 普通索引增加/删除 | 增删 `index` 或 `index(...)` | 自动同步,不改业务数据 |
|
||||
| 普通索引列或顺序变化 | 修改 `index(...)` | 删除旧索引并创建新索引 |
|
||||
| 增加唯一约束 | 增加 `unique` 或 `unique(...)` | 先验证无重复;失败时要求 migrate 去重 |
|
||||
| 删除唯一约束 | 删除 `unique` | 自动删除约束/索引 |
|
||||
| 普通索引改唯一 | `index` 改为 `unique` | 先验证无重复 |
|
||||
| 唯一索引改普通 | `unique` 改为 `index` | 自动放宽 |
|
||||
| 增加外键/修改关联目标 | 改为 `<model-package>.id` | 验证无孤儿记录;失败时要求 migrate |
|
||||
| 删除外键 | 改为普通字段类型或删除关联 | 自动删除约束;字段数据保留 |
|
||||
| 修改外键删除行为 | 第一轮使用命名 migrate | 默认 RESTRICT;通用关系选项语法后续设计 |
|
||||
| choice 增加变体 | 增加变体 | 自动放宽 CHECK |
|
||||
| choice 修改显示名称 | 修改右侧文本 | 无 schema 迁移,存储值不变 |
|
||||
| choice 删除稳定变体 | 删除变体 | 验证没有旧值;否则要求 migrate 映射 |
|
||||
| choice 稳定变体改名 | 第一轮使用命名 migrate | 显式更新已有存储值并重建 CHECK |
|
||||
| 删除字段 | 从 record 删除并写命名 migrate | 必须显式确认 drop;关联约束和索引按依赖顺序删除 |
|
||||
| 删除表 | 删除 Model 并写命名 migrate | 必须显式确认,禁止仅因文件消失自动删表 |
|
||||
| 表/Model 重命名 | 第一轮使用命名 migrate | 显式执行表 rename 并更新 schema identity |
|
||||
| 字段拆分 | 当前字段 + 命名 migrate | migrate 负责回填多个目标字段 |
|
||||
| 字段合并 | 当前字段 + 命名 migrate | migrate 负责合并与冲突规则 |
|
||||
| 主键策略或主键类型变化 | 命名 migrate | 重建引用关系,首轮不自动处理 |
|
||||
| generated 策略变化 | 直接修改声明并按需 migrate | 只影响新值时自动;需要改已有值时 migrate |
|
||||
| Seed 新增记录 | 修改 `seed` | 按唯一键只插入缺失记录 |
|
||||
| Seed 修改/删除已有记录 | 命名 migrate | Seed 不覆盖或删除线上数据 |
|
||||
| 字段声明顺序变化 | 调整源码顺序 | 不改变已有数据库列顺序,不产生迁移 |
|
||||
| 注释、标签、自定义方法变化 | 直接修改源码 | 不产生数据库迁移 |
|
||||
|
||||
## 长度修改示例
|
||||
|
||||
```dever
|
||||
type User {
|
||||
display_name: Text(128) from nickname
|
||||
}
|
||||
```
|
||||
|
||||
如果旧字段是 `nickname: Text(64)`,规划顺序是先 rename,再把最大长度放宽到 128。全新数据库只创建 `display_name` 的最终定义。
|
||||
|
||||
收窄时仍只修改目标类型:
|
||||
|
||||
```dever
|
||||
type User {
|
||||
display_name: Text(32) from nickname
|
||||
}
|
||||
```
|
||||
|
||||
ORM 先验证已有值都不超过 32 个 Unicode codepoint。全部满足就应用约束;存在超长值则整个迁移失败,不做自动截断。开发者必须通过命名 migrate 明确选择截断、拒绝、归档或其它业务规则。
|
||||
|
||||
## 方言执行边界
|
||||
|
||||
- PostgreSQL 原生支持 rename、类型变更、默认值、可空性和约束变更;类型变更可以使用 `USING` 表达式。
|
||||
- SQLite 直接 ALTER 能力较少。不能原地完成的同一逻辑变化由 dialect 在事务中创建目标临时表、复制转换后的数据、替换旧表并重建索引/约束。
|
||||
- 两个方言必须得到相同的规范化目标 schema 和 revision;底层是否重建表不能泄漏到 Dever Model 语法。
|
||||
- Dever 不使用静默截断、自动填充猜测、名称相似度推断或数据库特有隐式类型转换。
|
||||
|
||||
## 不属于首轮通用 Model 的数据库管理能力
|
||||
|
||||
分区表、表空间、存储参数、物化视图、触发器、行级安全策略、数据库专有索引方法和在线无锁迁移不进入首轮跨数据库 Model 协议。这些能力需要 PostgreSQL 专有扩展或显式原生迁移,不能伪装成 SQLite/PostgreSQL 通用行为。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "typed-orm",
|
||||
"name": "typed-orm",
|
||||
"title": "Typed ORM for SQLite and PostgreSQL",
|
||||
"description": "为 Dever 增加隐式挂起与错误传播基础、一文件 Model、数据库无关查询计划,以及 SQLite/PostgreSQL 的类型安全 ORM、事务和迁移能力。",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": "implicit suspension/error contracts + compiler model/query/transaction contracts + runtime config/orm/sqlite/postgres/log foundations + focused database and memory fixtures",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "Antigravity",
|
||||
"assignee": "Antigravity",
|
||||
"createdAt": "2026-09-13",
|
||||
"completedAt": "2026-09-17",
|
||||
"branch": "main",
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-04-dever-language-mvp",
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
Loaded 100 of 960 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user