@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.
- package/.claude-plugin/plugin.json +9 -0
- package/METACADRE.md +41 -0
- package/agents/developpeur.md +10 -0
- package/agents/explorateur.md +9 -0
- package/agents/questionneur.md +11 -0
- package/agents/referent-technique.md +9 -0
- package/agents/regulateur.md +11 -0
- package/agents/relecteur.md +9 -0
- package/agents/testwriter.md +10 -0
- package/bin/grillhouse.mjs +125 -0
- package/cadre/templates/projet/.claude/settings.json +30 -0
- package/cadre/templates/projet/.editorconfig +47 -0
- package/cadre/templates/projet/.env.example +57 -0
- package/cadre/templates/projet/.eslintrc.cjs +56 -0
- package/cadre/templates/projet/.github/workflows/ci.yml +66 -0
- package/cadre/templates/projet/.mcp.json +13 -0
- package/cadre/templates/projet/.prettierrc +11 -0
- package/cadre/templates/projet/CHANGELOG.md +61 -0
- package/cadre/templates/projet/CLAUDE.md +46 -0
- package/cadre/templates/projet/CONTRIBUTING.md +243 -0
- package/cadre/templates/projet/CREDITS.md +41 -0
- package/cadre/templates/projet/README.md +152 -0
- package/cadre/templates/projet/_gitignore +91 -0
- package/cadre/templates/projet/_npmignore +46 -0
- package/cadre/templates/projet/deployment/README.md +98 -0
- package/cadre/templates/projet/deployment/docs/01-INSTALL.md +145 -0
- package/cadre/templates/projet/deployment/docs/02-CONFIGURATION.md +117 -0
- package/cadre/templates/projet/deployment/docs/03-API.md +264 -0
- package/cadre/templates/projet/deployment/docs/04-TROUBLESHOOTING.md +262 -0
- package/cadre/templates/projet/deployment/scripts/install.ps1 +100 -0
- package/cadre/templates/projet/deployment/scripts/start.ps1 +61 -0
- package/cadre/templates/projet/deployment/scripts/stop.ps1 +37 -0
- package/cadre/templates/projet/deployment/scripts/test.ps1 +118 -0
- package/cadre/templates/projet/docs/agents/issue-tracker.md +44 -0
- package/cadre/templates/projet/ecosystem.config.cjs +73 -0
- package/cadre/templates/projet/package.json +90 -0
- package/cadre/templates/projet/pitmaster/SUIVI.md +22 -0
- package/cadre/templates/projet/src/config.ts +68 -0
- package/cadre/templates/projet/src/errors.ts +102 -0
- package/cadre/templates/projet/src/index.ts +46 -0
- package/cadre/templates/projet/src/services/.gitkeep +8 -0
- package/cadre/templates/projet/src/types/index.ts +24 -0
- package/cadre/templates/projet/src/utils/logger.ts +106 -0
- package/cadre/templates/projet/tests/integration/.gitkeep +7 -0
- package/cadre/templates/projet/tests/unit/config.spec.ts +68 -0
- package/cadre/templates/projet/tests/unit/errors.spec.ts +132 -0
- package/cadre/templates/projet/tests/unit/types.spec.ts +73 -0
- package/cadre/templates/projet/tsconfig.eslint.json +9 -0
- package/cadre/templates/projet/tsconfig.json +41 -0
- package/cadre/templates/projet/tsdoc.json +6 -0
- package/cadre/templates/projet/vitest.config.ts +44 -0
- package/cadre/templates/turbo/paquets.json +29 -0
- package/cadre/templates/turbo/sans-paquets.json +28 -0
- package/charte.md +107 -0
- package/docs/ARCHITECTURE.md +90 -0
- package/hooks/crochet.mjs +33 -0
- package/hooks/hooks.json +58 -0
- package/hooks/register.tsx +558 -0
- package/package.json +75 -4
- package/scripts/avancement.mjs +244 -0
- package/scripts/bilan.mjs +523 -0
- package/scripts/branche-de-nuit.mjs +59 -0
- package/scripts/cadre.mjs +109 -0
- package/scripts/circuit.mjs +4375 -0
- package/scripts/code-mort.mjs +371 -0
- package/scripts/composants-affectes.mjs +598 -0
- package/scripts/consommateurs-arbitres.mjs +192 -0
- package/scripts/consommateurs.mjs +310 -0
- package/scripts/crochet.mjs +24 -0
- package/scripts/crochets.mjs +135 -0
- package/scripts/dernier-vert.mjs +208 -0
- package/scripts/dette.mjs +181 -0
- package/scripts/droits.mjs +354 -0
- package/scripts/enveloppe/codes.mjs +17 -0
- package/scripts/enveloppe/composant.mjs +11 -0
- package/scripts/enveloppe/enveloppe.mjs +628 -0
- package/scripts/enveloppe/lancer.sh +311 -0
- package/scripts/etapes.mjs +323 -0
- package/scripts/etat-du-circuit.mjs +104 -0
- package/scripts/frontieres.mjs +456 -0
- package/scripts/gardes-du-cadre.mjs +72 -0
- package/scripts/greffons.mjs +338 -0
- package/scripts/grillhouse-maj.mjs +514 -0
- package/scripts/index-interfaces.mjs +293 -0
- package/scripts/init.mjs +117 -0
- package/scripts/integration/file-attente.mjs +61 -0
- package/scripts/integration/file.mjs +494 -0
- package/scripts/integration/integrer.mjs +381 -0
- package/scripts/integration/rejeu.mjs +139 -0
- package/scripts/integration/verdict.mjs +13 -0
- package/scripts/interfaces-contre-code.mjs +547 -0
- package/scripts/ligne-etat/defaut.sh +110 -0
- package/scripts/ligne-etat/ligne-etat.mjs +93 -0
- package/scripts/migrations/0.23.0-charte-importee.mjs +47 -0
- package/scripts/migrations/0.23.0-config-vitest.mjs +105 -0
- package/scripts/migrations/0.23.0-crochets-de-seance.mjs +55 -0
- package/scripts/migrations/0.23.0-marche-du-projet.mjs +82 -0
- package/scripts/migrations.mjs +43 -0
- package/scripts/nuit.sh +269 -0
- package/scripts/paquets.mjs +79 -0
- package/scripts/poussee-cles.mjs +46 -0
- package/scripts/preparation.mjs +84 -0
- package/scripts/references-par-les-types.mjs +420 -0
- package/scripts/registre.mjs +336 -0
- package/scripts/resultats.mjs +580 -0
- package/scripts/resumes.mjs +57 -0
- package/scripts/retirer-copie.sh +127 -0
- package/scripts/session-start.sh +203 -0
- package/scripts/sortie.mjs +155 -0
- package/scripts/sous-modules.sh +316 -0
- package/scripts/structure-guard.sh +27 -0
- package/scripts/superviseur.mjs +102 -0
- package/scripts/tableau.mjs +1886 -0
- package/scripts/tests.mjs +236 -0
- package/scripts/ticket.sh +23 -0
- package/scripts/turbo-des-paquets.mjs +899 -0
- package/scripts/un-commit-par-ticket.mjs +212 -0
- package/scripts/verifier.mjs +469 -0
- package/scripts/verrou-de-fichier.mjs +130 -0
- package/scripts/verrou-de-machine.mjs +211 -0
- package/scripts/verrous/commande.mjs +180 -0
- package/scripts/verrous/ticket-du-sous-agent.mjs +39 -0
- package/scripts/verrous/verrou.mjs +165 -0
- package/scripts/vitest-config/avancement.mjs +39 -0
- package/scripts/vitest-config/avancement.reporter.mjs +56 -0
- package/scripts/vitest-config/base-de-passe.mjs +128 -0
- package/scripts/vitest-config/base.d.ts +6 -0
- package/scripts/vitest-config/base.mjs +87 -0
- package/scripts/vitest-config/changements.mjs +173 -0
- package/scripts/vitest-config/cibles.mjs +207 -0
- package/scripts/vitest-config/duree.reporter.mjs +98 -0
- package/scripts/vitest-config/machine.setup.mjs +14 -0
- package/scripts/vitest-config/mesure.d.ts +6 -0
- package/scripts/vitest-config/mesure.mjs +13 -0
- package/scripts/vitest-config/mesure.setup.mjs +12 -0
- package/scripts/vitest-config/pression.mjs +90 -0
- package/scripts/vitest-config/resultats.mjs +23 -0
- package/scripts/vitest-config/resultats.setup.mjs +19 -0
- package/scripts/vitest.nuit.config.mjs +26 -0
- package/skills/developpeur/SKILL.md +175 -0
- package/skills/explorateur/SKILL.md +84 -0
- package/skills/grill/SKILL.md +238 -0
- package/skills/grill/references/releve-structurel.md +38 -0
- package/skills/mesure/SKILL.md +168 -0
- package/skills/pitmaster/SKILL.md +382 -0
- package/skills/pitmaster/references/cadre.md +119 -0
- package/skills/pitmaster/references/consigne-agent.md +308 -0
- package/skills/pitmaster/references/erreurs.md +25 -0
- package/skills/pitmaster/references/tour.md +124 -0
- package/skills/questionneur/SKILL.md +64 -0
- package/skills/redacteur/SKILL.md +84 -0
- package/skills/redacteur/references/documents-d-un-composant.md +105 -0
- package/skills/referent-technique/SKILL.md +145 -0
- package/skills/regulateur/SKILL.md +220 -0
- package/skills/release/SKILL.md +134 -0
- package/skills/release/scripts/update-version.cjs +92 -0
- package/skills/relecteur/SKILL.md +152 -0
- package/skills/relecteur/references/LICENSE-MIT +30 -0
- package/skills/relecteur/references/SOURCE.md +8 -0
- package/skills/relecteur/references/claims-check.md +14 -0
- package/skills/relecteur/references/deletion-check.md +14 -0
- package/skills/relecteur/references/edge-case-hunter.md +80 -0
- package/skills/relecteur/references/verification-gap.md +113 -0
- package/skills/testwriter/SKILL.md +187 -0
- package/types/index.d.ts +56 -0
- 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
|
+
}
|