spintax-core 0.1.0__tar.gz

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.
Files changed (57) hide show
  1. spintax_core-0.1.0/.github/workflows/ci.yml +105 -0
  2. spintax_core-0.1.0/.github/workflows/release.yml +103 -0
  3. spintax_core-0.1.0/.gitignore +35 -0
  4. spintax_core-0.1.0/LICENSE +21 -0
  5. spintax_core-0.1.0/PKG-INFO +117 -0
  6. spintax_core-0.1.0/README.md +95 -0
  7. spintax_core-0.1.0/docs/plan-p1.md +142 -0
  8. spintax_core-0.1.0/docs/plan-p2.md +265 -0
  9. spintax_core-0.1.0/docs/plan-p3.md +92 -0
  10. spintax_core-0.1.0/docs/spec-python-port.md +370 -0
  11. spintax_core-0.1.0/pyproject.toml +51 -0
  12. spintax_core-0.1.0/src/spintax_core/__init__.py +282 -0
  13. spintax_core-0.1.0/src/spintax_core/_analyze.py +62 -0
  14. spintax_core-0.1.0/src/spintax_core/_ast.py +211 -0
  15. spintax_core-0.1.0/src/spintax_core/_charclasses.py +317 -0
  16. spintax_core-0.1.0/src/spintax_core/_directives.py +136 -0
  17. spintax_core-0.1.0/src/spintax_core/_errors.py +46 -0
  18. spintax_core-0.1.0/src/spintax_core/_extract.py +53 -0
  19. spintax_core-0.1.0/src/spintax_core/_neutralize.py +63 -0
  20. spintax_core-0.1.0/src/spintax_core/_parser.py +502 -0
  21. spintax_core-0.1.0/src/spintax_core/_pipeline.py +69 -0
  22. spintax_core-0.1.0/src/spintax_core/_plurals.py +123 -0
  23. spintax_core-0.1.0/src/spintax_core/_postprocess.py +221 -0
  24. spintax_core-0.1.0/src/spintax_core/_render.py +679 -0
  25. spintax_core-0.1.0/src/spintax_core/_rng.py +67 -0
  26. spintax_core-0.1.0/src/spintax_core/_source.py +124 -0
  27. spintax_core-0.1.0/src/spintax_core/_validator.py +536 -0
  28. spintax_core-0.1.0/src/spintax_core/py.typed +0 -0
  29. spintax_core-0.1.0/tests/conftest.py +59 -0
  30. spintax_core-0.1.0/tests/data/generate_parser_parity.cjs +43 -0
  31. spintax_core-0.1.0/tests/data/generate_postprocess_parity.cjs +53 -0
  32. spintax_core-0.1.0/tests/data/parser_parity.json +4843 -0
  33. spintax_core-0.1.0/tests/data/parser_parity_templates.json +1 -0
  34. spintax_core-0.1.0/tests/data/postprocess_parity.json +2578 -0
  35. spintax_core-0.1.0/tests/data/postprocess_parity_cases.json +1 -0
  36. spintax_core-0.1.0/tests/rng_strategy.py +37 -0
  37. spintax_core-0.1.0/tests/test_analyze.py +201 -0
  38. spintax_core-0.1.0/tests/test_ascii_parity.py +72 -0
  39. spintax_core-0.1.0/tests/test_ast_walk.py +132 -0
  40. spintax_core-0.1.0/tests/test_case_folding.py +121 -0
  41. spintax_core-0.1.0/tests/test_charclass_tables.py +145 -0
  42. spintax_core-0.1.0/tests/test_golden_corpus.py +184 -0
  43. spintax_core-0.1.0/tests/test_include_and_config.py +279 -0
  44. spintax_core-0.1.0/tests/test_make_rng.py +109 -0
  45. spintax_core-0.1.0/tests/test_neutralize.py +56 -0
  46. spintax_core-0.1.0/tests/test_packaging.py +59 -0
  47. spintax_core-0.1.0/tests/test_parser_depth.py +76 -0
  48. spintax_core-0.1.0/tests/test_parser_parity.py +134 -0
  49. spintax_core-0.1.0/tests/test_plan_covers_corpus.py +87 -0
  50. spintax_core-0.1.0/tests/test_postprocess_parity.py +57 -0
  51. spintax_core-0.1.0/tests/test_render_contract.py +217 -0
  52. spintax_core-0.1.0/tests/test_review_gaps.py +203 -0
  53. spintax_core-0.1.0/tests/test_rng_strategy.py +59 -0
  54. spintax_core-0.1.0/tests/test_source.py +75 -0
  55. spintax_core-0.1.0/tests/test_terminators_and_body.py +77 -0
  56. spintax_core-0.1.0/tests/test_validator_semantic.py +204 -0
  57. spintax_core-0.1.0/tests/test_validator_structural.py +153 -0
@@ -0,0 +1,105 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ python: ["3.10", "3.11", "3.12", "3.13"]
14
+
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+
18
+ # The golden corpus is the cross-engine contract, shared with @spintax/core
19
+ # (TypeScript) and the PHP engine. It is checked out rather than vendored: a
20
+ # copy would drift, and a drifting contract is not a contract. Same pattern
21
+ # spintax-php already runs in production.
22
+ - name: Check out the golden corpus
23
+ uses: actions/checkout@v7
24
+ with:
25
+ repository: investblog/spintax-js
26
+ path: .corpus
27
+
28
+ - uses: actions/setup-python@v7
29
+ with:
30
+ python-version: ${{ matrix.python }}
31
+
32
+ - name: Install
33
+ run: |
34
+ python -m pip install --upgrade pip
35
+ pip install -e . pytest ruff mypy
36
+
37
+ - name: Golden corpus
38
+ env:
39
+ SPINTAX_FIXTURES: ${{ github.workspace }}/.corpus/packages/conformance/fixtures
40
+ run: pytest -q
41
+
42
+ # Gated, not merely runnable: the plan's definition of done names both, and a
43
+ # check nobody runs is a check that does not exist.
44
+ - name: Lint
45
+ run: ruff check .
46
+
47
+ - name: Types
48
+ run: mypy
49
+
50
+ # Everything above aims at the code. Nothing looked at the DISTRIBUTION, and that is
51
+ # where the last two publication blockers were hiding: a `Typing :: Typed` classifier
52
+ # with no marker behind it, and a licence declared twice. Neither shows up in ruff,
53
+ # mypy or pytest, and both would have reached PyPI.
54
+ #
55
+ # Its own job rather than a matrix step: a distribution does not vary by interpreter,
56
+ # so building it four times would only multiply the noise.
57
+ package:
58
+ runs-on: ubuntu-latest
59
+
60
+ steps:
61
+ - uses: actions/checkout@v7
62
+
63
+ - uses: actions/setup-python@v7
64
+ with:
65
+ python-version: "3.12"
66
+
67
+ - name: Install
68
+ run: |
69
+ python -m pip install --upgrade pip
70
+ pip install build twine pyroma
71
+
72
+ - name: Build
73
+ run: python -m build
74
+
75
+ # Guards the PyPI project page: a README that stops rendering is invisible here
76
+ # and permanent there.
77
+ - name: Distribution metadata
78
+ run: twine check dist/*
79
+
80
+ # Pinned at the rating this tree already earns, so the check catches a regression
81
+ # instead of asking for a round number. The two missing points are one complaint —
82
+ # no `author-email` — and that is deliberate: @spintax/core and spintax/core both
83
+ # publish name plus https://301.st and no address, routing contact through Issues.
84
+ # Do not "fix" it to 10 by adding an inbox the other two packages do not expose.
85
+ - name: Packaging quality
86
+ run: pyroma -n 8 .
87
+
88
+ # tests/test_packaging.py checks the marker beside the IMPORTED module, which under
89
+ # `pip install -e .` is the source tree — it would stay green if packaging config
90
+ # ever excluded the file. Only a built artifact can answer whether it ships.
91
+ - name: The typing marker must ship, not merely exist in the tree
92
+ run: |
93
+ python - <<'PY'
94
+ import glob, sys, tarfile, zipfile
95
+ wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
96
+ sdist = tarfile.open(glob.glob("dist/*.tar.gz")[0]).getnames()
97
+ missing = [
98
+ kind
99
+ for kind, names in (("wheel", wheel), ("sdist", sdist))
100
+ if not any(n.endswith("py.typed") for n in names)
101
+ ]
102
+ if missing:
103
+ sys.exit(f"py.typed is absent from: {', '.join(missing)}")
104
+ print("py.typed ships in both the wheel and the sdist")
105
+ PY
@@ -0,0 +1,103 @@
1
+ name: Release
2
+
3
+ # Tag-driven, and deliberately not automatic on push to main: a release is a decision, not
4
+ # a side effect of merging. Tag `v0.1.0` and this builds, re-verifies and uploads.
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ # The CI workflow already runs on every push, but a tag can be placed on any commit —
14
+ # including one CI never saw green. Re-running the gate here means the artifact that
15
+ # reaches PyPI is one that passed, rather than one that probably did.
16
+ verify:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+
21
+ - name: Check out the golden corpus
22
+ uses: actions/checkout@v7
23
+ with:
24
+ repository: investblog/spintax-js
25
+ path: .corpus
26
+
27
+ - uses: actions/setup-python@v7
28
+ with:
29
+ python-version: "3.12"
30
+
31
+ - name: Install
32
+ run: |
33
+ python -m pip install --upgrade pip
34
+ pip install -e . pytest ruff mypy
35
+
36
+ - name: Test
37
+ env:
38
+ SPINTAX_FIXTURES: ${{ github.workspace }}/.corpus/packages/conformance/fixtures
39
+ run: pytest -q
40
+
41
+ - name: Lint
42
+ run: ruff check .
43
+
44
+ - name: Types
45
+ run: mypy
46
+
47
+ publish:
48
+ needs: verify
49
+ runs-on: ubuntu-latest
50
+ environment: pypi
51
+ steps:
52
+ - uses: actions/checkout@v7
53
+
54
+ - uses: actions/setup-python@v7
55
+ with:
56
+ python-version: "3.12"
57
+
58
+ - name: Install
59
+ run: |
60
+ python -m pip install --upgrade pip
61
+ pip install build twine
62
+
63
+ - name: Build
64
+ run: python -m build
65
+
66
+ # The same three checks the `package` job runs on every push. Repeated here because
67
+ # this is the last point at which a bad artifact can still be stopped.
68
+ - name: Distribution metadata
69
+ run: twine check dist/*
70
+
71
+ - name: The version being published matches the tag
72
+ run: |
73
+ python - <<'PY'
74
+ import glob, os, re, sys
75
+ tag = os.environ["GITHUB_REF_NAME"].lstrip("v")
76
+ names = [os.path.basename(p) for p in glob.glob("dist/*.whl")]
77
+ built = re.match(r"spintax_core-([^-]+)-", names[0]).group(1)
78
+ if built != tag:
79
+ sys.exit(f"tag says {tag!r} but the wheel is {built!r} — bump pyproject or retag")
80
+ print(f"tag and wheel agree on {built}")
81
+ PY
82
+
83
+ - name: The typing marker ships
84
+ run: |
85
+ python - <<'PY'
86
+ import glob, sys, tarfile, zipfile
87
+ wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
88
+ sdist = tarfile.open(glob.glob("dist/*.tar.gz")[0]).getnames()
89
+ missing = [
90
+ kind
91
+ for kind, names in (("wheel", wheel), ("sdist", sdist))
92
+ if not any(n.endswith("py.typed") for n in names)
93
+ ]
94
+ if missing:
95
+ sys.exit(f"py.typed is absent from: {', '.join(missing)}")
96
+ print("py.typed ships in both the wheel and the sdist")
97
+ PY
98
+
99
+ - name: Upload to PyPI
100
+ env:
101
+ TWINE_USERNAME: __token__
102
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
103
+ run: twine upload dist/*
@@ -0,0 +1,35 @@
1
+ # Agent charters live locally, not in the repo (mirrors the spintax-js convention).
2
+ CLAUDE.md
3
+ AGENTS.md
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ .eggs/
10
+ build/
11
+ # `dist*` rather than `dist`: a wheel built into `dist-review/` for a one-off
12
+ # inspection slipped past a plain `dist/` and was committed (291ac13). The pattern
13
+ # has to cover the directory names people actually type, not just the canonical one.
14
+ dist*/
15
+ .venv/
16
+ venv/
17
+ .python-version
18
+
19
+ # Tooling caches
20
+ .pytest_cache/
21
+ .mypy_cache/
22
+ .ruff_cache/
23
+ .coverage
24
+ htmlcov/
25
+
26
+ # Local corpus checkout / fixture sync (see spec §6 Q4). `.corpus/` is where CI
27
+ # puts it and where the test fallback looks; never commit the fixtures here.
28
+ /fixtures-local/
29
+ /.corpus/
30
+
31
+ # Editors / OS
32
+ .vscode/
33
+ .idea/
34
+ .DS_Store
35
+ Thumbs.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 301st (https://301.st)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: spintax-core
3
+ Version: 0.1.0
4
+ Summary: Spintax engine for Python — enumerations, permutations, variables, conditionals, plural agreement, includes. Zero dependencies.
5
+ Project-URL: Homepage, https://spintax.net
6
+ Project-URL: Source, https://github.com/investblog/spintax-py
7
+ Project-URL: Issues, https://github.com/investblog/spintax-py/issues
8
+ Author: 301st
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: content-spinning,spinner,spintax,template-variations,text-spinning
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Text Processing :: Markup
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # spintax-core (Python)
24
+
25
+ A framework-agnostic **[Spintax](https://spintax.net) engine** for Python — parse, render,
26
+ validate, extract, analyze, and neutralize spintax templates. MIT, zero runtime dependencies,
27
+ Python 3.10+.
28
+
29
+ This is the third engine in the Spintax family, and an **independent implementation** — not a
30
+ transcription of the others. It is held to the same behavior contract by a **shared golden corpus**
31
+ of language-neutral fixtures, which already gates the TypeScript engine and the PHP one. All 168
32
+ of them pass here, none skipped, none expected to fail.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install spintax-core
38
+ ```
39
+
40
+ ## Use
41
+
42
+ ```python
43
+ from spintax_core import render, validate, parse
44
+
45
+ render("{Hello|Hi} there!") # "Hello there!" or "Hi there!"
46
+ render("{Hello|Hi} there!", seed=42) # same seed, same output, every time
47
+ render("%greeting%, world", context={"greeting": "Hello"}) # "Hello, world"
48
+
49
+ # Reuse a template: parse once, render many.
50
+ ast = parse('[<sep=", ">fast|cheap|good]')
51
+ [render(ast) for _ in range(3)]
52
+ # ['Cheap, fast, good', 'Good, cheap, fast', 'Cheap, good, fast']
53
+
54
+ # Check a template before you ship it.
55
+ [d.code for d in validate("{a|b")] # -> ['bracket.unclosed']
56
+ ```
57
+
58
+ Rendering is **lenient**: malformed markup degrades rather than raising, so a template a
59
+ non-programmer wrote cannot take a page down.
60
+
61
+ Syntax — enumerations `{a|b}`, permutations `[a|b]`, variables `%name%`, conditionals
62
+ `{?VAR?yes|no}`, plural agreement `{plural 3: one|few|many}`, `#set` / `#def` / `#include` — is
63
+ documented at **[spintax.net/docs](https://spintax.net/docs/)**.
64
+
65
+ - **Spec:** [`docs/spec-python-port.md`](docs/spec-python-port.md) — read it before writing any
66
+ code. It records the parity contract, the API surface, and the open questions. Corpus access
67
+ (Q4) is decided; Unicode in post-process (Q5) is a known trap with a verified stdlib answer,
68
+ and lands with P2.
69
+ - **Sibling engines:** [`@spintax/core`](https://www.npmjs.com/package/@spintax/core) (TypeScript,
70
+ MIT, published) · [Spintax for WordPress](https://wordpress.org/plugins/spintax/) (PHP, GPL, the
71
+ origin).
72
+ - **Tracking issue:** [investblog/spintax-js#43](https://github.com/investblog/spintax-js/issues/43).
73
+
74
+ ## Why
75
+
76
+ The existing PyPI `spintax` package is **GPLv3** and has not shipped since **2018**. GPL blocks
77
+ commercial adoption; this one is MIT and maintained.
78
+
79
+ ## License
80
+
81
+ [MIT](LICENSE). The WordPress plugin remains GPL; MIT/Expat is GPL-compatible.
82
+
83
+ ---
84
+
85
+ Part of the [301.st](https://301.st) toolset. Product home: [spintax.net](https://spintax.net).
86
+
87
+ ## Development
88
+
89
+ The test suite **is** the shared golden corpus — the same JSON fixtures the TypeScript and PHP
90
+ engines are tested against, read from a checkout rather than vendored here. A copy would drift,
91
+ and a drifting contract is not a contract.
92
+
93
+ ```sh
94
+ git clone https://github.com/investblog/spintax-js ../spintax-js # once
95
+ python -m venv .venv && .venv/bin/pip install -e . pytest
96
+ SPINTAX_FIXTURES=../spintax-js/packages/conformance/fixtures pytest
97
+ ```
98
+
99
+ Without the fixtures the suite **fails** rather than passing an empty run — a green suite that
100
+ tested nothing is the most expensive kind of green. The shape of the output is:
101
+
102
+ ```
103
+ N passed, M xfailed, 0 skipped
104
+ ```
105
+
106
+ The two numbers are the milestone tracker: `passed` grows as the engine does, `xfailed` is what
107
+ the corpus still expects and the engine cannot yet do. A **skip** should never appear — it would
108
+ mean a case is being neither asserted nor counted.
109
+
110
+ The xfails are the whole cross-engine contract, waiting on P1–P3. As milestones land, cases turn
111
+ into real passes with no change to the runner.
112
+
113
+ > **If you mutate a source file to check that a test catches it, delete `__pycache__` first.**
114
+ > Python invalidates bytecode on (mtime, size). A mutation that preserves both — swapping `.*?`
115
+ > for `.+?`, say — leaves the stale `.pyc` in place, so the *next* run still imports the mutated
116
+ > module after you have restored the file. That produced a failure pointing at correct code, and
117
+ > the obvious response to it would have been to break the code for real.
@@ -0,0 +1,95 @@
1
+ # spintax-core (Python)
2
+
3
+ A framework-agnostic **[Spintax](https://spintax.net) engine** for Python — parse, render,
4
+ validate, extract, analyze, and neutralize spintax templates. MIT, zero runtime dependencies,
5
+ Python 3.10+.
6
+
7
+ This is the third engine in the Spintax family, and an **independent implementation** — not a
8
+ transcription of the others. It is held to the same behavior contract by a **shared golden corpus**
9
+ of language-neutral fixtures, which already gates the TypeScript engine and the PHP one. All 168
10
+ of them pass here, none skipped, none expected to fail.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install spintax-core
16
+ ```
17
+
18
+ ## Use
19
+
20
+ ```python
21
+ from spintax_core import render, validate, parse
22
+
23
+ render("{Hello|Hi} there!") # "Hello there!" or "Hi there!"
24
+ render("{Hello|Hi} there!", seed=42) # same seed, same output, every time
25
+ render("%greeting%, world", context={"greeting": "Hello"}) # "Hello, world"
26
+
27
+ # Reuse a template: parse once, render many.
28
+ ast = parse('[<sep=", ">fast|cheap|good]')
29
+ [render(ast) for _ in range(3)]
30
+ # ['Cheap, fast, good', 'Good, cheap, fast', 'Cheap, good, fast']
31
+
32
+ # Check a template before you ship it.
33
+ [d.code for d in validate("{a|b")] # -> ['bracket.unclosed']
34
+ ```
35
+
36
+ Rendering is **lenient**: malformed markup degrades rather than raising, so a template a
37
+ non-programmer wrote cannot take a page down.
38
+
39
+ Syntax — enumerations `{a|b}`, permutations `[a|b]`, variables `%name%`, conditionals
40
+ `{?VAR?yes|no}`, plural agreement `{plural 3: one|few|many}`, `#set` / `#def` / `#include` — is
41
+ documented at **[spintax.net/docs](https://spintax.net/docs/)**.
42
+
43
+ - **Spec:** [`docs/spec-python-port.md`](docs/spec-python-port.md) — read it before writing any
44
+ code. It records the parity contract, the API surface, and the open questions. Corpus access
45
+ (Q4) is decided; Unicode in post-process (Q5) is a known trap with a verified stdlib answer,
46
+ and lands with P2.
47
+ - **Sibling engines:** [`@spintax/core`](https://www.npmjs.com/package/@spintax/core) (TypeScript,
48
+ MIT, published) · [Spintax for WordPress](https://wordpress.org/plugins/spintax/) (PHP, GPL, the
49
+ origin).
50
+ - **Tracking issue:** [investblog/spintax-js#43](https://github.com/investblog/spintax-js/issues/43).
51
+
52
+ ## Why
53
+
54
+ The existing PyPI `spintax` package is **GPLv3** and has not shipped since **2018**. GPL blocks
55
+ commercial adoption; this one is MIT and maintained.
56
+
57
+ ## License
58
+
59
+ [MIT](LICENSE). The WordPress plugin remains GPL; MIT/Expat is GPL-compatible.
60
+
61
+ ---
62
+
63
+ Part of the [301.st](https://301.st) toolset. Product home: [spintax.net](https://spintax.net).
64
+
65
+ ## Development
66
+
67
+ The test suite **is** the shared golden corpus — the same JSON fixtures the TypeScript and PHP
68
+ engines are tested against, read from a checkout rather than vendored here. A copy would drift,
69
+ and a drifting contract is not a contract.
70
+
71
+ ```sh
72
+ git clone https://github.com/investblog/spintax-js ../spintax-js # once
73
+ python -m venv .venv && .venv/bin/pip install -e . pytest
74
+ SPINTAX_FIXTURES=../spintax-js/packages/conformance/fixtures pytest
75
+ ```
76
+
77
+ Without the fixtures the suite **fails** rather than passing an empty run — a green suite that
78
+ tested nothing is the most expensive kind of green. The shape of the output is:
79
+
80
+ ```
81
+ N passed, M xfailed, 0 skipped
82
+ ```
83
+
84
+ The two numbers are the milestone tracker: `passed` grows as the engine does, `xfailed` is what
85
+ the corpus still expects and the engine cannot yet do. A **skip** should never appear — it would
86
+ mean a case is being neither asserted nor counted.
87
+
88
+ The xfails are the whole cross-engine contract, waiting on P1–P3. As milestones land, cases turn
89
+ into real passes with no change to the runner.
90
+
91
+ > **If you mutate a source file to check that a test catches it, delete `__pycache__` first.**
92
+ > Python invalidates bytecode on (mtime, size). A mutation that preserves both — swapping `.*?`
93
+ > for `.+?`, say — leaves the stale `.pyc` in place, so the *next* run still imports the mutated
94
+ > module after you have restored the file. That produced a failure pointing at correct code, and
95
+ > the obvious response to it would have been to break the code for real.
@@ -0,0 +1,142 @@
1
+ # P1 — parser, validator, extract
2
+
3
+ Status: **complete**, with one step moved rather than done. `validate()` and `extract()` are
4
+ wired and every case in `validate.json` and `extract.json` passes. Governing contract:
5
+ [`spec-python-port.md`](spec-python-port.md). Next: P2 (parser + renderer).
6
+
7
+ P0 left the corpus running against an empty engine: **7 passed, 168 xfailed, 0 skipped**. P1's
8
+ progress metric is that number moving — every step below turns a named set of xfails into passes,
9
+ and no step is "done" until its cases pass for the reason intended.
10
+
11
+ ## What P1 must close
12
+
13
+ `validate.json` — 40 cases (15 valid, 25 invalid) across **17 distinct diagnostic codes** — plus
14
+ `extract.json` (2 cases). That is 42 of the 168.
15
+
16
+ Two scope corrections against the spec's milestone list, both found by reading the fixtures rather
17
+ than the plan:
18
+
19
+ - **Plural *arity* belongs to P1, not P2.** 14 validate cases carry a `locale` and 5 assert
20
+ `plural.arity`, so `normalize_base_lang` and the arity table are P1 work. Rendering plurals
21
+ stays P2; deciding whether a template *could* render is P1.
22
+ - **`extract` is pulled forward from P3.** Once the parser exists, the names are already collected
23
+ for the diagnostics — `refs`, `sets`, `defs`, `includes` fall out of work P1 has to do anyway.
24
+ Leaving it in P3 would mean building the same index twice.
25
+
26
+ ## Steps
27
+
28
+ Ordered so that each one moves the counter, and the hardest lands last against a suite that is
29
+ already mostly green.
30
+
31
+ ### 1. Parser → `Ast` — **moved to P2, not done**
32
+
33
+ Reading the reference before writing any of it showed the premise was wrong: `validate` and
34
+ `extract` are raw-text scanners *by design*, because the AST is lenient — an unbalanced bracket
35
+ is not represented in it at all, and a `[…]` body stays a raw string. Neither needs a tree, so
36
+ building one first would have been a week with no case moving. It is P2's opening, where the
37
+ renderer actually requires it. `parse()` still raises.
38
+
39
+ What did survive from this step is the position work, and it moved into `_source.py`: comment
40
+ stripping keeps a map back to the original offsets, so a diagnostic points at the place the
41
+ author is looking at rather than at a place that existed before the comments were removed.
42
+
43
+ ### 2. Structural diagnostics — 8 codes
44
+
45
+ `bracket.unclosed`, `bracket.mismatched`, `bracket.unexpected-closing`, `set.malformed`,
46
+ `def.malformed`, `permutation.minsize-not-integer`, `permutation.maxsize-not-integer`,
47
+ `permutation.unknown-key`.
48
+
49
+ Mechanical, and the first visible movement in the corpus.
50
+
51
+ ### 3. Variable graph — 3 codes
52
+
53
+ `variable.self-reference`, `variable.circular-reference`, `variable.undefined` (a **warning**, and
54
+ the only one that must not flip a verdict to invalid).
55
+
56
+ `known_variables` suppression is implemented here and **is not gated by the corpus** — the fixture
57
+ schema has no such field. It needs local tests or it can break silently.
58
+
59
+ ### 4. Plural diagnostics — 2 codes, 14 cases touched
60
+
61
+ `normalize_base_lang` (`sr-Latn` → `sr`, `pt-BR` → `pt`; three-letter tags are *not* mapped) plus
62
+ the arity table: 3 forms for `ru`/`uk`/`be`/`sr`/`hr`/`bs`, 2 for everything else — including
63
+ `pl`/`cs`/`sk`/`sl`/`bg`, which are wrong-but-accepted by design.
64
+
65
+ Then `plural.arity`. Note an empty or absent locale **skips** the arity check entirely.
66
+
67
+ `plural.nested-brackets` lands here too, and needs no locale: a form slot must be plain text, so
68
+ `{plural 1: {a|b}|c}` is rejected structurally. Keep it distinct from step 6 — this one is about
69
+ brackets *inside a form*, that one about a count that only becomes bracketed after expansion.
70
+
71
+ ### 5. Definitions and includes — 3 codes
72
+
73
+ `definition.duplicate-name`, `def.include-in-value`, and `include.unknown-target`.
74
+
75
+ The include check is the only diagnostic that depends on caller-supplied data: it fires only when
76
+ `known_includes` is non-empty, so with no list every target is assumed to exist. Two fixtures
77
+ cover it, and one of them is a *valid* verdict — a circular include is a runtime outcome, not a
78
+ static error.
79
+
80
+ Read spec §5.3 before writing this: duplicate detection requires keeping directive **occurrences**
81
+ until after the diagnostic runs. Folding directives into a `dict[str, str]` first destroys the
82
+ evidence — the second assignment overwrites the first and there is nothing left to report. The PHP
83
+ pass lost duplicates exactly this way.
84
+
85
+ ### 6. `plural.count-macro` — 1 code, 5 cases, and the real work
86
+
87
+ A taint analysis, not a node check. A count slot is poisoned when it resolves — possibly through a
88
+ chain of `#set` aliases — to a value that still holds unresolved spintax at the moment plurals run.
89
+
90
+ ```
91
+ #set %m% = {1|4|9} # chained alias: taint must reach %n%
92
+ #set %n% = %m%
93
+ {plural %n%: item|items} # error
94
+
95
+ #set %n% = {?flag?{1|4}|2} # a conditional is exempt (resolves before plurals)…
96
+ {plural %n%: item|items} # …but the enumeration inside it is not — still an error
97
+ ```
98
+
99
+ The rule is **stage order**, not bracket-spotting: conditionals resolve before the plural pass, so
100
+ they are the single exemption; a nested `{plural …}` is not. Propagate to a fixed point — a
101
+ one-pass walk gets `plural-count-macro` right and `plural-count-macro-chained` wrong.
102
+
103
+ There is a paired case where a conditional count **is valid**. If both pass, the rule is a rule; if
104
+ only the error cases pass, it is over-eager and the valid one will say so.
105
+
106
+ ### 7. `extract`
107
+
108
+ `refs` / `sets` / `defs` / `includes` from the index step 3 already built. `sets` and `defs` are
109
+ separate buckets — the whole point of the fixture we added upstream.
110
+
111
+ ## What the corpus will not catch
112
+
113
+ Local tests are mandatory for these; a green corpus says nothing about any of them:
114
+
115
+ | surface | why it is invisible |
116
+ | --- | --- |
117
+ | `line` / `column` | **zero** fixtures assert positions |
118
+ | `known_variables` | no such field in the fixture schema |
119
+ | `max_depth` | only the circular-include outcome is pinned |
120
+ | `parse` itself | no fixtures; only observable through other ops |
121
+
122
+ ## Why `validate()` was wired only at the end
123
+
124
+ The corpus reports by **op**. While `validate` raised `NotImplementedError` all 40 of its cases
125
+ were xfails, which reads as "not built". Wiring a half-finished validator would have run them for
126
+ real — the ones whose codes existed passing, the rest **failing** — and turned the suite red for
127
+ work nobody had claimed was done. Red would have stopped meaning "something broke".
128
+
129
+ So each check was proved by its own tests first and the public entry point flipped exactly once,
130
+ when all seventeen codes existed. Same reason the reference suite lights up by op, not by code.
131
+ Worth repeating at P2: `render` should stay unwired until it can pass its own cases.
132
+
133
+ ## Definition of done
134
+
135
+ - `validate.json` and `extract.json` fully green — 42 cases moved from xfail to pass.
136
+ - The corpus's 42 P1 cases move from xfail to passed, and no case is ever skipped. The absolute
137
+ totals are deliberately not written down here: they change with every local test added, and a
138
+ number in a document is a number that goes stale.
139
+ - Local tests covering the ungated surfaces above, each verified by breaking the implementation
140
+ and watching the test fail — not by observing that it passes. `max_depth` is excluded: it is a
141
+ render budget, so it belongs to P2 with the code that enforces it.
142
+ - `mypy --strict` and `ruff` clean.