@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 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, de chaque exigence et la couverture des parcours. Un 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
- console.log('| Epic | État | Avancement | Exigences | Dépend de |');
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
- console.log(`| ${row.id} ${row.title} | ${state} | ${progress} | ${row.requirements.join(', ')} | ${row.dependsOn.join(', ')} |`);
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
- const rows = index.epics.map(({ id, title, requirements, dependsOn }) => {
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) => !isDone(epicState.get(dependency) ?? 'Unspecified'));
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artefact-ci/spec-trace",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Matrice scénarios → tests des specs Spec Kit (méthode artefact-sdd)",
5
5
  "license": "MIT",
6
6
  "repository": {