feat: people live on their organization's page until promoted; organization subtype, member-of, broken_anchors lint (#172)
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:
1 parent
f3ccbd86f9
commit
4ec22d376d
25 files changed
+368
-30
No files matched your search
+19
-1
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
|
||||
@@ -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:` "
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user