@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.
- package/README.md +96 -16
- package/dist/index.js +2307 -128
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/templates/awa/.agent/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.agent/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.agent/workflows/awa-align.md +3 -0
- package/templates/awa/.agent/workflows/awa-check.md +4 -0
- package/templates/awa/.agents/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.agents/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.awa/.agent/schemas/ALIGN_REPORT.schema.yaml +83 -0
- package/templates/awa/.awa/.agent/schemas/API.schema.yaml +7 -0
- package/templates/awa/.awa/.agent/schemas/ARCHITECTURE.schema.yaml +260 -0
- package/templates/awa/.awa/.agent/schemas/DESIGN.schema.yaml +361 -0
- package/templates/awa/.awa/.agent/schemas/EXAMPLES.schema.yaml +98 -0
- package/templates/awa/.awa/.agent/schemas/FEAT.schema.yaml +143 -0
- package/templates/awa/.awa/.agent/schemas/PLAN.schema.yaml +151 -0
- package/templates/awa/.awa/.agent/schemas/README.schema.yaml +137 -0
- package/templates/awa/.awa/.agent/schemas/REQ.schema.yaml +169 -0
- package/templates/awa/.awa/.agent/schemas/TASK.schema.yaml +200 -0
- package/templates/awa/.claude/agents/awa.md +2 -2
- package/templates/awa/.claude/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.claude/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.codex/prompts/awa-align.md +3 -0
- package/templates/awa/.codex/prompts/awa-check.md +4 -0
- package/templates/awa/.cursor/rules/awa-agent.md +1 -1
- package/templates/awa/.cursor/rules/awa-align.md +8 -0
- package/templates/awa/.cursor/rules/awa-check.md +9 -0
- package/templates/awa/.gemini/commands/awa-align.md +3 -0
- package/templates/awa/.gemini/commands/awa-check.md +4 -0
- package/templates/awa/.gemini/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.gemini/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.github/agents/awa.agent.md +2 -2
- package/templates/awa/.github/prompts/awa.align.prompt.md +8 -0
- package/templates/awa/.github/prompts/awa.check.prompt.md +9 -0
- package/templates/awa/.github/skills/awa-align/SKILL.md +8 -0
- package/templates/awa/.github/skills/awa-check/SKILL.md +9 -0
- package/templates/awa/.kilocode/rules/awa-agent.md +1 -1
- package/templates/awa/.kilocode/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.kilocode/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.kilocode/workflows/awa-align.md +3 -0
- package/templates/awa/.kilocode/workflows/awa-check.md +4 -0
- package/templates/awa/.opencode/agents/awa.md +2 -2
- package/templates/awa/.opencode/commands/awa-align.md +3 -0
- package/templates/awa/.opencode/commands/awa-check.md +4 -0
- package/templates/awa/.opencode/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.opencode/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.qwen/commands/awa-align.md +3 -0
- package/templates/awa/.qwen/commands/awa-check.md +4 -0
- package/templates/awa/.qwen/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.qwen/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.roo/rules/awa-agent.md +1 -1
- package/templates/awa/.roo/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.roo/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/.windsurf/rules/awa-agent.md +1 -1
- package/templates/awa/.windsurf/skills/awa-align/SKILL.md +3 -0
- package/templates/awa/.windsurf/skills/awa-check/SKILL.md +4 -0
- package/templates/awa/AGENTS.md +1 -1
- package/templates/awa/CLAUDE.md +1 -1
- package/templates/awa/GEMINI.md +1 -1
- package/templates/awa/QWEN.md +1 -1
- package/templates/awa/_README.md +3 -2
- package/templates/awa/_delete.txt +49 -0
- package/templates/awa/_partials/{_cmd.awa-validate-alignment.md → _cmd.awa-align.md} +1 -1
- package/templates/awa/_partials/_cmd.awa-check.md +6 -0
- package/templates/awa/_partials/_skill.awa-align.md +6 -0
- package/templates/awa/_partials/_skill.awa-check.md +6 -0
- package/templates/awa/_partials/{awa.validate-alignment.md → awa.align.md} +2 -2
- package/templates/awa/_partials/awa.architecture.md +1 -1
- package/templates/awa/_partials/awa.check.md +73 -0
- package/templates/awa/_partials/awa.code.md +1 -0
- package/templates/awa/_partials/awa.core.md +24 -10
- package/templates/awa/_partials/awa.design.md +3 -2
- package/templates/awa/_partials/awa.documentation.md +1 -1
- package/templates/awa/_partials/awa.examples.md +1 -1
- package/templates/awa/_partials/awa.feature.md +1 -1
- package/templates/awa/_partials/awa.plan.md +1 -1
- package/templates/awa/_partials/awa.refactor.md +1 -0
- package/templates/awa/_partials/awa.requirements.md +2 -1
- package/templates/awa/_partials/awa.tasks.md +3 -3
- package/templates/awa/_partials/awa.upgrade.md +13 -12
- package/templates/awa/_tests/claude.toml +7 -0
- package/templates/awa/_tests/copilot.toml +6 -0
- package/templates/awa/.agent/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.agent/workflows/awa-validate-alignment.md +0 -3
- package/templates/awa/.agents/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.awa/.agent/schemas/ALIGN_REPORT.schema.md +0 -156
- package/templates/awa/.awa/.agent/schemas/API.schema.md +0 -4
- package/templates/awa/.awa/.agent/schemas/ARCHITECTURE.schema.md +0 -176
- package/templates/awa/.awa/.agent/schemas/DESIGN.schema.md +0 -253
- package/templates/awa/.awa/.agent/schemas/EXAMPLES.schema.md +0 -51
- package/templates/awa/.awa/.agent/schemas/FEAT.schema.md +0 -61
- package/templates/awa/.awa/.agent/schemas/PLAN.schema.md +0 -8
- package/templates/awa/.awa/.agent/schemas/README.schema.md +0 -133
- package/templates/awa/.awa/.agent/schemas/REQ.schema.md +0 -125
- package/templates/awa/.awa/.agent/schemas/TASK.schema.md +0 -137
- package/templates/awa/.claude/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.codex/prompts/awa-validate-alignment.md +0 -3
- package/templates/awa/.cursor/rules/awa-validate-alignment.md +0 -8
- package/templates/awa/.gemini/commands/awa-validate-alignment.md +0 -3
- package/templates/awa/.gemini/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.github/prompts/awa.validate-alignment.prompt.md +0 -8
- package/templates/awa/.github/skills/awa-validate-alignment/SKILL.md +0 -8
- package/templates/awa/.kilocode/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.kilocode/workflows/awa-validate-alignment.md +0 -3
- package/templates/awa/.opencode/commands/awa-validate-alignment.md +0 -3
- package/templates/awa/.opencode/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.qwen/commands/awa-validate-alignment.md +0 -3
- package/templates/awa/.qwen/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.roo/skills/awa-validate-alignment/SKILL.md +0 -3
- package/templates/awa/.windsurf/skills/awa-validate-alignment/SKILL.md +0 -3
- 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.
|
|
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/
|
|
16
|
-
|
|
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
|
|
1224
|
+
return readFile5(path, "utf-8");
|
|
70
1225
|
}
|
|
71
1226
|
async function readBinaryFile(path) {
|
|
72
|
-
return
|
|
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 =
|
|
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
|
|
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
|
|
1257
|
+
return join2(dirname(currentDir), "templates");
|
|
103
1258
|
}
|
|
104
|
-
return
|
|
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
|
|
1269
|
+
import chalk2 from "chalk";
|
|
115
1270
|
var Logger = class {
|
|
116
1271
|
info(message) {
|
|
117
|
-
console.log(
|
|
1272
|
+
console.log(chalk2.blue("\u2139"), message);
|
|
118
1273
|
}
|
|
119
1274
|
success(message) {
|
|
120
|
-
console.log(
|
|
1275
|
+
console.log(chalk2.green("\u2714"), message);
|
|
121
1276
|
}
|
|
122
1277
|
warn(message) {
|
|
123
|
-
console.warn(
|
|
1278
|
+
console.warn(chalk2.yellow("\u26A0"), message);
|
|
124
1279
|
}
|
|
125
1280
|
error(message) {
|
|
126
|
-
console.error(
|
|
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(
|
|
1287
|
+
console.log(chalk2.green(" + "), chalk2.dim(outputPath));
|
|
133
1288
|
break;
|
|
134
1289
|
case "overwrite":
|
|
135
|
-
console.log(
|
|
1290
|
+
console.log(chalk2.yellow(" ~ "), chalk2.dim(outputPath));
|
|
136
1291
|
break;
|
|
137
1292
|
case "skip-user":
|
|
138
|
-
console.log(
|
|
1293
|
+
console.log(chalk2.blue(" - "), chalk2.dim(outputPath), chalk2.dim("(skipped)"));
|
|
139
1294
|
break;
|
|
140
1295
|
case "skip-empty":
|
|
141
|
-
console.log(
|
|
1296
|
+
console.log(chalk2.dim(" \xB7 "), chalk2.dim(outputPath), chalk2.dim("(empty)"));
|
|
142
1297
|
break;
|
|
143
1298
|
case "skip-equal":
|
|
144
|
-
console.log(
|
|
1299
|
+
console.log(chalk2.dim(" = "), chalk2.dim(outputPath), chalk2.dim("(unchanged)"));
|
|
145
1300
|
break;
|
|
146
1301
|
case "delete":
|
|
147
|
-
console.log(
|
|
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(
|
|
1309
|
+
console.log(chalk2.bold("Summary:"));
|
|
155
1310
|
if (result.created === 0 && result.overwritten === 0 && result.deleted === 0) {
|
|
156
|
-
console.log(
|
|
1311
|
+
console.log(chalk2.yellow(" \u26A0 No files were created, overwritten, or deleted"));
|
|
157
1312
|
}
|
|
158
1313
|
if (result.created > 0) {
|
|
159
|
-
console.log(
|
|
1314
|
+
console.log(chalk2.green(` Created: ${result.created}`));
|
|
160
1315
|
}
|
|
161
1316
|
if (result.overwritten > 0) {
|
|
162
|
-
console.log(
|
|
1317
|
+
console.log(chalk2.yellow(` Overwritten: ${result.overwritten}`));
|
|
163
1318
|
}
|
|
164
1319
|
if (result.deleted > 0) {
|
|
165
|
-
console.log(
|
|
1320
|
+
console.log(chalk2.red(` Deleted: ${result.deleted}`));
|
|
166
1321
|
}
|
|
167
1322
|
if (result.skippedEqual > 0) {
|
|
168
|
-
console.log(
|
|
1323
|
+
console.log(chalk2.dim(` Skipped (equal): ${result.skippedEqual}`));
|
|
169
1324
|
}
|
|
170
1325
|
if (result.skippedUser > 0) {
|
|
171
|
-
console.log(
|
|
1326
|
+
console.log(chalk2.blue(` Skipped (user): ${result.skippedUser}`));
|
|
172
1327
|
}
|
|
173
1328
|
if (result.skippedEmpty > 0) {
|
|
174
|
-
console.log(
|
|
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(
|
|
1337
|
+
console.log(chalk2.green(line));
|
|
183
1338
|
break;
|
|
184
1339
|
case "remove":
|
|
185
|
-
console.log(
|
|
1340
|
+
console.log(chalk2.red(line));
|
|
186
1341
|
break;
|
|
187
1342
|
case "context":
|
|
188
|
-
console.log(
|
|
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(
|
|
1352
|
+
console.log(chalk2.bold(`${filesCompared} files compared, ${differences} differences`));
|
|
198
1353
|
if (!result.hasDifferences) {
|
|
199
|
-
console.log(
|
|
1354
|
+
console.log(chalk2.green("\u2714 No differences found"));
|
|
200
1355
|
}
|
|
201
|
-
console.log(
|
|
202
|
-
console.log(
|
|
1356
|
+
console.log(chalk2.bold("Summary:"));
|
|
1357
|
+
console.log(chalk2.dim(` Identical: ${result.identical}`));
|
|
203
1358
|
if (result.modified > 0) {
|
|
204
|
-
console.log(
|
|
1359
|
+
console.log(chalk2.yellow(` Modified: ${result.modified}`));
|
|
205
1360
|
}
|
|
206
1361
|
if (result.newFiles > 0) {
|
|
207
|
-
console.log(
|
|
1362
|
+
console.log(chalk2.green(` New: ${result.newFiles}`));
|
|
208
1363
|
}
|
|
209
1364
|
if (result.extraFiles > 0) {
|
|
210
|
-
console.log(
|
|
1365
|
+
console.log(chalk2.red(` Extra: ${result.extraFiles}`));
|
|
211
1366
|
}
|
|
212
1367
|
if (result.binaryDiffers > 0) {
|
|
213
|
-
console.log(
|
|
1368
|
+
console.log(chalk2.red(` Binary differs: ${result.binaryDiffers}`));
|
|
214
1369
|
}
|
|
215
1370
|
if (result.deleteListed > 0) {
|
|
216
|
-
console.log(
|
|
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
|
|
1881
|
+
var batchRunner = new BatchRunner();
|
|
434
1882
|
|
|
435
1883
|
// src/core/differ.ts
|
|
436
1884
|
import { tmpdir } from "os";
|
|
437
|
-
import { join as
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 ? ` ${
|
|
1941
|
+
const hint = opt.hint ? ` ${chalk3.dim(`(${opt.hint})`)}` : "";
|
|
494
1942
|
switch (state) {
|
|
495
1943
|
case "active":
|
|
496
|
-
return `${
|
|
1944
|
+
return `${chalk3.cyan(_UNCHECKED_A)} ${label}${hint}`;
|
|
497
1945
|
case "selected":
|
|
498
|
-
return `${
|
|
1946
|
+
return `${chalk3.red(_CHECKED)} ${chalk3.dim(label)}${hint}`;
|
|
499
1947
|
case "active-selected":
|
|
500
|
-
return `${
|
|
1948
|
+
return `${chalk3.red(_CHECKED)} ${label}${hint}`;
|
|
501
1949
|
case "cancelled":
|
|
502
|
-
return
|
|
1950
|
+
return chalk3.strikethrough(chalk3.dim(label));
|
|
503
1951
|
case "submitted":
|
|
504
|
-
return
|
|
1952
|
+
return chalk3.dim(label);
|
|
505
1953
|
default:
|
|
506
|
-
return `${
|
|
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 = `${
|
|
518
|
-
${
|
|
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}${
|
|
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(
|
|
533
|
-
return `${header}${
|
|
534
|
-
${
|
|
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}${
|
|
538
|
-
${
|
|
539
|
-
${
|
|
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, {
|
|
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 =
|
|
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
|
|
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 =
|
|
910
|
-
const targetFilePath =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
2745
|
+
async function runDiff(diffOptions, options, mergedDir) {
|
|
1169
2746
|
try {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
|
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
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
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
|
|
3116
|
+
templatePath,
|
|
1290
3117
|
outputPath: options.output,
|
|
1291
3118
|
features,
|
|
1292
3119
|
force: options.force,
|
|
1293
|
-
dryRun:
|
|
3120
|
+
dryRun: effectiveDryRun,
|
|
1294
3121
|
delete: options.delete
|
|
1295
3122
|
});
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|