@sylad/cadence 1.34.1 → 1.36.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/NOTICE +28 -0
- package/README.md +1 -1
- package/agents/qa-reviewer.md +5 -4
- package/agents/spec-writer.md +2 -0
- package/agents/ux-reviewer.md +28 -0
- package/dist/cli.js +13 -29
- package/dist/config.js +8 -21
- package/dist/corrections-sans-code.js +12 -0
- package/dist/deliver-ci.js +105 -0
- package/dist/deliver.js +61 -79
- package/dist/gate-config.js +47 -0
- package/dist/orchestrate/accept.js +5 -2
- package/dist/orchestrate/ajout.js +186 -0
- package/dist/orchestrate/baseline.js +57 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +118 -140
- package/dist/orchestrate/cycle.js +69 -55
- package/dist/orchestrate/depot.js +33 -0
- package/dist/orchestrate/gate-notes.js +27 -0
- package/dist/orchestrate/gate-parts.js +135 -0
- package/dist/orchestrate/gate-place.js +66 -0
- package/dist/orchestrate/lot-budget.js +50 -0
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/retrait.js +37 -0
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/state.js +48 -10
- package/dist/orchestrate/tiers.js +9 -0
- package/dist/orchestrate/workers.js +31 -4
- package/dist/plan-commit.js +4 -2
- package/dist/plan-uncommitted.js +26 -0
- package/dist/release.js +9 -7
- package/dist/remote-gate.js +278 -0
- package/dist/state.js +4 -0
- package/dist/verify-gate-remote.js +55 -0
- package/dist/verify-gate.js +37 -28
- package/docs/corrections.md +4 -0
- package/docs/deliver.md +22 -2
- package/docs/lead.md +3 -1
- package/docs/news.md +2 -4
- package/docs/orchestrate.md +22 -0
- package/docs/overview.md +1 -0
- package/docs/raf.md +3 -3
- package/docs/releasing.md +17 -8
- package/docs/remote-gate.md +89 -0
- package/docs/skills.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +3 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +3 -1
|
@@ -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.
|
|
11
|
+
"version": "1.36.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.
|
|
4
|
+
"version": "1.36.0",
|
|
5
5
|
"author": { "name": "Sylvain Ladoire" },
|
|
6
6
|
"homepage": "https://github.com/Sylad/cadence",
|
|
7
7
|
"repository": "https://github.com/Sylad/cadence",
|
package/NOTICE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
cadence — third-party attribution
|
|
2
|
+
|
|
3
|
+
The "Delivery grid" section of agents/ux-reviewer.md lists AI-tic rules that are
|
|
4
|
+
reworded (not copied) from the rules of the work `detect.mjs`. Its parts ported
|
|
5
|
+
from Impeccable are under the Apache License, Version 2.0 (Apache-2.0,
|
|
6
|
+
https://www.apache.org/licenses/LICENSE-2.0); its own rules are under MIT.
|
|
7
|
+
|
|
8
|
+
Work: scripts/detect.mjs (static detection of AI-generated interface tics)
|
|
9
|
+
Repository: https://github.com/ryanthedev/design-for-ai
|
|
10
|
+
Commit read: 37d7ae63d1afa45eb57ec90bc246cef6dd60c033 (shallow clone, read only, 2026-11-01)
|
|
11
|
+
Licence: per rule. The header of scripts/detect.mjs limits Apache-2.0 to the
|
|
12
|
+
parts ported from Impeccable (https://github.com/pbakaus/impeccable,
|
|
13
|
+
Copyright Impeccable contributors, Apache-2.0) with modifications:
|
|
14
|
+
nested-cards, gradient-text, dark-glow, overused-font and
|
|
15
|
+
marketing-buzzword. It names purple-triplet and
|
|
16
|
+
unmodified-shadcn-tokens as new rules of design-for-ai, under MIT
|
|
17
|
+
(.claude-plugin/plugin.json declares "license": "MIT"; the
|
|
18
|
+
repository carries no LICENSE file).
|
|
19
|
+
Original authors: the design-for-ai authors (ryanthedev); Impeccable contributors
|
|
20
|
+
for the ported parts.
|
|
21
|
+
|
|
22
|
+
Of the 16 rules of detect.mjs, seven are kept and reworded (nested-cards,
|
|
23
|
+
gradient-text, purple-triplet, unmodified-shadcn-tokens, dark-glow,
|
|
24
|
+
overused-font, marketing-buzzword) and nine are not. The rules of the agent are
|
|
25
|
+
written in our own words with their own thresholds and charter exceptions; the
|
|
26
|
+
script is neither bundled nor run, and no text of the original is copied.
|
|
27
|
+
|
|
28
|
+
The package itself stays under the MIT licence (see LICENSE).
|
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
|
|
|
49
49
|
|
|
50
50
|
- [docs/raf.md](docs/raf.md): the plan, its commands and its file.
|
|
51
51
|
- [docs/orchestrate.md](docs/orchestrate.md): waves, budgets, and [the edge-case probe](docs/orchestrate.md#the-edge-case-probe-orchestrateprobe-l497).
|
|
52
|
-
- [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect.
|
|
52
|
+
- [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect; [docs/remote-gate.md](docs/remote-gate.md): the gate on another machine.
|
|
53
53
|
- [docs/spec.md](docs/spec.md): screen and back-end sheets.
|
|
54
54
|
- [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
|
|
55
55
|
- [docs/lead.md](docs/lead.md): the lead workflow, and the `livreur` agent that delivers a ready lot for it.
|
package/agents/qa-reviewer.md
CHANGED
|
@@ -40,8 +40,9 @@ missing, or the URL does not answer, say so and stop.
|
|
|
40
40
|
*suspect* at most. Return a DRAFT expectations file as text, for the human to correct: you do not
|
|
41
41
|
write it into the repository. Say plainly that without expectations an empty state cannot be told
|
|
42
42
|
from a normal one.
|
|
43
|
-
4. **Open each page in a real browser** (
|
|
44
|
-
and **390 px** wide.
|
|
43
|
+
4. **Open each page in a real browser** (your own Playwright context, see below), at **1440 px**
|
|
44
|
+
and **390 px** wide.
|
|
45
|
+
The browser is yours alone: open your own browser context, never the browser or the tabs another session already holds (a Playwright server shared with another agent, a tab left open by a previous pass). Launch a persistent context with `launchPersistentContext` from a Node script that you write outside the repository and run with Bash, in a `userDataDir` reserved for this agent, under `$HOME/.cache/cadence-qa/` (outside the repository), named after the app (host and port of the base URL, every character that is not a letter, a digit or a dash replaced by a dash, so `localhost:4200` gives `localhost-4200`), kept between passes on that app; launch it first, and do not test for a lock file before (two passes would both see none): only when the launch fails because that directory is open in another browser, use a directory suffixed with your process id instead (a suffixed profile is single-use: delete it at the end of the pass); a `SingletonLock` left by a browser that closed abruptly is a link `host-pid` whose pid is no longer running: remove that link and keep the named profile, so two QA passes running together, on two apps or on the same one, never share a profile. The `browser_*` tools of the Playwright MCP server take no `userDataDir` and use the one browser of the session that started you, which other agents use too: do not use them. The script loads `playwright` from a package already on the machine — the repository does not depend on it and the temporary directory has no `node_modules`, so a bare `require('playwright')` fails there: resolve it with `createRequire` (or `require` on an absolute path) from, in this order, the directory `CADENCE_PLAYWRIGHT` names when that variable is set, the app repository's own `node_modules/playwright` when the app depends on it, the package of the Playwright MCP server the wave started (`ps -eo args` shows `<dir>/node_modules/.bin/playwright-mcp`: load `<dir>/node_modules/playwright`), else the newest `~/.npm/_npx/*/node_modules/playwright`. Launch Playwright's bundled Chromium first; when the launch fails because its executable is missing (`Executable doesn't exist`), relaunch with `channel: 'chrome'`, the installed Chrome the MCP server itself runs. Never install anything (`npm i`, `npx playwright install`, `npx -y`): it needs the network, and a version pulled that way may not find its browser. When no candidate loads and launches, the pass does not take place: say so under « Not verified » as a missing browser tool and stop — the `browser_*` tools are not a fallback. At the end, close your own context and nothing else: you neither read nor close a tab you did not open, nor the browser of another session. Walk within the bounded pass below. Let it settle: after `load`, wait a fixed few seconds, scroll through the
|
|
45
46
|
page (lazy images), wait again — never for network idle, which streams and polling never reach.
|
|
46
47
|
Then measure:
|
|
47
48
|
- read the page as text first: its rendered text and structure (`innerText`, or the
|
|
@@ -49,7 +50,7 @@ missing, or the URL does not answer, say so and stop.
|
|
|
49
50
|
you listened to. Compare that text with the expectations, and take a capture only for a gap —
|
|
50
51
|
a `shows:` line unmet, a `never:` text found, a failed call — as evidence of that gap: a page
|
|
51
52
|
that matches its expectations gets none;
|
|
52
|
-
- browser state, once before the first page, in a profile already used (a persistent context, not a fresh one —
|
|
53
|
+
- browser state, once before the first page, in a profile already used (a persistent context, not a fresh one — the `userDataDir` of your own browser context, see above, never the user's own browser profile): compare the bundle the page loaded (its script URL) with the one `index.html` references, re-read without cache — a returning visitor still holds the old one, so a difference is stated in the report — then clear the cache and measure;
|
|
53
54
|
- the expected content is present and non-empty — name the selector or the text found and its
|
|
54
55
|
count (`.player-card` ×14), not "the list looks fine";
|
|
55
56
|
- no `never:` text on screen, and no other error or missing-data message;
|
|
@@ -166,7 +167,7 @@ A short report:
|
|
|
166
167
|
returned". It is the last line of the report.
|
|
167
168
|
|
|
168
169
|
Give screenshots and snapshots the absolute path of the capture directory your brief names when it names one (a wave's Playwright server resolves a bare name against the repository, not its output directory); with no such directory in the brief, a path under `.playwright-mcp/` (e.g. `.playwright-mcp/page-home.png`: the Playwright MCP resolves a bare file name against the repository, where it would land at the root and dirty the working tree) and no other path inside the repository (list them in the report if git does not ignore that folder). Other temporary files go in a temporary directory outside the repository, or in the one
|
|
169
|
-
the caller names; remove them, or list their paths in the report — except the QA browser profile, which is kept for the next pass. The working tree is left as you
|
|
170
|
+
the caller names; remove them, or list their paths in the report — except the QA browser profile named after the app, which is kept for the next pass (a profile suffixed with a process id is removed). The working tree is left as you
|
|
170
171
|
found it.
|
|
171
172
|
|
|
172
173
|
## Short reply
|
package/agents/spec-writer.md
CHANGED
|
@@ -11,6 +11,8 @@ Follow the brief you are given. The method:
|
|
|
11
11
|
1. **Read the charter first.** The project's CLAUDE.md, its CSS and variables, the neighbouring components and pages, `docs/qa/expectations.md`, the routes and API contracts already present. The mockup must look like the product: reuse what exists, invent nothing when an equivalent exists.
|
|
12
12
|
2. **List the states before drawing**: empty, loading, error, and loaded when there is data. Each is an addressable section `#etat-<état>` of one static `maquette.html` (HTML + CSS, no business JavaScript). Each field of the sheet carries `data-testid="<field id>"`.
|
|
13
13
|
**Write the texts before drawing and render them in the mockup**, with the structure the reviewer will check: an error message says what, why, how to fix; an empty or loading state says what it is, why it is empty, how to start; an action label is a verb that names the result, never “OK” or “Validate” alone; the confirmation of a destructive action names the object and the consequence and its buttons are named by the action; one word per notion, in the sheet and in the mockup. Draw one primary element per state, one visible label per field, and take every colour, size and spacing from the product’s CSS variables (a product with no CSS variables has none to take: follow its existing values). A word or a message already in the product is kept as it is (reuse what exists).
|
|
14
|
+
**Colours you add: semantic tokens, `on-*`, contrast.** Start from the charter's colour tokens and their text tokens (CSS variables). For every colour the screen adds, name a semantic token named by its role (`primary`, `success`, `warning`, `danger`, `surface`…) and its text token `on-<role>`; a colour added without its `on-*` token is a defect of the spec. The values come from a ramp drawn from the charter's brand colour (same hue, stepped lightness): the agent imports no external palette. A role already covered by a token of the charter keeps that token: reuse what exists, create no duplicate token, and only measure the existing pair. Write the measure in `contraste.md`, in the spec folder, committed with the sheet: `contraste.md` has two lists: the tokens created, and the existing pairs measured (an existing token appears only in the second), with one line per token / on-token pair: both values, the computed ratio (WCAG 2.2, relative luminance) and `ok` or `non`. Every pair is at least 4.5:1 (a pair at 4.49:1 is `non`, a pair at 4.5:1 is `ok`); fix a pair that falls short: change the step of the ramp, never the text size. A product with no CSS variable and no brand colour gets a mockup that follows its existing values: invent no ramp, measure the existing values in `contraste.md`, and write the absence of a coloured charter in the sheet. On a product that has a charter, never propose, draw or blame a generic style from a catalogue; with no charter, follow the existing values and say so, and choose no style.
|
|
15
|
+
|
|
14
16
|
3. **Render and look.** Capture each state at **1440 px** and **390 px** wide with the browser tool (Playwright), named `maquette-<état>-<largeur>.png`, with the absolute capture-directory path your brief gives in front of the name (a bare name lands in the repository), and look at every capture before keeping it: a mockup you did not look at is not checked. Copy the kept captures into the spec folder.
|
|
15
17
|
Before keeping a state, check its sample data: compute the weekday of every date shown (`date -d`), never write it from memory.
|
|
16
18
|
4. **Write the sheet** in the format of the template of the brief, and run `cadence spec check` on it until it passes. Every rule is `sourcee` (say where it comes from) or `hypothese` (say what would change if it were wrong, in `si_faux`). Never dress a guess as a source.
|
package/agents/ux-reviewer.md
CHANGED
|
@@ -116,3 +116,31 @@ session and a call by hand without it have none.
|
|
|
116
116
|
- If you cannot write the file, say so on the first line of the reply and give the whole report in the reply, as if
|
|
117
117
|
the brief had no report file: a verdict whose evidence nobody can read is lost.
|
|
118
118
|
- With no `Report file:` line in the brief, give the complete report in your reply.
|
|
119
|
+
|
|
120
|
+
## Delivery grid
|
|
121
|
+
|
|
122
|
+
This section applies in every case, with or without a `Report file:` line, to the UX step of a visible lot and to the review of an existing screen. It does not apply in mockup mode or conformity mode, whose captures stay at 1440 px and 390 px. With a `Report file:` line, each point is a line of the checklist of the report file, ticked or not, with the measure or the capture that supports it; the short reply does not repeat the list. Without a `Report file:` line (the orchestrated `ux` step), the structured report has no checklist and its schema is unchanged: a point that could not be checked goes in `nonVerifie` (`grille : focus visible non vérifié`), and the `verdict` line ends with the tally of the grid and the tics line, e.g. `… — grille 5/5 — tics d’IA : 0 constat`, so the lead knows the grid was passed. In both cases a point that fails is a finding on the blocking / major / minor scale of the Method section, no severity of its own (a focus that is not visible is ranked like any other accessibility gap).
|
|
123
|
+
|
|
124
|
+
Points, at widths **375, 768, 1024 and 1440 px**:
|
|
125
|
+
|
|
126
|
+
- visible focus on every interactive element;
|
|
127
|
+
- `prefers-reduced-motion` respected by animations and transitions;
|
|
128
|
+
- `cursor: pointer` on what is clickable;
|
|
129
|
+
- no emoji used as an icon;
|
|
130
|
+
- no horizontal scroll at any of the four widths.
|
|
131
|
+
|
|
132
|
+
A width that cannot be rendered goes in `nonVerifie`, not ticked.
|
|
133
|
+
|
|
134
|
+
**AI tics.** This is a static check: apply these rules while reading the diff of the lot, one by one; never run `detect.mjs` and use no tool beyond those above. They are reworded from the rules of `detect.mjs`, a static detector of 16 rules (Apache-2.0 for the parts it ports from Impeccable, MIT for its own rules purple-triplet and unmodified-shadcn-tokens; work, commit, licence and author in the NOTICE file of the package): seven of the 16 are kept, the nine others are not (see below). Each tic found is a numbered finding T1, T2… with its file, line, named rule and severity. A tic with no effect on the task or on legibility is minor and stays a note of the lot; a tic that harms legibility or slows the user follows the existing scale (major, or blocking if a task or accessibility fails), no severity of its own. A tic that is a choice of the product's charter is not a finding: the charter wins over the rules. The checklist (or, without a report file, the end of the `verdict` line) carries `tics d’IA : 0 constat` when none is found, and `tics d’IA : non applicable` when the lot has no front file.
|
|
135
|
+
|
|
136
|
+
- `tic-nested-cards`: a card (border or shadow, rounded or filled) inside another card, which flattens the hierarchy (Gestalt: one grouping level per idea);
|
|
137
|
+
- `tic-gradient-text`: text filled with a gradient (`background-clip: text`); measure the contrast (WCAG 1.4.3) at the lightest point of the gradient;
|
|
138
|
+
- `tic-purple-triplet`: the accent values `#6366F1`, `#8B5CF6` or `#A855F7` (or Tailwind `indigo`, `violet`, `purple` at 500) used as the accent of a product whose charter does not list them; flag only when it is not the charter's choice;
|
|
139
|
+
- `tic-unmodified-shadcn-tokens`: `--radius: 0.5rem` or `0.625rem` together with at least two of the stock shadcn colour values (e.g. `222.2 84% 4.9%`, `210 40% 96.1%`), i.e. the library defaults never replaced by the charter's tokens; flag only when the charter defines its own tokens and they are absent;
|
|
140
|
+
- `tic-dark-glow`: on a dark page, a coloured `box-shadow` (channels more than 30 apart) with a blur above 4 px; check the contrast of the text it lies behind;
|
|
141
|
+
- `tic-overused-font`: Inter, Roboto, Open Sans, Lato, Montserrat, Arial, Helvetica, Fraunces, Geist, Mona Sans, Plus Jakarta Sans, Space Grotesk, Recoleta or Instrument Sans/Serif as the primary face; flag only when it is not the charter's choice (a charter that uses Inter makes Inter correct);
|
|
142
|
+
- `tic-marketing-buzzword`: stock marketing phrases in interface text ("streamline your", "seamless experience", "best-in-class", "cutting-edge", "unleash the power"…), against the Nielsen heuristic « match between the system and the real world »; the voice of the charter wins.
|
|
143
|
+
|
|
144
|
+
Not kept from the 16 rules of `detect.mjs`, nine: `icon-tile-stack`, `hero-eyebrow-chip`, `repeated-section-kickers`, `uniform-low-opacity-shadow` (taste of composition, no named rule or measure behind them); `monotonous-spacing` (a single spacing scale is what a token system asks for, so the rule contradicts a charter); `em-dash-overuse`, `numbered-section-markers`, `aphoristic-cadence`, `theater-slop-phrase` (detectors of prose style, not of usability, and written for English marketing pages). The former rules `tic-violet-gradient`, `tic-side-border`, `tic-blur-panel`, `tic-glow-shadow` and `tic-bounce-easing`, which `detect.mjs` does not carry, were removed.
|
|
145
|
+
|
|
146
|
+
On a product that has a charter, never propose, draw or blame a generic style from a catalogue; with no charter, follow the existing values and say so, and choose no style.
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './veri
|
|
|
12
12
|
import { verifyGate } from './verify-gate.js';
|
|
13
13
|
import { ganttData, renderGantt } from './gantt.js';
|
|
14
14
|
import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
|
|
15
|
+
import { printUncommittedPlan } from './plan-uncommitted.js';
|
|
15
16
|
import { dirtyPaths, gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
|
|
16
17
|
import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
|
|
17
18
|
import { gateRefusal, pushRefusal, UNGATED_ENV } from './push-guard.js';
|
|
@@ -31,6 +32,7 @@ import { INTEGRATION_LOCK } from './orchestrate/integrate.js';
|
|
|
31
32
|
import { mainTreeWorkers } from './orchestrate/main-tree.js';
|
|
32
33
|
import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './orchestrate/registry.js';
|
|
33
34
|
import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
|
|
35
|
+
import { renderTestsSansCode, testsSansCode } from './corrections-sans-code.js';
|
|
34
36
|
import { bugCodeOf, bugCodePasses, classifyAll, classifySous, measure, ollamaClassifier, ollamaSousClassifier, readCache, readJournals, readRuns, readSousCache, renderReport, sampleKeys, serializeCache, serializeSousCache } from './corrections.js';
|
|
35
37
|
import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
|
|
36
38
|
import { findAcceptation, parseAcceptation, redAcceptations, resealAcceptation, resealForGreen, runAcceptation, setVert } from './orchestrate/accept.js';
|
|
@@ -478,9 +480,7 @@ function dispatch(argv, io) {
|
|
|
478
480
|
if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
|
|
479
481
|
io.out(`${lot.id} est visible : écrire l'entrée : cadence news new ${lot.id}`);
|
|
480
482
|
}
|
|
481
|
-
|
|
482
|
-
if (uncommitted)
|
|
483
|
-
io.out(uncommitted);
|
|
483
|
+
printUncommittedPlan(io.out, plan, root, lot.id, planConfig?.commit);
|
|
484
484
|
}
|
|
485
485
|
return 0;
|
|
486
486
|
}
|
|
@@ -508,9 +508,7 @@ function dispatch(argv, io) {
|
|
|
508
508
|
else if (!dry) {
|
|
509
509
|
if (plan.republishes)
|
|
510
510
|
io.err('raf: plan.onWrite republie le fichier du plan sans les lots archivés : le générateur de la copie publiée doit aussi lire .cadence/archive/*.yaml (clé `lots`), sinon l\'historique livré disparaît de la feuille de route publique');
|
|
511
|
-
|
|
512
|
-
if (uncommitted)
|
|
513
|
-
io.out(uncommitted);
|
|
511
|
+
printUncommittedPlan(io.out, plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
|
|
514
512
|
}
|
|
515
513
|
return 0;
|
|
516
514
|
}
|
|
@@ -617,7 +615,7 @@ function dispatch(argv, io) {
|
|
|
617
615
|
}
|
|
618
616
|
case 'ux':
|
|
619
617
|
case 'review':
|
|
620
|
-
return gate(command, rest, loadPlan, root, today, io, !!values.verbose);
|
|
618
|
+
return gate(command, rest, loadPlan, root, today, io, { verbose: !!values.verbose, commit: planConfig?.commit });
|
|
621
619
|
case 'commits': {
|
|
622
620
|
need(1, 'commits <lot>');
|
|
623
621
|
const plan = loadPlan();
|
|
@@ -867,11 +865,11 @@ function dispatch(argv, io) {
|
|
|
867
865
|
}
|
|
868
866
|
}
|
|
869
867
|
const GATES = {
|
|
870
|
-
ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active' },
|
|
871
|
-
review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active' },
|
|
868
|
+
ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active', action: PLAN_ACTIONS.uxRecorded },
|
|
869
|
+
review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active', action: PLAN_ACTIONS.reviewRecorded },
|
|
872
870
|
};
|
|
873
871
|
/** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
|
|
874
|
-
function gate(kind, rest, loadPlan, root, today, io,
|
|
872
|
+
function gate(kind, rest, loadPlan, root, today, io, opts) {
|
|
875
873
|
if (rest.length < 1)
|
|
876
874
|
throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
|
|
877
875
|
const plan = loadPlan();
|
|
@@ -896,7 +894,9 @@ function gate(kind, rest, loadPlan, root, today, io, verbose) {
|
|
|
896
894
|
plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null), neighbours?.repos.length ? repoShas(plan, neighbours.repos, rest[0]) : undefined);
|
|
897
895
|
}
|
|
898
896
|
plan.save();
|
|
899
|
-
touchedLot(plan, rest[0], verbose, io);
|
|
897
|
+
touchedLot(plan, rest[0], opts.verbose, io);
|
|
898
|
+
// Comme `raf done` (L371) : le plan et la copie publiée que `plan.onWrite` vient de régénérer partent dans le même commit (L509).
|
|
899
|
+
printUncommittedPlan(io.out, plan, root, rest[0], opts.commit, GATES[kind].action);
|
|
900
900
|
return 0;
|
|
901
901
|
}
|
|
902
902
|
/** Un titre de lot n'est jamais rendu en entier par défaut (L453) : ~300 caractères répétés à chaque commande, chaque commit de plan. */
|
|
@@ -915,23 +915,6 @@ function knownLot(plan, id, subtask) {
|
|
|
915
915
|
throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
|
|
916
916
|
return lot;
|
|
917
917
|
}
|
|
918
|
-
/**
|
|
919
|
-
* `raf done` écrit le plan et ne le commite jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
|
|
920
|
-
* que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
|
|
921
|
-
*/
|
|
922
|
-
function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
|
|
923
|
-
const repo = gitRoot(root);
|
|
924
|
-
if (!repo)
|
|
925
|
-
return null;
|
|
926
|
-
// Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
|
|
927
|
-
const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
|
|
928
|
-
if (paths.length === 0)
|
|
929
|
-
return null;
|
|
930
|
-
const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
|
|
931
|
-
const message = planCommitMessage(template, { lot, action });
|
|
932
|
-
const list = paths.map(quote).join(' ');
|
|
933
|
-
return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
|
|
934
|
-
}
|
|
935
918
|
function describe(l, commits) {
|
|
936
919
|
const bits = [`${l.estimate} j`];
|
|
937
920
|
if (commits)
|
|
@@ -1624,7 +1607,8 @@ async function corrections(rest, values, io) {
|
|
|
1624
1607
|
}
|
|
1625
1608
|
const m = measure(data, cache);
|
|
1626
1609
|
const bugCode = bugCodeOf(bugCodePasses(data.passes, cache), sousCache);
|
|
1627
|
-
|
|
1610
|
+
const sansCode = testsSansCode(data.passes);
|
|
1611
|
+
io.out(values.json ? JSON.stringify({ ...m, testsSansCode: sansCode, ...(bugCode ? { bugCode } : {}) }, null, 2) : [renderReport(m, bugCode), ...renderTestsSansCode(sansCode)].join('\n'));
|
|
1628
1612
|
return 0;
|
|
1629
1613
|
}
|
|
1630
1614
|
/**
|
package/dist/config.js
CHANGED
|
@@ -2,10 +2,11 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
import { isDay } from './dates.js';
|
|
5
|
+
import { readGate } from './gate-config.js';
|
|
5
6
|
import { DEFAULT_TEST_PATTERNS } from './testgate.js';
|
|
6
7
|
import { gitRoot } from './git.js';
|
|
7
8
|
import { planCommitProblem } from './plan-commit.js';
|
|
8
|
-
import {
|
|
9
|
+
import { checkTestReplay, readGateTest } from './orchestrate/gate-parts.js';
|
|
9
10
|
import { booleanKey } from './yaml-keys.js';
|
|
10
11
|
import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
|
|
11
12
|
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit'];
|
|
@@ -374,7 +375,9 @@ export function readOrchestrateConfig(file) {
|
|
|
374
375
|
throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
|
|
375
376
|
config.accept = o.accept.map((x) => x.trim());
|
|
376
377
|
}
|
|
377
|
-
|
|
378
|
+
if (o.test != null)
|
|
379
|
+
config.test = readGateTest(o.test, bad);
|
|
380
|
+
for (const k of ['start', 'verdict', 'testReplay', 'build', 'accept']) {
|
|
378
381
|
if (k === 'accept' && Array.isArray(o.accept))
|
|
379
382
|
continue;
|
|
380
383
|
if (o[k] == null)
|
|
@@ -383,31 +386,15 @@ export function readOrchestrateConfig(file) {
|
|
|
383
386
|
throw bad(`${k} : commande non vide attendue`);
|
|
384
387
|
config[k] = o[k].trim();
|
|
385
388
|
}
|
|
386
|
-
|
|
387
|
-
throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
|
|
388
|
-
if (config.testReplay && config.test && isComposedGate(config.test))
|
|
389
|
-
throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
|
|
389
|
+
checkTestReplay(config.test, config.testReplay, bad);
|
|
390
390
|
if (o.worktreeDeps != null) {
|
|
391
391
|
const deps = o.worktreeDeps;
|
|
392
392
|
if (!Array.isArray(deps) || !deps.length || deps.some((x) => typeof x !== 'string' || !x.trim() || isAbsolute(x) || x.split(/[\\/]/).includes('..')))
|
|
393
393
|
throw bad('worktreeDeps : liste de chemins relatifs au dépôt, sans « .. » (node_modules, .venv…)');
|
|
394
394
|
config.worktreeDeps = deps.map((x) => x.trim().replace(/\/$/, ''));
|
|
395
395
|
}
|
|
396
|
-
if (o.gate != null)
|
|
397
|
-
|
|
398
|
-
throw bad('gate : objet attendu (gate: { readsPlan: true })');
|
|
399
|
-
for (const k of Object.keys(o.gate))
|
|
400
|
-
if (k !== 'readsPlan')
|
|
401
|
-
throw bad(`gate.${k} inconnu (attendu : readsPlan)`);
|
|
402
|
-
const r = o.gate.readsPlan;
|
|
403
|
-
const msg = 'gate.readsPlan : true, false ou liste de chemins de tests relatifs au dépôt, sans « .. »';
|
|
404
|
-
if (r === true)
|
|
405
|
-
config.gate = { readsPlan: true };
|
|
406
|
-
else if (Array.isArray(r) && r.length && r.every((x) => typeof x === 'string' && x.trim() && !isAbsolute(x) && !x.split(/[\\/]/).includes('..')))
|
|
407
|
-
config.gate = { readsPlan: r.map((x) => x.trim()) };
|
|
408
|
-
else if (r !== false)
|
|
409
|
-
throw bad(msg);
|
|
410
|
-
}
|
|
396
|
+
if (o.gate != null)
|
|
397
|
+
config.gate = readGate(o.gate, bad);
|
|
411
398
|
if (o.maquette != null) {
|
|
412
399
|
if (typeof o.maquette !== 'boolean')
|
|
413
400
|
throw bad('maquette : true ou false attendu');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Les passes lancées sur des tests rouges et terminées sans changement de code (L510) : la part des corrections que la porte a demandées pour rien.
|
|
2
|
+
export function testsSansCode(passes) {
|
|
3
|
+
const onTests = passes.filter((p) => p.findings.length === 0);
|
|
4
|
+
return { passes: onTests.length, sansCode: onTests.filter((p) => p.commits.length === 0).length };
|
|
5
|
+
}
|
|
6
|
+
const plural = (n, one, many) => `${n} ${n < 2 ? one : many}`;
|
|
7
|
+
/** La ligne du rapport lisible, vide sans passe de ce genre. À part de `renderReport` : le relevé de référence du 10-10 reproduit ce rapport mot pour mot. */
|
|
8
|
+
export function renderTestsSansCode(t) {
|
|
9
|
+
if (t.passes === 0)
|
|
10
|
+
return [];
|
|
11
|
+
return [`${plural(t.passes, 'passe lancée', 'passes lancées')} sur des tests, ${plural(t.sansCode, 'terminée', 'terminées')} sans changement de code (${Math.round((100 * t.sansCode) / t.passes)} %, passes sans commit).`, ''];
|
|
12
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { isPlanOnly } from './audit.js';
|
|
3
|
+
import { isAncestor, readCommits } from './git.js';
|
|
4
|
+
import { appendStep, lastDelivery } from './state.js';
|
|
5
|
+
const POLL_CI = 15_000;
|
|
6
|
+
const CI_APPEAR = 300_000;
|
|
7
|
+
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
8
|
+
/** « HH:MM:SS » UTC d'un instant en millisecondes : l'heure qui ouvre chaque ligne d'étape de la sortie. */
|
|
9
|
+
const clockTime = (ms) => new Date(ms).toISOString().slice(11, 19);
|
|
10
|
+
export const stamped = (deps, line) => `${clockTime(deps.now())} ${line}`;
|
|
11
|
+
/**
|
|
12
|
+
* Livraison précédente quand la CI du sha n'a pas à être attendue : un seul fichier hors plan dans précédente..sha suffit à
|
|
13
|
+
* l'exiger. Null (CI attendue) sans plan, sans livraison précédente, si elle n'est pas un ancêtre du sha ou l'égale, si elle n'est pas un sha complet (ligne du journal écrite à la main), si la
|
|
14
|
+
* lecture échoue ou rend aucun commit : on ne saute la CI que sur une preuve (au moins un commit, tous plan seul).
|
|
15
|
+
*/
|
|
16
|
+
export function planOnlySince(ctx, sha) {
|
|
17
|
+
if (!ctx.plan)
|
|
18
|
+
return null;
|
|
19
|
+
const prev = lastDelivery(ctx.state);
|
|
20
|
+
if (!prev || !/^[0-9a-f]{40,64}$/.test(prev) || prev === sha || !isAncestor(ctx.root, prev, sha))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const commits = readCommits(ctx.root, { range: `${prev}..${sha}` });
|
|
24
|
+
return commits.length > 0 && commits.every((c) => isPlanOnly(c.sha, ctx.plan, ctx.root)) ? prev : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Un tour de `gh run list` : message d'échec, null si tous les runs sont verts, undefined tant qu'il faut attendre. */
|
|
31
|
+
function pollOnce(ctx, deps, sha, start, state) {
|
|
32
|
+
let runs = [];
|
|
33
|
+
let error = null;
|
|
34
|
+
try {
|
|
35
|
+
runs = deps.gh(sha);
|
|
36
|
+
state.failingSince = null;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
// Erreur passagère (réseau, quota d'API) : on réessaie ; persistante, on abandonne avec sa cause.
|
|
40
|
+
error = e.message;
|
|
41
|
+
state.failingSince ??= deps.now();
|
|
42
|
+
}
|
|
43
|
+
const elapsed = deps.now() - start;
|
|
44
|
+
if (error !== null) {
|
|
45
|
+
if (deps.now() - state.failingSince >= CI_APPEAR || elapsed >= ctx.config.ciTimeout * 1000)
|
|
46
|
+
return `gh run list en échec : ${error}`;
|
|
47
|
+
ctx.err(` gh run list en échec, nouvel essai : ${error}`);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (runs.length === 0) {
|
|
51
|
+
return elapsed >= CI_APPEAR ? `aucun run CI pour ce sha après ${CI_APPEAR / 60_000} min (le commit poussé est-il celui que la CI construit ?)` : undefined;
|
|
52
|
+
}
|
|
53
|
+
const pending = runs.filter((r) => r.status !== 'completed');
|
|
54
|
+
const summary = `${runs.length} run(s), ${runs.length - pending.length} terminé(s)`;
|
|
55
|
+
if (summary !== state.summary)
|
|
56
|
+
ctx.out(` ${summary}`);
|
|
57
|
+
state.summary = summary;
|
|
58
|
+
if (pending.length > 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const bad = runs.filter((r) => !CI_OK.has(r.conclusion));
|
|
61
|
+
return bad.length ? `CI en échec : ${bad.map((r) => `${r.name} (${r.conclusion})`).join(', ')}` : null;
|
|
62
|
+
}
|
|
63
|
+
/** Message d'échec, ou null quand les runs GitHub du sha sont tous verts. */
|
|
64
|
+
export async function pollGithubCi(ctx, deps, sha) {
|
|
65
|
+
const { ciTimeout } = ctx.config;
|
|
66
|
+
ctx.out(stamped(deps, `→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`));
|
|
67
|
+
const start = deps.now();
|
|
68
|
+
const state = { summary: '', failingSince: null };
|
|
69
|
+
for (;;) {
|
|
70
|
+
const verdict = pollOnce(ctx, deps, sha, start, state);
|
|
71
|
+
if (verdict !== undefined)
|
|
72
|
+
return verdict;
|
|
73
|
+
if (deps.now() - start >= ciTimeout * 1000)
|
|
74
|
+
return `CI non terminée après ${ciTimeout} s`;
|
|
75
|
+
await deps.sleep(POLL_CI);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Journal des étapes (L516) : `step(etape, debut, ok)` ajoute une ligne à `deliveries-steps.log`. Une mesure, jamais bloquante :
|
|
80
|
+
* une écriture qui échoue est signalée une seule fois, la livraison continue. `lots` n'est lu qu'à la première écriture.
|
|
81
|
+
*/
|
|
82
|
+
export function stepJournal(ctx, deps, sha, lots) {
|
|
83
|
+
let known = null;
|
|
84
|
+
let warned = false;
|
|
85
|
+
return (etape, debut, ok) => {
|
|
86
|
+
try {
|
|
87
|
+
const fin = deps.now();
|
|
88
|
+
appendStep(ctx.state, {
|
|
89
|
+
lots: (known ??= lots()),
|
|
90
|
+
sha,
|
|
91
|
+
projet: ctx.plan?.project || basename(ctx.root),
|
|
92
|
+
etape,
|
|
93
|
+
debut: new Date(debut).toISOString(),
|
|
94
|
+
fin: new Date(fin).toISOString(),
|
|
95
|
+
duree_s: Math.round((fin - debut) / 1000),
|
|
96
|
+
resultat: ok ? 'ok' : 'echec',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
if (!warned)
|
|
101
|
+
ctx.err(`deliver : journal des étapes non écrit : ${e.message}`);
|
|
102
|
+
warned = true;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|