Files
werkbaum/frontend/src/parser.js
T
mhoennigandClaude Opus 5 3018db9f61 notation: ohne Titel vertritt die Knoten-ID ihn (SPEC §1, D60)
`- #US-123` ergab bisher nichts — leeres Label, Zeile ignoriert, ID nicht
vergeben. Wo die Kennung schon der Name ist (Ticket-Referenzen, §11), war das
verkehrt herum: Wer den Titel nicht danebenschrieb, verlor den ganzen Knoten.
Jetzt wird `#id` das Label, mit Doppelpunkt geschrieben wie ohne.

Das `#` bleibt im Label — es sagt „hier steht die Adresse, weil es keinen
Titel gibt". Der `#`-Umschalter setzt bei so einem Knoten nichts davor
(sonst `#US-123: #US-123`), und Tooltip wie aria-label lassen die ID weg, die
schon im Titel steht.

Verhaltensänderung: Die ID ist damit vergeben — `- #auth` gefolgt von
`- [ ] Echt #auth` gibt jetzt zwei Knoten und eine duplicateId-Warnung.

SPEC §1, llms.md, D60; 6 neue Tests, der alte auf die neue Regel umgeschrieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:31:39 +02:00

371 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Werkbaum-Parser — headless, ohne DOM/UI nutzbar.
Setzt docs/SPEC.md §1–§8 um: Zeilenformat, Hierarchie (Einrückung),
Zerlegungsart (Gate), Status, Größe, Links, Personen-Tags, Kommentare.
Verhalten ist normativ gegen SPEC — Änderungen zuerst dort dokumentieren. */
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 };
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}.
`name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische
Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */
export const STATUS_BY_CODE = {
'?': {code:'?', key:'idee', name:'Idee'},
' ': {code:' ', key:'geplant', name:'geplant'},
'~': {code:'~', key:'arbeit', name:'in Arbeit'},
'/': {code:'/', key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {code:'x', key:'fertig', name:'fertig'},
'^': {code:'^', key:'prod', name:'in Produktion'},
'-': {code:'-', key:'verworfen', name:'verworfen'},
'!': {code:'!', key:'highrisk', name:'High Risk Aufwand unklar'}
};
/* Setzt (`'>'`) oder entfernt (`null`) die Faltmarke einer Zeile — die Umkehrung
der Extraktion aus §1, gebraucht fürs Zurückschreiben aus dem Diagramm
(D38-Nachtrag). Angefasst wird NUR die Marke samt ihrem Leerraum: Einrückung,
Zerlegungszeichen, Statusbox und Label bleiben zeichengenau stehen, auch bei
ungewöhnlicher Spaltung wie `- [x] X`. Wurzelzeilen (ohne Zeichen) bekommen
die Marke am Zeilenanfang, wie SPEC §1 es verlangt. */
export function setFoldMark(line, mark){
/* Geschrieben wird IMMER die kanonische Stellung: unmittelbar vor dem Label,
also hinter der Statusbox (SPEC §1, D34-Nachtrag 2). Eine Marke in der
alten Stellung (zwischen Zeichen und Box) wird dabei aufgelöst — sie wird
weiterhin gelesen, aber nicht mehr erzeugt. */
const m = line.match(
/^([ \t]*(?:[-|+]|=(?=[ \t]))?[ \t]*)(?:[><](?=[ \t])[ \t]*)?((?:\[[^\]]\][ \t]*)?)(?:[><](?=[ \t])[ \t]*)?/);
return m[1] + m[2] + (mark ? mark + ' ' : '') + line.slice(m[0].length);
}
const RE_LINE = /^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/;
const RE_ID_TOKEN = /(^|\s)#([\p{L}\p{N}._-]+)/u;
/* Fortsetzungszeile (SPEC §1): Leerraum, dann `\` als letztes Zeichen. Der
Leerraum davor ist Pflicht — ohne ihn verschluckte ein Label wie `C:\temp\`
stumm den folgenden Knoten. */
const RE_CONT = /(^|[ \t])\\[ \t]*$/;
const RE_SEP = /^[ \t]*-{3,}[ \t]*$/;
/* Text → logische Zeilen: `%%`-Kommentare weg (SPEC §1, Schritt 1), dann
Fortsetzungen anhängen (Schritt 1b). Ergebnis je Eintrag:
`{raw, line, cont}` — `line` ist die Nummer der ERSTEN Textzeile (sie trägt
Einrückung, Gate und Statusbox und wird von allen Rückschreibern angefasst),
`cont` sind die Nummern der angehängten.
Verbunden wird mit genau einem Leerzeichen, die Einrückung der Folgezeile
entfällt — bis auf den Fall, dass von der ersten Zeile nur die Einrückung
übrig bleibt (` \`): Dann trägt sie die Ebene und muss stehen bleiben.
Hinter dem `---`-Trenner wird nicht mehr verbunden; dort ist der
Zeilenumbruch Absatzstruktur (SPEC §1). */
export function logicalLines(text){
const lines = text.split('\n');
const out = [];
let inDesc = false;
for(let i = 0; i < lines.length; i++){
const rec = {raw: lines[i].replace(/%%.*$/, ''), line: i + 1, cont: []};
if(!inDesc && RE_SEP.test(rec.raw)) inDesc = true;
while(!inDesc && RE_CONT.test(rec.raw)){
rec.raw = rec.raw.replace(RE_CONT, '$1');
if(i + 1 >= lines.length) break; /* letzte Zeile: der `\` entfällt einfach */
if(rec.raw && !/[ \t]$/.test(rec.raw)) rec.raw += ' ';
rec.raw += lines[++i].replace(/%%.*$/, '').replace(/^[ \t]*/, '');
rec.cont.push(i + 1);
}
out.push(rec);
}
return out;
}
/* Kurzschreibweise der Knoten-ID auflösen: `#.kc` unter `#prod-stage` wird zu
`#prod-stage.kc` (D55). Das ist eine **Eingabehilfe**, keine Notation — die
Datei enthält am Ende immer die volle ID. Deshalb steht sie hier als
Text→Text-Funktion neben `setFoldMark`: Der Editor ruft sie beim Verlassen
der Zeile auf und schreibt das Ergebnis zurück, wie das Umklappen im
Diagramm seine Faltmarke zurückschreibt (D38-Nachtrag 2).
Aufgelöst wird gegen den **nächsten Vorfahren mit ID** — nicht zwingend den
direkten Elternknoten, der kann selbst ohne ID sein. Findet sich keiner
(Wurzelzeile) oder trägt er selbst noch eine Kurzform, bleibt die Zeile
unangetastet: Lieber `#.kc` stehen lassen, als etwas Falsches hineinschreiben.
Der Beschreibungsteil hinter `---` hat keinen Baum und wird nicht angefasst. */
export function expandShortIds(text){
const lines = text.split('\n');
const stack = []; /* {width, id} — auch Knoten OHNE ID stehen drin */
let changed = false;
let fortsetzung = false; /* die vorige Zeile endete auf `\` (SPEC §1) */
for(let i = 0; i < lines.length; i++){
const raw = lines[i];
if(/^\s*-{3,}\s*$/.test(raw)) break;
const k = raw.indexOf('%%');
const head = k === -1 ? raw : raw.slice(0, k);
const tail = k === -1 ? '' : raw.slice(k);
/* Eine Fortsetzungszeile trägt keinen eigenen Knoten: Sie darf weder den
Vorfahren-Stapel verändern noch als Ort einer Kurzform gelten — die ID
steht an der ersten Zeile. */
const warFortsetzung = fortsetzung;
fortsetzung = RE_CONT.test(head);
if(warFortsetzung) continue;
const m = head.match(RE_LINE);
if(!m) continue;
const body = m[6];
if(!body.trim()) continue; /* leer oder nur Kommentar */
if(/^"(\s|$)/.test(body)) continue; /* Beschreibungszeile, kein Knoten */
const width = m[1].replace(/\t/g, ' ').length;
while(stack.length && stack[stack.length - 1].width >= width) stack.pop();
const t = body.match(RE_ID_TOKEN);
let id = t ? t[2] : null;
/* `.kc` ja, `..kc` nein — zwei Punkte sind keine vereinbarte Bedeutung. */
if(id && /^\.[^.]/.test(id)){
let anc = null;
for(let j = stack.length - 1; j >= 0; j--){
if(stack[j].id && stack[j].id[0] !== '.'){ anc = stack[j].id; break; }
}
if(anc){
id = anc + id;
lines[i] = head.slice(0, head.length - body.length)
+ body.replace(RE_ID_TOKEN, (s, pre) => pre + '#' + id)
+ tail;
changed = true;
}
}
stack.push({width, id});
}
return changed ? lines.join('\n') : text;
}
/* Status, die als „realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind
investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und
neutrale Knoten zählen nicht. */
const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
/* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, fold, status, url,
size, tags, id, deps, desc, focus, children, line}.
`desc` (SPEC §11/D40) ist der Beschreibungstext: `"`-Zeilen unter dem
Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform),
in Dokumentreihenfolge mit Zeilenumbrüchen zusammengefügt; null ohne.
`descLines` sind die ZEILENNUMMERN der Zeilen, die zu diesem Knoten gehören,
ohne einen eigenen zu tragen (SPEC §9): die der Beschreibung und die der
Fortsetzungen hinter `\`. Steht der Cursor dort, gilt dieser Knoten als
ausgewählt.
`fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung —
den wirksamen Zustand rechnet `initialCollapsed()` in model.js.
`deps` sind ID-Strings, keine Knoten-Referenzen — aufgelöst wird erst beim
Konsumenten (D37); der Parser prüft nur die Existenz (`unknownDep`).
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
Gemischt-Warnung unverändert richtig — sie schlägt an, wenn `|` oder `=`
mit `-`/`+` (oder untereinander) gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> Knoten-ID -> Abhängigkeiten -> Fokusmarke -> Label. Hierarchie
über Einrückungsbreite (Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
export function parse(text){
const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}];
const warnings = [];
const idLines = new Map(); /* Knoten-ID -> Zeile der ersten Vergabe (D36) */
const idNodes = new Map(); /* Knoten-ID -> Knoten der ersten Vergabe */
/* Beschreibungen (SPEC §11, D40): gesammelt je Knoten, am Ende zu `desc`
zusammengefügt. `lastNode` trägt die Kurzform-Zuordnung („vorangehender
Knoten"), `descTarget` den offenen Block des Beschreibungsteils; SKIP
schluckt Blocktext unter einer unbekannten ID, ohne je Zeile zu warnen. */
const descLines = new Map();
/* Welche ZEILEN zu welchem Knoten gehören (SPEC §9): Steht der Cursor in
einer Beschreibung, gilt ihr Knoten als ausgewählt — die Zeile trägt
keinen eigenen Knoten, gehört aber zu einem. Getrennt von `descLines`
gehalten, weil dort Absatztrenner zusammenfallen und Blocktext unter
unbekannter ID gar nicht erst ankommt. */
const descOwner = new Map();
const SKIP = {};
let lastNode = null, inDesc = false, descTarget = null;
const ownLine = (node, i) => {
if(!node || node === SKIP) return;
let arr = descOwner.get(node);
if(!arr) descOwner.set(node, arr = []);
arr.push(i + 1);
};
const addDesc = (node, text) => {
let arr = descLines.get(node);
if(!arr) descLines.set(node, arr = []);
if(text === '' && (!arr.length || arr[arr.length-1] === '')) return;
arr.push(text);
};
/* Kommentare sind bereits entfernt und Fortsetzungszeilen angehängt
(`logicalLines`, SPEC §1 Schritt 1 und 1b). `i` ist ab hier die Nummer der
ERSTEN Textzeile einer logischen Zeile, minus eins. */
logicalLines(text).forEach(rec => {
const raw = rec.raw, i = rec.line - 1;
/* Trenner `---` (SPEC §11, D40): drei oder mehr Bindestriche, umgebender
Leerraum erlaubt — ab hier gilt der Beschreibungsteil. Es gibt keinen
Schlusszaun; weitere Trennzeilen darin haben keine Bedeutung. */
if(/^[ \t]*-{3,}[ \t]*$/.test(raw)){ inDesc = true; return; }
if(inDesc){
if(!raw.trim()){
if(descTarget && descTarget !== SKIP){
addDesc(descTarget, ''); /* Absatztrenner */
ownLine(descTarget, i); /* die Leerzeile gehört noch zum Block */
}
return;
}
if(/^[ \t]/.test(raw)){ /* eingerückt: Blocktext */
if(descTarget == null){ warnings.push({type:'descStray', line:i+1}); return; }
if(descTarget !== SKIP){ addDesc(descTarget, raw.trim()); ownLine(descTarget, i); }
return;
}
/* Der trennende Doppelpunkt (siehe Knoten-ID unten) ist auch hier
zugelassen — ein Block-Kopf hat zwar keinen Titel dahinter, aber wer
die Schreibweise `#auth:` gewohnt ist, soll nicht darüber stolpern. */
const idm = raw.match(/^#([\p{L}\p{N}._-]+):?\s*$/u);
if(!idm){
/* Uneingerückt, keine ID-Zeile — bei einem versehentlichen Trenner
mitten im Plan melden sich die verschluckten Knotenzeilen so
zeilengenau selbst (SPEC §11). */
warnings.push({type:'descStray', line:i+1});
descTarget = null;
return;
}
const target = idNodes.get(idm[1]);
if(!target){ warnings.push({type:'unknownDesc', line:i+1, id:idm[1]}); descTarget = SKIP; }
else { descTarget = target; ownLine(target, i); } /* der Block-Kopf nennt den Knoten */
return;
}
if(!raw.trim()) return;
/* Kurzform `"` (SPEC §11, D40): Beschreibung des VORANGEHENDEN Knotens,
nur mit folgendem Leerraum (Leerraum-Regel) und nur auf Zeilen ohne
Zerlegungszeichen — `"Zitat"` und `- " Zitat" …` bleiben Labels.
Die Einrückung der Zeile hat keine Bedeutung. */
const ts = raw.replace(/^[ \t]*/, '');
if(ts[0] === '"' && /[ \t]/.test(ts[1] || '')){
if(lastNode){ addDesc(lastNode, ts.slice(2).trim()); ownLine(lastNode, i); }
else warnings.push({type:'descStray', line:i+1});
return;
}
/* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
(fehlertolerant: die Zeile geht nicht verloren). */
/* `=` (XOR, SPEC §3) nur mit folgendem Leerraum — die Leerraum-Regel hält
Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher.
Die Faltmarke `>`/`<` (SPEC §1, D38) steht unmittelbar vor dem Label,
also hinter der Statusbox — ebenfalls nur mit folgendem Leerraum,
`- [x] >Achtung` bleibt ein Label. Die frühere Stellung ZWISCHEN Zeichen
und Box wird weiter gelesen (D34-Nachtrag 2), aber nicht mehr
geschrieben; deshalb zwei Marken-Gruppen, die erste gewinnt. */
const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : m[2] === '=' ? 'xor' : 'and';
const optional = m[2] === '+';
const fold = m[3] || m[5] || null;
const boxChar = m[4]; // undefined, wenn keine Statusbox
let rest = m[6], url = null, size = null;
const tags = [];
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 ''; });
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
/* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT — „C#"
bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen.
Nur der ERSTE Treffer (kein /g): weitere `#`-Token bleiben im Label
stehen, dort wohnt die reservierte Ticket-Referenz. Zeichenmenge wie bei
`@name`; kein Lookbehind (Safari erst ab 16.4).
Übliche Schreibweise ist die ID **vor** dem Titel, abgetrennt durch einen
Doppelpunkt: `#auth: Backend`. Der Doppelpunkt ist optional, gehört weder
zur ID noch zum Label und verschwindet hier. Er wird nur geschluckt, wenn
**Leerraum oder Zeilenende** folgt — sonst bliebe von `#auth:#db` nicht
die Abhängigkeit `:#db` übrig. Die ID-Erkennung selbst bleibt unberührt
(die Doppelpunkt-Gruppe ist optional, verlangt also nichts). */
let id = null;
rest = rest.replace(/(^|\s)#([\p{L}\p{N}._-]+)(?::(?=\s|$))?/u, (s, pre, g) => { id = g; return pre; });
/* Abhängigkeiten `:#a,#b` (SPEC §1, D37): EIN zusammenhängendes Token ohne
Leerraum, nur ALLEINSTEHEND ANGESETZT — eingeklammerte Erwähnungen wie
`(:#auth,#api)` bleiben damit Label (dieselbe Zitier-Konvention wie bei
der ID). Mehrere Token je Zeile werden zusammengeführt. */
const deps = [];
rest = rest.replace(/(^|\s):#([\p{L}\p{N}._-]+(?:,#[\p{L}\p{N}._-]+)*)/gu,
(s, pre, list) => { for(const p of list.split(',')) deps.push(p.replace(/^#/, '')); return pre; });
/* Fokusmarke `!!!` (SPEC §1) — nur ALLEINSTEHEND, damit „Achtung!!!" ein
gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4):
der führende Leerraum wird mitgefangen und wieder eingesetzt. */
let focus = false;
rest = rest.replace(/(^|\s)!!!(?=\s|$)/g, (s, pre) => { focus = true; return pre; });
/* Bleibt kein Titel übrig, vertritt die ID ihn (SPEC §1): `- #US-123`
ergibt einen Knoten mit dem Label `#US-123`. Ohne ID bleibt es dabei,
dass eine labellose Zeile keine ist. `labelFromId` merkt den Fall — der
`#`-Umschalter (§9) darf die ID dann nicht ein zweites Mal davorsetzen. */
let label = rest.replace(/\s+/g, ' ').trim();
const labelFromId = !label && id != null;
if(labelFromId) label = '#' + id;
if(!label) return;
let status = null;
if(boxChar != null){
status = STATUS_BY_CODE[boxChar.toLowerCase()] || null;
if(!status) warnings.push({type:'unknownStatus', line:i+1, code:boxChar});
}
/* Doppelte ID (SPEC §1): Warnung an der späteren Zeile, mit Nennung der
ersten; die spätere ID gilt trotzdem am Knoten (fehlertolerant). Erst
hier — eine Zeile ohne Label ist schon zurückgekehrt und belegt nichts. */
if(id != null){
if(idLines.has(id)) warnings.push({type:'duplicateId', line:i+1, id, firstLine:idLines.get(id)});
else idLines.set(id, i+1);
}
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, labelFromId, type, optional, fold, status, url, size, tags, id, deps, desc:null, descLines:null, focus, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
lastNode = node;
/* Die Fortsetzungszeilen gehören diesem Knoten (SPEC §1/§9) — genau wie
Beschreibungszeilen tragen sie keinen eigenen und wählen ihn deshalb
aus, wenn der Cursor darin steht. */
rec.cont.forEach(n => ownLine(node, n - 1));
if(id != null && !idNodes.has(id)) idNodes.set(id, node);
});
/* Beschreibungen zusammensetzen: Zeilen in Dokumentreihenfolge, Leerzeilen
bleiben als Absatztrenner, Ränder getrimmt (SPEC §11, D40). */
descLines.forEach((lines, node) => {
while(lines.length && lines[lines.length-1] === '') lines.pop();
while(lines.length && lines[0] === '') lines.shift();
if(lines.length) node.desc = lines.join('\n');
});
/* Zeilenzuordnung der Beschreibungen (SPEC §9): unabhängig vom Text — auch
ein Block, dessen Zeilen sich zu nichts zusammenfügen, gehört dem Knoten. */
descOwner.forEach((lines, node) => { node.descLines = lines; });
/* Unbekannte Abhängigkeits-IDs (SPEC §1): erst nach dem Einlesen prüfbar —
Vorwärts-Referenzen sind normal. Zyklen (auch auf sich selbst) werden
bewusst NICHT einmal erkannt: Sie sind zulässig und bedeuten „wird
gemeinsam fertig" (D34/D37) — eine Prüfung hätte keinen Abnehmer. */
(function checkDeps(nodes){
for(const n of nodes){
for(const d of n.deps)
if(!idLines.has(d)) warnings.push({type:'unknownDep', line:n.line, id:d});
checkDeps(n.children);
}
})(virtualRoot.children);
/* XOR-Regel (SPEC §3): In einer `=`-Gruppe darf genau EINE Alternative
realisiert sein. Jede weitere wird einzeln gemeldet — die Warnung zeigt so
auf die Zeile, die man ansehen muss, statt pauschal auf die Gruppe (D35).
Gruppen-Gate nach dem ersten Kind, wie in der Darstellung (§3). */
(function checkXor(node){
const kids = node.children;
if(kids.length && kids[0].type === 'xor'){
let realized = 0;
for(const k of kids){
if(k.status && REALIZED.has(k.status.key)){
realized++;
if(realized > 1) warnings.push({type:'xorConflict', line:k.line, label:k.label});
}
}
}
kids.forEach(checkXor);
})(virtualRoot);
return {roots: virtualRoot.children, warnings};
}