@sylad/cadence 1.31.0 → 1.33.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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.31.0",
11
+ "version": "1.33.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.31.0",
4
+ "version": "1.33.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -55,7 +55,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
55
55
  - [docs/lead.md](docs/lead.md): the lead workflow, and the `livreur` agent that delivers a ready lot for it.
56
56
  - [docs/overview.md](docs/overview.md): the longer introduction and the map of every page.
57
57
  - [docs/fonctionnement.md](docs/fonctionnement.md): how it all works, in four figures.
58
- - [docs/stabilite.md](docs/stabilite.md): what is guaranteed from 1.0.0.
58
+ - [docs/stabilite.md](docs/stabilite.md): what is guaranteed from 1.0.0; [docs/qualite.md](docs/qualite.md): the code-quality ratchet.
59
59
  - [docs/whats-new.md](docs/whats-new.md) and [CHANGELOG.md](CHANGELOG.md): what changed.
60
60
 
61
61
  ## License
package/dist/articles.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { isOpen } from './plan.js';
4
4
  /** Titre du lot ouvert à la livraison. */
5
- export const articleTitle = (name) => `Article ${name} à rafraîchir`;
5
+ const articleTitle = (name) => `Article ${name} à rafraîchir`;
6
6
  const SECTIONS_SHOWN = 12;
7
7
  /** Titres d'un article : lignes `#` du Markdown (hors en-tête YAML) et balises `<h1>`…`<h6>` d'une page Astro ou MDX. */
8
8
  export function articleHeadings(text) {
package/dist/audit.js CHANGED
@@ -140,7 +140,7 @@ export function isReleaseOnly(sha, root) {
140
140
  }));
141
141
  }
142
142
  /** Le commit a été acquitté par « raf ignore » : par son sha, ou par son sujet exact. */
143
- export function isAcknowledged(plan, c) {
143
+ function isAcknowledged(plan, c) {
144
144
  return plan.acknowledged.some((a) => (a.sha !== undefined && a.sha === c.sha) || (a.sha === undefined && a.subject === c.subject));
145
145
  }
146
146
  /**
@@ -158,7 +158,7 @@ export function exemptPlanOnly(linked, plan, root) {
158
158
  return { ...linked, orphans, unknown: linked.unknown.filter((u) => !acked(u.commit)) };
159
159
  }
160
160
  /** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
161
- export function auditSince(plan, explicit) {
161
+ function auditSince(plan, explicit) {
162
162
  if (explicit)
163
163
  return explicit;
164
164
  // Une date seule vaudrait « ce jour-là à l'heure actuelle » pour git : minuit explicite.
@@ -168,7 +168,7 @@ export function auditSince(plan, explicit) {
168
168
  * Avertissements : un lot visible (hors abandonné) sans titre public — le site retombe sur le titre technique du plan,
169
169
  * sauf pour un lot terminé qu'une Nouveauté cite : le site en reprend le titre (cf. reusedNewsTitle).
170
170
  */
171
- export function missingPublicTitles(lots, entries) {
171
+ function missingPublicTitles(lots, entries) {
172
172
  return lots
173
173
  .filter((l) => l.visible && l.status !== 'dropped' && !l.public && reusedNewsTitle(l, entries) === null)
174
174
  .map((l) => ({ message: `${l.id} : lot visible sans titre public — raf public ${l.id} "…"`, warning: true }));
@@ -225,7 +225,7 @@ export function hypothesesOf(root, configFile) {
225
225
  * travail touchent des chemins sans toucher le document que la règle désigne. Les commits de plan ne comptent pas.
226
226
  * Un lot dont l'écart est acquitté et motivé (`raf docs-ok`, L317) n'est pas compté.
227
227
  */
228
- export function docSyncIssues(plan, root, byLot) {
228
+ function docSyncIssues(plan, root, byLot) {
229
229
  const { sync, since } = readDocsConfig(plan.configFile ?? join(root, 'cadence.yaml'));
230
230
  if (!sync.length)
231
231
  return [];
@@ -349,7 +349,7 @@ export function unreviewedWork(plan, root, lotId) {
349
349
  * à la journée près, on ne distingue pas un lot fermé avant l'activation d'un lot fermé après
350
350
  * (et `raf done` refuse de toute façon dès l'activation).
351
351
  */
352
- export function uxIssues(plan) {
352
+ function uxIssues(plan) {
353
353
  const since = plan.uxSince;
354
354
  if (!since)
355
355
  return [];
@@ -363,7 +363,7 @@ export function uxIssues(plan) {
363
363
  * pas : aucun verdict, ou un verdict antérieur à ces commits. Même règle de date que `uxIssues` ; un
364
364
  * lot sans commit n'a rien à faire relire.
365
365
  */
366
- export function reviewIssues(plan, root, byLot) {
366
+ function reviewIssues(plan, root, byLot) {
367
367
  const since = plan.reviewSince;
368
368
  if (!since)
369
369
  return [];
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig, readVerifyConfig } from './config.js';
6
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
7
- import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork, workByLot } from './audit.js';
7
+ import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, isNonConformingVerdict, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork, workByLot } from './audit.js';
8
8
  import { short } from './check.js';
9
9
  import { isDay, toDay } from './dates.js';
10
10
  import { deliver, parseDeliverConfig, realDeps, shellQuote } from './deliver.js';
@@ -461,7 +461,9 @@ function dispatch(argv, io) {
461
461
  }
462
462
  }
463
463
  const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
464
- plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
464
+ const verdict = status === 'done' && !rest[0].includes('/') ? plan.lots().find((l) => l.id === rest[0])?.review?.verdict : undefined;
465
+ const nonConforming = verdict !== undefined && isNonConformingVerdict(verdict);
466
+ plan.setStatus(rest[0], status, today, { force: values.force, unreviewed, nonConforming });
465
467
  if (command === 'drop' && values.reason)
466
468
  plan.note(rest[0], `abandonné : ${values.reason}`, today);
467
469
  plan.save();
package/dist/config.js CHANGED
@@ -311,7 +311,7 @@ export function readSessionConfig(file) {
311
311
  return config;
312
312
  }
313
313
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
314
- export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
314
+ const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
315
315
  /** Clé `verify:` de cadence.yaml. Absente : `{}`. */
316
316
  export function readVerifyConfig(file) {
317
317
  if (!existsSync(file))
@@ -296,9 +296,9 @@ const SOUS_DEFINITIONS = {
296
296
  autre: 'tout bug de code qui n\'entre dans aucune des valeurs précédentes',
297
297
  };
298
298
  /** Les maillons de la chaîne où un mécanisme peut être posé (R2). */
299
- export const MAILLONS = ['brief implement', 'auto-vérification avant rendu', 'porte locale', 'fiche'];
299
+ const MAILLONS = ['brief implement', 'auto-vérification avant rendu', 'porte locale', 'fiche'];
300
300
  /** Le maillon dont relève chaque sous-cause (R2, hypothèse) ; `autre` n'en désigne aucun. */
301
- export const MAILLON_DE = {
301
+ const MAILLON_DE = {
302
302
  'cas-limite': 'brief implement',
303
303
  'test-vide': 'brief implement',
304
304
  'affirmation-fausse': 'auto-vérification avant rendu',
@@ -308,8 +308,8 @@ export const MAILLON_DE = {
308
308
  autre: null,
309
309
  };
310
310
  /** Seuils de désignation du maillon dominant (R2, hypothèse) : passes sous-classées au moins, et part minimale du maillon en tête, en %. */
311
- export const MAILLON_MIN_PASSES = 10;
312
- export const MAILLON_MIN_PART = 40;
311
+ const MAILLON_MIN_PASSES = 10;
312
+ const MAILLON_MIN_PART = 40;
313
313
  /** Extrait une sous-cause connue d'une réponse (JSON `{sous}`) ; null sinon. */
314
314
  export function parseSousCause(text) {
315
315
  try {
@@ -428,7 +428,7 @@ export function sousCausesRanked(b) {
428
428
  return SOUS_CAUSES.map((sous, i) => ({ sous, label: SOUS_LABELS[sous], passes: b.parSousCause[sous], i })).filter((x) => x.passes > 0).sort((a, b2) => b2.passes - a.passes || a.i - b2.i);
429
429
  }
430
430
  /** La section « Bug de code par sous-cause » de `cadence corrections` (A1) : sous-cause, passes, part des passes sous-classées, maillon. */
431
- export function renderSousCauses(b) {
431
+ function renderSousCauses(b) {
432
432
  const out = ['### Bug de code par sous-cause', '', `${b.classees} passes sous-classées sur ${b.classees + b.sansSousCause}${b.sansSousCause > 0 ? ` · sans sous-cause : ${b.sansSousCause} (à classer : cadence corrections --classify)` : ''}.`, ''];
433
433
  if (b.classees > 0)
434
434
  out.push('| Sous-cause | Passes | Part | Maillon |', '|---|---|---|---|', ...sousCausesRanked(b).map((r) => `| ${r.label} | ${r.passes} | ${pct(r.passes, b.classees)} | ${MAILLON_DE[r.sous] ?? 'aucun'} |`), '');
package/dist/dates.js CHANGED
@@ -27,7 +27,7 @@ export function addDays(day, n) {
27
27
  d.setUTCDate(d.getUTCDate() + n);
28
28
  return format(d);
29
29
  }
30
- export function isWeekend(day) {
30
+ function isWeekend(day) {
31
31
  const w = parse(day).getUTCDay();
32
32
  return w === 0 || w === 6;
33
33
  }
package/dist/deliver.js CHANGED
@@ -14,7 +14,7 @@ import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFaul
14
14
  const POLL_CI = 15_000;
15
15
  const CI_APPEAR = 300_000;
16
16
  /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
17
- export const POLL_VERIFY = 10_000;
17
+ const POLL_VERIFY = 10_000;
18
18
  const CI_OK = new Set(['success', 'skipped', 'neutral']);
19
19
  const GH_TIMEOUT = 60_000;
20
20
  export const TIMED_OUT = 124;
@@ -474,7 +474,7 @@ async function waitCi(ctx, deps, sha, env) {
474
474
  }
475
475
  }
476
476
  /** Un essai d'une vérification : cause de l'échec, ou null. Partagé par deliver et `cadence verify`. */
477
- export async function tryCheck(c, deps, sha, env, budgetMs) {
477
+ async function tryCheck(c, deps, sha, env, budgetMs) {
478
478
  if (c.command !== undefined) {
479
479
  const code = await deps.exec(c.command, env, budgetMs);
480
480
  return code === 0 ? null : codeText(code);
@@ -2,15 +2,15 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSy
2
2
  import { basename, join, resolve } from 'node:path';
3
3
  import { cadenceHome } from './orchestrate/registry.js';
4
4
  /** Au-delà, le chiffre écrit par la bande est trop vieux pour guider un arrêt (la bande le rafraîchit toutes les 5 s). */
5
- export const HUD_CONTEXT_MAX_AGE_MS = 120_000;
5
+ const HUD_CONTEXT_MAX_AGE_MS = 120_000;
6
6
  /** Un fichier de session plus vieux que cela est ignoré et supprimé à la lecture (une session fermée ne le rafraîchit plus). */
7
- export const HUD_CONTEXT_PURGE_MS = 24 * 3600_000;
7
+ const HUD_CONTEXT_PURGE_MS = 24 * 3600_000;
8
8
  /** Dossier où la bande cadence-hud publie le contexte de chaque session : un fichier `<id de session>.json` par session. */
9
- export function hudContextDir(env = process.env) {
9
+ function hudContextDir(env = process.env) {
10
10
  return join(cadenceHome(env), 'hud-context');
11
11
  }
12
12
  /** Le nom de fichier d'une session : le même nettoyage que le script d'écriture de la bande (lettres, chiffres, `-`, `_`). */
13
- export function hudContextFile(session) {
13
+ function hudContextFile(session) {
14
14
  return `${session.replace(/[^A-Za-z0-9_-]/g, '_')}.json`;
15
15
  }
16
16
  function sameFolder(a, b) {
@@ -56,7 +56,7 @@ function livingFiles(dir, nowMs) {
56
56
  return kept;
57
57
  }
58
58
  /** La variable que Claude Code pose dans l'environnement de ses commandes Bash : l'id de la session, nom du fichier que la bande écrit. */
59
- export const SESSION_ID_ENV = 'CLAUDE_CODE_SESSION_ID';
59
+ const SESSION_ID_ENV = 'CLAUDE_CODE_SESSION_ID';
60
60
  /**
61
61
  * Le contexte publié par la bande pour la session de l'appelant, dans l'ordre : `session` quand on la désigne, sinon la session de la
62
62
  * variable `CLAUDE_CODE_SESSION_ID` ; seulement si elle est absente ET qu'un seul fichier publie pour `cwd`, celui-là (deux sessions
package/dist/lead.js CHANGED
@@ -64,7 +64,7 @@ export function findLeadDir(start) {
64
64
  }
65
65
  }
66
66
  /** Seuil de contexte du lead quand `lead.contextThreshold` n'est pas posé (L437). */
67
- export const CONTEXT_THRESHOLD_DEFAULT = 150_000;
67
+ const CONTEXT_THRESHOLD_DEFAULT = 150_000;
68
68
  /** Clé `lead.contextThreshold` du cadence.yaml du dossier (L437) : le nombre de tokens de contexte à partir duquel le lead ne lance plus de vague. En tokens et non en % : 60 % d'une fenêtre de 1M valait 600k. Un entier ≥ 1, sinon refus. */
69
69
  export function readLeadContextThreshold(parent) {
70
70
  const found = readLeadBlock(parent, 'lead.contextThreshold', 'un entier ≥ 1 (tokens)');
@@ -94,15 +94,15 @@ export function readDepth(parent) {
94
94
  throw new RafError(`${file} : depth doit être un entier ≥ 1`);
95
95
  return d;
96
96
  }
97
- export const NOTES_MAX = 120;
97
+ const NOTES_MAX = 120;
98
98
  export const TITLE_MAX = 60;
99
99
  /** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
100
- export const TOUR_IDLE = 3;
100
+ const TOUR_IDLE = 3;
101
101
  /** Un lot créé depuis moins de ce nombre de jours (aujourd'hui compris : 0 à 6 jours d'écart) compte parmi les « ajoutés cette semaine ». */
102
- export const ADDED_DAYS = 7;
102
+ const ADDED_DAYS = 7;
103
103
  const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: null, progress: { done: 0, doing: 0, todo: 0, added7: 0 }, repo: [] });
104
104
  /** Les lots non clos du plan, hors récurrents, chacun suivi de ses sous-tâches non closes ; celles d'un lot clos ou récurrent sortent avec lui. */
105
- export function openLots(lots) {
105
+ function openLots(lots) {
106
106
  return lots
107
107
  .filter((l) => isOpen(l.status) && !isRecurring(l))
108
108
  .flatMap((l) => [
package/dist/link.js CHANGED
@@ -1,4 +1,4 @@
1
- export function isMerge(c) {
1
+ function isMerge(c) {
2
2
  return /^Merge\b/.test(c.subject);
3
3
  }
4
4
  /** Portée d'un sujet « type(L24,L25)! : … » ; null sans parenthèses. */
package/dist/news.js CHANGED
@@ -197,7 +197,7 @@ export function newsIssues(lots, entries, dir, hypotheses = new Map()) {
197
197
  }
198
198
  return issues;
199
199
  }
200
- export function slugify(title) {
200
+ function slugify(title) {
201
201
  return (title
202
202
  .normalize('NFD')
203
203
  .replace(/[̀-ͯ]/g, '')
@@ -4,13 +4,13 @@ import { tmpdir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { tryGit } from './git.js';
6
6
  /** Gabarit par défaut du message d'un commit de plan écrit dans une vague (L343 : inchangé depuis toujours). */
7
- export const PLAN_COMMIT_WAVE = 'plan: {lot} {action} (orchestrate {vague})';
7
+ const PLAN_COMMIT_WAVE = 'plan: {lot} {action} (orchestrate {vague})';
8
8
  /** Gabarit par défaut hors vague (`spec reseal`) : pas de vague à citer. */
9
- export const PLAN_COMMIT_PLAIN = 'plan: {lot} {action}';
9
+ const PLAN_COMMIT_PLAIN = 'plan: {lot} {action}';
10
10
  /** Gabarit par défaut du commit de la fiche sourcée par la réponse du lead : type `docs`, déjà conventionnel (L343). */
11
- export const LEAD_ANSWER_COMMIT = 'docs({lot}): {action} (orchestrate {vague})';
11
+ const LEAD_ANSWER_COMMIT = 'docs({lot}): {action} (orchestrate {vague})';
12
12
  /** Les mots que le gabarit `plan.commit` de cadence.yaml peut employer. */
13
- export const PLAN_COMMIT_FIELDS = ['lot', 'action', 'vague'];
13
+ const PLAN_COMMIT_FIELDS = ['lot', 'action', 'vague'];
14
14
  /** Ce que cadence écrit dans un commit de plan, par action : une seule source pour la vague et pour le dry-run. */
15
15
  export const PLAN_ACTIONS = {
16
16
  started: 'démarré',
package/dist/plan.js CHANGED
@@ -24,7 +24,7 @@ export function setPlanNotice(fn) {
24
24
  notice = fn;
25
25
  }
26
26
  export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
27
- export const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
27
+ const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
28
28
  export class RafError extends Error {
29
29
  }
30
30
  /** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
@@ -32,7 +32,7 @@ export const RESOURCE_NAME = /^[a-z0-9_-]+$/;
32
32
  export const RESOURCES_BAD_LIST = 'doit être une liste de noms de ressources';
33
33
  export const invalidResource = (name) => `ressource invalide : ${name === '' ? '(vide)' : name} (lettres minuscules, chiffres, - et _)`;
34
34
  /** Les noms de ressources de `--uses` ou de `raf uses` : chacun valide (`RafError` sinon), sans doublon, dans l'ordre donné. */
35
- export function cleanUses(names) {
35
+ function cleanUses(names) {
36
36
  for (const n of names)
37
37
  if (!RESOURCE_NAME.test(n))
38
38
  throw new RafError(invalidResource(n));
@@ -54,7 +54,7 @@ export function escapeRe(s) {
54
54
  */
55
55
  const DOTTED = '\\.\\w';
56
56
  /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
57
- export function refPattern(prefix) {
57
+ function refPattern(prefix) {
58
58
  return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
59
59
  }
60
60
  export function extractRefs(text, prefix) {
@@ -704,7 +704,8 @@ export class Plan {
704
704
  /**
705
705
  * `ref` is `L3` or `L3/t1`. `unreviewed` : nombre de commits du lot que sa revue de code ne couvre
706
706
  * pas (le plan ne lit pas git) — tous sans verdict, ceux postérieurs au commit relu sinon ; sans lui,
707
- * la revue de code n'est pas exigée.
707
+ * la revue de code n'est pas exigée. `nonConforming` : le verdict enregistré dit « non conforme » (le texte du
708
+ * verdict se juge dans `audit.ts`, `isNonConformingVerdict`) — refusé même si la revue couvre tous les commits.
708
709
  */
709
710
  setStatus(ref, status, today, opts = {}) {
710
711
  this.writable();
@@ -736,6 +737,9 @@ export class Plan {
736
737
  ? `${lotId} : la revue de code précède ${opts.unreviewed} commit(s) du lot, elle est à refaire — raf review ${lotId} "verdict" (--force pour passer outre)`
737
738
  : `${lotId} a ${opts.unreviewed} commit(s) : revue de code attendue avant done — raf review ${lotId} "verdict" (--force pour passer outre)`);
738
739
  }
740
+ if (this.reviewSince && opts.nonConforming && lot.review && !opts.force) {
741
+ throw new RafError(`${lotId} : la revue de code enregistrée est non conforme (« ${lot.review.verdict} ») — corriger puis refaire la revue : raf review ${lotId} "verdict" (--force pour passer outre)`);
742
+ }
739
743
  }
740
744
  node.set('status', status);
741
745
  if (!lot.started && status !== 'dropped')
package/dist/proc.js CHANGED
@@ -124,7 +124,7 @@ export function processStart(pid) {
124
124
  /** Âge au-delà duquel un relevé de secours (le relevé suivant a échoué) n'est plus cru : un pid y a pu être repris. */
125
125
  export const MAX_SNAPSHOT_AGE_MS = 1_000;
126
126
  /** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
127
- export const TRACK_INTERVAL_MS = 200;
127
+ const TRACK_INTERVAL_MS = 200;
128
128
  /**
129
129
  * Suivi continu de l'arbre d'une commande : toutes les TRACK_INTERVAL_MS, ses descendants sont relevés et
130
130
  * chacun est retenu avec son heure de démarrage. Un processus vu une fois reste suivi même si son parent
package/dist/ref/add.js CHANGED
@@ -7,7 +7,7 @@ import { DPI, extractImages, imageFile, imageRegions, listImages, pageCount, ren
7
7
  import { readDocument, writeDocument } from './store.js';
8
8
  import { requireTools } from './tools.js';
9
9
  /** Sans `--sections`, un document de cette taille ou plus petit est rendu en entier ; au-delà, seul l'index est construit. */
10
- export const WHOLE_DOCUMENT_MAX = 30;
10
+ const WHOLE_DOCUMENT_MAX = 30;
11
11
  /** Une page de moins de 20 caractères de texte est tenue pour scannée (recette du skill norme-etude). */
12
12
  const SCANNED_MAX_CHARS = 20;
13
13
  /** [1, 2, 3, 6] → « 1-3, 6 ». */
@@ -23,7 +23,7 @@ function ranges(pages) {
23
23
  return out.join(', ');
24
24
  }
25
25
  /** `3-5,9` → [3, 4, 5, 9] (pages du PDF, 1 = première), sans doublon, dans l'ordre. */
26
- export function parseSections(text, pages) {
26
+ function parseSections(text, pages) {
27
27
  const out = new Set();
28
28
  for (const part of text.split(',')) {
29
29
  const m = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(part);
@@ -9,9 +9,9 @@ import { troisiemeRegard } from './regard.js';
9
9
  import { sessionDeRef } from './session.js';
10
10
  /** Plafond déclaré d'une lecture : au plus 20 pages rendues lues d'un coup (design §5, « Taille »). Au-delà, `--sections` en choisit moins. */
11
11
  export const LECTURE_PAGES_MAX = 20;
12
- export const ANALYSE_MODELS = ['sonnet', 'opus', 'haiku'];
12
+ const ANALYSE_MODELS = ['sonnet', 'opus', 'haiku'];
13
13
  /** Sortie structurée de la session : ce que l'agent dit de sa lecture ; le programme ne s'y fie pas, il relit le fichier. */
14
- export const ANALYSE_SCHEMA = {
14
+ const ANALYSE_SCHEMA = {
15
15
  type: 'object',
16
16
  properties: {
17
17
  resume: { type: 'string', description: 'Two sentences: what the document is and what the reading holds.' },
@@ -20,7 +20,7 @@ export function lotIds(lot) {
20
20
  return idsInText([lot.title, lot.goal ?? '', ...lot.notes.map((n) => n.text).filter((t) => !isProgramNote(t))].join('\n'));
21
21
  }
22
22
  /** Les éléments que la fiche d'un lot justifie comme non pris (`<id> : <raison>` dans `sujet.perimetre.hors_perimetre`), par id. */
23
- export function nonPris(repo, specDir, lotId) {
23
+ function nonPris(repo, specDir, lotId) {
24
24
  const out = new Map();
25
25
  const doc = fichesDuDossier(repo, specDir).find(({ doc }) => doc.sujet?.lot === lotId)?.doc;
26
26
  const sujet = doc?.sujet;
@@ -64,7 +64,7 @@ function where(e) {
64
64
  * Calcule la couverture d'un inventaire valide par les lots du plan (fait, abandonné compté pour rien) et par ses exclusions écrites. Un lot fait dont la fiche
65
65
  * justifie un élément comme non pris (`justifies`) ne le couvre pas : l'élément reste un écart tant que le lead ne l'a pas exclu par écrit ou fait par un autre lot.
66
66
  */
67
- export function computeCoverage(inventaire, lots, justifies = () => new Map()) {
67
+ function computeCoverage(inventaire, lots, justifies = () => new Map()) {
68
68
  const elements = inventaire.elements;
69
69
  const known = new Set(elements.map((e) => e.id));
70
70
  const byId = new Map();
package/dist/ref/diff.js CHANGED
@@ -305,7 +305,7 @@ export function reprendreIds(lecture, precedent) {
305
305
  lecture.exclusions = precedent.exclusions;
306
306
  }
307
307
  /** L'inventaire que `--out` va remplacer, s'il en est un de valide ; sinon rien (un fichier illisible ou d'une autre forme n'est pas un inventaire précédent). */
308
- export function inventairePrecedent(path) {
308
+ function inventairePrecedent(path) {
309
309
  if (!existsSync(path))
310
310
  return undefined;
311
311
  try {
@@ -7,9 +7,9 @@ import { SLUG } from './add.js';
7
7
  import { decodePng, encodePng } from './png.js';
8
8
  import { readDocument } from './store.js';
9
9
  import { requireTools } from './tools.js';
10
- export const QUOI = ['couleurs', 'contraste', 'boites', 'texte'];
10
+ const QUOI = ['couleurs', 'contraste', 'boites', 'texte'];
11
11
  /** Largeur d'écran de référence, en pixels, à laquelle les mesures sont ramenées. */
12
- export const REFERENCE_WIDTH = 1440;
12
+ const REFERENCE_WIDTH = 1440;
13
13
  /** Écart d'un canal (0-255) après arrondi de la moyenne d'un carré. */
14
14
  const COLOR_TOLERANCE = 1;
15
15
  /** Part de la taille de police due à la forme des lettres : la hauteur de capitale n'est pas la même dans toutes les polices (0,70 à 0,73 em). */
@@ -41,13 +41,13 @@ function luminance([r, g, b]) {
41
41
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
42
42
  }
43
43
  /** Rapport de contraste WCAG 2.2, non arrondi. */
44
- export function contrastRatio(a, b) {
44
+ function contrastRatio(a, b) {
45
45
  const la = luminance(a);
46
46
  const lb = luminance(b);
47
47
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
48
48
  }
49
49
  /** `page`, `x,y` (un point) ou `x,y,l,h` (un carré), en pixels de la page rendue. */
50
- export function parseZone(text, raster) {
50
+ function parseZone(text, raster) {
51
51
  if (text === 'page')
52
52
  return { zone: { x: 0, y: 0, l: raster.width, h: raster.height }, point: false };
53
53
  const parts = text.split(',');
package/dist/ref/pdf.js CHANGED
@@ -89,7 +89,7 @@ export function renderPages(tools, file, pages, dir) {
89
89
  poppler('pdftoppm', tools, ['-r', String(DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)], file);
90
90
  }
91
91
  /** Résolution des vignettes de toutes les pages (à parcourir pour repérer les planches d'un document sans texte). */
92
- export const THUMB_DPI = 40;
92
+ const THUMB_DPI = 40;
93
93
  /** Vignettes basse résolution de toutes les pages dans `dir/page-<n>.png`. */
94
94
  export function renderThumbnails(tools, file, total, dir) {
95
95
  mkdirSync(dir, { recursive: true });
@@ -4,9 +4,9 @@ import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
4
4
  import { decodePng, encodePng } from './png.js';
5
5
  import { sessionDeRef } from './session.js';
6
6
  /** Marge (pixels de la page rendue) ajoutée de chaque côté de la zone d'un élément dans sa découpe : de quoi lire ce qui l'entoure. */
7
- export const MARGE_DECOUPE = 24;
7
+ const MARGE_DECOUPE = 24;
8
8
  /** Sortie structurée du troisième regard : un verdict par numéro d'élément ; le programme ne retient que les verdicts valides. */
9
- export const REGARD_SCHEMA = {
9
+ const REGARD_SCHEMA = {
10
10
  type: 'object',
11
11
  properties: {
12
12
  decisions: {
package/dist/ref/store.js CHANGED
@@ -20,7 +20,7 @@ export function referencesDir(root, configPath, env) {
20
20
  throw new RafError(`ref : references.dir (${abs}) est dans un dépôt Git : les documents de référence restent hors du dépôt (choisir un dossier ailleurs, ou retirer la clé pour $XDG_DATA_HOME/cadence/references)`);
21
21
  return abs;
22
22
  }
23
- export const documentFile = (docDir) => join(docDir, 'document.json');
23
+ const documentFile = (docDir) => join(docDir, 'document.json');
24
24
  export function readDocument(docDir) {
25
25
  const file = documentFile(docDir);
26
26
  if (!existsSync(file))
package/dist/ref/tools.js CHANGED
@@ -3,7 +3,7 @@ import { delimiter, join } from 'node:path';
3
3
  import { RafError } from '../plan.js';
4
4
  const POPPLER = 'sudo apt install poppler-utils';
5
5
  /** Les outils du système dont `cadence ref` dépend, dans l'ordre où l'erreur les nomme. */
6
- export const TOOLS = [
6
+ const TOOLS = [
7
7
  { name: 'pdftoppm', commands: ['pdftoppm'], install: POPPLER },
8
8
  { name: 'pdftotext', commands: ['pdftotext'], install: POPPLER },
9
9
  { name: 'pdfimages', commands: ['pdfimages'], install: POPPLER },
package/dist/release.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { currentBranch, hasTrackedChanges, tagExists } from './git.js';
4
4
  import { RafError } from './plan.js';
5
- export const BUMPS = ['patch', 'minor', 'major'];
5
+ const BUMPS = ['patch', 'minor', 'major'];
6
6
  const PACKAGE = 'package.json';
7
7
  const LOCK = 'package-lock.json';
8
8
  const PLUGIN = '.claude-plugin/plugin.json';
@@ -10,8 +10,8 @@ const MARKETPLACE = '.claude-plugin/marketplace.json';
10
10
  const CHANGELOG = 'CHANGELOG.md';
11
11
  const WHATS_NEW = 'docs/whats-new.md';
12
12
  /** Longueur maximale (en caractères) de la ligne d'une version dans la liste courte de docs/whats-new.md. */
13
- export const SHORT_LINE_MAX = 240;
14
- export function nextVersion(current, bump) {
13
+ const SHORT_LINE_MAX = 240;
14
+ function nextVersion(current, bump) {
15
15
  const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
16
16
  if (!m)
17
17
  throw new RafError(`release : la version de ${PACKAGE} (${current}) n'est pas de la forme x.y.z, elle ne se calcule pas`);
@@ -172,7 +172,7 @@ function plan(root, bump, today) {
172
172
  * une ligne de la liste « In two lines » (≤ 240 caractères, en tête) et un paragraphe long (en tête de « Version by version »).
173
173
  * Rend les écarts, une ligne chacun ; vide quand les deux sont là, à leur place.
174
174
  */
175
- export function whatsNewIssues(text, version) {
175
+ function whatsNewIssues(text, version) {
176
176
  const lines = text.split('\n');
177
177
  const issues = [];
178
178
  // « Version by version » coupe le fichier : la liste courte est avant, les paragraphes longs sont après (sauf l'introduction, premier paragraphe de la section).
package/dist/verify.js CHANGED
@@ -72,7 +72,7 @@ export function summaryLine(results) {
72
72
  : `verify : ${red} effet${red > 1 ? 's' : ''} rouge${red > 1 ? 's' : ''} sur ${n} vérification${n > 1 ? 's' : ''}`;
73
73
  }
74
74
  /** Variables passées aux commandes de vérification, comme à celles de deliver. */
75
- export function verifyEnv(sha) {
75
+ function verifyEnv(sha) {
76
76
  return { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: '' };
77
77
  }
78
78
  /**
@@ -98,7 +98,7 @@ export function expectedTarget(root, config, last) {
98
98
  /** Sha attendu de la reprise et de `cadence verify` sans --sha. */
99
99
  export const defaultTarget = (root, config) => expectedTarget(root, config, lastDelivery(sharedStateDir(root)));
100
100
  /** Délai de chaque essai d'une vérification de `cadence verify`. */
101
- export const VERIFY_ATTEMPT_MS = 120_000;
101
+ const VERIFY_ATTEMPT_MS = 120_000;
102
102
  /** `cadence verify` : 0 tout vert, 1 un effet rouge, 2 rien à vérifier. */
103
103
  export async function verifyCommand(ctx, deps) {
104
104
  const { config, out } = ctx;
@@ -81,7 +81,7 @@ A project shows at most 5 lots (L411), the first ones in the order above (so tho
81
81
 
82
82
  **Reordering (L402).** Drag a lot onto another lot of the same project and of the same display group (or a sub-task onto another sub-task of its lot, same group): press on the row, move with the button held, release. While you drag, the row you started from shows `●` and the row under the pointer shows where it will land, `⇡` (before it) or `⇣` (after it), or a red `⊘` when it belongs to another parent or to another display group (L410) and the drop will be refused. The keyboard does the same wherever the panel is drawn by a `Client` region (terminal and desktop app): click a row (or press ↑ / ↓ once the panel has the focus), then **Alt**+↑ / **Alt**+↓ moves the selected lot before its previous sibling / after its next one. Only the rows the panel shows take part (L411): a lot hidden behind `+ N lots`, or the sub-lots of a closed lot, are not rows, so they are never a target — Alt+↓ on the fifth lot, the last row before the `+ N lots` button, moves nothing, like Alt+↓ on the last row of a project; open the rest (or the lot) first, and the drop runs `raf move` as usual. The `+ N lots` / `moins` button sits outside the `Client` region, after the project's rows. The drop runs `raf move <id> --before|--after <id>` ([raf move](raf.md)) in the project folder, then the panel reads the plans again and says, under the title, `✔ L13 placé avant L10 — le plan est modifié, à commiter (raf move ne commite pas)`: the panel never commits, the plan file is yours to commit. Everything is relative to what the panel shows, in the L410 order, not to the order of the plan: the row you drop on, and the "previous" and "next" sibling of Alt+↑ / Alt+↓, are the ones displayed around the lot, and `raf move` is given that lot as its target. **A lot is reordered only inside its display group** (L410: in progress, free, blocked): dropping a lot on a lot of another group is refused, `raf` is not called and nothing moves. While you drag, the row under the pointer shows the red `⊘` (as for another parent), and on release the panel says why, by group: `✖ L411 est en cours : un lot libre se range parmi les libres` (the words: `en cours` / `libre` / `bloqué`; the lot is named by what it is, `un lot` or `une sous-tâche`). A parent with an in-progress sub-lot is shown with the lots in progress, so it counts as one of them. Alt+↑ / Alt+↓ toward a displayed neighbour of another group gets the same refusal; at the edge of the displayed rows (or before the `+ N lots` button) nothing happens. (Case that led to this: plan L402 L403 L407 L411, L403 free dropped on L411 in progress moved L403 below L407 and hid it behind `+ 11 lots`.) Inside a group the screen follows the plan, so a gesture that goes through always shows: the lot moves where you dropped it. A drop that would leave the order of the group as it is — the lot is already right before (or after) the target among the lots of its group — runs nothing and says `• L10 est déjà avant L13 dans le plan : rien à déplacer`; no gesture of the panel leads there (the screen follows the plan inside a group), the check guards a request the drawing did not make. The same holds between the sub-tasks of a lot. The `déjà` check is made on the table the panel last read: from a successful `raf move` until the end of a re-reading started *after* it (`cadence lead tour` takes several seconds; a reading already in flight read the old plan, so another one follows it) the table is held stale: no `déjà` short-circuit, `raf move` runs and decides (the group refusal stays: it depends on the states, not on the order). A refusal is written under the title with its reason and nothing moves: a wave holds the repository (`move : orchestration en cours …`), a plan kept by another tool (`plan en lecture seule : … — le modifier avec l'outil du projet`, the message does not name that tool's command: `raf` does not know it, so look in the project), an archived lot, different parents (`L402 et L401/2 n'ont pas le même parent …`) or different display groups (`L411 est en cours : …`), the last two checked before `raf` runs; `raf` missing from the `PATH` says so. On a surface with no `Client` region (mobile, editor) the panel stays read-only (`lecture seule` in its title).
83
83
 
84
- **Reordering the projects (L449).** Above the tree, `ordre des projets (priority:) — glisser, ou Alt+↑ / Alt+↓` and one row per project (`cadence 7 lots non clos`; `illisible` for a project the tour could not read), in the order of `priority:`. It is drawn only where the lots can be reordered (a `Client` region: terminal and desktop app), when the folder has two projects or more, and when the panel is tall enough to keep a row for each project and one lot under it (otherwise the tree has the room). Drag a project onto another (`●` the row you hold, `⇡` / `⇣` where it will land) or click it and press **Alt**+↑ / **Alt**+↓ (↑ / ↓ alone select): the drop runs `cadence lead priority <project> --before|--after <project> <folder>` ([lead priority](lead.md#lead-priority)), which rewrites the `priority:` list of the `cadence.yaml` of the parent folder, comments kept; the panel then reads the plans again and says under the title `✔ ol placé avant cadence — priority: du cadence.yaml est modifiée, à commiter`. This order is the one of the tour, of `cadence orchestrate --continue` and of the panel itself. The panel never commits. A refusal is written under the title with its reason (`✖ /dev/cadence.yaml illisible : …`, a `priority:` that is not a list) and nothing is written; `cadence` missing from the `PATH` says so; a project already at that place says `• … est déjà avant …`. The error row `cadence.yaml` that the tour adds for an unreadable file is not a project and cannot be moved or targeted.
84
+ **Reordering the projects (L449).** Above the tree, `ordre des projets (priority:) — glisser, ou Alt+↑ / Alt+↓` and one row per project, in the order of `priority:`: the name (in a `Box` of fixed width, the longest name's: the desktop app's font is proportional, so padding with spaces would align nothing; L495), the progress bar of the band — closed lots over total, filled cells coloured by the share done (red under 33 %, orange under 66 %, green above), the lots in progress with their ids and `+N cette semaine` (L481; `cadence ▮▮▮▯▯▯▯▯▯▯ 3/10 · 1 en cours : L401 · +2 cette semaine`) — so the progress reads without scrolling the band. A project with no `progress` in the tour (an older `cadence`) keeps `7 lots non clos`; `illisible` for a project the tour could not read. It is drawn only where the lots can be reordered (a `Client` region: terminal and desktop app), when the folder has two projects or more, and when the panel is tall enough to keep a row for each project and one lot under it (otherwise the tree has the room). Drag a project onto another (`●` the row you hold, `⇡` / `⇣` where it will land) or click it and press **Alt**+↑ / **Alt**+↓ (↑ / ↓ alone select): the drop runs `cadence lead priority <project> --before|--after <project> <folder>` ([lead priority](lead.md#lead-priority)), which rewrites the `priority:` list of the `cadence.yaml` of the parent folder, comments kept; the panel then reads the plans again and says under the title `✔ ol placé avant cadence — priority: du cadence.yaml est modifiée, à commiter`. This order is the one of the tour, of `cadence orchestrate --continue` and of the panel itself. The panel never commits. A refusal is written under the title with its reason (`✖ /dev/cadence.yaml illisible : …`, a `priority:` that is not a list) and nothing is written; `cadence` missing from the `PATH` says so; a project already at that place says `• … est déjà avant …`. The error row `cadence.yaml` that the tour adds for an unreadable file is not a project and cannot be moved or targeted.
85
85
 
86
86
  States: `▶ en cours` (doing), `○ libre` (todo, no open `after:`), `⊘ bloqué` (todo, an `after:` is still open); a lot in a wave takes the wave's state and words, as the band writes them (`… en attente`, `⟳ implémente`, `⟳ relit`, `⟳ corrige`, `? QUESTION`, `✔ prêt`, `↩ rendu`, `✖ échec`, `⏸ suspendu`). In a native plan the sub-tasks of an open lot (`L401/2`) are its sub-lots (`├`/`└`); they only read `▶ en cours` or `○ à faire`, and are not counted in the project's `N lots non clos`. In a plan kept by another tool the sub-lots come from `parent:`. An `after:` id that is closed or unknown is struck through and blocks nothing. A project the tour could not read shows `illisible : <error>`; the others stay.
87
87
 
package/docs/overview.md CHANGED
@@ -71,8 +71,9 @@ The two plugins are independent. `cadence@cadence` carries the rituals, which ca
71
71
  | [docs/lead.md](lead.md) | the lead workflow and `cadence lead tour` |
72
72
  | [docs/ref.md](ref.md) | reference documents: `cadence ref add` (rendered pages, positioned text, images, figure index), `cadence ref measure` (colours, WCAG contrast, boxes, OCR, font size), `cadence ref check` (the inventory schema, `templates/inventaire.json`), `cadence ref eval` (recall, precision, false « read », tokens per page against a known inventory), `cadence ref corpus` (the synthetic corpus with a known truth, rendered by Playwright) `cadence ref coverage` (which elements of the inventory no done lot covers, nor a written exclusion), `cadence ref diff` (reconciles two readings of a document by program) and `cadence ref analyse` (one reading by the `reference-analyst` agent; `--double` for two independent readings, `ref diff`, a third look by `reference-verifier` and the reconciled inventory, an option whose measurement showed no gain; 20 pages at most per reading) |
73
73
  | [docs/stabilite.md](stabilite.md) | what is guaranteed from 1.0.0 and what is not |
74
+ | [docs/qualite.md](qualite.md) | code-quality guard rails: ESLint (typescript-eslint, sonarjs), knip and jscpd compared to a versioned baseline by a ratchet (`npm run qualite`, `--init`, `--ecrire`, `--migrer`, `--contre`), in the wave's gate and the CI; the recipe for TypeScript, Python, Java and shell |
74
75
 
75
- Also: [news](news.md) (changelog, UX, code and QA reviews, documentation that follows the code), [session](session.md), [corrections](corrections.md), [perf](perf.md), [skills and agents](skills.md), [cadence-hud](cadence-hud.md), [releasing](releasing.md), [overview](overview.md), [how it all works](fonctionnement.md) (four figures).
76
+ Also: [news](news.md) (changelog, UX, code and QA reviews, documentation that follows the code), [quality](qualite.md), [session](session.md), [corrections](corrections.md), [perf](perf.md), [skills and agents](skills.md), [cadence-hud](cadence-hud.md), [releasing](releasing.md), [overview](overview.md), [how it all works](fonctionnement.md) (four figures).
76
77
 
77
78
  <details>
78
79
  <summary>The four tools in short</summary>
@@ -0,0 +1,132 @@
1
+ # Code-quality guard rails
2
+
3
+ What Checkstyle, PMD and Sonar do for a Java project, cadence does for its own code with four tools and a **ratchet**.
4
+ This page is also a recipe that does not depend on cadence: the same guard rails for TypeScript, Python, Java and shell,
5
+ to apply to any project (each project does it in a lot of its own).
6
+
7
+ ## The four tools and what each one is for
8
+
9
+ | Tool | Question it answers | What it measures | Starting threshold |
10
+ |---|---|---|---|
11
+ | [ESLint](https://eslint.org) + [typescript-eslint](https://typescript-eslint.io) | Is this unit too big or too tangled to read? | `max-lines` (file), `max-lines-per-function`, `max-depth`, `max-params`, `complexity` (cyclomatic) | 400 lines per file, 80 per function, depth 4, 5 parameters, complexity 15 |
12
+ | [eslint-plugin-sonarjs](https://github.com/SonarSource/SonarJS) | Is it hard to *follow*? | `sonarjs/cognitive-complexity` (nesting weighs more than a flat `switch`) | 15 |
13
+ | [knip](https://knip.dev) | Is something there that nobody uses? | unused files, exports, types, dependencies and binaries | none: any finding counts |
14
+ | [jscpd](https://github.com/kucherenko/jscpd) | Was this block copied? | duplicated blocks across files | 8 lines and 70 tokens |
15
+
16
+ Blank lines and comments do not count towards the length rules. The configuration is in `eslint.config.mjs`, `knip.json`
17
+ and `.jscpd.json`; the tests (`test/`) and the Claude Code plugin (`plugins/`) are not linted — the tests are knip entry
18
+ points, so what they import is not reported as dead. `jscpd` fails on a `path` that does not exist: keep the list in
19
+ `.jscpd.json` to the folders the project really has.
20
+
21
+ ## The ratchet
22
+
23
+ Putting a threshold on existing code turns hundreds of findings red on day one, and the usual answers are bad: raise the
24
+ threshold until it means nothing, or sprinkle `eslint-disable`. The ratchet keeps the thresholds honest instead:
25
+
26
+ 1. `node scripts/qualite.mjs --init` measures once and writes `qualite-baseline.json`, a flat map sorted by key,
27
+ versioned with the code. A key is `tool|file|rule` (`eslint|src/cli.ts|max-lines`, `knip|src/audit.ts|exports`,
28
+ `jscpd|src/config.ts`) and its value a count. Two metrics have a `…|total` or `…|lignes` twin that sums the figures:
29
+ for ESLint, every key has a `…|total` (the sum of the figures measured, a file of 1397 lines records 1397, three
30
+ functions of cognitive complexity 463, 20 and 18 record 501), and for jscpd every file has a `…|lignes` (the lines
31
+ of the duplicated blocks that touch it). A function that grows without passing the worst of its file, or a new
32
+ function that replaces a small one, makes the sum go up: the count alone would not see it.
33
+ 2. `npm run qualite` measures again and compares key by key. A key that is **higher** than in the baseline, or absent from
34
+ it (a new file, a rule never broken before), makes the command fail (exit 1) and names it with its old and new value.
35
+ The existing code passes; nothing can get worse; a touched file can only go down.
36
+ 3. A measure that went **down** passes, with a reminder. `npm run qualite:cliquet` (`--ecrire`) writes the gain into the
37
+ baseline — and refuses, leaving the file untouched, if anything got worse. `--init` refuses to run when the baseline
38
+ exists: the baseline never goes up, it is only ever lowered.
39
+ 4. A tool that crashes or whose output cannot be read fails the command (exit 2); it never counts as « zero finding ».
40
+ A missing baseline also exits 2, and says how to create it.
41
+
42
+ Counts, not line numbers, are compared: moving code inside a file does not move anything.
43
+
44
+ **Moving or splitting a file** (a 3000-line file cut into three modules, a function moved to its own file) is the one
45
+ legitimate way to get new paths in the baseline, and it has its own command: `node scripts/qualite.mjs --migrer --contre
46
+ main`. It measures, and rewrites the baseline only if (a) no key of a file that **existed at the start of the lot** went
47
+ up, and (b) the total of every measure over all files (sum of the `…|total`, `…|lignes` and knip counts per tool and
48
+ rule) did not go up. A new path starts free — its own `max-lines` finding is fine when the file it was cut from lost
49
+ more — but a defect cannot be moved into an existing file, nor added. The same rule is what `--contre` applies to the
50
+ baseline of the lot, so a migrated baseline passes the gate and the CI, and a hand-edited one does not. It needs
51
+ `--contre` (the files that « existed » are those of the start of the lot) and exits 2 without it.
52
+ The way out of a finding is to fix it and lock the gain. Two other ways would also lower the measure without fixing
53
+ anything, and the ratchet closes them:
54
+
55
+ - **A directive**: `eslint.config.mjs` sets `linterOptions.noInlineConfig`, so an `eslint-disable` comment disables
56
+ nothing (ESLint reports it, and the ratchet counts that report under `analyse`). The directives that the tools cannot
57
+ refuse (`jscpd:ignore-start`/`-end`, knip's `@public`/`@internal` tags) and any `eslint-disable` comment are counted per
58
+ file under `directive|file|name`, so adding one makes the measure go up like a new finding. Only the files the tool
59
+ concerned analyses are read (the files ESLint linted, the sources jscpd scanned, knip's `project` globs minus `ignore`):
60
+ a directive in a file the tool does not read disables nothing and is not counted.
61
+ - **A threshold or an exclusion**: under `--contre`, every file the tools read must be byte-identical to the start of
62
+ the lot — present, absent or changed the same way (exit 1 otherwise, naming each one as `nouveau`, `supprimé` or
63
+ `modifié`). That is not only the three files this repository uses: ESLint 9 loads `eslint.config.js` **before**
64
+ `eslint.config.mjs` (then `.cjs`, `.ts`, `.mts`, `.cts`), knip reads `knip.json`, `knip.jsonc`, `.knip.json`,
65
+ `.knip.jsonc`, `knip.ts`, `knip.js`, `knip.config.ts`, `knip.config.js` and `package.json#knip`, jscpd reads
66
+ `.jscpd.json` and `package.json#jscpd`, and knip as jscpd respect `.gitignore`. Of `package.json`, only the `knip` and
67
+ `jscpd` keys are compared. Changing a threshold is a decision of the plan, delivered alone; a lot judged by those
68
+ thresholds cannot move them. The lot that adopts the tools (no baseline at its start) introduces its configs freely.
69
+
70
+ **When the start of the lot has no baseline yet** (the lot that introduces the ratchet, while `main` moves under it):
71
+ the baseline measured at the start no longer matches the code once `main` is merged or the lot rebased, since `main`'s
72
+ own commits changed the figures, and the gate would hand back code the lot did not write. There is nothing to not
73
+ exceed in that case, so `node scripts/qualite.mjs --init --contre main` re-measures at the rebased tip and rewrites the
74
+ baseline (`--init` refuses as soon as `main` has a baseline: from then on `--ecrire` and `--migrer` are the only ways).
75
+ The refusal itself says so: when the start of the lot has no baseline, the list of measures that got worse ends with
76
+ that command.
77
+
78
+ The baseline is also the project's list of cleanups: each cleanup lot is measured by what it removes from it.
79
+
80
+ ## Where it runs
81
+
82
+ - **The wave's gate**: `cadence.yaml` declares `orchestrate.build: npx tsc --noEmit && node scripts/qualite.mjs --contre main`. The
83
+ build runs before the tests ([orchestrate.md](orchestrate.md)); a worse measure hands the lot back as a « build » finding,
84
+ and the session is told the exact command.
85
+ - **The CI**: `.github/workflows/tests.yml` runs `npm run qualite -- --contre origin/main` after the typecheck (the checkout is full-depth), and `prepublishOnly` runs it too.
86
+
87
+ ## Recipe for another project
88
+
89
+ Do it in one lot per project: install, configure, `--init`, wire into the gate and the CI, commit the baseline.
90
+ `scripts/qualite.mjs` is generic apart from its three readers (`lireEslint`, `lireKnip`, `lireJscpd`): copy it, and
91
+ change `mesurer` to the tools of the project. Whatever the language, a tool joins the ratchet once its report is turned
92
+ into `tool|file|rule → count`.
93
+
94
+ ### TypeScript / JavaScript
95
+
96
+ `npm i -D eslint typescript-eslint eslint-plugin-sonarjs knip jscpd`, then copy `eslint.config.mjs`, `knip.json`
97
+ and `.jscpd.json` of this repository and adapt `files`, `entry`, `project` and `path`. Add the `qualite` scripts to
98
+ `package.json`. In a monorepo, run the measure per workspace and prefix the keys with it.
99
+
100
+ ### Python
101
+
102
+ | Role | Tool | Command |
103
+ |---|---|---|
104
+ | complexity and size | [ruff](https://docs.astral.sh/ruff/) | `ruff check --select C901,PLR0912,PLR0913,PLR0915 --output-format json` (`C901` is McCabe, `PLR0913` too many arguments, `PLR0915` too many statements); threshold: `[tool.ruff.lint.mccabe] max-complexity = 15` |
105
+ | complexity, maintainability | [radon](https://radon.readthedocs.io) | `radon cc -j -n C src` (blocks of rank C or worse), `radon mi -j -n B src` |
106
+ | dead code | [vulture](https://github.com/jendrikseipp/vulture) | `vulture src --min-confidence 80` |
107
+ | duplicates | jscpd, which also reads Python | `format: ["python"]` in `.jscpd.json` |
108
+
109
+ ### Java (Maven)
110
+
111
+ | Role | Tool | Where |
112
+ |---|---|---|
113
+ | size and complexity | [Checkstyle](https://checkstyle.org) | `maven-checkstyle-plugin` with `FileLength`, `MethodLength`, `NestedIfDepth`, `ParameterNumber`, `CyclomaticComplexity` in the ruleset |
114
+ | smells, cognitive complexity, dead code | [PMD](https://pmd.github.io) | `maven-pmd-plugin`, rules `CognitiveComplexity`, `CyclomaticComplexity`, `UnusedPrivateField`, `UnusedPrivateMethod`, `UnusedLocalVariable` |
115
+ | duplicates | PMD CPD | `pmd:cpd-check` (`minimumTokens` 100 to start) |
116
+ | probable bugs | [SpotBugs](https://spotbugs.github.io) | `spotbugs-maven-plugin`, `check` goal |
117
+
118
+ Both Checkstyle and PMD write XML reports (`target/checkstyle-result.xml`, `target/pmd.xml`): count the violations per
119
+ file and rule from them. Bind `check` goals to the `verify` phase so that `mvn verify` is the gate's build command.
120
+
121
+ ### Shell
122
+
123
+ [shellcheck](https://www.shellcheck.net): `shellcheck -f json1 scripts/*.sh`, one key per file and code (`SC2086`…).
124
+ Shell has no complexity rule: keep a script small, and move logic into a language that has the tools above.
125
+
126
+ ## Choosing the thresholds
127
+
128
+ Start from the table above, measure, and fix the baseline **where the code is**: a threshold chosen to fit the existing
129
+ code is not a guard rail. Lower a threshold only when the baseline for it is empty or near, and never raise one: a
130
+ threshold that goes up is a decision, written in the plan.
131
+
132
+ The simplification audit of L461 (dead code, copies, how to split the three largest files, each point with the tests that protect it and its measure) is in [docs/mesures/audit-simplification-l461.md](mesures/audit-simplification-l461.md).
package/docs/raf.md CHANGED
@@ -62,7 +62,7 @@ raf gantt # .cadence/gantt.html (next to the plan)
62
62
  | `raf add`, `raf note`, `raf done` during an integration (L425) | while a wave integrates a lot of the repository (the integration lock is held by a live process), these three commands write the plan as usual and print on stderr `avertissement : intégration en cours (vague …, pid …) — …`: the plan is a tracked file of the main tree, and an integration that sees it modified refuses its lot (`arbre du dépôt modifié`, 2026-10-29: a `raf add` of the lead during the integration of L416). Commit the plan right away. Exit code 0; nothing is printed in a lot's worktree (its plan is not the tree the integration checks) nor when the lock belongs to a dead process |
63
63
  | `raf add`, `raf note`, `raf done` while a wave works in the main tree (L475) | when the repository's orchestration lock is held by a live wave and one of its lots is in `implementing`, `reviewing` or `fixing` **without a worktree** (a wave without worktrees, or a lot whose worktree folder does not exist), these commands write the plan as usual and print on stderr `avertissement : la vague … travaille dans l'arbre principal de ce dépôt (projet:lot) — …`: a plan commit of the lead then hands the lot back at review (2026-10-30: cadence L458 handed back by a plan commit during its review). Write the plan without committing it, or commit once the step ends. Exit code 0; nothing is printed for lots that are queued, in question, ready, handed back, failed or suspended, for lots playing in their worktree, from a worktree of the repository, from a session of the wave itself (`CADENCE_ORCHESTRATED` set), when the lock belongs to a dead process, or when the wave's state is unreadable |
64
64
  | `raf public <id> "title"` · `raf public <id> --clear` | set, replace or remove the public title of a lot |
65
- | `raf start <id>` · `raf done <id> [--force]` · `raf drop <id> [--reason text]` | dated transitions (`done` refuses open sub-tasks unless `--force`, and a lot whose `acceptation-<lot>.json` is still `vert: false`, L366: closed like that it would lock every later wave of the repository, see `cadence spec green`; with `--force` it only warns on stderr. Like every `raf` command it only writes the plan file and **never commits** it: when the plan or a file `plan.onWrite` produces is left with changes, `done` prints the `git add` / `git commit` command that takes them, the subject from the `plan.commit` template, L371) |
65
+ | `raf start <id>` · `raf done <id> [--force]` · `raf drop <id> [--reason text]` | dated transitions (`done` refuses open sub-tasks unless `--force`, with the code-review gate active (`raf review enable`) a lot whose commits its review does not cover, **and a lot whose recorded review verdict says `non conforme` / `not compliant` even when it covers every commit** (L476: `non conforme : 1 bloquant` at the last commit no longer closes; `conforme après 1 correction` is compliant; redo the review with `raf review <id> "verdict"` after the fix, or `--force`), and a lot whose `acceptation-<lot>.json` is still `vert: false`, L366: closed like that it would lock every later wave of the repository, see `cadence spec green`; with `--force` it only warns on stderr. Like every `raf` command it only writes the plan file and **never commits** it: when the plan or a file `plan.onWrite` produces is left with changes, `done` prints the `git add` / `git commit` command that takes them, the subject from the `plan.commit` template, L371) |
66
66
  | `cadence spec green <lot>` | L366: run the dedicated acceptance command (`orchestrate.accept`) on a lot driven or closed by hand; green, set `vert: true` in its `acceptation-<lot>.json` and commit that file alone (subject `chore(<lot>): acceptation verte, vert: true posé par cadence spec green` or the `plan.commit` template; the resealed tests are listed in the commit body, never in the subject), resealing the fingerprints of the listed tests refined since the red commit (journal `resceaux`) so the lock goes quiet; a listed test changed but not committed is refused. Red: exit 1, nothing changed. Refused for a `todo` lot, during a wave, and while an unfinished wave keeps a lot of the repository open; see [orchestrate](orchestrate.md) |
67
67
  | `raf todo <id> [--reason text]` | put a started lot back to `todo` (`doing` → `todo`, `started:` removed, dated note « remis en attente : reason »): refused when work commits cite the lot (plan-only and version commits do not count, as `raf commits`), or on a sub-task; sub-tasks and other keys are left as they are. **Reopening** (L396): on a `done` or `dropped` lot, `raf todo` is THE command — never the YAML — that reopens it (`→ todo`, `finished:` removed, `started:` kept so its commits stay its own, dated note « rouvert (était done) : reason »); `raf start`/`raf done`/`raf drop` on a closed lot say so (`L3 est done ; le rouvrir si c'est voulu : raf todo L3`). An archived lot reopened this way comes back into the plan file at that write |
68
68
  | `raf migrate [--dry-run]` | move `docs/plan/raf.yaml` to `.cadence/raf.yaml` and rewrite the readers of the old path in the repository, see [Moving the plan: `raf migrate`](#moving-the-plan-raf-migrate) |
package/docs/releasing.md CHANGED
@@ -33,8 +33,8 @@ A version exists in three places and is published in two; a release does all of
33
33
  on that version commit; a case below the threshold stops the release until you have read it in the report.
34
34
  4. `git tag v<version> && git push origin main v<version>` — the tag starts `.github/workflows/publish.yml`,
35
35
  which publishes to npm through Trusted Publishing (OIDC, no token stored anywhere): it checks the tag
36
- matches `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` and that `CHANGELOG.md` has a `## [x.y.z]` section for it (no section, no publication), then `npm publish --provenance`, where `prepublishOnly` runs the type-check and the
37
- tests and `prepare` builds `dist/`; a red suite stops the publication; then it creates the GitHub release with that CHANGELOG section as its text. The trusted publisher is declared
36
+ matches `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` and that `CHANGELOG.md` has a `## [x.y.z]` section for it (no section, no publication), then `npm publish --provenance`, where `prepublishOnly` runs the type-check, the quality ratchet (`npm run qualite`, [qualite.md](qualite.md): exit 1 when a measure got worse, 2 when a tool crashed or the baseline is missing) and the
37
+ tests and `prepare` builds `dist/`; a red suite or a measure that got worse stops the publication (fix the code, then `npm run qualite:cliquet` locks the gain); then it creates the GitHub release with that CHANGELOG section as its text. The trusted publisher is declared
38
38
  once on npmjs.com (package settings → Trusted Publisher → GitHub Actions, `Sylad/cadence`, `publish.yml`).
39
39
  5. Watch the run: `gh run watch` (or `gh run list --workflow publish.yml`).
40
40
  6. Check the effect: `npm view @sylad/cadence version` answers the new version.
package/docs/whats-new.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **1.33.0**: the lots panel shows each project's progress bar in its « ordre des projets » section, aligned in the desktop app; a simplification audit of the code, and dead exports removed.
8
+ - **1.32.0**: code-quality guard rails in a ratchet (ESLint, sonarjs, knip, jscpd vs a versioned baseline), a recipe for TS, Python, Java, shell; `raf done` refuses a lot whose recorded review is `non conforme`.
7
9
  - **1.31.0**: `deliver --sha` refuses, outside a wave too, a sha that carries commits of a lot neither done nor ready (a `non conforme` verdict is not ready); `raf add/note/done` warn when a wave session writes in the main tree.
8
10
  - **1.30.0**: a question can be answered while its wave still runs; the sibling-repository check of 1.29.0 only flags commits that cite a lot of the wave; `session context` reads the lead's threshold from the lead's folder.
9
11
  - **1.29.0**: a wave session can no longer commit on the main branch of another repository than its lot's (the lot is handed back with the finding); `verify --gate` no longer links data folders into its throwaway copy.
@@ -58,6 +60,10 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
58
60
 
59
61
  The long form of « In two lines » above (the README no longer carries a « What's new » section since L326: it links here). The full history, with the date of each version, is in [CHANGELOG.md](../CHANGELOG.md).
60
62
 
63
+ **1.33.0**: the lots panel (`/hud lots`) shows, on each row of its « ordre des projets » section, the progress bar of the band — closed lots over total, coloured by the share done, the lots in progress and `+N cette semaine` (L481, `docs/cadence-hud.md`); the bars all start at the same column, the project name sitting in a box as wide as the longest name, which a proportional font aligns where spaces did not (L494, L495). Dragging and Alt+↑ / Alt+↓ are unchanged. A read-only simplification audit of the code (L461, `docs/mesures/audit-simplification-l461.md`) ranks dead code, copies and split plans by gain and risk; its first cleanups remove exports nothing imports and a dead type (L483, L484), with no change of behaviour.
64
+
65
+ **1.32.0**: code-quality guard rails for cadence itself and a reference recipe for any project (L462, `docs/qualite.md`): ESLint with typescript-eslint and sonarjs (file and function length, depth, parameters, cyclomatic and cognitive complexity), knip (dead code, unused exports and dependencies) and jscpd (duplicates), compared by `npm run qualite` to a versioned baseline — existing code passes, nothing may get worse, a file a lot touches can only improve; `--migrer` is the admitted path to move or split a file of the baseline, and a changed tool configuration or a new suppression directive is refused. The recipe gives the same ratchet for Python, Java and shell. `raf done` refuses, without `--force`, a lot whose recorded code-review verdict is non-compliant, even when the review covers all its commits (L476, `docs/raf.md`), the predicate `cadence deliver --sha` uses since 1.31.0.
66
+
61
67
  **1.31.0**: `cadence deliver --sha <sha>` refuses, outside a wave too, a sha whose history since the last delivery carries work commits of a lot that is neither done nor ready — ready meaning a compliant code review that covers the lot's commits up to that sha, so a fix stacked above a reviewed sha does not block it and a `non conforme` verdict does — and `--dry-run` lists the lots of the interval with their state (L474, `docs/deliver.md`). Before, the check only ran during a wave of the repository: on 30-10 a sha delivered as « L49 alone » took two unreviewed lots whose commits sat beneath it. `raf add`, `raf note` and `raf done` warn on stderr when a session of a live wave writes in the main tree of the repository (L475, `docs/raf.md`): a plan commit of the lead at that moment hands the lot back, as it did on 30-10.
62
68
 
63
69
  **1.30.0**: `cadence orchestrate --answer <project>:<lot> "…"` (or `--resume --wave <wave> --answer`) is accepted while the wave is still running (L407, `docs/orchestrate.md`): the answer goes into a queue the live wave reads, the lot takes a slot again under the wave's cap, plays in its own worktree when the mode is on, and never runs beside another session of the same repository; the registry counts it at once, so « créneaux libres » stays right. Before, the lead had to wait for the end of the wave. The check of 1.29.0 against commits on the main branch of a sibling repository (L456) now flags only the commits that cite a lot of the wave (L458): a delivery's tag bump in a GitOps repository, the lead's plan commits and the lots of another live wave launched later no longer hand a lot back — three false positives on 30-10. `cadence session context` reads `lead.contextThreshold` from the lead's folder, the first parent holding `lead:` or `priority:`, instead of the current folder (L457).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",
@@ -31,6 +31,7 @@
31
31
  "docs/skills.md",
32
32
  "docs/cadence-hud.md",
33
33
  "docs/releasing.md",
34
+ "docs/qualite.md",
34
35
  "docs/overview.md",
35
36
  "docs/whats-new.md",
36
37
  "docs/fonctionnement.md",
@@ -62,16 +63,23 @@
62
63
  "service": "node scripts/mise-en-service.mjs",
63
64
  "test": "vitest run",
64
65
  "typecheck": "tsc --noEmit",
66
+ "qualite": "node scripts/qualite.mjs",
67
+ "qualite:cliquet": "node scripts/qualite.mjs --ecrire",
65
68
  "eval:plugin": "claude plugin eval . --max-cost-usd 5 --no-publish",
66
69
  "prepare": "tsc -p tsconfig.build.json",
67
- "prepublishOnly": "npm run typecheck && npm test"
70
+ "prepublishOnly": "npm run typecheck && npm run qualite && npm test"
68
71
  },
69
72
  "dependencies": {
70
73
  "yaml": "^2.5.0"
71
74
  },
72
75
  "devDependencies": {
73
76
  "@types/node": "^22.0.0",
77
+ "eslint": "^9.39.5",
78
+ "eslint-plugin-sonarjs": "^3.0.7",
79
+ "jscpd": "^4.3.0",
80
+ "knip": "^5.88.1",
74
81
  "typescript": "^5.5.0",
82
+ "typescript-eslint": "^8.72.1",
75
83
  "vitest": "^2.0.0"
76
84
  },
77
85
  "repository": {