forgetest: the lint reads a needs: list at the key's indentation and across lines, and an empty one fails closed

The coverage lint counts a step of a HOST_TESTED row's workflow only
in a job whose `needs:` are met on every run, and _needs read two
valid forms of the key as an empty list, which _needs_run takes as
needs met. A block list whose items sit at the key's own indentation
(`needs:` over `- build`) ended at its first item, which the loop took
for the next key. A flow list continued on the lines under the key
(`needs: [` over `build` over `]`) ended at the key's line, with no
item, and one split after a comma (`needs: [lint,` over `build]`)
kept the first line's names and dropped the rest. So a job that
needed a conditional job, written either way, counted as running on
every run, and a host test behind a conditional build kept its row
green and its paths covered. No workflow a row names writes its
`needs:` in these forms.

_needs now takes a `- ` item at the key's own indentation as part of
the block list, and ends the list at a key at the key's indentation
or less; items at two indentations are a form it does not read. A
flow list whose `]` is not on the key's line is joined with the lines
under it up to the `]`: a key, or a line at the key's indentation
that is not the `]`, before it ends the join as a form not read.
Every form not read gives None, a name no job has, so the need is
never met: fail closed. An empty `needs:` (`[]`, or nothing under the
key) gives None too: GitHub refuses an empty needs, and a job that
names one does not run. The docstring names every form that fails
closed, so its claim is true of each.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_job_runs_only_when_every_job_it_needs_runs gains the block
list at the key's indentation and the flow list across three lines
among the forms that run the test with the needed job unconditional
and do not with it under `if: false` or `if: github.event_name ==
'push'`; a flow list split after its comma in the chain of three,
which runs the test, and does not with the second-line job under
`if: false`; and, each failing closed with the needed job
unconditional, `needs: []`, a bare `needs:`, a flow list a key
interrupts before its `]`, and items at two indentations. Through
host_row_problems, a workflow on `[push]` whose test job needs, in a
block list at the key's indentation, a build job under `if: false`
is listed as "does not run it". Control: the parent, 8fc411f, with
the new test file fails the case (failures=1) at the block list at
the key's indentation, and run one by one against the parent's
manifest.py, each of the seven new forms reads as run and the row
passes. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
This commit is contained in:
ScottW514
2026-10-18 14:31:59 -04:00
parent 8fc411f50f
commit 9e7b065343
2 changed files with 52 additions and 10 deletions
+25 -1
View File
@@ -413,7 +413,8 @@ class CoverageReportTests(unittest.TestCase):
# on not every run, however the needs: is written
yml = "jobs:\n build:\n%s steps:\n - run: make\n test:\n%s steps:\n - run: ./build/tray_test\n"
for needs in (" needs: build\n", " needs: [build]\n", " needs: ['build']\n",
" needs:\n - build\n"):
" needs:\n - build\n", " needs:\n - build\n",
" needs: [\n build\n ]\n"):
self.assertTrue(self._runs(yml % ("", needs), test), needs)
self.assertFalse(self._runs(yml % (" if: false\n", needs), test), needs)
self.assertFalse(self._runs(yml % (" if: github.event_name == 'push'\n", needs), test), needs)
@@ -430,6 +431,29 @@ class CoverageReportTests(unittest.TestCase):
self.assertFalse(self._runs(yml % (" needs: test\n", " needs: build\n"), test))
self.assertFalse(self._runs(yml % ("", " needs: test\n"), test))
self.assertFalse(self._runs(yml % ("", " needs:\n build: true\n"), test))
# a flow list split after a comma keeps the name on its second line: a conditional job
# there holds the dependent job back
split = (chain % "").replace(" needs: [b]\n", " needs: [a,\n b]\n")
self.assertTrue(self._runs(split, test))
self.assertFalse(self._runs(split.replace(" b:\n needs: a\n", " b:\n needs: a\n if: false\n"), test))
# an empty needs: (which GitHub refuses), a flow list a key interrupts before its ], and
# list items at two indentations each fail closed
for needs in (" needs: []\n", " needs:\n", " needs: [\n build\n",
" needs:\n - build\n - build\n"):
self.assertFalse(self._runs(yml % ("", needs), test), needs)
# through a row: the test job needs, in a block list at the key's own indentation, a
# build job under if: false (the checkout in a job of its own, as the lint reads a
# checkout for the whole workflow)
man = helpers.make_manifest()
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
" - run: make\n test:\n needs:\n - build\n steps:\n"
" - run: python3 scripts/bench/stream_test.py\n"})
row = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
"forgefirm:.github/workflows/needs.yml")
self.assertEqual(m.host_row_problems(man, [row], root=root, sources=None),
[row[:3] + (row[3] + " does not run it",)])
# a job under always() runs on every run, and the lint evaluates no condition: it counts as
# no job for its own steps and for the jobs after it
self.assertFalse(self._runs(yml % (" if: false\n", " needs: build\n if: always()\n"), test))