diff --git a/luria/views.py b/luria/views.py index 0b0cb332..9ac00571 100644 --- a/luria/views.py +++ b/luria/views.py @@ -75,6 +75,28 @@ class SchemeInfo: blurb: str dir: str output: str + # "index" (a table and per-value pages under `output`) or "document" + # (the bodies assembled into the one page `output`). + render: str = "index" + + +@dataclass(frozen=True) +class OutputInfo: + """A chain or a view, as a template names it: its name, its title where + it has one, and the page it renders into, relative to the project root.""" + name: str + title: str + output: str + + +@dataclass(frozen=True) +class PathsInfo: + """The pages and directories luria renders at fixed, configured places, + relative to the project root.""" + docs: str + reports: str + record: str # the record description + configuration: str # the configuration reference @dataclass(frozen=True) @@ -213,9 +235,24 @@ def vocabulary(self, name: str) -> list[Term]: def schemes(self) -> list[SchemeInfo]: return [SchemeInfo(s.prefix, str(s.title or ""), str(s.blurb or "").strip(), self.cfg.rel(s.dir), - self.cfg.rel(s.output) if s.output else "") + self.cfg.rel(s.output) if s.output else "", + s.render) for s in self.cfg.schemes.values()] + def chains(self) -> list[OutputInfo]: + return [OutputInfo(c.name, c.title, self.cfg.rel(c.output)) + for c in self.cfg.chains.values()] + + def views(self) -> list[OutputInfo]: + return [OutputInfo(v.name, v.name, self.cfg.rel(v.output)) + for v in self.cfg.views.values()] + + def paths(self) -> PathsInfo: + return PathsInfo(self.cfg.rel(self.cfg.docs), + self.cfg.rel(self.cfg.reports), + self.cfg.rel(self.cfg.record_doc), + self.cfg.rel(self.cfg.config_doc)) + def journals(self) -> list[JournalInfo]: return [JournalInfo(j.name, str(j.title or ""), str(j.blurb or "").strip(), self.cfg.rel(j.dir), @@ -307,7 +344,8 @@ def render(view) -> str: env.globals.update(doc=record.doc, docs=record.docs, vocabulary=record.vocabulary, schemes=record.schemes, match=record.match, - journals=record.journals) + journals=record.journals, chains=record.chains, + views=record.views, paths=record.paths) try: text = env.get_template(_name(view)).render() except (jinja2.TemplateError, _Refused, TypeError, ValueError, diff --git a/record/changelog.d/generated-targets.md b/record/changelog.d/generated-targets.md new file mode 100644 index 00000000..e9038c41 --- /dev/null +++ b/record/changelog.d/generated-targets.md @@ -0,0 +1,9 @@ +### Added + +- More of the configuration reaches a view template: each scheme's + `render` kind on `schemes()`, `chains()` and `views()` with their + outputs, and `paths()` for the status reports, the record description + and the configuration reference. This repository's `CLAUDE.md` uses + them to derive its "never edit a generated file" list in the template. + A test holds that every file `luria index` writes falls under a path + the bootloader names. diff --git a/record/devlog.d/2026/10/16/045651.md b/record/devlog.d/2026/10/16/045651.md new file mode 100644 index 00000000..88903d1c --- /dev/null +++ b/record/devlog.d/2026/10/16/045651.md @@ -0,0 +1,26 @@ +--- +title: 'CLAUDE.md derives its list of generated files' +created: '2026-10-16T04:56:51' +tags: [] +--- + +`CLAUDE.md` named the generated files by hand, in a bullet saying never to +edit them. The list is now derived in its template, from loops over +`schemes()`, `journals()`, `chains()` and `views()`, plus `paths()` for +the pages luria renders at fixed places. A first version put the list in +`Config.generated()` and had `is_generated` ask it, so the check and the +page would share one list. It was dropped in review: the labels and the +loops are presentation and belong in the template, and `is_generated` was +right as it stood. + +The cost is a second derivation of "what luria writes", in the template, +so the guard is a property and not a list ([DP-003](../../record/principles.d/DP-003.md)). Every file +`luria index` renders for this record has to fall under a path the +rendered bootloader names. I fired it once ([DP-006](../../record/principles.d/DP-006.md)): with the journals +loop deleted from the template, it failed and named +`docs/devlog/2026-10.md`. A new kind of generated output fails it until +the template learns to say it. + +Trap: `adr_index.outputs()` includes nested records' views ([ADR-078](../../record/decisions.d/ADR-078.md)), +which this record's bootloader does not name. The test asks +`outputs(nested=False)`. diff --git a/record/views.d/CLAUDE.md.j2 b/record/views.d/CLAUDE.md.j2 index a9606c76..c9786607 100644 --- a/record/views.d/CLAUDE.md.j2 +++ b/record/views.d/CLAUDE.md.j2 @@ -29,10 +29,26 @@ Orient first: anything the next person would otherwise rediscover, a decision document when a real alternative was rejected. Filed with context loaded it costs a paragraph; re-derived cold it costs a session. -- **Never edit a generated file.** Anything stamped `GENERATED` — the - decision index, tag pages, `docs/design-principles.md`, journal books, - reports, `docs/record.md`, `docs/configuration.md`, the README badge - region — is rebuilt by `luria index`. Edit the sources, rerun it. +- **Never edit a generated file.** Anything stamped `GENERATED` is rebuilt + by `luria index`; edit the sources and rerun it. The README's generated + regions are rebuilt too, and so is everything below, derived from this + record's configuration: +{% for s in schemes() if s.output %} + - the {{ s.title or s.prefix }} {{ "index and its per-value pages" if s.render == "index" else "document" }}: `{{ s.output }}` +{% endfor %} +{% for j in journals() %} + - the {{ j.title }} books: `{{ j.output }}` +{% endfor %} +{% for c in chains() %} + - the {{ c.title }} chain page: `{{ c.output }}` +{% endfor %} +{% for v in views() %} + - the `{{ v.name }}` view: `{{ v.output }}` +{% endfor %} +{% set p = paths() %} + - the status reports: `{{ p.reports }}` + - the record description: `{{ p.record }}` + - the configuration reference: `{{ p.configuration }}` - **Never hand-write a link target for a code.** Write the bare code and run `luria link --fix`; record prose renders into views in other directories, so only the fixer knows the frame a target must resolve diff --git a/tests/test_generated_targets.py b/tests/test_generated_targets.py new file mode 100644 index 00000000..ccba4e0d --- /dev/null +++ b/tests/test_generated_targets.py @@ -0,0 +1,45 @@ +# tests/test_generated_targets.py +"""`CLAUDE.md`'s "never edit a generated file" list is derived in its +template (ADR-130), from what the record's config says it renders. The +template is then a second derivation of what `luria index` writes, beside +the generator itself, so this guards the property and not the list (DP-003): +every file the index writes lies at or under a path the bootloader names. +""" + +from __future__ import annotations + +import re + +from luria import adr_index, config, views + + +def named_paths() -> list[str]: + text = views.render(config.current().views["bootloader"]) + section = text.split("**Never edit a generated file.**", 1)[1] + section = section.split("\n- **", 1)[0] + return re.findall(r"^ - .*: `([^`]+)`$", section, re.MULTILINE) + + +def test_the_bootloader_names_every_file_the_index_writes(): + cfg = config.current() + named = [cfg.root / p for p in named_paths()] + missing = [cfg.rel(out) for out in adr_index.outputs(nested=False) + if not any(out == n or n in out.parents for n in named)] + assert missing == [] + + +def test_it_names_each_kind_of_view(): + named = named_paths() + for path in ("docs/decisions", "docs/design-principles.md", + "docs/devlog", "CLAUDE.md", "docs/reports", + "docs/record.md", "docs/configuration.md"): + assert path in named + + +def test_paths_and_scheme_render_kinds_reach_a_template(): + view = config.current().views["bootloader"] + record = views._Record(view.output) + kinds = {s.prefix: s.render for s in record.schemes()} + assert kinds == {"ADR": "index", "DP": "document"} + assert record.paths().record == "docs/record.md" + assert [v.output for v in record.views()] == ["CLAUDE.md"]