@specatlas/core 0.1.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 +21 -0
- package/dist/approvals-HCBC7QDM.js +7 -0
- package/dist/chunk-2BFXQ65Q.js +239 -0
- package/dist/chunk-5RKNY2ZC.js +94 -0
- package/dist/fsx-VF2P7ALA.js +26 -0
- package/dist/index.d.ts +1656 -0
- package/dist/index.js +4008 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4008 @@
|
|
|
1
|
+
import {
|
|
2
|
+
approvalsSchema,
|
|
3
|
+
artifactHash,
|
|
4
|
+
canonicalizeMarkdown,
|
|
5
|
+
changeMetaSchema,
|
|
6
|
+
countBySeverity,
|
|
7
|
+
diag,
|
|
8
|
+
hasErrors,
|
|
9
|
+
parseApprovals,
|
|
10
|
+
parseChangeMeta,
|
|
11
|
+
sha256,
|
|
12
|
+
shortHash,
|
|
13
|
+
signApproval
|
|
14
|
+
} from "./chunk-2BFXQ65Q.js";
|
|
15
|
+
import {
|
|
16
|
+
copyFile,
|
|
17
|
+
ensureDir,
|
|
18
|
+
exists,
|
|
19
|
+
isDirectory,
|
|
20
|
+
listDir,
|
|
21
|
+
listDirs,
|
|
22
|
+
readText,
|
|
23
|
+
readTextIfExists,
|
|
24
|
+
toPosix,
|
|
25
|
+
walkFiles,
|
|
26
|
+
writeText
|
|
27
|
+
} from "./chunk-5RKNY2ZC.js";
|
|
28
|
+
|
|
29
|
+
// src/version.ts
|
|
30
|
+
var CORE_VERSION = "0.0.1";
|
|
31
|
+
|
|
32
|
+
// src/model.ts
|
|
33
|
+
var REQ_ID_RE = /^REQ-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}$/;
|
|
34
|
+
var SCENARIO_ID_RE = /^REQ-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}-S\d+$/;
|
|
35
|
+
var RULE_ID_RE = /^BR-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}$/;
|
|
36
|
+
var TASK_ID_RE = /^T[A-Za-z0-9]+\.\d+$/;
|
|
37
|
+
|
|
38
|
+
// src/config.ts
|
|
39
|
+
import path from "path";
|
|
40
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
41
|
+
import { z } from "zod";
|
|
42
|
+
var CONFIG_FILE = "config.yaml";
|
|
43
|
+
var atlasConfigSchema = z.object({
|
|
44
|
+
schema_version: z.number().int().positive().default(1),
|
|
45
|
+
project: z.object({
|
|
46
|
+
name: z.string().default("proyecto"),
|
|
47
|
+
language: z.enum(["es", "en"]).default("es")
|
|
48
|
+
}).default({}),
|
|
49
|
+
lanes: z.object({
|
|
50
|
+
default: z.enum(["fix", "standard", "full"]).default("standard"),
|
|
51
|
+
allowed: z.array(z.enum(["fix", "standard", "full"])).default(["fix", "standard", "full"])
|
|
52
|
+
}).default({}),
|
|
53
|
+
gates: z.object({
|
|
54
|
+
approval: z.enum(["file", "none", "github-label"]).default("file"),
|
|
55
|
+
approval_label: z.string().default("spec-approved"),
|
|
56
|
+
analyze: z.object({ mode: z.enum(["off", "advisory", "blocking"]).default("blocking"), min_severity: z.enum(["low", "medium", "high"]).default("medium") }).default({}),
|
|
57
|
+
verify: z.object({ mode: z.enum(["off", "advisory", "blocking"]).default("blocking"), require_evidence: z.boolean().default(true) }).default({}),
|
|
58
|
+
review: z.object({ mode: z.enum(["off", "advisory", "blocking"]).default("advisory") }).default({}),
|
|
59
|
+
mockup: z.object({ require_approval: z.boolean().default(false), compare_in_verify: z.boolean().default(false) }).default({})
|
|
60
|
+
}).default({}),
|
|
61
|
+
trace: z.object({ mode: z.enum(["off", "advisory", "blocking"]).default("blocking"), prefix: z.string().default("REQ") }).default({}),
|
|
62
|
+
waves: z.object({ max_parallel: z.number().int().min(1).max(8).default(3) }).default({}),
|
|
63
|
+
ci: z.object({ drift: z.enum(["advisory", "strict"]).default("advisory") }).default({}),
|
|
64
|
+
spec: z.object({
|
|
65
|
+
language: z.enum(["es", "en"]).default("es"),
|
|
66
|
+
business_only: z.boolean().default(true),
|
|
67
|
+
glossary: z.string().default(".sdd/glossary.md")
|
|
68
|
+
}).default({}),
|
|
69
|
+
syntax: z.object({ headers: z.enum(["es", "en"]).optional() }).default({}),
|
|
70
|
+
mockups: z.object({
|
|
71
|
+
level: z.enum(["sketch", "hifi"]).default("hifi"),
|
|
72
|
+
platform: z.enum(["auto", "web", "mobile", "desktop"]).default("auto"),
|
|
73
|
+
a11y: z.enum(["off", "A", "AA", "AAA"]).default("AA")
|
|
74
|
+
}).default({}),
|
|
75
|
+
editor: z.object({ vscode: z.boolean().default(true), lsp: z.boolean().default(true) }).default({}),
|
|
76
|
+
adapters: z.object({
|
|
77
|
+
targets: z.array(z.enum(["opencode", "claude-code", "cursor", "copilot", "gemini", "codex", "generic"])).default(["opencode", "generic"])
|
|
78
|
+
}).default({}),
|
|
79
|
+
packs: z.array(z.string()).default([]),
|
|
80
|
+
integrations: z.object({ tracker: z.enum(["none", "github", "jira", "linear", "clickup"]).default("none") }).default({})
|
|
81
|
+
});
|
|
82
|
+
function defaultConfig(overrides = {}) {
|
|
83
|
+
return atlasConfigSchema.parse({
|
|
84
|
+
project: { name: overrides.name ?? "proyecto", language: overrides.language ?? "es" }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function configToYaml(cfg) {
|
|
88
|
+
const header = "# Configuraci\xF3n de SpecAtlas\n# language: es por defecto; en solo si el equipo trabaja en ingl\xE9s.\n# Los gates aceptan: off | advisory | blocking\n\n";
|
|
89
|
+
return header + stringifyYaml(cfg, { lineWidth: 120 });
|
|
90
|
+
}
|
|
91
|
+
function parseConfig(raw, configPath = CONFIG_FILE) {
|
|
92
|
+
let data;
|
|
93
|
+
try {
|
|
94
|
+
data = parseYaml(raw) ?? {};
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
config: defaultConfig(),
|
|
98
|
+
diagnostics: [diag("ATLAS-CONFIG-001", "error", `YAML inv\xE1lido en ${configPath}: ${err.message}`, { path: configPath })]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const parsed = atlasConfigSchema.safeParse(data);
|
|
102
|
+
if (!parsed.success) {
|
|
103
|
+
return {
|
|
104
|
+
config: defaultConfig(),
|
|
105
|
+
diagnostics: parsed.error.issues.map(
|
|
106
|
+
(issue) => diag("ATLAS-CONFIG-002", "error", `Configuraci\xF3n inv\xE1lida en ${issue.path.join(".") || "(ra\xEDz)"}: ${issue.message}`, {
|
|
107
|
+
path: configPath,
|
|
108
|
+
suggestion: "Corrige config.yaml seg\xFAn el esquema documentado"
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const cfg = parsed.data;
|
|
114
|
+
const diagnostics = [];
|
|
115
|
+
if (cfg.spec.language !== cfg.project.language) {
|
|
116
|
+
diagnostics.push(
|
|
117
|
+
diag("ATLAS-CONFIG-003", "warning", `spec.language (${cfg.spec.language}) difiere de project.language (${cfg.project.language})`, {
|
|
118
|
+
path: configPath,
|
|
119
|
+
suggestion: "Iguala ambos valores para evitar mezclar idiomas"
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!cfg.lanes.allowed.includes(cfg.lanes.default)) {
|
|
124
|
+
diagnostics.push(
|
|
125
|
+
diag("ATLAS-CONFIG-004", "error", `El carril por defecto "${cfg.lanes.default}" no est\xE1 en lanes.allowed`, { path: configPath })
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return { config: cfg, diagnostics };
|
|
129
|
+
}
|
|
130
|
+
async function loadConfig(sddDir) {
|
|
131
|
+
const configPath = path.join(sddDir, CONFIG_FILE);
|
|
132
|
+
const raw = await readTextIfExists(configPath);
|
|
133
|
+
if (raw === void 0) {
|
|
134
|
+
return {
|
|
135
|
+
config: defaultConfig(),
|
|
136
|
+
diagnostics: [
|
|
137
|
+
diag("ATLAS-CONFIG-000", "warning", "No existe .sdd/config.yaml; se usan valores por defecto (language: es)", {
|
|
138
|
+
path: configPath,
|
|
139
|
+
suggestion: "Ejecuta `satlas init` para generar la configuraci\xF3n"
|
|
140
|
+
})
|
|
141
|
+
]
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return parseConfig(raw, configPath);
|
|
145
|
+
}
|
|
146
|
+
async function writeConfig(sddDir, cfg) {
|
|
147
|
+
await writeText(path.join(sddDir, CONFIG_FILE), configToYaml(cfg));
|
|
148
|
+
}
|
|
149
|
+
function laneOrDefault(cfg, lane) {
|
|
150
|
+
if (lane === "fix" || lane === "standard" || lane === "full") return lane;
|
|
151
|
+
return cfg.lanes.default;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/frontmatter.ts
|
|
155
|
+
import { parse as parseYaml2, stringify as stringifyYaml2 } from "yaml";
|
|
156
|
+
var FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
157
|
+
function parseFrontmatter(content, filePath) {
|
|
158
|
+
const diagnostics = [];
|
|
159
|
+
const match = FM_RE.exec(content);
|
|
160
|
+
if (!match) {
|
|
161
|
+
return { data: {}, body: content, bodyStartLine: 1, diagnostics };
|
|
162
|
+
}
|
|
163
|
+
const rawYaml = match[1] ?? "";
|
|
164
|
+
let data;
|
|
165
|
+
try {
|
|
166
|
+
data = parseYaml2(rawYaml) ?? {};
|
|
167
|
+
} catch (err) {
|
|
168
|
+
diagnostics.push(
|
|
169
|
+
diag("LINT-STR-000", "error", `Frontmatter YAML inv\xE1lido: ${err.message}`, {
|
|
170
|
+
path: filePath,
|
|
171
|
+
line: 1
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
return { data: {}, body: content.slice(match[0].length), bodyStartLine: 1, diagnostics };
|
|
175
|
+
}
|
|
176
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
177
|
+
diagnostics.push(diag("LINT-STR-000", "error", "El frontmatter debe ser un objeto YAML", { path: filePath, line: 1 }));
|
|
178
|
+
data = {};
|
|
179
|
+
}
|
|
180
|
+
const consumed = match[0].split("\n").length - 1;
|
|
181
|
+
return { data, body: content.slice(match[0].length), bodyStartLine: consumed + 1, diagnostics };
|
|
182
|
+
}
|
|
183
|
+
function emitFrontmatter(data, body) {
|
|
184
|
+
if (Object.keys(data).length === 0) return body;
|
|
185
|
+
const yaml = stringifyYaml2(data, { lineWidth: 120 }).trimEnd();
|
|
186
|
+
return `---
|
|
187
|
+
${yaml}
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
${body}`;
|
|
191
|
+
}
|
|
192
|
+
function getString(data, key) {
|
|
193
|
+
const v = data[key];
|
|
194
|
+
return typeof v === "string" ? v : void 0;
|
|
195
|
+
}
|
|
196
|
+
function getNumber(data, key) {
|
|
197
|
+
const v = data[key];
|
|
198
|
+
return typeof v === "number" ? v : void 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/parse/spec.ts
|
|
202
|
+
var REQ_HEAD_RE = /^###\s+(?:Requirement|Requisito):\s+(REQ-[A-Z0-9-]+)\s*(?:—|-|–)\s*(.+?)\s*$/;
|
|
203
|
+
var SCENARIO_HEAD_RE = /^####\s+(?:Scenario|Escenario):\s+(REQ-[A-Z0-9-]+-S\d+)\s*(?:—|-|–)\s*(.+?)\s*$/;
|
|
204
|
+
var RULE_RE = /^-\s*(?:Rule|Regla)\s+(BR-[A-Z0-9-]+)\s*:\s*(.+?)\s*$/;
|
|
205
|
+
var WHEN_RE = /^-\s*\*\*\s*(?:WHEN|CUANDO|DADO QUE)\s*\*\*\s*(.+?)\s*$/i;
|
|
206
|
+
var THEN_RE = /^-\s*\*\*\s*(?:THEN|ENTONCES|Y|AND)\s*\*\*\s*(.+?)\s*$/i;
|
|
207
|
+
function newDraft(id, title, line) {
|
|
208
|
+
return { id, title, line, prose: [], rules: [], scenarios: [] };
|
|
209
|
+
}
|
|
210
|
+
function finalize(draft) {
|
|
211
|
+
return {
|
|
212
|
+
id: draft.id,
|
|
213
|
+
title: draft.title,
|
|
214
|
+
prose: draft.prose.join("\n").trim(),
|
|
215
|
+
rules: draft.rules,
|
|
216
|
+
scenarios: draft.scenarios,
|
|
217
|
+
line: draft.line
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function parseRequirementBlocks(body, startLine = 1, filePath) {
|
|
221
|
+
const diagnostics = [];
|
|
222
|
+
const lines = body.replace(/\r\n?/g, "\n").split("\n");
|
|
223
|
+
const requirements = [];
|
|
224
|
+
let current;
|
|
225
|
+
let currentScenario;
|
|
226
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
227
|
+
const seenScenarios = /* @__PURE__ */ new Set();
|
|
228
|
+
const pushCurrent = () => {
|
|
229
|
+
if (current) requirements.push(finalize(current));
|
|
230
|
+
current = void 0;
|
|
231
|
+
currentScenario = void 0;
|
|
232
|
+
};
|
|
233
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
234
|
+
const line = lines[i] ?? "";
|
|
235
|
+
const lineNo = startLine + i;
|
|
236
|
+
const reqMatch = REQ_HEAD_RE.exec(line);
|
|
237
|
+
if (reqMatch) {
|
|
238
|
+
pushCurrent();
|
|
239
|
+
const id = (reqMatch[1] ?? "").toUpperCase();
|
|
240
|
+
if (!REQ_ID_RE.test(id)) {
|
|
241
|
+
diagnostics.push(diag("LINT-STR-001", "error", `Id de requisito mal formado: ${id}`, { path: filePath, line: lineNo, suggestion: "Formato: REQ-DOMINIO-NNN" }));
|
|
242
|
+
}
|
|
243
|
+
if (seenIds.has(id)) {
|
|
244
|
+
diagnostics.push(diag("TRACE-008", "error", `Requisito duplicado: ${id}`, { path: filePath, line: lineNo }));
|
|
245
|
+
}
|
|
246
|
+
seenIds.add(id);
|
|
247
|
+
current = newDraft(id, reqMatch[2] ?? "", lineNo);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const scenMatch = SCENARIO_HEAD_RE.exec(line);
|
|
251
|
+
if (scenMatch) {
|
|
252
|
+
const id = (scenMatch[1] ?? "").toUpperCase();
|
|
253
|
+
if (!current) {
|
|
254
|
+
diagnostics.push(diag("LINT-STR-002", "error", `Escenario ${id} fuera de un requisito`, { path: filePath, line: lineNo }));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!SCENARIO_ID_RE.test(id)) {
|
|
258
|
+
diagnostics.push(diag("LINT-STR-001", "error", `Id de escenario mal formado: ${id}`, { path: filePath, line: lineNo }));
|
|
259
|
+
}
|
|
260
|
+
if (seenScenarios.has(id)) {
|
|
261
|
+
diagnostics.push(diag("TRACE-008", "error", `Escenario duplicado: ${id}`, { path: filePath, line: lineNo }));
|
|
262
|
+
}
|
|
263
|
+
seenScenarios.add(id);
|
|
264
|
+
currentScenario = { id, title: scenMatch[2] ?? "", reqId: current.id, when: [], then: [], line: lineNo };
|
|
265
|
+
current.scenarios.push(currentScenario);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const ruleMatch = RULE_RE.exec(line);
|
|
269
|
+
if (ruleMatch) {
|
|
270
|
+
const id = (ruleMatch[1] ?? "").toUpperCase();
|
|
271
|
+
if (!RULE_ID_RE.test(id)) {
|
|
272
|
+
diagnostics.push(diag("LINT-STR-001", "error", `Id de regla mal formado: ${id}`, { path: filePath, line: lineNo, suggestion: "Formato: BR-DOMINIO-NNN" }));
|
|
273
|
+
}
|
|
274
|
+
if (!current) {
|
|
275
|
+
diagnostics.push(diag("LINT-STR-002", "error", `Regla ${id} fuera de un requisito`, { path: filePath, line: lineNo }));
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
current.rules.push({ id, text: ruleMatch[2] ?? "", line: lineNo });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const whenMatch = WHEN_RE.exec(line);
|
|
282
|
+
if (whenMatch && currentScenario) {
|
|
283
|
+
currentScenario.when.push(whenMatch[1] ?? "");
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const thenMatch = THEN_RE.exec(line);
|
|
287
|
+
if (thenMatch && currentScenario) {
|
|
288
|
+
currentScenario.then.push(thenMatch[1] ?? "");
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (current && !currentScenario) {
|
|
292
|
+
if (line.trim() !== "") current.prose.push(line.trim());
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
pushCurrent();
|
|
296
|
+
for (const req of requirements) {
|
|
297
|
+
for (const sc of req.scenarios) {
|
|
298
|
+
if (sc.then.length === 0) {
|
|
299
|
+
diagnostics.push(diag("LINT-BIZ-005", "error", `El escenario ${sc.id} no tiene THEN/ENTONCES`, { path: filePath, line: sc.line, suggestion: "A\xF1ade al menos un resultado observable" }));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { requirements, diagnostics };
|
|
304
|
+
}
|
|
305
|
+
function parseSpecFile(content, filePath) {
|
|
306
|
+
const fm = parseFrontmatter(content, filePath);
|
|
307
|
+
const { requirements, diagnostics } = parseRequirementBlocks(fm.body, fm.bodyStartLine, filePath);
|
|
308
|
+
const spec = {
|
|
309
|
+
path: filePath,
|
|
310
|
+
frontmatter: fm.data,
|
|
311
|
+
requirements,
|
|
312
|
+
diagnostics: [...fm.diagnostics, ...diagnostics]
|
|
313
|
+
};
|
|
314
|
+
const domain = fm.data["domain"];
|
|
315
|
+
if (typeof domain === "string") spec.domain = domain;
|
|
316
|
+
const title = fm.data["title"];
|
|
317
|
+
if (typeof title === "string") spec.title = title;
|
|
318
|
+
const version = fm.data["version"];
|
|
319
|
+
if (typeof version === "number") spec.version = version;
|
|
320
|
+
return spec;
|
|
321
|
+
}
|
|
322
|
+
function renderRequirement(req, language = "es") {
|
|
323
|
+
const es = language !== "en";
|
|
324
|
+
const out = [];
|
|
325
|
+
out.push(`### ${es ? "Requisito" : "Requirement"}: ${req.id} \u2014 ${req.title}`);
|
|
326
|
+
if (req.prose) out.push(req.prose);
|
|
327
|
+
if (req.rules.length > 0) out.push("");
|
|
328
|
+
for (const rule of req.rules) out.push(`- ${es ? "Regla" : "Rule"} ${rule.id}: ${rule.text}`);
|
|
329
|
+
for (const sc of req.scenarios) {
|
|
330
|
+
out.push("");
|
|
331
|
+
out.push(`#### ${es ? "Escenario" : "Scenario"}: ${sc.id} \u2014 ${sc.title}`);
|
|
332
|
+
for (const w of sc.when) out.push(`- **${es ? "CUANDO" : "WHEN"}** ${w}`);
|
|
333
|
+
for (const t of sc.then) out.push(`- **${es ? "ENTONCES" : "THEN"}** ${t}`);
|
|
334
|
+
}
|
|
335
|
+
return out.join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/parse/delta.ts
|
|
339
|
+
var NOUN = "(?:Requirements?|Requisitos?)";
|
|
340
|
+
function sectionRe(ops) {
|
|
341
|
+
const words = ops.join("|");
|
|
342
|
+
return new RegExp(`^##\\s+(?:(?:${words})\\s+${NOUN}|${NOUN}\\s+(?:${words}))\\s*$`, "i");
|
|
343
|
+
}
|
|
344
|
+
var SECTION_DEFS = [
|
|
345
|
+
{ op: "added", re: sectionRe(["ADDED", "AGREGADOS", "AGREGADAS", "A\xD1ADIDOS", "A\xD1ADIDAS"]) },
|
|
346
|
+
{ op: "modified", re: sectionRe(["MODIFIED", "MODIFICADOS", "MODIFICADAS"]) },
|
|
347
|
+
{ op: "removed", re: sectionRe(["REMOVED", "ELIMINADOS", "ELIMINADAS", "QUITADOS", "QUITADAS"]) },
|
|
348
|
+
{ op: "renamed", re: sectionRe(["RENAMED", "RENOMBRADOS", "RENOMBRADAS"]) }
|
|
349
|
+
];
|
|
350
|
+
var FROM_RE = /^\s*-?\s*(?:FROM|DESDE):\s*(REQ-[A-Z0-9-]+)\s*(?:—|-|–)\s*(.+?)\s*$/i;
|
|
351
|
+
var TO_RE = /^\s*-?\s*(?:TO|HACIA):\s*(REQ-[A-Z0-9-]+)\s*(?:—|-|–)\s*(.+?)\s*$/i;
|
|
352
|
+
var REASON_RE = /^-\s*(?:Reason|Motivo|Razón):\s*(.+?)\s*$/im;
|
|
353
|
+
var MIGRATION_RE = /^-\s*(?:Migration|Migración):\s*(.+?)\s*$/im;
|
|
354
|
+
function parseDelta(content, filePath) {
|
|
355
|
+
const diagnostics = [];
|
|
356
|
+
const lines = content.replace(/\r\n?/g, "\n").split("\n");
|
|
357
|
+
const added = [];
|
|
358
|
+
const modified = [];
|
|
359
|
+
const removed = [];
|
|
360
|
+
const renamed = [];
|
|
361
|
+
const sectionsFound = [];
|
|
362
|
+
let currentOp;
|
|
363
|
+
let buffer = [];
|
|
364
|
+
let bufferStart = 1;
|
|
365
|
+
const flush = () => {
|
|
366
|
+
if (!currentOp || buffer.length === 0) {
|
|
367
|
+
buffer = [];
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const body = buffer.join("\n");
|
|
371
|
+
if (currentOp === "added" || currentOp === "modified") {
|
|
372
|
+
const parsed = parseRequirementBlocks(body, bufferStart, filePath);
|
|
373
|
+
diagnostics.push(...parsed.diagnostics);
|
|
374
|
+
if (currentOp === "added") added.push(...parsed.requirements);
|
|
375
|
+
else modified.push(...parsed.requirements);
|
|
376
|
+
} else if (currentOp === "removed") {
|
|
377
|
+
const parsed = parseRequirementBlocks(body, bufferStart, filePath);
|
|
378
|
+
diagnostics.push(...parsed.diagnostics);
|
|
379
|
+
for (const req of parsed.requirements) {
|
|
380
|
+
const blockText = body;
|
|
381
|
+
if (!REASON_RE.test(extractBlock(blockText, req.id))) {
|
|
382
|
+
diagnostics.push(diag("LINT-DLT-002", "error", `El requisito eliminado ${req.id} no declara "Motivo:"`, { path: filePath, line: req.line, suggestion: "A\xF1ade - Motivo: ... y - Migraci\xF3n: ..." }));
|
|
383
|
+
}
|
|
384
|
+
const hasMigration = MIGRATION_RE.test(extractBlock(blockText, req.id));
|
|
385
|
+
if (!hasMigration) {
|
|
386
|
+
diagnostics.push(diag("LINT-DLT-002", "error", `El requisito eliminado ${req.id} no declara "Migraci\xF3n:"`, { path: filePath, line: req.line, suggestion: "A\xF1ade - Migraci\xF3n: ... indicando c\xF3mo migrar" }));
|
|
387
|
+
}
|
|
388
|
+
removed.push(req);
|
|
389
|
+
}
|
|
390
|
+
} else if (currentOp === "renamed") {
|
|
391
|
+
let pendingFrom;
|
|
392
|
+
for (let i = 0; i < buffer.length; i += 1) {
|
|
393
|
+
const line = buffer[i] ?? "";
|
|
394
|
+
const from = FROM_RE.exec(line);
|
|
395
|
+
if (from) {
|
|
396
|
+
pendingFrom = { id: (from[1] ?? "").toUpperCase(), title: from[2] ?? "", line: bufferStart + i };
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const to = TO_RE.exec(line);
|
|
400
|
+
if (to) {
|
|
401
|
+
if (!pendingFrom) {
|
|
402
|
+
diagnostics.push(diag("LINT-DLT-003", "error", 'RENAMED: "TO/HACIA" sin "FROM/DESDE" previo', { path: filePath, line: bufferStart + i }));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
renamed.push({ from: { id: pendingFrom.id, title: pendingFrom.title }, to: { id: (to[1] ?? "").toUpperCase(), title: to[2] ?? "" }, line: pendingFrom.line });
|
|
406
|
+
pendingFrom = void 0;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (pendingFrom) {
|
|
410
|
+
diagnostics.push(diag("LINT-DLT-003", "error", `RENAMED: ${pendingFrom.id} sin l\xEDnea "TO/HACIA"`, { path: filePath, line: pendingFrom.line }));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
buffer = [];
|
|
414
|
+
};
|
|
415
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
416
|
+
const line = lines[i] ?? "";
|
|
417
|
+
const section = SECTION_DEFS.find((s) => s.re.test(line));
|
|
418
|
+
if (section) {
|
|
419
|
+
flush();
|
|
420
|
+
currentOp = section.op;
|
|
421
|
+
sectionsFound.push(section.op);
|
|
422
|
+
bufferStart = i + 2;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (currentOp) buffer.push(line);
|
|
426
|
+
}
|
|
427
|
+
flush();
|
|
428
|
+
const ops = new Set(sectionsFound);
|
|
429
|
+
for (const req of modified) {
|
|
430
|
+
if (req.scenarios.length === 0) {
|
|
431
|
+
diagnostics.push(diag("LINT-DLT-001", "error", `MODIFIED ${req.id} debe copiar el bloque completo (incluye escenarios)`, { path: filePath, line: req.line, suggestion: "Copia el bloque completo de la spec viva y ed\xEDtalo" }));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const delta = { path: filePath, added, modified, removed, renamed, sectionsFound: [...ops], diagnostics };
|
|
435
|
+
return delta;
|
|
436
|
+
}
|
|
437
|
+
function extractBlock(body, reqId) {
|
|
438
|
+
const lines = body.split("\n");
|
|
439
|
+
const start = lines.findIndex((l) => l.includes(reqId));
|
|
440
|
+
if (start < 0) return body;
|
|
441
|
+
let end = lines.length;
|
|
442
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
443
|
+
if (/^###\s+/.test(lines[i] ?? "")) {
|
|
444
|
+
end = i;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return lines.slice(start, end).join("\n");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/parse/tasks.ts
|
|
452
|
+
var BLOCK_HEAD_RE = /^##\s+(?:Block|Bloque)\s+([A-Za-z0-9._-]+)\s*(?:—|-|–)\s*(.+?)\s*$/;
|
|
453
|
+
var TASK_LINE_RE = /^(\s*)-\s*\[([ xX])\]\s+(T[A-Za-z0-9]+\.\d+)\s+(.+?)\s*$/;
|
|
454
|
+
var KEY_ALIASES = {
|
|
455
|
+
files: "files",
|
|
456
|
+
archivos: "files",
|
|
457
|
+
covers: "covers",
|
|
458
|
+
cubre: "covers",
|
|
459
|
+
depends: "depends",
|
|
460
|
+
"depende de": "depends",
|
|
461
|
+
rollback: "rollback",
|
|
462
|
+
reversi\u00F3n: "rollback",
|
|
463
|
+
"infra": "infra"
|
|
464
|
+
};
|
|
465
|
+
function normalizeKey(value) {
|
|
466
|
+
return value.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
467
|
+
}
|
|
468
|
+
var KEY_ALIASES_NORMALIZED = Object.fromEntries(
|
|
469
|
+
Object.entries(KEY_ALIASES).map(([key, value]) => [normalizeKey(key), value])
|
|
470
|
+
);
|
|
471
|
+
var SEPARATOR = /\s+·\s+/;
|
|
472
|
+
function parseTasksFile(content, filePath) {
|
|
473
|
+
const fm = parseFrontmatter(content, filePath);
|
|
474
|
+
const lines = fm.body.replace(/\r\n?/g, "\n").split("\n");
|
|
475
|
+
const diagnostics = [];
|
|
476
|
+
const blocks = [];
|
|
477
|
+
let current;
|
|
478
|
+
let done = 0;
|
|
479
|
+
let total = 0;
|
|
480
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
481
|
+
const line = lines[i] ?? "";
|
|
482
|
+
const lineNo = fm.bodyStartLine + i;
|
|
483
|
+
const blockMatch = BLOCK_HEAD_RE.exec(line);
|
|
484
|
+
if (blockMatch) {
|
|
485
|
+
current = { id: blockMatch[1] ?? "", title: blockMatch[2] ?? "", line: lineNo, tasks: [] };
|
|
486
|
+
blocks.push(current);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (/^##\s+/.test(line)) {
|
|
490
|
+
current = void 0;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const taskMatch = TASK_LINE_RE.exec(line);
|
|
494
|
+
if (taskMatch) {
|
|
495
|
+
const isDone = (taskMatch[2] ?? "").toLowerCase() === "x";
|
|
496
|
+
const id = taskMatch[3] ?? "";
|
|
497
|
+
const rest = taskMatch[4] ?? "";
|
|
498
|
+
if (!TASK_ID_RE.test(id)) {
|
|
499
|
+
diagnostics.push(diag("LINT-STR-003", "error", `Id de tarea mal formado: ${id}`, { path: filePath, line: lineNo, suggestion: "Formato: T<bloque>.<secuencia>, por ejemplo T1.2" }));
|
|
500
|
+
}
|
|
501
|
+
if (!current) {
|
|
502
|
+
current = { id: id.split(".")[0]?.replace(/^T/, "") ?? "?", title: "(sin bloque)", line: lineNo, tasks: [] };
|
|
503
|
+
blocks.push(current);
|
|
504
|
+
diagnostics.push(diag("LINT-STR-004", "warning", `Tarea ${id} fuera de un bloque (## Block N \u2014 T\xEDtulo)`, { path: filePath, line: lineNo }));
|
|
505
|
+
}
|
|
506
|
+
const parts = rest.split(SEPARATOR);
|
|
507
|
+
const textParts = [];
|
|
508
|
+
const fields = {};
|
|
509
|
+
let metaStarted = false;
|
|
510
|
+
for (const part of parts) {
|
|
511
|
+
const kv = /^([A-Za-zÁÉÍÓÚÑáéíóúñ ]+)\s*:\s*(.*)$/.exec(part);
|
|
512
|
+
const key = kv ? KEY_ALIASES_NORMALIZED[normalizeKey(kv[1] ?? "")] : void 0;
|
|
513
|
+
const bareKey = KEY_ALIASES_NORMALIZED[normalizeKey(part)];
|
|
514
|
+
if (kv && key) {
|
|
515
|
+
metaStarted = true;
|
|
516
|
+
fields[key] = (kv[2] ?? "").trim();
|
|
517
|
+
} else if (bareKey) {
|
|
518
|
+
metaStarted = true;
|
|
519
|
+
fields[bareKey] = "";
|
|
520
|
+
} else if (!metaStarted) {
|
|
521
|
+
textParts.push(part);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const text = textParts.join(" \xB7 ").trim();
|
|
525
|
+
const task = {
|
|
526
|
+
id,
|
|
527
|
+
block: current.id,
|
|
528
|
+
text,
|
|
529
|
+
done: isDone,
|
|
530
|
+
files: splitList(fields["files"]),
|
|
531
|
+
covers: splitList(fields["covers"]).map((c) => c.toUpperCase()),
|
|
532
|
+
dependsOn: splitList(fields["depends"]).map((d) => d.toUpperCase()),
|
|
533
|
+
infra: fields["infra"] !== void 0,
|
|
534
|
+
line: lineNo
|
|
535
|
+
};
|
|
536
|
+
if (fields["rollback"] !== void 0) task.rollback = fields["rollback"];
|
|
537
|
+
if (task.files.length === 0 && !task.infra) {
|
|
538
|
+
diagnostics.push(diag("LINT-TSK-001", "warning", `La tarea ${id} no declara Archivos:`, { path: filePath, line: lineNo, suggestion: "A\xF1ade \xB7 Archivos: ruta/archivo.ext" }));
|
|
539
|
+
}
|
|
540
|
+
if (task.covers.length === 0 && !task.infra) {
|
|
541
|
+
diagnostics.push(diag("TRACE-004", "error", `La tarea ${id} no declara Cubre:`, { path: filePath, line: lineNo, suggestion: "A\xF1ade \xB7 Cubre: REQ-\u2026-S1 o marca \xB7 Infra si es trabajo de infraestructura" }));
|
|
542
|
+
}
|
|
543
|
+
current.tasks.push(task);
|
|
544
|
+
total += 1;
|
|
545
|
+
if (isDone) done += 1;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return { path: filePath, blocks, diagnostics, counts: { done, total } };
|
|
550
|
+
}
|
|
551
|
+
function splitList(value) {
|
|
552
|
+
if (!value) return [];
|
|
553
|
+
return value.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/parse/evidence.ts
|
|
557
|
+
import { parse as parseYaml3 } from "yaml";
|
|
558
|
+
import { z as z2 } from "zod";
|
|
559
|
+
var evidenceSchema = z2.object({
|
|
560
|
+
scenario: z2.string().optional(),
|
|
561
|
+
method: z2.enum(["executable", "automatic", "semi", "manual"]),
|
|
562
|
+
command: z2.string().optional(),
|
|
563
|
+
result: z2.enum(["pass", "fail", "skipped"]),
|
|
564
|
+
output_hash: z2.string().optional(),
|
|
565
|
+
date: z2.string(),
|
|
566
|
+
by: z2.string(),
|
|
567
|
+
notes: z2.string().optional()
|
|
568
|
+
});
|
|
569
|
+
var FENCE_RE = /```evidence[ \t]*\r?\n([\s\S]*?)```/g;
|
|
570
|
+
var HEAD_SCENARIO_RE = /^###\s+(REQ-[A-Z0-9-]+-S\d+)\b/;
|
|
571
|
+
function parseVerifyFile(content, filePath) {
|
|
572
|
+
const diagnostics = [];
|
|
573
|
+
const evidence = [];
|
|
574
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
575
|
+
const lines = normalized.split("\n");
|
|
576
|
+
for (const match of normalized.matchAll(FENCE_RE)) {
|
|
577
|
+
const startIndex = match.index ?? 0;
|
|
578
|
+
const line = normalized.slice(0, startIndex).split("\n").length;
|
|
579
|
+
const raw = match[1] ?? "";
|
|
580
|
+
let data;
|
|
581
|
+
try {
|
|
582
|
+
data = parseYaml3(raw);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
diagnostics.push(diag("LINT-EVD-000", "error", `Bloque evidence con YAML inv\xE1lido: ${err.message}`, { path: filePath, line }));
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const parsed = evidenceSchema.safeParse(data);
|
|
588
|
+
if (!parsed.success) {
|
|
589
|
+
for (const issue of parsed.error.issues) {
|
|
590
|
+
diagnostics.push(diag("LINT-EVD-001", "error", `Evidencia inv\xE1lida (${issue.path.join(".") || "ra\xEDz"}): ${issue.message}`, { path: filePath, line }));
|
|
591
|
+
}
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const value = parsed.data;
|
|
595
|
+
let scenario = value.scenario?.toUpperCase();
|
|
596
|
+
if (!scenario) {
|
|
597
|
+
scenario = findScenarioHeading(lines, line - 1);
|
|
598
|
+
}
|
|
599
|
+
if (!scenario) {
|
|
600
|
+
diagnostics.push(diag("LINT-EVD-002", "error", "La evidencia no indica escenario (clave scenario: o encabezado ### REQ-\u2026-S1)", { path: filePath, line }));
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (!SCENARIO_ID_RE.test(scenario)) {
|
|
604
|
+
diagnostics.push(diag("LINT-EVD-002", "error", `Escenario mal formado en evidencia: ${scenario}`, { path: filePath, line }));
|
|
605
|
+
}
|
|
606
|
+
if (value.method === "executable" && !value.command) {
|
|
607
|
+
diagnostics.push(diag("LINT-EVD-001", "error", `La evidencia ${scenario} es ejecutable pero no declara command`, { path: filePath, line }));
|
|
608
|
+
}
|
|
609
|
+
if (value.output_hash !== void 0 && !/^sha256:[0-9a-f]{8,64}$/i.test(value.output_hash)) {
|
|
610
|
+
diagnostics.push(diag("LINT-EVD-003", "warning", `output_hash con formato inesperado en ${scenario}`, { path: filePath, line, suggestion: "Formato: sha256:<hex>" }));
|
|
611
|
+
}
|
|
612
|
+
const ev = {
|
|
613
|
+
scenario,
|
|
614
|
+
method: value.method,
|
|
615
|
+
result: value.result,
|
|
616
|
+
date: value.date,
|
|
617
|
+
by: value.by,
|
|
618
|
+
line
|
|
619
|
+
};
|
|
620
|
+
if (value.command !== void 0) ev.command = value.command;
|
|
621
|
+
if (value.output_hash !== void 0) ev.outputHash = value.output_hash;
|
|
622
|
+
if (value.notes !== void 0) ev.notes = value.notes;
|
|
623
|
+
evidence.push(ev);
|
|
624
|
+
}
|
|
625
|
+
return { path: filePath, evidence, diagnostics };
|
|
626
|
+
}
|
|
627
|
+
function findScenarioHeading(lines, blockLineIndex) {
|
|
628
|
+
for (let i = blockLineIndex; i >= 0; i -= 1) {
|
|
629
|
+
const m = HEAD_SCENARIO_RE.exec(lines[i] ?? "");
|
|
630
|
+
if (m) return (m[1] ?? "").toUpperCase();
|
|
631
|
+
if (/^###\s+/.test(lines[i] ?? "") && !HEAD_SCENARIO_RE.test(lines[i] ?? "")) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return void 0;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/lint.ts
|
|
639
|
+
var VAGUE_ES = ["r\xE1pido", "r\xE1pida", "r\xE1pidos", "r\xE1pidas", "f\xE1cil", "f\xE1ciles", "varios", "varias", "\xF3ptimo", "\xF3ptima", "robusto", "robusta", "adecuado", "adecuada", "eficiente", "amigable", "moderno", "moderna", "mejor", "mejores", "simple", "sencillo", "intuitivo", "intuitiva", "apropiado", "apropiada", "suficiente", "razonable"];
|
|
640
|
+
var VAGUE_EN = ["fast", "quick", "easy", "several", "optimal", "robust", "adequate", "efficient", "friendly", "modern", "better", "best", "simple", "intuitive", "appropriate", "sufficient", "reasonable", "nice", "clean"];
|
|
641
|
+
var TECH_ES = ["api", "endpoint", "tabla", "base de datos", "sql", "json", "xml", "frontend", "backend", "react", "vue", "angular", "node", "npm", "docker", "kubernetes", "servidor", "columna", "query", "script", "framework", "librer\xEDa", "microservicio", "cache", "cach\xE9", "deploy", "despliegue", "commit", "merge", "rama", "branch", "endpoint", "crud", "rest", "http", "sdk", "webhook", "orm", "job", "cron", "websocket", "graphql", "api rest", "tablas", "\xEDndice", "primary key", "foreign key"];
|
|
642
|
+
var TECH_EN = ["database", "repository", "table", "column", "server", "deploy", "deployment", "branch", "merge", "commit", "cache", "queue", "endpoint", "microservice", "framework", "library", "package", "schema", "migration"];
|
|
643
|
+
function wordBoundary(text, term) {
|
|
644
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
645
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "iu");
|
|
646
|
+
return re.test(text);
|
|
647
|
+
}
|
|
648
|
+
function lintText(text, code, terms, label, path18, line) {
|
|
649
|
+
const out = [];
|
|
650
|
+
const lower = text.toLowerCase();
|
|
651
|
+
for (const term of terms) {
|
|
652
|
+
if (wordBoundary(lower, term)) {
|
|
653
|
+
out.push(
|
|
654
|
+
diag(code, "error", `${label}: "${term}"`, {
|
|
655
|
+
path: path18,
|
|
656
|
+
line,
|
|
657
|
+
suggestion: "La especificaci\xF3n es funcional y de negocio: describe comportamiento, no tecnolog\xEDa ni adjetivos vagos"
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
function lintRequirement(req, path18, opts = {}) {
|
|
665
|
+
const out = [];
|
|
666
|
+
const vague = opts.language === "en" ? VAGUE_EN : VAGUE_ES;
|
|
667
|
+
const tech = opts.language === "en" ? TECH_EN : TECH_ES;
|
|
668
|
+
const parts = [
|
|
669
|
+
{ text: req.title, line: req.line },
|
|
670
|
+
{ text: req.prose, line: req.line },
|
|
671
|
+
...req.rules.map((r) => ({ text: r.text, line: r.line })),
|
|
672
|
+
...req.scenarios.flatMap((s) => [...s.when.map((w) => ({ text: w, line: s.line })), ...s.then.map((t) => ({ text: t, line: s.line }))])
|
|
673
|
+
];
|
|
674
|
+
for (const part of parts) {
|
|
675
|
+
out.push(...lintText(part.text, "LINT-BIZ-002", vague, "Palabra vaga en la especificaci\xF3n", path18, part.line));
|
|
676
|
+
if (opts.businessOnly !== false) {
|
|
677
|
+
out.push(...lintText(part.text, "LINT-BIZ-001", tech, "Jerga t\xE9cnica en la especificaci\xF3n de negocio", path18, part.line));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (req.scenarios.length === 0) {
|
|
681
|
+
out.push(diag("TRACE-001", "error", `El requisito ${req.id} no tiene ning\xFAn escenario`, { path: path18, line: req.line, suggestion: "A\xF1ade al menos un escenario CUANDO/ENTONCES" }));
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
function lintDelta(delta, livingRequirements, path18, opts = {}) {
|
|
686
|
+
const out = [...delta.diagnostics];
|
|
687
|
+
for (const req of [...delta.added, ...delta.modified]) {
|
|
688
|
+
out.push(...lintRequirement(req, path18, opts));
|
|
689
|
+
}
|
|
690
|
+
for (const req of delta.modified) {
|
|
691
|
+
const living = livingRequirements.get(req.id);
|
|
692
|
+
if (!living) {
|
|
693
|
+
out.push(diag("TRACE-007", "error", `MODIFIED ${req.id} no existe en la spec viva; usa ADDED`, { path: path18, line: req.line }));
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
for (const existing of living.scenarios) {
|
|
697
|
+
if (!req.scenarios.some((s) => s.id === existing.id)) {
|
|
698
|
+
out.push(
|
|
699
|
+
diag("TRACE-007", "error", `MODIFIED ${req.id} pierde el escenario ${existing.id}: copia el bloque completo`, {
|
|
700
|
+
path: path18,
|
|
701
|
+
line: req.line,
|
|
702
|
+
suggestion: "Copia el bloque completo de la spec viva y ed\xEDtalo; para quitarlo, decl\xE1ralo en REMOVED"
|
|
703
|
+
})
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (const req of delta.removed) {
|
|
709
|
+
const living = livingRequirements.get(req.id);
|
|
710
|
+
if (!living) {
|
|
711
|
+
out.push(diag("TRACE-007", "error", `REMOVED ${req.id} no existe en la spec viva`, { path: path18, line: req.line }));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const rename of delta.renamed) {
|
|
715
|
+
if (rename.from.id !== rename.to.id) {
|
|
716
|
+
out.push(diag("LINT-DLT-003", "error", `RENAMED cambia el id (${rename.from.id} \u2192 ${rename.to.id}); los ids son inmutables`, { path: path18, line: rename.line }));
|
|
717
|
+
}
|
|
718
|
+
if (!livingRequirements.has(rename.from.id)) {
|
|
719
|
+
out.push(diag("TRACE-007", "error", `RENAMED ${rename.from.id} no existe en la spec viva`, { path: path18, line: rename.line }));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
function lintTasks(_tasks) {
|
|
725
|
+
return [];
|
|
726
|
+
}
|
|
727
|
+
function lintVerify(_verify) {
|
|
728
|
+
return [];
|
|
729
|
+
}
|
|
730
|
+
function lintSpec(spec, opts = {}) {
|
|
731
|
+
const out = [...spec.diagnostics];
|
|
732
|
+
for (const req of spec.requirements) {
|
|
733
|
+
for (const d of lintRequirement(req, spec.path, opts)) {
|
|
734
|
+
if (d.code === "TRACE-001") continue;
|
|
735
|
+
out.push(d);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/trace.ts
|
|
742
|
+
function buildTraceGraph(input) {
|
|
743
|
+
const nodes = [];
|
|
744
|
+
const edges = [];
|
|
745
|
+
const { change } = input;
|
|
746
|
+
const reqIds = /* @__PURE__ */ new Set();
|
|
747
|
+
const scenarioIds = /* @__PURE__ */ new Set();
|
|
748
|
+
for (const spec of input.specs) {
|
|
749
|
+
for (const req of spec.spec.requirements) {
|
|
750
|
+
reqIds.add(req.id);
|
|
751
|
+
nodes.push({ kind: "requirement", id: req.id });
|
|
752
|
+
for (const sc of req.scenarios) {
|
|
753
|
+
scenarioIds.add(sc.id);
|
|
754
|
+
nodes.push({ kind: "scenario", id: sc.id });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
for (const deltaList of [change.delta?.added ?? [], change.delta?.modified ?? []]) {
|
|
759
|
+
for (const req of deltaList) {
|
|
760
|
+
if (!reqIds.has(req.id)) nodes.push({ kind: "requirement", id: req.id });
|
|
761
|
+
reqIds.add(req.id);
|
|
762
|
+
for (const sc of req.scenarios) {
|
|
763
|
+
if (!scenarioIds.has(sc.id)) nodes.push({ kind: "scenario", id: sc.id });
|
|
764
|
+
scenarioIds.add(sc.id);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (const block of change.tasks?.blocks ?? []) {
|
|
769
|
+
for (const task of block.tasks) {
|
|
770
|
+
nodes.push({ kind: "task", id: task.id });
|
|
771
|
+
for (const cover of task.covers) edges.push({ from: task.id, to: cover, kind: "covers" });
|
|
772
|
+
for (const dep of task.dependsOn) edges.push({ from: task.id, to: dep, kind: "depends" });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
for (const ev of change.verify?.evidence ?? []) {
|
|
776
|
+
nodes.push({ kind: "evidence", id: `${ev.scenario}@${ev.line}` });
|
|
777
|
+
edges.push({ from: `${ev.scenario}@${ev.line}`, to: ev.scenario, kind: "evidences" });
|
|
778
|
+
}
|
|
779
|
+
return { nodes, edges };
|
|
780
|
+
}
|
|
781
|
+
function checkTrace(input) {
|
|
782
|
+
const { change, specs, requireEvidence } = input;
|
|
783
|
+
const findings = [];
|
|
784
|
+
const livingReqs = /* @__PURE__ */ new Map();
|
|
785
|
+
const allScenarioIds = /* @__PURE__ */ new Set();
|
|
786
|
+
for (const spec of specs) {
|
|
787
|
+
for (const req of spec.spec.requirements) {
|
|
788
|
+
livingReqs.set(req.id, { id: req.id, scenarios: req.scenarios.map((s) => s.id) });
|
|
789
|
+
for (const sc of req.scenarios) allScenarioIds.add(sc.id);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const deltaAdded = change.delta?.added ?? [];
|
|
793
|
+
const deltaModified = change.delta?.modified ?? [];
|
|
794
|
+
const deltaScenarios = /* @__PURE__ */ new Set();
|
|
795
|
+
for (const req of [...deltaAdded, ...deltaModified]) {
|
|
796
|
+
for (const sc of req.scenarios) {
|
|
797
|
+
deltaScenarios.add(sc.id);
|
|
798
|
+
allScenarioIds.add(sc.id);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const taskById = /* @__PURE__ */ new Map();
|
|
802
|
+
const covers = /* @__PURE__ */ new Set();
|
|
803
|
+
for (const block of change.tasks?.blocks ?? []) {
|
|
804
|
+
for (const task of block.tasks) {
|
|
805
|
+
taskById.set(task.id, { id: task.id, covers: task.covers, dependsOn: task.dependsOn, block: task.block });
|
|
806
|
+
for (const c of task.covers) covers.add(c);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const coversExpanded = new Set(covers);
|
|
810
|
+
const deltaReqScenarios = /* @__PURE__ */ new Map();
|
|
811
|
+
for (const req of [...deltaAdded, ...deltaModified]) deltaReqScenarios.set(req.id, req.scenarios.map((s) => s.id));
|
|
812
|
+
for (const c of covers) {
|
|
813
|
+
const req = livingReqs.get(c);
|
|
814
|
+
if (req) for (const sc of req.scenarios) coversExpanded.add(sc);
|
|
815
|
+
const deltaScenariosOfReq = deltaReqScenarios.get(c);
|
|
816
|
+
if (deltaScenariosOfReq) for (const sc of deltaScenariosOfReq) coversExpanded.add(sc);
|
|
817
|
+
}
|
|
818
|
+
for (const req of [...deltaAdded, ...deltaModified]) {
|
|
819
|
+
if (req.scenarios.length === 0) {
|
|
820
|
+
findings.push(diag("TRACE-001", "error", `El requisito ${req.id} no tiene escenarios`, { path: change.delta?.path, line: req.line }));
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
for (const sc of deltaScenarios) {
|
|
824
|
+
if (!coversExpanded.has(sc)) {
|
|
825
|
+
findings.push(
|
|
826
|
+
diag("TRACE-002", "error", `El escenario ${sc} no est\xE1 cubierto por ninguna tarea`, {
|
|
827
|
+
path: change.tasks?.path ?? change.delta?.path,
|
|
828
|
+
suggestion: `A\xF1ade una tarea con \xB7 Cubre: ${sc}`
|
|
829
|
+
})
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
for (const task of taskById.values()) {
|
|
834
|
+
for (const c of task.covers) {
|
|
835
|
+
if (!livingReqs.has(c) && !deltaScenarios.has(c) && !allScenarioIds.has(c)) {
|
|
836
|
+
findings.push(
|
|
837
|
+
diag("TRACE-003", "error", `La tarea ${task.id} cubre ${c}, que no existe`, {
|
|
838
|
+
path: change.tasks?.path,
|
|
839
|
+
suggestion: "Corrige el id o crea el requisito/escenario"
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const dep of task.dependsOn) {
|
|
845
|
+
const target = taskById.get(dep);
|
|
846
|
+
if (!target) {
|
|
847
|
+
findings.push(diag("TRACE-009", "error", `La tarea ${task.id} depende de ${dep}, que no existe`, { path: change.tasks?.path }));
|
|
848
|
+
} else if (target.block !== task.block) {
|
|
849
|
+
findings.push(diag("TRACE-009", "warning", `La tarea ${task.id} depende de ${dep} de otro bloque (${target.block})`, { path: change.tasks?.path }));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const cycles = findCycles(taskById);
|
|
854
|
+
for (const cycle of cycles) {
|
|
855
|
+
findings.push(diag("TRACE-010", "error", `Ciclo de dependencias: ${cycle.join(" \u2192 ")}`, { path: change.tasks?.path }));
|
|
856
|
+
}
|
|
857
|
+
const tasksDone = change.tasks !== void 0 && change.tasks.counts.total > 0 && change.tasks.counts.done === change.tasks.counts.total;
|
|
858
|
+
if (requireEvidence && tasksDone) {
|
|
859
|
+
const passed = new Set((change.verify?.evidence ?? []).filter((e) => e.result === "pass").map((e) => e.scenario));
|
|
860
|
+
for (const sc of deltaScenarios) {
|
|
861
|
+
if (!passed.has(sc)) {
|
|
862
|
+
findings.push(
|
|
863
|
+
diag("TRACE-005", "error", `El escenario ${sc} no tiene evidencia de verificaci\xF3n (result: pass)`, {
|
|
864
|
+
path: change.verify?.path ?? `changes/${change.slug}/verify.md`,
|
|
865
|
+
suggestion: `Registra evidencia: satlas verify --record ${sc} --command "\u2026"`
|
|
866
|
+
})
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const knownScenarios = /* @__PURE__ */ new Set([...allScenarioIds, ...deltaScenarios]);
|
|
872
|
+
for (const ev of change.verify?.evidence ?? []) {
|
|
873
|
+
if (!knownScenarios.has(ev.scenario)) {
|
|
874
|
+
findings.push(diag("TRACE-006", "warning", `Evidencia hu\xE9rfana: ${ev.scenario} no existe`, { path: change.verify?.path, line: ev.line }));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const summary = countBySeverity(findings);
|
|
878
|
+
return { graph: buildTraceGraph(input), findings, summary };
|
|
879
|
+
}
|
|
880
|
+
function findCycles(taskById) {
|
|
881
|
+
const visited = /* @__PURE__ */ new Set();
|
|
882
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
883
|
+
const cycles = [];
|
|
884
|
+
const visit = (id, stack) => {
|
|
885
|
+
if (inStack.has(id)) {
|
|
886
|
+
const idx = stack.indexOf(id);
|
|
887
|
+
cycles.push([...stack.slice(idx), id]);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (visited.has(id)) return;
|
|
891
|
+
visited.add(id);
|
|
892
|
+
inStack.add(id);
|
|
893
|
+
stack.push(id);
|
|
894
|
+
for (const dep of taskById.get(id)?.dependsOn ?? []) {
|
|
895
|
+
if (taskById.has(dep)) visit(dep, stack);
|
|
896
|
+
}
|
|
897
|
+
stack.pop();
|
|
898
|
+
inStack.delete(id);
|
|
899
|
+
};
|
|
900
|
+
for (const id of taskById.keys()) visit(id, []);
|
|
901
|
+
return cycles;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/waves.ts
|
|
905
|
+
function compareTaskIds(a, b) {
|
|
906
|
+
const parse = (id) => {
|
|
907
|
+
const m = /^T([A-Za-z0-9]+)\.(\d+)$/.exec(id);
|
|
908
|
+
if (!m) return [id, 0, 0];
|
|
909
|
+
const blockPart = m[1] ?? "";
|
|
910
|
+
const blockNum = Number.parseInt(blockPart, 10);
|
|
911
|
+
const isNumeric = !Number.isNaN(blockNum);
|
|
912
|
+
return [isNumeric ? "" : blockPart, isNumeric ? blockNum : 0, Number.parseInt(m[2] ?? "0", 10)];
|
|
913
|
+
};
|
|
914
|
+
const pa = parse(a);
|
|
915
|
+
const pb = parse(b);
|
|
916
|
+
if (pa[0] !== pb[0]) return pa[0] < pb[0] ? -1 : 1;
|
|
917
|
+
if (pa[1] !== pb[1]) return pa[1] - pb[1];
|
|
918
|
+
return pa[2] - pb[2];
|
|
919
|
+
}
|
|
920
|
+
function normalizeFile(file) {
|
|
921
|
+
return toPosix(file.trim().toLowerCase());
|
|
922
|
+
}
|
|
923
|
+
function planWaves(tasks, opts = {}) {
|
|
924
|
+
const maxParallel = Math.max(1, Math.min(opts.maxParallel ?? 3, 8));
|
|
925
|
+
const blocks = [];
|
|
926
|
+
let totalWaves = 0;
|
|
927
|
+
for (const block of tasks.blocks) {
|
|
928
|
+
const plan = planBlock(block, maxParallel);
|
|
929
|
+
blocks.push(plan);
|
|
930
|
+
totalWaves += plan.waves.length;
|
|
931
|
+
}
|
|
932
|
+
return {
|
|
933
|
+
maxParallel,
|
|
934
|
+
blocks,
|
|
935
|
+
summary: { blocks: blocks.length, waves: totalWaves, tasks: tasks.counts.total }
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function planBlock(block, maxParallel) {
|
|
939
|
+
const diagnostics = [];
|
|
940
|
+
const degraded = [];
|
|
941
|
+
const allIds = new Set(block.tasks.map((t) => t.id));
|
|
942
|
+
const pending = block.tasks.filter((t) => !t.done).sort((a, b) => compareTaskIds(a.id, b.id));
|
|
943
|
+
const remaining = new Set(pending.map((t) => t.id));
|
|
944
|
+
for (const task of pending) {
|
|
945
|
+
for (const dep of task.dependsOn) {
|
|
946
|
+
if (dep.startsWith("T") && !allIds.has(dep)) {
|
|
947
|
+
diagnostics.push(diag("TRACE-009", "error", `La tarea ${task.id} depende de ${dep}, que no existe en el bloque`, { line: task.line }));
|
|
948
|
+
degraded.push({ task: task.id, reason: "orphan-dependency", movedToWave: 0 });
|
|
949
|
+
remaining.delete(task.id);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const waves = [];
|
|
954
|
+
let guard = 0;
|
|
955
|
+
while (remaining.size > 0) {
|
|
956
|
+
guard += 1;
|
|
957
|
+
if (guard > pending.length + 2) {
|
|
958
|
+
for (const id of remaining) {
|
|
959
|
+
const task = pending.find((t) => t.id === id);
|
|
960
|
+
diagnostics.push(diag("TRACE-010", "error", `No se puede planificar ${id}: dependencia circular`, { line: task?.line }));
|
|
961
|
+
}
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
const ready = pending.filter((t) => remaining.has(t.id) && t.dependsOn.every((dep) => !remaining.has(dep)));
|
|
965
|
+
if (ready.length === 0) {
|
|
966
|
+
for (const id of remaining) {
|
|
967
|
+
const task = pending.find((t) => t.id === id);
|
|
968
|
+
diagnostics.push(diag("TRACE-010", "error", `La tarea ${id} no puede planificarse: dependencia bloqueada`, { line: task?.line }));
|
|
969
|
+
}
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
const wave = [];
|
|
973
|
+
const filesInWave = /* @__PURE__ */ new Set();
|
|
974
|
+
for (const task of ready) {
|
|
975
|
+
if (wave.length >= maxParallel) break;
|
|
976
|
+
const files = task.files.map(normalizeFile);
|
|
977
|
+
if (files.some((f) => filesInWave.has(f))) continue;
|
|
978
|
+
for (const f of files) filesInWave.add(f);
|
|
979
|
+
wave.push(task);
|
|
980
|
+
}
|
|
981
|
+
if (wave.length === 0) {
|
|
982
|
+
const first = ready[0];
|
|
983
|
+
if (first) wave.push(first);
|
|
984
|
+
}
|
|
985
|
+
for (const task of wave) remaining.delete(task.id);
|
|
986
|
+
waves.push(wave);
|
|
987
|
+
for (const task of pending) {
|
|
988
|
+
if (!remaining.has(task.id)) continue;
|
|
989
|
+
if (task.files.map(normalizeFile).some((f) => filesInWave.has(f))) {
|
|
990
|
+
degraded.push({ task: task.id, reason: "file-collision", movedToWave: waves.length + 1 });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { block: block.id, title: block.title, waves, degraded, diagnostics };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/lifecycle.ts
|
|
998
|
+
import path2 from "path";
|
|
999
|
+
function verifyApproval(change, approvals, cfg, specContent) {
|
|
1000
|
+
if (cfg.gates.approval === "none") return { status: "not_required" };
|
|
1001
|
+
if ((change.meta?.overrides ?? []).some((o) => o.gate === "approval")) return { status: "overridden" };
|
|
1002
|
+
if (!change.delta) return { status: "missing" };
|
|
1003
|
+
const key = path2.posix.join("changes", change.slug, "spec.md");
|
|
1004
|
+
const approval = approvals.get(key) ?? approvals.get(`${key}`);
|
|
1005
|
+
if (!approval) return { status: "missing" };
|
|
1006
|
+
if (specContent === void 0) return { status: "valid", approvedBy: approval.by, approvedAt: approval.at };
|
|
1007
|
+
const current = artifactHash(specContent);
|
|
1008
|
+
if (current !== approval.hash) return { status: "stale", approvedBy: approval.by, approvedAt: approval.at };
|
|
1009
|
+
return { status: "valid", approvedBy: approval.by, approvedAt: approval.at };
|
|
1010
|
+
}
|
|
1011
|
+
function deriveState(input) {
|
|
1012
|
+
const { change, cfg, approval, blockingFindings } = input;
|
|
1013
|
+
const lane = change.meta?.lane ?? cfg.lanes.default;
|
|
1014
|
+
const tasksTotal = change.tasks?.counts.total ?? 0;
|
|
1015
|
+
const tasksDone = change.tasks?.counts.done ?? 0;
|
|
1016
|
+
const deltaScenarios = [...change.delta?.added ?? [], ...change.delta?.modified ?? []].flatMap((r) => r.scenarios);
|
|
1017
|
+
const passed = new Set((change.verify?.evidence ?? []).filter((e) => e.result === "pass").map((e) => e.scenario));
|
|
1018
|
+
const scenariosEvidenced = deltaScenarios.filter((s) => passed.has(s.id)).length;
|
|
1019
|
+
const progress = { tasksDone, tasksTotal, scenariosEvidenced, scenariosTotal: deltaScenarios.length };
|
|
1020
|
+
const blockedBy = [];
|
|
1021
|
+
const next = (command, description, requiresAgent = false) => ({ command, description, requiresAgent });
|
|
1022
|
+
if (change.meta?.paused) {
|
|
1023
|
+
return { state: "building", blockedBy: [`pausado: ${change.meta.paused.reason}`], nextAction: next(`satlas resume ${change.slug}`, "Reanudar el cambio pausado"), progress };
|
|
1024
|
+
}
|
|
1025
|
+
if (lane === "fix") {
|
|
1026
|
+
const fixEvidence = change.fix?.evidence ?? [];
|
|
1027
|
+
if (fixEvidence.length === 0) {
|
|
1028
|
+
return { state: "draft", blockedBy, nextAction: next(`/satlas-fix ${change.slug}`, "Investigar la causa ra\xEDz, aplicar el fix y registrar la evidencia", true), progress };
|
|
1029
|
+
}
|
|
1030
|
+
if (!fixEvidence.some((e) => e.result === "pass")) {
|
|
1031
|
+
blockedBy.push("la evidencia del fix no est\xE1 en pass");
|
|
1032
|
+
return { state: "built", blockedBy, nextAction: next(`satlas verify ${change.slug} --file fix`, "Corregir y registrar la evidencia del fix"), progress };
|
|
1033
|
+
}
|
|
1034
|
+
return { state: "ready", blockedBy, nextAction: next(`satlas archive ${change.slug}`, "Archivar el fix (carril express, sin plegar deltas)"), progress };
|
|
1035
|
+
}
|
|
1036
|
+
if (!change.delta) {
|
|
1037
|
+
return { state: "draft", blockedBy, nextAction: next(`/satlas.specify ${change.slug}`, "Especificar el cambio (spec funcional y de negocio)", true), progress };
|
|
1038
|
+
}
|
|
1039
|
+
if (blockingFindings > 0) {
|
|
1040
|
+
blockedBy.push(`${blockingFindings} hallazgo(s) bloqueante(s)`);
|
|
1041
|
+
return { state: "spec_draft", blockedBy, nextAction: next(`satlas validate --change ${change.slug}`, "Corregir los hallazgos de la especificaci\xF3n"), progress };
|
|
1042
|
+
}
|
|
1043
|
+
if (approval.status === "missing" || approval.status === "stale") {
|
|
1044
|
+
blockedBy.push(approval.status === "stale" ? "la firma de la spec qued\xF3 obsoleta (el archivo cambi\xF3)" : "la spec no est\xE1 aprobada");
|
|
1045
|
+
return {
|
|
1046
|
+
state: "awaiting_approval",
|
|
1047
|
+
blockedBy,
|
|
1048
|
+
nextAction: next(`satlas present ${change.slug}`, "Presentar la propuesta y firmar la aprobaci\xF3n (satlas approve)"),
|
|
1049
|
+
progress
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
if (!change.planPath && !change.tasks) {
|
|
1053
|
+
return { state: "approved", blockedBy, nextAction: next(`/satlas.plan ${change.slug}`, "Crear el plan t\xE9cnico y las tareas", true), progress };
|
|
1054
|
+
}
|
|
1055
|
+
if (tasksTotal > 0 && tasksDone < tasksTotal) {
|
|
1056
|
+
return { state: "building", blockedBy, nextAction: next(`/satlas.build ${change.slug}`, `Construir en olas (${tasksDone}/${tasksTotal} tareas)`, true), progress };
|
|
1057
|
+
}
|
|
1058
|
+
if (cfg.gates.verify.mode !== "off" && cfg.gates.verify.require_evidence && deltaScenarios.length > 0 && scenariosEvidenced < deltaScenarios.length) {
|
|
1059
|
+
blockedBy.push(`evidencia ${scenariosEvidenced}/${deltaScenarios.length}`);
|
|
1060
|
+
return { state: "built", blockedBy, nextAction: next(`satlas verify ${change.slug}`, "Registrar evidencia por escenario"), progress };
|
|
1061
|
+
}
|
|
1062
|
+
if (lane === "full" && cfg.gates.review.mode === "blocking" && !change.verify) {
|
|
1063
|
+
blockedBy.push("review pendiente");
|
|
1064
|
+
return { state: "verified", blockedBy, nextAction: next(`/satlas.review ${change.slug}`, "Revisi\xF3n de c\xF3digo", true), progress };
|
|
1065
|
+
}
|
|
1066
|
+
return { state: "ready", blockedBy, nextAction: next(`satlas archive ${change.slug}`, "Archivar el cambio y plegar los deltas"), progress };
|
|
1067
|
+
}
|
|
1068
|
+
function stateLabel(state) {
|
|
1069
|
+
const labels = {
|
|
1070
|
+
draft: "borrador",
|
|
1071
|
+
spec_draft: "spec en borrador",
|
|
1072
|
+
awaiting_approval: "esperando aprobaci\xF3n",
|
|
1073
|
+
approved: "aprobado",
|
|
1074
|
+
planned: "planificado",
|
|
1075
|
+
building: "construyendo",
|
|
1076
|
+
built: "construido",
|
|
1077
|
+
verified: "verificado",
|
|
1078
|
+
ready: "listo para archivar",
|
|
1079
|
+
archived: "archivado"
|
|
1080
|
+
};
|
|
1081
|
+
return labels[state];
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/profiles.ts
|
|
1085
|
+
import path3 from "path";
|
|
1086
|
+
import { parse as parseYaml4 } from "yaml";
|
|
1087
|
+
import { z as z3 } from "zod";
|
|
1088
|
+
var profileSchema = z3.object({
|
|
1089
|
+
name: z3.string().min(1),
|
|
1090
|
+
display_name: z3.string().default(""),
|
|
1091
|
+
detection: z3.object({
|
|
1092
|
+
files: z3.array(z3.string()).default([]),
|
|
1093
|
+
manifests: z3.array(z3.object({ file: z3.string(), contains: z3.array(z3.string()).default([]) })).default([]),
|
|
1094
|
+
priority: z3.number().default(0)
|
|
1095
|
+
}).default({}),
|
|
1096
|
+
domains: z3.array(z3.string()).default([]),
|
|
1097
|
+
commands: z3.object({ build: z3.string().optional(), lint: z3.string().optional(), test: z3.string().optional(), format: z3.string().optional() }).default({}),
|
|
1098
|
+
verify: z3.object({ executable: z3.array(z3.string()).default([]), automatic: z3.array(z3.string()).default([]), manual: z3.array(z3.string()).default([]) }).default({}),
|
|
1099
|
+
rollback: z3.string().default(""),
|
|
1100
|
+
antipatterns: z3.array(z3.string()).default([]),
|
|
1101
|
+
assumptions: z3.array(z3.string()).default([])
|
|
1102
|
+
});
|
|
1103
|
+
function globToRegExp(pattern) {
|
|
1104
|
+
let re = "";
|
|
1105
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
1106
|
+
const ch = pattern[i] ?? "";
|
|
1107
|
+
if (ch === "*") {
|
|
1108
|
+
if (pattern[i + 1] === "*") {
|
|
1109
|
+
re += ".*";
|
|
1110
|
+
i += 1;
|
|
1111
|
+
} else {
|
|
1112
|
+
re += "[^/]*";
|
|
1113
|
+
}
|
|
1114
|
+
} else if (ch === "?") {
|
|
1115
|
+
re += "[^/]";
|
|
1116
|
+
} else if ("\\^$.|+()[]{}".includes(ch)) {
|
|
1117
|
+
re += `\\${ch}`;
|
|
1118
|
+
} else {
|
|
1119
|
+
re += ch;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return new RegExp(`^${re}$`, "i");
|
|
1123
|
+
}
|
|
1124
|
+
function matchGlob(pattern, filePath) {
|
|
1125
|
+
const posix = toPosix(filePath);
|
|
1126
|
+
return globToRegExp(pattern).test(posix) || globToRegExp(`**/${pattern}`).test(posix);
|
|
1127
|
+
}
|
|
1128
|
+
async function loadProfileFile(filePath) {
|
|
1129
|
+
const raw = await readTextIfExists(filePath);
|
|
1130
|
+
if (raw === void 0) return void 0;
|
|
1131
|
+
let data;
|
|
1132
|
+
try {
|
|
1133
|
+
data = parseYaml4(raw);
|
|
1134
|
+
} catch {
|
|
1135
|
+
return void 0;
|
|
1136
|
+
}
|
|
1137
|
+
const parsed = profileSchema.safeParse(data);
|
|
1138
|
+
return parsed.success ? parsed.data : void 0;
|
|
1139
|
+
}
|
|
1140
|
+
async function loadProfilesFromDir(dir) {
|
|
1141
|
+
const entries = await listDir(dir);
|
|
1142
|
+
const out = [];
|
|
1143
|
+
for (const entry of entries) {
|
|
1144
|
+
if (!/\.ya?ml$/i.test(entry)) continue;
|
|
1145
|
+
const profile = await loadProfileFile(path3.join(dir, entry));
|
|
1146
|
+
if (profile) out.push(profile);
|
|
1147
|
+
}
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
async function detectProfiles(root, profiles) {
|
|
1151
|
+
const files = await walkFiles(root);
|
|
1152
|
+
const matches = [];
|
|
1153
|
+
for (const profile of profiles) {
|
|
1154
|
+
let score = 0;
|
|
1155
|
+
for (const pattern of profile.detection.files) {
|
|
1156
|
+
if (files.some((f) => matchGlob(pattern, f.path))) score += 2;
|
|
1157
|
+
}
|
|
1158
|
+
for (const manifest of profile.detection.manifests) {
|
|
1159
|
+
const found = files.find((f) => matchGlob(manifest.file, f.path) || toPosix(f.path) === toPosix(manifest.file) || f.name === manifest.file);
|
|
1160
|
+
if (!found) continue;
|
|
1161
|
+
const content = await readTextIfExists(found.path) ?? "";
|
|
1162
|
+
const containsAll = manifest.contains.every((c) => content.toLowerCase().includes(c.toLowerCase()));
|
|
1163
|
+
if (containsAll) score += 3;
|
|
1164
|
+
}
|
|
1165
|
+
if (score > 0) {
|
|
1166
|
+
const priority = profile.detection.priority ?? 0;
|
|
1167
|
+
matches.push({
|
|
1168
|
+
name: profile.name,
|
|
1169
|
+
displayName: profile.display_name || profile.name,
|
|
1170
|
+
score: score + Math.min(priority, 0) / 100,
|
|
1171
|
+
domains: profile.domains,
|
|
1172
|
+
path: profile.name
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
matches.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
1177
|
+
const result = { matches };
|
|
1178
|
+
const best = matches[0];
|
|
1179
|
+
if (best) result.best = best;
|
|
1180
|
+
return result;
|
|
1181
|
+
}
|
|
1182
|
+
async function loadDetectedBest(sddDir) {
|
|
1183
|
+
const raw = await readTextIfExists(path3.join(sddDir, "profiles", "detected.yaml"));
|
|
1184
|
+
if (raw === void 0) return void 0;
|
|
1185
|
+
try {
|
|
1186
|
+
const data = parseYaml4(raw);
|
|
1187
|
+
return typeof data?.best === "string" ? data.best : void 0;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return void 0;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function loadActiveProfile(sddDir, dirs) {
|
|
1193
|
+
const best = await loadDetectedBest(sddDir);
|
|
1194
|
+
const profiles = [];
|
|
1195
|
+
for (const dir of dirs) profiles.push(...await loadProfilesFromDir(dir));
|
|
1196
|
+
if (best) {
|
|
1197
|
+
const found = profiles.find((p) => p.name === best);
|
|
1198
|
+
if (found) return found;
|
|
1199
|
+
}
|
|
1200
|
+
return profiles.find((p) => p.name === "generic");
|
|
1201
|
+
}
|
|
1202
|
+
function detectionToYaml(result, generatedAt) {
|
|
1203
|
+
const lines = ["# Generado por `satlas init`. Editarlo a mano es v\xE1lido: no se sobrescribe.", `schema_version: 1`, `generated_at: ${generatedAt}`];
|
|
1204
|
+
if (result.best) {
|
|
1205
|
+
lines.push(`best: ${result.best.name}`);
|
|
1206
|
+
} else {
|
|
1207
|
+
lines.push("best: generic");
|
|
1208
|
+
}
|
|
1209
|
+
lines.push("candidates:");
|
|
1210
|
+
if (result.matches.length === 0) {
|
|
1211
|
+
lines.push(" - name: generic");
|
|
1212
|
+
lines.push(" score: 0");
|
|
1213
|
+
} else {
|
|
1214
|
+
for (const m of result.matches) {
|
|
1215
|
+
lines.push(` - name: ${m.name}`);
|
|
1216
|
+
lines.push(` score: ${m.score}`);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return lines.join("\n") + "\n";
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/workspace.ts
|
|
1223
|
+
import path4 from "path";
|
|
1224
|
+
var SDD_DIR = ".sdd";
|
|
1225
|
+
async function findWorkspaceRoot(start) {
|
|
1226
|
+
let current = path4.resolve(start);
|
|
1227
|
+
for (let i = 0; i < 40; i += 1) {
|
|
1228
|
+
if (await isDirectory(path4.join(current, SDD_DIR))) return current;
|
|
1229
|
+
const parent = path4.dirname(current);
|
|
1230
|
+
if (parent === current) return void 0;
|
|
1231
|
+
current = parent;
|
|
1232
|
+
}
|
|
1233
|
+
return void 0;
|
|
1234
|
+
}
|
|
1235
|
+
async function loadApprovals(sddDir) {
|
|
1236
|
+
const file = path4.join(sddDir, "approvals.yaml");
|
|
1237
|
+
const raw = await readTextIfExists(file);
|
|
1238
|
+
if (raw === void 0) return { byArtifact: /* @__PURE__ */ new Map(), diagnostics: [] };
|
|
1239
|
+
const parsed = parseApprovals(raw, file);
|
|
1240
|
+
const map = /* @__PURE__ */ new Map();
|
|
1241
|
+
for (const a of parsed.approvals?.approvals ?? []) {
|
|
1242
|
+
map.set(a.artifact.split(path4.sep).join("/"), { hash: a.artifactHash, by: a.approvedBy, at: a.approvedAt });
|
|
1243
|
+
}
|
|
1244
|
+
return { byArtifact: map, diagnostics: parsed.diagnostics };
|
|
1245
|
+
}
|
|
1246
|
+
async function loadSpecs(sddDir) {
|
|
1247
|
+
const specsDir = path4.join(sddDir, "specs");
|
|
1248
|
+
const domains = await listDirs(specsDir);
|
|
1249
|
+
const out = [];
|
|
1250
|
+
for (const domain of domains) {
|
|
1251
|
+
const file = path4.join(specsDir, domain, "spec.md");
|
|
1252
|
+
if (!await exists(file)) continue;
|
|
1253
|
+
const content = await readText(file);
|
|
1254
|
+
out.push({ domain, path: file, spec: parseSpecFile(content, file) });
|
|
1255
|
+
}
|
|
1256
|
+
return out;
|
|
1257
|
+
}
|
|
1258
|
+
async function loadChange(root, slug) {
|
|
1259
|
+
const dir = path4.join(root, SDD_DIR, "changes", slug);
|
|
1260
|
+
const diagnostics = [];
|
|
1261
|
+
const change = { slug, dir, diagnostics };
|
|
1262
|
+
const metaFile = path4.join(dir, "meta.yaml");
|
|
1263
|
+
const metaRaw = await readTextIfExists(metaFile);
|
|
1264
|
+
if (metaRaw === void 0) {
|
|
1265
|
+
diagnostics.push(diag("ATLAS-FILES-001", "error", `El cambio "${slug}" no tiene meta.yaml`, { path: metaFile, suggestion: "Crea meta.yaml con slug, lane y dominio" }));
|
|
1266
|
+
} else {
|
|
1267
|
+
const parsed = parseChangeMeta(metaRaw, metaFile);
|
|
1268
|
+
diagnostics.push(...parsed.diagnostics);
|
|
1269
|
+
if (parsed.meta) change.meta = parsed.meta;
|
|
1270
|
+
}
|
|
1271
|
+
const deltaFile = path4.join(dir, "spec.md");
|
|
1272
|
+
const deltaRaw = await readTextIfExists(deltaFile);
|
|
1273
|
+
if (deltaRaw !== void 0) {
|
|
1274
|
+
change.delta = parseDelta(deltaRaw, deltaFile);
|
|
1275
|
+
diagnostics.push(...change.delta.diagnostics);
|
|
1276
|
+
} else {
|
|
1277
|
+
diagnostics.push(diag("ATLAS-FILES-002", "warning", `El cambio "${slug}" no tiene spec.md (delta)`, { path: deltaFile }));
|
|
1278
|
+
}
|
|
1279
|
+
const planFile = path4.join(dir, "plan.md");
|
|
1280
|
+
if (await exists(planFile)) change.planPath = planFile;
|
|
1281
|
+
const tasksFile = path4.join(dir, "tasks.md");
|
|
1282
|
+
const tasksRaw = await readTextIfExists(tasksFile);
|
|
1283
|
+
if (tasksRaw !== void 0) {
|
|
1284
|
+
change.tasks = parseTasksFile(tasksRaw, tasksFile);
|
|
1285
|
+
diagnostics.push(...change.tasks.diagnostics);
|
|
1286
|
+
}
|
|
1287
|
+
const verifyFile = path4.join(dir, "verify.md");
|
|
1288
|
+
const verifyRaw = await readTextIfExists(verifyFile);
|
|
1289
|
+
if (verifyRaw !== void 0) {
|
|
1290
|
+
change.verify = parseVerifyFile(verifyRaw, verifyFile);
|
|
1291
|
+
diagnostics.push(...change.verify.diagnostics);
|
|
1292
|
+
}
|
|
1293
|
+
const fixFile = path4.join(dir, "fix.md");
|
|
1294
|
+
const fixRaw = await readTextIfExists(fixFile);
|
|
1295
|
+
if (fixRaw !== void 0) {
|
|
1296
|
+
change.fix = parseVerifyFile(fixRaw, fixFile);
|
|
1297
|
+
diagnostics.push(...change.fix.diagnostics);
|
|
1298
|
+
}
|
|
1299
|
+
const mockupManifest = path4.join(dir, "mockups", "manifest.yaml");
|
|
1300
|
+
if (await exists(mockupManifest)) change.mockupManifestPath = mockupManifest;
|
|
1301
|
+
return change;
|
|
1302
|
+
}
|
|
1303
|
+
async function listChangeSlugs(root, opts = {}) {
|
|
1304
|
+
const changesDir = path4.join(root, SDD_DIR, "changes");
|
|
1305
|
+
const dirs = await listDirs(changesDir);
|
|
1306
|
+
return dirs.filter((d) => opts.includeArchived ? true : d !== "archive");
|
|
1307
|
+
}
|
|
1308
|
+
async function loadWorkspace(root) {
|
|
1309
|
+
const sddDir = path4.join(root, SDD_DIR);
|
|
1310
|
+
const { config, diagnostics: configDiags } = await loadConfig(sddDir);
|
|
1311
|
+
const diagnostics = [...configDiags];
|
|
1312
|
+
const specs = await loadSpecs(sddDir);
|
|
1313
|
+
for (const spec of specs) diagnostics.push(...spec.spec.diagnostics);
|
|
1314
|
+
const slugs = await listChangeSlugs(root);
|
|
1315
|
+
const changes = [];
|
|
1316
|
+
for (const slug of slugs) {
|
|
1317
|
+
const change = await loadChange(root, slug);
|
|
1318
|
+
changes.push(change);
|
|
1319
|
+
diagnostics.push(...change.diagnostics);
|
|
1320
|
+
}
|
|
1321
|
+
return { workspace: { root, sddDir, specs, changes, diagnostics }, config };
|
|
1322
|
+
}
|
|
1323
|
+
async function ensureSddDirs(sddDir) {
|
|
1324
|
+
const created = [];
|
|
1325
|
+
const dirs = ["specs", "changes", "runs", "metrics", path4.join("profiles", "custom")];
|
|
1326
|
+
const { ensureDir: ensureDir2 } = await import("./fsx-VF2P7ALA.js");
|
|
1327
|
+
for (const d of dirs) {
|
|
1328
|
+
const full = path4.join(sddDir, d);
|
|
1329
|
+
if (!await exists(full)) {
|
|
1330
|
+
await ensureDir2(full);
|
|
1331
|
+
created.push(full);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return created;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/templates.ts
|
|
1338
|
+
import { stringify as stringifyYaml3 } from "yaml";
|
|
1339
|
+
var ES = {
|
|
1340
|
+
constitution: `# Constituci\xF3n del proyecto
|
|
1341
|
+
|
|
1342
|
+
> Reglas inmutables. Todo cambio las hereda. Jerarqu\xEDa: constituci\xF3n > spec aprobada > plan > tareas > c\xF3digo.
|
|
1343
|
+
|
|
1344
|
+
## Art\xEDculo 1 \u2014 Verdad y evidencia
|
|
1345
|
+
Ninguna afirmaci\xF3n de "terminado" se acepta sin evidencia registrada en verify.md.
|
|
1346
|
+
|
|
1347
|
+
## Art\xEDculo 2 \u2014 Seguridad
|
|
1348
|
+
No se versionan secretos. No se ejecutan comandos destructivos sin rollback documentado.
|
|
1349
|
+
|
|
1350
|
+
## Art\xEDculo 3 \u2014 Especificaci\xF3n de negocio
|
|
1351
|
+
Las specs se escriben en lenguaje de negocio, sin tecnolog\xEDa. El plan t\xE9cnico es otro artefacto.
|
|
1352
|
+
|
|
1353
|
+
## Art\xEDculo 4 \u2014 Trazabilidad
|
|
1354
|
+
Todo requisito tiene escenarios; todo escenario tiene tarea y evidencia. Los ids REQ-* son inmutables.
|
|
1355
|
+
|
|
1356
|
+
## Art\xEDculo 5 \u2014 Cambios de alcance
|
|
1357
|
+
Lo aprobado no se edita en silencio: se firma una revisi\xF3n (satlas amend, disponible en F2).
|
|
1358
|
+
|
|
1359
|
+
## Art\xEDculo 6 \u2014 Revisi\xF3n humana
|
|
1360
|
+
Aprobar, revisar y archivar son actos humanos registrados con nombre y fecha.
|
|
1361
|
+
|
|
1362
|
+
<!-- A\xF1ade aqu\xED los art\xEDculos propios de tu proyecto. Ampl\xEDa sin miedo: esta plantilla es tuya. -->
|
|
1363
|
+
`,
|
|
1364
|
+
glossary: `# Glosario de negocio
|
|
1365
|
+
|
|
1366
|
+
> T\xE9rminos con el significado que les da el negocio. La spec usa estos t\xE9rminos, no sin\xF3nimos t\xE9cnicos.
|
|
1367
|
+
|
|
1368
|
+
| T\xE9rmino | Definici\xF3n | Sin\xF3nimos aceptados |
|
|
1369
|
+
|---|---|---|
|
|
1370
|
+
| Cliente | Persona u organizaci\xF3n que contrata el servicio | Usuario |
|
|
1371
|
+
| Solicitud | Pedido formal de un cliente que inicia un proceso | Ticket |
|
|
1372
|
+
`,
|
|
1373
|
+
proposal: `# Propuesta \u2014 {{TITLE}}
|
|
1374
|
+
|
|
1375
|
+
## Por qu\xE9
|
|
1376
|
+
(problema u oportunidad, en lenguaje de negocio)
|
|
1377
|
+
|
|
1378
|
+
## Qu\xE9 cambia
|
|
1379
|
+
(alcance funcional de la propuesta)
|
|
1380
|
+
|
|
1381
|
+
## Fuera de alcance
|
|
1382
|
+
(lo que expl\xEDcitamente no se har\xE1)
|
|
1383
|
+
|
|
1384
|
+
## C\xF3mo se mide el \xE9xito
|
|
1385
|
+
(indicadores observables)
|
|
1386
|
+
`,
|
|
1387
|
+
specDelta: ({ title, domainUpper, domain }) => `# Delta \u2014 ${title}
|
|
1388
|
+
|
|
1389
|
+
## Requisitos agregados
|
|
1390
|
+
|
|
1391
|
+
### Requisito: REQ-${domainUpper}-001 \u2014 ${title}
|
|
1392
|
+
Describe la necesidad de negocio y el comportamiento esperado (sin tecnolog\xEDa).
|
|
1393
|
+
|
|
1394
|
+
- Regla BR-${domainUpper}-001: (regla de negocio verificable)
|
|
1395
|
+
|
|
1396
|
+
#### Escenario: REQ-${domainUpper}-001-S1 \u2014 Caso principal
|
|
1397
|
+
- **CUANDO** (situaci\xF3n o acci\xF3n del actor)
|
|
1398
|
+
- **ENTONCES** (resultado observable y medible)
|
|
1399
|
+
|
|
1400
|
+
#### Escenario: REQ-${domainUpper}-001-S2 \u2014 Error o caso l\xEDmite
|
|
1401
|
+
- **CUANDO** (situaci\xF3n inv\xE1lida)
|
|
1402
|
+
- **ENTONCES** (respuesta del sistema)
|
|
1403
|
+
|
|
1404
|
+
## Requisitos modificados
|
|
1405
|
+
|
|
1406
|
+
## Requisitos eliminados
|
|
1407
|
+
|
|
1408
|
+
## Requisitos renombrados
|
|
1409
|
+
|
|
1410
|
+
<!-- dominio del cambio: ${domain} -->
|
|
1411
|
+
`,
|
|
1412
|
+
plan: `# Plan \u2014 {{TITLE}}
|
|
1413
|
+
|
|
1414
|
+
## 1. Contexto AS-IS
|
|
1415
|
+
(existe / no existe / blast radius; anclas al c\xF3digo real)
|
|
1416
|
+
|
|
1417
|
+
## 2. Enfoque t\xE9cnico
|
|
1418
|
+
(decisiones y alternativas descartadas con su porqu\xE9)
|
|
1419
|
+
|
|
1420
|
+
## 3. Diagramas
|
|
1421
|
+
Incluye los diagramas mermaid que el cambio necesita:
|
|
1422
|
+
|
|
1423
|
+
| Tipo | Cu\xE1ndo |
|
|
1424
|
+
|---|---|
|
|
1425
|
+
| erDiagram | si toca modelo de datos o BD |
|
|
1426
|
+
| sequenceDiagram | integraciones, APIs, jobs, eventos |
|
|
1427
|
+
| flowchart | reglas de negocio, procesos, validaciones |
|
|
1428
|
+
| stateDiagram-v2 | m\xE1quinas de estado |
|
|
1429
|
+
| classDiagram | dominio o l\xF3gica no trivial |
|
|
1430
|
+
| C4Context / flowchart de capas | arquitectura o m\xF3dulos nuevos |
|
|
1431
|
+
|
|
1432
|
+
\`\`\`mermaid
|
|
1433
|
+
flowchart LR
|
|
1434
|
+
A[Entrada] --> B[Proceso] --> C[Resultado]
|
|
1435
|
+
\`\`\`
|
|
1436
|
+
|
|
1437
|
+
## 4. Dise\xF1o por capa / m\xF3dulos
|
|
1438
|
+
|
|
1439
|
+
## 5. Matriz de trazabilidad (REQ \u2192 tareas)
|
|
1440
|
+
| Requisito | Escenario | Tareas |
|
|
1441
|
+
|---|---|---|
|
|
1442
|
+
|
|
1443
|
+
## 6. Matriz de paridad AS-IS \u2192 TO-BE (solo refactors sustitutivos)
|
|
1444
|
+
| Elemento | Conservar | Descartar (motivo) | Nuevo |
|
|
1445
|
+
|---|---|---|---|
|
|
1446
|
+
|
|
1447
|
+
## 7. Tareas
|
|
1448
|
+
Ver tasks.md.
|
|
1449
|
+
|
|
1450
|
+
## 8. Riesgos y mitigaciones
|
|
1451
|
+
|
|
1452
|
+
## 9. Rollback
|
|
1453
|
+
|
|
1454
|
+
## 10. Dependencias y supuestos
|
|
1455
|
+
`,
|
|
1456
|
+
tasks: `# Tareas \u2014 {{TITLE}}
|
|
1457
|
+
|
|
1458
|
+
## Bloque 1 \u2014 Preparaci\xF3n
|
|
1459
|
+
|
|
1460
|
+
<!-- Cada tarea es at\xF3mica y usa el separador \xB7 :
|
|
1461
|
+
- [ ] T1.1 Descripci\xF3n \xB7 Archivos: ruta/al/archivo \xB7 Cubre: REQ-DOMINIO-001-S1 \xB7 Rollback: c\xF3mo revertir
|
|
1462
|
+
- [ ] T1.2 Otra tarea \xB7 Archivos: ruta/otra \xB7 Depende de: T1.1
|
|
1463
|
+
Para trabajo de infraestructura sin requisito: \xB7 Infra
|
|
1464
|
+
-->
|
|
1465
|
+
`,
|
|
1466
|
+
verify: `# Verificaci\xF3n \u2014 {{TITLE}}
|
|
1467
|
+
|
|
1468
|
+
> Una entrada por escenario con evidencia real (comando y resultado). Ejemplo:
|
|
1469
|
+
|
|
1470
|
+
<!--
|
|
1471
|
+
### REQ-DOMINIO-001-S1 \u2014 Caso principal
|
|
1472
|
+
\`\`\`evidence
|
|
1473
|
+
method: executable
|
|
1474
|
+
command: npm test -- modulo
|
|
1475
|
+
result: pass
|
|
1476
|
+
output_hash: sha256:\u2026
|
|
1477
|
+
date: 2026-01-01T00:00:00Z
|
|
1478
|
+
by: tu-nombre
|
|
1479
|
+
notes: 12/12 casos
|
|
1480
|
+
\`\`\`
|
|
1481
|
+
-->
|
|
1482
|
+
`,
|
|
1483
|
+
fix: `# Fix \u2014 {{TITLE}}
|
|
1484
|
+
|
|
1485
|
+
## S\xEDntoma
|
|
1486
|
+
(qu\xE9 se observa, d\xF3nde y desde cu\xE1ndo)
|
|
1487
|
+
|
|
1488
|
+
## Causa ra\xEDz
|
|
1489
|
+
(por qu\xE9 ocurre, con evidencia del c\xF3digo real)
|
|
1490
|
+
|
|
1491
|
+
## Cambio
|
|
1492
|
+
(qu\xE9 se toca y por qu\xE9 es el m\xEDnimo cambio correcto)
|
|
1493
|
+
|
|
1494
|
+
## Rollback
|
|
1495
|
+
(c\xF3mo revertir)
|
|
1496
|
+
|
|
1497
|
+
## Evidencia
|
|
1498
|
+
|
|
1499
|
+
<!-- Registra la evidencia real con:
|
|
1500
|
+
satlas verify <slug> --file fix --scenario REQ-DOMINIO-001-S1 --command "<comando>" --by "<nombre>"
|
|
1501
|
+
o, si es manual:
|
|
1502
|
+
satlas verify <slug> --file fix --scenario REQ-DOMINIO-001-S1 --method manual --result pass --by "<nombre>" --notes "<c\xF3mo se comprob\xF3>"
|
|
1503
|
+
-->
|
|
1504
|
+
`
|
|
1505
|
+
};
|
|
1506
|
+
var EN = {
|
|
1507
|
+
constitution: `# Project constitution
|
|
1508
|
+
|
|
1509
|
+
> Immutable rules. Every change inherits them. Hierarchy: constitution > approved spec > plan > tasks > code.
|
|
1510
|
+
|
|
1511
|
+
## Article 1 \u2014 Truth and evidence
|
|
1512
|
+
No "done" claim is accepted without evidence recorded in verify.md.
|
|
1513
|
+
|
|
1514
|
+
## Article 2 \u2014 Security
|
|
1515
|
+
No secrets in the repo. No destructive command without a documented rollback.
|
|
1516
|
+
|
|
1517
|
+
## Article 3 \u2014 Business specification
|
|
1518
|
+
Specs are written in business language, without technology. The technical plan is a separate artifact.
|
|
1519
|
+
|
|
1520
|
+
## Article 4 \u2014 Traceability
|
|
1521
|
+
Every requirement has scenarios; every scenario has a task and evidence. REQ-* ids are immutable.
|
|
1522
|
+
|
|
1523
|
+
## Article 5 \u2014 Scope changes
|
|
1524
|
+
Approved work is never edited silently: a signed revision is required.
|
|
1525
|
+
|
|
1526
|
+
## Article 6 \u2014 Human review
|
|
1527
|
+
Approving, reviewing and archiving are human acts recorded with name and date.
|
|
1528
|
+
`,
|
|
1529
|
+
glossary: `# Business glossary
|
|
1530
|
+
|
|
1531
|
+
> Terms as the business means them. Specs use these terms, not technical synonyms.
|
|
1532
|
+
|
|
1533
|
+
| Term | Definition | Accepted synonyms |
|
|
1534
|
+
|---|---|---|
|
|
1535
|
+
| Customer | Person or organization that contracts the service | User |
|
|
1536
|
+
`,
|
|
1537
|
+
proposal: `# Proposal \u2014 {{TITLE}}
|
|
1538
|
+
|
|
1539
|
+
## Why
|
|
1540
|
+
(problem or opportunity in business language)
|
|
1541
|
+
|
|
1542
|
+
## What changes
|
|
1543
|
+
(functional scope)
|
|
1544
|
+
|
|
1545
|
+
## Out of scope
|
|
1546
|
+
|
|
1547
|
+
## How success is measured
|
|
1548
|
+
`,
|
|
1549
|
+
specDelta: ({ title, domainUpper, domain }) => `# Delta \u2014 ${title}
|
|
1550
|
+
|
|
1551
|
+
## ADDED Requirements
|
|
1552
|
+
|
|
1553
|
+
### Requirement: REQ-${domainUpper}-001 \u2014 ${title}
|
|
1554
|
+
Describe the business need and expected behaviour (no technology).
|
|
1555
|
+
|
|
1556
|
+
- Rule BR-${domainUpper}-001: (verifiable business rule)
|
|
1557
|
+
|
|
1558
|
+
#### Scenario: REQ-${domainUpper}-001-S1 \u2014 Main case
|
|
1559
|
+
- **WHEN** (situation or actor action)
|
|
1560
|
+
- **THEN** (observable, measurable result)
|
|
1561
|
+
|
|
1562
|
+
#### Scenario: REQ-${domainUpper}-001-S2 \u2014 Error or edge case
|
|
1563
|
+
- **WHEN** (invalid situation)
|
|
1564
|
+
- **THEN** (system response)
|
|
1565
|
+
|
|
1566
|
+
## MODIFIED Requirements
|
|
1567
|
+
|
|
1568
|
+
## REMOVED Requirements
|
|
1569
|
+
|
|
1570
|
+
## RENAMED Requirements
|
|
1571
|
+
|
|
1572
|
+
<!-- change domain: ${domain} -->
|
|
1573
|
+
`,
|
|
1574
|
+
plan: `# Plan \u2014 {{TITLE}}
|
|
1575
|
+
|
|
1576
|
+
## 1. AS-IS context
|
|
1577
|
+
## 2. Technical approach
|
|
1578
|
+
## 3. Diagrams
|
|
1579
|
+
\`\`\`mermaid
|
|
1580
|
+
flowchart LR
|
|
1581
|
+
A[Input] --> B[Process] --> C[Outcome]
|
|
1582
|
+
\`\`\`
|
|
1583
|
+
## 4. Layer / module design
|
|
1584
|
+
## 5. Traceability matrix (REQ \u2192 tasks)
|
|
1585
|
+
## 6. AS-IS \u2192 TO-BE parity matrix (replacing refactors only)
|
|
1586
|
+
## 7. Tasks
|
|
1587
|
+
See tasks.md.
|
|
1588
|
+
## 8. Risks and mitigations
|
|
1589
|
+
## 9. Rollback
|
|
1590
|
+
## 10. Dependencies and assumptions
|
|
1591
|
+
`,
|
|
1592
|
+
tasks: `# Tasks \u2014 {{TITLE}}
|
|
1593
|
+
|
|
1594
|
+
## Block 1 \u2014 Preparation
|
|
1595
|
+
|
|
1596
|
+
<!--
|
|
1597
|
+
- [ ] T1.1 Description \xB7 Files: path/to/file \xB7 Covers: REQ-DOMAIN-001-S1 \xB7 Rollback: how to revert
|
|
1598
|
+
-->
|
|
1599
|
+
`,
|
|
1600
|
+
verify: `# Verification \u2014 {{TITLE}}
|
|
1601
|
+
|
|
1602
|
+
<!--
|
|
1603
|
+
### REQ-DOMAIN-001-S1 \u2014 Main case
|
|
1604
|
+
\`\`\`evidence
|
|
1605
|
+
method: executable
|
|
1606
|
+
command: npm test -- module
|
|
1607
|
+
result: pass
|
|
1608
|
+
output_hash: sha256:\u2026
|
|
1609
|
+
date: 2026-01-01T00:00:00Z
|
|
1610
|
+
by: your-name
|
|
1611
|
+
\`\`\`
|
|
1612
|
+
-->
|
|
1613
|
+
`,
|
|
1614
|
+
fix: `# Fix \u2014 {{TITLE}}
|
|
1615
|
+
|
|
1616
|
+
## Symptom
|
|
1617
|
+
## Root cause
|
|
1618
|
+
## Change
|
|
1619
|
+
## Rollback
|
|
1620
|
+
## Evidence
|
|
1621
|
+
|
|
1622
|
+
<!-- Register real evidence with:
|
|
1623
|
+
satlas verify <slug> --file fix --scenario REQ-DOMAIN-001-S1 --command "<command>" --by "<name>"
|
|
1624
|
+
-->
|
|
1625
|
+
`
|
|
1626
|
+
};
|
|
1627
|
+
function templatesFor(language) {
|
|
1628
|
+
return language === "en" ? EN : ES;
|
|
1629
|
+
}
|
|
1630
|
+
function renderTemplate(template, vars) {
|
|
1631
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
1632
|
+
}
|
|
1633
|
+
function changeMetaYaml(meta) {
|
|
1634
|
+
const doc = {
|
|
1635
|
+
schema_version: meta.schemaVersion,
|
|
1636
|
+
slug: meta.slug,
|
|
1637
|
+
lane: meta.lane
|
|
1638
|
+
};
|
|
1639
|
+
if (meta.title) doc["title"] = meta.title;
|
|
1640
|
+
if (meta.domain) doc["domain"] = meta.domain;
|
|
1641
|
+
if (meta.risk) doc["risk"] = meta.risk;
|
|
1642
|
+
if (meta.created) doc["created"] = meta.created;
|
|
1643
|
+
if (meta.owner) doc["owner"] = meta.owner;
|
|
1644
|
+
return `# Estado del cambio. La fase se DERIVA de los artefactos; aqu\xED solo hechos.
|
|
1645
|
+
` + stringifyYaml3(doc, { lineWidth: 120 });
|
|
1646
|
+
}
|
|
1647
|
+
function indexMarkdown(input) {
|
|
1648
|
+
const es = input.language !== "en";
|
|
1649
|
+
const lines = [];
|
|
1650
|
+
lines.push(`# \xCDndice \u2014 ${input.projectName}`);
|
|
1651
|
+
lines.push("");
|
|
1652
|
+
lines.push(es ? `> Generado por SpecAtlas. No editar a mano: \`satlas doctor\` lo regenera.` : `> Generated by SpecAtlas. Do not edit by hand: \`satlas doctor\` regenerates it.`);
|
|
1653
|
+
lines.push("");
|
|
1654
|
+
lines.push(es ? "## Specs vivas" : "## Living specs");
|
|
1655
|
+
lines.push("");
|
|
1656
|
+
if (input.specs.length === 0) lines.push(es ? "_Sin specs todav\xEDa._" : "_No specs yet._");
|
|
1657
|
+
for (const spec of input.specs) lines.push(`- \`${spec.domain}\` \u2014 ${spec.requirements} ${es ? "requisitos" : "requirements"}`);
|
|
1658
|
+
lines.push("");
|
|
1659
|
+
lines.push(es ? "## Cambios activos" : "## Active changes");
|
|
1660
|
+
lines.push("");
|
|
1661
|
+
if (input.changes.length === 0) lines.push(es ? "_Sin cambios activos._" : "_No active changes._");
|
|
1662
|
+
for (const change of input.changes) lines.push(`- \`${change.slug}\` \u2014 ${change.lane}`);
|
|
1663
|
+
lines.push("");
|
|
1664
|
+
lines.push(es ? `## Archivados
|
|
1665
|
+
|
|
1666
|
+
${input.archived} cambio(s) en \`changes/archive/\`.` : `## Archived
|
|
1667
|
+
|
|
1668
|
+
${input.archived} change(s) in \`changes/archive/\`.`);
|
|
1669
|
+
lines.push("");
|
|
1670
|
+
return lines.join("\n");
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// src/github.ts
|
|
1674
|
+
import path5 from "path";
|
|
1675
|
+
import { parse as parseYaml5 } from "yaml";
|
|
1676
|
+
|
|
1677
|
+
// src/exec.ts
|
|
1678
|
+
import { execFile } from "child_process";
|
|
1679
|
+
var SHELL_META = /[;&|<>`$()\r\n]|[%!^]|&&|\|\|/;
|
|
1680
|
+
function hasShellMetacharacters(command) {
|
|
1681
|
+
return SHELL_META.test(command);
|
|
1682
|
+
}
|
|
1683
|
+
function splitCommand(command) {
|
|
1684
|
+
const parts = [];
|
|
1685
|
+
let current = "";
|
|
1686
|
+
let quote;
|
|
1687
|
+
for (const ch of command.trim()) {
|
|
1688
|
+
if (quote) {
|
|
1689
|
+
if (ch === quote) quote = void 0;
|
|
1690
|
+
else current += ch;
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
if (ch === '"' || ch === "'") {
|
|
1694
|
+
quote = ch;
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (/\s/.test(ch)) {
|
|
1698
|
+
if (current !== "") parts.push(current);
|
|
1699
|
+
current = "";
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
current += ch;
|
|
1703
|
+
}
|
|
1704
|
+
if (current !== "") parts.push(current);
|
|
1705
|
+
return parts;
|
|
1706
|
+
}
|
|
1707
|
+
function firstToken(command) {
|
|
1708
|
+
return splitCommand(command)[0] ?? "";
|
|
1709
|
+
}
|
|
1710
|
+
function run(binary, args, opts) {
|
|
1711
|
+
const timeoutMs = opts.timeoutMs ?? 12e4;
|
|
1712
|
+
const maxBytes = opts.maxBytes ?? 2e5;
|
|
1713
|
+
const started = Date.now();
|
|
1714
|
+
return new Promise((resolve) => {
|
|
1715
|
+
const child = execFile(binary, args, { cwd: opts.cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: maxBytes }, (error, stdout, stderr) => {
|
|
1716
|
+
const durationMs = Date.now() - started;
|
|
1717
|
+
const err = error;
|
|
1718
|
+
const exitCode = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
1719
|
+
resolve({
|
|
1720
|
+
ok: !err,
|
|
1721
|
+
exitCode,
|
|
1722
|
+
stdout: stdout ?? "",
|
|
1723
|
+
stderr: stderr ?? "",
|
|
1724
|
+
durationMs,
|
|
1725
|
+
command: [binary, ...args].join(" "),
|
|
1726
|
+
resolvedBinary: binary
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
child.on("error", () => {
|
|
1730
|
+
});
|
|
1731
|
+
if (opts.input !== void 0 && child.stdin) {
|
|
1732
|
+
child.stdin.write(opts.input);
|
|
1733
|
+
child.stdin.end();
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
async function runProcess(command, opts = {}) {
|
|
1738
|
+
if (hasShellMetacharacters(command)) {
|
|
1739
|
+
return {
|
|
1740
|
+
ok: false,
|
|
1741
|
+
exitCode: 126,
|
|
1742
|
+
stdout: "",
|
|
1743
|
+
stderr: `Comando rechazado: contiene metacaracteres de shell (; | & < > \` $ ( ) % !). Ejecuta un \xFAnico comando sin redirecciones.`,
|
|
1744
|
+
durationMs: 0,
|
|
1745
|
+
command,
|
|
1746
|
+
resolvedBinary: ""
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
const parts = splitCommand(command);
|
|
1750
|
+
const bin = parts[0] ?? "";
|
|
1751
|
+
const args = parts.slice(1);
|
|
1752
|
+
if (bin === "") {
|
|
1753
|
+
return { ok: false, exitCode: 127, stdout: "", stderr: "Comando vac\xEDo", durationMs: 0, command, resolvedBinary: "" };
|
|
1754
|
+
}
|
|
1755
|
+
const first = await run(bin, args, opts);
|
|
1756
|
+
if (!first.ok && first.exitCode !== 0 && /ENOENT/.test(first.stderr) && process.platform === "win32" && !/\.(exe|cmd|bat|ps1)$/i.test(bin)) {
|
|
1757
|
+
const withCmd = await run(`${bin}.cmd`, args, opts);
|
|
1758
|
+
if (withCmd.resolvedBinary.endsWith(".cmd")) return { ...withCmd, command };
|
|
1759
|
+
return { ...first, command };
|
|
1760
|
+
}
|
|
1761
|
+
return { ...first, command };
|
|
1762
|
+
}
|
|
1763
|
+
function isCommandAllowed(command, allowedPrefixes) {
|
|
1764
|
+
const token = firstToken(command).toLowerCase();
|
|
1765
|
+
const base = token.replace(/\.(cmd|exe|bat|ps1)$/i, "");
|
|
1766
|
+
return allowedPrefixes.some((prefix) => {
|
|
1767
|
+
const first = firstToken(prefix).toLowerCase().replace(/\.(cmd|exe|bat|ps1)$/i, "");
|
|
1768
|
+
return first === base;
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/github.ts
|
|
1773
|
+
function parseGitHubRemote(url) {
|
|
1774
|
+
const trimmed = url.trim();
|
|
1775
|
+
const ssh = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/.exec(trimmed);
|
|
1776
|
+
if (ssh) return { owner: ssh[1] ?? "", name: ssh[2] ?? "" };
|
|
1777
|
+
const https = /^https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/.exec(trimmed);
|
|
1778
|
+
if (https) return { owner: https[1] ?? "", name: https[2] ?? "" };
|
|
1779
|
+
const shorthand = /^([\w.-]+)\/([\w.-]+)$/.exec(trimmed);
|
|
1780
|
+
if (shorthand) return { owner: shorthand[1] ?? "", name: shorthand[2] ?? "" };
|
|
1781
|
+
return void 0;
|
|
1782
|
+
}
|
|
1783
|
+
function changeMarker(slug) {
|
|
1784
|
+
return `<!-- specatlas:change=${slug} -->`;
|
|
1785
|
+
}
|
|
1786
|
+
var defaultRunner = (command, opts) => runProcess(command, opts);
|
|
1787
|
+
async function ghAvailable(runner = defaultRunner) {
|
|
1788
|
+
const result = await runner("gh --version");
|
|
1789
|
+
return result.ok;
|
|
1790
|
+
}
|
|
1791
|
+
async function ghAuthStatus(runner = defaultRunner) {
|
|
1792
|
+
const result = await runner("gh auth status");
|
|
1793
|
+
return result.ok;
|
|
1794
|
+
}
|
|
1795
|
+
async function detectRepo(root, runner = defaultRunner) {
|
|
1796
|
+
const remote = await runner("git remote get-url origin", { cwd: root });
|
|
1797
|
+
if (remote.ok) {
|
|
1798
|
+
const parsed = parseGitHubRemote(remote.stdout.trim());
|
|
1799
|
+
if (parsed) return parsed;
|
|
1800
|
+
}
|
|
1801
|
+
const view = await runner("gh repo view --json nameWithOwner -q .nameWithOwner", { cwd: root });
|
|
1802
|
+
if (view.ok) {
|
|
1803
|
+
const parsed = parseGitHubRemote(view.stdout.trim());
|
|
1804
|
+
if (parsed) return parsed;
|
|
1805
|
+
}
|
|
1806
|
+
return void 0;
|
|
1807
|
+
}
|
|
1808
|
+
function issueBody(input) {
|
|
1809
|
+
const { change } = input;
|
|
1810
|
+
const lines = [];
|
|
1811
|
+
lines.push(`## ${change.meta?.title ?? change.slug}`);
|
|
1812
|
+
lines.push("");
|
|
1813
|
+
lines.push(`- Cambio: \`${change.slug}\` \xB7 dominio \`${change.meta?.domain ?? "\u2014"}\` \xB7 carril \`${change.meta?.lane ?? "standard"}\``);
|
|
1814
|
+
lines.push(`- Estado: ${change.meta?.paused ? "pausado" : "en curso"}`);
|
|
1815
|
+
lines.push(`- Escenarios: ${input.scenarios}`);
|
|
1816
|
+
lines.push(`- Siguiente: \`${input.next}\` \u2014 ${input.nextDescription}`);
|
|
1817
|
+
lines.push("");
|
|
1818
|
+
lines.push("### Artefactos");
|
|
1819
|
+
lines.push(`- Spec: \`${path5.posix.join(".sdd", "changes", change.slug, "spec.md")}\``);
|
|
1820
|
+
if (change.planPath) lines.push(`- Plan: \`${path5.posix.join(".sdd", "changes", change.slug, "plan.md")}\``);
|
|
1821
|
+
if (change.tasks) lines.push(`- Tareas: ${change.tasks.counts.done}/${change.tasks.counts.total}`);
|
|
1822
|
+
if (change.verify) lines.push(`- Evidencia registrada: ${change.verify.evidence.length}`);
|
|
1823
|
+
lines.push("");
|
|
1824
|
+
lines.push("---");
|
|
1825
|
+
lines.push(changeMarker(change.slug));
|
|
1826
|
+
return lines.join("\n");
|
|
1827
|
+
}
|
|
1828
|
+
async function findLinkedIssue(root, slug, runner = defaultRunner) {
|
|
1829
|
+
const result = await runner(`gh issue list --state all --limit 10 --search "specatlas:change=${slug} in:body" --json number,url,title,state`, { cwd: root });
|
|
1830
|
+
if (!result.ok) return void 0;
|
|
1831
|
+
try {
|
|
1832
|
+
const list = JSON.parse(result.stdout);
|
|
1833
|
+
const first = list[0];
|
|
1834
|
+
if (!first || typeof first.number !== "number") return void 0;
|
|
1835
|
+
return { number: first.number, url: first.url ?? "", title: first.title ?? "", state: first.state ?? "OPEN" };
|
|
1836
|
+
} catch {
|
|
1837
|
+
return void 0;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
function parseIssueUrl(url) {
|
|
1841
|
+
const match = /https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/.exec(url);
|
|
1842
|
+
if (!match) return void 0;
|
|
1843
|
+
return { number: Number.parseInt(match[1] ?? "0", 10), url: url.trim(), title: "", state: "OPEN" };
|
|
1844
|
+
}
|
|
1845
|
+
async function createIssue(root, opts, runner = defaultRunner) {
|
|
1846
|
+
const labels = (opts.labels ?? []).map((label) => ` --label "${label}"`).join("");
|
|
1847
|
+
const result = await runner(`gh issue create --title "${opts.title.replace(/"/g, '\\"')}" --body-file -${labels}`, { cwd: root, input: opts.body });
|
|
1848
|
+
if (!result.ok) {
|
|
1849
|
+
return {
|
|
1850
|
+
diagnostics: [
|
|
1851
|
+
diag("ATLAS-GH-002", "error", `No se pudo crear el issue: ${(result.stderr || result.stdout).trim().slice(0, 300)}`, {
|
|
1852
|
+
suggestion: "Verifica `gh auth status` y los permisos del repositorio"
|
|
1853
|
+
})
|
|
1854
|
+
]
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
const url = result.stdout.split("\n").map((line) => line.trim()).find((line) => /github\.com\/.+\/issues\/\d+/.test(line));
|
|
1858
|
+
const issue = url ? parseIssueUrl(url) : void 0;
|
|
1859
|
+
return issue ? { issue, diagnostics: [] } : { diagnostics: [diag("ATLAS-GH-003", "error", "GitHub no devolvi\xF3 la URL del issue creado", { path: void 0 })] };
|
|
1860
|
+
}
|
|
1861
|
+
async function editIssue(root, number, opts, runner = defaultRunner) {
|
|
1862
|
+
const parts = [`gh issue edit ${number}`];
|
|
1863
|
+
if (opts.title) parts.push(`--title "${opts.title.replace(/"/g, '\\"')}"`);
|
|
1864
|
+
if (opts.body !== void 0) parts.push("--body-file -");
|
|
1865
|
+
const result = await runner(parts.join(" "), { cwd: root, ...opts.body !== void 0 ? { input: opts.body } : {} });
|
|
1866
|
+
if (!result.ok) {
|
|
1867
|
+
return [diag("ATLAS-GH-004", "error", `No se pudo actualizar el issue #${number}: ${(result.stderr || result.stdout).trim().slice(0, 300)}`)];
|
|
1868
|
+
}
|
|
1869
|
+
return [];
|
|
1870
|
+
}
|
|
1871
|
+
async function issueLabels(root, number, runner = defaultRunner) {
|
|
1872
|
+
const result = await runner(`gh issue view ${number} --json labels`, { cwd: root });
|
|
1873
|
+
if (!result.ok) {
|
|
1874
|
+
return { labels: [], diagnostics: [diag("ATLAS-GH-005", "error", `No se pudo leer el issue #${number}: ${(result.stderr || result.stdout).trim().slice(0, 300)}`)] };
|
|
1875
|
+
}
|
|
1876
|
+
try {
|
|
1877
|
+
const data = JSON.parse(result.stdout);
|
|
1878
|
+
return { labels: (data.labels ?? []).map((label) => label.name ?? "").filter((name) => name !== ""), diagnostics: [] };
|
|
1879
|
+
} catch {
|
|
1880
|
+
return { labels: [], diagnostics: [diag("ATLAS-GH-005", "error", `Respuesta inesperada de gh para el issue #${number}`)] };
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
async function commentIssue(root, number, body, runner = defaultRunner) {
|
|
1884
|
+
const result = await runner(`gh issue comment ${number} --body-file -`, { cwd: root, input: body });
|
|
1885
|
+
return result.ok ? [] : [diag("ATLAS-GH-006", "error", `No se pudo comentar el issue #${number}: ${(result.stderr || result.stdout).trim().slice(0, 300)}`)];
|
|
1886
|
+
}
|
|
1887
|
+
async function patchChangeMeta(root, slug, patch) {
|
|
1888
|
+
const file = path5.join(path5.resolve(root), ".sdd", "changes", slug, "meta.yaml");
|
|
1889
|
+
const raw = await readTextIfExists(file);
|
|
1890
|
+
if (raw === void 0) {
|
|
1891
|
+
return { path: file, diagnostics: [diag("ATLAS-GH-007", "error", `No existe ${path5.relative(root, file)}`)] };
|
|
1892
|
+
}
|
|
1893
|
+
let data;
|
|
1894
|
+
try {
|
|
1895
|
+
data = parseYaml5(raw) ?? {};
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
return { path: file, diagnostics: [diag("ATLAS-GH-007", "error", `meta.yaml inv\xE1lido: ${err.message}`)] };
|
|
1898
|
+
}
|
|
1899
|
+
const { stringify } = await import("yaml");
|
|
1900
|
+
await writeText(file, stringify({ ...data, ...patch }, { lineWidth: 120 }));
|
|
1901
|
+
return { path: file, diagnostics: [] };
|
|
1902
|
+
}
|
|
1903
|
+
async function approveFromGithub(opts) {
|
|
1904
|
+
const runner = opts.runner ?? defaultRunner;
|
|
1905
|
+
const label = opts.label ?? "spec-approved";
|
|
1906
|
+
const change = await loadChange(opts.root, opts.slug);
|
|
1907
|
+
const tracker = change.meta?.tracker;
|
|
1908
|
+
if (!tracker || tracker.provider !== "github" || !tracker.id) {
|
|
1909
|
+
return {
|
|
1910
|
+
approved: false,
|
|
1911
|
+
label,
|
|
1912
|
+
diagnostics: [
|
|
1913
|
+
diag("ATLAS-GH-009", "error", `El cambio "${opts.slug}" no tiene un issue de GitHub vinculado`, {
|
|
1914
|
+
suggestion: `Ejecuta \`satlas issue sync ${opts.slug}\` primero`
|
|
1915
|
+
})
|
|
1916
|
+
]
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
const number = Number.parseInt(tracker.id, 10);
|
|
1920
|
+
const { labels, diagnostics } = await issueLabels(opts.root, number, runner);
|
|
1921
|
+
if (diagnostics.length > 0) return { approved: false, label, issueNumber: number, diagnostics };
|
|
1922
|
+
if (!labels.includes(label)) {
|
|
1923
|
+
return {
|
|
1924
|
+
approved: false,
|
|
1925
|
+
label,
|
|
1926
|
+
issueNumber: number,
|
|
1927
|
+
diagnostics: [
|
|
1928
|
+
diag("ATLAS-GH-010", "warning", `El issue #${number} no tiene la etiqueta "${label}"`, {
|
|
1929
|
+
suggestion: `Pide a quien aprueba que aplique la etiqueta "${label}" en el issue y vuelve a intentarlo`
|
|
1930
|
+
})
|
|
1931
|
+
]
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
let by = opts.by?.trim();
|
|
1935
|
+
if (!by) {
|
|
1936
|
+
const me = await runner("gh api user -q .login", { cwd: opts.root });
|
|
1937
|
+
by = me.ok ? me.stdout.trim() : "";
|
|
1938
|
+
}
|
|
1939
|
+
if (!by) {
|
|
1940
|
+
return { approved: false, label, issueNumber: number, diagnostics: [diag("ATLAS-GH-011", "error", "No se pudo determinar qui\xE9n aprueba (--by ni `gh api user`)")] };
|
|
1941
|
+
}
|
|
1942
|
+
const { signApproval: signApproval2 } = await import("./approvals-HCBC7QDM.js");
|
|
1943
|
+
const signed = await signApproval2({
|
|
1944
|
+
root: opts.root,
|
|
1945
|
+
artifact: path5.posix.join("changes", opts.slug, "spec.md"),
|
|
1946
|
+
by,
|
|
1947
|
+
channel: "tracker",
|
|
1948
|
+
note: `Aprobado v\xEDa GitHub issue #${number} (etiqueta "${label}")`
|
|
1949
|
+
});
|
|
1950
|
+
return {
|
|
1951
|
+
approved: signed.approval !== void 0,
|
|
1952
|
+
label,
|
|
1953
|
+
issueNumber: number,
|
|
1954
|
+
approvedBy: by,
|
|
1955
|
+
diagnostics: signed.diagnostics
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
async function syncGithubIssue(root, slug, opts = {}) {
|
|
1959
|
+
const runner = opts.runner ?? defaultRunner;
|
|
1960
|
+
const diagnostics = [];
|
|
1961
|
+
if (!await ghAvailable(runner)) {
|
|
1962
|
+
return {
|
|
1963
|
+
slug,
|
|
1964
|
+
created: false,
|
|
1965
|
+
diagnostics: [
|
|
1966
|
+
diag("ATLAS-GH-001", "error", "GitHub CLI (`gh`) no est\xE1 disponible", {
|
|
1967
|
+
suggestion: "Instala gh (https://cli.github.com) y ejecuta `gh auth login`"
|
|
1968
|
+
})
|
|
1969
|
+
]
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
if (!await ghAuthStatus(runner)) {
|
|
1973
|
+
return { slug, created: false, diagnostics: [diag("ATLAS-GH-001", "error", "gh no est\xE1 autenticado", { suggestion: "Ejecuta `gh auth login`" })] };
|
|
1974
|
+
}
|
|
1975
|
+
const repo = await detectRepo(root, runner);
|
|
1976
|
+
if (!repo) {
|
|
1977
|
+
return {
|
|
1978
|
+
slug,
|
|
1979
|
+
created: false,
|
|
1980
|
+
diagnostics: [diag("ATLAS-GH-008", "error", "No se detect\xF3 un repositorio de GitHub (remoto origin o `gh repo view`)")]
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
const change = await loadChange(root, slug);
|
|
1984
|
+
const deltaScenarios = [...change.delta?.added ?? [], ...change.delta?.modified ?? []].flatMap((r) => r.scenarios);
|
|
1985
|
+
const next = opts.next ?? { command: `satlas next ${slug}`, description: "Consulta la siguiente acci\xF3n" };
|
|
1986
|
+
const body = issueBody({ change, next: next.command, nextDescription: next.description, scenarios: deltaScenarios.length });
|
|
1987
|
+
const title = `${change.meta?.title ?? slug} (${slug})`;
|
|
1988
|
+
const linked = change.meta?.tracker?.provider === "github" && change.meta.tracker.id ? { number: Number.parseInt(change.meta.tracker.id, 10), url: change.meta.tracker.url ?? "", title, state: "OPEN" } : await findLinkedIssue(root, slug, runner);
|
|
1989
|
+
if (linked && Number.isFinite(linked.number)) {
|
|
1990
|
+
diagnostics.push(...await editIssue(root, linked.number, { title, body }, runner));
|
|
1991
|
+
if (change.meta?.tracker?.url !== linked.url && linked.url) {
|
|
1992
|
+
diagnostics.push(...(await patchChangeMeta(root, slug, { tracker: { provider: "github", id: String(linked.number), url: linked.url } })).diagnostics);
|
|
1993
|
+
}
|
|
1994
|
+
return { slug, created: false, issue: linked, diagnostics };
|
|
1995
|
+
}
|
|
1996
|
+
const created = await createIssue(root, { title, body, ...opts.labels ? { labels: opts.labels } : {} }, runner);
|
|
1997
|
+
diagnostics.push(...created.diagnostics);
|
|
1998
|
+
if (created.issue) {
|
|
1999
|
+
diagnostics.push(...(await patchChangeMeta(root, slug, { tracker: { provider: "github", id: String(created.issue.number), url: created.issue.url } })).diagnostics);
|
|
2000
|
+
}
|
|
2001
|
+
return { slug, created: true, ...created.issue ? { issue: created.issue } : {}, diagnostics };
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/evidence.ts
|
|
2005
|
+
import path6 from "path";
|
|
2006
|
+
var SCENARIO_HEAD_RE2 = /^###\s+(REQ-[A-Z0-9-]+-S\d+)\b/;
|
|
2007
|
+
function locateBlocks(content) {
|
|
2008
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
2009
|
+
const lines = normalized.split("\n");
|
|
2010
|
+
const blocks = [];
|
|
2011
|
+
let heading;
|
|
2012
|
+
let i = 0;
|
|
2013
|
+
while (i < lines.length) {
|
|
2014
|
+
const line = lines[i] ?? "";
|
|
2015
|
+
const head = SCENARIO_HEAD_RE2.exec(line);
|
|
2016
|
+
if (head) heading = (head[1] ?? "").toUpperCase();
|
|
2017
|
+
if (/^```evidence[ \t]*$/.test(line)) {
|
|
2018
|
+
const start = i;
|
|
2019
|
+
let j = i + 1;
|
|
2020
|
+
while (j < lines.length && !/^```\s*$/.test(lines[j] ?? "")) j += 1;
|
|
2021
|
+
const yamlLines = lines.slice(i + 1, j);
|
|
2022
|
+
const yamlText = yamlLines.join("\n");
|
|
2023
|
+
const scenarioInYaml = /scenario:\s*([A-Z0-9-]+)/i.exec(yamlText)?.[1]?.toUpperCase();
|
|
2024
|
+
blocks.push({ scenario: scenarioInYaml ?? heading, start, end: j, yamlStart: i + 1, yamlEnd: j });
|
|
2025
|
+
i = j;
|
|
2026
|
+
}
|
|
2027
|
+
i += 1;
|
|
2028
|
+
}
|
|
2029
|
+
void normalized;
|
|
2030
|
+
return blocks;
|
|
2031
|
+
}
|
|
2032
|
+
function buildEvidenceYaml(evidence) {
|
|
2033
|
+
const lines = [`method: ${evidence.method}`];
|
|
2034
|
+
if (evidence.command) lines.push(`command: ${evidence.command}`);
|
|
2035
|
+
lines.push(`result: ${evidence.result}`);
|
|
2036
|
+
if (evidence.outputHash) lines.push(`output_hash: ${evidence.outputHash}`);
|
|
2037
|
+
lines.push(`date: ${evidence.date}`);
|
|
2038
|
+
lines.push(`by: ${evidence.by}`);
|
|
2039
|
+
if (evidence.notes) lines.push(`notes: ${evidence.notes}`);
|
|
2040
|
+
return lines.join("\n");
|
|
2041
|
+
}
|
|
2042
|
+
async function recordEvidence(opts) {
|
|
2043
|
+
const root = path6.resolve(opts.root);
|
|
2044
|
+
const slug = opts.slug;
|
|
2045
|
+
const fileKind = opts.file ?? "verify";
|
|
2046
|
+
const file = path6.join(root, ".sdd", "changes", slug, fileKind === "fix" ? "fix.md" : "verify.md");
|
|
2047
|
+
const diagnostics = [];
|
|
2048
|
+
const scenario = opts.scenario.toUpperCase();
|
|
2049
|
+
if (!SCENARIO_ID_RE.test(scenario)) {
|
|
2050
|
+
return { exitCode: 2, path: file, diagnostics: [diag("LINT-EVD-002", "error", `Escenario mal formado: ${opts.scenario}`, { suggestion: "Formato: REQ-DOMINIO-NNN-S1" })] };
|
|
2051
|
+
}
|
|
2052
|
+
if (!opts.by.trim()) {
|
|
2053
|
+
return { exitCode: 2, path: file, diagnostics: [diag("ATLAS-VERIFY-001", "error", "Falta --by <nombre>: la evidencia es nominal")] };
|
|
2054
|
+
}
|
|
2055
|
+
let method = opts.method ?? (opts.command ? "executable" : "manual");
|
|
2056
|
+
let result = opts.result ?? "pass";
|
|
2057
|
+
let command = opts.command;
|
|
2058
|
+
let outputHash;
|
|
2059
|
+
let stdout;
|
|
2060
|
+
let stderr;
|
|
2061
|
+
let exitCode = 0;
|
|
2062
|
+
if (method === "executable" || method === "automatic") {
|
|
2063
|
+
if (!command) {
|
|
2064
|
+
return { exitCode: 2, path: file, diagnostics: [diag("LINT-EVD-001", "error", `El m\xE9todo ${method} exige --command`)] };
|
|
2065
|
+
}
|
|
2066
|
+
if (hasShellMetacharacters(command)) {
|
|
2067
|
+
return { exitCode: 2, path: file, diagnostics: [diag("ATLAS-EXEC-001", "error", "El comando contiene metacaracteres de shell y no se ejecutar\xE1", { suggestion: "Ejecuta un \xFAnico comando sin tuber\xEDas ni redirecciones" })] };
|
|
2068
|
+
}
|
|
2069
|
+
const allowed = opts.allowedPrefixes ?? [];
|
|
2070
|
+
if (allowed.length > 0 && !opts.allowCommand && !isCommandAllowed(command, allowed)) {
|
|
2071
|
+
return {
|
|
2072
|
+
exitCode: 2,
|
|
2073
|
+
path: file,
|
|
2074
|
+
diagnostics: [
|
|
2075
|
+
diag("ATLAS-EXEC-002", "error", `El comando no est\xE1 declarado en el perfil: "${command}"`, {
|
|
2076
|
+
suggestion: `Comandos permitidos: ${allowed.join(" | ")}. Usa --allow-command si es intencional.`
|
|
2077
|
+
})
|
|
2078
|
+
]
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
const exec = await runProcess(command, { cwd: root, timeoutMs: opts.timeoutMs });
|
|
2082
|
+
exitCode = exec.exitCode;
|
|
2083
|
+
stdout = exec.stdout;
|
|
2084
|
+
stderr = exec.stderr;
|
|
2085
|
+
result = opts.result ?? (exec.ok ? "pass" : "fail");
|
|
2086
|
+
outputHash = sha256(`${exec.stdout}
|
|
2087
|
+
${exec.stderr}`);
|
|
2088
|
+
} else if (opts.result === void 0) {
|
|
2089
|
+
return { exitCode: 2, path: file, diagnostics: [diag("ATLAS-VERIFY-002", "error", `El m\xE9todo ${method} exige --result pass|fail|skipped`)] };
|
|
2090
|
+
}
|
|
2091
|
+
const evidence = {
|
|
2092
|
+
scenario,
|
|
2093
|
+
method,
|
|
2094
|
+
result,
|
|
2095
|
+
date: (opts.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
2096
|
+
by: opts.by.trim(),
|
|
2097
|
+
line: 0
|
|
2098
|
+
};
|
|
2099
|
+
if (command) evidence.command = command;
|
|
2100
|
+
if (outputHash) evidence.outputHash = outputHash;
|
|
2101
|
+
if (opts.notes) evidence.notes = opts.notes;
|
|
2102
|
+
if (!opts.dryRun) {
|
|
2103
|
+
const existing = await readTextIfExists(file) ?? `# Verificaci\xF3n \u2014 ${slug}
|
|
2104
|
+
`;
|
|
2105
|
+
const blocks = locateBlocks(existing);
|
|
2106
|
+
const yaml = buildEvidenceYaml(evidence);
|
|
2107
|
+
const target = blocks.find((b) => b.scenario === scenario);
|
|
2108
|
+
let next;
|
|
2109
|
+
if (target) {
|
|
2110
|
+
const normalized = existing.replace(/\r\n?/g, "\n");
|
|
2111
|
+
const lines = normalized.split("\n");
|
|
2112
|
+
next = [...lines.slice(0, target.yamlStart), ...yaml.split("\n"), ...lines.slice(target.yamlEnd)].join("\n");
|
|
2113
|
+
} else {
|
|
2114
|
+
const base = existing.replace(/\r\n?/g, "\n").trimEnd();
|
|
2115
|
+
next = `${base}
|
|
2116
|
+
|
|
2117
|
+
### ${scenario}
|
|
2118
|
+
|
|
2119
|
+
\`\`\`evidence
|
|
2120
|
+
${yaml}
|
|
2121
|
+
\`\`\`
|
|
2122
|
+
`;
|
|
2123
|
+
}
|
|
2124
|
+
await writeText(file, next.replace(/\n{3,}/g, "\n\n"));
|
|
2125
|
+
}
|
|
2126
|
+
const returned = { evidence, exitCode: result === "pass" ? 0 : 1, path: file, diagnostics };
|
|
2127
|
+
if (stdout !== void 0) returned.stdout = stdout;
|
|
2128
|
+
if (stderr !== void 0) returned.stderr = stderr;
|
|
2129
|
+
return returned;
|
|
2130
|
+
}
|
|
2131
|
+
function evidenceSummary(content) {
|
|
2132
|
+
const out = /* @__PURE__ */ new Map();
|
|
2133
|
+
for (const block of locateBlocks(content)) {
|
|
2134
|
+
if (!block.scenario) continue;
|
|
2135
|
+
const yamlText = content.replace(/\r\n?/g, "\n").split("\n").slice(block.yamlStart, block.yamlEnd).join("\n");
|
|
2136
|
+
const result = /result:\s*(\w+)/i.exec(yamlText)?.[1];
|
|
2137
|
+
if (result) out.set(block.scenario, result);
|
|
2138
|
+
}
|
|
2139
|
+
return out;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// src/runs.ts
|
|
2143
|
+
import path7 from "path";
|
|
2144
|
+
import { randomBytes } from "crypto";
|
|
2145
|
+
var RUN_EVENT_TYPES = [
|
|
2146
|
+
"run_started",
|
|
2147
|
+
"task_started",
|
|
2148
|
+
"task_completed",
|
|
2149
|
+
"wave_started",
|
|
2150
|
+
"wave_completed",
|
|
2151
|
+
"block_started",
|
|
2152
|
+
"block_completed",
|
|
2153
|
+
"validate_run",
|
|
2154
|
+
"trace_run",
|
|
2155
|
+
"analyze_run",
|
|
2156
|
+
"verify_run",
|
|
2157
|
+
"mockup_checked",
|
|
2158
|
+
"presentation_generated",
|
|
2159
|
+
"override_applied",
|
|
2160
|
+
"paused",
|
|
2161
|
+
"resumed",
|
|
2162
|
+
"run_completed"
|
|
2163
|
+
];
|
|
2164
|
+
function generateRunId(now = /* @__PURE__ */ new Date()) {
|
|
2165
|
+
const iso = now.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
2166
|
+
return `${iso.slice(0, 8)}-${iso.slice(8, 14)}-${randomBytes(3).toString("hex")}`;
|
|
2167
|
+
}
|
|
2168
|
+
function runsDir(root) {
|
|
2169
|
+
return path7.join(path7.resolve(root), ".sdd", "runs");
|
|
2170
|
+
}
|
|
2171
|
+
async function createRun(root, slug, phase, inputs, now = /* @__PURE__ */ new Date()) {
|
|
2172
|
+
const runId = generateRunId(now);
|
|
2173
|
+
const state = {
|
|
2174
|
+
runId,
|
|
2175
|
+
slug,
|
|
2176
|
+
phase,
|
|
2177
|
+
status: "active",
|
|
2178
|
+
startedAt: now.toISOString(),
|
|
2179
|
+
updatedAt: now.toISOString()
|
|
2180
|
+
};
|
|
2181
|
+
if (inputs) state.inputs = inputs;
|
|
2182
|
+
const dir = path7.join(runsDir(root), runId);
|
|
2183
|
+
await ensureDir(dir);
|
|
2184
|
+
await writeText(path7.join(dir, "state.json"), `${JSON.stringify(state, null, 2)}
|
|
2185
|
+
`);
|
|
2186
|
+
await writeText(path7.join(dir, "events.jsonl"), "");
|
|
2187
|
+
await appendRunEvent(root, runId, "run_started", { slug, phase });
|
|
2188
|
+
return state;
|
|
2189
|
+
}
|
|
2190
|
+
async function appendRunEvent(root, runId, type, data, now = /* @__PURE__ */ new Date()) {
|
|
2191
|
+
const event = { eventId: randomBytes(4).toString("hex"), at: now.toISOString(), type };
|
|
2192
|
+
if (data) event.data = data;
|
|
2193
|
+
const file = path7.join(runsDir(root), runId, "events.jsonl");
|
|
2194
|
+
const previous = await readTextIfExists(file) ?? "";
|
|
2195
|
+
await writeText(file, `${previous}${JSON.stringify(event)}
|
|
2196
|
+
`);
|
|
2197
|
+
return event;
|
|
2198
|
+
}
|
|
2199
|
+
async function updateRunStatus(root, runId, status, now = /* @__PURE__ */ new Date()) {
|
|
2200
|
+
const file = path7.join(runsDir(root), runId, "state.json");
|
|
2201
|
+
const raw = await readTextIfExists(file);
|
|
2202
|
+
if (raw === void 0) return;
|
|
2203
|
+
const state = JSON.parse(raw);
|
|
2204
|
+
state.status = status;
|
|
2205
|
+
state.updatedAt = now.toISOString();
|
|
2206
|
+
await writeText(file, `${JSON.stringify(state, null, 2)}
|
|
2207
|
+
`);
|
|
2208
|
+
}
|
|
2209
|
+
async function readRun(root, runId) {
|
|
2210
|
+
const dir = path7.join(runsDir(root), runId);
|
|
2211
|
+
const stateRaw = await readTextIfExists(path7.join(dir, "state.json"));
|
|
2212
|
+
if (stateRaw === void 0) {
|
|
2213
|
+
return { diagnostics: [diag("ATLAS-RUN-001", "error", `No existe el run ${runId}`, { path: dir })] };
|
|
2214
|
+
}
|
|
2215
|
+
const state = JSON.parse(stateRaw);
|
|
2216
|
+
const eventsRaw = await readTextIfExists(path7.join(dir, "events.jsonl")) ?? "";
|
|
2217
|
+
const events = [];
|
|
2218
|
+
for (const line of eventsRaw.split("\n")) {
|
|
2219
|
+
if (line.trim() === "") continue;
|
|
2220
|
+
try {
|
|
2221
|
+
events.push(JSON.parse(line));
|
|
2222
|
+
} catch {
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
return { record: { state, events }, diagnostics: [] };
|
|
2226
|
+
}
|
|
2227
|
+
async function listRuns(root, filter = {}) {
|
|
2228
|
+
const dir = runsDir(root);
|
|
2229
|
+
if (!await exists(dir)) return [];
|
|
2230
|
+
const ids = await listDirs(dir);
|
|
2231
|
+
const out = [];
|
|
2232
|
+
for (const id of ids) {
|
|
2233
|
+
const raw = await readTextIfExists(path7.join(dir, id, "state.json"));
|
|
2234
|
+
if (raw === void 0) continue;
|
|
2235
|
+
try {
|
|
2236
|
+
const state = JSON.parse(raw);
|
|
2237
|
+
if (filter.slug && state.slug !== filter.slug) continue;
|
|
2238
|
+
if (filter.status && state.status !== filter.status) continue;
|
|
2239
|
+
out.push(state);
|
|
2240
|
+
} catch {
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return out.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// src/analyze.ts
|
|
2247
|
+
import path10 from "path";
|
|
2248
|
+
|
|
2249
|
+
// src/mockups.ts
|
|
2250
|
+
import path8 from "path";
|
|
2251
|
+
import { parse as parseYaml6, stringify as stringifyYaml4 } from "yaml";
|
|
2252
|
+
import { z as z4 } from "zod";
|
|
2253
|
+
var mockupManifestSchema = z4.object({
|
|
2254
|
+
schema_version: z4.number().int().positive().default(1),
|
|
2255
|
+
version: z4.number().int().positive().default(1),
|
|
2256
|
+
level: z4.enum(["sketch", "hifi"]).default("hifi"),
|
|
2257
|
+
platform: z4.enum(["web", "mobile", "desktop"]).default("web"),
|
|
2258
|
+
inputs_hash: z4.string().optional(),
|
|
2259
|
+
generated_at: z4.string().optional(),
|
|
2260
|
+
tokens: z4.string().optional(),
|
|
2261
|
+
screens: z4.array(
|
|
2262
|
+
z4.object({
|
|
2263
|
+
id: z4.string(),
|
|
2264
|
+
file: z4.string(),
|
|
2265
|
+
title: z4.string().optional(),
|
|
2266
|
+
illustrates: z4.array(z4.string()).default([]),
|
|
2267
|
+
states: z4.array(z4.string()).default([]),
|
|
2268
|
+
breakpoints: z4.array(z4.number()).default([]),
|
|
2269
|
+
themes: z4.array(z4.string()).default([])
|
|
2270
|
+
})
|
|
2271
|
+
).default([]),
|
|
2272
|
+
screenshots: z4.array(z4.string()).default([])
|
|
2273
|
+
});
|
|
2274
|
+
var TOKEN_CANDIDATES = ["design/tokens.json", "DESIGN.md", "design/DESIGN.md", ".sdd/design/tokens.json"];
|
|
2275
|
+
function mockupsDir(root, slug) {
|
|
2276
|
+
return path8.join(path8.resolve(root), ".sdd", "changes", slug, "mockups");
|
|
2277
|
+
}
|
|
2278
|
+
async function tokensFile(root) {
|
|
2279
|
+
for (const candidate of TOKEN_CANDIDATES) {
|
|
2280
|
+
const abs = path8.join(root, candidate);
|
|
2281
|
+
if (await exists(abs)) return candidate;
|
|
2282
|
+
}
|
|
2283
|
+
return void 0;
|
|
2284
|
+
}
|
|
2285
|
+
async function computeInputsHash(root, change) {
|
|
2286
|
+
const deltaPath = path8.join(change.dir, "spec.md");
|
|
2287
|
+
const delta = await readTextIfExists(deltaPath) ?? "";
|
|
2288
|
+
const tokens = await tokensFile(root);
|
|
2289
|
+
const tokensContent = tokens ? await readTextIfExists(path8.join(root, tokens)) ?? "" : "";
|
|
2290
|
+
return artifactHash(`${delta}
|
|
2291
|
+
---tokens---
|
|
2292
|
+
${tokensContent}`);
|
|
2293
|
+
}
|
|
2294
|
+
function planMockups(change, platform = "web") {
|
|
2295
|
+
const screens = [];
|
|
2296
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2297
|
+
for (const requirement of [...change.delta?.added ?? [], ...change.delta?.modified ?? []]) {
|
|
2298
|
+
if (seen.has(requirement.id)) continue;
|
|
2299
|
+
seen.add(requirement.id);
|
|
2300
|
+
const id = requirement.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2301
|
+
const states = platform === "mobile" ? ["default", "loading", "empty", "error", "offline"] : ["default", "loading", "empty", "error"];
|
|
2302
|
+
screens.push({
|
|
2303
|
+
id,
|
|
2304
|
+
title: requirement.title,
|
|
2305
|
+
file: `${id}.html`,
|
|
2306
|
+
illustrates: requirement.scenarios.map((s) => s.id),
|
|
2307
|
+
states,
|
|
2308
|
+
breakpoints: platform === "mobile" ? [390, 768] : [390, 768, 1440]
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
return { platform, screens };
|
|
2312
|
+
}
|
|
2313
|
+
async function readMockupManifest(root, slug) {
|
|
2314
|
+
const file = path8.join(mockupsDir(root, slug), "manifest.yaml");
|
|
2315
|
+
const raw = await readTextIfExists(file);
|
|
2316
|
+
if (raw === void 0) return { diagnostics: [] };
|
|
2317
|
+
let data;
|
|
2318
|
+
try {
|
|
2319
|
+
data = parseYaml6(raw);
|
|
2320
|
+
} catch (err) {
|
|
2321
|
+
return { path: file, diagnostics: [diag("ATLAS-MKP-002", "error", `manifest.yaml inv\xE1lido: ${err.message}`, { path: file })] };
|
|
2322
|
+
}
|
|
2323
|
+
const parsed = mockupManifestSchema.safeParse(data);
|
|
2324
|
+
if (!parsed.success) {
|
|
2325
|
+
return {
|
|
2326
|
+
path: file,
|
|
2327
|
+
diagnostics: parsed.error.issues.map((issue) => diag("ATLAS-MKP-003", "error", `manifest.yaml (${issue.path.join(".") || "ra\xEDz"}): ${issue.message}`, { path: file }))
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
const v = parsed.data;
|
|
2331
|
+
const manifest = {
|
|
2332
|
+
schemaVersion: v.schema_version,
|
|
2333
|
+
version: v.version,
|
|
2334
|
+
level: v.level,
|
|
2335
|
+
platform: v.platform,
|
|
2336
|
+
screens: v.screens.map((s) => {
|
|
2337
|
+
const screen = { id: s.id, file: s.file };
|
|
2338
|
+
if (s.title !== void 0) screen.title = s.title;
|
|
2339
|
+
if (s.illustrates.length > 0) screen.illustrates = s.illustrates;
|
|
2340
|
+
if (s.states.length > 0) screen.states = s.states;
|
|
2341
|
+
if (s.breakpoints.length > 0) screen.breakpoints = s.breakpoints;
|
|
2342
|
+
if (s.themes.length > 0) screen.themes = s.themes;
|
|
2343
|
+
return screen;
|
|
2344
|
+
})
|
|
2345
|
+
};
|
|
2346
|
+
if (v.inputs_hash !== void 0) manifest.inputsHash = v.inputs_hash;
|
|
2347
|
+
if (v.generated_at !== void 0) manifest.generatedAt = v.generated_at;
|
|
2348
|
+
if (v.tokens !== void 0) manifest.tokens = v.tokens;
|
|
2349
|
+
return { manifest, path: file, diagnostics: [] };
|
|
2350
|
+
}
|
|
2351
|
+
function lintMockupHtml(html, file) {
|
|
2352
|
+
const findings = [];
|
|
2353
|
+
if (!/mockup/i.test(html)) {
|
|
2354
|
+
findings.push(diag("LINT-MKP-008", "error", 'El mockup no muestra el banner "MOCKUP \xB7 NO FUNCIONAL"', { path: file, suggestion: "A\xF1ade el banner visible con versi\xF3n y fecha" }));
|
|
2355
|
+
}
|
|
2356
|
+
if (/lorem ipsum/i.test(html)) {
|
|
2357
|
+
findings.push(diag("LINT-MKP-003", "error", "El mockup usa Lorem ipsum", { path: file, suggestion: "Usa datos reales del glosario y del dominio" }));
|
|
2358
|
+
}
|
|
2359
|
+
if (/\bitem\s*\d+\b/i.test(html)) {
|
|
2360
|
+
findings.push(diag("LINT-MKP-003", "warning", 'El mockup usa textos gen\xE9ricos ("Item 1")', { path: file, suggestion: "Usa datos realistas del dominio" }));
|
|
2361
|
+
}
|
|
2362
|
+
const external = /(src|href)\s*=\s*["']https?:/i.test(html) || /<script[^>]+src=/i.test(html) || /@import\s+url\(/i.test(html);
|
|
2363
|
+
if (external) {
|
|
2364
|
+
findings.push(diag("LINT-MKP-009", "error", "El mockup referencia recursos externos (red)", { path: file, suggestion: "Embebe fuentes, im\xE1genes y estilos en el propio HTML" }));
|
|
2365
|
+
}
|
|
2366
|
+
if (!/<meta[^>]+name=["']viewport["']/i.test(html)) {
|
|
2367
|
+
findings.push(diag("LINT-MKP-010", "warning", "Falta la meta viewport", { path: file, suggestion: 'A\xF1ade <meta name="viewport" content="width=device-width, initial-scale=1">' }));
|
|
2368
|
+
}
|
|
2369
|
+
const states = new Set([...html.matchAll(/data-state=["']([^"']+)["']/gi)].map((m) => (m[1] ?? "").toLowerCase()));
|
|
2370
|
+
if (states.size > 0 && states.size < 3) {
|
|
2371
|
+
findings.push(diag("LINT-MKP-001", "warning", `El mockup solo declara ${states.size} estado(s)`, { path: file, suggestion: "Incluye default, loading, vac\xEDo y error" }));
|
|
2372
|
+
}
|
|
2373
|
+
return findings;
|
|
2374
|
+
}
|
|
2375
|
+
function lintMockupManifest(manifest, knownScenarios, file) {
|
|
2376
|
+
const findings = [];
|
|
2377
|
+
if (manifest.screens.length === 0) {
|
|
2378
|
+
findings.push(diag("LINT-MKP-004", "error", "El manifiesto de mockups no declara pantallas", { path: file }));
|
|
2379
|
+
}
|
|
2380
|
+
for (const screen of manifest.screens) {
|
|
2381
|
+
if (!screen.illustrates || screen.illustrates.length === 0) {
|
|
2382
|
+
findings.push(diag("LINT-MKP-006", "error", `La pantalla "${screen.id}" no declara qu\xE9 escenarios ilustra`, { path: file, suggestion: "A\xF1ade illustrates: [REQ-\u2026-S1]" }));
|
|
2383
|
+
}
|
|
2384
|
+
for (const scenario of screen.illustrates ?? []) {
|
|
2385
|
+
if (!SCENARIO_ID_RE.test(scenario) || !knownScenarios.has(scenario)) {
|
|
2386
|
+
findings.push(diag("TRACE-011", "error", `La pantalla "${screen.id}" ilustra ${scenario}, que no existe en el cambio`, { path: file }));
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
if (!screen.states || screen.states.length === 0) {
|
|
2390
|
+
findings.push(diag("LINT-MKP-001", "warning", `La pantalla "${screen.id}" no declara estados`, { path: file }));
|
|
2391
|
+
}
|
|
2392
|
+
if (!screen.breakpoints || screen.breakpoints.length === 0) {
|
|
2393
|
+
findings.push(diag("LINT-MKP-002", "warning", `La pantalla "${screen.id}" no declara breakpoints`, { path: file }));
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
return findings;
|
|
2397
|
+
}
|
|
2398
|
+
async function checkMockups(root, slug, change) {
|
|
2399
|
+
const dir = mockupsDir(root, slug);
|
|
2400
|
+
if (!await exists(dir)) {
|
|
2401
|
+
return { findings: [diag("LINT-MKP-004", "warning", "El cambio no tiene carpeta de mockups", { path: dir, suggestion: "Genera los mockups con la fase /satlas-mockup" })], stale: false };
|
|
2402
|
+
}
|
|
2403
|
+
const { manifest, path: manifestPath, diagnostics } = await readMockupManifest(root, slug);
|
|
2404
|
+
const findings = [...diagnostics];
|
|
2405
|
+
if (!manifest || !manifestPath) {
|
|
2406
|
+
return { findings: [...findings, diag("LINT-MKP-004", "error", "No existe mockups/manifest.yaml", { path: path8.join(dir, "manifest.yaml") })], stale: false };
|
|
2407
|
+
}
|
|
2408
|
+
const known = /* @__PURE__ */ new Set();
|
|
2409
|
+
for (const req of [...change.delta?.added ?? [], ...change.delta?.modified ?? []]) {
|
|
2410
|
+
for (const scenario of req.scenarios) known.add(scenario.id);
|
|
2411
|
+
}
|
|
2412
|
+
findings.push(...lintMockupManifest(manifest, known, manifestPath));
|
|
2413
|
+
for (const screen of manifest.screens) {
|
|
2414
|
+
const html = await readTextIfExists(path8.join(dir, screen.file));
|
|
2415
|
+
if (html === void 0) {
|
|
2416
|
+
findings.push(diag("ATLAS-MKP-004", "error", `Falta el archivo del mockup: ${screen.file}`, { path: path8.join(dir, screen.file) }));
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
findings.push(...lintMockupHtml(html, path8.join(dir, screen.file)));
|
|
2420
|
+
}
|
|
2421
|
+
const inputsHash = await computeInputsHash(root, change);
|
|
2422
|
+
const stale = manifest.inputsHash !== void 0 && manifest.inputsHash !== inputsHash;
|
|
2423
|
+
if (stale) {
|
|
2424
|
+
findings.push(
|
|
2425
|
+
diag("MKP-STALE", "warning", "Los mockups est\xE1n desactualizados respecto a la spec (inputs_hash cambi\xF3)", {
|
|
2426
|
+
path: manifestPath,
|
|
2427
|
+
suggestion: "Regenera los mockups y vuelve a aprobarlos"
|
|
2428
|
+
})
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
return { findings, stale, manifest, manifestPath };
|
|
2432
|
+
}
|
|
2433
|
+
async function writeMockupPlan(root, slug, plan, now = /* @__PURE__ */ new Date()) {
|
|
2434
|
+
const file = path8.join(mockupsDir(root, slug), "plan.yaml");
|
|
2435
|
+
if (await exists(file)) return file;
|
|
2436
|
+
const doc = {
|
|
2437
|
+
schema_version: 1,
|
|
2438
|
+
generated_at: now.toISOString(),
|
|
2439
|
+
platform: plan.platform,
|
|
2440
|
+
screens: plan.screens.map((s) => ({ id: s.id, title: s.title, file: s.file, illustrates: s.illustrates, states: s.states, breakpoints: s.breakpoints }))
|
|
2441
|
+
};
|
|
2442
|
+
await writeText(file, stringifyYaml4(doc, { lineWidth: 120 }));
|
|
2443
|
+
return file;
|
|
2444
|
+
}
|
|
2445
|
+
async function writeMockupManifest(root, slug, plan, inputsHash, now = /* @__PURE__ */ new Date()) {
|
|
2446
|
+
const file = path8.join(mockupsDir(root, slug), "manifest.yaml");
|
|
2447
|
+
if (await exists(file)) return file;
|
|
2448
|
+
const doc = {
|
|
2449
|
+
schema_version: 1,
|
|
2450
|
+
version: 1,
|
|
2451
|
+
level: "hifi",
|
|
2452
|
+
platform: plan.platform,
|
|
2453
|
+
inputs_hash: inputsHash,
|
|
2454
|
+
generated_at: now.toISOString(),
|
|
2455
|
+
screens: plan.screens.map((s) => ({ id: s.id, title: s.title, file: s.file, illustrates: s.illustrates, states: s.states, breakpoints: s.breakpoints })),
|
|
2456
|
+
screenshots: []
|
|
2457
|
+
};
|
|
2458
|
+
await writeText(file, stringifyYaml4(doc, { lineWidth: 120 }));
|
|
2459
|
+
return file;
|
|
2460
|
+
}
|
|
2461
|
+
async function updateMockupScreenshots(root, slug, screenshots) {
|
|
2462
|
+
const file = path8.join(mockupsDir(root, slug), "manifest.yaml");
|
|
2463
|
+
const raw = await readTextIfExists(file);
|
|
2464
|
+
if (raw === void 0) return void 0;
|
|
2465
|
+
const data = parseYaml6(raw) ?? {};
|
|
2466
|
+
data["screenshots"] = screenshots;
|
|
2467
|
+
await writeText(file, stringifyYaml4(data, { lineWidth: 120 }));
|
|
2468
|
+
return file;
|
|
2469
|
+
}
|
|
2470
|
+
async function captureMockups(root, slug, manifest, now = /* @__PURE__ */ new Date()) {
|
|
2471
|
+
const diagnostics = [];
|
|
2472
|
+
const dir = mockupsDir(root, slug);
|
|
2473
|
+
let playwright;
|
|
2474
|
+
try {
|
|
2475
|
+
const dynamicImport = new Function("specifier", "return import(specifier)");
|
|
2476
|
+
playwright = await dynamicImport("playwright");
|
|
2477
|
+
} catch {
|
|
2478
|
+
diagnostics.push(
|
|
2479
|
+
diag("ATLAS-MKP-001", "warning", "Playwright no est\xE1 instalado: captura omitida", {
|
|
2480
|
+
suggestion: "Instala playwright (npm i -D playwright) y reintenta, o valida los mockups manualmente"
|
|
2481
|
+
})
|
|
2482
|
+
);
|
|
2483
|
+
return { screenshots: [], diagnostics };
|
|
2484
|
+
}
|
|
2485
|
+
const browser = await playwright.chromium.launch({ channel: process.platform === "win32" ? "msedge" : void 0 });
|
|
2486
|
+
const screenshots = [];
|
|
2487
|
+
try {
|
|
2488
|
+
for (const screen of manifest.screens) {
|
|
2489
|
+
const page2 = await browser.newPage();
|
|
2490
|
+
for (const bp of screen.breakpoints && screen.breakpoints.length > 0 ? screen.breakpoints : [1440]) {
|
|
2491
|
+
await page2.setViewportSize({ width: bp, height: 900 });
|
|
2492
|
+
await page2.goto(`file://${path8.join(dir, screen.file).split(path8.sep).join("/")}`);
|
|
2493
|
+
const rel = path8.posix.join("screens", `${screen.id}-${bp}.png`);
|
|
2494
|
+
await page2.screenshot({ path: path8.join(dir, rel) });
|
|
2495
|
+
screenshots.push(rel);
|
|
2496
|
+
}
|
|
2497
|
+
await page2.close();
|
|
2498
|
+
}
|
|
2499
|
+
} finally {
|
|
2500
|
+
await browser.close();
|
|
2501
|
+
}
|
|
2502
|
+
void now;
|
|
2503
|
+
return { screenshots, diagnostics };
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// src/packs.ts
|
|
2507
|
+
import path9 from "path";
|
|
2508
|
+
import { parse as parseYaml7 } from "yaml";
|
|
2509
|
+
import { z as z5 } from "zod";
|
|
2510
|
+
var checkSchema = z5.object({
|
|
2511
|
+
id: z5.string().min(1),
|
|
2512
|
+
title: z5.string().min(1),
|
|
2513
|
+
type: z5.enum(["task-rollback", "evidence-strong", "spec-terms", "rule-terms", "nfr-measurable", "approval-provider"]),
|
|
2514
|
+
severity: z5.enum(["error", "warning", "info"]).default("warning"),
|
|
2515
|
+
applies_to: z5.object({ domains: z5.array(z5.string()).optional(), lanes: z5.array(z5.enum(["fix", "standard", "full"])).optional() }).optional(),
|
|
2516
|
+
params: z5.object({ terms: z5.array(z5.string()).optional() }).optional(),
|
|
2517
|
+
hint: z5.string().optional()
|
|
2518
|
+
});
|
|
2519
|
+
var packSchema = z5.object({
|
|
2520
|
+
id: z5.string().min(1),
|
|
2521
|
+
title: z5.string().min(1),
|
|
2522
|
+
description: z5.string().default(""),
|
|
2523
|
+
checks: z5.array(checkSchema).min(1)
|
|
2524
|
+
});
|
|
2525
|
+
var BUILTIN_PACKS = [
|
|
2526
|
+
{
|
|
2527
|
+
id: "seguridad",
|
|
2528
|
+
title: "Seguridad de la informaci\xF3n",
|
|
2529
|
+
description: "Controles m\xEDnimos para cambios que tocan autenticaci\xF3n, datos o infraestructura.",
|
|
2530
|
+
source: "builtin",
|
|
2531
|
+
checks: [
|
|
2532
|
+
{
|
|
2533
|
+
id: "SEG-1",
|
|
2534
|
+
title: "Toda tarea declara c\xF3mo revertir",
|
|
2535
|
+
type: "task-rollback",
|
|
2536
|
+
severity: "error",
|
|
2537
|
+
hint: "A\xF1ade \xB7 Reversi\xF3n: <c\xF3mo revertir> a cada tarea del cambio."
|
|
2538
|
+
},
|
|
2539
|
+
{
|
|
2540
|
+
id: "SEG-2",
|
|
2541
|
+
title: "La evidencia es automatizable (no manual)",
|
|
2542
|
+
type: "evidence-strong",
|
|
2543
|
+
severity: "warning",
|
|
2544
|
+
appliesTo: { domains: ["backend", "database", "infra", "fullstack"] },
|
|
2545
|
+
hint: "Registra la evidencia con un comando real (satlas verify --command) o una comprobaci\xF3n autom\xE1tica."
|
|
2546
|
+
},
|
|
2547
|
+
{
|
|
2548
|
+
id: "SEG-3",
|
|
2549
|
+
title: "Los requisitos nombran el control de seguridad",
|
|
2550
|
+
type: "rule-terms",
|
|
2551
|
+
severity: "warning",
|
|
2552
|
+
params: { terms: ["autenticaci\xF3n", "autorizaci\xF3n", "permiso", "cifrado", "secreto", "token", "sesi\xF3n"] },
|
|
2553
|
+
hint: "Si el cambio toca seguridad, descr\xEDbelo en una regla (BR-*): qui\xE9n accede, con qu\xE9 permiso y c\xF3mo se protege."
|
|
2554
|
+
}
|
|
2555
|
+
]
|
|
2556
|
+
},
|
|
2557
|
+
{
|
|
2558
|
+
id: "datos",
|
|
2559
|
+
title: "Datos personales y privacidad",
|
|
2560
|
+
description: "Controles de privacidad para cambios que tratan datos de personas.",
|
|
2561
|
+
source: "builtin",
|
|
2562
|
+
checks: [
|
|
2563
|
+
{
|
|
2564
|
+
id: "DAT-1",
|
|
2565
|
+
title: "Se declara la finalidad y el ciclo de vida del dato",
|
|
2566
|
+
type: "rule-terms",
|
|
2567
|
+
severity: "warning",
|
|
2568
|
+
params: { terms: ["datos personales", "consentimiento", "retenci\xF3n", "anonimiz", "privacidad", "eliminaci\xF3n"] },
|
|
2569
|
+
hint: "A\xF1ade una regla de negocio que diga para qu\xE9 se usan los datos y cu\xE1ndo se eliminan."
|
|
2570
|
+
},
|
|
2571
|
+
{
|
|
2572
|
+
id: "DAT-2",
|
|
2573
|
+
title: "Toda tarea declara c\xF3mo revertir",
|
|
2574
|
+
type: "task-rollback",
|
|
2575
|
+
severity: "error",
|
|
2576
|
+
hint: "A\xF1ade \xB7 Reversi\xF3n: <c\xF3mo revertir> a cada tarea del cambio."
|
|
2577
|
+
},
|
|
2578
|
+
{
|
|
2579
|
+
id: "DAT-3",
|
|
2580
|
+
title: "Los escenarios cubren la eliminaci\xF3n o exportaci\xF3n",
|
|
2581
|
+
type: "spec-terms",
|
|
2582
|
+
severity: "warning",
|
|
2583
|
+
params: { terms: ["elimin", "export", "borrar", "anonimiz"] },
|
|
2584
|
+
hint: "Incluye un escenario donde la persona ejerce sus derechos sobre el dato."
|
|
2585
|
+
}
|
|
2586
|
+
]
|
|
2587
|
+
},
|
|
2588
|
+
{
|
|
2589
|
+
id: "auditoria",
|
|
2590
|
+
title: "Auditor\xEDa y control interno",
|
|
2591
|
+
description: "Controles para procesos financieros o regulados: rastro completo y evidencia fuerte.",
|
|
2592
|
+
source: "builtin",
|
|
2593
|
+
checks: [
|
|
2594
|
+
{
|
|
2595
|
+
id: "AUD-1",
|
|
2596
|
+
title: "Toda tarea declara c\xF3mo revertir",
|
|
2597
|
+
type: "task-rollback",
|
|
2598
|
+
severity: "error"
|
|
2599
|
+
},
|
|
2600
|
+
{
|
|
2601
|
+
id: "AUD-2",
|
|
2602
|
+
title: "Evidencia ejecutable o autom\xE1tica (sin manual)",
|
|
2603
|
+
type: "evidence-strong",
|
|
2604
|
+
severity: "error",
|
|
2605
|
+
hint: "Un proceso auditado no se cierra con evidencia manual: usa pruebas, .http o un script."
|
|
2606
|
+
},
|
|
2607
|
+
{
|
|
2608
|
+
id: "AUD-3",
|
|
2609
|
+
title: "Se declara el rastro de auditor\xEDa",
|
|
2610
|
+
type: "rule-terms",
|
|
2611
|
+
severity: "warning",
|
|
2612
|
+
params: { terms: ["auditor\xEDa", "registra", "hist\xF3rico", "trazabilidad", "comprobante"] },
|
|
2613
|
+
hint: "Describe qu\xE9 queda registrado, con qu\xE9 datos y qui\xE9n puede consultarlo."
|
|
2614
|
+
},
|
|
2615
|
+
{
|
|
2616
|
+
id: "AUD-4",
|
|
2617
|
+
title: "La aprobaci\xF3n no est\xE1 desactivada",
|
|
2618
|
+
type: "approval-provider",
|
|
2619
|
+
severity: "error",
|
|
2620
|
+
hint: "Configura gates.approval en `file` o `github-label`: un proceso auditado exige firma nominal."
|
|
2621
|
+
}
|
|
2622
|
+
]
|
|
2623
|
+
},
|
|
2624
|
+
{
|
|
2625
|
+
id: "accesibilidad",
|
|
2626
|
+
title: "Accesibilidad (WCAG AA)",
|
|
2627
|
+
description: "Controles para interfaces web y m\xF3viles.",
|
|
2628
|
+
source: "builtin",
|
|
2629
|
+
checks: [
|
|
2630
|
+
{
|
|
2631
|
+
id: "ACC-1",
|
|
2632
|
+
title: "Los escenarios de interfaz cubren teclado, foco o contraste",
|
|
2633
|
+
type: "spec-terms",
|
|
2634
|
+
severity: "warning",
|
|
2635
|
+
appliesTo: { domains: ["frontend", "mobile", "fullstack"] },
|
|
2636
|
+
params: { terms: ["teclado", "foco", "contraste", "lector de pantalla", "aria", "accesib"] },
|
|
2637
|
+
hint: "A\xF1ade un escenario de uso con teclado, foco visible o contraste suficiente."
|
|
2638
|
+
},
|
|
2639
|
+
{
|
|
2640
|
+
id: "ACC-2",
|
|
2641
|
+
title: "Hay un requisito no funcional medible (AA)",
|
|
2642
|
+
type: "nfr-measurable",
|
|
2643
|
+
severity: "warning",
|
|
2644
|
+
appliesTo: { domains: ["frontend", "mobile", "fullstack"] },
|
|
2645
|
+
hint: "Incluye una regla con un valor medible (contraste 4.5:1, \xE1rea t\xE1ctil 44 px, etc.)."
|
|
2646
|
+
}
|
|
2647
|
+
]
|
|
2648
|
+
}
|
|
2649
|
+
];
|
|
2650
|
+
function normalize(text) {
|
|
2651
|
+
return text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
2652
|
+
}
|
|
2653
|
+
function changeTexts(change) {
|
|
2654
|
+
const delta = change.delta;
|
|
2655
|
+
const requirements = [...delta?.added ?? [], ...delta?.modified ?? []];
|
|
2656
|
+
const spec = requirements.map((requirement) => `${requirement.title}
|
|
2657
|
+
${requirement.prose}`).join("\n");
|
|
2658
|
+
const rules = requirements.flatMap((requirement) => requirement.rules.map((rule) => rule.text)).join("\n");
|
|
2659
|
+
const scenarios = requirements.flatMap((requirement) => requirement.scenarios.flatMap((scenario) => [scenario.title, ...scenario.when, ...scenario.then])).join("\n");
|
|
2660
|
+
return { spec, rules, scenarios };
|
|
2661
|
+
}
|
|
2662
|
+
function hasAnyTerm(text, terms) {
|
|
2663
|
+
const haystack = normalize(text);
|
|
2664
|
+
return terms.some((term) => haystack.includes(normalize(term)));
|
|
2665
|
+
}
|
|
2666
|
+
function applies(check, change) {
|
|
2667
|
+
const appliesTo = check.appliesTo;
|
|
2668
|
+
if (!appliesTo) return true;
|
|
2669
|
+
const domain = change.meta?.domain ?? "";
|
|
2670
|
+
const lane = change.meta?.lane ?? "standard";
|
|
2671
|
+
if (appliesTo.domains && appliesTo.domains.length > 0 && !appliesTo.domains.includes(domain)) return false;
|
|
2672
|
+
if (appliesTo.lanes && appliesTo.lanes.length > 0 && !appliesTo.lanes.includes(lane)) return false;
|
|
2673
|
+
return true;
|
|
2674
|
+
}
|
|
2675
|
+
function evaluateCheck(check, change, tasks, verify, config) {
|
|
2676
|
+
const base = { packId: "", checkId: check.id, title: check.title, severity: check.severity };
|
|
2677
|
+
if (!applies(check, change)) {
|
|
2678
|
+
return { ...base, status: "n/a" };
|
|
2679
|
+
}
|
|
2680
|
+
const texts = changeTexts(change);
|
|
2681
|
+
switch (check.type) {
|
|
2682
|
+
case "task-rollback": {
|
|
2683
|
+
const pending = tasks?.blocks.flatMap((block) => block.tasks) ?? [];
|
|
2684
|
+
const tasksWithout = pending.filter((task) => !task.infra && (!task.rollback || task.rollback.trim() === ""));
|
|
2685
|
+
if (pending.length === 0) return { ...base, status: "fail", message: "El cambio no tiene tareas con las que verificar el control", ...check.hint ? { hint: check.hint } : {} };
|
|
2686
|
+
if (tasksWithout.length > 0) {
|
|
2687
|
+
return { ...base, status: "fail", message: `${tasksWithout.length} tarea(s) sin Reversi\xF3n: ${tasksWithout.map((task) => task.id).join(", ")}`, ...check.hint ? { hint: check.hint } : {} };
|
|
2688
|
+
}
|
|
2689
|
+
return { ...base, status: "ok" };
|
|
2690
|
+
}
|
|
2691
|
+
case "evidence-strong": {
|
|
2692
|
+
const evidence = verify?.evidence ?? [];
|
|
2693
|
+
if (evidence.length === 0) return { ...base, status: "fail", message: "Todav\xEDa no hay evidencia registrada", ...check.hint ? { hint: check.hint } : {} };
|
|
2694
|
+
const manual = evidence.filter((entry) => entry.method === "manual" || entry.method === "semi");
|
|
2695
|
+
if (manual.length > 0) {
|
|
2696
|
+
return { ...base, status: "fail", message: `${manual.length} evidencia(s) manual/semi: ${manual.map((entry) => entry.scenario).join(", ")}`, ...check.hint ? { hint: check.hint } : {} };
|
|
2697
|
+
}
|
|
2698
|
+
return { ...base, status: "ok" };
|
|
2699
|
+
}
|
|
2700
|
+
case "spec-terms": {
|
|
2701
|
+
const terms = check.params?.terms ?? [];
|
|
2702
|
+
const ok = hasAnyTerm(`${texts.spec}
|
|
2703
|
+
${texts.scenarios}`, terms);
|
|
2704
|
+
return ok ? { ...base, status: "ok" } : { ...base, status: "fail", message: "El cambio no menciona ninguno de los t\xE9rminos esperados", ...check.hint ? { hint: check.hint } : {} };
|
|
2705
|
+
}
|
|
2706
|
+
case "rule-terms": {
|
|
2707
|
+
const terms = check.params?.terms ?? [];
|
|
2708
|
+
const ok = hasAnyTerm(`${texts.rules}
|
|
2709
|
+
${texts.scenarios}`, terms);
|
|
2710
|
+
return ok ? { ...base, status: "ok" } : { ...base, status: "fail", message: "Ninguna regla o escenario nombra el control esperado", ...check.hint ? { hint: check.hint } : {} };
|
|
2711
|
+
}
|
|
2712
|
+
case "nfr-measurable": {
|
|
2713
|
+
const measurable = /(\d+(?:[.,]\d+)?)\s*(%|px|ms|s|seg|min|h|d|kb|mb|gb|:1|veces)/i.test(`${texts.rules}
|
|
2714
|
+
${texts.scenarios}`);
|
|
2715
|
+
return measurable ? { ...base, status: "ok" } : { ...base, status: "fail", message: "No se encontr\xF3 ning\xFAn valor medible en reglas o escenarios", ...check.hint ? { hint: check.hint } : {} };
|
|
2716
|
+
}
|
|
2717
|
+
case "approval-provider": {
|
|
2718
|
+
return config.gates.approval !== "none" ? { ...base, status: "ok" } : { ...base, status: "fail", message: 'El provider de aprobaci\xF3n est\xE1 en "none"', ...check.hint ? { hint: check.hint } : {} };
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
async function loadProjectPacks(sddDir) {
|
|
2723
|
+
const dir = path9.join(sddDir, "packs");
|
|
2724
|
+
const diagnostics = [];
|
|
2725
|
+
const packs = [];
|
|
2726
|
+
for (const entry of await listDir(dir)) {
|
|
2727
|
+
if (!/\.ya?ml$/i.test(entry)) continue;
|
|
2728
|
+
const file = path9.join(dir, entry);
|
|
2729
|
+
const raw = await readTextIfExists(file);
|
|
2730
|
+
if (raw === void 0) continue;
|
|
2731
|
+
let data;
|
|
2732
|
+
try {
|
|
2733
|
+
data = parseYaml7(raw);
|
|
2734
|
+
} catch (err) {
|
|
2735
|
+
diagnostics.push(diag("PACK-000", "error", `Pack inv\xE1lido (${entry}): ${err.message}`, { path: file }));
|
|
2736
|
+
continue;
|
|
2737
|
+
}
|
|
2738
|
+
const parsed = packSchema.safeParse(data);
|
|
2739
|
+
if (!parsed.success) {
|
|
2740
|
+
for (const issue of parsed.error.issues) {
|
|
2741
|
+
diagnostics.push(diag("PACK-000", "error", `Pack ${entry} (${issue.path.join(".") || "ra\xEDz"}): ${issue.message}`, { path: file }));
|
|
2742
|
+
}
|
|
2743
|
+
continue;
|
|
2744
|
+
}
|
|
2745
|
+
const value = parsed.data;
|
|
2746
|
+
packs.push({
|
|
2747
|
+
id: value.id,
|
|
2748
|
+
title: value.title,
|
|
2749
|
+
description: value.description,
|
|
2750
|
+
source: "project",
|
|
2751
|
+
checks: value.checks.map((check) => {
|
|
2752
|
+
const built = { id: check.id, title: check.title, type: check.type, severity: check.severity };
|
|
2753
|
+
if (check.applies_to) {
|
|
2754
|
+
const appliesTo = {};
|
|
2755
|
+
if (check.applies_to.domains) appliesTo.domains = check.applies_to.domains;
|
|
2756
|
+
if (check.applies_to.lanes) appliesTo.lanes = check.applies_to.lanes;
|
|
2757
|
+
built.appliesTo = appliesTo;
|
|
2758
|
+
}
|
|
2759
|
+
if (check.params) built.params = check.params;
|
|
2760
|
+
if (check.hint !== void 0) built.hint = check.hint;
|
|
2761
|
+
return built;
|
|
2762
|
+
})
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
return { packs, diagnostics };
|
|
2766
|
+
}
|
|
2767
|
+
async function resolvePacks(sddDir, config) {
|
|
2768
|
+
const { packs: projectPacks, diagnostics } = await loadProjectPacks(sddDir);
|
|
2769
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2770
|
+
for (const pack of BUILTIN_PACKS) byId.set(pack.id, pack);
|
|
2771
|
+
for (const pack of projectPacks) byId.set(pack.id, pack);
|
|
2772
|
+
const active = config.packs.map((id) => byId.get(id)).filter((pack) => pack !== void 0);
|
|
2773
|
+
for (const id of config.packs) {
|
|
2774
|
+
if (!byId.has(id)) {
|
|
2775
|
+
diagnostics.push(diag("PACK-001", "warning", `El pack "${id}" no existe (ni integrado ni en .sdd/packs/)`, { suggestion: "Ejecuta `satlas packs` para ver los disponibles" }));
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
return { packs: active, diagnostics };
|
|
2779
|
+
}
|
|
2780
|
+
function evaluatePacks(packs, change, config) {
|
|
2781
|
+
return packs.map((pack) => {
|
|
2782
|
+
const results = pack.checks.map((check) => {
|
|
2783
|
+
const result = evaluateCheck(check, change, change.tasks, change.verify ?? change.fix, config);
|
|
2784
|
+
return { ...result, packId: pack.id };
|
|
2785
|
+
});
|
|
2786
|
+
const failed = results.filter((result) => result.status === "fail").length;
|
|
2787
|
+
const passed = results.filter((result) => result.status === "ok").length;
|
|
2788
|
+
const evaluated = results.filter((result) => result.status !== "n/a").length;
|
|
2789
|
+
return {
|
|
2790
|
+
pack,
|
|
2791
|
+
status: evaluated === 0 ? "n/a" : failed > 0 ? "fail" : "ok",
|
|
2792
|
+
results,
|
|
2793
|
+
passed,
|
|
2794
|
+
failed
|
|
2795
|
+
};
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
function packFindings(evaluations, change) {
|
|
2799
|
+
const findings = [];
|
|
2800
|
+
for (const evaluation of evaluations) {
|
|
2801
|
+
for (const result of evaluation.results) {
|
|
2802
|
+
if (result.status !== "fail") continue;
|
|
2803
|
+
findings.push(
|
|
2804
|
+
diag(`PACK-${evaluation.pack.id.toUpperCase()}-${result.checkId}`, result.severity, `[${evaluation.pack.title}] ${result.title}${result.message ? `: ${result.message}` : ""}`, {
|
|
2805
|
+
...result.hint ? { suggestion: result.hint } : {}
|
|
2806
|
+
})
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
void change;
|
|
2811
|
+
return findings;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// src/analyze.ts
|
|
2815
|
+
var UI_DOMAINS = /* @__PURE__ */ new Set(["frontend", "mobile", "fullstack"]);
|
|
2816
|
+
async function runAnalyze(opts) {
|
|
2817
|
+
const root = path10.resolve(opts.root);
|
|
2818
|
+
const { workspace, config } = await loadWorkspace(root);
|
|
2819
|
+
const change = workspace.changes.find((c) => c.slug === opts.slug);
|
|
2820
|
+
if (!change) {
|
|
2821
|
+
return {
|
|
2822
|
+
slug: opts.slug,
|
|
2823
|
+
status: "blocked",
|
|
2824
|
+
findings: [diag("ATLAS-ANALYZE-000", "error", `No existe el cambio "${opts.slug}"`)],
|
|
2825
|
+
summary: { errors: 1, warnings: 0, infos: 0 },
|
|
2826
|
+
evidence: { done: 0, total: 0 }
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
const findings = [...change.diagnostics];
|
|
2830
|
+
const living = /* @__PURE__ */ new Map();
|
|
2831
|
+
for (const spec of workspace.specs) {
|
|
2832
|
+
for (const req of spec.spec.requirements) living.set(req.id, req);
|
|
2833
|
+
}
|
|
2834
|
+
if (change.delta) {
|
|
2835
|
+
findings.push(...lintDelta(change.delta, living, path10.join(change.dir, "spec.md"), { language: config.spec.language }));
|
|
2836
|
+
}
|
|
2837
|
+
const trace = checkTrace({ specs: workspace.specs, change, requireEvidence: false });
|
|
2838
|
+
findings.push(...trace.findings);
|
|
2839
|
+
let waves;
|
|
2840
|
+
if (change.tasks && change.tasks.counts.total > 0) {
|
|
2841
|
+
const plan = planWaves(change.tasks, { maxParallel: config.waves.max_parallel });
|
|
2842
|
+
findings.push(...plan.blocks.flatMap((b) => b.diagnostics));
|
|
2843
|
+
waves = plan.summary;
|
|
2844
|
+
}
|
|
2845
|
+
const lane = change.meta?.lane ?? config.lanes.default;
|
|
2846
|
+
if (lane !== "fix" && !change.planPath) {
|
|
2847
|
+
findings.push(diag("ATLAS-ANALYZE-001", "warning", "El cambio no tiene plan.md (plan t\xE9cnico)", { suggestion: "Ejecuta la fase /satlas-plan" }));
|
|
2848
|
+
}
|
|
2849
|
+
const deltaScenarios = [...change.delta?.added ?? [], ...change.delta?.modified ?? []].flatMap((r) => r.scenarios);
|
|
2850
|
+
const passed = new Set((change.verify?.evidence ?? []).filter((e) => e.result === "pass").map((e) => e.scenario));
|
|
2851
|
+
const evidence = { done: deltaScenarios.filter((s) => passed.has(s.id)).length, total: deltaScenarios.length };
|
|
2852
|
+
let mockups;
|
|
2853
|
+
const domain = change.meta?.domain ?? "";
|
|
2854
|
+
if (UI_DOMAINS.has(domain)) {
|
|
2855
|
+
const check = await checkMockups(root, opts.slug, change);
|
|
2856
|
+
findings.push(...check.findings);
|
|
2857
|
+
mockups = { screens: check.manifest?.screens.length ?? 0, stale: check.stale };
|
|
2858
|
+
}
|
|
2859
|
+
let packs;
|
|
2860
|
+
if (config.packs.length > 0) {
|
|
2861
|
+
const resolved = await resolvePacks(workspace.sddDir, config);
|
|
2862
|
+
findings.push(...resolved.diagnostics);
|
|
2863
|
+
const evaluations = evaluatePacks(resolved.packs, change, config);
|
|
2864
|
+
findings.push(...packFindings(evaluations, change));
|
|
2865
|
+
packs = evaluations.map((evaluation) => ({
|
|
2866
|
+
id: evaluation.pack.id,
|
|
2867
|
+
title: evaluation.pack.title,
|
|
2868
|
+
status: evaluation.status,
|
|
2869
|
+
passed: evaluation.passed,
|
|
2870
|
+
failed: evaluation.failed
|
|
2871
|
+
}));
|
|
2872
|
+
}
|
|
2873
|
+
const summary = countBySeverity(findings);
|
|
2874
|
+
const status = summary.errors > 0 ? "blocked" : summary.warnings > 5 ? "passed_with_debt" : "passed";
|
|
2875
|
+
const result = { slug: opts.slug, status, findings, summary, evidence };
|
|
2876
|
+
if (waves) result.waves = waves;
|
|
2877
|
+
if (mockups) result.mockups = mockups;
|
|
2878
|
+
if (packs) result.packs = packs;
|
|
2879
|
+
if (opts.write !== false) {
|
|
2880
|
+
const file = path10.join(change.dir, "analyze.md");
|
|
2881
|
+
await writeText(file, renderAnalyze(change, result, (opts.now ?? /* @__PURE__ */ new Date()).toISOString()));
|
|
2882
|
+
result.path = file;
|
|
2883
|
+
}
|
|
2884
|
+
return result;
|
|
2885
|
+
}
|
|
2886
|
+
function renderAnalyze(change, result, generatedAt) {
|
|
2887
|
+
const lines = [];
|
|
2888
|
+
lines.push(`# An\xE1lisis \u2014 ${change.meta?.title ?? change.slug}`);
|
|
2889
|
+
lines.push("");
|
|
2890
|
+
lines.push(`- Generado: ${generatedAt}`);
|
|
2891
|
+
lines.push(`- Estado: **${result.status}**`);
|
|
2892
|
+
lines.push(`- Evidencia: ${result.evidence.done}/${result.evidence.total} escenarios`);
|
|
2893
|
+
if (result.waves) lines.push(`- Olas: ${result.waves.blocks} bloque(s), ${result.waves.waves} ola(s), ${result.waves.tasks} tarea(s)`);
|
|
2894
|
+
if (result.mockups) lines.push(`- Mockups: ${result.mockups.screens} pantalla(s)${result.mockups.stale ? " (desactualizados)" : ""}`);
|
|
2895
|
+
lines.push("");
|
|
2896
|
+
if (result.packs && result.packs.length > 0) {
|
|
2897
|
+
lines.push("## Packs de cumplimiento");
|
|
2898
|
+
lines.push("");
|
|
2899
|
+
lines.push("| Pack | Estado | Controles |");
|
|
2900
|
+
lines.push("|---|---|---|");
|
|
2901
|
+
for (const pack of result.packs) {
|
|
2902
|
+
lines.push(`| ${pack.title} (\`${pack.id}\`) | ${pack.status} | ${pack.passed} ok \xB7 ${pack.failed} con falla |`);
|
|
2903
|
+
}
|
|
2904
|
+
lines.push("");
|
|
2905
|
+
}
|
|
2906
|
+
lines.push(`## Hallazgos (${result.summary.errors} errores, ${result.summary.warnings} avisos)`);
|
|
2907
|
+
lines.push("");
|
|
2908
|
+
if (result.findings.length === 0) {
|
|
2909
|
+
lines.push("Sin hallazgos.");
|
|
2910
|
+
} else {
|
|
2911
|
+
lines.push("| C\xF3digo | Severidad | Mensaje | Ubicaci\xF3n |");
|
|
2912
|
+
lines.push("|---|---|---|---|");
|
|
2913
|
+
for (const finding of result.findings) {
|
|
2914
|
+
const location = finding.path ? `${finding.path}${finding.line ? `:${finding.line}` : ""}` : "\u2014";
|
|
2915
|
+
lines.push(`| ${finding.code} | ${finding.severity} | ${finding.message.replace(/\|/g, "\\|")} | ${location} |`);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
lines.push("");
|
|
2919
|
+
return lines.join("\n");
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// src/metrics.ts
|
|
2923
|
+
import path11 from "path";
|
|
2924
|
+
async function collectMetrics(root, now = /* @__PURE__ */ new Date()) {
|
|
2925
|
+
const resolved = path11.resolve(root);
|
|
2926
|
+
const { workspace, config } = await loadWorkspace(resolved);
|
|
2927
|
+
const approvals = await loadApprovals(workspace.sddDir);
|
|
2928
|
+
const living = /* @__PURE__ */ new Map();
|
|
2929
|
+
for (const spec of workspace.specs) {
|
|
2930
|
+
for (const requirement of spec.spec.requirements) living.set(requirement.id, requirement);
|
|
2931
|
+
}
|
|
2932
|
+
const changes = [];
|
|
2933
|
+
const evidenceByMethod = {};
|
|
2934
|
+
const wipByState = {};
|
|
2935
|
+
const blocked = [];
|
|
2936
|
+
for (const change of workspace.changes) {
|
|
2937
|
+
const deltaContent = await readDelta(change);
|
|
2938
|
+
const approval = verifyApproval(change, approvals.byArtifact, config, deltaContent);
|
|
2939
|
+
const findings = [];
|
|
2940
|
+
if (change.delta) findings.push(...lintDelta(change.delta, living, path11.join(change.dir, "spec.md"), { language: config.spec.language }));
|
|
2941
|
+
const trace = checkTrace({
|
|
2942
|
+
specs: workspace.specs,
|
|
2943
|
+
change,
|
|
2944
|
+
requireEvidence: config.gates.verify.mode !== "off" && config.gates.verify.require_evidence
|
|
2945
|
+
});
|
|
2946
|
+
findings.push(...trace.findings);
|
|
2947
|
+
const state = deriveState({ change, cfg: config, approval, blockingFindings: change.delta ? countBySeverity(findings).errors : 0 });
|
|
2948
|
+
const scenarios = [...change.delta?.added ?? [], ...change.delta?.modified ?? []].flatMap((req) => req.scenarios);
|
|
2949
|
+
const passed = scenarios.filter((scenario) => (change.verify?.evidence ?? []).some((e) => e.scenario === scenario.id && e.result === "pass"));
|
|
2950
|
+
for (const entry of [...change.verify?.evidence ?? [], ...change.fix?.evidence ?? []]) {
|
|
2951
|
+
evidenceByMethod[entry.method] = (evidenceByMethod[entry.method] ?? 0) + 1;
|
|
2952
|
+
}
|
|
2953
|
+
const summary = countBySeverity(findings);
|
|
2954
|
+
const ageDays = change.meta?.created ? Math.max(0, Math.round((now.getTime() - Date.parse(change.meta.created)) / 864e5)) : void 0;
|
|
2955
|
+
const metric = {
|
|
2956
|
+
slug: change.slug,
|
|
2957
|
+
...change.meta?.title !== void 0 ? { title: change.meta.title } : {},
|
|
2958
|
+
...change.meta?.domain !== void 0 ? { domain: change.meta.domain } : {},
|
|
2959
|
+
lane: change.meta?.lane ?? config.lanes.default,
|
|
2960
|
+
state: state.state,
|
|
2961
|
+
stateLabel: stateLabel(state.state),
|
|
2962
|
+
tasksDone: state.progress.tasksDone,
|
|
2963
|
+
tasksTotal: state.progress.tasksTotal,
|
|
2964
|
+
scenariosTotal: scenarios.length,
|
|
2965
|
+
scenariosPassed: passed.length,
|
|
2966
|
+
findingsErrors: summary.errors,
|
|
2967
|
+
findingsWarnings: summary.warnings,
|
|
2968
|
+
blockedBy: state.blockedBy,
|
|
2969
|
+
next: state.nextAction.command,
|
|
2970
|
+
...ageDays !== void 0 ? { ageDays } : {}
|
|
2971
|
+
};
|
|
2972
|
+
changes.push(metric);
|
|
2973
|
+
const bucket = wipByState[state.state] ?? { label: stateLabel(state.state), count: 0 };
|
|
2974
|
+
bucket.count += 1;
|
|
2975
|
+
wipByState[state.state] = bucket;
|
|
2976
|
+
if (state.blockedBy.length > 0) blocked.push({ slug: change.slug, reason: state.blockedBy.join("; ") });
|
|
2977
|
+
}
|
|
2978
|
+
changes.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
2979
|
+
const ages = changes.map((change) => change.ageDays).filter((age) => age !== void 0);
|
|
2980
|
+
const averageDays = ages.length > 0 ? Math.round(ages.reduce((acc, age) => acc + age, 0) / ages.length * 10) / 10 : void 0;
|
|
2981
|
+
const oldestChange = changes.filter((change) => change.ageDays !== void 0).sort((a, b) => (b.ageDays ?? 0) - (a.ageDays ?? 0))[0];
|
|
2982
|
+
const agingBuckets = [
|
|
2983
|
+
{ label: "0-3 d\xEDas", value: ages.filter((age) => age <= 3).length },
|
|
2984
|
+
{ label: "4-7 d\xEDas", value: ages.filter((age) => age >= 4 && age <= 7).length },
|
|
2985
|
+
{ label: "8-14 d\xEDas", value: ages.filter((age) => age >= 8 && age <= 14).length },
|
|
2986
|
+
{ label: "15+ d\xEDas", value: ages.filter((age) => age >= 15).length }
|
|
2987
|
+
];
|
|
2988
|
+
const laneCounts = /* @__PURE__ */ new Map();
|
|
2989
|
+
for (const change of changes) laneCounts.set(change.lane, (laneCounts.get(change.lane) ?? 0) + 1);
|
|
2990
|
+
const byLane = [...laneCounts.entries()].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value);
|
|
2991
|
+
const attention = [];
|
|
2992
|
+
for (const change of changes) {
|
|
2993
|
+
if (change.blockedBy.length > 0) {
|
|
2994
|
+
attention.push({ slug: change.slug, kind: "blocked", detail: change.blockedBy.join("; ") });
|
|
2995
|
+
continue;
|
|
2996
|
+
}
|
|
2997
|
+
if (change.ageDays !== void 0 && change.ageDays >= 15) {
|
|
2998
|
+
attention.push({ slug: change.slug, kind: "aging", detail: `lleva ${change.ageDays} d\xEDas sin cerrarse` });
|
|
2999
|
+
continue;
|
|
3000
|
+
}
|
|
3001
|
+
if (change.state === "built" && change.scenariosTotal > 0 && change.scenariosPassed < change.scenariosTotal) {
|
|
3002
|
+
attention.push({ slug: change.slug, kind: "missing-evidence", detail: `evidencia ${change.scenariosPassed}/${change.scenariosTotal}` });
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
const archiveDir = path11.join(workspace.sddDir, "changes", "archive");
|
|
3006
|
+
const archivedEntries = await listDirs(archiveDir);
|
|
3007
|
+
const throughputByMonth = {};
|
|
3008
|
+
for (const entry of archivedEntries) {
|
|
3009
|
+
const month = /^(\d{4}-\d{2})/.exec(entry)?.[1];
|
|
3010
|
+
if (month) throughputByMonth[month] = (throughputByMonth[month] ?? 0) + 1;
|
|
3011
|
+
}
|
|
3012
|
+
const totals = {
|
|
3013
|
+
changes: changes.length,
|
|
3014
|
+
tasks: changes.reduce((acc, c) => acc + c.tasksTotal, 0),
|
|
3015
|
+
tasksDone: changes.reduce((acc, c) => acc + c.tasksDone, 0),
|
|
3016
|
+
scenarios: changes.reduce((acc, c) => acc + c.scenariosTotal, 0),
|
|
3017
|
+
scenariosPassed: changes.reduce((acc, c) => acc + c.scenariosPassed, 0),
|
|
3018
|
+
errors: changes.reduce((acc, c) => acc + c.findingsErrors, 0),
|
|
3019
|
+
warnings: changes.reduce((acc, c) => acc + c.findingsWarnings, 0),
|
|
3020
|
+
archived: archivedEntries.length
|
|
3021
|
+
};
|
|
3022
|
+
return {
|
|
3023
|
+
generatedAt: now.toISOString(),
|
|
3024
|
+
project: config.project.name,
|
|
3025
|
+
root: resolved,
|
|
3026
|
+
changes,
|
|
3027
|
+
totals,
|
|
3028
|
+
wipByState,
|
|
3029
|
+
throughputByMonth,
|
|
3030
|
+
evidenceByMethod,
|
|
3031
|
+
blocked,
|
|
3032
|
+
aging: {
|
|
3033
|
+
...averageDays !== void 0 ? { averageDays } : {},
|
|
3034
|
+
...oldestChange && oldestChange.ageDays !== void 0 ? { oldest: { slug: oldestChange.slug, ageDays: oldestChange.ageDays } } : {},
|
|
3035
|
+
buckets: agingBuckets
|
|
3036
|
+
},
|
|
3037
|
+
byLane,
|
|
3038
|
+
attention
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
async function readDelta(change) {
|
|
3042
|
+
const { readTextIfExists: readTextIfExists2 } = await import("./fsx-VF2P7ALA.js");
|
|
3043
|
+
return readTextIfExists2(path11.join(change.dir, "spec.md"));
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// src/present.ts
|
|
3047
|
+
import path12 from "path";
|
|
3048
|
+
import { escapeHtml, inlineMarkdown, renderMarkdown as renderRich } from "@specatlas/render";
|
|
3049
|
+
import { defaultTokens, renderDocument, renderStyles } from "@specatlas/render";
|
|
3050
|
+
async function generatePresentation(opts) {
|
|
3051
|
+
const root = path12.resolve(opts.root);
|
|
3052
|
+
const { workspace, config } = await loadWorkspace(root);
|
|
3053
|
+
const language = config.project.language;
|
|
3054
|
+
const change = workspace.changes.find((c) => c.slug === opts.slug);
|
|
3055
|
+
if (!change) {
|
|
3056
|
+
return { slug: opts.slug, diagnostics: [diag("ATLAS-PRESENT-000", "error", `No existe el cambio "${opts.slug}"`)] };
|
|
3057
|
+
}
|
|
3058
|
+
if (!change.delta) {
|
|
3059
|
+
return { slug: opts.slug, diagnostics: [diag("ATLAS-PRESENT-001", "error", `El cambio "${opts.slug}" no tiene spec.md (delta)`)] };
|
|
3060
|
+
}
|
|
3061
|
+
const deltaPath = path12.join(change.dir, "spec.md");
|
|
3062
|
+
const deltaContent = await readTextIfExists(deltaPath) ?? "";
|
|
3063
|
+
const hash = artifactHash(deltaContent);
|
|
3064
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
3065
|
+
const presentationDir = path12.join(change.dir, "presentation");
|
|
3066
|
+
const mockupsCopyDir = path12.join(presentationDir, "mockups");
|
|
3067
|
+
await ensureDir(presentationDir);
|
|
3068
|
+
const mockupInfo = await copyMockups(root, change, mockupsCopyDir);
|
|
3069
|
+
const proposalRaw = await readTextIfExists(path12.join(change.dir, "proposal.md"));
|
|
3070
|
+
const proposalBody = proposalRaw ? parseFrontmatter(proposalRaw).body : "";
|
|
3071
|
+
const labels = labelsFor(language);
|
|
3072
|
+
const html = page({
|
|
3073
|
+
language,
|
|
3074
|
+
labels,
|
|
3075
|
+
title: change.meta?.title ?? change.slug,
|
|
3076
|
+
domain: change.meta?.domain ?? "\xE2\u20AC\u201D",
|
|
3077
|
+
lane: change.meta?.lane ?? config.lanes.default,
|
|
3078
|
+
project: config.project.name,
|
|
3079
|
+
version: change.delta.sectionsFound.length,
|
|
3080
|
+
hash,
|
|
3081
|
+
generatedAt: now.toISOString(),
|
|
3082
|
+
proposalHtml: proposalBody ? renderMarkdown(proposalBody) : `<p><em>${labels.noProposal}</em></p>`,
|
|
3083
|
+
requirements: change.delta ? [...change.delta.added.map((r) => ({ req: r, kind: "added" })), ...change.delta.modified.map((r) => ({ req: r, kind: "modified" }))] : [],
|
|
3084
|
+
evidence: new Map((change.verify?.evidence ?? []).map((e) => [e.scenario, e.result])),
|
|
3085
|
+
mockups: mockupInfo.screens,
|
|
3086
|
+
screenshots: mockupInfo.screenshots,
|
|
3087
|
+
approveHint: `satlas approve ${change.slug} --by "<nombre>" --channel presentation`
|
|
3088
|
+
});
|
|
3089
|
+
const outFile = path12.join(presentationDir, "index.html");
|
|
3090
|
+
await writeText(outFile, html);
|
|
3091
|
+
return { slug: change.slug, path: outFile, hash, diagnostics: mockupInfo.diagnostics };
|
|
3092
|
+
}
|
|
3093
|
+
async function copyMockups(root, change, destDir) {
|
|
3094
|
+
const diagnostics = [];
|
|
3095
|
+
const dir = mockupsDir(root, change.slug);
|
|
3096
|
+
const { manifest } = await readMockupManifest(root, change.slug);
|
|
3097
|
+
if (!manifest || !await exists(dir)) return { screens: [], screenshots: [], diagnostics };
|
|
3098
|
+
await ensureDir(destDir);
|
|
3099
|
+
const screens = [];
|
|
3100
|
+
for (const screen of manifest.screens) {
|
|
3101
|
+
const src = path12.join(dir, screen.file);
|
|
3102
|
+
if (!await exists(src)) {
|
|
3103
|
+
diagnostics.push(diag("ATLAS-PRESENT-002", "warning", `El mockup ${screen.file} no existe y no se incluir\xC3\xA1`, { path: src }));
|
|
3104
|
+
continue;
|
|
3105
|
+
}
|
|
3106
|
+
await copyFile(src, path12.join(destDir, screen.file));
|
|
3107
|
+
screens.push({
|
|
3108
|
+
id: screen.id,
|
|
3109
|
+
title: screen.title ?? screen.id,
|
|
3110
|
+
file: `mockups/${screen.file}`,
|
|
3111
|
+
platform: manifest.platform,
|
|
3112
|
+
illustrates: screen.illustrates ?? []
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
const screenshots = [];
|
|
3116
|
+
const shotsDir = path12.join(dir, "screens");
|
|
3117
|
+
if (await exists(shotsDir)) {
|
|
3118
|
+
await ensureDir(path12.join(destDir, "screens"));
|
|
3119
|
+
const { listDir: listDir4 } = await import("./fsx-VF2P7ALA.js");
|
|
3120
|
+
for (const file of await listDir4(shotsDir)) {
|
|
3121
|
+
if (!/\.(png|jpe?g|webp)$/i.test(file)) continue;
|
|
3122
|
+
await copyFile(path12.join(shotsDir, file), path12.join(destDir, "screens", file));
|
|
3123
|
+
screenshots.push(`mockups/screens/${file}`);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
return { screens, screenshots, diagnostics };
|
|
3127
|
+
}
|
|
3128
|
+
function labelsFor(language) {
|
|
3129
|
+
if (language === "en") {
|
|
3130
|
+
return {
|
|
3131
|
+
kicker: "Proposal",
|
|
3132
|
+
summary: "Business summary",
|
|
3133
|
+
criteria: "Acceptance criteria",
|
|
3134
|
+
requirements: "Requirements and scenarios",
|
|
3135
|
+
mockups: "Mockups",
|
|
3136
|
+
approve: "Approval",
|
|
3137
|
+
approveText: "This proposal is approved by signing the spec (hash + author). Any later change invalidates the signature.",
|
|
3138
|
+
command: "Command",
|
|
3139
|
+
hash: "Hash",
|
|
3140
|
+
generated: "Generated",
|
|
3141
|
+
noProposal: "The proposal is empty.",
|
|
3142
|
+
added: "added",
|
|
3143
|
+
modified: "modified",
|
|
3144
|
+
scenario: "Scenario",
|
|
3145
|
+
evidence: "Evidence",
|
|
3146
|
+
pending: "pending",
|
|
3147
|
+
noMockups: "No mockups declared for this change.",
|
|
3148
|
+
screenshots: "Screenshots"
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
return {
|
|
3152
|
+
kicker: "Propuesta",
|
|
3153
|
+
summary: "Resumen de negocio",
|
|
3154
|
+
criteria: "Criterios de aceptaci\xC3\xB3n",
|
|
3155
|
+
requirements: "Requisitos y escenarios",
|
|
3156
|
+
mockups: "Mockups",
|
|
3157
|
+
approve: "Aprobaci\xC3\xB3n",
|
|
3158
|
+
approveText: "Esta propuesta se aprueba firmando la spec (hash + autor). Cualquier cambio posterior invalida la firma.",
|
|
3159
|
+
command: "Comando",
|
|
3160
|
+
hash: "Hash",
|
|
3161
|
+
generated: "Generado",
|
|
3162
|
+
noProposal: "La propuesta est\xC3\xA1 vac\xC3\xADa.",
|
|
3163
|
+
added: "agregado",
|
|
3164
|
+
modified: "modificado",
|
|
3165
|
+
scenario: "Escenario",
|
|
3166
|
+
evidence: "Evidencia",
|
|
3167
|
+
pending: "pendiente",
|
|
3168
|
+
noMockups: "Este cambio no declara mockups.",
|
|
3169
|
+
screenshots: "Capturas"
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
var CSS = `
|
|
3173
|
+
:root { --bg:#f8fafc; --card:#ffffff; --ink:#0f172a; --muted:#64748b; --line:#e2e8f0; --accent:#0f766e; --accent-soft:#ccfbf1; --warn:#b45309; --ok:#15803d; }
|
|
3174
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0b1220; --card:#111a2c; --ink:#e2e8f0; --muted:#94a3b8; --line:#1e293b; --accent:#2dd4bf; --accent-soft:#134e4a; --warn:#f59e0b; --ok:#4ade80; } }
|
|
3175
|
+
* { box-sizing: border-box; }
|
|
3176
|
+
body { margin:0; background:var(--bg); color:var(--ink); font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
3177
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 32px 20px 80px; }
|
|
3178
|
+
.hero { background: linear-gradient(135deg, var(--accent), #0ea5e9); color: white; border-radius: 18px; padding: 28px; margin-bottom: 28px; }
|
|
3179
|
+
.hero h1 { margin: 6px 0 10px; font-size: 30px; line-height: 1.2; }
|
|
3180
|
+
.kicker { text-transform: uppercase; letter-spacing: .12em; font-size: 12px; opacity: .9; margin: 0; }
|
|
3181
|
+
.meta { margin: 0; font-size: 13px; opacity: .95; }
|
|
3182
|
+
section { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 22px; margin-bottom: 22px; }
|
|
3183
|
+
section h2 { margin-top: 0; font-size: 20px; }
|
|
3184
|
+
h3 { margin-bottom: 4px; }
|
|
3185
|
+
h4 { margin: 14px 0 6px; color: var(--muted); font-weight: 600; }
|
|
3186
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
3187
|
+
th, td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
|
|
3188
|
+
th { background: color-mix(in srgb, var(--accent-soft) 50%, transparent); }
|
|
3189
|
+
code { background: color-mix(in srgb, var(--line) 60%, transparent); padding: 1px 5px; border-radius: 6px; font-size: 13px; }
|
|
3190
|
+
pre { background: #0b1220; color: #e2e8f0; padding: 14px; border-radius: 10px; overflow:auto; font-size: 13px; }
|
|
3191
|
+
pre code { background: transparent; color: inherit; }
|
|
3192
|
+
.badge { display:inline-block; font-size: 11px; text-transform: uppercase; letter-spacing:.08em; background: var(--accent-soft); color: var(--accent); border-radius: 999px; padding: 2px 10px; margin-left: 8px; }
|
|
3193
|
+
.req { border-top: 1px solid var(--line); padding-top: 16px; margin-top: 16px; }
|
|
3194
|
+
.req:first-of-type { border-top: 0; margin-top: 0; padding-top: 0; }
|
|
3195
|
+
.rules { color: var(--muted); font-size: 14px; }
|
|
3196
|
+
.scenario { margin: 8px 0 8px 0; }
|
|
3197
|
+
.when { color: var(--muted); }
|
|
3198
|
+
.then { font-weight: 600; }
|
|
3199
|
+
.pill { font-size: 12px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); }
|
|
3200
|
+
.pill.pass { color: var(--ok); border-color: var(--ok); }
|
|
3201
|
+
.pill.fail { color: #dc2626; border-color: #dc2626; }
|
|
3202
|
+
.pill.skipped, .pill.pending { color: var(--warn); border-color: var(--warn); }
|
|
3203
|
+
.mockups { display:grid; gap: 18px; }
|
|
3204
|
+
.mockup { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
|
3205
|
+
.mockup header { padding: 10px 14px; border-bottom: 1px solid var(--line); font-weight: 600; font-size: 14px; }
|
|
3206
|
+
.mockup iframe { width: 100%; height: 560px; border: 0; background: white; }
|
|
3207
|
+
.mockup.phone iframe { max-width: 420px; margin: 0 auto; display:block; }
|
|
3208
|
+
.grid-shots { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
|
3209
|
+
.grid-shots img { width: 100%; border: 1px solid var(--line); border-radius: 10px; }
|
|
3210
|
+
footer { color: var(--muted); font-size: 13px; text-align:center; }
|
|
3211
|
+
.callout { border-left: 4px solid var(--accent); background: color-mix(in srgb, var(--accent-soft) 35%, transparent); padding: 12px 14px; border-radius: 8px; }
|
|
3212
|
+
@media print { body { background: white; } section { break-inside: avoid; } .hero { background: #0f766e; } }
|
|
3213
|
+
`.trim();
|
|
3214
|
+
function page(input) {
|
|
3215
|
+
const l = input.labels;
|
|
3216
|
+
const reqHtml = input.requirements.map(({ req, kind }) => {
|
|
3217
|
+
const rules = req.rules.map((r) => `<li><code>${esc(r.id)}</code> \xE2\u20AC\u201D ${inline(r.text)}</li>`).join("");
|
|
3218
|
+
const scenarios = req.scenarios.map((s) => {
|
|
3219
|
+
const when = s.when.map((w) => `<div class="when">CUANDO ${inline(w)}</div>`).join("");
|
|
3220
|
+
const then = s.then.map((t) => `<div class="then">ENTONCES ${inline(t)}</div>`).join("");
|
|
3221
|
+
return `<div class="scenario"><h4>${esc(s.id)} \xE2\u20AC\u201D ${esc(s.title)}</h4>${when}${then}</div>`;
|
|
3222
|
+
}).join("");
|
|
3223
|
+
return `<div class="req"><h3>${esc(req.id)} \xE2\u20AC\u201D ${esc(req.title)}<span class="badge">${kind === "added" ? l.added : l.modified}</span></h3><p>${inline(req.prose)}</p>${rules ? `<ul class="rules">${rules}</ul>` : ""}${scenarios}</div>`;
|
|
3224
|
+
}).join("");
|
|
3225
|
+
const criteriaRows = input.requirements.flatMap(({ req }) => req.scenarios).map((s) => {
|
|
3226
|
+
const status = input.evidence.get(s.id);
|
|
3227
|
+
const cls = status ?? "pending";
|
|
3228
|
+
const label = status ?? l.pending;
|
|
3229
|
+
return `<tr><td><code>${esc(s.id)}</code></td><td>${esc(s.title)}</td><td><span class="pill ${cls}">${esc(label)}</span></td></tr>`;
|
|
3230
|
+
}).join("");
|
|
3231
|
+
const mockupHtml = input.mockups.length === 0 ? `<p><em>${l.noMockups}</em></p>` : `<div class="mockups">${input.mockups.map(
|
|
3232
|
+
(m) => `<div class="mockup ${m.platform === "mobile" ? "phone" : ""}"><header>${esc(m.title)} \xE2\u20AC\u201D ${esc(m.file)}</header><iframe src="${esc(m.file)}" loading="lazy" title="${esc(m.title)}"></iframe></div>`
|
|
3233
|
+
).join("")}</div>`;
|
|
3234
|
+
const shotsHtml = input.screenshots.length === 0 ? "" : `<h3>${l.screenshots}</h3><div class="grid-shots">${input.screenshots.map((s) => `<img src="${esc(s)}" alt="${esc(s)}" loading="lazy">`).join("")}</div>`;
|
|
3235
|
+
return `<!doctype html>
|
|
3236
|
+
<html lang="${input.language}">
|
|
3237
|
+
<head>
|
|
3238
|
+
<meta charset="utf-8">
|
|
3239
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3240
|
+
<title>${esc(l.kicker)} \xE2\u20AC\u201D ${esc(input.title)}</title>
|
|
3241
|
+
<style>${CSS}</style>
|
|
3242
|
+
</head>
|
|
3243
|
+
<body>
|
|
3244
|
+
<div class="wrap">
|
|
3245
|
+
<header class="hero">
|
|
3246
|
+
<p class="kicker">${esc(l.kicker)} \xC2\xB7 ${esc(input.domain)} \xC2\xB7 ${esc(input.lane)}</p>
|
|
3247
|
+
<h1>${esc(input.title)}</h1>
|
|
3248
|
+
<p class="meta">${esc(input.project)} \xC2\xB7 ${esc(l.hash)} ${esc(shortHash(input.hash))} \xC2\xB7 ${esc(l.generated)} ${esc(input.generatedAt)}</p>
|
|
3249
|
+
</header>
|
|
3250
|
+
|
|
3251
|
+
<section>
|
|
3252
|
+
<h2>${esc(l.summary)}</h2>
|
|
3253
|
+
${input.proposalHtml}
|
|
3254
|
+
</section>
|
|
3255
|
+
|
|
3256
|
+
<section>
|
|
3257
|
+
<h2>${esc(l.criteria)}</h2>
|
|
3258
|
+
<table><thead><tr><th>ID</th><th>${esc(l.scenario)}</th><th>${esc(l.evidence)}</th></tr></thead><tbody>${criteriaRows}</tbody></table>
|
|
3259
|
+
</section>
|
|
3260
|
+
|
|
3261
|
+
<section>
|
|
3262
|
+
<h2>${esc(l.requirements)}</h2>
|
|
3263
|
+
${reqHtml}
|
|
3264
|
+
</section>
|
|
3265
|
+
|
|
3266
|
+
<section>
|
|
3267
|
+
<h2>${esc(l.mockups)}</h2>
|
|
3268
|
+
${mockupHtml}
|
|
3269
|
+
${shotsHtml}
|
|
3270
|
+
</section>
|
|
3271
|
+
|
|
3272
|
+
<section>
|
|
3273
|
+
<h2>${esc(l.approve)}</h2>
|
|
3274
|
+
<div class="callout">${esc(l.approveText)}</div>
|
|
3275
|
+
<p>${esc(l.command)}: <code>${esc(input.approveHint)}</code></p>
|
|
3276
|
+
</section>
|
|
3277
|
+
|
|
3278
|
+
<footer>SpecAtlas \xC2\xB7 ${esc(input.project)} \xC2\xB7 ${esc(input.generatedAt)}</footer>
|
|
3279
|
+
</div>
|
|
3280
|
+
</body>
|
|
3281
|
+
</html>
|
|
3282
|
+
`;
|
|
3283
|
+
}
|
|
3284
|
+
function esc(text) {
|
|
3285
|
+
return escapeHtml(text);
|
|
3286
|
+
}
|
|
3287
|
+
function inline(text) {
|
|
3288
|
+
return inlineMarkdown(text);
|
|
3289
|
+
}
|
|
3290
|
+
function renderMarkdown(markdown) {
|
|
3291
|
+
return renderRich(markdown, { highlight: true });
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
// src/adopt.ts
|
|
3295
|
+
import path13 from "path";
|
|
3296
|
+
var COMMON_ROOTS = ["src", "app", "lib", "modules", "services", "packages", "api", "features", "domain", "internal", "components"];
|
|
3297
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "coverage", ".git", ".sdd", ".opencode", ".claude", "test", "tests", "__tests__", "e2e", "docs", "scripts", "assets", "public", "migrations", "vendor", "bin", "obj"]);
|
|
3298
|
+
var SOURCE_EXT = /* @__PURE__ */ new Set([
|
|
3299
|
+
".ts",
|
|
3300
|
+
".tsx",
|
|
3301
|
+
".js",
|
|
3302
|
+
".jsx",
|
|
3303
|
+
".mjs",
|
|
3304
|
+
".cjs",
|
|
3305
|
+
".py",
|
|
3306
|
+
".cs",
|
|
3307
|
+
".java",
|
|
3308
|
+
".kt",
|
|
3309
|
+
".kts",
|
|
3310
|
+
".go",
|
|
3311
|
+
".rb",
|
|
3312
|
+
".php",
|
|
3313
|
+
".rs",
|
|
3314
|
+
".swift",
|
|
3315
|
+
".dart",
|
|
3316
|
+
".vue",
|
|
3317
|
+
".svelte",
|
|
3318
|
+
".sql",
|
|
3319
|
+
".cshtml",
|
|
3320
|
+
".razor",
|
|
3321
|
+
".vb",
|
|
3322
|
+
".fs",
|
|
3323
|
+
".c",
|
|
3324
|
+
".cpp",
|
|
3325
|
+
".h",
|
|
3326
|
+
".hpp",
|
|
3327
|
+
".scala",
|
|
3328
|
+
".ex",
|
|
3329
|
+
".exs",
|
|
3330
|
+
".clj",
|
|
3331
|
+
".lua",
|
|
3332
|
+
".r",
|
|
3333
|
+
".m",
|
|
3334
|
+
".mm",
|
|
3335
|
+
".pl",
|
|
3336
|
+
".groovy"
|
|
3337
|
+
]);
|
|
3338
|
+
async function adoptWorkspace(opts) {
|
|
3339
|
+
const root = path13.resolve(opts.root);
|
|
3340
|
+
const dryRun = opts.dryRun ?? false;
|
|
3341
|
+
const diagnostics = [];
|
|
3342
|
+
const sddDir = path13.join(root, ".sdd");
|
|
3343
|
+
if (!await exists(path13.join(sddDir, "config.yaml"))) {
|
|
3344
|
+
return {
|
|
3345
|
+
root,
|
|
3346
|
+
domains: [],
|
|
3347
|
+
createdSpecs: [],
|
|
3348
|
+
diagnostics: [diag("ATLAS-ADOPT-001", "error", "No hay .sdd/config.yaml: inicializa el proyecto con `satlas init` antes de adoptar", { path: sddDir })],
|
|
3349
|
+
dryRun
|
|
3350
|
+
};
|
|
3351
|
+
}
|
|
3352
|
+
const { config } = await loadConfig(sddDir);
|
|
3353
|
+
const files = await walkFiles(root, { skipDirs: [...SKIP_DIRS] });
|
|
3354
|
+
const inventoried = files.map((f) => toPosix(path13.relative(root, f.path)));
|
|
3355
|
+
const relative = inventoried.filter((f) => SOURCE_EXT.has(path13.extname(f).toLowerCase()));
|
|
3356
|
+
if (relative.length === 0) {
|
|
3357
|
+
diagnostics.push(diag("ATLAS-ADOPT-002", "warning", "No se encontraron archivos de c\xF3digo para inventariar", { path: root, suggestion: "A\xF1ade el dominio a mano con --domains <nombre>" }));
|
|
3358
|
+
}
|
|
3359
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3360
|
+
for (const file of relative) {
|
|
3361
|
+
const domain = inferDomain(file);
|
|
3362
|
+
if (!domain) continue;
|
|
3363
|
+
const list = counts.get(domain) ?? [];
|
|
3364
|
+
list.push(file);
|
|
3365
|
+
counts.set(domain, list);
|
|
3366
|
+
}
|
|
3367
|
+
if (counts.size === 0 && relative.length > 0) {
|
|
3368
|
+
const fallback = await fallbackDomainName(root, config.project.name);
|
|
3369
|
+
counts.set(fallback, relative);
|
|
3370
|
+
}
|
|
3371
|
+
const requested = opts.domains?.map((d) => d.toLowerCase());
|
|
3372
|
+
let domainNames = [...counts.keys()].filter((name) => requested ? requested.includes(name) : true);
|
|
3373
|
+
if (requested) {
|
|
3374
|
+
for (const name of requested) if (!counts.has(name)) domainNames.push(name);
|
|
3375
|
+
}
|
|
3376
|
+
if (domainNames.length === 0) domainNames = ["general"];
|
|
3377
|
+
const profiles = await loadProjectProfiles(root, opts.profilesDirs ?? []);
|
|
3378
|
+
const detection = profiles.length > 0 ? await detectProfiles(root, profiles) : void 0;
|
|
3379
|
+
const domains = [];
|
|
3380
|
+
const createdSpecs = [];
|
|
3381
|
+
for (const name of domainNames.sort()) {
|
|
3382
|
+
const domainFiles = (counts.get(name) ?? []).sort();
|
|
3383
|
+
const existingSpec = await exists(path13.join(sddDir, "specs", name, "spec.md"));
|
|
3384
|
+
domains.push({ name, files: domainFiles.length, samples: domainFiles.slice(0, 8), existingSpec });
|
|
3385
|
+
if (existingSpec) {
|
|
3386
|
+
diagnostics.push(diag("ATLAS-ADOPT-003", "info", `La spec de "${name}" ya existe; se conserva`, { path: path13.join(sddDir, "specs", name, "spec.md") }));
|
|
3387
|
+
continue;
|
|
3388
|
+
}
|
|
3389
|
+
const specPath = path13.join(sddDir, "specs", name, "spec.md");
|
|
3390
|
+
if (!dryRun) {
|
|
3391
|
+
await writeText(specPath, baselineSpec(name, domainFiles));
|
|
3392
|
+
createdSpecs.push(specPath);
|
|
3393
|
+
} else {
|
|
3394
|
+
createdSpecs.push(specPath);
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
const reportPath = path13.join(sddDir, "adopt-report.md");
|
|
3398
|
+
if (!dryRun) {
|
|
3399
|
+
await writeText(reportPath, adoptReport({ root, config, domains, detection: detection?.best, language: config.project.language, files: relative.length, inventoried: inventoried.length, now: opts.now ?? /* @__PURE__ */ new Date() }));
|
|
3400
|
+
}
|
|
3401
|
+
return {
|
|
3402
|
+
root,
|
|
3403
|
+
domains,
|
|
3404
|
+
createdSpecs,
|
|
3405
|
+
reportPath,
|
|
3406
|
+
...detection?.best ? { stack: { name: detection.best.name, score: detection.best.score } } : {},
|
|
3407
|
+
diagnostics,
|
|
3408
|
+
dryRun
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
async function loadProjectProfiles(root, dirs) {
|
|
3412
|
+
const out = [];
|
|
3413
|
+
for (const dir of dirs) {
|
|
3414
|
+
if (!await exists(dir)) continue;
|
|
3415
|
+
out.push(...await loadProfilesFromDir(dir));
|
|
3416
|
+
}
|
|
3417
|
+
const custom = path13.join(root, ".sdd", "profiles", "custom");
|
|
3418
|
+
if (await exists(custom)) out.push(...await loadProfilesFromDir(custom));
|
|
3419
|
+
return out;
|
|
3420
|
+
}
|
|
3421
|
+
async function fallbackDomainName(root, projectName) {
|
|
3422
|
+
const pkg = await readTextIfExists(path13.join(root, "package.json"));
|
|
3423
|
+
if (pkg) {
|
|
3424
|
+
try {
|
|
3425
|
+
const data = JSON.parse(pkg);
|
|
3426
|
+
if (typeof data.name === "string" && data.name.trim() !== "") {
|
|
3427
|
+
return slugify(data.name.replace(/^@[^/]+\//, ""));
|
|
3428
|
+
}
|
|
3429
|
+
} catch {
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
return slugify(projectName) || "general";
|
|
3433
|
+
}
|
|
3434
|
+
function slugify(value) {
|
|
3435
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
3436
|
+
}
|
|
3437
|
+
function inferDomain(file) {
|
|
3438
|
+
const parts = file.split("/");
|
|
3439
|
+
if (parts.length < 2) return void 0;
|
|
3440
|
+
const [first, second] = parts;
|
|
3441
|
+
if (first && COMMON_ROOTS.includes(first) && second && parts.length > 2) {
|
|
3442
|
+
if (isSourceLike(second)) return void 0;
|
|
3443
|
+
return second.toLowerCase();
|
|
3444
|
+
}
|
|
3445
|
+
if (parts.length === 2 && first && isSourceLike(first)) return first.replace(/\.[^.]+$/, "").toLowerCase();
|
|
3446
|
+
if (parts.length >= 3 && first === "packages" && second) return second.toLowerCase();
|
|
3447
|
+
return void 0;
|
|
3448
|
+
}
|
|
3449
|
+
function isSourceLike(name) {
|
|
3450
|
+
return /\.(ts|tsx|js|jsx|py|cs|java|go|rb|php|rs|kt|swift|mjs|cjs)$/i.test(name);
|
|
3451
|
+
}
|
|
3452
|
+
function baselineSpec(domain, files) {
|
|
3453
|
+
const title = domain.charAt(0).toUpperCase() + domain.slice(1);
|
|
3454
|
+
const anchors = files.slice(0, 40);
|
|
3455
|
+
const lines = [];
|
|
3456
|
+
lines.push("---");
|
|
3457
|
+
lines.push(`domain: ${domain}`);
|
|
3458
|
+
lines.push(`title: ${title}`);
|
|
3459
|
+
lines.push("version: 1");
|
|
3460
|
+
lines.push(`updated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
|
|
3461
|
+
lines.push("---");
|
|
3462
|
+
lines.push("");
|
|
3463
|
+
lines.push(`# ${title}`);
|
|
3464
|
+
lines.push("");
|
|
3465
|
+
lines.push("> Spec baseline generada por `satlas adopt` a partir del c\xF3digo existente.");
|
|
3466
|
+
lines.push("> Los requisitos deben derivarse con el agente y el usuario (fase /satlas-adopt) y plegarse con `satlas archive`.");
|
|
3467
|
+
lines.push("");
|
|
3468
|
+
lines.push("## Anclas de implementaci\xF3n");
|
|
3469
|
+
lines.push("");
|
|
3470
|
+
for (const file of anchors) lines.push(`- ${file}`);
|
|
3471
|
+
if (files.length > anchors.length) lines.push(`- \u2026 (${files.length - anchors.length} archivos m\xE1s)`);
|
|
3472
|
+
lines.push("");
|
|
3473
|
+
return lines.join("\n");
|
|
3474
|
+
}
|
|
3475
|
+
function adoptReport(input) {
|
|
3476
|
+
const es = input.language !== "en";
|
|
3477
|
+
const lines = [];
|
|
3478
|
+
lines.push(es ? "# Adopci\xF3n (brownfield)" : "# Adoption (brownfield)");
|
|
3479
|
+
lines.push("");
|
|
3480
|
+
lines.push(es ? `- Proyecto: ${input.config.project.name}` : `- Project: ${input.config.project.name}`);
|
|
3481
|
+
lines.push(es ? `- Generado: ${input.now.toISOString()}` : `- Generated: ${input.now.toISOString()}`);
|
|
3482
|
+
lines.push(es ? `- Archivos de c\xF3digo inventariados: ${input.files} (de ${input.inventoried} archivos totales)` : `- Inventoried source files: ${input.files} (of ${input.inventoried} total files)`);
|
|
3483
|
+
if (input.detection) lines.push(es ? `- Stack detectado: ${input.detection.name} (${input.detection.score} puntos)` : `- Detected stack: ${input.detection.name} (${input.detection.score})`);
|
|
3484
|
+
lines.push("");
|
|
3485
|
+
lines.push(es ? "## Dominios candidatos" : "## Candidate domains");
|
|
3486
|
+
lines.push("");
|
|
3487
|
+
lines.push(es ? "| Dominio | Archivos | Spec | Ejemplos |" : "| Domain | Files | Spec | Samples |");
|
|
3488
|
+
lines.push("|---|---|---|---|");
|
|
3489
|
+
for (const domain of input.domains) {
|
|
3490
|
+
lines.push(`| ${domain.name} | ${domain.files} | ${domain.existingSpec ? es ? "existente" : "existing" : es ? "creada" : "created"} | ${domain.samples.slice(0, 3).join("<br>") || "\u2014"} |`);
|
|
3491
|
+
}
|
|
3492
|
+
lines.push("");
|
|
3493
|
+
lines.push(es ? "## Siguiente paso" : "## Next step");
|
|
3494
|
+
lines.push("");
|
|
3495
|
+
if (es) {
|
|
3496
|
+
lines.push("1. Ejecuta la fase `/satlas-adopt` por dominio: el agente lee las anclas y el c\xF3digo, entrevista al usuario y redacta los requisitos AS-IS.");
|
|
3497
|
+
lines.push("2. Crea el cambio con `satlas new adopt-<dominio> --domain <dominio>` y escribe el delta con los requisitos y escenarios del comportamiento actual.");
|
|
3498
|
+
lines.push("3. `satlas validate --change adopt-<dominio>` hasta quedar sin errores y pliega con `satlas archive adopt-<dominio> --yes`.");
|
|
3499
|
+
lines.push("4. Repite por dominio. La adopci\xF3n documenta lo que existe: no requiere tareas ni verificaci\xF3n.");
|
|
3500
|
+
} else {
|
|
3501
|
+
lines.push("1. Run the `/satlas-adopt` phase per domain: the agent reads anchors and code, interviews the user and drafts AS-IS requirements.");
|
|
3502
|
+
lines.push("2. Create the change with `satlas new adopt-<domain> --domain <domain>` and write the delta with current behaviour.");
|
|
3503
|
+
lines.push("3. `satlas validate --change adopt-<domain>` until clean and fold with `satlas archive adopt-<domain> --yes`.");
|
|
3504
|
+
}
|
|
3505
|
+
lines.push("");
|
|
3506
|
+
return lines.join("\n");
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// src/init.ts
|
|
3510
|
+
import path14 from "path";
|
|
3511
|
+
async function initWorkspace(opts) {
|
|
3512
|
+
const root = path14.resolve(opts.root);
|
|
3513
|
+
const sddDir = path14.join(root, ".sdd");
|
|
3514
|
+
const diagnostics = [];
|
|
3515
|
+
const created = [];
|
|
3516
|
+
if (await exists(path14.join(sddDir, "config.yaml"))) {
|
|
3517
|
+
return {
|
|
3518
|
+
sddDir,
|
|
3519
|
+
created,
|
|
3520
|
+
diagnostics: [diag("ATLAS-INIT-001", "error", "El proyecto ya est\xE1 inicializado (.sdd/config.yaml existe)", { path: sddDir, suggestion: "No hace falta volver a ejecutar `satlas init`" })]
|
|
3521
|
+
};
|
|
3522
|
+
}
|
|
3523
|
+
created.push(...await ensureSddDirs(sddDir));
|
|
3524
|
+
const language = opts.language ?? "es";
|
|
3525
|
+
const cfg = defaultConfig({ name: opts.name, language });
|
|
3526
|
+
cfg.project.name = opts.name ?? path14.basename(root);
|
|
3527
|
+
await writeConfig(sddDir, cfg);
|
|
3528
|
+
created.push(path14.join(sddDir, "config.yaml"));
|
|
3529
|
+
const templates = templatesFor(language);
|
|
3530
|
+
await writeText(path14.join(sddDir, "constitution.md"), templates.constitution);
|
|
3531
|
+
created.push(path14.join(sddDir, "constitution.md"));
|
|
3532
|
+
await writeText(path14.join(sddDir, "glossary.md"), templates.glossary);
|
|
3533
|
+
created.push(path14.join(sddDir, "glossary.md"));
|
|
3534
|
+
const detected = await detectIfPossible(root, opts.profilesDir);
|
|
3535
|
+
if (detected) {
|
|
3536
|
+
const yaml = detectionToYaml(detected, (opts.now ?? /* @__PURE__ */ new Date()).toISOString());
|
|
3537
|
+
await writeText(path14.join(sddDir, "profiles", "detected.yaml"), yaml);
|
|
3538
|
+
created.push(path14.join(sddDir, "profiles", "detected.yaml"));
|
|
3539
|
+
if (detected.best) {
|
|
3540
|
+
diagnostics.push(diag("ATLAS-INIT-002", "info", `Stack detectado: ${detected.best.displayName} (${detected.best.score} puntos)`, { suggestion: "Revisa .sdd/profiles/detected.yaml" }));
|
|
3541
|
+
} else {
|
|
3542
|
+
diagnostics.push(diag("ATLAS-INIT-003", "info", "No se detect\xF3 un stack conocido: se usar\xE1 el perfil gen\xE9rico", { suggestion: "Crea un perfil propio con `satlas profile` (F2) o edita .sdd/profiles/custom/" }));
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
await writeText(path14.join(sddDir, "INDEX.md"), initialIndex(language, cfg.project.name));
|
|
3546
|
+
created.push(path14.join(sddDir, "INDEX.md"));
|
|
3547
|
+
if (opts.local) {
|
|
3548
|
+
const gitignore = path14.join(root, ".gitignore");
|
|
3549
|
+
const current = await readTextIfExists(gitignore) ?? "";
|
|
3550
|
+
if (!/(^|\n)\.sdd\/?(\n|$)/.test(current)) {
|
|
3551
|
+
const next = current.endsWith("\n") || current === "" ? `${current}.sdd/
|
|
3552
|
+
` : `${current}
|
|
3553
|
+
.sdd/
|
|
3554
|
+
`;
|
|
3555
|
+
await writeText(gitignore, next);
|
|
3556
|
+
created.push(gitignore);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
return detected ? { sddDir, created, detected, diagnostics } : { sddDir, created, diagnostics };
|
|
3560
|
+
}
|
|
3561
|
+
async function detectIfPossible(root, profilesDir) {
|
|
3562
|
+
if (!profilesDir) return void 0;
|
|
3563
|
+
if (!await exists(profilesDir)) return void 0;
|
|
3564
|
+
const profiles = await loadProfilesFromDir(profilesDir);
|
|
3565
|
+
if (profiles.length === 0) return void 0;
|
|
3566
|
+
return detectProfiles(root, profiles);
|
|
3567
|
+
}
|
|
3568
|
+
function initialIndex(language, projectName) {
|
|
3569
|
+
const es = language !== "en";
|
|
3570
|
+
return `# \xCDndice \u2014 ${projectName}
|
|
3571
|
+
|
|
3572
|
+
${es ? "> Generado por SpecAtlas. Las specs vivas y los cambios se listan aqu\xED a medida que existan." : "> Generated by SpecAtlas."}
|
|
3573
|
+
|
|
3574
|
+
${es ? "## Specs vivas\n\n_Sin specs todav\xEDa._" : "## Living specs\n\n_No specs yet._"}
|
|
3575
|
+
|
|
3576
|
+
${es ? "## Cambios activos\n\n_Sin cambios activos._" : "## Active changes\n\n_No active changes._"}
|
|
3577
|
+
|
|
3578
|
+
${es ? "## Archivados\n\n0 cambio(s) en `changes/archive/`." : "## Archived\n\n0 change(s)."}
|
|
3579
|
+
`;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// src/new.ts
|
|
3583
|
+
import path15 from "path";
|
|
3584
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{1,49}$/;
|
|
3585
|
+
async function createChange(opts) {
|
|
3586
|
+
const diagnostics = [];
|
|
3587
|
+
const slug = opts.slug.trim().toLowerCase();
|
|
3588
|
+
const language = opts.language ?? opts.cfg?.project.language ?? "es";
|
|
3589
|
+
const dir = path15.join(path15.resolve(opts.root), ".sdd", "changes", slug);
|
|
3590
|
+
if (!SLUG_RE.test(slug)) {
|
|
3591
|
+
return {
|
|
3592
|
+
slug,
|
|
3593
|
+
dir,
|
|
3594
|
+
files: [],
|
|
3595
|
+
diagnostics: [diag("ATLAS-NEW-001", "error", `Slug inv\xE1lido: "${slug}"`, { suggestion: "Usa kebab-case: min\xFAsculas, n\xFAmeros y guiones (2-50 caracteres)" })]
|
|
3596
|
+
};
|
|
3597
|
+
}
|
|
3598
|
+
if (await exists(dir)) {
|
|
3599
|
+
return {
|
|
3600
|
+
slug,
|
|
3601
|
+
dir,
|
|
3602
|
+
files: [],
|
|
3603
|
+
diagnostics: [diag("ATLAS-NEW-002", "error", `El cambio "${slug}" ya existe`, { path: dir })]
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
const lane = opts.lane ?? opts.cfg?.lanes.default ?? "standard";
|
|
3607
|
+
const domain = (opts.domain ?? "general").toLowerCase();
|
|
3608
|
+
const title = opts.title ?? slug.replace(/-/g, " ");
|
|
3609
|
+
const meta = {
|
|
3610
|
+
schemaVersion: 1,
|
|
3611
|
+
slug,
|
|
3612
|
+
title,
|
|
3613
|
+
domain,
|
|
3614
|
+
lane,
|
|
3615
|
+
created: (opts.now ?? /* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
3616
|
+
};
|
|
3617
|
+
await ensureDir(dir);
|
|
3618
|
+
const templates = templatesFor(language);
|
|
3619
|
+
const files = [];
|
|
3620
|
+
const metaFile = path15.join(dir, "meta.yaml");
|
|
3621
|
+
await writeText(metaFile, changeMetaYaml(meta));
|
|
3622
|
+
files.push(metaFile);
|
|
3623
|
+
if (lane === "fix") {
|
|
3624
|
+
const fixFile = path15.join(dir, "fix.md");
|
|
3625
|
+
await writeText(fixFile, renderTemplate(templates.fix, { TITLE: title }));
|
|
3626
|
+
files.push(fixFile);
|
|
3627
|
+
return { slug, dir, files, diagnostics };
|
|
3628
|
+
}
|
|
3629
|
+
const proposalFile = path15.join(dir, "proposal.md");
|
|
3630
|
+
await writeText(proposalFile, renderTemplate(templates.proposal, { TITLE: title }));
|
|
3631
|
+
files.push(proposalFile);
|
|
3632
|
+
const domainUpper = domain.replace(/[^a-z0-9]/gi, "").toUpperCase() || "GEN";
|
|
3633
|
+
const deltaFile = path15.join(dir, "spec.md");
|
|
3634
|
+
await writeText(deltaFile, templates.specDelta({ title, domainUpper, domain }));
|
|
3635
|
+
files.push(deltaFile);
|
|
3636
|
+
return { slug, dir, files, diagnostics };
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
// src/archive.ts
|
|
3640
|
+
import { promises as fs } from "fs";
|
|
3641
|
+
import path16 from "path";
|
|
3642
|
+
var REQ_HEADER_RE = /^###\s+(?:Requisito|Requirement):\s+(REQ-[A-Z0-9-]+)\s*(?:—|-|–)\s*(.*)$/;
|
|
3643
|
+
function foldDelta(livingBody, delta, language = "es") {
|
|
3644
|
+
const diagnostics = [];
|
|
3645
|
+
let lines = livingBody.replace(/\r\n?/g, "\n").split("\n");
|
|
3646
|
+
const applied = { added: [], modified: [], removed: [], renamed: [] };
|
|
3647
|
+
const findRange = (id) => {
|
|
3648
|
+
let start = -1;
|
|
3649
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
3650
|
+
const m = REQ_HEADER_RE.exec(lines[i] ?? "");
|
|
3651
|
+
if (m && (m[1] ?? "").toUpperCase() === id) {
|
|
3652
|
+
start = i;
|
|
3653
|
+
break;
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
if (start < 0) return void 0;
|
|
3657
|
+
let end = lines.length;
|
|
3658
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
3659
|
+
if (/^###\s+/.test(lines[i] ?? "")) {
|
|
3660
|
+
end = i;
|
|
3661
|
+
break;
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
return { start, end };
|
|
3665
|
+
};
|
|
3666
|
+
for (const req of delta.modified) {
|
|
3667
|
+
const range = findRange(req.id);
|
|
3668
|
+
if (!range) {
|
|
3669
|
+
diagnostics.push(diag("TRACE-007", "error", `No se puede modificar ${req.id}: no existe en la spec viva`, { path: delta.path, line: req.line }));
|
|
3670
|
+
continue;
|
|
3671
|
+
}
|
|
3672
|
+
const rendered = renderRequirement(req, language).split("\n");
|
|
3673
|
+
lines = [...lines.slice(0, range.start), ...rendered, "", ...lines.slice(range.end)];
|
|
3674
|
+
applied.modified.push(req.id);
|
|
3675
|
+
}
|
|
3676
|
+
for (const req of delta.removed) {
|
|
3677
|
+
const range = findRange(req.id);
|
|
3678
|
+
if (!range) {
|
|
3679
|
+
diagnostics.push(diag("TRACE-007", "error", `No se puede eliminar ${req.id}: no existe en la spec viva`, { path: delta.path, line: req.line }));
|
|
3680
|
+
continue;
|
|
3681
|
+
}
|
|
3682
|
+
lines = [...lines.slice(0, range.start), ...lines.slice(range.end)];
|
|
3683
|
+
applied.removed.push(req.id);
|
|
3684
|
+
}
|
|
3685
|
+
for (const rename of delta.renamed) {
|
|
3686
|
+
const range = findRange(rename.from.id);
|
|
3687
|
+
if (!range) {
|
|
3688
|
+
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 }));
|
|
3689
|
+
continue;
|
|
3690
|
+
}
|
|
3691
|
+
const header = lines[range.start] ?? "";
|
|
3692
|
+
const label = /Requirement/i.test(header) ? "Requirement" : "Requisito";
|
|
3693
|
+
lines[range.start] = header.replace(REQ_HEADER_RE, (_match, id) => `### ${label}: ${id} \u2014 ${rename.to.title}`);
|
|
3694
|
+
applied.renamed.push(rename.from.id);
|
|
3695
|
+
}
|
|
3696
|
+
for (const req of delta.added) {
|
|
3697
|
+
const rendered = renderRequirement(req, language);
|
|
3698
|
+
const trimmed = trimTrailingBlanks(lines);
|
|
3699
|
+
lines = [...trimmed, "", ...rendered.split("\n"), ""];
|
|
3700
|
+
applied.added.push(req.id);
|
|
3701
|
+
}
|
|
3702
|
+
return { content: lines.join("\n"), applied, diagnostics };
|
|
3703
|
+
}
|
|
3704
|
+
function trimTrailingBlanks(lines) {
|
|
3705
|
+
const out = [...lines];
|
|
3706
|
+
while (out.length > 0 && (out[out.length - 1] ?? "").trim() === "") out.pop();
|
|
3707
|
+
return out;
|
|
3708
|
+
}
|
|
3709
|
+
async function archiveChange(opts) {
|
|
3710
|
+
const root = path16.resolve(opts.root);
|
|
3711
|
+
const diagnostics = [];
|
|
3712
|
+
const { config } = await loadWorkspace(root);
|
|
3713
|
+
const language = opts.language ?? config.project.language;
|
|
3714
|
+
const change = await loadChange(root, opts.slug);
|
|
3715
|
+
diagnostics.push(...change.diagnostics);
|
|
3716
|
+
const emptyFold = { content: "", applied: { added: [], modified: [], removed: [], renamed: [] }, diagnostics: [] };
|
|
3717
|
+
if (!change.meta) {
|
|
3718
|
+
return { slug: opts.slug, fold: emptyFold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3719
|
+
}
|
|
3720
|
+
if (change.meta.lane === "fix") {
|
|
3721
|
+
if (!change.fix) {
|
|
3722
|
+
diagnostics.push(diag("ATLAS-ARCH-005", "error", `El fix "${opts.slug}" no tiene fix.md`, { path: change.dir }));
|
|
3723
|
+
return { slug: opts.slug, fold: emptyFold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3724
|
+
}
|
|
3725
|
+
const nowFix = opts.now ?? /* @__PURE__ */ new Date();
|
|
3726
|
+
const targetFix = path16.join(root, ".sdd", "changes", "archive", `${nowFix.toISOString().slice(0, 7)}-${opts.slug}`);
|
|
3727
|
+
if (await exists(targetFix)) {
|
|
3728
|
+
diagnostics.push(diag("ATLAS-ARCH-002", "error", `Ya existe un cambio archivado en ${targetFix}`, { path: targetFix }));
|
|
3729
|
+
return { slug: opts.slug, fold: emptyFold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3730
|
+
}
|
|
3731
|
+
if (!opts.dryRun) {
|
|
3732
|
+
await ensureDir(path16.dirname(targetFix));
|
|
3733
|
+
await fs.rename(change.dir, targetFix);
|
|
3734
|
+
await regenerateIndex(root, config);
|
|
3735
|
+
}
|
|
3736
|
+
return { slug: opts.slug, archivedTo: targetFix, fold: emptyFold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3737
|
+
}
|
|
3738
|
+
if (!change.delta) {
|
|
3739
|
+
diagnostics.push(diag("ATLAS-ARCH-003", "error", `El cambio "${opts.slug}" no tiene spec.md (delta): no hay nada que plegar`, { path: change.dir }));
|
|
3740
|
+
return { slug: opts.slug, fold: emptyFold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3741
|
+
}
|
|
3742
|
+
const domain = change.meta.domain;
|
|
3743
|
+
if (!domain) {
|
|
3744
|
+
diagnostics.push(diag("ATLAS-ARCH-001", "error", `El cambio "${opts.slug}" no declara domain en meta.yaml`, { path: path16.join(change.dir, "meta.yaml") }));
|
|
3745
|
+
return { slug: opts.slug, fold: { content: "", applied: { added: [], modified: [], removed: [], renamed: [] }, diagnostics }, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3746
|
+
}
|
|
3747
|
+
const specFile = path16.join(root, ".sdd", "specs", domain, "spec.md");
|
|
3748
|
+
const existing = await readTextIfExists(specFile);
|
|
3749
|
+
const base = existing ?? `---
|
|
3750
|
+
domain: ${domain}
|
|
3751
|
+
title: ${change.meta.title ?? domain}
|
|
3752
|
+
version: 0
|
|
3753
|
+
---
|
|
3754
|
+
|
|
3755
|
+
# ${change.meta.title ?? domain}
|
|
3756
|
+
`;
|
|
3757
|
+
const fm = parseFrontmatter(base);
|
|
3758
|
+
const fold = foldDelta(fm.body, change.delta, language);
|
|
3759
|
+
diagnostics.push(...fold.diagnostics);
|
|
3760
|
+
const version = typeof fm.data["version"] === "number" ? fm.data["version"] : 0;
|
|
3761
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
3762
|
+
const nextFm = {
|
|
3763
|
+
domain,
|
|
3764
|
+
title: fm.data["title"] ?? change.meta.title ?? domain,
|
|
3765
|
+
owner: fm.data["owner"],
|
|
3766
|
+
version: version + 1,
|
|
3767
|
+
updated: now.toISOString().slice(0, 10)
|
|
3768
|
+
};
|
|
3769
|
+
for (const key of Object.keys(nextFm)) if (nextFm[key] === void 0) delete nextFm[key];
|
|
3770
|
+
const body = fold.content.replace(/^[\r\n]+/, "").trimEnd() + "\n";
|
|
3771
|
+
const nextContent = `---
|
|
3772
|
+
${Object.entries(nextFm).map(([k, v]) => `${k}: ${String(v)}`).join("\n")}
|
|
3773
|
+
---
|
|
3774
|
+
|
|
3775
|
+
${body}`;
|
|
3776
|
+
const month = now.toISOString().slice(0, 7);
|
|
3777
|
+
const archiveDir = path16.join(root, ".sdd", "changes", "archive");
|
|
3778
|
+
const target = path16.join(archiveDir, `${month}-${opts.slug}`);
|
|
3779
|
+
if (await exists(target)) {
|
|
3780
|
+
diagnostics.push(diag("ATLAS-ARCH-002", "error", `Ya existe un cambio archivado en ${target}`, { path: target }));
|
|
3781
|
+
return { slug: opts.slug, domain, fold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3782
|
+
}
|
|
3783
|
+
if (!opts.dryRun) {
|
|
3784
|
+
await writeText(specFile, nextContent);
|
|
3785
|
+
await ensureDir(archiveDir);
|
|
3786
|
+
await fs.rename(change.dir, target);
|
|
3787
|
+
await regenerateIndex(root, config, now);
|
|
3788
|
+
}
|
|
3789
|
+
return { slug: opts.slug, domain, archivedTo: target, fold, diagnostics, dryRun: opts.dryRun ?? false };
|
|
3790
|
+
}
|
|
3791
|
+
async function regenerateIndex(root, cfg, now = /* @__PURE__ */ new Date()) {
|
|
3792
|
+
const { workspace, config } = cfg ? { workspace: (await loadWorkspace(root)).workspace, config: cfg } : await loadWorkspace(root);
|
|
3793
|
+
const archiveDir = path16.join(root, ".sdd", "changes", "archive");
|
|
3794
|
+
const archived = await exists(archiveDir) ? (await fs.readdir(archiveDir)).filter((e) => !e.startsWith(".")).length : 0;
|
|
3795
|
+
const markdown = indexMarkdown({
|
|
3796
|
+
projectName: config.project.name,
|
|
3797
|
+
language: config.project.language,
|
|
3798
|
+
specs: workspace.specs.map((s) => ({ domain: s.domain, requirements: s.spec.requirements.length })),
|
|
3799
|
+
changes: workspace.changes.map((c) => ({ slug: c.slug, lane: c.meta?.lane ?? config.lanes.default })),
|
|
3800
|
+
archived
|
|
3801
|
+
});
|
|
3802
|
+
void now;
|
|
3803
|
+
await writeText(path16.join(root, ".sdd", "INDEX.md"), markdown);
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
// src/doctor.ts
|
|
3807
|
+
import path17 from "path";
|
|
3808
|
+
async function runDoctor(root) {
|
|
3809
|
+
const findings = [];
|
|
3810
|
+
const { workspace, config } = await loadWorkspace(root);
|
|
3811
|
+
findings.push(...workspace.diagnostics);
|
|
3812
|
+
const approvals = await loadApprovals(workspace.sddDir);
|
|
3813
|
+
findings.push(...approvals.diagnostics);
|
|
3814
|
+
for (const change of workspace.changes) {
|
|
3815
|
+
const deltaPath = path17.join(change.dir, "spec.md");
|
|
3816
|
+
const deltaContent = await readTextIfExists(deltaPath);
|
|
3817
|
+
const approval = verifyApproval(change, approvals.byArtifact, config, deltaContent ?? void 0);
|
|
3818
|
+
if ((change.planPath || change.tasks) && (approval.status === "missing" || approval.status === "stale")) {
|
|
3819
|
+
findings.push(
|
|
3820
|
+
diag("ATLAS-LIFECYCLE-001", "error", `El cambio "${change.slug}" tiene plan/tareas sin una firma vigente de la spec`, {
|
|
3821
|
+
path: deltaPath,
|
|
3822
|
+
suggestion: "Firma la spec (`satlas approve`) o rehaz la aprobaci\xF3n si la spec cambi\xF3"
|
|
3823
|
+
})
|
|
3824
|
+
);
|
|
3825
|
+
}
|
|
3826
|
+
const failing = (change.verify?.evidence ?? []).filter((e) => e.result === "fail");
|
|
3827
|
+
if (failing.length > 0) {
|
|
3828
|
+
findings.push(
|
|
3829
|
+
diag("ATLAS-LIFECYCLE-002", "error", `El cambio "${change.slug}" tiene ${failing.length} evidencia(s) con result: fail`, {
|
|
3830
|
+
path: change.verify?.path,
|
|
3831
|
+
suggestion: "Corrige y vuelve a registrar la evidencia antes de archivar"
|
|
3832
|
+
})
|
|
3833
|
+
);
|
|
3834
|
+
}
|
|
3835
|
+
for (const override of change.meta?.overrides ?? []) {
|
|
3836
|
+
if (!override.reason.trim() || !override.by.trim()) {
|
|
3837
|
+
findings.push(diag("ATLAS-LIFECYCLE-004", "error", `Override del gate "${override.gate}" sin motivo o autor`, { path: path17.join(change.dir, "meta.yaml") }));
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3842
|
+
for (const specRef of workspace.specs) {
|
|
3843
|
+
for (const req of specRef.spec.requirements) {
|
|
3844
|
+
const owner = seen.get(req.id);
|
|
3845
|
+
if (owner && owner !== specRef.domain) {
|
|
3846
|
+
findings.push(diag("ATLAS-TRACE-008", "error", `El requisito ${req.id} est\xE1 duplicado en "${owner}" y "${specRef.domain}"`, { path: specRef.path }));
|
|
3847
|
+
}
|
|
3848
|
+
seen.set(req.id, specRef.domain);
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
return { findings, summary: countBySeverity(findings) };
|
|
3852
|
+
}
|
|
3853
|
+
async function specHashOf(filePath) {
|
|
3854
|
+
const content = await readTextIfExists(filePath);
|
|
3855
|
+
if (content === void 0) return void 0;
|
|
3856
|
+
return artifactHash(content);
|
|
3857
|
+
}
|
|
3858
|
+
export {
|
|
3859
|
+
BLOCK_HEAD_RE,
|
|
3860
|
+
BUILTIN_PACKS,
|
|
3861
|
+
CONFIG_FILE,
|
|
3862
|
+
CORE_VERSION,
|
|
3863
|
+
REQ_HEAD_RE,
|
|
3864
|
+
REQ_ID_RE,
|
|
3865
|
+
RULE_ID_RE,
|
|
3866
|
+
RULE_RE,
|
|
3867
|
+
RUN_EVENT_TYPES,
|
|
3868
|
+
SCENARIO_HEAD_RE,
|
|
3869
|
+
SCENARIO_ID_RE,
|
|
3870
|
+
SDD_DIR,
|
|
3871
|
+
SLUG_RE,
|
|
3872
|
+
TASK_ID_RE,
|
|
3873
|
+
TASK_LINE_RE,
|
|
3874
|
+
adoptWorkspace,
|
|
3875
|
+
appendRunEvent,
|
|
3876
|
+
approvalsSchema,
|
|
3877
|
+
approveFromGithub,
|
|
3878
|
+
archiveChange,
|
|
3879
|
+
artifactHash,
|
|
3880
|
+
atlasConfigSchema,
|
|
3881
|
+
buildTraceGraph,
|
|
3882
|
+
canonicalizeMarkdown,
|
|
3883
|
+
captureMockups,
|
|
3884
|
+
changeMarker,
|
|
3885
|
+
changeMetaSchema,
|
|
3886
|
+
changeMetaYaml,
|
|
3887
|
+
checkMockups,
|
|
3888
|
+
checkTrace,
|
|
3889
|
+
collectMetrics,
|
|
3890
|
+
commentIssue,
|
|
3891
|
+
compareTaskIds,
|
|
3892
|
+
computeInputsHash,
|
|
3893
|
+
configToYaml,
|
|
3894
|
+
copyFile,
|
|
3895
|
+
countBySeverity,
|
|
3896
|
+
createChange,
|
|
3897
|
+
createIssue,
|
|
3898
|
+
createRun,
|
|
3899
|
+
defaultConfig,
|
|
3900
|
+
defaultTokens,
|
|
3901
|
+
deriveState,
|
|
3902
|
+
detectProfiles,
|
|
3903
|
+
detectRepo,
|
|
3904
|
+
detectionToYaml,
|
|
3905
|
+
diag,
|
|
3906
|
+
editIssue,
|
|
3907
|
+
emitFrontmatter,
|
|
3908
|
+
ensureDir,
|
|
3909
|
+
ensureSddDirs,
|
|
3910
|
+
esc,
|
|
3911
|
+
evaluatePacks,
|
|
3912
|
+
evidenceSummary,
|
|
3913
|
+
exists,
|
|
3914
|
+
findLinkedIssue,
|
|
3915
|
+
findWorkspaceRoot,
|
|
3916
|
+
firstToken,
|
|
3917
|
+
foldDelta,
|
|
3918
|
+
generatePresentation,
|
|
3919
|
+
generateRunId,
|
|
3920
|
+
getNumber,
|
|
3921
|
+
getString,
|
|
3922
|
+
ghAuthStatus,
|
|
3923
|
+
ghAvailable,
|
|
3924
|
+
hasErrors,
|
|
3925
|
+
hasShellMetacharacters,
|
|
3926
|
+
indexMarkdown,
|
|
3927
|
+
initWorkspace,
|
|
3928
|
+
inline,
|
|
3929
|
+
isCommandAllowed,
|
|
3930
|
+
isDirectory,
|
|
3931
|
+
issueBody,
|
|
3932
|
+
issueLabels,
|
|
3933
|
+
laneOrDefault,
|
|
3934
|
+
lintDelta,
|
|
3935
|
+
lintMockupHtml,
|
|
3936
|
+
lintMockupManifest,
|
|
3937
|
+
lintRequirement,
|
|
3938
|
+
lintSpec,
|
|
3939
|
+
lintTasks,
|
|
3940
|
+
lintVerify,
|
|
3941
|
+
listChangeSlugs,
|
|
3942
|
+
listDir,
|
|
3943
|
+
listDirs,
|
|
3944
|
+
listRuns,
|
|
3945
|
+
loadActiveProfile,
|
|
3946
|
+
loadApprovals,
|
|
3947
|
+
loadChange,
|
|
3948
|
+
loadConfig,
|
|
3949
|
+
loadDetectedBest,
|
|
3950
|
+
loadProfileFile,
|
|
3951
|
+
loadProfilesFromDir,
|
|
3952
|
+
loadProjectPacks,
|
|
3953
|
+
loadSpecs,
|
|
3954
|
+
loadWorkspace,
|
|
3955
|
+
matchGlob,
|
|
3956
|
+
mockupManifestSchema,
|
|
3957
|
+
mockupsDir,
|
|
3958
|
+
packFindings,
|
|
3959
|
+
parseApprovals,
|
|
3960
|
+
parseChangeMeta,
|
|
3961
|
+
parseConfig,
|
|
3962
|
+
parseDelta,
|
|
3963
|
+
parseFrontmatter,
|
|
3964
|
+
parseGitHubRemote,
|
|
3965
|
+
parseIssueUrl,
|
|
3966
|
+
parseRequirementBlocks,
|
|
3967
|
+
parseSpecFile,
|
|
3968
|
+
parseTasksFile,
|
|
3969
|
+
parseVerifyFile,
|
|
3970
|
+
patchChangeMeta,
|
|
3971
|
+
planBlock,
|
|
3972
|
+
planMockups,
|
|
3973
|
+
planWaves,
|
|
3974
|
+
profileSchema,
|
|
3975
|
+
readMockupManifest,
|
|
3976
|
+
readRun,
|
|
3977
|
+
readText,
|
|
3978
|
+
readTextIfExists,
|
|
3979
|
+
recordEvidence,
|
|
3980
|
+
regenerateIndex,
|
|
3981
|
+
renderDocument,
|
|
3982
|
+
renderMarkdown,
|
|
3983
|
+
renderRequirement,
|
|
3984
|
+
renderStyles,
|
|
3985
|
+
renderTemplate,
|
|
3986
|
+
resolvePacks,
|
|
3987
|
+
runAnalyze,
|
|
3988
|
+
runDoctor,
|
|
3989
|
+
runProcess,
|
|
3990
|
+
sha256,
|
|
3991
|
+
shortHash,
|
|
3992
|
+
signApproval,
|
|
3993
|
+
specHashOf,
|
|
3994
|
+
splitCommand,
|
|
3995
|
+
stateLabel,
|
|
3996
|
+
syncGithubIssue,
|
|
3997
|
+
templatesFor,
|
|
3998
|
+
toPosix,
|
|
3999
|
+
tokensFile,
|
|
4000
|
+
updateMockupScreenshots,
|
|
4001
|
+
updateRunStatus,
|
|
4002
|
+
verifyApproval,
|
|
4003
|
+
walkFiles,
|
|
4004
|
+
writeConfig,
|
|
4005
|
+
writeMockupManifest,
|
|
4006
|
+
writeMockupPlan,
|
|
4007
|
+
writeText
|
|
4008
|
+
};
|