From 5b6af150bf80aa24758878c608b2d5d152675ea1 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 11:25:58 +0800 Subject: [PATCH] emrg: every task template states both permanent red lines --- emrg/server/competition_prompt.md | 2 + emrg/server/journal_prompt.md | 2 + emrg/server/open_source_prompt.md | 2 + emrg/server/paper_prompt.md | 2 + emrg/server/promote_prompt.md | 2 + tests/test_upgrade_chain_red_line.py | 197 +++++++++++++++++++++++++-- 6 files changed, 197 insertions(+), 10 deletions(-) diff --git a/emrg/server/competition_prompt.md b/emrg/server/competition_prompt.md index 3941d399..3704fa53 100644 --- a/emrg/server/competition_prompt.md +++ b/emrg/server/competition_prompt.md @@ -280,3 +280,5 @@ The session transcript is what the next round reads โ€” nothing is appended to a - ๐Ÿ›‘ Do not modify `~/.emrg/config.toml` - ๐Ÿ›‘ Do not do multiple unrelated things in one round - ๐Ÿ›‘ Do not skip the preparation step (even when "everything looks fine") +- ๐Ÿ›‘ **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** โ€” not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไบŒ; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) โ€” a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. +- ๐Ÿ›‘ **Never write, restore or introduce anything that triggers the real auto-upgrade chain** โ€” not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไธ‰; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/emrg/server/journal_prompt.md b/emrg/server/journal_prompt.md index 6f329f65..510fc1a3 100644 --- a/emrg/server/journal_prompt.md +++ b/emrg/server/journal_prompt.md @@ -571,3 +571,5 @@ Each reflection answers these 7 questions (cannot be omitted): - ๐Ÿ›‘ Do not do multiple unrelated things in one cycle - ๐Ÿ›‘ Do not skip the preparation step (even when "everything looks fine") - ๐Ÿ›‘ Do not modify `~/.emrg/config.toml` +- ๐Ÿ›‘ **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** โ€” not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไบŒ; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) โ€” a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. +- ๐Ÿ›‘ **Never write, restore or introduce anything that triggers the real auto-upgrade chain** โ€” not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไธ‰; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/emrg/server/open_source_prompt.md b/emrg/server/open_source_prompt.md index f3e44e2a..026efa88 100644 --- a/emrg/server/open_source_prompt.md +++ b/emrg/server/open_source_prompt.md @@ -599,3 +599,5 @@ Other platforms (Gitee/Gitea/Gerrit, etc.): prefer the platform's official CLI ( - ๐Ÿ›‘ Contributors are forbidden from executing `gh pr review`, `gh pr merge`, `gh issue close` and other write operations - ๐Ÿ›‘ Do not do multiple unrelated things in one cycle - ๐Ÿ›‘ Do not skip the preparation step (even when "everything looks fine") +- ๐Ÿ›‘ **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** โ€” not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไบŒ; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) โ€” a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. +- ๐Ÿ›‘ **Never write, restore or introduce anything that triggers the real auto-upgrade chain** โ€” not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไธ‰; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/emrg/server/paper_prompt.md b/emrg/server/paper_prompt.md index 3da8b912..74825bae 100644 --- a/emrg/server/paper_prompt.md +++ b/emrg/server/paper_prompt.md @@ -260,3 +260,5 @@ Each round the closing summary must answer these 7 questions (cannot be omitted) - Do not modify `~/.emrg/config.toml` - Do not fabricate data or citations (must come from actual literature) - Do not modify files unrelated to the paper +- ๐Ÿ›‘ **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** โ€” not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไบŒ; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) โ€” a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. +- ๐Ÿ›‘ **Never write, restore or introduce anything that triggers the real auto-upgrade chain** โ€” not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไธ‰; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/emrg/server/promote_prompt.md b/emrg/server/promote_prompt.md index e2808e38..61e23fe4 100644 --- a/emrg/server/promote_prompt.md +++ b/emrg/server/promote_prompt.md @@ -383,3 +383,5 @@ Record the numbers in the state file's `promotion metrics` field (one line per r - ๐Ÿ›‘ No promotion in topics unrelated to the project - ๐Ÿ›‘ Don't modify `~/.emrg/config.toml` - ๐Ÿ›‘ Don't skip the preparation step (even when "everything looks fine") +- ๐Ÿ›‘ **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** โ€” not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไบŒ; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) โ€” a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. +- ๐Ÿ›‘ **Never write, restore or introduce anything that triggers the real auto-upgrade chain** โ€” not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` ็ฌฌๅ››ๆก้™„ๅˆ™ไธ‰; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/tests/test_upgrade_chain_red_line.py b/tests/test_upgrade_chain_red_line.py index 19967acd..418177db 100644 --- a/tests/test_upgrade_chain_red_line.py +++ b/tests/test_upgrade_chain_red_line.py @@ -26,6 +26,24 @@ of the **session cwd** โ€” it is present for this repo's tasks and absent for every other project's. +A third class: the six task templates, not only the evolution one +--------------------------------------------------------------- +Both carriers named above are one template and one session prompt. The scheduler ships +**six** templates โ€” `competition`, `evolution`, `journal`, `open_source`, `paper`, +`promote` โ€” and each is the instruction half of its own kind of session. A task cycle +reads its own template, and the template's `### Forbidden` list is where a reader looks +for a rule of exactly this class, which is why the templates restate the shared rules: +the sibling `~/.emrg/config.toml` prohibition is in all six. Measured at master +`1716a630` (`cyc20260919-105421`): both permanent red lines were in **one** of the six +(`evolution_prompt.md`); the other five matched neither `้™„ๅˆ™ไบŒ` nor `้™„ๅˆ™ไธ‰`. So a +journal, paper, promote, competition or open_source cycle read the two permanent rules +only where its session prompt happened to carry them โ€” the original asymmetry of this +file, five carriers over. Duplication is forced here: `TaskHandler._build_evolution_prompt` +renders with `jinja2.Environment(undefined=Undefined).from_string(...)`, which has no +loader, so no `{% include %}` can share one copy. That makes drift the live risk โ€” five +hand-copied clauses are five places to reword three of them โ€” so this file pins one term +set for all six carriers and one wording for the five task templates. + Named limit ----------- This pins the *presence* of both statements, not the behaviour they ask for. The @@ -46,6 +64,12 @@ SYSTEM_PROMPT = REPO_ROOT / "emrg" / "server" / "prompts" / "system.j2" EVOLUTION_TEMPLATE = REPO_ROOT / "emrg" / "server" / "evolution_prompt.md" +#: Every task template the scheduler can be configured with โ€” the class, not the instance. +#: Taken from the directory rather than a literal list so a new task type is a new carrier +#: the moment its template lands (the deficit this file exists for was "five carriers were +#: never asked"); the count is asserted below so the glob cannot silently go empty. +TASK_TEMPLATES = sorted((REPO_ROOT / "emrg" / "server").glob("*_prompt.md")) + #: The heading of the ้™„ๅˆ™ไธ‰ block in the host session prompt. The block runs to EOF. UPGRADE_HEADING = "## โ›” ๆœ€้ซ˜ๅŽŸๅˆ™ยทๆฐธไน…๏ผˆๅฎฟไธป 2026-08-21 10:35 ็กฎ็ซ‹๏ผ‰" @@ -68,8 +92,11 @@ #: The verdict of "this is not a choice the system may revise", spelled as the host did. PERMANENCE = "not subject to any evolution mechanism" -#: Terms of the ้™„ๅˆ™ไธ‰ clause in the shipped evolution template's `### Forbidden` list. -TEMPLATE_TERMS = ( +#: Terms of the ้™„ๅˆ™ไธ‰ clause every template carrier must state. Written against +#: `evolution_prompt.md`'s wording, which the five task templates now carry too โ€” so this +#: one list answers for all six, and a reword that drops a route from any of them fails +#: here. +SHARED_UPGRADE_TERMS = ( "triggers the real auto-upgrade chain", # the rule itself "UpgradeManager.tick()", # the in-process route "install/version.txt", # the file the chain must not touch @@ -79,6 +106,29 @@ PERMANENCE, ) +#: Terms of the sibling ้™„ๅˆ™ไบŒ clause every template carrier must state. Narrower than +#: `SHARED_UPGRADE_TERMS` on purpose: `evolution_prompt.md`'s clause names the teardown-as- +#: mocked-with-`test_shutdown_all_*` shape while the five task templates name the two +#: autouse fixtures, and both are truthful โ€” so the shared set is the rule, the entry +#: points, the provenance and the permanence, which every carrier states. +SHARED_STOP_TERMS = ( + "stops or restarts the emrg server", # the rule itself + "stop_all()", # the in-process route + "stop_daemon()", # the other in-process route + "emrg server stop", # the CLI route (also covers stop/restart) + "็ฌฌๅ››ๆก้™„ๅˆ™ไบŒ", # provenance + PERMANENCE, +) + +#: The mark that identifies each clause's bullet inside a ยงForbidden list, used where the +#: question is *which bullets state the rule* rather than which routes they name. +STOP_CLAUSE_MARK = "anything that stops or restarts the emrg server" +UPGRADE_CLAUSE_MARK = "anything that triggers the real auto-upgrade chain" + +#: The five carriers that are not `evolution_prompt.md`. They are the ones that were silent +#: before this cycle, and the ones that must therefore agree word for word. +TASK_ONLY_TEMPLATES = tuple(t for t in TASK_TEMPLATES if t.name != "evolution_prompt.md") + def _block_after(text: str, heading: str) -> str: """The block starting at ``heading``, up to the next `## ` heading or EOF. @@ -95,11 +145,27 @@ def _block_after(text: str, heading: str) -> str: def _forbidden_section(text: str) -> str: - """The template's `### Forbidden` section, up to the next `###` heading.""" - mark = "### Forbidden" - start = text.find(mark) - assert start != -1, "the template has no `### Forbidden` section" - rest = text[start + len(mark):] + """The template's ยงForbidden section, up to the next `###` heading. + + Found by "the last `##`/`###` heading whose title *ends* with `Forbidden`" rather than + by the literal `### Forbidden`: `journal_prompt.md` states the section as + `### 5. Error Handling + Forbidden`, so the literal marker reads that carrier as + sectionless โ€” a failure to measure, which is the one answer this helper must not give + for a carrier that does state the rules. + + "Ends with" rather than "contains", so that prose *about* the section is not mistaken + for it: the control below feeds `# A template with no Forbidden section`, whose heading + mentions the word mid-sentence and must still read as absent. Level `##` is accepted + because a carrier is free to promote the section without changing its meaning. + """ + lines = text.splitlines(keepends=True) + start = None + for index, line in enumerate(lines): + stripped = line.rstrip("\n") + if stripped.startswith(("## ", "### ")) and stripped.endswith("Forbidden"): + start = index + assert start is not None, "the template has no `### Forbidden` section" + rest = "".join(lines[start + 1:]) end = rest.find("\n### ") return rest if end == -1 else rest[:end] @@ -109,6 +175,15 @@ def _bullet_lines(section: str) -> list[str]: return [line for line in section.splitlines() if line.startswith("- ")] +def _clause_bullets(section: str, mark: str) -> list[str]: + """The ยงForbidden bullets stating one red line, identified by its opening phrase. + + The mark is the clause's own opening words, so this answers "how many times is the + rule stated here?" โ€” the count the one-copy property is taken on. + """ + return [line for line in _bullet_lines(section) if mark in line] + + def _missing_terms(text: str, terms: tuple[str, ...]) -> list[str]: """The terms this text does not carry. Empty means the rule is stated.""" return [term for term in terms if term not in text] @@ -150,7 +225,7 @@ def test_the_host_session_prompt_keeps_the_sibling_block_it_had() -> None: def test_the_shipped_evolution_template_states_the_auto_upgrade_red_line() -> None: section = _forbidden_section(EVOLUTION_TEMPLATE.read_text(encoding="utf-8")) - missing = _missing_terms(section, TEMPLATE_TERMS) + missing = _missing_terms(section, SHARED_UPGRADE_TERMS) assert not missing, ( "emrg/server/evolution_prompt.md ยงForbidden must state the permanent auto-upgrade " f"red line (MANIFESTO.md ็ฌฌๅ››ๆก้™„ๅˆ™ไธ‰, host 2026-08-21T10:35:57); missing: {missing}" @@ -192,7 +267,7 @@ def test_each_carrier_states_the_rule_in_its_own_language() -> None: template_text = EVOLUTION_TEMPLATE.read_text(encoding="utf-8") for name, text, terms in ( ("system.j2", system_text, UPGRADE_TERMS), - ("evolution_prompt.md", template_text, TEMPLATE_TERMS), + ("evolution_prompt.md", template_text, SHARED_UPGRADE_TERMS), ): for route in ("UpgradeManager.tick()", "install/version.txt", "emrg-upgrade"): assert route in text, ( @@ -251,7 +326,7 @@ def test_the_checks_can_report_absence() -> None: assert _block_after(sample, UPGRADE_HEADING) == "" assert _missing_terms(sample, UPGRADE_TERMS) == list(UPGRADE_TERMS) assert _missing_terms(_forbidden_section(sample + "\n### Forbidden\n\n- Must push\n"), - TEMPLATE_TERMS) == list(TEMPLATE_TERMS) + SHARED_UPGRADE_TERMS) == list(SHARED_UPGRADE_TERMS) def test_a_prompt_without_a_forbidden_section_is_not_silently_healthy() -> None: @@ -306,3 +381,105 @@ def test_the_rendered_host_prompt_carries_the_rule_not_merely_the_template() -> f"{rendered.count(UPGRADE_HEADING)} copies" ) + +def test_every_task_template_states_both_permanent_red_lines() -> None: + """All six carriers state both rules โ€” the check the five silent ones lacked. + + Every earlier check in this file reads `evolution_prompt.md` or `system.j2`. The five + other templates are as much a carrier of the rules as those two are, and until this + cycle nothing asked them: a journal, paper, promote, competition or open_source cycle + received the two permanent red lines only through `system.j2`, which is a *different + artifact* โ€” the installed package's session prompt, not the template the source tree + hands the cycle. That asymmetry is the defect this file was written for, so the + question is asked of the class, not of one member of it. + + The glob is asserted non-empty first: a `TASK_TEMPLATES` that resolved to no files + would make the loop below pass over nothing, which is the failure mode this file's + own control test exists to prevent. + """ + assert len(TASK_TEMPLATES) == 6, ( + "the six shipped task templates must all be found; found " + f"{[carrier.name for carrier in TASK_TEMPLATES]}" + ) + for carrier in TASK_TEMPLATES: + section = _forbidden_section(carrier.read_text(encoding="utf-8")) + for rule, terms in (("้™„ๅˆ™ไบŒ", SHARED_STOP_TERMS), ("้™„ๅˆ™ไธ‰", SHARED_UPGRADE_TERMS)): + missing = _missing_terms(section, terms) + assert not missing, ( + f"emrg/server/{carrier.name} ยงForbidden must state the permanent {rule} " + f"red line (MANIFESTO.md ็ฌฌๅ››ๆก{rule}); missing: {missing}" + ) + + +def test_every_task_template_states_each_red_line_once() -> None: + """One statement per carrier, taken over *all six* rather than two. + + `test_neither_carrier_states_the_rule_twice` pins this for `system.j2`'s two blocks and + for `evolution_prompt.md`'s upgrade clause. The five task templates were pinned for + neither rule, and they are the carriers most likely to grow a second copy: their + ยงForbidden lists already restate a shared rule (`~/.emrg/config.toml`), so appending + "the red lines too" a second time is an ordinary-looking edit. + """ + for carrier in TASK_TEMPLATES: + section = _forbidden_section(carrier.read_text(encoding="utf-8")) + for rule, mark in (("้™„ๅˆ™ไบŒ", STOP_CLAUSE_MARK), ("้™„ๅˆ™ไธ‰", UPGRADE_CLAUSE_MARK)): + found = len(_clause_bullets(section, mark)) + assert found == 1, ( + f"emrg/server/{carrier.name} ยงForbidden must state the {rule} rule once; " + f"found {found} bullets stating it" + ) + + +def test_the_five_task_templates_agree_word_for_word() -> None: + """The duplication that is forced must not become the drift that is optional. + + `TaskHandler._build_evolution_prompt` renders with + `jinja2.Environment(undefined=Undefined).from_string(...)`, which has no loader โ€” so + there is no `{% include %}` with which the five task templates could share one copy of + the clause, and the text is copied five times by construction. The presence check above + cannot see the consequence: reword any one copy as long as it keeps every load-bearing + term and that check stays green, while the five carriers now state five variants of the + same permanent rule โ€” the "two copies free to drift apart" that + `test_neither_carrier_states_the_rule_twice` refuses one level up. So the wording + itself is the property: outside `evolution_prompt.md`, whose wording predates this and + is host-owned, the five must be identical. + + Named limit: this compares the five *to each other*, so a reword applied to all five in + one edit passes here โ€” correctly, since one wording across the carriers is what is + asked; the term check above is what keeps such a reword from dropping a route. + """ + by_wording: dict[str, dict[str, list[str]]] = {} + for carrier in TASK_ONLY_TEMPLATES: + section = _forbidden_section(carrier.read_text(encoding="utf-8")) + for mark in (STOP_CLAUSE_MARK, UPGRADE_CLAUSE_MARK): + bullets = _clause_bullets(section, mark) + assert len(bullets) == 1, ( + f"emrg/server/{carrier.name} must state the clause once before its wording " + f"can be compared; found {len(bullets)}" + ) + by_wording.setdefault(mark, {}).setdefault(bullets[0], []).append(carrier.name) + for mark, wordings in by_wording.items(): + assert len(wordings) == 1, ( + f"the five task templates must state the {mark!r} clause in one wording; found " + f"{len(wordings)}: " + + "; ".join(f"{names} carry {text[:70]!r}" for text, names in wordings.items()) + ) + + +def test_the_task_template_checks_can_report_absence() -> None: + """The control for the three checks above, on a section of their own shape. + + The carriers' ยงForbidden sections restate the `~/.emrg/config.toml` rule, so a control + that feeds them "a section with no red line in it" must use exactly that: the config + rule present, neither red line, `Forbidden` as the section's own title โ€” the shape a + carrier takes if the clause is deleted. Absence must then be visible to the term + check, the count check and the wording check alike. + """ + section = _forbidden_section( + "### Forbidden\n\n- Do not modify `~/.emrg/config.toml`\n- Must push\n" + ) + assert _missing_terms(section, SHARED_STOP_TERMS) == list(SHARED_STOP_TERMS) + assert _missing_terms(section, SHARED_UPGRADE_TERMS) == list(SHARED_UPGRADE_TERMS) + assert _clause_bullets(section, STOP_CLAUSE_MARK) == [] + assert _clause_bullets(section, UPGRADE_CLAUSE_MARK) == [] +