@spec-wave/cli 0.16.1 → 0.16.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -282,6 +282,34 @@ async function checkConfig(ctx) {
282
282
  * @param {string[]} [params.repoLabels] nomes das labels do repo
283
283
  * @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[], missingLabels: string[] }}
284
284
  */
285
+ /**
286
+ * Os ids de opção do .spec-wave.json batem com os do Project? (função PURA)
287
+ *
288
+ * Compara ID, não nome. `inspectBoardHygiene` compara nomes — e é justamente
289
+ * essa cegueira que deixou passar dois defeitos: o `refresh --stages` que
290
+ * recriava as opções, e o config commitado defasado.
291
+ *
292
+ * Por que importa: o que liga um item do board à coluna é o id. Um
293
+ * `.spec-wave.json` com ids velhos faz TODA escrita de Etapa falhar com "The
294
+ * single select option Id does not belong to the field" — e a falha é
295
+ * best-effort em quase todo caminho (implement, code-review, qa), então o card
296
+ * simplesmente não se move e ninguém percebe.
297
+ *
298
+ * Acontece sempre que alguém roda `refresh --config` e não commita: quem clona
299
+ * o repo — o dev-agent, o runner do Action — lê a versão velha.
300
+ *
301
+ * @param {object} params
302
+ * @param {Record<string,string>|null} [params.configOptions] nome → id do config
303
+ * @param {Record<string,string>|null} [params.boardOptions] nome → id do Project
304
+ * @returns {{ staleIds: string[], checked: boolean }}
305
+ */
306
+ export function inspectStageIds({ configOptions = null, boardOptions = null } = {}) {
307
+ if (!configOptions || !boardOptions) return { staleIds: [], checked: false };
308
+ const staleIds = Object.keys(configOptions)
309
+ .filter(name => boardOptions[name] && boardOptions[name] !== configOptions[name]);
310
+ return { staleIds, checked: true };
311
+ }
312
+
285
313
  export function inspectBoardHygiene({ boardStages = null, repoLabels = null } = {}) {
286
314
  const canonical = STATUS_OPTIONS.map(s => s.name);
287
315
  const known = new Set(canonical);
@@ -396,6 +424,24 @@ async function checkBoardHygiene(ctx) {
396
424
  'do campo "Etapa" e rode `refresh --config`.'
397
425
  );
398
426
  }
427
+ // Divergência de ID entre o config e o Project: invisível para quem só
428
+ // compara nomes, e fatal para toda escrita de Etapa.
429
+ const { staleIds, checked } = inspectStageIds({
430
+ configOptions: cfg.project.fields?.['Etapa']?.options ?? null,
431
+ boardOptions: snapshot?.fields?.['Etapa']?.options ?? null,
432
+ });
433
+ if (checked && staleIds.length > 0) {
434
+ status = 'warn';
435
+ notes.push(
436
+ `${CONFIG_FILE} com ids de opção DEFASADOS (${staleIds.length} de ` +
437
+ `${Object.keys(cfg.project.fields['Etapa'].options).length}): ${staleIds.join(', ')}. ` +
438
+ 'Toda escrita de Etapa falha com "The single select option Id does not belong to the ' +
439
+ 'field" — e a falha é best-effort, então o card não se move e nada é reportado. ' +
440
+ `Rode \`refresh --config\` e **commite** o ${CONFIG_FILE}: quem clona o repo ` +
441
+ '(dev-agent, runner do Action) lê a versão commitada.'
442
+ );
443
+ }
444
+
399
445
  const { missingTrackStages } = inspectBugTrack({ boardStages, openBugCount });
400
446
  if (missingTrackStages.length > 0) {
401
447
  status = 'warn';
@@ -8,10 +8,11 @@ import {
8
8
  CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE, STAGE_ORDER,
9
9
  PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE, labelNames,
10
10
  } from '../config.mjs';
11
- import { getIssue, listIssueComments, listBlockedBy } from '../api/github-rest.mjs';
11
+ import { getIssue, listIssueComments, listBlockedBy, getFileContent } from '../api/github-rest.mjs';
12
12
  import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue } from '../api/github-graphql.mjs';
13
13
  import { detectIssueType } from '../lib/issue-type.mjs';
14
14
  import { bugDocPaths } from '../lib/bug-doc.mjs';
15
+ import { missingDocMessage, existsOnRemote } from '../lib/doc-availability.mjs';
15
16
  import { buildBugContext } from '../lib/bug-context.mjs';
16
17
  import { slugify } from '../lib/slugify.mjs';
17
18
  import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
@@ -393,7 +394,14 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
393
394
  bugDoc = readFileSync(fileAbs, 'utf-8');
394
395
  p.log.info(`bug.md encontrado em ${chalk.cyan(fileRel)}.`);
395
396
  } else {
396
- p.log.warn(`Sem bug.md em ${fileRel} — o contexto assume a investigação inteira.`);
397
+ const onRemote = await existsOnRemote({
398
+ getFileContent, token, owner, repo, pathRel: fileRel,
399
+ });
400
+ p.log.warn(missingDocMessage({
401
+ pathRel: fileRel,
402
+ onRemote,
403
+ fallback: 'o contexto assume a investigação inteira.',
404
+ }));
397
405
  }
398
406
 
399
407
  // Board: Bug → Desenvolvimento (In Progress). Não move a Feature-pai.
@@ -564,7 +572,15 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
564
572
  if (existsSync(featureDir)) {
565
573
  specPlan = readSpecPlan(featureDir);
566
574
  } else {
567
- p.log.warn(`Diretório da feature não encontrado (${featureDir}); seguindo só com as Stories.`);
575
+ const specRel = `docs/features/${slugify(feature.title)}/spec.md`;
576
+ const onRemote = await existsOnRemote({
577
+ getFileContent, token, owner, repo, pathRel: specRel,
578
+ });
579
+ p.log.warn(missingDocMessage({
580
+ pathRel: specRel,
581
+ onRemote,
582
+ fallback: 'seguindo só com as Stories.',
583
+ }));
568
584
  }
569
585
 
570
586
  // F7. Dependências EXTERNAS ainda abertas (da Feature e das Stories pendentes)
@@ -754,7 +770,17 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
754
770
  if (featureDir && existsSync(featureDir)) {
755
771
  specPlan = readSpecPlan(featureDir);
756
772
  } else if (featureDir) {
757
- p.log.warn(`Diretório da feature não encontrado (${featureDir}); seguindo só com as tasks.`);
773
+ const specRel = feature?.title
774
+ ? `docs/features/${slugify(feature.title)}/spec.md`
775
+ : featureDir;
776
+ const onRemote = feature?.title
777
+ ? await existsOnRemote({ getFileContent, token, owner, repo, pathRel: specRel })
778
+ : null;
779
+ p.log.warn(missingDocMessage({
780
+ pathRel: specRel,
781
+ onRemote,
782
+ fallback: 'seguindo só com as tasks.',
783
+ }));
758
784
  } else {
759
785
  p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
760
786
  }
@@ -279,5 +279,13 @@ export async function refresh(options = {}) {
279
279
  `${chalk.dim('Versão CLI:')} ${pkg.version}`,
280
280
  'Configuração atualizada'
281
281
  );
282
- p.outro(`${CONFIG_FILE} atualizado. Faça commit do arquivo para versioná-lo.`);
282
+ // O commit não é opcional: quem CLONA o repo — o dev-agent e o runner do
283
+ // Action — lê a versão commitada, não a local. Um config não commitado após
284
+ // mudança de board faz toda escrita de Etapa falhar em silêncio para eles.
285
+ p.log.warn(
286
+ `Commite o ${CONFIG_FILE} agora: ele guarda os IDS das opções do board, e quem ` +
287
+ 'clona o repositório (dev-agent, GitHub Actions) lê a versão commitada. Sem o ' +
288
+ 'commit, os cards deixam de se mover para eles — sem erro visível.'
289
+ );
290
+ p.outro(`${CONFIG_FILE} atualizado. git add ${CONFIG_FILE} && git commit -m "chore: sincroniza o board"`);
283
291
  }
@@ -596,7 +596,7 @@ export async function update(options = {}) {
596
596
  (skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
597
597
  (prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
598
598
  (!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
599
- (configPending ? ` Faça commit do ${CONFIG_FILE}.` : '') +
599
+ (configPending ? ` COMMITE o ${CONFIG_FILE} — quem clona o repo (dev-agent, Actions) lê a versão commitada.` : '') +
600
600
  (finalConfigDecision.included
601
601
  ? ` O ${CONFIG_FILE} local ficou igual ao do PR — depois do merge, descarte a cópia ` +
602
602
  `local com \`git checkout -- ${CONFIG_FILE}\`.`
@@ -0,0 +1,52 @@
1
+ // Documento ausente LOCALMENTE não é a mesma coisa que documento inexistente.
2
+ //
3
+ // spec.md, plan.md e bug.md são gerados por GitHub Action e commitados no
4
+ // REMOTO. Quem roda `implement` logo depois — que é o caso comum, porque a
5
+ // geração é o passo anterior — tem o arquivo no repositório e não no clone.
6
+ //
7
+ // A mensagem antiga dizia "sem bug.md — o contexto assume a investigação
8
+ // inteira", mandando o executor investigar do zero quando bastava um
9
+ // `git pull`. Encontrado no smoke test do RFC-004.
10
+ //
11
+ // Decisão em função pura, I/O na função imperativa: a mensagem é testável sem
12
+ // rede, e a consulta ao remoto é best-effort (nunca lança, nunca bloqueia).
13
+
14
+ /**
15
+ * Mensagem para um documento ausente no clone local (função PURA).
16
+ *
17
+ * @param {object} params
18
+ * @param {string} params.pathRel caminho do documento, relativo à raiz do repo
19
+ * @param {boolean|null} params.onRemote true = existe no remoto; false = não
20
+ * existe; null = não foi possível consultar (sem token/rede)
21
+ * @param {string} params.fallback o que o comando fará sem o documento
22
+ * @returns {string}
23
+ */
24
+ export function missingDocMessage({ pathRel, onRemote, fallback }) {
25
+ if (onRemote === true) {
26
+ return (
27
+ `${pathRel} existe no repositório mas NÃO no seu clone — rode \`git pull\` e ` +
28
+ 'repita o comando. Seguir agora ignoraria o documento já gerado.'
29
+ );
30
+ }
31
+ if (onRemote === false) {
32
+ return `Sem ${pathRel} no repositório — ${fallback}`;
33
+ }
34
+ return (
35
+ `Sem ${pathRel} no clone local (não foi possível consultar o remoto) — ${fallback} ` +
36
+ 'Se o documento já foi gerado, rode `git pull` antes.'
37
+ );
38
+ }
39
+
40
+ /**
41
+ * O documento existe no remoto? Best-effort — NUNCA lança.
42
+ *
43
+ * @returns {Promise<boolean|null>} null quando não deu para consultar
44
+ */
45
+ export async function existsOnRemote({ getFileContent, token, owner, repo, pathRel }) {
46
+ if (!token || !owner || !repo) return null;
47
+ try {
48
+ return (await getFileContent(token, owner, repo, pathRel)) != null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spec-wave",
3
3
  "displayName": "Spec Wave",
4
- "version": "0.16.1",
4
+ "version": "0.16.3",
5
5
  "description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
6
6
  "author": {
7
7
  "name": "Astratech",