specatlas 0.1.15 → 0.1.17

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/dist/bin.js CHANGED
@@ -8871,34 +8871,49 @@ function renderMarkdown(markdown2, opts = {}) {
8871
8871
  quote.push((lines[i] ?? "").replace(/^>\s?/, ""));
8872
8872
  i += 1;
8873
8873
  }
8874
- const joined = quote.join(" ");
8875
- const labelRe = /(?:^|\s)([\p{L}][\p{L}\s/()-]{1,32}):\s/gu;
8876
- const hits = [];
8877
- let hitMatch;
8878
- while ((hitMatch = labelRe.exec(joined)) !== null) {
8879
- if (hitMatch[1].trim().split(/\s+/).length > 4) continue;
8880
- hits.push({ start: hitMatch.index, end: hitMatch.index + hitMatch[0].length, label: hitMatch[1] });
8881
- }
8882
8874
  const chips = [];
8883
8875
  const prose = [];
8884
- let cursor = 0;
8885
- for (let h = 0; h < hits.length; h += 1) {
8886
- const hit = hits[h];
8887
- const before = joined.slice(cursor, hit.start).replace(/[\s·]+$/u, "").trim();
8888
- if (before) prose.push(before);
8889
- const valueEnd = h + 1 < hits.length ? hits[h + 1].start : joined.length;
8890
- let value = joined.slice(hit.end, valueEnd).replace(/[\s·]+$/u, "").trim();
8891
- const sentenceBreak = /[·.]\s+(?=[\p{Lu}][\p{L}]+(?:\s+[\p{L}]+){3,})/u.exec(value);
8892
- if (sentenceBreak) {
8893
- const note = value.slice(sentenceBreak.index + 1).trim();
8894
- if (note) prose.push(note);
8895
- value = value.slice(0, sentenceBreak.index + 1);
8876
+ const chipFor = (label, value) => `<span class="meta-chip"><b>${inlineMarkdown(label)}:</b> ${inlineMarkdown(value)}</span>`;
8877
+ for (const rawLine of quote) {
8878
+ const line2 = rawLine.trim();
8879
+ if (!line2) continue;
8880
+ const whole = /^(?:\*{0,2})([\p{L}][\p{L}\s/()-]{1,32}?)(?:\*{0,2})\s*:\s*(.+)$/u.exec(line2);
8881
+ const hasInlineLabels = / · \s*\*{0,2}[\p{L}][\p{L}\s/()-]{1,32}\*{0,2}:\s/u.test(line2);
8882
+ if (whole && !hasInlineLabels && whole[1].trim().split(/\s+/).length <= 4) {
8883
+ chips.push(chipFor(whole[1], whole[2]));
8884
+ continue;
8885
+ }
8886
+ const labelRe = /(?:^|\s|\*{0,2})([\p{L}][\p{L}\s/()-]{1,32}?)\*{0,2}:\s/gu;
8887
+ const hits = [];
8888
+ let hitMatch;
8889
+ while ((hitMatch = labelRe.exec(line2)) !== null) {
8890
+ if (hitMatch[1].trim().split(/\s+/).length > 4) continue;
8891
+ const prefix = /^\*+/.exec(hitMatch[0])?.[0].length ?? 0;
8892
+ hits.push({ start: hitMatch.index + prefix, end: hitMatch.index + hitMatch[0].length, label: hitMatch[1] });
8893
+ }
8894
+ if (hits.length === 0) {
8895
+ prose.push(line2);
8896
+ continue;
8897
+ }
8898
+ let cursor = 0;
8899
+ for (let h = 0; h < hits.length; h += 1) {
8900
+ const hit = hits[h];
8901
+ const before = line2.slice(cursor, hit.start).replace(/[\s·*]+$/u, "").trim();
8902
+ if (before) prose.push(before);
8903
+ const valueEnd = h + 1 < hits.length ? hits[h + 1].start : line2.length;
8904
+ let value = line2.slice(hit.end, valueEnd).replace(/[\s·*]+$/u, "").trim();
8905
+ const sentenceBreak = /[·.]\s+(?=[\p{Lu}][\p{L}]+(?:\s+[\p{L}]+){3,})/u.exec(value);
8906
+ if (sentenceBreak) {
8907
+ const note = value.slice(sentenceBreak.index + 1).trim();
8908
+ if (note) prose.push(note);
8909
+ value = value.slice(0, sentenceBreak.index + 1);
8910
+ }
8911
+ chips.push(chipFor(hit.label, value));
8912
+ cursor = valueEnd;
8896
8913
  }
8897
- chips.push(`<span class="meta-chip"><b>${inlineMarkdown(hit.label)}:</b> ${inlineMarkdown(value)}</span>`);
8898
- cursor = valueEnd;
8914
+ const tail = line2.slice(cursor).replace(/^[\s·*]+/u, "").trim();
8915
+ if (tail) prose.push(tail);
8899
8916
  }
8900
- const tail = joined.slice(cursor).replace(/^[\s·]+/u, "").trim();
8901
- if (tail) prose.push(tail);
8902
8917
  const chipLine = chips.length > 0 ? `<div class="meta-line">${chips.join("")}</div>` : "";
8903
8918
  const proseHtml = prose.length > 0 ? `<p>${inlineMarkdown(prose.join(" \xB7 "))}</p>` : "";
8904
8919
  out.push(`<blockquote>${chipLine}${proseHtml}</blockquote>`);
@@ -8976,6 +8991,7 @@ import path13 from "path";
8976
8991
  import path14 from "path";
8977
8992
  import path15 from "path";
8978
8993
  import { promises as fs } from "fs";
8994
+ import { cp, rename, rm } from "fs/promises";
8979
8995
  import path16 from "path";
8980
8996
  import path17 from "path";
8981
8997
  import path18 from "path";
@@ -9620,12 +9636,77 @@ function lintDelta(delta, livingRequirements, path192, opts = {}) {
9620
9636
  out.push(diag("TRACE-007", "error", `REMOVED ${req.id} no existe en la spec viva`, { path: path192, line: req.line }));
9621
9637
  }
9622
9638
  }
9623
- for (const rename of delta.renamed) {
9624
- if (rename.from.id !== rename.to.id) {
9625
- out.push(diag("LINT-DLT-003", "error", `RENAMED cambia el id (${rename.from.id} \u2192 ${rename.to.id}); los ids son inmutables`, { path: path192, line: rename.line }));
9639
+ for (const rename2 of delta.renamed) {
9640
+ if (rename2.from.id !== rename2.to.id) {
9641
+ out.push(diag("LINT-DLT-003", "error", `RENAMED cambia el id (${rename2.from.id} \u2192 ${rename2.to.id}); los ids son inmutables`, { path: path192, line: rename2.line }));
9626
9642
  }
9627
- if (!livingRequirements.has(rename.from.id)) {
9628
- out.push(diag("TRACE-007", "error", `RENAMED ${rename.from.id} no existe en la spec viva`, { path: path192, line: rename.line }));
9643
+ if (!livingRequirements.has(rename2.from.id)) {
9644
+ out.push(diag("TRACE-007", "error", `RENAMED ${rename2.from.id} no existe en la spec viva`, { path: path192, line: rename2.line }));
9645
+ }
9646
+ }
9647
+ return out;
9648
+ }
9649
+ var MERMAID_KEYWORDS = [
9650
+ "flowchart",
9651
+ "graph",
9652
+ "sequenceDiagram",
9653
+ "stateDiagram-v2",
9654
+ "stateDiagram",
9655
+ "classDiagram",
9656
+ "erDiagram",
9657
+ "journey",
9658
+ "gantt",
9659
+ "pie",
9660
+ "mindmap",
9661
+ "timeline",
9662
+ "quadrantChart",
9663
+ "xychart-beta",
9664
+ "block-beta",
9665
+ "architecture-beta"
9666
+ ];
9667
+ var MERMAID_BLOCKS = /^\s*(alt|loop|opt|par|rect|critical|break|subgraph)\b/;
9668
+ function lintPlan(planText, path192) {
9669
+ const out = [];
9670
+ const blocks = [...planText.matchAll(/```mermaid\r?\n([\s\S]*?)```/g)];
9671
+ for (const [index, block] of blocks.entries()) {
9672
+ const code = (block[1] ?? "").replace(/\r\n?/g, "\n");
9673
+ const lines = code.split("\n");
9674
+ const first = (lines.find((line) => line.trim().length > 0) ?? "").trim();
9675
+ if (!MERMAID_KEYWORDS.some((keyword) => first.startsWith(keyword))) {
9676
+ out.push(
9677
+ diag("LINT-PLN-002", "error", `Diagrama mermaid ${index + 1}: la primera l\xEDnea debe declarar el tipo (${MERMAID_KEYWORDS.slice(0, 5).join(", ")}\u2026) y empieza por "${first.slice(0, 30)}"`, {
9678
+ path: path192,
9679
+ suggestion: "Corrige el tipo del diagrama o elimina el bloque"
9680
+ })
9681
+ );
9682
+ continue;
9683
+ }
9684
+ let open = 0;
9685
+ for (const line of lines) {
9686
+ if (MERMAID_BLOCKS.test(line)) open += 1;
9687
+ else if (/^\s*end\b/.test(line)) open -= 1;
9688
+ }
9689
+ if (open !== 0) {
9690
+ out.push(
9691
+ diag("LINT-PLN-002", "error", `Diagrama mermaid ${index + 1}: faltan ${Math.abs(open)} \`end\` (bloques alt/loop/subgraph sin cerrar)`, {
9692
+ path: path192,
9693
+ suggestion: "Cierra cada bloque alt/loop/opt/par/rect/subgraph con `end`"
9694
+ })
9695
+ );
9696
+ }
9697
+ if (first.startsWith("sequenceDiagram")) {
9698
+ for (const line of lines) {
9699
+ if (!/^\s*[^\s:]+-{1,2}>{1,2}[^\s:]*\s*:\s*/.test(line)) continue;
9700
+ const message = line.slice(line.indexOf(":") + 1);
9701
+ if (message.includes(";")) {
9702
+ out.push(
9703
+ diag("LINT-PLN-003", "error", `Diagrama mermaid ${index + 1}: el mensaje "${message.trim().slice(0, 40)}\u2026" usa \`;\` y mermaid lo interpreta como fin de sentencia`, {
9704
+ path: path192,
9705
+ suggestion: "Sustituye `;` por `\xB7` o `,` en los mensajes de sequenceDiagram"
9706
+ })
9707
+ );
9708
+ }
9709
+ }
9629
9710
  }
9630
9711
  }
9631
9712
  return out;
@@ -11719,6 +11800,10 @@ async function runAnalyze(opts) {
11719
11800
  if (lane !== "fix" && !change.planPath) {
11720
11801
  findings.push(diag("ATLAS-ANALYZE-001", "warning", "El cambio no tiene plan.md (plan t\xE9cnico)", { suggestion: "Ejecuta la fase /satlas-plan" }));
11721
11802
  }
11803
+ if (change.planPath) {
11804
+ const planText = await readTextIfExists(change.planPath) ?? "";
11805
+ findings.push(...lintPlan(planText, change.planPath));
11806
+ }
11722
11807
  const deltaScenarios = [...change.delta?.added ?? [], ...change.delta?.modified ?? []].flatMap((r) => r.scenarios);
11723
11808
  const passed = new Set((change.verify?.evidence ?? []).filter((e) => e.result === "pass").map((e) => e.scenario));
11724
11809
  const evidence = { done: deltaScenarios.filter((s) => passed.has(s.id)).length, total: deltaScenarios.length };
@@ -12539,16 +12624,16 @@ function foldDelta(livingBody, delta, language = "es") {
12539
12624
  lines = [...lines.slice(0, range.start), ...lines.slice(range.end)];
12540
12625
  applied.removed.push(req.id);
12541
12626
  }
12542
- for (const rename of delta.renamed) {
12543
- const range = findRange(rename.from.id);
12627
+ for (const rename2 of delta.renamed) {
12628
+ const range = findRange(rename2.from.id);
12544
12629
  if (!range) {
12545
- diagnostics.push(diag("TRACE-007", "error", `No se puede renombrar ${rename.from.id}: no existe en la spec viva`, { path: delta.path, line: rename.line }));
12630
+ diagnostics.push(diag("TRACE-007", "error", `No se puede renombrar ${rename2.from.id}: no existe en la spec viva`, { path: delta.path, line: rename2.line }));
12546
12631
  continue;
12547
12632
  }
12548
12633
  const header = lines[range.start] ?? "";
12549
12634
  const label = /Requirement/i.test(header) ? "Requirement" : "Requisito";
12550
- lines[range.start] = header.replace(REQ_HEADER_RE, (_match, id) => `### ${label}: ${id} \u2014 ${rename.to.title}`);
12551
- applied.renamed.push(rename.from.id);
12635
+ lines[range.start] = header.replace(REQ_HEADER_RE, (_match, id) => `### ${label}: ${id} \u2014 ${rename2.to.title}`);
12636
+ applied.renamed.push(rename2.from.id);
12552
12637
  }
12553
12638
  for (const req of delta.added) {
12554
12639
  const rendered = renderRequirement(req, language);
@@ -12563,6 +12648,46 @@ function trimTrailingBlanks(lines) {
12563
12648
  while (out.length > 0 && (out[out.length - 1] ?? "").trim() === "") out.pop();
12564
12649
  return out;
12565
12650
  }
12651
+ var TRANSIENT_MOVE_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "ENOTEMPTY", "EACCES"]);
12652
+ function delay(ms) {
12653
+ return new Promise((resolve) => setTimeout(resolve, ms));
12654
+ }
12655
+ async function moveDirectory(from, to, ops = {}) {
12656
+ const doRename = ops.rename ?? rename;
12657
+ const doCopy = ops.cp ?? cp;
12658
+ const doRemove = ops.rm ?? rm;
12659
+ let lastError;
12660
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
12661
+ try {
12662
+ await doRename(from, to);
12663
+ return { strategy: "rename" };
12664
+ } catch (error) {
12665
+ lastError = error;
12666
+ const code = error.code ?? "";
12667
+ if (!TRANSIENT_MOVE_CODES.has(code)) throw error;
12668
+ await delay(250 * attempt);
12669
+ }
12670
+ }
12671
+ await doCopy(from, to, { recursive: true, force: true });
12672
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
12673
+ try {
12674
+ await doRemove(from, { recursive: true, force: true, maxRetries: 2 });
12675
+ return { strategy: "copy" };
12676
+ } catch (error) {
12677
+ lastError = error;
12678
+ await delay(350 * attempt);
12679
+ }
12680
+ }
12681
+ await doRemove(to, { recursive: true, force: true }).catch(() => void 0);
12682
+ throw lastError;
12683
+ }
12684
+ async function restoreFile(file, previous) {
12685
+ try {
12686
+ if (previous === void 0) await fs.rm(file, { force: true });
12687
+ else await writeText(file, previous);
12688
+ } catch {
12689
+ }
12690
+ }
12566
12691
  async function archiveChange(opts) {
12567
12692
  const root = path16.resolve(opts.root);
12568
12693
  const diagnostics = [];
@@ -12587,7 +12712,17 @@ async function archiveChange(opts) {
12587
12712
  }
12588
12713
  if (!opts.dryRun) {
12589
12714
  await ensureDir(path16.dirname(targetFix));
12590
- await fs.rename(change.dir, targetFix);
12715
+ try {
12716
+ await moveDirectory(change.dir, targetFix);
12717
+ } catch (error) {
12718
+ diagnostics.push(
12719
+ diag("ATLAS-ARCH-004", "error", `No se pudo mover el cambio al hist\xF3rico: ${error.message}`, {
12720
+ path: change.dir,
12721
+ suggestion: "Cierra las pesta\xF1as con archivos de este cambio (y espera unos segundos si OneDrive est\xE1 sincronizando) y vuelve a intentar"
12722
+ })
12723
+ );
12724
+ return { slug: opts.slug, fold: emptyFold, diagnostics, dryRun: false };
12725
+ }
12591
12726
  await regenerateIndex(root, config);
12592
12727
  }
12593
12728
  return { slug: opts.slug, archivedTo: targetFix, fold: emptyFold, diagnostics, dryRun: opts.dryRun ?? false };
@@ -12640,7 +12775,18 @@ ${body}`;
12640
12775
  if (!opts.dryRun) {
12641
12776
  await writeText(specFile, nextContent);
12642
12777
  await ensureDir(archiveDir);
12643
- await fs.rename(change.dir, target);
12778
+ try {
12779
+ await moveDirectory(change.dir, target);
12780
+ } catch (error) {
12781
+ await restoreFile(specFile, existing);
12782
+ diagnostics.push(
12783
+ diag("ATLAS-ARCH-004", "error", `No se pudo mover el cambio al hist\xF3rico: ${error.message}`, {
12784
+ path: change.dir,
12785
+ suggestion: "Cierra las pesta\xF1as con archivos de este cambio (y espera unos segundos si OneDrive est\xE1 sincronizando) y vuelve a intentar; el cambio y la spec viva quedaron como estaban"
12786
+ })
12787
+ );
12788
+ return { slug: opts.slug, domain, fold, diagnostics, dryRun: false };
12789
+ }
12644
12790
  await regenerateIndex(root, config, now);
12645
12791
  }
12646
12792
  return { slug: opts.slug, domain, archivedTo: target, fold, diagnostics, dryRun: opts.dryRun ?? false };
@@ -12738,6 +12884,10 @@ async function runCiGate(opts) {
12738
12884
  requireEvidence: config.gates.verify.mode !== "off" && config.gates.verify.require_evidence
12739
12885
  });
12740
12886
  const changeDiags = [...lintFindings, ...trace.findings];
12887
+ if (change.planPath) {
12888
+ const planText = await readTextIfExists(change.planPath) ?? "";
12889
+ changeDiags.push(...lintPlan(planText, change.planPath));
12890
+ }
12741
12891
  if (change.tasks && change.tasks.counts.total > 0) {
12742
12892
  const plan = planWaves(change.tasks, { maxParallel: config.waves.max_parallel });
12743
12893
  changeDiags.push(...plan.blocks.flatMap((block) => block.diagnostics));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specatlas",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "SpecAtlas: kernel determinista de Spec-Driven Development (CLI)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@specatlas/adapters": "0.1.1",
43
- "@specatlas/core": "0.1.12"
43
+ "@specatlas/core": "0.1.14"
44
44
  },
45
45
  "scripts": {
46
46
  "typecheck": "tsc --noEmit",
@@ -30,6 +30,7 @@ El contenido se escribe en **{{LANGUAGE_NAME}}**. El plan es interno: puede (y d
30
30
  1. Contexto AS-IS · 2. Enfoque técnico (con alternativas descartadas) · 3. **Diagramas** · 4. Diseño por capa/módulos · 5. Matriz de trazabilidad (REQ → tareas) · 6. Matriz de paridad AS-IS → TO-BE (solo refactors sustitutivos) · 7. Tareas (referencia) · 8. Riesgos y mitigaciones · 9. Rollback · 10. Dependencias y supuestos.
31
31
  - **Encabezado**: antes de la sección 1, una cita con los metadatos **una clave por línea** — **Cambio**, **Carril**, **Dominio**, **Spec aprobada** y **Contrato visual** (si existe) — y una última línea: «Los artefactos aprobados no se editan: este plan y `tasks.md` son los únicos artefactos que produce esta fase.»
32
32
  3. En `## 3. Diagramas` incluye los diagramas mermaid que el cambio necesite: `erDiagram` si toca datos, `sequenceDiagram` si hay integración/API/jobs, `flowchart` si hay proceso o validaciones, `stateDiagram-v2` si hay estados, `classDiagram` si el dominio no es trivial, diagrama de arquitectura si hay módulos nuevos.
33
+ - Reglas mermaid: cada bloque empieza con el tipo (`flowchart`, `sequenceDiagram`, `stateDiagram-v2`, `erDiagram`…), todo bloque (`alt`/`loop`/`opt`/`par`/`rect`/`subgraph`) se cierra con `end`, y **no uses `;` dentro de los mensajes o etiquetas** de `sequenceDiagram` (mermaid lo interpreta como fin de sentencia: usa `·` o `,`).
33
34
  4. Escribe `tasks.md` con la gramática canónica (separador ` · `):
34
35
  - `## Bloque N — Título` y `- [ ] T<N>.<seq> Acción · Archivos: ruta · Cubre: REQ-…-S1 · Depende de: T<N>.<seq> · Reversión: cómo revertir`
35
36
  - Tareas atómicas (una acción por tarea). Trabajo de infraestructura sin requisito: `· Infra`.