@sylad/cadence 0.28.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +244 -18
- package/agents/acceptance-writer.md +19 -0
- package/agents/code-reviewer.md +2 -1
- package/agents/spec-writer.md +21 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +8 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +91 -6
- package/dist/config.js +34 -5
- package/dist/corrections.js +228 -0
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +303 -0
- package/dist/orchestrate/aval.js +46 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +19 -2
- package/dist/orchestrate/cycle.js +529 -27
- package/dist/orchestrate/launch.js +12 -5
- package/dist/orchestrate/maquette.js +27 -0
- package/dist/orchestrate/sandbox.js +38 -0
- package/dist/orchestrate/schemas.js +17 -1
- package/dist/orchestrate/spec.js +140 -0
- package/dist/plan.js +22 -1
- package/dist/spec.js +230 -0
- package/package.json +2 -1
- package/templates/acceptation-runner.mjs +53 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +12 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
package/dist/audit.js
CHANGED
|
@@ -4,7 +4,8 @@ import { check } from './check.js';
|
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
import { changedFiles, fileAt, readCommits } from './git.js';
|
|
6
6
|
import { citedRefs, linkCommits, scopeOf } from './link.js';
|
|
7
|
-
import { readDocsConfig, readNewsConfig } from './config.js';
|
|
7
|
+
import { readDocsConfig, readNewsConfig, readOrchestrateConfig } from './config.js';
|
|
8
|
+
import { ficheHypotheses } from './spec.js';
|
|
8
9
|
import { docSyncGaps, filesOf, gapMessage } from './docsync.js';
|
|
9
10
|
import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
|
|
10
11
|
import { isRecurring } from './recurring.js';
|
|
@@ -158,11 +159,22 @@ export function audit(plan, root, newsDir, today, opts = {}) {
|
|
|
158
159
|
const m = l.public ? publicTitleTooLong(l.public, max) : reused ? publicTitleTooLong(reused, max) : null;
|
|
159
160
|
return m ? [{ message: `${l.id} : ${reused && !l.public ? m.replace('titre public', 'titre public repris de la Nouveauté') : m}` }] : [];
|
|
160
161
|
});
|
|
161
|
-
const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir), ...titles, ...gates, ...docSyncIssues(plan, root, all.byLot), ...(plan.hasPublicField ? missingPublicTitles(lots, entries) : []),
|
|
162
|
+
const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir, hypothesesOf(root, plan.configFile ?? join(root, 'cadence.yaml'))), ...titles, ...gates, ...docSyncIssues(plan, root, all.byLot), ...(plan.hasPublicField ? missingPublicTitles(lots, entries) : []),
|
|
162
163
|
...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
|
|
163
164
|
// Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
|
|
164
165
|
return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf (start|public) .*$/, '') })) : issues;
|
|
165
166
|
}
|
|
167
|
+
/** Hypothèses des fiches, dans le dossier de `orchestrate.spec.dir` ; une configuration d'orchestrate illisible n'empêche pas le contrôle : dossier par défaut. */
|
|
168
|
+
export function hypothesesOf(root, configFile) {
|
|
169
|
+
let dir = 'docs/specs';
|
|
170
|
+
try {
|
|
171
|
+
dir = readOrchestrateConfig(configFile).spec.dir;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// dossier par défaut
|
|
175
|
+
}
|
|
176
|
+
return ficheHypotheses(root, dir);
|
|
177
|
+
}
|
|
166
178
|
/**
|
|
167
179
|
* Documentation en retard (L143, clé `docs.sync`) : un lot en cours — ou terminé à partir de `docs.since` — dont les commits de
|
|
168
180
|
* travail touchent des chemins sans toucher le document que la règle désigne. Les commits de plan ne comptent pas.
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { readDocsConfig, readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
|
|
5
5
|
import { repoSections, repoShas, resolveLotRepos } from './repos.js';
|
|
6
|
-
import { audit, exemptPlanOnly, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
|
|
6
|
+
import { audit, exemptPlanOnly, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
|
|
7
7
|
import { short } from './check.js';
|
|
8
8
|
import { isDay, toDay } from './dates.js';
|
|
9
9
|
import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
|
|
@@ -21,13 +21,15 @@ import { findProjects, leadTour, tourLine } from './lead.js';
|
|
|
21
21
|
import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
|
|
22
22
|
import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
|
|
23
23
|
import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
|
|
24
|
+
import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
|
|
25
|
+
import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
|
|
24
26
|
import { readHudContext } from './hud-context.js';
|
|
25
27
|
import { sessionClose, sessionStart } from './session.js';
|
|
26
28
|
import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
|
|
27
29
|
const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
|
|
28
30
|
|
|
29
31
|
raf init [--project nom] [--prefix L] [--no-hook]
|
|
30
|
-
raf add "titre" [--estimate j] [--quickwin] [--visible] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
|
|
32
|
+
raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
|
|
31
33
|
raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
|
|
32
34
|
raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
|
|
33
35
|
raf note <id> "texte"
|
|
@@ -46,6 +48,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
46
48
|
raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
|
|
47
49
|
cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague]
|
|
48
50
|
cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
|
|
51
|
+
cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
|
|
49
52
|
cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
|
|
50
53
|
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
51
54
|
|
|
@@ -106,6 +109,7 @@ function dispatch(argv, io) {
|
|
|
106
109
|
estimate: { type: 'string' },
|
|
107
110
|
quickwin: { type: 'boolean' },
|
|
108
111
|
visible: { type: 'boolean' },
|
|
112
|
+
screen: { type: 'string' },
|
|
109
113
|
public: { type: 'string' },
|
|
110
114
|
every: { type: 'string' },
|
|
111
115
|
dir: { type: 'string' },
|
|
@@ -129,6 +133,11 @@ function dispatch(argv, io) {
|
|
|
129
133
|
ignored: { type: 'boolean' },
|
|
130
134
|
notes: { type: 'boolean' },
|
|
131
135
|
json: { type: 'boolean' },
|
|
136
|
+
classify: { type: 'boolean' },
|
|
137
|
+
model: { type: 'string' },
|
|
138
|
+
cache: { type: 'string' },
|
|
139
|
+
sample: { type: 'string' },
|
|
140
|
+
until: { type: 'string' },
|
|
132
141
|
help: { type: 'boolean', short: 'h' },
|
|
133
142
|
},
|
|
134
143
|
});
|
|
@@ -168,6 +177,10 @@ function dispatch(argv, io) {
|
|
|
168
177
|
}
|
|
169
178
|
return 0;
|
|
170
179
|
}
|
|
180
|
+
if (command === 'corrections')
|
|
181
|
+
return corrections(rest, values, io);
|
|
182
|
+
if (command === 'spec')
|
|
183
|
+
return spec(rest, io);
|
|
171
184
|
// --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
|
|
172
185
|
if (command === 'session' && (values.all || values.depth !== undefined))
|
|
173
186
|
return sessionAll(rest, values, io);
|
|
@@ -211,6 +224,8 @@ function dispatch(argv, io) {
|
|
|
211
224
|
const plan = loadPlan();
|
|
212
225
|
let id;
|
|
213
226
|
if (values.parent) {
|
|
227
|
+
if (values.screen !== undefined)
|
|
228
|
+
throw new RafError('--screen se pose sur un lot, pas sur une sous-tâche');
|
|
214
229
|
if (values.public !== undefined)
|
|
215
230
|
throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
|
|
216
231
|
if (values.every !== undefined)
|
|
@@ -227,7 +242,7 @@ function dispatch(argv, io) {
|
|
|
227
242
|
throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
|
|
228
243
|
if (values.public !== undefined)
|
|
229
244
|
refuseLongTitle(values.public);
|
|
230
|
-
id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, public: values.public, every, after });
|
|
245
|
+
id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, public: values.public, every, after });
|
|
231
246
|
}
|
|
232
247
|
plan.save();
|
|
233
248
|
io.out(id);
|
|
@@ -321,6 +336,8 @@ function dispatch(argv, io) {
|
|
|
321
336
|
io.out(`after : ${lot.after.join(', ')}`);
|
|
322
337
|
if (lot.public)
|
|
323
338
|
io.out(`public : ${lot.public}`);
|
|
339
|
+
if (lot.screen)
|
|
340
|
+
io.out(`screen : ${lot.screen}`);
|
|
324
341
|
if (notes.length) {
|
|
325
342
|
io.out('notes :');
|
|
326
343
|
// Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
|
|
@@ -479,7 +496,7 @@ function dispatch(argv, io) {
|
|
|
479
496
|
return 0;
|
|
480
497
|
}
|
|
481
498
|
case 'news':
|
|
482
|
-
return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle);
|
|
499
|
+
return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle, () => hypothesesOf(root, configPath));
|
|
483
500
|
default:
|
|
484
501
|
throw new RafError(`commande inconnue : ${command} (raf --help)`);
|
|
485
502
|
}
|
|
@@ -665,7 +682,7 @@ function postCommit(load, newsDir, root, today, io, mode) {
|
|
|
665
682
|
}
|
|
666
683
|
return 0;
|
|
667
684
|
}
|
|
668
|
-
function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle) {
|
|
685
|
+
function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle, hypotheses) {
|
|
669
686
|
const lots = plan.lots();
|
|
670
687
|
switch (sub) {
|
|
671
688
|
case 'new': {
|
|
@@ -694,7 +711,7 @@ function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle) {
|
|
|
694
711
|
case 'check':
|
|
695
712
|
case 'build': {
|
|
696
713
|
const entries = loadEntries(dir);
|
|
697
|
-
const issues = newsIssues(lots, entries, dir);
|
|
714
|
+
const issues = newsIssues(lots, entries, dir, sub === 'check' ? hypotheses() : undefined);
|
|
698
715
|
for (const i of issues)
|
|
699
716
|
io.out(`✗ ${i.message}`);
|
|
700
717
|
// Le build refuse seulement ce qu'il ne saurait pas publier : entrée illisible, capture absente.
|
|
@@ -736,6 +753,74 @@ function morningEffects(configPath, root) {
|
|
|
736
753
|
return [`✗ ${e.message}`];
|
|
737
754
|
}
|
|
738
755
|
}
|
|
756
|
+
/** `cadence spec check|qa <fiche>` (L170) : code 0 valide, 1 écarts, 2 usage ou fichier illisible. `qa` imprime la section expectations.md de l'écran. */
|
|
757
|
+
function spec(rest, io) {
|
|
758
|
+
const sub = rest[0];
|
|
759
|
+
if ((sub !== 'check' && sub !== 'qa') || rest.length !== 2)
|
|
760
|
+
throw new RafError('usage : cadence spec check|qa <fiche.yaml>');
|
|
761
|
+
const doc = readFiche(resolve(io.cwd, rest[1]));
|
|
762
|
+
const problems = checkFiche(doc);
|
|
763
|
+
if (sub === 'qa') {
|
|
764
|
+
// les écarts vont sur la sortie d'erreur : la sortie standard est la section, qu'on redirige vers le fichier
|
|
765
|
+
for (const m of problems)
|
|
766
|
+
io.err(`✗ ${m}`);
|
|
767
|
+
if (problems.length > 0)
|
|
768
|
+
return 1;
|
|
769
|
+
io.out(deriveQa(doc).trimEnd());
|
|
770
|
+
return 0;
|
|
771
|
+
}
|
|
772
|
+
for (const m of problems)
|
|
773
|
+
io.out(`✗ ${m}`);
|
|
774
|
+
if (problems.length > 0) {
|
|
775
|
+
io.out(`${problems.length} écart(s)`);
|
|
776
|
+
return 1;
|
|
777
|
+
}
|
|
778
|
+
io.out(summary(doc));
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
/** `cadence corrections` (L165) : la mesure de référence des passes de correction d'une vague, lue dans <dossier>/.cadence/runs. */
|
|
782
|
+
async function corrections(rest, values, io) {
|
|
783
|
+
if (rest.length > 1)
|
|
784
|
+
throw new RafError('usage : cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json]');
|
|
785
|
+
const parent = resolve(io.cwd, rest[0] ?? '.');
|
|
786
|
+
const runsDir = join(parent, '.cadence', 'runs');
|
|
787
|
+
if (!existsSync(runsDir))
|
|
788
|
+
throw new RafError(`corrections : aucun journal de vague dans ${runsDir}`);
|
|
789
|
+
const sample = values.sample === undefined ? undefined : Number(values.sample);
|
|
790
|
+
if (sample !== undefined && !(Number.isInteger(sample) && sample > 0))
|
|
791
|
+
throw new RafError(`--sample invalide : ${values.sample}`);
|
|
792
|
+
if (values.until !== undefined && Number.isNaN(Date.parse(values.until)))
|
|
793
|
+
throw new RafError(`--until invalide : ${values.until} (une date ISO, ex. 2026-10-10T18:31Z)`);
|
|
794
|
+
const model = values.model ?? 'mistral-small3.2:24b';
|
|
795
|
+
const cacheFile = resolve(io.cwd, values.cache ?? join(parent, '.cadence', 'corrections.json'));
|
|
796
|
+
const data = readRuns(runsDir, values.until);
|
|
797
|
+
const cache = readCache(cacheFile);
|
|
798
|
+
if (values.classify) {
|
|
799
|
+
const classify = ollamaClassifier({ url: io.env.OLLAMA_HOST ?? 'http://localhost:11434', model });
|
|
800
|
+
await classifyAll(data.passes, cache, classify, model, (done, total) => {
|
|
801
|
+
if (done % 25 === 0 || done === total)
|
|
802
|
+
io.err(`classement : ${done}/${total}`);
|
|
803
|
+
}, { every: 10, save: () => writeFileSync(cacheFile, serializeCache(cache)) });
|
|
804
|
+
writeFileSync(cacheFile, serializeCache(cache));
|
|
805
|
+
}
|
|
806
|
+
if (sample !== undefined) {
|
|
807
|
+
const byKey = new Map(data.passes.map((p) => [p.key, p]));
|
|
808
|
+
// Une passe sans constat (tests rouges) n'a rien à relire : elle ne fait pas partie de l'échantillon.
|
|
809
|
+
for (const key of sampleKeys(data.passes.filter((p) => p.findings.length > 0).map((p) => p.key), sample)) {
|
|
810
|
+
const p = byKey.get(key);
|
|
811
|
+
io.out(`## ${key} (${p.project} ${p.lot}) · modèle : ${cache.get(key)?.cause ?? 'non classé'}${cache.get(key)?.manual ? ` · relu : ${cache.get(key).manual}` : ''}`);
|
|
812
|
+
for (const f of p.findings)
|
|
813
|
+
io.out(`- ${f}`);
|
|
814
|
+
for (const c of p.commits)
|
|
815
|
+
io.out(` commit : ${c}`);
|
|
816
|
+
io.out('');
|
|
817
|
+
}
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
const m = measure(data, cache);
|
|
821
|
+
io.out(values.json ? JSON.stringify(m, null, 2) : renderReport(m));
|
|
822
|
+
return 0;
|
|
823
|
+
}
|
|
739
824
|
/** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
|
|
740
825
|
async function sessionAll(rest, values, io) {
|
|
741
826
|
const sub = rest[0];
|
package/dist/config.js
CHANGED
|
@@ -220,13 +220,13 @@ export function readSessionConfig(file) {
|
|
|
220
220
|
return config;
|
|
221
221
|
}
|
|
222
222
|
const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
223
|
-
export const EFFORT_STEPS = ['precheck', 'implement', 'fix', 'review', 'ux'];
|
|
223
|
+
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
224
224
|
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
225
225
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
226
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
226
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
227
227
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
228
228
|
export function readOrchestrateConfig(file) {
|
|
229
|
-
const config = { precheck: true, precheckLocal: false, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
229
|
+
const config = { precheck: true, maquette: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
230
230
|
if (!existsSync(file))
|
|
231
231
|
return config;
|
|
232
232
|
let raw;
|
|
@@ -253,19 +253,48 @@ export function readOrchestrateConfig(file) {
|
|
|
253
253
|
for (const k of Object.keys(o))
|
|
254
254
|
if (!ORCH_KEYS.includes(k))
|
|
255
255
|
throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
|
|
256
|
-
for (const k of ['start', 'verdict', 'test', 'build']) {
|
|
256
|
+
for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
|
|
257
257
|
if (o[k] == null)
|
|
258
258
|
continue;
|
|
259
259
|
if (typeof o[k] !== 'string' || !o[k].trim())
|
|
260
260
|
throw bad(`${k} : commande non vide attendue`);
|
|
261
261
|
config[k] = o[k].trim();
|
|
262
262
|
}
|
|
263
|
+
if (o.maquette != null) {
|
|
264
|
+
if (typeof o.maquette !== 'boolean')
|
|
265
|
+
throw bad('maquette : true ou false attendu');
|
|
266
|
+
config.maquette = o.maquette;
|
|
267
|
+
}
|
|
263
268
|
if (o.precheck != null) {
|
|
264
269
|
if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
|
|
265
270
|
throw bad('precheck : true, false ou local attendu');
|
|
266
271
|
config.precheck = o.precheck !== false;
|
|
267
272
|
config.precheckLocal = o.precheck === 'local';
|
|
268
273
|
}
|
|
274
|
+
if (o.prodAccess != null) {
|
|
275
|
+
if (typeof o.prodAccess !== 'boolean')
|
|
276
|
+
throw bad('prodAccess : true ou false attendu');
|
|
277
|
+
config.prodAccess = o.prodAccess;
|
|
278
|
+
}
|
|
279
|
+
if (o.spec != null) {
|
|
280
|
+
if (!isObject(o.spec))
|
|
281
|
+
throw bad('spec doit être un objet { dir, render }');
|
|
282
|
+
for (const k of Object.keys(o.spec))
|
|
283
|
+
if (k !== 'dir' && k !== 'render')
|
|
284
|
+
throw bad(`spec.${k} inconnu (attendu : dir, render)`);
|
|
285
|
+
if (o.spec.dir != null) {
|
|
286
|
+
const raw = typeof o.spec.dir === 'string' ? o.spec.dir.trim().replace(/\/+$/, '') : '';
|
|
287
|
+
const d = raw === '' ? '' : posix.normalize(raw).replace(/\/+$/, '');
|
|
288
|
+
if (d === '' || isAbsolute(d) || d.split(/[\\/]/).includes('..'))
|
|
289
|
+
throw bad('spec.dir : chemin relatif non vide au dépôt attendu, sans « .. »');
|
|
290
|
+
config.spec.dir = d;
|
|
291
|
+
}
|
|
292
|
+
if (o.spec.render != null) {
|
|
293
|
+
if (typeof o.spec.render !== 'string' || !o.spec.render.trim())
|
|
294
|
+
throw bad('spec.render : commande non vide attendue');
|
|
295
|
+
config.spec.render = o.spec.render.trim();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
269
298
|
if (o.review != null) {
|
|
270
299
|
if (!isObject(o.review))
|
|
271
300
|
throw bad('review doit être un objet { threshold, light, full }');
|
|
@@ -289,7 +318,7 @@ export function readOrchestrateConfig(file) {
|
|
|
289
318
|
}
|
|
290
319
|
if (o.effort != null) {
|
|
291
320
|
if (!isObject(o.effort))
|
|
292
|
-
throw bad('effort doit être un objet { precheck, implement, fix, review, ux }');
|
|
321
|
+
throw bad('effort doit être un objet { precheck, spec, maquette, accept, implement, fix, review, ux }');
|
|
293
322
|
for (const [k, v] of Object.entries(o.effort)) {
|
|
294
323
|
if (!EFFORT_STEPS.includes(k))
|
|
295
324
|
throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { RafError } from './plan.js';
|
|
4
|
+
/** Les causes d'une passe de correction (L165), dans l'ordre où le rapport les liste. */
|
|
5
|
+
export const CAUSES = ['spec-floue', 'regle-inventee', 'derive-architecture', 'donnees-sql', 'bug-code', 'test-manquant', 'faux-constat'];
|
|
6
|
+
const LABELS = {
|
|
7
|
+
'spec-floue': 'spec floue ou critère absent',
|
|
8
|
+
'regle-inventee': 'règle de gestion inventée',
|
|
9
|
+
'derive-architecture': "dérive d'architecture",
|
|
10
|
+
'donnees-sql': 'données / SQL',
|
|
11
|
+
'bug-code': 'bug de code',
|
|
12
|
+
'test-manquant': 'test manquant',
|
|
13
|
+
'faux-constat': 'faux constat de revue',
|
|
14
|
+
'sans-constat': 'sans revue (tests rouges)',
|
|
15
|
+
'non-classe': 'non classé',
|
|
16
|
+
};
|
|
17
|
+
const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
|
|
18
|
+
function readJson(file) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Les textes des constats d'un rapport de revue (champ `structured`, sinon le JSON de `result`). */
|
|
27
|
+
function findingsOf(report) {
|
|
28
|
+
let body = report?.structured;
|
|
29
|
+
if (!body && typeof report?.result === 'string') {
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(report.result);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
body = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const list = Array.isArray(body?.constats) ? body.constats : [];
|
|
38
|
+
return list.map((c) => (typeof c === 'string' ? c : c?.texte)).filter((t) => typeof t === 'string' && t.length > 0);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json`. Un lot compte (dénominateur) dès qu'une session
|
|
42
|
+
* d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
|
|
43
|
+
*/
|
|
44
|
+
export function readRuns(runsDir, until) {
|
|
45
|
+
const lots = [];
|
|
46
|
+
const passes = [];
|
|
47
|
+
if (!existsSync(runsDir))
|
|
48
|
+
return { lots, passes };
|
|
49
|
+
const limit = until === undefined ? Infinity : Date.parse(until);
|
|
50
|
+
// Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
|
|
51
|
+
const inTime = (s) => until === undefined || Date.parse(s.ended ?? '') <= limit;
|
|
52
|
+
for (const wave of readdirSync(runsDir).sort()) {
|
|
53
|
+
const waveDir = join(runsDir, wave);
|
|
54
|
+
if (!statSync(waveDir).isDirectory())
|
|
55
|
+
continue;
|
|
56
|
+
if (until !== undefined) {
|
|
57
|
+
// Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
|
|
58
|
+
const created = Date.parse(readJson(join(waveDir, 'wave.json'))?.created);
|
|
59
|
+
if (!(created <= limit))
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const file of readdirSync(waveDir).sort()) {
|
|
63
|
+
if (!file.endsWith('.json') || file === 'wave.json')
|
|
64
|
+
continue;
|
|
65
|
+
const rec = readJson(join(waveDir, file));
|
|
66
|
+
if (!rec || !Array.isArray(rec.steps))
|
|
67
|
+
continue;
|
|
68
|
+
const steps = rec.steps.filter((s) => s && typeof s === 'object');
|
|
69
|
+
const done = (s) => s.status === 'ok' && inTime(s);
|
|
70
|
+
if (!steps.some((s) => s.kind === 'implement' && done(s)))
|
|
71
|
+
continue;
|
|
72
|
+
const name = basename(file, '.json');
|
|
73
|
+
const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
|
|
74
|
+
lots.push({ project: rec.project, lot: rec.lot, visible: !!rec.visible, fixes: fixSteps.length });
|
|
75
|
+
for (const fix of fixSteps) {
|
|
76
|
+
const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
|
|
77
|
+
// Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
|
|
78
|
+
const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
|
|
79
|
+
const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
|
|
80
|
+
passes.push({ key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [] });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { lots, passes };
|
|
85
|
+
}
|
|
86
|
+
const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
|
|
87
|
+
const causes = () => ({ ...Object.fromEntries([...CAUSES, 'sans-constat', 'non-classe'].map((c) => [c, 0])) });
|
|
88
|
+
export function measure(data, cache) {
|
|
89
|
+
const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
|
|
90
|
+
for (const l of data.lots) {
|
|
91
|
+
const groups = [m.total, (m.byProject[l.project] ??= tally()), l.visible ? m.byVisibility.visible : m.byVisibility.hidden];
|
|
92
|
+
for (const t of groups) {
|
|
93
|
+
t.lots++;
|
|
94
|
+
t.passes += l.fixes;
|
|
95
|
+
if (l.fixes >= 1)
|
|
96
|
+
t.atLeast1++;
|
|
97
|
+
if (l.fixes >= 2)
|
|
98
|
+
t.atLeast2++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const p of data.passes) {
|
|
102
|
+
const c = cache.get(p.key);
|
|
103
|
+
const cause = p.findings.length === 0 ? 'sans-constat' : c ? (c.manual ?? c.cause) : 'non-classe';
|
|
104
|
+
m.byCause[cause]++;
|
|
105
|
+
(m.byProjectCause[p.project] ??= causes())[cause]++;
|
|
106
|
+
if (c?.manual && cause !== 'sans-constat') {
|
|
107
|
+
m.agreement.reviewed++;
|
|
108
|
+
if (c.manual === c.cause)
|
|
109
|
+
m.agreement.agree++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return m;
|
|
113
|
+
}
|
|
114
|
+
/** Extrait une cause connue d'une réponse (JSON `{cause}` ou texte libre) ; null sinon. */
|
|
115
|
+
export function parseCause(text) {
|
|
116
|
+
try {
|
|
117
|
+
const c = JSON.parse(text)?.cause;
|
|
118
|
+
if (typeof c === 'string' && CAUSES.includes(c))
|
|
119
|
+
return c;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return CAUSES.find((c) => text.includes(c)) ?? null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const SYSTEM = `Tu classes la CAUSE d'une passe de correction dans un projet logiciel mené par vagues d'agents : une session a écrit du code, une revue a formulé des constats, une session de correction les a traités. Réponds par UNE cause, celle du constat le plus grave, en cherchant la racine de ce qui a obligé à corriger.
|
|
127
|
+
Règle de départ : si le code livré ne fait pas ce que le lot ou le commit annonce, ou fait faux (logique, cas limite, condition trop large, rendu qui ne s'applique pas, accessibilité, état périmé, travail annoncé mais non fait), c'est bug-code.
|
|
128
|
+
- bug-code : le code livré est faux ou incomplet par rapport à une intention CLAIRE ;
|
|
129
|
+
- spec-floue : UNIQUEMENT si le constat dit que l'énoncé du lot est ambigu, muet, contradictoire ou sans critère mesurable et que la session a dû deviner ; un constat qui compare le code à un énoncé précis n'est PAS spec-floue ;
|
|
130
|
+
- regle-inventee : la session a ajouté de son chef une règle de gestion, un seuil, un message ou un comportement que le lot ne demandait pas ;
|
|
131
|
+
- derive-architecture : une convention ou une règle écrite du projet (CLAUDE.md, README, documentation, structure, duplication, couche contournée, agent ou brief incohérent) n'est pas suivie ;
|
|
132
|
+
- donnees-sql : requête, schéma, migration, format ou contenu de données faux ;
|
|
133
|
+
- test-manquant : le code est juste mais un comportement n'est pas couvert, ou un test ne vérifie pas ce que son titre promet ;
|
|
134
|
+
- faux-constat : la revue se trompe (le code était correct, constat infondé, hors lot, sans effet réel).`;
|
|
135
|
+
/** Classeur sur un modèle local servi par Ollama (/api/chat, température 0, sortie contrainte par un schéma) : hors quota Anthropic. */
|
|
136
|
+
export function ollamaClassifier(opts) {
|
|
137
|
+
const doFetch = opts.fetch ?? fetch;
|
|
138
|
+
return async (pass) => {
|
|
139
|
+
const findings = pass.findings.length ? pass.findings.map((f, i) => `${i + 1}. ${f.slice(0, 1500)}`).join('\n') : '(aucun constat lisible)';
|
|
140
|
+
const user = `Lot ${pass.project} ${pass.lot}.\nConstats de la revue :\n${findings}\n\nCommits de la correction :\n${pass.commits.join('\n') || '(aucun)'}`;
|
|
141
|
+
try {
|
|
142
|
+
const res = await doFetch(`${opts.url.replace(/\/$/, '')}/api/chat`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
model: opts.model,
|
|
147
|
+
stream: false,
|
|
148
|
+
options: { temperature: 0 },
|
|
149
|
+
format: { type: 'object', properties: { cause: { type: 'string', enum: [...CAUSES] }, raison: { type: 'string' } }, required: ['cause', 'raison'] },
|
|
150
|
+
messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: user }],
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
return null;
|
|
155
|
+
const body = (await res.json());
|
|
156
|
+
return parseCause(body.message?.content ?? '');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Classe les passes absentes du cache, une à une ; un échec n'entre pas au cache (une relance le reprend). */
|
|
164
|
+
export async function classifyAll(passes, cache, classify, model, progress, checkpoint) {
|
|
165
|
+
const todo = passes.filter((p) => p.findings.length > 0 && !cache.has(p.key));
|
|
166
|
+
let done = 0;
|
|
167
|
+
for (const p of todo) {
|
|
168
|
+
const cause = await classify(p);
|
|
169
|
+
if (cause)
|
|
170
|
+
cache.set(p.key, { cause, by: `ollama:${model}` });
|
|
171
|
+
done++;
|
|
172
|
+
progress?.(done, todo.length);
|
|
173
|
+
// Sauvegarde périodique : une interruption (Ctrl-C, Ollama tombé) ne perd que les derniers classements.
|
|
174
|
+
if (checkpoint && done % checkpoint.every === 0)
|
|
175
|
+
checkpoint.save();
|
|
176
|
+
}
|
|
177
|
+
if (checkpoint && done % checkpoint.every !== 0)
|
|
178
|
+
checkpoint.save();
|
|
179
|
+
}
|
|
180
|
+
/** n clés réparties à intervalle régulier dans l'ordre trié : le même échantillon à chaque lecture. */
|
|
181
|
+
export function sampleKeys(keys, n) {
|
|
182
|
+
const sorted = [...keys].sort();
|
|
183
|
+
if (sorted.length <= n)
|
|
184
|
+
return sorted;
|
|
185
|
+
const step = sorted.length / n;
|
|
186
|
+
return Array.from({ length: n }, (_, i) => sorted[Math.floor(i * step)]);
|
|
187
|
+
}
|
|
188
|
+
export function readCache(file) {
|
|
189
|
+
let raw = null;
|
|
190
|
+
if (existsSync(file)) {
|
|
191
|
+
// Un fichier illisible lu comme vide serait réécrit par --classify et perdrait les relectures saisies à la main.
|
|
192
|
+
raw = readJson(file);
|
|
193
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
194
|
+
throw new RafError(`${file} : JSON illisible (un objet clé → classement est attendu), corrigez ou supprimez le fichier`);
|
|
195
|
+
}
|
|
196
|
+
const known = (c) => typeof c === 'string' && CAUSES.includes(c);
|
|
197
|
+
const entries = Object.entries((raw ?? {}));
|
|
198
|
+
// Une cause inconnue (faute de frappe dans une relecture) ferait disparaître la passe des tableaux : on refuse le fichier.
|
|
199
|
+
for (const [key, c] of entries) {
|
|
200
|
+
if (!known(c?.cause))
|
|
201
|
+
throw new RafError(`${file} : cause inconnue pour ${key} : ${String(c?.cause)}`);
|
|
202
|
+
if (c.manual !== undefined && !known(c.manual))
|
|
203
|
+
throw new RafError(`${file} : relecture inconnue pour ${key} : ${String(c.manual)} (causes : ${CAUSES.join(', ')})`);
|
|
204
|
+
}
|
|
205
|
+
return new Map(entries);
|
|
206
|
+
}
|
|
207
|
+
export function serializeCache(cache) {
|
|
208
|
+
return JSON.stringify(Object.fromEntries([...cache].sort(([a], [b]) => a.localeCompare(b))), null, 2) + '\n';
|
|
209
|
+
}
|
|
210
|
+
const pct = (a, b) => (b === 0 ? '—' : `${Math.round((100 * a) / b)} %`);
|
|
211
|
+
const row = (name, t) => `| ${name} | ${t.lots} | ${t.atLeast1} (${pct(t.atLeast1, t.lots)}) | ${t.atLeast2} (${pct(t.atLeast2, t.lots)}) | ${t.passes} |`;
|
|
212
|
+
export function renderReport(m) {
|
|
213
|
+
const all = [...CAUSES, 'sans-constat', 'non-classe'];
|
|
214
|
+
const used = all.filter((c) => (c !== 'non-classe' && c !== 'sans-constat') || m.byCause[c] > 0);
|
|
215
|
+
const projects = Object.keys(m.byProject).sort();
|
|
216
|
+
const passes = m.total.passes;
|
|
217
|
+
const out = [];
|
|
218
|
+
out.push(`**${m.total.lots} lots** passés par une session d'écriture, **${passes} passes de correction** : ${pct(m.total.atLeast1, m.total.lots)} des lots ont eu au moins une correction, ${pct(m.total.atLeast2, m.total.lots)} au moins deux.`, '');
|
|
219
|
+
out.push('### Par cause', '', '| Cause | Passes | Part |', '|---|---|---|', ...used.map((c) => `| ${LABELS[c]} | ${m.byCause[c]} | ${pct(m.byCause[c], passes)} |`), '');
|
|
220
|
+
out.push('### Par cause et par projet (passes)', '', `| Projet | ${used.map((c) => LABELS[c]).join(' | ')} | Total |`, `|---|${used.map(() => '---').join('|')}|---|`);
|
|
221
|
+
for (const p of projects)
|
|
222
|
+
out.push(`| ${p} | ${used.map((c) => m.byProjectCause[p]?.[c] ?? 0).join(' | ')} | ${m.byProject[p].passes} |`);
|
|
223
|
+
out.push('', '### Lots avec correction', '', '| Projet | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', ...projects.map((p) => row(p, m.byProject[p])), row('**Total**', m.total), '');
|
|
224
|
+
out.push('### Lots visibles et non visibles', '', '| Lots | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', row('visibles', m.byVisibility.visible), row('non visibles', m.byVisibility.hidden), '');
|
|
225
|
+
if (m.agreement.reviewed > 0)
|
|
226
|
+
out.push(`Échantillon relu à la main : ${m.agreement.reviewed} passes, le modèle local est d'accord avec la relecture sur ${m.agreement.agree} (${pct(m.agreement.agree, m.agreement.reviewed)}) ; la cause relue prime dans les tableaux.`, '');
|
|
227
|
+
return out.join('\n');
|
|
228
|
+
}
|
package/dist/news.js
CHANGED
|
@@ -142,7 +142,21 @@ export function loadEntries(dir, added = () => addedTimes(dir)) {
|
|
|
142
142
|
return b.file.localeCompare(a.file);
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
-
|
|
145
|
+
/** Lignes `- Rn — …` de la section `## Hypothèses` du corps d'une entrée : null sans la section. */
|
|
146
|
+
function hypothesisLines(body) {
|
|
147
|
+
const lines = body.split('\n');
|
|
148
|
+
const start = lines.findIndex((l) => /^##\s+Hypothèses\s*$/i.test(l));
|
|
149
|
+
if (start < 0)
|
|
150
|
+
return null;
|
|
151
|
+
const rest = lines.slice(start + 1);
|
|
152
|
+
const end = rest.findIndex((l) => /^#{1,6}\s/.test(l));
|
|
153
|
+
return (end < 0 ? rest : rest.slice(0, end)).filter((l) => /^\s*[-*]\s+R\d+(?!\d)/.test(l));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `hypotheses` : par lot, les règles `hypothese` de sa fiche (L174, `ficheHypotheses`). L'entrée d'un lot qui en a doit se terminer
|
|
157
|
+
* par une section `## Hypothèses` avec une ligne `- Rn — …` par règle.
|
|
158
|
+
*/
|
|
159
|
+
export function newsIssues(lots, entries, dir, hypotheses = new Map()) {
|
|
146
160
|
const issues = [];
|
|
147
161
|
const ids = new Set(lots.map((l) => l.id));
|
|
148
162
|
const covered = new Set(entries.flatMap((e) => e.lots));
|
|
@@ -169,6 +183,18 @@ export function newsIssues(lots, entries, dir) {
|
|
|
169
183
|
issues.push({ kind: 'visible-without-entry', message: `${l.id} est visible et terminé sans entrée Nouveautés — cadence news new ${l.id}` });
|
|
170
184
|
}
|
|
171
185
|
}
|
|
186
|
+
for (const [id, rules] of hypotheses) {
|
|
187
|
+
const mine = entries.filter((e) => e.lots.includes(id));
|
|
188
|
+
if (!rules.length || !mine.length)
|
|
189
|
+
continue;
|
|
190
|
+
const found = mine.map((e) => ({ e, lines: hypothesisLines(e.body) })).map(({ e, lines }) => ({ e, lines, missing: lines ? rules.filter((r) => !lines.some((l) => new RegExp(`^\\s*[-*]\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\d)`).test(l))) : rules }));
|
|
191
|
+
if (found.some((f) => f.lines && f.missing.length === 0))
|
|
192
|
+
continue;
|
|
193
|
+
const withSection = found.filter((f) => f.lines).sort((a, b) => a.missing.length - b.missing.length)[0];
|
|
194
|
+
issues.push(withSection
|
|
195
|
+
? { kind: 'missing-hypotheses', message: `${id} : la section « ## Hypothèses » de ${withSection.e.file} n'a pas de ligne pour ${withSection.missing.join(', ')}` }
|
|
196
|
+
: { kind: 'missing-hypotheses', message: `${id} : la fiche a des hypothèses (${rules.join(', ')}) mais l'entrée ${mine[0].file} n'a pas la section « ## Hypothèses »` });
|
|
197
|
+
}
|
|
172
198
|
return issues;
|
|
173
199
|
}
|
|
174
200
|
export function slugify(title) {
|
|
@@ -242,19 +268,40 @@ function inline(text) {
|
|
|
242
268
|
})
|
|
243
269
|
.join('');
|
|
244
270
|
}
|
|
245
|
-
/** Markdown minimal : paragraphes, listes « -
|
|
271
|
+
/** Markdown minimal : titres « # » à « ###### », paragraphes, listes « - » (puce continuée sur une ligne indentée), **gras**, `code`, [liens](url). Tout le reste est du texte échappé. */
|
|
246
272
|
export function renderMarkdown(md) {
|
|
247
273
|
return md
|
|
248
274
|
.replace(/\r\n/g, '\n')
|
|
249
275
|
.split(/\n\s*\n/)
|
|
250
276
|
.map((block) => block.trim())
|
|
251
277
|
.filter(Boolean)
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
278
|
+
.flatMap((block) => {
|
|
279
|
+
// un titre tient sur sa ligne : ce qui le suit (liste, paragraphe) est un bloc à part, même sans ligne vide
|
|
280
|
+
const parts = [];
|
|
281
|
+
for (const line of block.split('\n')) {
|
|
282
|
+
const prev = parts[parts.length - 1];
|
|
283
|
+
if (!prev || /^#{1,6} /.test(line) || /^#{1,6} /.test(prev[0]))
|
|
284
|
+
parts.push([line]);
|
|
285
|
+
else
|
|
286
|
+
prev.push(line);
|
|
256
287
|
}
|
|
257
|
-
return
|
|
288
|
+
return parts.map((lines) => {
|
|
289
|
+
const heading = /^(#{1,6}) +(.+)$/.exec(lines[0]);
|
|
290
|
+
if (heading)
|
|
291
|
+
return `<h${Math.min(heading[1].length + 1, 6)}>${inline(heading[2].trim())}</h${Math.min(heading[1].length + 1, 6)}>`;
|
|
292
|
+
// une puce peut continuer sur la ligne suivante, indentée (format d'une hypothèse, spec §8)
|
|
293
|
+
if (/^\s*[-*] /.test(lines[0]) && lines.every((l) => /^\s*[-*] /.test(l) || /^\s+\S/.test(l))) {
|
|
294
|
+
const items = [];
|
|
295
|
+
for (const l of lines) {
|
|
296
|
+
if (/^\s*[-*] /.test(l))
|
|
297
|
+
items.push(l.replace(/^\s*[-*] /, ''));
|
|
298
|
+
else
|
|
299
|
+
items[items.length - 1] += `\n${l.trim()}`;
|
|
300
|
+
}
|
|
301
|
+
return `<ul>${items.map((i) => `<li>${inline(i)}</li>`).join('')}</ul>`;
|
|
302
|
+
}
|
|
303
|
+
return `<p>${inline(lines.join('\n'))}</p>`;
|
|
304
|
+
});
|
|
258
305
|
})
|
|
259
306
|
.join('\n');
|
|
260
307
|
}
|