@ncoderz/awa 1.0.0 → 1.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.
Files changed (112) hide show
  1. package/README.md +96 -16
  2. package/dist/index.js +2307 -128
  3. package/dist/index.js.map +1 -1
  4. package/package.json +10 -4
  5. package/templates/awa/.agent/skills/awa-align/SKILL.md +3 -0
  6. package/templates/awa/.agent/skills/awa-check/SKILL.md +4 -0
  7. package/templates/awa/.agent/workflows/awa-align.md +3 -0
  8. package/templates/awa/.agent/workflows/awa-check.md +4 -0
  9. package/templates/awa/.agents/skills/awa-align/SKILL.md +3 -0
  10. package/templates/awa/.agents/skills/awa-check/SKILL.md +4 -0
  11. package/templates/awa/.awa/.agent/schemas/ALIGN_REPORT.schema.yaml +83 -0
  12. package/templates/awa/.awa/.agent/schemas/API.schema.yaml +7 -0
  13. package/templates/awa/.awa/.agent/schemas/ARCHITECTURE.schema.yaml +260 -0
  14. package/templates/awa/.awa/.agent/schemas/DESIGN.schema.yaml +361 -0
  15. package/templates/awa/.awa/.agent/schemas/EXAMPLES.schema.yaml +98 -0
  16. package/templates/awa/.awa/.agent/schemas/FEAT.schema.yaml +143 -0
  17. package/templates/awa/.awa/.agent/schemas/PLAN.schema.yaml +151 -0
  18. package/templates/awa/.awa/.agent/schemas/README.schema.yaml +137 -0
  19. package/templates/awa/.awa/.agent/schemas/REQ.schema.yaml +169 -0
  20. package/templates/awa/.awa/.agent/schemas/TASK.schema.yaml +200 -0
  21. package/templates/awa/.claude/agents/awa.md +2 -2
  22. package/templates/awa/.claude/skills/awa-align/SKILL.md +3 -0
  23. package/templates/awa/.claude/skills/awa-check/SKILL.md +4 -0
  24. package/templates/awa/.codex/prompts/awa-align.md +3 -0
  25. package/templates/awa/.codex/prompts/awa-check.md +4 -0
  26. package/templates/awa/.cursor/rules/awa-agent.md +1 -1
  27. package/templates/awa/.cursor/rules/awa-align.md +8 -0
  28. package/templates/awa/.cursor/rules/awa-check.md +9 -0
  29. package/templates/awa/.gemini/commands/awa-align.md +3 -0
  30. package/templates/awa/.gemini/commands/awa-check.md +4 -0
  31. package/templates/awa/.gemini/skills/awa-align/SKILL.md +3 -0
  32. package/templates/awa/.gemini/skills/awa-check/SKILL.md +4 -0
  33. package/templates/awa/.github/agents/awa.agent.md +2 -2
  34. package/templates/awa/.github/prompts/awa.align.prompt.md +8 -0
  35. package/templates/awa/.github/prompts/awa.check.prompt.md +9 -0
  36. package/templates/awa/.github/skills/awa-align/SKILL.md +8 -0
  37. package/templates/awa/.github/skills/awa-check/SKILL.md +9 -0
  38. package/templates/awa/.kilocode/rules/awa-agent.md +1 -1
  39. package/templates/awa/.kilocode/skills/awa-align/SKILL.md +3 -0
  40. package/templates/awa/.kilocode/skills/awa-check/SKILL.md +4 -0
  41. package/templates/awa/.kilocode/workflows/awa-align.md +3 -0
  42. package/templates/awa/.kilocode/workflows/awa-check.md +4 -0
  43. package/templates/awa/.opencode/agents/awa.md +2 -2
  44. package/templates/awa/.opencode/commands/awa-align.md +3 -0
  45. package/templates/awa/.opencode/commands/awa-check.md +4 -0
  46. package/templates/awa/.opencode/skills/awa-align/SKILL.md +3 -0
  47. package/templates/awa/.opencode/skills/awa-check/SKILL.md +4 -0
  48. package/templates/awa/.qwen/commands/awa-align.md +3 -0
  49. package/templates/awa/.qwen/commands/awa-check.md +4 -0
  50. package/templates/awa/.qwen/skills/awa-align/SKILL.md +3 -0
  51. package/templates/awa/.qwen/skills/awa-check/SKILL.md +4 -0
  52. package/templates/awa/.roo/rules/awa-agent.md +1 -1
  53. package/templates/awa/.roo/skills/awa-align/SKILL.md +3 -0
  54. package/templates/awa/.roo/skills/awa-check/SKILL.md +4 -0
  55. package/templates/awa/.windsurf/rules/awa-agent.md +1 -1
  56. package/templates/awa/.windsurf/skills/awa-align/SKILL.md +3 -0
  57. package/templates/awa/.windsurf/skills/awa-check/SKILL.md +4 -0
  58. package/templates/awa/AGENTS.md +1 -1
  59. package/templates/awa/CLAUDE.md +1 -1
  60. package/templates/awa/GEMINI.md +1 -1
  61. package/templates/awa/QWEN.md +1 -1
  62. package/templates/awa/_README.md +3 -2
  63. package/templates/awa/_delete.txt +49 -0
  64. package/templates/awa/_partials/{_cmd.awa-validate-alignment.md → _cmd.awa-align.md} +1 -1
  65. package/templates/awa/_partials/_cmd.awa-check.md +6 -0
  66. package/templates/awa/_partials/_skill.awa-align.md +6 -0
  67. package/templates/awa/_partials/_skill.awa-check.md +6 -0
  68. package/templates/awa/_partials/{awa.validate-alignment.md → awa.align.md} +2 -2
  69. package/templates/awa/_partials/awa.architecture.md +1 -1
  70. package/templates/awa/_partials/awa.check.md +73 -0
  71. package/templates/awa/_partials/awa.code.md +1 -0
  72. package/templates/awa/_partials/awa.core.md +24 -10
  73. package/templates/awa/_partials/awa.design.md +3 -2
  74. package/templates/awa/_partials/awa.documentation.md +1 -1
  75. package/templates/awa/_partials/awa.examples.md +1 -1
  76. package/templates/awa/_partials/awa.feature.md +1 -1
  77. package/templates/awa/_partials/awa.plan.md +1 -1
  78. package/templates/awa/_partials/awa.refactor.md +1 -0
  79. package/templates/awa/_partials/awa.requirements.md +2 -1
  80. package/templates/awa/_partials/awa.tasks.md +3 -3
  81. package/templates/awa/_partials/awa.upgrade.md +13 -12
  82. package/templates/awa/_tests/claude.toml +7 -0
  83. package/templates/awa/_tests/copilot.toml +6 -0
  84. package/templates/awa/.agent/skills/awa-validate-alignment/SKILL.md +0 -3
  85. package/templates/awa/.agent/workflows/awa-validate-alignment.md +0 -3
  86. package/templates/awa/.agents/skills/awa-validate-alignment/SKILL.md +0 -3
  87. package/templates/awa/.awa/.agent/schemas/ALIGN_REPORT.schema.md +0 -156
  88. package/templates/awa/.awa/.agent/schemas/API.schema.md +0 -4
  89. package/templates/awa/.awa/.agent/schemas/ARCHITECTURE.schema.md +0 -176
  90. package/templates/awa/.awa/.agent/schemas/DESIGN.schema.md +0 -253
  91. package/templates/awa/.awa/.agent/schemas/EXAMPLES.schema.md +0 -51
  92. package/templates/awa/.awa/.agent/schemas/FEAT.schema.md +0 -61
  93. package/templates/awa/.awa/.agent/schemas/PLAN.schema.md +0 -8
  94. package/templates/awa/.awa/.agent/schemas/README.schema.md +0 -133
  95. package/templates/awa/.awa/.agent/schemas/REQ.schema.md +0 -125
  96. package/templates/awa/.awa/.agent/schemas/TASK.schema.md +0 -137
  97. package/templates/awa/.claude/skills/awa-validate-alignment/SKILL.md +0 -3
  98. package/templates/awa/.codex/prompts/awa-validate-alignment.md +0 -3
  99. package/templates/awa/.cursor/rules/awa-validate-alignment.md +0 -8
  100. package/templates/awa/.gemini/commands/awa-validate-alignment.md +0 -3
  101. package/templates/awa/.gemini/skills/awa-validate-alignment/SKILL.md +0 -3
  102. package/templates/awa/.github/prompts/awa.validate-alignment.prompt.md +0 -8
  103. package/templates/awa/.github/skills/awa-validate-alignment/SKILL.md +0 -8
  104. package/templates/awa/.kilocode/skills/awa-validate-alignment/SKILL.md +0 -3
  105. package/templates/awa/.kilocode/workflows/awa-validate-alignment.md +0 -3
  106. package/templates/awa/.opencode/commands/awa-validate-alignment.md +0 -3
  107. package/templates/awa/.opencode/skills/awa-validate-alignment/SKILL.md +0 -3
  108. package/templates/awa/.qwen/commands/awa-validate-alignment.md +0 -3
  109. package/templates/awa/.qwen/skills/awa-validate-alignment/SKILL.md +0 -3
  110. package/templates/awa/.roo/skills/awa-validate-alignment/SKILL.md +0 -3
  111. package/templates/awa/.windsurf/skills/awa-validate-alignment/SKILL.md +0 -3
  112. package/templates/awa/_partials/_skill.awa-validate-alignment.md +0 -6
package/dist/index.js CHANGED
@@ -6,14 +6,1169 @@ import { Command } from "commander";
6
6
  // src/_generated/package_info.ts
7
7
  var PACKAGE_INFO = {
8
8
  "name": "@ncoderz/awa",
9
- "version": "1.0.0",
9
+ "version": "1.1.0",
10
10
  "author": "Richard Sewell <richard.sewell@ncoderz.com>",
11
11
  "license": "MIT",
12
12
  "description": "awa is an Agent Workflow for AIs. It is also a CLI tool to powerfully manage agent workflow files using templates."
13
13
  };
14
14
 
15
- // src/commands/diff.ts
16
- import { intro, outro } from "@clack/prompts";
15
+ // src/core/check/code-spec-checker.ts
16
+ function checkCodeAgainstSpec(markers, specs, config) {
17
+ const findings = [];
18
+ const idRegex = new RegExp(`^${config.idPattern}$`);
19
+ for (const marker of markers.markers) {
20
+ if (marker.type === "component") {
21
+ continue;
22
+ }
23
+ if (!idRegex.test(marker.id)) {
24
+ findings.push({
25
+ severity: "error",
26
+ code: "invalid-id-format",
27
+ message: `Marker ID '${marker.id}' does not match expected pattern: ${config.idPattern}`,
28
+ filePath: marker.filePath,
29
+ line: marker.line,
30
+ id: marker.id
31
+ });
32
+ }
33
+ }
34
+ for (const marker of markers.markers) {
35
+ if (marker.type === "component") {
36
+ if (!specs.componentNames.has(marker.id)) {
37
+ findings.push({
38
+ severity: "error",
39
+ code: "orphaned-marker",
40
+ message: `Component marker '${marker.id}' not found in any spec file`,
41
+ filePath: marker.filePath,
42
+ line: marker.line,
43
+ id: marker.id
44
+ });
45
+ }
46
+ } else {
47
+ if (!specs.allIds.has(marker.id)) {
48
+ findings.push({
49
+ severity: "error",
50
+ code: "orphaned-marker",
51
+ message: `Marker '${marker.id}' not found in any spec file`,
52
+ filePath: marker.filePath,
53
+ line: marker.line,
54
+ id: marker.id
55
+ });
56
+ }
57
+ }
58
+ }
59
+ const testedIds = new Set(markers.markers.filter((m) => m.type === "test").map((m) => m.id));
60
+ for (const acId of specs.acIds) {
61
+ if (!testedIds.has(acId)) {
62
+ const loc = specs.idLocations.get(acId);
63
+ const specFile = loc ? void 0 : specs.specFiles.find((sf) => sf.acIds.includes(acId));
64
+ findings.push({
65
+ severity: "warning",
66
+ code: "uncovered-ac",
67
+ message: `Acceptance criterion '${acId}' has no @awa-test reference`,
68
+ filePath: loc?.filePath ?? specFile?.filePath,
69
+ line: loc?.line,
70
+ id: acId
71
+ });
72
+ }
73
+ }
74
+ return { findings };
75
+ }
76
+
77
+ // src/core/check/marker-scanner.ts
78
+ import { readFile } from "fs/promises";
79
+
80
+ // src/core/check/glob.ts
81
+ import { glob } from "fs/promises";
82
+ function matchSimpleGlob(path, pattern) {
83
+ const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "<<GLOBSTAR>>").replace(/\*/g, "[^/]*").replace(/<<GLOBSTAR>>/g, ".*");
84
+ return new RegExp(`(^|/)${regex}($|/)`).test(path);
85
+ }
86
+ async function collectFiles(globs, ignore) {
87
+ const files = [];
88
+ const dirPrefixes = ignore.filter((ig) => ig.endsWith("/**")).map((ig) => ig.slice(0, -3));
89
+ for (const pattern of globs) {
90
+ for await (const filePath of glob(pattern, {
91
+ exclude: (p) => dirPrefixes.includes(p) || ignore.some((ig) => matchSimpleGlob(p, ig))
92
+ })) {
93
+ if (!ignore.some((ig) => matchSimpleGlob(filePath, ig))) {
94
+ files.push(filePath);
95
+ }
96
+ }
97
+ }
98
+ return [...new Set(files)];
99
+ }
100
+
101
+ // src/core/check/marker-scanner.ts
102
+ var MARKER_TYPE_MAP = {
103
+ "@awa-impl": "impl",
104
+ "@awa-test": "test",
105
+ "@awa-component": "component"
106
+ };
107
+ var IGNORE_FILE_RE = /@awa-ignore-file\b/;
108
+ var IGNORE_NEXT_LINE_RE = /@awa-ignore-next-line\b/;
109
+ var IGNORE_LINE_RE = /@awa-ignore\b/;
110
+ var IGNORE_START_RE = /@awa-ignore-start\b/;
111
+ var IGNORE_END_RE = /@awa-ignore-end\b/;
112
+ async function scanMarkers(config) {
113
+ const files = await collectCodeFiles(config.codeGlobs, config.ignore);
114
+ const markers = [];
115
+ const findings = [];
116
+ for (const filePath of files) {
117
+ if (config.ignoreMarkers.some((ig) => matchSimpleGlob(filePath, ig))) {
118
+ continue;
119
+ }
120
+ const result = await scanFile(filePath, config.markers);
121
+ markers.push(...result.markers);
122
+ findings.push(...result.findings);
123
+ }
124
+ return { markers, findings };
125
+ }
126
+ function buildMarkerRegex(markerNames) {
127
+ const escaped = markerNames.map((m) => m.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
128
+ return new RegExp(`(${escaped.join("|")}):\\s*(.+)`, "g");
129
+ }
130
+ var ID_TOKEN_RE = /^([A-Z][A-Z0-9]*(?:[-_][A-Za-z0-9]+)*(?:\.\d+)?)/;
131
+ async function scanFile(filePath, markerNames) {
132
+ let content;
133
+ try {
134
+ content = await readFile(filePath, "utf-8");
135
+ } catch {
136
+ return { markers: [], findings: [] };
137
+ }
138
+ if (IGNORE_FILE_RE.test(content)) {
139
+ return { markers: [], findings: [] };
140
+ }
141
+ const regex = buildMarkerRegex(markerNames);
142
+ const lines = content.split("\n");
143
+ const markers = [];
144
+ const findings = [];
145
+ let ignoreNextLine = false;
146
+ let ignoreBlock = false;
147
+ for (const [i, line] of lines.entries()) {
148
+ if (IGNORE_START_RE.test(line)) {
149
+ ignoreBlock = true;
150
+ continue;
151
+ }
152
+ if (IGNORE_END_RE.test(line)) {
153
+ ignoreBlock = false;
154
+ continue;
155
+ }
156
+ if (ignoreBlock) {
157
+ continue;
158
+ }
159
+ if (ignoreNextLine) {
160
+ ignoreNextLine = false;
161
+ continue;
162
+ }
163
+ if (IGNORE_NEXT_LINE_RE.test(line)) {
164
+ ignoreNextLine = true;
165
+ continue;
166
+ }
167
+ if (IGNORE_LINE_RE.test(line)) {
168
+ continue;
169
+ }
170
+ regex.lastIndex = 0;
171
+ let match = regex.exec(line);
172
+ while (match !== null) {
173
+ const markerName = match[1] ?? "";
174
+ const idsRaw = match[2] ?? "";
175
+ const type = resolveMarkerType(markerName, markerNames);
176
+ const ids = idsRaw.split(",").map((id) => id.trim()).filter(Boolean);
177
+ for (const id of ids) {
178
+ const tokenMatch = ID_TOKEN_RE.exec(id);
179
+ const cleanId = tokenMatch?.[1]?.trim() ?? "";
180
+ if (cleanId && tokenMatch) {
181
+ const remainder = id.slice(tokenMatch[0].length).trim();
182
+ if (remainder) {
183
+ findings.push({
184
+ severity: "error",
185
+ code: "marker-trailing-text",
186
+ message: `Marker has trailing text after ID '${cleanId}': '${remainder}' \u2014 use comma-separated IDs only`,
187
+ filePath,
188
+ line: i + 1,
189
+ id: cleanId
190
+ });
191
+ }
192
+ markers.push({ type, id: cleanId, filePath, line: i + 1 });
193
+ }
194
+ }
195
+ match = regex.exec(line);
196
+ }
197
+ }
198
+ return { markers, findings };
199
+ }
200
+ function resolveMarkerType(markerName, configuredMarkers) {
201
+ const mapped = MARKER_TYPE_MAP[markerName];
202
+ if (mapped) return mapped;
203
+ const idx = configuredMarkers.indexOf(markerName);
204
+ if (idx === 1) return "test";
205
+ if (idx === 2) return "component";
206
+ return "impl";
207
+ }
208
+ async function collectCodeFiles(codeGlobs, ignore) {
209
+ return collectFiles(codeGlobs, ignore);
210
+ }
211
+
212
+ // src/core/check/reporter.ts
213
+ import chalk from "chalk";
214
+ function report(findings, format) {
215
+ if (format === "json") {
216
+ reportJson(findings);
217
+ } else {
218
+ reportText(findings);
219
+ }
220
+ }
221
+ function reportJson(findings) {
222
+ const errors = findings.filter((f) => f.severity === "error");
223
+ const warnings = findings.filter((f) => f.severity === "warning");
224
+ const output = {
225
+ valid: errors.length === 0,
226
+ errors: errors.length,
227
+ warnings: warnings.length,
228
+ findings: findings.map((f) => ({
229
+ severity: f.severity,
230
+ code: f.code,
231
+ message: f.message,
232
+ ...f.filePath ? { filePath: f.filePath } : {},
233
+ ...f.line ? { line: f.line } : {},
234
+ ...f.id ? { id: f.id } : {},
235
+ ...f.ruleSource ? { ruleSource: f.ruleSource } : {},
236
+ ...f.rule ? { rule: f.rule } : {}
237
+ }))
238
+ };
239
+ console.log(JSON.stringify(output, null, 2));
240
+ }
241
+ function reportText(findings) {
242
+ const errors = findings.filter((f) => f.severity === "error");
243
+ const warnings = findings.filter((f) => f.severity === "warning");
244
+ if (errors.length > 0) {
245
+ console.log(chalk.red(`
246
+ ${errors.length} error(s):
247
+ `));
248
+ for (const f of errors) {
249
+ const location = formatLocation(f.filePath, f.line);
250
+ console.log(chalk.red(" \u2716"), f.message, location ? chalk.dim(location) : "");
251
+ printRuleContext(f);
252
+ }
253
+ }
254
+ if (warnings.length > 0) {
255
+ console.log(chalk.yellow(`
256
+ ${warnings.length} warning(s):
257
+ `));
258
+ for (const f of warnings) {
259
+ const location = formatLocation(f.filePath, f.line);
260
+ console.log(chalk.yellow(" \u26A0"), f.message, location ? chalk.dim(location) : "");
261
+ printRuleContext(f);
262
+ }
263
+ }
264
+ if (errors.length === 0 && warnings.length === 0) {
265
+ console.log(chalk.green("\n\u2714 Validation passed \u2014 no issues found"));
266
+ } else {
267
+ console.log("");
268
+ const parts = [];
269
+ if (errors.length > 0) parts.push(chalk.red(`${errors.length} error(s)`));
270
+ if (warnings.length > 0) parts.push(chalk.yellow(`${warnings.length} warning(s)`));
271
+ console.log(`Summary: ${parts.join(", ")}`);
272
+ }
273
+ }
274
+ function formatLocation(filePath, line) {
275
+ if (!filePath) return "";
276
+ return line ? `(${filePath}:${line})` : `(${filePath})`;
277
+ }
278
+ function printRuleContext(f) {
279
+ if (!f.ruleSource && !f.rule) return;
280
+ const parts = [];
281
+ if (f.ruleSource) parts.push(f.ruleSource);
282
+ if (f.rule) parts.push(f.rule);
283
+ console.log(chalk.dim(` rule: ${parts.join(" \u2014 ")}`));
284
+ }
285
+
286
+ // src/core/check/rule-loader.ts
287
+ import { readFile as readFile2 } from "fs/promises";
288
+ import { join } from "path";
289
+ import { parse as parseYaml } from "yaml";
290
+ async function loadRules(schemaDir) {
291
+ const pattern = join(schemaDir, "*.schema.yaml");
292
+ const files = await collectFiles([pattern], []);
293
+ const results = [];
294
+ for (const filePath of files) {
295
+ const ruleSet = await loadRuleFile(filePath);
296
+ if (ruleSet) {
297
+ results.push(ruleSet);
298
+ }
299
+ }
300
+ return results;
301
+ }
302
+ function matchesTargetGlob(filePath, targetGlob) {
303
+ return matchSimpleGlob(filePath, targetGlob);
304
+ }
305
+ async function loadRuleFile(filePath) {
306
+ let content;
307
+ try {
308
+ content = await readFile2(filePath, "utf-8");
309
+ } catch {
310
+ return null;
311
+ }
312
+ const parsed = parseYaml(content);
313
+ if (!parsed || typeof parsed !== "object") {
314
+ throw new RuleValidationError(`Rule file is not a valid YAML object: ${filePath}`);
315
+ }
316
+ const raw = parsed;
317
+ const ruleFile = validateRuleFile(raw, filePath);
318
+ return {
319
+ ruleFile,
320
+ sourcePath: filePath,
321
+ targetGlob: ruleFile["target-files"]
322
+ };
323
+ }
324
+ function validateRuleFile(raw, filePath) {
325
+ if (typeof raw["target-files"] !== "string" || raw["target-files"].length === 0) {
326
+ throw new RuleValidationError(`Missing or empty 'target-files' in ${filePath}`);
327
+ }
328
+ let sections;
329
+ if (raw.sections !== void 0) {
330
+ if (!Array.isArray(raw.sections) || raw.sections.length === 0) {
331
+ throw new RuleValidationError(
332
+ `'sections' must be a non-empty array if present in ${filePath}`
333
+ );
334
+ }
335
+ sections = raw.sections.map(
336
+ (s, i) => validateSectionRule(s, `sections[${i}]`, filePath)
337
+ );
338
+ }
339
+ let sectionsProhibited;
340
+ if (raw["sections-prohibited"] !== void 0) {
341
+ if (!Array.isArray(raw["sections-prohibited"]) || !raw["sections-prohibited"].every((v) => typeof v === "string")) {
342
+ throw new RuleValidationError(`'sections-prohibited' must be a string array in ${filePath}`);
343
+ }
344
+ sectionsProhibited = raw["sections-prohibited"];
345
+ }
346
+ return {
347
+ "target-files": raw["target-files"],
348
+ ...typeof raw.description === "string" ? { description: raw.description } : {},
349
+ ...typeof raw["line-limit"] === "number" && raw["line-limit"] > 0 ? { "line-limit": raw["line-limit"] } : {},
350
+ sections: sections ?? [],
351
+ ...sectionsProhibited ? { "sections-prohibited": sectionsProhibited } : {},
352
+ ...typeof raw.example === "string" ? { example: raw.example } : {}
353
+ };
354
+ }
355
+ function validateSectionRule(raw, path, filePath) {
356
+ if (!raw || typeof raw !== "object") {
357
+ throw new RuleValidationError(`${path} must be an object in ${filePath}`);
358
+ }
359
+ const section = raw;
360
+ if (typeof section.heading !== "string" || section.heading.length === 0) {
361
+ throw new RuleValidationError(`${path}.heading must be a non-empty string in ${filePath}`);
362
+ }
363
+ if (typeof section.level !== "number" || section.level < 1 || section.level > 6) {
364
+ throw new RuleValidationError(`${path}.level must be 1-6 in ${filePath}`);
365
+ }
366
+ validatePattern(section.heading, `${path}.heading`, filePath);
367
+ let contains;
368
+ if (section.contains !== void 0) {
369
+ if (!Array.isArray(section.contains)) {
370
+ throw new RuleValidationError(`${path}.contains must be an array in ${filePath}`);
371
+ }
372
+ contains = section.contains.map(
373
+ (c, i) => validateContainsRule(c, `${path}.contains[${i}]`, filePath)
374
+ );
375
+ }
376
+ let children;
377
+ if (section.children !== void 0) {
378
+ if (!Array.isArray(section.children)) {
379
+ throw new RuleValidationError(`${path}.children must be an array in ${filePath}`);
380
+ }
381
+ children = section.children.map(
382
+ (c, i) => validateSectionRule(c, `${path}.children[${i}]`, filePath)
383
+ );
384
+ }
385
+ return {
386
+ heading: section.heading,
387
+ level: section.level,
388
+ ...typeof section.required === "boolean" ? { required: section.required } : {},
389
+ ...typeof section.repeatable === "boolean" ? { repeatable: section.repeatable } : {},
390
+ ...typeof section.description === "string" ? { description: section.description } : {},
391
+ ...contains ? { contains } : {},
392
+ ...children ? { children } : {}
393
+ };
394
+ }
395
+ function validateContainsRule(raw, path, filePath) {
396
+ if (!raw || typeof raw !== "object") {
397
+ throw new RuleValidationError(`${path} must be an object in ${filePath}`);
398
+ }
399
+ const rule = raw;
400
+ const when = rule.when !== void 0 ? validateWhenCondition(rule.when, `${path}.when`, filePath) : void 0;
401
+ if (typeof rule.pattern === "string") {
402
+ validatePattern(rule.pattern, `${path}.pattern`, filePath);
403
+ return {
404
+ pattern: rule.pattern,
405
+ ...typeof rule.label === "string" ? { label: rule.label } : {},
406
+ ...typeof rule.description === "string" ? { description: rule.description } : {},
407
+ ...typeof rule.required === "boolean" ? { required: rule.required } : {},
408
+ ...typeof rule.prohibited === "boolean" ? { prohibited: rule.prohibited } : {},
409
+ ...when ? { when } : {}
410
+ };
411
+ }
412
+ if (rule.list && typeof rule.list === "object") {
413
+ const list = rule.list;
414
+ if (typeof list.pattern !== "string") {
415
+ throw new RuleValidationError(`${path}.list.pattern must be a string in ${filePath}`);
416
+ }
417
+ validatePattern(list.pattern, `${path}.list.pattern`, filePath);
418
+ return {
419
+ list: {
420
+ pattern: list.pattern,
421
+ ...typeof list.min === "number" ? { min: list.min } : {},
422
+ ...typeof list.label === "string" ? { label: list.label } : {}
423
+ },
424
+ ...typeof rule.description === "string" ? { description: rule.description } : {},
425
+ ...when ? { when } : {}
426
+ };
427
+ }
428
+ if (rule.table && typeof rule.table === "object") {
429
+ const table = rule.table;
430
+ if (!Array.isArray(table.columns) || !table.columns.every((c) => typeof c === "string")) {
431
+ throw new RuleValidationError(`${path}.table.columns must be a string array in ${filePath}`);
432
+ }
433
+ return {
434
+ table: {
435
+ ...typeof table.heading === "string" ? { heading: table.heading } : {},
436
+ columns: table.columns,
437
+ ...typeof table["min-rows"] === "number" ? { "min-rows": table["min-rows"] } : {}
438
+ },
439
+ ...typeof rule.description === "string" ? { description: rule.description } : {},
440
+ ...when ? { when } : {}
441
+ };
442
+ }
443
+ if (rule["code-block"] === true) {
444
+ return {
445
+ "code-block": true,
446
+ ...typeof rule.label === "string" ? { label: rule.label } : {},
447
+ ...typeof rule.description === "string" ? { description: rule.description } : {},
448
+ ...when ? { when } : {}
449
+ };
450
+ }
451
+ if (typeof rule["heading-or-text"] === "string") {
452
+ return {
453
+ "heading-or-text": rule["heading-or-text"],
454
+ ...typeof rule.required === "boolean" ? { required: rule.required } : {},
455
+ ...typeof rule.description === "string" ? { description: rule.description } : {},
456
+ ...when ? { when } : {}
457
+ };
458
+ }
459
+ throw new RuleValidationError(`${path} has no recognized rule type in ${filePath}`);
460
+ }
461
+ function validateWhenCondition(raw, path, filePath) {
462
+ if (!raw || typeof raw !== "object") {
463
+ throw new RuleValidationError(`${path} must be an object in ${filePath}`);
464
+ }
465
+ const condition = raw;
466
+ const result = {};
467
+ if (typeof condition["heading-matches"] === "string") {
468
+ validatePattern(condition["heading-matches"], `${path}.heading-matches`, filePath);
469
+ result["heading-matches"] = condition["heading-matches"];
470
+ }
471
+ if (typeof condition["heading-not-matches"] === "string") {
472
+ validatePattern(condition["heading-not-matches"], `${path}.heading-not-matches`, filePath);
473
+ result["heading-not-matches"] = condition["heading-not-matches"];
474
+ }
475
+ if (!result["heading-matches"] && !result["heading-not-matches"]) {
476
+ throw new RuleValidationError(
477
+ `${path} must have 'heading-matches' or 'heading-not-matches' in ${filePath}`
478
+ );
479
+ }
480
+ return result;
481
+ }
482
+ function validatePattern(pattern, path, filePath) {
483
+ if (/[.+*?^${}()|[\]\\]/.test(pattern)) {
484
+ try {
485
+ new RegExp(pattern);
486
+ } catch (e) {
487
+ const msg = e instanceof Error ? e.message : String(e);
488
+ throw new RuleValidationError(`Invalid regex in ${path}: ${msg} (${filePath})`);
489
+ }
490
+ }
491
+ }
492
+ var RuleValidationError = class extends Error {
493
+ constructor(message) {
494
+ super(message);
495
+ this.name = "RuleValidationError";
496
+ }
497
+ };
498
+
499
+ // src/core/check/schema-checker.ts
500
+ import { readFile as readFile3 } from "fs/promises";
501
+ import remarkGfm from "remark-gfm";
502
+ import remarkParse from "remark-parse";
503
+ import { unified } from "unified";
504
+ function formatSectionRule(rule) {
505
+ const parts = [`section: heading="${rule.heading}" level=${rule.level}`];
506
+ if (rule.required) parts.push("required");
507
+ if (rule.repeatable) parts.push("repeatable");
508
+ return parts.join(" ");
509
+ }
510
+ function formatContainsRule(rule) {
511
+ if ("pattern" in rule && typeof rule.pattern === "string") {
512
+ const r = rule;
513
+ if (r.prohibited) return `contains: prohibited pattern="${r.pattern}"`;
514
+ return `contains: pattern="${r.pattern}"${r.required === false ? "" : " required"}`;
515
+ }
516
+ if ("list" in rule) {
517
+ const r = rule;
518
+ return `contains: list pattern="${r.list.pattern}"${r.list.min !== void 0 ? ` min=${r.list.min}` : ""}`;
519
+ }
520
+ if ("table" in rule) {
521
+ const r = rule;
522
+ return `contains: table columns=[${r.table.columns.join(", ")}]${r.table["min-rows"] !== void 0 ? ` min-rows=${r.table["min-rows"]}` : ""}`;
523
+ }
524
+ if ("code-block" in rule) {
525
+ return "contains: code-block";
526
+ }
527
+ if ("heading-or-text" in rule) {
528
+ const r = rule;
529
+ return `contains: heading-or-text="${r["heading-or-text"]}"`;
530
+ }
531
+ return "contains: (unknown rule)";
532
+ }
533
+ function formatLineLimitRule(limit) {
534
+ return `line-limit: ${limit}`;
535
+ }
536
+ function formatProhibitedRule(pattern) {
537
+ return `sections-prohibited: "${pattern}"`;
538
+ }
539
+ async function checkSchemasAsync(specFiles, ruleSets) {
540
+ const findings = [];
541
+ const parser = unified().use(remarkParse).use(remarkGfm);
542
+ for (const spec of specFiles) {
543
+ const matchingRules = ruleSets.filter((rs) => matchesTargetGlob(spec.filePath, rs.targetGlob));
544
+ if (matchingRules.length === 0) continue;
545
+ let content;
546
+ try {
547
+ content = await readFile3(spec.filePath, "utf-8");
548
+ } catch {
549
+ continue;
550
+ }
551
+ const tree = parser.parse(content);
552
+ const sectionTree = buildSectionTree(tree);
553
+ const allSections = flattenSections(sectionTree);
554
+ for (const ruleSet of matchingRules) {
555
+ const ruleSource = ruleSet.sourcePath;
556
+ if (ruleSet.ruleFile["line-limit"] !== void 0) {
557
+ const lineCount = content.split("\n").length;
558
+ if (lineCount > ruleSet.ruleFile["line-limit"]) {
559
+ findings.push({
560
+ severity: "warning",
561
+ code: "schema-line-limit",
562
+ message: `File has ${lineCount} lines, exceeds limit of ${ruleSet.ruleFile["line-limit"]}`,
563
+ filePath: spec.filePath,
564
+ ruleSource,
565
+ rule: formatLineLimitRule(ruleSet.ruleFile["line-limit"])
566
+ });
567
+ }
568
+ }
569
+ findings.push(
570
+ ...checkRulesAgainstSections(
571
+ allSections,
572
+ ruleSet.ruleFile.sections,
573
+ spec.filePath,
574
+ ruleSource
575
+ )
576
+ );
577
+ if (ruleSet.ruleFile["sections-prohibited"]) {
578
+ findings.push(
579
+ ...checkProhibited(
580
+ content,
581
+ ruleSet.ruleFile["sections-prohibited"],
582
+ spec.filePath,
583
+ ruleSource
584
+ )
585
+ );
586
+ }
587
+ }
588
+ }
589
+ return { findings };
590
+ }
591
+ function buildSectionTree(tree) {
592
+ return buildSectionsFromNodes(tree.children, 0, 0).sections;
593
+ }
594
+ function buildSectionsFromNodes(nodes, start, parentLevel) {
595
+ const sections = [];
596
+ let i = start;
597
+ while (i < nodes.length) {
598
+ const node = nodes[i];
599
+ if (!node) break;
600
+ if (node.type === "heading") {
601
+ const h = node;
602
+ if (parentLevel > 0 && h.depth <= parentLevel) break;
603
+ const headingText = extractText(h.children);
604
+ const contentNodes = [];
605
+ i++;
606
+ while (i < nodes.length) {
607
+ const next = nodes[i];
608
+ if (!next || next.type === "heading") break;
609
+ contentNodes.push(next);
610
+ i++;
611
+ }
612
+ const childResult = buildSectionsFromNodes(nodes, i, h.depth);
613
+ i = childResult.nextIndex;
614
+ sections.push({
615
+ heading: h,
616
+ headingText,
617
+ level: h.depth,
618
+ children: childResult.sections,
619
+ contentNodes
620
+ });
621
+ } else {
622
+ i++;
623
+ }
624
+ }
625
+ return { sections, nextIndex: i };
626
+ }
627
+ function flattenSections(sections) {
628
+ const result = [];
629
+ for (const s of sections) {
630
+ result.push(s);
631
+ result.push(...flattenSections(s.children));
632
+ }
633
+ return result;
634
+ }
635
+ function extractText(children) {
636
+ return children.map((c) => {
637
+ if ("value" in c) return c.value;
638
+ if ("children" in c) return extractText(c.children);
639
+ return "";
640
+ }).join("");
641
+ }
642
+ function checkRulesAgainstSections(allSections, rules, filePath, ruleSource) {
643
+ const findings = [];
644
+ for (const rule of rules) {
645
+ const matches = findMatchingSections(allSections, rule);
646
+ if (matches.length === 0 && rule.required) {
647
+ findings.push({
648
+ severity: "error",
649
+ code: "schema-missing-section",
650
+ message: `Missing required section: '${rule.heading}' (level ${rule.level})`,
651
+ filePath,
652
+ ruleSource,
653
+ rule: formatSectionRule(rule)
654
+ });
655
+ continue;
656
+ }
657
+ for (const match of matches) {
658
+ if (match.level !== rule.level) {
659
+ findings.push({
660
+ severity: "warning",
661
+ code: "schema-wrong-level",
662
+ message: `Section '${match.headingText}' is level ${match.level}, expected ${rule.level}`,
663
+ filePath,
664
+ line: match.heading.position?.start.line,
665
+ ruleSource,
666
+ rule: formatSectionRule(rule)
667
+ });
668
+ }
669
+ if (rule.contains) {
670
+ for (const cr of rule.contains) {
671
+ findings.push(...checkContainsRule(match, cr, filePath, ruleSource));
672
+ }
673
+ }
674
+ if (rule.children) {
675
+ const childFlat = flattenSections(match.children);
676
+ findings.push(...checkRulesAgainstSections(childFlat, rule.children, filePath, ruleSource));
677
+ }
678
+ }
679
+ }
680
+ return findings;
681
+ }
682
+ function findMatchingSections(allSections, rule) {
683
+ const regex = createHeadingRegex(rule.heading);
684
+ const matches = allSections.filter((s) => s.level === rule.level && regex.test(s.headingText));
685
+ if (!rule.repeatable && matches.length > 1) {
686
+ return [matches[0]];
687
+ }
688
+ return matches;
689
+ }
690
+ function createHeadingRegex(pattern) {
691
+ if (/[.+*?^${}()|[\]\\]/.test(pattern)) {
692
+ try {
693
+ return new RegExp(`^${pattern}$`);
694
+ } catch {
695
+ return new RegExp(`^${escapeRegex(pattern)}$`);
696
+ }
697
+ }
698
+ return new RegExp(`^${escapeRegex(pattern)}$`, "i");
699
+ }
700
+ function escapeRegex(str) {
701
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
702
+ }
703
+ function checkContainsRule(section, rule, filePath, ruleSource) {
704
+ const when = "when" in rule ? rule.when : void 0;
705
+ if (when && !evaluateWhenCondition(when, section.headingText)) {
706
+ return [];
707
+ }
708
+ const formattedRule = formatContainsRule(rule);
709
+ if ("pattern" in rule && typeof rule.pattern === "string") {
710
+ return checkPatternContains(
711
+ section,
712
+ rule,
713
+ filePath,
714
+ ruleSource,
715
+ formattedRule
716
+ );
717
+ }
718
+ if ("list" in rule) {
719
+ return checkListContains(
720
+ section,
721
+ rule,
722
+ filePath,
723
+ ruleSource,
724
+ formattedRule
725
+ );
726
+ }
727
+ if ("table" in rule) {
728
+ return checkTableContains(
729
+ section,
730
+ rule,
731
+ filePath,
732
+ ruleSource,
733
+ formattedRule
734
+ );
735
+ }
736
+ if ("code-block" in rule) {
737
+ return checkCodeBlockContains(
738
+ section,
739
+ rule,
740
+ filePath,
741
+ ruleSource,
742
+ formattedRule
743
+ );
744
+ }
745
+ if ("heading-or-text" in rule) {
746
+ return checkHeadingOrText(
747
+ section,
748
+ rule,
749
+ filePath,
750
+ ruleSource,
751
+ formattedRule
752
+ );
753
+ }
754
+ return [];
755
+ }
756
+ function checkPatternContains(section, rule, filePath, ruleSource, formattedRule) {
757
+ const text = getFullSectionText(section);
758
+ const found = new RegExp(rule.pattern, "m").test(text);
759
+ if (rule.prohibited) {
760
+ if (found) {
761
+ return [
762
+ {
763
+ severity: "warning",
764
+ code: "schema-prohibited",
765
+ message: `Section '${section.headingText}' contains prohibited content: ${rule.label ?? rule.pattern}`,
766
+ filePath,
767
+ line: section.heading.position?.start.line,
768
+ ruleSource,
769
+ rule: formattedRule
770
+ }
771
+ ];
772
+ }
773
+ return [];
774
+ }
775
+ if (found) return [];
776
+ if (rule.required !== false) {
777
+ return [
778
+ {
779
+ severity: "error",
780
+ code: "schema-missing-content",
781
+ message: `Section '${section.headingText}' missing required content: ${rule.label ?? rule.pattern}`,
782
+ filePath,
783
+ line: section.heading.position?.start.line,
784
+ ruleSource,
785
+ rule: formattedRule
786
+ }
787
+ ];
788
+ }
789
+ return [];
790
+ }
791
+ function checkListContains(section, rule, filePath, ruleSource, formattedRule) {
792
+ const items = collectAllListItems(section);
793
+ const regex = new RegExp(rule.list.pattern);
794
+ const count = items.filter((item) => regex.test(item)).length;
795
+ if (rule.list.min !== void 0 && count < rule.list.min) {
796
+ return [
797
+ {
798
+ severity: "error",
799
+ code: "schema-missing-content",
800
+ message: `Section '${section.headingText}' has ${count} matching ${rule.list.label ?? "list items"}, expected at least ${rule.list.min}`,
801
+ filePath,
802
+ line: section.heading.position?.start.line,
803
+ ruleSource,
804
+ rule: formattedRule
805
+ }
806
+ ];
807
+ }
808
+ return [];
809
+ }
810
+ function checkTableContains(section, rule, filePath, ruleSource, formattedRule) {
811
+ const tables = collectAllTables(section);
812
+ if (tables.length === 0) {
813
+ return [
814
+ {
815
+ severity: "error",
816
+ code: "schema-missing-content",
817
+ message: `Section '${section.headingText}' missing required table${rule.table.heading ? ` (${rule.table.heading})` : ""}`,
818
+ filePath,
819
+ line: section.heading.position?.start.line,
820
+ ruleSource,
821
+ rule: formattedRule
822
+ }
823
+ ];
824
+ }
825
+ let matched;
826
+ let firstMismatch;
827
+ for (const table of tables) {
828
+ const headerRow = table.children[0];
829
+ if (!headerRow) continue;
830
+ const headers = headerRow.children.map(
831
+ (cell) => extractText(cell.children).trim()
832
+ );
833
+ if (rule.table.columns.every((col) => headers.includes(col))) {
834
+ matched = table;
835
+ break;
836
+ }
837
+ if (!firstMismatch) {
838
+ firstMismatch = { table, headers };
839
+ }
840
+ }
841
+ if (!matched) {
842
+ const mm = firstMismatch;
843
+ return [
844
+ {
845
+ severity: "error",
846
+ code: "schema-table-columns",
847
+ message: `No table in '${section.headingText}' has columns [${rule.table.columns.join(", ")}]${mm ? `, found [${mm.headers.join(", ")}]` : ""}`,
848
+ filePath,
849
+ line: mm?.table.position?.start.line ?? section.heading.position?.start.line,
850
+ ruleSource,
851
+ rule: formattedRule
852
+ }
853
+ ];
854
+ }
855
+ const findings = [];
856
+ const dataRows = matched.children.length - 1;
857
+ if (rule.table["min-rows"] !== void 0 && dataRows < rule.table["min-rows"]) {
858
+ findings.push({
859
+ severity: "error",
860
+ code: "schema-missing-content",
861
+ message: `Table in '${section.headingText}' has ${dataRows} data rows, expected at least ${rule.table["min-rows"]}`,
862
+ filePath,
863
+ line: matched.position?.start.line,
864
+ ruleSource,
865
+ rule: formattedRule
866
+ });
867
+ }
868
+ return findings;
869
+ }
870
+ function checkCodeBlockContains(section, rule, filePath, ruleSource, formattedRule) {
871
+ if (collectAllCodeBlocks(section).length > 0) return [];
872
+ return [
873
+ {
874
+ severity: "error",
875
+ code: "schema-missing-content",
876
+ message: `Section '${section.headingText}' missing required ${rule.label ?? "code block"}`,
877
+ filePath,
878
+ line: section.heading.position?.start.line,
879
+ ruleSource,
880
+ rule: formattedRule
881
+ }
882
+ ];
883
+ }
884
+ function checkHeadingOrText(section, rule, filePath, ruleSource, formattedRule) {
885
+ const needle = rule["heading-or-text"].toUpperCase();
886
+ if (section.children.some((c) => c.headingText.toUpperCase().includes(needle))) return [];
887
+ if (getFullSectionText(section).toUpperCase().includes(needle)) return [];
888
+ if (rule.required !== false) {
889
+ return [
890
+ {
891
+ severity: "error",
892
+ code: "schema-missing-content",
893
+ message: `Section '${section.headingText}' missing required heading or text: '${rule["heading-or-text"]}'`,
894
+ filePath,
895
+ line: section.heading.position?.start.line,
896
+ ruleSource,
897
+ rule: formattedRule
898
+ }
899
+ ];
900
+ }
901
+ return [];
902
+ }
903
+ function evaluateWhenCondition(when, headingText) {
904
+ if (when["heading-matches"]) {
905
+ if (!new RegExp(when["heading-matches"]).test(headingText)) return false;
906
+ }
907
+ if (when["heading-not-matches"]) {
908
+ if (new RegExp(when["heading-not-matches"]).test(headingText)) return false;
909
+ }
910
+ return true;
911
+ }
912
+ function checkProhibited(content, prohibited, filePath, ruleSource) {
913
+ const findings = [];
914
+ const lines = content.split("\n");
915
+ for (const pattern of prohibited) {
916
+ const regex = new RegExp(escapeRegex(pattern));
917
+ let inCodeBlock = false;
918
+ for (const [i, line] of lines.entries()) {
919
+ if (line.startsWith("```")) {
920
+ inCodeBlock = !inCodeBlock;
921
+ continue;
922
+ }
923
+ if (inCodeBlock) continue;
924
+ if (regex.test(line)) {
925
+ findings.push({
926
+ severity: "warning",
927
+ code: "schema-prohibited",
928
+ message: `Prohibited formatting '${pattern}' found`,
929
+ filePath,
930
+ line: i + 1,
931
+ ruleSource,
932
+ rule: formatProhibitedRule(pattern)
933
+ });
934
+ break;
935
+ }
936
+ }
937
+ }
938
+ return findings;
939
+ }
940
+ function getFullSectionText(section) {
941
+ let text = section.contentNodes.map(nodeToText).join("\n");
942
+ for (const child of section.children) {
943
+ text += `
944
+ ${child.headingText}
945
+ ${getFullSectionText(child)}`;
946
+ }
947
+ return text;
948
+ }
949
+ function nodeToText(node) {
950
+ if ("value" in node) return node.value;
951
+ if ("children" in node) {
952
+ return node.children.map((c) => nodeToText(c)).join("");
953
+ }
954
+ return "";
955
+ }
956
+ function extractListItems(nodes) {
957
+ const items = [];
958
+ for (const node of nodes) {
959
+ if (node.type === "list") {
960
+ for (const item of node.children) {
961
+ const raw = nodeToText(item);
962
+ const li = item;
963
+ if (li.checked === true) {
964
+ items.push(`[x] ${raw}`);
965
+ } else if (li.checked === false) {
966
+ items.push(`[ ] ${raw}`);
967
+ } else {
968
+ items.push(raw);
969
+ }
970
+ }
971
+ }
972
+ }
973
+ return items;
974
+ }
975
+ function collectAllListItems(section) {
976
+ const items = extractListItems(section.contentNodes);
977
+ for (const child of section.children) items.push(...collectAllListItems(child));
978
+ return items;
979
+ }
980
+ function collectAllTables(section) {
981
+ const tables = section.contentNodes.filter((n) => n.type === "table");
982
+ for (const child of section.children) tables.push(...collectAllTables(child));
983
+ return tables;
984
+ }
985
+ function collectAllCodeBlocks(section) {
986
+ const blocks = section.contentNodes.filter((n) => n.type === "code");
987
+ for (const child of section.children) blocks.push(...collectAllCodeBlocks(child));
988
+ return blocks;
989
+ }
990
+
991
+ // src/core/check/spec-parser.ts
992
+ import { readFile as readFile4 } from "fs/promises";
993
+ import { basename } from "path";
994
+ async function parseSpecs(config) {
995
+ const files = await collectSpecFiles(config.specGlobs, config.ignore);
996
+ const specFiles = [];
997
+ const requirementIds = /* @__PURE__ */ new Set();
998
+ const acIds = /* @__PURE__ */ new Set();
999
+ const propertyIds = /* @__PURE__ */ new Set();
1000
+ const componentNames = /* @__PURE__ */ new Set();
1001
+ const idLocations = /* @__PURE__ */ new Map();
1002
+ for (const filePath of files) {
1003
+ const specFile = await parseSpecFile(filePath, config.crossRefPatterns);
1004
+ if (specFile) {
1005
+ specFiles.push(specFile);
1006
+ for (const id of specFile.requirementIds) requirementIds.add(id);
1007
+ for (const id of specFile.acIds) acIds.add(id);
1008
+ for (const id of specFile.propertyIds) propertyIds.add(id);
1009
+ for (const name of specFile.componentNames) componentNames.add(name);
1010
+ for (const [id, loc] of specFile.idLocations ?? []) {
1011
+ idLocations.set(id, loc);
1012
+ }
1013
+ }
1014
+ }
1015
+ const allIds = /* @__PURE__ */ new Set([...requirementIds, ...acIds, ...propertyIds, ...componentNames]);
1016
+ return { requirementIds, acIds, propertyIds, componentNames, allIds, specFiles, idLocations };
1017
+ }
1018
+ async function parseSpecFile(filePath, crossRefPatterns) {
1019
+ let content;
1020
+ try {
1021
+ content = await readFile4(filePath, "utf-8");
1022
+ } catch {
1023
+ return null;
1024
+ }
1025
+ const code = extractCodePrefix(filePath);
1026
+ const lines = content.split("\n");
1027
+ const requirementIds = [];
1028
+ const acIds = [];
1029
+ const propertyIds = [];
1030
+ const componentNames = [];
1031
+ const crossRefs = [];
1032
+ const idLocations = /* @__PURE__ */ new Map();
1033
+ const reqIdRegex = /^###\s+([A-Z][A-Z0-9]*-\d+(?:\.\d+)?)\s*:/;
1034
+ const acIdRegex = /^-\s+\[[ x]\]\s+([A-Z][A-Z0-9]*-\d+(?:\.\d+)?_AC-\d+)\s/;
1035
+ const propIdRegex = /^-\s+([A-Z][A-Z0-9]*_P-\d+)\s/;
1036
+ const componentRegex = /^###\s+([A-Z][A-Z0-9]*-[A-Za-z][A-Za-z0-9]*(?:[A-Z][a-z0-9]*)*)\s*$/;
1037
+ for (const [i, line] of lines.entries()) {
1038
+ const lineNum = i + 1;
1039
+ const reqMatch = reqIdRegex.exec(line);
1040
+ if (reqMatch?.[1]) {
1041
+ requirementIds.push(reqMatch[1]);
1042
+ idLocations.set(reqMatch[1], { filePath, line: lineNum });
1043
+ }
1044
+ const acMatch = acIdRegex.exec(line);
1045
+ if (acMatch?.[1]) {
1046
+ acIds.push(acMatch[1]);
1047
+ idLocations.set(acMatch[1], { filePath, line: lineNum });
1048
+ }
1049
+ const propMatch = propIdRegex.exec(line);
1050
+ if (propMatch?.[1]) {
1051
+ propertyIds.push(propMatch[1]);
1052
+ idLocations.set(propMatch[1], { filePath, line: lineNum });
1053
+ }
1054
+ const compMatch = componentRegex.exec(line);
1055
+ if (compMatch?.[1]) {
1056
+ if (!reqIdRegex.test(line)) {
1057
+ componentNames.push(compMatch[1]);
1058
+ idLocations.set(compMatch[1], { filePath, line: lineNum });
1059
+ }
1060
+ }
1061
+ for (const pattern of crossRefPatterns) {
1062
+ const patIdx = line.indexOf(pattern);
1063
+ if (patIdx !== -1) {
1064
+ const afterPattern = line.slice(patIdx + pattern.length);
1065
+ const ids = extractIdsFromText(afterPattern);
1066
+ if (ids.length > 0) {
1067
+ const type = pattern.toLowerCase().includes("implements") ? "implements" : "validates";
1068
+ crossRefs.push({ type, ids, filePath, line: i + 1 });
1069
+ }
1070
+ }
1071
+ }
1072
+ }
1073
+ return {
1074
+ filePath,
1075
+ code,
1076
+ requirementIds,
1077
+ acIds,
1078
+ propertyIds,
1079
+ componentNames,
1080
+ crossRefs,
1081
+ idLocations
1082
+ };
1083
+ }
1084
+ function extractCodePrefix(filePath) {
1085
+ const name = basename(filePath, ".md");
1086
+ const match = /^(?:REQ|DESIGN|FEAT|EXAMPLES|API)-([A-Z][A-Z0-9]*)-/.exec(name);
1087
+ if (match?.[1]) return match[1];
1088
+ return "";
1089
+ }
1090
+ function extractIdsFromText(text) {
1091
+ const idRegex = /[A-Z][A-Z0-9]*-\d+(?:\.\d+)?(?:_AC-\d+)?|[A-Z][A-Z0-9]*_P-\d+/g;
1092
+ const ids = [];
1093
+ let match = idRegex.exec(text);
1094
+ while (match !== null) {
1095
+ ids.push(match[0]);
1096
+ match = idRegex.exec(text);
1097
+ }
1098
+ return ids;
1099
+ }
1100
+ async function collectSpecFiles(specGlobs, ignore) {
1101
+ return collectFiles(specGlobs, ignore);
1102
+ }
1103
+
1104
+ // src/core/check/spec-spec-checker.ts
1105
+ function checkSpecAgainstSpec(specs, markers, config) {
1106
+ const findings = [];
1107
+ for (const specFile of specs.specFiles) {
1108
+ for (const crossRef of specFile.crossRefs) {
1109
+ for (const refId of crossRef.ids) {
1110
+ if (!specs.allIds.has(refId)) {
1111
+ findings.push({
1112
+ severity: "error",
1113
+ code: "broken-cross-ref",
1114
+ message: `Cross-reference '${refId}' (${crossRef.type}) not found in any spec file`,
1115
+ filePath: crossRef.filePath,
1116
+ line: crossRef.line,
1117
+ id: refId
1118
+ });
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ if (!config.specOnly) {
1124
+ const referencedCodes = /* @__PURE__ */ new Set();
1125
+ for (const marker of markers.markers) {
1126
+ const codeMatch = /^([A-Z][A-Z0-9]*)[-_]/.exec(marker.id);
1127
+ if (codeMatch?.[1]) {
1128
+ referencedCodes.add(codeMatch[1]);
1129
+ }
1130
+ }
1131
+ for (const specFile of specs.specFiles) {
1132
+ for (const crossRef of specFile.crossRefs) {
1133
+ for (const refId of crossRef.ids) {
1134
+ const codeMatch = /^([A-Z][A-Z0-9]*)[-_]/.exec(refId);
1135
+ if (codeMatch?.[1]) {
1136
+ referencedCodes.add(codeMatch[1]);
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1141
+ for (const specFile of specs.specFiles) {
1142
+ if (!specFile.code) continue;
1143
+ if (!referencedCodes.has(specFile.code)) {
1144
+ findings.push({
1145
+ severity: "warning",
1146
+ code: "orphaned-spec",
1147
+ message: `Spec file code '${specFile.code}' is not referenced by any other spec or code marker`,
1148
+ filePath: specFile.filePath,
1149
+ id: specFile.code
1150
+ });
1151
+ }
1152
+ }
1153
+ }
1154
+ return { findings };
1155
+ }
1156
+
1157
+ // src/core/check/types.ts
1158
+ var DEFAULT_CHECK_CONFIG = {
1159
+ specGlobs: [".awa/specs/**/*.md"],
1160
+ codeGlobs: ["src/**/*.{ts,js,tsx,jsx,py,go,rs,java,cs}"],
1161
+ ignore: ["node_modules/**", "dist/**"],
1162
+ ignoreMarkers: [],
1163
+ markers: ["@awa-impl", "@awa-test", "@awa-component"],
1164
+ idPattern: "([A-Z][A-Z0-9]*-\\d+(?:\\.\\d+)?(?:_AC-\\d+)?|[A-Z][A-Z0-9]*_P-\\d+)",
1165
+ crossRefPatterns: ["IMPLEMENTS:", "VALIDATES:"],
1166
+ format: "text",
1167
+ schemaDir: ".awa/.agent/schemas",
1168
+ schemaEnabled: true,
1169
+ allowWarnings: false,
1170
+ specOnly: false
1171
+ };
17
1172
 
18
1173
  // src/core/config.ts
19
1174
  import { parse } from "smol-toml";
@@ -50,9 +1205,9 @@ var GenerationError = class extends Error {
50
1205
  };
51
1206
 
52
1207
  // src/utils/fs.ts
53
- import { mkdir, readdir, readFile, rm, stat, writeFile } from "fs/promises";
1208
+ import { mkdir, readdir, readFile as readFile5, rm, stat, writeFile } from "fs/promises";
54
1209
  import { homedir } from "os";
55
- import { dirname, join } from "path";
1210
+ import { dirname, join as join2 } from "path";
56
1211
  import { fileURLToPath } from "url";
57
1212
  async function ensureDir(dirPath) {
58
1213
  await mkdir(dirPath, { recursive: true });
@@ -66,10 +1221,10 @@ async function pathExists(path) {
66
1221
  }
67
1222
  }
68
1223
  async function readTextFile(path) {
69
- return readFile(path, "utf-8");
1224
+ return readFile5(path, "utf-8");
70
1225
  }
71
1226
  async function readBinaryFile(path) {
72
- return readFile(path);
1227
+ return readFile5(path);
73
1228
  }
74
1229
  async function writeTextFile(path, content) {
75
1230
  await ensureDir(dirname(path));
@@ -78,7 +1233,7 @@ async function writeTextFile(path, content) {
78
1233
  async function* walkDirectory(dir) {
79
1234
  const entries = await readdir(dir, { withFileTypes: true });
80
1235
  for (const entry of entries) {
81
- const fullPath = join(dir, entry.name);
1236
+ const fullPath = join2(dir, entry.name);
82
1237
  if (entry.isDirectory()) {
83
1238
  if (entry.name.startsWith("_")) {
84
1239
  continue;
@@ -93,15 +1248,15 @@ async function* walkDirectory(dir) {
93
1248
  }
94
1249
  }
95
1250
  function getCacheDir() {
96
- return join(homedir(), ".cache", "awa", "templates");
1251
+ return join2(homedir(), ".cache", "awa", "templates");
97
1252
  }
98
1253
  function getTemplateDir() {
99
1254
  const currentFile = fileURLToPath(import.meta.url);
100
1255
  const currentDir = dirname(currentFile);
101
1256
  if (currentDir.includes("/dist")) {
102
- return join(dirname(currentDir), "templates");
1257
+ return join2(dirname(currentDir), "templates");
103
1258
  }
104
- return join(currentDir, "..", "..", "templates");
1259
+ return join2(currentDir, "..", "..", "templates");
105
1260
  }
106
1261
  async function rmDir(dirPath) {
107
1262
  await rm(dirPath, { recursive: true, force: true });
@@ -111,67 +1266,67 @@ async function deleteFile(filePath) {
111
1266
  }
112
1267
 
113
1268
  // src/utils/logger.ts
114
- import chalk from "chalk";
1269
+ import chalk2 from "chalk";
115
1270
  var Logger = class {
116
1271
  info(message) {
117
- console.log(chalk.blue("\u2139"), message);
1272
+ console.log(chalk2.blue("\u2139"), message);
118
1273
  }
119
1274
  success(message) {
120
- console.log(chalk.green("\u2714"), message);
1275
+ console.log(chalk2.green("\u2714"), message);
121
1276
  }
122
1277
  warn(message) {
123
- console.warn(chalk.yellow("\u26A0"), message);
1278
+ console.warn(chalk2.yellow("\u26A0"), message);
124
1279
  }
125
1280
  error(message) {
126
- console.error(chalk.red("\u2716"), message);
1281
+ console.error(chalk2.red("\u2716"), message);
127
1282
  }
128
1283
  fileAction(action) {
129
1284
  const { type, outputPath } = action;
130
1285
  switch (type) {
131
1286
  case "create":
132
- console.log(chalk.green(" + "), chalk.dim(outputPath));
1287
+ console.log(chalk2.green(" + "), chalk2.dim(outputPath));
133
1288
  break;
134
1289
  case "overwrite":
135
- console.log(chalk.yellow(" ~ "), chalk.dim(outputPath));
1290
+ console.log(chalk2.yellow(" ~ "), chalk2.dim(outputPath));
136
1291
  break;
137
1292
  case "skip-user":
138
- console.log(chalk.blue(" - "), chalk.dim(outputPath), chalk.dim("(skipped)"));
1293
+ console.log(chalk2.blue(" - "), chalk2.dim(outputPath), chalk2.dim("(skipped)"));
139
1294
  break;
140
1295
  case "skip-empty":
141
- console.log(chalk.dim(" \xB7 "), chalk.dim(outputPath), chalk.dim("(empty)"));
1296
+ console.log(chalk2.dim(" \xB7 "), chalk2.dim(outputPath), chalk2.dim("(empty)"));
142
1297
  break;
143
1298
  case "skip-equal":
144
- console.log(chalk.dim(" = "), chalk.dim(outputPath), chalk.dim("(unchanged)"));
1299
+ console.log(chalk2.dim(" = "), chalk2.dim(outputPath), chalk2.dim("(unchanged)"));
145
1300
  break;
146
1301
  case "delete":
147
- console.log(chalk.red(" \u2716 "), chalk.dim(outputPath), chalk.red("(deleted)"));
1302
+ console.log(chalk2.red(" \u2716 "), chalk2.dim(outputPath), chalk2.red("(deleted)"));
148
1303
  break;
149
1304
  }
150
1305
  }
151
1306
  // @awa-impl: GEN-9_AC-1, GEN-9_AC-2, GEN-9_AC-3, GEN-9_AC-4, GEN-9_AC-5, GEN-9_AC-6
152
1307
  summary(result) {
153
1308
  console.log("");
154
- console.log(chalk.bold("Summary:"));
1309
+ console.log(chalk2.bold("Summary:"));
155
1310
  if (result.created === 0 && result.overwritten === 0 && result.deleted === 0) {
156
- console.log(chalk.yellow(" \u26A0 No files were created, overwritten, or deleted"));
1311
+ console.log(chalk2.yellow(" \u26A0 No files were created, overwritten, or deleted"));
157
1312
  }
158
1313
  if (result.created > 0) {
159
- console.log(chalk.green(` Created: ${result.created}`));
1314
+ console.log(chalk2.green(` Created: ${result.created}`));
160
1315
  }
161
1316
  if (result.overwritten > 0) {
162
- console.log(chalk.yellow(` Overwritten: ${result.overwritten}`));
1317
+ console.log(chalk2.yellow(` Overwritten: ${result.overwritten}`));
163
1318
  }
164
1319
  if (result.deleted > 0) {
165
- console.log(chalk.red(` Deleted: ${result.deleted}`));
1320
+ console.log(chalk2.red(` Deleted: ${result.deleted}`));
166
1321
  }
167
1322
  if (result.skippedEqual > 0) {
168
- console.log(chalk.dim(` Skipped (equal): ${result.skippedEqual}`));
1323
+ console.log(chalk2.dim(` Skipped (equal): ${result.skippedEqual}`));
169
1324
  }
170
1325
  if (result.skippedUser > 0) {
171
- console.log(chalk.blue(` Skipped (user): ${result.skippedUser}`));
1326
+ console.log(chalk2.blue(` Skipped (user): ${result.skippedUser}`));
172
1327
  }
173
1328
  if (result.skippedEmpty > 0) {
174
- console.log(chalk.dim(` Skipped (empty): ${result.skippedEmpty}`));
1329
+ console.log(chalk2.dim(` Skipped (empty): ${result.skippedEmpty}`));
175
1330
  }
176
1331
  console.log("");
177
1332
  }
@@ -179,13 +1334,13 @@ var Logger = class {
179
1334
  diffLine(line, type) {
180
1335
  switch (type) {
181
1336
  case "add":
182
- console.log(chalk.green(line));
1337
+ console.log(chalk2.green(line));
183
1338
  break;
184
1339
  case "remove":
185
- console.log(chalk.red(line));
1340
+ console.log(chalk2.red(line));
186
1341
  break;
187
1342
  case "context":
188
- console.log(chalk.dim(line));
1343
+ console.log(chalk2.dim(line));
189
1344
  break;
190
1345
  }
191
1346
  }
@@ -194,26 +1349,26 @@ var Logger = class {
194
1349
  console.log("");
195
1350
  const filesCompared = result.identical + result.modified + result.newFiles + result.extraFiles + result.binaryDiffers + result.deleteListed;
196
1351
  const differences = result.modified + result.newFiles + result.extraFiles + result.binaryDiffers + result.deleteListed;
197
- console.log(chalk.bold(`${filesCompared} files compared, ${differences} differences`));
1352
+ console.log(chalk2.bold(`${filesCompared} files compared, ${differences} differences`));
198
1353
  if (!result.hasDifferences) {
199
- console.log(chalk.green("\u2714 No differences found"));
1354
+ console.log(chalk2.green("\u2714 No differences found"));
200
1355
  }
201
- console.log(chalk.bold("Summary:"));
202
- console.log(chalk.dim(` Identical: ${result.identical}`));
1356
+ console.log(chalk2.bold("Summary:"));
1357
+ console.log(chalk2.dim(` Identical: ${result.identical}`));
203
1358
  if (result.modified > 0) {
204
- console.log(chalk.yellow(` Modified: ${result.modified}`));
1359
+ console.log(chalk2.yellow(` Modified: ${result.modified}`));
205
1360
  }
206
1361
  if (result.newFiles > 0) {
207
- console.log(chalk.green(` New: ${result.newFiles}`));
1362
+ console.log(chalk2.green(` New: ${result.newFiles}`));
208
1363
  }
209
1364
  if (result.extraFiles > 0) {
210
- console.log(chalk.red(` Extra: ${result.extraFiles}`));
1365
+ console.log(chalk2.red(` Extra: ${result.extraFiles}`));
211
1366
  }
212
1367
  if (result.binaryDiffers > 0) {
213
- console.log(chalk.red(` Binary differs: ${result.binaryDiffers}`));
1368
+ console.log(chalk2.red(` Binary differs: ${result.binaryDiffers}`));
214
1369
  }
215
1370
  if (result.deleteListed > 0) {
216
- console.log(chalk.red(` Delete listed: ${result.deleteListed}`));
1371
+ console.log(chalk2.red(` Delete listed: ${result.deleteListed}`));
217
1372
  }
218
1373
  console.log("");
219
1374
  }
@@ -364,6 +1519,53 @@ var ConfigLoader = class {
364
1519
  }
365
1520
  config["list-unknown"] = parsed["list-unknown"];
366
1521
  }
1522
+ if (parsed.check !== void 0) {
1523
+ if (parsed.check === null || typeof parsed.check !== "object" || Array.isArray(parsed.check)) {
1524
+ throw new ConfigError(
1525
+ `Invalid type for 'check': expected table`,
1526
+ "INVALID_TYPE",
1527
+ pathToLoad
1528
+ );
1529
+ }
1530
+ config.check = parsed.check;
1531
+ }
1532
+ if (parsed.overlay !== void 0) {
1533
+ if (!Array.isArray(parsed.overlay) || !parsed.overlay.every((o) => typeof o === "string")) {
1534
+ throw new ConfigError(
1535
+ `Invalid type for 'overlay': expected array of strings`,
1536
+ "INVALID_TYPE",
1537
+ pathToLoad
1538
+ );
1539
+ }
1540
+ config.overlay = parsed.overlay;
1541
+ }
1542
+ if (parsed.targets !== void 0) {
1543
+ if (parsed.targets === null || typeof parsed.targets !== "object" || Array.isArray(parsed.targets)) {
1544
+ throw new ConfigError(
1545
+ `Invalid type for 'targets': expected table of target sections`,
1546
+ "INVALID_TYPE",
1547
+ pathToLoad
1548
+ );
1549
+ }
1550
+ const targets = {};
1551
+ for (const [targetName, targetValue] of Object.entries(
1552
+ parsed.targets
1553
+ )) {
1554
+ if (targetValue === null || typeof targetValue !== "object" || Array.isArray(targetValue)) {
1555
+ throw new ConfigError(
1556
+ `Invalid target '${targetName}': expected table`,
1557
+ "INVALID_TYPE",
1558
+ pathToLoad
1559
+ );
1560
+ }
1561
+ targets[targetName] = this.parseTargetSection(
1562
+ targetValue,
1563
+ targetName,
1564
+ pathToLoad
1565
+ );
1566
+ }
1567
+ config.targets = targets;
1568
+ }
367
1569
  const knownKeys = /* @__PURE__ */ new Set([
368
1570
  "output",
369
1571
  "template",
@@ -375,7 +1577,10 @@ var ConfigLoader = class {
375
1577
  "dry-run",
376
1578
  "delete",
377
1579
  "refresh",
378
- "list-unknown"
1580
+ "list-unknown",
1581
+ "check",
1582
+ "targets",
1583
+ "overlay"
379
1584
  ]);
380
1585
  for (const key of Object.keys(parsed)) {
381
1586
  if (!knownKeys.has(key)) {
@@ -415,6 +1620,9 @@ var ConfigLoader = class {
415
1620
  const enableDelete = cli.delete ?? file?.delete ?? false;
416
1621
  const refresh = cli.refresh ?? file?.refresh ?? false;
417
1622
  const listUnknown = cli.listUnknown ?? file?.["list-unknown"] ?? false;
1623
+ const overlay = cli.overlay ?? file?.overlay ?? [];
1624
+ const json = cli.json ?? false;
1625
+ const summary = cli.summary ?? false;
418
1626
  return {
419
1627
  output,
420
1628
  template,
@@ -426,20 +1634,260 @@ var ConfigLoader = class {
426
1634
  delete: enableDelete,
427
1635
  refresh,
428
1636
  presets,
429
- listUnknown
1637
+ listUnknown,
1638
+ overlay,
1639
+ json,
1640
+ summary
430
1641
  };
431
1642
  }
1643
+ // Parse a [targets.<name>] section, validating allowed keys and types
1644
+ parseTargetSection(section, targetName, configPath) {
1645
+ const target = {};
1646
+ const allowedKeys = /* @__PURE__ */ new Set(["output", "template", "features", "preset", "remove-features"]);
1647
+ for (const key of Object.keys(section)) {
1648
+ if (!allowedKeys.has(key)) {
1649
+ logger.warn(`Unknown option in target '${targetName}': '${key}'`);
1650
+ }
1651
+ }
1652
+ if (section.output !== void 0) {
1653
+ if (typeof section.output !== "string") {
1654
+ throw new ConfigError(
1655
+ `Invalid type for 'targets.${targetName}.output': expected string, got ${typeof section.output}`,
1656
+ "INVALID_TYPE",
1657
+ configPath
1658
+ );
1659
+ }
1660
+ target.output = section.output;
1661
+ }
1662
+ if (section.template !== void 0) {
1663
+ if (typeof section.template !== "string") {
1664
+ throw new ConfigError(
1665
+ `Invalid type for 'targets.${targetName}.template': expected string, got ${typeof section.template}`,
1666
+ "INVALID_TYPE",
1667
+ configPath
1668
+ );
1669
+ }
1670
+ target.template = section.template;
1671
+ }
1672
+ if (section.features !== void 0) {
1673
+ if (!Array.isArray(section.features) || !section.features.every((f) => typeof f === "string")) {
1674
+ throw new ConfigError(
1675
+ `Invalid type for 'targets.${targetName}.features': expected array of strings`,
1676
+ "INVALID_TYPE",
1677
+ configPath
1678
+ );
1679
+ }
1680
+ target.features = section.features;
1681
+ }
1682
+ if (section.preset !== void 0) {
1683
+ if (!Array.isArray(section.preset) || !section.preset.every((p) => typeof p === "string")) {
1684
+ throw new ConfigError(
1685
+ `Invalid type for 'targets.${targetName}.preset': expected array of strings`,
1686
+ "INVALID_TYPE",
1687
+ configPath
1688
+ );
1689
+ }
1690
+ target.preset = section.preset;
1691
+ }
1692
+ if (section["remove-features"] !== void 0) {
1693
+ if (!Array.isArray(section["remove-features"]) || !section["remove-features"].every((f) => typeof f === "string")) {
1694
+ throw new ConfigError(
1695
+ `Invalid type for 'targets.${targetName}.remove-features': expected array of strings`,
1696
+ "INVALID_TYPE",
1697
+ configPath
1698
+ );
1699
+ }
1700
+ target["remove-features"] = section["remove-features"];
1701
+ }
1702
+ return target;
1703
+ }
1704
+ // Resolve a target by merging target config with root config (target overrides root via nullish coalescing)
1705
+ resolveTarget(targetName, fileConfig) {
1706
+ const targets = fileConfig.targets;
1707
+ if (!targets || Object.keys(targets).length === 0) {
1708
+ throw new ConfigError(
1709
+ "No targets defined in configuration. Add [targets.<name>] sections to .awa.toml.",
1710
+ "NO_TARGETS",
1711
+ null
1712
+ );
1713
+ }
1714
+ const target = targets[targetName];
1715
+ if (!target) {
1716
+ throw new ConfigError(
1717
+ `Unknown target: '${targetName}'. Available targets: ${Object.keys(targets).join(", ")}`,
1718
+ "UNKNOWN_TARGET",
1719
+ null
1720
+ );
1721
+ }
1722
+ return {
1723
+ ...fileConfig,
1724
+ output: target.output ?? fileConfig.output,
1725
+ template: target.template ?? fileConfig.template,
1726
+ features: target.features ?? fileConfig.features,
1727
+ preset: target.preset ?? fileConfig.preset,
1728
+ "remove-features": target["remove-features"] ?? fileConfig["remove-features"],
1729
+ targets: void 0
1730
+ // Don't propagate targets into resolved config
1731
+ };
1732
+ }
1733
+ // Get all target names from config
1734
+ getTargetNames(fileConfig) {
1735
+ if (!fileConfig?.targets) {
1736
+ return [];
1737
+ }
1738
+ return Object.keys(fileConfig.targets);
1739
+ }
1740
+ };
1741
+ var configLoader = new ConfigLoader();
1742
+
1743
+ // src/commands/check.ts
1744
+ async function checkCommand(cliOptions) {
1745
+ try {
1746
+ const fileConfig = await configLoader.load(cliOptions.config ?? null);
1747
+ const config = buildCheckConfig(fileConfig, cliOptions);
1748
+ const emptyMarkers = {
1749
+ markers: [],
1750
+ findings: []
1751
+ };
1752
+ const [markers, specs, ruleSets] = await Promise.all([
1753
+ config.specOnly ? Promise.resolve(emptyMarkers) : scanMarkers(config),
1754
+ parseSpecs(config),
1755
+ config.schemaEnabled ? loadRules(config.schemaDir) : Promise.resolve([])
1756
+ ]);
1757
+ const codeSpecResult = config.specOnly ? { findings: [] } : checkCodeAgainstSpec(markers, specs, config);
1758
+ const specSpecResult = checkSpecAgainstSpec(specs, markers, config);
1759
+ const schemaResult = config.schemaEnabled && ruleSets.length > 0 ? await checkSchemasAsync(specs.specFiles, ruleSets) : { findings: [] };
1760
+ const combinedFindings = [
1761
+ ...markers.findings,
1762
+ ...codeSpecResult.findings,
1763
+ ...specSpecResult.findings,
1764
+ ...schemaResult.findings
1765
+ ];
1766
+ const allFindings = config.allowWarnings ? combinedFindings : combinedFindings.map(
1767
+ (f) => f.severity === "warning" ? { ...f, severity: "error" } : f
1768
+ );
1769
+ report(allFindings, config.format);
1770
+ const hasErrors = allFindings.some((f) => f.severity === "error");
1771
+ return hasErrors ? 1 : 0;
1772
+ } catch (error) {
1773
+ if (error instanceof Error) {
1774
+ logger.error(error.message);
1775
+ } else {
1776
+ logger.error(String(error));
1777
+ }
1778
+ return 2;
1779
+ }
1780
+ }
1781
+ function buildCheckConfig(fileConfig, cliOptions) {
1782
+ const section = fileConfig?.check;
1783
+ const specGlobs = toStringArray(section?.["spec-globs"]) ?? [...DEFAULT_CHECK_CONFIG.specGlobs];
1784
+ const codeGlobs = toStringArray(section?.["code-globs"]) ?? [...DEFAULT_CHECK_CONFIG.codeGlobs];
1785
+ const markers = toStringArray(section?.markers) ?? [...DEFAULT_CHECK_CONFIG.markers];
1786
+ const crossRefPatterns = toStringArray(section?.["cross-ref-patterns"]) ?? [
1787
+ ...DEFAULT_CHECK_CONFIG.crossRefPatterns
1788
+ ];
1789
+ const idPattern = typeof section?.["id-pattern"] === "string" ? section["id-pattern"] : DEFAULT_CHECK_CONFIG.idPattern;
1790
+ const configIgnore = toStringArray(section?.ignore) ?? [...DEFAULT_CHECK_CONFIG.ignore];
1791
+ const cliIgnore = cliOptions.ignore ?? [];
1792
+ const ignore = [...configIgnore, ...cliIgnore];
1793
+ const ignoreMarkers = toStringArray(section?.["ignore-markers"]) ?? [
1794
+ ...DEFAULT_CHECK_CONFIG.ignoreMarkers
1795
+ ];
1796
+ const format = cliOptions.format === "json" ? "json" : section?.format === "json" ? "json" : DEFAULT_CHECK_CONFIG.format;
1797
+ const schemaDir = typeof section?.["schema-dir"] === "string" ? section["schema-dir"] : DEFAULT_CHECK_CONFIG.schemaDir;
1798
+ const schemaEnabled = typeof section?.["schema-enabled"] === "boolean" ? section["schema-enabled"] : DEFAULT_CHECK_CONFIG.schemaEnabled;
1799
+ const allowWarnings = cliOptions.allowWarnings === true ? true : typeof section?.["allow-warnings"] === "boolean" ? section["allow-warnings"] : DEFAULT_CHECK_CONFIG.allowWarnings;
1800
+ const specOnly = cliOptions.specOnly === true ? true : typeof section?.["spec-only"] === "boolean" ? section["spec-only"] : DEFAULT_CHECK_CONFIG.specOnly;
1801
+ return {
1802
+ specGlobs,
1803
+ codeGlobs,
1804
+ ignore,
1805
+ ignoreMarkers,
1806
+ markers,
1807
+ idPattern,
1808
+ crossRefPatterns,
1809
+ format,
1810
+ schemaDir,
1811
+ schemaEnabled,
1812
+ allowWarnings,
1813
+ specOnly
1814
+ };
1815
+ }
1816
+ function toStringArray(value) {
1817
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
1818
+ return value;
1819
+ }
1820
+ return null;
1821
+ }
1822
+
1823
+ // src/commands/diff.ts
1824
+ import { intro, outro } from "@clack/prompts";
1825
+
1826
+ // src/core/batch-runner.ts
1827
+ var BatchRunner = class {
1828
+ // Resolve all targets or a single named target from config
1829
+ resolveTargets(cli, fileConfig, mode, targetName) {
1830
+ if (!fileConfig) {
1831
+ throw new ConfigError(
1832
+ "No configuration file found. --all and --target require a config file with [targets.*] sections.",
1833
+ "NO_TARGETS",
1834
+ null
1835
+ );
1836
+ }
1837
+ const targetNames = configLoader.getTargetNames(fileConfig);
1838
+ if (targetNames.length === 0) {
1839
+ throw new ConfigError(
1840
+ "No targets defined in configuration. Add [targets.<name>] sections to .awa.toml.",
1841
+ "NO_TARGETS",
1842
+ null
1843
+ );
1844
+ }
1845
+ const namesToProcess = mode === "all" ? targetNames : [targetName];
1846
+ const results = [];
1847
+ for (const name of namesToProcess) {
1848
+ const resolved = configLoader.resolveTarget(name, fileConfig);
1849
+ const targetCli = {
1850
+ ...cli,
1851
+ output: mode === "all" ? void 0 : cli.output
1852
+ };
1853
+ let options;
1854
+ try {
1855
+ options = configLoader.merge(targetCli, resolved);
1856
+ } catch (error) {
1857
+ if (error instanceof ConfigError && error.code === "MISSING_OUTPUT") {
1858
+ throw new ConfigError(
1859
+ `Target '${name}' has no output directory. Specify 'output' in [targets.${name}] or in the root config.`,
1860
+ "MISSING_OUTPUT",
1861
+ null
1862
+ );
1863
+ }
1864
+ throw error;
1865
+ }
1866
+ results.push({ targetName: name, options });
1867
+ }
1868
+ return results;
1869
+ }
1870
+ // Log a message prefixed with target name
1871
+ logForTarget(targetName, message) {
1872
+ logger.info(`[${targetName}] ${message}`);
1873
+ }
1874
+ warnForTarget(targetName, message) {
1875
+ logger.warn(`[${targetName}] ${message}`);
1876
+ }
1877
+ errorForTarget(targetName, message) {
1878
+ logger.error(`[${targetName}] ${message}`);
1879
+ }
432
1880
  };
433
- var configLoader = new ConfigLoader();
1881
+ var batchRunner = new BatchRunner();
434
1882
 
435
1883
  // src/core/differ.ts
436
1884
  import { tmpdir } from "os";
437
- import { join as join4, relative as relative2 } from "path";
1885
+ import { join as join5, relative as relative2 } from "path";
438
1886
  import { structuredPatch } from "diff";
439
1887
  import { isBinaryFile as detectBinaryFile } from "isbinaryfile";
440
1888
 
441
1889
  // src/core/delete-list.ts
442
- import { join as join2 } from "path";
1890
+ import { join as join3 } from "path";
443
1891
  var DELETE_LIST_FILENAME = "_delete.txt";
444
1892
  function parseDeleteList(content) {
445
1893
  const entries = [];
@@ -466,7 +1914,7 @@ function resolveDeleteList(entries, activeFeatures) {
466
1914
  return entries.filter((e) => e.features === void 0 || !e.features.some((f) => activeSet.has(f))).map((e) => e.path);
467
1915
  }
468
1916
  async function loadDeleteList(templatePath) {
469
- const deleteListPath = join2(templatePath, DELETE_LIST_FILENAME);
1917
+ const deleteListPath = join3(templatePath, DELETE_LIST_FILENAME);
470
1918
  if (!await pathExists(deleteListPath)) {
471
1919
  return [];
472
1920
  }
@@ -475,12 +1923,12 @@ async function loadDeleteList(templatePath) {
475
1923
  }
476
1924
 
477
1925
  // src/core/generator.ts
478
- import { join as join3, relative } from "path";
1926
+ import { join as join4, relative } from "path";
479
1927
 
480
1928
  // src/core/resolver.ts
481
1929
  import { MultiSelectPrompt } from "@clack/core";
482
1930
  import { isCancel, multiselect } from "@clack/prompts";
483
- import chalk2 from "chalk";
1931
+ import chalk3 from "chalk";
484
1932
  var _unicode = process.platform !== "win32";
485
1933
  var _s = (c, fb) => _unicode ? c : fb;
486
1934
  var _CHECKED = _s("\u25FC", "[+]");
@@ -490,20 +1938,20 @@ var _BAR = _s("\u2502", "|");
490
1938
  var _BAR_END = _s("\u2514", "-");
491
1939
  function _renderDeleteItem(opt, state) {
492
1940
  const label = opt.label ?? opt.value;
493
- const hint = opt.hint ? ` ${chalk2.dim(`(${opt.hint})`)}` : "";
1941
+ const hint = opt.hint ? ` ${chalk3.dim(`(${opt.hint})`)}` : "";
494
1942
  switch (state) {
495
1943
  case "active":
496
- return `${chalk2.cyan(_UNCHECKED_A)} ${label}${hint}`;
1944
+ return `${chalk3.cyan(_UNCHECKED_A)} ${label}${hint}`;
497
1945
  case "selected":
498
- return `${chalk2.red(_CHECKED)} ${chalk2.dim(label)}${hint}`;
1946
+ return `${chalk3.red(_CHECKED)} ${chalk3.dim(label)}${hint}`;
499
1947
  case "active-selected":
500
- return `${chalk2.red(_CHECKED)} ${label}${hint}`;
1948
+ return `${chalk3.red(_CHECKED)} ${label}${hint}`;
501
1949
  case "cancelled":
502
- return chalk2.strikethrough(chalk2.dim(label));
1950
+ return chalk3.strikethrough(chalk3.dim(label));
503
1951
  case "submitted":
504
- return chalk2.dim(label);
1952
+ return chalk3.dim(label);
505
1953
  default:
506
- return `${chalk2.dim(_UNCHECKED)} ${chalk2.dim(label)}`;
1954
+ return `${chalk3.dim(_UNCHECKED)} ${chalk3.dim(label)}`;
507
1955
  }
508
1956
  }
509
1957
  async function deleteMultiselect(opts) {
@@ -514,8 +1962,8 @@ async function deleteMultiselect(opts) {
514
1962
  required,
515
1963
  render() {
516
1964
  const self = this;
517
- const header = `${chalk2.gray(_BAR)}
518
- ${chalk2.cyan(_BAR)} ${message}
1965
+ const header = `${chalk3.gray(_BAR)}
1966
+ ${chalk3.cyan(_BAR)} ${message}
519
1967
  `;
520
1968
  const getState = (opt, idx) => {
521
1969
  const active = idx === self.cursor;
@@ -527,16 +1975,16 @@ ${chalk2.cyan(_BAR)} ${message}
527
1975
  };
528
1976
  switch (self.state) {
529
1977
  case "submit":
530
- return `${header}${chalk2.gray(_BAR)} ` + (self.options.filter((o) => self.value.includes(o.value)).map((o) => _renderDeleteItem(o, "submitted")).join(chalk2.dim(", ")) || chalk2.dim("none"));
1978
+ return `${header}${chalk3.gray(_BAR)} ` + (self.options.filter((o) => self.value.includes(o.value)).map((o) => _renderDeleteItem(o, "submitted")).join(chalk3.dim(", ")) || chalk3.dim("none"));
531
1979
  case "cancel": {
532
- const cancelled = self.options.filter((o) => self.value.includes(o.value)).map((o) => _renderDeleteItem(o, "cancelled")).join(chalk2.dim(", "));
533
- return `${header}${chalk2.gray(_BAR)} ${cancelled.trim() ? `${cancelled}
534
- ${chalk2.gray(_BAR)}` : chalk2.dim("none")}`;
1980
+ const cancelled = self.options.filter((o) => self.value.includes(o.value)).map((o) => _renderDeleteItem(o, "cancelled")).join(chalk3.dim(", "));
1981
+ return `${header}${chalk3.gray(_BAR)} ${cancelled.trim() ? `${cancelled}
1982
+ ${chalk3.gray(_BAR)}` : chalk3.dim("none")}`;
535
1983
  }
536
1984
  default:
537
- return `${header}${chalk2.cyan(_BAR)} ` + self.options.map((o, i) => _renderDeleteItem(o, getState(o, i))).join(`
538
- ${chalk2.cyan(_BAR)} `) + `
539
- ${chalk2.cyan(_BAR_END)}
1985
+ return `${header}${chalk3.cyan(_BAR)} ` + self.options.map((o, i) => _renderDeleteItem(o, getState(o, i))).join(`
1986
+ ${chalk3.cyan(_BAR)} `) + `
1987
+ ${chalk3.cyan(_BAR_END)}
540
1988
  `;
541
1989
  }
542
1990
  }
@@ -661,7 +2109,8 @@ var TemplateEngine = class {
661
2109
  try {
662
2110
  const templateContent = await readTextFile(templatePath);
663
2111
  const rendered = await this.eta.renderStringAsync(templateContent, {
664
- features: context.features
2112
+ features: context.features,
2113
+ version: context.version ?? ""
665
2114
  });
666
2115
  const trimmed = rendered.trim();
667
2116
  const isEmpty = trimmed.length === 0;
@@ -703,7 +2152,10 @@ var FileGenerator = class {
703
2152
  try {
704
2153
  for await (const templateFile of this.walkTemplates(templatePath)) {
705
2154
  const outputFile = this.computeOutputPath(templateFile, templatePath, outputPath);
706
- const result = await templateEngine.render(templateFile, { features });
2155
+ const result = await templateEngine.render(templateFile, {
2156
+ features,
2157
+ version: PACKAGE_INFO.version
2158
+ });
707
2159
  if (result.isEmpty && !result.isEmptyFileMarker) {
708
2160
  actions.push({
709
2161
  type: "skip-empty",
@@ -802,7 +2254,7 @@ var FileGenerator = class {
802
2254
  const generatedOutputPaths = new Set(filesToProcess.map((f) => f.outputFile));
803
2255
  const deleteCandidates = [];
804
2256
  for (const relPath of deleteList) {
805
- const absPath = join3(outputPath, relPath);
2257
+ const absPath = join4(outputPath, relPath);
806
2258
  if (generatedOutputPaths.has(absPath)) {
807
2259
  logger.warn(
808
2260
  `Delete list entry '${relPath}' conflicts with generated file \u2014 skipping deletion`
@@ -864,7 +2316,7 @@ var FileGenerator = class {
864
2316
  // @awa-impl: GEN-1_AC-1, GEN-1_AC-2, GEN-1_AC-3
865
2317
  computeOutputPath(templatePath, templateRoot, outputRoot) {
866
2318
  const relativePath = relative(templateRoot, templatePath);
867
- return join3(outputRoot, relativePath);
2319
+ return join4(outputRoot, relativePath);
868
2320
  }
869
2321
  };
870
2322
  var fileGenerator = new FileGenerator();
@@ -906,8 +2358,8 @@ var DiffEngine = class {
906
2358
  }
907
2359
  const files = [];
908
2360
  for (const relPath of generatedFiles) {
909
- const generatedFilePath = join4(tempPath, relPath);
910
- const targetFilePath = join4(targetPath, relPath);
2361
+ const generatedFilePath = join5(tempPath, relPath);
2362
+ const targetFilePath = join5(targetPath, relPath);
911
2363
  if (targetFiles.has(relPath)) {
912
2364
  const fileDiff = await this.compareFiles(generatedFilePath, targetFilePath, relPath);
913
2365
  files.push(fileDiff);
@@ -971,7 +2423,7 @@ var DiffEngine = class {
971
2423
  const systemTemp = tmpdir();
972
2424
  const timestamp = Date.now();
973
2425
  const random = Math.random().toString(36).substring(2, 8);
974
- const tempPath = join4(systemTemp, `awa-diff-${timestamp}-${random}`);
2426
+ const tempPath = join5(systemTemp, `awa-diff-${timestamp}-${random}`);
975
2427
  await ensureDir(tempPath);
976
2428
  return tempPath;
977
2429
  }
@@ -1073,16 +2525,69 @@ var FeatureResolver = class {
1073
2525
  };
1074
2526
  var featureResolver = new FeatureResolver();
1075
2527
 
2528
+ // src/core/json-output.ts
2529
+ function serializeGenerationResult(result) {
2530
+ const actions = result.actions.map((action) => ({
2531
+ type: action.type,
2532
+ path: action.outputPath
2533
+ }));
2534
+ return {
2535
+ actions,
2536
+ counts: {
2537
+ created: result.created,
2538
+ overwritten: result.overwritten,
2539
+ skipped: result.skipped,
2540
+ deleted: result.deleted
2541
+ }
2542
+ };
2543
+ }
2544
+ function serializeDiffResult(result) {
2545
+ const diffs = result.files.map((file) => {
2546
+ const entry = {
2547
+ path: file.relativePath,
2548
+ status: file.status
2549
+ };
2550
+ if (file.unifiedDiff) {
2551
+ entry.diff = file.unifiedDiff;
2552
+ }
2553
+ return entry;
2554
+ });
2555
+ return {
2556
+ diffs,
2557
+ counts: {
2558
+ changed: result.modified,
2559
+ new: result.newFiles,
2560
+ matching: result.identical,
2561
+ deleted: result.deleteListed
2562
+ }
2563
+ };
2564
+ }
2565
+ function formatGenerationSummary(result) {
2566
+ return `created: ${result.created}, overwritten: ${result.overwritten}, skipped: ${result.skipped}, deleted: ${result.deleted}`;
2567
+ }
2568
+ function formatDiffSummary(result) {
2569
+ return `changed: ${result.modified}, new: ${result.newFiles}, matching: ${result.identical}, deleted: ${result.deleteListed}`;
2570
+ }
2571
+ function writeJsonOutput(data) {
2572
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
2573
+ `);
2574
+ }
2575
+
2576
+ // src/core/overlay.ts
2577
+ import { cp } from "fs/promises";
2578
+ import { tmpdir as tmpdir2 } from "os";
2579
+ import { join as join7 } from "path";
2580
+
1076
2581
  // src/core/template-resolver.ts
1077
2582
  import { createHash } from "crypto";
1078
2583
  import { rm as rm2 } from "fs/promises";
1079
- import { isAbsolute, join as join5, resolve } from "path";
2584
+ import { isAbsolute, join as join6, resolve } from "path";
1080
2585
  import degit from "degit";
1081
2586
  var TemplateResolver = class {
1082
2587
  // @awa-impl: CLI-3_AC-2, TPL-10_AC-1
1083
2588
  async resolve(source, refresh) {
1084
2589
  if (!source) {
1085
- const bundledPath = join5(getTemplateDir(), "awa");
2590
+ const bundledPath = join6(getTemplateDir(), "awa");
1086
2591
  return {
1087
2592
  type: "bundled",
1088
2593
  localPath: bundledPath,
@@ -1145,7 +2650,7 @@ var TemplateResolver = class {
1145
2650
  source
1146
2651
  );
1147
2652
  }
1148
- // @awa-impl: TPL-2_AC-1 through TPL-2_AC-6
2653
+ // @awa-impl: TPL-2_AC-1, TPL-2_AC-2, TPL-2_AC-3, TPL-2_AC-4, TPL-2_AC-5, TPL-2_AC-6
1149
2654
  detectType(source) {
1150
2655
  if (source.startsWith(".") || source.startsWith("/") || source.startsWith("~")) {
1151
2656
  return "local";
@@ -1159,34 +2664,95 @@ var TemplateResolver = class {
1159
2664
  getCachePath(source) {
1160
2665
  const hash = createHash("sha256").update(source).digest("hex").substring(0, 16);
1161
2666
  const cacheDir = getCacheDir();
1162
- return join5(cacheDir, hash);
2667
+ return join6(cacheDir, hash);
1163
2668
  }
1164
2669
  };
1165
2670
  var templateResolver = new TemplateResolver();
1166
2671
 
2672
+ // src/core/overlay.ts
2673
+ async function resolveOverlays(overlays, refresh) {
2674
+ const dirs = [];
2675
+ for (const source of overlays) {
2676
+ const resolved = await templateResolver.resolve(source, refresh);
2677
+ dirs.push(resolved.localPath);
2678
+ }
2679
+ return dirs;
2680
+ }
2681
+ async function buildMergedDir(baseDir, overlayDirs) {
2682
+ const timestamp = Date.now();
2683
+ const random = Math.random().toString(36).substring(2, 8);
2684
+ const tempPath = join7(tmpdir2(), `awa-overlay-${timestamp}-${random}`);
2685
+ await ensureDir(tempPath);
2686
+ await cp(baseDir, tempPath, { recursive: true });
2687
+ for (const overlayDir of overlayDirs) {
2688
+ await cp(overlayDir, tempPath, { recursive: true });
2689
+ }
2690
+ return tempPath;
2691
+ }
2692
+
2693
+ // src/utils/file-watcher.ts
2694
+ import { watch } from "fs";
2695
+
2696
+ // src/utils/debouncer.ts
2697
+ var Debouncer = class {
2698
+ constructor(delayMs) {
2699
+ this.delayMs = delayMs;
2700
+ }
2701
+ timer = null;
2702
+ trigger(callback) {
2703
+ if (this.timer) {
2704
+ clearTimeout(this.timer);
2705
+ }
2706
+ this.timer = setTimeout(() => {
2707
+ this.timer = null;
2708
+ callback();
2709
+ }, this.delayMs);
2710
+ }
2711
+ cancel() {
2712
+ if (this.timer) {
2713
+ clearTimeout(this.timer);
2714
+ this.timer = null;
2715
+ }
2716
+ }
2717
+ };
2718
+
2719
+ // src/utils/file-watcher.ts
2720
+ var FileWatcher = class {
2721
+ watcher = null;
2722
+ debouncer;
2723
+ directory;
2724
+ onChange;
2725
+ constructor(options) {
2726
+ this.directory = options.directory;
2727
+ this.debouncer = new Debouncer(options.debounceMs ?? 300);
2728
+ this.onChange = options.onChange;
2729
+ }
2730
+ start() {
2731
+ this.watcher = watch(this.directory, { recursive: true }, () => {
2732
+ this.debouncer.trigger(this.onChange);
2733
+ });
2734
+ }
2735
+ stop() {
2736
+ this.debouncer.cancel();
2737
+ if (this.watcher) {
2738
+ this.watcher.close();
2739
+ this.watcher = null;
2740
+ }
2741
+ }
2742
+ };
2743
+
1167
2744
  // src/commands/diff.ts
1168
- async function diffCommand(cliOptions) {
2745
+ async function runDiff(diffOptions, options, mergedDir) {
1169
2746
  try {
1170
- intro("awa CLI - Template Diff");
1171
- const fileConfig = await configLoader.load(cliOptions.config ?? null);
1172
- const options = configLoader.merge(cliOptions, fileConfig);
1173
- if (!await pathExists(options.output)) {
1174
- throw new DiffError(`Target directory does not exist: ${options.output}`);
2747
+ const result = await diffEngine.diff(diffOptions);
2748
+ if (options.json) {
2749
+ writeJsonOutput(serializeDiffResult(result));
2750
+ return result.hasDifferences ? 1 : 0;
2751
+ }
2752
+ if (options.summary) {
2753
+ console.log(formatDiffSummary(result));
2754
+ return result.hasDifferences ? 1 : 0;
1175
2755
  }
1176
- const targetPath = options.output;
1177
- const template = await templateResolver.resolve(options.template, options.refresh);
1178
- const features = featureResolver.resolve({
1179
- baseFeatures: [...options.features],
1180
- presetNames: [...options.preset],
1181
- removeFeatures: [...options.removeFeatures],
1182
- presetDefinitions: options.presets
1183
- });
1184
- const result = await diffEngine.diff({
1185
- templatePath: template.localPath,
1186
- targetPath,
1187
- features,
1188
- listUnknown: options.listUnknown
1189
- });
1190
2756
  for (const file of result.files) {
1191
2757
  switch (file.status) {
1192
2758
  case "modified":
@@ -1225,8 +2791,106 @@ async function diffCommand(cliOptions) {
1225
2791
  }
1226
2792
  }
1227
2793
  logger.diffSummary(result);
1228
- outro("Diff complete!");
1229
2794
  return result.hasDifferences ? 1 : 0;
2795
+ } finally {
2796
+ if (mergedDir) {
2797
+ try {
2798
+ await rmDir(mergedDir);
2799
+ } catch {
2800
+ }
2801
+ }
2802
+ }
2803
+ }
2804
+ async function prepareDiff(options) {
2805
+ if (!await pathExists(options.output)) {
2806
+ throw new DiffError(`Target directory does not exist: ${options.output}`);
2807
+ }
2808
+ const targetPath = options.output;
2809
+ const template = await templateResolver.resolve(options.template, options.refresh);
2810
+ const features = featureResolver.resolve({
2811
+ baseFeatures: [...options.features],
2812
+ presetNames: [...options.preset],
2813
+ removeFeatures: [...options.removeFeatures],
2814
+ presetDefinitions: options.presets
2815
+ });
2816
+ let mergedDir = null;
2817
+ let templatePath = template.localPath;
2818
+ if (options.overlay.length > 0) {
2819
+ const overlayDirs = await resolveOverlays([...options.overlay], options.refresh);
2820
+ mergedDir = await buildMergedDir(template.localPath, overlayDirs);
2821
+ templatePath = mergedDir;
2822
+ }
2823
+ return {
2824
+ diffOptions: {
2825
+ templatePath,
2826
+ targetPath,
2827
+ features,
2828
+ listUnknown: options.listUnknown
2829
+ },
2830
+ template,
2831
+ mergedDir
2832
+ };
2833
+ }
2834
+ async function diffCommand(cliOptions) {
2835
+ try {
2836
+ const fileConfig = await configLoader.load(cliOptions.config ?? null);
2837
+ if (cliOptions.all || cliOptions.target) {
2838
+ const mode = cliOptions.all ? "all" : "single";
2839
+ const targets = batchRunner.resolveTargets(cliOptions, fileConfig, mode, cliOptions.target);
2840
+ let hasDifferences = false;
2841
+ for (const { targetName, options: options2 } of targets) {
2842
+ batchRunner.logForTarget(targetName, "Starting diff...");
2843
+ const { diffOptions: diffOptions2, mergedDir: mergedDir2 } = await prepareDiff(options2);
2844
+ const exitCode = await runDiff(diffOptions2, options2, mergedDir2);
2845
+ if (exitCode === 1) {
2846
+ hasDifferences = true;
2847
+ }
2848
+ batchRunner.logForTarget(targetName, "Diff complete.");
2849
+ }
2850
+ outro("All targets diffed!");
2851
+ return hasDifferences ? 1 : 0;
2852
+ }
2853
+ const options = configLoader.merge(cliOptions, fileConfig);
2854
+ const silent = options.json || options.summary;
2855
+ if (!silent) {
2856
+ intro("awa CLI - Template Diff");
2857
+ }
2858
+ const { diffOptions, template, mergedDir } = await prepareDiff(options);
2859
+ if (cliOptions.watch && template.type !== "local" && template.type !== "bundled") {
2860
+ throw new DiffError("--watch is only supported with local template sources");
2861
+ }
2862
+ const result = await runDiff(diffOptions, options, mergedDir);
2863
+ if (!cliOptions.watch) {
2864
+ if (!silent) {
2865
+ outro("Diff complete!");
2866
+ }
2867
+ return result;
2868
+ }
2869
+ logger.info(`Watching for changes in ${template.localPath}...`);
2870
+ return new Promise((resolve2) => {
2871
+ let running = false;
2872
+ const watcher = new FileWatcher({
2873
+ directory: template.localPath,
2874
+ onChange: async () => {
2875
+ if (running) return;
2876
+ running = true;
2877
+ try {
2878
+ console.clear();
2879
+ logger.info(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Change detected, re-running diff...`);
2880
+ logger.info("---");
2881
+ await runDiff(diffOptions, options, null);
2882
+ } finally {
2883
+ running = false;
2884
+ }
2885
+ }
2886
+ });
2887
+ watcher.start();
2888
+ process.once("SIGINT", () => {
2889
+ watcher.stop();
2890
+ logger.info("\nWatch mode stopped.");
2891
+ resolve2(0);
2892
+ });
2893
+ });
1230
2894
  } catch (error) {
1231
2895
  if (error instanceof Error) {
1232
2896
  logger.error(error.message);
@@ -1237,8 +2901,161 @@ async function diffCommand(cliOptions) {
1237
2901
  }
1238
2902
  }
1239
2903
 
2904
+ // src/commands/features.ts
2905
+ import { intro as intro2, outro as outro2 } from "@clack/prompts";
2906
+
2907
+ // src/core/features/reporter.ts
2908
+ import chalk4 from "chalk";
2909
+ var FeaturesReporter = class {
2910
+ // @awa-impl: DISC-6_AC-1, DISC-7_AC-1
2911
+ /** Render the features report to stdout. */
2912
+ report(options) {
2913
+ const { scanResult, json, presets } = options;
2914
+ if (json) {
2915
+ this.reportJson(scanResult, presets);
2916
+ } else {
2917
+ this.reportTable(scanResult, presets);
2918
+ }
2919
+ }
2920
+ // @awa-impl: DISC-6_AC-1
2921
+ /** Build the JSON output object (also used by tests). */
2922
+ buildJsonOutput(scanResult, presets) {
2923
+ const output = {
2924
+ features: scanResult.features.map((f) => ({
2925
+ name: f.name,
2926
+ files: f.files
2927
+ })),
2928
+ filesScanned: scanResult.filesScanned
2929
+ };
2930
+ if (presets && Object.keys(presets).length > 0) {
2931
+ output.presets = presets;
2932
+ }
2933
+ return output;
2934
+ }
2935
+ reportJson(scanResult, presets) {
2936
+ const output = this.buildJsonOutput(scanResult, presets);
2937
+ console.log(JSON.stringify(output, null, 2));
2938
+ }
2939
+ // @awa-impl: DISC-7_AC-1
2940
+ reportTable(scanResult, presets) {
2941
+ const { features, filesScanned } = scanResult;
2942
+ if (features.length === 0) {
2943
+ console.log(chalk4.yellow("No feature flags found."));
2944
+ console.log(chalk4.dim(`(${filesScanned} files scanned)`));
2945
+ return;
2946
+ }
2947
+ console.log(chalk4.bold(`Feature flags (${features.length} found):
2948
+ `));
2949
+ for (const feature of features) {
2950
+ console.log(` ${chalk4.cyan(feature.name)}`);
2951
+ for (const file of feature.files) {
2952
+ console.log(` ${chalk4.dim(file)}`);
2953
+ }
2954
+ }
2955
+ console.log("");
2956
+ console.log(chalk4.dim(`${filesScanned} files scanned`));
2957
+ if (presets && Object.keys(presets).length > 0) {
2958
+ console.log("");
2959
+ console.log(chalk4.bold("Presets (from .awa.toml):\n"));
2960
+ for (const [name, flags] of Object.entries(presets)) {
2961
+ console.log(` ${chalk4.green(name)}: ${flags.join(", ")}`);
2962
+ }
2963
+ }
2964
+ }
2965
+ };
2966
+ var featuresReporter = new FeaturesReporter();
2967
+
2968
+ // src/core/features/scanner.ts
2969
+ import { readdir as readdir2, readFile as readFile6 } from "fs/promises";
2970
+ import { join as join8, relative as relative3 } from "path";
2971
+ var FEATURE_PATTERN = /it\.features\.(?:includes|indexOf)\(\s*['"]([^'"]+)['"]\s*\)/g;
2972
+ async function* walkAllFiles(dir) {
2973
+ const entries = await readdir2(dir, { withFileTypes: true });
2974
+ for (const entry of entries) {
2975
+ const fullPath = join8(dir, entry.name);
2976
+ if (entry.isDirectory()) {
2977
+ yield* walkAllFiles(fullPath);
2978
+ } else if (entry.isFile()) {
2979
+ yield fullPath;
2980
+ }
2981
+ }
2982
+ }
2983
+ var FeatureScanner = class {
2984
+ // @awa-impl: DISC-1_AC-1, DISC-2_AC-1
2985
+ /** Extract feature flag names from a single file's content. */
2986
+ extractFlags(content) {
2987
+ const flags = /* @__PURE__ */ new Set();
2988
+ for (const match of content.matchAll(FEATURE_PATTERN)) {
2989
+ if (match[1]) {
2990
+ flags.add(match[1]);
2991
+ }
2992
+ }
2993
+ return [...flags];
2994
+ }
2995
+ // @awa-impl: DISC-1_AC-1, DISC-2_AC-1, DISC-3_AC-1
2996
+ /** Scan a template directory and return all discovered feature flags. */
2997
+ async scan(templatePath) {
2998
+ const flagToFiles = /* @__PURE__ */ new Map();
2999
+ let filesScanned = 0;
3000
+ for await (const filePath of walkAllFiles(templatePath)) {
3001
+ filesScanned++;
3002
+ try {
3003
+ const content = await readFile6(filePath, "utf-8");
3004
+ const flags = this.extractFlags(content);
3005
+ const relPath = relative3(templatePath, filePath);
3006
+ for (const flag of flags) {
3007
+ const existing = flagToFiles.get(flag);
3008
+ if (existing) {
3009
+ existing.add(relPath);
3010
+ } else {
3011
+ flagToFiles.set(flag, /* @__PURE__ */ new Set([relPath]));
3012
+ }
3013
+ }
3014
+ } catch {
3015
+ }
3016
+ }
3017
+ const features = [...flagToFiles.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, files]) => ({
3018
+ name,
3019
+ files: [...files].sort()
3020
+ }));
3021
+ return { features, filesScanned };
3022
+ }
3023
+ };
3024
+ var featureScanner = new FeatureScanner();
3025
+
3026
+ // src/commands/features.ts
3027
+ async function featuresCommand(cliOptions) {
3028
+ try {
3029
+ if (!cliOptions.json) {
3030
+ intro2("awa CLI - Feature Discovery");
3031
+ }
3032
+ const fileConfig = await configLoader.load(cliOptions.config ?? null);
3033
+ const templateSource = cliOptions.template ?? fileConfig?.template ?? null;
3034
+ const refresh = cliOptions.refresh ?? fileConfig?.refresh ?? false;
3035
+ const template = await templateResolver.resolve(templateSource, refresh);
3036
+ const scanResult = await featureScanner.scan(template.localPath);
3037
+ const presets = fileConfig?.presets;
3038
+ featuresReporter.report({
3039
+ scanResult,
3040
+ json: cliOptions.json ?? false,
3041
+ presets
3042
+ });
3043
+ if (!cliOptions.json) {
3044
+ outro2("Feature discovery complete!");
3045
+ }
3046
+ return 0;
3047
+ } catch (error) {
3048
+ if (error instanceof Error) {
3049
+ logger.error(error.message);
3050
+ } else {
3051
+ logger.error(String(error));
3052
+ }
3053
+ return 1;
3054
+ }
3055
+ }
3056
+
1240
3057
  // src/commands/generate.ts
1241
- import { intro as intro2, isCancel as isCancel2, multiselect as multiselect2, outro as outro2 } from "@clack/prompts";
3058
+ import { intro as intro3, isCancel as isCancel2, multiselect as multiselect2, outro as outro3 } from "@clack/prompts";
1242
3059
  var TOOL_FEATURES = [
1243
3060
  { value: "copilot", label: "GitHub Copilot" },
1244
3061
  { value: "claude", label: "Claude Code" },
@@ -1254,20 +3071,18 @@ var TOOL_FEATURES = [
1254
3071
  { value: "agents-md", label: "AGENTS.md (cross-tool)" }
1255
3072
  ];
1256
3073
  var TOOL_FEATURE_VALUES = new Set(TOOL_FEATURES.map((t) => t.value));
1257
- async function generateCommand(cliOptions) {
1258
- try {
1259
- intro2("awa CLI - Template Generator");
1260
- const fileConfig = await configLoader.load(cliOptions.config ?? null);
1261
- const options = configLoader.merge(cliOptions, fileConfig);
1262
- const template = await templateResolver.resolve(options.template, options.refresh);
1263
- const features = featureResolver.resolve({
1264
- baseFeatures: [...options.features],
1265
- presetNames: [...options.preset],
1266
- removeFeatures: [...options.removeFeatures],
1267
- presetDefinitions: options.presets
1268
- });
3074
+ async function runGenerate(options, batchMode) {
3075
+ const silent = options.json || options.summary;
3076
+ const template = await templateResolver.resolve(options.template, options.refresh);
3077
+ const features = featureResolver.resolve({
3078
+ baseFeatures: [...options.features],
3079
+ presetNames: [...options.preset],
3080
+ removeFeatures: [...options.removeFeatures],
3081
+ presetDefinitions: options.presets
3082
+ });
3083
+ if (!batchMode) {
1269
3084
  const hasToolFlag = features.some((f) => TOOL_FEATURE_VALUES.has(f));
1270
- if (!hasToolFlag) {
3085
+ if (!hasToolFlag && !silent) {
1271
3086
  const selected = await multiselect2({
1272
3087
  message: "Select AI tools to generate for (space to toggle, enter to confirm):",
1273
3088
  options: TOOL_FEATURES.map((t) => ({ value: t.value, label: t.label })),
@@ -1279,22 +3094,74 @@ async function generateCommand(cliOptions) {
1279
3094
  }
1280
3095
  features.push(...selected);
1281
3096
  }
1282
- if (options.dryRun) {
3097
+ }
3098
+ const effectiveDryRun = options.json || options.dryRun;
3099
+ if (!silent) {
3100
+ if (effectiveDryRun) {
1283
3101
  logger.info("Running in dry-run mode (no files will be modified)");
1284
3102
  }
1285
3103
  if (options.force) {
1286
3104
  logger.info("Force mode enabled (existing files will be overwritten)");
1287
3105
  }
3106
+ }
3107
+ let mergedDir = null;
3108
+ let templatePath = template.localPath;
3109
+ if (options.overlay.length > 0) {
3110
+ const overlayDirs = await resolveOverlays([...options.overlay], options.refresh);
3111
+ mergedDir = await buildMergedDir(template.localPath, overlayDirs);
3112
+ templatePath = mergedDir;
3113
+ }
3114
+ try {
1288
3115
  const result = await fileGenerator.generate({
1289
- templatePath: template.localPath,
3116
+ templatePath,
1290
3117
  outputPath: options.output,
1291
3118
  features,
1292
3119
  force: options.force,
1293
- dryRun: options.dryRun,
3120
+ dryRun: effectiveDryRun,
1294
3121
  delete: options.delete
1295
3122
  });
1296
- logger.summary(result);
1297
- outro2("Generation complete!");
3123
+ if (options.json) {
3124
+ writeJsonOutput(serializeGenerationResult(result));
3125
+ } else if (options.summary) {
3126
+ console.log(formatGenerationSummary(result));
3127
+ } else {
3128
+ logger.summary(result);
3129
+ }
3130
+ } finally {
3131
+ if (mergedDir) {
3132
+ try {
3133
+ await rmDir(mergedDir);
3134
+ } catch {
3135
+ }
3136
+ }
3137
+ }
3138
+ }
3139
+ async function generateCommand(cliOptions) {
3140
+ try {
3141
+ const fileConfig = await configLoader.load(cliOptions.config ?? null);
3142
+ if (!cliOptions.config && fileConfig === null) {
3143
+ logger.info("Tip: create .awa.toml to save your options for next time.");
3144
+ }
3145
+ if (cliOptions.all || cliOptions.target) {
3146
+ const mode = cliOptions.all ? "all" : "single";
3147
+ const targets = batchRunner.resolveTargets(cliOptions, fileConfig, mode, cliOptions.target);
3148
+ for (const { targetName, options: options2 } of targets) {
3149
+ batchRunner.logForTarget(targetName, "Starting generation...");
3150
+ await runGenerate(options2, true);
3151
+ batchRunner.logForTarget(targetName, "Generation complete.");
3152
+ }
3153
+ outro3("All targets generated!");
3154
+ return;
3155
+ }
3156
+ const options = configLoader.merge(cliOptions, fileConfig);
3157
+ const silent = options.json || options.summary;
3158
+ if (!silent) {
3159
+ intro3("awa CLI - Template Generator");
3160
+ }
3161
+ await runGenerate(options, false);
3162
+ if (!silent) {
3163
+ outro3("Generation complete!");
3164
+ }
1298
3165
  } catch (error) {
1299
3166
  if (error instanceof Error) {
1300
3167
  logger.error(error.message);
@@ -1305,49 +3172,361 @@ async function generateCommand(cliOptions) {
1305
3172
  }
1306
3173
  }
1307
3174
 
3175
+ // src/commands/test.ts
3176
+ import { intro as intro4, outro as outro4 } from "@clack/prompts";
3177
+
3178
+ // src/core/template-test/fixture-loader.ts
3179
+ import { readdir as readdir3 } from "fs/promises";
3180
+ import { basename as basename2, extname, join as join9 } from "path";
3181
+ import { parse as parse2 } from "smol-toml";
3182
+ async function discoverFixtures(templatePath) {
3183
+ const testsDir = join9(templatePath, "_tests");
3184
+ let entries;
3185
+ try {
3186
+ const dirEntries = await readdir3(testsDir, { withFileTypes: true });
3187
+ entries = dirEntries.filter((e) => e.isFile() && extname(e.name) === ".toml").map((e) => e.name).sort();
3188
+ } catch {
3189
+ return [];
3190
+ }
3191
+ const fixtures = [];
3192
+ for (const filename of entries) {
3193
+ const filePath = join9(testsDir, filename);
3194
+ const fixture = await parseFixture(filePath);
3195
+ fixtures.push(fixture);
3196
+ }
3197
+ return fixtures;
3198
+ }
3199
+ async function parseFixture(filePath) {
3200
+ const content = await readTextFile(filePath);
3201
+ const parsed = parse2(content);
3202
+ const name = basename2(filePath, extname(filePath));
3203
+ const features = toStringArray2(parsed.features) ?? [];
3204
+ const preset = toStringArray2(parsed.preset) ?? [];
3205
+ const removeFeatures = toStringArray2(parsed["remove-features"]) ?? [];
3206
+ const expectedFiles = toStringArray2(parsed["expected-files"]) ?? [];
3207
+ return {
3208
+ name,
3209
+ features,
3210
+ preset,
3211
+ removeFeatures,
3212
+ expectedFiles,
3213
+ filePath
3214
+ };
3215
+ }
3216
+ function toStringArray2(value) {
3217
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
3218
+ return value;
3219
+ }
3220
+ return null;
3221
+ }
3222
+
3223
+ // src/core/template-test/reporter.ts
3224
+ import chalk5 from "chalk";
3225
+ function report2(result) {
3226
+ console.log("");
3227
+ for (const fixture of result.results) {
3228
+ reportFixture(fixture);
3229
+ }
3230
+ console.log("");
3231
+ console.log(chalk5.bold("Test Summary:"));
3232
+ console.log(` Total: ${result.total}`);
3233
+ console.log(chalk5.green(` Passed: ${result.passed}`));
3234
+ if (result.failed > 0) {
3235
+ console.log(chalk5.red(` Failed: ${result.failed}`));
3236
+ }
3237
+ console.log("");
3238
+ }
3239
+ function reportFixture(fixture) {
3240
+ const icon = fixture.passed ? chalk5.green("\u2714") : chalk5.red("\u2716");
3241
+ console.log(`${icon} ${fixture.name}`);
3242
+ if (fixture.error) {
3243
+ console.log(chalk5.red(` Error: ${fixture.error}`));
3244
+ return;
3245
+ }
3246
+ const missingFiles = fixture.fileResults.filter((r) => !r.found);
3247
+ for (const missing of missingFiles) {
3248
+ console.log(chalk5.red(` Missing file: ${missing.path}`));
3249
+ }
3250
+ const snapshotFailures = fixture.snapshotResults.filter((r) => r.status !== "match");
3251
+ for (const failure of snapshotFailures) {
3252
+ switch (failure.status) {
3253
+ case "mismatch":
3254
+ console.log(chalk5.yellow(` Snapshot mismatch: ${failure.path}`));
3255
+ break;
3256
+ case "missing-snapshot":
3257
+ console.log(chalk5.yellow(` Missing snapshot: ${failure.path}`));
3258
+ break;
3259
+ case "extra-snapshot":
3260
+ console.log(chalk5.yellow(` Extra snapshot (not in output): ${failure.path}`));
3261
+ break;
3262
+ }
3263
+ }
3264
+ }
3265
+
3266
+ // src/core/template-test/runner.ts
3267
+ import { mkdir as mkdir3, rm as rm4 } from "fs/promises";
3268
+ import { tmpdir as tmpdir3 } from "os";
3269
+ import { join as join11 } from "path";
3270
+
3271
+ // src/core/template-test/snapshot.ts
3272
+ import { mkdir as mkdir2, readdir as readdir4, rm as rm3 } from "fs/promises";
3273
+ import { join as join10, relative as relative4 } from "path";
3274
+ async function walkRelative(dir, base) {
3275
+ const results = [];
3276
+ const entries = await readdir4(dir, { withFileTypes: true });
3277
+ for (const entry of entries) {
3278
+ const fullPath = join10(dir, entry.name);
3279
+ if (entry.isDirectory()) {
3280
+ const sub = await walkRelative(fullPath, base);
3281
+ results.push(...sub);
3282
+ } else if (entry.isFile()) {
3283
+ results.push(relative4(base, fullPath));
3284
+ }
3285
+ }
3286
+ return results;
3287
+ }
3288
+ async function compareSnapshots(renderedDir, snapshotDir) {
3289
+ const results = [];
3290
+ const renderedFiles = await walkRelative(renderedDir, renderedDir);
3291
+ const snapshotFiles = await walkRelative(snapshotDir, snapshotDir);
3292
+ const snapshotSet = new Set(snapshotFiles);
3293
+ const renderedSet = new Set(renderedFiles);
3294
+ for (const file of renderedFiles) {
3295
+ const renderedPath = join10(renderedDir, file);
3296
+ const snapshotPath = join10(snapshotDir, file);
3297
+ if (!snapshotSet.has(file)) {
3298
+ results.push({ path: file, status: "missing-snapshot" });
3299
+ continue;
3300
+ }
3301
+ const renderedContent = await readTextFile(renderedPath);
3302
+ const snapshotContent = await readTextFile(snapshotPath);
3303
+ results.push({
3304
+ path: file,
3305
+ status: renderedContent === snapshotContent ? "match" : "mismatch"
3306
+ });
3307
+ }
3308
+ for (const file of snapshotFiles) {
3309
+ if (!renderedSet.has(file)) {
3310
+ results.push({ path: file, status: "extra-snapshot" });
3311
+ }
3312
+ }
3313
+ return results;
3314
+ }
3315
+ async function updateSnapshots(renderedDir, snapshotDir) {
3316
+ if (await pathExists(snapshotDir)) {
3317
+ await rm3(snapshotDir, { recursive: true, force: true });
3318
+ }
3319
+ await mkdir2(snapshotDir, { recursive: true });
3320
+ const files = await walkRelative(renderedDir, renderedDir);
3321
+ for (const file of files) {
3322
+ const srcPath = join10(renderedDir, file);
3323
+ const destPath = join10(snapshotDir, file);
3324
+ const content = await readTextFile(srcPath);
3325
+ await writeTextFile(destPath, content);
3326
+ }
3327
+ }
3328
+
3329
+ // src/core/template-test/runner.ts
3330
+ async function runFixture(fixture, templatePath, options, presetDefinitions = {}) {
3331
+ const tempDir = join11(tmpdir3(), `awa-test-${fixture.name}-${Date.now()}`);
3332
+ try {
3333
+ await mkdir3(tempDir, { recursive: true });
3334
+ const features = featureResolver.resolve({
3335
+ baseFeatures: [...fixture.features],
3336
+ presetNames: [...fixture.preset],
3337
+ removeFeatures: [...fixture.removeFeatures],
3338
+ presetDefinitions
3339
+ });
3340
+ await fileGenerator.generate({
3341
+ templatePath,
3342
+ outputPath: tempDir,
3343
+ features,
3344
+ force: true,
3345
+ dryRun: false,
3346
+ delete: false
3347
+ });
3348
+ const fileResults = [];
3349
+ for (const expectedFile of fixture.expectedFiles) {
3350
+ const fullPath = join11(tempDir, expectedFile);
3351
+ const found = await pathExists(fullPath);
3352
+ fileResults.push({ path: expectedFile, found });
3353
+ }
3354
+ const missingFiles = fileResults.filter((r) => !r.found);
3355
+ const snapshotDir = join11(templatePath, "_tests", fixture.name);
3356
+ let snapshotResults = [];
3357
+ if (options.updateSnapshots) {
3358
+ await updateSnapshots(tempDir, snapshotDir);
3359
+ } else if (await pathExists(snapshotDir)) {
3360
+ snapshotResults = await compareSnapshots(tempDir, snapshotDir);
3361
+ }
3362
+ const snapshotFailures = snapshotResults.filter((r) => r.status !== "match");
3363
+ const passed = missingFiles.length === 0 && snapshotFailures.length === 0;
3364
+ return {
3365
+ name: fixture.name,
3366
+ passed,
3367
+ fileResults,
3368
+ snapshotResults
3369
+ };
3370
+ } catch (error) {
3371
+ return {
3372
+ name: fixture.name,
3373
+ passed: false,
3374
+ fileResults: [],
3375
+ snapshotResults: [],
3376
+ error: error instanceof Error ? error.message : String(error)
3377
+ };
3378
+ } finally {
3379
+ await rm4(tempDir, { recursive: true, force: true }).catch(() => {
3380
+ });
3381
+ }
3382
+ }
3383
+ async function runAll(fixtures, templatePath, options, presetDefinitions = {}) {
3384
+ const results = [];
3385
+ for (const fixture of fixtures) {
3386
+ const result = await runFixture(fixture, templatePath, options, presetDefinitions);
3387
+ results.push(result);
3388
+ }
3389
+ const passed = results.filter((r) => r.passed).length;
3390
+ const failed = results.filter((r) => !r.passed).length;
3391
+ return {
3392
+ results,
3393
+ total: results.length,
3394
+ passed,
3395
+ failed
3396
+ };
3397
+ }
3398
+
3399
+ // src/commands/test.ts
3400
+ async function testCommand(options) {
3401
+ try {
3402
+ intro4("awa CLI - Template Test");
3403
+ const fileConfig = await configLoader.load(options.config ?? null);
3404
+ const templateSource = options.template ?? fileConfig?.template ?? null;
3405
+ const template = await templateResolver.resolve(templateSource, false);
3406
+ const fixtures = await discoverFixtures(template.localPath);
3407
+ if (fixtures.length === 0) {
3408
+ logger.warn("No test fixtures found in _tests/ directory");
3409
+ outro4("No tests to run.");
3410
+ return 0;
3411
+ }
3412
+ logger.info(`Found ${fixtures.length} fixture(s)`);
3413
+ const presetDefinitions = fileConfig?.presets ?? {};
3414
+ const result = await runAll(
3415
+ fixtures,
3416
+ template.localPath,
3417
+ { updateSnapshots: options.updateSnapshots },
3418
+ presetDefinitions
3419
+ );
3420
+ report2(result);
3421
+ if (result.failed > 0) {
3422
+ outro4(`${result.failed} fixture(s) failed.`);
3423
+ return 1;
3424
+ }
3425
+ outro4("All tests passed!");
3426
+ return 0;
3427
+ } catch (error) {
3428
+ if (error instanceof Error) {
3429
+ logger.error(error.message);
3430
+ } else {
3431
+ logger.error(String(error));
3432
+ }
3433
+ return 2;
3434
+ }
3435
+ }
3436
+
1308
3437
  // src/cli/index.ts
1309
3438
  var version = PACKAGE_INFO.version;
1310
3439
  var program = new Command();
1311
3440
  program.name("awa").description("awa - tool for generating AI coding agent configuration files").version(version, "-v, --version", "Display version number");
1312
- program.command("generate").description("Generate AI agent configuration files from templates").argument("[output]", "Output directory (optional if specified in config)").option("-t, --template <source>", "Template source (local path or Git repository)").option("-f, --features <flag...>", "Feature flags (can be specified multiple times)").option("--preset <name...>", "Preset names to enable (can be specified multiple times)").option(
3441
+ program.command("generate").alias("init").description("Generate AI agent configuration files from templates").argument("[output]", "Output directory (optional if specified in config)").option("-t, --template <source>", "Template source (local path or Git repository)").option("-f, --features <flag...>", "Feature flags (can be specified multiple times)").option("--preset <name...>", "Preset names to enable (can be specified multiple times)").option(
1313
3442
  "--remove-features <flag...>",
1314
3443
  "Feature flags to remove (can be specified multiple times)"
1315
3444
  ).option("--force", "Force overwrite existing files without prompting", false).option("--dry-run", "Preview changes without modifying files", false).option(
1316
3445
  "--delete",
1317
3446
  "Enable deletion of files listed in the delete list (default: warn only)",
1318
3447
  false
1319
- ).option("-c, --config <path>", "Path to configuration file").option("--refresh", "Force refresh of cached Git templates", false).action(async (output, options) => {
3448
+ ).option("-c, --config <path>", "Path to configuration file").option("--refresh", "Force refresh of cached Git templates", false).option("--all", "Process all named targets from config", false).option("--target <name>", "Process a specific named target from config").option("--overlay <path...>", "Overlay directory paths applied over base template (repeatable)").option("--json", "Output results as JSON (implies --dry-run)", false).option("--summary", "Output compact one-line summary", false).action(async (output, options) => {
1320
3449
  const cliOptions = {
1321
3450
  output,
1322
3451
  template: options.template,
1323
- features: options.features || [],
1324
- preset: options.preset || [],
1325
- removeFeatures: options.removeFeatures || [],
3452
+ features: options.features,
3453
+ preset: options.preset,
3454
+ removeFeatures: options.removeFeatures,
1326
3455
  force: options.force,
1327
3456
  dryRun: options.dryRun,
1328
3457
  delete: options.delete,
1329
3458
  config: options.config,
1330
- refresh: options.refresh
3459
+ refresh: options.refresh,
3460
+ all: options.all,
3461
+ target: options.target,
3462
+ overlay: options.overlay || [],
3463
+ json: options.json,
3464
+ summary: options.summary
1331
3465
  };
1332
3466
  await generateCommand(cliOptions);
1333
3467
  });
1334
3468
  program.command("diff").description("Compare template output with existing target directory").argument("[target]", "Target directory to compare against (optional if specified in config)").option("-t, --template <source>", "Template source (local path or Git repository)").option("-f, --features <flag...>", "Feature flags (can be specified multiple times)").option("--preset <name...>", "Preset names to enable (can be specified multiple times)").option(
1335
3469
  "--remove-features <flag...>",
1336
3470
  "Feature flags to remove (can be specified multiple times)"
1337
- ).option("-c, --config <path>", "Path to configuration file").option("--refresh", "Force refresh of cached Git templates", false).option("--list-unknown", "Include target-only files in diff results", false).action(async (target, options) => {
3471
+ ).option("-c, --config <path>", "Path to configuration file").option("--refresh", "Force refresh of cached Git templates", false).option("--list-unknown", "Include target-only files in diff results", false).option("--all", "Process all named targets from config", false).option("--target <name>", "Process a specific named target from config").option("-w, --watch", "Watch template directory for changes and re-run diff", false).option("--overlay <path...>", "Overlay directory paths applied over base template (repeatable)").option("--json", "Output results as JSON", false).option("--summary", "Output compact one-line summary", false).action(async (target, options) => {
1338
3472
  const cliOptions = {
1339
3473
  output: target,
1340
3474
  // Use target as output for consistency
1341
3475
  template: options.template,
1342
- features: options.features || [],
1343
- preset: options.preset || [],
1344
- removeFeatures: options.removeFeatures || [],
3476
+ features: options.features,
3477
+ preset: options.preset,
3478
+ removeFeatures: options.removeFeatures,
1345
3479
  config: options.config,
1346
3480
  refresh: options.refresh,
1347
- listUnknown: options.listUnknown
3481
+ listUnknown: options.listUnknown,
3482
+ all: options.all,
3483
+ target: options.target,
3484
+ watch: options.watch,
3485
+ overlay: options.overlay || [],
3486
+ json: options.json,
3487
+ summary: options.summary
1348
3488
  };
1349
3489
  const exitCode = await diffCommand(cliOptions);
1350
3490
  process.exit(exitCode);
1351
3491
  });
3492
+ program.command("check").description(
3493
+ "Validate spec files against schemas and check traceability between code markers and specs"
3494
+ ).option("-c, --config <path>", "Path to configuration file").option("--ignore <pattern...>", "Glob patterns for paths to exclude").option("--format <format>", "Output format (text or json)", "text").option(
3495
+ "--allow-warnings",
3496
+ "Allow warnings without failing (default: warnings are errors)",
3497
+ false
3498
+ ).option(
3499
+ "--spec-only",
3500
+ "Run only spec-level checks (schema and cross-refs); skip code-to-spec traceability",
3501
+ false
3502
+ ).action(async (options) => {
3503
+ const cliOptions = {
3504
+ config: options.config,
3505
+ ignore: options.ignore,
3506
+ format: options.format,
3507
+ allowWarnings: options.allowWarnings,
3508
+ specOnly: options.specOnly
3509
+ };
3510
+ const exitCode = await checkCommand(cliOptions);
3511
+ process.exit(exitCode);
3512
+ });
3513
+ program.command("features").description("Discover feature flags available in a template").option("-t, --template <source>", "Template source (local path or Git repository)").option("-c, --config <path>", "Path to configuration file").option("--refresh", "Force refresh of cached Git templates", false).option("--json", "Output results as JSON", false).action(async (options) => {
3514
+ const exitCode = await featuresCommand({
3515
+ template: options.template,
3516
+ config: options.config,
3517
+ refresh: options.refresh,
3518
+ json: options.json
3519
+ });
3520
+ process.exit(exitCode);
3521
+ });
3522
+ program.command("test").description("Run template test fixtures to verify expected output").option("-t, --template <source>", "Template source (local path or Git repository)").option("-c, --config <path>", "Path to configuration file").option("--update-snapshots", "Update stored snapshots with current rendered output", false).action(async (options) => {
3523
+ const testOptions = {
3524
+ template: options.template,
3525
+ config: options.config,
3526
+ updateSnapshots: options.updateSnapshots
3527
+ };
3528
+ const exitCode = await testCommand(testOptions);
3529
+ process.exit(exitCode);
3530
+ });
1352
3531
  program.parse();
1353
3532
  //# sourceMappingURL=index.js.map