@artefact-ci/spec-trace 0.1.0 → 0.2.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/README.md +1 -1
- package/dist/cli.mjs +13 -5
- package/dist/core.mjs +49 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npx @artefact-ci/spec-trace --tests 'src/**/*.test.ts' # autres fichiers de tes
|
|
|
12
12
|
À lancer à la racine du dépôt (Node 24.11 ou plus récent, et git).
|
|
13
13
|
|
|
14
14
|
- Chaque scénario d'acceptation actif `NNN-US<story>-AS<n>` doit être cité par au moins un test ; une spec en `Draft` y échappe, sauf si on la vise. Aucun test ne doit citer un scénario absent ou retiré.
|
|
15
|
-
- Si `specs/product.yml` existe, chaque spec nomme ses exigences (`**Product Requirements**:`), et la commande affiche l'état des exigences, des epics et la couverture des parcours (`E03-J1`).
|
|
15
|
+
- Si `specs/product.yml` existe, chaque spec nomme ses exigences (`**Product Requirements**:`), et la commande affiche l'état des exigences, des epics et la couverture des parcours (`E03-J1`). Un epic peut porter une priorité (`priority: P1`, `P2`…, sur tous les epics ou sur aucun) : la commande refuse un niveau mal formé et un epic plus urgent qu'un epic dont il dépend, trie le tableau des epics par priorité et nomme le prochain epic à livrer (`Prochain epic`). Un epic retiré ne retient plus ceux qui en dépendent.
|
|
16
16
|
- Code de sortie 1 quand quelque chose manque.
|
|
17
17
|
|
|
18
18
|
## Fichiers de tests
|
package/dist/cli.mjs
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* Aucun test ne doit citer un scénario absent ou retiré. Code de sortie 1 sinon.
|
|
13
13
|
* Si specs/product.yml existe, chaque spec nomme les exigences qu'elle implémente
|
|
14
14
|
* (`**Product Requirements**:`), toutes présentes dans l'index ; sans argument, la commande
|
|
15
|
-
* affiche aussi l'état de chaque epic
|
|
16
|
-
* terminé exige un test par parcours (`E03-J1: …`).
|
|
15
|
+
* affiche aussi l'état de chaque epic (par priorité, puis le prochain à livrer), de chaque
|
|
16
|
+
* exigence et la couverture des parcours. Un epic terminé exige un test par parcours (`E03-J1: …`).
|
|
17
17
|
*/
|
|
18
18
|
import { execFileSync } from 'node:child_process';
|
|
19
19
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
@@ -97,12 +97,20 @@ function printRequirements(report, { hasIndex, showRows }) {
|
|
|
97
97
|
function printEpics(report, { showRows }) {
|
|
98
98
|
if (showRows && report.rows.length > 0) {
|
|
99
99
|
console.log(`\n## Epics (${REQUIREMENT_INDEX}) : ${report.ok ? 'OK' : 'INCOMPLET'}\n`);
|
|
100
|
-
|
|
101
|
-
console.log('
|
|
100
|
+
const prioritized = report.rows.some((row) => row.priority !== undefined);
|
|
101
|
+
console.log(`| Epic |${prioritized ? ' Priorité |' : ''} État | Avancement | Exigences | Dépend de |`);
|
|
102
|
+
console.log(`| ---- |${prioritized ? ' -------- |' : ''} ---- | ---------- | --------- | --------- |`);
|
|
102
103
|
for (const row of report.rows) {
|
|
103
104
|
const { state, progress } = epicCells(row);
|
|
104
|
-
|
|
105
|
+
const priority = prioritized ? ` P${row.priority} |` : '';
|
|
106
|
+
console.log(`| ${row.id} ${row.title} |${priority} ${state} | ${progress} | ${row.requirements.join(', ')} | ${row.dependsOn.join(', ')} |`);
|
|
105
107
|
}
|
|
108
|
+
const level = report.rows.find((row) => row.id === report.next[0])?.priority;
|
|
109
|
+
const allDone = report.rows.every((row) => row.state === 'Retired' || row.state === 'Implemented' || row.state === 'Shipped');
|
|
110
|
+
console.log(report.next.length === 0
|
|
111
|
+
? `\nProchain epic : aucun, ${allDone ? 'tous sont terminés' : 'aucun prêt (dépendances)'}.`
|
|
112
|
+
: `\nProchain epic${report.next.length > 1 ? ' (au choix)' : ''} : ` +
|
|
113
|
+
`${report.next.join(', ')}${level === undefined ? '' : ` (P${level})`}.`);
|
|
106
114
|
}
|
|
107
115
|
if (showRows && report.journeys.length > 0) {
|
|
108
116
|
console.log('\n| Parcours | Tests |');
|
package/dist/core.mjs
CHANGED
|
@@ -140,6 +140,7 @@ export function driftedFeatures(diff, changedPaths) {
|
|
|
140
140
|
const REQUIREMENT_ID = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
141
141
|
const EPIC_ID = /^E\d+$/;
|
|
142
142
|
const JOURNEY_ID = /^E\d+-J\d+$/;
|
|
143
|
+
const PRIORITY = /^P([1-9]\d*)$/;
|
|
143
144
|
function fieldsOf(value, what) {
|
|
144
145
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
145
146
|
throw new Error(`${what} : attendu un objet.`);
|
|
@@ -163,6 +164,16 @@ function textList(fields, key, owner) {
|
|
|
163
164
|
}
|
|
164
165
|
return value;
|
|
165
166
|
}
|
|
167
|
+
function priorityOf(fields, owner) {
|
|
168
|
+
const value = fields['priority'];
|
|
169
|
+
if (value === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
const level = typeof value === 'string' ? PRIORITY.exec(value)?.[1] : undefined;
|
|
172
|
+
if (!level) {
|
|
173
|
+
throw new Error(`${owner} : priority "${String(value)}" invalide (attendu : P1, P2…).`);
|
|
174
|
+
}
|
|
175
|
+
return Number(level);
|
|
176
|
+
}
|
|
166
177
|
function assertUnique(ids, what) {
|
|
167
178
|
const seen = new Set();
|
|
168
179
|
for (const id of ids) {
|
|
@@ -196,6 +207,26 @@ function findCycle(epics) {
|
|
|
196
207
|
}
|
|
197
208
|
return undefined;
|
|
198
209
|
}
|
|
210
|
+
// Une priorité sur tous les epics ou sur aucun ; un epic ne passe pas avant ce dont il dépend. Un
|
|
211
|
+
// epic retiré (toutes ses exigences le sont) n'a plus rien à livrer : sa priorité ne compte plus.
|
|
212
|
+
function assertPriorities(epics, retired) {
|
|
213
|
+
if (epics.every((epic) => epic.priority === undefined))
|
|
214
|
+
return;
|
|
215
|
+
const missing = epics.find((epic) => epic.priority === undefined);
|
|
216
|
+
if (missing) {
|
|
217
|
+
throw new Error(`L'epic ${missing.id} : priority absente, alors que d'autres epics en ont une (tous ou aucun).`);
|
|
218
|
+
}
|
|
219
|
+
const priorityOf = new Map(epics.map((epic) => [epic.id, epic.priority ?? 0]));
|
|
220
|
+
for (const epic of epics.filter((candidate) => !retired.has(candidate.id))) {
|
|
221
|
+
for (const dependency of epic.dependsOn.filter((id) => !retired.has(id))) {
|
|
222
|
+
const level = priorityOf.get(dependency) ?? 0;
|
|
223
|
+
if (level > (epic.priority ?? 0)) {
|
|
224
|
+
throw new Error(`L'epic ${epic.id} (P${epic.priority}) dépend de ${dependency} (P${level}), moins ` +
|
|
225
|
+
'prioritaire : il ne peut pas être livré avant lui.');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
199
230
|
/** Lit et valide l'index : identifiants, références entre epics et exigences, cycles. */
|
|
200
231
|
export function parseProductIndex(yamlText) {
|
|
201
232
|
let data;
|
|
@@ -262,6 +293,7 @@ export function parseProductIndex(yamlText) {
|
|
|
262
293
|
goal: text(fields, 'goal', owner),
|
|
263
294
|
requirements: epicRequirements,
|
|
264
295
|
dependsOn: textList(fields, 'depends_on', owner),
|
|
296
|
+
priority: priorityOf(fields, owner),
|
|
265
297
|
journeys,
|
|
266
298
|
};
|
|
267
299
|
});
|
|
@@ -295,6 +327,10 @@ export function parseProductIndex(yamlText) {
|
|
|
295
327
|
const cycle = findCycle(epics);
|
|
296
328
|
if (cycle)
|
|
297
329
|
throw new Error(`Dépendances circulaires entre epics : ${cycle.join(' → ')}.`);
|
|
330
|
+
const retiredRequirements = new Set(requirements.filter((requirement) => requirement.retired).map(({ id }) => id));
|
|
331
|
+
assertPriorities(epics, new Set(epics
|
|
332
|
+
.filter((epic) => epic.requirements.every((id) => retiredRequirements.has(id)))
|
|
333
|
+
.map((epic) => epic.id)));
|
|
298
334
|
return { requirements, epics };
|
|
299
335
|
}
|
|
300
336
|
export function parseRequirementRefs(specMarkdown) {
|
|
@@ -375,7 +411,9 @@ export function epicCells({ state, progress: { total, specified, done }, }) {
|
|
|
375
411
|
export function traceEpics(index, requirementRows, specs, testFiles) {
|
|
376
412
|
const stateOf = new Map(requirementRows.map((row) => [row.id, row.state]));
|
|
377
413
|
const retiredInIndex = new Set(index.requirements.filter((requirement) => requirement.retired).map(({ id }) => id));
|
|
378
|
-
|
|
414
|
+
// Tri stable : à niveau égal, l'ordre du fichier.
|
|
415
|
+
const ordered = [...index.epics].sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity));
|
|
416
|
+
const rows = ordered.map(({ id, title, requirements, dependsOn, priority }) => {
|
|
379
417
|
// Seul l'index retire une exigence d'un epic : si toutes ses specs sont retirées alors qu'elle
|
|
380
418
|
// reste active, elle est à refaire.
|
|
381
419
|
const states = requirements
|
|
@@ -390,9 +428,16 @@ export function traceEpics(index, requirementRows, specs, testFiles) {
|
|
|
390
428
|
specified: states.filter((candidate) => candidate !== 'Unspecified').length,
|
|
391
429
|
done: states.filter(isDone).length,
|
|
392
430
|
};
|
|
393
|
-
return { id, title, state, progress, requirements, dependsOn };
|
|
431
|
+
return { id, title, state, progress, requirements, dependsOn, priority };
|
|
394
432
|
});
|
|
395
433
|
const epicState = new Map(rows.map((row) => [row.id, row.state]));
|
|
434
|
+
// Une dépendance ne retient plus rien une fois terminée, ou retirée : il n'y a plus rien à attendre.
|
|
435
|
+
const settled = (dependency) => {
|
|
436
|
+
const state = epicState.get(dependency) ?? 'Unspecified';
|
|
437
|
+
return isDone(state) || state === 'Retired';
|
|
438
|
+
};
|
|
439
|
+
const ready = rows.filter((row) => row.state !== 'Retired' && !isDone(row.state) && row.dependsOn.every(settled));
|
|
440
|
+
const next = ready.filter((row) => row.priority === ready[0]?.priority).map((row) => row.id);
|
|
396
441
|
const journeyList = index.epics.flatMap((epic) => epic.journeys.map((journey) => ({ ...journey, epicId: epic.id })));
|
|
397
442
|
const known = new Map(journeyList.map((journey) => [journey.id, journey]));
|
|
398
443
|
const testsById = new Map();
|
|
@@ -430,13 +475,14 @@ export function traceEpics(index, requirementRows, specs, testFiles) {
|
|
|
430
475
|
for (const spec of specs.filter((candidate) => candidate.status === 'Draft')) {
|
|
431
476
|
const epics = new Set((spec.refs ?? []).flatMap((id) => epicOf.get(id) ?? []));
|
|
432
477
|
for (const epic of epics) {
|
|
433
|
-
const waitingOn = epic.dependsOn.filter((dependency) => !
|
|
478
|
+
const waitingOn = epic.dependsOn.filter((dependency) => !settled(dependency));
|
|
434
479
|
if (waitingOn.length > 0)
|
|
435
480
|
blocked.push({ featureDir: spec.featureDir, epicId: epic.id, waitingOn });
|
|
436
481
|
}
|
|
437
482
|
}
|
|
438
483
|
return {
|
|
439
484
|
rows,
|
|
485
|
+
next,
|
|
440
486
|
journeys,
|
|
441
487
|
uncovered,
|
|
442
488
|
orphans,
|