Commit Graph
100 Commits
Author SHA1 Message Date
ScottW514 614efb23e4 forgetest: the lint counts a host test only on a line of run text whose failure ends the step
workflow_runs counted any simple command in a step's run text as
running the test: _SPLIT_RX split the text on &&, ||, ;, |, newlines,
then, do and else, and every part was matched. So a step that hid the
test's failure, or never ran it, kept a HOST_TESTED row green, against
the HOST_TESTED comment's "a step ... whose failure fails it":
`./build/tray_test || true`, `| tee log`, `&`, `true ||
./build/tray_test`, an `exit 0` or a `set +e` line before the test,
`if false; then ./build/tray_test; fi`, a here-document's lines, a
folded block (`run: >`) whose next line is `|| true`, and a step
under `shell: bash {0}` or a `defaults: run: shell:` of pwsh each
counted. The workflow_runs docstring said "Only what a step's run
commands execute counts", which a command the shell skips, or whose
status it drops, does not meet. No workflow of the tree uses these
forms at a row's test.

workflow_runs now reads the run text line by line as bash -e or sh -e
runs it (_run_commands), a backslash at the end of a line joining the
next. _shell_words reads a line's words and operators, with quotes and
expansions ($(...), ${...}, `...`) inside the words. A command counts
on a line that holds it alone, with its redirections, or in a pipeline
under pipefail (shell: bash, or `set -o pipefail` on an earlier line),
and only while errexit holds: a `set` of errexit or pipefail is
followed. A pipeline with no pipefail does not count. The lint stops at
a line with any other operator (`||`, `&&`, `;`, `&`, a subshell, a
here-document), at a command that opens a compound command or negates a
status (if, case, while, until, a group, a function, `!`), at a command
after which it cannot tell what a later line runs (exit, exec, trap,
eval, source, alias, shopt, hash and the like), and at a `set` of an
option it does not follow (noexec): nothing on that line or after it
counts. A for loop counts in one form: `for <name> in <words>; do` on
its own line, body lines that count (a pipeline under pipefail only),
and `done` on its own line. A step counts only when its own shell:,
and the `defaults: run: shell:` of its job and of the workflow, are
each bash, sh or absent: workflow_steps now reads those defaults, and
reads a folded block, or a scalar continued on the lines under its key,
as one line, as YAML folds it. A prefix is taken off only with the
flags with which it runs its command (`sudo -E`, `env -i`), so `command
-v ./build/tray_test`, which prints the path, does not count; exec is a
stop, no longer a prefix. The HOST_TESTED comment and the docstrings of
workflow_steps and workflow_runs say what the lint reads.

The lint does not read, and counts as no step: `./build/x; true`
(bash -e stops at the test, and the lint refuses the `;` all the
same), `exec ./build/x`, a loop on one line or with `do` on a line of
its own, a pipeline with the test last and no pipefail, a test under
`set +e` whose status a later line checks, a test after a guard line
with `||` or `&&`, and a `set` of any other option (`set -E`). It
counts without reading: what a loop's body does with the loop's name
(its list counts), a `cd` before the test, a `defaults: run:
working-directory:`, what PATH or BASH_ENV change, and the runner's
operating system (it does not read runs-on; a Windows runner's default
shell is pwsh).

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains these legs, none of which runs the test: `|| true`, `true ||`,
`; true`, `&` and a pipe with no pipefail on the test's line; an `exit
0`, `set +e`, `set -n` or here-document line before it; a `{` group
that `} || true` ends; a loop whose body is `python $t || true`, or a
pipe with no pipefail; a folded block and a continued scalar with `||
true` under the test; a step under `shell: bash {0}`; a job's and a
workflow's `defaults: run: shell: pwsh`. A pipe under `set -o
pipefail` and under shell: bash, and a step under shell: sh, run it.
test_what_running_a_host_test_looks_like gains `command -v
./build/tray_test`, which does not run it; its loop case is the loop as
python3-gfhardware's CI writes it (an assignment from `$(basename "$t"
.py)` in the body), and its glob case is a loop on lines of its own.
Negative controls on scratch copies: manifest.py as it was fails the
first new leg (True is not false), and each new leg reads as running
the test there; 19 mutations of the new reading each fail a case: exit
no stop, errexit not followed, an unknown `set` option followed, a
here-document read as a redirection, no reserved words, pipefail always
on, shell: bash with no pipefail, `set -o pipefail` not followed, the
loop's body not read, a body pipeline with no pipefail read, a folded
block read line by line, a continued scalar's lines dropped, the job's
defaults not read, the workflow's defaults not read, any shell read,
sh not read, `$(` read as an operator, every flag of a prefix taken
off, and `sudo -E` kept. Under bash -e with a failing stand-in test,
the step exits 0 for `|| true`, `true ||`, `&`, a pipe with no
pipefail, an earlier `exit 0`, `set +e` or `set -n`, the `{ } || true`
group, both loop bodies and the folded block; `; true` exits 1. A
here-document's lines are its command's input (`cat <<'EOF'` exits 0),
and `bash {0}` runs with no errexit, so a line after the test decides
the step (exit 0). The 17 workflows of the tree's repositories (211
steps) read to the same events and steps; of their 161 run steps, 20
count fewer commands and 4 the same commands with a redirection taken
off or an expansion kept as written, none of them a step that runs a
row's test. Each
of the 46 HOST_TESTED rows runs its test under the new reading, in the
workflows at the recipe pins and at the heads of forgectrl and
forgeext. The coverage lint on the tree manifest from the recipe pins
reports 0 uncovered paths, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 07:14:07 -04:00
ScottW514 3aecf800a5 forgetest: the lint's stray-quote rule reads a step's first key, on its dash line, too
The rule that a key with one stray quote makes every step of the
workflow count as no step never saw a step's first key, the one on its
dash line. _STEP_RX reads no key with a stray quote (`- run": make`,
`- name": Build`), and _stray_key cannot match a line that starts with
`- `. So such a step dropped out with no "unread" set, and the
workflow's other steps still counted. YAML reads the key with its quote
(PyYAML gives {'name"': 'Build', 'run': 'make'}), or refuses the file
(`- "run: make`), and GitHub refuses a workflow with a step key outside
the step schema: a HOST_TESTED row passed the lint for a workflow that
runs nothing. The HOST_TESTED comment, the workflow_steps docstring and
the Test page state the rule for a step's key, and every step's first
key is on its dash line. No workflow of the tree has such a key.

workflow_steps now reads the first key of every list item as it reads a
step's other keys: the dash and the spaces after it become spaces, and
a key with one stray quote there makes every step of the workflow carry
"unread", which _on_every_run refuses. The rule also takes in a list
item outside the steps (a matrix include entry): that only lists more.
The docstring says so.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step `- run": make` before a step that runs the test: no step
counts. Negative control on a scratch copy: manifest.py as it was fails
that case (True is not false). A probe of workflow_runs: `- name":
Build` with `run: make` under it, and `- "run: make`, each before a
step that runs the test, count as no step, as a stray quote on a
step's second key already did. The 17 workflows of the tree's
repositories (211 steps) read to the same events and steps before and
after the change. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 06:59:13 -04:00
ScottW514 609ae561bf forgetest: forgectrl's test scratch trees leave the repository, and the two still written are ignored
forgetest/cool-flow-test/, forgetest/super-test/ and
forgetest/cool-fanwrite-test/ (46 files) are the scratch trees that
forgectrl's cool_flow_test and super_test, and the fan-write test that
cool_flow_test took in, write in the directory they run from: fake
sysfs attributes, a cooling.state verdict, an empty dev/glowforge.
447f902 committed them beside a change to suite/exthost.py that needs
none of them. Nothing reads them: forgetest.bb fetches forgetest/LICENSE,
forgetest/forgetest/, forgetest/forgetest.init and scripts/bench/, no
manifest component holds them, and no other file of the repository
names them.

The three trees go. forgetest/.gitignore ignores the two that the
forgectrl tests still write, so a run from this directory cannot stage
them again.

Proof: git grep finds the three directory names nowhere else in the
repository. forgetest-ci "Unit tests" (`python -B -m unittest discover
-s tests`) on this tree with meta-openglow beside it, on Ubuntu 24.04
with python 3.12 in a private PID namespace: 357 tests OK, with
test_image.py's account-render case skipped there (it makes a namespace
through sudo -n); test_image.py on its own outside the namespace: 29
tests OK. pyflakes reports no undefined name. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests.

Acceptance: none. forgetest is dev-only, and the trees are outside
forgetest.bb's sources, every manifest component and every layer
content hash.
2026-10-16 23:56:28 -04:00
ScottW514 c849603942 bench README: the lifecycle harness row links the Test page plainly, as the bench page does
The row said that what laser_lifecycle_test.py walks is on the site,
Developers, "Testing". The Test page lists four of the fifteen sessions
main() runs, by choice: the harness header is the full list. And the
page is "Test" in the site's navigation, in its title and in the bench
page's own link; the site has no page called "Testing". The row now
says what the harness is and links the Test page by its title, as the
bench page of the site does.

Proof: README text only, no behavior change. The row names no session
and makes no claim about the page's list; its link text is the page's
title (mkdocs.yml: "Test: developers/testing.md"), and its URL does not
change.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 23:49:30 -04:00
ScottW514 6473e7d9ae forgetest-ci: the undefined-name step prints why it failed, and fails a run SIGINT stopped
The comment over the step said that a signal fails it. pyflakes ends a
SIGINT and a SIGPIPE itself, with status 1 ("... stopped" for SIGINT),
and status 1 goes on to the grep, so a run SIGINT stopped mid-tree
passed the step with nothing judged. And every failure the step gained
for a status other than 0 or 1 left no cause in the step log: the
group's 2>&1 also captures bash's own notice of a signal ("Killed"),
and nothing printed pyflakes.txt, so a usage error or a killed run
showed only the pyflakes version line before the failure.

The capture is printed when the pyflakes group fails, and the grep
also takes the "... stopped" line. The comment says what fails the
step: the grep's lines, a SIGINT stop, any status other than 0 or 1,
and a capture that cannot be written; a SIGPIPE ends with status 1 and
no line, and a write to the capture file raises none.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line a no-op: the interpreter on PATH is a shim to a
venv). The tree as it is exits 0 with both bodies, and an undefined
name planted exits 1 with its line in the log. A run that exits 2 and
a run killed by SIGKILL while it blocks on a FIFO exit 1 with both
bodies; the step log of the former body holds only the version line,
and this one prints "unrecognized arguments" and the "Killed" notice.
A run stopped by SIGINT exits 0 with the former body and 1 with this
one, its "... stopped" line in the log. A working directory that
cannot be written exits 1 with both, its "Permission denied" in the
log.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 23:49:02 -04:00
ScottW514 fc6b7797af forgetest: the shared judges' comments say why puls.py is one
The comment over SHARED_JUDGES in catalog.py said that every test runs
through all four judges without importing them, puls.py among them, and
the comment of test_a_shared_judge_edit_moves_every_test said the same.
No test runs puls.py but the three that print the dark job
(cloud.lid-interlock-abort, cloud.oversize-stream and
exthost.armed-freeze, through offline_job), and suite/cloud.py imports
it with `from .. import puls`: it is a judge because the sibling walk
does not follow that import, as the Acceptance page of the site says.

Both comments now say that every test runs through runner.py, hw.py and
baseline.py with no import to follow, and that puls.py comes in by a
package import the walk does not follow. Comments only: SHARED_JUDGES
does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. The only import
of puls in the package is suite/cloud.py's (grep). pyflakes reports no
undefined name.

Acceptance: none. catalog.py is the registry and in no fingerprint, and
tests/ are in none (source_sha of 43 of 43 unchanged).
2026-10-16 23:46:48 -04:00
ScottW514 a10fd7782d forgetest: the hash cases hold what a test's own import imports in turn, and the guard refuses an absolute package import
No case held the step of implementation_sha that adds what a test's own
import imports in turn (`sibling_imports(sib, sibs)`), nor the
recursion in sibling_imports itself: the judge.py of
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
imports nothing. Either line deleted kept test_manifest.py green, while
on the real catalog the first drops motion.py's shared text from
cloud.mode-switch's hash and setup.py's from motion.release's. The guard
on the suite's package imports read the relative spelling alone: a
`from forgetest import server`, an `import forgetest.server`, or a
`from forgetest.suite.setup import ...` in a suite module passed it,
and the sibling walk does not follow any of them, so the module they
name joined no hash.

The case's judge.py now imports limit.py, which imports base.py: an
edit to either moves m.two and not m.one. The guard refuses any import
of the package by its absolute name in a suite module, at any depth.
No suite module imports the package that way. catalog.py does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. Negative
controls on scratch copies: catalog.py without `sibling_imports(sib,
sibs)` in implementation_sha fails the case at the limit.py edit; the
walk without its recursion fails it at the base.py edit; in
suite/forgectrl.py, a `from forgetest import server` and an `import
forgetest.server` inside a function, and forgectrl.auth's import of
setup.py spelled `from forgetest.suite.setup import request, decode`,
each fail the guard. pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:45:25 -04:00
ScottW514 2af9b0277f forgetest: the lint's docstrings and its row reason say what it reads, not what a workflow runs on
host_row_problems' docstring gave the reason a row is listed as "a
dispatch, a call or a schedule runs on no change". A workflow on
workflow_call alone runs on every run of the workflow that calls it
(forgeext's package.yml is one); the lint lists it because it does not
follow a call to its caller. The reason it printed, "<ci> runs on no
push or pull request", was false the same way for such a workflow, and
for a flow mapping (`on: {push: ...}`) that names push. The docstrings
of _on_every_run and workflow_runs said that a step under an `if:`
other than true does not run on every run, where the lint evaluates no
condition: a step under `always()` or `success()` runs on every run and
still counts as no step. The Test page of the site says each of these
as the lint applies it.

The docstrings now say what the lint reads: a row is listed when the
workflow's own `on:` names neither push nor pull_request as the lint
reads it (a dispatch, a call or a schedule alone, a call not followed
to its caller, a flow mapping), and a step with a condition counts as
no step because the lint evaluates none. The row's reason reads "<ci>:
the lint reads no push or pull_request in its own on:". The comments
of the two cases that hold these rules say the same. No rule changes:
the same rows are listed, for the same workflows.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_the_workflow_has_to_run_the_host_test asserting the new reason. A
probe of host_row_problems: a workflow on workflow_call alone and one
whose flow-mapping `on:` names push are each listed with the new
reason, and a plain `on: [push]` one is not. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:43:23 -04:00
ScottW514 c717ecbcce forgetest: the lint reads a quoted key only in a matched pair, and a key with one stray quote runs nothing
_KEY_RX took an opening quote and a closing quote each on its own, so a
key with one stray quote read as the bare key: `push":` and
`pull_request':` counted as push and pull_request, where YAML reads the
keys `push"` and `pull_request'`, which are no events; a step's
`run": ...` counted as its run command, and `uses": actions/checkout@v4`
as a checkout. A HOST_TESTED row whose workflow was spelled so passed
the lint, while that workflow runs on no push and runs no such step.

_KEY_RX now reads a quote only as a matched pair. A key with one stray
quote is no key the lint reads, and where the lint reads keys it counts
as running nothing: an event key spelled so makes the `on:` name no
event, and one at the top level, at a job's name or key, or at a step's
key below its dash line makes every step of the workflow count as no
step (each carries "unread", which _on_every_run refuses). So a step's `if": false` still
keeps the step from counting, as it did under the loose reading, and
under a `jobs":` top level, where a job's `if: false` goes unread, no
step counts either. The HOST_TESTED comment says so. The lint only
lists more: a workflow with no such key reads as before.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_the_events_a_workflow_runs_on gains an `on:` with `push":`, and
one with `push:` and `'pull_request:`, each naming no event, and
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step with `if": false`, a step with `run": ./build/tray_test`,
a job with `if": false` and a `jobs":` top level, none of them running
the test. Negative controls on scratch copies: _KEY_RX as it was fails
the `run":` step and the `push":` event; the matched pairs with no
stray-quote rule fail the `if": false` step and the second `on:`; the
rule at a step's keys alone fails the job's `if": false`. The 17
workflows of the tree's repositories (211 steps) read to the same
events and steps before and after the change. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:41:24 -04:00
ScottW514 49f087f376 forgetest: a failed press is recorded as the fixture's only when its request went out
_press_taken recorded the press as the fixture's for every FixtureError
that arrived with the button out. That included failures in which the
box made no press for the request: its refusal (a 401, or the 409 of a
jumper pulled after the probe), a failed mDNS lookup, a refused
connect. When the person at the machine pressed meanwhile, or the arm
wait ended for another reason, the evidence and the exported artifact
gave the box a press it never made, and press_now returned True.

FixtureError now carries `sent`: True when the failure came after the
request went out (a timeout or a reset once the request was sent,
which urllib raises bare; a failed connect or send is a URLError), so
the box may have pressed. The one retry after a fresh lookup keeps it,
also when the lookup itself fails. A refusal and a failed lookup leave
it False. With the button out, _press_taken still puts up no notice,
and it records the press as the fixture's only for a sent error;
otherwise the record stays the operator's, and press_now returns
whether the fixture pressed. No judge reads `by`, and no energy path
changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_fixture.py runs 22 tests, OK on Ubuntu
24.04 with python 3.12, and on Windows with python 3.14 with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_a_failure_after_the_request_went_out_says_so drives the real
client against the fake box: a press whose reply comes after the
client's timeout is sent, with one press, also when the refresh lookup
after it finds nothing; the jumper-out refusal, a failed lookup and a
refused connect are not sent, with no press.
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box refuses while the button goes out: press_now returns
False with no notice and the record the operator's, and arm_press
leaves the same. test_server.py (10), test_operator.py (17),
test_queue.py (15), test_mode.py (9), test_setup_dark.py (21) and
test_setup_burns.py (24) pass on both. Negative controls on scratch
copies: _press_taken giving every failure to the box fails the refusal
leg (press_now True); `sent` never set fails the first client leg;
`sent` set for a URLError too fails the refused-connect leg; the retry
that drops the flag fails the retry leg. pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move); fixture.py
is in none. Against the campaign this series already needs, that adds
no run: 850498f moved all 43 once. tests/ are in none.
2026-10-16 23:36:59 -04:00
ScottW514 e046c534c2 setup.focus: the tube-current case plants a reading at HV_ON, so it holds the threshold
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails planted
pic/hv_current at 0. That proved the check on the test's own reads
exists, but not its threshold: the check written as `> 0`, or as
`>= HV_ON`, passed every case. The case now plants HV_ON itself (30,
which is not over 30), so with the 431 the passing cases plant, the
threshold is held between 30 and 430. setup_burns.py does not change.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. Negative controls on
scratch copies: the check as `> 0`, as `>= HV_ON`, and as no check at
all each fail the case (Failed not raised); the case as it was (a
plant of 0) passes against the `> 0` check. pyflakes reports no
undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:30:06 -04:00
ScottW514 36c1b75bb8 setup.focus: the head's place judged against the stops its manual home declared, read from the home anchor
head_at_home placed the gantry stops at minus manual_home_x and
manual_home_y as the settings read at the burn. A manual home reads
them once: it declares the head at the stops at minus the offsets, and
with an offset it moves the head to X0 Y0, that far in front of them. A
write of the offsets after the home restarts no controller and keeps
the X/Y reference. So a home made with 5 and 3, the offsets then
cleared, had the head at X0 Y0 judged home 5 and 3 mm in front of the
stops, and the burn started: the false PASS 2172ae4 set out to close.
The other way round, offsets set after a home made with none failed a
head that was at the stops. The home anchor holds the stops as the home
declared them: /status reports the anchor's X and Y plus the step
counters.

head_at_home now takes the stops from the X and Y of the anchor
(baseline.ANCHOR_PATH) when /status reports X and Y referenced by a
manual home, and judges the head against them within HOME_TOL_MM. An
anchor that is gone, or that is not a manual home of X and Y, fails the
test before any start. The settings are read for the remedy alone. With
an offset, the remedy names how the push ends: release the motors, push
the head against the stops, and end the release with Energize motors
($ME), not with Set home here, which moves the head off the stops to X0
Y0 again. The remedy before said only to release the motors and push
the head, and a release left standing makes the burn ask home-push,
which the test does not answer. Without an offset it stays "home it
there". The judge only refuses more: no energy or motion path changes.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. BurnRunTests plants
the anchor of a home with no offset beside the fake's /status, and
test_a_manual_home_offset_puts_the_stops_off_x0_y0 plants the anchor of
each home: a home made with 5 and 3 fails the head at X0 Y0 and passes
it at X-5 Y-3; the offsets cleared after that home still fail the head
at X0 Y0 before any start (no wizard start, no lever); the offsets set
after a home made with none pass the head at X0 Y0; an anchor that is
gone, or that reads as the startup's Z-only reference, fails before any
start. That last pair replaces the leg where an offset that did not
read as a number failed: the stops no longer come from the settings,
and the rule that leg held (no stops placed, nothing starts) holds on
the anchor. test_setup_dark.py (21) and test_manifest.py (39, with the
guard on the suite's package imports, which setup_burns.py's import of
baseline.py passes) pass. Negative controls on scratch copies:
head_at_home as it was fails the case at the offsets cleared after the
home (Failed not raised), and so do the stops read from the settings;
an anchor that cannot be read taken as X0 Y0 fails it at the anchor
that is gone; an anchor of any axes and any source fails it at the
startup anchor. A probe of the failure text: with the offsets 5 and 3
it names Energize motors ($ME), not Set home here; with none it says
"home it there". pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text and the test's own description); the other 42 do not move
(source_sha compared before and after). baseline.py is a shared judge
already, in every hash. Against the campaign this series already
needs, that adds no run: 850498f moved all 43 once.
2026-10-16 23:28:00 -04:00
ScottW514 ce73dbb052 forgetest: a check run_check stops following at its timeout is aborted before the test fails
run_check judged its timeout with ctx.check, which raises, ahead of the
abort meant for that case, so the abort never ran: a check still
running at the end of its follow was left running behind the FAIL. For
setup.focus that is a live burn wizard. Its put-back waits 120 s for
the burn to end and then skips the controller restart, and forgectrl's
own bound on a live run (1500 s) outlasts the test's 900 s follow. The
same driver serves the setup.check-* tests,
setup.cloud-header-capture and motion.release.

The abort now goes out before the timeout is judged, and the
unreachable return after it is gone. The change only ends a check the
test started itself: it adds no energy and no motion.

Proof: tests/test_setup_dark.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 21 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails
(a lens.travel check that never ends, followed for 0.2 s: Failed, and
/wiz/lens.travel/abort posted). test_setup_burns.py (24) and
test_setup_suite.py (15) pass. Negative control on a scratch copy: the
timeout judged before the abort, as it was, fails the case (only
/wiz/lens.travel/start posted). setup.focus run whole against
test_setup_burns.py's scripted daemon with a burn that never ends (the
follow cut to 0.5 s, the press prompt held open): before, Failed with no
abort, the wizard still running and the restart skipped ("the burn is
still ending"); after, Failed with laser.focus aborted, the wizard
stopped, and the controller stopped and started (200, 200). pyflakes
reports no undefined name.

Acceptance: the shared text of setup_dark.py moves the fingerprints of
its five tests (setup.check-switches, setup.check-airflow,
setup.check-motion, setup.check-lens, setup.cloud-header-capture), of
setup.focus (setup_burns.py imports it at its top level) and of
motion.release (its own function imports run_check); the other 36 do
not move (source_sha compared before and after). Against the campaign
this series already needs, that adds no run: 850498f moved all 43
once.
2026-10-16 23:00:54 -04:00
ScottW514 7b0f065a3f laser.emission-witness: the head is placed back only by a sample that read the window open
rapid_place searched the samples up to the first disarmed one, that one
included, and sample_to_disarm read each sample's X after its armed
flag. So the first sample to read the window closed could place the
head back with an X read after the close: a window that closed on the
return leg, up to about one sampling loop before the head was back,
placed the rapid inside the window with a disarm time of 0, and the
`0 <=` on that time could not fail.

rapid_place now searches the samples before the first disarmed one
alone, and sample_to_disarm reads the controller's report (its state
and X) before the sample, so a sample that reads the window open read
its X with the window open. The head stands back for 1 s before the M2,
not 0.5 s: the placement needs a sample whose X and armed flag both
come between the head's return and the close, and a slow sampling loop
needs that time. The judge only gets stricter. No energy or motion path
changes: the longer dwell runs dark after the M5 with the window open,
as the shorter one did.

Proof: tests/test_laser_dwell.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 13 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
gains the close at the first sample with the head back (2, None, 6),
and test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
holds the order (each sample's X read before its armed flag).
test_cooling_suite.py (3) passes. Negative controls on scratch copies:
the search with the first disarmed sample included fails the first
case with (2, 6, 6); the report read after the sample fails the second.
emission_witness run whole on a fake clock with only its I/O stubbed
(the review's sweep: the armed flag served mid-GET, X read mid status
report, 40 phases of the rapid against sampling loops of 0.20 to
0.64 s): a window closing 0 to 2 s before the head is back fails the
placement at every phase and every loop, and the normal close passes
at 40 of 40 phases at every loop. The code before this change passed
early closes at some phases up to about one loop early (0.20 s at a
0.20 s loop, 0.5 s at a 0.64 s loop). With the new order and the 0.5 s
dwell, the normal close passed 39 of 40 at a 0.38 s loop and 12 of 40
at a 0.64 s loop. pyflakes reports no undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text and
laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
placement on the bench.
2026-10-16 22:56:33 -04:00
ScottW514 d57421c71d bench README: the lifecycle harness row points at the Testing page
The row for laser_lifecycle_test.py in scripts/bench/README.md credited
the harness with M5/M3 persistence inside a job, the M2 close, and the
arm refusal under a blocking cooling verdict. The harness runs no
session for any of the three: its header gives them to laser_arm_test.c
case I, laser_stream_test session K, and laser_arm_test.c cases C, A2
and A3. The row also restated a list that the site keeps (Developers,
"Test"), and a README is an index card. The row now says what the
harness is and points at that page, as the bench page of the site does.

Proof: README text only, no behavior change. The three claims are gone
from the file, and each of the four sessions the Test page lists
is one main() runs.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 14:54:18 -04:00
ScottW514 50c56a863d laser_stream_test: rule 19 names every laser session that runs with the dose curve in force
Rule 19 of the header said that every session but the rolloff reference
run has laser_dose_curve = off. Beside that run, four laser sessions run
with the curve in force: rolloff-g1 and rolloff-g2 (rule 20) and
rolloff-two (rule 23) under DENSITY_CONF_CURVED, which carries no curve
key, and jog-dark (rule 22), which runs with no configuration at all,
so the controller's curve_default applies. Rule 20 itself says "under
M4 with the curve in force". Rule 19 now says that every laser session
but the rolloff ones (rules 20 and 23) and the jog run (rule 22) runs
with laser_dose_curve = off. The z session carries no laser key and
fires nothing, so "laser session" leaves it out.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_stream_test.py, and the
harness passes whole, every session, against the native null-sink
build of the driver at badc832 (-DGFMCODE_WAIT_S=5.0). The harness runs
in grblHAL-glowforge CI (the step "Laser stream emission rules",
python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-16 14:53:14 -04:00
ScottW514 2172ae4101 setup.focus: the head's place judged against the gantry stops, at minus the manual home offsets when they are set
head_at_home judged a head referenced by a manual home at X0 Y0, on
the premise that a manual home declares X0 Y0 at the gantry stops. That
holds only while manual_home_x and manual_home_y are blank or 0. With an
offset, the manual provider declares the head at the stops at minus the
offsets and then jogs it to X0 Y0, that far in front of them
(glowforge_homing.c manual_home; the Homing pages of the docs), and
/status reads pos X0 Y0 there. The wizard draws every card from the
head's home against the stops (wizlive.c head_home; Setup, "The head at
home"), and the test answers HOME to that prompt. So with an offset the
judge recorded judged True and "the head is home on record" for a head
the offsets in front of the stops, the burn ran from there, and its box
landed that much farther from the stops. The failure message (home it
there) rebuilt the same state.

The sources put the home of the burn at the stops, and the stops at
minus the offsets in the coordinates /status reports, so the judge now
judges the head there. head_at_home reads the two offsets from
/settings (blank is 0; a value that is not a number of 0 or more fails
the test before any start), records the stops it judges against in
head_at_start, and judges pos against them within HOME_TOL_MM. With no
offset that is X0 Y0, the rule as it was. With an offset, a head that a
manual home left at X0 Y0 fails before any wizard start, with no
controller lever touched, and the message names the offsets, where
they put the stops, and the remedy: release the motors and push the
head against the stops (the head's place then rests on the operator's
word, as for any unreferenced head, once the release ends with
Energize motors). Judging at X0 Y0 under an offset
would affirm a place the wizard does not ask for, and recording the
read without judging it would let the test answer HOME with a read in
hand that puts the head away from the stops; neither is taken. The
offsets are read as they stand at the burn: /status does not report
the offsets the home used, and the home anchor, which holds the stops
that home declared, is not read. The description and the comments say
what is judged.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 24 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_manual_home_offset_puts_the_stops_off_x0_y0 (manual_home_x 5 and
manual_home_y 3, homed_axes 7, home_source manual: at X0 Y0 the test
fails before any start, the stops on record at X-5 Y-3, no /controller/
lever posted, the settings as found; at X-5 Y-3 the burn runs, judged
True; manual_home_x "five" fails before any start), and the lit burn's
record carries the stops at X0 Y0. Negative controls on scratch copies:
setup_burns.py as it was, and the offsets taken as 0, each fail the new
case at its first run (the burn starts). pyflakes reports no undefined
name.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other test moves (source_sha compared
before and after). Against the campaign this series already needs, that
adds no run: 850498f moved all 43 once.
2026-10-16 14:48:32 -04:00
ScottW514 9451587728 setup.focus: the test's own sysfs reads go on record however the burn ends, and their tube-current check has a case
emission_seen, the two peaks the test reads from sysfs at every poll
and the count of polls that read both, was written inside emission_ok
after the three checks of the burn's result. A burn the daemon reports
dark fails at the first of them (Context.check raises), and a burn that
ends in a wizard error, a timeout or an abort never reaches emission_ok.
On each of those paths the record kept the daemon's word alone, where
the test's own reading is the one worth comparing with it. burn() now
records emission_seen, and logs it, in the finally of its run_check, so
it is on record however the burn ends. emission_ok keeps its checks, in
their order.

The check that the tube current read over HV_ON from here had no case
that could fail it: the one negative case reads both attributes at 0
and stops at the LASER_ON check, which comes first. A new case reads
the LASER_ON count nonzero and the tube current at 0, and fails on the
tube current.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 23 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12, with
tests/test_setup_dark.py (20 OK):
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails
(cnc/laser_on_sampled 118, pic/hv_current 0: Failed "the tube current
never read over 30 from here", the settings as found);
test_a_dark_burn_fails_and_still_restores finds emission_seen at 118
and 431 beside the dark result; the aborted burn's record carries
emission_seen too. run_test keeps the Run of a test that fails.
Negative controls on scratch copies: the tube-current check made
vacuous (True or ...) fails the new case (Failed not raised);
setup_burns.py as it was fails the dark case (no emission_seen) and the
aborted case. pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text); no other test moves (source_sha compared before and after).
Against the campaign this series already needs, that adds no run:
850498f moved all 43 once.
2026-10-16 14:44:40 -04:00
ScottW514 bed46e961e laser.emission-witness: the read rule ends where the sampling stopped, and the rapid is placed inside the window
The dark stretch after the M5 runs on past the close, and three of its
judgments did not hold with it.

The read rule took the last sample served as the trail's last edge, and
sample() returns None for a request the daemon does not answer. So an
outage after the served close (one request that hangs to the client's
10 s timeout is enough) left a trail that ended at the close with no
gap, and the window that latches after the close was never read: the
test passed whole with a 30 ms fire at the end of the return rapid.
The sampling could also stop as early as about 1.8 s past the close,
and with a 0.9 s unread tail allowed, the last reading could come
before the latch of the window in flight: the review's tail model
(the kernel's ~1 s window latch, the sampler's GET timing, a fire just
before the close) finds 4 runs in 20000, 21 with a wider GET spread,
where that window goes unread and the judge passes.

The placement and the disarm timing searched the whole stretch. A
window that closed before the head was back (in the dwell at +20 mm
or on the return leg, up to about 2 s early) found the head back after
the close and passed "the rapid ran inside the dark stretch", with a
negative disarm time.

The rapid's sample count in the record ran past the close while its
span stopped at the close, so the record read about 8 Hz for a 5 Hz
loop.

sample_to_disarm moves from emission_witness to module level, ends two
windows and EMISSION_READ_GAP_S past the close, and returns the time
the sampling stopped beside the samples. read_gaps takes that time as
the trail's last edge (t_end), so a tail the daemon served none of is a
gap. rapid_place judges the placement over the armed part alone, up to
the first disarmed sample, and the disarm time must be 0 or more. The
rapid block counts its samples to the close and records how long the
sampling ran past it (sampled_past_close_s), and the log line says
both. The relit, HV and read judgments still cover the whole stretch.
The description says what is judged. tests/test_laser_dwell.py drops
relit(), a copy of the judge that no change to laser.py could fail.
Every change makes the judge stricter: no energy or motion path
changes.

Proof: tests/test_laser_dwell.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 13 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_tail_the_daemon_never_served_fails (every sample served read,
then nothing for 2.9 s before the sampling stopped: gap 2.9 s, the rule
fails; served through, it holds),
test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
(sample_to_disarm on a fake clock, the daemon serving the close and
nothing after it: the sampling ends at least 2.9 s past the close and
the rule fails), and
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
(a close after the return places out, back and the close; a close in
the dwell at +20 mm leaves back None). test_cooling_suite.py (3) and
test_setup_burns.py (22) pass. Negative controls on scratch copies:
read_gaps without the end edge fails the first two cases; the end at
two windows fails the second; sample_to_disarm that returns the samples
alone errors in the second; rapid_place over the whole stretch fails
the third. emission_witness run whole on a fake clock with only its I/O
stubbed: a window closing 0.2, 1.0, 1.9 or 2.5 s before the head is
back fails the placement (the code before this change passed the first
three), and a normal close passes with 22 samples over 4.07 s to the
close in the record (32 before). Against a simulated daemon and kernel
latch with that 30 ms fire: an outage after the served close and one
request hung to the 10 s timeout fail the read rule (gaps 3.05 s and
10.32 s; both passed before); a tail served through with no fire
passes. In the review's tail model, the end edge alone and the longer
sampling alone each leave 0 of 20000 runs with the window in flight
unread and the judge passing, in both GET spreads. pyflakes reports no
undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
rapid with the end edge on the bench.
2026-10-16 14:40:53 -04:00
ScottW514 41ca7fab82 forgetest-ci: the coverage lint step fails when the lint fails, not only when tee does
The "Coverage lint (enforced)" step pipes the lint into tee for the
step summary. A step with no shell: runs under bash -e with no
pipefail, so the status of the step was tee's. An uncovered path, an
empty covers entry, a host-tested row problem (the lint exits 1) and a
manifest the lint cannot load (exit 2) each passed the job: the first
three after they printed the report, the last after one error line on
stderr and an empty step summary. The header of the workflow and the
Testing page say that an uncovered path fails the job, and this step
is the only place that runs the lint with --enforce.

The run block now starts with `set -o pipefail`, so the status of the
step is the lint's. The tee to the step summary stays. A comment above
the block says why.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e from
forgefirm/forgetest of a copy of the tree on Ubuntu 24.04 with python
3.12, GITHUB_STEP_SUMMARY a file. The tree manifest from the recipe
pins: both bodies exit 0 and write the summary (0 uncovered paths, 0
host-tested row problems, 43 tests). The same manifest with one
uncovered forgectrl path planted: the lint reports 1 uncovered path,
the former body exits 0, this one exits 1, and both write the summary.
The manifest missing: the former body exits 0, this one exits 2.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:27:06 -04:00
ScottW514 eb684d366e forgetest-ci: the undefined-name step fails on a pyflakes status other than 0 or 1
The step captured the pyflakes run with `|| true`, because pyflakes
exits 1 for any message, and judged the capture alone: "undefined
name" or "Traceback". A run that ended abnormally with no traceback
passed the step with nothing checked: a run killed by a signal, a usage
error (exit 2), or a working directory where the capture cannot be
written (grep then reads no file, and the `if` is false).

The capture is now `{ python -B -m pyflakes forgetest tests || test $?
-eq 1; } > pyflakes.txt 2>&1`. The two normal statuses of pyflakes, 0
and 1, go on to the grep; any other status fails the step under
bash -e, and so does a redirect that fails. The grep stays the gate
for an undefined name and for a Traceback. A comment above the run
block says so.

Proof: the step body, read from this workflow and, as the control,
from the workflow before this change, run under bash -e over the
forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line left out: the interpreter on PATH is a shim or a
venv). Both bodies: the tree as it is exits 0; an undefined name
planted, pyflakes missing, and a pyflakes that raises each exit 1. A
pyflakes killed by SIGKILL, one ended by SIGTERM, one that exits 2,
and a working directory that cannot be written: the former body exits
0 each time, this one exits 1. PyYAML reads the run text of the step
as written.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:24:54 -04:00
ScottW514 9ea9c5d10f forgefirm-image: the pack-licenses test says what tar does with a member it cannot read
The docstring of the case that holds the refusal said that the
manifest check alone passes a bundle truncated after the unreadable
member, and a comment on the member's directory said that it sorts
after license.manifest. Neither is the mechanism. tar reports an error
that it cannot open the member, skips it, writes every other member
and the end of the archive, and exits nonzero only at its end, a
status the former pipeline lost. Where the member sorts does not
matter. The docstring now says that tar skips the member and the
manifest check alone passes the bundle without it, and the comment is
gone.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests, 5 tests OK. With
the pack script as it was before 03146a1, the case fails with the
member under zlib/ and with it under aaa/ (exit status 0 both times);
with the script as it is, both placements pass. At the shell, on a
fixture tree with the unreadable member under aaa/ and then under
zlib/, the script as it was exits 0 both times after "Cannot open:
Permission denied", writes a bundle that holds every other member
(zzz/generic_Last, which sorts last, included), and removes the tree.
Text only, so no negative control applies to the change itself.
pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint.
2026-10-16 14:23:02 -04:00
ScottW514 5d26818946 forgetest: a press the fixture made and reported failed leaves no notice standing
press_now and arm_press hand the arm press to the operator when the
bench actuator reports that the press failed. When the box makes its
200 ms press and its reply is lost, the client raises that failure at
its 3 s timeout or later (with ip set in the config it does not
retry), and by then the controller has taken the press and put the
button out. The fallback put the notice up all the same and started
the watch, which waits for the button to light before it waits for it
to go out. The button does not light again, so "Press it now" stood
through the job and asked for a second press, and a press during the
job pauses it.

Both fallbacks now record the fixture's error and then read the
button. Out: the press was taken, it is recorded as the fixture's, and
no notice goes up. Lit, or unreadable: the notice goes up with its
watch, as before. Nothing presses for the operator, and no judge or
energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 and on
Windows with python 3.14: test_fixture.py's
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box puts the button out and then reports a failure: press_now
returns True with no notice and the press recorded as the fixture's,
and arm_press leaves no notice and the same record. The file's 21
tests OK on Ubuntu, and on Windows with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_setup_burns.py (22), test_setup_dark.py (20), test_server.py
(10), test_operator.py (17), test_queue.py (15) and test_mode.py (9)
OK on Ubuntu. Negative controls on scratch copies: the leg
against the runner as it was fails at press_now (False, the notice
standing); with arm_press's fallback alone as it was, the leg fails at
its record ('operator', the notice up). pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in none.
2026-10-16 14:21:24 -04:00
ScottW514 8d16b93e86 forgetest: the shared judges and a test's own imports reach the hash the gate reads
The sibling walk read the top-level statements of a module alone. Five
tests import their judging helpers inside their own functions, so that
no other test of the module moves with them: cloud.mode-switch
(homeoff.py: judge_whole_motions, camera_home_return),
cooling.flow-under-load and cooling.fail-tier-stop (laser.py's sample
and run_and_sample, motion.py's log readers), forgectrl.auth (setup.py's
request and decode) and motion.release (setup_dark.py's run_check).
None of those helpers was in the hash of its test, and homeoff.py was
in no hash at all: an edit to judge_whole_motions moved 0 of 43 tests,
and an edit to laser.sample moved the three laser.* tests and neither
cooling test, so an inherited PASS survived a change to its own judge.
The guard that every package module a suite module imports is a listed
judge read the top level alone too, and no unit test held the
production path: Test.source_sha, which the fingerprint, the campaign
and the gate read, could drop the shared judges with CI green.

implementation_sha now adds to the hash of a test the suite modules its
own @test function imports, at any depth, and what they import in turn;
no other test of the module takes them. The shared text of a module
gives the modules it imports at any depth too: a helper that imports
inside its body brings that module into every test of the module, as
the helper itself does. _test_defs finds the @test functions of a
module for module_parts and for both walks. The guard walks the whole
tree of each suite module, so a package import inside a function or a
block is held to the list as well. test_a_shared_judge_edit_moves_every_test
holds the production path on a suite test: its source_sha equals
implementation_sha with SHARED_JUDGES and differs from it with none.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
(a judge imported in the body of one test moves that test and not the
other; imported in the body of a helper, it moves both), the
source_sha assertions, and the guard on the whole tree; test_artifact.py,
test_campaign.py, test_redact.py and test_responsiveness.py pass.
Negative controls on scratch copies: implementation_sha's default
taking no judges fails the source_sha assertion, and so does
Test.source_sha passing judges=(); the new case fails against
catalog.py as it was, and against a shared-text walk of top-level
statements alone; a `from .. import server` inside a function of
suite/forgectrl.py, or in a try block at its top level, fails the
guard, and the function-local one passes the guard that reads the top
level alone. On the real catalog: an edit to judge_whole_motions moves
cloud.mode-switch alone (0 tests before); an edit to laser.sample moves
the three laser.* tests and both cooling tests (the three alone
before); an edit to run_check moves the six setup.* tests and
motion.release (the six alone before); an edit to setup.request moves
forgectrl.auth and motion.release (motion.release through setup_dark.py,
which imports setup.py) beside the 8 it moved before; an edit to
hw.sysfs_int
moves 43 of 43, as before. The coverage lint on the tree manifest
from the recipe pins reports 0 uncovered paths, 0 host-tested row
problems, 43 tests. pyflakes reports no undefined name.

Acceptance: cloud.mode-switch, cooling.flow-under-load,
cooling.fail-tier-stop, forgectrl.auth and motion.release: the
implementation hash of each moves once, its own imports joining it; the
other 38 do not move (source_sha compared before and after). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in no fingerprint.
2026-10-16 14:17:00 -04:00
ScottW514 5bcc536853 forgetest: the lint's step parser keeps a step's real key column and reads a quoted key
workflow_steps rebuilt the first line of a step with its first key two
columns past the dash, whatever followed the dash. A step written with
more than one space after its dash (`-   run: ./build/tray_test`, its
`if: false` at the column of the run key) then had its first key at one
column and the others at another: every key after the first was read as
a child of the first and dropped, `if:`, `continue-on-error:`,
`working-directory:` and `with:` among them. So such a step under
`if: false` counted as running its host test, and such a checkout under
a condition counted as a checkout. _KEY_RX did not read a quoted key,
so a step's `"if": false` was dropped the same way. Both spellings are
valid YAML that GitHub skips or tolerates.

The parser now replaces the dash with a space and realigns nothing, so
the key column of a step is the column its keys use. _KEY_RX reads a
key in single or double quotes as the same key. The HOST_TESTED comment
states the rule the lint applies: no `if:` other than `true` and no
`continue-on-error:` other than `false` on the step or its job.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
extended by a step with three spaces after its dash under `if: false`
and a step under a quoted `"if": false`, neither running the test.
Negative controls on scratch copies: the first line rebuilt as before
fails the three-space case; _KEY_RX as before fails the quoted case.
The 17 workflows of the tree's repositories (205 steps) parse to the
same steps and events before and after the change. The coverage lint
on the tree manifest from the recipe pins reports 0 uncovered paths,
0 host-tested row problems, 43 tests. pyflakes reports no undefined
name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 14:11:07 -04:00
ScottW514 c0c74d1658 laser_lifecycle_test: rule 3 names the holders of the sender-change reset
Rule 3 of the header says a sender change closes the window and a
reconnected client must re-arm. The harness does not walk the sequence
on from there, a new sender's soft reset of the held job, as a session
of its own (commit 09cf924 removed test_sender_change_then_reset), and
the header did not say where each assertion of that sequence lives.
The disarm and the latch relock at the change are laser_arm_test.c
case H (glowforge_laser.c: the client generation changing mid-cycle
enqueues the feed hold and disarms). The soft reset of the held job is
the core's reset from a completed hold, which session
hold-reset-restart walks under rule 10 with more asserted than the
removed case had: the reset banner, Idle with no alarm, and the
position kept. The hold's origin does not reach the reset path
(driver.c driverReset disarms and resets the stream whatever held the
job, and gflaser_disarm is idempotent), so the sender-change variant
is a permutation of that one branch, and this repository keeps one
proof per rule. Rule 3 now names both holders.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_lifecycle_test.py, and
the harness passes whole against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0), hold-reset-restart among
its sessions. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/laser_lifecycle_test.py build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-15 22:42:49 -04:00
ScottW514 b2a011ac08 ctlport_test: the polling sender is a Grbl sender, not a product
The NewlinePoller docstring and poll_case's named one sender product
as the sender that polls '?' with an end of line behind it, and quoted
that product's poll rate beside the harness's own. The behavior under
test is the controller port's: an empty line is not the sender
speaking, a port jog is never refused for a poll, and every poll draws
its one ok. Which sender polls, and how often, is not the contract;
the harness's own rate (four times a second, period 0.25) is the fact
that matters. Both docstrings now say a Grbl sender that polls, and
the comparative rate is gone.

Proof: docstrings only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/ctlport_test.py, and the file
names no product. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/ctlport_test.py build/grblHAL_glowforge).

Acceptance: none; docstrings of a harness outside every covers map.
2026-10-15 22:42:49 -04:00
ScottW514 68e9d3fb8e laser_stream_test: the rolloff reference pins the bench-default curve's rendering of S300 end to end
Rule 19 names laser_arm_test.c case P as the proof of the dose curve's
mapping. Case P calls curveComputeValue directly and reads its counts;
it does not reach the wiring from the S word to the stream:
spindleConfig installing the curve as the PWM compute hook, and the
level the hook returns riding the dither into FIRE density. A driver
that left the hook uninstalled passes case P and renders every cut
through the identity mapping, a different delivered dose at every S.

The rolloff reference session (rule 23) already runs one M4 line at
S300 under the shipped curve and reads its cruise density, to compare
against the two-cut run. It now also holds that density to the curve's
own point: 30 percent light through the bench-default curve lands
between its (45 percent density, 21 percent light) and (60, 37)
points, 53.44 percent density, level 68 of 127, density 0.535, held
to 0.02. The expectation is mirrored next to the curved configuration
the way the floor is, so a change to the curve changes it. The
header's rule 19 names the point. No session is added, and no session's
configuration changes.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the rolloff reference cruise
renders 0.5355 against the expected 0.5354, and the whole harness
passes, every session. Negative control, through a scratch copy of the
harness: the same cut with laser_dose_curve = off renders 0.3625 (the
identity mapping, level 46 through the 10 percent floor), 0.17 from
the point, and fails the assertion.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 f47373a20d laser_stream_test: check_density holds each lit span to the move, so a level applied late shows
check_density judges a density run by what its lit spans carry: every
power byte at full duty, each span's FIRE density at its level, no
burst past the base period. It did not read a span's length against
the move it rode. A level that reaches the stream late (the move opens
dark, FIRE starts where the level is applied) renders the right
density over a shorter span, and the density alone cannot see it:
with FIRE blanked in the first 40 percent of the S300 move of the
idle-s stream, the check passed. Rule 14 (a level set while idle
survives to the next run) and rule 12 (density rung by rung) proved
the density of whatever was lit, not that the whole move was lit;
rules 16 and 17 keep their span length through check_cut_spans.

check_density takes an optional move length in machine ticks and holds
each span to 0.9 to 1.1 of it. The three density call sites pass
theirs: the ladder sessions (density, floor-derived) LADDER_TICKS,
from the ladder's now explicit LADDER_FEED, and idle-s IDLE_S_TICKS.
Span and move are both in the mode's own ticks, so the bound holds at
every microstep mode. The bound is 0.9 to 1.1, not check_cut_spans'
0.8 to 1.25, because the real spread is about 1 percent (the faintest
rung, S20, opens on carried debt at 0.991 of the move; idle-s runs at
1.007) and the looser bound lets a move 15 percent dark pass. The
header's rules 12 and 14 say what they prove.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the density, idle-s and
floor-derived sessions pass the bound with span ratios 0.991 to 1.007,
and the whole harness passes, every session. Negative control on the
captured idle-s stream, through a scratch copy of the harness: FIRE
blanked in the first 40 percent of the S300 move (span ratio 0.604)
and in the first 15 percent (0.855) fail the bound, and the same
streams pass with the move length omitted; a 5 percent trim (0.956)
passes, so the bound is neither vacuous nor hair-trigger.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 199282c029 setup.focus: the head's place read from /status and judged at X0 Y0 before the burn starts
The focus burn answers the wizard's home prompt with HOME from a dict,
and the wizard takes the operator's word the same way: nothing reads
where the head stands. A read at that prompt places nothing either: the
wizard restarts the controller before it, and a fresh controller drops
the X/Y reference. The one read with meaning comes before POST
/wiz/laser.focus/start, under the campaign's own controller, when a
manual home references X and Y: with manual_home_x and manual_home_y
blank or 0, it is the one provider that declares X0 Y0 at the gantry
stops.

head_at_home(ctx), called after the ready gate and before anything
starts, waits up to 10 s for a /status that serves a position (a
transient miss is not a stop; ten seconds of none fails the test before
any energy), records the read as head_at_start (pos, homed_axes,
home_source, judged), and judges the head at X0 Y0 within HOME_TOL_MM
(0.05 mm) when homed_axes carries X and Y and home_source is manual:
off its home, the test fails before any wizard start, so no controller
lever is touched. Unreferenced, the head's place rests on the
operator's word at the ready gate, the log says so, and judged is
False. The HOME answer and the steps stay; the description names the
read. The strict form, a manual home before every focus burn, is a
procedure the operator must add to every campaign; this change records
and judges what it can read.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 22 tests OK
on Windows and on Linux, with tests/test_setup_dark.py (20 OK) and
tests/test_laser_dwell.py (10 OK). The scripted daemon's /status
carries homed_axes 7 and home_source manual: at X0 Y0 the burn runs and
the evidence carries the read with judged True; at X 12.5 the test
fails before any start, with no burn started, no /controller/ lever
posted, and the settings as found; unreferenced (homed_axes 4,
home_source startup) the burn runs with judged False. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py as it was before this change (the HOME answer alone),
the X 12.5 case burns (Failed not raised) and the two evidence cases
find no head_at_start; the three tests catch it.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other suite module imports from
.setup_burns, so nothing else moves. The next campaign runs setup.focus
with the head's place on record.
2026-10-15 22:42:49 -04:00
ScottW514 6a4ece4b0d setup.focus: the test reads the LASER_ON count and the tube current from sysfs itself at every poll of the burn
The focus burn's emission was judged from the burn's result alone: the
daemon's own 25 Hz reads of cnc/laser_on_sampled and pic/hv_current,
reported as laser_on_samples and hv_max. The one live catalog test of
the wizard's burn took the daemon under test at its word for the
emission it reported, where the laser.* tests judge from the test's own
/status samples.

run_check takes on_poll(doc), called once per poll of /wiz/dark with
the status document, a caller's own witness read beside the check's;
the default keeps every positional caller as it is. Witnesses, in
setup_burns, reads the two attributes from sysfs at every poll and
keeps each at its peak and the count of polls that read both. burn()
passes it to run_check, and emission_ok(ctx, last, seen) keeps the
result's three checks and adds the test's own: at least one poll read
both witnesses, the LASER_ON count read nonzero from here, and the tube
current read over HV_ON (30, the daemon's own rule for a lit burn,
jobstream.h JOBSTREAM_HV_ON) from here. Once the result's three checks
pass, the evidence carries the two peaks and the read count as
emission_seen; the two readings are not compared with each other. The beam sensor's handling is unchanged, and
the description names both readings.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 20 tests
OK on Windows and on Linux, with tests/test_setup_dark.py (20 OK) for
the positional callers of run_check. BurnRunTests plants
cnc/laser_on_sampled and pic/hv_current under GF_SYSFS_ROOT (the shape
of a bench record), the lit burn's evidence carries the two peaks with
reads over zero, and a burn whose result reports a lit burn while the
two attributes read 0 from here fails on the LASER_ON count, with the
setting put back and the controller restarted on it. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py and setup_dark.py as they were before this change, that
same case passes (Failed not raised) and the evidence has no
emission_seen; the two new assertions catch both.

Acceptance: setup.focus's fingerprint moves (its own text, and the
shared text of setup_burns.py and setup_dark.py), and every test of
setup_dark.py moves through run_check: setup.check-switches,
setup.check-airflow, setup.check-motion, setup.check-lens and
setup.cloud-header-capture. motion.release does not move (its run_check
import is inside the function body). The next campaign runs setup.focus
with both readings on the bench.
2026-10-15 22:42:49 -04:00
ScottW514 802cd3af8d laser.emission-witness: the LASER_ON count read through the rapid, and two windows past the close
The dark stretch after the M5 judged emission from the samples whose
/status carried a laser block. forgectrl leaves that block out of a
served /status when cnc/laser_on_sampled or cnc/laser_pgood_sampled
does not read, and sample() records the count as None, which the relit
judge (any sample with emission) and the HV judge (the maximum over the
readings) both pass: a stretch whose count went unread after the dark
point passed as dark. The kernel latches the count once per ~1 s window
and holds it through the next, so a window with no read in it is a
window in which a fire is not witnessed. The stretch also ended at the
first disarmed sample, before the count for the window in flight at the
close had latched, so the rapid's last second was never read.

read_gaps(samples, key) reports, over a trail, the samples that carry a
reading, the ones that do not, and the longest stretch of seconds
between two readings, with the trail's ends as edges: an unread lead or
tail is a gap, and a trail with no reading is one gap, its whole span.
The judge requires RAPID_MIN_READS (4) readings of the count with no
gap over EMISSION_READ_GAP_S (0.9 s), inside the EMISSION_WINDOW_S
(1.0 s) latch period, and the same floor of readings for the HV
current. Both checks come before the relit and HV-dark checks, so those
never judge an unread stretch. sample_to_disarm keeps sampling for two
windows after the first disarmed sample: the count for the window in
flight at the close latches up to one window later and is held through
the next, and the gap rule asks for a read of it in that tail,
measured to the last sample served. The timeout bounds the wait for
the close alone. The close is the first disarmed
sample, which the disarm timing and the span are measured to; the
evidence's rapid block carries emission_reads and hv_reads, the log
line says how many samples carried the count and the longest unread
stretch, and the description names the rule.

Proof: tests/test_laser_dwell.py RapidReadTests, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 10 tests OK
on Windows and on Linux: every sample read (gap 0.2 s), one miss
(0.4 s) and a 0.8 s hole are inside the rule; a 1.2 s hole, the block
gone after the dark point (1 of 25 read, gap 4.8 s), the block gone for
the last 1.6 s, and a one-sample stretch fail it. pyflakes reports no
undefined name. Negative control on a scratch copy: with the trail's tail dropped
from the edges, the last-window case and the block-gone case report no
gap and their two tests catch it; with the gap bound raised to 1.3 s,
the 1.2 s hole passes the rule and its test catches it.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), and laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text; no other
suite module imports .laser at module level. laser.emission-witness is
always run, and the next campaign judges the rapid stretch's read
cadence on the bench (about 5 Hz in the last campaign's record).
2026-10-15 22:42:49 -04:00
ScottW514 099399b58f forgetest-ci: the undefined-name step fails when pyflakes is missing or raises
The "No undefined names" step runs under bash -e with no pipefail, and
-e does not reach the condition of an `if`. The former body pipes the
pyflakes run into grep, so the step's status is grep's alone: with
pyflakes absent ("No module named pyflakes" on stderr, exit 1) or
crashing, grep sees no match, the `if` is false, and the step passes
with nothing checked. Its `pip install pyflakes` also names no
interpreter.

The step now installs with `python -m pip`, into the interpreter it
runs; runs `pyflakes --version`, which fails the step under -e when
the module is missing; captures the pyflakes run, stderr included, in
a file (its own exit status is 1 for any message, unused imports
included, so it cannot be the gate); and fails on "undefined name" or
on "Traceback", a crash. The grep is pinned to pyflakes' message text;
no pyflakes version is pinned.

Proof: the step body, read from this workflow, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12, against the
former body as the control. pyflakes missing: the former body exits 0,
this one exits 1. pyflakes 4.0.3 present, the tree as committed: both
exit 0 (22 unused imports and 2 unused locals, no undefined name). An
undefined name planted in a scratch copy: both exit 1. A pyflakes that
raises (a shim, scratch only): the former body exits 0, this one exits
1. In CI the step's log shows the pyflakes version before the check.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-15 21:13:05 -04:00
ScottW514 03146a1529 forgefirm-image: pack-licenses.sh refuses a license text tar cannot read
pack-licenses.sh packs the image's license tree into one tar.gz and
removes the tree. It piped tar into gzip under /bin/sh (dash, no
pipefail): the pipeline's status is gzip's, and tar's is lost. The
check that follows proves only that the bundle holds license.manifest.
So a member tar cannot read leaves a bundle without that text, the
build green, and the tree removed, the only copy of the text with it.

tar now runs the compressor itself (-I 'gzip -9 -n'), so its exit
status covers the read and the write. A failed pack removes the
partial bundle and the script refuses, with the tree left for the
build log; pack_licenses in forgefirm-image.bb fails do_rootfs on the
refusal. The bundle's bytes do not change: tar feeds the same stream
to the same gzip, and a tree packed by both forms gives one sha256.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests gain
test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left (a
text under zlib/ with mode 0: a nonzero exit, a message on stderr, no
bundle, the manifest left); the class's 5 tests OK. Negative control
on a scratch copy: the new case against the script without this change
fails at assertRefused with an exit status of 0. At the shell, on a
fixture tree with the unreadable member, the script without this
change exits 0, leaves the bundle without the text and removes the
tree; with it, the script exits 1, leaves no bundle and leaves the
tree. On a readable tree both forms write byte-identical bundles (one
sha256 for both). pyflakes reports no undefined name.

Acceptance: every catalog test. pack-licenses.sh is under
meta-forgefirm, whose content hash is part of the platform in every
fingerprint, so the image that carries this change needs the whole
catalog on the bench.
2026-10-15 21:12:04 -04:00
ScottW514 852943e20c forgetest: the fixture's press docstring stops inviting a long press
Fixture._press said that a check that must reach the machine while the
button is down asks for the longest press the box can make, and the
unit test's comment repeated the invitation. That contradicts the
design: no press is ever held, every reader of the button catches the
default press (an edge, or a poll of 100 ms or less), and the box
clamps any request into 20 to 500 ms. The docstring now says what `ms`
is (the box's own 200 ms when it is not said, clamped by the box) and
that no check needs a longer press. The `ms` plumbing stays: it
mirrors the box's API field, and the box clamps regardless. The test's
pass-through assertion stays as the client's contract that a request
reaches the box as asked; the clamp is the box's, held by the fixture
firmware's policy test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12: the
ClientTests of test_fixture.py, 6 tests, OK. A grep for "longest
press" and "while the button is down" over fixture.py and
test_fixture.py prints nothing. Text only, so no negative control
applies. pyflakes reports no undefined name.

Acceptance: none. fixture.py is a non-suite module outside the shared
judges, and tests/ are in no fingerprint; no press length or path
changes.
2026-10-15 21:09:29 -04:00
ScottW514 a596767a9a forgetest: a press the fixture could not make leaves a notice that comes down at the press
press_now and arm_press hand the arm press to the operator when the
bench actuator refuses it (the box is off) or when the button never
lights for it within the arm wait, and put the notice up for a person.
Those three fallbacks raised the notice without the watch that takes
it down at the press, so the notice stood until the test's next step:
the second-press hazard the watch exists for, because a press during
the job pauses it. Each fallback now calls _clear_when_taken() after
its notice, so the notice comes down the moment the controller takes
the press, as the operator's own path does. No press, judge, or energy
path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12:
test_fixture.py's test_the_arm_notice_comes_down_when_the_press_is_taken
gains the leg where the fixture cannot press: press_now's notice for a
refused press, arm_press's notice for a refused press, and arm_press's
notice for a button that never lit each come down within 2 s of the
press. Three runs OK, the file's 21 tests OK. Negative control on a
scratch copy: the leg against the runner without the three calls fails
at the first press, the notice still standing. pyflakes reports no
undefined name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f
moved all 43 once. tests/ are in none.
2026-10-15 18:06:36 -04:00
ScottW514 3d2f2d9ab4 forgetest: the arm-notice watch ends with its notice and the run
_clear_when_taken takes the operator's arm notice down the moment the
controller takes the press (the button lights for the arm wait and
goes out at the press). Its watch thread polled the button LEDs every
50 ms with the arm's timeouts as its only exits: 30 minutes for the
button to light, 90 minutes in all. Nothing in it read the run's end,
the abort event, or whether the notice it guarded still stood, so a
test that ended before the press (a FAIL on the way to the arm) left a
thread reading up to six sysfs files twenty times a second behind it.

The watch now lives only as long as the notice it guards and the run:
a notice cleared or replaced, a run that finished, or an abort ends
it. `standing()` reads both under the run's lock on every poll, and
the poll waits on the run's abort event instead of sleeping, so an
abort ends the wait at once. The cadence stays 50 ms: the notice still
drops within 50 ms of the press. The watch commands nothing; no press,
judge, or energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12:
test_fixture.py's test_the_arm_notice_comes_down_when_the_press_is_taken
gains the leg that a run finished before the press ends the watch
within 2 s and leaves the finished run's notice as it was; the file's
21 tests pass; test_setup_burns.py, whose burns reach the watch
through press_now, passes its 19 tests and leaves no
forgetest-arm-notice thread behind (10 before this change); and
test_server.py, test_operator.py and test_queue.py pass. Negative control on a scratch
copy: the new leg against the unchanged runner fails with the
forgetest-arm-notice thread still alive after the run finished.
pyflakes reports no undefined name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f
moved all 43 once. tests/ are in none.
2026-10-15 18:01:31 -04:00
ScottW514 850498fb00 forgetest: the shared judges join every test's implementation hash
Every catalog test judges the machine through package modules it never
imports: the Context a test is handed and the takeover (runner.py), the
sysfs, LED, switch and HTTP readers behind it (hw.py), and the
fresh-boot idle state judged around every run (baseline.py). The cloud
and extension tests print the dark job of puls.py, which the suite
imports at level 2, and the sibling walk follows level-1 imports only.
So none of the four was in any fingerprint: a weakening of sysfs_int,
button_lit, Context.check, wait_for or position_quantized moved no
hash, and an inherited PASS of kernel.latch-locked-idle,
laser.armed-kill or any takeover test survived it unseen by the gate.

catalog.py names them in SHARED_JUDGES, with the criterion in the
comment above it: a module every test runs through without importing
it, or the dark job the cloud tests print. The rest of the package (the
server, the page, the campaign rules, the artifact, the manifest, the
registry, the bench actuator) judges nothing about the machine and
stays out. shared_judge_paths() resolves the four next to the module,
and implementation_sha folds their text after the module's shared text
and its imported siblings. A listed file that is missing raises, never
a hash computed without it. _direct_siblings stays level-1 only, and
the tool version does not move: the gate judges by source_sha and the
fingerprint.

Every catalog test's implementation hash moves once with this change,
so every recorded PASS is stale and the next campaign is a full one, 43
tests. The catalog hash does not move, so an open campaign stays open
with every test required. From here on, an edit to runner.py, hw.py,
baseline.py or puls.py moves every test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, with
test_a_shared_judge_edit_moves_every_test (an edit to a judge moves
both tests of a module, a package file outside the list moves neither)
and test_the_shared_judges_exist_and_take_every_package_import_of_the_suite
(the four files exist next to catalog.py, and every level-2 import of
a suite module is a listed judge or the registry). Negative controls
on scratch copies: `judged = []` in implementation_sha fails the first
case alone; "runner.py" dropped from SHARED_JUDGES fails the guard
alone, naming homeoff.py's import of runner. On the real catalog the
source_sha of 43 of 43 tests differs from HEAD's; with hw.sysfs_int
weakened to return the latch bit on an unparsable read, 0 of 43 move at
HEAD and 43 of 43 move with this change. The coverage lint on the tree
manifest from the recipe pins reports 0 uncovered paths, 0 host-tested
row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: every catalog test. The implementation hash of all 43 moves
once, so the next campaign runs the whole catalog.
2026-10-15 17:54:06 -04:00
ScottW514 4d8f0d2e67 forgetest: the lint counts a host test as run only by a step every run of the workflow runs, on push or pull request
A HOST_TESTED row names a workflow and a host test, and the coverage
lint judges the row by what the workflow's steps run. The parser stored
a step's `if:` and `continue-on-error:` and read neither, read no job
key, and read no `on:`. So a step under `if: false`, a step whose
failure the run tolerates, a job under an `if:`, and a workflow on
`workflow_dispatch` alone all counted as running the test, and a
checkout under a condition counted as a checkout: a disabled or
tolerated host test keeps its row green and its paths covered.

The lint now reads the workflow's events and the conditions of a step
and of its job. `workflow_triggers` returns the events of the `on:`
mapping, list or scalar. What is under an event (its branches, its
paths, a dispatch's inputs) is not an event, and a flow mapping names
none. A row's workflow must run on push or pull_request, or the lint
reports that it runs on no push or pull request: a dispatch or a
schedule alone runs on no change, and a call alone is listed as well,
since the lint does not follow it to its caller. `workflow_steps`
tracks the job
each step is in and carries the job's `if:` and `continue-on-error:`
on the step as `job`. `_on_every_run` counts a step only when neither
it nor its job carries an `if:` other than the literal `true` or a
`continue-on-error:` other than the literal `false`. `workflow_runs`
and `checkout_path` use it, so a conditional step runs nothing and a
conditional checkout is no checkout. The reason for such a step stays
"does not run it". `needs:` is not read, nor are the `paths:` and
`branches:` filters of an event: the lint stays a floor there.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 36 tests, OK, with the
two new cases, test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
and test_the_events_a_workflow_runs_on, and with
test_the_workflow_has_to_run_the_host_test extended by a dispatch-only
workflow and a tolerated step. Negative controls on scratch copies:
`_on_every_run` forced to return True fails the step case and the
host-test case (failures=2); the trigger check deleted from
host_row_problems fails the host-test case (failures=1). The coverage
lint on the tree manifest from the recipe pins reports 0 uncovered
paths, 0 host-tested row problems, 43 tests. pyflakes reports no
undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-15 17:52:16 -04:00
ScottW514 f165713d05 forgetest: button.c and led.c leave the host-tested rows for the covers of the tests that drive them
HOST_TESTED carried forgectrl's src/button.* and src/led.* against
host_setup.sh, which presents the button as a held word in a fake
switches file and no LED at all. Both are driven on the machine by
catalog tests that still run: the fuse view forgectrl.auth reads is
two-factor, the token and the physical button (main.c cb_fuse_identity
through operator_present, button.c); the setup checks' press prompts
read the button and light it through led.c, and setup.check-switches
reads both back. manifest.py's own rule puts a path a catalog test
exercises on the hardware in that test's covers, not in a row: in a row
it is in no fingerprint, so a change to the button's poll, its debounce
or the LED release moved nothing and every PASS was inherited past it.

- manifest.py: the two rows go (src/hooks.h stays: main.c's hook
  declarations, proven through the setup routes on a host).
- suite/forgectrl.py: src/button.* joins _COVERS_AUTH.
- suite/setup_dark.py: src/button.* and src/led.* join DARK_COVERS, the
  covers of every setup check.

The laser tests judge the LED by the target the controller writes
(glowforge_laser.c button_led), so led.c is not theirs.

Proof: the coverage lint, enforced, on the tree manifest from the
current pins: 0 uncovered paths, 0 host-tested row problems, 43 tests.
tests/test_manifest.py (34) and the unit suite pass.

Acceptance: the fingerprints of forgectrl.auth and of every setup check
move; a release campaign runs them again.
2026-10-15 16:18:51 -04:00
ScottW514 80ddd53bcd laser_stream_test: rule 8 reads a FIRE tick's distance from a step, so a dwell burn shows under the dither
check_fire_gaps counted consecutive stepless ticks carrying FIRE and
reset on any dark tick: the analog rendering of a fire state leaked
into a cycle's idle pad (stepper_stream.c gf_stream_go_idle names it:
a stationary dwell burn at every planner starve). Under the density
model every tick goes through the dither mask, so the same leaked
state renders as bursts of at most one period (80 ticks at S800)
split by dark ticks, and the run never reaches the limit. With the
analog churn session gone, density-churn was the only session on rule
9, and it could not see the leak it was there for.

The check now reads rule 8 both ways: the run of lit stepless ticks as
before, and for every FIRE tick its distance from the nearest step on
either side, the one before it and the one after it. A cut's first
FIRE tick leads its first step by the accel-from-rest interval (412
ticks at x32) and its last trails its last step by less, both inside
the limit (1000); a FIRE tick with no step that near on either side is
in a gap, and lit, however the dither spaced it. The distance is read
from both sides because a stream opens dark on its lead-in pad and a
cycle opens lit before its first step: depth into a stepless run alone
flagged the m4 session's first lit tick, 22529 ticks of dark pad after
the stream start and a few hundred before the first step.

Proof: on synthetic streams, a legitimate 2253-tick dark gap between
two cuts passes both readings; a continuous analog leak across it
fails both; the same gap carrying a dithered leak at S800 (64 of 80
ticks lit) and at S200 (16 of 80) passed the old reading and fails the
new. The whole harness passes against the native null-sink build
(-DGFMCODE_WAIT_S=5.0), every session.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 16:18:06 -04:00
ScottW514 c3da9fa1bd Catalog: setup.account-login, motion.cancel-abort, forgectrl.settings-bounds and exthost.service leave; the rootfs lint takes image.health's file checks
The catalog is 43. Each test leaves for a host proof that its
component's CI runs at the pins:

- setup.account-login: the login rules are host_routes.sh's (forgectrl
  6c2bb38); the render is test_image.py's new AccountRenderTests, which
  runs the layer's forgefirm-users.init with reload in a private mount
  namespace as root (a fresh /run, the scratch account files over /etc,
  the scratch data directory, root's, over /data): the data directory
  found 0700 is 0711 after the render, and as the account's own uid the
  home is entered and written while listing the data directory and
  reading the users record are EACCES. A control without the script's
  chmod go+x fails it (700 != 711). forgectrl.auth's cover widens to
  src/ui/login.*, since login.html had no other cover.
- motion.cancel-abort: laser_lifecycle_test.py's new rule 17 on the
  null-sink controller: 0x85 stops a jog short of its target, Idle; ^X
  mid-move lands in Alarm with the position kept and no return to the
  job start; $X recovers to Idle and the next jog ships steps (counted
  in GFSINK_DUMP). It passes on the driver at its pin, badc832.
  motion.deadman requires motion.pacing in its place.
- forgectrl.settings-bounds: host_routes.sh refuses the out-of-range and
  over-length values with the settings file byte for byte the same, and
  the lid lamp under GF_SYSFS_ROOT follows lid_lamp_idle (forgectrl
  6c2bb38).
- exthost.service: forgeext's host tests at the pinned bb75b23 -
  sandbox_test (the confinement from inside, the pulse device, the
  settings file and the setup record refused), run_test.py (the API,
  safe mode, the master switch, and the killed host under the init
  script's own wrapper), install_test.py and sdk_test.py.
  exthost.armed-freeze requires exthost.platform in its place.

HOST_TESTED rows for each path the lint named once exthost.service
left: forgectrl src/logs.* to logs_test.c and
docs/advisories/extensions.md to host_setup.sh; forgeext src/api.*,
caps.*, evfeed.*, holdkeep.*, httpreq.*, manifest.*, quota.*,
sandbox.*, settings.*, state.* to their unit tests, netrules.* to
netrules_test.py, call.* to sdk_test.py, install.*, pkg.* and index.* to
install_test.py, and main.c, fflog.* and CMakeLists.txt to run_test.py.
test_manifest's behavioral-path case is unaffected.

The rootfs lint takes image.health's file checks (rules init-order,
logging, watchdog, read-only, dhcpv6, and on a release image factory),
each proved in RootfsLintTests by a drift that fails it. image.health
keeps the live half: the rendered issue and motd against the running
manifest, one host and no pool process, watchdog0, no userspace
watchdog running, the mounts, the dev image's factory slots read-only,
no DHCPv6 client running, the DHCP client's -b and parent, ifup, the
getty, and IPv6. release.sh drops its PermitRootLogin and
PermitEmptyPasswords greps, its ROOTFS_READ_ONLY check and its /factory
fstab check, which the lint holds; it keeps the empty root shadow
field, / mounted ro, and the three HostKey lines under /data.

setup.py registers no test now: its helpers the deleted tests alone
called go (tls_base, tcp_open, mode, press_status, wiz_summary,
wait_settled, the login constants), and test_setup_suite.py keeps the
cases of what remains.

Fingerprints: image.health's moves (its text), every test in
exthost.py, forgectrl.py and motion.py moves with its module text, and
the layer content hash moves with rootfs-lint.sh: the next image
inherits no campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (43 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(334), test_image.py's 28 among them. The lint passes image
20261015142442's release tree as release and its dev tree as dev, and
refuses the dev tree as release (sshd, factory).
laser_lifecycle_test.py passes whole on the null-sink build of badc832.
2026-10-15 14:02:20 -04:00
ScottW514 56aa164708 Pin forgeext 0.1.4 on the pushed head
- forgeext e09f7b8 -> bb75b23 (0.1.3 -> 0.1.4): sandbox_test opens the
  machine's own files from inside the sandbox - /dev/glowforge,
  /data/forgefirm/forgefirm.conf, /data/forgefirm/setup.json - and
  requires EACCES of each (ENOENT is no pass; a file the host lacks is
  made for the run, readable by every account, and removed after).
  Test-only; nothing the image installs changes.

Proof: forgeext's CI run on bb75b23 (38780750106) is green on a kernel
with Landlock ABI 7; its log shows the three files made for the run and
each open refused with EACCES (sandbox_test, 0 failures). bitbake -c
fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:57:52 -04:00
ScottW514 df5ae151ab Pin forgeext 0.1.3 on the pushed head
- forgeext 31cdb40 -> e09f7b8 (0.1.2 -> 0.1.3): run_test's last phase
  runs the host under init/forgeext.init's own wrapper; a SIGKILL of the
  host takes its service with it at once, the wrapper starts the host
  again, and the service runs again under a new pid. Test-only; nothing
  the image installs changes.

Proof: forgeext's CI run on e09f7b8 (38777839870) is green on a kernel
with Landlock ABI 7, and the phase's six checks passed in it (run_test,
0 failures). bitbake -c fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:37:42 -04:00
ScottW514 10ae7bf39c Pin forgectrl 0.1.33 on the pushed head
- forgectrl 22a2cfc -> 6c2bb38 (0.1.32 -> 0.1.33): cam.c resolves the
  lid and head lamps under GF_SYSFS_ROOT (unset: /sys/glowforge/, the
  paths as they were), and host_routes.sh proves the lid lamp follows
  lid_lamp_idle: a level is written, a refused value writes nothing, a
  cleared key writes the default 236.

Proof: forgectrl's CI run on 6c2bb38 (38769524512) is green, and
host_routes.sh ran in it. bitbake -c fetch forgectrl from this pin
succeeds (2 tasks). The tree manifest from the pins holds forgectrl at
6c2bb38 (229 files); the coverage lint finds no uncovered path and no
row problem (47 tests); check-ui-vendor finds the shared UI files
identical; test_tree_manifest passes; the gate self-check refuses the
empty artifact (rc 1).
2026-10-15 12:50:22 -04:00
ScottW514 c40dc6c24c Catalog: setup.machine-name leaves for the rootfs lint and test_image.py
Its checks, and what holds each now:
- the name is forgefirm-<the last four hex digits of the MAC>, wlan0
  first and eth0 on a machine with no WiFi, and the console banner
  offers the addresses and nothing else: test_image.py's
  MachineNameTests run forgefirm-hostname and forgefirm-banner as the
  image runs them, on a scratch /sys/class/net;
- /etc/hostname is the bind-mounted copy: image.health;
- the DHCP request sends the name, and no mDNS responder is on the
  image: the rootfs lint's dhcp-name and mdns rules (08c7df9), which
  image 20261015142442 ran in do_rootfs and which passed campaign
  c-20261015143634-68f0, 48 of 48.
It covered no component path, so no HOST_TESTED row. Its helpers
mac_suffix and cmdlines had no other caller and go with it, with
test_setup_suite's cases of them and of its registration; setup.py's re
and lan_ip imports went with them.

Fingerprints: every test in setup.py moves with the module text.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (47 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(329).
2026-10-15 11:28:25 -04:00
ScottW514 08c7df9cbb rootfs-lint.sh: the image content fixed at build time, checked as the last rootfs step
A new script beside the image recipe, run by both images as the last
rootfs step (ROOTFS_POSTPROCESS_COMMAND:append, after every +=, the dev
recipe's and debug-tweaks' included); a refusal is bbfatal. It checks
every rule and prints every failure before it exits. FORGEFIRM_IMAGE_KIND
(release by default, dev in forgefirm-image-dev.bb) picks the sshd policy.
The rules:

- syslog: /usr/sbin/rsyslogd is on the image, /etc/init.d/syslog starts
  it and runlevel 5 starts that script; no syslogd or klogd is linked in
  a bin directory and no init script runs one. This is the image half of
  what logs.routing checked on the board; that test left in 01f5991 with
  nothing else holding it.
- sshd: sshd_config and sshd_config_readonly take passwords
  (PasswordAuthentication yes) and send no banner; on a release image
  PermitRootLogin no and PermitEmptyPasswords no; sshd_config.d sets none
  of the four, since an included value comes first. setup.ssh-until-
  reboot checked the first two on the board and left in 01f5991;
  release.sh gates the release pair at release time only.
- dhcp-name: the wlan0 dhcp stanza's udhcpc_opts carries
  -x hostname:$(hostname).
- mdns: no /etc/avahi, avahi-daemon or libnss_mdns, and no mdns source
  on nsswitch.conf's hosts line.
dhcp-name and mdns are the image half of setup.machine-name, whose
scripts test_image.py's MachineNameTests already run.

test_image.py's RootfsLintTests run it on a fixture tree: the tree that
holds every rule passes as either image, the BSP layer's own interfaces
file passes, and one drift per rule fails naming the rule (a busybox
syslogd link; a Banner, on either image; root and an empty password,
refused on a release image and passed on a dev one; a dhcp stanza with
no name; /etc/avahi).

Proof: test_image.py under Linux, 21 OK; pyflakes clean. On image
20261015134521's two rootfs trees from the build, the release tree
passes as release, the dev tree passes as dev, and the dev tree linted
as release is refused on PermitRootLogin and PermitEmptyPasswords in
both sshd files. bitbake -e places rootfs_lint last in both images'
ROOTFS_POSTPROCESS_COMMAND, with FORGEFIRM_IMAGE_KIND release and dev.
The layer content hash moves: the next image cannot inherit a campaign.
2026-10-15 10:19:54 -04:00
ScottW514 61652e237d Catalog: exthost.motion-job leaves, after it passed on forgeext 31cdb40
exthost.motion-job stayed in 01f5991 as the bench proof of
machine_host_token() on the default token path. forgeext 31cdb40, the
revision c177aab pins, makes the token file overridable
(FORGEEXT_HOST_TOKEN_FILE) and keeps /run/forgefirm/ext-host.token when
the variable is unset or empty. The recipe's binary from that revision
(sha256 15ca6003b59f69fb9eadf686094430fbbd25e7cc177230a0417738484fe3427e)
ran on the bench reference with the variable unset, and
exthost.platform, exthost.service, motion.jog-roundtrip and
exthost.motion-job passed on it (campaign c-20261015103714-0c91): the
package's job reached the machine as job:org.forgetest.reference through
the credential read from the default file.

Its paths, now:
- forgectrl src/jobpost.*: tests/jobpost_test.c, POST /job's multipart
  reader (row added).
- forgectrl src/tokens.*: tests/tokens_test.c, the scoped tokens and
  the extension host's credential file (row added).
- forgectrl src/jobrun.*: still covered by the burn tests.
- forgeext src/api.*, src/machine.*, src/run.*: still covered by
  exthost.service's forgeext/**.
Both rows name tests forgectrl's build.yml runs at the pinned 22a2cfc.
No other catalog test required exthost.motion-job. exthost.py's hw
import went with it.

Fingerprints: every exthost test moves with the module text. The next
image owes exthost.service and exthost.armed-freeze for forgeext's
source change.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (48 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(327).
2026-10-15 09:38:20 -04:00
ScottW514 c177aabefa Pin forgeext 0.1.2 on the pushed head
- forgeext 2d0391e -> 31cdb40 (0.1.1 -> 0.1.2): machine_host_token()
  opens FORGEEXT_HOST_TOKEN_FILE when it is set and not empty, and
  /run/forgefirm/ext-host.token otherwise (ebdbf59); the rest is host
  tests (run_test's sender claim, event stream, storage quota and
  memory.max; install_test's served page; the trimmed kit, ffx and
  allowlist cases).

Proof: the recipe's forgeext, built from 31cdb40 (bitbake forgeext
through an untracked externalsrc overlay; packaged /usr/bin/forgeext
sha256 15ca6003b59f69fb9eadf686094430fbbd25e7cc177230a0417738484fe3427e),
replaced /usr/bin/forgeext on the bench reference's image
20261015101725 and ran with no FORGEEXT_HOST_TOKEN_FILE in its
environment. exthost.platform, exthost.service, motion.jog-roundtrip
and exthost.motion-job passed on it, in campaign c-20261015103714-0c91:
the package's job reached the machine as job:org.forgetest.reference
through the host's credential read from the default token file, 6 lines
sent and acknowledged, the head moved, the latch locked and no emission
throughout. The flashed binary was put back after the run. bitbake -c
fetch forgeext resolves this pin.
2026-10-15 09:31:54 -04:00
ScottW514 01f59914e8 Catalog: forgectrl.panel-serves, setup.ssh-until-reboot, setup.first-run-flow and logs.routing leave
Each leaves for a host proof that forgectrl's build.yml runs at the
pinned 22a2cfc, and that run passed (38728407038):

- forgectrl.panel-serves: tests/host_routes.sh serves the panel page
  bundled, with the theme and the save bar and the policy frame-src
  'none', and answers /status, /cam/status and the dose-curve reads.
  Rows: src/ui.h.
- setup.ssh-until-reboot: tests/host_routes.sh, GET and POST
  /system/ssh with the flag in the run directory and sshd's init a
  stand-in told start and stop; enable=2 refused. Rows: src/paths.h.
- setup.first-run-flow: tests/host_setup.sh, the first run in the
  page's order: the four documents accepted at their hashes, the press
  step breathing teal on a scratch LED tree, the press taken as the
  acceptance, the account and its cookie, the gate's reason after each
  form step, the run completed. Rows: src/advisories.*, src/button.*,
  src/led.*, src/hooks.h and the four first-run documents, one row
  each (docs/advisories/extensions.md stays with exthost.service);
  src/sheetid.* to tests/sheetid_test.c, the golden ids, because
  host_setup.sh does not read the sheet id.
- logs.routing: tests/fflog_e2e.sh, the image's rsyslog.conf and the
  rendered rules, each logger in its own file at its level, a stray
  tag in system/, the kern facility in kernel/. Rows: src/fflog.*.

logs.py held logs.routing alone and goes with its import. The five
camera tests no longer require forgectrl.panel-serves.
test_setup_suite.py keeps setup.account-login and setup.machine-name in
its ids; the cases that described setup.first-run-flow (takeover,
fixture, covers) go, with the cases of the helpers that left with the
two tests (breathes_teal, first_run_record, sshd_policy).
test_manifest's behavioral-path case runs the report with no
host-tested rows: the real table now names the first-run documents,
and the case is about the allowlist.

exthost.motion-job stays. It is the only catalog test that grants
motion.job and posts /v0/motion/job, the path that reads
machine_host_token() on the default token file, and it stays the bench
proof of that path until an image carries forgeext's token-file change.
src/jobpost.* stays in its covers, with no row.

Fingerprints: every test in forgectrl.py, setup.py and camera.py moves
with the module text. The next image cannot inherit a campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (49 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes.
2026-10-15 08:48:40 -04:00
ScottW514 87155be433 Pin forgectrl 0.1.32 on the pushed head
- forgectrl c9e3347 -> 22a2cfc (0.1.31 -> 0.1.32): fflog_e2e runs its
  private rsyslogd from a copy of the binary, out of reach of Ubuntu's
  AppArmor profile for /usr/sbin/rsyslogd. The diff is
  tests/fflog_e2e.sh alone; nothing the image installs changes.

At c9e3347 the CI run stopped at fflog_e2e, so host_routes.sh and
host_setup.sh, later in the same job, never ran there. The rows the
next catalog commit adds name those two scripts and fflog_e2e.sh, and
the coverage lint reads build.yml at this pin, so the pin is the
revision whose run executed them.

Proof: forgectrl's CI run on 22a2cfc (38728407038) is green;
fflog_e2e, host_routes, host_setup and jobpost_test passed in it.
2026-10-15 08:13:41 -04:00
ScottW514 893602e44c Pin grblHAL-glowforge 0.1.21 on the pushed head
- grblHAL-glowforge b34636f -> badc832 (0.1.20 -> 0.1.21): the host
  tests trimmed to one proof per rule, with the cases the cut dropped
  back (the tray, session-budget and envelope clamps, the switch bits
  as numbers, an M5 then an M3 inside the armed window); a host build
  may shorten the M-code answer wait (-DGFMCODE_WAIT_S, CI's 5 s, for
  mcode_test.py's no-answer case) while the image keeps 30 s; the
  homing comment covers a setup step's hand-back. The driver the image
  builds is unchanged in behavior.

It follows 205f9f2 on its own because the driver's CI runs this
repository's harnesses from master, and its mcode_test expects the
5 s host build.

Proof: image 20261015101725 carried the driver built from badc832 (its
manifest's srcrev), and campaign c-20261015103714-0c91 passed all 53
catalog tests on it with none inherited. bitbake -c fetch
grblhal-glowforge from this pin succeeds (2 tasks). The tree manifest
from the pins holds grblhal-glowforge at badc832 (176 files); the
coverage lint over it finds no uncovered path, no empty entry and no
host-tested row problem (53 tests); test_tree_manifest passes. The
driver's CI on badc832, with this repository's harnesses at 205f9f2,
passes.
2026-10-15 07:41:32 -04:00
ScottW514 205f9f22f2 Pin forgectrl 0.1.31 and forgefirm-app 0.1.32 on the pushed heads, and meta-openglow in the kas lock
- forgectrl c274f19 -> c9e3347 (0.1.30 -> 0.1.31): setup is required
  up to the intake fans and recommended after, the coolant flow
  calibration is a required step, the TEC check gives way to the
  operator's answer, the head's id is head/info's "id" line, the
  intake fan count is decided in wizcalc, and the burns hand the lens
  back referenced; every start logs its firmware and slot, and every
  update job is logged; the beam sensor is recorded and logged; the
  panel's emission help parses again. The daemon starts on a host with
  its machine paths, release URLs and controller binaries from the
  environment, and the host cases for the routes, the tokens, the
  setup, the logs, the extension host and the cooling engine run
  through it in CI.
- forgefirm-app 021323d -> 0f59c54 (0.1.31+git -> 0.1.32+git): the
  python3-gfhardware head, host tests only; it moves with the BSP's
  python3-gfhardware pin.
- kas lock: meta-openglow f012d8e -> b7626be, the head that pins the
  kernel module f924cd9, python3-gfhardware 0f59c54 and gfutilities
  993f9e1.

grblHAL-glowforge keeps b34636f here: its CI runs this repository's
harnesses from master, and its head's mcode_test expects the 5 s host
build, so the driver is pushed after this and pinned on its own.

Proof: image 20261015101725 carried forgectrl c9e3347, the module
f924cd9 and this tree's forgetest, and campaign c-20261015103714-0c91
passed all 53 catalog tests on it with none inherited. bitbake -c fetch
of forgectrl, gfcloud, gfhome, python3-ffmachine, python3-gfhardware,
python3-gfutilities and kernel-module-glowforge from these pins
succeeds (14 tasks). The tree manifest from the pins holds forgectrl at
c9e3347 (229 files); the coverage lint over it finds no uncovered path,
no empty entry and no host-tested row problem (53 tests);
check-ui-vendor finds the shared UI files identical at c9e3347;
test_tree_manifest passes, and the gate self-check refuses the empty
artifact (rc 1).
2026-10-15 07:24:51 -04:00
ScottW514 7f2da68e34 Catalog: image.license-bundle and image.network-boot leave; the network boot's board checks fold into image.health
Each left after its host proofs passed, with the coverage lint clean at
the pins, its only failure the known setup.check-lens entry on
src/lensfind.*:

- image.license-bundle: the pack half is test_image.py's
  PackLicensesTests on pack-licenses.sh (forgefirm 0d63f87); the serve
  half is host_routes.sh's license cases, the manifest, the bundle and
  the Licenses page read with no token (forgectrl c9e3347). Its
  src/main.c stays covered by image.health, and its three pages by
  forgectrl.panel-serves (src/ui/**). The 40-package and 5-text counts
  leave with it.
- image.network-boot: the interfaces file's DHCPv6 check is
  NetworkBootTests' case on the BSP layer's file (forgefirm 0d63f87).
  The board checks move into image.health, unchanged: wlan0 in ifup's
  state file and no ifup or ifdown running; a getty on ttymxc0; at least
  one udhcpc, each with -b as its own word and parent pid 1; no udhcpc6,
  dhcp6c or odhcp6c running or on the PATH, and no
  /usr/share/udhcpc/default6.script; on wlan0 disable_ipv6 0, accept_ra
  neither empty nor 0, and a link-local address, the global address
  logged. image.health's description says so. The interfaces file
  carries no -b, so -b is checked here, on the client that runs.

No HOST_TESTED row: network-boot covered nothing, and license-bundle's
paths stay covered by the two tests that keep them. image.health's
covers are unchanged. The helpers ifstate_names, dhcp6_in_interfaces,
parse_stat, link_addrs, _procs and _read, and the module's constants,
stay as they were; only the two functions and the inside of
image_health changed.

Fingerprints: image.health's moved and no other id's (the module's
shared text, which every exthost test reads through its import of
kernel_config, is unchanged). image.health is always-required, so the
next campaign reruns it.

Proof: the coverage lint, 53 tests (forgectrl, src/lensfind.* its one
entry); pyflakes; forgetest's unit suite on Linux (python3 -B -m
unittest discover -s tests), 335 OK.
2026-10-14 21:46:58 -04:00
ScottW514 0d63f8740e pack-licenses.sh: the image's license step refuses a manifest without the project's recipes; the layer's interfaces file starts no DHCPv6 client
The host proofs of image.license-bundle's pack half and of
image.network-boot's interfaces check. No catalog test is removed here;
suite/image.py is unchanged.

pack-licenses.sh, next to forgefirm-image.bb, is the license step.
pack_licenses calls it with the common-licenses directory and the
output tar path (forgefirm-image-dev.bb requires the release recipe, so
both images run the one call), and do_rootfs's file-checksums name it.
Before it writes the tar or removes the tree, it exits non-zero with a
message on stderr when the directory is missing, when the manifest is
missing, or when the manifest lacks any of the newline-terminated lines
"RECIPE NAME: forgectrl", "RECIPE NAME: grblhal-glowforge" and "RECIPE
NAME: kernel-module-glowforge". On success it packs as before: tar
--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner, gzip -9 -n,
the member common-licenses/license.manifest, and the loose tree removed
once the bundle holds the manifest.

test_image.py's PackLicensesTests drives that script on a fixture: a
missing directory, a missing manifest, and a manifest missing each one
of the three lines are refused with a message on stderr and no tar, the
tree left; a manifest naming all three packs, the tar holds
common-licenses/license.manifest, and the tree is gone.

NetworkBootTests reads the BSP layer's interfaces file from the
meta-openglow checkout beside forgefirm's (forgetest-ci checks out its
scarthgap branch there) and finds no line that starts a DHCPv6 client.
A missing file fails the case.

meta-forgefirm's content hash moved (forgefirm-image.bb and the new
script are layer content, and the platform identity of
forgefirm-image-manifest.bbclass leaves out only *.md, *-pin.inc and
forgefirm-release.inc): the next image cannot inherit a campaign.

Proof: python3 -B -m unittest discover -s tests -p test_image.py on
Linux (Ubuntu 24.04, GNU tar 1.35, meta-openglow at f012d8e): 14 OK, the
five new cases among them; pyflakes names no undefined name.
2026-10-14 21:42:12 -04:00
ScottW514 558889659c setup_burns: the module docstring describes setup.focus alone
setup.floor left the catalog in f9c298a, and the docstring still named it:
the second burn, the floor needing Focus on the record, the faintest
rung, and each wizard. It now describes the focus burn alone; the focus
burn's own description is unchanged.

The docstring is the module's shared text, so setup.focus's fingerprint
moves again; f9c298a had already moved it. No other test's fingerprint
moves (Test.source_sha compared before and after: setup.focus alone).

Proof: test_setup_burns OK; pyflakes; the coverage lint, 55 tests.
2026-10-14 21:19:46 -04:00
ScottW514 4c6ca8d701 test_setup_suite: only registered tests named
The file named setup.mirror (gone at 70e13bd) and setup.first-run-page,
setup.check-sensors, setup.check-intakes and setup.floor (gone at
f9c298a), so it failed in forgetest-ci's discover run. Every id, cover,
requires edge and attended list it asserts is now a registered test:

- IDS and OPERATOR: account-login, ssh-until-reboot, machine-name and
  first-run-flow; the four assertions on ids long folded into others go
  with them.
- the takeover check names first-run-flow; the mirror's covers check goes.
- the intake precheck: setup.check-airflow.
- first-run-flow: its fixture run and its backend covers, without the
  page's half.
- the attended setup tests: setup.cloud-header-capture and setup.focus.

It also puts tests/ on sys.path before importing helpers, as the other
suite tests do, so python -B -m unittest tests.test_setup_suite runs from
forgetest/.

Proof: python -B -m unittest tests.test_setup_suite and discover -p
test_setup_suite.py, 30 OK under Linux and on Windows; pyflakes.
tests/** is in no fingerprint.
2026-10-14 21:13:56 -04:00
ScottW514 f9c298a347 Catalog: eleven tests leave for their host proofs
Each left after its host cases passed (forgectrl 1dc85f6) and with
the coverage lint clean at the pins, its only failure the known
setup.check-lens entry on src/lensfind.*:

- forgectrl.tokens: host_routes.sh's scoped tokens (a route refused to a
  token not given it, neither token in the list or the store), with
  tokens_test and auth_scoped_test.
- update.product-gate: host_update.sh's product gate on the upload, with
  fwproduct_test.
- setup.first-run-page: tests/wizard_walk.py.
- setup.check-sensors, setup.check-intakes, setup.check-cameras,
  setup.check-flow-verify, setup.check-envelope, setup.floor:
  host_setup.sh's checks and the floor burn, with wizcalc_test.
  envelope_test.py's cases are unchanged. setup.floor's emission stays
  with setup.focus and laser.emission-witness.
- motion.job: jobpost_test, with jobrun_test and lease_test; jobpost.* is
  covered by exthost.motion-job, which stays.
- exthost.motion-jog: host_routes.sh's credential case.

Two HOST_TESTED rows, each a binary forgectrl's build.yml runs at the pin
c274f19 that proves the path the lint named: src/fwproduct.* to
fwproduct_test, src/grblport.* to grblport_test.

exthost.motion-job now requires motion.jog-roundtrip in place of
motion.job. bedsize.py held setup.check-envelope alone and goes;
forgectrl.py loses _words_of and its hw import, motion.py the job
runner's helpers, setup_burns.py FLOOR_KEY and FLOOR_KEYS.

forgetest's own tests: test_setup_dark.py loses the sensors and intakes
judges and the departed ids; test_setup_burns.py loses the floor's cases,
and the guards burn() and preview_ok() hold for the focus burn (the
program's tail and reach, the thickness in inches and the one refused, a
prompt not answered, the witnesses) run on setup.focus.

Stay: forgectrl.auth (the fuse view's button refusal on the real
button); forgectrl.settings-bounds (the lid lamp: cam.c writes
/sys/glowforge/pic/lid_led, not under GF_SYSFS_ROOT); forgectrl.panel-serves
(src/ui.h, which no binary at the pin proves; Z inside the lens reach is
setup.check-lens's); logs.routing; setup.account-login (the account's
render and its home, image content); setup.ssh-until-reboot
(src/paths.h); setup.machine-name (udhcpc -x hostname and no mDNS
responder, image content); setup.first-run-flow (src/button.*, led.*,
advisories.*, hooks.h and the four advisory documents); exthost.motion-job
(jobpost.*, which no binary at the pin proves).

Proof: the coverage lint, 55 tests; pyflakes; test_setup_burns,
test_setup_dark, test_takeover_position and test_image (54 OK).

Shared text moved the fingerprints of exthost.armed-freeze,
exthost.platform, exthost.service, forgectrl.auth,
forgectrl.panel-serves, forgectrl.settings-bounds, laser.armed-kill,
laser.emission-witness, laser.pause-resume-lid-cancel,
motion.cancel-abort, motion.deadman, motion.jog-roundtrip,
motion.liveness-probe, motion.microstep-modes, motion.pacing,
motion.release, motion.step-timing-under-load, setup.account-login,
setup.check-airflow, setup.check-lens, setup.check-motion,
setup.check-switches, setup.cloud-header-capture, setup.first-run-flow,
setup.focus, setup.machine-name, setup.ssh-until-reboot and
update.slots-and-signature; exthost.motion-job's own text moved with its
requires.
2026-10-14 21:03:58 -04:00
ScottW514 754c239e08 test_image: the hostname and the banner scripts, run over a scratch tree
MachineNameTests runs the layer's own forgefirm-hostname and
forgefirm-banner in private mount, UTS and network namespaces: /run a
fresh tmpfs, a scratch directory over /sys/class/net, /etc a read-only
overlay with the scratch hostname and issue over the host's (so each
script makes its copy under /run/forgefirm and writes through its own
bind mount, as at boot). unshare -Urmun where user namespaces are open,
sudo -n unshare -mun otherwise; skipped only where neither makes a
namespace.

- the name: wlan0's last four hex digits (forgefirm-12ab with eth0 beside
  it), eth0's with no wlan0, eth0's with a zero wlan0 MAC; the live name
  and /etc/hostname agree.
- the banner: the image's text, then the heading and one URL per global
  address of wlan0 (a veth carrying 192.0.2.5, 2001:db8::5 and fe80::5):
  https://192.0.2.5/ and https://[2001:db8::5]/, the link-local address
  left out, no marker line, no .local name.

Proof: test_image.py under Linux by both namespace routes (9 tests OK),
and on Windows with the two skipped. Mutants of scratch copies of the
scripts, each failing: eth0 read before wlan0, a zero MAC taken, the file
written without the script's bind mount, a marker line in the banner, a
.local URL, the link-local address kept.

setup.machine-name stays: the DHCP client's -x hostname and no mDNS
responder on the image are image content. Nothing under meta-forgefirm
changed.
2026-10-14 21:03:33 -04:00
ScottW514 b3a40b4aea Catalog: twelve tests leave for their host proofs; image.health reads the board temperatures
Each test below leaves for the host cases that now hold it, every one
run and passing before this commit:

- cooling.fans-quiet-after-motion, cooling.gate-off (its engine half),
  cooling.floor-and-warm-up, cooling.tec-drive, cooling.fire-watch-tiers,
  cooling.crash-watch-plumbing, cooling.critical-tier and
  cooling.fan-gate-trips: forgectrl's cool_flow_test and cool_gate_test
  (forgectrl 8e9cc0a). The jog and M8/M9 report stays in grblHAL's
  cooling_test.c and cool_report_test.py; real heater climb, a lit tube,
  tach spin-down and fans meeting their floors stay with
  cooling.flow-verify, cooling.flow-under-load, cooling.fail-tier-stop,
  cooling.aa-offset-calibrate and setup.check-airflow.
- motion.gate-waits-for-lid: super_test D2 (forgectrl 8e9cc0a). The
  physical lid read stays with setup.check-switches.
- motion.lid-cancel-home: laser_lifecycle_test's hold-lid-cancel (forgefirm
  107676b). check_kernel_returned stays in laser.pause-resume-lid-cancel's
  live lid phase.
- homing.manual: manual_home_test.py cases 1 and 7, and status_test's
  anchor readback (forgectrl 8e9cc0a).
- laser.recorder-dark: curverec_test (forgectrl 8e9cc0a). The dark arm
  wait stays test_button_wait_arms.

cooling.gate-off's board half moves into image.health: /status
temps.chassis_c from 5 through 80, temps.soc_c from 10 through 100,
temps.supply_raw an integer from 0 through 1023, and temps.soc_throttle
0. image.health's covers gain src/status.*, which serves them. The next
campaign reruns image.health (it is always run).

Stays: motion.cancel-abort (its host case fails on the abort's cnc/stop:
the null-sink controller writes cnc/stop only with a device open),
exthost.service (its init-script case has not passed on unmodified code
here), exthost.armed-freeze (its armed-capture decision has no host
proof), exthost.motion-jog (the package's jog through the host credential
has no host proof), exthost.motion-job (its POST /job reader is step 4's
jobpost_test.c).

Requires re-pointed off the tests that left: setup.check-airflow and
setup.check-intakes no longer require cooling.fan-gate-trips,
laser.pause-resume-lid-cancel no longer requires motion.lid-cancel-home,
cooling.fail-tier-stop no longer requires cooling.crash-watch-plumbing,
and exthost.armed-freeze no longer requires cooling.floor-and-warm-up.
cooling.py's docstring now describes the four cooling tests that stay.
The helpers only the departed tests used go with them, and laser.py
imports only the motion helpers it still calls.

Proof: the coverage lint on the tree manifest reports 0 uncovered paths
and 0 host-tested row problems across 66 tests; its one failure is
setup.check-lens's src/lensfind.* entry, which clears with the forgectrl
pin. No HOST_TESTED row was needed. The catalog validates; pyflakes
reports only the two unused locals that were there before
(cooling.fail-tier-stop's t0, update.py's plain);
test_cooling_order, test_cooling_suite, test_image, test_laser_dwell,
test_camera_home_return, test_setup_dark, test_setup_burns,
test_kernel_suite and test_cloud_suite pass.

Acceptance cost: the catalog goes from 78 tests to 66. Implementation
hashes move for cooling.aa-offset-calibrate, cooling.flow-verify,
cooling.flow-under-load and cooling.fail-tier-stop (cooling.py's shared
text); motion.pacing, motion.jog-roundtrip, motion.microstep-modes,
motion.liveness-probe, motion.cancel-abort, motion.deadman,
motion.step-timing-under-load, motion.release, motion.job,
laser.armed-kill, laser.emission-witness, laser.pause-resume-lid-cancel
and setup.check-envelope (motion.py's and laser.py's shared text, and the
modules that import motion.py); image.health, setup.check-airflow,
setup.check-intakes and exthost.armed-freeze (their own text).
2026-10-14 19:33:43 -04:00
ScottW514 107676b77d laser_lifecycle_test: the lid cancels a job held on the button
Rule 16, hold-lid-cancel: a travel job held by a press, then the lid
opened. The controller reports "lid opened - job canceled", prints the
reset banner, raises no alarm, and returns the head to the job start
(MPos X within 0.05 mm of where the job began); a press once the lid is
closed again resumes nothing (Idle, the position unchanged).

This is the part of motion.lid-cancel-home no host test held: the lid
during Run is test_lid_cancels_and_returns, and the kernel playing the
return stays with laser.pause-resume-lid-cancel's live lid phase
(check_kernel_returned).

Proof: the whole harness passes on the null-sink build as grblHAL's CI
builds it (-DGFMCODE_WAIT_S=5.0): every earlier case, then
hold-lid-cancel, lid-hold and sigterm-mid-job.

Acceptance cost: none. scripts/bench/ is outside every fingerprint.
2026-10-14 19:09:00 -04:00
ScottW514 6594f57362 Catalog: motion.port-jog and motion.tray leave; two leftovers of the audit
Two catalog tests leave for the host proofs that carry them; the catalog
goes from 80 to 78 (78 @test in forgetest/forgetest/suite/).

- motion.port-jog. forgectrl's tests/host_routes.sh (7b2f629 on e69d967)
  runs the controller port's routes against the port's stand-in: a jog
  refused without the token, a jog that is no number 400 with nothing on
  the port, a numeric jog on the port as G91 G21, cancel, release and
  energize each one line, GET /motion/state passing the port's state on
  with state and sender, GET /motion/sender 200. The port beside a
  sender, its polls, the cancel, the client first and the bound are
  grblHAL's ctlport_test.py and forgectrl's grblport_test; motion.release
  keeps the physical currents. The helpers before it (_port_state,
  _port_idle, _words) stay: bedsize, laser and motion.job read them.
- motion.tray. host_routes.sh holds POST /motion/tray (tray=in on the
  port, tray=up 400) and the panel page's switch (id="traybtn",
  trayToggle, /motion/tray); grblHAL's tray_test.py the mode, its offset,
  its persistence and its frame; forgectrl's tray_test the daemon's side.
  suite/tray.py held only it: the module and its import go.

The coverage lint then named forgectrl src/tray.c and src/tray.h, and
nothing else; grblhal-glowforge's src/glowforge_tray.* stays covered by
another catalog test, so it gets no row. One HOST_TESTED row takes
forgectrl's, to forgectrl:tests/tray_test.c on
forgectrl:.github/workflows/build.yml, which the pin c274f19 runs
(./build/tray_test).

Two of the audit's leftovers, in the same change:
- test_manifest.py's ImplementationHashTests sat after the __main__
  guard, so running the file directly skipped it. It is above the guard
  now, byte for byte: python3 -m unittest test_manifest.py and python3
  test_manifest.py (from tests/, where its helpers import resolves) each
  run its 7 cases, and 34 in all; before, the direct run ran none of
  them.
- laser_lifecycle_test.py's test_lid_open_in_wait said an alarm is
  raised; the docstring now says what the test asserts: the lid-open
  cancel is reported and no alarm is raised. The assertions are
  unchanged and the harness was not run.

The coverage lint on the tree manifest finds no uncovered path and no
host-tested row problem across the 78 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry. pyflakes reports nothing
on motion.py, manifest.py, test_manifest.py or laser_lifecycle_test.py.
The full forgetest suite was not run.

Fingerprints, measured against the parent: 17 tests move with
motion.py's shared text: the 11 left in motion.py, and homing.manual,
laser's four and setup.check-envelope, whose modules read it. The
pending pin bumps from forgectrl c274f19 and forgeext 2d0391e already
owe all 17; this deletion adds none to the owed list. The two leftovers
move no fingerprint. releases/ is unchanged.
2026-10-14 18:21:59 -04:00
ScottW514 40c478d56c Catalog: forgectrl.lease leaves
forgectrl.lease leaves for forgectrl's tests/host_lease.sh (0086e65),
which passed as build.yml runs it: with the gate open and cnc/state idle,
the switches check held at its first wait names wizard:switches, kind
hardware, as the holder; POST /diag/flow-verify, /curve/record,
/logs/export, /mode with the mode in force, /controller/start and
/settings are each 409 in the holder's name, none for an idle machine or
a closed gate; the abort frees the lease and the same settings write is
200; lease.changed carries the take before the free. lease_test and
super_test case K hold the lease's own rules. The catalog goes from 81 to
80 (80 @test in forgetest/forgetest/suite/).

_EventStream, _lease and _text, which only it read, go with it, and so
does urllib.parse, which only _EventStream used; socket stays for lan_ip.
No test requires the id, and no unit test names it.

The coverage lint on the tree manifest then named forgectrl src/events.c
and src/events.h, and nothing else beside setup.check-lens's empty
src/lensfind.* entry. One HOST_TESTED row takes them, to
forgectrl:tests/events_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/events_test). The lint then finds
no uncovered path and no host-tested row problem across the 80 tests;
its one finding is setup.check-lens's. test_manifest.py (34) passes, and
pyflakes reports nothing on forgectrl.py or manifest.py. The full
forgetest suite was not run.

Fingerprints, measured against the parent: 26 tests move with
forgectrl.py's shared text: the 4 left in it (auth, panel-serves,
settings-bounds, tokens), the 5 left in exthost.py and the 17 of the
setup family, whose modules import lan_ip from it (exthost.py and
setup.py, and through setup.py the rest). The pending pin bumps from
forgectrl c274f19 and forgeext 2d0391e already owe 24 of them;
exthost.platform has been owed since 30c0a05 and setup.machine-name
since 70e13bd. This deletion adds none to the owed list. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 17:33:16 -04:00
ScottW514 70e13bdd88 Catalog: setup.mirror and logs.tree-tail-export leave; the catalog's address in forgetest-ci
Two catalog tests leave for the host proofs that carry them; the catalog
goes from 83 to 81 (81 @test in forgetest/forgetest/suite/).

- setup.mirror, for forgectrl's tests/host_ext.sh (0086e65): with a
  temporary account and two logins, a package's check that session A
  starts is owned and A's, owned and not B's, and the token's with no
  session; B's abort is 409 and the run goes on; B's takeover makes it
  B's; B's abort ends it aborted. Its section header, "the lifecycle",
  held only it. setup.check-sensors and setup.account-login stay.
- logs.tree-tail-export, for forgectrl's tests/host_guard.sh (0086e65):
  GET /logs names forgectrl, the tail carries a line just written, an
  unknown logger is 404, and the export carries README.txt, the
  installer's directory (a probe without its MAC or IP address, and
  install.log) and system/extensions.json, with neither the panel token
  nor the camera key in any member. _LOG_COVERS, the _PROBE_ constants,
  and the gzip, io, and tarfile imports, which only it read, go, and so
  does logs.routing's requires on it. logs.routing stays.

forgectrl.lease stays. Its proof passed (forgectrl's tests/host_lease.sh,
0086e65), but its deletion left forgectrl src/events.c and src/events.h
uncovered, a path outside the two this chunk may row, so it does not
land.

The coverage lint then named forgectrl src/sanitize.c, src/sanitize.h,
src/extpkg.c and src/extpkg.h, and nothing else. Two HOST_TESTED rows
take them, to forgectrl:tests/sanitize_test.c and
forgectrl:tests/extpkg_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/sanitize_test, ./build/extpkg_test).

forgetest-ci, after the tree-manifest step: the key comparison moves
there unchanged, and a step beside it checks the catalog's address.
From forgectrl-pin.inc's SRCREV it reads src/extpkg.h out of the
manifest cache (git show) and takes EXTPKG_INDEX_URL; from the
checked-out catalog's tools/publish.py it takes the basename of the file
passed to gh release create; the define must be exactly
https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/
and that basename. A missing pin, define, or basename fails. Nothing
requests the address or fetches the asset. The step's run text, taken
from the workflow, passes from the tree root (c274f19's address and
index-1.ffi) and on a /tmp copy of the tree; controls on that copy,
publish.py passing index-2.ffi and extpkg.h naming another repository:
each fails the comparison. The key step's text is unchanged and was not
run again.

test_manifest.py (34) passes. The coverage lint on the tree manifest
finds no uncovered path and no host-tested row problem across the 81
tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports nothing on setup.py, logs.py or manifest.py.
The full forgetest suite was not run.

Fingerprints, measured against the parent: 23 tests move with
setup.py's and logs.py's shared text: the 5 left in setup.py, the 12 in
the modules that read setup.py (setup_dark.py's 9, setup_burns.py's 2,
bedsize.py's setup.check-envelope), the 5 left in exthost.py, which
reads it too, and logs.routing. The pending pin bumps from forgectrl
c274f19 and forgeext 2d0391e already owe 21 of them, and
exthost.platform has been owed since 30c0a05. This adds one:
setup.machine-name, which no pinned change selects. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 17:09:32 -04:00
ScottW514 b90299acdc Catalog: the package routes, the catalog, a package's check and the camera key leave
Four catalog tests leave for the host proofs that carry them; the
catalog goes from 87 to 83 (83 @test in forgetest/forgetest/suite/).
Each proof passed; none skipped.

- exthost.package-routes, for forgectrl's tests/host_ext.sh (05848ac):
  against a compiled stand-in that is a live host, GET /ext/status and
  POST /ext/package are 403 without the token with the host not run,
  the status passes the host's list through with host.running true and
  the stand-in's pid, enable runs as the host's command, a bad action
  and a bad id are 400 with nothing run, and a package that is not
  installed is 409 in the host's words. extpkg_test holds the relay's
  argv; the state file, the required-holds directory and removal are
  forgeext's install_test.py and run_test.py; the page card is
  check_ui_js.py. REQUIRED_HOLDS, which only it read, and the "the hold"
  section header over it go.
- exthost.wizard, for host_ext.sh (05848ac): the check listed, run to its
  prompt with no lease holder, answered yes, ended with its summary and
  listed done; the setup record's wizards and the gate as they were; a
  second run aborted; a check of a package that is not installed 404.
  wizpkg_test holds each step to its form. suite/extwizard.py held only
  it: the module and its import go.
- exthost.catalog: install_test.py and extpkg_test already assert its
  behavioral claims, and the one fact the bench test did not prove, that
  the image's extension key is the catalog's published key, is now a
  forgetest-ci step: the catalog is checked out at main as a sibling and
  meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
  is cmp -s'd against its keys/forgefirm-ext.pub, either one missing a
  failure. suite/extcat.py held only it: the module and its import go.
- camera.key-read, for forgectrl's tests/host_lan.sh (05848ac): with
  panel_open_reads=0, GET /cam/status from the LAN client over HTTP and
  HTTPS is 403 with no key or a wrong one and 200 with the key as ?key=
  or the header, the key alone writes no setting, and a rotation stops
  the old key and the new one reads. camera.py's json import, which only
  it used, goes.

The coverage lint then named forgectrl src/wizpkg.c, src/wizpkg.h,
src/camkey.c and src/camkey.h, and nothing else. Two HOST_TESTED rows
take them, to forgectrl:tests/wizpkg_test.c and
forgectrl:tests/camkey_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/wizpkg_test, ./build/camkey_test).
src/extpkg.* stays covered (logs.tree-tail-export, logs.routing), and
forgeext/** by exthost.service.

The key step's run text, taken from the workflow, passes from the tree
root with both files present; controls on a /tmp copy, a published key
that differs and one that is missing: each fails. test_manifest.py (34)
passes. The coverage lint on the tree manifest finds no uncovered path
and no host-tested row problem across the 83 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry. pyflakes reports nothing
on exthost.py, camera.py or manifest.py. The full forgetest suite was
not run.

Fingerprints, measured: 10 tests move, the 5 left in exthost.py
(armed-freeze, motion-jog, motion-job, platform, service) and the 5 left
in camera.py (frame-health, h264-stream, lid-privacy, sensor-profile,
snapshot). The pending pin bumps from forgectrl c274f19 and forgeext
2d0391e already owe 9 of them; exthost.platform is owed already since
30c0a05. This adds none. releases/ and TEST-AUDIT.md are unchanged.
2026-10-14 16:24:45 -04:00
ScottW514 30c0a05b86 Catalog: exthost.panel-install leaves
exthost.panel-install leaves for the host proofs that carry it; the
catalog goes from 88 to 87.

- The upload sink is forgectrl's tests/host_ext.sh (005b159): 403
  without the token, 409 and not idle while cnc/state reads running with
  the host never run, the host's refusal at 400 in its words with the
  staging file gone, an archive the host takes staged byte for byte as
  tier unverified with consent button, the discard, an install with
  nothing staged at 409, and a key without the button at 409 with no key
  written. The consent each tier takes, the phrase, the grants' form,
  the button the install and a key are passed, and the key's name and
  the host's refusal of it are forgectrl's extpkg_test; the tiers
  themselves are forgeext's install_test.py.
- Its section header, "installing through the panel", held only it, and
  EXT_STAGE and the contextlib import, which only it read, go with it. Its
  requires line goes with the function.

Three stay. exthost.package-routes: extpkg_test has the status
document, the holds, disable and enable, remove, a bad action and a bad
id at 400 with nothing run, and the host's refusal at 409, but not the
403 without the login on GET /ext/status and POST /ext/package, which is
main.c's guard and no case of extpkg_test's. exthost.wizard: a package's
check takes no lease (wizdark.c), and the lease is held in memory, so
there is no lease file to assert absent at the prompt. exthost.catalog:
install_test.py and extpkg_test assert every listed claim, and what is
left is the image's own key.

The coverage lint names no uncovered path, so no HOST_TESTED row is
added: forgectrl's src/auth.* stays covered by other catalog tests, and
src/extpkg.* by the three that stay.

test_setup_suite.py (31) and test_manifest.py (34) pass. The coverage
lint on the tree manifest finds no uncovered path and no host-tested row
problem across the 87 tests; its one finding is setup.check-lens's
empty src/lensfind.* entry. pyflakes reports nothing on exthost.py. The
full forgetest suite was not run.

Fingerprints: exthost.py's shared text moves, so 8 tests move: the 6
left in exthost.py, and exthost.catalog and exthost.wizard in the
modules that read it. The pending pin bumps from forgectrl c274f19 and
forgeext 2d0391e already owe 7 of them; exthost.platform is the one this
adds. releases/ and TEST-AUDIT.md are unchanged.
2026-10-14 15:42:34 -04:00
ScottW514 cfa6557847 Catalog: the sender keep-out, the event stream and the page's call leave
Three catalog tests leave for the host proofs that carry them; the
catalog goes from 91 to 88. Each proof passed; none skipped.

- exthost.sender-keep-out, for forgeext's run_test.py (ebdbf59): a
  package granted sender.keep_out posts /v0/sender {"out": true}, the
  stand-in takes it only with the host's token, and the claim is kept
  fresh beside the holds, raised in the host's words, until the package
  is disabled. The machine's side is forgectrl's senderout_test.
  suite/extsender.py held only it: the module and its import go.
- exthost.events, for forgeext's run_test.py (ebdbf59) and forgectrl's
  host_lan.sh (b272b3a): the host holds one stream while a package wants
  events and lets it go when none does, a poll at the head comes back
  empty at its one-second deadline, a waiting poll is answered 0.10 s
  after an event; and the host's slot is a loopback peer's alone, the
  same header from a LAN address leaving the host's stream open.
  forgeext's evfeed_test and forgectrl's events_test hold the two
  daemons' own sides. Its section header, the operator's door, held
  only it, and EVENTS_MAX_STREAMS, EVENTS_HOST_HEADER and _stream, which
  only it read, go with it.
- exthost.page-call, for forgeext's sdk_test.py (ebdbf59): disabled, the
  page package's socket name is gone and its call is refused in the
  host's words; enabled, the same call answers 200. sdk_test.py already
  held the calls, their form and refusals, and the frozen service;
  forgectrl's extpkg_test holds the relay. suite/extcall.py held only
  it: the module and its import go.

The coverage lint then named forgectrl src/senderout.c and
src/senderout.h, and nothing else; one HOST_TESTED row takes them, to
forgectrl:tests/senderout_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs. grblhal-glowforge's src/serial.* stays
covered by a catalog test and gets no row; holdkeep, evfeed, call and
sandbox stay under exthost.service's forgeext/**, and forgectrl's
src/events.* under forgectrl.lease.

test_setup_suite.py (31) and test_manifest.py (34) pass. The coverage
lint on the tree manifest finds no uncovered path and no host-tested row
problem across the 88 tests; its one finding is setup.check-lens's
empty src/lensfind.* entry. pyflakes reports nothing on exthost.py or
manifest.py. The full forgetest suite was not run.

Fingerprints: exthost.py's shared text moves, so 9 tests move: the 7
left in exthost.py, and exthost.catalog and exthost.wizard in the
modules that read it. The pending pin bump from c274f19 already owes 8
of them; exthost.platform is the one it adds, as before. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 14:07:21 -04:00
ScottW514 b5f355cb61 Catalog: exthost.ui-delivery leaves
exthost.ui-delivery leaves for the host proofs that carry it; the
catalog goes from 92 to 91.

- forgeext ui, in forgeext's tests/install_test.py (23156a4): an
  installed package's page comes back byte for byte as the html string,
  with bytes its length; an id that is not installed and a package the
  operator disabled are each refused in words.
- forgectrl's relay of the page and the settings, in tests/extpkg_test.c
  on forgectrl's build.yml: the page served, a disabled package's page
  404, the settings read, a value over its bound 400.
The install-time mismatch between the ui capability and a page stays
with forgeext's own tests.

It has no glob of its own, so no HOST_TESTED row. exthost.sender-keep-out
stays: forgeext carries a package's claim on the Grbl sender to the
machine with the token it reads only from /run/forgefirm/ext-host.token,
so run_test.py does not prove it, and suite/extsender.py is unchanged.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 91 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports nothing new in exthost.py. The full forgetest
suite was not run.

Fingerprints: exthost.py's shared text moves, so 12 tests move: the 8
left in exthost.py, and exthost.page-call, exthost.catalog,
exthost.wizard and exthost.sender-keep-out in the modules that read it. The pending pin bump
from c274f19 already owes 11 of them; exthost.platform is the one it
adds, as before. releases/ and TEST-AUDIT.md are unchanged.
2026-10-14 12:41:15 -04:00
ScottW514 b447ae77f1 Catalog: the LAN redirect, the release check, and the update job's lease leave
Three catalog tests leave for the host proofs that carry them; the
catalog goes from 95 to 92.

- setup.https-only-writes, for forgectrl's tests/host_lan.sh (13273a0):
  from a LAN peer in a network namespace, reads served over HTTPS and
  HTTP, three writes over HTTP each a 302 to the same path on HTTPS that
  acts on nothing, the same write served from loopback, and the token
  writing over HTTPS with no account.
- update.release-check and update.job-locks, together, for forgectrl's
  tests/host_update.sh (acc3dcf), against a release server of its own:
  GET /update/release's keys, POST /update/check with a v<semver> tag, a
  size above 0, notes and new; the dismissal refused for a version that
  is not a tag, taken for the tag, cleared by an empty one; and a probe
  download that holds the machine (a settings write and a G21 job each
  409 in the job's name, update:download of kind system the holder),
  ends refused by the signature check leaving no file, and lets the same
  settings write through after.

update.release-check was the last test in suite/update.py; _VERSION_RX
and the re import, which only it used, go with it. update.job-locks was
the only test in suite/updlock.py: the module and its import in
suite/__init__.py go. setup.https-only-writes was the only test of
setup.py's "HTTPS boundary" section. tests/test_setup_suite.py drops its
id.

update.product-gate stays: fwup is on neither this host's PATH nor CI's,
so host_update.sh does not prove the five uploads, and no HOST_TESTED
row is added for src/fwproduct.*. No other HOST_TESTED row either.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 92 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports only the unused local plain in
update.slots-and-signature, which HEAD already has. The full forgetest
suite was not run.

Fingerprints: setup.py's and update.py's shared text move, so 33 tests
move: the 6 left in setup.py, the 25 in the modules that read it, and
update.slots-and-signature and update.product-gate. The pending pin bump
from c274f19 already owes 31 of them; setup.machine-name and
exthost.platform are the two it adds, as before. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 12:06:11 -04:00
ScottW514 21046cd6ff Catalog: setup.cert-page leaves
setup.cert-page leaves for forgectrl's tests/host_guard.sh (4d2d109):
GET /settings reports a 95-character tls_fingerprint, and over HTTP and
over HTTPS, with no token, GET /cert is 200 with no redirect, that
fingerprint and the host's name, and GET /cert.pem is a PEM certificate.
The catalog test reached the board by its LAN address; the page is what
it proved. The catalog goes from 96 to 95.

It called no helper of its own. src/tls.* stays on the login, HTTPS,
first-run and TLS-record tests, src/ui/wizard.html on
image.license-bundle and setup.mirror, and src/ui/wizard.js on
forgectrl.auth, exthost.wizard and setup.mirror. No unit test names it.
No HOST_TESTED row: the pinned workflow does not run host_guard.sh.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 95 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes is clean on setup.py. The full forgetest suite was not
run.

Fingerprints: the deletion moves suite/setup.py's shared text, so 32
tests move: the 7 left in setup.py and the 25 in the modules that read
it. The pending pin bump from c274f19 already owes 30 of them;
setup.machine-name and exthost.platform are the two it adds, as before.
releases/ is unchanged.
2026-10-14 11:25:28 -04:00
ScottW514 098cfdf193 Catalog: setup.record-export leaves
setup.record-export leaves for forgectrl's tests/host_guard.sh (84779c8
on d21e200): the record as JSON with the token (schema 1, the sheet id
GET /wiz reports, no password hash, a plain read not an attachment), the
download an attachment of the record named with that id, the printable
page text/html with no script, the sheet id, "setup record" and an <h3>
per completed wizard, both routes 403 without a token or a login, and
the sanitized export carrying system/setup.json with the same id and
wizards, no $6$, and no panel token anywhere in the archive. The catalog
goes from 97 to 96.

One HOST_TESTED row: forgectrl src/recordhtml.*, the one path no other
catalog test covers, to tests/recordhtml_test.c (no script, the sheet
id, the step headings, the escaping), which forgectrl's build.yml runs
at the pinned revision. src/setup.* and src/wiz.* stay on the setup
family, src/logs.* on exthost.service, forgectrl.lease and the two log
tests, and src/main.c, src/ui/index.html, src/ui/panel.js and
src/ui/wizard.html on the tests that already name them.
tests/host_guard.sh is not named: the pinned workflow does not run it.

tests/test_setup_suite.py drops the id, the record-export hardware
assertion, and the export lines of the lifecycle covers test (the
covers set and its two assertions); the mirror's assertions stay.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 96 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes is clean on setup.py and test_setup_suite.py. The full
forgetest suite was not run.

Fingerprints: the deletion moves suite/setup.py's shared text, so 33
tests move: the 8 left in setup.py and the 25 in the modules that read
it (setup_dark, setup_burns, bedsize, exthost, extcall, extcat,
extsender, extwizard). The pending pin bump from c274f19 already owes 31
of them; setup.machine-name and exthost.platform are the two it adds, as
before. releases/ is unchanged.
2026-10-14 11:10:16 -04:00
ScottW514 1e28eb92cd Catalog: setup.factory-return leaves
setup.factory-return leaves for forgectrl's tests/host_guard.sh
(d21e200): POST /restore/factory-return with the token and no confirm
is 400 and names confirm, confirm=0 is 400, GET /update/status shows no
job running, GET /slots lists the archives (run only where
/usr/sbin/ffboot is not on the host), and GET /setup carries the
confirmed call; confirm=1 is never sent. The catalog goes from 98 to 97.

It was the only test in the "the operator" section of suite/setup.py,
and called no helper of its own. tests/test_setup_suite.py drops its id
and test_the_factory_return_never_runs_the_return. src/update.* stays
covered by the update tests, src/ui/wizard.js by the wizard and mirror
tests, and src/main.c by the tests that already name it.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 97 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes is clean on both files. The full forgetest suite was not
run. No HOST_TESTED row: the pinned workflow does not run host_guard.sh.

Fingerprints: the deletion changes suite/setup.py's shared text, so the
34 tests that read it move again: the 9 left in setup.py, setup_dark 9,
setup_burns 2, bedsize 1, exthost 9, extcall, extcat, extsender and
extwizard 1 each. The pending pin bump from c274f19 already owes 32 of
them; setup.machine-name and exthost.platform are the two it adds, as
before. releases/ is unchanged.
2026-10-14 10:48:52 -04:00
ScottW514 33e8c6e43f Catalog: logs.level-settings leaves
logs.level-settings leaves for forgectrl's tests/logs_test.c (9317b54):
the six levels taken and verbose and 7 refused, port 70000 refused,
udp and tcp taken and tls refused, a server name with spaces refused,
and a remote level changed since boot listed as configured against
effective with pending_reboot raised, and lowered once they match. The
settings route's refusal of a value its validator rejects, with the file
left byte for byte, is tests/host_routes.sh's bounds case. The catalog
goes from 99 to 98.

It was the last test in suite/logs.py and called no helper of its own,
so nothing else goes. logs.tree-tail-export and logs.routing stay: they
are the only catalog covers of forgectrl src/fflog.*, and the pinned
workflow runs neither fflog_e2e.sh nor the new log tests.
setup.record-export keeps its requirement on logs.tree-tail-export.

The coverage lint on the tree manifest finds no uncovered path and no
host-tested row problem across the 98 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry, which the forgectrl pin
bump clears. pyflakes is clean on logs.py. The full forgetest suite was
not run.

Fingerprints: the deletion changes suite/logs.py's shared text (the two
separator lines), so logs.tree-tail-export and logs.routing move; no
other module imports it. The pending forgectrl pin bump from c274f19
already owes both, so this commit adds none to it. releases/ is
unchanged.
2026-10-14 10:20:32 -04:00
ScottW514 71556b2480 Catalog: the setup gate, the advisories, the extensions' consent and cloud-off leave
Four catalog tests leave for the host proofs that carry them; the
catalog goes from 103 to 99.

- setup.gate-blocks-controllers, setup.advisories-rehash,
  setup.extensions-consent and setup.cloud-disabled-surface. forgectrl's
  tests/host_setup.sh (91d30f1) runs the four HTTP proofs against the
  built daemon with the controller up: each reason the gate holds on and
  the live fan-count close and reopen; the first-run advisories, their
  hashes, and an acceptance that takes the press back; the extensions'
  advisory, its five refusals, and the consent; and every cloud selection
  refused while cloud mode is off, POST /mode included. builtin_test.c
  holds the cloud sweep, sha256_test.c the digest, and
  settings_dir_test.c the search bit the consent adds to the data
  directory.
- gate_is_open and _GATE_COVERS go with the gate test, ADVISORY_DOCS and
  the hashlib import with the rehash; nothing else calls them. The record
  builders, REQUIRED_STEPS, SAFETY_PHRASE, Restore and request stay.
- The requires that named them: _FIRST_RUN_REQUIRES held only the two
  deleted ids and goes, so setup.first-run-flow requires nothing from it
  and setup.first-run-page requires setup.first-run-flow alone;
  exthost.service keeps exthost.platform.
- Coverage: HOST_TESTED rows for forgectrl src/sha256.* (tests/sha256_test.c)
  and src/builtin.* (tests/builtin_test.c), both run by forgectrl's
  build.yml at the pinned revision. docs/advisories/** stays behavioral:
  setup.first-run-flow, which accepts them, covers safety-and-risk.md,
  licenses.md, privacy.md and cloud-service.md, and exthost.service,
  which turns extensions on over it, covers extensions.md.
- tests/test_setup_suite.py drops the four ids from the id list and the
  takeover list, the gate test's precheck assertion (the method now names
  the intake prechecks only), and CloudDisabledSurfaceTests, with the
  imports only that class used; the docstring no longer describes it.

test_setup_suite.py passes (32 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 99 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry, which the forgectrl pin bump clears. pyflakes is clean on the
four files. The full forgetest suite was not run.

Fingerprints: deleting the four functions changes suite/setup.py's
shared text, so 35 of the 99 tests' implementation hashes move: every
remaining test in setup.py (10), and the modules that import it at
module level, setup_dark (9), setup_burns (2), bedsize (1), exthost (9),
extcall (1), extsender (1), extwizard (1), and extcat (1, through its
import of exthost). The next forgectrl pin bump already owes 33 of them;
exthost.platform and setup.machine-name are the two it adds. releases/
and TEST-AUDIT.md are unchanged.
2026-10-14 09:26:41 -04:00
ScottW514 7172bba92b Catalog: the report channel, the event stream and what-changed leave
Three catalog tests leave for the host proofs that now carry them; the
catalog goes from 106 to 103.

- cooling.report-channel. forgectrl's tests/host_routes.sh (e69d967)
  runs the channel against a live daemon: a run report with the
  controller's secret writes exhaust 65535; no secret, a wrong secret
  and a short secret are 403 and the duty file is unchanged; an accepted
  idle while the ring still reads running writes 32768; the secret dies
  with the controller. cool_report_test.py and super_test case L own the
  secret, and cooling.fans-quiet-after-motion owns the live M8/M9 return
  to the idle duty. The forged Host: localhost was the same secret
  refusal.
- events.stream. events_test.c owns the hello, the replacement, and
  motors.released; host_routes.sh owns the live cap (three peers held,
  a fourth 503, GET /settings 200 beside them, closing one admitting the
  next). forgectrl.lease and exthost.events still cover src/events.*,
  and _EventStream stays for forgectrl.lease.
- setup.what-changed. setup_test holds tray -> laser.focus and fan ->
  airflow under the image's table beside tube -> laser.floor;
  host_routes.sh holds /wiz/changed (403, 400) and that every step the
  menu names is one GET /wiz lists. src/setup.* and src/wiz.* stay
  covered by the other setup tests, and help.js, panel.js and index.html
  by camera.key-read. preserved() and flag_of() go with it; recommended()
  stays.

tests/test_setup_suite.py drops setup.what-changed from the id list, the
takeover list and the covers lookup, and its flag_of case keeps only
the recommended() assertion.

test_setup_suite.py passes (36 tests). The coverage lint on the tree
manifest finds no uncovered path across the 103 tests; its one finding
is setup.check-lens's empty src/lensfind.* entry, which the forgectrl
pin bump clears. pyflakes on the four files reports only the unused t0
in cooling.py that HEAD already has. The full forgetest suite was not
run.

Fingerprints: the cuts change the shared text of suite/cooling.py,
suite/forgectrl.py and suite/setup.py, so 56 of the 103 tests'
implementation hashes move (cooling 12, setup 26, forgectrl 5, and
exthost 13 through its import of suite/forgectrl.py). The next forgectrl
pin bump already owes 54 of them; exthost.platform and
setup.machine-name are the two it adds. releases/ is unchanged.
2026-10-14 07:56:36 -04:00
ScottW514 8e1fe5090d Harnesses: the null-sink cases other tests prove leave
The grblHAL harnesses keep what only a running controller shows; the
cases a host unit test in grblHAL-glowforge proves, and the log and
reply wording, leave.

- laser_lifecycle_test: test_arm_waits_for_the_fans goes. laser_arm_test
  case A4 (grblHAL-glowforge) proves the fan grace stretching the arm's
  acknowledgment budget, on the same arm path. The docstring names it.
- xy_mode_test: case_x32, case_invalid and the log checks go; the
  default, x16 and the low tick stay. xy_scale_test holds each mode's
  tick and the not-a-mode parse.
- planner_blocks_test: one restart, at $398 1000; the 400 pass and the
  fresh-store probe go.
- ctlport_test: test_poll_lf and test_crlf go (the CR LF poll stays); in
  test_sender_out the network turn-away goes (serial_test holds it), and
  the busy gating, the grant and the outlives-client part stay.
  test_port_dark is whole.
- mcode_test: the timeout case runs on the host build's 5 s
  GFMCODE_WAIT_S (grblHAL-glowforge's CI configures it; the image keeps
  30 s) and still shows the hold, at 5.1 s; 5 s outlasts the 3 s
  test_dark watches the wait; test_table refuses one form per
  parser branch. test_timeout_holds and test_dark stay.
- envelope_test: test_unset and test_bounds go (lens_home_test holds the
  clamp); test_armed and the open, apply and close cases stay.
- manual_home_test: the out-of-range offset block goes, a repeat of
  gfhome_clamp_home_mm (lens_home_test); the modal-M3 FIRE check and
  every release and energize case stay.
- cool_report_test: one non-hex secret and one CR LF injection stay;
  runner-malformed and the exact header-set checks go. A bad secret is
  never sent and a good one is.
- tray_test: test_offset keeps 20 mm; test_errors keeps a bad P and a
  bad axis; the frame shift, envelope, barrier, persistence, port op,
  unreferenced lens and camera home stay.

Proof: with grblHAL-glowforge's matching commit, the eleven harnesses
its CI runs pass under Linux against the null-sink build: nine on a
build with a 1 s M-code wait, and mcode_test and tray_test, the two
that wait at an M-code, on the 5 s build CI configures
(-DGFMCODE_WAIT_S=5.0). pyflakes reports nothing in the nine changed
files.

Push this before grblHAL-glowforge's commit.

Acceptance: none; host harnesses only.
2026-10-13 21:20:31 -04:00
ScottW514 520d62ac05 forgetest tests: the confirmed trims of the tool's own unit tests
The cases that tested a mock, a timer, a wording or a table, or that
replayed a judgment a host test already holds, leave forgetest/tests.
What catches a false PASS, keeps the bench safe, or guards the gate
stays.

- test_baseline: the fake sysfs tree is a mixin with no test_ methods,
  so ExtensionFreeTests and BaselineModeTests no longer re-run
  BaselineTests' 25 cases (133 runs, now 45). CoolPublishTests, the
  lamp-fade cases and TransientNotLeftoverTests go; PositionDeadbandTests
  keeps a reading past the band and an unreadable reading, both never
  excused. Both latch-relock cases stay.
- test_fixture: MdnsTests, the three timing cases, the probe's log
  wording, and nine prompt-routing variants go. A live test never runs
  unattended, the arm-press opt-in, the short-press edge, the presence
  press handing over the arm press, and the arm notice dropping at the
  press stay (35 cases, now 21).
- test_cloud_suite: three false-PASS guards stay: the latch open at a
  dark print's button, a lid stop that is not edge-driven, the kernel
  refusing the resume. The guards for the latch unlocking in the hold
  and a paused print resuming left with their catalog tests in 6a434bb.
  The rest go, with the harness pieces and the hunt-lid fixture only
  they read (41 cases, now 3).
- test_cooling_suite: GateOffTests, CriticalTierTests, FanGateTests and
  FansQuietTests go with their harness; the flow verdict's log reads
  stay: only the text after the offset, never an older verdict before it
  (the flow-under-load false-PASS guard), a rotated log read whole.
- test_queue: RunClockTests and three ordering cases go, and refusals
  are judged by a reason given, not its words; both live
  acknowledgment cases stay.
- test_takeover_position: the six cases that judge the head and move
  nothing stay.
- test_camera_home_return: the five cases where the head must not move
  stay, and so do the three that check the hand-back jog's direction and
  distance (homed, unhomed with the counters not counted twice, after
  cloud mode): a wrong sign or a double count drives the head into the
  gantry stops (15 cases, now 8).
- test_server: the keep-alive and ETag cases, the takeover and scope
  half of the bench case, and the page's self-contained asserts
  (test_page.py's) go.
- test_operator: the wording cases, the fixture case (test_fixture.py's)
  and JournalTests go.
- test_mode: the supervisor timeout table goes.
- test_takeover_client: the listener, the offline service back, the
  online client without the hunt, and GRBL under no marker stay.
- test_responsiveness: PageTests and ImplementationCostTests go; the
  log cache keeps appends, a partial line, a replaced file and a fresh
  list, and the fingerprint cache a changed manifest and one with no
  content hash.
- test_bench_registry: GfbenchTests and two description and argument
  pins go.
- test_installer: the log-format and TLS-wording cases and
  InstallerShapeTests go; the die case checks the reason is in the log,
  not its format.
- test_image: KernelIdentTests and the registration pin go.

Each file's __main__ guard now ends the file, and the docstrings say what
each still proves.

Proof: each of the fifteen files passes under Linux as forgetest-ci
discovers it, and pyflakes reports nothing new in them.

Acceptance: none; test-only.
2026-10-13 20:29:25 -04:00
ScottW514 8536ae7076 laser_stream_test: the analog copies, the in-run levels and the curve run leave
The analog dose model runs on no machine; the density sessions beside
these prove the same rules on the shipped model.

- Sessions J and K (M5 at idle, the next job's first cut) run under
  density only.
- Session C (churn, analog) goes; its pad budget (no runaway pad across
  cycle churn) moves onto session G, density-churn, which already held
  FIRE, termination and the gaps on the same job.
- Session H (a level change inside a run costs no byte) goes: every
  density power byte is already held at full duty by check_density;
  the byte count was a cost, not an energy rule.
- Rule 19's end-to-end curve run goes: laser_arm_test.c case P pins the
  curve's mapping. The rolloff sessions keep the curve in force.
- The constants only those sessions read go (CURVE_DEFAULT,
  curve_density_for, CURVE_S, JOB_CURVE, JOB_LEVELS); the header's rules
  11, 16 and 19 say what the harness now holds.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (23 sessions). pyflakes is
clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:10:00 -04:00
ScottW514 09cf9242a9 laser_lifecycle_test: the sessions the unit tests and the stream prove leave
Each case below repeated a rule another test in CI already proves, read
assertion by assertion next to it in the 2026-10-13 test audit:

- test_job_window: laser_arm_test.c case I (S changes, and now M5 then
  M3, inside the window never re-prompt; df7087b) and laser_stream_test
  session K (M2 closes the window, the next job re-arms).
- test_hold_grace: test_resume_after_grace makes the same assertion.
- test_verdict_blocks_arm, test_arm_waits_for_engine_ack,
  test_arm_proceeds_on_a_late_ack: laser_arm_test.c cases C, A2, A3 and
  cooling_test.c's arm-time gate. test_arm_waits_for_the_fans stays.
- test_sender_change: laser_arm_test.c case H; the disarm on a client
  change is unconditional.
- test_jog_does_not_extend_window: laser_arm_test.c case S10.
- test_verdict_fail_tier_ends_job: laser_stream_test rule 25 and
  laser_arm_test.c case V2.
- test_verdict_pause_resumes_without_press: laser_stream_test rule 24,
  which now counts one arm across the pause, and case V1.
- test_sender_change_mid_job: merged into
  test_sender_change_holds_and_rearms, which takes its "held inside the
  move" position check.
- test_sender_change_then_reset: the window is closed and the latch
  locked by the sender change itself (case H); a reset from Hold is not
  energy.

The header's rules 1, 2, 5, 11, 12 and 14 keep their numbers and name
the tests that now prove them.

Proof: laser_lifecycle_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (17 sessions), and
laser_stream_test with it. pyflakes is clean; no helper was left
without a caller.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:10:00 -04:00
ScottW514 280ceee849 laser_stream_test: rule 24's pause holds the window open: one arm
Rule 24 proved the pause tier holds lit to the stop, masks FIRE while
held, never writes the latch and resumes lit on the clean verdict. What
only laser_lifecycle_test's verdict-pause session held was that the
window stayed open through it: the resume armed nothing new. Rule 24 now
counts the arms, so that session can leave.

- verdict-rehold: exactly one "laser armed" across the pause, the
  re-hold and the resume.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:10:00 -04:00
ScottW514 8149a44f93 laser_stream_test: session B's FIRE-gap check moves to its density twin
Session B ran JOB_M3_TERM under the analog model; session F runs the
same job under density and checked its span, density and termination,
but not check_fire_gaps (no stepless run carrying FIRE), which B alone
held. F now runs it, and B goes: analog never runs on a machine, and
the analog ladder keeps its own termination and gap checks.

- check_fire_gaps on density-full, its worst gap in the PASS line.
- Session B's run and checks go; the JOB_M3_TERM comment names F.

Proof: the harness passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux, density-full with a worst
fire gap of 270 ticks. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:01:30 -04:00
ScottW514 c91f6daa9e laser_stream_test: rule 14 runs under the density model
Rule 14 (a level set while the stream is idle still cuts at that level)
ran only under the analog model, which no machine runs: its check
counted fire ticks per analog duty. Under the shipped density model the
level is the FIRE density at full duty, and nothing proved a stale
level there.

- Session I runs JOB_IDLE_S under DENSITY_CONF_FLOORED and judges it
  with rule 18's check_density: every power byte at full duty, and each
  move's span at its own S's density (23/127, 46/127, 81/127), not the
  one before it. The stream still ends dark.
- The header's rule 14 says the level is judged as the FIRE density.

Proof: the harness passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (densities 0.1812, 0.3624,
0.638 against 0.1811, 0.3622, 0.6378). A control judging the same
stream against the levels in reverse order fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:01:30 -04:00
ScottW514 f8dc21b309 forgetest: motion.button-hold-resume leaves the catalog
motion.button-hold-resume held on the machine that the button holds a
travel job and the next press resumes it to its target. The host proves
it now: laser_lifecycle_test rule 7 runs the same job (M5, G91, G1 X40
F300) through the toggle and lands it within 0.05 mm of +40 (a0a4edb),
beside the armed half that owns the pause and resume messages. Real
stepping stays with the kept motion tests; the physical button with
setup.check-switches.

- The test goes; no helper was its alone (start_move and Watch serve
  motion.lid-cancel-home, which keeps _LID_COVERS).
- laser.pause-resume-lid-cancel no longer requires it. The removed test
  required only motion.pacing, which motion.lid-cancel-home still
  brings.

Proof: the coverage lint reports 0 uncovered paths and 0 host-tested
row problems over 106 tests (its one failure is setup.check-lens's
src/lensfind.* entry, which the forgectrl pin bump clears); the gate
self-check refuses an empty artifact cleanly; pyflakes adds no finding.
The unit modules that load the whole catalog pass under Linux:
test_artifact, test_campaign, test_cooling_order, test_fixture,
test_manifest, test_operator.

Acceptance: motion.py's shared text and laser.pause-resume-lid-cancel's
own text move. Against the image on the bench that adds no run:
6a434bb already changed both laser.py's and motion.py's shared text.
2026-10-13 18:48:38 -04:00
ScottW514 a0a4edb322 laser_lifecycle_test: a travel job takes the button toggle and lands on its target
Rule 7 proved the button's pause/resume toggle on an armed job only, and
nothing checked that the move resumed from the hold reaches its target.
The catalog's motion.button-hold-resume held both on the machine with a
travel job; it can leave the catalog once the host does.

- test_button_pause_resume, after its armed half, runs the catalog's
  job: M5, G91, G1 X40 F300 (8 s). It must reach Run without asking for
  the arm press; a press holds it part-way; a second press resumes it to
  Idle within 0.05 mm of +40.
- Rule 7's line in the header and the case's docstring say so.

Proof: the case passes on a null-sink build of the grblHAL-glowforge
working tree under Linux (the armed half as before; the travel job held
at 8.747 mm and resumed to 39.999 mm). A control, the same job held and
soft-reset instead of resumed, ends at 8.747 mm, which the landing check
fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 18:42:23 -04:00
ScottW514 67ac8f4064 forgetest: events.button-telemetry leaves the catalog
events.button-telemetry held on the machine that one physical press
reaches the event stream as pressed then released through its 25 Hz
switch poll, and the telemetry event's fields. The host proves the
rules now:

- the press between two 5 Hz gathers, idle snapshot, scripted switch
  word: forgectrl events_test test_short_press (d8d07fc);
- the telemetry field string, and that a press the machine is waiting
  for (arming, armed, leased, a button wait) is no event: events_test
  test_button.

The physical button reaching the switch word stays in the catalog:
setup.check-switches.

- evmore.py, the test's own module, goes with its import in
  suite/__init__.py; nothing else imported it.
- Its covers (forgectrl src/events.*, main.c, status.*) stay covered:
  events.stream holds all three, and other catalog tests hold main.c
  and status.*. No HOST_TESTED row is needed.

Proof: the coverage lint reports 0 uncovered paths and 0 host-tested
row problems over 107 tests (its one failure is setup.check-lens's
src/lensfind.* entry, which the forgectrl pin bump clears); the gate
self-check refuses an empty artifact cleanly. The unit modules that load
the whole catalog pass under Linux: test_artifact, test_campaign,
test_cooling_order, test_fixture, test_manifest, test_operator.

Acceptance: the catalog hash moves; no remaining test's fingerprint
moves (the module goes whole).
2026-10-13 18:34:04 -04:00
ScottW514 0350b943d7 forgetest: laser.py's unused motion imports, and the two helpers no test calls
laser.py imported wait_state, wait_state_text, wait_left_state and
drain_text from motion.py and used none of them. wait_state and
drain_text stay in motion.py, which uses them; wait_state_text and
wait_left_state had no caller anywhere, so they go.

Proof: pyflakes no longer reports laser.py's four unused imports and
adds no finding. The coverage lint reports 0 uncovered paths and 0
host-tested row problems over 108 tests (its one failure is
setup.check-lens's src/lensfind.* entry, which the forgectrl pin bump
clears); the gate self-check refuses an empty artifact cleanly; the
forgetest host suite passes under Linux (539 tests).

Acceptance: the shared text of laser.py and motion.py moves, and with
motion.py's the tests of every module that imports it (motion, laser,
tray, updlock, and bedsize through homeoff). Against the image on the
bench that adds no run: 6a434bb already changed both files' shared
text.
2026-10-13 18:14:26 -04:00
ScottW514 35add7cad0 forgetest: laser.power-floor leaves the catalog; laser.py's dead names go
laser.power-floor read $$ off the controller and checked $35 is the
floor, that it lands at PWMSAR 12 or above, and that $31 is 0. The host
proves all of it now: laser_stream_test rule 18 reads $$ on a fresh
controller and after the arm (d7379d9), and laser_arm_test.c case J
holds a set floor key (honored, an out-of-range key falling back to the
default, 0 honored). forgectrl keeps no default for laser_floor_density:
an absent key is the controller's own floor.

- Its own lines go, and the names only it or the removed tests read:
  _floor, _grbl_settings, FLOOR_DEFAULT with the comment above it,
  PWM_PERIOD, PWMSAR_FLOOR_MIN, DISARMED_MSG, and the extra blank lines
  the earlier own-lines removals left.
- The module docstring no longer carves laser.power-floor out as the one
  test that does not emit; it says the live tests here emit, since
  laser.recorder-dark never fires.
- HV_DARK_MAX stays: it is the emission witness's limit.

Proof: the fingerprint parts of laser.py before and after: the shared
text changed, and every remaining test's own text is unchanged. The
coverage lint reports 0 uncovered paths and 0 host-tested row problems
over 108 tests (its one failure is setup.check-lens's src/lensfind.*
entry, which the forgectrl pin bump clears); the gate self-check refuses
an empty artifact cleanly; pyflakes adds no finding; the forgetest host
suite passes under Linux (539 tests).

Acceptance: laser.py's shared text moves the fingerprints of
laser.emission-witness, laser.armed-kill, laser.pause-resume-lid-cancel
and laser.recorder-dark. Against the image on the bench that adds no
run: 6a434bb already changed laser.py's shared text, so all four re-run
in the next campaign either way.
2026-10-13 18:03:51 -04:00
ScottW514 d7379d9fc0 laser_stream_test: rule 18 reads $31 and the floor off the controller
Rule 18 proved a typed $35 is overwritten at the arm and the ladder
renders through the floor key, but took the shipped settings on faith:
the harness mirrors $31 = 0 and the board's 10 % floor as constants. The
catalog's laser.power-floor read them off the machine; it can leave the
catalog once the host does.

- The floor-derived session reads $$ on the fresh controller (no floor
  key: the shipped default) and again after the arm overwrote the typed
  $35. Both reads must show $31 = 0 (the floor, not $31, sets the bottom
  of the S range) and $35 = the board's floor, and that floor must land
  at PWMSAR_FLOOR_MIN (12) or above.
- A set floor key is laser_arm_test.c case J's (12.5 honored, an
  out-of-range key falling back to 10, 0 honored). forgectrl keeps no
  default for laser_floor_density: an absent key is the controller's
  own floor, so there is no second default to compare.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux ("$31=0 and $35=10 (PWMSAR
12) fresh and after the arm"). A control session with a floor key of 5
reads back $35 = 5 both times through the same parse, which rule 18
would fail. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 17:49:33 -04:00
ScottW514 21f53e4e83 forgetest: laser.verdict-cut leaves the catalog; the stream rules hold its claim
laser.verdict-cut held, on the machine, that a stale cooling verdict
mid-cut holds the job dark under the open window with the latch
untouched, and that the clean verdict resumes it lit with no press.
Every part of that is a rule the host now proves:

- dark while held: laser_stream_test rules 24 and 27, check_hold_dark on
  every verdict hold's stationary stretch, and check_fire_gaps (ef15c55);
- the hold, the message, the window kept, the latch never written, the
  resume lit with no press: rules 24 and 27 (latch sideband [unlock,
  lock]), laser_lifecycle_test rule 11, cooling_test.c;
- the physical dwell gap (a kernel-run gap inside the unlocked window,
  the button latch clear, HV_ENABLE back lit): laser.emission-witness,
  unchanged.

- Its own lines go, decorator through body, and nothing else:
  DISARMED_MSG, the module constant only its check read, stays with the
  blank lines around it, so laser.py's shared text is unchanged.
- test_laser_verdict.py goes: every case loaded dark_span out of
  verdict_cut's body.

Proof: the fingerprint parts of laser.py before and after: the shared
text unchanged, the other five tests' own text unchanged. The coverage
lint reports 0 uncovered paths and 0 host-tested row problems over 109
tests (its one failure is setup.check-lens's src/lensfind.* entry, which
the forgectrl pin bump clears); the gate self-check refuses an empty
artifact cleanly; pyflakes adds no finding; the forgetest host suite
passes under Linux (539 tests).

Acceptance: the catalog hash moves; no remaining test's fingerprint
moves.
2026-10-13 17:14:13 -04:00
ScottW514 ef15c55577 laser_stream_test: the verdict's holds ship no FIRE while held
Rules 24 (the pause tier, held and held again) and 27 (a stale verdict)
asserted the deceleration into each hold ran lit to the stop, the
stretch between the holds dark, and the cut lit after the clear, but not
that the stream is dark inside a hold: the central claim of the
catalog's laser.verdict-cut, which can leave the catalog once the host
proves it. Rule 21 proved it only for a plain feed hold.

- check_hold_dark: no FIRE tick inside a hold's stationary stretch, its
  edges aside (one step pulse straddles each edge: rule 21's margin,
  HOLD_EDGE_TICKS, 120 ticks at x8 scaled to the mode: 480 at the x32
  default).
- Rule 24 checks both of its holds; rule 27 checks its one hold.
- check_fire_gaps runs on both sessions, as on rule 21's.
- The header's rules 24 and 27 say so; their PASS lines report the held
  stretches.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux: verdict-rehold dark while
held for 22620 and 22622 ticks, verdict-stale for 22621, check_fire_gaps
clean on both. A negative control on check_hold_dark: a dark 5000-tick
hold passes, and one FIRE tick inside it fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 16:55:50 -04:00
ScottW514 571f702113 forgetest: the emission witness samples a rapid after its M5; m5-rapid-dark leaves
The rule that a rapid after an M5 ships no FIRE is the host's (laser
stream rule 16). What only the machine shows is LASER_ON staying off
with FIRE clear while the chain can fire; kernel.fire-line's dark
sample runs with the chain unarmed. laser.m5-rapid-dark held that
sample in a test of its own, with its own arm and press. The emission
witness, which every campaign runs, now holds it.

- The square ends at M5 with no program end, so the window stays open.
- The dark point: the LASER_ON count reads 0 after the M5, the window
  still armed, and the SoC lock line released (laser_locked false: the
  engine's armed flag can read true after the latch has locked, and a
  dark reading with the latch locked is the unarmed case). All three
  are checked before anything else is sent.
- Only then is the rapid sent: G0 X20, a 2.5 s dwell, G0 X-20, a 0.5 s
  dwell, the program end. Sending it after the dark point keeps the
  mid-square dwell's own zero from being taken for the dark point.
- The test samples until the window closes (a dwell reports Idle, so
  Idle cannot end it), with the controller's machine X beside each
  sample.
- Every sample from the dark point until the window closes reads no
  emission, and the HV current stays at its idle reading (HV_DARK_MAX);
  the reports show the head out at +20 mm and back at the start inside
  that stretch, and the latch reads released on the sample at +20 mm.
- The square still has to show emission and an HV rise, judged on the
  samples before the M5; the beam detector is judged there too.
- The job-based disarm is timed from the head being back: under 10 s,
  where the idle grace is 60 s.
- The rapid's list and its sampler live inside the test, and the
  deletion takes only m5-rapid-dark's own lines (decorator through
  body), so laser.py's shared text is byte-for-byte unchanged.
- live_fire_drills.py's m5dark note and the bench README say the
  emission witness now carries the drill's check.

Proof: the fingerprint parts of laser.py before and after: the shared
text unchanged, every other test's own text unchanged. In the
controller, glowforge_laser.c keeps the latch unlocked from the job's
first laser-on through M5/M3 toggles and relocks it at program end or
after the laser_disarm_s spindle-off grace (60 s by default), so the
dark point and the rapid fall inside the window. The coverage lint
reports 0 uncovered paths over 110 tests (its one failure is
setup.check-lens's src/lensfind.* entry, which the forgectrl pin bump
clears); pyflakes adds no finding; the forgetest host suite passes
under Linux.

Acceptance: only laser.emission-witness moves, and as an always-required
test it must pass on the bench in the next campaign before a release
can inherit its PASS.
2026-10-13 16:30:45 -04:00
ScottW514 6a434bbf77 forgetest: nineteen tests a host test already proves leave the catalog
The catalog is for what needs the machine or an operator. Each test
below asserted software rules that a host test in CI already proves,
read assertion by assertion next to that host test before it left; the
record of that reading is kept with the audit.

Safety rules (each approved for deletion by the owner on that reading):
- laser.disarm-in-hold: laser_lifecycle_test test_resume_after_grace.
- laser.arm-wait-lid: laser_arm_test E-G, laser_lifecycle_test
  test_lid_open_in_wait.
- laser.port-dark: ctlport_test test_port_dark (port jogs, a long jog
  and its cancel, four injected words, the panel ops: no FIRE tick
  under an open window with M3 modal).
- motion.respawn-gate: super_test A and D.
- motion.interlock-cancel-home: the lifecycle loop cases, laser_arm_test G.
- motion.lid-policy-hold: laser_lifecycle_test test_lid_policy_hold.
- cloud.lid-during-button-wait, cloud.dark-print, cloud.verdict-refuse,
  cloud.paused-lid-cancel: test_machine_lid_button.py.
- cooling.fan-duty-readback: cool_fanwrite_test.
- cooling.intake-one-fan: cool_intake_test A-E.
- exthost.hold-pause-tier: holdkeep_test, run_test.py, cool_flow_test P,
  holds_test.

Other rules:
- homing.cloud-offsets: manual_home_test [own-offsets],
  test_gfhome_motion; the real session stays in cloud.mode-switch.
- exthost.core-range: version_test, install_test.
- exthost.operator-destinations: run_test.py, netrules_test,
  install_test, extpkg_test.
- exthost.lifecycle: run_test.py's ext.shutdown read before the stop.
- exthost.mcode: the null-sink mcode_test, forgectrl mcode_test,
  sdk_test, install_test.
- motion.soft-limits: z_envelope_test's homed cases, which now assert
  no step before the alarm, and envelope_test.

- cooling_intake.py, extcore.py, extdest.py, extlife.py and extmcode.py
  go whole; homeoff.py keeps only the camera-home helpers that
  cloud.mode-switch, setup.check-envelope and their meta-tests use.
- The helpers only the removed tests used go with them.
- exthost.armed-freeze still runs the cloud suite's dark-print helper,
  whose latch checks still run; its description says so. It now
  requires cooling.floor-and-warm-up, the dark print's own prerequisite,
  in place of the removed cloud.dark-print.
- HOST_TESTED: forgectrl src/holds.* (holds_test) and src/mcode.*
  (mcode_test), whose only catalog home left with these tests.
- The meta-tests that replayed the removed tests go:
  test_cooling_intake_suite.py, the cloud replay cases and their
  fixture. The one that catches a false PASS stays and runs the dark
  print as armed-freeze does: a latch open at the button fails.
- Text that named a removed test (exthost.motion-job's description and
  comment, test_baseline's docstring) no longer does.

Proof: the coverage lint on a manifest from the recipe pins reports 0
uncovered paths and 0 host-tested row problems over 111 tests (its one
failure is setup.check-lens's src/lensfind.* entry, which the forgectrl
pin bump clears); pyflakes adds no finding; the gate self-check refuses
an empty artifact cleanly; the forgetest host suite passes under Linux
(547 tests); the catalog loads with 111 tests after armed-freeze's
description edit.

Acceptance: the catalog hash moves, so the next image opens a new
campaign. Every remaining test in laser.py, motion.py, cloud.py,
cooling.py, exthost.py, homeoff.py and bedsize.py moves its
fingerprint (their modules' shared text changed); exthost.armed-freeze
moves by its requires.
2026-10-13 16:05:14 -04:00
ScottW514 bef1bf9902 z_envelope_test: a move refused past the bed ships no step
The homed cases asserted that a move past X max, Y max or X min draws
ALARM:2, and the harness's own description said "before any motion",
but nothing checked the motion. The catalog's motion.soft-limits held
that half on the machine, by the kernel counters; it leaves the catalog
with this harness as its proof.

- Every session mirrors the shipped stream (GFSINK_DUMP) into its work
  directory; steps() counts the tick bytes with an X, Y or Z step bit,
  as ctlport_test.py does.
- Each refused move is judged by the step count across it: unchanged,
  or the case fails with the ticks that shipped.
- The move inside the bed must ship steps: the control that shows the
  count sees motion at all.

Proof: z_envelope_test passes on a null-sink build of the
grblHAL-glowforge working tree under Linux, every case including the
three refusals before any motion and the control.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 16:05:08 -04:00
ScottW514 7246707df5 forgetest: the gate test leaves the step lists to forgectrl's setup_test
setup.gate-blocks-controllers asserted on the machine which steps the
image's table requires (GET /wiz's missing list) and recommends (each
not run yet, never the gate's parts or the optional steps). Both are
the setup table's decisions over a record, software with no hardware in
them, and forgectrl's setup_test now proves both on the image's own
table: the required steps in order (10b), the missing list emptying
when they are done (4b), and the recommended steps in order, each not
run yet (10b). The catalog keeps what needs the machine: the supervisor
holding and spawning the controller, the process, and the Grbl port.

- The two missing-list checks and the recommended-list check leave the
  test's body; its description says where they are proven.
- RECOMMENDED_STEPS and NEVER_RECOMMENDED go with them (no test read
  them any more), and the meta-test case that only compared them.

Proof: the forgetest host suite passes under Linux (test_setup_suite
with the case removed among it); the coverage lint is unchanged.

Acceptance: every setup.py test (the constants were the module's shared
text), setup.gate-blocks-controllers among them.
2026-10-13 09:45:16 -04:00
ScottW514 ed1322518b forgetest: the lint takes python -m unittest given a test's file or module
forgectrl's CI runs its dev-server mock test as
`python3 -B -m unittest -v tests/test_devserver_mock.py`, and the
workflow judge counted python -m unittest only as `unittest discover`:
a HOST_TESTED row for that test was refused as not run.

_unittest_names: an argument to python -m unittest (not discover) runs
the test when it is the test's file (resolved against the step's
working-directory) or a dotted name whose module is that file
(tests.test_x, tests.test_x.Case.test_one); a -k pattern's value and an
argument with a shell variable are not names.

Proof: host tests: the CI's own command line, a module, a module with
a class and a method; not a module beside it, nor a -k pattern naming
it. The manifest tests (34) pass, and the forgetest host suite passes
under Linux. Against forgectrl's pinned workflow a row for
tests/test_devserver_mock.py is accepted; every earlier verdict on the
real workflows is unchanged.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-13 09:45:16 -04:00
ScottW514 8003d6f18c forgetest: the burns cover only what the burns run
BURN_COVERS named src/accel.* and src/wizcalc.*, carried over from the
sheet test the burns replaced. The burns' path is wizlive.c, then
wiz_lens_rehome() and lenshome_run(); neither file is on it, and the
focus burn holds the lens window as found rather than measuring stops.
An accelerometer or dark-wizard change re-required both burns for
nothing. Both files stay covered: src/accel.* by the cooling tests,
src/wizcalc.* by the setup checks that run it (lens travel among them).

Proof: the coverage lint on a manifest from the recipe pins reports 0
uncovered paths, as before. test_setup_burns, test_manifest,
test_campaign and test_artifact pass under Linux, and the forgetest
host suite passes under Linux.

Acceptance: setup.focus and setup.floor (BURN_COVERS is setup_burns.py's
shared text, so both fingerprints move; the catalog hash changes with
the covers).
2026-10-13 09:16:08 -04:00
ScottW514 a9c794be34 forgetest: a path a host test proves needs no catalog test
The coverage lint failed CI on any behavioral path no catalog test
covered, so every software change pulled a new or wider acceptance test
in with it, though the catalog is for what needs the machine or an
operator. A path whose behavior a host test in CI proves now has a row
in HOST_TESTED (manifest.py): the component, a glob, the host test, and
the CI workflow that runs it, the last two as "<repository>:<path>" - a
file of a component in the manifest (its tests/ and .github/ files are
listed there), or "forgefirm:" and a path in this repository (the
null-sink harnesses, which grblhal-glowforge's workflow runs).

- coverage_report counts a host-tested path as covered. Fingerprints
  are unchanged: a path no catalog test covers is in no fingerprint,
  so a change there makes no acceptance run necessary.
- host_row_problems reports a row whose glob selects no behavioral
  file; a host test or workflow that does not exist (a component file
  missing from the manifest, a "forgefirm:" file missing from the
  checkout, the dev-only forgetest, no repository named); a workflow
  that should be readable and is not; one that does not check out the
  test's repository; and one with no step that runs the test.
- The workflow is read at the pinned revision: read_source takes a
  component's blob from the bare repo manifest-from-tree.py fetched for
  its source URL (the checkout's .manifest-cache, or --sources), a
  "forgefirm:" file from the checkout. With no sources (an image) it is
  not read and the lint says so; with sources, a read that fails is a
  problem, never a pass.
- workflow_steps reads the workflow's steps by indentation (uses, run,
  working-directory, with), comments dropped, whole lines and from
  " #"; a quoted scalar loses its quotes, a quote inside a command
  stays. checkout_path finds where the test's repository is checked out
  (its own: the checkout naming no repository; another: the checkout
  of <owner>/<name>, the name from the component's source URL).
- workflow_runs looks at what a step's run commands execute, split into
  simple commands (;, &&, ||, |, newlines, then/do/else; a backslash
  continuation joined), with sudo and its flags, env, time, exec and
  variable assignments passed over, each path resolved against the
  step's working-directory and compared with the test's workspace path:
  the command itself (a script run directly, a C test's built binary
  <dir>/<stem>); the script an interpreter runs (python, bash, sh) or a
  for loop's list, by path or a glob matching the whole path segment by
  segment; unittest discover starting in the test's own directory with
  a pattern its name matches (test*.py unless -p); pytest given the test
  or its own directory, for a name it collects. An argument to anything
  else (echo, cat, a build target), a step's name, a comment, a
  trigger's paths filter, or a path that only ends like the test's does
  not count.
- The lint prints the row problems and fails --enforce on them, as on
  an uncovered path or an empty covers entry; --json carries them as
  host_tested, with workflows_read (false when a workflow that should
  have been read was not). It reads HOST_TESTED when it runs.
- The table starts empty: rows come with the tests that leave the
  catalog.

Proof: host tests: a host-tested row takes its paths out of the report
and moves no fingerprint; good rows report nothing; each kind of bad
row is reported with its reason; a workflow that runs one harness and
names another only in its paths filter, a comment line and an inline
comment runs the first and not the second; workflow_runs on a built
binary, sudo and a binary, a script, a harness under another checkout,
pytest on a directory, a for-loop glob and unittest discover under a
step's working-directory, and not under another one; and not on a
paths entry, an inline comment, a glob or a discover that the path
only ends like (tools/tests/..., scripts/bench/tests/...), a longer
name, another file, a step's name, or a commented line in a block;
not on echo with the stem or the binary's path, a build target, cat
of a script, unittest discover from a directory above the test or
with a pattern its name misses, or pytest on a directory above it;
on discover with -p '*_test.py', pytest given the file, a script run
directly and by bash, and sudo -E with assignments continued onto the
next line; checkout_path for its own and another repository and none;
repo_name from a source URL; a component's workflow read from a bare
repo's blob, judged, not judged without the sources, and a blob that
cannot be read reported; the command line with a workflow it cannot
read reports workflows_read false and the read failure, and fails
--enforce. The manifest tests (34) pass on Windows and under Linux;
the forgetest host suite passes under Linux. Against the recipe
pins and their fetched workflows: rows for forgectrl's tray_test,
forgeext's holdkeep_test, sandbox_test (under sudo) and run_test.py
(sudo, env and assignments over three lines), the laser_stream_test
harness (grblhal-glowforge's checkout of forgefirm), gfhardware's
test_machine_lid_button (a for-loop glob under its working-directory),
gfutilities' test_frame (pytest on tests) and forgetest's own
test_manifest (unittest discover under forgefirm/forgetest) pass;
rows for forgectrl's fflog_e2e.sh, the bench script fire_test.py and
kas/source-bundle.yml (only in forgetest-ci's paths filter) are
refused. The lint on that manifest reports 0 uncovered paths and 0
host-tested row problems.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-13 09:04:28 -04:00
ScottW514 1191389a2b forgetest: no press is ever held; the arm notice goes down at the press
- The presence press at the Ready gate polled forgectrl's /status over
  HTTPS every 0.25 s plus the request's own time, so a short press fell
  between two reads and the operator held the button for seconds. It is
  read as an edge from the switches' input events (hw.ButtonEdges, no
  grab), opened before the notice goes up: a press of any length counts.
  Events read and not yet looked at are kept, so a short press whose
  release arrives in the same read gives the release to the next wait.
  With no switches device (the host harnesses) the status is polled at
  50 ms.
- An operator's arm notice (arm_press, press_now) stood until the test's
  next step, over a press already taken. A watcher takes it down the
  moment the controller puts the button out at the press; a notice put
  up meanwhile is left alone.

Proof: host tests: a press and release written back to back on a FIFO
standing in for the switches' device pass the Ready gate in well under a
second with the status never showing the button down (the first version
dropped the release with the press and waited 10 s: the test caught it);
the arm notice stays while the button is dark or lit and goes down when
it goes out, and a notice put up meanwhile stays. The fixture (35),
setup (87), manifest (26) and operator (22) host tests pass.

Acceptance: these are the harness's own paths; every live and operator
test with a press runs through them.
2026-10-12 21:51:45 -04:00
ScottW514 4c5dde2549 forgetest: the burns, the lens and the intake checks follow forgectrl's review fixes
- baseline: the lid lamp fades to a new level (the kernel's smooth
  trigger), so the hand-back reads it until it holds the wanted level
  twice in a row, for up to 3 s, before it is a leftover.
  motion.deadman failed its hand-back reading 232 against 236 0.05 s
  after the supervisor re-applied 236 to a respawned controller.
- setup.check-lens and setup.focus: grblHAL reads the lens window and
  the edge only at its start, and their restore came after the wizard
  had restarted the controller on the test's values. Each test now waits
  for the step to end, puts the settings back, and restarts the
  controller (stop, start, running and verified with a new pid) on every
  exit path; cloud mode restarts nothing.
- setup.focus and setup.floor: the served program ends with G0 Z<edge>,
  G0 X0 Y0, G92.1, M2; setup.floor requires setup.focus and checks the
  scrap's thickness against the lens reach; the burns cover lenshome.
- setup.check-intakes: the two assertions that could not fail are gone;
  an intake-count prompt fails the test unanswered (the bench
  reference's intakes both turn). The precheck's refusal names no
  setting.
- setup.check-airflow: the machine's count rules (intake 2 turning
  writes 2; silent on a one-fan machine, the count is not written).

Shared text: setup_dark.py's precheck string (all setup_dark tests,
setup_burns, setup.check-envelope), setup_burns.py's imports, covers,
docstring and preview check, cooling_intake.py's docstring; every test
they move covers paths forgectrl changed in the same work.

Proof: the forgetest host suite passes (548), with new host tests for
the lamp's fade, the restarts on pass, fail and abort, the floor's Focus
refusal and reach, the tail without G92.1, the intakes prompt, and the
airflow count rules; pyflakes is clean on the changed modules.

Acceptance: these are the tests.
2026-10-12 19:53:20 -04:00
ScottW514 005ff8008b forgetest: the coolant flow calibration is a required step; the gate's missing list
forgectrl requires cooling.flow after preferences, machine, and cloud,
and GET /wiz lists the required steps the gate waits for as missing.

- REQUIRED_STEPS is the form steps plus cooling.flow, in the gate's
  order; REQUIRED_FORM_STEPS are the form steps. cooling.flow leaves
  RECOMMENDED_STEPS.
- setup.gate-blocks-controllers expects "setup required: preferences,
  machine, cloud, cooling.flow" on a record with no step done, with all
  four named in missing, and an empty missing list once they are done
  and the gate is open.
- setup.first-run-flow walks the form steps alone: its seed keeps the
  checks done, the calibration among them, so the gate's reason
  narrows from the three form steps to open.

Proof: the setup host tests (74) and the manifest tests pass; the host
test of record_with_required_steps keeps the four.

Acceptance: these are the tests.
2026-10-12 16:58:58 -04:00