@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,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The mechanical steps of one integration, run by the circuit of the ticket (scripts/circuit.mjs)
|
|
3
|
+
// once the reviewer has accepted the lot, which the circuit builds from the ticket's copy. It
|
|
4
|
+
// commits the lot on the branch of the ticket, or refuses it with the full output of the step that
|
|
5
|
+
// refused. The branch then waits for the queue (scripts/integration/file.mjs), which brings it onto
|
|
6
|
+
// main. Run from any working tree of the repository, it works on the main tree, found from git's
|
|
7
|
+
// common directory.
|
|
8
|
+
//
|
|
9
|
+
// npx grillhouse integrer --ticket <id>
|
|
10
|
+
// [--tests <patch>] [--code <patch>] --message <file>
|
|
11
|
+
//
|
|
12
|
+
// One delivery is one ticket: the ticket gives its branch `agent/<ticket>` and its copy
|
|
13
|
+
// (`.claude/worktrees/<ticket>`, the one the lot was written in, when it exists); a second
|
|
14
|
+
// --ticket is a usage error, named. In order, each step refusing stops the run:
|
|
15
|
+
// 1. the verdict: the ticket has `ACCEPTÉ` as its last reviewer verdict (`ACCEPTÉ` or
|
|
16
|
+
// `RENDU` at the start of a comment of the author `relecteur`; another author's is none) and, when labelled `arbitrage`, each arbitrage
|
|
17
|
+
// (`estArbitrage`: a « ## Arbitrage » comment, or the regulator's the circuit writes) is settled
|
|
18
|
+
// (`arbitrageTranche`: the regulator's, `### Verdict — tranché`, or a « ## Réponse du
|
|
19
|
+
// responsable » comment after it);
|
|
20
|
+
// 2. the lots: the tests lot touches only what the testwriter writes (`isTesterFile`: test files
|
|
21
|
+
// and the shared test material), the code lot none of it;
|
|
22
|
+
// 3. the main tree: every file of the lots is unmodified there; the ticket's copy is on its
|
|
23
|
+
// branch and carries the lot and nothing else: a modified or untracked file outside the lot
|
|
24
|
+
// refuses, named (what an ignored folder such as node_modules holds is not work);
|
|
25
|
+
// 4. a clean integration copy (`.claude/worktrees/integration`) on HEAD, made ready by the
|
|
26
|
+
// replay (scripts/integration/rejeu.mjs: submodules by scripts/sous-modules.sh, dependencies
|
|
27
|
+
// in agreement with the lock file and the manifests, mounts);
|
|
28
|
+
// 5. the lots, applied in three ways in the copy, tests first; the files of the lot in the
|
|
29
|
+
// ticket's copy are compared with the copy's own base plus the lot, not with the integration
|
|
30
|
+
// copy. Once the lots are in the copy, the replay makes the dependencies agree with the lock
|
|
31
|
+
// file and the manifests the lot leaves (`npm ci`, as the continuous integration runs it); an
|
|
32
|
+
// install that fails refuses the lot, « Les dépendances de la copie ne s'installent pas. »;
|
|
33
|
+
// 6. the tests: `npm run verifier` in the copy, one command (Turborepo: the build, then the
|
|
34
|
+
// guards, the frame's and the project's own (its root script `gardes`), and every test of the
|
|
35
|
+
// day beside each other, each task replayed from the cache of the
|
|
36
|
+
// main tree when its inputs are unchanged). Green is required: main stays green, and nothing is compared with a base. A test
|
|
37
|
+
// known red is put in quarantine in the test itself (`it.skip` or `it.fails`, with its
|
|
38
|
+
// ticket), as a mature continuous integration does; the integration admits none. A
|
|
39
|
+
// `npm run verifier` « à repasser » (75: a failure of duration under load, which found no rest
|
|
40
|
+
// in its bound, scripts/verifier.mjs) is neither green nor red: the run stops there, exit 75,
|
|
41
|
+
// nothing committed and nothing refused, the lot where it was, for the same command again;
|
|
42
|
+
// any other code than 0 refuses;
|
|
43
|
+
// 7. the commit in the copy, its hooks included, under GRILLHOUSE_INTEGRATION=1: a pre-commit
|
|
44
|
+
// hook that plays the guards skips them, as step 6 just played them; the commit validated is
|
|
45
|
+
// the commit that enters;
|
|
46
|
+
// 8. the branch `agent/<ticket>` is set on that commit, the ticket's copy follows it and is
|
|
47
|
+
// removed by `scripts/retirer-copie.sh` (no `--jetable`; `--garder-travail`: the working
|
|
48
|
+
// directory of the ticket's circuit, `<copie>.travail`, stays for the circuit that still runs,
|
|
49
|
+
// and the queue removes it at the ticket's closure): a refusal, a locked copy for
|
|
50
|
+
// example, is written to the ticket, the commit stands. The ticket is labelled
|
|
51
|
+
// `en-file` and the branch takes its place at the end of the queue
|
|
52
|
+
// (scripts/integration/file-attente.mjs). main does not move here.
|
|
53
|
+
//
|
|
54
|
+
// What steps 2, 3 and 5 read of a lot is its files: each file added, modified or deleted, a
|
|
55
|
+
// rename counting its source and its destination (named as the file is, not as git quotes it in
|
|
56
|
+
// the patch), a copy its destination only. Step 2 judges them against the testwriter's files, step 3
|
|
57
|
+
// against the main tree and the ticket's copy, and step 5 compares them with the copy's own base
|
|
58
|
+
// plus the lot.
|
|
59
|
+
//
|
|
60
|
+
// The main tree is not touched. The duration is written at the end.
|
|
61
|
+
// One integration runs at a time (`.git/integration.lock`): another waits for the end of the
|
|
62
|
+
// running one; a lock whose process is gone is taken over. Exit code: 0 committed, 1 refused,
|
|
63
|
+
// 2 usage, 75 à repasser.
|
|
64
|
+
import { spawnSync } from 'node:child_process';
|
|
65
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
66
|
+
import path from 'node:path';
|
|
67
|
+
import { pathToFileURL } from 'node:url';
|
|
68
|
+
import { arbitrageTranche, estArbitrage } from '../consommateurs-arbitres.mjs';
|
|
69
|
+
import { isTesterFile } from '../verrous/verrou.mjs';
|
|
70
|
+
import { lacherVerrou, prendreVerrou } from '../verrou-de-fichier.mjs';
|
|
71
|
+
import { dossierCommun, inscrireEntree } from './file-attente.mjs';
|
|
72
|
+
import { ouvrirCopie, RETIRER_COPIE } from './rejeu.mjs';
|
|
73
|
+
import { verdictDe } from './verdict.mjs';
|
|
74
|
+
import { A_REPASSER } from '../vitest-config/pression.mjs';
|
|
75
|
+
import { ouvrirBase } from '../vitest-config/base-de-passe.mjs';
|
|
76
|
+
|
|
77
|
+
// A file name as git writes it in a patch: a name with a character outside ASCII, a quote, a
|
|
78
|
+
// backslash or a control character comes between quotes with C escapes (`\303\251` for `é`).
|
|
79
|
+
// Returns the name itself.
|
|
80
|
+
function nomDeGit(ecrit) {
|
|
81
|
+
if (!ecrit.startsWith('"')) return ecrit;
|
|
82
|
+
const octets = [];
|
|
83
|
+
const echappes = { n: 10, t: 9, r: 13, a: 7, b: 8, f: 12, v: 11 };
|
|
84
|
+
const corps = Buffer.from(ecrit.slice(1, -1), 'utf8');
|
|
85
|
+
for (let i = 0; i < corps.length; i++) {
|
|
86
|
+
if (corps[i] !== 0x5c) {
|
|
87
|
+
octets.push(corps[i]);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const suite = String.fromCharCode(corps[i + 1]);
|
|
91
|
+
const octal = /^[0-7]{3}/.exec(corps.subarray(i + 1, i + 4).toString('latin1'));
|
|
92
|
+
if (octal) {
|
|
93
|
+
octets.push(Number.parseInt(octal[0], 8));
|
|
94
|
+
i += 3;
|
|
95
|
+
} else {
|
|
96
|
+
octets.push(echappes[suite] ?? corps[i + 1]);
|
|
97
|
+
i += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Buffer.from(octets).toString('utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The refusal for the verdicts of one ticket, or null. `ticket` and `comments` are Beads' JSON.
|
|
104
|
+
export function refusVerdicts(ticket, comments) {
|
|
105
|
+
const id = ticket.id;
|
|
106
|
+
const dernier = comments.filter(c => verdictDe(c)).at(-1);
|
|
107
|
+
if (verdictDe(dernier) !== 'ACCEPTÉ') {
|
|
108
|
+
return `${id} n'a pas le verdict ACCEPTÉ du relecteur pour dernier verdict.`;
|
|
109
|
+
}
|
|
110
|
+
if ((ticket.labels ?? []).includes('arbitrage')) {
|
|
111
|
+
const arbitrages = comments.filter(c => estArbitrage(c.text));
|
|
112
|
+
if (arbitrages.length === 0)
|
|
113
|
+
return `${id} est étiqueté arbitrage sans commentaire « Arbitrage ».`;
|
|
114
|
+
for (const a of arbitrages) {
|
|
115
|
+
if (!arbitrageTranche(comments, a)) {
|
|
116
|
+
const titre = a.text.trim().split('\n')[0];
|
|
117
|
+
return `${id} : « ${titre} » n'est ni tranché ni répondu par le responsable.`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The refusal for the files of the lots, or null. The files are relative to the repository root
|
|
125
|
+
// <racine>; the tests lot holds only what the testwriter writes, the code lot none of it.
|
|
126
|
+
export function refusLots(tests, code, racine) {
|
|
127
|
+
const testwriter = f => isTesterFile(f, racine);
|
|
128
|
+
const horsTests = tests.filter(f => !testwriter(f));
|
|
129
|
+
if (horsTests.length) return `Le lot de tests touche du code : ${horsTests.join(', ')}.`;
|
|
130
|
+
const duTestwriter = code.filter(testwriter);
|
|
131
|
+
if (duTestwriter.length)
|
|
132
|
+
return `Le lot de code touche des fichiers du testwriter (tests ou matière de test partagée) : ${duTestwriter.join(', ')}.`;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The options, or { faute } for a usage error.
|
|
137
|
+
function lire(argv) {
|
|
138
|
+
const o = { ticket: null, tests: null, code: null, message: null };
|
|
139
|
+
for (let i = 0; i < argv.length; i++) {
|
|
140
|
+
const v = argv[i + 1];
|
|
141
|
+
switch (argv[i]) {
|
|
142
|
+
case '--ticket':
|
|
143
|
+
if (o.ticket !== null)
|
|
144
|
+
return {
|
|
145
|
+
faute: `un seul --ticket : ${v} en est un second, et une livraison est un ticket.`,
|
|
146
|
+
};
|
|
147
|
+
o.ticket = v;
|
|
148
|
+
i++;
|
|
149
|
+
break;
|
|
150
|
+
case '--tests':
|
|
151
|
+
o.tests = v;
|
|
152
|
+
i++;
|
|
153
|
+
break;
|
|
154
|
+
case '--code':
|
|
155
|
+
o.code = v;
|
|
156
|
+
i++;
|
|
157
|
+
break;
|
|
158
|
+
case '--message':
|
|
159
|
+
o.message = v;
|
|
160
|
+
i++;
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
return { faute: null };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!o.ticket || !o.message || !(o.tests || o.code)) return { faute: null };
|
|
167
|
+
return o;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The files of the ticket's copy that are not the lot: every modified, deleted or untracked file
|
|
171
|
+
// (ignored ones do not count) the lot does not name.
|
|
172
|
+
function horsLot(copieTicket, fichiers) {
|
|
173
|
+
const r = spawnSync(
|
|
174
|
+
'git',
|
|
175
|
+
['-C', copieTicket, 'status', '--porcelain=v1', '-z', '-uall', '--no-renames'],
|
|
176
|
+
{ encoding: 'utf8' }
|
|
177
|
+
);
|
|
178
|
+
if (r.status !== 0) return null;
|
|
179
|
+
const dans = new Set(fichiers);
|
|
180
|
+
return r.stdout
|
|
181
|
+
.split('\0')
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.map(l => l.slice(3))
|
|
184
|
+
.filter(f => !dans.has(f));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The files of the lot whose content in the ticket's copy differs from its own base plus the lot:
|
|
188
|
+
// the lots are applied to the copy's HEAD in a temporary index, which the copy's files are compared
|
|
189
|
+
// to. Every file of the lot differs when the lots do not apply to that base.
|
|
190
|
+
function differents(copieTicket, lots, fichiers, tmp) {
|
|
191
|
+
const index = path.join(tmp, 'index-copie');
|
|
192
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
193
|
+
const dansCopie = (...args) =>
|
|
194
|
+
spawnSync('git', ['-C', copieTicket, ...args], { encoding: 'utf8', env });
|
|
195
|
+
if (dansCopie('read-tree', 'HEAD').status !== 0) return fichiers;
|
|
196
|
+
for (const p of lots) if (dansCopie('apply', '--cached', p).status !== 0) return fichiers;
|
|
197
|
+
const r = dansCopie('diff', '--name-only', '--no-renames', '-z', '--', ...fichiers);
|
|
198
|
+
if (r.status !== 0) return fichiers;
|
|
199
|
+
return r.stdout.split('\0').filter(Boolean);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function main(argv) {
|
|
203
|
+
const o = lire(argv);
|
|
204
|
+
if ('faute' in o) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
'usage: integrer.mjs --ticket <id> [--tests <patch>] [--code <patch>] --message <file>\n' +
|
|
207
|
+
(o.faute ? `⛔ ${o.faute}\n` : '')
|
|
208
|
+
);
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
// The main tree, from git's common directory: run from a copy, the integration still works on it.
|
|
212
|
+
const racine = path.dirname(
|
|
213
|
+
spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
214
|
+
encoding: 'utf8',
|
|
215
|
+
}).stdout.trim()
|
|
216
|
+
);
|
|
217
|
+
const git = (...args) => spawnSync('git', ['-C', racine, ...args], { encoding: 'utf8' });
|
|
218
|
+
const bd = (...a) =>
|
|
219
|
+
spawnSync('bd', a, { cwd: racine, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
220
|
+
const dire = s => process.stdout.write(`${s}\n`);
|
|
221
|
+
const refuser = s => {
|
|
222
|
+
process.stdout.write(`⛔ ${s}\nLe lot retourne à son agent avec cette sortie.\n`);
|
|
223
|
+
return 1;
|
|
224
|
+
};
|
|
225
|
+
const absolu = p => path.resolve(p);
|
|
226
|
+
|
|
227
|
+
// One integration at a time: a second waits for the end of the first; a lock whose process is
|
|
228
|
+
// gone is taken over.
|
|
229
|
+
const verrou = path.join(dossierCommun(racine), 'integration.lock');
|
|
230
|
+
prendreVerrou(verrou, { attendre: true });
|
|
231
|
+
const debut = Date.now();
|
|
232
|
+
// The working directory of the integration: a base of its own, removed at its end, swept by the
|
|
233
|
+
// next one to open a base when the integration is killed (../vitest-config/base-de-passe.mjs).
|
|
234
|
+
const base = ouvrirBase('integration');
|
|
235
|
+
const tmp = base.dossier;
|
|
236
|
+
try {
|
|
237
|
+
// 1. The verdict.
|
|
238
|
+
const show = bd('show', o.ticket, '--json');
|
|
239
|
+
const comments = bd('comments', o.ticket, '--json');
|
|
240
|
+
if (show.status !== 0 || comments.status !== 0)
|
|
241
|
+
return refuser(`Ticket ${o.ticket} illisible : ${show.stderr}${comments.stderr}`);
|
|
242
|
+
const r1 = refusVerdicts(JSON.parse(show.stdout)[0], JSON.parse(comments.stdout || '[]'));
|
|
243
|
+
if (r1) return refuser(r1);
|
|
244
|
+
dire('✓ verdicts');
|
|
245
|
+
|
|
246
|
+
// 2. The lots.
|
|
247
|
+
const lots = [o.tests, o.code].filter(Boolean).map(absolu);
|
|
248
|
+
const fichiersDe = p => {
|
|
249
|
+
const r = git('apply', '--numstat', '-z', p);
|
|
250
|
+
if (r.status !== 0) return null;
|
|
251
|
+
const destinations = r.stdout
|
|
252
|
+
.split('\0')
|
|
253
|
+
.map(l => l.split('\t')[2])
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
// A rename names its source too (numstat names only the destination); a copy leaves its
|
|
256
|
+
// source intact, so only its destination counts.
|
|
257
|
+
const sources = [...readFileSync(p, 'utf8').matchAll(/^rename from (.+)$/gm)].map(m =>
|
|
258
|
+
nomDeGit(m[1])
|
|
259
|
+
);
|
|
260
|
+
return [...destinations, ...sources];
|
|
261
|
+
};
|
|
262
|
+
const tests = o.tests ? fichiersDe(absolu(o.tests)) : [];
|
|
263
|
+
const code = o.code ? fichiersDe(absolu(o.code)) : [];
|
|
264
|
+
if (!tests || !code) return refuser('Un patch du lot est illisible.');
|
|
265
|
+
const r2 = refusLots(tests, code, racine);
|
|
266
|
+
if (r2) return refuser(r2);
|
|
267
|
+
const fichiers = [...new Set([...tests, ...code])];
|
|
268
|
+
dire(`✓ lots : ${fichiers.length} fichiers`);
|
|
269
|
+
|
|
270
|
+
// 3. The main tree: the lots' files are free there, so the queue can bring them. The ticket's
|
|
271
|
+
// copy holds the lot and nothing else.
|
|
272
|
+
const tenus = git('status', '--porcelain', '--', ...fichiers).stdout.trim();
|
|
273
|
+
if (tenus) return refuser(`Des fichiers du lot sont modifiés dans l'arbre :\n${tenus}`);
|
|
274
|
+
dire('✓ arbre principal libre');
|
|
275
|
+
const branche = `agent/${o.ticket}`;
|
|
276
|
+
const copieTicket = path.join(racine, '.claude', 'worktrees', o.ticket);
|
|
277
|
+
// A ticket without a copy (none was made, or it is gone) has nothing to check nor to remove.
|
|
278
|
+
const avecCopie = existsSync(copieTicket);
|
|
279
|
+
if (avecCopie) {
|
|
280
|
+
const surBranche = spawnSync(
|
|
281
|
+
'git',
|
|
282
|
+
['-C', copieTicket, 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
283
|
+
{ encoding: 'utf8' }
|
|
284
|
+
);
|
|
285
|
+
if (surBranche.stdout.trim() !== branche) {
|
|
286
|
+
return refuser(`La copie du ticket ${copieTicket} n'est pas sur la branche ${branche}.`);
|
|
287
|
+
}
|
|
288
|
+
const autres = horsLot(copieTicket, fichiers);
|
|
289
|
+
if (autres === null) return refuser(`L'état de la copie ${copieTicket} ne se lit pas.`);
|
|
290
|
+
if (autres.length) {
|
|
291
|
+
return refuser(
|
|
292
|
+
`La copie du ticket porte autre chose que le lot :\n${autres.map(f => ` ${f}`).join('\n')}`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
dire('✓ copie du ticket : le lot seul');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 4. The integration copy: detached on HEAD, without any untracked file; its dependencies
|
|
299
|
+
// are kept only while the lock file and the manifests are the same (a CI cache keyed by them).
|
|
300
|
+
const copie = path.join(racine, '.claude', 'worktrees', 'integration');
|
|
301
|
+
const ouverte = ouvrirCopie({ racine, copie, ref: 'HEAD', dire });
|
|
302
|
+
if (ouverte.refus) return refuser(ouverte.refus);
|
|
303
|
+
const { dansCopie, monter, installer, tester } = ouverte;
|
|
304
|
+
|
|
305
|
+
// 5. The lots, applied in the copy, then installed.
|
|
306
|
+
for (const p of lots) {
|
|
307
|
+
const r = dansCopie('apply', '--3way', '--index', p);
|
|
308
|
+
if (r.status !== 0) return refuser(`Le lot ${p} ne s'applique pas :\n${r.stdout}${r.stderr}`);
|
|
309
|
+
}
|
|
310
|
+
const divergents = avecCopie ? differents(copieTicket, lots, fichiers, tmp) : [];
|
|
311
|
+
if (divergents.length) {
|
|
312
|
+
return refuser(
|
|
313
|
+
`Des fichiers du lot diffèrent dans la copie du ticket :\n${divergents.map(f => ` ${f}`).join('\n')}`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
dire('✓ lots appliqués');
|
|
317
|
+
const installation = installer();
|
|
318
|
+
if (installation) return refuser(installation);
|
|
319
|
+
|
|
320
|
+
// 6. The tests: the build, the guards and every test of the day, green required.
|
|
321
|
+
dire('— npm run verifier (construction, gardes, tests)');
|
|
322
|
+
const verdict = tester().status;
|
|
323
|
+
if (verdict === A_REPASSER) {
|
|
324
|
+
dire(
|
|
325
|
+
`⏱ À repasser (code ${A_REPASSER}) : un échec de durée sous charge, sans repos dans la borne. Le lot n'est ni commis ni refusé ; relance la même commande.`
|
|
326
|
+
);
|
|
327
|
+
return A_REPASSER;
|
|
328
|
+
}
|
|
329
|
+
if (verdict !== 0) return refuser('Les tests refusent le lot (sortie ci-dessus).');
|
|
330
|
+
dire('✓ tests');
|
|
331
|
+
|
|
332
|
+
// 7. The commit, in the copy: the commit validated is the commit that enters.
|
|
333
|
+
// The guards have just run (in `npm run verifier`): a pre-commit hook that plays them skips them under
|
|
334
|
+
// GRILLHOUSE_INTEGRATION=1; commit-msg (a closed ticket refuses a second commit, a reopened one takes its corrections) runs as always.
|
|
335
|
+
const c = spawnSync(
|
|
336
|
+
'bash',
|
|
337
|
+
monter('git', '-C', copie, 'commit', '-q', '-F', absolu(o.message)),
|
|
338
|
+
{
|
|
339
|
+
encoding: 'utf8',
|
|
340
|
+
env: { ...process.env, GRILLHOUSE_INTEGRATION: '1' },
|
|
341
|
+
}
|
|
342
|
+
);
|
|
343
|
+
if (c.status !== 0) return refuser(`Le commit est refusé :\n${c.stdout}${c.stderr}`);
|
|
344
|
+
const commit = dansCopie('rev-parse', 'HEAD').stdout.trim();
|
|
345
|
+
|
|
346
|
+
// 8. The branch of the ticket takes the commit; the ticket's copy follows it (it carried the
|
|
347
|
+
// lot, which the commit now holds) and is removed; the ticket waits in the queue.
|
|
348
|
+
const pose = git('update-ref', `refs/heads/${branche}`, commit);
|
|
349
|
+
if (pose.status !== 0) {
|
|
350
|
+
return refuser(`La branche ${branche} ne prend pas ${commit.slice(0, 7)} :\n${pose.stderr}`);
|
|
351
|
+
}
|
|
352
|
+
if (avecCopie) {
|
|
353
|
+
spawnSync('git', ['-C', copieTicket, 'reset', '-q', '--hard', commit], { encoding: 'utf8' });
|
|
354
|
+
}
|
|
355
|
+
bd('update', o.ticket, '--add-label', 'en-file');
|
|
356
|
+
dire(`✓ commit ${commit.slice(0, 7)} sur ${branche}`);
|
|
357
|
+
const retrait = avecCopie
|
|
358
|
+
? spawnSync('bash', [RETIRER_COPIE, '--garder-travail', copieTicket], {
|
|
359
|
+
cwd: racine,
|
|
360
|
+
encoding: 'utf8',
|
|
361
|
+
})
|
|
362
|
+
: { status: 0, stderr: '' };
|
|
363
|
+
if (retrait.status !== 0) {
|
|
364
|
+
const raison = `La copie ${copieTicket} n'est pas retirée après le commit de ${branche} : ${retrait.stderr.trim()}\nLa branche reste en file.`;
|
|
365
|
+
bd('comments', 'add', o.ticket, raison);
|
|
366
|
+
dire(`⚠ ${raison}`);
|
|
367
|
+
}
|
|
368
|
+
inscrireEntree(racine, branche, o.ticket);
|
|
369
|
+
dire('✓ en file');
|
|
370
|
+
const duree = Math.round((Date.now() - debut) / 100) / 10;
|
|
371
|
+
dire(`⏱ ${duree} s`);
|
|
372
|
+
return 0;
|
|
373
|
+
} finally {
|
|
374
|
+
base.fermer();
|
|
375
|
+
lacherVerrou(verrou);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
380
|
+
process.exitCode = main(process.argv.slice(2));
|
|
381
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The replay of a lot in a clean copy, shared by the integration (scripts/integration/integrer.mjs,
|
|
2
|
+
// a lot on the main of the moment) and the queue (scripts/integration/file.mjs, a branch replaced
|
|
3
|
+
// on the main of the moment): the copy made ready, the project's commands, its tests.
|
|
4
|
+
//
|
|
5
|
+
// - `ouvrirCopie` makes the copy <copie> ready on <ref>: detached, every untracked file removed, its
|
|
6
|
+
// submodules set (`scripts/sous-modules.sh`, a local modification overwritten; a submodule the
|
|
7
|
+
// project shares, `grillhouse.partage`, is linked to the main repository's instead), its
|
|
8
|
+
// dependencies made to agree with its lock file and manifests (below). It builds nothing: the
|
|
9
|
+
// tests build. An existing copy that does not get ready is removed by
|
|
10
|
+
// `scripts/retirer-copie.sh --jetable` (local work does not hold it, a lock does) and created
|
|
11
|
+
// again, once; a copy that does not get ready the second time refuses, and so does a removal the
|
|
12
|
+
// script refuses, with its reason. Every command of the project plays through
|
|
13
|
+
// `scripts/sous-modules.sh monter`: each submodule the preparation linked is read-only, by the
|
|
14
|
+
// list the pose printed, whatever a command rewrites afterwards. With a link and no possible
|
|
15
|
+
// mount (bwrap absent or refused), the copy refuses before any command runs.
|
|
16
|
+
// - The dependencies follow the lock file and the manifests of the moment. One function (`accorder`,
|
|
17
|
+
// which `ouvrirCopie` hands out as `installer`) compares the fingerprint of the lock file and of
|
|
18
|
+
// the manifests (every `package.json` git tracks) with the key
|
|
19
|
+
// `node_modules/.integration-lock`, runs `npm ci` when they differ and writes the key again; a
|
|
20
|
+
// failing install gives `DEPENDANCES_REFUSEES`, the one form of that refusal. It runs at the
|
|
21
|
+
// opening, and the consumer calls `installer` once the lot is in the copy, before the tests: a
|
|
22
|
+
// lot that changes the lock file or a manifest is installed as the continuous integration
|
|
23
|
+
// installs it (`npm ci` refuses a lock file in disagreement with its manifest). The tests run on
|
|
24
|
+
// dependencies in agreement, and they neither read the key nor install.
|
|
25
|
+
// - `tester` plays `npm run verifier` in the copy: Turborepo builds the project, then plays its
|
|
26
|
+
// guards and its tests beside each other (`turbo.json`), each task kept in the cache of the main
|
|
27
|
+
// tree (`.turbo/cache`, shared by every copy), so a task whose inputs an earlier run already
|
|
28
|
+
// played replays its log. It gives the command's result, green (status 0), « à repasser »
|
|
29
|
+
// (75, scripts/verifier.mjs) or red (any other), its output written as it comes.
|
|
30
|
+
// - Every command of the project run in the copy (`lancer`: the install, the tests) leaves the
|
|
31
|
+
// index of its copy up to date, green or red: the consumers that apply a lot with `--index` (the
|
|
32
|
+
// integration, the queue) read an index that matches the files the command touched, and none
|
|
33
|
+
// refreshes for its own account.
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const SOUS_MODULES = fileURLToPath(new URL('../sous-modules.sh', import.meta.url));
|
|
41
|
+
export const RETIRER_COPIE = fileURLToPath(new URL('../retirer-copie.sh', import.meta.url));
|
|
42
|
+
|
|
43
|
+
const VERROU = 'package-lock.json';
|
|
44
|
+
const DEPENDANCES_REFUSEES = "Les dépendances de la copie ne s'installent pas.";
|
|
45
|
+
// The most output a command of the project may write: a whole run of the tests, logs included.
|
|
46
|
+
const SORTIE_MAX = 256 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
// The fingerprint of what `npm ci` reads in <copie>: the lock file at <verrou> and every
|
|
49
|
+
// `package.json` git tracks (the root's and each workspace's, whatever pattern declares it: a
|
|
50
|
+
// manifest too many costs one more `npm ci`, a manifest missed lets a disagreement pass).
|
|
51
|
+
// `dansCopie` is git in the copy.
|
|
52
|
+
function empreinteDesDependances(copie, verrou, dansCopie) {
|
|
53
|
+
const suivis = dansCopie('ls-files', '-z', '--', ':(glob)**/package.json')
|
|
54
|
+
.stdout.split('\0')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.sort();
|
|
57
|
+
const h = createHash('sha256').update(readFileSync(verrou));
|
|
58
|
+
for (const f of suivis) h.update(`\0${f}\0`).update(readFileSync(path.join(copie, f)));
|
|
59
|
+
return h.digest('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The copy <copie> ready on <ref>, or `{ refus }`. `dire` writes a progress line. Returns the
|
|
63
|
+
// copy's tools: `dansCopie` (git in the copy), `monter` (the command line of a mounted command),
|
|
64
|
+
// `installer`, `tester`, and `tete`, the commit the copy was made ready on.
|
|
65
|
+
export function ouvrirCopie({ racine, copie, ref, dire }) {
|
|
66
|
+
const git = (...args) => spawnSync('git', ['-C', racine, ...args], { encoding: 'utf8' });
|
|
67
|
+
const dansCopie = (...args) => spawnSync('git', ['-C', copie, ...args], { encoding: 'utf8' });
|
|
68
|
+
const tete = git('rev-parse', ref).stdout.trim();
|
|
69
|
+
let partages = '';
|
|
70
|
+
const preparer = () => {
|
|
71
|
+
const prete = existsSync(copie)
|
|
72
|
+
? dansCopie('checkout', '-q', '--detach', '--force', tete).status === 0 &&
|
|
73
|
+
dansCopie('clean', '-q', '-f', '-d', '-x', '-e', 'node_modules').status === 0
|
|
74
|
+
: git('worktree', 'add', '-q', '--detach', copie, tete).status === 0;
|
|
75
|
+
if (!prete) return `La copie ${copie} ne se prépare pas.`;
|
|
76
|
+
const poses = spawnSync('bash', [SOUS_MODULES, 'pose', copie], { encoding: 'utf8' });
|
|
77
|
+
if (poses.status !== 0) return `La copie ne pose pas ses sous-modules : ${poses.stderr.trim()}`;
|
|
78
|
+
partages = poses.stdout;
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
const existait = existsSync(copie);
|
|
82
|
+
let refus = preparer();
|
|
83
|
+
if (refus && existait) {
|
|
84
|
+
const retrait = spawnSync('bash', [RETIRER_COPIE, '--jetable', copie], {
|
|
85
|
+
cwd: racine,
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
});
|
|
88
|
+
if (retrait.status !== 0) {
|
|
89
|
+
return { refus: `${refus}\nLa copie ne se retire pas : ${retrait.stderr.trim()}` };
|
|
90
|
+
}
|
|
91
|
+
refus = preparer();
|
|
92
|
+
}
|
|
93
|
+
if (refus) return { refus };
|
|
94
|
+
const monter = (...commande) => [
|
|
95
|
+
SOUS_MODULES,
|
|
96
|
+
'monter',
|
|
97
|
+
copie,
|
|
98
|
+
'--partages',
|
|
99
|
+
partages,
|
|
100
|
+
...commande,
|
|
101
|
+
];
|
|
102
|
+
const montage = spawnSync('bash', monter('true'), { encoding: 'utf8' });
|
|
103
|
+
if (montage.status !== 0) return { refus: montage.stderr.trim() };
|
|
104
|
+
const lancer = (commande, args) => {
|
|
105
|
+
const r = spawnSync('bash', monter(commande, ...args), {
|
|
106
|
+
cwd: copie,
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
maxBuffer: SORTIE_MAX,
|
|
109
|
+
});
|
|
110
|
+
process.stdout.write(r.stdout + r.stderr);
|
|
111
|
+
// After every command, green or red, `git update-index -q --refresh` re-reads the stat data of
|
|
112
|
+
// the files it touched (a hard link changes an inode date), as git does before it judges a
|
|
113
|
+
// tree. A refresh that fails changes nothing to the result, which is the command's.
|
|
114
|
+
dansCopie('update-index', '-q', '--refresh');
|
|
115
|
+
return r;
|
|
116
|
+
};
|
|
117
|
+
const npm = args => lancer('npm', args);
|
|
118
|
+
// The one point that makes the dependencies agree with the lock file and the manifests of the
|
|
119
|
+
// moment: the key of node_modules is compared with their fingerprint, `npm ci` runs when they
|
|
120
|
+
// differ, the key is written again. Null when they agree, else the refusal.
|
|
121
|
+
const accorder = () => {
|
|
122
|
+
const verrouNpm = path.join(copie, VERROU);
|
|
123
|
+
if (!existsSync(verrouNpm)) return null;
|
|
124
|
+
const cle = path.join(copie, 'node_modules', '.integration-lock');
|
|
125
|
+
const empreinte = empreinteDesDependances(copie, verrouNpm, dansCopie);
|
|
126
|
+
if (existsSync(cle) && readFileSync(cle, 'utf8') === empreinte) return null;
|
|
127
|
+
if (npm(['ci', '--no-audit', '--no-fund', '--silent']).status !== 0)
|
|
128
|
+
return DEPENDANCES_REFUSEES;
|
|
129
|
+
writeFileSync(cle, empreinte);
|
|
130
|
+
return null;
|
|
131
|
+
};
|
|
132
|
+
const refusOuverture = accorder();
|
|
133
|
+
if (refusOuverture) return { refus: refusOuverture };
|
|
134
|
+
dire(`✓ copie propre sur ${tete.slice(0, 7)}`);
|
|
135
|
+
|
|
136
|
+
// The checks of the copy: Turborepo builds, then plays the guards and the tests.
|
|
137
|
+
const tester = () => npm(['run', 'verifier']);
|
|
138
|
+
return { dansCopie, monter, installer: accorder, tester, tete };
|
|
139
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The reviewer's verdict at a ticket, as the circuit, the integration and the board read it: a
|
|
2
|
+
// module of its own, which imports nothing, so the board reads a verdict without the integration.
|
|
3
|
+
|
|
4
|
+
// The first word of a reviewer's verdict: ACCEPTÉ or RENDU.
|
|
5
|
+
export const VERDICT = /^\s*(ACCEPTÉ|RENDU)(?![\p{L}\d])/u;
|
|
6
|
+
// The author of the reviewer's verdicts at a ticket (Beads' `author`): the circuit writes the
|
|
7
|
+
// reviewer's final answer under it (BEADS_ACTOR), and the reviewer's session runs under it.
|
|
8
|
+
export const AUTEUR_DU_VERDICT = 'relecteur';
|
|
9
|
+
|
|
10
|
+
// The verdict the comment <c> (Beads' JSON: `author`, `text`) carries: ACCEPTÉ or RENDU for a
|
|
11
|
+
// comment of the author relecteur whose first word is one of them, else null.
|
|
12
|
+
export const verdictDe = c =>
|
|
13
|
+
c?.author === AUTEUR_DU_VERDICT ? (VERDICT.exec(String(c.text ?? ''))?.[1] ?? null) : null;
|