Developer documentation
This page is for whoever has to change DDD rather than use it: to add an output format, to add a check, or to understand why the code is arranged the way it is before moving something in it.
DDD is a front end and a set of backends with one contract between them. The front end reads the description files, resolves the project and reports every disagreement it finds; each backend turns the resolved result into files of one output format. The front end never mentions c or a2l, and a backend never touches the loader or the checks. That is not a style preference: a code generator whose layers leak becomes a generator in which nobody can change the c output without wondering what it does to the a2l, and in which a rule about ownership ends up being re-implemented, slightly differently, in a jinja template.
Layers
layer |
knows about |
does not know about |
|---|---|---|
|
the json file formats, storage sizes, value ranges |
c, a2l |
|
files, includes, globs, encodings |
what the data means |
|
ownership, agreement between components, references |
how anything is rendered |
|
the contract: the resolved data dictionary |
how it is rendered |
|
the plugin contract: the blocks, the hooks |
the loader, the analysis, any backend |
|
|
a2l, the loader, the checks, what the generated files are called |
|
|
c, the loader, the checks |
|
the language server protocol, a document’s bytes and positions in it |
any output format |
|
ELF images, DWARF 2 to 5, C types, the variables of static storage |
DDD: it imports no |
|
turning what a project already has into DDD descriptions, once |
where its output goes, any output format |
The analysis row says “how anything is rendered” rather than “any output format” for a
reason: two of the checks are about what an output format can carry, so the module states
the three dimensions MATRIX_DIM holds in ASAP2 1.6.1 and the range of a c int on a
32 bit target, and says so in the findings it reports. What it does not know is how a
MATRIX_DIM or an int is written, which is what the row is about.
The last two backend rows are deliberately not symmetric. The a2l backend carries its own templates,
because ASAP2 is a format ASAM defines and a project has nothing to decide about it; the c
backend carries none, because what generated c looks like is a house style. It is constructed
with the template directory --template-dir names, works out what to render from the file
names it finds there, and therefore does not know before a run which files that run produces.
Those naming rules are part of the interface a project depends on: Templates documents
them, and the module docstring of src/ddd/backends/c/backend.py states them again next to
the code that implements them.
Six smaller modules sit beside them. diagnostics.py holds the severity policy and the
registry of every check, and is what both the loader and the analysis report through.
compare.py answers the directional question of whether one dictionary may replace
another, and is the second consumer of the contract next to the backends. cli.py is the
only module that knows about argument parsing, exit codes and where output goes; it is also
where the backends a ddd generate run uses are assembled, and it reaches every layer
above through an import inside the handler that needs it rather than at the top of the file,
so that ddd --version and ddd --help - which a cmake configure step asks for once per
project and a pre-commit hook once per file - are answered without building a single
contract. names.py is what makes that possible: the handful of spellings argparse reads
while it is still deciding what was asked for, in a module that imports nothing, each of them
the one definition of its name. identity.py makes an object
identity and writes one into a description file textually, so that ddd id --assign
produces a diff of one line per object rather than a reformatted document. build_info.py
is the hand-off from a build to an editor: which project description was configured, and
under which severity policy, neither of which any *.ddd.json file records.
src/ddd/lsp/ is a layer of its own rather than a smaller module: nine modules that speak
the language server protocol over a pipe, locate a json pointer in the bytes of a document
(ranges.py, which identity.py reuses rather than growing a second scanner), find the
projects a file belongs to and turn the findings into what an editor underlines. It is a
second front end over the same loader and analysis, and knows about no output format.
Editor integration describes what it offers a client.
The two contract pages describe the data that travels between the layers: the input file formats under data contracts, and the resolved form under data dictionary.
The split is enforced by a test
A layering that lives only in the documentation rots the first time somebody is in a hurry,
so DDD asserts it. tests/test_backends.py parses the layered modules - loading.py,
analysis.py, ir.py, diagnostics.py, everything under backends/,
plugins.py, elf.py and everything under toolbox/ - with ast, collects the
ddd.* modules each one imports, and fails if the import graph disagrees with the table
above:
loading.py,analysis.py,ir.pyanddiagnostics.pyimport no backend,nothing under
backends/importsddd.loadingorddd.analysis,the c backend does not import the a2l backend, and the a2l backend does not import the c one,
both backends satisfy the
ddd.backends.base.Backendprotocol, and every datatype is spelled by both of them, so a new datatype cannot be added to the contract while one output format silently has no name for it,plugins.pyimports no loader, analysis or backend at runtime, so a plugin sees exactly what a backend sees, and theBackendprotocol it names is only imported there underTYPE_CHECKING,elf.pyimports nodddmodule at all, so that reading an address map straight out of an image can use it without the toolbox, andnothing under
toolbox/imports a backend orddd.cli.
A second test in the same file reads the text of src/ddd/models/ and fails if a spelling
that belongs to a single output format - uint16_t, UWORD, COMPU_, AXIS_PTS,
stdint - has leaked into the input contract. The one documented exception is
src/ddd/models/reserved.py, which lists the c keywords and the names <stdint.h>
claims: which identifiers a c compiler takes for itself is a property of the input format,
because generating c is not optional in DDD. It is a file of its own precisely so that the
guard can stay strict about everything else.
Note
The word measurement is deliberately not on that list. It is DDD’s own term for an
online value and part of the input file format; that the a2l keyword MEASUREMENT
happens to coincide with it is not a leak.
What a backend is
A backend is anything with a name and a generate(dictionary, output_dir) method, as
described by the protocol in src/ddd/backends/base.py:
@runtime_checkable
class Backend(Protocol):
"""Turns a data dictionary into files."""
name: str
def generate(self, dictionary: DataDictionary, output_dir: Path) -> list[GeneratedFile]:
"""Render every artefact of this backend; nothing is written to disk."""
...
That signature carries two decisions worth knowing. First, generate receives the resolved
ddd.ir.DataDictionary and nothing else: no workspace, no diagnostic bag, no command
line arguments. Anything it needs about the project has to be a field of the contract, which
is what stops one output format from acquiring its own private view of what a project means.
Second, a backend renders but does not write. It returns fully rendered GeneratedFile
objects, and the driver decides what happens to them, which is what makes three things
possible in one place rather than in every backend:
two artefacts claiming the same path are refused with a message naming the backends involved, instead of one silently overwriting the other,
a file whose content has not changed is left alone, so that a regeneration does not trigger a rebuild of everything downstream,
--dry-runis a parameter of the writing step rather than a flag every backend has to honour correctly.
What a backend needs about the run rather than about the project is settled when it is constructed, and the constructor is a backend’s own business. That is where the two differ:
class CBackend:
def __init__(
self, template_dir: Path, options: COptions | None = None, generator: str = "ddd"
) -> None: ...
class A2lBackend:
def __init__(self, options: A2lOptions | None = None, generator: str = "ddd") -> None: ...
The template directory comes first for the c backend because it is the one argument that has
no default: a run without --template-dir is a usage error of ddd generate, never a
fallback to a built-in set of templates, and there is nothing for the constructor to fall back
to either. The a2l backend takes no such argument at all, since its templates are part of the
package.
Adding an output format
A format DDD does not ship is a plugin’s backend: the project names the
plugin, ddd generate <name> runs it, and nothing inside the tool changes. Adding a
built-in one - a header for another language, a csv, an ARXML - means adding a package next
to the existing two and registering it as an artefact of the generate command:
Create
src/ddd/backends/<format>/with a class exposingnameandgenerate. Follow the shape of the existing two: amodel.pythat turns the dictionary into whatever the templates want to iterate over, atypes.pymapping everyddd.models.Datatypeto the spelling of that format, and anoptions.pyfor what the command line can tune. Then decide where the templates come from, which is the one design question a new backend has to answer for itself: ship atemplates/directory inside the package if the format is specified elsewhere, as a2l is, or take a template directory as the first constructor argument if the format is a house style, as c is. Useddd.backends.base.make_environmentandrender_templateeither way, so the jinja settings - in particularStrictUndefined, which turns a typo in a template into an error rather than into an empty string - are the same as everywhere else.Export it from
src/ddd/backends/__init__.py.Register the artefact in
src/ddd/cli.py: an entry in the tuple_build_parserturns into thegeneratesubcommands, arender_<format>flag set by_add_generate_argumentstogether with the options that configure it, and the branch of_command_generatethat appends the backend when the flag is set. Add the name toBUILT_IN_GENERATEDinsrc/ddd/plugins.pyas well: any other lowercase name is taken for a plugin’s artefact before the parser is built, and a name that is both is a conflicting subparser. That tuple is the one to edit rather thanBUILT_IN_ARTEFACTS, which derives from it by addingall; the choices of--withoutand whatddd artefactsreports as built-in are read from it too, so both follow on their own.Add it to the protocol assertion in
tests/test_backends.py, and to the lists of artefactstests/test_cli.pyandtests/test_plugins.pyenumerate. The import graph tests pick the new package up on their own, so the first thing the suite will tell you is whether the new backend reached into the front end.
The front end changes in those two places and nowhere else, and neither of the existing
backends is touched. cmake/Ddd.cmake asks for all on every build, so the new artefact
reaches every cmake project from the moment it exists; a build that does not want it needs a
new option on ddd_generate appending --without <format>, the way NO_A2L appends
--without a2l.
Diagnostics never raise
The loader and the analysis report through a ddd.diagnostics.DiagnosticBag and do
not raise. An exception escaping from the middle of a run would end it with a bare python
message and throw away everything already collected, and the author would fix one problem,
run again, and be told about the next one. Collecting instead means a run reports as much as
it can: a file that cannot be read, a file that is not utf-8, json nested deeper than python
can parse, a path the operating system cannot represent - each comes back as a located
finding, and the rest of the project is still checked.
Two consequences for anyone adding a check. A check is an entry in the CHECKS registry
in src/ddd/diagnostics.py - an identifier, a default severity and a one line description
- plus the code that adds the finding; the identifier is part of the public interface of the
tool, since it is what -W and --strict address and what a ci job matches on, and
ddd checks prints the registry as it stands (see consistency checks). And a check that cannot be relaxed has to say so: the eight entries
marked overridable=False are the ones after which nothing further can be said about the
file, or the project, at all, and every other check has to survive being set to ignore.
What the calibration tools actually implement
Structured data can be written into an a2l two ways, and the choice is not a matter of taste.
ASAP2 has a typedef family - TYPEDEF_STRUCTURE, STRUCTURE_COMPONENT, INSTANCE - that
describes a structure once and instantiates it, which is the obvious fit for a project with
twenty instances of one type. The alternative is to flatten: one ordinary object per leaf, named
after the path to it, at the address of the instance plus the offset of the member.
The native form was tested against CANape 15 before any of it was built, with hand written a2l files, and it is not usable there:
ASAP2_VERSION 1 71is refused outright -unknown ASAP2 version 1.71- so the version DDD declares cannot simply be raised.1 70is accepted.at
1 61and1 70a file containing aTYPEDEF_STRUCTUREand twoINSTANCEof it loads without a warning and contains no objects at all. Loading is not evidence of support.the grammar does know the keyword: a
SYMBOL_TYPE_LINKinside the structure is a syntax error on its own line, not on the enclosing block, so the body is being parsed and validated. It accepts the shape and exposes nothing for it.a
GROUPreferencing members through their instance loads, and is empty.
So the a2l flattens, and two further constraints come out of the same exercise. Do not emit
ASAP2_VERSION 1 71, since 1 61 carries everything a flattened structure needs. And do not
rely on a tool to report a bad reference: CANape silently dropped one it could not resolve, which
means a mistake in a generated name costs an object with no diagnostic anywhere, and the burden
of catching it sits here.
INCA has not been tested. Since it is generally the more conservative of the two, flattening is what a project targeting both can rely on.
The coverage gate
Coverage runs with every test run, over statements and branches, and a gap fails the run:
[tool.pytest.ini_options]
addopts = "-q --cov --cov-report=term-missing --cov-fail-under=100"
[tool.coverage.run]
source = ["ddd"]
branch = true
The reasoning is that a line nobody executes is a line nobody has ever seen behave - and in
a code generator, an unexercised branch means an output nobody has ever looked at. The two
consequences are worth stating plainly, because they change how the gate is met. The gate is
what found the dead code this project used to carry, in the shape of unused properties on
the analysis and contract types; the fix was deleting them, not writing tests for them. And
the paths that only a coverage run reaches - unreadable files, malformed json, relaxed
severities, odd float literals - are collected in tests/test_edge_cases.py rather than
being scattered through the suite that describes behaviour.
Five suites guard things a type checker cannot. tests/test_backends.py walks the import
graph, as described above. tests/test_cmake.py configures and builds the cmake module -
over the shipped example, over a collected project naming a plugin, over a hand written one
and over a project written to exercise the keywords of one call - with the cmake the
development requirements install, so that the module is held to
what it does rather than to what it says. A configure and a build cost seconds each, which
makes that file a third of the suite’s runtime, so the classes whose tests ask several
questions of one tree configure and build it once, in a class-scoped fixture, and each test
reads one answer out of what it left behind. tests/test_hardening.py holds one test per defect that once
reached a customer-facing artefact or verdict - a transposed a2l array, a header that does
not compile, a legal name rejected, a description file that ended the run with a python
traceback - grouped by what was at stake rather than by module. tests/test_documentation.py
asserts that every check identifier, every command, every object kind and every datatype is
named in README.md and in SPEC.md, that the README invents no check that is not
registered, and that no link in either points at a file that no longer exists. And
tests/test_transcripts.py re-runs the documentation: every $ ddd command a page runs
over the shipped examples, and the whole tutorial through bash, has to print the lines the
page shows beneath it. A page that shows commands and runs none of them fails as well, unless
each of those commands ends in a comment marking it as an illustration, so that no page can
quietly leave the harness by naming files the examples do not ship. It is the stronger of the
two documentation guards - a claim about what the tool prints is checked against what it
prints - so a reworded diagnostic fails there first. What it does not run is counted rather
than left to be discovered: a page that runs one of its commands has the rest read as
illustrations, and SILENTLY_SHOWN in that file records how many such commands each page
has - 51 of the 173 shown, and five of the eighty-seven runs pinning an exit status. A page
that gains one fails until somebody writes the new number down.
The ELF fixtures
ddd tool from-elf is tested against ten images of one C source - little and big endian, 32
and 64 bit, gcc and clang, DWARF 2 to 5, compressed debug sections and not, a static PIE - which
tests/fixtures/elf/ holds beside a manifest of what each image’s own toolchain says about its
target: byte order, the signedness of char, the sizes of long, long double and an
enum, the alignment of a uint64_t, and the section and size of every symbol - and what
readelf says of each image as a file: its ELF type, the versions of its DWARF and whether
its debug sections are compressed. The reader’s tests hold it to the manifest, never to itself.
examples/firmware/firmware.elf is a copy of the Cortex-M4 image, for the transcripts of
The toolbox. Beside the ten rows sit the negative inputs, each built by x86_64 gcc from a
small source of its own: DWARF type units at versions 4 and 5, gcc’s link-time optimisation,
and a variable --gc-sections discarded - the images the reader refuses, or reads a way of
its own.
The images are committed, so the suite needs neither Docker nor a compiler. They are built in
Docker, out of tests/fixtures/elf/src/, by docker/build_elf_fixtures.py in the image of
docker/elf-fixtures.Dockerfile, a Debian image pinned by digest. Its packages are not
pinned, since a pinned version stops the build once a point release of Debian replaces it;
the manifest records the compiler that built each image instead:
$ docker compose run --rm elf-fixtures # rebuilds every image and the manifest
The manifest holds a hash of every file the images are built from, and
tests/test_elf_fixtures.py fails, naming that command, when one of them changed without a
rebuild. Commit what the rebuild writes.
Running the checks
python -m pytest # the suite, the coverage gate and the documentation checks
python -m pytest --no-cov # quicker, while working on a single test
python -m pytest --co --no-cov # what would run, without the gate weighing a run of none
python -m ruff check .
python -m ruff format .
python -m mypy
mypy runs in strict mode over src/ddd and tools/ with the pydantic plugin;
ruff lints the sources, the tests, the release machinery and the documentation
configuration with a line length of 100. The whole suite takes a couple of minutes in a warm
checkout - longer in a fresh environment, where tests/test_cmake.py configures and builds
the module - so --no-cov and a -k are what a single test is worth running under, and
the whole of it is what a commit is worth running under. Not -q: the addopts in
pyproject.toml carry one already, pytest counts them, and a second drops the summary - the
“N passed” line and the coverage total both - leaving the exit code as the only statement of
what happened. And --no-cov beside --co: coverage is in the addopts too, so a
collection-only run measures a run of nothing and prints Required test coverage of 100% not
reached before exiting 0, which reads as a failure and is not one.
Nothing in the suite skips, and a test in tests/test_documentation.py holds it to that:
no pytest.skip, skipif, importorskip or xfail anywhere under tests/. A
test that skips reports success without having run, so what it covers is covered on somebody
else’s machine and nowhere else. Two places used to do it. Validating the examples against
the committed schemas needed jsonschema, and skipped everywhere except on the machine of
whoever happened to have it installed; it is a development dependency instead. A case about a
second spelling of an output directory made a directory junction, which is a windows feature,
and skipped on the ubuntu cells of the matrix - so the page said every cell ran everything
while a third of them ran that one nowhere. A platform makes the spelling of such a path
differ, not the behaviour under test, so tests/conftest.py offers directory_link: a
junction on Windows, a symbolic link elsewhere, and a path whose resolve() is another path
on both.
The same file carries the positive controls under the guards that read the pages with a regex. A guard looping over what a pattern found passes when it found nothing, which is how the count of the checks whose severity is fixed went stale: the sentence it counts was reworded, the pattern stopped matching, and the suite stayed green. So each such guard has a test beside it asserting that the pattern still recognises something - and the two transcript tests, which are parametrized over sets computed at import, have one too, because pytest answers a parametrize over nothing with a skip rather than a failure.
The repository also ships a small linux image, which is what the generated c code is actually compiled with - a generator whose output no compiler has ever accepted is a generator with no evidence behind it. Run it from a WSL shell, where docker speaks linux containers:
docker compose run --rm test # pytest with the coverage gate
docker compose run --rm coverage # same, plus build/htmlcov/index.html
docker compose run --rm lint # ruff check, ruff format --check and mypy
docker compose run --rm compile # generate, compile, link and verify the symbols
docker compose run --rm cmake # build examples/cmake through cmake/Ddd.cmake
docker compose run --rm docs # build this documentation
The image serves ddd gui too: an earlier stage of docker/Dockerfile compiles the pages,
and only the pages reach the image, installed with the package - no node. A service runs the
working tree, though, and with it the pages compiled there, if any - except gui, which clears
PYTHONPATH to run the image’s own code and pages instead, over the checkout’s project files
still: an edit made in the browser writes back into the working tree, the file keeping its owner
and permissions although the service runs as root like every other. docker compose up gui
builds the image first, since the code it serves is the image’s, and starts it listening beyond
the container’s loopback and publishes the same port number on the
host’s loopback, -p 127.0.0.1:8123:8123 - a different one would misdirect the Host header
ddd gui checks - so the address it prints opens in a browser there.
gui-screenshots runs beside it: docker compose run --rm gui-screenshots photographs
every story of gui/ and compares it with its reference image, and UPDATE=1 docker
compose run --rm gui-screenshots writes the references afresh. Playwright’s own Linux
image, of the version gui/package.json pins, is the one place those images are made,
since Windows and Linux draw text differently. Node lives in that image, so the machine
running this needs none; its packages go into a volume of their own rather than the
checkout, and what the run writes into the checkout - the references, Ladle’s build and
Playwright’s results - is handed back to whoever owns gui/, since this service too runs
as root.
The compile service is the one that keeps the c backend honest. It generates the demo
project, writes one translation unit per generated header that includes it twice - which
proves that every header is self contained and that its include guard works - compiles
everything with -std=c11 -Wall -Wextra -Wpedantic -Werror -Wconversion -Wshadow
-Wcast-qual -Wstrict-prototypes, links all objects into one binary, and finally compares
nm against ddd dump --format json so that every variable DDD promised is defined
exactly once and nothing else is. The last four steps run twice, once plain and once with
the conditional declarations enabled, so both states of a #if guarded variable are
covered. It renders the example templates, which is what makes them evidence rather than a
sketch: the set a project starts from is the set a compiler has accepted. TEMPLATES points
the service at any other directory, so a project can put its own templates through the same
treatment.
Continuous integration
.github/workflows/ci.yml runs the commands above - the suite with its coverage gate,
ruff twice and mypy - on every push to master and every pull request, in two jobs,
and five more the commands above do not cover. extension installs node and the package,
runs npm ci, npm test and npm run package in editors/vscode, and uploads the
.vsix it produced. Its tests start a real language server, which is why it installs the
python package as well as compiling typescript, and packaging the extension there proves that
the artefact a customer is handed can be produced at all.
gui builds and tests the browser interface on ubuntu and windows: it installs the package and
node, generates the TypeScript types from ddd schema, runs Biome, the type check and Vitest
with its coverage gate, compiles the pages, and drives them in Chromium against a real
ddd gui with Playwright. On ubuntu it then builds the wheel and the sdist with the pages it
compiled, checks that the wheel carries them the way the release build checks its own, and
uploads both as ddd-tool-<commit>: what installs a branch without node. The release build
still compiles the pages again, into the wheel it publishes - an artifact expires, and needs a
GitHub account to reach.
container builds the image behind docker compose, checks that the package installed in
it carries the pages of ddd gui and that neither node nor npm reached it, and runs the
generate service in it. Nothing built the image for a long time, and it is the local
equivalent of every other job here: a COPY of a directory removed three releases earlier
failed the build on its first line, and every service with it, while ci stayed green - ci
installs the package itself and never came near the image.
The service run after it is the other half of what broke then: the image built, and the
service exited with a usage error from an option set two releases old. The five other services
are not run here; what they exercise is either covered by a job above or, for compile, the
run a contributor does locally. The pages are asked for with python -I, which leaves out
the working tree a service puts first on the path: a clean checkout has no compiled pages, so
without it the check would read the checkout rather than the image.
dev-build and dev-publish put a development build of the last commit of every push to
master, and of every push to this repository’s own pull requests, on TestPyPI, as
Development builds below describes.
The suite runs across a matrix of ubuntu and windows on python 3.12, 3.13 and 3.14, which is
the six combinations the classifiers in pyproject.toml advertise. That is not thoroughness
for its own sake: a path handling defect that only appeared on linux has already reached a
user of this project, having passed the whole suite on windows first. A test in
tests/test_documentation.py keeps the matrix and those classifiers in agreement, so
advertising a new interpreter without testing it fails.
The two python jobs install the project with pip install -e ".[dev]" rather than running
it out of src - the extension job installs pip install -e ., since what it needs is a
ddd on the path to launch. That is deliberate too, and it is the cheapest check in the
file: it exercises the packaging metadata, which the tests themselves never touch, so a
dependency list that no longer builds fails here rather than for whoever installs the
distribution.
Style and types are checked once rather than per platform, since neither varies by platform.
publish.yml runs the suite again before it builds a release, which is not redundant: a
release can be cut from a commit this workflow never saw, and an upload to an index is
permanent.
Nothing in the toolchain moves on its own. Every uses: is pinned by a major tag, the
extension’s and the browser interface’s lock files pin their dependencies exactly, and
ruff and mypy are capped to a minor in requirements-dev.txt - those two are gates
rather than libraries, so a release of either fails the lint job on the day it is published
rather than on the day somebody upgrades it. What proposes the moves instead is
.github/dependabot.yml, weekly, for the actions, the requirements files, the extension and
the browser interface: a bump then arrives as a pull request that ci has already run, which is
the difference between upgrading a tool and discovering on a release day that one has moved on
without you. A test holds every action to one version across the three workflows, and both
caps to being caps.
Development builds
The last commit of every push to master, and of every push to one of this repository’s own
pull requests, is published to TestPyPI as a
development build, so that such a commit installs with pip install - its compiled pages
included, and no Node.js anywhere - without the GitHub account an artifact needs, and after the
artifact has expired; TestPyPI is itself pruned now and then, though, so a development build is
no archive. A commit that was not the last of its push has no build of its own. A run in which
any other job fails publishes nothing, and neither does one that a newer push cancels before its
upload has begun; a dev-publish that fails once its upload has begun leaves on TestPyPI
whatever it had uploaded.
dev-build waits for every other job of the run - test, lint, container,
extension and gui - so that a commit any of them fails is never a build anybody is
pointed at; a test holds a job added to ci.yml to joining the list. It checks the commit
out - a pull request’s own head, not the merge gui tests it as - and fails at once, with an
error saying to merge master into the branch, when that head has no
tools/dev_version.py: a pull request whose branch was cut before development builds
existed. It then stamps the development version into the checkout, compiles the pages and
builds the wheel and the sdist as the release build does, type check included, and checks that
the wheel carries the pages. dev-publish uploads what it built, and writes the run’s
summary. They are two jobs for the reason publish.yml builds a release in one job and
uploads it in another: npm ci runs the install scripts of every package the pages depend on,
and a build runs whatever its backend is, so none of that runs in the job that can ask for a
token to publish with. That job checks nothing out, and takes nothing it is handed on trust:
the version has to be this run’s; dist/ has to hold that version’s wheel and sdist and
nothing else - a file left there for a later run’s version would otherwise be uploaded under it,
and that run’s own upload refused; and the wheel has to name this run’s commit and declare
requirements an install line can print as they are. All of it is checked before the upload.
The version is the next patch, as a development release numbered by the run. After 0.10.0,
run 57 publishes 0.10.1.dev57. The commit cannot be part of the version: PEP 440 refuses
0.10.0-<sha>, and PyPI and TestPyPI both refuse a local label such as 0.10.0+g<sha>. The
build’s metadata carries it instead, as the Commit link of the project, and the run’s
summary maps the version to it. The base is the next patch because 0.10.0.dev57 would sort
before 0.10.0 itself, beneath the release every such commit came after. The run number grows
across every branch, so every run publishes a version of its own; a re-run keeps its number,
and turns skip-existing on. That is on for any second attempt, not only one where
dev-publish itself had already failed: a first attempt that fails in a job dev-build
waits for never reaches dev-publish at all, and the re-run that finally does reach it is on
skip-existing as well - so it passes over a file already there under that version even
though it is trying to upload it for the first time. A first attempt skips nothing: a file
already on TestPyPI under its version came from another run, and the upload fails on it rather
than passing over it with a summary naming somebody else’s build. So a first attempt whose
upload is refused because the file exists means investigate before re-running - not with
Re-run failed jobs, not with Re-run all jobs, and not by re-running dev-publish alone:
any second attempt would pass over the file and write a summary naming it as its own build.
Find out why the number was reused instead.
Renaming ci.yml is one way: it restarts the run numbers - and its registration below names
the file - so its uploads are refused until the numbers, or the next release, move past the
versions already published.
tools/dev_version.py writes that version into the job’s own checkout, and nothing it
rewrites is committed. It writes it into the three places the installed package compares at
run time: pyproject.toml; src/ddd/__init__.py, so that ddd --version prints ddd
0.10.1.dev57, in the format it always has; and cmake/Ddd.cmake, whose
DDD_MODULE_VERSION the module compares with that output by exact string, refusing a tool of
any other release. Nothing compares the other spellings, listed under
Publishing a release, at run time: the extension’s manifest and lock file reach no
installed package, and the rest is prose - the README’s included, which the wheel carries as
its description. The script refuses a version with no next patch, and a release candidate is
one: a run whose commit states a candidate in __version__ fails dev-build - every run of
master for as long as the candidate stands there - while a pull request branched before it
still builds. tests/test_documentation.py holds the script to all of this, and runs the
checks dev-publish makes.
The run’s summary gives the two commands that install the build, for instance:
pip install "jinja2<4,>=3.1" "pydantic<3,>=2.7"
pip install --no-deps --index-url https://test.pypi.org/simple/ ddd-tool==0.10.1.dev57
The first installs the runtime dependencies from PyPI; the second installs ddd-tool alone,
from TestPyPI. They are two on purpose: given both indexes at once, pip takes each name’s
highest version from either, and anybody can upload a lookalike to TestPyPI. dev-publish
writes both, and nothing of it is taken from dev-build unchecked: an output of a job can span
lines, and the build could have handed over one that closes the code fence and prints an
install line of its own. The first line is read off the Requires-Dist of the checked
wheel, by the runner’s own python reading the workflow’s own lines, and held to quoted
requirements of letters, digits and the signs a specifier needs - so a runtime requirement with
an environment marker, whose strings need quotes, would be refused until the pattern is widened
for it. The second is written from the version the job checked.
The publisher is a third registration. The upload authenticates with trusted publishing,
as a release does, so TestPyPI has to know this workflow as well as publish.yml. Register,
on test.pypi.org, project ddd-tool, owner Sauci, repository ddd, workflow
ci.yml and environment testpypi-dev, which GitHub creates the first time the job names
it. Leave that environment without a deployment branch policy, since a pull request’s run
publishes from it as well as a push to master. Until the registration exists the upload
fails with invalid-publisher and every job before it passes; once it does, re-running the
run publishes it, as it does for a release.
Only this repository’s runs publish. A pull request from a fork is given no token to publish
with, and a fork’s own push to its master has no publisher, so both skip the two jobs rather
than failing at the upload; a dispatch publishes nothing either. Within the repository,
testpypi-dev is anybody’s who can push a branch, as every environment here is:
Publishing a release says what that means for pypi.org, and what closes it.
Building this documentation
pip install -e ".[docs]"
sphinx-build -b html docs build/docs/html -W --keep-going
Warnings are errors, which matters more here than in most projects: the reference sections
are generated from the sources - autoprogram renders the command line from the argument
parser itself, autodoc_pydantic renders the file formats from the contracts - so a
renamed option or a changed field cannot leave its documentation behind, and a reference
that no longer resolves fails the build instead of quietly disappearing from the page.
Two programs have to be on the path as well, and each fails visibly rather than silently
dropping a figure: dot from graphviz draws the entity relationship diagram of every model
on the file format pages, and plantuml draws the .. uml::
diagrams. Without a plantuml installation, docs/conf.py still names one, so the build
reports a warning per diagram - which under -W is a failure. Both are apt packages, and
both are in the image behind docker compose run --rm docs, which carries the python
requirements above as well and is the way to build the documentation without installing any
of it.
Publishing this documentation
.github/workflows/docs.yml builds the html and publishes it to
GitHub Pages. It runs on every pull request, and deploys on
two events: a push to master, and a published release. The deployment is a push to the
gh-pages branch with the workflow’s own token, and Pages serves that branch directly;
nothing else authenticates, and no deployment environment is involved.
The site keeps one directory per version, and a menu in the bottom left corner of every page switches between them:
Directory |
Written by |
Rewritten |
|---|---|---|
|
a push to |
every push |
|
publishing that release |
never |
Old documentation describes old code, so nothing rebuilds a released version. That is not only a saving: a tag from two years ago would have to keep building under the sphinx of the day, and the run that failed would be the one publishing the current release.
A build produces one version and the site is all of them, so the versions that are not being
built have to come from somewhere: the gh-pages branch holds them. The branch is the site
rather than an archive of it - Pages is configured to serve it, so the push at the end of the
job is the publish, and there is no second copy to disagree with. It used to be one of two
publishes, the branch and an artifact handed to deploy-pages, and the two parted twice
without a red step anywhere; the run now ends by reading the page it published back from the
site, and fails if it is not served within five minutes. The branch is created by the first
deployment; there is nothing to set up in the repository.
The menu cannot be baked into a page at build time, or a version released today would be
missing from the menu of every page built before it - which is the menu somebody reading an
old page is looking at. So docs/_templates/versions.html renders an empty menu and
docs/_static/js/versions.js fills it in on load from versions.json at the root of the
site, which the workflow rewrites from what is on disk on every deployment. A build with no
such file above it - a local one, a pull request one - shows no menu at all, which is honest:
there is nothing to switch to.
The root of the site redirects to the newest release rather than to latest. Somebody
arriving without a version in the url wants the documentation of what they can install, not
master’s account of features that are not released yet - and not a release candidate
either: the newest release is the newest tag whose version carries nothing after the
numbers, so v0.10.0rc1 is published, listed in the menu under its own version, and left
out of that choice until v0.10.0 follows it. Before the first release there is nothing
else to land on, so the root points at latest.
That rule is tools/site_versions.py, which the deploy job runs, rather than a heredoc
inside the workflow: tests/test_documentation.py pins the orderings it produces - the
candidate, the release it leads to, and a hotfix on the older line published after it - which
is what nothing could do while it was a workflow step.
The workflow installs graphviz and plantuml from apt, so publishing needs nothing but a stock
runner: there is no prepared image to keep in step with the sources. Only html is built. A pdf
would want a LaTeX distribution, roughly a gigabyte of packages, and nothing asks for one -
docs/conf.py still carries the LaTeX settings, so sphinx-build -M latexpdf docs output
produces one for whoever does.
Two things are worth knowing before the first run.
Pages has to be pointed at the branch by hand, once. In Settings → Pages, set
Source to Deploy from a branch and choose gh-pages at its root. The workflow pushes
the branch whether or not anything serves it, so with the setting missing the push succeeds
and the Read back what was published step fails five minutes later, naming that setting.
There is nothing to change in the repository to fix it: correct the setting and re-run.
A pull request builds but never deploys. The deploy job names the branch and the release event it publishes, rather than resting on the event alone, because a pull request from a fork proposes arbitrary content: without that condition, opening one would be enough to publish somebody else’s revision as the product’s documentation.
Publishing a release
.github/workflows/publish.yml builds, checks and uploads the distribution. It never
holds an API token: the upload authenticates with trusted publishing, where GitHub mints a short lived OIDC token
and the index decides whether the claims in it match a publisher somebody registered.
Two things follow from that, and both have bitten this project.
The two indices are separate registrations. TestPyPI and PyPI are different services with
different accounts and different publisher configurations. A publisher registered on
pypi.org has no effect whatsoever on test.pypi.org, even though the pages look
identical. The workflow uploads to whichever the job names, so each needs its own:
field |
|
|
|---|---|---|
PyPI project name |
|
|
owner |
|
|
repository |
|
|
workflow name |
|
|
environment name |
|
|
The environment name is the field most easily got wrong, because it is the GitHub
deployment environment of the job rather than anything about the index: the job publishing
to TestPyPI declares environment: name: testpypi, so the registration has to say
testpypi too.
Until the project exists on an index, its registration is a pending publisher, which is also what creates the project on first upload. Note what the page itself warns: a pending publisher does not reserve the name, so anybody may take it first. Once the project exists the registration becomes an ordinary publisher, and a project that already exists needs the publisher configured on the project rather than as a pending one.
The configuration is read at upload time, not at commit time. A run that failed with
invalid-publisher will succeed on a plain re-run once the registration is corrected -
there is nothing to change in the repository and no new commit to push.
A workflow_dispatch run with target: testpypi is the dry run; publishing to PyPI
happens on a published GitHub release tagged v<version>, and the build refuses to go on
unless that tag is exactly v followed by the version in pyproject.toml. The prefix is
checked rather than stripped, because the documentation site publishes a release under a
directory named after its tag and lists only the ones beginning with v.
A dispatch with target: pypi runs only from a v* tag, and is checked against
pyproject.toml there exactly as a release is. On any other ref the job is skipped: the
same run started on a branch would have built whatever that branch’s pyproject.toml said
and uploaded it under no tag, with no .vsix and no documentation directory - and an index
accepts a file name once and for ever, so the only way back is the next version number.
The version is spelled in ten files, and a test holds five of them together.
src/ddd/__init__.py is where it lives: docs/conf.py imports __version__ rather than
restating it, and the banner of every generated file carries it from there. pyproject.toml,
which the release tag is checked against, and editors/vscode/package.json, which the
extension is packaged with, repeat it, and a test each asserts that they agree with
__version__. editors/vscode/package-lock.json records it twice more - in its own header
and in the entry for the root package - and a test now asserts that both agree with
__version__ as well. Nothing else would: what npm ci compares with the manifest is the
dependencies, not the root package’s own version, so a bump that edits only the manifest
packaged a .vsix whose lock file still said the version before. cmake/Ddd.cmake states
it as DDD_MODULE_VERSION, because the module refuses a ddd of another release and so
has to know its own - a release that bumps the package and not the module refuses itself - and
a test asserts that too. The other five files spell it out as text and nothing
derives it for them: the wheel file name in README.md and in Getting started, the two
ddd --version transcripts of that page - only the first of which the transcript test re-runs,
since the second carries a trailing comment and is shown rather than run - and the banner of a
generated file quoted in Getting started, Generated artefacts,
FAQ and Templates. Bumping the version means walking all ten in the release
commit.
The publishing jobs name a deployment environment - pypi and testpypi in
publish.yml, testpypi-dev in ci.yml. As this repository stands, none carries a
deployment branch policy and none has a protection rule, so nothing in the settings decides
which ref may publish: the workflow does, and a workflow is whatever the ref it runs from says.
A pull request from one of this repository’s branches runs that branch’s own ci.yml, and a
dispatch runs the dispatched branch’s own publish.yml - where the condition keeping
publish-pypi to a release or a v* tag lives, so a branch that edits it out publishes to
pypi.org. Anybody who can push a branch here can therefore publish to TestPyPI through
testpypi-dev or testpypi, and to pypi.org through pypi. The development builds add no
route to pypi.org: the token ci.yml is given names ci.yml, and the publisher on
pypi.org names publish.yml and pypi alone. What stands between a pushed branch and
pypi.org is the pypi environment’s protection, and today there is none.
That protection is the maintainer’s to set, in Settings → Environments → pypi: a
required reviewer, who approves every deployment to it, or a deployment rule limited to v*
tags, under Selected branches and tags. Against somebody who can push a branch it is the only
lock, not a second one - the workflow’s condition is theirs to edit. A tag rule stops a branch
but not a pushed tag, unless a tag ruleset also restricts who may create v* tags; a required
reviewer stops both. An environment restricted to the default branch instead is the whole of
what a release is not - a release runs from its tag - and rejects the release after a green
build, with not allowed to deploy … due to environment protection rules.
Delivering the editor extension
The same workflow packages editors/vscode and attaches the ddd-<version>.vsix to the
release. That asset is the whole of how the extension is delivered: a permanent url needing
no account and no network policy exception, installed with code --install-extension
ddd-<version>.vsix or through Install from VSIX… in the Extensions view.
The job runs after the tag check rather than beside it, because it names the file it uploads after the tag; a test pins that ordering.
It is deliberately not published to the Visual Studio Marketplace. A step that would have
done so existed until 0.6.0 and never once worked: the marketplace has no equivalent of
trusted publishing, so it needed a personal access token from an Azure DevOps organisation
owning a sauci publisher, and neither the organisation nor the publisher was ever
created. It failed on every release it ran on while the rest of the pipeline reported success
around it, and four pages meanwhile told a customer to search the Extensions view for an item
that answered 404.
Publishing there again is a decision with a prerequisite, not a step to restore. The
publisher is created once at https://marketplace.visualstudio.com/manage against an Azure
DevOps organisation; its id has to be the publisher field of
editors/vscode/package.json, which is what makes the extension sauci.ddd. The token
is a personal access token scoped Marketplace → Manage, issued against All accessible
organizations rather than a single one - a token scoped to one authenticates and is then
refused when it publishes, which reads as a wrong password rather than a wrong scope. It
expires within a year, and the failure lands on a release that has already uploaded to PyPI.
Whoever takes that on puts the install instructions back on the four pages at the same time;
a test refuses the two halves separately.
The browser interface
ddd gui serves pages compiled from gui/, a Vite project in TypeScript and React, into
src/ddd/gui/static/. git ignores the compiled pages, and Node.js is needed where they are
compiled, never where ddd is installed. The release build compiles them before it builds the
wheel, which then carries them. The gui job of ci does the same and uploads the wheel as
ddd-tool-<commit>, so every branch ci runs on installs without Node.js as well, and
dev-build does it again for the development build of the last
commit of a push to master or to a pull request, which TestPyPI serves to anybody. The image
behind docker compose compiles them in a build stage of its own, thrown away with its
Node.js: the image carries the pages and no Node.js. A source checkout needs Node.js 24 to build
them, with the package installed so that its types can be generated:
cd gui
npm ci
npm run schemas # TypeScript types, from ddd schema all
npm run build # the pages, and third-party-licenses.txt beside them
npm run watch # rebuilds on every change; reload the page ddd gui serves
React Aria Components is the widget library the screens are built from, wrapped once in
gui/src/ui/ - Button, ComboBox and Table over its own primitives, Chip, Banner and
LinkTabs beside them, and Panel around a Button - under the tokens of
gui/src/styles/tokens.css and the rules of gui/src/styles/ui.css, and a screen takes
none of its widgets from anywhere else. React Aria’s press handling injects a <style>
element that the page’s Content-Security-Policy forbids, so gui/public/pressable.css,
linked from gui/index.html under the id react-aria-pressable-style, stands in for it;
a React Aria upgrade must check the rule still matches the one React Aria would inject.
npm run ladle serves every story with hot reloading, on mock data and without ddd
gui; a story imports nothing from Ladle itself, because it ships raw .tsx sources that
fail this project’s type check, and skipLibCheck does not skip a .tsx. Every story is
then photographed and compared with its reference image by npm run screenshots: only in
Playwright’s Linux image, since Windows and Linux draw text differently, through docker
compose run --rm gui-screenshots on a developer’s machine or ci’s own job of that name.
UPDATE=1 docker compose run --rm gui-screenshots writes the references afresh, and they
are made nowhere else.
npm run lint and npm run typecheck are the frontend’s ruff and mypy, and npm test
runs Vitest with a 100 % gate over the modules that hold logic - src/api, src/lib and
src/state. The screens are covered by npm run e2e: Playwright drives the real
ddd gui over a copy of examples/demo, over copies of examples/vocabulary and
examples/structures that eight of the spec files serve instead, and over a project it
generates and serves the same way - gui/e2e/fixtures.ts’s own vocabularyGui,
structuresGui and generatedGui - each started with the interpreter DDD_PYTHON
names, and PLAYWRIGHT_CHANNEL=msedge drives the installed Edge on a machine without
Playwright’s own Chromium. The build refuses a bundled package whose licence is not
MIT, ISC, Apache-2.0, BSD-2-Clause, BSD-3-Clause or 0BSD. The project screen’s canvas is drawn
with @xyflow/react and laid out with @dagrejs/dagre, both MIT like every other bundled
package.
.venv/bin/python tools/generate_project.py DIRECTORY --declarations N writes a project of
N declarations, rounded down to even components and refused under 120, to measure ddd
gui on - large enough to matter in a way nothing under examples/ is, the same bytes for
the same arguments on every machine. --shape chooses many small components (the
default), large thirty components sharing every declaration, or mixed half of each.
Every output takes an id and a unit its vocabulary lists, and every input reads one stating what
its producer states, so a project generated with no further option carries no finding at all.
--missing-ids gives that fraction of outputs no id instead, a missing-id each; --unread
turns that fraction of the inputs into outputs of their own instead, each leaving two outputs
unread - itself, and the one it would have read - an unused-output each: the findings a
project half-way through a migration carries, at the density asked for.
--missing-ids 1 --unread 0.5 is what “findings-heavy” means wherever
docs/superpowers/plans/2026-09-30-gui-large-projects.md or the pull request say it, as
opposed to the clean project the defaults make.
The benchmark measuring ddd gui on what it makes is in two halves, run by hand and never in
continuous integration - both are slow, and the machine’s own, so a shared or variably loaded
runner could not answer either honestly. .venv/bin/python tools/bench_gui.py PROJECT...
times the server half in process, one Session and one
Api over each project named in turn, in this order: opening it; six of its
plain GET endpoints - /api/state, /graph, /units, /types, /shared and
/files; the Findings tab’s first page and one component’s own findings; a variable’s panel;
a unit’s panel; judging a removal on the Files tab; one analysis; an edit answered and then
analysed; and, once that analysis has landed, planning the rename of the unit stated in the most
files. Then, over a second session polling once a second, as ddd gui’s own does, three
requests each asked halfway through an analysis - by the run’s own measure of one - to be
answered while it runs: /api/state, the plan of that edit’s own change (GET /api/settle,
what a variable’s panel asks while a reader picks a unit), and the edit itself. One answered
once its analysis had ended is no figure of that, and its row says so in the figure’s place,
the run going on to the next measure. The page half needs the pages built first, and a channel
naming an installed browser: cd gui && npm run build && PLAYWRIGHT_CHANNEL=chrome npm run
bench (playwright test -c playwright.bench.config.ts) times it the same way a reader would
meet it, against a running ddd gui - DDD_BENCH_PROJECT naming the project.ddd.json
to start it over and DDD_PYTHON the interpreter to start it with - timing the page answering
after opening, the first analysed screen and, apart, the layout worker’s own laying out of its
graph, each tab’s own first drawing but the Compare tab’s, typing in a panel, scrolling a long
table, and an Apply until its own change shows and until the findings are current: the heading
no longer saying they are updating, and the edit’s own finding drawn in its component’s list.
Each of those last two starts timing only once that heading already says the page is up to
date, not while it may still be catching up on an earlier measure’s own analysis, and each is
followed by an Undo that puts the edit back and waits for the same heading before it returns,
so the project it leaves behind is analysed, not merely restored, for whichever measure runs
next. Every measure’s own row is printed and, where DDD_BENCH_OUT names a file, appended to it
too. The server half is checked by a smoke test on a small generated project
(tests/test_bench_gui.py); nothing checks the page half beyond gui’s own type checking
and linting - nothing runs gui/bench/page.bench.ts outside npm run bench itself. The
generator both halves start from is checked on its own (tests/test_generate_project.py). The
figures either half takes on the sizes and shapes it makes are not kept here, but in
docs/superpowers/plans/2026-09-30-gui-large-projects.md’s own Figures section, and the
pull request that carried them.
A change to the project’s units - a rename, an addition, a description, a removal, an
adoption - is planned once, in ddd.lsp.units: the operations ddd.editing takes, on
json pointers, file by file, or a refusal naming the file it concerns. The language server
renders a plan as the text edits of F2 on a unit and of its two quick fixes on
unknown-unit; ddd gui previews the same plan and posts its changes, unchanged, to
POST /api/edit. Neither client decides on its own what a rename or an adoption does, so
the two can never disagree about it - as ddd.lsp.edits.settle already does for a
variable’s keys, one rule the language server’s reconcile quick fix and ddd gui’s
variable panel both read.
What a key of a variable offers the panel is worked out in ddd.variable_keys: per key,
and per declaration, whether that declaration’s kind has the key at all and whether it
must state it - ddd.models.definition_keys, plus the storage keys a declaration cannot
be left without - beside the values in play, one entry per value however its file spells
it, the components stating it and whether one of them produces the variable. It says which
field chooses the key and what that field names: the eleven datatypes, the project’s
types, its declared constants, or its axes and measurements, which the navigation index
records as it reads each declaration’s kind. GET /api/variable answers those beside
the declarations themselves; GET /api/settle settles any of the twelve keys of
ddd.lsp.edits.PROPAGATED_KEYS and is the same endpoint the unit has always used.
ddd.value_identity.same_value decides when two spellings are one value - a conversion
through the models and an enum by its name, limits as the numbers they resolve to,
everything else its canonical json text, anything the models refuse outright falling back to
that text - so the panel’s values in play, definition-mismatch and what
GET /api/settle previews can never disagree about one.
Where a finding leads is answered once, in ddd.finding_routes, from the finding’s own
check, file and pointer, together with the file’s own kind and whether it loaded: the
variable a declaration is about, the unit a check such as unknown-unit names, the type
an entry declares, or the component the finding is filed on - and nothing for a finding
with nowhere to go, a file that did not load or one of a kind the page has no screen for
among them. GET /api/findings, GET /api/variable, GET /api/unit and GET
/api/type all carry that answer on every finding they list. ddd.finding_fixes plans
the one fix a finding has nowhere else to offer: a missing-id finding’s identity, from
the same walk ddd.identity.unstamped gives ddd id and the language server’s own
quick fix, written as the operations POST /api/edit takes rather than as a text edit;
GET /api/fix previews it, as GET /api/settle previews a settlement.
ddd.lsp.edits.settle itself now compares what a value means rather than its own text,
the same ddd.value_identity.same_value the panel’s values in play already read - which
is why ddd gui no longer narrows a settlement of its own: ddd.variables.narrowed is
gone, the rule read from ddd.lsp.edits.settle alone now.
The types a project declares are read by ddd.project_types and changed by
ddd.type_plans, the pair ddd.project_units and ddd.lsp.units already make for
units. Neither indexes anything new: ddd.lsp.navigation.Index records every type’s
entry, every declaration and member naming one, and every c identifier the project has
spent, and what a type says is read from the document at its entry. A rename is the
editor’s rename - rename_sites says which strings it rewrites and rename_problem
why a name may not be used, so the tab and the editor cannot disagree - planned as
edit-engine operations rather than the text edits rename_edits answers, the way the
identity fix does. GET /api/types lists them, GET /api/type answers one with its
uses and its members, and GET /api/type-plan previews a change of either kind as
PlanReply, which the page applies through POST /api/edit and the undo puts back.
ddd.declaration_plans sits beside ddd.type_plans and ddd.project_units: a
component may add a declaration of a variable the project already declares or of a new
object of one of the six kinds, and its three verbs take a file, a name and a scope to
read one; a file, a scope and a definition to declare one; and a file and a name to
remove one.
ddd.object_values sits beside ddd.declaration_plans and ddd.type_plans: what
one object’s grid shows - its kind, datatype, unit, conversion, resolved limits, shape,
values and the breakpoints of the axes it is laid against - and what changing one cell of
it takes: an edit-engine set at the element’s own pointer where the file already holds
an array, or of the whole init where it states one value for every element or none at
all, written into the file that produces it either way. set_values sits beside
set_cell for a whole table pasted at once. The shape is checked here too, because
the page that already checked it is not this function’s only caller, and every element
is weighed by the same _acceptable, a refusal naming up to five that fail - each
with its own reason, since a block can fail two checks at once - and, beyond that, how
many more, rather than stopping at the first. It always plans one
set of the whole init regardless of what the file held before - one line for a
curve, whose init sits on a line of its own, and one line per row for a map, whose
init is written a row to a line. A shape of more than two dimensions is refused
rather than drawn a dimension short, a grid being rows of cells and nothing deeper; and
a name that is not produced by exactly one declaration answers its grid read-only,
because the values and the owner are the analysis’s own
producer’s and Index.producers need not name that same declaration first.
The edit engine learnt one thing for adoption: a change whose fingerprint is null
creates its file, one set of the whole document at the root pointer, and is refused as
stale if the file exists by the time the edit is made. It is staged and renamed into
place like any other write, and taken away again if a later file of the same edit fails. The
session of ddd gui accepts such a change only beside the open project’s description, and
only when the same edit’s change to that description leaves the new file among its
includes; any other is refused as invalid before anything is written. The file it
creates takes the mode, and where the process may set them the owner and group, of the
project description beside it, so ddd gui running in a container leaves the developer a
file of their own rather than root’s.
Undo is the same engine read backwards. ddd.editing.apply_changes answers a
Written per file - the bytes it held before, None for a file the
edit created, and the fingerprint of the bytes it left - and ddd.editing.restore writes
those back through the same staging, taking away again a file the edit created and, when one
file of several cannot be written, writing the ones already put back forward to what the
edit had left them. ddd.editing.unchanged is what says an edit may still be put back: a
file whose bytes are no longer the ones it left is somebody else’s now, and the whole undo
is refused as stale rather than half of it applied. The session keeps one stack of
Undoable entries per open project, numbered as it pushes and capped at MAX_UNDO -
fifty - entries, each labelled by the screen that applied it; POST /api/edit takes that
label, because only the screen knows that the edit was “the rename of ‘rpm’ to ‘RPM’”.
GET /api/state carries Session.undoable, the top of the stack, so the control
appears without a request of its own, GET /api/undo previews it - the lines each file
would get back, and no operations, since an undo is bytes the server is holding rather than
an edit the page composes - and POST /api/undo calls Session.undo with the entry’s
own number, refusing one that is no longer the top so that two windows cannot put back each
other’s changes. The stack lives in the running server and nothing of it is written into the
project.