@bantay/cli 0.1.0 → 0.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/package.json +1 -1
- package/src/cli.ts +95 -9
- package/src/commands/ci.ts +228 -0
- package/src/commands/status.ts +309 -0
- package/src/detectors/authjs.ts +92 -0
- package/src/detectors/clerk.ts +88 -0
- package/src/detectors/drizzle.ts +105 -0
- package/src/detectors/index.ts +35 -14
- package/src/detectors/nextjs.ts +13 -3
- package/src/detectors/stripe.ts +88 -0
- package/src/detectors/types.ts +12 -0
- package/src/export/all.ts +5 -1
- package/src/export/codex.ts +70 -0
- package/src/export/index.ts +1 -0
- package/src/generators/invariants.ts +170 -54
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
handleAideLock,
|
|
13
13
|
printAideHelp,
|
|
14
14
|
} from "./commands/aide";
|
|
15
|
-
import { exportInvariants, exportClaude, exportCursor, exportAll } from "./export";
|
|
15
|
+
import { exportInvariants, exportClaude, exportCursor, exportCodex, exportAll } from "./export";
|
|
16
|
+
import { runStatus, formatStatus } from "./commands/status";
|
|
17
|
+
import { runCi, type CiOptions } from "./commands/ci";
|
|
16
18
|
|
|
17
19
|
const args = process.argv.slice(2);
|
|
18
20
|
const command = args[0];
|
|
@@ -33,10 +35,11 @@ async function main() {
|
|
|
33
35
|
} else if (command === "aide") {
|
|
34
36
|
await handleAide(args.slice(1));
|
|
35
37
|
} else if (command === "ci") {
|
|
36
|
-
|
|
37
|
-
process.exit(1);
|
|
38
|
+
await handleCi(args.slice(1));
|
|
38
39
|
} else if (command === "export") {
|
|
39
40
|
await handleExport(args.slice(1));
|
|
41
|
+
} else if (command === "status") {
|
|
42
|
+
await handleStatus(args.slice(1));
|
|
40
43
|
} else {
|
|
41
44
|
console.error(`Unknown command: ${command}`);
|
|
42
45
|
console.error('Run "bantay help" for usage information.');
|
|
@@ -72,6 +75,7 @@ Commands:
|
|
|
72
75
|
aide Manage the aide entity tree (add, remove, link, show, validate, lock)
|
|
73
76
|
ci Generate CI workflow configuration
|
|
74
77
|
export Export invariants to agent context files
|
|
78
|
+
status Show scenario implementation status
|
|
75
79
|
|
|
76
80
|
Options:
|
|
77
81
|
-h, --help Show this help message
|
|
@@ -83,10 +87,12 @@ Examples:
|
|
|
83
87
|
bantay aide show Show the aide entity tree
|
|
84
88
|
bantay aide add inv_test --parent invariants --prop "statement=Test"
|
|
85
89
|
bantay ci --github-actions Generate GitHub Actions workflow
|
|
86
|
-
bantay export all
|
|
87
|
-
bantay export invariants
|
|
88
|
-
bantay export claude
|
|
89
|
-
bantay export cursor
|
|
90
|
+
bantay export all Export all targets
|
|
91
|
+
bantay export invariants Generate invariants.md from bantay.aide
|
|
92
|
+
bantay export claude Export to CLAUDE.md
|
|
93
|
+
bantay export cursor Export to .cursorrules
|
|
94
|
+
bantay status Show scenario implementation status
|
|
95
|
+
bantay status --json Output as JSON
|
|
90
96
|
|
|
91
97
|
Run "bantay aide help" for aide subcommand details.
|
|
92
98
|
`);
|
|
@@ -133,6 +139,12 @@ async function handleInit(args: string[]) {
|
|
|
133
139
|
console.log(" Auth: Not detected");
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
if (result.detection.payments) {
|
|
143
|
+
console.log(` Payments: ${result.detection.payments.name} (${result.detection.payments.confidence} confidence)`);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(" Payments: Not detected");
|
|
146
|
+
}
|
|
147
|
+
|
|
136
148
|
console.log("");
|
|
137
149
|
|
|
138
150
|
// Display warnings
|
|
@@ -223,14 +235,16 @@ async function handleExport(args: string[]) {
|
|
|
223
235
|
console.error("Usage: bantay export <target>");
|
|
224
236
|
console.error("");
|
|
225
237
|
console.error("Targets:");
|
|
226
|
-
console.error(" all Export all targets (invariants, claude, cursor)");
|
|
238
|
+
console.error(" all Export all targets (invariants, claude, cursor, codex)");
|
|
227
239
|
console.error(" invariants Generate invariants.md from bantay.aide");
|
|
228
240
|
console.error(" claude Export to CLAUDE.md with section markers");
|
|
229
241
|
console.error(" cursor Export to .cursorrules with section markers");
|
|
242
|
+
console.error(" codex Export to AGENTS.md with section markers");
|
|
230
243
|
console.error("");
|
|
231
244
|
console.error("Examples:");
|
|
232
245
|
console.error(" bantay export invariants");
|
|
233
246
|
console.error(" bantay export claude");
|
|
247
|
+
console.error(" bantay export codex");
|
|
234
248
|
console.error(" bantay export --target cursor");
|
|
235
249
|
process.exit(1);
|
|
236
250
|
}
|
|
@@ -256,9 +270,13 @@ async function handleExport(args: string[]) {
|
|
|
256
270
|
const result = await exportCursor(projectPath, { dryRun });
|
|
257
271
|
console.log(`Exported to ${result.outputPath}`);
|
|
258
272
|
console.log(` ${result.bytesWritten} bytes written`);
|
|
273
|
+
} else if (target === "codex") {
|
|
274
|
+
const result = await exportCodex(projectPath, { dryRun });
|
|
275
|
+
console.log(`Exported to ${result.outputPath}`);
|
|
276
|
+
console.log(` ${result.bytesWritten} bytes written`);
|
|
259
277
|
} else {
|
|
260
278
|
console.error(`Unknown export target: ${target}`);
|
|
261
|
-
console.error('Valid targets: all, invariants, claude, cursor');
|
|
279
|
+
console.error('Valid targets: all, invariants, claude, cursor, codex');
|
|
262
280
|
process.exit(1);
|
|
263
281
|
}
|
|
264
282
|
|
|
@@ -277,6 +295,74 @@ async function handleExport(args: string[]) {
|
|
|
277
295
|
}
|
|
278
296
|
}
|
|
279
297
|
|
|
298
|
+
async function handleCi(args: string[]) {
|
|
299
|
+
const projectPath = process.cwd();
|
|
300
|
+
|
|
301
|
+
// Parse provider from args
|
|
302
|
+
const hasGitHub = args.includes("--github-actions") || args.includes("--github");
|
|
303
|
+
const hasGitLab = args.includes("--gitlab");
|
|
304
|
+
const force = args.includes("--force");
|
|
305
|
+
|
|
306
|
+
let provider: "github-actions" | "gitlab" | "generic";
|
|
307
|
+
|
|
308
|
+
if (hasGitHub) {
|
|
309
|
+
provider = "github-actions";
|
|
310
|
+
} else if (hasGitLab) {
|
|
311
|
+
provider = "gitlab";
|
|
312
|
+
} else {
|
|
313
|
+
provider = "generic";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const result = await runCi(projectPath, { provider, force });
|
|
318
|
+
|
|
319
|
+
if (result.alreadyExists) {
|
|
320
|
+
console.error(`${result.outputPath} already exists.`);
|
|
321
|
+
console.error("Use --force to overwrite.");
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (provider === "generic") {
|
|
326
|
+
console.log(result.content);
|
|
327
|
+
} else {
|
|
328
|
+
console.log(`Generated ${result.outputPath}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
process.exit(0);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof Error) {
|
|
334
|
+
console.error(`Error: ${error.message}`);
|
|
335
|
+
} else {
|
|
336
|
+
console.error("Error running ci:", error);
|
|
337
|
+
}
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function handleStatus(args: string[]) {
|
|
343
|
+
const projectPath = process.cwd();
|
|
344
|
+
const jsonOutput = args.includes("--json");
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const result = await runStatus(projectPath, { json: jsonOutput });
|
|
348
|
+
|
|
349
|
+
if (jsonOutput) {
|
|
350
|
+
console.log(JSON.stringify(result, null, 2));
|
|
351
|
+
} else {
|
|
352
|
+
console.log(formatStatus(result));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
process.exit(0);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error instanceof Error) {
|
|
358
|
+
console.error(`Error: ${error.message}`);
|
|
359
|
+
} else {
|
|
360
|
+
console.error("Error running status:", error);
|
|
361
|
+
}
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
280
366
|
async function handleAide(args: string[]) {
|
|
281
367
|
const subcommand = args[0];
|
|
282
368
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI workflow generator
|
|
3
|
+
*
|
|
4
|
+
* Generates CI configuration for GitHub Actions, GitLab CI, or generic shell commands.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile, writeFile, access, mkdir } from "fs/promises";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
|
|
10
|
+
export interface CiOptions {
|
|
11
|
+
provider: "github-actions" | "gitlab" | "generic";
|
|
12
|
+
force?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CiResult {
|
|
16
|
+
provider: string;
|
|
17
|
+
outputPath?: string;
|
|
18
|
+
content: string;
|
|
19
|
+
alreadyExists?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
await access(path);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate GitHub Actions workflow YAML
|
|
33
|
+
*/
|
|
34
|
+
function generateGitHubWorkflow(): string {
|
|
35
|
+
return `name: Bantay Invariant Check
|
|
36
|
+
|
|
37
|
+
on:
|
|
38
|
+
pull_request:
|
|
39
|
+
branches: [main, master]
|
|
40
|
+
push:
|
|
41
|
+
branches: [main, master]
|
|
42
|
+
|
|
43
|
+
jobs:
|
|
44
|
+
bantay-check:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
|
|
49
|
+
- name: Setup Bun
|
|
50
|
+
uses: oven-sh/setup-bun@v1
|
|
51
|
+
with:
|
|
52
|
+
bun-version: latest
|
|
53
|
+
|
|
54
|
+
- name: Install dependencies
|
|
55
|
+
run: bun install
|
|
56
|
+
|
|
57
|
+
- name: Run Bantay check
|
|
58
|
+
run: bunx @bantay/cli check --json > bantay-results.json 2>&1 || true
|
|
59
|
+
|
|
60
|
+
- name: Upload results
|
|
61
|
+
uses: actions/upload-artifact@v4
|
|
62
|
+
with:
|
|
63
|
+
name: bantay-results
|
|
64
|
+
path: bantay-results.json
|
|
65
|
+
|
|
66
|
+
- name: Check for failures
|
|
67
|
+
run: |
|
|
68
|
+
if grep -q '"status":"fail"' bantay-results.json; then
|
|
69
|
+
echo "Invariant violations found!"
|
|
70
|
+
cat bantay-results.json
|
|
71
|
+
exit 1
|
|
72
|
+
fi
|
|
73
|
+
echo "All invariants pass!"
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate GitLab CI configuration
|
|
79
|
+
*/
|
|
80
|
+
function generateGitLabConfig(): string {
|
|
81
|
+
return `# Bantay invariant check stage
|
|
82
|
+
# Add this to your .gitlab-ci.yml
|
|
83
|
+
|
|
84
|
+
bantay:
|
|
85
|
+
stage: test
|
|
86
|
+
image: oven/bun:latest
|
|
87
|
+
script:
|
|
88
|
+
- bun install
|
|
89
|
+
- bunx @bantay/cli check --json > bantay-results.json
|
|
90
|
+
artifacts:
|
|
91
|
+
paths:
|
|
92
|
+
- bantay-results.json
|
|
93
|
+
reports:
|
|
94
|
+
dotenv: bantay-results.json
|
|
95
|
+
rules:
|
|
96
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
97
|
+
- if: '$CI_COMMIT_BRANCH == "main"'
|
|
98
|
+
- if: '$CI_COMMIT_BRANCH == "master"'
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Generate generic CI instructions
|
|
104
|
+
*/
|
|
105
|
+
function generateGenericInstructions(): string {
|
|
106
|
+
return `# Bantay CI Integration
|
|
107
|
+
|
|
108
|
+
Run these commands in your CI pipeline:
|
|
109
|
+
|
|
110
|
+
## Install Bun (if not available)
|
|
111
|
+
curl -fsSL https://bun.sh/install | bash
|
|
112
|
+
|
|
113
|
+
## Install dependencies
|
|
114
|
+
bun install
|
|
115
|
+
|
|
116
|
+
## Run invariant check
|
|
117
|
+
bunx @bantay/cli check
|
|
118
|
+
|
|
119
|
+
## For JSON output (recommended for CI parsing)
|
|
120
|
+
bunx @bantay/cli check --json > bantay-results.json
|
|
121
|
+
|
|
122
|
+
## Exit code
|
|
123
|
+
# - 0: All invariants pass
|
|
124
|
+
# - 1: One or more invariants failed
|
|
125
|
+
# - 2: Configuration error
|
|
126
|
+
|
|
127
|
+
## Example for common CI systems:
|
|
128
|
+
|
|
129
|
+
### CircleCI
|
|
130
|
+
# jobs:
|
|
131
|
+
# bantay:
|
|
132
|
+
# docker:
|
|
133
|
+
# - image: oven/bun:latest
|
|
134
|
+
# steps:
|
|
135
|
+
# - checkout
|
|
136
|
+
# - run: bun install
|
|
137
|
+
# - run: bunx @bantay/cli check
|
|
138
|
+
|
|
139
|
+
### Jenkins (Jenkinsfile)
|
|
140
|
+
# stage('Bantay Check') {
|
|
141
|
+
# steps {
|
|
142
|
+
# sh 'curl -fsSL https://bun.sh/install | bash'
|
|
143
|
+
# sh 'bun install'
|
|
144
|
+
# sh 'bunx @bantay/cli check'
|
|
145
|
+
# }
|
|
146
|
+
# }
|
|
147
|
+
|
|
148
|
+
### Azure Pipelines
|
|
149
|
+
# - script: |
|
|
150
|
+
# curl -fsSL https://bun.sh/install | bash
|
|
151
|
+
# export PATH="$HOME/.bun/bin:$PATH"
|
|
152
|
+
# bun install
|
|
153
|
+
# bunx @bantay/cli check
|
|
154
|
+
# displayName: 'Run Bantay check'
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run CI generator
|
|
160
|
+
*/
|
|
161
|
+
export async function runCi(
|
|
162
|
+
projectPath: string,
|
|
163
|
+
options: CiOptions
|
|
164
|
+
): Promise<CiResult> {
|
|
165
|
+
const { provider, force } = options;
|
|
166
|
+
|
|
167
|
+
if (provider === "github-actions") {
|
|
168
|
+
const workflowDir = join(projectPath, ".github", "workflows");
|
|
169
|
+
const outputPath = join(workflowDir, "bantay.yml");
|
|
170
|
+
|
|
171
|
+
// Check if file already exists
|
|
172
|
+
if (await fileExists(outputPath)) {
|
|
173
|
+
if (!force) {
|
|
174
|
+
return {
|
|
175
|
+
provider: "github-actions",
|
|
176
|
+
outputPath,
|
|
177
|
+
content: "",
|
|
178
|
+
alreadyExists: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Create directory if needed
|
|
184
|
+
await mkdir(workflowDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
// Generate and write workflow
|
|
187
|
+
const content = generateGitHubWorkflow();
|
|
188
|
+
await writeFile(outputPath, content, "utf-8");
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
provider: "github-actions",
|
|
192
|
+
outputPath,
|
|
193
|
+
content,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (provider === "gitlab") {
|
|
198
|
+
const outputPath = join(projectPath, ".gitlab-ci.bantay.yml");
|
|
199
|
+
|
|
200
|
+
// Check if file already exists
|
|
201
|
+
if (await fileExists(outputPath)) {
|
|
202
|
+
if (!force) {
|
|
203
|
+
return {
|
|
204
|
+
provider: "gitlab",
|
|
205
|
+
outputPath,
|
|
206
|
+
content: "",
|
|
207
|
+
alreadyExists: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Generate and write config
|
|
213
|
+
const content = generateGitLabConfig();
|
|
214
|
+
await writeFile(outputPath, content, "utf-8");
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
provider: "gitlab",
|
|
218
|
+
outputPath,
|
|
219
|
+
content,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Generic - just return instructions, don't write file
|
|
224
|
+
return {
|
|
225
|
+
provider: "generic",
|
|
226
|
+
content: generateGenericInstructions(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { readFile, readdir, access } from "fs/promises";
|
|
2
|
+
import { join, relative } from "path";
|
|
3
|
+
import * as yaml from "js-yaml";
|
|
4
|
+
|
|
5
|
+
export interface StatusOptions {
|
|
6
|
+
json?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ScenarioStatus {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
parentCuj: string;
|
|
13
|
+
status: "implemented" | "missing";
|
|
14
|
+
testFile?: string;
|
|
15
|
+
line?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StatusSummary {
|
|
19
|
+
total: number;
|
|
20
|
+
implemented: number;
|
|
21
|
+
missing: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StatusResult {
|
|
25
|
+
scenarios: ScenarioStatus[];
|
|
26
|
+
summary: StatusSummary;
|
|
27
|
+
cujs?: Record<string, string>;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AideEntity {
|
|
32
|
+
parent?: string;
|
|
33
|
+
props?: {
|
|
34
|
+
name?: string;
|
|
35
|
+
feature?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface AideFile {
|
|
41
|
+
entities: Record<string, AideEntity>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
await access(path);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function findTestFiles(testsDir: string): Promise<string[]> {
|
|
54
|
+
const files: string[] = [];
|
|
55
|
+
|
|
56
|
+
async function walk(dir: string): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const fullPath = join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
await walk(fullPath);
|
|
63
|
+
} else if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.js")) {
|
|
64
|
+
files.push(fullPath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Directory doesn't exist or isn't readable
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await walk(testsDir);
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function searchTestFileForScenario(
|
|
77
|
+
testFilePath: string,
|
|
78
|
+
scenarioId: string
|
|
79
|
+
): Promise<{ found: boolean; line?: number }> {
|
|
80
|
+
try {
|
|
81
|
+
const content = await readFile(testFilePath, "utf-8");
|
|
82
|
+
const lines = content.split("\n");
|
|
83
|
+
|
|
84
|
+
// Priority 1: Look for explicit scenario marker (highest priority)
|
|
85
|
+
// Formats: @scenario sc_xxx, // sc_xxx:, // sc_xxx, * @scenario sc_xxx
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
if (
|
|
89
|
+
line.includes(`@scenario ${scenarioId}`) ||
|
|
90
|
+
line.includes(`@scenario: ${scenarioId}`) ||
|
|
91
|
+
line.includes(`// ${scenarioId}:`) ||
|
|
92
|
+
line.includes(`// ${scenarioId} `) ||
|
|
93
|
+
line.match(new RegExp(`\\*\\s*@scenario\\s+${scenarioId}\\b`))
|
|
94
|
+
) {
|
|
95
|
+
return { found: true, line: i + 1 };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Priority 2: Look for describe/test block containing exact scenario ID
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
const line = lines[i];
|
|
102
|
+
// Match scenario ID in describe block or test name
|
|
103
|
+
const isDescribeOrTest = /^\s*(describe|test|it)\s*\(/.test(line);
|
|
104
|
+
if (isDescribeOrTest && (line.includes(`"${scenarioId}"`) || line.includes(`'${scenarioId}'`))) {
|
|
105
|
+
return { found: true, line: i + 1 };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { found: false };
|
|
110
|
+
} catch {
|
|
111
|
+
return { found: false };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scenarioIdToTestFileName(scenarioId: string): string[] {
|
|
116
|
+
// Convert sc_init_prerequisites to possible test file names
|
|
117
|
+
// sc_init_prerequisites -> [prerequisites, init-prerequisites, init]
|
|
118
|
+
const withoutPrefix = scenarioId.replace(/^sc_/, "");
|
|
119
|
+
const parts = withoutPrefix.split("_");
|
|
120
|
+
|
|
121
|
+
const candidates: string[] = [];
|
|
122
|
+
|
|
123
|
+
// Full name with dashes: init-prerequisites
|
|
124
|
+
candidates.push(parts.join("-"));
|
|
125
|
+
|
|
126
|
+
// Last part only: prerequisites
|
|
127
|
+
if (parts.length > 1) {
|
|
128
|
+
candidates.push(parts[parts.length - 1]);
|
|
129
|
+
candidates.push(parts.slice(1).join("-"));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// First part only: init
|
|
133
|
+
candidates.push(parts[0]);
|
|
134
|
+
|
|
135
|
+
return candidates;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function testFileMatchesScenario(testFileName: string, scenarioId: string): boolean {
|
|
139
|
+
const baseName = testFileName.replace(/\.test\.(ts|js)$/, "");
|
|
140
|
+
const candidates = scenarioIdToTestFileName(scenarioId);
|
|
141
|
+
|
|
142
|
+
return candidates.some(candidate => baseName === candidate);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function runStatus(
|
|
146
|
+
projectPath: string,
|
|
147
|
+
options?: StatusOptions
|
|
148
|
+
): Promise<StatusResult> {
|
|
149
|
+
const aidePath = join(projectPath, "bantay.aide");
|
|
150
|
+
|
|
151
|
+
// Check if aide file exists
|
|
152
|
+
if (!(await fileExists(aidePath))) {
|
|
153
|
+
return {
|
|
154
|
+
scenarios: [],
|
|
155
|
+
summary: { total: 0, implemented: 0, missing: 0 },
|
|
156
|
+
error: "bantay.aide not found",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Parse aide file
|
|
161
|
+
let aideContent: AideFile;
|
|
162
|
+
try {
|
|
163
|
+
const rawContent = await readFile(aidePath, "utf-8");
|
|
164
|
+
aideContent = yaml.load(rawContent) as AideFile;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return {
|
|
167
|
+
scenarios: [],
|
|
168
|
+
summary: { total: 0, implemented: 0, missing: 0 },
|
|
169
|
+
error: `Failed to parse bantay.aide: ${e instanceof Error ? e.message : String(e)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!aideContent?.entities) {
|
|
174
|
+
return {
|
|
175
|
+
scenarios: [],
|
|
176
|
+
summary: { total: 0, implemented: 0, missing: 0 },
|
|
177
|
+
error: "bantay.aide has no entities",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Extract CUJ information
|
|
182
|
+
const cujs: Record<string, string> = {};
|
|
183
|
+
for (const [id, entity] of Object.entries(aideContent.entities)) {
|
|
184
|
+
if (id.startsWith("cuj_") && entity.props?.feature) {
|
|
185
|
+
cujs[id] = entity.props.feature;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Extract all sc_* entities
|
|
190
|
+
const scenarios: ScenarioStatus[] = [];
|
|
191
|
+
for (const [id, entity] of Object.entries(aideContent.entities)) {
|
|
192
|
+
if (id.startsWith("sc_")) {
|
|
193
|
+
scenarios.push({
|
|
194
|
+
id,
|
|
195
|
+
name: entity.props?.name || id,
|
|
196
|
+
parentCuj: entity.parent || "unknown",
|
|
197
|
+
status: "missing",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Find test files
|
|
203
|
+
const testsDir = join(projectPath, "tests");
|
|
204
|
+
const testFiles = await findTestFiles(testsDir);
|
|
205
|
+
|
|
206
|
+
// Match scenarios to test files
|
|
207
|
+
for (const scenario of scenarios) {
|
|
208
|
+
// Priority 1: Explicit scenario ID in test file
|
|
209
|
+
for (const testFile of testFiles) {
|
|
210
|
+
// Skip status-command.test.ts as it's a meta test that contains scenario IDs as test data
|
|
211
|
+
if (testFile.includes("status-command.test.ts")) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const result = await searchTestFileForScenario(testFile, scenario.id);
|
|
216
|
+
if (result.found) {
|
|
217
|
+
scenario.status = "implemented";
|
|
218
|
+
scenario.testFile = relative(projectPath, testFile);
|
|
219
|
+
scenario.line = result.line;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Priority 2: Test file name matches scenario (if not already matched)
|
|
225
|
+
if (scenario.status === "missing") {
|
|
226
|
+
for (const testFile of testFiles) {
|
|
227
|
+
if (testFile.includes("status-command.test.ts")) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const fileName = testFile.split("/").pop() || "";
|
|
232
|
+
if (testFileMatchesScenario(fileName, scenario.id)) {
|
|
233
|
+
// Found a file that matches by name, but verify it has relevant tests
|
|
234
|
+
try {
|
|
235
|
+
const content = await readFile(testFile, "utf-8");
|
|
236
|
+
// Ensure it's not an empty or stub test file
|
|
237
|
+
if (content.includes("describe(") || content.includes("test(")) {
|
|
238
|
+
scenario.status = "implemented";
|
|
239
|
+
scenario.testFile = relative(projectPath, testFile);
|
|
240
|
+
scenario.line = 1; // Start of file when matched by name
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Skip if can't read file
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Calculate summary
|
|
252
|
+
const implemented = scenarios.filter((s) => s.status === "implemented").length;
|
|
253
|
+
const missing = scenarios.filter((s) => s.status === "missing").length;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
scenarios,
|
|
257
|
+
summary: {
|
|
258
|
+
total: scenarios.length,
|
|
259
|
+
implemented,
|
|
260
|
+
missing,
|
|
261
|
+
},
|
|
262
|
+
cujs,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function formatStatus(result: StatusResult): string {
|
|
267
|
+
if (result.error) {
|
|
268
|
+
return `Error: ${result.error}\n`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
lines.push("# Scenario Implementation Status\n");
|
|
273
|
+
|
|
274
|
+
// Group scenarios by CUJ
|
|
275
|
+
const byParent: Record<string, ScenarioStatus[]> = {};
|
|
276
|
+
for (const scenario of result.scenarios) {
|
|
277
|
+
if (!byParent[scenario.parentCuj]) {
|
|
278
|
+
byParent[scenario.parentCuj] = [];
|
|
279
|
+
}
|
|
280
|
+
byParent[scenario.parentCuj].push(scenario);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Output by CUJ
|
|
284
|
+
for (const [cuj, scenarios] of Object.entries(byParent)) {
|
|
285
|
+
const cujName = result.cujs?.[cuj] || cuj;
|
|
286
|
+
lines.push(`\n## ${cujName}\n`);
|
|
287
|
+
|
|
288
|
+
for (const scenario of scenarios) {
|
|
289
|
+
const icon = scenario.status === "implemented" ? "✓" : "○";
|
|
290
|
+
const location = scenario.testFile
|
|
291
|
+
? `${scenario.testFile}${scenario.line ? `:${scenario.line}` : ""}`
|
|
292
|
+
: "";
|
|
293
|
+
|
|
294
|
+
lines.push(`${icon} ${scenario.id}: ${scenario.name}`);
|
|
295
|
+
if (location) {
|
|
296
|
+
lines.push(` → ${location}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Summary
|
|
302
|
+
lines.push(`\n---`);
|
|
303
|
+
lines.push(
|
|
304
|
+
`Implemented: ${result.summary.implemented}/${result.summary.total} (${Math.round((result.summary.implemented / result.summary.total) * 100)}%)`
|
|
305
|
+
);
|
|
306
|
+
lines.push(`Missing: ${result.summary.missing}`);
|
|
307
|
+
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|