feat: people live on their organization's page until promoted; organization subtype, member-of, broken_anchors lint (#172)
CI / verify (push) Successful in 5m26s
CI / pwsh (push) Successful in 2m8s
Release / release (push) Successful in 34s

Files changed:
- CHANGES.md
- README.md
- VERSION
- instructions/kb-profiles.md
- instructions/link-taxonomy.md
- instructions/page-lifecycle.md
- instructions/wiki-ingest/SKILL.md
- instructions/wiki-lint/SKILL.md
- kb/entities/COLLECTION.md
- kb/entities/INDEX.md
- kb/entities/organizations/E3DC GmbH.md
- kb/entities/people/E3DC GmbH.md
- kb/index.md
- kb/log.md
- tools/CONTRACT.md
- tools/chemenu/commands/lint.py
- tools/chemenu/kb_scan.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_types_cmd.py
- types/entity.md
- types/entity.organization.md
- types/entity.person.md
- types/entity.schema.yaml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-04 10:51:46 +02:00
1 parent f3ccbd86f9
commit 4ec22d376d
25 files changed
+368 -30

No files matched your search

+19 -1
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
---
## 8.0.0-beta.34 - 2026-10-03 - Link-Katalog: Beteiligungs- und RACI-Label von der Projektseite aus
## 8.0.0-beta.35 - 2026-10-04 - Organisationsseiten: Personen als Abschnitt mit Aufstieg, entity_type organization, member-of, Lint-Befund broken_anchors
**Author:** Torben Nehmer
@@ -105,6 +105,7 @@ concern - readable here, never shipped as something to parse.
- new: a subtype gets its own page skeleton from types/<type>.<value>.md
- The test suite no longer ships, and dist upgrade deletes what a release stops shipping
- Link-Katalog: Beteiligungs- und RACI-Label von der Projektseite aus
- Organisationsseiten: Personen als Abschnitt mit Aufstieg, entity_type organization, member-of, Lint-Befund broken_anchors
**Low impact**
- version bump no longer points at version release in its output
@@ -147,6 +148,23 @@ concern - readable here, never shipped as something to parse.
- Page-material passages in type-spec.md, type-guidance.md and language-boundaries.md name subtype templates
<!-- /wikitool:bumps -->
### Organisationsseiten: Personen als Abschnitt mit Aufstieg, entity_type organization, member-of, Lint-Befund broken_anchors
Personen, von denen eine Quelle nur Namen, Rolle und Tätigkeitsbereich hergibt, waren bisher
Stub-Seiten - weit unter dem `Stub Threshold` und gegen die Regel aus `wiki-ingest` Schritt 7, nach
der ein Thema nur mit Material eine Seite bekommt. Nach Kunde gruppieren ließen sie sich auch
nicht, weil `entity_type` genau eine Area wählt. Jetzt stehen sie als `###`-Abschnitt unter
`## Personen` auf der Seite ihrer Organisation und steigen erst zur eigenen Seite auf, wenn eine
Quelle Material dafür hergibt; dann trägt die Personenseite `member-of` auf die Organisation.
Dazu kommt ein eigener `entity_type: organization` mit Area `organizations/` und eigener
Seitenvorlage (`types/entity.organization.md`). `person` meint nur noch Menschen, und
`E3DC GmbH` ist im Korpus umgezogen. Der Aufstieg ist eine Prozedur in
`instructions/page-lifecycle.md` und bewusst kein Kommando, weil zwei seiner Schritte Urteile sind.
Was er stillschweigend kaputt machen kann, meldet `lint` jetzt als **Broken Anchors**: ein
`[[Seite#Abschnitt]]`, dessen Seite existiert, aber keinen solchen Abschnitt mehr hat. Der Befund
ist nicht hart, damit der Lint einer bestehenden Instanz nach dem Upgrade nicht rot wird, wo er
vorher grün war (Gitea #172).
### Link-Katalog: Beteiligungs- und RACI-Label von der Projektseite aus
Der Katalog hatte für Beteiligung kein Label - nur `owns`, `maintains` und `authored`, alle von der
+2 -1
View File
@@ -120,7 +120,8 @@ chemenu/
│ │ ├── systems/
│ │ ├── tools/ # own INDEX.md once past 50 pages
│ │ ├── technologies/
│ │ └── people/
│ │ ├── people/
│ │ └── organizations/
│ ├── concepts/ # COLLECTION.md + INDEX.md + areas below
│ │ ├── architectures/
│ │ ├── patterns/
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.34
8.0.0-beta.35
+6 -3
View File
@@ -128,13 +128,16 @@ want it.
### `entities`
Concrete, pointable things: codebases, deployed systems, tools, technologies, people.
Concrete, pointable things: codebases, deployed systems, tools, technologies, people,
organizations.
- **Quality goal:** pointability plus currency - what the thing is, where it actually is, and
whether that is still true.
- **Areas** driven by the `entity_type:` field: `codebases/`, `systems/`, `tools/`,
`technologies/`, `people/`. Areas, not collections - they inherit the contract and carry no
`COLLECTION.md`.
`technologies/`, `people/`, `organizations/`. Areas, not collections - they inherit the
contract and carry no `COLLECTION.md`.
- **People live on their organization's page** as a section until a source carries material for
a page of their own - the alternative to a directory of one-line person stubs.
- **Per-area emphasis** spelled out, so a system page is not written like a technology page.
- `required_by_stack: false`.
+5
View File
@@ -144,6 +144,7 @@ entity to entity.
| `staffed-by` | — | is carried out, in part, by the target's work |
| `consults` | — | draws on the target's judgment without the target carrying the work |
| `informs` | — | keeps the target informed, without the target taking part |
| `member-of` | — | belongs to the organization the target is |
| `alternative-to` | itself | serves the same purpose as the target, so a reader choosing between them wants both |
`uses` versus `depends-on` is the distinction worth keeping sharp: if removing the target breaks
@@ -172,6 +173,10 @@ A collection authorises whichever of the five its pages need. A household wiki m
and drop `involves`. Someone only mentioned - neither working, consulted nor informed - takes no
edge at all (step 1).
`member-of` versus `part-of`: a department is a component of its company and takes `part-of`; a
person belongs to one without being a component of it, and takes `member-of`. It has no inverse -
an organization page lists its people in its own text, and its inbound view shows the rest.
`alternative-to` versus `contrasts` versus `compares-with`: `contrasts` asserts a *difference
worth reading both for*, `alternative-to` asserts *substitutability* - two things a reader might
pick between for the same job. `compares-with` weighs them on named dimensions, which in this
+44 -4
View File
@@ -1,7 +1,7 @@
---
type: types/instruction.md
name: page-lifecycle
description: Rename a page, delete one, or drop a single cross-reference without breaking the links that point at it.
description: Rename a page, delete one, move it, promote a section of one to a page of its own, or drop a single cross-reference - without breaking the links that point at it.
---
# Rename, delete, or unlink a page
@@ -20,6 +20,7 @@ one and leaves the others pointing at nothing.
- [Rename](#rename)
- [Delete](#delete)
- [Move](#move)
- [Promote a section to its own page](#promote-a-section-to-its-own-page)
- [Drop a single reference](#drop-a-single-reference)
- [Afterwards](#afterwards)
- [Scope](#scope)
@@ -88,6 +89,45 @@ in case or Unicode normalization - is refused, not silently overwritten. That on
pre-existing duplicate-title collision, which `lint`'s **Duplicate Titles** and **Unportable
Titles** findings report separately.
## Promote a section to its own page
A subject can live as a section of another page until it earns its own - the shipped `entities`
profile does this with people on their organization's page. Promoting one is not a move: a new
page is born, and a section shrinks. No command does it in one step, because two of its steps
are judgments - which edges meant the person and which the organization - and it is rare.
1. **Create the page** from the section's content, with the tool:
```bash
tools/wikitool new entity --name "<Name>" --set entity_type=person --set provenance=<value>
```
Move the section's prose into it, and its citations with `cite add` against the same sources.
2. **Shrink the section** on the parent page to one bullet under the heading that held it -
`- [[<Name>]] - <role>` - and drop the section's own heading. With the heading gone, any link
step 4 misses stops resolving and step 5 reports it, instead of landing quietly on a stub.
3. **Connect the two** - for a person, the membership edge on the new page:
```bash
tools/wikitool xref add --a "<Name>" --b "<Organization>" --rel member-of
```
4. **Find every link that meant the section**, and point it at the new page:
```bash
tools/wikitool search "[[<Organization>#<Name>"
```
Search is literal by default, so the brackets need no escaping. Rewrite each hit to
`[[<Name>]]`, and move any edge on those pages that meant the person - `consults:
<Organization>` for a client contact, say - from the organization to the new page with
`xref remove` and `xref add`. An edge that meant the organization as a whole stays.
5. **Check** - `tools/wikitool lint` reports no `broken_anchors` and no `broken_links`. Then close
out as for a new page.
## Drop a single reference
```bash
@@ -101,9 +141,9 @@ hand-edit gets cleared. Idempotent.
## Afterwards
Always close out with [publish-cycle.md](publish-cycle.md), using `--op rename`, `--op delete`,
or `--op move`. A move changed no reference, so run `wikitool index rebuild` rather than
`sources rebuild-index` - the catalog is built from where a page's file sits, and nothing else
about it moved. Then confirm nothing was left dangling:
`--op move`, or `--op create` for a promotion. A move changed no reference, so run
`wikitool index rebuild` rather than `sources rebuild-index` - the catalog is built from where a
page's file sits, and nothing else about it moved. Then confirm nothing was left dangling:
```bash
tools/wikitool lint
+5 -2
View File
@@ -303,13 +303,16 @@ validator complains - and the ticked list is the only record that they happened.
not a page of its own. A page that only restates its own title is worse than the mention it
came from: `lint` measures structure and never substance, so nothing reports it, and the next
session reads it as covered ground and stops looking at the source. Applies per subject, not
per source - a wide source may well earn ten pages and decline twenty.
per source - a wide source may well earn ten pages and decline twenty. A person the source
names with no more than a role goes where the collection contract puts such people - with the
shipped `entities` profile, a section on their organization's page rather than a page of
their own.
New:
```bash
tools/wikitool new entity --name "<Name>" \
--set entity_type=<system|codebase|tool|technology|person> --set provenance=sourced
--set entity_type=<system|codebase|tool|technology|person|organization> --set provenance=sourced
```
(`mixed` if you will also add unsourced general-knowledge context.) Then write the
+2 -2
View File
@@ -41,8 +41,8 @@ mechanical half looks exactly like a complete one.
No flags: prints the sections that found something, writes the full report to
`reports/Lint Report <YYYY-MM-DD>.md`, and names that path. This deterministically finds
unreadable frontmatter, broken wikilinks, wikilinks wrapped across a line break, dangling
frontmatter references, orphan pages,
unreadable frontmatter, broken wikilinks, wikilinks wrapped across a line break, section
anchors that name no heading on their page, dangling frontmatter references, orphan pages,
catalog drift, missing fields, duplicate titles, titles that are not valid, unique file
names on Windows and macOS, filename/title mismatches, broken
`raw_files:` references, raw files claimed by more than one source page, invalid type paths,
+42 -2
View File
@@ -1,7 +1,7 @@
---
profile: entities
outbound:
entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, owned-by, authored, involves, alternative-to, implements, part-of, composition, supersedes, derived-from, adapted-from, see-also]
entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, owned-by, authored, involves, member-of, alternative-to, implements, part-of, composition, supersedes, derived-from, adapted-from, see-also]
concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, authored, alternative-to, see-also]
sources: [evidenced-by, defined-in, see-also]
comparisons: [compares-with, see-also]
@@ -23,6 +23,17 @@ provenance, citation, the confidence machinery - and
[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms,
tone, relationship labels, the confidence rubric. Neither is restated here.
<!-- wikitool:toc -->
## Contents
- [Types offered](#types-offered)
- [Per-area emphasis](#per-area-emphasis)
- [People live on their organization's page until they earn their own](#people-live-on-their-organizations-page-until-they-earn-their-own)
- [Authorised labels](#authorised-labels)
- [Outbound linking](#outbound-linking)
- [What does not belong here](#what-does-not-belong-here)
<!-- /wikitool:toc -->
## Types offered
`entity` (`tools/wikitool types describe entity`). The `entity_type:` field selects the area:
@@ -33,7 +44,8 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
| `systems/` | Deployed and running systems, given a descriptive name |
| `tools/` | CLI and desktop tools, named as the tool names itself |
| `technologies/` | Protocols, languages, formats, in their standard spelling and capitalization |
| `people/` | People and organizations, by full name or common handle |
| `people/` | People, by full name or common handle |
| `organizations/` | Companies, public bodies, associations and their departments, by the name they use themselves |
These are areas, not collections: they inherit this contract and carry no `COLLECTION.md`.
@@ -48,6 +60,29 @@ These are areas, not collections: they inherit this contract and carry no `COLLE
- **Tools** - purpose, installation, usage, notable options, which projects use it.
- **People** - role, affiliation, and the projects or decisions they are connected to. Nothing
personal beyond what the source states.
- **Organizations** - what kind of body it is, what it stands to this wiki as (client, supplier,
vendor), and the people in it - see below.
## People live on their organization's page until they earn their own
A person whose source material is a name, a role and a field of work does not get a page: that
page would restate its own title, which is worse than the mention it came from (`wiki-ingest`
step 7). They get a `###` section under the `## Personen` heading of their organization's page
instead - role, field of work, one to three lines. The organization page is then the grouping a
directory level cannot be, and it sits inside the size a page should have rather than a dozen
stubs below it.
A person **is promoted** to a page of their own once a source carries material for one. Their
section shrinks to one line with a `[[wikilink]]`, their page carries `member-of` to the
organization, and every edge and anchor link that meant them moves to the new page. The steps are
[instructions/page-lifecycle.md](../../instructions/page-lifecycle.md) § "Promote a section to its
own page"; `wikitool lint` reports an anchor link left pointing at the vanished section as
`broken_anchors`.
Until then, a person is reached through their organization: a project page's edge points at the
organization (`consults: Kunde X`), and the prose names the person as `[[Kunde X#Anna Müller]]`.
An organization that outgrows its page - by `Split Threshold`, roughly 120-150 lines - splits off
a department or site as an organization page of its own, linked `part-of` the parent.
## Authorised labels
@@ -73,6 +108,11 @@ for the thing nor keeps it running; take `maintains` or `owns` from the person's
those is true instead. `owned-by` is `owns` read from the thing's side - write whichever page a
reader would ask the question on, not both by reflex.
`member-of` runs from a person page to the organization they belong to, and only from a person
who has been promoted to a page (see above) - everyone else is a section on that organization's
page already. It is not `part-of`: a department is a component of its company, a person is not,
and `part-of` stays for the department or site split off an organization that outgrew its page.
Adding a label here is a deliberate contract change, not a way around a refusal.
## Outbound linking
+6 -1
View File
@@ -20,12 +20,17 @@
| [[wiki-skills]] | codebase | Umsetzung der Wiki-Skills für Claude Code von kfchou | 2026-09-19 |
| [[wiki-skills-vanillaflava]] | codebase | Referenzimplementierung plattformübergreifender LLM-Wiki-Skills | 2026-09-19 |
## Organisationen
| Page | Type | Summary | Last Modified |
|------|------|---------|----------------|
| [[E3DC GmbH]] | organization | Deutscher Hersteller von Energiespeichersystemen für Wohn- und Gewerbegebäude. | 2026-10-04 |
## Personen
| Page | Type | Summary | Last Modified |
|------|------|---------|----------------|
| [[Andrej Karpathy]] | person | KI-Forscher, der das grundlegende LLM-Wiki-Muster geprägt und damit die Methodik der Wissenskompilierung etabliert hat. | 2026-08-29 |
| [[E3DC GmbH]] | person | Deutscher Hersteller von Energiespeichersystemen für Wohn- und Gewerbegebäude. | 2026-08-29 |
| [[Rohit Gupta]] | person | Urheber von agentmemory, einem persistenten Speicher für KI-Coding-Agenten mit über 20000 GitHub-Stars. | 2026-08-29 |
| [[Vannevar Bush]] | person | Amerikanischer Ingenieur und Wissenschaftsadministrator, der 1945 das Memex-Konzept ersann: ein persönlicher, kuratierter Wissensspeicher mit assoziativen Dokumentpfaden. | 2026-08-29 |
@@ -1,9 +1,9 @@
---
type: types/entity.md
entity_type: person
entity_type: organization
tags: []
created: 2026-08-02
modified: 2026-08-29
modified: 2026-10-04
related:
- owns: E3DC
sources: []
@@ -12,7 +12,7 @@ summary: Deutscher Hersteller von Energiespeichersystemen für Wohn- und Gewerbe
---
# E3DC GmbH
**Typ:** person
**Typ:** organization
## Beschreibung
+3 -2
View File
@@ -19,7 +19,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
- **Entities:** 72
- **Gtd:** 3
- **Sources:** 29
- **Last Updated:** 2026-09-30
- **Last Updated:** 2026-10-04
---
@@ -49,7 +49,8 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
| Area | Pages | Index |
|------|------:|-------|
| Codebasen | 11 | [entities/INDEX.md#codebasen](entities/INDEX.md#codebasen) |
| Personen | 4 | [entities/INDEX.md#personen](entities/INDEX.md#personen) |
| Organisationen | 1 | [entities/INDEX.md#organisationen](entities/INDEX.md#organisationen) |
| Personen | 3 | [entities/INDEX.md#personen](entities/INDEX.md#personen) |
| Systeme | 6 | [entities/INDEX.md#systeme](entities/INDEX.md#systeme) |
| Technologien | 20 | [entities/INDEX.md#technologien](entities/INDEX.md#technologien) |
| Werkzeuge | 31 | [entities/INDEX.md#werkzeuge](entities/INDEX.md#werkzeuge) |
+6
View File
@@ -239,3 +239,9 @@ Inhalt in 'Chemenu 8.0.0 - Installation und Windows' aufgegangen; keine eingehen
Demo-Projektseiten nach Gitea #156 (E1): completed aus Gitea #119/#124, dormant aus Gitea #69; provenance general.
---
## [2026-10-04] move | E3DC GmbH
entity_type person -> organization (neuer Subtyp, Gitea #172); Seite nach kb/entities/organizations/ verschoben, Typ-Zeile angepasst.
---
+1
View File
@@ -1124,6 +1124,7 @@ Run structural lint checks against kb/.
- Structural and provenance checks over `kb/`: broken wikilinks, wikilinks wrapped across a line break, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, and unbalanced generated-region markers.
- Unportable Titles is a hard finding, and hard at every `kb_version`: a page whose title is not a valid file name on Windows and macOS (forbidden character, reserved name, trailing dot or space), or that collides with another page by case or Unicode normalization. `wikitool rename` is the fix.
- Wrapped Wikilinks is a hard finding: a `[[...]]` with a line break inside it. The link graph reads it as the title it folds to (the break and its indentation become one space), so it is not also a broken link unless that title is missing; the fix is to put it back on one line.
- Broken Anchors is advisory: a `[[Page#Section]]` whose page exists but has no heading the anchor names (any level, compared without case, inline-code backticks or extra whitespace; every segment of a nested `[[Page#A#B]]`). The link still reaches the page, so nothing else reports it - typically a section that was promoted to a page of its own or renamed. A missing page is Broken Wikilinks instead.
- Pages nested more than one directory below their collection are a hard finding - the generated catalog folds these into their area silently rather than merely reading it.
- Edges whose label is missing or not authorised by the source collection's `outbound:` are both hard once `kb_version` has reached the release that introduced labelled edges, and advisory below it.
- Advisory only: `see-also` edges whose reverse direction already carries a specific label - never migration-gated.
+5
View File
@@ -73,6 +73,11 @@ __all__ = [
"graph reads it as the title it folds to (the break and its indentation become one "
"space), so it is not also a broken link unless that title is missing; the fix is to put "
"it back on one line.",
"Broken Anchors is advisory: a `[[Page#Section]]` whose page exists but has no heading "
"the anchor names (any level, compared without case, inline-code backticks or extra "
"whitespace; every segment of a nested `[[Page#A#B]]`). The link still reaches the page, "
"so nothing else reports it - typically a section that was promoted to a page of its "
"own or renamed. A missing page is Broken Wikilinks instead.",
"Pages nested more than one directory below their collection are a hard finding - the "
"generated catalog folds these into their area silently rather than merely reading it.",
"Edges whose label is missing or not authorised by the source collection's `outbound:` "
+56
View File
@@ -144,6 +144,62 @@ def wrapped_wikilinks(body: str) -> list[str]:
]
# `[[Target#Section]]`, `[[Target#Section#Sub|alias]]`: group 1 is the target,
# exactly as `WIKILINK_RE` reads it; group 2 is everything from the first `#` up
# to an alias or the closing brackets. A bare self-anchor `[[#Section]]` names
# no target and is not matched - `WIKILINK_RE` reads no link there either.
ANCHORED_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)#([^\]|]*)")
# An ATX heading at any level. Optional closing hashes are not part of its text.
_HEADING_LINE_RE = re.compile(r"^#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")
def normalize_anchor(text: str) -> str:
"""The form a section anchor and a heading are compared in: inline-code
backticks dropped, whitespace runs folded, case folded.
Deliberately loose. An anchor is written by hand from a heading a reader
saw rendered, so `[[Kunde X#anna müller]]` means the `### Anna Müller`
section; what `broken_anchors` is for is a section that is *gone*, not one
spelled with different capitals.
"""
return " ".join(text.replace("`", "").split()).casefold()
def heading_anchors(body: str) -> set[str]:
"""Every heading in this body, at any level, in `normalize_anchor` form.
Code-aware like `toc.iter_headings`: detection runs on the masked body, so
a `# comment` line inside a fence is not a heading, and the text is read
back from the unmasked line at the same position.
"""
original = body.split("\n")
masked = strip_code_spans(body).split("\n")
anchors: set[str] = set()
for masked_line, original_line in zip(masked, original):
if not masked_line.startswith("#") or not _HEADING_LINE_RE.match(masked_line):
continue
match = _HEADING_LINE_RE.match(original_line)
if match:
anchors.add(normalize_anchor(match.group(1)))
return anchors
def anchored_wikilinks(body: str) -> list[tuple[str, str]]:
"""`(target, anchor)` for every wikilink in this body that names a
section, in order of appearance, code masked out as everywhere else.
The target is normalized like every other reader's; the anchor is returned
as written (`A#B` for a nested one), so a report can quote it - compare it
through `normalize_anchor`, segment by segment.
"""
return [
(normalize_link_target(m.group(1)), m.group(2).strip())
for m in ANCHORED_WIKILINK_RE.finditer(strip_code_spans(body))
if m.group(2).strip()
]
def count_wikilinks(body: str) -> Counter[str]:
"""How often this body links to each page.
+42
View File
@@ -36,12 +36,15 @@ from chemenu.version import Version
from chemenu.kb_scan import (
GENERATED_INDEX,
WIKILINK_RE,
anchored_wikilinks,
build_link_graph,
find_duplicate_title_paths,
find_nested_pages,
heading_anchors,
inbound_links,
iter_kb_pages,
load_kb_pages,
normalize_anchor,
normalize_link_target,
wrapped_wikilinks,
)
@@ -303,6 +306,33 @@ def _repo_relative(path: Path, kb_dir: Path) -> str:
return path.relative_to(kb_dir.parent).as_posix()
def broken_anchors(pages: dict[str, Page]) -> list[dict]:
"""`{"page", "target", "anchor"}` for every `[[Target#Section]]` whose
target page exists but carries no heading the anchor names.
A missing target is `broken_links`' finding and is not repeated here. A
nested anchor `[[T#A#B]]` resolves when every segment is a heading on `T` -
the section path a renderer would walk, without insisting on the nesting.
The case this exists for is a section that went away: a person promoted
from their organization's page to one of their own, a section renamed. The
link still reaches the right page, so nothing else reports it, and it no
longer reaches the part of the page it was written for.
"""
headings: dict[str, set[str]] = {}
findings = []
for title, page in sorted(pages.items()):
for target, anchor in sorted(set(anchored_wikilinks(page.body))):
if target not in pages:
continue
if target not in headings:
headings[target] = heading_anchors(pages[target].body)
segments = [normalize_anchor(s) for s in anchor.split("#") if s.strip()]
if not all(segment in headings[target] for segment in segments):
findings.append({"page": title, "target": target, "anchor": anchor})
return findings
def run_lint(kb_dir: Path) -> dict:
pages = load_kb_pages(kb_dir)
duplicate_titles = find_duplicate_title_paths(kb_dir, config.ROOT)
@@ -550,6 +580,7 @@ def run_lint(kb_dir: Path) -> dict:
"frontmatter_errors": frontmatter_errors,
"broken_links": broken_links,
"wrapped_wikilinks": wrapped_links,
"broken_anchors": broken_anchors(pages),
"orphan_pages": orphan_pages,
"most_linked": most_linked,
"inbound_counts": inbound_counts,
@@ -615,6 +646,11 @@ def render_markdown(report: dict) -> str:
lines, "Wrapped Wikilinks", report.get("wrapped_wikilinks", []),
lambda i: f"[[{i['page']}]] wraps a wikilink across lines - write it on one: [[{i['target']}]]",
)
_section(
lines, "Broken Anchors - advisory, not an error", report.get("broken_anchors", []),
lambda i: f"[[{i['page']}]] links to [[{i['target']}#{i['anchor']}]], but [[{i['target']}]] "
"has no such heading - point the link at the page that section became, or drop the anchor",
)
_section(lines, "Orphan Pages (no inbound links)", report["orphan_pages"], lambda i: f"[[{i}]]")
_section(
lines, f"Most-Linked Pages (top {MOST_LINKED_COUNT} hubs)", report["most_linked"],
@@ -864,6 +900,12 @@ def default_report_path(report: dict) -> Path:
# it needs no migration - so failing a lint run on it would penalise an instance
# that never asked for that platform.
#
# `broken_anchors` is advisory: the link still reaches the page it names, only
# not the section, which is a weaker defect than `broken_links`. And it arrived
# after corpora that may already carry such links - promoting it would turn an
# instance's lint red on the upgrade that shipped it, which no other part of
# that upgrade asked for.
#
# `wrapped_wikilinks` is hard from the start without tightening anything: every
# link it names was a hard `broken_links` finding before the link graph learned
# to fold a wrapped target, so an instance's lint is exactly as red as it was -
+76
View File
@@ -103,6 +103,82 @@ def test_a_wrapped_link_inside_code_is_no_finding(kb_dir):
assert report["broken_links"] == []
def _organization_with_people(kb_dir) -> None:
"""An organization page holding its people as sections - the shape
`broken_anchors` exists for - plus a heading that only appears inside a
fence, which must not count as one."""
write_page(
kb_dir / "entities/organizations/Kunde X.md",
{"type": "types/entity.md", "entity_type": "organization", "tags": [],
"created": "2026-10-04", "modified": "2026-10-04", "related": [], "sources": []},
"\n# Kunde X\n\n## Personen\n\n### Anna Müller\n\nEinkauf.\n\n"
"#### `Bob` Builder ##\n\nBetrieb.\n\n```\n## Fenced\n```\n",
)
def test_an_anchor_naming_an_existing_heading_is_no_finding(kb_dir):
"""Any heading level, any capitalisation, inline-code backticks and closing
hashes ignored, an alias after the anchor, a nested anchor whose every
segment is a heading."""
_organization_with_people(kb_dir)
_gdeploy_saying(
kb_dir,
"[[Kunde X#Anna Müller]], [[Kunde X#anna MÜLLER|Anna]], [[Kunde X#Bob Builder]], "
"[[Kunde X#Personen#Anna Müller]], [[Kunde X#Kunde X]].",
)
report = run_lint(kb_dir)
assert report["broken_anchors"] == []
assert report["broken_links"] == []
def test_an_anchor_naming_no_heading_is_a_finding(kb_dir):
"""The promoted-person case: the page is still there, the section is not.
The anchor is reported as written, not in its normalized form."""
_organization_with_people(kb_dir)
_gdeploy_saying(kb_dir, "Ask [[Kunde X#Carla Neu|Carla]] or [[Kunde X#Personen#Dora]].")
report = run_lint(kb_dir)
assert report["broken_anchors"] == [
{"page": "gdeploy", "target": "Kunde X", "anchor": "Carla Neu"},
{"page": "gdeploy", "target": "Kunde X", "anchor": "Personen#Dora"},
]
assert report["broken_links"] == []
assert "has no such heading" in render_markdown(report)
def test_a_heading_inside_a_fence_is_not_a_section(kb_dir):
_organization_with_people(kb_dir)
_gdeploy_saying(kb_dir, "See [[Kunde X#Fenced]].")
assert run_lint(kb_dir)["broken_anchors"] == [
{"page": "gdeploy", "target": "Kunde X", "anchor": "Fenced"},
]
def test_an_anchor_on_a_missing_page_is_only_a_broken_link(kb_dir):
_gdeploy_saying(kb_dir, "See [[Nonexistent Page#Somewhere]].")
report = run_lint(kb_dir)
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
assert report["broken_anchors"] == []
def test_an_anchor_inside_code_and_a_bare_self_anchor_are_no_finding(kb_dir):
_organization_with_people(kb_dir)
_gdeploy_saying(
kb_dir,
"Write `[[Kunde X#Nowhere]]` like this, or:\n\n```\n[[Kunde X#Nowhere]]\n```\n\n"
"Jump to [[#Nowhere]].",
)
assert run_lint(kb_dir)["broken_anchors"] == []
def test_a_broken_anchor_alone_keeps_lint_green():
"""Advisory: the link still reaches its page, and a hard finding would turn
an existing instance's lint red on the upgrade that shipped the check."""
assert "broken_anchors" not in HARD_ERROR_KEYS
report = {key: [] for key in HARD_ERROR_KEYS}
report["broken_anchors"] = [{"page": "a", "target": "b", "anchor": "c"}]
assert not has_hard_errors(report)
def test_lint_fixture_has_no_dangling_frontmatter_refs(kb_dir):
assert run_lint(kb_dir)["dangling_frontmatter_refs"] == []
+13
View File
@@ -1127,6 +1127,19 @@ def test_new_person_scaffolds_the_person_template(monkeypatch, kb_dir):
assert "{" not in body
def test_new_organization_scaffolds_the_organization_template(monkeypatch, kb_dir):
"""An organization is its own subtype with its own area, and its skeleton
carries the section its people live in until they earn a page."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Kunde X", "--set", "entity_type=organization",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "entities/organizations/Kunde X.md")
assert body.strip() == _rendered("entity", "entity_type", "organization", "Kunde X")
assert "## Personen" in body
assert "{" not in body
def test_new_decision_scaffolds_the_decision_template(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "concept", "--name", "Flat Subtype Files", "--set", "concept_type=decision",
+3 -2
View File
@@ -8,7 +8,7 @@ def test_get_enum_returns_schema_declared_values():
"""entity_type's valid values come from entity.schema.yaml's enum - this
is what lets config.py and new_page.py stop hand-maintaining that list."""
values = resolver.get_enum("types/entity.md", "entity_type")
assert values == ["codebase", "system", "tool", "technology", "person"]
assert values == ["codebase", "system", "tool", "technology", "person", "organization"]
def test_get_enum_shared_across_types():
@@ -147,10 +147,11 @@ def test_get_layout_reads_entity_type_specs_own_layout_field():
"tool": "tools",
"technology": "technologies",
"person": "people",
"organization": "organizations",
}
assert all(spec.get("title") for spec in layout.values())
# Order drives kb/index.md section order.
assert list(layout) == ["codebase", "system", "tool", "technology", "person"]
assert list(layout) == ["codebase", "system", "tool", "technology", "person", "organization"]
def test_concept_layout_covers_every_declared_concept_type():
+1 -1
View File
@@ -41,7 +41,7 @@ def test_types_describe_entity_reports_schema_and_body():
fields_by_name = {f["field"]: f for f in data["fields"]}
assert fields_by_name["entity_type"]["required"] is True
assert fields_by_name["entity_type"]["enum"] == [
"codebase", "system", "tool", "technology", "person",
"codebase", "system", "tool", "technology", "person", "organization",
]
assert fields_by_name["tags"]["required"] is False
# The body must carry the page skeleton an authoring LLM works from...
+3 -2
View File
@@ -1,7 +1,7 @@
---
type: types/type-spec.md
name: entity
description: Base type for entity pages - codebases, systems, tools, technologies or people
description: Base type for entity pages - codebases, systems, tools, technologies, people or organizations
schema: types/entity.schema.yaml
subtype_field: entity_type
base_dir: entities
@@ -13,6 +13,7 @@ layout:
tool: {dir: tools, title: Werkzeuge}
technology: {dir: technologies, title: Technologien}
person: {dir: people, title: Personen}
organization: {dir: organizations, title: Organisationen}
---
# Entity
@@ -27,7 +28,7 @@ how to write a conforming page is [types/entity.guidance.md](entity.guidance.md)
| Field | Required | Use |
|---|---:|---|
| `type` | Yes | `types/entity.md` |
| `entity_type` | Yes | One of: codebase, system, tool, technology, person |
| `entity_type` | Yes | One of: codebase, system, tool, technology, person, organization |
| `tags` | No | Navigation tags for categorization |
| `created` | Yes | Creation date (YYYY-MM-DD) |
| `modified` | Yes | Date last changed (YYYY-MM-DD) |
+21
View File
@@ -0,0 +1,21 @@
# {name}
**Typ:** {entity_type|capitalize}
## Beschreibung
TODO: 1-2 Absätze dazu, was diese Organisation ist und wofür sie in diesem Wiki steht.
## Kerndaten
- **Art:** TODO (Unternehmen, Behörde, Verein, Abteilung …)
- **Sitz:** TODO (falls zutreffend)
- **Beziehung:** TODO (Kunde, Lieferant, Partner … falls zutreffend)
## Personen
TODO: Ein `###`-Abschnitt je Person mit Rolle und Tätigkeitsbereich, ein bis drei Zeilen - keine eigene Seite. Eine Person steigt erst zur eigenen Seite auf, wenn eine Quelle Material dafür hergibt; dann schrumpft ihr Abschnitt auf eine Zeile mit Wikilink (instructions/page-lifecycle.md, Promote a section to its own page).
## Historie
- [{today}] - Page created via wikitool
+2 -2
View File
@@ -4,7 +4,7 @@
## Beschreibung
TODO: 1-2 Absätze dazu, wer diese Person oder Organisation ist und wofür sie in diesem Wiki steht.
TODO: 1-2 Absätze dazu, wer diese Person ist und wofür sie in diesem Wiki steht.
## Kerndaten
@@ -14,7 +14,7 @@ TODO: 1-2 Absätze dazu, wer diese Person oder Organisation ist und wofür sie i
## Beiträge
TODO: Was diese Person oder Organisation geschaffen, vertreten oder beeinflusst hat - mit Verweisen auf die betreffenden Seiten
TODO: Was diese Person geschaffen, vertreten oder beeinflusst hat - mit Verweisen auf die betreffenden Seiten
## Historie
+1 -1
View File
@@ -7,7 +7,7 @@ properties:
description: Must reference the entity type-spec
entity_type:
type: string
enum: [codebase, system, tool, technology, person]
enum: [codebase, system, tool, technology, person, organization]
description: The specific category of entity
tags:
type: array