@roomi-fields/grillhouse 0.0.0-stage → 0.23.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.
Files changed (166) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/METACADRE.md +41 -0
  3. package/agents/developpeur.md +10 -0
  4. package/agents/explorateur.md +9 -0
  5. package/agents/questionneur.md +11 -0
  6. package/agents/referent-technique.md +9 -0
  7. package/agents/regulateur.md +11 -0
  8. package/agents/relecteur.md +9 -0
  9. package/agents/testwriter.md +10 -0
  10. package/bin/grillhouse.mjs +125 -0
  11. package/cadre/templates/projet/.claude/settings.json +30 -0
  12. package/cadre/templates/projet/.editorconfig +47 -0
  13. package/cadre/templates/projet/.env.example +57 -0
  14. package/cadre/templates/projet/.eslintrc.cjs +56 -0
  15. package/cadre/templates/projet/.github/workflows/ci.yml +66 -0
  16. package/cadre/templates/projet/.mcp.json +13 -0
  17. package/cadre/templates/projet/.prettierrc +11 -0
  18. package/cadre/templates/projet/CHANGELOG.md +61 -0
  19. package/cadre/templates/projet/CLAUDE.md +46 -0
  20. package/cadre/templates/projet/CONTRIBUTING.md +243 -0
  21. package/cadre/templates/projet/CREDITS.md +41 -0
  22. package/cadre/templates/projet/README.md +152 -0
  23. package/cadre/templates/projet/_gitignore +91 -0
  24. package/cadre/templates/projet/_npmignore +46 -0
  25. package/cadre/templates/projet/deployment/README.md +98 -0
  26. package/cadre/templates/projet/deployment/docs/01-INSTALL.md +145 -0
  27. package/cadre/templates/projet/deployment/docs/02-CONFIGURATION.md +117 -0
  28. package/cadre/templates/projet/deployment/docs/03-API.md +264 -0
  29. package/cadre/templates/projet/deployment/docs/04-TROUBLESHOOTING.md +262 -0
  30. package/cadre/templates/projet/deployment/scripts/install.ps1 +100 -0
  31. package/cadre/templates/projet/deployment/scripts/start.ps1 +61 -0
  32. package/cadre/templates/projet/deployment/scripts/stop.ps1 +37 -0
  33. package/cadre/templates/projet/deployment/scripts/test.ps1 +118 -0
  34. package/cadre/templates/projet/docs/agents/issue-tracker.md +44 -0
  35. package/cadre/templates/projet/ecosystem.config.cjs +73 -0
  36. package/cadre/templates/projet/package.json +90 -0
  37. package/cadre/templates/projet/pitmaster/SUIVI.md +22 -0
  38. package/cadre/templates/projet/src/config.ts +68 -0
  39. package/cadre/templates/projet/src/errors.ts +102 -0
  40. package/cadre/templates/projet/src/index.ts +46 -0
  41. package/cadre/templates/projet/src/services/.gitkeep +8 -0
  42. package/cadre/templates/projet/src/types/index.ts +24 -0
  43. package/cadre/templates/projet/src/utils/logger.ts +106 -0
  44. package/cadre/templates/projet/tests/integration/.gitkeep +7 -0
  45. package/cadre/templates/projet/tests/unit/config.spec.ts +68 -0
  46. package/cadre/templates/projet/tests/unit/errors.spec.ts +132 -0
  47. package/cadre/templates/projet/tests/unit/types.spec.ts +73 -0
  48. package/cadre/templates/projet/tsconfig.eslint.json +9 -0
  49. package/cadre/templates/projet/tsconfig.json +41 -0
  50. package/cadre/templates/projet/tsdoc.json +6 -0
  51. package/cadre/templates/projet/vitest.config.ts +44 -0
  52. package/cadre/templates/turbo/paquets.json +29 -0
  53. package/cadre/templates/turbo/sans-paquets.json +28 -0
  54. package/charte.md +107 -0
  55. package/docs/ARCHITECTURE.md +90 -0
  56. package/hooks/crochet.mjs +33 -0
  57. package/hooks/hooks.json +58 -0
  58. package/hooks/register.tsx +558 -0
  59. package/package.json +75 -4
  60. package/scripts/avancement.mjs +244 -0
  61. package/scripts/bilan.mjs +523 -0
  62. package/scripts/branche-de-nuit.mjs +59 -0
  63. package/scripts/cadre.mjs +109 -0
  64. package/scripts/circuit.mjs +4375 -0
  65. package/scripts/code-mort.mjs +371 -0
  66. package/scripts/composants-affectes.mjs +598 -0
  67. package/scripts/consommateurs-arbitres.mjs +192 -0
  68. package/scripts/consommateurs.mjs +310 -0
  69. package/scripts/crochet.mjs +24 -0
  70. package/scripts/crochets.mjs +135 -0
  71. package/scripts/dernier-vert.mjs +208 -0
  72. package/scripts/dette.mjs +181 -0
  73. package/scripts/droits.mjs +354 -0
  74. package/scripts/enveloppe/codes.mjs +17 -0
  75. package/scripts/enveloppe/composant.mjs +11 -0
  76. package/scripts/enveloppe/enveloppe.mjs +628 -0
  77. package/scripts/enveloppe/lancer.sh +311 -0
  78. package/scripts/etapes.mjs +323 -0
  79. package/scripts/etat-du-circuit.mjs +104 -0
  80. package/scripts/frontieres.mjs +456 -0
  81. package/scripts/gardes-du-cadre.mjs +72 -0
  82. package/scripts/greffons.mjs +338 -0
  83. package/scripts/grillhouse-maj.mjs +514 -0
  84. package/scripts/index-interfaces.mjs +293 -0
  85. package/scripts/init.mjs +117 -0
  86. package/scripts/integration/file-attente.mjs +61 -0
  87. package/scripts/integration/file.mjs +494 -0
  88. package/scripts/integration/integrer.mjs +381 -0
  89. package/scripts/integration/rejeu.mjs +139 -0
  90. package/scripts/integration/verdict.mjs +13 -0
  91. package/scripts/interfaces-contre-code.mjs +547 -0
  92. package/scripts/ligne-etat/defaut.sh +110 -0
  93. package/scripts/ligne-etat/ligne-etat.mjs +93 -0
  94. package/scripts/migrations/0.23.0-charte-importee.mjs +47 -0
  95. package/scripts/migrations/0.23.0-config-vitest.mjs +105 -0
  96. package/scripts/migrations/0.23.0-crochets-de-seance.mjs +55 -0
  97. package/scripts/migrations/0.23.0-marche-du-projet.mjs +82 -0
  98. package/scripts/migrations.mjs +43 -0
  99. package/scripts/nuit.sh +269 -0
  100. package/scripts/paquets.mjs +79 -0
  101. package/scripts/poussee-cles.mjs +46 -0
  102. package/scripts/preparation.mjs +84 -0
  103. package/scripts/references-par-les-types.mjs +420 -0
  104. package/scripts/registre.mjs +336 -0
  105. package/scripts/resultats.mjs +580 -0
  106. package/scripts/resumes.mjs +57 -0
  107. package/scripts/retirer-copie.sh +127 -0
  108. package/scripts/session-start.sh +203 -0
  109. package/scripts/sortie.mjs +155 -0
  110. package/scripts/sous-modules.sh +316 -0
  111. package/scripts/structure-guard.sh +27 -0
  112. package/scripts/superviseur.mjs +102 -0
  113. package/scripts/tableau.mjs +1886 -0
  114. package/scripts/tests.mjs +236 -0
  115. package/scripts/ticket.sh +23 -0
  116. package/scripts/turbo-des-paquets.mjs +899 -0
  117. package/scripts/un-commit-par-ticket.mjs +212 -0
  118. package/scripts/verifier.mjs +469 -0
  119. package/scripts/verrou-de-fichier.mjs +130 -0
  120. package/scripts/verrou-de-machine.mjs +211 -0
  121. package/scripts/verrous/commande.mjs +180 -0
  122. package/scripts/verrous/ticket-du-sous-agent.mjs +39 -0
  123. package/scripts/verrous/verrou.mjs +165 -0
  124. package/scripts/vitest-config/avancement.mjs +39 -0
  125. package/scripts/vitest-config/avancement.reporter.mjs +56 -0
  126. package/scripts/vitest-config/base-de-passe.mjs +128 -0
  127. package/scripts/vitest-config/base.d.ts +6 -0
  128. package/scripts/vitest-config/base.mjs +87 -0
  129. package/scripts/vitest-config/changements.mjs +173 -0
  130. package/scripts/vitest-config/cibles.mjs +207 -0
  131. package/scripts/vitest-config/duree.reporter.mjs +98 -0
  132. package/scripts/vitest-config/machine.setup.mjs +14 -0
  133. package/scripts/vitest-config/mesure.d.ts +6 -0
  134. package/scripts/vitest-config/mesure.mjs +13 -0
  135. package/scripts/vitest-config/mesure.setup.mjs +12 -0
  136. package/scripts/vitest-config/pression.mjs +90 -0
  137. package/scripts/vitest-config/resultats.mjs +23 -0
  138. package/scripts/vitest-config/resultats.setup.mjs +19 -0
  139. package/scripts/vitest.nuit.config.mjs +26 -0
  140. package/skills/developpeur/SKILL.md +175 -0
  141. package/skills/explorateur/SKILL.md +84 -0
  142. package/skills/grill/SKILL.md +238 -0
  143. package/skills/grill/references/releve-structurel.md +38 -0
  144. package/skills/mesure/SKILL.md +168 -0
  145. package/skills/pitmaster/SKILL.md +382 -0
  146. package/skills/pitmaster/references/cadre.md +119 -0
  147. package/skills/pitmaster/references/consigne-agent.md +308 -0
  148. package/skills/pitmaster/references/erreurs.md +25 -0
  149. package/skills/pitmaster/references/tour.md +124 -0
  150. package/skills/questionneur/SKILL.md +64 -0
  151. package/skills/redacteur/SKILL.md +84 -0
  152. package/skills/redacteur/references/documents-d-un-composant.md +105 -0
  153. package/skills/referent-technique/SKILL.md +145 -0
  154. package/skills/regulateur/SKILL.md +220 -0
  155. package/skills/release/SKILL.md +134 -0
  156. package/skills/release/scripts/update-version.cjs +92 -0
  157. package/skills/relecteur/SKILL.md +152 -0
  158. package/skills/relecteur/references/LICENSE-MIT +30 -0
  159. package/skills/relecteur/references/SOURCE.md +8 -0
  160. package/skills/relecteur/references/claims-check.md +14 -0
  161. package/skills/relecteur/references/deletion-check.md +14 -0
  162. package/skills/relecteur/references/edge-case-hunter.md +80 -0
  163. package/skills/relecteur/references/verification-gap.md +113 -0
  164. package/skills/testwriter/SKILL.md +187 -0
  165. package/types/index.d.ts +56 -0
  166. package/README.md +0 -3
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // A line of a `## Consommateurs` section that a lot changes enters with its settled arbitrage at the
3
+ // ticket: each dependency between components is decided before it is written, as a code owner
4
+ // approves a change to the files it owns.
5
+ //
6
+ // npx grillhouse consommateurs-arbitres run from the repository root (a guard of grillhouse:gardes)
7
+ //
8
+ // - It judges the copy it runs in, on the branch `agent/<ticket>`: for each component the guards
9
+ // judge (`aVerifier` of scripts/interfaces-contre-code.mjs; in the envelope of a component,
10
+ // `GRILLHOUSE_ENVELOPPE`, that component alone), each list line (`- …`) of the `## Consommateurs`
11
+ // section of its `docs/INTERFACE.md` in the working tree that the section of HEAD does not hold
12
+ // is a changed line (an interface absent from HEAD: all its lines). Out of a branch `agent/…`
13
+ // (main, the detached copy of the integration, the CI, the night), there is no lot: nothing is
14
+ // judged.
15
+ // - With a changed line, the comments of the ticket are read (`bd comments <ticket> --json`, run
16
+ // from the main tree, the parent of git's common directory). A changed line passes when an
17
+ // arbitrage that is settled (`arbitrageTranche`: a « ## Arbitrage… » comment, or the regulator's,
18
+ // that the circuit writes from its sheet, `estArbitrage`) names its consumer, the first
19
+ // name between backquotes of the line, found as it is in the comment's text. Without a changed
20
+ // line, bd is not called.
21
+ // - A changed line no arbitrage settles passes too when the section « Architecture » of the
22
+ // ticket's description (`bd show <ticket> --json`, from its heading `## Architecture` to the
23
+ // next heading of its level or above) names its consumer and each name the line declares, word
24
+ // for word (`nommeParLArchitecture`): the ticket fixed them before the lot. The description is
25
+ // read only for such a line; a description that cannot be read refuses, as the comments do.
26
+ // - A refusal names `<INTERFACE.md> § Consommateurs` and the line; a refusal inscribed in the
27
+ // project's debt (`grillhouse-dette.json`, see `dette.mjs`) passes, keyed by the interface and
28
+ // `consommateur-sans-arbitrage`. Comments that cannot be read refuse, never inscribed.
29
+ import { spawnSync } from 'node:child_process';
30
+ import { existsSync, readFileSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { pathToFileURL } from 'node:url';
33
+ import { trancher } from './dette.mjs';
34
+ import { aVerifier } from './interfaces-contre-code.mjs';
35
+
36
+ const REGLE = 'consommateur-sans-arbitrage';
37
+
38
+ // The regulator's arbitrage as the circuit writes it at the ticket, from the `arbitrage` of its
39
+ // sheet (scripts/circuit.mjs): `Circuit · <composant> · regulateur : arbitrage — <texte>`.
40
+ const DU_REGULATEUR = /^Circuit · [^\n]* · regulateur : arbitrage — /;
41
+
42
+ // True when the text <text> of a comment is an arbitrage: a « ## Arbitrage… » comment, or the
43
+ // regulator's, as the circuit writes it.
44
+ export const estArbitrage = text =>
45
+ /^\s*## Arbitrage\b/.test(String(text ?? '')) || DU_REGULATEUR.test(String(text ?? ''));
46
+
47
+ // True when the arbitrage <a> of <comments> (Beads' JSON, in their order) is settled: the
48
+ // regulator's (it settles only what the letter of a criterion, a written precedent or a rule of
49
+ // the frame settles), a line `### Verdict — tranché`, or a « ## Réponse du responsable » comment
50
+ // after it.
51
+ export function arbitrageTranche(comments, a) {
52
+ const tranche = DU_REGULATEUR.test(a.text) || /^### Verdict\s*—\s*tranché/m.test(a.text);
53
+ return (
54
+ tranche ||
55
+ comments
56
+ .slice(comments.indexOf(a) + 1)
57
+ .some(c => /^\s*## Réponse du responsable\b/.test(c.text))
58
+ );
59
+ }
60
+
61
+ // The list lines of the section `## Consommateurs` of <texte>, trimmed.
62
+ function lignesConsommateurs(texte) {
63
+ const out = [];
64
+ let dedans = false;
65
+ for (const l of texte.split('\n')) {
66
+ if (/^##\s/.test(l)) dedans = /^##\s+Consommateurs\s*$/.test(l);
67
+ else if (dedans && /^\s*-\s+\S/.test(l)) out.push(l.trim());
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // The section « Architecture » of the ticket's description <description>: its lines from its
73
+ // heading (`## Architecture`) to the next heading of its level or above, or '' without one.
74
+ function sectionArchitecture(description) {
75
+ const out = [];
76
+ let niveau = 0;
77
+ for (const l of String(description ?? '').split('\n')) {
78
+ const titre = /^(#{1,6})\s+(.*)$/.exec(l);
79
+ if (niveau && titre && titre[1].length <= niveau) break;
80
+ if (niveau) out.push(l);
81
+ else if (titre && /^Architecture\b/.test(titre[2].trim())) niveau = titre[1].length;
82
+ }
83
+ return out.join('\n');
84
+ }
85
+
86
+ // Whether the text <texte> names the consumer and each name of the `## Consommateurs` line
87
+ // <ligne> (``- `<consommateur>` : <nom>, <nom>``), word for word: each one whole, with no
88
+ // character of a name (a letter, a digit, `_`, `$`, `-`) beside it; `*`, a namespace, between
89
+ // backquotes.
90
+ function nommeParLArchitecture(texte, ligne) {
91
+ const m = /^[-*]\s+`?([^`:\s]+)`?\s*:\s*(.+)$/.exec(ligne);
92
+ if (!m) return false;
93
+ const noms = m[2]
94
+ .split(',')
95
+ .map(s => s.replace(/`/g, '').trim())
96
+ .filter(Boolean);
97
+ const echappe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ const nomme = nom =>
99
+ nom === '*'
100
+ ? texte.includes('`*`')
101
+ : new RegExp(`(?<![\\w$-])${echappe(nom)}(?![\\w$-])`).test(texte);
102
+ return [m[1], ...noms].every(nomme);
103
+ }
104
+
105
+ const git = (racine, ...a) => spawnSync('git', ['-C', racine, ...a], { encoding: 'utf8' });
106
+
107
+ // The ticket of the copy at <racine>: <ticket> of its branch `agent/<ticket>`, or null.
108
+ function ticketDeLaCopie(racine) {
109
+ const r = git(racine, 'rev-parse', '--abbrev-ref', 'HEAD');
110
+ const m = r.status === 0 ? /^agent\/(.+)$/.exec(r.stdout.trim()) : null;
111
+ return m ? m[1] : null;
112
+ }
113
+
114
+ // The findings of the copy <racine> under <env>, in the debt's form (`dette.mjs`).
115
+ export function verifier(racine, env = process.env) {
116
+ const ticket = ticketDeLaCopie(racine);
117
+ if (!ticket) return [];
118
+ const changees = [];
119
+ for (const c of aVerifier(racine, env)) {
120
+ const iface = path.join(c.dir, 'docs', 'INTERFACE.md');
121
+ if (!existsSync(iface)) continue;
122
+ const rel = path.relative(racine, iface);
123
+ const head = git(racine, 'show', `HEAD:${rel}`);
124
+ const avant = new Set(head.status === 0 ? lignesConsommateurs(head.stdout) : []);
125
+ for (const ligne of lignesConsommateurs(readFileSync(iface, 'utf8'))) {
126
+ if (!avant.has(ligne)) changees.push({ rel, ligne });
127
+ }
128
+ }
129
+ if (changees.length === 0) return [];
130
+ const commun = git(racine, 'rev-parse', '--path-format=absolute', '--git-common-dir');
131
+ const principal = path.dirname(commun.stdout.trim());
132
+ const r = spawnSync('bd', ['comments', ticket, '--json'], {
133
+ cwd: principal,
134
+ encoding: 'utf8',
135
+ maxBuffer: 256 * 1024 * 1024,
136
+ });
137
+ let comments;
138
+ try {
139
+ if (r.status !== 0) throw new Error(r.stderr || r.error?.message || `code ${r.status}`);
140
+ comments = (JSON.parse(r.stdout || '[]') ?? []).map(x => ({ text: String(x.text ?? '') }));
141
+ } catch (e) {
142
+ return [
143
+ {
144
+ message: `Les commentaires du ticket ${ticket} ne se lisent pas (bd comments ${ticket} --json) : ${String(e.message ?? e).trim()} ; les lignes Consommateurs changées ne se jugent pas.`,
145
+ },
146
+ ];
147
+ }
148
+ const arbitrages = comments.filter(a => estArbitrage(a.text) && arbitrageTranche(comments, a));
149
+ const consommateurDe = ligne => /`([^`]+)`/.exec(ligne)?.[1] ?? ligne.replace(/^-\s+/, '');
150
+ const restantes = changees.filter(
151
+ ({ ligne }) => !arbitrages.some(a => a.text.includes(consommateurDe(ligne)))
152
+ );
153
+ if (restantes.length === 0) return [];
154
+ // The lines no arbitrage settles: the section « Architecture » of the ticket.
155
+ const show = spawnSync('bd', ['show', ticket, '--json'], {
156
+ cwd: principal,
157
+ encoding: 'utf8',
158
+ maxBuffer: 256 * 1024 * 1024,
159
+ });
160
+ let architecture;
161
+ try {
162
+ if (show.status !== 0) {
163
+ throw new Error(show.stderr || show.error?.message || `code ${show.status}`);
164
+ }
165
+ architecture = sectionArchitecture(JSON.parse(show.stdout || '[]')?.[0]?.description);
166
+ } catch (e) {
167
+ return [
168
+ {
169
+ message: `Le ticket ${ticket} ne se lit pas (bd show ${ticket} --json) : ${String(e.message ?? e).trim()} ; les lignes Consommateurs changées ne se jugent pas.`,
170
+ },
171
+ ];
172
+ }
173
+ const constats = [];
174
+ for (const { rel, ligne } of restantes) {
175
+ if (nommeParLArchitecture(architecture, ligne)) continue;
176
+ const consommateur = consommateurDe(ligne);
177
+ constats.push({
178
+ fichier: rel,
179
+ regle: REGLE,
180
+ message: `${rel} § Consommateurs : la ligne « ${ligne} » change sans arbitrage tranché au ticket ${ticket} qui nomme \`${consommateur}\`, ni section « Architecture » du ticket qui nomme ce consommateur et chaque nom de la ligne ; une dépendance entre composants se décide avant de s'écrire : un commentaire « ## Arbitrage » tranché au ticket qui nomme ce consommateur, ou l'Architecture du ticket qui la fixe.`,
181
+ });
182
+ }
183
+ return constats;
184
+ }
185
+
186
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
187
+ const racine = process.cwd();
188
+ const { refus, lignes } = trancher(racine, 'consommateurs-arbitres', verifier(racine));
189
+ for (const r of refus) process.stderr.write(`⛔ ${r.message}\n`);
190
+ for (const l of lignes) process.stderr.write(`${l}\n`);
191
+ process.exitCode = refus.length ? 1 : 0;
192
+ }
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // The consumers of the interfaces: each component's `docs/INTERFACE.md` declares, in its
3
+ // `## Consommateurs` section, which components use which of its elements:
4
+ //
5
+ // ## Consommateurs
6
+ //
7
+ // - `a` : duree, tempo
8
+ // - `c` : *
9
+ //
10
+ // This check reads what the production code of every component imports from every other, and
11
+ // refuses (exit 1) a use that the provider's interface does not declare. A declared use that no
12
+ // code makes is reported, without refusing. `*` stands for a namespace, default, re-export-all or
13
+ // side-effect import. A refusal inscribed in the project's debt (`grillhouse-dette.json`, see
14
+ // `dette.mjs`) passes, keyed by the provider's interface and `usage-non-declare` (`homonyme` for
15
+ // two components of one name, keyed by the first path).
16
+ //
17
+ // npx grillhouse consommateurs run from the repository root
18
+ //
19
+ // Components are the directories under `packages/` (reached by their package name or a relative
20
+ // path) and under `src/` (reached by a relative path), except those listed out of the frame in
21
+ // `docs/agents/hors-cadre.txt`: neither read nor checked, as provider or as consumer. Every check
22
+ // of the frame enumerates the components through `composants`. In a repository of packages, such a
23
+ // package stays a workspace, and Turborepo plays none of its tasks (scripts/turbo-des-paquets.mjs
24
+ // writes its configuration). For the check of the uses, test files,
25
+ // `dist/`, `docs/` and `node_modules/` are not production code and are not read; nor are Vite's
26
+ // temporary files, and a file gone between the walk and its read is passed over (`parcourir`).
27
+ // The identity of a component is its name: two directories of the same name, one under `packages/`
28
+ // and one under `src/`, are refused, both paths named (the way npm workspaces, Nx and Cargo refuse
29
+ // two projects of one name). Names differing by case are two names.
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { pathToFileURL } from 'node:url';
33
+ import { trancher } from './dette.mjs';
34
+ import { horsCadre } from './paquets.mjs';
35
+
36
+ const SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|svelte|vue)$/;
37
+ const TEST_DIR = new Set(['test', 'tests', '__tests__']);
38
+ // The name of a test file: `x.test.ts`, `x.spec.mjs`…
39
+ export const NOM_DE_TEST = /\.(test|spec)\.[cm]?[jt]sx?$/;
40
+ const isTest = name => NOM_DE_TEST.test(name);
41
+ const SKIP = new Set(['node_modules', 'dist', 'docs']);
42
+
43
+ // Every directory under `packages/` and `src/` of the repository at <racine>: [{ nom, dir }].
44
+ function tousDossiers(racine) {
45
+ const found = [];
46
+ for (const parent of ['packages', 'src']) {
47
+ const base = path.join(racine, parent);
48
+ if (!existsSync(base)) continue;
49
+ for (const e of readdirSync(base, { withFileTypes: true })) {
50
+ if (e.isDirectory()) found.push({ nom: e.name, dir: path.join(base, e.name) });
51
+ }
52
+ }
53
+ return found;
54
+ }
55
+
56
+ // The component directories of the repository at <racine>: [{ nom, dir }], each directory under
57
+ // `packages/` and `src/` that `docs/agents/hors-cadre.txt` does not list.
58
+ function dossiers(racine) {
59
+ const hors = horsCadre(racine);
60
+ return tousDossiers(racine).filter(d => !hors.has(d.nom));
61
+ }
62
+
63
+ // The names of `docs/agents/hors-cadre.txt` that no directory under `packages/` or `src/` bears,
64
+ // in the order of the list.
65
+ export function horsCadreInconnus(racine) {
66
+ const presents = new Set(tousDossiers(racine).map(d => d.nom));
67
+ return [...horsCadre(racine)].filter(nom => !presents.has(nom));
68
+ }
69
+
70
+ // The components of the repository at <racine>: [{ nom, dir, paquet }], `paquet` its package name.
71
+ // Throws when a package.json is unreadable.
72
+ export function composants(racine) {
73
+ return dossiers(racine).map(({ nom, dir }) => {
74
+ const pj = path.join(dir, 'package.json');
75
+ const paquet = existsSync(pj) ? (JSON.parse(readFileSync(pj, 'utf8')).name ?? null) : null;
76
+ return { nom, dir, paquet };
77
+ });
78
+ }
79
+
80
+ // The subpath export by which a component publishes the shared test material.
81
+ const EXPORT_MATIERE = './test-fixtures';
82
+
83
+ // The components of <racine> whose `package.json` exports `./test-fixtures`, in any form of its
84
+ // value, a package out of the frame never one: the packages of the shared test material,
85
+ // [{ nom, dir, paquet }] as `composants` gives.
86
+ // A component whose `package.json` is unreadable is not a package of the material.
87
+ export function matieres(racine) {
88
+ const found = [];
89
+ for (const { nom, dir } of dossiers(racine)) {
90
+ const pj = path.join(dir, 'package.json');
91
+ if (!existsSync(pj)) continue;
92
+ let paquet;
93
+ try {
94
+ paquet = JSON.parse(readFileSync(pj, 'utf8'));
95
+ } catch {
96
+ continue;
97
+ }
98
+ const { exports } = paquet ?? {};
99
+ if (exports !== null && typeof exports === 'object' && EXPORT_MATIERE in exports) {
100
+ found.push({ nom, dir, paquet: paquet.name ?? null });
101
+ }
102
+ }
103
+ return found;
104
+ }
105
+
106
+ // The declared consumers of an interface text: Map consumer → Set of element names.
107
+ export function declares(texte) {
108
+ const out = new Map();
109
+ let dedans = false;
110
+ for (const l of texte.split('\n')) {
111
+ if (/^#{1,6} /.test(l)) {
112
+ dedans = /^## Consommateurs\s*$/.test(l);
113
+ continue;
114
+ }
115
+ const m = dedans && /^[-*]\s+`?([^`:\s]+)`?\s*:\s*(.+)$/.exec(l.trim());
116
+ if (!m) continue;
117
+ const noms = m[2]
118
+ .split(',')
119
+ .map(s => s.replace(/`/g, '').trim())
120
+ .filter(Boolean);
121
+ out.set(m[1], new Set(noms));
122
+ }
123
+ return out;
124
+ }
125
+
126
+ // The imports of a source text: [{ spec, noms }].
127
+ export function imports(source) {
128
+ const out = [];
129
+ // A static clause holds only names, braces, commas, `*`, `as` and `type`: it never crosses a
130
+ // `;`, a quote, a parenthesis or an `=`, so it never runs from one statement into the next.
131
+ const re =
132
+ /^\s*(?:import|export)\s+(?:type\s+)?([^;'"()=]*?)\s*from\s*['"]([^'"]+)['"]|^\s*import\s*['"]([^'"]+)['"]|\b(?:require|import)\(\s*['"]([^'"]+)['"]\s*\)/gm;
133
+ for (const m of source.matchAll(re)) {
134
+ if (m[3] || m[4]) {
135
+ out.push({ spec: m[3] ?? m[4], noms: ['*'] });
136
+ continue;
137
+ }
138
+ const clause = m[1];
139
+ const noms = [];
140
+ const braces = /\{([^}]*)\}/.exec(clause);
141
+ if (braces) {
142
+ for (const part of braces[1].split(',')) {
143
+ const nom = part
144
+ .replace(/^\s*type\s+/, '')
145
+ .split(/\s+as\s+/)[0]
146
+ .trim();
147
+ if (nom) noms.push(nom);
148
+ }
149
+ }
150
+ if (
151
+ clause
152
+ .replace(/\{[^}]*\}/, '')
153
+ .replace(/,/g, '')
154
+ .trim()
155
+ )
156
+ noms.push('*');
157
+ out.push({ spec: m[2], noms });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ // The temporary file Vite writes beside a configuration it bundles, then removes
163
+ // (`vitest.config.ts.timestamp-<n>-<hash>.mjs`).
164
+ const TEMPORAIRE_DE_VITE = /\.timestamp-[^/\\]*\.mjs$/;
165
+
166
+ // <f> read by <lire>, or <absent> when it no longer exists (ENOENT).
167
+ function siPresent(lire, f, absent) {
168
+ try {
169
+ return lire(f);
170
+ } catch (e) {
171
+ if (e?.code === 'ENOENT') return absent;
172
+ throw e;
173
+ }
174
+ }
175
+
176
+ // The files under <chemin> (a directory, or a file), each its absolute path: an entry below it
177
+ // whose path <sauter> keeps out is neither entered nor listed, and Vite's temporary files are
178
+ // never listed. The guards of the frame walk the repository while other processes write in it
179
+ // (`npm run verifier` plays them beside the tests): a file or a directory gone before the walk
180
+ // reaches it is passed over. The walk of every guard of the frame.
181
+ export function parcourir(chemin, sauter = () => false) {
182
+ const out = [];
183
+ const walk = (p, dossier) => {
184
+ if (!dossier) {
185
+ if (!TEMPORAIRE_DE_VITE.test(p)) out.push(p);
186
+ return;
187
+ }
188
+ for (const e of siPresent(d => readdirSync(d, { withFileTypes: true }), p, [])) {
189
+ const q = path.join(p, e.name);
190
+ if (!sauter(q)) walk(q, e.isDirectory());
191
+ }
192
+ };
193
+ const racine = siPresent(statSync, chemin, null);
194
+ if (racine) walk(chemin, racine.isDirectory());
195
+ return out;
196
+ }
197
+
198
+ // The text of the file <f> a walk listed, or null when it is gone since.
199
+ export const lireSiPresent = f => siPresent(x => readFileSync(x, 'utf8'), f, null);
200
+
201
+ // The production source files under <dir>: test folders and test files left out.
202
+ function fichiers(dir) {
203
+ const nom = p => path.basename(p);
204
+ return parcourir(
205
+ dir,
206
+ p => SKIP.has(nom(p)) || nom(p).startsWith('.') || TEST_DIR.has(nom(p))
207
+ ).filter(p => SOURCE.test(nom(p)) && !isTest(nom(p)));
208
+ }
209
+
210
+ // The component an import specifier reaches from <fichier>, other than <soi>, or null.
211
+ function fournisseur(spec, fichier, tous, soi) {
212
+ if (spec.startsWith('.')) {
213
+ const cible = path.resolve(path.dirname(fichier), spec);
214
+ return (
215
+ tous.find(c => c !== soi && (cible === c.dir || cible.startsWith(c.dir + path.sep))) ?? null
216
+ );
217
+ }
218
+ return (
219
+ tous.find(
220
+ c => c !== soi && c.paquet && (spec === c.paquet || spec.startsWith(c.paquet + '/'))
221
+ ) ?? null
222
+ );
223
+ }
224
+
225
+ // What each component really uses of the others: Map provider → Map consumer → Set of names.
226
+ export function utilise(racine, tous = composants(racine)) {
227
+ const out = new Map();
228
+ for (const c of tous) {
229
+ for (const f of fichiers(c.dir)) {
230
+ for (const { spec, noms } of imports(lireSiPresent(f) ?? '')) {
231
+ const p = fournisseur(spec, f, tous, c);
232
+ if (!p) continue;
233
+ if (!out.has(p.nom)) out.set(p.nom, new Map());
234
+ const parConsommateur = out.get(p.nom);
235
+ if (!parConsommateur.has(c.nom)) parConsommateur.set(c.nom, new Set());
236
+ for (const n of noms) parConsommateur.get(c.nom).add(n);
237
+ }
238
+ }
239
+ }
240
+ return out;
241
+ }
242
+
243
+ // The refusals of the components that share a name: one finding per name, both paths.
244
+ function homonymes(racine) {
245
+ const parNom = new Map();
246
+ for (const { nom, dir } of dossiers(racine)) {
247
+ parNom.set(nom, [...(parNom.get(nom) ?? []), path.relative(racine, dir)]);
248
+ }
249
+ return [...parNom]
250
+ .filter(([, dirs]) => dirs.length > 1)
251
+ .map(([nom, dirs]) => ({
252
+ fichier: dirs[0],
253
+ regle: 'homonyme',
254
+ message: `${nom} : ${dirs.join(' et ')} portent le même nom de composant.`,
255
+ }));
256
+ }
257
+
258
+ // The refusals (uses no interface declares), as findings `{ fichier, regle, message }` the debt
259
+ // (`dette.mjs`) judges, and the reports (declared uses no code makes). A declared consumer out of
260
+ // the frame is passed over: its code is not read.
261
+ export function verifier(racine) {
262
+ const tous = composants(racine);
263
+ const hors = horsCadre(racine);
264
+ const reel = utilise(racine, tous);
265
+ const refus = homonymes(racine);
266
+ const signaux = [];
267
+ for (const p of tous) {
268
+ const iface = path.join(p.dir, 'docs', 'INTERFACE.md');
269
+ const rel = path.relative(racine, iface);
270
+ const declare = existsSync(iface) ? declares(readFileSync(iface, 'utf8')) : new Map();
271
+ const utilises = reel.get(p.nom) ?? new Map();
272
+ for (const [consommateur, noms] of utilises) {
273
+ const permis = declare.get(consommateur) ?? new Set();
274
+ for (const n of noms) {
275
+ if (!permis.has(n))
276
+ refus.push({
277
+ fichier: rel,
278
+ regle: 'usage-non-declare',
279
+ message: `${consommateur} utilise « ${n} » de ${p.nom}, que ${rel} ne déclare pas.`,
280
+ });
281
+ }
282
+ }
283
+ for (const [consommateur, noms] of declare) {
284
+ if (hors.has(consommateur)) continue;
285
+ const faits = utilises.get(consommateur) ?? new Set();
286
+ for (const n of noms) {
287
+ if (!faits.has(n))
288
+ signaux.push(
289
+ `${rel} déclare « ${n} » pour ${consommateur}, qu'aucun code de ${consommateur} n'utilise.`
290
+ );
291
+ }
292
+ }
293
+ }
294
+ return { refus, signaux };
295
+ }
296
+
297
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
298
+ const racine = process.cwd();
299
+ const verdict = verifier(racine);
300
+ const { refus, lignes } = trancher(racine, 'consommateurs', verdict.refus);
301
+ for (const s of verdict.signaux) process.stderr.write(`⚠️ ${s}\n`);
302
+ for (const r of refus) process.stderr.write(`⛔ ${r.message}\n`);
303
+ for (const l of lignes) process.stderr.write(`${l}\n`);
304
+ if (refus.length) {
305
+ process.stderr.write(
306
+ "Une consommation nouvelle se décide (la section « Architecture » du ticket qui la nomme, sinon le référent technique), puis entre dans la section « Consommateurs » de l'interface du fournisseur.\n"
307
+ );
308
+ process.exitCode = 1;
309
+ }
310
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // One git hook of the frame at work: the guard of the hook <nom> (`CROCHETS` of
3
+ // scripts/crochets.mjs), run with git's arguments and standard input, its exit code the hook's.
4
+ //
5
+ // npx grillhouse hook <nom> [git's arguments…]
6
+ //
7
+ // - The guard runs in this process, as if Node had been given its path, as `grillhouse` runs a
8
+ // subcommand (bin/grillhouse.mjs).
9
+ // - A name that is not a hook of the frame is refused, named on the error stream; exit code 2.
10
+ import path from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { CROCHETS } from './crochets.mjs';
13
+
14
+ const [nom, ...args] = process.argv.slice(2);
15
+ if (nom === undefined || !Object.hasOwn(CROCHETS, nom)) {
16
+ process.stderr.write(
17
+ `⛔ crochet inconnu : ${nom ?? '(aucun)'} ; crochets du cadre : ${Object.keys(CROCHETS).join(', ')}\n`
18
+ );
19
+ process.exitCode = 2;
20
+ } else {
21
+ const garde = path.join(path.dirname(fileURLToPath(import.meta.url)), CROCHETS[nom].garde);
22
+ process.argv = [process.argv[0], garde, ...args];
23
+ await import(pathToFileURL(garde).href);
24
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // The frame's git hooks, on Husky's model: the project's `prepare`, which npm runs at its install,
3
+ // places them, and each calls the project's command, `grillhouse hook <nom>`.
4
+ //
5
+ // npx grillhouse hooks [<project>] places both hooks (the current directory by default)
6
+ // npx grillhouse hooks --absents [<project>] prints the name of each absent hook, one per line
7
+ //
8
+ // - commit-msg: « un ticket fermé garde sa livraison ; un ticket rouvert reçoit ses corrections » (scripts/un-commit-par-ticket.mjs).
9
+ // - pre-push: the night branch guard (scripts/branche-de-nuit.mjs).
10
+ // - A hook lives in the folder git reads for the project (`git rev-parse --git-path hooks`, which
11
+ // follows `core.hooksPath`, Beads' `.beads/hooks` included); the configuration is never changed
12
+ // here. The update of the frame (scripts/grillhouse-maj.mjs) places them by the same function.
13
+ // - The frame's lines run `node_modules/.bin/grillhouse` of the project, the package installed;
14
+ // without it they do nothing and let the hook go on, as Husky's lines do without Husky.
15
+ // - A hook that does not exist is created (shebang, the frame's lines), executable. In one that
16
+ // exists, the frame's lines of any version (a comment « … (Grillhouse): », a line that names a
17
+ // guard's script or the frame's shell variables `_gh_…`) give way to the current ones, written
18
+ // where the first stood, else right after the shebang; the project's lines and Beads' sections
19
+ // stay as they are. The frame's lines never end the hook early (no `exec` of a command), so the
20
+ // lines that follow still run.
21
+ // - A hook is absent when it is missing from that folder or lacks the current lines of the frame.
22
+ import { execFileSync } from 'node:child_process';
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { pathToFileURL } from 'node:url';
26
+
27
+ // The project's command, which the frame's lines run.
28
+ const COMMANDE = '_gh_commande="$(git rev-parse --show-toplevel)/node_modules/.bin/grillhouse"';
29
+
30
+ // The frame's hooks: name, guard (the script `grillhouse hook <nom>` runs) and lines. The pre-push
31
+ // guard reads git's standard input, which is kept in a file and given back to the lines that
32
+ // follow (Beads' section).
33
+ export const CROCHETS = {
34
+ 'commit-msg': {
35
+ garde: 'un-commit-par-ticket.mjs',
36
+ lignes: [
37
+ '# A closed ticket keeps its one delivery (Grillhouse): a second commit for a closed ticket whose delivery stands is refused, a commit for an open (reopened) ticket is a correction and passes; a revert (Revert "…", Reapply "…") passes and withdraws the delivery it names. Without the package @roomi-fields/grillhouse installed, nothing.',
38
+ COMMANDE,
39
+ 'if [ -x "$_gh_commande" ]; then "$_gh_commande" hook commit-msg "$@" || exit 1; fi',
40
+ ],
41
+ },
42
+ 'pre-push': {
43
+ garde: 'branche-de-nuit.mjs',
44
+ lignes: [
45
+ '# Night branch guard (Grillhouse): only the night pushes the branch of grillhouse.poussee.nuit, after its green. Without the package @roomi-fields/grillhouse installed, nothing.',
46
+ COMMANDE,
47
+ 'if [ -x "$_gh_commande" ]; then _gh_entree=$(mktemp) && cat > "$_gh_entree" && "$_gh_commande" hook pre-push "$@" < "$_gh_entree" || { rm -f "$_gh_entree"; exit 1; }; exec < "$_gh_entree"; rm -f "$_gh_entree"; fi',
48
+ ],
49
+ },
50
+ };
51
+
52
+ // A line of the frame, of this version or of a former one (see the header).
53
+ const ligneDuCadre = ligne =>
54
+ /^# .*\(Grillhouse\):/.test(ligne) ||
55
+ ligne.includes('_gh_') ||
56
+ Object.values(CROCHETS).some(({ garde }) => ligne.includes(garde));
57
+
58
+ // The folder git reads the hooks of `projet` from, or null when it is not a git repository.
59
+ function dossierDesCrochets(projet) {
60
+ try {
61
+ return path.resolve(
62
+ projet,
63
+ execFileSync('git', ['-C', projet, 'rev-parse', '--git-path', 'hooks'], {
64
+ encoding: 'utf8',
65
+ stdio: ['ignore', 'pipe', 'ignore'],
66
+ }).trim()
67
+ );
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // The text of the hook `nom` once the frame's current lines are placed in `actuel` (null: none).
74
+ function texteDuCrochet(nom, actuel) {
75
+ const { lignes } = CROCHETS[nom];
76
+ if (actuel === null) {
77
+ return `#!/usr/bin/env sh\n${lignes.join('\n')}\n`;
78
+ }
79
+ const avant = actuel.split('\n');
80
+ const premiere = avant.findIndex(ligneDuCadre);
81
+ const reste = avant.filter(l => !ligneDuCadre(l));
82
+ const ou = premiere >= 0 ? premiere : avant[0]?.startsWith('#!') ? 1 : 0;
83
+ reste.splice(ou, 0, ...lignes);
84
+ return reste.join('\n');
85
+ }
86
+
87
+ // Places the frame's hook `nom` in the project (see the header). Returns its path when written.
88
+ function poserCrochet(projet, nom) {
89
+ const dossier = dossierDesCrochets(projet);
90
+ if (!dossier) {
91
+ return null;
92
+ }
93
+ const f = path.join(dossier, nom);
94
+ const actuel = existsSync(f) ? readFileSync(f, 'utf8') : null;
95
+ const texte = texteDuCrochet(nom, actuel);
96
+ const ecrit = texte !== actuel;
97
+ if (ecrit) {
98
+ mkdirSync(dossier, { recursive: true });
99
+ writeFileSync(f, texte);
100
+ }
101
+ chmodSync(f, statSync(f).mode | 0o111);
102
+ return ecrit ? f : null;
103
+ }
104
+
105
+ /** Places both frame hooks in the project; returns the paths written. */
106
+ export function poserCrochets(projet) {
107
+ return Object.keys(CROCHETS)
108
+ .map(nom => poserCrochet(projet, nom))
109
+ .filter(Boolean);
110
+ }
111
+
112
+ /** Names the frame hooks absent from the folder git reads for the project. */
113
+ export function crochetsAbsents(projet) {
114
+ const dossier = dossierDesCrochets(projet);
115
+ return Object.entries(CROCHETS)
116
+ .filter(([nom, { lignes }]) => {
117
+ const f = dossier && path.join(dossier, nom);
118
+ return !f || !existsSync(f) || !readFileSync(f, 'utf8').includes(`${lignes.join('\n')}\n`);
119
+ })
120
+ .map(([nom]) => nom);
121
+ }
122
+
123
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
124
+ const args = process.argv.slice(2);
125
+ const absents = args[0] === '--absents';
126
+ const projet = path.resolve((absents ? args[1] : args[0]) ?? '.');
127
+ if (absents) {
128
+ const noms = crochetsAbsents(projet);
129
+ if (noms.length > 0) {
130
+ process.stdout.write(`${noms.join('\n')}\n`);
131
+ }
132
+ } else {
133
+ poserCrochets(projet);
134
+ }
135
+ }