plain-forge 1.0.21 → 1.0.22-next.2d602b1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/forge/rules/linked-resources.md +5 -2
- package/forge/skills/add-resource/SKILL.md +5 -3
- package/forge/skills/render-spec/SKILL.md +164 -0
- package/forge/skills/render-spec/references/plain-lang.md +71 -0
- package/forge/skills/render-spec/references/render-plan.md +8 -0
- package/forge/skills/render-spec/scripts/check_version.py +223 -0
- package/forge/skills/render-spec/scripts/copy_folder.py +49 -0
- package/forge/skills/render-spec/scripts/plain_sections.py +351 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Pick whichever entry point matches how much upfront design you want:
|
|
|
34
34
|
|
|
35
35
|
- **`debug-specs`** — when the rendered app misbehaves, this traces the generated code back to the spec that caused it and fixes the spec (never the generated code).
|
|
36
36
|
- **`run-codeplain`** — experimental supervised render. Launches `codeplain` for you, tails the log, watches generated code appear under `plain_modules/`, and detects pathologies (stuck conformance loops, complexity errors, missing concepts, render failures). On approval it stops the renderer, hands off to the right spec-edit skill, and resumes.
|
|
37
|
+
- **`render-spec`** — [pyro](https://github.com/plainlang/pyro), the open-source ***plain renderer, bundled with plain-forge. Renders a `.plain` module (and the modules it requires or imports) into working, tested code directly inside your agent. See [Render with pyro](#render-with-pyro).
|
|
37
38
|
- **`implement-unit-testing-script`** / **`implement-conformance-testing-script`** / **`implement-prepare-environment-script`** — generate the per-language testing scripts that the renderer and you both invoke. New languages can be added by these skills without touching any other part of the project.
|
|
38
39
|
|
|
39
40
|
Each skill operates on the same one-question-at-a-time, write-immediately, refine-through-follow-ups loop. Specs land on disk after every answer; later answers fix earlier writes in place. There is no batched interview, no "I'll gather context first," and no hand-authored functional specs — every new spec goes through the authoring skills so the complexity and conflict checks actually run.
|
|
@@ -187,6 +188,23 @@ If you'd rather have plain-forge babysit the run from your AI coding agent, invo
|
|
|
187
188
|
|
|
188
189
|
This is an **experimental** feature — the default and most reliable way to render is still the manual `codeplain <module>.plain` invocation above.
|
|
189
190
|
|
|
191
|
+
#### Render with pyro
|
|
192
|
+
|
|
193
|
+
plain-forge ships [pyro](https://github.com/plainlang/pyro), an open-source renderer packaged as the `render-spec` skill that renders inside your AI coding agent. Invoke it with the module to render:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
/render-spec <module>.plain
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
It resolves the modules the target requires or imports, renders them in dependency order, and writes:
|
|
200
|
+
|
|
201
|
+
- implementation code and unit tests to `plain_module/code/`
|
|
202
|
+
- conformance tests to `plain_module/tests/`
|
|
203
|
+
- the target module's final output to `dist/`
|
|
204
|
+
- its working files (render plan, dependencies, scenarios, requirement lists) to `.pyro/`
|
|
205
|
+
|
|
206
|
+
pyro needs **Python 3.8 or newer** on the machine and nothing else. `npx plain-forge@latest update` updates pyro along with the rest of plain-forge.
|
|
207
|
+
|
|
190
208
|
### Debugging specs
|
|
191
209
|
|
|
192
210
|
Hit a bug in the rendered app, a failing test, or behavior that doesn't match what you specified?
|
|
@@ -210,12 +228,39 @@ forge/ # canonical content, copied verbatim on install
|
|
|
210
228
|
bin/
|
|
211
229
|
cli.mjs # the `plain-forge` CLI — `install` and `update` commands
|
|
212
230
|
|
|
231
|
+
vendor/
|
|
232
|
+
pyro/ # git submodule: plainlang/pyro, pinned to the release plain-forge ships
|
|
233
|
+
|
|
234
|
+
scripts/
|
|
235
|
+
bundle-pyro.sh # copies vendor/pyro's `render-spec` skill into forge/skills/ at publish time
|
|
236
|
+
deploy.sh # publishes a release to npm from a workstation
|
|
237
|
+
|
|
213
238
|
test/
|
|
214
239
|
cli.test.mjs # tests for the install / update CLI
|
|
215
240
|
|
|
216
241
|
package.json # ships only `bin/cli.mjs` and `forge/` to npm
|
|
217
242
|
```
|
|
218
243
|
|
|
244
|
+
### Bundled pyro skill
|
|
245
|
+
|
|
246
|
+
The `render-spec` skill comes from [pyro](https://github.com/plainlang/pyro), which has its own release cycle. It is included as a **git submodule** at [`vendor/pyro`](vendor/pyro), so the exact pyro commit plain-forge ships is visible in the repository. Clone with submodules:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
git clone --recurse-submodules https://github.com/plainlang/plain-forge.git
|
|
250
|
+
# or, in an existing clone:
|
|
251
|
+
git submodule update --init
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Agents only discover skills that sit directly under `skills/`, so the npm publish CI job copies `vendor/pyro/skills/render-spec` into `forge/skills/render-spec` (gitignored) with `scripts/bundle-pyro.sh` just before `npm publish`. Run the script yourself to try the skill locally.
|
|
255
|
+
|
|
256
|
+
To ship a newer pyro, bump the submodule to pyro's `main` (it only ever points at pyro's latest stable release) and commit the new pointer:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
git submodule update --remote vendor/pyro
|
|
260
|
+
git add vendor/pyro
|
|
261
|
+
git commit -m "Bump pyro to vX.Y.Z"
|
|
262
|
+
```
|
|
263
|
+
|
|
219
264
|
On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.agents/` for Codex/Copilot/universal, `.forge/`, or `.opencode/` — see the per-agent table above for global-scope paths), recording every file it wrote in `<agent-dir>/.plain-forge/manifest.json` so `update` can later refresh and prune precisely. For agents that need it, it also wires the rules into `opencode.json` or ForgeCode's `AGENTS.md` (see [How the rules get applied per agent](#how-the-rules-get-applied-per-agent)).
|
|
220
265
|
|
|
221
266
|
## Available Skills
|
|
@@ -227,6 +272,7 @@ On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them int
|
|
|
227
272
|
| `forge-plain` | End-to-end QA interview that produces complete `.plain` spec files for a new project |
|
|
228
273
|
| `init-plain-project` | Lightweight project initializer — scaffolds `template/base.plain` (base impl + test reqs), a stub top-level module, the testing scripts, and `config.yaml`. No functional specs, no concepts, no dry-run. Pair with `add-feature` to grow the project feature-by-feature. |
|
|
229
274
|
| `add-feature` | Interview the user about a single feature, then write all the specs for it |
|
|
275
|
+
| `render-spec` | [pyro](https://github.com/plainlang/pyro), the open-source renderer: renders a `.plain` module and the modules it requires or imports into working, tested code, from inside your agent. See [Render with pyro](#render-with-pyro). |
|
|
230
276
|
| `run-codeplain` | **Experimental.** Launch a `codeplain` render and supervise it end-to-end — tails `codeplain.log`, watches generated code appear, detects pathologies (stuck conformance loops, complexity errors, missing concepts, render failures), and on approval stops the renderer, hands off to the right spec-edit skill, and resumes with `--render-from`. The default render path is still the manual `codeplain <module>.plain` command. |
|
|
231
277
|
|
|
232
278
|
### Spec Authoring
|
|
@@ -61,6 +61,9 @@ A linked resource **must not** be any of the following:
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
## File location and path resolution
|
|
64
|
-
- Paths are resolved
|
|
64
|
+
- Paths are resolved against the directory that contains the `.plain` file passed to `codeplain`, not the directory where `codeplain` is run
|
|
65
|
+
- This includes links inside imported and required modules — they resolve against that same directory, not against the directory the imported module itself lives in
|
|
66
|
+
- A path that does not resolve there is then looked up in the `--template-dir` directory (if given), and last in the built-in `standard_template_library`
|
|
67
|
+
- Because the `.plain` file's own directory is the anchor, a render gives the same result from any working directory
|
|
65
68
|
- Relative paths may traverse into parent directories — `[resource](../../resources/resource.md)` is valid — as long as they resolve to a real text file on disk
|
|
66
|
-
- The conventional location is a `resources/` directory
|
|
69
|
+
- The conventional location is a `resources/` directory next to the `.plain` file
|
|
@@ -40,7 +40,9 @@ Use standard markdown link syntax inside any spec section:
|
|
|
40
40
|
|
|
41
41
|
## Path Rules
|
|
42
42
|
|
|
43
|
-
- Paths are resolved **
|
|
43
|
+
- Paths are resolved **against the directory that contains the `.plain` file passed to `codeplain`**, not the directory where `codeplain` is run.
|
|
44
|
+
- This includes links inside imported and required modules — they resolve against that same directory, not against the directory the imported module itself lives in.
|
|
45
|
+
- A path that does not resolve there is then looked up in the `--template-dir` directory (if given), and last in the built-in `standard_template_library`.
|
|
44
46
|
- Relative paths may traverse into parent directories — `[resource](../../resources/resource.md)` is valid — as long as they resolve to a real text file on disk.
|
|
45
47
|
- **No external URLs** — only local file references.
|
|
46
48
|
- **No folder paths** — the target must be a file, not a directory.
|
|
@@ -69,7 +71,7 @@ The **only** exceptions are URLs and paths that are *values the produced softwar
|
|
|
69
71
|
|
|
70
72
|
### What a linked resource CAN be
|
|
71
73
|
|
|
72
|
-
- A single file inside the project, reachable by a path relative to
|
|
74
|
+
- A single file inside the project, reachable by a path relative to the `.plain` file — parent-directory traversal (`../`) is allowed (`resources/` is the conventional home).
|
|
73
75
|
- A **text-based** file the renderer can read end-to-end: JSON, YAML, XML, HTML, Markdown, plain text, CSV, TSV, source code in any language (`.py`, `.js`, `.ts`, `.go`, `.java`, `.rb`, `.rs`, `.kt`, `.swift`, `.c`, `.cpp`, `.cs`, …), shell scripts, SQL, JSON Schema, OpenAPI, AsyncAPI, Protobuf `.proto`, GraphQL SDL, `.jolt`, `.env.example`, `.toml`, `.ini`, `.proto`, `Dockerfile`, etc.
|
|
74
76
|
|
|
75
77
|
## Common Resource Types
|
|
@@ -101,6 +103,6 @@ The **only** exceptions are URLs and paths that are *values the produced softwar
|
|
|
101
103
|
- [ ] **Target is a text-based file** (no binary extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tiff`, `.webp`, `.ico`, `.pdf`, `.docx`, `.xlsx`, `.pptx`, `.zip`, `.tar`, `.gz`, `.mp3`, `.mp4`, `.wav`, `.exe`, `.so`, `.dylib`, `.class`, `.wasm`, …)
|
|
102
104
|
- [ ] **No URLs or folder paths anywhere in the surrounding `.plain` content** (not as link targets, not in body prose), with the sole exception of URLs / paths that are runtime values the generated software itself uses
|
|
103
105
|
- [ ] Path is relative to the `.plain` file, not absolute
|
|
104
|
-
- [ ] Path resolves to a real text file on disk
|
|
106
|
+
- [ ] Path resolves to a real text file on disk relative to the `.plain` file (`../` traversal is allowed)
|
|
105
107
|
- [ ] Markdown link syntax is correct: `[display text](relative/path)`
|
|
106
108
|
- [ ] Resource content is relevant and adds value beyond what the spec text says
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: render-spec
|
|
3
|
+
description: >-
|
|
4
|
+
Renders a .plain specification module into working, tested code. Use when
|
|
5
|
+
asked to render, build, or implement a .plain spec file or module: resolves
|
|
6
|
+
and renders its required/imported modules in dependency order, producing
|
|
7
|
+
implementation code in plain_module/code/, conformance tests in
|
|
8
|
+
plain_module/tests/, and the target module's output in dist/.
|
|
9
|
+
metadata:
|
|
10
|
+
version: "0.4.0"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Skill instructions
|
|
14
|
+
|
|
15
|
+
When starting each step or substep, report with a message "Starting Step <Step_ID>: <Description>".
|
|
16
|
+
|
|
17
|
+
Do every step yourself. Never hand any step to a subagent, Agent/Task tool or workflow. Module count or spec size are not exceptions.
|
|
18
|
+
|
|
19
|
+
# Step 0: Python interpreter and version check
|
|
20
|
+
|
|
21
|
+
`<skill_folder>` is the absolute path of the directory holding this `SKILL.md`. Resolve it now and reuse it everywhere in this skill where `<skill_folder>` is used:
|
|
22
|
+
* On Claude Code it is `${CLAUDE_SKILL_DIR}`; on other agents derive it from the location this skill was loaded from.
|
|
23
|
+
* Never reach a file inside this skill by a relative path.
|
|
24
|
+
|
|
25
|
+
`<python>` is required Python 3.8 or newer. Resolve it now and reuse it everywhere in this skill where `<python>` is used. Try these candidates one at a time, in this order, and stop at the first one whose output contains a `current:` line - its interpreter is `<python>`. Do not run the remaining candidates once one has succeeded, and do not run them in parallel:
|
|
26
|
+
1. `python3 "<skill_folder>/scripts/check_version.py" check`
|
|
27
|
+
2. `py -3 "<skill_folder>/scripts/check_version.py" check`
|
|
28
|
+
3. `python "<skill_folder>/scripts/check_version.py" check`
|
|
29
|
+
|
|
30
|
+
Report the Python tool you settled on. If none of the three prints a `current:` line, Python is a missing dependency: abort the rendering and report it.
|
|
31
|
+
|
|
32
|
+
From that same command's output report with a message "Running pyro: <current>".
|
|
33
|
+
* If the output shows `status: update-available`, warn with a message: "A newer pyro release <latest> is available (this is <current>)".
|
|
34
|
+
* On any other status, say nothing about updates and continue.
|
|
35
|
+
|
|
36
|
+
# Step 1: Introduction, inputs and outputs
|
|
37
|
+
|
|
38
|
+
Your mission is to implement software code as defined by .plain specifications (specs).
|
|
39
|
+
To understand the specs language, load the complete `<skill_folder>/references/plain-lang.md`.
|
|
40
|
+
|
|
41
|
+
Input arguments are:
|
|
42
|
+
* <TargetModule> which is the filename of to the module spec to be rendered.
|
|
43
|
+
|
|
44
|
+
Inputs artifacts are:
|
|
45
|
+
* specs of <TargetModule> and the modules it requires or imports, including referenced files inside the specs.
|
|
46
|
+
|
|
47
|
+
Generated artifacts are:
|
|
48
|
+
* :RenderPlan: is a table listing all the modules for rendering and current render state. :RenderPlan: lives in `./.pyro/render-plan.md`.
|
|
49
|
+
* :plainImplementationCode: lives under `./plain_module/code`. Every module is rendered into this same folder, each building on top of the previously rendered ones.
|
|
50
|
+
* :ConformanceTests: are living under `./plain_module/tests`.
|
|
51
|
+
* Internal working files (dependencies, per-module scenarios and requirements) live under `./.pyro`.
|
|
52
|
+
|
|
53
|
+
### Available tools and paths
|
|
54
|
+
|
|
55
|
+
`<python>` is the interpreter resolved in Step 0 - always run them with it, and never through a shell of your own, so the same command line works on macOS, Linux and Windows alike.
|
|
56
|
+
|
|
57
|
+
`scripts/plain_sections.py` extracts one section out of any number of specs and prints it to stdout:
|
|
58
|
+
`<python> "<skill_folder>/scripts/plain_sections.py" [--include-filename] [--output <path>] <section> <spec.plain> [<spec.plain> ...]`
|
|
59
|
+
* `<section>` is one of `defs`, `impl-reqs`, `test-reqs`, `func-specs`, `acc-tests`, `all`.
|
|
60
|
+
* `--output <path>` writes the result to `<path>`.
|
|
61
|
+
* Always gather sections with this script - never transcribe or summarize them by hand.
|
|
62
|
+
|
|
63
|
+
# Step 2: Prepare the :RenderPlan:
|
|
64
|
+
|
|
65
|
+
Find and read the frontmatter of <TargetModule> and all specs that <TargetModule> requires.
|
|
66
|
+
|
|
67
|
+
Ensure there are no loops in the dependency graph of modules. If there are, abandon the work and describe the error (loop).
|
|
68
|
+
|
|
69
|
+
Prepare the optimal order for rendering <TargetModule> and all the required modules based on the dependency graph.
|
|
70
|
+
|
|
71
|
+
Prepare a :RenderPlan: : List all the modules to be rendered in the planned rendering order in the example format from `<skill_folder>/references/render-plan.md` and write it to :RenderPlan: file (overwrite if already exists).
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
## Step 3: Verifying the environment
|
|
75
|
+
|
|
76
|
+
If any dependency is missing - DO NOT INSTALL ANYTHING, but do:
|
|
77
|
+
* immediatly abort the rendering
|
|
78
|
+
* report with message listing missing dependencies.
|
|
79
|
+
|
|
80
|
+
### Step 3.1: Dependencies
|
|
81
|
+
|
|
82
|
+
Report with a message "Step 3.1: Dependencies".
|
|
83
|
+
|
|
84
|
+
Gather all :plainImplementationReqs: and :plainTestReqs: from all specs of the modules' to be rendered:
|
|
85
|
+
* `<python> "<skill_folder>/scripts/plain_sections.py" --include-filename impl-reqs <all specs to be rendered>`
|
|
86
|
+
* `<python> "<skill_folder>/scripts/plain_sections.py" --include-filename test-reqs <all specs to be rendered>`
|
|
87
|
+
|
|
88
|
+
Write a list of all the required dependencies for implementation and for tests into `.pyro/dependencies.md` file. Verify all the all the dependendencies are present and update the list.
|
|
89
|
+
|
|
90
|
+
Report with a message showing the `.pyro/dependencies.md`.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## Step 4: Rendering
|
|
94
|
+
|
|
95
|
+
Render every module yourself - never delegate any module to a subagent, Agent/Task tool or workflow.
|
|
96
|
+
|
|
97
|
+
Before rendering the first module, create the folders `plain_module/code`, `plain_module/tests` and `.pyro` (skip any that already exist).
|
|
98
|
+
|
|
99
|
+
Load the :RenderPlan: and for every module follow precisely the steps:
|
|
100
|
+
|
|
101
|
+
### Step 4.1: Specs, reqs, test scenarios
|
|
102
|
+
Report with a message "Step 4.1: Loading specs, writing reqs and test scenarios for: <module> + <required-or-imported-modules>".
|
|
103
|
+
|
|
104
|
+
Run the command with helper script, where <specs> are the module's spec followed by the specs of its imported or required modules:
|
|
105
|
+
`<python> "<skill_folder>/scripts/plain_sections.py" all <specs>`
|
|
106
|
+
The output of this command are all the necessary specs for succesfully rendering this module.
|
|
107
|
+
|
|
108
|
+
Write exhaustive conformance test scenarios for every :plainFunctionality: of the <module>'s spec into `.pyro/<module>/scenarios.md`.
|
|
109
|
+
* Scenarios should exhaustively test every :plainFunctionality: and should include :AcceptanceTests:.
|
|
110
|
+
* Get the <module>'s :AcceptanceTests: with `<python> "<skill_folder>/scripts/plain_sections.py" acc-tests <module spec>` and cover every one of them.
|
|
111
|
+
|
|
112
|
+
Write the lists of requirements using the helper script, where <specs> are module's spec plus the specs of its imported modules:
|
|
113
|
+
* impl. reqs: `<python> "<skill_folder>/scripts/plain_sections.py" impl-reqs <specs> --output .pyro/<module>/impl-reqs.md`
|
|
114
|
+
* test reqs: `<python> "<skill_folder>/scripts/plain_sections.py" test-reqs <specs> --output .pyro/<module>/test-reqs.md`
|
|
115
|
+
Both lists hold the requirements verbatim - never paraphrase, reorder or drop any of them.
|
|
116
|
+
|
|
117
|
+
### Step 4.2: Implement code and tests
|
|
118
|
+
Report with a message "Step 4.2: Implementation of <module>".
|
|
119
|
+
|
|
120
|
+
All the implementation code must be put in the self-contained `plain_module/code` folder. Nothing outside of `plain_module` and `.pyro` folders can be touched during this step.
|
|
121
|
+
|
|
122
|
+
Implement all :plainFunctionality: of <module> specs while respecting all the requirements written in `.pyro/<module>/impl-reqs.md`.
|
|
123
|
+
|
|
124
|
+
Implement :UnitTests:.
|
|
125
|
+
|
|
126
|
+
Implement :ConformanceTests: covering all test scenarios:
|
|
127
|
+
* Read all test scenarios from `.pyro/<module>/scenarios.md`.
|
|
128
|
+
* Read test requirements in `.pyro/<module>/test-reqs.md`.
|
|
129
|
+
* Implement the conformance tests covering all test scenarios and respecting test requirements into the `plain_module/tests` folder.
|
|
130
|
+
|
|
131
|
+
### Step 4.3: Tests verification
|
|
132
|
+
Report with a message "#Step 4.3: Tests verification of <module>".
|
|
133
|
+
|
|
134
|
+
If any tests are failing, go back to the implementation step (4.2), debug it and fix implementation code:
|
|
135
|
+
* Run and verify all :ConformanceTests: are passing.
|
|
136
|
+
* Run and verify all :UnitTests: are passing.
|
|
137
|
+
|
|
138
|
+
### Step 4.4: Reqs verification
|
|
139
|
+
Report with a message "Step 4.4: Reqs verification of <module>".
|
|
140
|
+
|
|
141
|
+
Read the list `.pyro/<module>/impl-reqs.md` and for every item:
|
|
142
|
+
* Review if the implementation respects it.
|
|
143
|
+
* Add checkbox with checked/unchecked status to the item.
|
|
144
|
+
|
|
145
|
+
Read the list `.pyro/<module>/test-reqs.md` and for every item:
|
|
146
|
+
* Review if the conformance tests respect it.
|
|
147
|
+
* Add checkbox with checked/unchecked status to the item.
|
|
148
|
+
|
|
149
|
+
IMPORTANT: if any requirement list item is not passing, go back to the implementation (step 4.2), debug and fix it in the code.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
When all 4.x steps are done, continue with rendering the next module until no more modules are left.
|
|
153
|
+
|
|
154
|
+
## Step 5: Finalize and report
|
|
155
|
+
|
|
156
|
+
When all modules are rendered do:
|
|
157
|
+
- remove installed dependency artifacts (e.g. `node_modules/`, `.venv/`, `__pycache__/`, vendored packages) in `plain_module/code`, but keep the dependency manifests and lock files so dependencies can be reinstalled later.
|
|
158
|
+
- copy all of the files in the `plain_module/code` folder to the `./dist` folder:
|
|
159
|
+
`<python> "<skill_folder>/scripts/copy_folder.py" plain_module/code dist`
|
|
160
|
+
- prepare a short report on the :plainImplementationCode: and :ConformanceTests:
|
|
161
|
+
- present commands to run tests (unit and/or conformance tests)
|
|
162
|
+
- present the command to run the rendered <TargetModule>
|
|
163
|
+
|
|
164
|
+
Finally, report with a message: "pyro <current> (latest: <latest>)" - reiterating pyro versions and update warning resolved in Step 0.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# .plain Language Reference
|
|
2
|
+
|
|
3
|
+
.plain is a specification language designed for writing software requirements in a clear, structured format. It is used to generate production-ready code from `.plain` spec files using AI. The `.plain` files are the source of truth. They describe what the software should do, how it should be built, and how it should be tested. The generated code is a read-only artifact produced by the renderer.
|
|
4
|
+
|
|
5
|
+
## .plain File Structure
|
|
6
|
+
|
|
7
|
+
A `.plain` file has a YAML frontmatter section followed by standardized sections (marked with `***section name***` headers):
|
|
8
|
+
- `***definitions***` — declares concepts used throughout the specification
|
|
9
|
+
- `***implementation reqs***` — non-functional requirements about how the software should be built
|
|
10
|
+
- `***test reqs***` — requirements for conformance testing
|
|
11
|
+
- `***functional specs***` — describes what the software should do
|
|
12
|
+
|
|
13
|
+
## Concept Notation
|
|
14
|
+
|
|
15
|
+
Concepts are the building blocks of .plain specifications. They are written between colons: `:ConceptName:`. Valid characters include letters, digits, plus, minus, dot, and underscore.
|
|
16
|
+
|
|
17
|
+
Concepts must be defined in `***definitions***` before being referenced in other sections. Concept names must be globally unique across the specification and its imports. Concept references must not form cycles — if concept A references concept B, then concept B must not reference concept A.
|
|
18
|
+
|
|
19
|
+
### Predefined Concepts
|
|
20
|
+
|
|
21
|
+
.plain provides predefined concepts available in all specifications without needing to be defined:
|
|
22
|
+
|
|
23
|
+
| Concept | Meaning |
|
|
24
|
+
|---------|---------|
|
|
25
|
+
| `:plainDefinitions:` | Content of the `***definitions***` section |
|
|
26
|
+
| `:plainImplementationReqs:` | Content of the `***implementation reqs***` section |
|
|
27
|
+
| `:plainFunctionality:` | Content of the `***functional specs***` section |
|
|
28
|
+
| `:plainTestReqs:` | Content of the `***test reqs***` section |
|
|
29
|
+
| `:Implementation:` | The system implementing `:plainFunctionality:` |
|
|
30
|
+
| `:plainImplementationCode:` | The generated implementation code |
|
|
31
|
+
| `:UnitTests:` | Auto-generated unit tests for individual functionalities (configured in `***implementation reqs***`) |
|
|
32
|
+
| `:ConformanceTests:` | Auto-generated tests that verify implementation conforms to specs |
|
|
33
|
+
| `:AcceptanceTest:` / `:AcceptanceTests:` | Tests that validate specific aspects of the implementation |
|
|
34
|
+
|
|
35
|
+
## Definitions Section (`***definitions***`)
|
|
36
|
+
|
|
37
|
+
Declares concepts used throughout the specification. A concept's definition can come from the module's own `***definitions***` section, from an `import`ed module's definitions, or from a `require`d module's `exported_concepts` (but not transitively). Attributes, constraints and clarifications can be nested as sub-bullets (see the example above).
|
|
38
|
+
|
|
39
|
+
## Implementation Reqs Section (`***implementation reqs***`)
|
|
40
|
+
|
|
41
|
+
A free-form section for any instructions that steer code generation. Common uses include technology choices, architectural constraints, coding standards, and naming conventions, but it can also contain detailed implementation guidance — data formats, error handling strategies, algorithm descriptions, or any other context the renderer needs to produce correct code. These describe HOW to build the software, not WHAT it should do. Unit-testing specs also go here, not in `***test reqs***`.
|
|
42
|
+
|
|
43
|
+
## Test Reqs Section (`***test reqs***`)
|
|
44
|
+
|
|
45
|
+
Specifies requirements for conformance testing — test frameworks, execution methods, and testing constraints. It does not refer to unit tests, which are specified in the `***implementation reqs***` section.
|
|
46
|
+
|
|
47
|
+
## Functional Specs Section (`***functional specs***`)
|
|
48
|
+
|
|
49
|
+
Describes what the software should do. Each bullet point is a single piece of functionality that will be implemented.
|
|
50
|
+
|
|
51
|
+
Each functional spec must be limited in complexity. If a spec is too complex, the renderer responds with "Functional spec too complex!" and it must be broken down into smaller specs. Complexity is measured in lines of code - each spec should imply no more than 200 lines of code.
|
|
52
|
+
|
|
53
|
+
Functional specs are in **sequential order** and rendered incrementally, one by one. When a spec is rendered, only **previous** functional specs are in the renderer's context — those earlier in the list, plus any from `requires` modules; later specs are invisible, so a spec can never reference or anticipate what comes after it. This ordering matters for incremental rendering and for detecting conflicts between specs.
|
|
54
|
+
|
|
55
|
+
## Acceptance Tests (`***acceptance tests***`)
|
|
56
|
+
|
|
57
|
+
Nested under individual functional specs to specify how to verify correct implementation. They extend conformance tests.
|
|
58
|
+
|
|
59
|
+
## YAML Frontmatter
|
|
60
|
+
|
|
61
|
+
The frontmatter is enclosed between `---` markers and supports:
|
|
62
|
+
|
|
63
|
+
- **`import`** — includes definitions, implementation reqs, and test reqs from templates. Imported modules must not contain functional specs. The default import directory is `template/` — the `template/` prefix is not needed (e.g., `airplain` resolves to `template/airplain.plain`).
|
|
64
|
+
- **`requires`** — specifies dependencies on other root-level modules that must be built first. Unlike `import`, required modules can contain functional specs and represent complete software modules. Requires paths point to root-level modules (e.g., `auth`, `messaging`).
|
|
65
|
+
- **`description`** — optional description of the specification.
|
|
66
|
+
- **`required_concepts`** — concepts that must be defined by any module that imports this spec.
|
|
67
|
+
- **`exported_concepts`** — concepts made available to modules that `require` this one. **Exports are not transitive.** A concept exported from module `A` is visible only to modules that `requires: A` directly. If `B` `requires: A` and `C` `requires: B`, the concepts `A` exports are not visible to `C` — only the concepts `B` itself lists in its own `exported_concepts` (re-declaring and forwarding them in its own `***definitions***` as needed).
|
|
68
|
+
|
|
69
|
+
## Comments
|
|
70
|
+
|
|
71
|
+
Lines starting with `>` are ignored when rendering:
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Render Plan
|
|
2
|
+
|
|
3
|
+
| Order | Module | Spec File | Imports | Requires | Functional Reqs | Status* |
|
|
4
|
+
|-------|--------|-----------|---------|----------|-----|--------|
|
|
5
|
+
| 1 | m_lib | m_lib.plain | base | - | 1 | done |
|
|
6
|
+
| 2 | m_tool | m_tool.plain | base | m_lib | 3 | done |
|
|
7
|
+
|
|
8
|
+
*Valid statuses: todo, in progress, retrying, done, failed
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Report the render-spec skill's own version and whether a newer stable
|
|
3
|
+
# release has been published.
|
|
4
|
+
#
|
|
5
|
+
# Usage: check_version.py current [--skill-md <path>]
|
|
6
|
+
# check_version.py latest [--url <url>]
|
|
7
|
+
# check_version.py check [--skill-md <path>] [--url <url>]
|
|
8
|
+
#
|
|
9
|
+
# current prints the version this copy of the skill ships with, read from
|
|
10
|
+
# the `metadata: version:` field of the SKILL.md next to this script.
|
|
11
|
+
# latest prints the latest published stable version, read from the
|
|
12
|
+
# [project] version in pyproject.toml on the repository's main
|
|
13
|
+
# branch. The release workflow fast-forwards main only when a stable
|
|
14
|
+
# release is published, so main's pyproject version IS the latest
|
|
15
|
+
# stable release - no git and no GitHub API (and its rate limits)
|
|
16
|
+
# needed. Exits 1 if it cannot be determined.
|
|
17
|
+
# check prints both plus a verdict, one `key: value` per line:
|
|
18
|
+
# current: 0.1.0
|
|
19
|
+
# latest: 0.2.0
|
|
20
|
+
# status: update-available
|
|
21
|
+
# status is one of:
|
|
22
|
+
# update-available a newer stable release is published
|
|
23
|
+
# up-to-date this copy is the latest stable release
|
|
24
|
+
# ahead this copy is newer (a dev or rc checkout)
|
|
25
|
+
# unknown the latest version could not be determined
|
|
26
|
+
# Unlike `latest`, network trouble is not an error here: check
|
|
27
|
+
# degrades to `latest: unknown` / `status: unknown` and exits 0,
|
|
28
|
+
# because an update hint must never break a render.
|
|
29
|
+
#
|
|
30
|
+
# --skill-md <path> and --url <url> (also the = spellings) override where the
|
|
31
|
+
# current and latest versions are read from; the tests use them with fixture
|
|
32
|
+
# files and file:// URLs.
|
|
33
|
+
#
|
|
34
|
+
# Versions are `X.Y.Z` with an optional `-rc.N` prerelease, and a release
|
|
35
|
+
# outranks its own release candidates: 0.1.0 < 0.2.0-rc.1 < 0.2.0.
|
|
36
|
+
#
|
|
37
|
+
# Portability: the standard library only, Python 3.8+, no shell, no git. The
|
|
38
|
+
# output is written as bytes to stay UTF-8 with LF endings on every platform.
|
|
39
|
+
#
|
|
40
|
+
# Exit: 0 ok, 1 SKILL.md missing or without a parseable version (or `latest`
|
|
41
|
+
# could not be determined), 2 usage error.
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
# Deliberately no f-strings anywhere in this file: it has to stay parseable by Python 2 so
|
|
48
|
+
# that a probe landing on an old `python` reaches this guard and gets a clear message instead
|
|
49
|
+
# of a SyntaxError traceback.
|
|
50
|
+
if sys.version_info < (3, 8):
|
|
51
|
+
sys.stderr.write(
|
|
52
|
+
"check_version.py: error: Python 3.8 or newer is required, found %s\n"
|
|
53
|
+
% ".".join(str(part) for part in sys.version_info[:3])
|
|
54
|
+
)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
PROG = os.path.basename(sys.argv[0])
|
|
58
|
+
|
|
59
|
+
LATEST_URL = "https://raw.githubusercontent.com/plainlang/pyro/main/pyproject.toml"
|
|
60
|
+
FETCH_TIMEOUT = 3 # seconds
|
|
61
|
+
SKILL_MD = os.path.normpath(
|
|
62
|
+
os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "SKILL.md")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$")
|
|
66
|
+
|
|
67
|
+
USAGE = """usage: %(prog)s current [--skill-md <path>]
|
|
68
|
+
%(prog)s latest [--url <url>]
|
|
69
|
+
%(prog)s check [--skill-md <path>] [--url <url>]
|
|
70
|
+
|
|
71
|
+
current print this skill's own version (from SKILL.md metadata)
|
|
72
|
+
latest print the latest published stable version (pyproject.toml on main)
|
|
73
|
+
check print both and a status: update-available | up-to-date | ahead | unknown
|
|
74
|
+
""" % {"prog": PROG}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def emit(text):
|
|
78
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
79
|
+
sys.stdout.buffer.flush()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def version_key(version):
|
|
83
|
+
"""Sort key for a version string, or None if it is not one we understand.
|
|
84
|
+
|
|
85
|
+
A release sorts above its own release candidates: (X, Y, Z, 1, 0) for a
|
|
86
|
+
release, (X, Y, Z, 0, N) for X.Y.Z-rc.N.
|
|
87
|
+
"""
|
|
88
|
+
match = VERSION_RE.match(version)
|
|
89
|
+
if not match:
|
|
90
|
+
return None
|
|
91
|
+
major, minor, patch, rc = match.groups()
|
|
92
|
+
if rc is None:
|
|
93
|
+
return (int(major), int(minor), int(patch), 1, 0)
|
|
94
|
+
return (int(major), int(minor), int(patch), 0, int(rc))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_current(skill_md):
|
|
98
|
+
"""The version from SKILL.md's frontmatter, or None.
|
|
99
|
+
|
|
100
|
+
The stdlib has no YAML parser, so this scans only the frontmatter block
|
|
101
|
+
(between the opening and closing --- lines) for the one indented
|
|
102
|
+
`version:` key, which lives under `metadata:`.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
with open(skill_md, "rb") as handle:
|
|
106
|
+
text = handle.read().decode("utf-8", "replace")
|
|
107
|
+
except OSError:
|
|
108
|
+
return None
|
|
109
|
+
lines = text.splitlines()
|
|
110
|
+
if not lines or lines[0].strip() != "---":
|
|
111
|
+
return None
|
|
112
|
+
frontmatter = []
|
|
113
|
+
for line in lines[1:]:
|
|
114
|
+
if line.strip() == "---":
|
|
115
|
+
break
|
|
116
|
+
frontmatter.append(line)
|
|
117
|
+
match = re.search(
|
|
118
|
+
r"(?m)^[ \t]+version:[ \t]*[\"']?([^\"'\s]+)", "\n".join(frontmatter)
|
|
119
|
+
)
|
|
120
|
+
return match.group(1) if match else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_latest(url):
|
|
124
|
+
"""The [project] version from the pyproject.toml at `url`, or None.
|
|
125
|
+
|
|
126
|
+
Any failure - offline, DNS, TLS, HTTP error, timeout, garbage payload,
|
|
127
|
+
a version string that does not parse - just makes the latest version
|
|
128
|
+
unknown; the caller decides how loud to be. A non-None result is always
|
|
129
|
+
a valid version.
|
|
130
|
+
"""
|
|
131
|
+
import urllib.request
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT) as response:
|
|
135
|
+
text = response.read().decode("utf-8", "replace")
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
project = re.search(r"(?ms)^\[project\][ \t]*\r?$(.*?)(?=^\[|\Z)", text)
|
|
139
|
+
if not project:
|
|
140
|
+
return None
|
|
141
|
+
match = re.search(r"(?m)^version[ \t]*=[ \t]*[\"']([^\"']+)[\"']", project.group(1))
|
|
142
|
+
if not match or version_key(match.group(1)) is None:
|
|
143
|
+
return None
|
|
144
|
+
return match.group(1)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def compare(current, latest):
|
|
148
|
+
if latest is None:
|
|
149
|
+
return "unknown"
|
|
150
|
+
current_key = version_key(current)
|
|
151
|
+
latest_key = version_key(latest)
|
|
152
|
+
if latest_key > current_key:
|
|
153
|
+
return "update-available"
|
|
154
|
+
if latest_key < current_key:
|
|
155
|
+
return "ahead"
|
|
156
|
+
return "up-to-date"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_args(argv):
|
|
160
|
+
command = None
|
|
161
|
+
options = {"--skill-md": SKILL_MD, "--url": LATEST_URL}
|
|
162
|
+
index = 0
|
|
163
|
+
while index < len(argv):
|
|
164
|
+
name, equals, value = argv[index].partition("=")
|
|
165
|
+
if name in options:
|
|
166
|
+
if not equals:
|
|
167
|
+
index += 1
|
|
168
|
+
if index >= len(argv):
|
|
169
|
+
usage_error("%s needs a value" % name)
|
|
170
|
+
value = argv[index]
|
|
171
|
+
options[name] = value
|
|
172
|
+
elif name in ("current", "latest", "check") and not equals:
|
|
173
|
+
if command is not None:
|
|
174
|
+
usage_error("only one command allowed, got %r after %r" % (name, command))
|
|
175
|
+
command = name
|
|
176
|
+
else:
|
|
177
|
+
usage_error("unknown argument %r" % argv[index])
|
|
178
|
+
index += 1
|
|
179
|
+
if command is None:
|
|
180
|
+
usage_error("a command is required")
|
|
181
|
+
return command, options["--skill-md"], options["--url"]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def usage_error(message):
|
|
185
|
+
sys.stderr.write("%s: error: %s\n\n%s" % (PROG, message, USAGE))
|
|
186
|
+
sys.exit(2)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv):
|
|
190
|
+
command, skill_md, url = parse_args(argv)
|
|
191
|
+
|
|
192
|
+
if command == "latest":
|
|
193
|
+
latest = fetch_latest(url)
|
|
194
|
+
if latest is None:
|
|
195
|
+
sys.stderr.write(
|
|
196
|
+
"%s: error: could not determine the latest published version\n" % PROG
|
|
197
|
+
)
|
|
198
|
+
return 1
|
|
199
|
+
emit(latest + "\n")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
current = read_current(skill_md)
|
|
203
|
+
if current is None or version_key(current) is None:
|
|
204
|
+
sys.stderr.write(
|
|
205
|
+
"%s: error: no parseable metadata version in %s\n" % (PROG, skill_md)
|
|
206
|
+
)
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
if command == "current":
|
|
210
|
+
emit(current + "\n")
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
# check: never fail on account of the network.
|
|
214
|
+
latest = fetch_latest(url)
|
|
215
|
+
emit(
|
|
216
|
+
"current: %s\nlatest: %s\nstatus: %s\n"
|
|
217
|
+
% (current, latest or "unknown", compare(current, latest))
|
|
218
|
+
)
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copy the complete contents of one folder into another, recursively.
|
|
3
|
+
#
|
|
4
|
+
# Usage: copy_folder.py <source> <destination>
|
|
5
|
+
#
|
|
6
|
+
# The destination folder is created if it does not exist. If it already
|
|
7
|
+
# exists, the source contents are merged into it and files with the same
|
|
8
|
+
# name are overwritten - the same semantics as `cp -r <source>/* <dest>/`
|
|
9
|
+
# on unix, but working identically on macOS, Linux and Windows.
|
|
10
|
+
#
|
|
11
|
+
# Exits 0 on success, 1 on error (source missing or not a directory).
|
|
12
|
+
#
|
|
13
|
+
# Requires Python 3.8+ (shutil.copytree dirs_exist_ok).
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> int:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
description="Copy the complete contents of a folder to a destination folder."
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("source", help="folder to copy from")
|
|
26
|
+
parser.add_argument("destination", help="folder to copy into")
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
source = Path(args.source)
|
|
30
|
+
destination = Path(args.destination)
|
|
31
|
+
|
|
32
|
+
if not source.exists():
|
|
33
|
+
print(f"error: source does not exist: {source}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
if not source.is_dir():
|
|
36
|
+
print(f"error: source is not a directory: {source}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
shutil.copytree(source, destination, dirs_exist_ok=True)
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
print(f"error: copy failed: {exc}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
sys.exit(main())
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Extract one section from .plain specs and print the bodies to stdout.
|
|
3
|
+
#
|
|
4
|
+
# Usage: plain_sections.py [--include-filename] [--output <path>] <section> <spec.plain> [spec.plain ...]
|
|
5
|
+
#
|
|
6
|
+
# Sections (case-insensitive, `-` and `_` interchangeable):
|
|
7
|
+
# definitions defs :plainDefinitions:
|
|
8
|
+
# implementation-reqs impl-reqs impl :plainImplementationReqs:
|
|
9
|
+
# test-reqs :plainTestReqs:
|
|
10
|
+
# functional-specs func-specs specs :plainFunctionality:
|
|
11
|
+
# acceptance-tests acc-tests :AcceptanceTests:
|
|
12
|
+
# all all-sections everything - every section, in file order
|
|
13
|
+
#
|
|
14
|
+
# The ***section*** marker itself is never printed, except for `all`, which keeps every
|
|
15
|
+
# section's own marker so the sections stay distinguishable. `>` comment lines are always
|
|
16
|
+
# dropped. With --include-filename each body is preceded by a "## <path>" heading.
|
|
17
|
+
#
|
|
18
|
+
# --output <path> (or --output=<path>) writes the result to <path> instead of stdout, creating
|
|
19
|
+
# missing parent folders, so callers never need shell redirection. Windows PowerShell 5.1
|
|
20
|
+
# writes UTF-16LE for `>` and `>>`, which corrupts files that later steps read back, so
|
|
21
|
+
# redirecting here rather than in the calling shell keeps the output UTF-8/LF on every
|
|
22
|
+
# platform. Warnings stay on stderr.
|
|
23
|
+
#
|
|
24
|
+
# Portability: the standard library only, Python 3.8+, no shell and no awk - so the same
|
|
25
|
+
# command line works from sh, PowerShell and cmd alike. Everything is written as bytes to
|
|
26
|
+
# keep the output UTF-8 with LF endings even on a Windows console, whose text streams would
|
|
27
|
+
# otherwise re-encode to cp1252 and translate \n to \r\n.
|
|
28
|
+
#
|
|
29
|
+
# Exit: 0 ok (a missing section only warns on stderr), 1 unreadable file or unwritable
|
|
30
|
+
# --output path, 2 usage error / unknown section.
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
# Deliberately no f-strings anywhere in this file: it has to stay parseable by Python 2 so
|
|
37
|
+
# that a probe landing on an old `python` reaches this guard and gets a clear message instead
|
|
38
|
+
# of a SyntaxError traceback.
|
|
39
|
+
if sys.version_info < (3, 8):
|
|
40
|
+
sys.stderr.write(
|
|
41
|
+
"plain_sections.py: error: Python 3.8 or newer is required, found %s\n"
|
|
42
|
+
% ".".join(str(part) for part in sys.version_info[:3])
|
|
43
|
+
)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
PROG = os.path.basename(sys.argv[0])
|
|
47
|
+
|
|
48
|
+
USAGE = """usage: %(prog)s [--include-filename] [--output <path>] <section> <spec.plain> [spec.plain ...]
|
|
49
|
+
|
|
50
|
+
sections (case-insensitive, - and _ interchangeable):
|
|
51
|
+
definitions defs :plainDefinitions:
|
|
52
|
+
implementation-reqs impl-reqs impl :plainImplementationReqs:
|
|
53
|
+
test-reqs :plainTestReqs:
|
|
54
|
+
functional-specs func-specs specs :plainFunctionality:
|
|
55
|
+
acceptance-tests acc-tests :AcceptanceTests:
|
|
56
|
+
all all-sections everything
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Every alias, normalized, mapped to the canonical section name the parser matches on.
|
|
60
|
+
SECTIONS = {
|
|
61
|
+
"definitions": "definitions",
|
|
62
|
+
"defs": "definitions",
|
|
63
|
+
"plaindefinitions": "definitions",
|
|
64
|
+
"implementation reqs": "implementation reqs",
|
|
65
|
+
"impl reqs": "implementation reqs",
|
|
66
|
+
"impl": "implementation reqs",
|
|
67
|
+
"plainimplementationreqs": "implementation reqs",
|
|
68
|
+
"test reqs": "test reqs",
|
|
69
|
+
"plaintestreqs": "test reqs",
|
|
70
|
+
"functional specs": "functional specs",
|
|
71
|
+
"func specs": "functional specs",
|
|
72
|
+
"specs": "functional specs",
|
|
73
|
+
"plainfunctionality": "functional specs",
|
|
74
|
+
"acceptance tests": "acceptance tests",
|
|
75
|
+
"acc tests": "acceptance tests",
|
|
76
|
+
"acceptancetests": "acceptance tests",
|
|
77
|
+
"all": "all",
|
|
78
|
+
"all sections": "all",
|
|
79
|
+
"everything": "all",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
COMMENT_RE = re.compile(r"^[ \t]*>")
|
|
83
|
+
BLANK_RE = re.compile(r"^[ \t]*$")
|
|
84
|
+
MARKER_RE = re.compile(r"^[ \t]*\*\*\*(.*)\*\*\*[ \t]*$")
|
|
85
|
+
SPEC_ITEM_RE = re.compile(r"^-[ \t]")
|
|
86
|
+
TRAILING_WS_RE = re.compile(r"[ \t]+$")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def byte_sink(stream):
|
|
90
|
+
"""The binary writer under a text stream - or the stream itself when it already is one."""
|
|
91
|
+
return getattr(stream, "buffer", stream)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_text(sink, text):
|
|
95
|
+
"""Write text as UTF-8 bytes, bypassing any encoding and newline translation on the way."""
|
|
96
|
+
try:
|
|
97
|
+
sink.write(text.encode("utf-8", "surrogateescape"))
|
|
98
|
+
except TypeError: # a text-only stream substituted by a caller
|
|
99
|
+
sink.write(text)
|
|
100
|
+
sink.flush()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def error(message):
|
|
104
|
+
write_text(byte_sink(sys.stderr), "%s: error: %s\n" % (PROG, message))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def warning(message):
|
|
108
|
+
write_text(byte_sink(sys.stderr), "%s: warning: %s\n" % (PROG, message))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def usage():
|
|
112
|
+
write_text(byte_sink(sys.stderr), USAGE % {"prog": PROG})
|
|
113
|
+
sys.exit(2)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def normalize_section(name):
|
|
117
|
+
"""Lowercase, - / _ -> space, collapse and trim whitespace, strip the colons of :Concept:."""
|
|
118
|
+
name = " ".join(name.lower().replace("_", " ").replace("-", " ").split())
|
|
119
|
+
if name.startswith(":"):
|
|
120
|
+
name = name[1:]
|
|
121
|
+
if name.endswith(":"):
|
|
122
|
+
name = name[:-1]
|
|
123
|
+
return name
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def marker_name(line):
|
|
127
|
+
"""Section name of a ***marker*** line, normalized; None if the line is not a marker."""
|
|
128
|
+
match = MARKER_RE.match(line)
|
|
129
|
+
if match is None:
|
|
130
|
+
return None
|
|
131
|
+
name = re.sub(r"[ \t]+", " ", match.group(1).lower()).strip(" ")
|
|
132
|
+
if name == "": # a bare ****** is not a section marker
|
|
133
|
+
return None
|
|
134
|
+
return name
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def indent_of(line):
|
|
138
|
+
width = 0
|
|
139
|
+
while width < len(line) and line[width] in " \t":
|
|
140
|
+
width += 1
|
|
141
|
+
return width
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def dedent(line, width):
|
|
145
|
+
return line[min(indent_of(line), width):]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_lines(path):
|
|
149
|
+
"""Records exactly as awk would see them: split on \\n only, one trailing \\r stripped."""
|
|
150
|
+
handle = open(path, "r", encoding="utf-8", errors="surrogateescape", newline="")
|
|
151
|
+
try:
|
|
152
|
+
text = handle.read()
|
|
153
|
+
finally:
|
|
154
|
+
handle.close()
|
|
155
|
+
if text == "":
|
|
156
|
+
return []
|
|
157
|
+
if text.endswith("\n"):
|
|
158
|
+
text = text[:-1]
|
|
159
|
+
return [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def extract(path, want, show_path):
|
|
163
|
+
"""The body of `want` in `path`, newline-terminated; None when the section is absent."""
|
|
164
|
+
buf = []
|
|
165
|
+
pending = 0 # blank lines held back, so a trailing run never reaches the output
|
|
166
|
+
in_section = False
|
|
167
|
+
in_fs = False
|
|
168
|
+
in_block = False
|
|
169
|
+
block_indent = 0
|
|
170
|
+
block_content = False
|
|
171
|
+
parent = ""
|
|
172
|
+
last_parent = ""
|
|
173
|
+
|
|
174
|
+
def flush_blanks():
|
|
175
|
+
for _ in range(pending):
|
|
176
|
+
buf.append("")
|
|
177
|
+
|
|
178
|
+
for line in read_lines(path):
|
|
179
|
+
marker = marker_name(line)
|
|
180
|
+
col0 = line.startswith("***")
|
|
181
|
+
|
|
182
|
+
if want == "acceptance tests":
|
|
183
|
+
if marker is not None and col0: # a top-level section starts
|
|
184
|
+
in_fs = marker == "functional specs"
|
|
185
|
+
in_block = False
|
|
186
|
+
continue
|
|
187
|
+
if not in_fs:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if marker == "acceptance tests": # nested, indented under one spec
|
|
191
|
+
in_block = True
|
|
192
|
+
block_indent = indent_of(line)
|
|
193
|
+
pending = 0
|
|
194
|
+
block_content = False
|
|
195
|
+
if parent != "" and parent != last_parent:
|
|
196
|
+
if buf:
|
|
197
|
+
buf.append("")
|
|
198
|
+
buf.append(parent)
|
|
199
|
+
last_parent = parent
|
|
200
|
+
continue
|
|
201
|
+
if marker is not None:
|
|
202
|
+
in_block = False
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if in_block:
|
|
206
|
+
if BLANK_RE.match(line):
|
|
207
|
+
pending += 1
|
|
208
|
+
continue
|
|
209
|
+
if indent_of(line) >= block_indent:
|
|
210
|
+
if COMMENT_RE.match(line):
|
|
211
|
+
continue
|
|
212
|
+
if block_content:
|
|
213
|
+
flush_blanks()
|
|
214
|
+
pending = 0 # drop blank lines right after the marker
|
|
215
|
+
block_content = True
|
|
216
|
+
buf.append(dedent(line, block_indent))
|
|
217
|
+
continue
|
|
218
|
+
in_block = False # dedented out of the block; reconsider line
|
|
219
|
+
|
|
220
|
+
if SPEC_ITEM_RE.match(line): # the functional spec this block belongs to
|
|
221
|
+
parent = TRAILING_WS_RE.sub("", line)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Top-level section mode.
|
|
225
|
+
if marker is not None and col0:
|
|
226
|
+
if want == "all":
|
|
227
|
+
if buf:
|
|
228
|
+
buf.append("") # blank line before each new section
|
|
229
|
+
buf.append(line) # keep the ***marker*** verbatim
|
|
230
|
+
in_section = True
|
|
231
|
+
pending = 0
|
|
232
|
+
elif marker == want:
|
|
233
|
+
in_section = True
|
|
234
|
+
pending = 0
|
|
235
|
+
elif in_section:
|
|
236
|
+
in_section = False
|
|
237
|
+
continue
|
|
238
|
+
if not in_section:
|
|
239
|
+
continue
|
|
240
|
+
if COMMENT_RE.match(line):
|
|
241
|
+
continue
|
|
242
|
+
if BLANK_RE.match(line):
|
|
243
|
+
if buf:
|
|
244
|
+
pending += 1
|
|
245
|
+
continue
|
|
246
|
+
flush_blanks()
|
|
247
|
+
pending = 0
|
|
248
|
+
buf.append(line)
|
|
249
|
+
|
|
250
|
+
while buf and BLANK_RE.match(buf[-1]):
|
|
251
|
+
buf.pop()
|
|
252
|
+
if not buf:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
head = ["## " + path, ""] if show_path else []
|
|
256
|
+
return "\n".join(head + buf) + "\n"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def parse_args(argv):
|
|
260
|
+
"""Options are recognized wherever they appear, including after the positionals."""
|
|
261
|
+
show_path = False
|
|
262
|
+
out = None
|
|
263
|
+
positional = []
|
|
264
|
+
|
|
265
|
+
args = list(argv)
|
|
266
|
+
while args:
|
|
267
|
+
arg = args.pop(0)
|
|
268
|
+
if arg == "--include-filename":
|
|
269
|
+
show_path = True
|
|
270
|
+
elif arg == "--output":
|
|
271
|
+
if not args:
|
|
272
|
+
error("--output requires a path")
|
|
273
|
+
usage()
|
|
274
|
+
out = args.pop(0)
|
|
275
|
+
elif arg.startswith("--output="):
|
|
276
|
+
out = arg[len("--output="):]
|
|
277
|
+
if out == "":
|
|
278
|
+
error("--output requires a path")
|
|
279
|
+
usage()
|
|
280
|
+
elif arg in ("-h", "--help"):
|
|
281
|
+
usage()
|
|
282
|
+
else:
|
|
283
|
+
positional.append(arg)
|
|
284
|
+
|
|
285
|
+
return show_path, out, positional
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main(argv):
|
|
289
|
+
show_path, out, positional = parse_args(argv)
|
|
290
|
+
|
|
291
|
+
if len(positional) < 2:
|
|
292
|
+
usage()
|
|
293
|
+
|
|
294
|
+
section_arg = positional[0]
|
|
295
|
+
want = SECTIONS.get(normalize_section(section_arg))
|
|
296
|
+
if want is None:
|
|
297
|
+
error("unknown section: %s" % section_arg)
|
|
298
|
+
usage()
|
|
299
|
+
|
|
300
|
+
# Open once, here, so every write below lands in the file. Truncating up front makes an
|
|
301
|
+
# unwritable path a clean error before any spec is read.
|
|
302
|
+
if out is None:
|
|
303
|
+
sink = byte_sink(sys.stdout)
|
|
304
|
+
handle = None
|
|
305
|
+
else:
|
|
306
|
+
try:
|
|
307
|
+
parent = os.path.dirname(out)
|
|
308
|
+
if parent and not os.path.isdir(parent):
|
|
309
|
+
os.makedirs(parent)
|
|
310
|
+
handle = open(out, "wb")
|
|
311
|
+
except (IOError, OSError):
|
|
312
|
+
error("cannot write %s" % out)
|
|
313
|
+
return 1
|
|
314
|
+
sink = handle
|
|
315
|
+
|
|
316
|
+
exit_status = 0
|
|
317
|
+
printed = False
|
|
318
|
+
try:
|
|
319
|
+
for path in positional[1:]:
|
|
320
|
+
if os.path.isdir(path) or not os.access(path, os.R_OK):
|
|
321
|
+
error("cannot read %s" % path)
|
|
322
|
+
exit_status = 1
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
body = extract(path, want, show_path)
|
|
327
|
+
except (IOError, OSError):
|
|
328
|
+
error("cannot read %s" % path)
|
|
329
|
+
exit_status = 1
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
if body is None:
|
|
333
|
+
if want == "all":
|
|
334
|
+
warning("no sections in %s" % path)
|
|
335
|
+
else:
|
|
336
|
+
warning("no '%s' in %s" % (want, path))
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
if printed:
|
|
340
|
+
write_text(sink, "\n")
|
|
341
|
+
write_text(sink, body)
|
|
342
|
+
printed = True
|
|
343
|
+
finally:
|
|
344
|
+
if handle is not None:
|
|
345
|
+
handle.close()
|
|
346
|
+
|
|
347
|
+
return exit_status
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
sys.exit(main(sys.argv[1:]))
|