@ncoderz/awa 1.0.0 → 1.2.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/chunk-3SSUJFKN.js +625 -0
- package/dist/chunk-3SSUJFKN.js.map +1 -0
- package/dist/config-2TOQATI3.js +10 -0
- package/dist/config-2TOQATI3.js.map +1 -0
- package/dist/index.js +2190 -414
- 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/skills/awa-usage/SKILL.md +3 -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/.agents/skills/awa-usage/SKILL.md +3 -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/.claude/skills/awa-usage/SKILL.md +3 -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/.gemini/skills/awa-usage/SKILL.md +3 -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/.github/skills/awa-usage/SKILL.md +8 -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/skills/awa-usage/SKILL.md +3 -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/.opencode/skills/awa-usage/SKILL.md +3 -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/.qwen/skills/awa-usage/SKILL.md +3 -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/.roo/skills/awa-usage/SKILL.md +3 -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/.windsurf/skills/awa-usage/SKILL.md +3 -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/_skill.awa-usage.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/_partials/awa.usage.md +265 -0
- 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
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ConfigError,
|
|
4
|
+
DiffError,
|
|
5
|
+
GenerationError,
|
|
6
|
+
TemplateError,
|
|
7
|
+
configLoader,
|
|
8
|
+
deleteFile,
|
|
9
|
+
ensureDir,
|
|
10
|
+
getCacheDir,
|
|
11
|
+
getTemplateDir,
|
|
12
|
+
logger,
|
|
13
|
+
pathExists,
|
|
14
|
+
readBinaryFile,
|
|
15
|
+
readTextFile,
|
|
16
|
+
rmDir,
|
|
17
|
+
walkDirectory,
|
|
18
|
+
writeTextFile
|
|
19
|
+
} from "./chunk-3SSUJFKN.js";
|
|
2
20
|
|
|
3
21
|
// src/cli/index.ts
|
|
4
22
|
import { Command } from "commander";
|
|
@@ -6,431 +24,1336 @@ import { Command } from "commander";
|
|
|
6
24
|
// src/_generated/package_info.ts
|
|
7
25
|
var PACKAGE_INFO = {
|
|
8
26
|
"name": "@ncoderz/awa",
|
|
9
|
-
"version": "1.
|
|
27
|
+
"version": "1.2.0",
|
|
10
28
|
"author": "Richard Sewell <richard.sewell@ncoderz.com>",
|
|
11
29
|
"license": "MIT",
|
|
12
30
|
"description": "awa is an Agent Workflow for AIs. It is also a CLI tool to powerfully manage agent workflow files using templates."
|
|
13
31
|
};
|
|
14
32
|
|
|
15
|
-
// src/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
// src/core/check/code-spec-checker.ts
|
|
34
|
+
function checkCodeAgainstSpec(markers, specs, config) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
const idRegex = new RegExp(`^${config.idPattern}$`);
|
|
37
|
+
for (const marker of markers.markers) {
|
|
38
|
+
if (marker.type === "component") {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!idRegex.test(marker.id)) {
|
|
42
|
+
findings.push({
|
|
43
|
+
severity: "error",
|
|
44
|
+
code: "invalid-id-format",
|
|
45
|
+
message: `Marker ID '${marker.id}' does not match expected pattern: ${config.idPattern}`,
|
|
46
|
+
filePath: marker.filePath,
|
|
47
|
+
line: marker.line,
|
|
48
|
+
id: marker.id
|
|
49
|
+
});
|
|
50
|
+
}
|
|
26
51
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
for (const marker of markers.markers) {
|
|
53
|
+
if (marker.type === "component") {
|
|
54
|
+
if (!specs.componentNames.has(marker.id)) {
|
|
55
|
+
findings.push({
|
|
56
|
+
severity: "error",
|
|
57
|
+
code: "orphaned-marker",
|
|
58
|
+
message: `Component marker '${marker.id}' not found in any spec file`,
|
|
59
|
+
filePath: marker.filePath,
|
|
60
|
+
line: marker.line,
|
|
61
|
+
id: marker.id
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
if (!specs.allIds.has(marker.id)) {
|
|
66
|
+
findings.push({
|
|
67
|
+
severity: "error",
|
|
68
|
+
code: "orphaned-marker",
|
|
69
|
+
message: `Marker '${marker.id}' not found in any spec file`,
|
|
70
|
+
filePath: marker.filePath,
|
|
71
|
+
line: marker.line,
|
|
72
|
+
id: marker.id
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
34
76
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
77
|
+
const testedIds = new Set(markers.markers.filter((m) => m.type === "test").map((m) => m.id));
|
|
78
|
+
for (const acId of specs.acIds) {
|
|
79
|
+
if (!testedIds.has(acId)) {
|
|
80
|
+
const loc = specs.idLocations.get(acId);
|
|
81
|
+
const specFile = loc ? void 0 : specs.specFiles.find((sf) => sf.acIds.includes(acId));
|
|
82
|
+
findings.push({
|
|
83
|
+
severity: "warning",
|
|
84
|
+
code: "uncovered-ac",
|
|
85
|
+
message: `Acceptance criterion '${acId}' has no @awa-test reference`,
|
|
86
|
+
filePath: loc?.filePath ?? specFile?.filePath,
|
|
87
|
+
line: loc?.line,
|
|
88
|
+
id: acId
|
|
89
|
+
});
|
|
90
|
+
}
|
|
42
91
|
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
92
|
+
return { findings };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/core/check/marker-scanner.ts
|
|
96
|
+
import { readFile } from "fs/promises";
|
|
97
|
+
|
|
98
|
+
// src/core/check/glob.ts
|
|
99
|
+
import { glob } from "fs/promises";
|
|
100
|
+
function matchSimpleGlob(path, pattern) {
|
|
101
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "<<GLOBSTAR>>").replace(/\*/g, "[^/]*").replace(/<<GLOBSTAR>>/g, ".*");
|
|
102
|
+
return new RegExp(`(^|/)${regex}($|/)`).test(path);
|
|
103
|
+
}
|
|
104
|
+
async function collectFiles(globs, ignore) {
|
|
105
|
+
const files = [];
|
|
106
|
+
const dirPrefixes = ignore.filter((ig) => ig.endsWith("/**")).map((ig) => ig.slice(0, -3));
|
|
107
|
+
for (const pattern of globs) {
|
|
108
|
+
for await (const filePath of glob(pattern, {
|
|
109
|
+
exclude: (p) => dirPrefixes.includes(p) || ignore.some((ig) => matchSimpleGlob(p, ig))
|
|
110
|
+
})) {
|
|
111
|
+
if (!ignore.some((ig) => matchSimpleGlob(filePath, ig))) {
|
|
112
|
+
files.push(filePath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
49
115
|
}
|
|
50
|
-
|
|
116
|
+
return [...new Set(files)];
|
|
117
|
+
}
|
|
51
118
|
|
|
52
|
-
// src/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
// src/core/check/marker-scanner.ts
|
|
120
|
+
var MARKER_TYPE_MAP = {
|
|
121
|
+
"@awa-impl": "impl",
|
|
122
|
+
"@awa-test": "test",
|
|
123
|
+
"@awa-component": "component"
|
|
124
|
+
};
|
|
125
|
+
var IGNORE_FILE_RE = /@awa-ignore-file\b/;
|
|
126
|
+
var IGNORE_NEXT_LINE_RE = /@awa-ignore-next-line\b/;
|
|
127
|
+
var IGNORE_LINE_RE = /@awa-ignore\b/;
|
|
128
|
+
var IGNORE_START_RE = /@awa-ignore-start\b/;
|
|
129
|
+
var IGNORE_END_RE = /@awa-ignore-end\b/;
|
|
130
|
+
async function scanMarkers(config) {
|
|
131
|
+
const files = await collectCodeFiles(config.codeGlobs, config.codeIgnore);
|
|
132
|
+
const markers = [];
|
|
133
|
+
const findings = [];
|
|
134
|
+
for (const filePath of files) {
|
|
135
|
+
if (config.ignoreMarkers.some((ig) => matchSimpleGlob(filePath, ig))) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const result = await scanFile(filePath, config.markers);
|
|
139
|
+
markers.push(...result.markers);
|
|
140
|
+
findings.push(...result.findings);
|
|
141
|
+
}
|
|
142
|
+
return { markers, findings };
|
|
143
|
+
}
|
|
144
|
+
function buildMarkerRegex(markerNames) {
|
|
145
|
+
const escaped = markerNames.map((m) => m.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
146
|
+
return new RegExp(`(${escaped.join("|")}):\\s*(.+)`, "g");
|
|
59
147
|
}
|
|
60
|
-
|
|
148
|
+
var ID_TOKEN_RE = /^([A-Z][A-Z0-9]*(?:[-_][A-Za-z0-9]+)*(?:\.\d+)?)/;
|
|
149
|
+
async function scanFile(filePath, markerNames) {
|
|
150
|
+
let content;
|
|
61
151
|
try {
|
|
62
|
-
await
|
|
63
|
-
return true;
|
|
152
|
+
content = await readFile(filePath, "utf-8");
|
|
64
153
|
} catch {
|
|
65
|
-
return
|
|
154
|
+
return { markers: [], findings: [] };
|
|
66
155
|
}
|
|
156
|
+
if (IGNORE_FILE_RE.test(content)) {
|
|
157
|
+
return { markers: [], findings: [] };
|
|
158
|
+
}
|
|
159
|
+
const regex = buildMarkerRegex(markerNames);
|
|
160
|
+
const lines = content.split("\n");
|
|
161
|
+
const markers = [];
|
|
162
|
+
const findings = [];
|
|
163
|
+
let ignoreNextLine = false;
|
|
164
|
+
let ignoreBlock = false;
|
|
165
|
+
for (const [i, line] of lines.entries()) {
|
|
166
|
+
if (IGNORE_START_RE.test(line)) {
|
|
167
|
+
ignoreBlock = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (IGNORE_END_RE.test(line)) {
|
|
171
|
+
ignoreBlock = false;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ignoreBlock) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ignoreNextLine) {
|
|
178
|
+
ignoreNextLine = false;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (IGNORE_NEXT_LINE_RE.test(line)) {
|
|
182
|
+
ignoreNextLine = true;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (IGNORE_LINE_RE.test(line)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
regex.lastIndex = 0;
|
|
189
|
+
let match = regex.exec(line);
|
|
190
|
+
while (match !== null) {
|
|
191
|
+
const markerName = match[1] ?? "";
|
|
192
|
+
const idsRaw = match[2] ?? "";
|
|
193
|
+
const type = resolveMarkerType(markerName, markerNames);
|
|
194
|
+
const ids = idsRaw.split(",").map((id) => id.trim()).filter(Boolean);
|
|
195
|
+
for (const id of ids) {
|
|
196
|
+
const tokenMatch = ID_TOKEN_RE.exec(id);
|
|
197
|
+
const cleanId = tokenMatch?.[1]?.trim() ?? "";
|
|
198
|
+
if (cleanId && tokenMatch) {
|
|
199
|
+
const remainder = id.slice(tokenMatch[0].length).trim();
|
|
200
|
+
if (remainder) {
|
|
201
|
+
findings.push({
|
|
202
|
+
severity: "error",
|
|
203
|
+
code: "marker-trailing-text",
|
|
204
|
+
message: `Marker has trailing text after ID '${cleanId}': '${remainder}' \u2014 use comma-separated IDs only`,
|
|
205
|
+
filePath,
|
|
206
|
+
line: i + 1,
|
|
207
|
+
id: cleanId
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
markers.push({ type, id: cleanId, filePath, line: i + 1 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
match = regex.exec(line);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { markers, findings };
|
|
67
217
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
218
|
+
function resolveMarkerType(markerName, configuredMarkers) {
|
|
219
|
+
const mapped = MARKER_TYPE_MAP[markerName];
|
|
220
|
+
if (mapped) return mapped;
|
|
221
|
+
const idx = configuredMarkers.indexOf(markerName);
|
|
222
|
+
if (idx === 1) return "test";
|
|
223
|
+
if (idx === 2) return "component";
|
|
224
|
+
return "impl";
|
|
73
225
|
}
|
|
74
|
-
async function
|
|
75
|
-
|
|
76
|
-
await writeFile(path, content, "utf-8");
|
|
226
|
+
async function collectCodeFiles(codeGlobs, ignore) {
|
|
227
|
+
return collectFiles(codeGlobs, ignore);
|
|
77
228
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
yield* walkDirectory(fullPath);
|
|
87
|
-
} else if (entry.isFile()) {
|
|
88
|
-
if (entry.name.startsWith("_")) {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
yield fullPath;
|
|
92
|
-
}
|
|
229
|
+
|
|
230
|
+
// src/core/check/reporter.ts
|
|
231
|
+
import chalk from "chalk";
|
|
232
|
+
function report(findings, format) {
|
|
233
|
+
if (format === "json") {
|
|
234
|
+
reportJson(findings);
|
|
235
|
+
} else {
|
|
236
|
+
reportText(findings);
|
|
93
237
|
}
|
|
94
238
|
}
|
|
95
|
-
function
|
|
96
|
-
|
|
239
|
+
function reportJson(findings) {
|
|
240
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
241
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
242
|
+
const output = {
|
|
243
|
+
valid: errors.length === 0,
|
|
244
|
+
errors: errors.length,
|
|
245
|
+
warnings: warnings.length,
|
|
246
|
+
findings: findings.map((f) => ({
|
|
247
|
+
severity: f.severity,
|
|
248
|
+
code: f.code,
|
|
249
|
+
message: f.message,
|
|
250
|
+
...f.filePath ? { filePath: f.filePath } : {},
|
|
251
|
+
...f.line ? { line: f.line } : {},
|
|
252
|
+
...f.id ? { id: f.id } : {},
|
|
253
|
+
...f.ruleSource ? { ruleSource: f.ruleSource } : {},
|
|
254
|
+
...f.rule ? { rule: f.rule } : {}
|
|
255
|
+
}))
|
|
256
|
+
};
|
|
257
|
+
console.log(JSON.stringify(output, null, 2));
|
|
97
258
|
}
|
|
98
|
-
function
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
259
|
+
function reportText(findings) {
|
|
260
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
261
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
262
|
+
if (errors.length > 0) {
|
|
263
|
+
console.log(chalk.red(`
|
|
264
|
+
${errors.length} error(s):
|
|
265
|
+
`));
|
|
266
|
+
for (const f of errors) {
|
|
267
|
+
const location = formatLocation(f.filePath, f.line);
|
|
268
|
+
console.log(chalk.red(" \u2716"), f.message, location ? chalk.dim(location) : "");
|
|
269
|
+
printRuleContext(f);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (warnings.length > 0) {
|
|
273
|
+
console.log(chalk.yellow(`
|
|
274
|
+
${warnings.length} warning(s):
|
|
275
|
+
`));
|
|
276
|
+
for (const f of warnings) {
|
|
277
|
+
const location = formatLocation(f.filePath, f.line);
|
|
278
|
+
console.log(chalk.yellow(" \u26A0"), f.message, location ? chalk.dim(location) : "");
|
|
279
|
+
printRuleContext(f);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
283
|
+
console.log(chalk.green("\n\u2714 Validation passed \u2014 no issues found"));
|
|
284
|
+
} else {
|
|
285
|
+
console.log("");
|
|
286
|
+
const parts = [];
|
|
287
|
+
if (errors.length > 0) parts.push(chalk.red(`${errors.length} error(s)`));
|
|
288
|
+
if (warnings.length > 0) parts.push(chalk.yellow(`${warnings.length} warning(s)`));
|
|
289
|
+
console.log(`Summary: ${parts.join(", ")}`);
|
|
103
290
|
}
|
|
104
|
-
return join(currentDir, "..", "..", "templates");
|
|
105
291
|
}
|
|
106
|
-
|
|
107
|
-
|
|
292
|
+
function formatLocation(filePath, line) {
|
|
293
|
+
if (!filePath) return "";
|
|
294
|
+
return line ? `(${filePath}:${line})` : `(${filePath})`;
|
|
108
295
|
}
|
|
109
|
-
|
|
110
|
-
|
|
296
|
+
function printRuleContext(f) {
|
|
297
|
+
if (!f.ruleSource && !f.rule) return;
|
|
298
|
+
const parts = [];
|
|
299
|
+
if (f.ruleSource) parts.push(f.ruleSource);
|
|
300
|
+
if (f.rule) parts.push(f.rule);
|
|
301
|
+
console.log(chalk.dim(` rule: ${parts.join(" \u2014 ")}`));
|
|
111
302
|
}
|
|
112
303
|
|
|
113
|
-
// src/
|
|
114
|
-
import
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
304
|
+
// src/core/check/rule-loader.ts
|
|
305
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
306
|
+
import { join } from "path";
|
|
307
|
+
import { parse as parseYaml } from "yaml";
|
|
308
|
+
async function loadRules(schemaDir) {
|
|
309
|
+
const pattern = join(schemaDir, "*.schema.yaml");
|
|
310
|
+
const files = await collectFiles([pattern], []);
|
|
311
|
+
const results = [];
|
|
312
|
+
for (const filePath of files) {
|
|
313
|
+
const ruleSet = await loadRuleFile(filePath);
|
|
314
|
+
if (ruleSet) {
|
|
315
|
+
results.push(ruleSet);
|
|
316
|
+
}
|
|
118
317
|
}
|
|
119
|
-
|
|
120
|
-
|
|
318
|
+
return results;
|
|
319
|
+
}
|
|
320
|
+
function matchesTargetGlob(filePath, targetGlob) {
|
|
321
|
+
return matchSimpleGlob(filePath, targetGlob);
|
|
322
|
+
}
|
|
323
|
+
async function loadRuleFile(filePath) {
|
|
324
|
+
let content;
|
|
325
|
+
try {
|
|
326
|
+
content = await readFile2(filePath, "utf-8");
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
121
329
|
}
|
|
122
|
-
|
|
123
|
-
|
|
330
|
+
const parsed = parseYaml(content);
|
|
331
|
+
if (!parsed || typeof parsed !== "object") {
|
|
332
|
+
throw new RuleValidationError(`Rule file is not a valid YAML object: ${filePath}`);
|
|
124
333
|
}
|
|
125
|
-
|
|
126
|
-
|
|
334
|
+
const raw = parsed;
|
|
335
|
+
const ruleFile = validateRuleFile(raw, filePath);
|
|
336
|
+
return {
|
|
337
|
+
ruleFile,
|
|
338
|
+
sourcePath: filePath,
|
|
339
|
+
targetGlob: ruleFile["target-files"]
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function validateRuleFile(raw, filePath) {
|
|
343
|
+
if (typeof raw["target-files"] !== "string" || raw["target-files"].length === 0) {
|
|
344
|
+
throw new RuleValidationError(`Missing or empty 'target-files' in ${filePath}`);
|
|
127
345
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
case "overwrite":
|
|
135
|
-
console.log(chalk.yellow(" ~ "), chalk.dim(outputPath));
|
|
136
|
-
break;
|
|
137
|
-
case "skip-user":
|
|
138
|
-
console.log(chalk.blue(" - "), chalk.dim(outputPath), chalk.dim("(skipped)"));
|
|
139
|
-
break;
|
|
140
|
-
case "skip-empty":
|
|
141
|
-
console.log(chalk.dim(" \xB7 "), chalk.dim(outputPath), chalk.dim("(empty)"));
|
|
142
|
-
break;
|
|
143
|
-
case "skip-equal":
|
|
144
|
-
console.log(chalk.dim(" = "), chalk.dim(outputPath), chalk.dim("(unchanged)"));
|
|
145
|
-
break;
|
|
146
|
-
case "delete":
|
|
147
|
-
console.log(chalk.red(" \u2716 "), chalk.dim(outputPath), chalk.red("(deleted)"));
|
|
148
|
-
break;
|
|
346
|
+
let sections;
|
|
347
|
+
if (raw.sections !== void 0) {
|
|
348
|
+
if (!Array.isArray(raw.sections) || raw.sections.length === 0) {
|
|
349
|
+
throw new RuleValidationError(
|
|
350
|
+
`'sections' must be a non-empty array if present in ${filePath}`
|
|
351
|
+
);
|
|
149
352
|
}
|
|
353
|
+
sections = raw.sections.map(
|
|
354
|
+
(s, i) => validateSectionRule(s, `sections[${i}]`, filePath)
|
|
355
|
+
);
|
|
150
356
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (result.created === 0 && result.overwritten === 0 && result.deleted === 0) {
|
|
156
|
-
console.log(chalk.yellow(" \u26A0 No files were created, overwritten, or deleted"));
|
|
357
|
+
let sectionsProhibited;
|
|
358
|
+
if (raw["sections-prohibited"] !== void 0) {
|
|
359
|
+
if (!Array.isArray(raw["sections-prohibited"]) || !raw["sections-prohibited"].every((v) => typeof v === "string")) {
|
|
360
|
+
throw new RuleValidationError(`'sections-prohibited' must be a string array in ${filePath}`);
|
|
157
361
|
}
|
|
158
|
-
|
|
159
|
-
|
|
362
|
+
sectionsProhibited = raw["sections-prohibited"];
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
"target-files": raw["target-files"],
|
|
366
|
+
...typeof raw.description === "string" ? { description: raw.description } : {},
|
|
367
|
+
...typeof raw["line-limit"] === "number" && raw["line-limit"] > 0 ? { "line-limit": raw["line-limit"] } : {},
|
|
368
|
+
sections: sections ?? [],
|
|
369
|
+
...sectionsProhibited ? { "sections-prohibited": sectionsProhibited } : {},
|
|
370
|
+
...typeof raw.example === "string" ? { example: raw.example } : {}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function validateSectionRule(raw, path, filePath) {
|
|
374
|
+
if (!raw || typeof raw !== "object") {
|
|
375
|
+
throw new RuleValidationError(`${path} must be an object in ${filePath}`);
|
|
376
|
+
}
|
|
377
|
+
const section = raw;
|
|
378
|
+
if (typeof section.heading !== "string" || section.heading.length === 0) {
|
|
379
|
+
throw new RuleValidationError(`${path}.heading must be a non-empty string in ${filePath}`);
|
|
380
|
+
}
|
|
381
|
+
if (typeof section.level !== "number" || section.level < 1 || section.level > 6) {
|
|
382
|
+
throw new RuleValidationError(`${path}.level must be 1-6 in ${filePath}`);
|
|
383
|
+
}
|
|
384
|
+
validatePattern(section.heading, `${path}.heading`, filePath);
|
|
385
|
+
let contains;
|
|
386
|
+
if (section.contains !== void 0) {
|
|
387
|
+
if (!Array.isArray(section.contains)) {
|
|
388
|
+
throw new RuleValidationError(`${path}.contains must be an array in ${filePath}`);
|
|
160
389
|
}
|
|
161
|
-
|
|
162
|
-
|
|
390
|
+
contains = section.contains.map(
|
|
391
|
+
(c, i) => validateContainsRule(c, `${path}.contains[${i}]`, filePath)
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
let children;
|
|
395
|
+
if (section.children !== void 0) {
|
|
396
|
+
if (!Array.isArray(section.children)) {
|
|
397
|
+
throw new RuleValidationError(`${path}.children must be an array in ${filePath}`);
|
|
163
398
|
}
|
|
164
|
-
|
|
165
|
-
|
|
399
|
+
children = section.children.map(
|
|
400
|
+
(c, i) => validateSectionRule(c, `${path}.children[${i}]`, filePath)
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
heading: section.heading,
|
|
405
|
+
level: section.level,
|
|
406
|
+
...typeof section.required === "boolean" ? { required: section.required } : {},
|
|
407
|
+
...typeof section.repeatable === "boolean" ? { repeatable: section.repeatable } : {},
|
|
408
|
+
...typeof section.description === "string" ? { description: section.description } : {},
|
|
409
|
+
...contains ? { contains } : {},
|
|
410
|
+
...children ? { children } : {}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function validateContainsRule(raw, path, filePath) {
|
|
414
|
+
if (!raw || typeof raw !== "object") {
|
|
415
|
+
throw new RuleValidationError(`${path} must be an object in ${filePath}`);
|
|
416
|
+
}
|
|
417
|
+
const rule = raw;
|
|
418
|
+
const when = rule.when !== void 0 ? validateWhenCondition(rule.when, `${path}.when`, filePath) : void 0;
|
|
419
|
+
if (typeof rule.pattern === "string") {
|
|
420
|
+
validatePattern(rule.pattern, `${path}.pattern`, filePath);
|
|
421
|
+
return {
|
|
422
|
+
pattern: rule.pattern,
|
|
423
|
+
...typeof rule.label === "string" ? { label: rule.label } : {},
|
|
424
|
+
...typeof rule.description === "string" ? { description: rule.description } : {},
|
|
425
|
+
...typeof rule.required === "boolean" ? { required: rule.required } : {},
|
|
426
|
+
...typeof rule.prohibited === "boolean" ? { prohibited: rule.prohibited } : {},
|
|
427
|
+
...when ? { when } : {}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (rule.list && typeof rule.list === "object") {
|
|
431
|
+
const list = rule.list;
|
|
432
|
+
if (typeof list.pattern !== "string") {
|
|
433
|
+
throw new RuleValidationError(`${path}.list.pattern must be a string in ${filePath}`);
|
|
166
434
|
}
|
|
167
|
-
|
|
168
|
-
|
|
435
|
+
validatePattern(list.pattern, `${path}.list.pattern`, filePath);
|
|
436
|
+
return {
|
|
437
|
+
list: {
|
|
438
|
+
pattern: list.pattern,
|
|
439
|
+
...typeof list.min === "number" ? { min: list.min } : {},
|
|
440
|
+
...typeof list.label === "string" ? { label: list.label } : {}
|
|
441
|
+
},
|
|
442
|
+
...typeof rule.description === "string" ? { description: rule.description } : {},
|
|
443
|
+
...when ? { when } : {}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (rule.table && typeof rule.table === "object") {
|
|
447
|
+
const table = rule.table;
|
|
448
|
+
if (!Array.isArray(table.columns) || !table.columns.every((c) => typeof c === "string")) {
|
|
449
|
+
throw new RuleValidationError(`${path}.table.columns must be a string array in ${filePath}`);
|
|
169
450
|
}
|
|
170
|
-
|
|
171
|
-
|
|
451
|
+
return {
|
|
452
|
+
table: {
|
|
453
|
+
...typeof table.heading === "string" ? { heading: table.heading } : {},
|
|
454
|
+
columns: table.columns,
|
|
455
|
+
...typeof table["min-rows"] === "number" ? { "min-rows": table["min-rows"] } : {}
|
|
456
|
+
},
|
|
457
|
+
...typeof rule.description === "string" ? { description: rule.description } : {},
|
|
458
|
+
...when ? { when } : {}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (rule["code-block"] === true) {
|
|
462
|
+
return {
|
|
463
|
+
"code-block": true,
|
|
464
|
+
...typeof rule.label === "string" ? { label: rule.label } : {},
|
|
465
|
+
...typeof rule.description === "string" ? { description: rule.description } : {},
|
|
466
|
+
...when ? { when } : {}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (typeof rule["heading-or-text"] === "string") {
|
|
470
|
+
return {
|
|
471
|
+
"heading-or-text": rule["heading-or-text"],
|
|
472
|
+
...typeof rule.required === "boolean" ? { required: rule.required } : {},
|
|
473
|
+
...typeof rule.description === "string" ? { description: rule.description } : {},
|
|
474
|
+
...when ? { when } : {}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
throw new RuleValidationError(`${path} has no recognized rule type in ${filePath}`);
|
|
478
|
+
}
|
|
479
|
+
function validateWhenCondition(raw, path, filePath) {
|
|
480
|
+
if (!raw || typeof raw !== "object") {
|
|
481
|
+
throw new RuleValidationError(`${path} must be an object in ${filePath}`);
|
|
482
|
+
}
|
|
483
|
+
const condition = raw;
|
|
484
|
+
const result = {};
|
|
485
|
+
if (typeof condition["heading-matches"] === "string") {
|
|
486
|
+
validatePattern(condition["heading-matches"], `${path}.heading-matches`, filePath);
|
|
487
|
+
result["heading-matches"] = condition["heading-matches"];
|
|
488
|
+
}
|
|
489
|
+
if (typeof condition["heading-not-matches"] === "string") {
|
|
490
|
+
validatePattern(condition["heading-not-matches"], `${path}.heading-not-matches`, filePath);
|
|
491
|
+
result["heading-not-matches"] = condition["heading-not-matches"];
|
|
492
|
+
}
|
|
493
|
+
if (!result["heading-matches"] && !result["heading-not-matches"]) {
|
|
494
|
+
throw new RuleValidationError(
|
|
495
|
+
`${path} must have 'heading-matches' or 'heading-not-matches' in ${filePath}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
function validatePattern(pattern, path, filePath) {
|
|
501
|
+
if (/[.+*?^${}()|[\]\\]/.test(pattern)) {
|
|
502
|
+
try {
|
|
503
|
+
new RegExp(pattern);
|
|
504
|
+
} catch (e) {
|
|
505
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
506
|
+
throw new RuleValidationError(`Invalid regex in ${path}: ${msg} (${filePath})`);
|
|
172
507
|
}
|
|
173
|
-
|
|
174
|
-
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
var RuleValidationError = class extends Error {
|
|
511
|
+
constructor(message) {
|
|
512
|
+
super(message);
|
|
513
|
+
this.name = "RuleValidationError";
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// src/core/check/schema-checker.ts
|
|
518
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
519
|
+
import remarkGfm from "remark-gfm";
|
|
520
|
+
import remarkParse from "remark-parse";
|
|
521
|
+
import { unified } from "unified";
|
|
522
|
+
function formatSectionRule(rule) {
|
|
523
|
+
const parts = [`section: heading="${rule.heading}" level=${rule.level}`];
|
|
524
|
+
if (rule.required) parts.push("required");
|
|
525
|
+
if (rule.repeatable) parts.push("repeatable");
|
|
526
|
+
return parts.join(" ");
|
|
527
|
+
}
|
|
528
|
+
function formatContainsRule(rule) {
|
|
529
|
+
if ("pattern" in rule && typeof rule.pattern === "string") {
|
|
530
|
+
const r = rule;
|
|
531
|
+
if (r.prohibited) return `contains: prohibited pattern="${r.pattern}"`;
|
|
532
|
+
return `contains: pattern="${r.pattern}"${r.required === false ? "" : " required"}`;
|
|
533
|
+
}
|
|
534
|
+
if ("list" in rule) {
|
|
535
|
+
const r = rule;
|
|
536
|
+
return `contains: list pattern="${r.list.pattern}"${r.list.min !== void 0 ? ` min=${r.list.min}` : ""}`;
|
|
537
|
+
}
|
|
538
|
+
if ("table" in rule) {
|
|
539
|
+
const r = rule;
|
|
540
|
+
return `contains: table columns=[${r.table.columns.join(", ")}]${r.table["min-rows"] !== void 0 ? ` min-rows=${r.table["min-rows"]}` : ""}`;
|
|
541
|
+
}
|
|
542
|
+
if ("code-block" in rule) {
|
|
543
|
+
return "contains: code-block";
|
|
544
|
+
}
|
|
545
|
+
if ("heading-or-text" in rule) {
|
|
546
|
+
const r = rule;
|
|
547
|
+
return `contains: heading-or-text="${r["heading-or-text"]}"`;
|
|
548
|
+
}
|
|
549
|
+
return "contains: (unknown rule)";
|
|
550
|
+
}
|
|
551
|
+
function formatLineLimitRule(limit) {
|
|
552
|
+
return `line-limit: ${limit}`;
|
|
553
|
+
}
|
|
554
|
+
function formatProhibitedRule(pattern) {
|
|
555
|
+
return `sections-prohibited: "${pattern}"`;
|
|
556
|
+
}
|
|
557
|
+
async function checkSchemasAsync(specFiles, ruleSets) {
|
|
558
|
+
const findings = [];
|
|
559
|
+
const parser = unified().use(remarkParse).use(remarkGfm);
|
|
560
|
+
for (const spec of specFiles) {
|
|
561
|
+
const matchingRules = ruleSets.filter((rs) => matchesTargetGlob(spec.filePath, rs.targetGlob));
|
|
562
|
+
if (matchingRules.length === 0) continue;
|
|
563
|
+
let content;
|
|
564
|
+
try {
|
|
565
|
+
content = await readFile3(spec.filePath, "utf-8");
|
|
566
|
+
} catch {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const tree = parser.parse(content);
|
|
570
|
+
const sectionTree = buildSectionTree(tree);
|
|
571
|
+
const allSections = flattenSections(sectionTree);
|
|
572
|
+
for (const ruleSet of matchingRules) {
|
|
573
|
+
const ruleSource = ruleSet.sourcePath;
|
|
574
|
+
if (ruleSet.ruleFile["line-limit"] !== void 0) {
|
|
575
|
+
const lineCount = content.split("\n").length;
|
|
576
|
+
if (lineCount > ruleSet.ruleFile["line-limit"]) {
|
|
577
|
+
findings.push({
|
|
578
|
+
severity: "warning",
|
|
579
|
+
code: "schema-line-limit",
|
|
580
|
+
message: `File has ${lineCount} lines, exceeds limit of ${ruleSet.ruleFile["line-limit"]}`,
|
|
581
|
+
filePath: spec.filePath,
|
|
582
|
+
ruleSource,
|
|
583
|
+
rule: formatLineLimitRule(ruleSet.ruleFile["line-limit"])
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
findings.push(
|
|
588
|
+
...checkRulesAgainstSections(
|
|
589
|
+
allSections,
|
|
590
|
+
ruleSet.ruleFile.sections,
|
|
591
|
+
spec.filePath,
|
|
592
|
+
ruleSource
|
|
593
|
+
)
|
|
594
|
+
);
|
|
595
|
+
if (ruleSet.ruleFile["sections-prohibited"]) {
|
|
596
|
+
findings.push(
|
|
597
|
+
...checkProhibited(
|
|
598
|
+
content,
|
|
599
|
+
ruleSet.ruleFile["sections-prohibited"],
|
|
600
|
+
spec.filePath,
|
|
601
|
+
ruleSource
|
|
602
|
+
)
|
|
603
|
+
);
|
|
604
|
+
}
|
|
175
605
|
}
|
|
176
|
-
console.log("");
|
|
177
606
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
607
|
+
return { findings };
|
|
608
|
+
}
|
|
609
|
+
function buildSectionTree(tree) {
|
|
610
|
+
return buildSectionsFromNodes(tree.children, 0, 0).sections;
|
|
611
|
+
}
|
|
612
|
+
function buildSectionsFromNodes(nodes, start, parentLevel) {
|
|
613
|
+
const sections = [];
|
|
614
|
+
let i = start;
|
|
615
|
+
while (i < nodes.length) {
|
|
616
|
+
const node = nodes[i];
|
|
617
|
+
if (!node) break;
|
|
618
|
+
if (node.type === "heading") {
|
|
619
|
+
const h = node;
|
|
620
|
+
if (parentLevel > 0 && h.depth <= parentLevel) break;
|
|
621
|
+
const headingText = extractText(h.children);
|
|
622
|
+
const contentNodes = [];
|
|
623
|
+
i++;
|
|
624
|
+
while (i < nodes.length) {
|
|
625
|
+
const next = nodes[i];
|
|
626
|
+
if (!next || next.type === "heading") break;
|
|
627
|
+
contentNodes.push(next);
|
|
628
|
+
i++;
|
|
629
|
+
}
|
|
630
|
+
const childResult = buildSectionsFromNodes(nodes, i, h.depth);
|
|
631
|
+
i = childResult.nextIndex;
|
|
632
|
+
sections.push({
|
|
633
|
+
heading: h,
|
|
634
|
+
headingText,
|
|
635
|
+
level: h.depth,
|
|
636
|
+
children: childResult.sections,
|
|
637
|
+
contentNodes
|
|
638
|
+
});
|
|
639
|
+
} else {
|
|
640
|
+
i++;
|
|
190
641
|
}
|
|
191
642
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
643
|
+
return { sections, nextIndex: i };
|
|
644
|
+
}
|
|
645
|
+
function flattenSections(sections) {
|
|
646
|
+
const result = [];
|
|
647
|
+
for (const s of sections) {
|
|
648
|
+
result.push(s);
|
|
649
|
+
result.push(...flattenSections(s.children));
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
function extractText(children) {
|
|
654
|
+
return children.map((c) => {
|
|
655
|
+
if ("value" in c) return c.value;
|
|
656
|
+
if ("children" in c) return extractText(c.children);
|
|
657
|
+
return "";
|
|
658
|
+
}).join("");
|
|
659
|
+
}
|
|
660
|
+
function checkRulesAgainstSections(allSections, rules, filePath, ruleSource) {
|
|
661
|
+
const findings = [];
|
|
662
|
+
for (const rule of rules) {
|
|
663
|
+
const matches = findMatchingSections(allSections, rule);
|
|
664
|
+
if (matches.length === 0 && rule.required) {
|
|
665
|
+
findings.push({
|
|
666
|
+
severity: "error",
|
|
667
|
+
code: "schema-missing-section",
|
|
668
|
+
message: `Missing required section: '${rule.heading}' (level ${rule.level})`,
|
|
669
|
+
filePath,
|
|
670
|
+
ruleSource,
|
|
671
|
+
rule: formatSectionRule(rule)
|
|
672
|
+
});
|
|
673
|
+
continue;
|
|
200
674
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
675
|
+
for (const match of matches) {
|
|
676
|
+
if (match.level !== rule.level) {
|
|
677
|
+
findings.push({
|
|
678
|
+
severity: "warning",
|
|
679
|
+
code: "schema-wrong-level",
|
|
680
|
+
message: `Section '${match.headingText}' is level ${match.level}, expected ${rule.level}`,
|
|
681
|
+
filePath,
|
|
682
|
+
line: match.heading.position?.start.line,
|
|
683
|
+
ruleSource,
|
|
684
|
+
rule: formatSectionRule(rule)
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (rule.contains) {
|
|
688
|
+
for (const cr of rule.contains) {
|
|
689
|
+
findings.push(...checkContainsRule(match, cr, filePath, ruleSource));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (rule.children) {
|
|
693
|
+
const childFlat = flattenSections(match.children);
|
|
694
|
+
findings.push(...checkRulesAgainstSections(childFlat, rule.children, filePath, ruleSource));
|
|
695
|
+
}
|
|
205
696
|
}
|
|
206
|
-
|
|
207
|
-
|
|
697
|
+
}
|
|
698
|
+
return findings;
|
|
699
|
+
}
|
|
700
|
+
function findMatchingSections(allSections, rule) {
|
|
701
|
+
const regex = createHeadingRegex(rule.heading);
|
|
702
|
+
const matches = allSections.filter((s) => s.level === rule.level && regex.test(s.headingText));
|
|
703
|
+
if (!rule.repeatable && matches.length > 1) {
|
|
704
|
+
return [matches[0]];
|
|
705
|
+
}
|
|
706
|
+
return matches;
|
|
707
|
+
}
|
|
708
|
+
function createHeadingRegex(pattern) {
|
|
709
|
+
if (/[.+*?^${}()|[\]\\]/.test(pattern)) {
|
|
710
|
+
try {
|
|
711
|
+
return new RegExp(`^${pattern}$`);
|
|
712
|
+
} catch {
|
|
713
|
+
return new RegExp(`^${escapeRegex(pattern)}$`);
|
|
208
714
|
}
|
|
209
|
-
|
|
210
|
-
|
|
715
|
+
}
|
|
716
|
+
return new RegExp(`^${escapeRegex(pattern)}$`, "i");
|
|
717
|
+
}
|
|
718
|
+
function escapeRegex(str) {
|
|
719
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
720
|
+
}
|
|
721
|
+
function checkContainsRule(section, rule, filePath, ruleSource) {
|
|
722
|
+
const when = "when" in rule ? rule.when : void 0;
|
|
723
|
+
if (when && !evaluateWhenCondition(when, section.headingText)) {
|
|
724
|
+
return [];
|
|
725
|
+
}
|
|
726
|
+
const formattedRule = formatContainsRule(rule);
|
|
727
|
+
if ("pattern" in rule && typeof rule.pattern === "string") {
|
|
728
|
+
return checkPatternContains(
|
|
729
|
+
section,
|
|
730
|
+
rule,
|
|
731
|
+
filePath,
|
|
732
|
+
ruleSource,
|
|
733
|
+
formattedRule
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if ("list" in rule) {
|
|
737
|
+
return checkListContains(
|
|
738
|
+
section,
|
|
739
|
+
rule,
|
|
740
|
+
filePath,
|
|
741
|
+
ruleSource,
|
|
742
|
+
formattedRule
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
if ("table" in rule) {
|
|
746
|
+
return checkTableContains(
|
|
747
|
+
section,
|
|
748
|
+
rule,
|
|
749
|
+
filePath,
|
|
750
|
+
ruleSource,
|
|
751
|
+
formattedRule
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
if ("code-block" in rule) {
|
|
755
|
+
return checkCodeBlockContains(
|
|
756
|
+
section,
|
|
757
|
+
rule,
|
|
758
|
+
filePath,
|
|
759
|
+
ruleSource,
|
|
760
|
+
formattedRule
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if ("heading-or-text" in rule) {
|
|
764
|
+
return checkHeadingOrText(
|
|
765
|
+
section,
|
|
766
|
+
rule,
|
|
767
|
+
filePath,
|
|
768
|
+
ruleSource,
|
|
769
|
+
formattedRule
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
function checkPatternContains(section, rule, filePath, ruleSource, formattedRule) {
|
|
775
|
+
const text = getFullSectionText(section);
|
|
776
|
+
const found = new RegExp(rule.pattern, "m").test(text);
|
|
777
|
+
if (rule.prohibited) {
|
|
778
|
+
if (found) {
|
|
779
|
+
return [
|
|
780
|
+
{
|
|
781
|
+
severity: "warning",
|
|
782
|
+
code: "schema-prohibited",
|
|
783
|
+
message: `Section '${section.headingText}' contains prohibited content: ${rule.label ?? rule.pattern}`,
|
|
784
|
+
filePath,
|
|
785
|
+
line: section.heading.position?.start.line,
|
|
786
|
+
ruleSource,
|
|
787
|
+
rule: formattedRule
|
|
788
|
+
}
|
|
789
|
+
];
|
|
211
790
|
}
|
|
212
|
-
|
|
213
|
-
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
if (found) return [];
|
|
794
|
+
if (rule.required !== false) {
|
|
795
|
+
return [
|
|
796
|
+
{
|
|
797
|
+
severity: "error",
|
|
798
|
+
code: "schema-missing-content",
|
|
799
|
+
message: `Section '${section.headingText}' missing required content: ${rule.label ?? rule.pattern}`,
|
|
800
|
+
filePath,
|
|
801
|
+
line: section.heading.position?.start.line,
|
|
802
|
+
ruleSource,
|
|
803
|
+
rule: formattedRule
|
|
804
|
+
}
|
|
805
|
+
];
|
|
806
|
+
}
|
|
807
|
+
return [];
|
|
808
|
+
}
|
|
809
|
+
function checkListContains(section, rule, filePath, ruleSource, formattedRule) {
|
|
810
|
+
const items = collectAllListItems(section);
|
|
811
|
+
const regex = new RegExp(rule.list.pattern);
|
|
812
|
+
const count = items.filter((item) => regex.test(item)).length;
|
|
813
|
+
if (rule.list.min !== void 0 && count < rule.list.min) {
|
|
814
|
+
return [
|
|
815
|
+
{
|
|
816
|
+
severity: "error",
|
|
817
|
+
code: "schema-missing-content",
|
|
818
|
+
message: `Section '${section.headingText}' has ${count} matching ${rule.list.label ?? "list items"}, expected at least ${rule.list.min}`,
|
|
819
|
+
filePath,
|
|
820
|
+
line: section.heading.position?.start.line,
|
|
821
|
+
ruleSource,
|
|
822
|
+
rule: formattedRule
|
|
823
|
+
}
|
|
824
|
+
];
|
|
825
|
+
}
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
function checkTableContains(section, rule, filePath, ruleSource, formattedRule) {
|
|
829
|
+
const tables = collectAllTables(section);
|
|
830
|
+
if (tables.length === 0) {
|
|
831
|
+
return [
|
|
832
|
+
{
|
|
833
|
+
severity: "error",
|
|
834
|
+
code: "schema-missing-content",
|
|
835
|
+
message: `Section '${section.headingText}' missing required table${rule.table.heading ? ` (${rule.table.heading})` : ""}`,
|
|
836
|
+
filePath,
|
|
837
|
+
line: section.heading.position?.start.line,
|
|
838
|
+
ruleSource,
|
|
839
|
+
rule: formattedRule
|
|
840
|
+
}
|
|
841
|
+
];
|
|
842
|
+
}
|
|
843
|
+
let matched;
|
|
844
|
+
let firstMismatch;
|
|
845
|
+
for (const table of tables) {
|
|
846
|
+
const headerRow = table.children[0];
|
|
847
|
+
if (!headerRow) continue;
|
|
848
|
+
const headers = headerRow.children.map(
|
|
849
|
+
(cell) => extractText(cell.children).trim()
|
|
850
|
+
);
|
|
851
|
+
if (rule.table.columns.every((col) => headers.includes(col))) {
|
|
852
|
+
matched = table;
|
|
853
|
+
break;
|
|
214
854
|
}
|
|
215
|
-
if (
|
|
216
|
-
|
|
855
|
+
if (!firstMismatch) {
|
|
856
|
+
firstMismatch = { table, headers };
|
|
217
857
|
}
|
|
218
|
-
console.log("");
|
|
219
858
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
859
|
+
if (!matched) {
|
|
860
|
+
const mm = firstMismatch;
|
|
861
|
+
return [
|
|
862
|
+
{
|
|
863
|
+
severity: "error",
|
|
864
|
+
code: "schema-table-columns",
|
|
865
|
+
message: `No table in '${section.headingText}' has columns [${rule.table.columns.join(", ")}]${mm ? `, found [${mm.headers.join(", ")}]` : ""}`,
|
|
866
|
+
filePath,
|
|
867
|
+
line: mm?.table.position?.start.line ?? section.heading.position?.start.line,
|
|
868
|
+
ruleSource,
|
|
869
|
+
rule: formattedRule
|
|
870
|
+
}
|
|
871
|
+
];
|
|
872
|
+
}
|
|
873
|
+
const findings = [];
|
|
874
|
+
const dataRows = matched.children.length - 1;
|
|
875
|
+
if (rule.table["min-rows"] !== void 0 && dataRows < rule.table["min-rows"]) {
|
|
876
|
+
findings.push({
|
|
877
|
+
severity: "error",
|
|
878
|
+
code: "schema-missing-content",
|
|
879
|
+
message: `Table in '${section.headingText}' has ${dataRows} data rows, expected at least ${rule.table["min-rows"]}`,
|
|
880
|
+
filePath,
|
|
881
|
+
line: matched.position?.start.line,
|
|
882
|
+
ruleSource,
|
|
883
|
+
rule: formattedRule
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return findings;
|
|
887
|
+
}
|
|
888
|
+
function checkCodeBlockContains(section, rule, filePath, ruleSource, formattedRule) {
|
|
889
|
+
if (collectAllCodeBlocks(section).length > 0) return [];
|
|
890
|
+
return [
|
|
891
|
+
{
|
|
892
|
+
severity: "error",
|
|
893
|
+
code: "schema-missing-content",
|
|
894
|
+
message: `Section '${section.headingText}' missing required ${rule.label ?? "code block"}`,
|
|
895
|
+
filePath,
|
|
896
|
+
line: section.heading.position?.start.line,
|
|
897
|
+
ruleSource,
|
|
898
|
+
rule: formattedRule
|
|
239
899
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
900
|
+
];
|
|
901
|
+
}
|
|
902
|
+
function checkHeadingOrText(section, rule, filePath, ruleSource, formattedRule) {
|
|
903
|
+
const needle = rule["heading-or-text"].toUpperCase();
|
|
904
|
+
if (section.children.some((c) => c.headingText.toUpperCase().includes(needle))) return [];
|
|
905
|
+
if (getFullSectionText(section).toUpperCase().includes(needle)) return [];
|
|
906
|
+
if (rule.required !== false) {
|
|
907
|
+
return [
|
|
908
|
+
{
|
|
909
|
+
severity: "error",
|
|
910
|
+
code: "schema-missing-content",
|
|
911
|
+
message: `Section '${section.headingText}' missing required heading or text: '${rule["heading-or-text"]}'`,
|
|
912
|
+
filePath,
|
|
913
|
+
line: section.heading.position?.start.line,
|
|
914
|
+
ruleSource,
|
|
915
|
+
rule: formattedRule
|
|
253
916
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
917
|
+
];
|
|
918
|
+
}
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
function evaluateWhenCondition(when, headingText) {
|
|
922
|
+
if (when["heading-matches"]) {
|
|
923
|
+
if (!new RegExp(when["heading-matches"]).test(headingText)) return false;
|
|
924
|
+
}
|
|
925
|
+
if (when["heading-not-matches"]) {
|
|
926
|
+
if (new RegExp(when["heading-not-matches"]).test(headingText)) return false;
|
|
927
|
+
}
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
function checkProhibited(content, prohibited, filePath, ruleSource) {
|
|
931
|
+
const findings = [];
|
|
932
|
+
const lines = content.split("\n");
|
|
933
|
+
for (const pattern of prohibited) {
|
|
934
|
+
const regex = new RegExp(escapeRegex(pattern));
|
|
935
|
+
let inCodeBlock = false;
|
|
936
|
+
for (const [i, line] of lines.entries()) {
|
|
937
|
+
if (line.startsWith("```")) {
|
|
938
|
+
inCodeBlock = !inCodeBlock;
|
|
939
|
+
continue;
|
|
263
940
|
}
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
941
|
+
if (inCodeBlock) continue;
|
|
942
|
+
if (regex.test(line)) {
|
|
943
|
+
findings.push({
|
|
944
|
+
severity: "warning",
|
|
945
|
+
code: "schema-prohibited",
|
|
946
|
+
message: `Prohibited formatting '${pattern}' found`,
|
|
947
|
+
filePath,
|
|
948
|
+
line: i + 1,
|
|
949
|
+
ruleSource,
|
|
950
|
+
rule: formatProhibitedRule(pattern)
|
|
951
|
+
});
|
|
952
|
+
break;
|
|
273
953
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return findings;
|
|
957
|
+
}
|
|
958
|
+
function getFullSectionText(section) {
|
|
959
|
+
let text = section.contentNodes.map(nodeToText).join("\n");
|
|
960
|
+
for (const child of section.children) {
|
|
961
|
+
text += `
|
|
962
|
+
${child.headingText}
|
|
963
|
+
${getFullSectionText(child)}`;
|
|
964
|
+
}
|
|
965
|
+
return text;
|
|
966
|
+
}
|
|
967
|
+
function nodeToText(node) {
|
|
968
|
+
if ("value" in node) return node.value;
|
|
969
|
+
if ("children" in node) {
|
|
970
|
+
return node.children.map((c) => nodeToText(c)).join("");
|
|
971
|
+
}
|
|
972
|
+
return "";
|
|
973
|
+
}
|
|
974
|
+
function extractListItems(nodes) {
|
|
975
|
+
const items = [];
|
|
976
|
+
for (const node of nodes) {
|
|
977
|
+
if (node.type === "list") {
|
|
978
|
+
for (const item of node.children) {
|
|
979
|
+
const raw = nodeToText(item);
|
|
980
|
+
const li = item;
|
|
981
|
+
if (li.checked === true) {
|
|
982
|
+
items.push(`[x] ${raw}`);
|
|
983
|
+
} else if (li.checked === false) {
|
|
984
|
+
items.push(`[ ] ${raw}`);
|
|
985
|
+
} else {
|
|
986
|
+
items.push(raw);
|
|
281
987
|
}
|
|
282
|
-
config.preset = parsed.preset;
|
|
283
988
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return items;
|
|
992
|
+
}
|
|
993
|
+
function collectAllListItems(section) {
|
|
994
|
+
const items = extractListItems(section.contentNodes);
|
|
995
|
+
for (const child of section.children) items.push(...collectAllListItems(child));
|
|
996
|
+
return items;
|
|
997
|
+
}
|
|
998
|
+
function collectAllTables(section) {
|
|
999
|
+
const tables = section.contentNodes.filter((n) => n.type === "table");
|
|
1000
|
+
for (const child of section.children) tables.push(...collectAllTables(child));
|
|
1001
|
+
return tables;
|
|
1002
|
+
}
|
|
1003
|
+
function collectAllCodeBlocks(section) {
|
|
1004
|
+
const blocks = section.contentNodes.filter((n) => n.type === "code");
|
|
1005
|
+
for (const child of section.children) blocks.push(...collectAllCodeBlocks(child));
|
|
1006
|
+
return blocks;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/core/check/spec-parser.ts
|
|
1010
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1011
|
+
import { basename } from "path";
|
|
1012
|
+
async function parseSpecs(config) {
|
|
1013
|
+
const files = await collectSpecFiles(config.specGlobs, config.specIgnore);
|
|
1014
|
+
const specFiles = [];
|
|
1015
|
+
const requirementIds = /* @__PURE__ */ new Set();
|
|
1016
|
+
const acIds = /* @__PURE__ */ new Set();
|
|
1017
|
+
const propertyIds = /* @__PURE__ */ new Set();
|
|
1018
|
+
const componentNames = /* @__PURE__ */ new Set();
|
|
1019
|
+
const idLocations = /* @__PURE__ */ new Map();
|
|
1020
|
+
for (const filePath of files) {
|
|
1021
|
+
const specFile = await parseSpecFile(filePath, config.crossRefPatterns);
|
|
1022
|
+
if (specFile) {
|
|
1023
|
+
specFiles.push(specFile);
|
|
1024
|
+
for (const id of specFile.requirementIds) requirementIds.add(id);
|
|
1025
|
+
for (const id of specFile.acIds) acIds.add(id);
|
|
1026
|
+
for (const id of specFile.propertyIds) propertyIds.add(id);
|
|
1027
|
+
for (const name of specFile.componentNames) componentNames.add(name);
|
|
1028
|
+
for (const [id, loc] of specFile.idLocations ?? []) {
|
|
1029
|
+
idLocations.set(id, loc);
|
|
293
1030
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const allIds = /* @__PURE__ */ new Set([...requirementIds, ...acIds, ...propertyIds, ...componentNames]);
|
|
1034
|
+
return { requirementIds, acIds, propertyIds, componentNames, allIds, specFiles, idLocations };
|
|
1035
|
+
}
|
|
1036
|
+
async function parseSpecFile(filePath, crossRefPatterns) {
|
|
1037
|
+
let content;
|
|
1038
|
+
try {
|
|
1039
|
+
content = await readFile4(filePath, "utf-8");
|
|
1040
|
+
} catch {
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
const code = extractCodePrefix(filePath);
|
|
1044
|
+
const lines = content.split("\n");
|
|
1045
|
+
const requirementIds = [];
|
|
1046
|
+
const acIds = [];
|
|
1047
|
+
const propertyIds = [];
|
|
1048
|
+
const componentNames = [];
|
|
1049
|
+
const crossRefs = [];
|
|
1050
|
+
const idLocations = /* @__PURE__ */ new Map();
|
|
1051
|
+
const reqIdRegex = /^###\s+([A-Z][A-Z0-9]*-\d+(?:\.\d+)?)\s*:/;
|
|
1052
|
+
const acIdRegex = /^-\s+\[[ x]\]\s+([A-Z][A-Z0-9]*-\d+(?:\.\d+)?_AC-\d+)\s/;
|
|
1053
|
+
const propIdRegex = /^-\s+([A-Z][A-Z0-9]*_P-\d+)\s/;
|
|
1054
|
+
const componentRegex = /^###\s+([A-Z][A-Z0-9]*-[A-Za-z][A-Za-z0-9]*(?:[A-Z][a-z0-9]*)*)\s*$/;
|
|
1055
|
+
for (const [i, line] of lines.entries()) {
|
|
1056
|
+
const lineNum = i + 1;
|
|
1057
|
+
const reqMatch = reqIdRegex.exec(line);
|
|
1058
|
+
if (reqMatch?.[1]) {
|
|
1059
|
+
requirementIds.push(reqMatch[1]);
|
|
1060
|
+
idLocations.set(reqMatch[1], { filePath, line: lineNum });
|
|
1061
|
+
}
|
|
1062
|
+
const acMatch = acIdRegex.exec(line);
|
|
1063
|
+
if (acMatch?.[1]) {
|
|
1064
|
+
acIds.push(acMatch[1]);
|
|
1065
|
+
idLocations.set(acMatch[1], { filePath, line: lineNum });
|
|
1066
|
+
}
|
|
1067
|
+
const propMatch = propIdRegex.exec(line);
|
|
1068
|
+
if (propMatch?.[1]) {
|
|
1069
|
+
propertyIds.push(propMatch[1]);
|
|
1070
|
+
idLocations.set(propMatch[1], { filePath, line: lineNum });
|
|
1071
|
+
}
|
|
1072
|
+
const compMatch = componentRegex.exec(line);
|
|
1073
|
+
if (compMatch?.[1]) {
|
|
1074
|
+
if (!reqIdRegex.test(line)) {
|
|
1075
|
+
componentNames.push(compMatch[1]);
|
|
1076
|
+
idLocations.set(compMatch[1], { filePath, line: lineNum });
|
|
303
1077
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1078
|
+
}
|
|
1079
|
+
for (const pattern of crossRefPatterns) {
|
|
1080
|
+
const patIdx = line.indexOf(pattern);
|
|
1081
|
+
if (patIdx !== -1) {
|
|
1082
|
+
const afterPattern = line.slice(patIdx + pattern.length);
|
|
1083
|
+
const ids = extractIdsFromText(afterPattern);
|
|
1084
|
+
if (ids.length > 0) {
|
|
1085
|
+
const type = pattern.toLowerCase().includes("implements") ? "implements" : "validates";
|
|
1086
|
+
crossRefs.push({ type, ids, filePath, line: i + 1 });
|
|
311
1087
|
}
|
|
312
|
-
config["dry-run"] = parsed["dry-run"];
|
|
313
1088
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return {
|
|
1092
|
+
filePath,
|
|
1093
|
+
code,
|
|
1094
|
+
requirementIds,
|
|
1095
|
+
acIds,
|
|
1096
|
+
propertyIds,
|
|
1097
|
+
componentNames,
|
|
1098
|
+
crossRefs,
|
|
1099
|
+
idLocations
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function extractCodePrefix(filePath) {
|
|
1103
|
+
const name = basename(filePath, ".md");
|
|
1104
|
+
const match = /^(?:REQ|DESIGN|FEAT|EXAMPLES|API)-([A-Z][A-Z0-9]*)-/.exec(name);
|
|
1105
|
+
if (match?.[1]) return match[1];
|
|
1106
|
+
return "";
|
|
1107
|
+
}
|
|
1108
|
+
function extractIdsFromText(text) {
|
|
1109
|
+
const idRegex = /[A-Z][A-Z0-9]*-\d+(?:\.\d+)?(?:_AC-\d+)?|[A-Z][A-Z0-9]*_P-\d+/g;
|
|
1110
|
+
const ids = [];
|
|
1111
|
+
let match = idRegex.exec(text);
|
|
1112
|
+
while (match !== null) {
|
|
1113
|
+
ids.push(match[0]);
|
|
1114
|
+
match = idRegex.exec(text);
|
|
1115
|
+
}
|
|
1116
|
+
return ids;
|
|
1117
|
+
}
|
|
1118
|
+
async function collectSpecFiles(specGlobs, ignore) {
|
|
1119
|
+
return collectFiles(specGlobs, ignore);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// src/core/check/spec-spec-checker.ts
|
|
1123
|
+
function checkSpecAgainstSpec(specs, markers, config) {
|
|
1124
|
+
const findings = [];
|
|
1125
|
+
for (const specFile of specs.specFiles) {
|
|
1126
|
+
for (const crossRef of specFile.crossRefs) {
|
|
1127
|
+
for (const refId of crossRef.ids) {
|
|
1128
|
+
if (!specs.allIds.has(refId)) {
|
|
1129
|
+
findings.push({
|
|
1130
|
+
severity: "error",
|
|
1131
|
+
code: "broken-cross-ref",
|
|
1132
|
+
message: `Cross-reference '${refId}' (${crossRef.type}) not found in any spec file`,
|
|
1133
|
+
filePath: crossRef.filePath,
|
|
1134
|
+
line: crossRef.line,
|
|
1135
|
+
id: refId
|
|
1136
|
+
});
|
|
321
1137
|
}
|
|
322
|
-
config.delete = parsed.delete;
|
|
323
1138
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
config.refresh = parsed.refresh;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (!config.specOnly) {
|
|
1142
|
+
const referencedCodes = /* @__PURE__ */ new Set();
|
|
1143
|
+
for (const marker of markers.markers) {
|
|
1144
|
+
const codeMatch = /^([A-Z][A-Z0-9]*)[-_]/.exec(marker.id);
|
|
1145
|
+
if (codeMatch?.[1]) {
|
|
1146
|
+
referencedCodes.add(codeMatch[1]);
|
|
333
1147
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
const defs = {};
|
|
343
|
-
for (const [presetName, value] of Object.entries(
|
|
344
|
-
parsed.presets
|
|
345
|
-
)) {
|
|
346
|
-
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
347
|
-
throw new ConfigError(
|
|
348
|
-
`Invalid preset '${presetName}': expected array of strings`,
|
|
349
|
-
"INVALID_PRESET",
|
|
350
|
-
pathToLoad
|
|
351
|
-
);
|
|
1148
|
+
}
|
|
1149
|
+
for (const specFile of specs.specFiles) {
|
|
1150
|
+
for (const crossRef of specFile.crossRefs) {
|
|
1151
|
+
for (const refId of crossRef.ids) {
|
|
1152
|
+
const codeMatch = /^([A-Z][A-Z0-9]*)[-_]/.exec(refId);
|
|
1153
|
+
if (codeMatch?.[1]) {
|
|
1154
|
+
referencedCodes.add(codeMatch[1]);
|
|
352
1155
|
}
|
|
353
|
-
defs[presetName] = value;
|
|
354
|
-
}
|
|
355
|
-
config.presets = defs;
|
|
356
|
-
}
|
|
357
|
-
if (parsed["list-unknown"] !== void 0) {
|
|
358
|
-
if (typeof parsed["list-unknown"] !== "boolean") {
|
|
359
|
-
throw new ConfigError(
|
|
360
|
-
`Invalid type for 'list-unknown': expected boolean, got ${typeof parsed["list-unknown"]}`,
|
|
361
|
-
"INVALID_TYPE",
|
|
362
|
-
pathToLoad
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
config["list-unknown"] = parsed["list-unknown"];
|
|
366
|
-
}
|
|
367
|
-
const knownKeys = /* @__PURE__ */ new Set([
|
|
368
|
-
"output",
|
|
369
|
-
"template",
|
|
370
|
-
"features",
|
|
371
|
-
"preset",
|
|
372
|
-
"remove-features",
|
|
373
|
-
"presets",
|
|
374
|
-
"force",
|
|
375
|
-
"dry-run",
|
|
376
|
-
"delete",
|
|
377
|
-
"refresh",
|
|
378
|
-
"list-unknown"
|
|
379
|
-
]);
|
|
380
|
-
for (const key of Object.keys(parsed)) {
|
|
381
|
-
if (!knownKeys.has(key)) {
|
|
382
|
-
logger.warn(`Unknown configuration option: '${key}'`);
|
|
383
1156
|
}
|
|
384
1157
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
|
|
1158
|
+
}
|
|
1159
|
+
for (const specFile of specs.specFiles) {
|
|
1160
|
+
if (!specFile.code) continue;
|
|
1161
|
+
if (!referencedCodes.has(specFile.code)) {
|
|
1162
|
+
findings.push({
|
|
1163
|
+
severity: "warning",
|
|
1164
|
+
code: "orphaned-spec",
|
|
1165
|
+
message: `Spec file code '${specFile.code}' is not referenced by any other spec or code marker`,
|
|
1166
|
+
filePath: specFile.filePath,
|
|
1167
|
+
id: specFile.code
|
|
1168
|
+
});
|
|
389
1169
|
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return { findings };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/core/check/types.ts
|
|
1176
|
+
var DEFAULT_CHECK_CONFIG = {
|
|
1177
|
+
specGlobs: [
|
|
1178
|
+
".awa/specs/ARCHITECTURE.md",
|
|
1179
|
+
".awa/specs/FEAT-*.md",
|
|
1180
|
+
".awa/specs/REQ-*.md",
|
|
1181
|
+
".awa/specs/DESIGN-*.md",
|
|
1182
|
+
".awa/specs/EXAMPLES-*.md",
|
|
1183
|
+
".awa/specs/API-*.tsp",
|
|
1184
|
+
".awa/tasks/TASK-*.md",
|
|
1185
|
+
".awa/plans/PLAN-*.md",
|
|
1186
|
+
".awa/align/ALIGN-*.md"
|
|
1187
|
+
],
|
|
1188
|
+
codeGlobs: [
|
|
1189
|
+
"**/*.{ts,js,tsx,jsx,mts,mjs,cjs,py,go,rs,java,kt,kts,cs,c,h,cpp,cc,cxx,hpp,hxx,swift,rb,php,scala,ex,exs,dart,lua,zig}"
|
|
1190
|
+
],
|
|
1191
|
+
specIgnore: [],
|
|
1192
|
+
codeIgnore: [
|
|
1193
|
+
"node_modules/**",
|
|
1194
|
+
"dist/**",
|
|
1195
|
+
"vendor/**",
|
|
1196
|
+
"target/**",
|
|
1197
|
+
"build/**",
|
|
1198
|
+
"out/**",
|
|
1199
|
+
".awa/**"
|
|
1200
|
+
],
|
|
1201
|
+
ignoreMarkers: [],
|
|
1202
|
+
markers: ["@awa-impl", "@awa-test", "@awa-component"],
|
|
1203
|
+
idPattern: "([A-Z][A-Z0-9]*-\\d+(?:\\.\\d+)?(?:_AC-\\d+)?|[A-Z][A-Z0-9]*_P-\\d+)",
|
|
1204
|
+
crossRefPatterns: ["IMPLEMENTS:", "VALIDATES:"],
|
|
1205
|
+
format: "text",
|
|
1206
|
+
schemaDir: ".awa/.agent/schemas",
|
|
1207
|
+
schemaEnabled: true,
|
|
1208
|
+
allowWarnings: false,
|
|
1209
|
+
specOnly: false
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
// src/commands/check.ts
|
|
1213
|
+
async function checkCommand(cliOptions) {
|
|
1214
|
+
try {
|
|
1215
|
+
const fileConfig = await configLoader.load(cliOptions.config ?? null);
|
|
1216
|
+
const config = buildCheckConfig(fileConfig, cliOptions);
|
|
1217
|
+
const emptyMarkers = {
|
|
1218
|
+
markers: [],
|
|
1219
|
+
findings: []
|
|
1220
|
+
};
|
|
1221
|
+
const [markers, specs, ruleSets] = await Promise.all([
|
|
1222
|
+
config.specOnly ? Promise.resolve(emptyMarkers) : scanMarkers(config),
|
|
1223
|
+
parseSpecs(config),
|
|
1224
|
+
config.schemaEnabled ? loadRules(config.schemaDir) : Promise.resolve([])
|
|
1225
|
+
]);
|
|
1226
|
+
const codeSpecResult = config.specOnly ? { findings: [] } : checkCodeAgainstSpec(markers, specs, config);
|
|
1227
|
+
const specSpecResult = checkSpecAgainstSpec(specs, markers, config);
|
|
1228
|
+
const schemaResult = config.schemaEnabled && ruleSets.length > 0 ? await checkSchemasAsync(specs.specFiles, ruleSets) : { findings: [] };
|
|
1229
|
+
const combinedFindings = [
|
|
1230
|
+
...markers.findings,
|
|
1231
|
+
...codeSpecResult.findings,
|
|
1232
|
+
...specSpecResult.findings,
|
|
1233
|
+
...schemaResult.findings
|
|
1234
|
+
];
|
|
1235
|
+
const allFindings = config.allowWarnings ? combinedFindings : combinedFindings.map(
|
|
1236
|
+
(f) => f.severity === "warning" ? { ...f, severity: "error" } : f
|
|
1237
|
+
);
|
|
1238
|
+
report(allFindings, config.format);
|
|
1239
|
+
const hasErrors = allFindings.some((f) => f.severity === "error");
|
|
1240
|
+
return hasErrors ? 1 : 0;
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (error instanceof Error) {
|
|
1243
|
+
logger.error(error.message);
|
|
1244
|
+
} else {
|
|
1245
|
+
logger.error(String(error));
|
|
1246
|
+
}
|
|
1247
|
+
return 2;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function buildCheckConfig(fileConfig, cliOptions) {
|
|
1251
|
+
const section = fileConfig?.check;
|
|
1252
|
+
const specGlobs = toStringArray(section?.["spec-globs"]) ?? [...DEFAULT_CHECK_CONFIG.specGlobs];
|
|
1253
|
+
const codeGlobs = toStringArray(section?.["code-globs"]) ?? [...DEFAULT_CHECK_CONFIG.codeGlobs];
|
|
1254
|
+
const markers = toStringArray(section?.markers) ?? [...DEFAULT_CHECK_CONFIG.markers];
|
|
1255
|
+
const crossRefPatterns = toStringArray(section?.["cross-ref-patterns"]) ?? [
|
|
1256
|
+
...DEFAULT_CHECK_CONFIG.crossRefPatterns
|
|
1257
|
+
];
|
|
1258
|
+
const idPattern = typeof section?.["id-pattern"] === "string" ? section["id-pattern"] : DEFAULT_CHECK_CONFIG.idPattern;
|
|
1259
|
+
const configSpecIgnore = toStringArray(section?.["spec-ignore"]) ?? [
|
|
1260
|
+
...DEFAULT_CHECK_CONFIG.specIgnore
|
|
1261
|
+
];
|
|
1262
|
+
const configCodeIgnore = toStringArray(section?.["code-ignore"]) ?? [
|
|
1263
|
+
...DEFAULT_CHECK_CONFIG.codeIgnore
|
|
1264
|
+
];
|
|
1265
|
+
const specIgnore = [...configSpecIgnore, ...cliOptions.specIgnore ?? []];
|
|
1266
|
+
const codeIgnore = [...configCodeIgnore, ...cliOptions.codeIgnore ?? []];
|
|
1267
|
+
const ignoreMarkers = toStringArray(section?.["ignore-markers"]) ?? [
|
|
1268
|
+
...DEFAULT_CHECK_CONFIG.ignoreMarkers
|
|
1269
|
+
];
|
|
1270
|
+
const format = cliOptions.format === "json" ? "json" : section?.format === "json" ? "json" : DEFAULT_CHECK_CONFIG.format;
|
|
1271
|
+
const schemaDir = typeof section?.["schema-dir"] === "string" ? section["schema-dir"] : DEFAULT_CHECK_CONFIG.schemaDir;
|
|
1272
|
+
const schemaEnabled = typeof section?.["schema-enabled"] === "boolean" ? section["schema-enabled"] : DEFAULT_CHECK_CONFIG.schemaEnabled;
|
|
1273
|
+
const allowWarnings = cliOptions.allowWarnings === true ? true : typeof section?.["allow-warnings"] === "boolean" ? section["allow-warnings"] : DEFAULT_CHECK_CONFIG.allowWarnings;
|
|
1274
|
+
const specOnly = cliOptions.specOnly === true ? true : typeof section?.["spec-only"] === "boolean" ? section["spec-only"] : DEFAULT_CHECK_CONFIG.specOnly;
|
|
1275
|
+
return {
|
|
1276
|
+
specGlobs,
|
|
1277
|
+
codeGlobs,
|
|
1278
|
+
specIgnore,
|
|
1279
|
+
codeIgnore,
|
|
1280
|
+
ignoreMarkers,
|
|
1281
|
+
markers,
|
|
1282
|
+
idPattern,
|
|
1283
|
+
crossRefPatterns,
|
|
1284
|
+
format,
|
|
1285
|
+
schemaDir,
|
|
1286
|
+
schemaEnabled,
|
|
1287
|
+
allowWarnings,
|
|
1288
|
+
specOnly
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function toStringArray(value) {
|
|
1292
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
1293
|
+
return value;
|
|
1294
|
+
}
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/commands/diff.ts
|
|
1299
|
+
import { intro, outro } from "@clack/prompts";
|
|
1300
|
+
|
|
1301
|
+
// src/core/batch-runner.ts
|
|
1302
|
+
var BatchRunner = class {
|
|
1303
|
+
// Resolve all targets or a single named target from config
|
|
1304
|
+
resolveTargets(cli, fileConfig, mode, targetName) {
|
|
1305
|
+
if (!fileConfig) {
|
|
390
1306
|
throw new ConfigError(
|
|
391
|
-
|
|
392
|
-
"
|
|
393
|
-
|
|
1307
|
+
"No configuration file found. --all and --target require a config file with [targets.*] sections.",
|
|
1308
|
+
"NO_TARGETS",
|
|
1309
|
+
null
|
|
394
1310
|
);
|
|
395
1311
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
// @awa-impl: CLI-2_AC-2, CLI-2_AC-3, CLI-2_AC-4
|
|
399
|
-
merge(cli, file) {
|
|
400
|
-
const output = cli.output ?? file?.output;
|
|
401
|
-
if (!output) {
|
|
1312
|
+
const targetNames = configLoader.getTargetNames(fileConfig);
|
|
1313
|
+
if (targetNames.length === 0) {
|
|
402
1314
|
throw new ConfigError(
|
|
403
|
-
"
|
|
404
|
-
"
|
|
1315
|
+
"No targets defined in configuration. Add [targets.<name>] sections to .awa.toml.",
|
|
1316
|
+
"NO_TARGETS",
|
|
405
1317
|
null
|
|
406
1318
|
);
|
|
407
1319
|
}
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
1320
|
+
const namesToProcess = mode === "all" ? targetNames : [targetName];
|
|
1321
|
+
const results = [];
|
|
1322
|
+
for (const name of namesToProcess) {
|
|
1323
|
+
const resolved = configLoader.resolveTarget(name, fileConfig);
|
|
1324
|
+
const targetCli = {
|
|
1325
|
+
...cli,
|
|
1326
|
+
output: mode === "all" ? void 0 : cli.output
|
|
1327
|
+
};
|
|
1328
|
+
let options;
|
|
1329
|
+
try {
|
|
1330
|
+
options = configLoader.merge(targetCli, resolved);
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
if (error instanceof ConfigError && error.code === "MISSING_OUTPUT") {
|
|
1333
|
+
throw new ConfigError(
|
|
1334
|
+
`Target '${name}' has no output directory. Specify 'output' in [targets.${name}] or in the root config.`,
|
|
1335
|
+
"MISSING_OUTPUT",
|
|
1336
|
+
null
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
throw error;
|
|
1340
|
+
}
|
|
1341
|
+
results.push({ targetName: name, options });
|
|
1342
|
+
}
|
|
1343
|
+
return results;
|
|
1344
|
+
}
|
|
1345
|
+
// Log a message prefixed with target name
|
|
1346
|
+
logForTarget(targetName, message) {
|
|
1347
|
+
logger.info(`[${targetName}] ${message}`);
|
|
1348
|
+
}
|
|
1349
|
+
warnForTarget(targetName, message) {
|
|
1350
|
+
logger.warn(`[${targetName}] ${message}`);
|
|
1351
|
+
}
|
|
1352
|
+
errorForTarget(targetName, message) {
|
|
1353
|
+
logger.error(`[${targetName}] ${message}`);
|
|
431
1354
|
}
|
|
432
1355
|
};
|
|
433
|
-
var
|
|
1356
|
+
var batchRunner = new BatchRunner();
|
|
434
1357
|
|
|
435
1358
|
// src/core/differ.ts
|
|
436
1359
|
import { tmpdir } from "os";
|
|
@@ -661,7 +1584,8 @@ var TemplateEngine = class {
|
|
|
661
1584
|
try {
|
|
662
1585
|
const templateContent = await readTextFile(templatePath);
|
|
663
1586
|
const rendered = await this.eta.renderStringAsync(templateContent, {
|
|
664
|
-
features: context.features
|
|
1587
|
+
features: context.features,
|
|
1588
|
+
version: context.version ?? ""
|
|
665
1589
|
});
|
|
666
1590
|
const trimmed = rendered.trim();
|
|
667
1591
|
const isEmpty = trimmed.length === 0;
|
|
@@ -703,7 +1627,10 @@ var FileGenerator = class {
|
|
|
703
1627
|
try {
|
|
704
1628
|
for await (const templateFile of this.walkTemplates(templatePath)) {
|
|
705
1629
|
const outputFile = this.computeOutputPath(templateFile, templatePath, outputPath);
|
|
706
|
-
const result = await templateEngine.render(templateFile, {
|
|
1630
|
+
const result = await templateEngine.render(templateFile, {
|
|
1631
|
+
features,
|
|
1632
|
+
version: PACKAGE_INFO.version
|
|
1633
|
+
});
|
|
707
1634
|
if (result.isEmpty && !result.isEmptyFileMarker) {
|
|
708
1635
|
actions.push({
|
|
709
1636
|
type: "skip-empty",
|
|
@@ -1073,9 +2000,62 @@ var FeatureResolver = class {
|
|
|
1073
2000
|
};
|
|
1074
2001
|
var featureResolver = new FeatureResolver();
|
|
1075
2002
|
|
|
2003
|
+
// src/core/json-output.ts
|
|
2004
|
+
function serializeGenerationResult(result) {
|
|
2005
|
+
const actions = result.actions.map((action) => ({
|
|
2006
|
+
type: action.type,
|
|
2007
|
+
path: action.outputPath
|
|
2008
|
+
}));
|
|
2009
|
+
return {
|
|
2010
|
+
actions,
|
|
2011
|
+
counts: {
|
|
2012
|
+
created: result.created,
|
|
2013
|
+
overwritten: result.overwritten,
|
|
2014
|
+
skipped: result.skipped,
|
|
2015
|
+
deleted: result.deleted
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
function serializeDiffResult(result) {
|
|
2020
|
+
const diffs = result.files.map((file) => {
|
|
2021
|
+
const entry = {
|
|
2022
|
+
path: file.relativePath,
|
|
2023
|
+
status: file.status
|
|
2024
|
+
};
|
|
2025
|
+
if (file.unifiedDiff) {
|
|
2026
|
+
entry.diff = file.unifiedDiff;
|
|
2027
|
+
}
|
|
2028
|
+
return entry;
|
|
2029
|
+
});
|
|
2030
|
+
return {
|
|
2031
|
+
diffs,
|
|
2032
|
+
counts: {
|
|
2033
|
+
changed: result.modified,
|
|
2034
|
+
new: result.newFiles,
|
|
2035
|
+
matching: result.identical,
|
|
2036
|
+
deleted: result.deleteListed
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
function formatGenerationSummary(result) {
|
|
2041
|
+
return `created: ${result.created}, overwritten: ${result.overwritten}, skipped: ${result.skipped}, deleted: ${result.deleted}`;
|
|
2042
|
+
}
|
|
2043
|
+
function formatDiffSummary(result) {
|
|
2044
|
+
return `changed: ${result.modified}, new: ${result.newFiles}, matching: ${result.identical}, deleted: ${result.deleteListed}`;
|
|
2045
|
+
}
|
|
2046
|
+
function writeJsonOutput(data) {
|
|
2047
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
2048
|
+
`);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// src/core/overlay.ts
|
|
2052
|
+
import { cp } from "fs/promises";
|
|
2053
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2054
|
+
import { join as join6 } from "path";
|
|
2055
|
+
|
|
1076
2056
|
// src/core/template-resolver.ts
|
|
1077
2057
|
import { createHash } from "crypto";
|
|
1078
|
-
import { rm
|
|
2058
|
+
import { rm } from "fs/promises";
|
|
1079
2059
|
import { isAbsolute, join as join5, resolve } from "path";
|
|
1080
2060
|
import degit from "degit";
|
|
1081
2061
|
var TemplateResolver = class {
|
|
@@ -1119,7 +2099,7 @@ var TemplateResolver = class {
|
|
|
1119
2099
|
try {
|
|
1120
2100
|
if (cacheExists && refresh) {
|
|
1121
2101
|
logger.info(`Refreshing template: ${source}`);
|
|
1122
|
-
await
|
|
2102
|
+
await rm(cachePath, { recursive: true, force: true });
|
|
1123
2103
|
} else {
|
|
1124
2104
|
logger.info(`Fetching template: ${source}`);
|
|
1125
2105
|
}
|
|
@@ -1145,7 +2125,7 @@ var TemplateResolver = class {
|
|
|
1145
2125
|
source
|
|
1146
2126
|
);
|
|
1147
2127
|
}
|
|
1148
|
-
// @awa-impl: TPL-2_AC-1
|
|
2128
|
+
// @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
2129
|
detectType(source) {
|
|
1150
2130
|
if (source.startsWith(".") || source.startsWith("/") || source.startsWith("~")) {
|
|
1151
2131
|
return "local";
|
|
@@ -1155,38 +2135,99 @@ var TemplateResolver = class {
|
|
|
1155
2135
|
}
|
|
1156
2136
|
return "git";
|
|
1157
2137
|
}
|
|
1158
|
-
// @awa-impl: TPL-3_AC-1
|
|
1159
|
-
getCachePath(source) {
|
|
1160
|
-
const hash = createHash("sha256").update(source).digest("hex").substring(0, 16);
|
|
1161
|
-
const cacheDir = getCacheDir();
|
|
1162
|
-
return join5(cacheDir, hash);
|
|
2138
|
+
// @awa-impl: TPL-3_AC-1
|
|
2139
|
+
getCachePath(source) {
|
|
2140
|
+
const hash = createHash("sha256").update(source).digest("hex").substring(0, 16);
|
|
2141
|
+
const cacheDir = getCacheDir();
|
|
2142
|
+
return join5(cacheDir, hash);
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
var templateResolver = new TemplateResolver();
|
|
2146
|
+
|
|
2147
|
+
// src/core/overlay.ts
|
|
2148
|
+
async function resolveOverlays(overlays, refresh) {
|
|
2149
|
+
const dirs = [];
|
|
2150
|
+
for (const source of overlays) {
|
|
2151
|
+
const resolved = await templateResolver.resolve(source, refresh);
|
|
2152
|
+
dirs.push(resolved.localPath);
|
|
2153
|
+
}
|
|
2154
|
+
return dirs;
|
|
2155
|
+
}
|
|
2156
|
+
async function buildMergedDir(baseDir, overlayDirs) {
|
|
2157
|
+
const timestamp = Date.now();
|
|
2158
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
2159
|
+
const tempPath = join6(tmpdir2(), `awa-overlay-${timestamp}-${random}`);
|
|
2160
|
+
await ensureDir(tempPath);
|
|
2161
|
+
await cp(baseDir, tempPath, { recursive: true });
|
|
2162
|
+
for (const overlayDir of overlayDirs) {
|
|
2163
|
+
await cp(overlayDir, tempPath, { recursive: true });
|
|
2164
|
+
}
|
|
2165
|
+
return tempPath;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// src/utils/file-watcher.ts
|
|
2169
|
+
import { watch } from "fs";
|
|
2170
|
+
|
|
2171
|
+
// src/utils/debouncer.ts
|
|
2172
|
+
var Debouncer = class {
|
|
2173
|
+
constructor(delayMs) {
|
|
2174
|
+
this.delayMs = delayMs;
|
|
2175
|
+
}
|
|
2176
|
+
timer = null;
|
|
2177
|
+
trigger(callback) {
|
|
2178
|
+
if (this.timer) {
|
|
2179
|
+
clearTimeout(this.timer);
|
|
2180
|
+
}
|
|
2181
|
+
this.timer = setTimeout(() => {
|
|
2182
|
+
this.timer = null;
|
|
2183
|
+
callback();
|
|
2184
|
+
}, this.delayMs);
|
|
2185
|
+
}
|
|
2186
|
+
cancel() {
|
|
2187
|
+
if (this.timer) {
|
|
2188
|
+
clearTimeout(this.timer);
|
|
2189
|
+
this.timer = null;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
|
|
2194
|
+
// src/utils/file-watcher.ts
|
|
2195
|
+
var FileWatcher = class {
|
|
2196
|
+
watcher = null;
|
|
2197
|
+
debouncer;
|
|
2198
|
+
directory;
|
|
2199
|
+
onChange;
|
|
2200
|
+
constructor(options) {
|
|
2201
|
+
this.directory = options.directory;
|
|
2202
|
+
this.debouncer = new Debouncer(options.debounceMs ?? 300);
|
|
2203
|
+
this.onChange = options.onChange;
|
|
2204
|
+
}
|
|
2205
|
+
start() {
|
|
2206
|
+
this.watcher = watch(this.directory, { recursive: true }, () => {
|
|
2207
|
+
this.debouncer.trigger(this.onChange);
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
stop() {
|
|
2211
|
+
this.debouncer.cancel();
|
|
2212
|
+
if (this.watcher) {
|
|
2213
|
+
this.watcher.close();
|
|
2214
|
+
this.watcher = null;
|
|
2215
|
+
}
|
|
1163
2216
|
}
|
|
1164
2217
|
};
|
|
1165
|
-
var templateResolver = new TemplateResolver();
|
|
1166
2218
|
|
|
1167
2219
|
// src/commands/diff.ts
|
|
1168
|
-
async function
|
|
2220
|
+
async function runDiff(diffOptions, options, mergedDir) {
|
|
1169
2221
|
try {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
2222
|
+
const result = await diffEngine.diff(diffOptions);
|
|
2223
|
+
if (options.json) {
|
|
2224
|
+
writeJsonOutput(serializeDiffResult(result));
|
|
2225
|
+
return result.hasDifferences ? 1 : 0;
|
|
2226
|
+
}
|
|
2227
|
+
if (options.summary) {
|
|
2228
|
+
console.log(formatDiffSummary(result));
|
|
2229
|
+
return result.hasDifferences ? 1 : 0;
|
|
1175
2230
|
}
|
|
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
2231
|
for (const file of result.files) {
|
|
1191
2232
|
switch (file.status) {
|
|
1192
2233
|
case "modified":
|
|
@@ -1225,8 +2266,106 @@ async function diffCommand(cliOptions) {
|
|
|
1225
2266
|
}
|
|
1226
2267
|
}
|
|
1227
2268
|
logger.diffSummary(result);
|
|
1228
|
-
outro("Diff complete!");
|
|
1229
2269
|
return result.hasDifferences ? 1 : 0;
|
|
2270
|
+
} finally {
|
|
2271
|
+
if (mergedDir) {
|
|
2272
|
+
try {
|
|
2273
|
+
await rmDir(mergedDir);
|
|
2274
|
+
} catch {
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
async function prepareDiff(options) {
|
|
2280
|
+
if (!await pathExists(options.output)) {
|
|
2281
|
+
throw new DiffError(`Target directory does not exist: ${options.output}`);
|
|
2282
|
+
}
|
|
2283
|
+
const targetPath = options.output;
|
|
2284
|
+
const template = await templateResolver.resolve(options.template, options.refresh);
|
|
2285
|
+
const features = featureResolver.resolve({
|
|
2286
|
+
baseFeatures: [...options.features],
|
|
2287
|
+
presetNames: [...options.preset],
|
|
2288
|
+
removeFeatures: [...options.removeFeatures],
|
|
2289
|
+
presetDefinitions: options.presets
|
|
2290
|
+
});
|
|
2291
|
+
let mergedDir = null;
|
|
2292
|
+
let templatePath = template.localPath;
|
|
2293
|
+
if (options.overlay.length > 0) {
|
|
2294
|
+
const overlayDirs = await resolveOverlays([...options.overlay], options.refresh);
|
|
2295
|
+
mergedDir = await buildMergedDir(template.localPath, overlayDirs);
|
|
2296
|
+
templatePath = mergedDir;
|
|
2297
|
+
}
|
|
2298
|
+
return {
|
|
2299
|
+
diffOptions: {
|
|
2300
|
+
templatePath,
|
|
2301
|
+
targetPath,
|
|
2302
|
+
features,
|
|
2303
|
+
listUnknown: options.listUnknown
|
|
2304
|
+
},
|
|
2305
|
+
template,
|
|
2306
|
+
mergedDir
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
async function diffCommand(cliOptions) {
|
|
2310
|
+
try {
|
|
2311
|
+
const fileConfig = await configLoader.load(cliOptions.config ?? null);
|
|
2312
|
+
if (cliOptions.all || cliOptions.target) {
|
|
2313
|
+
const mode = cliOptions.all ? "all" : "single";
|
|
2314
|
+
const targets = batchRunner.resolveTargets(cliOptions, fileConfig, mode, cliOptions.target);
|
|
2315
|
+
let hasDifferences = false;
|
|
2316
|
+
for (const { targetName, options: options2 } of targets) {
|
|
2317
|
+
batchRunner.logForTarget(targetName, "Starting diff...");
|
|
2318
|
+
const { diffOptions: diffOptions2, mergedDir: mergedDir2 } = await prepareDiff(options2);
|
|
2319
|
+
const exitCode = await runDiff(diffOptions2, options2, mergedDir2);
|
|
2320
|
+
if (exitCode === 1) {
|
|
2321
|
+
hasDifferences = true;
|
|
2322
|
+
}
|
|
2323
|
+
batchRunner.logForTarget(targetName, "Diff complete.");
|
|
2324
|
+
}
|
|
2325
|
+
outro("All targets diffed!");
|
|
2326
|
+
return hasDifferences ? 1 : 0;
|
|
2327
|
+
}
|
|
2328
|
+
const options = configLoader.merge(cliOptions, fileConfig);
|
|
2329
|
+
const silent = options.json || options.summary;
|
|
2330
|
+
if (!silent) {
|
|
2331
|
+
intro("awa CLI - Template Diff");
|
|
2332
|
+
}
|
|
2333
|
+
const { diffOptions, template, mergedDir } = await prepareDiff(options);
|
|
2334
|
+
if (cliOptions.watch && template.type !== "local" && template.type !== "bundled") {
|
|
2335
|
+
throw new DiffError("--watch is only supported with local template sources");
|
|
2336
|
+
}
|
|
2337
|
+
const result = await runDiff(diffOptions, options, mergedDir);
|
|
2338
|
+
if (!cliOptions.watch) {
|
|
2339
|
+
if (!silent) {
|
|
2340
|
+
outro("Diff complete!");
|
|
2341
|
+
}
|
|
2342
|
+
return result;
|
|
2343
|
+
}
|
|
2344
|
+
logger.info(`Watching for changes in ${template.localPath}...`);
|
|
2345
|
+
return new Promise((resolve2) => {
|
|
2346
|
+
let running = false;
|
|
2347
|
+
const watcher = new FileWatcher({
|
|
2348
|
+
directory: template.localPath,
|
|
2349
|
+
onChange: async () => {
|
|
2350
|
+
if (running) return;
|
|
2351
|
+
running = true;
|
|
2352
|
+
try {
|
|
2353
|
+
console.clear();
|
|
2354
|
+
logger.info(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Change detected, re-running diff...`);
|
|
2355
|
+
logger.info("---");
|
|
2356
|
+
await runDiff(diffOptions, options, null);
|
|
2357
|
+
} finally {
|
|
2358
|
+
running = false;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
});
|
|
2362
|
+
watcher.start();
|
|
2363
|
+
process.once("SIGINT", () => {
|
|
2364
|
+
watcher.stop();
|
|
2365
|
+
logger.info("\nWatch mode stopped.");
|
|
2366
|
+
resolve2(0);
|
|
2367
|
+
});
|
|
2368
|
+
});
|
|
1230
2369
|
} catch (error) {
|
|
1231
2370
|
if (error instanceof Error) {
|
|
1232
2371
|
logger.error(error.message);
|
|
@@ -1237,8 +2376,161 @@ async function diffCommand(cliOptions) {
|
|
|
1237
2376
|
}
|
|
1238
2377
|
}
|
|
1239
2378
|
|
|
2379
|
+
// src/commands/features.ts
|
|
2380
|
+
import { intro as intro2, outro as outro2 } from "@clack/prompts";
|
|
2381
|
+
|
|
2382
|
+
// src/core/features/reporter.ts
|
|
2383
|
+
import chalk3 from "chalk";
|
|
2384
|
+
var FeaturesReporter = class {
|
|
2385
|
+
// @awa-impl: DISC-6_AC-1, DISC-7_AC-1
|
|
2386
|
+
/** Render the features report to stdout. */
|
|
2387
|
+
report(options) {
|
|
2388
|
+
const { scanResult, json, presets } = options;
|
|
2389
|
+
if (json) {
|
|
2390
|
+
this.reportJson(scanResult, presets);
|
|
2391
|
+
} else {
|
|
2392
|
+
this.reportTable(scanResult, presets);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
// @awa-impl: DISC-6_AC-1
|
|
2396
|
+
/** Build the JSON output object (also used by tests). */
|
|
2397
|
+
buildJsonOutput(scanResult, presets) {
|
|
2398
|
+
const output = {
|
|
2399
|
+
features: scanResult.features.map((f) => ({
|
|
2400
|
+
name: f.name,
|
|
2401
|
+
files: f.files
|
|
2402
|
+
})),
|
|
2403
|
+
filesScanned: scanResult.filesScanned
|
|
2404
|
+
};
|
|
2405
|
+
if (presets && Object.keys(presets).length > 0) {
|
|
2406
|
+
output.presets = presets;
|
|
2407
|
+
}
|
|
2408
|
+
return output;
|
|
2409
|
+
}
|
|
2410
|
+
reportJson(scanResult, presets) {
|
|
2411
|
+
const output = this.buildJsonOutput(scanResult, presets);
|
|
2412
|
+
console.log(JSON.stringify(output, null, 2));
|
|
2413
|
+
}
|
|
2414
|
+
// @awa-impl: DISC-7_AC-1
|
|
2415
|
+
reportTable(scanResult, presets) {
|
|
2416
|
+
const { features, filesScanned } = scanResult;
|
|
2417
|
+
if (features.length === 0) {
|
|
2418
|
+
console.log(chalk3.yellow("No feature flags found."));
|
|
2419
|
+
console.log(chalk3.dim(`(${filesScanned} files scanned)`));
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
console.log(chalk3.bold(`Feature flags (${features.length} found):
|
|
2423
|
+
`));
|
|
2424
|
+
for (const feature of features) {
|
|
2425
|
+
console.log(` ${chalk3.cyan(feature.name)}`);
|
|
2426
|
+
for (const file of feature.files) {
|
|
2427
|
+
console.log(` ${chalk3.dim(file)}`);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
console.log("");
|
|
2431
|
+
console.log(chalk3.dim(`${filesScanned} files scanned`));
|
|
2432
|
+
if (presets && Object.keys(presets).length > 0) {
|
|
2433
|
+
console.log("");
|
|
2434
|
+
console.log(chalk3.bold("Presets (from .awa.toml):\n"));
|
|
2435
|
+
for (const [name, flags] of Object.entries(presets)) {
|
|
2436
|
+
console.log(` ${chalk3.green(name)}: ${flags.join(", ")}`);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
var featuresReporter = new FeaturesReporter();
|
|
2442
|
+
|
|
2443
|
+
// src/core/features/scanner.ts
|
|
2444
|
+
import { readdir, readFile as readFile5 } from "fs/promises";
|
|
2445
|
+
import { join as join7, relative as relative3 } from "path";
|
|
2446
|
+
var FEATURE_PATTERN = /it\.features\.(?:includes|indexOf)\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
2447
|
+
async function* walkAllFiles(dir) {
|
|
2448
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
2449
|
+
for (const entry of entries) {
|
|
2450
|
+
const fullPath = join7(dir, entry.name);
|
|
2451
|
+
if (entry.isDirectory()) {
|
|
2452
|
+
yield* walkAllFiles(fullPath);
|
|
2453
|
+
} else if (entry.isFile()) {
|
|
2454
|
+
yield fullPath;
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
var FeatureScanner = class {
|
|
2459
|
+
// @awa-impl: DISC-1_AC-1, DISC-2_AC-1
|
|
2460
|
+
/** Extract feature flag names from a single file's content. */
|
|
2461
|
+
extractFlags(content) {
|
|
2462
|
+
const flags = /* @__PURE__ */ new Set();
|
|
2463
|
+
for (const match of content.matchAll(FEATURE_PATTERN)) {
|
|
2464
|
+
if (match[1]) {
|
|
2465
|
+
flags.add(match[1]);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
return [...flags];
|
|
2469
|
+
}
|
|
2470
|
+
// @awa-impl: DISC-1_AC-1, DISC-2_AC-1, DISC-3_AC-1
|
|
2471
|
+
/** Scan a template directory and return all discovered feature flags. */
|
|
2472
|
+
async scan(templatePath) {
|
|
2473
|
+
const flagToFiles = /* @__PURE__ */ new Map();
|
|
2474
|
+
let filesScanned = 0;
|
|
2475
|
+
for await (const filePath of walkAllFiles(templatePath)) {
|
|
2476
|
+
filesScanned++;
|
|
2477
|
+
try {
|
|
2478
|
+
const content = await readFile5(filePath, "utf-8");
|
|
2479
|
+
const flags = this.extractFlags(content);
|
|
2480
|
+
const relPath = relative3(templatePath, filePath);
|
|
2481
|
+
for (const flag of flags) {
|
|
2482
|
+
const existing = flagToFiles.get(flag);
|
|
2483
|
+
if (existing) {
|
|
2484
|
+
existing.add(relPath);
|
|
2485
|
+
} else {
|
|
2486
|
+
flagToFiles.set(flag, /* @__PURE__ */ new Set([relPath]));
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
} catch {
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
const features = [...flagToFiles.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, files]) => ({
|
|
2493
|
+
name,
|
|
2494
|
+
files: [...files].sort()
|
|
2495
|
+
}));
|
|
2496
|
+
return { features, filesScanned };
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
var featureScanner = new FeatureScanner();
|
|
2500
|
+
|
|
2501
|
+
// src/commands/features.ts
|
|
2502
|
+
async function featuresCommand(cliOptions) {
|
|
2503
|
+
try {
|
|
2504
|
+
if (!cliOptions.json) {
|
|
2505
|
+
intro2("awa CLI - Feature Discovery");
|
|
2506
|
+
}
|
|
2507
|
+
const fileConfig = await configLoader.load(cliOptions.config ?? null);
|
|
2508
|
+
const templateSource = cliOptions.template ?? fileConfig?.template ?? null;
|
|
2509
|
+
const refresh = cliOptions.refresh ?? fileConfig?.refresh ?? false;
|
|
2510
|
+
const template = await templateResolver.resolve(templateSource, refresh);
|
|
2511
|
+
const scanResult = await featureScanner.scan(template.localPath);
|
|
2512
|
+
const presets = fileConfig?.presets;
|
|
2513
|
+
featuresReporter.report({
|
|
2514
|
+
scanResult,
|
|
2515
|
+
json: cliOptions.json ?? false,
|
|
2516
|
+
presets
|
|
2517
|
+
});
|
|
2518
|
+
if (!cliOptions.json) {
|
|
2519
|
+
outro2("Feature discovery complete!");
|
|
2520
|
+
}
|
|
2521
|
+
return 0;
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
if (error instanceof Error) {
|
|
2524
|
+
logger.error(error.message);
|
|
2525
|
+
} else {
|
|
2526
|
+
logger.error(String(error));
|
|
2527
|
+
}
|
|
2528
|
+
return 1;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
1240
2532
|
// src/commands/generate.ts
|
|
1241
|
-
import { intro as
|
|
2533
|
+
import { intro as intro3, isCancel as isCancel2, multiselect as multiselect2, outro as outro3 } from "@clack/prompts";
|
|
1242
2534
|
var TOOL_FEATURES = [
|
|
1243
2535
|
{ value: "copilot", label: "GitHub Copilot" },
|
|
1244
2536
|
{ value: "claude", label: "Claude Code" },
|
|
@@ -1254,20 +2546,18 @@ var TOOL_FEATURES = [
|
|
|
1254
2546
|
{ value: "agents-md", label: "AGENTS.md (cross-tool)" }
|
|
1255
2547
|
];
|
|
1256
2548
|
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
|
-
});
|
|
2549
|
+
async function runGenerate(options, batchMode) {
|
|
2550
|
+
const silent = options.json || options.summary;
|
|
2551
|
+
const template = await templateResolver.resolve(options.template, options.refresh);
|
|
2552
|
+
const features = featureResolver.resolve({
|
|
2553
|
+
baseFeatures: [...options.features],
|
|
2554
|
+
presetNames: [...options.preset],
|
|
2555
|
+
removeFeatures: [...options.removeFeatures],
|
|
2556
|
+
presetDefinitions: options.presets
|
|
2557
|
+
});
|
|
2558
|
+
if (!batchMode) {
|
|
1269
2559
|
const hasToolFlag = features.some((f) => TOOL_FEATURE_VALUES.has(f));
|
|
1270
|
-
if (!hasToolFlag) {
|
|
2560
|
+
if (!hasToolFlag && !silent) {
|
|
1271
2561
|
const selected = await multiselect2({
|
|
1272
2562
|
message: "Select AI tools to generate for (space to toggle, enter to confirm):",
|
|
1273
2563
|
options: TOOL_FEATURES.map((t) => ({ value: t.value, label: t.label })),
|
|
@@ -1279,22 +2569,74 @@ async function generateCommand(cliOptions) {
|
|
|
1279
2569
|
}
|
|
1280
2570
|
features.push(...selected);
|
|
1281
2571
|
}
|
|
1282
|
-
|
|
2572
|
+
}
|
|
2573
|
+
const effectiveDryRun = options.json || options.dryRun;
|
|
2574
|
+
if (!silent) {
|
|
2575
|
+
if (effectiveDryRun) {
|
|
1283
2576
|
logger.info("Running in dry-run mode (no files will be modified)");
|
|
1284
2577
|
}
|
|
1285
2578
|
if (options.force) {
|
|
1286
2579
|
logger.info("Force mode enabled (existing files will be overwritten)");
|
|
1287
2580
|
}
|
|
2581
|
+
}
|
|
2582
|
+
let mergedDir = null;
|
|
2583
|
+
let templatePath = template.localPath;
|
|
2584
|
+
if (options.overlay.length > 0) {
|
|
2585
|
+
const overlayDirs = await resolveOverlays([...options.overlay], options.refresh);
|
|
2586
|
+
mergedDir = await buildMergedDir(template.localPath, overlayDirs);
|
|
2587
|
+
templatePath = mergedDir;
|
|
2588
|
+
}
|
|
2589
|
+
try {
|
|
1288
2590
|
const result = await fileGenerator.generate({
|
|
1289
|
-
templatePath
|
|
2591
|
+
templatePath,
|
|
1290
2592
|
outputPath: options.output,
|
|
1291
2593
|
features,
|
|
1292
2594
|
force: options.force,
|
|
1293
|
-
dryRun:
|
|
2595
|
+
dryRun: effectiveDryRun,
|
|
1294
2596
|
delete: options.delete
|
|
1295
2597
|
});
|
|
1296
|
-
|
|
1297
|
-
|
|
2598
|
+
if (options.json) {
|
|
2599
|
+
writeJsonOutput(serializeGenerationResult(result));
|
|
2600
|
+
} else if (options.summary) {
|
|
2601
|
+
console.log(formatGenerationSummary(result));
|
|
2602
|
+
} else {
|
|
2603
|
+
logger.summary(result);
|
|
2604
|
+
}
|
|
2605
|
+
} finally {
|
|
2606
|
+
if (mergedDir) {
|
|
2607
|
+
try {
|
|
2608
|
+
await rmDir(mergedDir);
|
|
2609
|
+
} catch {
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
async function generateCommand(cliOptions) {
|
|
2615
|
+
try {
|
|
2616
|
+
const fileConfig = await configLoader.load(cliOptions.config ?? null);
|
|
2617
|
+
if (!cliOptions.config && fileConfig === null) {
|
|
2618
|
+
logger.info("Tip: create .awa.toml to save your options for next time.");
|
|
2619
|
+
}
|
|
2620
|
+
if (cliOptions.all || cliOptions.target) {
|
|
2621
|
+
const mode = cliOptions.all ? "all" : "single";
|
|
2622
|
+
const targets = batchRunner.resolveTargets(cliOptions, fileConfig, mode, cliOptions.target);
|
|
2623
|
+
for (const { targetName, options: options2 } of targets) {
|
|
2624
|
+
batchRunner.logForTarget(targetName, "Starting generation...");
|
|
2625
|
+
await runGenerate(options2, true);
|
|
2626
|
+
batchRunner.logForTarget(targetName, "Generation complete.");
|
|
2627
|
+
}
|
|
2628
|
+
outro3("All targets generated!");
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const options = configLoader.merge(cliOptions, fileConfig);
|
|
2632
|
+
const silent = options.json || options.summary;
|
|
2633
|
+
if (!silent) {
|
|
2634
|
+
intro3("awa CLI - Template Generator");
|
|
2635
|
+
}
|
|
2636
|
+
await runGenerate(options, false);
|
|
2637
|
+
if (!silent) {
|
|
2638
|
+
outro3("Generation complete!");
|
|
2639
|
+
}
|
|
1298
2640
|
} catch (error) {
|
|
1299
2641
|
if (error instanceof Error) {
|
|
1300
2642
|
logger.error(error.message);
|
|
@@ -1305,49 +2647,483 @@ async function generateCommand(cliOptions) {
|
|
|
1305
2647
|
}
|
|
1306
2648
|
}
|
|
1307
2649
|
|
|
2650
|
+
// src/commands/test.ts
|
|
2651
|
+
import { intro as intro4, outro as outro4 } from "@clack/prompts";
|
|
2652
|
+
|
|
2653
|
+
// src/core/template-test/fixture-loader.ts
|
|
2654
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
2655
|
+
import { basename as basename2, extname, join as join8 } from "path";
|
|
2656
|
+
import { parse } from "smol-toml";
|
|
2657
|
+
async function discoverFixtures(templatePath) {
|
|
2658
|
+
const testsDir = join8(templatePath, "_tests");
|
|
2659
|
+
let entries;
|
|
2660
|
+
try {
|
|
2661
|
+
const dirEntries = await readdir2(testsDir, { withFileTypes: true });
|
|
2662
|
+
entries = dirEntries.filter((e) => e.isFile() && extname(e.name) === ".toml").map((e) => e.name).sort();
|
|
2663
|
+
} catch {
|
|
2664
|
+
return [];
|
|
2665
|
+
}
|
|
2666
|
+
const fixtures = [];
|
|
2667
|
+
for (const filename of entries) {
|
|
2668
|
+
const filePath = join8(testsDir, filename);
|
|
2669
|
+
const fixture = await parseFixture(filePath);
|
|
2670
|
+
fixtures.push(fixture);
|
|
2671
|
+
}
|
|
2672
|
+
return fixtures;
|
|
2673
|
+
}
|
|
2674
|
+
async function parseFixture(filePath) {
|
|
2675
|
+
const content = await readTextFile(filePath);
|
|
2676
|
+
const parsed = parse(content);
|
|
2677
|
+
const name = basename2(filePath, extname(filePath));
|
|
2678
|
+
const features = toStringArray2(parsed.features) ?? [];
|
|
2679
|
+
const preset = toStringArray2(parsed.preset) ?? [];
|
|
2680
|
+
const removeFeatures = toStringArray2(parsed["remove-features"]) ?? [];
|
|
2681
|
+
const expectedFiles = toStringArray2(parsed["expected-files"]) ?? [];
|
|
2682
|
+
return {
|
|
2683
|
+
name,
|
|
2684
|
+
features,
|
|
2685
|
+
preset,
|
|
2686
|
+
removeFeatures,
|
|
2687
|
+
expectedFiles,
|
|
2688
|
+
filePath
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
function toStringArray2(value) {
|
|
2692
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
2693
|
+
return value;
|
|
2694
|
+
}
|
|
2695
|
+
return null;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// src/core/template-test/reporter.ts
|
|
2699
|
+
import chalk4 from "chalk";
|
|
2700
|
+
function report2(result) {
|
|
2701
|
+
console.log("");
|
|
2702
|
+
for (const fixture of result.results) {
|
|
2703
|
+
reportFixture(fixture);
|
|
2704
|
+
}
|
|
2705
|
+
console.log("");
|
|
2706
|
+
console.log(chalk4.bold("Test Summary:"));
|
|
2707
|
+
console.log(` Total: ${result.total}`);
|
|
2708
|
+
console.log(chalk4.green(` Passed: ${result.passed}`));
|
|
2709
|
+
if (result.failed > 0) {
|
|
2710
|
+
console.log(chalk4.red(` Failed: ${result.failed}`));
|
|
2711
|
+
}
|
|
2712
|
+
console.log("");
|
|
2713
|
+
}
|
|
2714
|
+
function reportFixture(fixture) {
|
|
2715
|
+
const icon = fixture.passed ? chalk4.green("\u2714") : chalk4.red("\u2716");
|
|
2716
|
+
console.log(`${icon} ${fixture.name}`);
|
|
2717
|
+
if (fixture.error) {
|
|
2718
|
+
console.log(chalk4.red(` Error: ${fixture.error}`));
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
const missingFiles = fixture.fileResults.filter((r) => !r.found);
|
|
2722
|
+
for (const missing of missingFiles) {
|
|
2723
|
+
console.log(chalk4.red(` Missing file: ${missing.path}`));
|
|
2724
|
+
}
|
|
2725
|
+
const snapshotFailures = fixture.snapshotResults.filter((r) => r.status !== "match");
|
|
2726
|
+
for (const failure of snapshotFailures) {
|
|
2727
|
+
switch (failure.status) {
|
|
2728
|
+
case "mismatch":
|
|
2729
|
+
console.log(chalk4.yellow(` Snapshot mismatch: ${failure.path}`));
|
|
2730
|
+
break;
|
|
2731
|
+
case "missing-snapshot":
|
|
2732
|
+
console.log(chalk4.yellow(` Missing snapshot: ${failure.path}`));
|
|
2733
|
+
break;
|
|
2734
|
+
case "extra-snapshot":
|
|
2735
|
+
console.log(chalk4.yellow(` Extra snapshot (not in output): ${failure.path}`));
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// src/core/template-test/runner.ts
|
|
2742
|
+
import { mkdir as mkdir2, rm as rm3 } from "fs/promises";
|
|
2743
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
2744
|
+
import { join as join10 } from "path";
|
|
2745
|
+
|
|
2746
|
+
// src/core/template-test/snapshot.ts
|
|
2747
|
+
import { mkdir, readdir as readdir3, rm as rm2 } from "fs/promises";
|
|
2748
|
+
import { join as join9, relative as relative4 } from "path";
|
|
2749
|
+
async function walkRelative(dir, base) {
|
|
2750
|
+
const results = [];
|
|
2751
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
2752
|
+
for (const entry of entries) {
|
|
2753
|
+
const fullPath = join9(dir, entry.name);
|
|
2754
|
+
if (entry.isDirectory()) {
|
|
2755
|
+
const sub = await walkRelative(fullPath, base);
|
|
2756
|
+
results.push(...sub);
|
|
2757
|
+
} else if (entry.isFile()) {
|
|
2758
|
+
results.push(relative4(base, fullPath));
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return results;
|
|
2762
|
+
}
|
|
2763
|
+
async function compareSnapshots(renderedDir, snapshotDir) {
|
|
2764
|
+
const results = [];
|
|
2765
|
+
const renderedFiles = await walkRelative(renderedDir, renderedDir);
|
|
2766
|
+
const snapshotFiles = await walkRelative(snapshotDir, snapshotDir);
|
|
2767
|
+
const snapshotSet = new Set(snapshotFiles);
|
|
2768
|
+
const renderedSet = new Set(renderedFiles);
|
|
2769
|
+
for (const file of renderedFiles) {
|
|
2770
|
+
const renderedPath = join9(renderedDir, file);
|
|
2771
|
+
const snapshotPath = join9(snapshotDir, file);
|
|
2772
|
+
if (!snapshotSet.has(file)) {
|
|
2773
|
+
results.push({ path: file, status: "missing-snapshot" });
|
|
2774
|
+
continue;
|
|
2775
|
+
}
|
|
2776
|
+
const renderedContent = await readTextFile(renderedPath);
|
|
2777
|
+
const snapshotContent = await readTextFile(snapshotPath);
|
|
2778
|
+
results.push({
|
|
2779
|
+
path: file,
|
|
2780
|
+
status: renderedContent === snapshotContent ? "match" : "mismatch"
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
for (const file of snapshotFiles) {
|
|
2784
|
+
if (!renderedSet.has(file)) {
|
|
2785
|
+
results.push({ path: file, status: "extra-snapshot" });
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
return results;
|
|
2789
|
+
}
|
|
2790
|
+
async function updateSnapshots(renderedDir, snapshotDir) {
|
|
2791
|
+
if (await pathExists(snapshotDir)) {
|
|
2792
|
+
await rm2(snapshotDir, { recursive: true, force: true });
|
|
2793
|
+
}
|
|
2794
|
+
await mkdir(snapshotDir, { recursive: true });
|
|
2795
|
+
const files = await walkRelative(renderedDir, renderedDir);
|
|
2796
|
+
for (const file of files) {
|
|
2797
|
+
const srcPath = join9(renderedDir, file);
|
|
2798
|
+
const destPath = join9(snapshotDir, file);
|
|
2799
|
+
const content = await readTextFile(srcPath);
|
|
2800
|
+
await writeTextFile(destPath, content);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// src/core/template-test/runner.ts
|
|
2805
|
+
async function runFixture(fixture, templatePath, options, presetDefinitions = {}) {
|
|
2806
|
+
const tempDir = join10(tmpdir3(), `awa-test-${fixture.name}-${Date.now()}`);
|
|
2807
|
+
try {
|
|
2808
|
+
await mkdir2(tempDir, { recursive: true });
|
|
2809
|
+
const features = featureResolver.resolve({
|
|
2810
|
+
baseFeatures: [...fixture.features],
|
|
2811
|
+
presetNames: [...fixture.preset],
|
|
2812
|
+
removeFeatures: [...fixture.removeFeatures],
|
|
2813
|
+
presetDefinitions
|
|
2814
|
+
});
|
|
2815
|
+
await fileGenerator.generate({
|
|
2816
|
+
templatePath,
|
|
2817
|
+
outputPath: tempDir,
|
|
2818
|
+
features,
|
|
2819
|
+
force: true,
|
|
2820
|
+
dryRun: false,
|
|
2821
|
+
delete: false
|
|
2822
|
+
});
|
|
2823
|
+
const fileResults = [];
|
|
2824
|
+
for (const expectedFile of fixture.expectedFiles) {
|
|
2825
|
+
const fullPath = join10(tempDir, expectedFile);
|
|
2826
|
+
const found = await pathExists(fullPath);
|
|
2827
|
+
fileResults.push({ path: expectedFile, found });
|
|
2828
|
+
}
|
|
2829
|
+
const missingFiles = fileResults.filter((r) => !r.found);
|
|
2830
|
+
const snapshotDir = join10(templatePath, "_tests", fixture.name);
|
|
2831
|
+
let snapshotResults = [];
|
|
2832
|
+
if (options.updateSnapshots) {
|
|
2833
|
+
await updateSnapshots(tempDir, snapshotDir);
|
|
2834
|
+
} else if (await pathExists(snapshotDir)) {
|
|
2835
|
+
snapshotResults = await compareSnapshots(tempDir, snapshotDir);
|
|
2836
|
+
}
|
|
2837
|
+
const snapshotFailures = snapshotResults.filter((r) => r.status !== "match");
|
|
2838
|
+
const passed = missingFiles.length === 0 && snapshotFailures.length === 0;
|
|
2839
|
+
return {
|
|
2840
|
+
name: fixture.name,
|
|
2841
|
+
passed,
|
|
2842
|
+
fileResults,
|
|
2843
|
+
snapshotResults
|
|
2844
|
+
};
|
|
2845
|
+
} catch (error) {
|
|
2846
|
+
return {
|
|
2847
|
+
name: fixture.name,
|
|
2848
|
+
passed: false,
|
|
2849
|
+
fileResults: [],
|
|
2850
|
+
snapshotResults: [],
|
|
2851
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2852
|
+
};
|
|
2853
|
+
} finally {
|
|
2854
|
+
await rm3(tempDir, { recursive: true, force: true }).catch(() => {
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
async function runAll(fixtures, templatePath, options, presetDefinitions = {}) {
|
|
2859
|
+
const results = [];
|
|
2860
|
+
for (const fixture of fixtures) {
|
|
2861
|
+
const result = await runFixture(fixture, templatePath, options, presetDefinitions);
|
|
2862
|
+
results.push(result);
|
|
2863
|
+
}
|
|
2864
|
+
const passed = results.filter((r) => r.passed).length;
|
|
2865
|
+
const failed = results.filter((r) => !r.passed).length;
|
|
2866
|
+
return {
|
|
2867
|
+
results,
|
|
2868
|
+
total: results.length,
|
|
2869
|
+
passed,
|
|
2870
|
+
failed
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
// src/commands/test.ts
|
|
2875
|
+
async function testCommand(options) {
|
|
2876
|
+
try {
|
|
2877
|
+
intro4("awa CLI - Template Test");
|
|
2878
|
+
const fileConfig = await configLoader.load(options.config ?? null);
|
|
2879
|
+
const templateSource = options.template ?? fileConfig?.template ?? null;
|
|
2880
|
+
const template = await templateResolver.resolve(templateSource, false);
|
|
2881
|
+
const fixtures = await discoverFixtures(template.localPath);
|
|
2882
|
+
if (fixtures.length === 0) {
|
|
2883
|
+
logger.warn("No test fixtures found in _tests/ directory");
|
|
2884
|
+
outro4("No tests to run.");
|
|
2885
|
+
return 0;
|
|
2886
|
+
}
|
|
2887
|
+
logger.info(`Found ${fixtures.length} fixture(s)`);
|
|
2888
|
+
const presetDefinitions = fileConfig?.presets ?? {};
|
|
2889
|
+
const result = await runAll(
|
|
2890
|
+
fixtures,
|
|
2891
|
+
template.localPath,
|
|
2892
|
+
{ updateSnapshots: options.updateSnapshots },
|
|
2893
|
+
presetDefinitions
|
|
2894
|
+
);
|
|
2895
|
+
report2(result);
|
|
2896
|
+
if (result.failed > 0) {
|
|
2897
|
+
outro4(`${result.failed} fixture(s) failed.`);
|
|
2898
|
+
return 1;
|
|
2899
|
+
}
|
|
2900
|
+
outro4("All tests passed!");
|
|
2901
|
+
return 0;
|
|
2902
|
+
} catch (error) {
|
|
2903
|
+
if (error instanceof Error) {
|
|
2904
|
+
logger.error(error.message);
|
|
2905
|
+
} else {
|
|
2906
|
+
logger.error(String(error));
|
|
2907
|
+
}
|
|
2908
|
+
return 2;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// src/utils/update-check.ts
|
|
2913
|
+
import chalk5 from "chalk";
|
|
2914
|
+
function compareSemver(a, b) {
|
|
2915
|
+
const partsA = a.split(".").map((s) => Number.parseInt(s, 10));
|
|
2916
|
+
const partsB = b.split(".").map((s) => Number.parseInt(s, 10));
|
|
2917
|
+
for (let i = 0; i < 3; i++) {
|
|
2918
|
+
const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
|
|
2919
|
+
if (diff !== 0) return diff;
|
|
2920
|
+
}
|
|
2921
|
+
return 0;
|
|
2922
|
+
}
|
|
2923
|
+
function isMajorVersionBump(current, latest) {
|
|
2924
|
+
const currentMajor = Number.parseInt(current.split(".")[0] ?? "0", 10);
|
|
2925
|
+
const latestMajor = Number.parseInt(latest.split(".")[0] ?? "0", 10);
|
|
2926
|
+
return latestMajor > currentMajor;
|
|
2927
|
+
}
|
|
2928
|
+
async function checkForUpdate() {
|
|
2929
|
+
try {
|
|
2930
|
+
const response = await fetch("https://registry.npmjs.org/@ncoderz/awa/latest", {
|
|
2931
|
+
signal: AbortSignal.timeout(5e3)
|
|
2932
|
+
});
|
|
2933
|
+
if (!response.ok) return null;
|
|
2934
|
+
const data = await response.json();
|
|
2935
|
+
const latest = data.version;
|
|
2936
|
+
if (!latest || typeof latest !== "string") return null;
|
|
2937
|
+
const current = PACKAGE_INFO.version;
|
|
2938
|
+
const isOutdated = compareSemver(current, latest) < 0;
|
|
2939
|
+
return {
|
|
2940
|
+
current,
|
|
2941
|
+
latest,
|
|
2942
|
+
isOutdated,
|
|
2943
|
+
isMajorBump: isOutdated && isMajorVersionBump(current, latest)
|
|
2944
|
+
};
|
|
2945
|
+
} catch {
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
function printUpdateWarning(log, result) {
|
|
2950
|
+
if (!result.isOutdated) return;
|
|
2951
|
+
console.log("");
|
|
2952
|
+
if (result.isMajorBump) {
|
|
2953
|
+
log.warn(
|
|
2954
|
+
chalk5.yellow(
|
|
2955
|
+
`New major version available: ${result.current} \u2192 ${result.latest} (breaking changes)`
|
|
2956
|
+
)
|
|
2957
|
+
);
|
|
2958
|
+
log.warn(chalk5.dim(" See https://github.com/ncoderz/awa/releases for details"));
|
|
2959
|
+
} else {
|
|
2960
|
+
log.warn(chalk5.yellow(`Update available: ${result.current} \u2192 ${result.latest}`));
|
|
2961
|
+
}
|
|
2962
|
+
log.warn(chalk5.dim(" Run `npm install -g @ncoderz/awa` to update"));
|
|
2963
|
+
console.log("");
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// src/utils/update-check-cache.ts
|
|
2967
|
+
import { mkdir as mkdir3, readFile as readFile6, writeFile } from "fs/promises";
|
|
2968
|
+
import { homedir } from "os";
|
|
2969
|
+
import { dirname, join as join11 } from "path";
|
|
2970
|
+
var CACHE_DIR = join11(homedir(), ".cache", "awa");
|
|
2971
|
+
var CACHE_FILE = join11(CACHE_DIR, "update-check.json");
|
|
2972
|
+
var DEFAULT_INTERVAL_MS = 864e5;
|
|
2973
|
+
async function shouldCheck(intervalMs = DEFAULT_INTERVAL_MS) {
|
|
2974
|
+
try {
|
|
2975
|
+
const raw = await readFile6(CACHE_FILE, "utf-8");
|
|
2976
|
+
const data = JSON.parse(raw);
|
|
2977
|
+
if (typeof data.timestamp !== "number" || typeof data.latestVersion !== "string") {
|
|
2978
|
+
return true;
|
|
2979
|
+
}
|
|
2980
|
+
return Date.now() - data.timestamp >= intervalMs;
|
|
2981
|
+
} catch {
|
|
2982
|
+
return true;
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
async function writeCache(latestVersion) {
|
|
2986
|
+
try {
|
|
2987
|
+
await mkdir3(dirname(CACHE_FILE), { recursive: true });
|
|
2988
|
+
const data = {
|
|
2989
|
+
timestamp: Date.now(),
|
|
2990
|
+
latestVersion
|
|
2991
|
+
};
|
|
2992
|
+
await writeFile(CACHE_FILE, JSON.stringify(data), "utf-8");
|
|
2993
|
+
} catch {
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
|
|
1308
2997
|
// src/cli/index.ts
|
|
1309
2998
|
var version = PACKAGE_INFO.version;
|
|
1310
2999
|
var program = new Command();
|
|
1311
3000
|
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(
|
|
3001
|
+
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
3002
|
"--remove-features <flag...>",
|
|
1314
3003
|
"Feature flags to remove (can be specified multiple times)"
|
|
1315
3004
|
).option("--force", "Force overwrite existing files without prompting", false).option("--dry-run", "Preview changes without modifying files", false).option(
|
|
1316
3005
|
"--delete",
|
|
1317
3006
|
"Enable deletion of files listed in the delete list (default: warn only)",
|
|
1318
3007
|
false
|
|
1319
|
-
).option("-c, --config <path>", "Path to configuration file").option("--refresh", "Force refresh of cached Git templates", false).action(async (output, options) => {
|
|
3008
|
+
).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
3009
|
const cliOptions = {
|
|
1321
3010
|
output,
|
|
1322
3011
|
template: options.template,
|
|
1323
|
-
features: options.features
|
|
1324
|
-
preset: options.preset
|
|
1325
|
-
removeFeatures: options.removeFeatures
|
|
3012
|
+
features: options.features,
|
|
3013
|
+
preset: options.preset,
|
|
3014
|
+
removeFeatures: options.removeFeatures,
|
|
1326
3015
|
force: options.force,
|
|
1327
3016
|
dryRun: options.dryRun,
|
|
1328
3017
|
delete: options.delete,
|
|
1329
3018
|
config: options.config,
|
|
1330
|
-
refresh: options.refresh
|
|
3019
|
+
refresh: options.refresh,
|
|
3020
|
+
all: options.all,
|
|
3021
|
+
target: options.target,
|
|
3022
|
+
overlay: options.overlay || [],
|
|
3023
|
+
json: options.json,
|
|
3024
|
+
summary: options.summary
|
|
1331
3025
|
};
|
|
1332
3026
|
await generateCommand(cliOptions);
|
|
1333
3027
|
});
|
|
1334
3028
|
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
3029
|
"--remove-features <flag...>",
|
|
1336
3030
|
"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) => {
|
|
3031
|
+
).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
3032
|
const cliOptions = {
|
|
1339
3033
|
output: target,
|
|
1340
3034
|
// Use target as output for consistency
|
|
1341
3035
|
template: options.template,
|
|
1342
|
-
features: options.features
|
|
1343
|
-
preset: options.preset
|
|
1344
|
-
removeFeatures: options.removeFeatures
|
|
3036
|
+
features: options.features,
|
|
3037
|
+
preset: options.preset,
|
|
3038
|
+
removeFeatures: options.removeFeatures,
|
|
1345
3039
|
config: options.config,
|
|
1346
3040
|
refresh: options.refresh,
|
|
1347
|
-
listUnknown: options.listUnknown
|
|
3041
|
+
listUnknown: options.listUnknown,
|
|
3042
|
+
all: options.all,
|
|
3043
|
+
target: options.target,
|
|
3044
|
+
watch: options.watch,
|
|
3045
|
+
overlay: options.overlay || [],
|
|
3046
|
+
json: options.json,
|
|
3047
|
+
summary: options.summary
|
|
1348
3048
|
};
|
|
1349
3049
|
const exitCode = await diffCommand(cliOptions);
|
|
1350
3050
|
process.exit(exitCode);
|
|
1351
3051
|
});
|
|
1352
|
-
program.
|
|
3052
|
+
program.command("check").description(
|
|
3053
|
+
"Validate spec files against schemas and check traceability between code markers and specs"
|
|
3054
|
+
).option("-c, --config <path>", "Path to configuration file").option("--spec-ignore <pattern...>", "Glob patterns to exclude from spec file scanning").option("--code-ignore <pattern...>", "Glob patterns to exclude from code file scanning").option("--format <format>", "Output format (text or json)", "text").option(
|
|
3055
|
+
"--allow-warnings",
|
|
3056
|
+
"Allow warnings without failing (default: warnings are errors)",
|
|
3057
|
+
false
|
|
3058
|
+
).option(
|
|
3059
|
+
"--spec-only",
|
|
3060
|
+
"Run only spec-level checks (schema and cross-refs); skip code-to-spec traceability",
|
|
3061
|
+
false
|
|
3062
|
+
).action(async (options) => {
|
|
3063
|
+
const cliOptions = {
|
|
3064
|
+
config: options.config,
|
|
3065
|
+
specIgnore: options.specIgnore,
|
|
3066
|
+
codeIgnore: options.codeIgnore,
|
|
3067
|
+
format: options.format,
|
|
3068
|
+
allowWarnings: options.allowWarnings,
|
|
3069
|
+
specOnly: options.specOnly
|
|
3070
|
+
};
|
|
3071
|
+
const exitCode = await checkCommand(cliOptions);
|
|
3072
|
+
process.exit(exitCode);
|
|
3073
|
+
});
|
|
3074
|
+
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) => {
|
|
3075
|
+
const exitCode = await featuresCommand({
|
|
3076
|
+
template: options.template,
|
|
3077
|
+
config: options.config,
|
|
3078
|
+
refresh: options.refresh,
|
|
3079
|
+
json: options.json
|
|
3080
|
+
});
|
|
3081
|
+
process.exit(exitCode);
|
|
3082
|
+
});
|
|
3083
|
+
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) => {
|
|
3084
|
+
const testOptions = {
|
|
3085
|
+
template: options.template,
|
|
3086
|
+
config: options.config,
|
|
3087
|
+
updateSnapshots: options.updateSnapshots
|
|
3088
|
+
};
|
|
3089
|
+
const exitCode = await testCommand(testOptions);
|
|
3090
|
+
process.exit(exitCode);
|
|
3091
|
+
});
|
|
3092
|
+
var updateCheckPromise = null;
|
|
3093
|
+
var isJsonOrSummary = process.argv.includes("--json") || process.argv.includes("--summary");
|
|
3094
|
+
var isTTY = process.stdout.isTTY === true;
|
|
3095
|
+
var isDisabledByEnv = !!process.env.NO_UPDATE_NOTIFIER;
|
|
3096
|
+
if (!isJsonOrSummary && isTTY && !isDisabledByEnv) {
|
|
3097
|
+
updateCheckPromise = (async () => {
|
|
3098
|
+
try {
|
|
3099
|
+
const { configLoader: configLoader2 } = await import("./config-2TOQATI3.js");
|
|
3100
|
+
const configPath = process.argv.indexOf("-c") !== -1 ? process.argv[process.argv.indexOf("-c") + 1] : process.argv.indexOf("--config") !== -1 ? process.argv[process.argv.indexOf("--config") + 1] : void 0;
|
|
3101
|
+
const fileConfig = await configLoader2.load(configPath ?? null);
|
|
3102
|
+
const updateCheckConfig = fileConfig?.["update-check"];
|
|
3103
|
+
if (updateCheckConfig?.enabled === false) return null;
|
|
3104
|
+
const intervalSeconds = updateCheckConfig?.interval ?? 86400;
|
|
3105
|
+
const intervalMs = intervalSeconds * 1e3;
|
|
3106
|
+
const needsCheck = await shouldCheck(intervalMs);
|
|
3107
|
+
if (!needsCheck) return null;
|
|
3108
|
+
const result = await checkForUpdate();
|
|
3109
|
+
if (result) {
|
|
3110
|
+
await writeCache(result.latest);
|
|
3111
|
+
}
|
|
3112
|
+
return result;
|
|
3113
|
+
} catch {
|
|
3114
|
+
return null;
|
|
3115
|
+
}
|
|
3116
|
+
})();
|
|
3117
|
+
}
|
|
3118
|
+
program.hook("postAction", async () => {
|
|
3119
|
+
if (!updateCheckPromise) return;
|
|
3120
|
+
try {
|
|
3121
|
+
const result = await updateCheckPromise;
|
|
3122
|
+
if (result?.isOutdated) {
|
|
3123
|
+
printUpdateWarning(logger, result);
|
|
3124
|
+
}
|
|
3125
|
+
} catch {
|
|
3126
|
+
}
|
|
3127
|
+
});
|
|
3128
|
+
program.parseAsync();
|
|
1353
3129
|
//# sourceMappingURL=index.js.map
|