@ogygie/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/cli.js +15154 -0
- package/package.json +32 -0
- package/templates/feature/README.md +9 -0
- package/templates/project/AGENTS.md +483 -0
- package/templates/project/AGENTS.product.md +48 -0
- package/templates/project/CLAUDE.md +29 -0
- package/templates/project/DECISIONS.md +18 -0
- package/templates/project/DECISIONS.product.md +11 -0
- package/templates/project/FAILURES.md +16 -0
- package/templates/project/ci/ogygie-gates.yml +42 -0
- package/templates/project/design/patterns.md +28 -0
- package/templates/project/design/tokens.json +50 -0
- package/templates/project/gitignore +5 -0
- package/templates/project/ogygie.manifest.json +15 -0
- package/templates/project/package.json +31 -0
- package/templates/project/pnpm-workspace.yaml +8 -0
- package/templates/project/scripts/lint-interdits.ts +119 -0
- package/templates/project/scripts/ogygie-design-brief.ts +192 -0
- package/templates/project/scripts/ogygie-impact.ts +301 -0
- package/templates/project/scripts/verify-contracts.ts +86 -0
- package/templates/project/scripts/verify-manifest.ts +306 -0
- package/templates/project/scripts/verify-specs.ts +222 -0
- package/templates/project/specs/_TEMPLATE.spec.md +37 -0
- package/templates/project/tsconfig.base.json +14 -0
- package/templates/project/tsconfig.json +9 -0
- package/templates/project/vitest.config.ts +23 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Governed by Ogygie (spec ogygie-standalone-repo B2): overwritten as-is by
|
|
2
|
+
# `ogygie upgrade`, never merged. Product-specific CI lives in OTHER workflow
|
|
3
|
+
# files, never in this one.
|
|
4
|
+
name: ogygie-gates
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
# Least-privilege token: gitleaks-action reads the PR's commit list on
|
|
11
|
+
# pull_request events; PR comments stay disabled so no write scope is needed.
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
pull-requests: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
verifier:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
with: { fetch-depth: 0 }
|
|
22
|
+
- uses: pnpm/action-setup@v4
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with: { node-version: 22, cache: pnpm }
|
|
25
|
+
- run: pnpm install --frozen-lockfile
|
|
26
|
+
# Deliberate order: Ogygie gates BEFORE the tests —
|
|
27
|
+
# a lying manifest must break faster than a red test.
|
|
28
|
+
- run: pnpm exec ogygie check # lockstep versions + governed-file checksums (B5)
|
|
29
|
+
- run: pnpm ogygie:verify-manifest # manifest ↔ actual code (routes, folders, tables)
|
|
30
|
+
- run: pnpm ogygie:verify-specs # frontmatter + sections + spec lifecycle + decision refs
|
|
31
|
+
- run: pnpm ogygie:verify-contracts # transitive purity of every schemas.ts
|
|
32
|
+
- run: pnpm typecheck
|
|
33
|
+
- run: pnpm lint # any, ts-ignore, raw-db, dangerouslySetInnerHTML
|
|
34
|
+
- run: pnpm test
|
|
35
|
+
# Security
|
|
36
|
+
- uses: gitleaks/gitleaks-action@v2 # committed secrets
|
|
37
|
+
env:
|
|
38
|
+
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
39
|
+
GITLEAKS_ENABLE_COMMENTS: "false"
|
|
40
|
+
- uses: semgrep/semgrep-action@v1 # dangerous patterns (SAST)
|
|
41
|
+
with: { config: "p/typescript p/owasp-top-ten" }
|
|
42
|
+
- run: pnpm audit --audit-level=high # dependency CVEs
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Catalogue des patterns UI
|
|
2
|
+
|
|
3
|
+
> Les patterns sont les SEULES briques d'écran autorisées (AGENTS.md §Front) :
|
|
4
|
+
> un écran est une COMPOSITION de patterns, jamais de `<table>`, `<form>` ni
|
|
5
|
+
> fetch nus. Le starter n'a pas encore d'app front produit : à l'ouverture du
|
|
6
|
+
> premier front, chaque pattern retenu est COPIÉ dans `apps/web/src/patterns/`
|
|
7
|
+
> et sa fiche ci-dessous devient son contrat. Un pattern manquant se crée ICI
|
|
8
|
+
> d'abord (fiche + décision), jamais en one-off dans un écran.
|
|
9
|
+
>
|
|
10
|
+
> Ce catalogue est une source de vérité du brief de design
|
|
11
|
+
> (`pnpm ogygie:design-brief <feature>`) : chaque fiche `## <Name>` devient
|
|
12
|
+
> une entrée du vocabulaire de composition. Noms en anglais (machine-lisible),
|
|
13
|
+
> fiches en français (document humain).
|
|
14
|
+
|
|
15
|
+
## DataTable
|
|
16
|
+
Tableau de données (TanStack Table, seul usage autorisé). États : squelette de chargement (`DataTable.Skeleton`), vide (déléguer à EmptyState), nominal trié/paginé, erreur (déléguer à ErrorBanner). Chiffres en `tabular-nums` alignés à droite, unité explicite.
|
|
17
|
+
|
|
18
|
+
## EmptyState
|
|
19
|
+
État vide intentionnel : un titre, une explication courte, UNE action de création. Jamais de tableau ou de liste vides nus — l'écran vide est le premier écran que voit chaque nouvel utilisateur.
|
|
20
|
+
|
|
21
|
+
## ErrorBanner
|
|
22
|
+
Bandeau d'erreur qui affiche une OgygieError : message traduit via `userMessageKey` (clé ICU), code stable et `suggestedFix` repliables. Tout état d'erreur d'écran passe par lui — jamais de message d'erreur improvisé.
|
|
23
|
+
|
|
24
|
+
## ContractForm
|
|
25
|
+
Formulaire DÉRIVÉ d'un contrat Zod (react-hook-form, seul usage autorisé) : les contraintes du schéma (min/max/regex, obligatoire) sont les validations des champs — rien n'est redéclaré à la main. États : saisie, soumission en cours (bouton désactivé), erreurs de champ, erreur serveur (ErrorBanner).
|
|
26
|
+
|
|
27
|
+
## VarianceChart
|
|
28
|
+
Graphique d'écart (Recharts, seul usage autorisé) : compare une valeur constatée à une référence. États : chargement, vide, nominal, erreur. Montants en `tabular-nums`, axe et unité toujours étiquetés.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$intent": "Placeholder design tokens of the starter. A product REWRITES these values when it opens (AGENTS.product.md); the structure stays. The design tool composes ONLY from these tokens — a missing color or spacing is an amendment of this file (explicit decision), never a hardcoded value (AGENTS.md §Front).",
|
|
3
|
+
"color": {
|
|
4
|
+
"surface": "#f6f6f4",
|
|
5
|
+
"surface-raised": "#ffffff",
|
|
6
|
+
"border": "#e2e1dc",
|
|
7
|
+
"text": "#22241f",
|
|
8
|
+
"text-muted": "#5f6259",
|
|
9
|
+
"accent": "#3b5b92",
|
|
10
|
+
"accent-contrast": "#ffffff",
|
|
11
|
+
"positive": "#2f6b4f",
|
|
12
|
+
"warning": "#a96a1f",
|
|
13
|
+
"danger": "#b3423a",
|
|
14
|
+
"focus": "#7aa2ff"
|
|
15
|
+
},
|
|
16
|
+
"typography": {
|
|
17
|
+
"font-sans": "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
|
18
|
+
"font-mono": "ui-monospace, 'SF Mono', 'Cascadia Mono', monospace",
|
|
19
|
+
"size": {
|
|
20
|
+
"xs": "0.75rem",
|
|
21
|
+
"sm": "0.875rem",
|
|
22
|
+
"base": "1rem",
|
|
23
|
+
"lg": "1.125rem",
|
|
24
|
+
"xl": "1.375rem",
|
|
25
|
+
"2xl": "1.75rem"
|
|
26
|
+
},
|
|
27
|
+
"leading": {
|
|
28
|
+
"tight": "1.25",
|
|
29
|
+
"normal": "1.5"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"spacing": {
|
|
33
|
+
"1": "0.25rem",
|
|
34
|
+
"2": "0.5rem",
|
|
35
|
+
"3": "0.75rem",
|
|
36
|
+
"4": "1rem",
|
|
37
|
+
"6": "1.5rem",
|
|
38
|
+
"8": "2rem",
|
|
39
|
+
"12": "3rem"
|
|
40
|
+
},
|
|
41
|
+
"radius": {
|
|
42
|
+
"sm": "0.25rem",
|
|
43
|
+
"md": "0.5rem",
|
|
44
|
+
"lg": "0.75rem",
|
|
45
|
+
"full": "9999px"
|
|
46
|
+
},
|
|
47
|
+
"shadow": {
|
|
48
|
+
"raised": "0 1px 2px rgb(0 0 0 / 0.06), 0 2px 8px rgb(0 0 0 / 0.04)"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"product": {
|
|
4
|
+
"name": "__PRODUCT_NAME__",
|
|
5
|
+
"description": "Produit Ogygie __PRODUCT_NAME__ — décrire le job-to-be-done ici (checklist de démarrage)."
|
|
6
|
+
},
|
|
7
|
+
"ogygieVersion": "__OGYGIE_VERSION__",
|
|
8
|
+
"apps": [],
|
|
9
|
+
"features": [],
|
|
10
|
+
"conventions": {
|
|
11
|
+
"agentsMd": "AGENTS.md",
|
|
12
|
+
"failuresMd": "FAILURES.md",
|
|
13
|
+
"specsFolder": "specs/"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PRODUCT_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"packageManager": "pnpm@11.3.0",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"typecheck": "tsc -p tsconfig.json && pnpm -r --if-present run typecheck",
|
|
12
|
+
"lint": "tsx scripts/lint-interdits.ts",
|
|
13
|
+
"ogygie:verify-manifest": "tsx scripts/verify-manifest.ts",
|
|
14
|
+
"ogygie:verify-specs": "tsx scripts/verify-specs.ts",
|
|
15
|
+
"ogygie:verify-contracts": "tsx scripts/verify-contracts.ts",
|
|
16
|
+
"ogygie:impact": "tsx scripts/ogygie-impact.ts",
|
|
17
|
+
"ogygie:design-brief": "tsx scripts/ogygie-design-brief.ts",
|
|
18
|
+
"verify": "pnpm ogygie:verify-manifest && pnpm ogygie:verify-specs && pnpm ogygie:verify-contracts && pnpm typecheck && pnpm lint && pnpm test"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@ogygie/core": "__OGYGIE_VERSION__"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@ogygie/cli": "__OGYGIE_VERSION__",
|
|
25
|
+
"@types/node": "^24.0.0",
|
|
26
|
+
"tsx": "^4.20.0",
|
|
27
|
+
"typescript": "^5.8.0",
|
|
28
|
+
"vitest": "^3.2.0",
|
|
29
|
+
"zod": "^4.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent CI gate `pnpm lint`: greps the source for the absolute bans of
|
|
3
|
+
* AGENTS.md §Interdits. Deliberately dependency-free — a real ESLint setup
|
|
4
|
+
* (incl. the `ogygie/no-raw-db` rule) is tracked as a follow-up. Type-level
|
|
5
|
+
* bans are only checked on code (comments stripped); the ts-ignore ban is
|
|
6
|
+
* checked everywhere since it lives in comments by nature.
|
|
7
|
+
*
|
|
8
|
+
* The raw-db ban is no longer a hand-kept list: it is DERIVED from
|
|
9
|
+
* ogygie.manifest.json through @ogygie/core/shared-tables (mayUseRawDb), so a
|
|
10
|
+
* feature earns the right to touch the database directly only by declaring, in
|
|
11
|
+
* the manifest, that every table it owns is shared (AGENTS.md §Multi-tenant
|
|
12
|
+
* rule 4). The policy itself is unit-tested with its mutations (D22) — this
|
|
13
|
+
* script only reports.
|
|
14
|
+
*/
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { join, relative } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { OgygieManifestSchema } from "@ogygie/core/manifest";
|
|
19
|
+
import { mayUseRawDb } from "@ogygie/core/shared-tables";
|
|
20
|
+
|
|
21
|
+
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
22
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
23
|
+
const SCANNED_DIRS = ["apps", "packages", "scripts"];
|
|
24
|
+
const IGNORED_DIRS = new Set(["node_modules", "dist", ".git"]);
|
|
25
|
+
const EXTENSIONS = [".ts", ".tsx", ".mts", ".mjs"];
|
|
26
|
+
|
|
27
|
+
const TS_IGNORE_BAN = {
|
|
28
|
+
pattern: new RegExp(`@ts-${"ignore"}`),
|
|
29
|
+
rule: "ts-ignore interdit — @ts-expect-error avec justification si inévitable",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Closed-list socle exception (AGENTS.md §Multi-tenant): the tenancy feature
|
|
33
|
+
// BUILDS the contexts, so it predates them. Every other feature goes through
|
|
34
|
+
// tenantDb — unless the manifest declares that every table it owns is SHARED,
|
|
35
|
+
// which have no tenant column to filter on. Interim guard until the real
|
|
36
|
+
// ESLint `ogygie/no-raw-db` rule lands.
|
|
37
|
+
const FEATURES_PREFIX = "apps/api/src/features/";
|
|
38
|
+
// TODO(product): the ONE tenancy socle feature allowed to touch the raw
|
|
39
|
+
// database (it BUILDS the tenant contexts) — declare it in AGENTS.product.md
|
|
40
|
+
// and here. Empty until the socle exists.
|
|
41
|
+
const RAW_DB_SOCLE_ALLOWLIST = new Set<string>([]);
|
|
42
|
+
const RAW_DB_BAN = {
|
|
43
|
+
pattern: /\bctx\.db\b/,
|
|
44
|
+
rule: "ctx.db interdit hors du socle et des features à tables partagées — passer par tenantDb (AGENTS.md §Multi-tenant)",
|
|
45
|
+
};
|
|
46
|
+
const CODE_BANS = [
|
|
47
|
+
{ pattern: /as unknown as/, rule: "as unknown as interdit" },
|
|
48
|
+
{ pattern: /(:\s*any\b)|(<any[,>])|(\bas any\b)|(\bany\[\])/, rule: "type any interdit" },
|
|
49
|
+
{ pattern: /dangerouslySetInnerHTML/, rule: "dangerouslySetInnerHTML interdit" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const manifest = OgygieManifestSchema.parse(
|
|
53
|
+
JSON.parse(readFileSync(join(ROOT, "ogygie.manifest.json"), "utf8")),
|
|
54
|
+
);
|
|
55
|
+
/** Feature folder names allowed to touch the raw database, derived — not held. */
|
|
56
|
+
const rawDbAllowedFolders = new Set(
|
|
57
|
+
manifest.features
|
|
58
|
+
.filter((feature) => mayUseRawDb(feature, RAW_DB_SOCLE_ALLOWLIST))
|
|
59
|
+
.map((feature) => feature.folder.split("/").at(-1) ?? feature.name),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const violations: string[] = [];
|
|
63
|
+
|
|
64
|
+
function stripComments(line: string): string {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (trimmed.startsWith("*") || trimmed.startsWith("/*") || trimmed.startsWith("//")) {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
return line.replace(/\/\/.*$/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scan(dir: string): void {
|
|
73
|
+
for (const entry of readdirSync(dir)) {
|
|
74
|
+
if (IGNORED_DIRS.has(entry)) continue;
|
|
75
|
+
const entryPath = join(dir, entry);
|
|
76
|
+
const stats = statSync(entryPath);
|
|
77
|
+
if (stats.isDirectory()) {
|
|
78
|
+
scan(entryPath);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (entryPath === SELF) continue;
|
|
82
|
+
if (!EXTENSIONS.some((extension) => entry.endsWith(extension))) continue;
|
|
83
|
+
const lines = readFileSync(entryPath, "utf8").split("\n");
|
|
84
|
+
lines.forEach((line, index) => {
|
|
85
|
+
const location = `${relative(ROOT, entryPath)}:${index + 1}`;
|
|
86
|
+
if (TS_IGNORE_BAN.pattern.test(line)) {
|
|
87
|
+
violations.push(`${location} — ${TS_IGNORE_BAN.rule}`);
|
|
88
|
+
}
|
|
89
|
+
const code = stripComments(line);
|
|
90
|
+
for (const { pattern, rule } of CODE_BANS) {
|
|
91
|
+
if (pattern.test(code)) {
|
|
92
|
+
violations.push(`${location} — ${rule}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const relPath = relative(ROOT, entryPath);
|
|
96
|
+
if (relPath.startsWith(FEATURES_PREFIX)) {
|
|
97
|
+
const featureFolder = relPath.slice(FEATURES_PREFIX.length).split("/")[0] ?? "";
|
|
98
|
+
if (!rawDbAllowedFolders.has(featureFolder) && RAW_DB_BAN.pattern.test(code)) {
|
|
99
|
+
violations.push(`${location} — ${RAW_DB_BAN.rule}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const dir of SCANNED_DIRS) {
|
|
107
|
+
try {
|
|
108
|
+
scan(join(ROOT, dir));
|
|
109
|
+
} catch {
|
|
110
|
+
// folder may not exist yet in a fresh checkout — nothing to scan
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (violations.length > 0) {
|
|
115
|
+
for (const violation of violations) console.error(violation);
|
|
116
|
+
console.error(`\nlint — ${violations.length} violation(s) des interdits AGENTS.md.`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
console.log("lint — OK, aucun interdit détecté.");
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent Session tool `pnpm ogygie:design-brief <feature> [--stdout]`
|
|
3
|
+
* (specs/ogygie-design-brief.spec.md): writes the feature's design brief to
|
|
4
|
+
* design/briefs/<feature>.md, DERIVED from the repo's sources of truth —
|
|
5
|
+
* manifest routes, Zod contracts (via z.toJSONSchema), the feature spec,
|
|
6
|
+
* design/tokens.json, design/patterns.md, the front apps' pattern modules,
|
|
7
|
+
* and test-fixtures/ counts. Same family as ogygie-impact and verify-*:
|
|
8
|
+
* fs + manifest in, findings as OgygieError, no new dependency.
|
|
9
|
+
*
|
|
10
|
+
* This file only GATHERS: every decision (formats, warnings, empty-section
|
|
11
|
+
* signaling, rendering) lives in @ogygie/core/design-brief with its spec
|
|
12
|
+
* tests. Missing sources are passed as null — signaled downstream, never
|
|
13
|
+
* guessed here.
|
|
14
|
+
*
|
|
15
|
+
* Output contract (spec §États UI): Markdown file + console summary; --stdout
|
|
16
|
+
* additionally prints the document. Exit 0, or 1 when the feature is unknown.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import {
|
|
23
|
+
buildDesignBrief,
|
|
24
|
+
renderDesignBriefMarkdown,
|
|
25
|
+
type DesignBriefSources,
|
|
26
|
+
type JsonSchemaNode,
|
|
27
|
+
} from "@ogygie/core/design-brief";
|
|
28
|
+
import { OgygieManifestSchema } from "@ogygie/core/manifest";
|
|
29
|
+
import { dbTableNamesOf } from "@ogygie/core/shared-tables";
|
|
30
|
+
import { ogygieError } from "@ogygie/core/errors";
|
|
31
|
+
|
|
32
|
+
const ROOT = new URL("..", import.meta.url).pathname;
|
|
33
|
+
const MANIFEST_PATH = "ogygie.manifest.json";
|
|
34
|
+
const BRIEFS_FOLDER = join("design", "briefs");
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------- helpers
|
|
37
|
+
function readTextIfExists(relativePath: string): string | null {
|
|
38
|
+
const fullPath = join(ROOT, relativePath);
|
|
39
|
+
return existsSync(fullPath) ? readFileSync(fullPath, "utf8") : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** First `@intent` line of a module, the pattern's one-line contract. */
|
|
43
|
+
function intentLineOf(content: string): string | null {
|
|
44
|
+
const match = /@intent\s+(.+)/.exec(content);
|
|
45
|
+
return match?.[1] === undefined ? null : match[1].trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Production .ts/.tsx files of a folder, tests excluded, stable order. */
|
|
49
|
+
function productionSourcesOf(folder: string): Array<{ file: string; content: string }> {
|
|
50
|
+
const fullFolder = join(ROOT, folder);
|
|
51
|
+
if (!existsSync(fullFolder)) return [];
|
|
52
|
+
const sources: Array<{ file: string; content: string }> = [];
|
|
53
|
+
for (const entry of readdirSync(fullFolder, { withFileTypes: true }).sort((a, b) =>
|
|
54
|
+
a.name.localeCompare(b.name, "en"),
|
|
55
|
+
)) {
|
|
56
|
+
const relativePath = join(folder, entry.name);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
sources.push(...productionSourcesOf(relativePath));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!/\.(ts|tsx)$/.test(entry.name) || /\.test\.(ts|tsx)$/.test(entry.name)) continue;
|
|
62
|
+
sources.push({ file: relativePath, content: readFileSync(join(ROOT, relativePath), "utf8") });
|
|
63
|
+
}
|
|
64
|
+
return sources;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Rows of test-fixtures/<table>.json — null when absent or not an array. */
|
|
68
|
+
function fixtureRowCountOf(table: string): number | null {
|
|
69
|
+
const raw = readTextIfExists(join("test-fixtures", `${table}.json`));
|
|
70
|
+
if (raw === null) return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed: unknown = JSON.parse(raw);
|
|
73
|
+
return Array.isArray(parsed) ? parsed.length : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A named export of schemas.ts turned into JSON Schema — null, never a guess. */
|
|
80
|
+
function jsonSchemaOfExport(
|
|
81
|
+
schemasModule: Record<string, unknown> | null,
|
|
82
|
+
exportName: string,
|
|
83
|
+
): JsonSchemaNode | null {
|
|
84
|
+
const candidate = schemasModule?.[exportName];
|
|
85
|
+
if (candidate === undefined || candidate === null) return null;
|
|
86
|
+
try {
|
|
87
|
+
return z.toJSONSchema(candidate as z.ZodType) as JsonSchemaNode;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------- CLI args
|
|
94
|
+
const cliArguments = process.argv.slice(2);
|
|
95
|
+
const printToStdout = cliArguments.includes("--stdout");
|
|
96
|
+
const featureArgument = cliArguments.find((argument) => !argument.startsWith("--"));
|
|
97
|
+
|
|
98
|
+
if (featureArgument === undefined) {
|
|
99
|
+
const failure = ogygieError({
|
|
100
|
+
code: "DESIGN_BRIEF_UNKNOWN_FEATURE",
|
|
101
|
+
message: "No feature name given. Usage: pnpm ogygie:design-brief <feature> [--stdout].",
|
|
102
|
+
file: null,
|
|
103
|
+
cause: "process.argv carries no non-flag argument",
|
|
104
|
+
suggestedFix:
|
|
105
|
+
"Pass a feature name from ogygie.manifest.json, e.g. `pnpm ogygie:design-brief notes`.",
|
|
106
|
+
});
|
|
107
|
+
console.error(JSON.stringify(failure, null, 2));
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const featureName = featureArgument;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------- gathering
|
|
113
|
+
const manifest = OgygieManifestSchema.parse(
|
|
114
|
+
JSON.parse(readFileSync(join(ROOT, MANIFEST_PATH), "utf8")),
|
|
115
|
+
);
|
|
116
|
+
const apiApp = manifest.apps.find((app) => app.type === "api");
|
|
117
|
+
const feature = manifest.features.find((candidate) => candidate.name === featureName);
|
|
118
|
+
const owningApp =
|
|
119
|
+
feature?.app === undefined
|
|
120
|
+
? apiApp
|
|
121
|
+
: manifest.apps.find((app) => app.name === feature.app) ?? apiApp;
|
|
122
|
+
|
|
123
|
+
const patternModules: DesignBriefSources["patternModules"] = [];
|
|
124
|
+
for (const app of manifest.apps.filter((candidate) => candidate.type === "front")) {
|
|
125
|
+
const patternsFolder = join(app.path, "src", "patterns");
|
|
126
|
+
for (const source of productionSourcesOf(patternsFolder)) {
|
|
127
|
+
const name = source.file.split("/").pop()?.replace(/\.(ts|tsx)$/, "");
|
|
128
|
+
if (name !== undefined) patternModules.push({ name, intent: intentLineOf(source.content) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let sources: DesignBriefSources = {
|
|
133
|
+
featureName,
|
|
134
|
+
validFeatureNames: manifest.features.map((candidate) => candidate.name),
|
|
135
|
+
visualTokensJson: readTextIfExists(join("design", "tokens.json")),
|
|
136
|
+
patternsCatalogDoc: readTextIfExists(join("design", "patterns.md")),
|
|
137
|
+
patternModules,
|
|
138
|
+
routes: [],
|
|
139
|
+
specContent: null,
|
|
140
|
+
featureSources: [],
|
|
141
|
+
fixtureCounts: [],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (feature !== undefined && owningApp !== undefined) {
|
|
145
|
+
const featureFolder = join(owningApp.path, feature.folder);
|
|
146
|
+
const schemasPath = join(ROOT, featureFolder, "schemas.ts");
|
|
147
|
+
let schemasModule: Record<string, unknown> | null = null;
|
|
148
|
+
if (existsSync(schemasPath)) {
|
|
149
|
+
schemasModule = (await import(pathToFileURL(schemasPath).href)) as Record<string, unknown>;
|
|
150
|
+
}
|
|
151
|
+
sources = {
|
|
152
|
+
...sources,
|
|
153
|
+
routes: feature.routes.map((route) => ({
|
|
154
|
+
method: route.method,
|
|
155
|
+
path: route.path,
|
|
156
|
+
description: route.description,
|
|
157
|
+
inputSchemaName: route.schemaInput,
|
|
158
|
+
outputSchemaName: route.schemaOutput,
|
|
159
|
+
inputJsonSchema: jsonSchemaOfExport(schemasModule, route.schemaInput),
|
|
160
|
+
outputJsonSchema: jsonSchemaOfExport(schemasModule, route.schemaOutput),
|
|
161
|
+
})),
|
|
162
|
+
specContent: feature.spec == null ? null : readTextIfExists(feature.spec),
|
|
163
|
+
featureSources: productionSourcesOf(featureFolder),
|
|
164
|
+
fixtureCounts: dbTableNamesOf(feature).map((table) => ({
|
|
165
|
+
table,
|
|
166
|
+
count: fixtureRowCountOf(table),
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------- build
|
|
172
|
+
const result = buildDesignBrief(sources);
|
|
173
|
+
if (result.failure !== null) {
|
|
174
|
+
console.error(JSON.stringify(result.failure, null, 2));
|
|
175
|
+
console.error("\nogygie:design-brief — feature inconnue.");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const markdown = renderDesignBriefMarkdown(result.brief);
|
|
180
|
+
mkdirSync(join(ROOT, BRIEFS_FOLDER), { recursive: true });
|
|
181
|
+
const briefPath = join(BRIEFS_FOLDER, `${featureName}.md`);
|
|
182
|
+
writeFileSync(join(ROOT, briefPath), markdown);
|
|
183
|
+
|
|
184
|
+
if (printToStdout) console.log(markdown);
|
|
185
|
+
console.log(`── ogygie:design-brief ${featureName} ──`);
|
|
186
|
+
console.log(`Brief écrit : ${briefPath} (${markdown.split("\n").length - 1} lignes)`);
|
|
187
|
+
console.log(
|
|
188
|
+
result.brief.warnings.length === 0
|
|
189
|
+
? "Avertissements : aucun."
|
|
190
|
+
: `Avertissements : ${result.brief.warnings.length}\n${result.brief.warnings.map((warning) => ` ⚠ ${warning}`).join("\n")}`,
|
|
191
|
+
);
|
|
192
|
+
process.exit(0);
|