Compare commits
2
Commits
041961c8c6
...
17106f3370
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17106f3370 | ||
|
|
fec03099bc |
@@ -1,107 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(python3 -c ' *)",
|
|
||||||
"WebFetch(domain:docs.turso.tech)",
|
|
||||||
"Bash(awk '/^```/{n++} END{print \"fences:\",n,\\(n%2==0?\"balanced\":\"UNBALANCED\"\\)}' SPEC.md)",
|
|
||||||
"Bash(python3 -c \"import h5py; print\\('h5py', h5py.__version__\\)\")",
|
|
||||||
"Bash(python3 scripts/make_jellyfin_gallery.py --help)",
|
|
||||||
"Bash(timeout 30 python3 -c ' *)",
|
|
||||||
"Bash(ps -o pid,etime,cmd -C cmake)",
|
|
||||||
"Bash(cargo check *)",
|
|
||||||
"Bash(cargo test *)",
|
|
||||||
"Bash(cargo clippy *)",
|
|
||||||
"Bash(cargo deny *)",
|
|
||||||
"Bash(rustc --version)",
|
|
||||||
"Bash(cargo metadata *)",
|
|
||||||
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(d['packages'][0].get\\('license'\\)\\)\")",
|
|
||||||
"Bash(timeout 900 cargo install cargo-deny --locked)",
|
|
||||||
"Bash(timeout 1500 docker build -t jray-server:test .)",
|
|
||||||
"Bash(timeout 900 docker build -f /tmp/claude-1000/-home-dtourolle-Development-Jray-project/d861f764-2add-4f42-a069-0954ad1f9565/scratchpad/Dockerfile.probe -t jray-probe .)",
|
|
||||||
"Bash(docker rm *)",
|
|
||||||
"Bash(docker volume *)",
|
|
||||||
"Bash(timeout 900 docker build -t jray-server:test .)",
|
|
||||||
"Bash(docker run *)",
|
|
||||||
"Bash(xargs -I{} echo \"clippy: {}\")",
|
|
||||||
"Bash(timeout 900 docker build -q -t jray-server:test .)",
|
|
||||||
"Bash(awk '{s+=$4} END {print \"total: \" s \" passing\"}')",
|
|
||||||
"Bash(xargs -I{} echo \"clippy issues: {}\")",
|
|
||||||
"Bash(timeout 600 dotnet build Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj -v q --nologo)",
|
|
||||||
"Bash(awk '{s+=$4} END {print \"server tests: \" s \" passing\"}')",
|
|
||||||
"Bash(timeout 300 dotnet build Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj -v q --nologo)",
|
|
||||||
"Bash(cp SPEC.md /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/SPEC.md.bak)",
|
|
||||||
"Bash(perl -pi -e ' *)",
|
|
||||||
"Bash(grep -vE \"AR-|DP-|IR-|GR-|VR-|SR-|PR-|H[0-9]|[0-9]{3,}\")",
|
|
||||||
"Bash(git update-index *)",
|
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"Bash(git -C /home/dtourolle/Development/Jray-worktrees/audio-signature status --short)",
|
|
||||||
"Bash(git -C /home/dtourolle/Development/Jray-worktrees/audio-signature branch --show-current)",
|
|
||||||
"Bash(python3 -c \"import pytest; print\\(pytest.__version__\\)\")",
|
|
||||||
"Bash(python3 -c \"import numpy; print\\('numpy', numpy.__version__\\)\")",
|
|
||||||
"Read(//usr/lib/cmake/**)",
|
|
||||||
"Read(//usr/include/**)",
|
|
||||||
"Bash(grep -n \"^#\\\\{1,3\\\\} \" JRay-public-server/SPEC.md)",
|
|
||||||
"Bash(grep -n \"^#\\\\{1,3\\\\} \" scene-actor-extraction/docs/SPEC.md)",
|
|
||||||
"Bash(cargo build *)",
|
|
||||||
"Bash(python3 gen.py tone.wav)",
|
|
||||||
"Bash(python3 ref.py tone.wav)",
|
|
||||||
"Bash(python3 -c \"import json;d=json.load\\(open\\('package.json'\\)\\);print\\(json.dumps\\({k:v for k,v in d.get\\('scripts',{}\\).items\\(\\) if 'trace' in k.lower\\(\\)},indent=2\\)\\)\")",
|
|
||||||
"Bash(python3 scripts/traceability/extract_traces.py --format json)",
|
|
||||||
"Bash(python3 scripts/test_extract_traces.py)",
|
|
||||||
"Bash(python3 -m pytest scripts/traceability/test_extract_traces.py -q)",
|
|
||||||
"Bash(python3 scripts/traceability/extract_traces.py --format coverage)",
|
|
||||||
"Bash(python3 scripts/extract_traces.py)",
|
|
||||||
"Bash(python3 scripts/extract_traces.py --format json)",
|
|
||||||
"Bash(git -C /home/dtourolle/Development/Jray-worktrees/traceability-tooling log --oneline -3)",
|
|
||||||
"Bash(python3 /home/dtourolle/Development/Jray-worktrees/traceability-tooling/scripts/traceability/extract_traces.py --root /home/dtourolle/Development/Jray-project/JRay-public-server --requirements /home/dtourolle/Development/Jray-project/JRay-public-server/docs/requirements.md --system-spec /home/dtourolle/Development/Jray-project/SPEC.md --format coverage)",
|
|
||||||
"Bash(python3 scripts/traceability/test_extract_traces.py)",
|
|
||||||
"Bash(sh scripts/traceability/traceability-gate.sh)",
|
|
||||||
"Bash(python3 -c \"import pyflakes; print\\('pyflakes', pyflakes.__version__\\)\")",
|
|
||||||
"Bash(timeout 300 dotnet build -v q --nologo)",
|
|
||||||
"Bash(git check-ignore *)",
|
|
||||||
"Bash(rm -rf /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full)",
|
|
||||||
"Bash(cmake -S . -B /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full -DSAE_BUILD_TESTS=ON)",
|
|
||||||
"Bash(pkg-config --cflags opencv4)",
|
|
||||||
"Bash(pkg-config --cflags opencv)",
|
|
||||||
"Bash(awk '{s+=$4} END {print \"tests: \" s}')",
|
|
||||||
"Bash(python3 -m pyflakes scripts/validation/min_face_size.py)",
|
|
||||||
"Bash(python3 -m flake8 --select=F scripts/validation/min_face_size.py)",
|
|
||||||
"Bash(git -c user.name=\"Duncan Tourolle\" -c user.email=\"duncan@tourolle.paris\" commit -q -F -)",
|
|
||||||
"Bash(cmake -S . -B /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full -DSAE_BUILD_TESTS=ON -DSAE_GEMM_BACKEND=CPU)",
|
|
||||||
"Bash(python3 -c \"import h5py, numpy, requests, PIL; print\\('deps ok'\\)\")",
|
|
||||||
"Bash(python3 -m py_compile scripts/validation/min_face_size.py)",
|
|
||||||
"Bash(git remote *)",
|
|
||||||
"Bash(python3 et.py --root /home/dtourolle/Development/Jray-project/jRay --requirements /home/dtourolle/Development/Jray-project/jRay/docs/requirements.md --system-spec /home/dtourolle/Development/Jray-project/SPEC.md --format coverage)",
|
|
||||||
"Bash(git -C /home/dtourolle/Development/Jray-worktrees/traceability-tooling status --short)",
|
|
||||||
"Bash(git -C /home/dtourolle/Development/Jray-worktrees/traceability-tooling log --oneline -2 -- scripts/traceability/)",
|
|
||||||
"Bash(timeout 240 python3 scripts/stamp_gallery.py --gallery /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/g_plain.h5 --show)",
|
|
||||||
"Bash(cp /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/g_plain.h5 /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/g_mig.h5)",
|
|
||||||
"Bash(python3 scripts/stamp_gallery.py --gallery /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/g_mig.h5 --arcface /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/fake.onnx)",
|
|
||||||
"Bash(timeout 590 cmake -S . -B /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/bt -DSAE_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release)",
|
|
||||||
"Bash(grep -n \"^#\\\\{1,3\\\\} \\\\|^| ID \\\\|^| Requirement \\\\|^|---\" jRay/docs/requirements.md)",
|
|
||||||
"Bash(./build-full/tests/sae_tests)",
|
|
||||||
"Bash(ls /usr/include/catch2/catch_test_macros.hpp 2>/dev/null && echo \"catch2 system\"; ls /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/ 2>/dev/null | head; ls /usr/lib/libCatch2* 2>/dev/null | head)",
|
|
||||||
"Read(//usr/lib/**)",
|
|
||||||
"Bash(find /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/catch2-build -name libCatch2*)",
|
|
||||||
"Bash(timeout 590 g++ -std=c++20 -O1 -I/home/dtourolle/Development/Jray-worktrees/gallery-model-binding/src -I/usr/include/opencv5 -I/tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/nlohmann_json-src/single_include -I/tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/catch2-src/src -I/tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/catch2-build/generated-includes '-DSAE_MODELS_DIR=\"/home/dtourolle/Development/Jray-worktrees/gallery-model-binding/models\"' -o gallery_tests /home/dtourolle/Development/Jray-worktrees/gallery-model-binding/tests/test_gallery_store.cpp /home/dtourolle/Development/Jray-worktrees/gallery-model-binding/src/gallery/gallery_store.cpp /home/dtourolle/Development/Jray-worktrees/gallery-model-binding/src/gallery/embedder_stamp.cpp /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/catch2-build/src/libCatch2Main.a /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/build-full/_deps/catch2-build/src/libCatch2.a -lhdf5_cpp -lhdf5 -lopencv_core)",
|
|
||||||
"Bash(./gallery_tests)",
|
|
||||||
"Bash(grep -viE \"^\\\\[gallery\\\\]|^ |^$|WARNING \\\\\\(GR-004\\\\\\)|\\\\*\\\\*\\\\*\\\\*\")",
|
|
||||||
"Bash(python3 scripts/traceability/extract_traces.py --root jRay --requirements jRay/docs/requirements.md --system-spec SPEC.md --types JR --suffixes .cs,.js --scan-roots Jellyfin.Plugin.JRay --format coverage)",
|
|
||||||
"Bash(python3 scripts/traceability/extract_traces.py --root /home/dtourolle/Development/Jray-project/jRay --requirements /home/dtourolle/Development/Jray-project/jRay/docs/requirements.md --system-spec /home/dtourolle/Development/Jray-project/SPEC.md --types JR --suffixes .cs,.js --scan-roots Jellyfin.Plugin.JRay --format coverage)",
|
|
||||||
"Bash(cp -r /home/dtourolle/Development/Jray-project/scene-actor-extraction/external/KPN/. external/KPN/)",
|
|
||||||
"Bash(rm -rf external/KPN/.git)",
|
|
||||||
"Bash(rm -rf /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/bt)",
|
|
||||||
"Bash(timeout 590 cmake -S . -B /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/bt -DSAE_BUILD_TESTS=ON -DSAE_GEMM_BACKEND=CPU -DCMAKE_BUILD_TYPE=Release)",
|
|
||||||
"Bash(python3 scripts/traceability/extract_traces.py --root scene-actor-extraction --requirements scene-actor-extraction/docs/requirements.md --format coverage)",
|
|
||||||
"Bash(timeout 590 cmake --build /tmp/claude-1000/-home-dtourolle-Development-Jray-project/de7e497c-da04-462d-a971-d0fcd36bda2a/scratchpad/bt --target sae_tests -j8)",
|
|
||||||
"Bash(sh -n scripts/traceability/traceability-gate.sh)",
|
|
||||||
"Bash(cd /home/dtourolle/Development/Jray-project *)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"/home/dtourolle/Development/Jray-worktrees/gallery-model-binding/src/gallery",
|
|
||||||
"/home/dtourolle/Development/Jray-worktrees/min-face-study/scripts/validation",
|
|
||||||
"/home/dtourolle/Development/Jray-worktrees/audio-signature/tests",
|
|
||||||
"/home/dtourolle/Development/Jray-worktrees/traceability-tooling/scripts/traceability"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -94,6 +94,32 @@ is what creates orphan tags.
|
|||||||
When adding a requirement, give it a parent and a register row. When adding code,
|
When adding a requirement, give it a parent and a register row. When adding code,
|
||||||
give it a tag.
|
give it a tag.
|
||||||
|
|
||||||
|
### Commits name their requirements too
|
||||||
|
|
||||||
|
Every commit that implements, changes, or withdraws a requirement carries a
|
||||||
|
`TRACES:` trailer — the same token and syntax as the code tags, so one grep
|
||||||
|
pattern serves both:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(audio): v1 content-derived audio signature
|
||||||
|
|
||||||
|
Implements the §3 construction with the six previously-undefined
|
||||||
|
parameters pinned, plus a golden fixture the plugin can be written from.
|
||||||
|
|
||||||
|
TRACES: IR-004, IR-005, IR-007, IR-008 | SR-003
|
||||||
|
```
|
||||||
|
|
||||||
|
This closes the last link in the chain. Code tags say *where* a requirement
|
||||||
|
lives; commit trailers say *when and why it changed* — `git log --grep=AR-012`
|
||||||
|
then reconstructs a requirement's entire history, which no other artifact gives
|
||||||
|
you.
|
||||||
|
|
||||||
|
- Trailer last, after any `Co-Authored-By`.
|
||||||
|
- A commit serving no requirement (formatting, tooling, a typo) simply omits it —
|
||||||
|
absence is meaningful, so do not invent a tag to satisfy the form.
|
||||||
|
- Withdrawing a requirement is a change to it: name it, so the withdrawal is
|
||||||
|
findable later.
|
||||||
|
|
||||||
- **Specs are requirements + current-state deltas.** Each requirement carries
|
- **Specs are requirements + current-state deltas.** Each requirement carries
|
||||||
`Current:` and `Gap:` so the document doubles as a work list. Keep that shape
|
`Current:` and `Gap:` so the document doubles as a work list. Keep that shape
|
||||||
when editing.
|
when editing.
|
||||||
|
|||||||
@@ -4,23 +4,36 @@
|
|||||||
see who is on screen — for their own library, on their own hardware, with nobody
|
see who is on screen — for their own library, on their own hardware, with nobody
|
||||||
else learning what they own.
|
else learning what they own.
|
||||||
|
|
||||||
This is the project home. It owns the [system specification](SPEC.md) — the
|
This is the project home. It owns the [system specification](SPEC.md), the
|
||||||
requirements that span more than one component — and the tooling shared between
|
requirements that span more than one component, and the tooling they share. The
|
||||||
them. The code lives in three separate repositories, linked below.
|
code lives in three repositories, linked below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The three components
|
## The problem
|
||||||
|
|
||||||
| Repository | Role | Language |
|
You are watching a film. Someone appears and you know you have seen them
|
||||||
|---|---|---|
|
before — but pausing to search breaks the film, and the answer is rarely worth
|
||||||
| [`scene-actor-extraction`](https://gitea.tourolle.paris/dtourolle/scene-actor-extraction) | Derives presence data from a media file | C++ / Python |
|
the interruption. Amazon X-Ray solves this well, and only for Amazon's catalogue.
|
||||||
| [`jRay`](https://gitea.tourolle.paris/dtourolle/jRay) | Jellyfin plugin: surfaces it in the player, owns the truth-file format | C# |
|
|
||||||
| [`JRay-public-server`](https://gitea.tourolle.paris/dtourolle/JRay-public-server) | Exchanges presence data between instances | Rust |
|
|
||||||
|
|
||||||
They ship independently — the plugin to Jellyfin's catalogue, the server as a
|
Doing the same for a personal library is harder than it looks:
|
||||||
binary, the pipeline to a GPU host — which is why they are separate repositories
|
|
||||||
rather than one monorepo.
|
- **Face recognition on a paused frame answers the wrong question.** In dialogue
|
||||||
|
the camera is usually on whoever is *not* speaking, so a per-frame answer
|
||||||
|
reports the other actor absent. X-Ray credits a whole scene's cast for the
|
||||||
|
scene's duration, and that is the more useful question (SR-002).
|
||||||
|
- **The compute is real.** Detecting, embedding and tracking every face in a
|
||||||
|
feature takes GPU time. Doing it once per viewer, for the same film, is waste.
|
||||||
|
- **The obvious fix leaks.** A service that identifies your library must be told
|
||||||
|
what your library contains, which recreates the thing being replaced (PR-005).
|
||||||
|
|
||||||
|
JRay's answer: extract presence data locally, share the *timings* rather than
|
||||||
|
the media or the faces, and make the shared artefact structurally incapable of
|
||||||
|
carrying anything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
```
|
```
|
||||||
media file ──► extraction ──► truth file (sidecar or pushed) ──► plugin ──► player overlay
|
media file ──► extraction ──► truth file (sidecar or pushed) ──► plugin ──► player overlay
|
||||||
@@ -30,9 +43,28 @@ media file ──► extraction ──► truth file (sidecar or pushed) ──
|
|||||||
(Jellyfin + TMDB)
|
(Jellyfin + TMDB)
|
||||||
```
|
```
|
||||||
|
|
||||||
Two axes, deliberately separate: **presence data** flows outward and is
|
| Repository | Role | Language |
|
||||||
shareable, being timings against public identifiers. **Gallery data** — the
|
|---|---|---|
|
||||||
actor reference faces — is built locally and never leaves the instance (SR-005).
|
| [`scene-actor-extraction`](https://gitea.tourolle.paris/dtourolle/scene-actor-extraction) | Derives presence data from a media file | C++ / Python |
|
||||||
|
| [`jRay`](https://gitea.tourolle.paris/dtourolle/jRay) | Jellyfin plugin: surfaces it in the player, owns the truth-file format | C# |
|
||||||
|
| [`JRay-public-server`](https://gitea.tourolle.paris/dtourolle/JRay-public-server) | Exchanges presence data between instances | Rust |
|
||||||
|
|
||||||
|
They ship independently — the plugin to Jellyfin's catalogue, the server as a
|
||||||
|
static binary, the pipeline to a GPU host — which is why they are separate
|
||||||
|
repositories rather than one monorepo.
|
||||||
|
|
||||||
|
**Two data axes, deliberately separate.** *Presence data* flows outward and is
|
||||||
|
shareable: it is timings against public TMDB identifiers. *Gallery data* — the
|
||||||
|
actor reference faces — is built locally from your own Jellyfin instance plus
|
||||||
|
TMDB, and never leaves the machine (SR-005). Not by export, not by opt-in, not
|
||||||
|
at all: the capability is what creates the exposure, so it does not exist.
|
||||||
|
|
||||||
|
**The manifest server holds no binary content.** An accepted manifest contains
|
||||||
|
bounded numbers, regex-constrained identifiers, and references to TMDB persons.
|
||||||
|
No images, no embeddings, no free-form strings, no extension points (SR-004).
|
||||||
|
That is what makes it safe for a volunteer to operate an instance, and it is a
|
||||||
|
property preserved by prohibition — any proposal to ship blobs through it is a
|
||||||
|
proposal to delete it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,21 +81,20 @@ git clone git@gitea.tourolle.paris:dtourolle/jRay.git
|
|||||||
git clone git@gitea.tourolle.paris:dtourolle/JRay-public-server.git
|
git clone git@gitea.tourolle.paris:dtourolle/JRay-public-server.git
|
||||||
```
|
```
|
||||||
|
|
||||||
The component directories are `.gitignore`d here, so they sit beside the system
|
Each component has its own README with build instructions. They are
|
||||||
spec without this repository trying to track them. Each has its own README with
|
`.gitignore`d here, so they sit beside the system spec without this repository
|
||||||
build instructions.
|
trying to track them.
|
||||||
|
|
||||||
### Why not submodules for the components
|
### Why the components are not submodules
|
||||||
|
|
||||||
A submodule pins a commit. With feature branches and worktrees in flight across
|
A submodule pins a commit. With feature branches and worktrees in flight across
|
||||||
the components, every component commit would leave this repository's pointer
|
the components, every component commit would leave this repository's pointer
|
||||||
stale and its `git status` dirty until someone committed a pointer bump — churn
|
stale and its `git status` dirty until someone committed a bump — churn that
|
||||||
that buys nothing, since the components are developed together in one directory
|
buys nothing, since the components are developed together in one directory.
|
||||||
anyway.
|
|
||||||
|
|
||||||
The dependency runs the other way instead: **components pull *this* repository
|
The dependency runs the other way: **each component pulls *this* repository in**
|
||||||
in** for the shared tooling and the system spec, both of which change rarely.
|
as a submodule, for the system spec and the shared tooling, both of which change
|
||||||
That is the asymmetry submodules suit.
|
rarely. That is the asymmetry submodules suit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,22 +103,19 @@ That is the asymmetry submodules suit.
|
|||||||
| Doc | Owns |
|
| Doc | Owns |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`SPEC.md`](SPEC.md) | **System requirements** — `PR-nnn` project goals, `SR-nnn` cross-component contracts |
|
| [`SPEC.md`](SPEC.md) | **System requirements** — `PR-nnn` project goals, `SR-nnn` cross-component contracts |
|
||||||
| [`CLAUDE.md`](CLAUDE.md) | Working notes and the invariants that must not be violated silently |
|
| [`CLAUDE.md`](CLAUDE.md) | Working notes, and the invariants that must not be violated silently |
|
||||||
| Each repo's `SPEC.md` | That component's software requirements |
|
| Each repo's `SPEC.md` | That component's software requirements |
|
||||||
| Each repo's `docs/requirements.md` | Its stable requirement IDs, status, and verification plan |
|
| Each repo's `docs/requirements.md` | Its stable requirement IDs, status, and verification plan |
|
||||||
|
|
||||||
Read the system spec first. Every component requirement traces up to an
|
Read the system spec first. Every component requirement traces up to an
|
||||||
`SR-nnn`, and every `SR-nnn` to a `PR-nnn`, so the chain explains *why* a given
|
`SR-nnn`, and every `SR-nnn` to a `PR-nnn`, so the chain explains *why* a given
|
||||||
piece of code exists.
|
piece of code exists — and makes it visible when something exists for no stated
|
||||||
|
reason.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirement traceability
|
## Requirement traceability
|
||||||
|
|
||||||
Requirements are traceable **up** to the project goal and **down** to the code
|
|
||||||
implementing them. A requirement nothing traces to is either unnecessary or
|
|
||||||
unimplemented, and both are worth knowing.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
PR-nnn project requirement (SPEC.md §1) — why the system exists
|
PR-nnn project requirement (SPEC.md §1) — why the system exists
|
||||||
└─ SR-nnn system requirement (SPEC.md §3) — what spans components
|
└─ SR-nnn system requirement (SPEC.md §3) — what spans components
|
||||||
@@ -95,26 +123,65 @@ PR-nnn project requirement (SPEC.md §1) — why the system exists
|
|||||||
└─ TRACES tag (source)
|
└─ TRACES tag (source)
|
||||||
```
|
```
|
||||||
|
|
||||||
Tag the code that *satisfies* a requirement:
|
Tag the code that *satisfies* a requirement — the unit that decides, not every
|
||||||
|
helper it calls:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
/// TRACES: UR-003 | SR-004
|
/// TRACES: UR-003, UR-011 | SR-004
|
||||||
pub fn validate_manifest(m: Jmanifest) -> VResult<ValidManifest> { … }
|
pub fn validate_manifest(m: Jmanifest) -> VResult<ValidManifest> { … }
|
||||||
```
|
```
|
||||||
|
|
||||||
A pipe separates requirement *types*; a comma separates IDs within a type. Tag
|
A pipe separates requirement *types*; a comma separates IDs within a type.
|
||||||
the unit that decides, not every helper it calls — a tag on every function is
|
Tests carry tags too (`UT-nnn`, `IT-nnn`), which is what shows a requirement is
|
||||||
noise, and rots faster than it helps.
|
*verified* rather than merely implemented. A deliberate departure from an
|
||||||
|
invariant is tagged `EXCEPTION:` with its reason — an untagged one is a defect.
|
||||||
|
|
||||||
The gate reports coverage, orphan tags (an ID no register defines), and untraced
|
### Running the gate
|
||||||
requirements. Two rules it inherits from JellyTau, both learned the hard way:
|
|
||||||
|
The tooling lives in [`scripts/traceability/`](scripts/traceability/) here and
|
||||||
|
is vendored into each component as a submodule, so there is **one
|
||||||
|
implementation**. Each component declares its own taxonomy in a
|
||||||
|
`traceability.toml` at its root:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
requirement_types = ["UR", "DR"]
|
||||||
|
languages = ["rust"]
|
||||||
|
source_roots = ["src", "tests"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/traceability-gate.sh # from any component
|
||||||
|
```
|
||||||
|
|
||||||
|
It reports coverage, orphan tags (an ID no register defines), untraced
|
||||||
|
requirements, and requirements verifiable only on hardware CI lacks.
|
||||||
|
|
||||||
|
**Two rules inherited from JellyTau, both learned the hard way:**
|
||||||
|
|
||||||
- **Denominators are read from the register at run time, never hardcoded.** A
|
- **Denominators are read from the register at run time, never hardcoded.** A
|
||||||
gate that divides by a frozen literal reported 158% coverage for months and so
|
gate that divided by a frozen literal reported *158% coverage* for months
|
||||||
could never fail — worse than no gate, because it was trusted.
|
while the requirement count grew, so its threshold could never trip. A gate
|
||||||
|
that cannot fail is worse than no gate, because it is trusted.
|
||||||
- **Coverage above 100% is a hard failure**, not a pass. It means the
|
- **Coverage above 100% is a hard failure**, not a pass. It means the
|
||||||
computation is broken, and it is the signal that catches the above
|
computation is broken, and it is the signal that catches the above at once.
|
||||||
immediately.
|
|
||||||
|
The same reasoning is why a requirement whose only evidence is a test that never
|
||||||
|
runs is reported as *tagged but unexecuted*, never counted as covered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Component | State |
|
||||||
|
|---|---|
|
||||||
|
| `scene-actor-extraction` | Pipeline redesign in progress — presence follows track extent (AR-012), replacing per-frame recognition |
|
||||||
|
| `jRay` | Truth-file serving and overlay working; manifest-sharing configuration added, fetch path outstanding |
|
||||||
|
| `JRay-public-server` | Core implemented: 23/32 requirements traced, 189 tests. Audio-tier matching and federation deferred by design |
|
||||||
|
|
||||||
|
**One schema bump is pending across all three repos** (SR-003). It removes
|
||||||
|
`anneal_sec`, adds `extinction_sec` and `gallery_scope`, gives each window its
|
||||||
|
belief and identification route, and adds the audio signature. Breaking changes
|
||||||
|
are batched, so these ship together rather than piecemeal.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -334,12 +334,18 @@ unimplemented, and both are worth knowing.
|
|||||||
### The chain
|
### The chain
|
||||||
|
|
||||||
```
|
```
|
||||||
PR-n project requirement (this document, §1) — why the system exists
|
PR-nnn project requirement (this document, §1) — why the system exists
|
||||||
└─ SR-n system requirement (this document, §3) — what spans components
|
└─ SR-nnn system requirement (this document, §3) — what spans components
|
||||||
└─ A1…E8 / §n software requirement (component specs) — what one repo does
|
└─ component requirement (component registers) — what one repo does
|
||||||
└─ @implements tag (source) — where it actually is
|
├─ TRACES tag (source) — where it lives
|
||||||
|
└─ TRACES trailer (commit message) — when and why it changed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The commit trailer is the last link and the only one that carries *history*:
|
||||||
|
`git log --grep=AR-012` reconstructs everything that ever happened to a
|
||||||
|
requirement, which no other artifact provides. Same token and syntax as the code
|
||||||
|
tag, so one grep pattern serves both.
|
||||||
|
|
||||||
### ID scheme
|
### ID scheme
|
||||||
|
|
||||||
Zero-padded three digits throughout. **IDs are permanent**: a withdrawn
|
Zero-padded three digits throughout. **IDs are permanent**: a withdrawn
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,27 @@ TAG = "TRA" + "CES:"
|
|||||||
EXC = "EXCEP" + "TION:"
|
EXC = "EXCEP" + "TION:"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Test configuration
|
||||||
|
#
|
||||||
|
# Every entry point takes a Config since the tool became shared. These helpers
|
||||||
|
# keep each test stating only the field it varies.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _config(root=".", **kw) -> "et.Config":
|
||||||
|
"""A Config with the extraction repo's shape, overridable per test."""
|
||||||
|
fields = dict(
|
||||||
|
requirement_types=("AR", "DP", "IR", "GR", "VR"),
|
||||||
|
source_suffixes=frozenset(et.LANGUAGE_SUFFIXES["cpp"]
|
||||||
|
| et.LANGUAGE_SUFFIXES["python"]),
|
||||||
|
source_roots=("src", "tests", "scripts", "experiments", "eval"),
|
||||||
|
root=Path(root),
|
||||||
|
)
|
||||||
|
fields.update(kw)
|
||||||
|
return et.Config(**fields)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Tag parsing
|
# Tag parsing
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -127,11 +148,11 @@ def test_scans_cpp_and_python_but_not_vendored_or_non_source():
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
_write_tree(root)
|
_write_tree(root)
|
||||||
files = et.iter_source_files(root)
|
files = et.iter_source_files(_config(root))
|
||||||
names = sorted(f.name for f in files)
|
names = sorted(f.name for f in files)
|
||||||
assert names == ["gallery.py", "tracker.hpp"], names
|
assert names == ["gallery.py", "tracker.hpp"], names
|
||||||
|
|
||||||
scan = et.scan_files(files, root)
|
scan = et.scan_files(files, _config(root))
|
||||||
traced = sorted({i for t in scan.traces for i in t.requirements})
|
traced = sorted({i for t in scan.traces for i in t.requirements})
|
||||||
assert traced == ["AR-012", "AR-013", "GR-001", "SR-002", "SR-005"]
|
assert traced == ["AR-012", "AR-013", "GR-001", "SR-002", "SR-005"]
|
||||||
|
|
||||||
@@ -141,7 +162,7 @@ def test_context_is_found_below_a_cpp_tag_and_above_a_python_tag():
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
_write_tree(root)
|
_write_tree(root)
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
contexts = {t.file: t.context for t in scan.traces}
|
contexts = {t.file: t.context for t in scan.traces}
|
||||||
assert "TrackRegistry" in contexts["src/tracker.hpp"]
|
assert "TrackRegistry" in contexts["src/tracker.hpp"]
|
||||||
assert "build_gallery" in contexts["scripts/gallery.py"]
|
assert "build_gallery" in contexts["scripts/gallery.py"]
|
||||||
@@ -164,7 +185,7 @@ def test_exception_tag_is_captured_with_its_reason():
|
|||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
(root / "src").mkdir(parents=True)
|
(root / "src").mkdir(parents=True)
|
||||||
(root / "src" / "outlier.cpp").write_text(EXCEPTION_SOURCE, encoding="utf-8")
|
(root / "src" / "outlier.cpp").write_text(EXCEPTION_SOURCE, encoding="utf-8")
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
assert len(scan.exceptions) == 1
|
assert len(scan.exceptions) == 1
|
||||||
exc = scan.exceptions[0]
|
exc = scan.exceptions[0]
|
||||||
assert exc.requirement == "AR-024"
|
assert exc.requirement == "AR-024"
|
||||||
@@ -179,11 +200,11 @@ def test_exception_is_never_counted_as_coverage():
|
|||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
(root / "src").mkdir(parents=True)
|
(root / "src").mkdir(parents=True)
|
||||||
(root / "src" / "outlier.cpp").write_text(EXCEPTION_SOURCE, encoding="utf-8")
|
(root / "src" / "outlier.cpp").write_text(EXCEPTION_SOURCE, encoding="utf-8")
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
assert scan.traces == []
|
assert scan.traces == []
|
||||||
register = et.parse_register(
|
register = et.parse_register(
|
||||||
"| ID | Requirement | Status |\n|---|---|---|\n"
|
"| ID | Requirement | Status |\n|---|---|---|\n"
|
||||||
"| AR-024 | Always the calibrated probability | Planned |\n")
|
"| AR-024 | Always the calibrated probability | Planned |\n", _config())
|
||||||
cov = et.compute_coverage(
|
cov = et.compute_coverage(
|
||||||
[i for t in scan.traces for i in t.requirements], register)
|
[i for t in scan.traces for i in t.requirements], register)
|
||||||
assert cov.covered == []
|
assert cov.covered == []
|
||||||
@@ -197,7 +218,7 @@ def test_exception_without_a_reason_is_reported():
|
|||||||
(root / "src").mkdir(parents=True)
|
(root / "src").mkdir(parents=True)
|
||||||
(root / "src" / "bare.cpp").write_text(
|
(root / "src" / "bare.cpp").write_text(
|
||||||
f"// {EXC} AR-024\n", encoding="utf-8")
|
f"// {EXC} AR-024\n", encoding="utf-8")
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
assert len(scan.exceptions) == 1
|
assert len(scan.exceptions) == 1
|
||||||
assert scan.diagnostics.exceptions_without_reason
|
assert scan.diagnostics.exceptions_without_reason
|
||||||
|
|
||||||
@@ -210,7 +231,7 @@ def test_mixed_type_group_is_reported():
|
|||||||
(root / "src").mkdir(parents=True)
|
(root / "src").mkdir(parents=True)
|
||||||
(root / "src" / "a.cpp").write_text(
|
(root / "src" / "a.cpp").write_text(
|
||||||
f"// {TAG} AR-001, SR-002\n", encoding="utf-8")
|
f"// {TAG} AR-001, SR-002\n", encoding="utf-8")
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
assert scan.diagnostics.mixed_type_groups
|
assert scan.diagnostics.mixed_type_groups
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +246,7 @@ def test_counts_a_well_formed_table_row_as_a_defined_requirement():
|
|||||||
md = REGISTER_HEADER + (
|
md = REGISTER_HEADER + (
|
||||||
"| AR-001 | Detect faces in sampled frames | SR-002 | High | Done |\n"
|
"| AR-001 | Detect faces in sampled frames | SR-002 | High | Done |\n"
|
||||||
"| AR-002 | Minimum face size 66x66 px | SR-002 | High | Planned |\n")
|
"| AR-002 | Minimum face size 66x66 px | SR-002 | High | Planned |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.count("AR") == 2
|
assert register.count("AR") == 2
|
||||||
assert register.count("GR") == 0
|
assert register.count("GR") == 0
|
||||||
assert register.total == 2
|
assert register.total == 2
|
||||||
@@ -237,7 +258,7 @@ def test_does_not_count_ids_that_appear_only_in_the_traces_to_column():
|
|||||||
md = REGISTER_HEADER + (
|
md = REGISTER_HEADER + (
|
||||||
"| GR-001 | Build gallery from library cast | SR-001, SR-005 | High | Done |\n"
|
"| GR-001 | Build gallery from library cast | SR-001, SR-005 | High | Done |\n"
|
||||||
"| GR-002 | Incremental merge refresh | PR-003 | High | Done |\n")
|
"| GR-002 | Incremental merge refresh | PR-003 | High | Done |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.count("GR") == 2
|
assert register.count("GR") == 2
|
||||||
assert register.ids == {"GR-001", "GR-002"}
|
assert register.ids == {"GR-001", "GR-002"}
|
||||||
|
|
||||||
@@ -246,7 +267,7 @@ def test_does_not_count_ids_mentioned_in_prose():
|
|||||||
md = ("Some prose explaining that AR-005 relates to GR-001 and VR-003.\n\n"
|
md = ("Some prose explaining that AR-005 relates to GR-001 and VR-003.\n\n"
|
||||||
+ REGISTER_HEADER
|
+ REGISTER_HEADER
|
||||||
+ "| AR-005 | Align to 112x112 | SR-002 | High | Done |\n")
|
+ "| AR-005 | Align to 112x112 | SR-002 | High | Done |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.ids == {"AR-005"}
|
assert register.ids == {"AR-005"}
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +281,7 @@ def test_does_not_count_the_verification_plan_table_as_definitions():
|
|||||||
+ "| ID | Tier | Test asserts | Edge cases to cover |\n|---|---|---|---|\n"
|
+ "| ID | Tier | Test asserts | Edge cases to cover |\n|---|---|---|---|\n"
|
||||||
+ "| AR-001 | T3 | Detector returns plausible boxes | smoke only |\n"
|
+ "| AR-001 | T3 | Detector returns plausible boxes | smoke only |\n"
|
||||||
+ "| AR-099 | T1 | Something not in the register | - |\n")
|
+ "| AR-099 | T1 | Something not in the register | - |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.ids == {"AR-001"}
|
assert register.ids == {"AR-001"}
|
||||||
assert register.total == 1
|
assert register.total == 1
|
||||||
|
|
||||||
@@ -271,7 +292,7 @@ def test_deduplicates_an_id_listed_in_two_definition_tables():
|
|||||||
+ "\n"
|
+ "\n"
|
||||||
+ REGISTER_HEADER
|
+ REGISTER_HEADER
|
||||||
+ "| AR-001 | Detect faces | SR-002 | High | Done |\n")
|
+ "| AR-001 | Detect faces | SR-002 | High | Done |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.total == 1
|
assert register.total == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -281,7 +302,7 @@ def test_withdrawn_requirements_leave_the_denominator():
|
|||||||
md = REGISTER_HEADER + (
|
md = REGISTER_HEADER + (
|
||||||
"| AR-001 | Detect faces | SR-002 | High | Done |\n"
|
"| AR-001 | Detect faces | SR-002 | High | Done |\n"
|
||||||
"| AR-002 | Superseded mechanism | SR-002 | High | Withdrawn |\n")
|
"| AR-002 | Superseded mechanism | SR-002 | High | Withdrawn |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.ids == {"AR-001"}
|
assert register.ids == {"AR-001"}
|
||||||
assert "AR-002" in register.withdrawn
|
assert "AR-002" in register.withdrawn
|
||||||
|
|
||||||
@@ -291,8 +312,8 @@ def test_the_denominator_is_live_adding_a_row_lowers_coverage():
|
|||||||
# more requirement defined => a lower percentage, mechanically.
|
# more requirement defined => a lower percentage, mechanically.
|
||||||
base = REGISTER_HEADER + "| AR-001 | A | SR-002 | High | Done |\n"
|
base = REGISTER_HEADER + "| AR-001 | A | SR-002 | High | Done |\n"
|
||||||
grown = base + "| AR-002 | B | SR-002 | High | Planned |\n"
|
grown = base + "| AR-002 | B | SR-002 | High | Planned |\n"
|
||||||
before = et.compute_coverage(["AR-001"], et.parse_register(base))
|
before = et.compute_coverage(["AR-001"], et.parse_register(base, _config()))
|
||||||
after = et.compute_coverage(["AR-001"], et.parse_register(grown))
|
after = et.compute_coverage(["AR-001"], et.parse_register(grown, _config()))
|
||||||
assert before.percent == 100.0
|
assert before.percent == 100.0
|
||||||
assert after.percent == 50.0
|
assert after.percent == 50.0
|
||||||
assert after.total == 2
|
assert after.total == 2
|
||||||
@@ -301,7 +322,7 @@ def test_the_denominator_is_live_adding_a_row_lowers_coverage():
|
|||||||
def test_register_captures_the_row_fields_not_just_the_id():
|
def test_register_captures_the_row_fields_not_just_the_id():
|
||||||
md = REGISTER_HEADER + (
|
md = REGISTER_HEADER + (
|
||||||
"| AR-012 | Presence follows track extent | **SR-002** | High | Planned |\n")
|
"| AR-012 | Presence follows track extent | **SR-002** | High | Planned |\n")
|
||||||
req = et.parse_register(md).requirements["AR-012"]
|
req = et.parse_register(md, _config()).requirements["AR-012"]
|
||||||
assert req.text == "Presence follows track extent"
|
assert req.text == "Presence follows track extent"
|
||||||
assert req.traces_to == "**SR-002**"
|
assert req.traces_to == "**SR-002**"
|
||||||
assert req.status == "Planned"
|
assert req.status == "Planned"
|
||||||
@@ -328,7 +349,7 @@ def test_tier_assignment_handles_lists_ranges_and_wildcards():
|
|||||||
"| AR-002 … AR-004 | **T2** | replay |\n"
|
"| AR-002 … AR-004 | **T2** | replay |\n"
|
||||||
"| AR-006 | T1 + T4 | mixed |\n"
|
"| AR-006 | T1 + T4 | mixed |\n"
|
||||||
"| VR-* | Out of CI | studies |\n")
|
"| VR-* | Out of CI | studies |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.requirements["AR-001"].tiers == {"T3"}
|
assert register.requirements["AR-001"].tiers == {"T3"}
|
||||||
assert register.requirements["AR-003"].tiers == {"T2"}
|
assert register.requirements["AR-003"].tiers == {"T2"}
|
||||||
assert register.requirements["AR-006"].tiers == {"T1", "T4"}
|
assert register.requirements["AR-006"].tiers == {"T1", "T4"}
|
||||||
@@ -337,7 +358,7 @@ def test_tier_assignment_handles_lists_ranges_and_wildcards():
|
|||||||
|
|
||||||
def test_a_range_cannot_invent_a_requirement_the_register_lacks():
|
def test_a_range_cannot_invent_a_requirement_the_register_lacks():
|
||||||
md = TIER_REGISTER + "\n" + _tier_table("| AR-001 … AR-050 | T2 | wide |\n")
|
md = TIER_REGISTER + "\n" + _tier_table("| AR-001 … AR-050 | T2 | wide |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.total == 12
|
assert register.total == 12
|
||||||
assert "AR-050" not in register.ids
|
assert "AR-050" not in register.ids
|
||||||
|
|
||||||
@@ -346,7 +367,7 @@ def test_slash_shorthand_in_the_verification_plan_expands():
|
|||||||
md = TIER_REGISTER + "\n" + (
|
md = TIER_REGISTER + "\n" + (
|
||||||
"| ID | Tier | Test asserts | Edge cases |\n|---|---|---|---|\n"
|
"| ID | Tier | Test asserts | Edge cases |\n|---|---|---|---|\n"
|
||||||
"| AR-009/008 | T2 | Cut shifts weighting | cut with same people |\n")
|
"| AR-009/008 | T2 | Cut shifts weighting | cut with same people |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.requirements["AR-008"].tiers == {"T2"}
|
assert register.requirements["AR-008"].tiers == {"T2"}
|
||||||
assert register.requirements["AR-009"].tiers == {"T2"}
|
assert register.requirements["AR-009"].tiers == {"T2"}
|
||||||
|
|
||||||
@@ -358,15 +379,15 @@ def test_tiers_from_both_tables_are_unioned_not_overwritten():
|
|||||||
md = TIER_REGISTER + "\n" + _tier_table("| AR-006 | T4 | GPU host only |\n") + "\n" + (
|
md = TIER_REGISTER + "\n" + _tier_table("| AR-006 | T4 | GPU host only |\n") + "\n" + (
|
||||||
"| ID | Tier | Test asserts | Edge cases |\n|---|---|---|---|\n"
|
"| ID | Tier | Test asserts | Edge cases |\n|---|---|---|---|\n"
|
||||||
"| AR-006 | T1 + T4 | GEMM equals reference loop | small input in CI |\n")
|
"| AR-006 | T1 + T4 | GEMM equals reference loop | small input in CI |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.requirements["AR-006"].tiers == {"T1", "T4"}
|
assert register.requirements["AR-006"].tiers == {"T1", "T4"}
|
||||||
assert register.requirements["AR-006"].ci_executable
|
assert register.is_ci_executable("AR-006")
|
||||||
|
|
||||||
|
|
||||||
def test_a_t4_only_requirement_is_not_ci_executable():
|
def test_a_t4_only_requirement_is_not_ci_executable():
|
||||||
md = TIER_REGISTER + "\n" + _tier_table("| AR-007 | **T4** | GPU only |\n")
|
md = TIER_REGISTER + "\n" + _tier_table("| AR-007 | **T4** | GPU only |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert not register.requirements["AR-007"].ci_executable
|
assert not register.is_ci_executable("AR-007")
|
||||||
assert register.unexecutable_ids() == {"AR-007"}
|
assert register.unexecutable_ids() == {"AR-007"}
|
||||||
|
|
||||||
|
|
||||||
@@ -379,12 +400,12 @@ def test_a_requirement_tracing_up_to_nothing_is_reported():
|
|||||||
"| AR-002 | Parent is a section | §4 | Medium | Planned |\n"
|
"| AR-002 | Parent is a section | §4 | Medium | Planned |\n"
|
||||||
"| AR-003 | Serves nothing stated | - | Low | Planned |\n"
|
"| AR-003 | Serves nothing stated | - | Low | Planned |\n"
|
||||||
"| AR-004 | Blank cell | | Low | Planned |\n")
|
"| AR-004 | Blank cell | | Low | Planned |\n")
|
||||||
register = et.parse_register(md)
|
register = et.parse_register(md, _config())
|
||||||
assert register.parentless_ids() == {"AR-003", "AR-004"}
|
assert register.parentless_ids() == {"AR-003", "AR-004"}
|
||||||
|
|
||||||
|
|
||||||
def test_a_requirement_with_no_tier_is_unknown_not_unexecutable():
|
def test_a_requirement_with_no_tier_is_unknown_not_unexecutable():
|
||||||
register = et.parse_register(TIER_REGISTER)
|
register = et.parse_register(TIER_REGISTER, _config())
|
||||||
assert register.tier_unknown_ids() == register.ids
|
assert register.tier_unknown_ids() == register.ids
|
||||||
assert register.unexecutable_ids() == set()
|
assert register.unexecutable_ids() == set()
|
||||||
|
|
||||||
@@ -402,7 +423,7 @@ COVERAGE_REGISTER = et.parse_register(
|
|||||||
+ "\n"
|
+ "\n"
|
||||||
+ _tier_table("| AR-001, AR-002 | T2 | replay |\n"
|
+ _tier_table("| AR-001, AR-002 | T2 | replay |\n"
|
||||||
"| GR-001 | T1 | bookkeeping |\n"
|
"| GR-001 | T1 | bookkeeping |\n"
|
||||||
"| AR-027 | **T4** | GPU host only |\n"))
|
"| AR-027 | **T4** | GPU host only |\n"), _config())
|
||||||
|
|
||||||
|
|
||||||
def test_coverage_is_the_intersection_of_traced_and_defined():
|
def test_coverage_is_the_intersection_of_traced_and_defined():
|
||||||
@@ -510,10 +531,30 @@ def _fixture_repo(tmp: str, source: str) -> Path:
|
|||||||
(root / "src").mkdir(parents=True, exist_ok=True)
|
(root / "src").mkdir(parents=True, exist_ok=True)
|
||||||
(root / "docs" / "requirements.md").write_text(FIXTURE_REGISTER, encoding="utf-8")
|
(root / "docs" / "requirements.md").write_text(FIXTURE_REGISTER, encoding="utf-8")
|
||||||
(root / "src" / "pipeline.cpp").write_text(source, encoding="utf-8")
|
(root / "src" / "pipeline.cpp").write_text(source, encoding="utf-8")
|
||||||
|
(root / et.CONFIG_FILENAME).write_text(
|
||||||
|
'requirement_types = ["AR", "DP", "IR", "GR", "VR"]\n'
|
||||||
|
'languages = ["cpp", "python"]\n'
|
||||||
|
'source_roots = ["src", "tests", "scripts", "experiments", "eval"]\n',
|
||||||
|
encoding="utf-8")
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def _run_gate(root: Path, *extra: str):
|
def _run_gate(root: Path, *extra: str):
|
||||||
|
"""Run the CLI against `root`, supplying the per-repo config it now needs.
|
||||||
|
|
||||||
|
The tool no longer guesses a taxonomy: a repo declares its prefixes and
|
||||||
|
languages, and the gate refuses to run without them rather than reporting a
|
||||||
|
misleading zero. So a gate test must provide them too — written as a real
|
||||||
|
`traceability.toml`, which exercises the config-discovery path rather than
|
||||||
|
bypassing it.
|
||||||
|
"""
|
||||||
|
config = root / et.CONFIG_FILENAME
|
||||||
|
if not config.exists():
|
||||||
|
config.write_text(
|
||||||
|
'requirement_types = ["AR", "DP", "IR", "GR", "VR"]\n'
|
||||||
|
'languages = ["cpp", "python"]\n'
|
||||||
|
'source_roots = ["src", "tests", "scripts", "experiments", "eval"]\n',
|
||||||
|
encoding="utf-8")
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
with redirect_stdout(buffer):
|
with redirect_stdout(buffer):
|
||||||
code = et.main(["--root", str(root), "--format", "coverage", *extra])
|
code = et.main(["--root", str(root), "--format", "coverage", *extra])
|
||||||
@@ -595,11 +636,11 @@ def test_gate_hard_fails_on_an_impossible_ratio():
|
|||||||
# reported as a pass. Forced here by handing the reporter a poisoned value.
|
# reported as a pass. Forced here by handing the reporter a poisoned value.
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = _fixture_repo(tmp, f"// {TAG} AR-001\nint main() {{}}\n")
|
root = _fixture_repo(tmp, f"// {TAG} AR-001\nint main() {{}}\n")
|
||||||
register = et.read_register(root / "docs" / "requirements.md")
|
register = et.read_register(_config(requirements_path=str(root / "docs" / "requirements.md")))
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
report = et.build_report(root, register, scan)
|
report = et.build_report(_config(root, min_coverage=50.0), register, scan)
|
||||||
report.coverage.percent = 158.0
|
report.coverage.percent = 158.0
|
||||||
text, code = et.format_coverage_report(report, 50.0)
|
text, code = et.format_coverage_report(report)
|
||||||
assert code == 1
|
assert code == 1
|
||||||
assert "exceeds 100%" in text
|
assert "exceeds 100%" in text
|
||||||
|
|
||||||
@@ -610,9 +651,9 @@ def test_the_per_type_breakdown_sums_to_the_headline_figure():
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = _fixture_repo(
|
root = _fixture_repo(
|
||||||
tmp, f"// {TAG} AR-001, AR-027\nint main() {{}}\n")
|
tmp, f"// {TAG} AR-001, AR-027\nint main() {{}}\n")
|
||||||
register = et.read_register(root / "docs" / "requirements.md")
|
register = et.read_register(_config(requirements_path=str(root / "docs" / "requirements.md")))
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(et.iter_source_files(_config(root)), _config(root))
|
||||||
report = et.build_report(root, register, scan)
|
report = et.build_report(_config(root), register, scan)
|
||||||
stats = et.per_type_stats(report)
|
stats = et.per_type_stats(report)
|
||||||
assert sum(c for c, _, _ in stats.values()) == len(report.coverage.covered)
|
assert sum(c for c, _, _ in stats.values()) == len(report.coverage.covered)
|
||||||
assert sum(u for _, u, _ in stats.values()) == len(report.coverage.unexecuted)
|
assert sum(u for _, u, _ in stats.values()) == len(report.coverage.unexecuted)
|
||||||
@@ -637,7 +678,7 @@ def test_json_and_markdown_outputs_are_written_and_consistent():
|
|||||||
assert data["coverage"]["covered"] == 1
|
assert data["coverage"]["covered"] == 1
|
||||||
assert data["coverage"]["percent"] == round(100 / 3, 1)
|
assert data["coverage"]["percent"] == round(100 / 3, 1)
|
||||||
assert data["byType"]["SR"] == ["SR-002"]
|
assert data["byType"]["SR"] == ["SR-002"]
|
||||||
assert data["gpuOnlyRequirements"] == ["AR-027"]
|
assert data["unexecutableRequirements"] == ["AR-027"]
|
||||||
assert "AR-001" in md_out.read_text(encoding="utf-8")
|
assert "AR-001" in md_out.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -662,144 +703,36 @@ def test_system_spec_parsing_enables_orphan_checks_for_pr_and_sr():
|
|||||||
# requirements are added.
|
# requirements are added.
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
#: The live-register tests below ran against this repo's own register when the
|
LIVE_REGISTER = os.environ.get('LIVE_REGISTER')
|
||||||
#: tool lived inside `scene-actor-extraction`. Now that it is shared, this repo
|
|
||||||
#: is the project home and holds no component register of its own, so they take
|
|
||||||
#: a path from the environment and skip when it is absent.
|
|
||||||
#:
|
|
||||||
#: LIVE_REGISTER=../scene-actor-extraction/docs/requirements.md \
|
|
||||||
#: python3 test_extract_traces.py
|
|
||||||
#:
|
|
||||||
#: They are kept rather than deleted because they assert something the synthetic
|
|
||||||
#: fixtures cannot: that a *real* register, with all its formatting accidents,
|
|
||||||
#: parses at all.
|
|
||||||
LIVE_REGISTER = os.environ.get("LIVE_REGISTER")
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_live_register_parses_and_assigns_tiers():
|
def test_the_live_register_parses_and_assigns_tiers():
|
||||||
if not LIVE_REGISTER:
|
if not LIVE_REGISTER:
|
||||||
return # skipped: no component register to point at
|
return # skipped: the project home holds no component register
|
||||||
register = et.read_register(Path(LIVE_REGISTER))
|
register = et.read_register(_config(root=str(Path(LIVE_REGISTER).resolve().parent.parent), requirements_path=str(LIVE_REGISTER)))
|
||||||
assert register.total > 0
|
assert register.total > 0
|
||||||
|
for req_type in et.LOCAL_TYPES:
|
||||||
|
assert register.count(req_type) > 0, req_type
|
||||||
assert sum(register.count(t) for t in et.LOCAL_TYPES) == register.total
|
assert sum(register.count(t) for t in et.LOCAL_TYPES) == register.total
|
||||||
# A tier that no GPU-less host can run must be visible in the parse, not
|
# The GPU-less CI host must be visible in the parse, not just in prose.
|
||||||
# merely stated in prose.
|
assert "AR-027" in register.unexecutable_ids()
|
||||||
assert register.unexecutable_ids() <= register.ids
|
assert register.requirements["AR-027"].tiers == {"T4"}
|
||||||
|
assert register.requirements["AR-012"].tiers == {"T2"}
|
||||||
|
assert register.unexecutable_ids() < register.ids
|
||||||
|
|
||||||
|
|
||||||
def test_the_live_register_yields_a_gate_run_that_cannot_exceed_one_hundred():
|
def test_the_live_register_yields_a_gate_run_that_cannot_exceed_one_hundred():
|
||||||
if not LIVE_REGISTER:
|
if not LIVE_REGISTER:
|
||||||
return
|
return
|
||||||
register = et.read_register(Path(LIVE_REGISTER))
|
register = et.read_register(_config(root=str(Path(LIVE_REGISTER).resolve().parent.parent), requirements_path=str(LIVE_REGISTER)))
|
||||||
root = Path(LIVE_REGISTER).resolve().parent.parent
|
files = et.iter_source_files(et.REPO_ROOT, _config())
|
||||||
scan = et.scan_files(et.iter_source_files(root), root)
|
scan = et.scan_files(files, et.REPO_ROOT, _config())
|
||||||
cov = et.compute_coverage(
|
cov = et.compute_coverage(
|
||||||
[i for t in scan.traces for i in t.requirements], register)
|
[i for t in scan.traces for i in t.requirements], register)
|
||||||
assert 0.0 <= cov.percent <= 100.0
|
assert 0.0 <= cov.percent <= 100.0
|
||||||
assert len(cov.covered) <= cov.total
|
assert len(cov.covered) <= cov.total
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Per-repo configuration — the flags that make this tool shareable.
|
|
||||||
#
|
|
||||||
# Without them a repo whose prefixes or language differ from the defaults gets
|
|
||||||
# zero requirements and zero files, which the gate correctly refuses to report
|
|
||||||
# as coverage. These assert the overrides actually take effect.
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _restore_taxonomy(fn):
|
|
||||||
"""Run `fn` with the module defaults restored afterwards."""
|
|
||||||
local, suffixes, roots = et.LOCAL_TYPES, set(et.SOURCE_SUFFIXES), et.SCAN_ROOTS
|
|
||||||
try:
|
|
||||||
fn()
|
|
||||||
finally:
|
|
||||||
et.configure_taxonomy(local)
|
|
||||||
et.configure_suffixes(suffixes)
|
|
||||||
et.configure_scan_roots(roots)
|
|
||||||
|
|
||||||
|
|
||||||
def test_types_override_changes_which_prefixes_are_counted():
|
|
||||||
def body():
|
|
||||||
register_md = (
|
|
||||||
"| ID | Requirement | Traces to | Priority | Status |\n"
|
|
||||||
"|---|---|---|---|---|\n"
|
|
||||||
"| UR-001 | A user requirement | SR-001 | High | Done |\n"
|
|
||||||
"| DR-001 | A dev requirement | PR-004 | High | Done |\n"
|
|
||||||
)
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "requirements.md"
|
|
||||||
path.write_text(register_md, encoding="utf-8")
|
|
||||||
|
|
||||||
# Under the defaults these prefixes are unknown, so nothing counts.
|
|
||||||
et.configure_taxonomy(("AR", "DP"))
|
|
||||||
assert et.read_register(path).total == 0
|
|
||||||
|
|
||||||
et.configure_taxonomy(("UR", "DR"))
|
|
||||||
register = et.read_register(path)
|
|
||||||
assert register.total == 2
|
|
||||||
assert register.count("UR") == 1
|
|
||||||
assert register.count("DR") == 1
|
|
||||||
|
|
||||||
_restore_taxonomy(body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_suffix_override_changes_which_files_are_scanned():
|
|
||||||
def body():
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
(root / "src").mkdir()
|
|
||||||
(root / "src" / "lib.rs").write_text(
|
|
||||||
"/// TRACES: UR-001 | SR-004\npub fn f() {}\n", encoding="utf-8")
|
|
||||||
|
|
||||||
# Default suffixes are C++/Python, so a Rust tree scans as empty —
|
|
||||||
# the failure mode this override exists to fix.
|
|
||||||
assert et.iter_source_files(root) == []
|
|
||||||
|
|
||||||
et.configure_suffixes([".rs"])
|
|
||||||
files = et.iter_source_files(root)
|
|
||||||
assert len(files) == 1
|
|
||||||
scan = et.scan_files(files, root)
|
|
||||||
assert [i for t in scan.traces for i in t.requirements] == [
|
|
||||||
"UR-001", "SR-004"]
|
|
||||||
|
|
||||||
_restore_taxonomy(body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_suffix_override_accepts_extensions_with_or_without_a_dot():
|
|
||||||
def body():
|
|
||||||
et.configure_suffixes(["rs", ".cs"])
|
|
||||||
assert et.SOURCE_SUFFIXES == {".rs", ".cs"}
|
|
||||||
|
|
||||||
_restore_taxonomy(body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_scan_root_override_limits_the_walk():
|
|
||||||
def body():
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
# Not "vendor" — that is in EXCLUDED_DIR_NAMES and would be
|
|
||||||
# filtered whatever the scan roots say, testing the wrong thing.
|
|
||||||
for d in ("src", "eval"):
|
|
||||||
(root / d).mkdir()
|
|
||||||
(root / d / "f.py").write_text("# TRACES: AR-001\n", encoding="utf-8")
|
|
||||||
|
|
||||||
et.configure_scan_roots(["src"])
|
|
||||||
assert len(et.iter_source_files(root)) == 1
|
|
||||||
|
|
||||||
et.configure_scan_roots(["src", "eval"])
|
|
||||||
assert len(et.iter_source_files(root)) == 2
|
|
||||||
|
|
||||||
_restore_taxonomy(body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_are_unchanged_by_the_overrides_existing():
|
|
||||||
# The extraction pipeline must keep working with no flags at all, or moving
|
|
||||||
# the tool here would have broken the repo it came from.
|
|
||||||
assert et.LOCAL_TYPES == ("AR", "DP", "IR", "GR", "VR")
|
|
||||||
assert ".py" in et.SOURCE_SUFFIXES and ".cpp" in et.SOURCE_SUFFIXES
|
|
||||||
assert ".rs" not in et.SOURCE_SUFFIXES
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
def _main() -> int:
|
def _main() -> int:
|
||||||
|
|||||||
@@ -1,89 +1,62 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
#
|
#
|
||||||
# Requirement traceability gate. Run locally exactly as CI runs it:
|
# Requirement traceability gate. Run locally exactly as CI runs it, from the
|
||||||
|
# component repo root:
|
||||||
#
|
#
|
||||||
# scripts/traceability/traceability-gate.sh
|
# scripts/traceability/traceability-gate.sh
|
||||||
#
|
#
|
||||||
# Writes traces-report.json and docs/traceability.md, prints the coverage
|
# Writes the JSON report and the markdown matrix, prints the coverage report,
|
||||||
# report, and exits non-zero when the gate fails.
|
# and exits non-zero when the gate fails.
|
||||||
#
|
#
|
||||||
# Environment:
|
# This script is shared by every JRay component, so it knows nothing about any
|
||||||
# MIN_COVERAGE minimum overall coverage percent (default 0 - see below)
|
# one repo. All repo-specific settings - requirement ID prefixes, source
|
||||||
# ALLOW_ORPHANS set to 1 to report orphan tags without failing
|
# suffixes, scan roots, register path, thresholds - live in `traceability.toml`
|
||||||
# TRACES_JSON JSON report path (default traces-report.json)
|
# at the component repo root. Run
|
||||||
# TRACES_MD markdown matrix path (default docs/traceability.md)
|
|
||||||
# REPO_ROOT repository to scan. Defaults to two levels above this
|
|
||||||
# script, which is correct when the tooling lives in the repo
|
|
||||||
# it checks. **When vendored as a submodule that default is
|
|
||||||
# the submodule itself**, so a consuming repo must set this —
|
|
||||||
# its wrapper does.
|
|
||||||
# TYPES comma-separated requirement prefixes (e.g. UR,DR). Defaults
|
|
||||||
# to the extraction set; a repo whose prefixes differ parses
|
|
||||||
# to zero requirements without this.
|
|
||||||
# SUFFIXES comma-separated file extensions (e.g. .rs). Defaults to the
|
|
||||||
# C++/Python set; a repo whose language differs scans zero
|
|
||||||
# files without this.
|
|
||||||
# SCAN_ROOTS comma-separated directories to walk, relative to REPO_ROOT.
|
|
||||||
# SYSTEM_SPEC optional path to the system SPEC.md, which defines the PR/SR
|
|
||||||
# IDs; when given, PR/SR orphans are reported too. It lives in
|
|
||||||
# the project home (jray-project) — when this tooling is
|
|
||||||
# vendored from there, it is a sibling of this script.
|
|
||||||
#
|
#
|
||||||
# Threshold policy lives here and nowhere else. It is deliberately NOT
|
# scripts/traceability/extract_traces.py --print-example-config
|
||||||
# duplicated into the workflow YAML: a threshold written in two places is a
|
|
||||||
# threshold that will disagree with itself.
|
|
||||||
#
|
#
|
||||||
# MIN_COVERAGE defaults to 0 because almost nothing is tagged yet - tags are
|
# for the annotated schema. A repo whose config is wrong parses zero
|
||||||
# added as the pipeline is built, so a low number today is accurate rather than
|
# requirements or scans zero files, and the gate refuses to report rather than
|
||||||
# alarming. A zero threshold does NOT mean the gate cannot fail: orphan tags,
|
# printing a misleading 0%.
|
||||||
# a >100% ratio, a register that parses to nothing, and an empty source scan
|
#
|
||||||
# are all hard failures from day one. Raise MIN_COVERAGE as tags land; treat
|
# Environment (all optional; each overrides the config file):
|
||||||
# every raise as a ratchet, never a reset.
|
# TRACES_CONFIG path to traceability.toml
|
||||||
|
# TRACES_ROOT repo root (default: nearest dir containing traceability.toml)
|
||||||
|
# MIN_COVERAGE minimum overall coverage percent
|
||||||
|
# ALLOW_ORPHANS 1 to report orphan tags without failing
|
||||||
|
# TRACES_JSON JSON report path
|
||||||
|
# TRACES_MD markdown matrix path
|
||||||
|
# SYSTEM_SPEC SPEC.md defining PR/SR; enables PR/SR orphan checking
|
||||||
|
# PYTHON interpreter (default: python3)
|
||||||
|
#
|
||||||
|
# Threshold policy belongs in traceability.toml, not here and not in the
|
||||||
|
# workflow YAML: a threshold written in two places is a threshold that will
|
||||||
|
# disagree with itself.
|
||||||
#
|
#
|
||||||
# POSIX sh, no bashisms, no jq - the extractor does its own arithmetic and
|
# POSIX sh, no bashisms, no jq - the extractor does its own arithmetic and
|
||||||
# printing so CI needs nothing beyond python3.
|
# printing, so CI needs nothing beyond python3.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
REPO_ROOT="${REPO_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)}"
|
|
||||||
|
|
||||||
MIN_COVERAGE="${MIN_COVERAGE:-0}"
|
|
||||||
TRACES_JSON="${TRACES_JSON:-$REPO_ROOT/traces-report.json}"
|
|
||||||
TRACES_MD="${TRACES_MD:-$REPO_ROOT/docs/traceability.md}"
|
|
||||||
|
|
||||||
PYTHON="${PYTHON:-python3}"
|
PYTHON="${PYTHON:-python3}"
|
||||||
command -v "$PYTHON" >/dev/null 2>&1 || {
|
command -v "$PYTHON" >/dev/null 2>&1 || {
|
||||||
echo "FAILED: $PYTHON not found. The traceability gate needs Python 3.9+" >&2
|
echo "FAILED: $PYTHON not found. The traceability gate needs Python 3.9+," >&2
|
||||||
|
echo " or 3.11+ to read traceability.toml." >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
set -- \
|
set -- --format coverage
|
||||||
--root "$REPO_ROOT" \
|
|
||||||
--requirements "${REQUIREMENTS:-$REPO_ROOT/docs/requirements.md}" \
|
|
||||||
--format coverage \
|
|
||||||
--json-out "$TRACES_JSON" \
|
|
||||||
--markdown-out "$TRACES_MD" \
|
|
||||||
--min-coverage "$MIN_COVERAGE"
|
|
||||||
|
|
||||||
if [ "${ALLOW_ORPHANS:-0}" = "1" ]; then
|
# Explicit `if` rather than `[ ... ] && ...`, because a trailing false test in
|
||||||
set -- "$@" --allow-orphans
|
# an && list exits under `set -e` in some POSIX shells.
|
||||||
fi
|
if [ -n "${TRACES_CONFIG:-}" ]; then set -- "$@" --config "$TRACES_CONFIG"; fi
|
||||||
|
if [ -n "${TRACES_ROOT:-}" ]; then set -- "$@" --root "$TRACES_ROOT"; fi
|
||||||
if [ -n "${SYSTEM_SPEC:-}" ]; then
|
if [ -n "${MIN_COVERAGE:-}" ]; then set -- "$@" --min-coverage "$MIN_COVERAGE"; fi
|
||||||
set -- "$@" --system-spec "$SYSTEM_SPEC"
|
if [ -n "${TRACES_JSON:-}" ]; then set -- "$@" --json-out "$TRACES_JSON"; fi
|
||||||
fi
|
if [ -n "${TRACES_MD:-}" ]; then set -- "$@" --markdown-out "$TRACES_MD"; fi
|
||||||
|
if [ -n "${SYSTEM_SPEC:-}" ]; then set -- "$@" --system-spec "$SYSTEM_SPEC"; fi
|
||||||
if [ -n "${TYPES:-}" ]; then
|
if [ "${ALLOW_ORPHANS:-0}" = "1" ]; then set -- "$@" --allow-orphans; fi
|
||||||
set -- "$@" --types "$TYPES"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${SUFFIXES:-}" ]; then
|
|
||||||
set -- "$@" --suffixes "$SUFFIXES"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${SCAN_ROOTS:-}" ]; then
|
|
||||||
set -- "$@" --scan-roots "$SCAN_ROOTS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$PYTHON" "$SCRIPT_DIR/extract_traces.py" "$@"
|
exec "$PYTHON" "$SCRIPT_DIR/extract_traces.py" "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user