@artefact-ci/spec-trace 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artefact CI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # @artefact-ci/spec-trace
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Matrice scénarios → tests des specs Spec Kit, sortie de `tools/dev/spec-trace.mts` du boilerplate (`b30bb7b`) : même logique, même sortie. C'est la commande `commands.trace` de `.specify/stack.yml`.
4
+
5
+ ```sh
6
+ npx @artefact-ci/spec-trace # toutes les features de specs/ (CI)
7
+ npx @artefact-ci/spec-trace specs/003-order-note # une seule, en mode strict
8
+ npx @artefact-ci/spec-trace --base origin/dev # avertit en plus des tests modifiés sans leur spec
9
+ npx @artefact-ci/spec-trace --tests 'src/**/*.test.ts' # autres fichiers de tests (répétable)
10
+ ```
11
+
12
+ À lancer à la racine du dépôt (Node 24.11 ou plus récent, et git).
13
+
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`). 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
+ - Code de sortie 1 quand quelque chose manque.
17
+
18
+ ## Fichiers de tests
19
+
20
+ Ceux de `tests.globs` dans `.specify/stack.yml` ; `--tests` les remplace. Sans l'un ni l'autre : `**/*.spec.ts` et `**/*.spec.tsx`, ceux du boilerplate. Seuls les fichiers suivis par git ou nouveaux (hors `.gitignore`) sont lus.
21
+
22
+ Les motifs sont des pathspecs git (`:(glob)`), pour la matrice comme pour l'avertissement de `--base` : les deux voient les mêmes fichiers, quel que soit le moteur JS.
23
+
24
+ - `*` ne traverse pas un `/`, `**/` traverse zéro ou plusieurs dossiers, racine et dossiers à point (`.storybook/`) compris ;
25
+ - les accolades sont développées par l'outil : `src/**/*.{test,spec}.ts` vaut deux motifs ;
26
+ - un `./` en tête est ignoré ; un chemin est relatif à la racine du dépôt.
27
+
28
+ Le format des ids (`NNN-US<story>-AS<n>`, `E<n>-J<n>`) est celui de la méthode : les commandes du preset et la constitution le citent, il ne se configure pas ici.
29
+
30
+ Il faut Node 24.11 ou plus récent, aussi sur un projet Bun : `bunx` lance l'outil avec Node quand il est installé (shebang `node`), et n'est pas testé sous Bun.
31
+
32
+ ## Développer
33
+
34
+ ```sh
35
+ pnpm install
36
+ pnpm --filter @artefact-ci/spec-trace test # node --test sur src/*.test.mts
37
+ pnpm --filter @artefact-ci/spec-trace typecheck
38
+ pnpm --filter @artefact-ci/spec-trace build # dist/*.mjs, ce que publie le paquet
39
+ ```
40
+
41
+ `src/core.mts` est la logique pure, `src/stack.mts` la lecture de `stack.yml` et les motifs, `src/cli.mts` les fichiers, git et l'affichage. `src/cli.test.mts` lance la CLI sur un dépôt jetable (`test/fixture/`) et compare sa sortie à celle de l'outil d'origine (`test/golden/`, produite par `tools/dev/spec-trace.mts` du boilerplate sur ce même dépôt).
package/dist/cli.mjs ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Matrice scénario → tests des specs Spec Kit, à lancer à la racine du dépôt.
4
+ * spec-trace → toutes les features de specs/ (CI)
5
+ * spec-trace specs/003-order-note → une seule, en mode strict
6
+ * spec-trace --base origin/dev → avertit en plus des tests modifiés sans leur spec
7
+ * spec-trace --tests 'src/**\/*.test.ts' → d'autres fichiers de tests que ceux de stack.yml
8
+ * Les fichiers de tests sont ceux de `tests.globs` dans .specify/stack.yml (par défaut
9
+ * `**\/*.spec.ts` et `**\/*.spec.tsx`).
10
+ * Chaque scénario d'acceptation actif `NNN-US<story>-AS<n>` doit être cité par au moins un test
11
+ * (`it('003-US1-AS2: …')`) ; une spec en Draft y échappe, sauf si on la vise (mode strict).
12
+ * Aucun test ne doit citer un scénario absent ou retiré. Code de sortie 1 sinon.
13
+ * Si specs/product.yml existe, chaque spec nomme les exigences qu'elle implémente
14
+ * (`**Product Requirements**:`), toutes présentes dans l'index ; sans argument, la commande
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
+ */
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { driftedFeatures, epicCells, featureIdOf, parseProductIndex, parseRequirementRefs, parseScenarios, parseStatus, traceEpics, traceFeature, traceRequirements, } from "./core.mjs";
22
+ import { pathspecs, testGlobs } from "./stack.mjs";
23
+ const SPECS_DIR = 'specs';
24
+ const REQUIREMENT_INDEX = join(SPECS_DIR, 'product.yml');
25
+ const LEGACY_INDEX = join(SPECS_DIR, 'product-requirements.json');
26
+ const STACK_FILE = join('.specify', 'stack.yml');
27
+ const USAGE = 'Usage : spec-trace [specs/NNN-nom…] [--base <ref git>] [--tests <motif>…]';
28
+ function featureDirs(args) {
29
+ if (args.length > 0)
30
+ return args;
31
+ if (!existsSync(SPECS_DIR))
32
+ return [];
33
+ return readdirSync(SPECS_DIR, { withFileTypes: true })
34
+ .filter((entry) => entry.isDirectory() && existsSync(join(SPECS_DIR, entry.name, 'spec.md')))
35
+ .map((entry) => join(SPECS_DIR, entry.name))
36
+ .sort();
37
+ }
38
+ // Fichiers suivis et nouveaux (pas encore ajoutés), hors fichiers ignorés (node_modules, dist…).
39
+ // Filtrés par git, comme l'avertissement de dérive : un seul moteur de motifs.
40
+ function testFiles(globs) {
41
+ const output = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '--', ...pathspecs(globs)], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
42
+ return output
43
+ .split('\n')
44
+ .filter((path) => path !== '' && existsSync(path))
45
+ .map((path) => ({ path, content: readFileSync(path, 'utf8') }));
46
+ }
47
+ function print(featureDir, report) {
48
+ console.log(`\n## ${featureDir} (${report.status}) : ${report.ok ? 'OK' : 'INCOMPLET'}\n`);
49
+ if (report.rows.length === 0) {
50
+ console.log(`Aucun scénario d'acceptation trouvé dans ${join(featureDir, 'spec.md')}.`);
51
+ }
52
+ else {
53
+ console.log('| Scénario | Tests |');
54
+ console.log('| -------- | ----- |');
55
+ for (const row of report.rows) {
56
+ const tests = row.tests.length > 0 ? row.tests.join('<br>') : row.retired ? '' : '**aucun**';
57
+ console.log(`| ${row.id}${row.retired ? ' (retiré)' : ''} | ${tests} |`);
58
+ }
59
+ }
60
+ if (!report.coverageRequired && report.status === 'Draft' && report.uncovered.length > 0) {
61
+ console.log(`\nSpec en Draft : ${report.uncovered.length} scénario(s) encore sans test, toléré. ` +
62
+ 'Passer le statut à Implemented quand tous sont couverts.');
63
+ }
64
+ for (const orphan of report.orphans) {
65
+ console.log(`\nTest orphelin : ${orphan.path} cite ${orphan.id}, absent de la spec.`);
66
+ }
67
+ for (const stale of report.retiredCited) {
68
+ console.log(`\nTest périmé : ${stale.path} cite ${stale.id}, scénario retiré.`);
69
+ }
70
+ }
71
+ function printRequirements(report, { hasIndex, showRows }) {
72
+ if (showRows) {
73
+ console.log(`\n## Exigences (${REQUIREMENT_INDEX}) : ${report.ok ? 'OK' : 'INCOMPLET'}\n`);
74
+ console.log('| Exigence | État | Specs |');
75
+ console.log('| -------- | ---- | ----- |');
76
+ for (const row of report.rows) {
77
+ const specs = row.specs.map((spec) => `${spec.featureDir} (${spec.status})`).join('<br>');
78
+ const state = row.state === 'Unspecified' ? '**non spécifiée**' : row.state;
79
+ console.log(`| ${row.id} ${row.title} | ${state} | ${specs} |`);
80
+ }
81
+ }
82
+ for (const { featureDir, id } of report.unknown) {
83
+ const where = hasIndex
84
+ ? `absente de ${REQUIREMENT_INDEX}`
85
+ : `et ${REQUIREMENT_INDEX} n'existe pas`;
86
+ console.log(`\nExigence inconnue : ${featureDir}/spec.md cite ${id}, ${where}.`);
87
+ }
88
+ for (const { featureDir, id } of report.retiredCited) {
89
+ console.log(`\nAvertissement : ${featureDir}/spec.md cite ${id}, exigence retirée dans ${REQUIREMENT_INDEX} ` +
90
+ "(retirer la spec, ou la rattacher à l'exigence qui la remplace).");
91
+ }
92
+ for (const featureDir of report.missingLine) {
93
+ console.log(`\nLigne **Product Requirements** absente de ${featureDir}/spec.md ` +
94
+ `(les exigences de ${REQUIREMENT_INDEX} qu'elle implémente, ou None).`);
95
+ }
96
+ }
97
+ function printEpics(report, { showRows }) {
98
+ if (showRows && report.rows.length > 0) {
99
+ console.log(`\n## Epics (${REQUIREMENT_INDEX}) : ${report.ok ? 'OK' : 'INCOMPLET'}\n`);
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 ? ' -------- |' : ''} ---- | ---------- | --------- | --------- |`);
103
+ for (const row of report.rows) {
104
+ const { state, progress } = epicCells(row);
105
+ const priority = prioritized ? ` P${row.priority} |` : '';
106
+ console.log(`| ${row.id} ${row.title} |${priority} ${state} | ${progress} | ${row.requirements.join(', ')} | ${row.dependsOn.join(', ')} |`);
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})`}.`);
114
+ }
115
+ if (showRows && report.journeys.length > 0) {
116
+ console.log('\n| Parcours | Tests |');
117
+ console.log('| -------- | ----- |');
118
+ for (const journey of report.journeys) {
119
+ const tests = journey.tests.length > 0 ? journey.tests.join('<br>') : journey.retired ? '' : '**aucun**';
120
+ console.log(`| ${journey.id}${journey.retired ? ' (retiré)' : ''} ${journey.title} | ${tests} |`);
121
+ }
122
+ }
123
+ for (const id of report.uncovered) {
124
+ console.log(`\nParcours sans test : ${id}, alors que son epic est terminé.`);
125
+ }
126
+ for (const orphan of report.orphans) {
127
+ console.log(`\nTest orphelin : ${orphan.path} cite ${orphan.id}, parcours absent de ${REQUIREMENT_INDEX}.`);
128
+ }
129
+ for (const stale of report.retiredCited) {
130
+ console.log(`\nTest périmé : ${stale.path} cite ${stale.id}, parcours retiré.`);
131
+ }
132
+ for (const { featureDir, epicId, waitingOn } of report.blocked) {
133
+ console.log(`\nAvertissement : ${featureDir} avance sur ${epicId}, qui dépend de ${waitingOn.join(', ')}, ` +
134
+ 'pas encore terminé (Implemented).');
135
+ }
136
+ }
137
+ // Exigences citées par toutes les specs de specs/ ; une spec illisible est signalée quand on la
138
+ // vise, et ignorée ici.
139
+ function allRequirementRefs() {
140
+ return featureDirs([]).flatMap((featureDir) => {
141
+ const spec = readFileSync(join(featureDir, 'spec.md'), 'utf8');
142
+ try {
143
+ return [{ featureDir, status: parseStatus(spec), refs: parseRequirementRefs(spec) }];
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ });
149
+ }
150
+ // Tests modifiés depuis la base (commits et fichiers en cours) dont la spec n'a pas bougé.
151
+ function warnDrift(base, globs) {
152
+ const git = (args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
153
+ const mergeBase = git(['merge-base', base, 'HEAD']).trim();
154
+ const diff = git(['diff', '-U0', mergeBase, '--', ...pathspecs(globs)]);
155
+ const changed = [
156
+ ...git(['diff', '--name-only', mergeBase]).split('\n'),
157
+ ...git(['ls-files', '--others', '--exclude-standard']).split('\n'),
158
+ ].filter(Boolean);
159
+ const drift = driftedFeatures(diff, changed);
160
+ if (drift.length === 0)
161
+ return;
162
+ console.log('\n## Avertissements\n');
163
+ for (const { featureId, paths } of drift) {
164
+ const message = `Des tests qui citent les scénarios ${featureId}-… ont changé, pas specs/${featureId}-*/spec.md. ` +
165
+ 'Si le comportement a changé, mettre la spec à jour dans la même PR.';
166
+ console.log(`- ${message} (${paths.join(', ')})`);
167
+ // Annotation visible dans la PR, sans faire échouer le job.
168
+ if (process.env['GITHUB_ACTIONS'] === 'true') {
169
+ for (const path of paths)
170
+ console.log(`::warning file=${path}::${message}`);
171
+ }
172
+ }
173
+ }
174
+ const args = process.argv.slice(2);
175
+ if (args.includes('--help') || args.includes('-h')) {
176
+ console.log(USAGE);
177
+ process.exit(0);
178
+ }
179
+ // Retire une option et sa valeur des arguments ; `undefined` si elle manque, `''` si sa valeur manque.
180
+ function takeOption(name) {
181
+ const at = args.indexOf(name);
182
+ if (at < 0)
183
+ return undefined;
184
+ const value = args[at + 1];
185
+ const valid = value !== undefined && !value.startsWith('--');
186
+ args.splice(at, valid ? 2 : 1);
187
+ return valid ? value : '';
188
+ }
189
+ const base = takeOption('--base');
190
+ if (base === '') {
191
+ console.error('--base attend une référence git (ex. origin/dev).');
192
+ process.exit(1);
193
+ }
194
+ const cliGlobs = [];
195
+ for (let glob = takeOption('--tests'); glob !== undefined; glob = takeOption('--tests')) {
196
+ if (glob === '') {
197
+ console.error("--tests attend un motif de fichiers de tests (ex. 'src/**/*.test.ts').");
198
+ process.exit(1);
199
+ }
200
+ cliGlobs.push(glob);
201
+ }
202
+ const unknownOption = args.find((arg) => arg.startsWith('--'));
203
+ if (unknownOption) {
204
+ console.error(`Option inconnue : ${unknownOption}\n${USAGE}`);
205
+ process.exit(1);
206
+ }
207
+ let globs;
208
+ try {
209
+ globs =
210
+ cliGlobs.length > 0
211
+ ? cliGlobs
212
+ : testGlobs(existsSync(STACK_FILE) ? readFileSync(STACK_FILE, 'utf8') : undefined);
213
+ pathspecs(globs); // une accolade non fermée s'arrête ici, avant toute sortie
214
+ }
215
+ catch (error) {
216
+ console.error(`${STACK_FILE} : ${error.message}`);
217
+ process.exit(1);
218
+ }
219
+ // Viser une feature, c'est vérifier qu'elle est complète : même en Draft, tout scénario a son test.
220
+ const strict = args.length > 0;
221
+ const dirs = featureDirs(args);
222
+ if (existsSync(LEGACY_INDEX)) {
223
+ console.error(`${LEGACY_INDEX} n'est plus lu : l'index du produit est ${REQUIREMENT_INDEX} ` +
224
+ '(mêmes exigences en YAML, plus les epics) : le convertir, puis supprimer l\'ancien fichier.');
225
+ process.exit(1);
226
+ }
227
+ let index;
228
+ if (existsSync(REQUIREMENT_INDEX)) {
229
+ try {
230
+ index = parseProductIndex(readFileSync(REQUIREMENT_INDEX, 'utf8'));
231
+ }
232
+ catch (error) {
233
+ console.error(`${REQUIREMENT_INDEX} : ${error.message}`);
234
+ process.exit(1);
235
+ }
236
+ }
237
+ if (dirs.length === 0 && !index) {
238
+ console.log(`Aucune spec dans ${SPECS_DIR}/.`);
239
+ if (base)
240
+ warnDrift(base, globs);
241
+ process.exit(0);
242
+ }
243
+ const files = testFiles(globs);
244
+ let ok = true;
245
+ const requirementRefs = [];
246
+ for (const dir of dirs) {
247
+ const specPath = join(dir, 'spec.md');
248
+ if (!existsSync(specPath)) {
249
+ console.error(`${specPath} introuvable.`);
250
+ ok = false;
251
+ continue;
252
+ }
253
+ const featureId = featureIdOf(dir);
254
+ const spec = readFileSync(specPath, 'utf8');
255
+ let status;
256
+ try {
257
+ status = parseStatus(spec);
258
+ }
259
+ catch (error) {
260
+ console.error(`${specPath} : ${error.message}`);
261
+ ok = false;
262
+ continue;
263
+ }
264
+ const report = traceFeature(featureId, parseScenarios(featureId, spec), { status, strict }, files);
265
+ print(dir, report);
266
+ ok &&= report.ok;
267
+ requirementRefs.push({ featureDir: dir, status, refs: parseRequirementRefs(spec) });
268
+ }
269
+ const requirements = traceRequirements(index, requirementRefs);
270
+ // Le tableau des exigences n'a de sens que sur toutes les specs.
271
+ printRequirements(requirements, {
272
+ hasIndex: index !== undefined,
273
+ showRows: index !== undefined && !strict,
274
+ });
275
+ ok &&= requirements.ok;
276
+ // L'état d'un epic dépend de toutes les specs, même quand on n'en vise qu'une : sinon une v2 en
277
+ // Draft ailleurs passerait inaperçue et l'epic paraîtrait terminé.
278
+ const allRows = strict ? traceRequirements(index, allRequirementRefs()).rows : requirements.rows;
279
+ // Sans index, un test qui cite un parcours reste orphelin.
280
+ const epics = traceEpics(index ?? { requirements: [], epics: [] }, allRows, requirementRefs, files);
281
+ printEpics(epics, { showRows: !strict });
282
+ ok &&= epics.ok;
283
+ if (base)
284
+ warnDrift(base, globs);
285
+ process.exit(ok ? 0 : 1);
package/dist/core.mjs ADDED
@@ -0,0 +1,493 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ /**
3
+ * Traçabilité spec → tests : chaque scénario d'acceptation d'une spec Spec Kit
4
+ * (`specs/NNN-<nom>/spec.md`) porte un identifiant `NNN-US<story>-AS<n>`, et au moins un test
5
+ * le cite dans son titre. Logique pure ; la lecture des fichiers est dans cli.mts.
6
+ */
7
+ /**
8
+ * Cycle de vie d'une spec : Draft (en cours, des scénarios peuvent encore attendre leur test),
9
+ * Implemented (tous couverts), Shipped (feature flag supprimé), Retired (fonctionnalité retirée :
10
+ * plus aucun test ne la cite).
11
+ */
12
+ const SPEC_STATUSES = ['Draft', 'Implemented', 'Shipped', 'Retired'];
13
+ export function featureIdOf(featureDir) {
14
+ const name = featureDir.replace(/\/+$/, '').split('/').pop() ?? '';
15
+ const match = /^(\d+)-/.exec(name);
16
+ if (!match?.[1]) {
17
+ throw new Error(`Le dossier de feature "${featureDir}" n'a pas de préfixe numérique (attendu : NNN-<nom>).`);
18
+ }
19
+ return match[1];
20
+ }
21
+ export function parseStatus(specMarkdown) {
22
+ const value = /^\*\*Status\*\*:\s*(.*?)\s*$/m.exec(specMarkdown)?.[1];
23
+ if (value === undefined) {
24
+ throw new Error(`Ligne **Status** absente (attendu : ${SPEC_STATUSES.join(', ')}).`);
25
+ }
26
+ const status = SPEC_STATUSES.find((candidate) => candidate === value);
27
+ if (!status) {
28
+ throw new Error(`Statut "${value}" inconnu (attendu : ${SPEC_STATUSES.join(', ')}).`);
29
+ }
30
+ return status;
31
+ }
32
+ /**
33
+ * Scénarios = éléments numérotés de la liste « Acceptance Scenarios » de chaque « User Story N ».
34
+ * Le numéro est celui écrit dans la spec : retirer un scénario ne renumérote pas les suivants.
35
+ */
36
+ export function parseScenarios(featureId, specMarkdown) {
37
+ const scenarios = [];
38
+ let story;
39
+ let inScenarios = false;
40
+ for (const line of specMarkdown.split('\n')) {
41
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
42
+ if (heading) {
43
+ story = /^User Story (\d+)\b/.exec(heading[1] ?? '')?.[1];
44
+ inScenarios = false;
45
+ continue;
46
+ }
47
+ if (story && line.startsWith('**Acceptance Scenarios**')) {
48
+ inScenarios = true;
49
+ continue;
50
+ }
51
+ const item = inScenarios ? /^(\d+)\.\s+(.*)$/.exec(line) : null;
52
+ if (story && item) {
53
+ const text = item[2] ?? '';
54
+ scenarios.push({
55
+ id: `${featureId}-US${story}-AS${item[1]}`,
56
+ text,
57
+ retired: text.startsWith('**Retired**'),
58
+ });
59
+ }
60
+ }
61
+ return scenarios;
62
+ }
63
+ export function traceFeature(featureId, scenarios, options, testFiles) {
64
+ // Une spec retirée l'est en entier : chacun de ses scénarios est retiré.
65
+ const featureRetired = options.status === 'Retired';
66
+ const retiredIds = new Set(scenarios.filter((scenario) => featureRetired || scenario.retired).map((s) => s.id));
67
+ const known = new Set(scenarios.map((scenario) => scenario.id));
68
+ const idPattern = new RegExp(`\\b${featureId}-US\\d+-AS\\d+\\b`, 'g');
69
+ const testsById = new Map();
70
+ const orphans = [];
71
+ const retiredCited = [];
72
+ for (const file of testFiles) {
73
+ for (const id of new Set(file.content.match(idPattern) ?? [])) {
74
+ if (!known.has(id)) {
75
+ orphans.push({ id, path: file.path });
76
+ continue;
77
+ }
78
+ testsById.set(id, (testsById.get(id) ?? new Set()).add(file.path));
79
+ if (retiredIds.has(id))
80
+ retiredCited.push({ id, path: file.path });
81
+ }
82
+ }
83
+ const rows = scenarios.map((scenario) => ({
84
+ id: scenario.id,
85
+ text: scenario.text,
86
+ retired: retiredIds.has(scenario.id),
87
+ tests: [...(testsById.get(scenario.id) ?? [])].sort(),
88
+ }));
89
+ const active = rows.filter((row) => !row.retired);
90
+ const uncovered = active.filter((row) => row.tests.length === 0).map((row) => row.id);
91
+ const byIdThenPath = (a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path);
92
+ orphans.sort(byIdThenPath);
93
+ retiredCited.sort(byIdThenPath);
94
+ const coverageRequired = !featureRetired && (options.strict === true || options.status !== 'Draft');
95
+ const coverageOk = !coverageRequired || (active.length > 0 && uncovered.length === 0);
96
+ return {
97
+ featureId,
98
+ status: options.status,
99
+ coverageRequired,
100
+ rows,
101
+ uncovered,
102
+ orphans,
103
+ retiredCited,
104
+ ok: coverageOk && orphans.length === 0 && retiredCited.length === 0,
105
+ };
106
+ }
107
+ /**
108
+ * Dérive probable : un diff (`git diff -U0`) modifie des lignes de test qui citent un scénario
109
+ * d'une feature, sans que le `spec.md` de cette feature figure parmi les fichiers modifiés.
110
+ * Simple signal pour la revue : renommer ou refactorer un test ne change pas le comportement.
111
+ */
112
+ export function driftedFeatures(diff, changedPaths) {
113
+ const specChanged = new Set(changedPaths.flatMap((path) => /^specs\/(\d+)-[^/]+\/spec\.md$/.exec(path)?.[1] ?? []));
114
+ const pathsByFeature = new Map();
115
+ let oldPath;
116
+ let path;
117
+ for (const line of diff.split('\n')) {
118
+ if (line.startsWith('--- ')) {
119
+ oldPath = line.slice(4).replace(/^a\//, '');
120
+ continue;
121
+ }
122
+ if (line.startsWith('+++ ')) {
123
+ const newPath = line.slice(4);
124
+ path = newPath === '/dev/null' ? oldPath : newPath.replace(/^b\//, '');
125
+ continue;
126
+ }
127
+ if (!path || !/^[+-]/.test(line))
128
+ continue;
129
+ for (const match of line.matchAll(/\b(\d+)-US\d+-AS\d+\b/g)) {
130
+ const featureId = match[1] ?? '';
131
+ if (specChanged.has(featureId))
132
+ continue;
133
+ pathsByFeature.set(featureId, (pathsByFeature.get(featureId) ?? new Set()).add(path));
134
+ }
135
+ }
136
+ return [...pathsByFeature]
137
+ .map(([featureId, paths]) => ({ featureId, paths: [...paths].sort() }))
138
+ .sort((a, b) => a.featureId.localeCompare(b.featureId));
139
+ }
140
+ const REQUIREMENT_ID = /^[A-Z][A-Z0-9]*-\d+$/;
141
+ const EPIC_ID = /^E\d+$/;
142
+ const JOURNEY_ID = /^E\d+-J\d+$/;
143
+ const PRIORITY = /^P([1-9]\d*)$/;
144
+ function fieldsOf(value, what) {
145
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
146
+ throw new Error(`${what} : attendu un objet.`);
147
+ }
148
+ return value;
149
+ }
150
+ function text(fields, key, owner) {
151
+ const value = fields[key];
152
+ if (typeof value !== 'string' || value.trim() === '') {
153
+ throw new Error(`${owner} : champ ${key} absent ou vide.`);
154
+ }
155
+ return value;
156
+ }
157
+ function retiredOf(fields, owner) {
158
+ return fields['retired'] === undefined ? undefined : text(fields, 'retired', owner);
159
+ }
160
+ function textList(fields, key, owner) {
161
+ const value = fields[key] ?? [];
162
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
163
+ throw new Error(`${owner} : ${key} doit être une liste de textes.`);
164
+ }
165
+ return value;
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
+ }
177
+ function assertUnique(ids, what) {
178
+ const seen = new Set();
179
+ for (const id of ids) {
180
+ if (seen.has(id))
181
+ throw new Error(`${what} ${id} apparaît deux fois.`);
182
+ seen.add(id);
183
+ }
184
+ }
185
+ // Premier cycle trouvé dans le graphe des dépendances, sous la forme E01 → E02 → E01.
186
+ function findCycle(epics) {
187
+ const dependsOn = new Map(epics.map((epic) => [epic.id, epic.dependsOn]));
188
+ const done = new Set();
189
+ const visit = (id, path) => {
190
+ const start = path.indexOf(id);
191
+ if (start >= 0)
192
+ return [...path.slice(start), id];
193
+ if (done.has(id))
194
+ return undefined;
195
+ for (const next of dependsOn.get(id) ?? []) {
196
+ const cycle = visit(next, [...path, id]);
197
+ if (cycle)
198
+ return cycle;
199
+ }
200
+ done.add(id);
201
+ return undefined;
202
+ };
203
+ for (const epic of epics) {
204
+ const cycle = visit(epic.id, []);
205
+ if (cycle)
206
+ return cycle;
207
+ }
208
+ return undefined;
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
+ }
230
+ /** Lit et valide l'index : identifiants, références entre epics et exigences, cycles. */
231
+ export function parseProductIndex(yamlText) {
232
+ let data;
233
+ try {
234
+ data = parseYaml(yamlText);
235
+ }
236
+ catch (error) {
237
+ throw new Error(`YAML invalide : ${error.message}`);
238
+ }
239
+ const root = (data ?? {});
240
+ if (!Array.isArray(root['requirements'])) {
241
+ throw new Error('Attendu : requirements: [{ id: PAY-03, title: … }], puis epics (optionnel).');
242
+ }
243
+ const requirements = root['requirements'].map((entry, position) => {
244
+ const fields = fieldsOf(entry, `Exigence n° ${position + 1}`);
245
+ const id = fields['id'];
246
+ if (typeof id !== 'string' || !REQUIREMENT_ID.test(id)) {
247
+ throw new Error(`Identifiant d'exigence "${String(id)}" invalide (attendu : PAY-03).`);
248
+ }
249
+ return {
250
+ id,
251
+ title: text(fields, 'title', `L'exigence ${id}`),
252
+ retired: retiredOf(fields, `L'exigence ${id}`),
253
+ };
254
+ });
255
+ assertUnique(requirements.map((requirement) => requirement.id), "L'exigence");
256
+ const rawEpics = root['epics'] ?? [];
257
+ if (!Array.isArray(rawEpics))
258
+ throw new Error('epics doit être une liste.');
259
+ const epics = rawEpics.map((entry, position) => {
260
+ const fields = fieldsOf(entry, `Epic n° ${position + 1}`);
261
+ const id = fields['id'];
262
+ if (typeof id !== 'string' || !EPIC_ID.test(id)) {
263
+ throw new Error(`Identifiant d'epic "${String(id)}" invalide (attendu : E03).`);
264
+ }
265
+ const owner = `L'epic ${id}`;
266
+ const epicRequirements = textList(fields, 'requirements', owner);
267
+ if (epicRequirements.length === 0) {
268
+ throw new Error(`${owner} : requirements vide (au moins une exigence).`);
269
+ }
270
+ const rawJourneys = fields['journeys'] ?? [];
271
+ if (!Array.isArray(rawJourneys))
272
+ throw new Error(`${owner} : journeys doit être une liste.`);
273
+ const journeys = rawJourneys.map((journeyEntry, index) => {
274
+ const journey = fieldsOf(journeyEntry, `${owner}, parcours n° ${index + 1}`);
275
+ const journeyId = journey['id'];
276
+ if (typeof journeyId !== 'string' || !JOURNEY_ID.test(journeyId)) {
277
+ throw new Error(`Identifiant de parcours "${String(journeyId)}" invalide (attendu : ${id}-J1).`);
278
+ }
279
+ if (!journeyId.startsWith(`${id}-J`)) {
280
+ throw new Error(`Le parcours ${journeyId} de l'epic ${id} doit s'appeler ${id}-J<n>.`);
281
+ }
282
+ const journeyOwner = `Le parcours ${journeyId}`;
283
+ const retired = retiredOf(journey, journeyOwner);
284
+ const steps = textList(journey, 'steps', journeyOwner);
285
+ if (!retired && steps.length === 0) {
286
+ throw new Error(`${journeyOwner} : steps vide (les étapes que le test enchaîne).`);
287
+ }
288
+ return { id: journeyId, title: text(journey, 'title', journeyOwner), steps, retired };
289
+ });
290
+ return {
291
+ id,
292
+ title: text(fields, 'title', owner),
293
+ goal: text(fields, 'goal', owner),
294
+ requirements: epicRequirements,
295
+ dependsOn: textList(fields, 'depends_on', owner),
296
+ priority: priorityOf(fields, owner),
297
+ journeys,
298
+ };
299
+ });
300
+ assertUnique(epics.map((epic) => epic.id), "L'epic");
301
+ assertUnique(epics.flatMap((epic) => epic.journeys.map((journey) => journey.id)), 'Le parcours');
302
+ const knownRequirements = new Set(requirements.map((requirement) => requirement.id));
303
+ const knownEpics = new Set(epics.map((epic) => epic.id));
304
+ const epicOf = new Map();
305
+ for (const epic of epics) {
306
+ for (const requirement of epic.requirements) {
307
+ if (!knownRequirements.has(requirement)) {
308
+ throw new Error(`L'epic ${epic.id} cite ${requirement}, absente de requirements.`);
309
+ }
310
+ const other = epicOf.get(requirement);
311
+ if (other) {
312
+ throw new Error(`L'exigence ${requirement} est dans deux epics : ${other} et ${epic.id}.`);
313
+ }
314
+ epicOf.set(requirement, epic.id);
315
+ }
316
+ for (const dependency of epic.dependsOn) {
317
+ if (!knownEpics.has(dependency)) {
318
+ throw new Error(`L'epic ${epic.id} dépend de ${dependency}, absent de epics.`);
319
+ }
320
+ }
321
+ }
322
+ if (epics.length > 0) {
323
+ const orphan = requirements.find((requirement) => !requirement.retired && !epicOf.has(requirement.id));
324
+ if (orphan)
325
+ throw new Error(`L'exigence ${orphan.id} n'appartient à aucun epic.`);
326
+ }
327
+ const cycle = findCycle(epics);
328
+ if (cycle)
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)));
334
+ return { requirements, epics };
335
+ }
336
+ export function parseRequirementRefs(specMarkdown) {
337
+ const value = /^\*\*Product Requirements\*\*:\s*(.*?)\s*$/m.exec(specMarkdown)?.[1];
338
+ if (value === undefined)
339
+ return undefined;
340
+ if (value === '' || value === 'None')
341
+ return [];
342
+ return value
343
+ .split(',')
344
+ .map((id) => id.trim())
345
+ .filter((id) => id !== '');
346
+ }
347
+ const STATE_ORDER = ['Draft', 'Implemented', 'Shipped'];
348
+ export function traceRequirements(index, specs) {
349
+ const known = new Set(index?.requirements.map((requirement) => requirement.id));
350
+ const retired = new Set(index?.requirements.filter((requirement) => requirement.retired).map(({ id }) => id));
351
+ const unknown = specs
352
+ .flatMap(({ featureDir, refs }) => (refs ?? []).map((id) => ({ featureDir, id })))
353
+ .filter(({ id }) => !known.has(id));
354
+ // Une spec retirée avant la création de l'index n'a pas à être complétée.
355
+ const missingLine = index
356
+ ? specs
357
+ .filter((spec) => spec.refs === undefined && spec.status !== 'Retired')
358
+ .map((spec) => spec.featureDir)
359
+ : [];
360
+ const retiredCited = specs
361
+ .filter((spec) => spec.status !== 'Retired')
362
+ .flatMap(({ featureDir, refs }) => (refs ?? []).map((id) => ({ featureDir, id })))
363
+ .filter(({ id }) => retired.has(id));
364
+ const rows = (index?.requirements ?? []).map(({ id, title }) => {
365
+ const cited = specs
366
+ .filter((spec) => spec.refs?.includes(id))
367
+ .map(({ featureDir, status }) => ({ featureDir, status }))
368
+ .sort((a, b) => a.featureDir.localeCompare(b.featureDir));
369
+ const active = cited.filter((spec) => spec.status !== 'Retired');
370
+ // L'exigence n'avance qu'avec sa spec la moins avancée : une v2 en Draft la remet en cours.
371
+ const state = retired.has(id)
372
+ ? 'Retired'
373
+ : cited.length === 0
374
+ ? 'Unspecified'
375
+ : active.length === 0
376
+ ? 'Retired'
377
+ : (STATE_ORDER.find((status) => active.some((spec) => spec.status === status)) ??
378
+ 'Draft');
379
+ return { id, title, state, specs: cited };
380
+ });
381
+ return {
382
+ rows,
383
+ unknown,
384
+ missingLine,
385
+ retiredCited,
386
+ ok: unknown.length === 0 && missingLine.length === 0,
387
+ };
388
+ }
389
+ // Du moins avancé au plus avancé : l'état d'un epic est celui de son exigence la moins avancée.
390
+ const PROGRESS = [
391
+ 'Unspecified',
392
+ 'Draft',
393
+ 'Implemented',
394
+ 'Shipped',
395
+ ];
396
+ const isDone = (state) => state === 'Implemented' || state === 'Shipped';
397
+ /**
398
+ * Cellules État et Avancement du tableau des epics. L'état reste celui de l'exigence la moins
399
+ * avancée ; un epic commencé ne s'affiche pas pour autant comme non spécifié.
400
+ */
401
+ export function epicCells({ state, progress: { total, specified, done }, }) {
402
+ if (state === 'Retired')
403
+ return { state, progress: '' };
404
+ const label = state !== 'Unspecified' ? state : specified > 0 ? '**en cours**' : '**non spécifié**';
405
+ return { state: label, progress: `${specified}/${total} spécifiées, ${done}/${total} terminées` };
406
+ }
407
+ /**
408
+ * `requirementRows` vient de toutes les specs (l'état d'un epic en dépend) ; `specs`, seulement de
409
+ * celles à vérifier (avertissements de dépendance).
410
+ */
411
+ export function traceEpics(index, requirementRows, specs, testFiles) {
412
+ const stateOf = new Map(requirementRows.map((row) => [row.id, row.state]));
413
+ const retiredInIndex = new Set(index.requirements.filter((requirement) => requirement.retired).map(({ id }) => id));
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 }) => {
417
+ // Seul l'index retire une exigence d'un epic : si toutes ses specs sont retirées alors qu'elle
418
+ // reste active, elle est à refaire.
419
+ const states = requirements
420
+ .filter((requirement) => !retiredInIndex.has(requirement))
421
+ .map((requirement) => stateOf.get(requirement) ?? 'Unspecified')
422
+ .map((state) => (state === 'Retired' ? 'Unspecified' : state));
423
+ const state = states.length === 0
424
+ ? 'Retired'
425
+ : (PROGRESS.find((candidate) => states.includes(candidate)) ?? 'Unspecified');
426
+ const progress = {
427
+ total: states.length,
428
+ specified: states.filter((candidate) => candidate !== 'Unspecified').length,
429
+ done: states.filter(isDone).length,
430
+ };
431
+ return { id, title, state, progress, requirements, dependsOn, priority };
432
+ });
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);
441
+ const journeyList = index.epics.flatMap((epic) => epic.journeys.map((journey) => ({ ...journey, epicId: epic.id })));
442
+ const known = new Map(journeyList.map((journey) => [journey.id, journey]));
443
+ const testsById = new Map();
444
+ const orphans = [];
445
+ const retiredCited = [];
446
+ for (const file of testFiles) {
447
+ for (const id of new Set(file.content.match(/\bE\d+-J\d+\b/g) ?? [])) {
448
+ const journey = known.get(id);
449
+ if (!journey) {
450
+ orphans.push({ id, path: file.path });
451
+ continue;
452
+ }
453
+ testsById.set(id, (testsById.get(id) ?? new Set()).add(file.path));
454
+ if (journey.retired)
455
+ retiredCited.push({ id, path: file.path });
456
+ }
457
+ }
458
+ const byIdThenPath = (a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path);
459
+ orphans.sort(byIdThenPath);
460
+ retiredCited.sort(byIdThenPath);
461
+ const journeys = journeyList.map(({ id, epicId, title, retired }) => ({
462
+ id,
463
+ epicId,
464
+ title,
465
+ retired: retired !== undefined,
466
+ tests: [...(testsById.get(id) ?? [])].sort(),
467
+ }));
468
+ const uncovered = journeys
469
+ .filter((journey) => !journey.retired &&
470
+ journey.tests.length === 0 &&
471
+ isDone(epicState.get(journey.epicId) ?? 'Unspecified'))
472
+ .map((journey) => journey.id);
473
+ const epicOf = new Map(index.epics.flatMap((epic) => epic.requirements.map((id) => [id, epic])));
474
+ const blocked = [];
475
+ for (const spec of specs.filter((candidate) => candidate.status === 'Draft')) {
476
+ const epics = new Set((spec.refs ?? []).flatMap((id) => epicOf.get(id) ?? []));
477
+ for (const epic of epics) {
478
+ const waitingOn = epic.dependsOn.filter((dependency) => !settled(dependency));
479
+ if (waitingOn.length > 0)
480
+ blocked.push({ featureDir: spec.featureDir, epicId: epic.id, waitingOn });
481
+ }
482
+ }
483
+ return {
484
+ rows,
485
+ next,
486
+ journeys,
487
+ uncovered,
488
+ orphans,
489
+ retiredCited,
490
+ blocked,
491
+ ok: uncovered.length === 0 && orphans.length === 0 && retiredCited.length === 0,
492
+ };
493
+ }
package/dist/stack.mjs ADDED
@@ -0,0 +1,72 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+ /**
3
+ * Ce que spec-trace lit dans `.specify/stack.yml` : les motifs des fichiers de tests
4
+ * (`tests.globs`). Sans eux, ceux du boilerplate nx-react-nestjs.
5
+ */
6
+ export const DEFAULT_TEST_GLOBS = ['**/*.spec.ts', '**/*.spec.tsx'];
7
+ const SHAPE = "tests.globs doit être une liste de motifs (ex. ['src/**/*.test.ts']).";
8
+ export function testGlobs(stackYaml) {
9
+ if (stackYaml === undefined)
10
+ return DEFAULT_TEST_GLOBS;
11
+ let data;
12
+ try {
13
+ data = parseYaml(stackYaml);
14
+ }
15
+ catch (error) {
16
+ throw new Error(`YAML invalide : ${error.message}`);
17
+ }
18
+ const tests = data?.tests;
19
+ if (tests === undefined || tests === null)
20
+ return DEFAULT_TEST_GLOBS;
21
+ if (typeof tests !== 'object' || Array.isArray(tests)) {
22
+ throw new Error(`tests doit être un objet (globs, needs). ${SHAPE}`);
23
+ }
24
+ const globs = tests.globs;
25
+ if (globs === undefined || globs === null)
26
+ return DEFAULT_TEST_GLOBS;
27
+ if (!Array.isArray(globs) ||
28
+ globs.length === 0 ||
29
+ globs.some((glob) => typeof glob !== 'string' || glob.trim() === '')) {
30
+ throw new Error(SHAPE);
31
+ }
32
+ return globs;
33
+ }
34
+ /** `src/*.{test,spec}.ts` → `src/*.test.ts`, `src/*.spec.ts` ; accolades imbriquées comprises. */
35
+ export function expandBraces(glob) {
36
+ const open = glob.indexOf('{');
37
+ if (open < 0)
38
+ return [glob];
39
+ let depth = 0;
40
+ const commas = [];
41
+ for (let at = open; at < glob.length; at++) {
42
+ const char = glob[at];
43
+ if (char === '{')
44
+ depth++;
45
+ else if (char === '}' && --depth === 0) {
46
+ const parts = [];
47
+ let start = open + 1;
48
+ for (const comma of commas) {
49
+ parts.push(glob.slice(start, comma));
50
+ start = comma + 1;
51
+ }
52
+ parts.push(glob.slice(start, at));
53
+ const before = glob.slice(0, open);
54
+ const after = glob.slice(at + 1);
55
+ return parts.flatMap((part) => expandBraces(`${before}${part}${after}`));
56
+ }
57
+ else if (char === ',' && depth === 1)
58
+ commas.push(at);
59
+ }
60
+ throw new Error(`Accolade non fermée dans le motif « ${glob} ».`);
61
+ }
62
+ /**
63
+ * Les motifs en pathspecs git (`:(glob)…`) : la liste des tests et l'avertissement de dérive
64
+ * passent tous deux par git, donc par le même moteur de motifs, quel que soit le runtime. `**`
65
+ * y couvre aussi la racine et les dossiers à point (`.storybook/`).
66
+ */
67
+ export function pathspecs(globs) {
68
+ return globs
69
+ .flatMap(expandBraces)
70
+ .map((glob) => glob.replace(/^(\.\/)+/, ''))
71
+ .map((glob) => `:(glob)${glob}`);
72
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@artefact-ci/spec-trace",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Matrice scénarios → tests des specs Spec Kit (méthode artefact-sdd)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/artefact-ci/speckit-artefact.git",
9
+ "directory": "packages/spec-trace"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "spec-trace": "dist/cli.mjs"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "engines": {
23
+ "node": ">=24.11"
24
+ },
25
+ "dependencies": {
26
+ "yaml": "^2.9.1"
27
+ },
28
+ "scripts": {
29
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
30
+ "typecheck": "tsc -p tsconfig.json",
31
+ "test": "node --test 'src/*.test.mts'"
32
+ }
6
33
  }