feat: Größe ist das letzte alleinstehende (L)-Token der Zeile (D68)

Erkannt nur alleinstehend angesetzt (wie #id und :#…) — damit greifen die
Zitier-Konventionen "(L)" und ((L)) von selbst, und Backend(L) bleibt
Label. Das letzte Token gewinnt statt des ersten: Die Größe steht hinter
dem Titel, frühere Vorkommen sind Text. SPEC §1/§5 und llms.md nachgezogen;
mitgelieferte Beispiele unverändert (Parse-Vergleich alt/neu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-25 10:19:52 +02:00
co-authored by Claude Fable 5
parent 9999c7973e
commit 59da485af6
6 changed files with 119 additions and 5 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-25 ## 2026-08-25
- The size is now the last free-standing `(L)`-style token of the line — earlier ones stay in the title, and `"(L)"` or `((L))` mention a size literally
- Fix: Ctrl+click on a dependency preceded by a non-breaking space did nothing, although the parser reads it as a dependency - Fix: Ctrl+click on a dependency preceded by a non-breaking space did nothing, although the parser reads it as a dependency
- Ctrl+click a dependency `:#id` in the text jumps to the line that defines the ID — Ctrl+Enter does the same at the caret - Ctrl+click a dependency `:#id` in the text jumps to the line that defines the ID — Ctrl+Enter does the same at the caret
+42
View File
@@ -5056,3 +5056,45 @@ kann den Handler prinzipiell nicht treffen — der erste Prüflauf sah deshalb
wie ein Fehler aus, der keiner war. Geprüft wird das Tastatur-Pendant mit wie ein Fehler aus, der keiner war. Geprüft wird das Tastatur-Pendant mit
einem korrekt gebauten `KeyboardEvent`; der Klick-Weg ließ sich dagegen echt einem korrekt gebauten `KeyboardEvent`; der Klick-Weg ließ sich dagegen echt
auslösen. auslösen.
## D68 — Größe: das letzte alleinstehende Token, nicht das erste
Gemeldet vom Nutzer: Manchmal braucht der Titel runde Klammern — und wenn ihr
Inhalt zufällig ein Größenkürzel ist (`Variante (L) bauen`), fraß die
Extraktion das Titel-`(L)` als Größe. Mit echter Angabe dahinter war es
doppelt falsch: Das Literal wurde die Größe, das gemeinte `(M)` blieb im
Label. Entschieden (Nutzer): beide vorgeschlagenen Regeln kombiniert.
**Alleinstehend angesetzt** (`(^|\s)\(…\)`) — dieselbe Regel-Familie wie bei
`#id`, `:#…`, `!!!` und `&tag`, und der eigentliche Gewinn liegt darin, dass
die **vorhandenen Zitier-Konventionen dadurch von selbst greifen**: `"(L)"`
bleibt Label (das `(` hängt am `"`), `((L))` ebenso (am äußeren `(`). Kein
neues Zeichen, kein Escape — die Notation escapt weiterhin nirgends (D59).
Wer eine Größe erwähnen will, zitiert sie; llms.md führt beide Formen in der
Zitier-Faustregel.
**Das letzte Token gewinnt, nicht das erste.** Die übliche Schreibweise
stellt die Größe hinter den Titel — das letzte Token ist die Angabe, alles
davor ist Text. Damit löst sich auch der Fall ohne Anführungszeichen richtig
auf: `Variante (L) bauen (M)` → Größe `M`, Label „Variante (L) bauen“. In
der Idee-Runde war „letztes statt erstes“ zunächst verworfen worden („stille
Umdeutung bestehender Zeilen“); der Einwand wiegt hier wenig, weil die alte
Erste-gewinnt-Lesart in genau diesen Zeilen schon falsch war — es gibt keine
richtige Bedeutung, die verloren ginge.
**Preis, benannt:** `Backend(L)` ohne Leerzeichen ist keine Größe mehr. Der
Fehlermodus ist der laute (das Badge fehlt sichtbar, D59-Haltung), und in den
mitgelieferten Beispielen kommt die enge Schreibweise nicht vor — geprüft per
Grep und per Parse-Vergleich alt/neu über alle `docs/examples/*.werkbaum`
(Knoten- und Größenzahlen identisch, Warnungen unverändert; die drei
`sizeConflict` der Demo-Pläne sind Altbestand).
**Umgesetzt als Schleife über alle Treffer** statt eines cleveren
Rückwärts-Regex: gut lesbar, und der führende Leerraum der Fundstelle bleibt
beim Entfernen stehen (wie `pre` bei den übrigen Extraktionen). SPEC §1
(Schritt 4, eigener Größen-Block, Referenz-Regex) und §5 zuerst, llms.md im
selben Zug (Schritt-3-Regel, Größen-Abschnitt, Zitier-Faustregel).
**Nachgemessen:** 445 Tests (4 neue in `tests/parser.test.js`); Gegenprobe
per Mutation: erster statt letzter Treffer → genau der Letztes-gewinnt-Test
fällt; Anker entfernt → genau die zwei Alleinstehend-/Zitier-Tests. Alle
Snapshots (kanonisches Beispiel §10) unverändert.
+25 -1
View File
@@ -20,7 +20,9 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit):
(`>` / `<`) per Zeilen-Regex; `=` nur mit folgendem Leerraum (§3), (`>` / `<`) per Zeilen-Regex; `=` nur mit folgendem Leerraum (§3),
die Faltmarke ebenso (siehe unten). die Faltmarke ebenso (siehe unten).
3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht). 3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht).
4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 4. Größe: das **letzte** alleinstehend angesetzte `(XS|S|M|L|XL|XXL)`,
Groß-/Kleinschreibung egal (siehe unten). Frühere Vorkommen bleiben im
Label stehen.
5. Tags: alle `@name`-Vorkommen. 5. Tags: alle `@name`-Vorkommen.
6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten). 6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten).
7. Abhängigkeiten: alle alleinstehend angesetzten `:#a,#b`-Token (siehe unten). 7. Abhängigkeiten: alle alleinstehend angesetzten `:#a,#b`-Token (siehe unten).
@@ -84,6 +86,19 @@ Referenz-Regex (Schritt 1b, geprüft auf der kommentarfreien Zeile):
Bild sagen dasselbe. Sie sagen nichts über Fortschritt (§4) oder Bild sagen dasselbe. Sie sagen nichts über Fortschritt (§4) oder
Notwendigkeit (§3) und ändern weder Kosten noch Warnungen. Notwendigkeit (§3) und ändern weder Kosten noch Warnungen.
**Größe `(L)`** — die T-Shirt-Größe (§5) im Zeilenformat:
- Erkannt nur **alleinstehend angesetzt** (`(^|\s)\(…\)`, dieselbe Regel wie
bei `#name` und `:#…`): `Backend(L)` bleibt damit ein Label, und die
Zitier-Konventionen gelten auch hier — `"(L)"` und `((L))` bleiben Label,
wer eine Größe **erwähnen** will, schreibt sie so.
- Das **letzte** solche Token der Zeile ist die Größe; frühere Vorkommen
bleiben im Label stehen (`Variante (L) bauen (M)` → Größe `M`, Label
„Variante (L) bauen“). Die Größe steht nach der üblichen Schreibweise
hinter dem Titel — das letzte Token ist die Angabe, alles davor ist Text.
(Bis D68 galt das **erste** Token; die Umkehrung trifft nur Zeilen mit
mehreren Kandidaten.)
**Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig; **Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig;
sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11). sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11).
@@ -201,6 +216,13 @@ sie setzt die Marke immer in die zweite Stellung und löst die erste dabei auf:
^([ \t]*(?:[-|+]|=(?=[ \t]))?[ \t]*)(?:[><](?=[ \t])[ \t]*)?((?:\[[^\]]\][ \t]*)?)(?:[><](?=[ \t])[ \t]*)? ^([ \t]*(?:[-|+]|=(?=[ \t]))?[ \t]*)(?:[><](?=[ \t])[ \t]*)?((?:\[[^\]]\][ \t]*)?)(?:[><](?=[ \t])[ \t]*)?
``` ```
Für die Größe (Schritt 4, nur der **letzte** Treffer; der führende Leerraum
bleibt stehen):
```
(^|\s)\((XXL|XS|XL|S|M|L)\)
```
Für die Knoten-ID (Schritt 6, nur der erste Treffer; die letzte Gruppe ist der Für die Knoten-ID (Schritt 6, nur der erste Treffer; die letzte Gruppe ist der
optionale Trenn-Doppelpunkt, der mit entfällt): optionale Trenn-Doppelpunkt, der mit entfällt):
@@ -313,6 +335,8 @@ effektiv nicht weiter sein als das, was er braucht.
## 5. Aufwand (T-Shirt-Größen) ## 5. Aufwand (T-Shirt-Größen)
- Werte: `XS < S < M < L < XL < XXL`, notiert in Klammern, z. B. `(L)`. - Werte: `XS < S < M < L < XL < XXL`, notiert in Klammern, z. B. `(L)`.
Erkannt wird das letzte alleinstehend angesetzte Token der Zeile (§1) —
`"(L)"` und `((L))` im Titel bleiben Text.
- **Untergliederungsregel:** Ab `(M)` muss ein Element weiter zerlegt sein. - **Untergliederungsregel:** Ab `(M)` muss ein Element weiter zerlegt sein.
Ein Element ≥ M **ohne Kinder** erhält einen Geister-Knoten an gestrichelter Ein Element ≥ M **ohne Kinder** erhält einen Geister-Knoten an gestrichelter
Linie darunter (in `--warn`, `#B45309`). Sein Label ist knapp „…“; die Linie darunter (in `--warn`, `#B45309`). Sein Label ist knapp „…“; die
+7 -3
View File
@@ -105,7 +105,10 @@ One node per line. Everything except the label is optional.
### Size (effort) ### Size (effort)
- T-shirt sizes in parentheses: `(XS) (S) (M) (L) (XL) (XXL)`. - T-shirt sizes in parentheses: `(XS) (S) (M) (L) (XL) (XXL)`. Recognized
only **free-standing** (preceded by start-of-line or whitespace), and the
**last** such token of the line is the size — earlier ones stay in the
label. `"(L)"` and `((L))` mention a size literally (quoting convention).
- From `(M)` upward a node **should be decomposed further**; a node ≥ M - From `(M)` upward a node **should be decomposed further**; a node ≥ M
without children gets a placeholder hint in the diagram. without children gets a placeholder hint in the diagram.
- For cost estimation a missing size is **estimated from the sub-packages**: - For cost estimation a missing size is **estimated from the sub-packages**:
@@ -134,7 +137,8 @@ One node per line. Everything except the label is optional.
(whole line or trailing part). This also applies inside descriptions. (whole line or trailing part). This also applies inside descriptions.
2. Bare `https?://…` URL — makes the node clickable; extracted early so `@`, 2. Bare `https?://…` URL — makes the node clickable; extracted early so `@`,
`#` and `!!!` inside URLs never trigger. `#` and `!!!` inside URLs never trigger.
3. `(SIZE)`first match, case-insensitive. 3. `(SIZE)`the **last free-standing** match, case-insensitive; earlier
size-like tokens stay in the label.
4. `@name` — people tags; several per line, any position. Characters: 4. `@name` — people tags; several per line, any position. Characters:
Unicode letters, digits, `.`, `_`, `-`. Unicode letters, digits, `.`, `_`, `-`.
5. `#id`**node ID**: the *first* free-standing `#token` of the line 5. `#id`**node ID**: the *first* free-standing `#token` of the line
@@ -214,7 +218,7 @@ One node per line. Everything except the label is optional.
- Keep rejected alternatives as `[-]` instead of deleting them: the decision - Keep rejected alternatives as `[-]` instead of deleting them: the decision
stays visible (add the reason as a `%%` comment). stays visible (add the reason as a `%%` comment).
- To mention syntax literally in a label without triggering it, wrap it in - To mention syntax literally in a label without triggering it, wrap it in
parentheses or quotes: `(#id)`, `(:#a,#b)`, `"#123"`. parentheses or quotes: `(#id)`, `(:#a,#b)`, `"#123"`, `"(L)"`, `((L))`.
## Complete example ## Complete example
+15 -1
View File
@@ -286,7 +286,21 @@ export function parse(text){
let rest = m[6], url = null, size = null; let rest = m[6], url = null, size = null;
const tags = []; const tags = [];
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; }); rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; }); /* Größe (SPEC §1 Schritt 4, D68): das LETZTE alleinstehend angesetzte
`(L)`-Token der Zeile — die Größe steht nach der üblichen Schreibweise
hinter dem Titel, frühere Vorkommen sind Text und bleiben im Label.
Alleinstehend wie bei `#id` und `:#…`: `Backend(L)` bleibt Label, und
die Zitier-Konventionen `"(L)"` und `((L))` gelten damit von selbst. */
{
const reSize = /(^|\s)\((XXL|XS|XL|S|M|L)\)/gi;
let sm, lastSize = null;
while((sm = reSize.exec(rest))) lastSize = sm;
if(lastSize){
size = lastSize[2].toUpperCase();
rest = rest.slice(0, lastSize.index) + lastSize[1]
+ rest.slice(lastSize.index + lastSize[0].length);
}
}
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; }); rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
/* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT — „C#" /* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT — „C#"
bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen. bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen.
+29
View File
@@ -128,6 +128,35 @@ describe('parse — Randfälle', () => {
expect(roots[1].size).toBe('M'); expect(roots[1].size).toBe('M');
}); });
/* Größen-Token (SPEC §1 Schritt 4, D68): das LETZTE alleinstehend
angesetzte `(L)`-Token ist die Größe; frühere bleiben im Label, und die
Zitier-Konventionen `"(L)"`/`((L))` halten ein Literal heraus. */
it('das letzte alleinstehende Größen-Token gewinnt, frühere bleiben im Label', () => {
const { roots } = parse('- Variante (L) bauen (M)');
expect(roots[0].size).toBe('M');
expect(roots[0].label).toBe('Variante (L) bauen');
});
it('Größe nur alleinstehend angesetzt: Backend(L) bleibt Label', () => {
const { roots } = parse('- Backend(L)');
expect(roots[0].size).toBe(null);
expect(roots[0].label).toBe('Backend(L)');
});
it('Zitier-Konventionen: "(L)" und ((L)) bleiben Label', () => {
const { roots } = parse('- Merch "(L)" drucken\n- Auswahl ((L)) treffen (S)');
expect(roots[0].size).toBe(null);
expect(roots[0].label).toBe('Merch "(L)" drucken');
expect(roots[1].size).toBe('S');
expect(roots[1].label).toBe('Auswahl ((L)) treffen');
});
it('Größe am Zeilenanfang des Rests zählt weiterhin', () => {
const { roots } = parse('- (L) Backend');
expect(roots[0].size).toBe('L');
expect(roots[0].label).toBe('Backend');
});
it('ohne Angaben: status/url/size null, tags leer', () => { it('ohne Angaben: status/url/size null, tags leer', () => {
const { roots } = parse('- Nackt'); const { roots } = parse('- Nackt');
expect(roots[0]).toMatchObject({ status: null, url: null, size: null, tags: [] }); expect(roots[0]).toMatchObject({ status: null, url: null, size: null, tags: [] });