From d3e7bffb1932b161a04ad81cd61fda5232e3b8d2 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 04:11:14 +0200 Subject: [PATCH 01/15] test(arch): EYT-80 packages/ui bekommt eine ausfuehrbare Importgrenze --- apps/api/test/architecture-red-case.test.ts | 51 +++++++++++++++++++++ apps/api/test/architecture/rules.ts | 43 +++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/apps/api/test/architecture-red-case.test.ts b/apps/api/test/architecture-red-case.test.ts index c0f0e041..ad32f74d 100644 --- a/apps/api/test/architecture-red-case.test.ts +++ b/apps/api/test/architecture-red-case.test.ts @@ -212,4 +212,55 @@ describe("Rot-Fall", () => { it("ist am sauberen Baum gruen — der Rot-Fall kommt von den Verstoessen, nicht vom Aufbau", () => { expect(violationsFor().violations).toEqual([]); }); + + it("faengt Domain-, Vertrags- und Routerimporte in packages/ui", () => { + // Die drei realistischen Einbruchstellen einer geteilten UI-Bibliothek: + // ein Domaintyp „nur fuer die Typisierung", ein Vertrags-DTO „nur fuer die + // Props", und der Router, weil ein Link ja irgendwohin muss. Alle drei + // wuerden `packages/ui` an easyTree binden und die Wiederverwendung in der + // Feld-App unmoeglich machen. + write( + "packages/ui/src/bad.tsx", + [ + 'import type { PlanningWeek } from "@easytree/domain";', + 'import type { SessionDto } from "@easytree/contracts";', + 'import Link from "next/link";', + 'import { useRouter } from "next/navigation";', + "export const bad = [Link, useRouter] as const;", + "export type Props = { week: PlanningWeek; session: SessionDto };", + ].join("\n") + "\n", + ); + + const found = violationsFor().violations.filter((v) => v.rule === "ui-dependency-allowlist"); + const messages = found.map((v) => v.message); + for (const needle of [ + "@easytree/domain", + "@easytree/contracts", + "next/link", + "next/navigation", + ]) { + expect( + messages.some((m) => m.includes(needle)), + `nicht gemeldet: ${needle}`, + ).toBe(true); + } + }); + + it("laesst react und paketinterne Pfade in packages/ui zu", () => { + // Ohne diese Gegenprobe waere eine Regel, die ALLES meldet, ebenso gruen. + write("packages/ui/src/ok-nachbar.tsx", "export const nachbar = 1;\n"); + write( + "packages/ui/src/ok.tsx", + [ + 'import type { ReactNode } from "react";', + 'import { nachbar } from "./ok-nachbar.js";', + "export const ok = (kind: ReactNode) => [kind, nachbar];", + ].join("\n") + "\n", + ); + + const gemeldet = violationsFor() + .violations.filter((v) => v.rule === "ui-dependency-allowlist") + .filter((v) => v.file === "packages/ui/src/ok.tsx"); + expect(gemeldet).toEqual([]); + }); }); diff --git a/apps/api/test/architecture/rules.ts b/apps/api/test/architecture/rules.ts index 3f7e7d0b..4632be62 100644 --- a/apps/api/test/architecture/rules.ts +++ b/apps/api/test/architecture/rules.ts @@ -107,6 +107,24 @@ const API_ALLOWED_PACKAGES: readonly RegExp[] = [ /^jose$/, ]; +/** + * Was `packages/ui/src` importieren darf (EYT-80). + * + * Absichtlich eine ALLOWLIST — dieselbe Lehre wie bei `domain-allowlist` im + * Dateikopf: „domaenenfrei" ist ein universelles Verbot, und eine endliche + * Liste verbotener Pakete kann es nie ausdruecken. Eine Sperrliste + * `["@easytree/domain", "next"]` liesse `next/link`, `@easytree/domain/dist/…` + * und jedes kuenftige Fachpaket durch. + * + * `react` steht hier, weil ein React-Primitive ohne React keins ist. Ein + * Routerpaket steht bewusst NICHT hier: `DateRangeControl` bekommt sein + * Link-Element injiziert, damit dasselbe Primitive spaeter die Feld-App + * bedienen kann (EYT-80, Zwei-Client-Architektur, Confluence 8486960). + */ +const UI_ALLOWED_PACKAGES: readonly RegExp[] = [/^react$/, /^react\/jsx-runtime$/]; + +const UI_PACKAGE = "packages/ui/src/"; + export const RULES: readonly Rule[] = [ { // ADR-001 Z. 74 — als Allowlist formuliert, siehe Dateikopf. @@ -279,6 +297,31 @@ export const RULES: readonly Rule[] = [ ? `Produktionscode darf "${ref.specifier}" nicht importieren — @easytree/contracts/testing/* ist ausschliesslich Testinfrastruktur (EYT-103).` : null, }, + { + // EYT-80 — `packages/ui` ist domaenenfrei, API-frei, auth-frei und + // routerunabhaengig (ADR-001 Z. 77, Basisdesign v2.0, Zwei-Client- + // Architektur). Bis EYT-80 war das eine Zusage im Dateikopf von + // `packages/ui/src/index.ts` und sonst nirgends: KEINE Regel hatte + // `packages/ui` im Geltungsbereich, gemessen 27.08.2026. + id: "ui-dependency-allowlist", + inScope: (file): boolean => file.startsWith(UI_PACKAGE), + check: (ref): string | null => { + if (ref.isTypeReference) { + return `packages/ui darf keine /// fuehren — sie zieht eine ganze Ambient-Oberflaeche in den Scope.`; + } + if (!ref.specifier.startsWith(".")) { + if (UI_ALLOWED_PACKAGES.some((allowed) => allowed.test(ref.specifier))) return null; + return `"${ref.specifier}" steht nicht auf der Importliste von packages/ui/src. Erlaubt sind ausschliesslich react und paketinterne relative Pfade — Fachbegriffe, Vertraege, Router und HTTP-Clients gehoeren in die Anwendung (EYT-80).`; + } + if (ref.resolved === null) { + return `Relativer Import "${ref.specifier}" ist nicht aufloesbar — der Scanner koennte ihn sonst stillschweigend uebergehen.`; + } + if (!ref.resolved.startsWith(UI_PACKAGE)) { + return `packages/ui darf nicht nach "${ref.resolved}" hinausgreifen (EYT-80).`; + } + return null; + }, + }, ]; export function evaluate(refs: readonly ImportRef[]): { From f8f2c05eaa18a05c7300d44f3fb2a3baaad76a93 Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 04:45:43 +0200 Subject: [PATCH 02/15] test(arch): EYT-80 Geltungsbereich der Importgrenze festnageln und Aufraeumen nachziehen --- apps/api/test/architecture-red-case.test.ts | 22 ++++++++---- apps/api/test/architecture.test.ts | 22 ++++++++++++ apps/api/test/architecture/rules.ts | 38 ++++++++++++++++----- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/apps/api/test/architecture-red-case.test.ts b/apps/api/test/architecture-red-case.test.ts index ad32f74d..3dd9a9ff 100644 --- a/apps/api/test/architecture-red-case.test.ts +++ b/apps/api/test/architecture-red-case.test.ts @@ -209,10 +209,6 @@ describe("Rot-Fall", () => { rmSync(join(root, "apps/api/src/modules/workforce/services"), { recursive: true, force: true }); }); - it("ist am sauberen Baum gruen — der Rot-Fall kommt von den Verstoessen, nicht vom Aufbau", () => { - expect(violationsFor().violations).toEqual([]); - }); - it("faengt Domain-, Vertrags- und Routerimporte in packages/ui", () => { // Die drei realistischen Einbruchstellen einer geteilten UI-Bibliothek: // ein Domaintyp „nur fuer die Typisierung", ein Vertrags-DTO „nur fuer die @@ -244,6 +240,7 @@ describe("Rot-Fall", () => { `nicht gemeldet: ${needle}`, ).toBe(true); } + rmSync(join(root, "packages/ui"), { recursive: true, force: true }); }); it("laesst react und paketinterne Pfade in packages/ui zu", () => { @@ -258,9 +255,22 @@ describe("Rot-Fall", () => { ].join("\n") + "\n", ); - const gemeldet = violationsFor() - .violations.filter((v) => v.rule === "ui-dependency-allowlist") + const alle = violationsFor(); + // Erst die Sichtbarkeit, dann die Sauberkeit: waere `ok.tsx` gar nicht + // gescannt worden — Pfadtippfehler, geaenderte SOURCE_EXTENSIONS, ein neuer + // SKIP_DIRS-Eintrag —, waere die leere Liste unten ohne jede Messung gruen. + expect(alle.scopeCounts.get("ui-dependency-allowlist")).toContain("packages/ui/src/ok.tsx"); + const gemeldet = alle.violations + .filter((v) => v.rule === "ui-dependency-allowlist") .filter((v) => v.file === "packages/ui/src/ok.tsx"); expect(gemeldet).toEqual([]); + rmSync(join(root, "packages/ui"), { recursive: true, force: true }); + }); + + it("ist am sauberen Baum gruen — der Rot-Fall kommt von den Verstoessen, nicht vom Aufbau", () => { + // Steht bewusst am ENDE des Blocks: als Schlussbilanz faengt sie eine + // Fixture, die ein frueherer Fall liegen gelassen hat. Weiter oben wuerde + // sie das nicht mehr tun. + expect(violationsFor().violations).toEqual([]); }); }); diff --git a/apps/api/test/architecture.test.ts b/apps/api/test/architecture.test.ts index c4bf8403..b46b8d61 100644 --- a/apps/api/test/architecture.test.ts +++ b/apps/api/test/architecture.test.ts @@ -103,6 +103,28 @@ describe("Architekturgrenzen", () => { expect(scopeCounts.get(ruleId)?.size ?? 0).toBeGreaterThan(0); }); + it("Regel ui-dependency-allowlist ueberwacht JEDE Datei unter packages/ui/src", () => { + // `scopeCounts >= 1` unterscheidet nicht zwischen „bewacht das Paket" und + // „bewacht eine Datei". Gemessen 27.08.2026: eine Verengung auf + // `packages/ui/src/b` bewacht 1 von 11 Dateien, und VOR dieser Zusicherung + // blieben dabei beide Suiten vollstaendig gruen — der Waechter waere still + // abgeruestet gewesen. Diese Zeile nennt die Verengung jetzt beim Namen + // (1 statt 11); die Sichtbarkeitsprobe im Rot-Fall („laesst react und + // paketinterne Pfade in packages/ui zu") faellt unabhaengig davon ebenfalls. + // + // Die linke Seite kommt aus dem Praefix ueber die eingesammelten Importe, + // die rechte aus `inScope` der Regel. Beide Wege muessen dieselbe Menge + // ergeben. Importfreie Dateien tauchen auf keiner Seite auf, deshalb kann + // ein neues Primitive ohne Import diese Zusicherung nicht falsch rot machen. + const ausDemPraefix = new Set( + refs.filter((ref) => ref.from.startsWith("packages/ui/src/")).map((ref) => ref.from), + ); + expect(ausDemPraefix.size).toBeGreaterThan(5); + expect([...(scopeCounts.get("ui-dependency-allowlist") ?? [])].sort()).toEqual( + [...ausDemPraefix].sort(), + ); + }); + it.each([...SCAFFOLDED_MODULES])( "Modul %s besitzt echten Inhalt und eine oeffentliche API", (slug) => { diff --git a/apps/api/test/architecture/rules.ts b/apps/api/test/architecture/rules.ts index 4632be62..3f0bd27b 100644 --- a/apps/api/test/architecture/rules.ts +++ b/apps/api/test/architecture/rules.ts @@ -116,12 +116,21 @@ const API_ALLOWED_PACKAGES: readonly RegExp[] = [ * `["@easytree/domain", "next"]` liesse `next/link`, `@easytree/domain/dist/…` * und jedes kuenftige Fachpaket durch. * - * `react` steht hier, weil ein React-Primitive ohne React keins ist. Ein - * Routerpaket steht bewusst NICHT hier: `DateRangeControl` bekommt sein - * Link-Element injiziert, damit dasselbe Primitive spaeter die Feld-App - * bedienen kann (EYT-80, Zwei-Client-Architektur, Confluence 8486960). + * `react` steht hier, weil ein React-Primitive ohne React keins ist — und ist + * der EINZIGE Eintrag. Ein Routerpaket steht bewusst NICHT hier: + * `DateRangeControl` bekommt sein Link-Element injiziert, damit dasselbe + * Primitive spaeter die Feld-App bedienen kann (EYT-80, + * Zwei-Client-Architektur, Confluence 8486960). + * + * `react/jsx-runtime` steht ebenfalls nicht hier, und das ist kein Versehen: + * `tsconfig.build.json` setzt `jsx: "react-jsx"`, der Laufzeitimport entsteht + * also beim EMIT und nie im Quelltext. `ts.preProcessFile` liest Quelltext, + * der Spezifizierer kann diese Regel damit gar nicht erreichen. Gemessen + * 27.08.2026 ist `"react"` der einzige nicht-relative Spezifizierer in allen + * 11 Dateien unter `packages/ui/src`. Ein Eintrag, der nie feuern kann, + * verbraucht die Reviewsichtbarkeit dieser Liste fuer nichts. */ -const UI_ALLOWED_PACKAGES: readonly RegExp[] = [/^react$/, /^react\/jsx-runtime$/]; +const UI_ALLOWED_PACKAGES: readonly RegExp[] = [/^react$/]; const UI_PACKAGE = "packages/ui/src/"; @@ -300,9 +309,20 @@ export const RULES: readonly Rule[] = [ { // EYT-80 — `packages/ui` ist domaenenfrei, API-frei, auth-frei und // routerunabhaengig (ADR-001 Z. 77, Basisdesign v2.0, Zwei-Client- - // Architektur). Bis EYT-80 war das eine Zusage im Dateikopf von - // `packages/ui/src/index.ts` und sonst nirgends: KEINE Regel hatte - // `packages/ui` im Geltungsbereich, gemessen 27.08.2026. + // Architektur). Bis EYT-80 stand das als Zusage im Dateikopf von + // `packages/ui/src/index.ts` und sonst nirgends. Vier Regeln sahen + // `packages/ui` zwar im Geltungsbereich (`no-app-to-app`, + // `no-generic-shared-package`, `no-fixtures-in-production-code`, + // `no-contract-testing-in-production-code`), aber alle vier sind enge + // Verbote: KEINE beschraenkte die Abhaengigkeitsoberflaeche, und + // `@easytree/domain` wie `next/link` waeren durch alle vier gelaufen + // (gemessen 27.08.2026: 11 Dateien unter packages/ui/src, 0 Regeln mit + // Importliste). + // + // Geltungsbereich ist `src/` und NICHT das ganze Paket: `test/` importiert + // zu Recht vitest und @testing-library, die eine Paket-Allowlist ablehnen + // muesste. `src/` ist zugleich genau die ausgelieferte Oberflaeche + // (tsconfig.build.json `include: ["src"]`, package.json `files: ["dist"]`). id: "ui-dependency-allowlist", inScope: (file): boolean => file.startsWith(UI_PACKAGE), check: (ref): string | null => { @@ -311,7 +331,7 @@ export const RULES: readonly Rule[] = [ } if (!ref.specifier.startsWith(".")) { if (UI_ALLOWED_PACKAGES.some((allowed) => allowed.test(ref.specifier))) return null; - return `"${ref.specifier}" steht nicht auf der Importliste von packages/ui/src. Erlaubt sind ausschliesslich react und paketinterne relative Pfade — Fachbegriffe, Vertraege, Router und HTTP-Clients gehoeren in die Anwendung (EYT-80).`; + return `"${ref.specifier}" steht nicht auf der Importliste von packages/ui/src. Erlaubt sind ausschliesslich react und paketinterne relative Pfade — Fachbegriffe, Vertraege, Router und HTTP-Clients gehoeren in die Anwendung. Der Ausweg ist NICHT, diese Liste zu erweitern: ein Primitive bekommt sein Link-Element, seinen Formatierer oder seine Daten als Prop injiziert, damit es auch die Feld-App bedienen kann (EYT-80).`; } if (ref.resolved === null) { return `Relativer Import "${ref.specifier}" ist nicht aufloesbar — der Scanner koennte ihn sonst stillschweigend uebergehen.`; From 4a6429ef7abeebaab0c7f45f990d950923754b4e Mon Sep 17 00:00:00 2001 From: DYAI2025 Date: Thu, 27 Aug 2026 04:51:47 +0200 Subject: [PATCH 03/15] feat(ui): EYT-80 domaenenfreier AppShell mit einem Anker statt zwei Literalen --- packages/ui/src/app-shell.tsx | 72 +++++++++++++++++++++++ packages/ui/src/index.ts | 1 + packages/ui/test/app-shell.test.tsx | 91 +++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 packages/ui/src/app-shell.tsx create mode 100644 packages/ui/test/app-shell.test.tsx diff --git a/packages/ui/src/app-shell.tsx b/packages/ui/src/app-shell.tsx new file mode 100644 index 00000000..6d83eb62 --- /dev/null +++ b/packages/ui/src/app-shell.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; + +export interface AppShellProps { + /** + * Beschriftung des Sprungankers. Bewusst PFLICHT und ohne Vorgabe: eine + * deutsche Vorgabe hier waere Produkttext im domaenenfreien Paket und wuerde + * die Wortwahl der Werkbank stillschweigend in die Feld-App tragen. + */ + skipLinkLabel: string; + /** Marken-/Logobereich links im Kopf. */ + brand: ReactNode; + /** + * Navigationsbereich. Der Rahmen stellt ihn nur hin — WELCHE Punkte darin + * stehen und nach welchen Rechten gefiltert wird, entscheidet die Anwendung. + * Auch das `