@bantay/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ import { readFile, access } from "fs/promises";
2
+ import { spawn } from "bun";
3
+ import { join } from "path";
4
+ import { parseInvariants, type Invariant } from "../generators/invariants";
5
+ import { runChecker, hasChecker } from "../checkers/registry";
6
+ import { loadConfig } from "../config";
7
+ import { getGitDiff, shouldCheckInvariant } from "../diff";
8
+ import type { CheckResult, CheckerContext } from "../checkers/types";
9
+ import { read as readAide } from "../aide";
10
+
11
+ export interface CheckOptions {
12
+ id?: string;
13
+ diff?: string;
14
+ }
15
+
16
+ export interface CheckSummary {
17
+ passed: number;
18
+ failed: number;
19
+ skipped: number;
20
+ tested: number;
21
+ enforced: number;
22
+ total: number;
23
+ results: CheckResult[];
24
+ }
25
+
26
+ async function fileExists(path: string): Promise<boolean> {
27
+ try {
28
+ await access(path);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ interface ProjectCheckerResult {
36
+ pass: boolean;
37
+ violations: Array<{ file: string; line: number; message: string }>;
38
+ }
39
+
40
+ interface InvariantEnforcementInfo {
41
+ checker?: string;
42
+ test?: string;
43
+ enforced?: string;
44
+ }
45
+
46
+ /**
47
+ * Load checker and test paths from bantay.aide for each invariant
48
+ */
49
+ async function getEnforcementInfo(
50
+ projectPath: string
51
+ ): Promise<Map<string, InvariantEnforcementInfo>> {
52
+ const info = new Map<string, InvariantEnforcementInfo>();
53
+ const aidePath = join(projectPath, "bantay.aide");
54
+
55
+ try {
56
+ const tree = await readAide(aidePath);
57
+ for (const [id, entity] of Object.entries(tree.entities)) {
58
+ if (id.startsWith("inv_")) {
59
+ const enforcement: InvariantEnforcementInfo = {};
60
+ if (entity.props?.checker) {
61
+ enforcement.checker = entity.props.checker as string;
62
+ }
63
+ if (entity.props?.test) {
64
+ enforcement.test = entity.props.test as string;
65
+ }
66
+ if (entity.props?.enforced) {
67
+ enforcement.enforced = entity.props.enforced as string;
68
+ }
69
+ if (enforcement.checker || enforcement.test || enforcement.enforced) {
70
+ info.set(id, enforcement);
71
+ }
72
+ }
73
+ }
74
+ } catch {
75
+ // No aide file or can't read it
76
+ }
77
+
78
+ return info;
79
+ }
80
+
81
+ /**
82
+ * Run a project checker from .bantay/checkers/
83
+ */
84
+ async function runProjectChecker(
85
+ checkerPath: string,
86
+ projectPath: string
87
+ ): Promise<ProjectCheckerResult> {
88
+ // checkerPath is like "./no-eval" or "./bin-field"
89
+ const checkerFile = checkerPath.startsWith("./")
90
+ ? checkerPath.slice(2) + ".ts"
91
+ : checkerPath + ".ts";
92
+ const fullPath = join(projectPath, ".bantay", "checkers", checkerFile);
93
+
94
+ try {
95
+ // Import and run the checker
96
+ const checker = await import(fullPath);
97
+
98
+ if (typeof checker.check !== "function") {
99
+ return {
100
+ pass: false,
101
+ violations: [
102
+ {
103
+ file: checkerFile,
104
+ line: 0,
105
+ message: "Checker does not export a check() function",
106
+ },
107
+ ],
108
+ };
109
+ }
110
+
111
+ const result = await checker.check({ projectPath });
112
+
113
+ return {
114
+ pass: result.pass === true,
115
+ violations: Array.isArray(result.violations) ? result.violations : [],
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ pass: false,
120
+ violations: [
121
+ {
122
+ file: checkerFile,
123
+ line: 0,
124
+ message: `Failed to run checker: ${err instanceof Error ? err.message : err}`,
125
+ },
126
+ ],
127
+ };
128
+ }
129
+ }
130
+
131
+ export async function runCheck(
132
+ projectPath: string,
133
+ options: CheckOptions = {}
134
+ ): Promise<CheckSummary> {
135
+ const invariantsPath = join(projectPath, "invariants.md");
136
+
137
+ // Check if invariants.md exists
138
+ if (!(await fileExists(invariantsPath))) {
139
+ throw new Error(
140
+ 'No invariants.md found. Run "bantay init" to create one.'
141
+ );
142
+ }
143
+
144
+ // Load invariants
145
+ const content = await readFile(invariantsPath, "utf-8");
146
+ let invariants = parseInvariants(content);
147
+
148
+ // Filter by ID if specified
149
+ if (options.id) {
150
+ invariants = invariants.filter((inv) => inv.id === options.id);
151
+ if (invariants.length === 0) {
152
+ throw new Error(`Invariant with ID "${options.id}" not found.`);
153
+ }
154
+ }
155
+
156
+ // Load config
157
+ const config = await loadConfig(projectPath);
158
+
159
+ const context: CheckerContext = {
160
+ projectPath,
161
+ config,
162
+ };
163
+
164
+ // Get diff info if in diff mode
165
+ let affectedCategories: Set<string> | null = null;
166
+ if (options.diff) {
167
+ const diffResult = await getGitDiff(projectPath, options.diff);
168
+ affectedCategories = diffResult.categories;
169
+ }
170
+
171
+ // Get enforcement info (checker and test paths) from bantay.aide
172
+ const enforcementInfo = await getEnforcementInfo(projectPath);
173
+
174
+ // Run checkers for each invariant
175
+ const results: CheckResult[] = [];
176
+
177
+ for (const invariant of invariants) {
178
+ // In diff mode, skip invariants for unaffected categories
179
+ if (affectedCategories && !shouldCheckInvariant(invariant.category, affectedCategories)) {
180
+ // Skip this invariant entirely - don't even report it
181
+ continue;
182
+ }
183
+
184
+ // Get enforcement info for this invariant
185
+ const enforcement = enforcementInfo.get(invariant.id);
186
+
187
+ if (enforcement?.checker) {
188
+ // Run project checker
189
+ const projectResult = await runProjectChecker(enforcement.checker, projectPath);
190
+ results.push({
191
+ invariant,
192
+ status: projectResult.pass ? "pass" : "fail",
193
+ violations: projectResult.violations.map(v => ({
194
+ filePath: v.file,
195
+ line: v.line,
196
+ message: v.message,
197
+ })),
198
+ message: projectResult.pass ? undefined : `Project checker: ${enforcement.checker}`,
199
+ });
200
+ } else if (enforcement?.test) {
201
+ // Invariant is enforced by a test, not a checker
202
+ results.push({
203
+ invariant,
204
+ status: "tested",
205
+ violations: [],
206
+ message: `Enforced by test: ${enforcement.test}`,
207
+ });
208
+ } else if (enforcement?.enforced) {
209
+ // Invariant is enforced by implementation code directly
210
+ results.push({
211
+ invariant,
212
+ status: "enforced",
213
+ violations: [],
214
+ message: `Enforced by: ${enforcement.enforced}`,
215
+ });
216
+ } else {
217
+ // Try built-in checker
218
+ const result = await runChecker(invariant, context);
219
+ results.push(result);
220
+ }
221
+ }
222
+
223
+ // Calculate summary
224
+ const summary: CheckSummary = {
225
+ passed: results.filter((r) => r.status === "pass").length,
226
+ failed: results.filter((r) => r.status === "fail").length,
227
+ skipped: results.filter((r) => r.status === "skipped").length,
228
+ tested: results.filter((r) => r.status === "tested").length,
229
+ enforced: results.filter((r) => r.status === "enforced").length,
230
+ total: results.length,
231
+ results,
232
+ };
233
+
234
+ return summary;
235
+ }
236
+
237
+ export interface JsonCheckOutput {
238
+ timestamp: string;
239
+ commit: string | null;
240
+ results: Array<{
241
+ id: string;
242
+ status: "pass" | "fail" | "skipped" | "tested" | "enforced";
243
+ message?: string;
244
+ violations?: Array<{
245
+ file: string;
246
+ line?: number;
247
+ message: string;
248
+ }>;
249
+ }>;
250
+ summary: {
251
+ passed: number;
252
+ failed: number;
253
+ skipped: number;
254
+ tested: number;
255
+ enforced: number;
256
+ total: number;
257
+ };
258
+ }
259
+
260
+ async function getGitCommit(projectPath: string): Promise<string | null> {
261
+ try {
262
+ const proc = spawn({
263
+ cmd: ["git", "rev-parse", "HEAD"],
264
+ cwd: projectPath,
265
+ stdout: "pipe",
266
+ stderr: "pipe",
267
+ });
268
+
269
+ const [exitCode, stdout] = await Promise.all([
270
+ proc.exited,
271
+ new Response(proc.stdout).text(),
272
+ ]);
273
+
274
+ if (exitCode === 0) {
275
+ return stdout.trim();
276
+ }
277
+ return null;
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ export async function formatCheckResultsJson(
284
+ summary: CheckSummary,
285
+ projectPath: string
286
+ ): Promise<JsonCheckOutput> {
287
+ const commit = await getGitCommit(projectPath);
288
+
289
+ return {
290
+ timestamp: new Date().toISOString(),
291
+ commit,
292
+ results: summary.results.map((result) => ({
293
+ id: result.invariant.id,
294
+ status: result.status,
295
+ message: result.message,
296
+ violations:
297
+ result.violations.length > 0
298
+ ? result.violations.map((v) => ({
299
+ file: v.filePath,
300
+ line: v.line,
301
+ message: v.message,
302
+ }))
303
+ : undefined,
304
+ })),
305
+ summary: {
306
+ passed: summary.passed,
307
+ failed: summary.failed,
308
+ skipped: summary.skipped,
309
+ tested: summary.tested,
310
+ enforced: summary.enforced,
311
+ total: summary.total,
312
+ },
313
+ };
314
+ }
315
+
316
+ export function formatCheckResults(summary: CheckSummary): string {
317
+ const lines: string[] = [];
318
+
319
+ lines.push("Invariant Check Results");
320
+ lines.push("=======================");
321
+ lines.push("");
322
+
323
+ for (const result of summary.results) {
324
+ const statusIcon = result.status === "pass" ? "✓"
325
+ : result.status === "fail" ? "✗"
326
+ : result.status === "tested" ? "~"
327
+ : result.status === "enforced" ? "◆"
328
+ : "○";
329
+ const statusText = result.status.toUpperCase();
330
+
331
+ lines.push(`${statusIcon} [${result.invariant.id}] ${statusText}`);
332
+ lines.push(` ${result.invariant.statement}`);
333
+
334
+ if (result.message) {
335
+ lines.push(` Note: ${result.message}`);
336
+ }
337
+
338
+ for (const violation of result.violations) {
339
+ const location = violation.line
340
+ ? `${violation.filePath}:${violation.line}`
341
+ : violation.filePath;
342
+ lines.push(` - ${location}: ${violation.message}`);
343
+ }
344
+
345
+ lines.push("");
346
+ }
347
+
348
+ lines.push("Summary");
349
+ lines.push("-------");
350
+ const parts = [`${summary.passed} passed`, `${summary.failed} failed`];
351
+ if (summary.tested > 0) {
352
+ parts.push(`${summary.tested} tested`);
353
+ }
354
+ if (summary.enforced > 0) {
355
+ parts.push(`${summary.enforced} enforced`);
356
+ }
357
+ if (summary.skipped > 0) {
358
+ parts.push(`${summary.skipped} skipped`);
359
+ }
360
+ lines.push(parts.join(", "));
361
+
362
+ return lines.join("\n");
363
+ }
@@ -0,0 +1,75 @@
1
+ import { writeFile, access } from "fs/promises";
2
+ import { join } from "path";
3
+ import { detectStack, type StackDetectionResult } from "../detectors";
4
+ import { generateInvariants } from "../generators/invariants";
5
+ import { generateConfig, configToYaml } from "../generators/config";
6
+
7
+ export interface InitOptions {
8
+ regenerateConfig?: boolean;
9
+ }
10
+
11
+ export interface InitResult {
12
+ success: boolean;
13
+ filesCreated: string[];
14
+ warnings: string[];
15
+ detection: StackDetectionResult;
16
+ }
17
+
18
+ async function fileExists(path: string): Promise<boolean> {
19
+ try {
20
+ await access(path);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export async function runInit(
28
+ projectPath: string,
29
+ options?: InitOptions
30
+ ): Promise<InitResult> {
31
+ const filesCreated: string[] = [];
32
+ const warnings: string[] = [];
33
+
34
+ // Detect stack
35
+ const detection = await detectStack(projectPath);
36
+
37
+ // Add warnings for missing detections
38
+ if (!detection.framework) {
39
+ warnings.push("No framework detected");
40
+ }
41
+
42
+ const invariantsPath = join(projectPath, "invariants.md");
43
+ const configPath = join(projectPath, "bantay.config.yml");
44
+
45
+ // Check if invariants.md already exists
46
+ const invariantsExists = await fileExists(invariantsPath);
47
+
48
+ if (invariantsExists) {
49
+ warnings.push("invariants.md already exists");
50
+ }
51
+
52
+ // Generate invariants.md if it doesn't exist
53
+ if (!invariantsExists) {
54
+ const invariantsContent = await generateInvariants(detection);
55
+ await writeFile(invariantsPath, invariantsContent);
56
+ filesCreated.push("invariants.md");
57
+ }
58
+
59
+ // Generate config (always, or when regenerateConfig is true)
60
+ const shouldGenerateConfig = !invariantsExists || options?.regenerateConfig;
61
+
62
+ if (shouldGenerateConfig) {
63
+ const config = await generateConfig(detection, projectPath);
64
+ const configContent = configToYaml(config);
65
+ await writeFile(configPath, configContent);
66
+ filesCreated.push("bantay.config.yml");
67
+ }
68
+
69
+ return {
70
+ success: true,
71
+ filesCreated,
72
+ warnings,
73
+ detection,
74
+ };
75
+ }
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { readFile } from "fs/promises";
2
+ import type { BantayConfig } from "./checkers/types";
3
+
4
+ export async function loadConfig(projectPath: string): Promise<BantayConfig> {
5
+ const configPath = `${projectPath}/bantay.config.yml`;
6
+
7
+ try {
8
+ const content = await readFile(configPath, "utf-8");
9
+ return parseYamlConfig(content);
10
+ } catch {
11
+ // Default config if file doesn't exist
12
+ return {
13
+ sourceDirectories: ["src"],
14
+ };
15
+ }
16
+ }
17
+
18
+ function parseYamlConfig(content: string): BantayConfig {
19
+ const config: BantayConfig = {
20
+ sourceDirectories: [],
21
+ };
22
+
23
+ const lines = content.split("\n");
24
+ let currentKey: string | null = null;
25
+
26
+ for (const line of lines) {
27
+ const trimmed = line.trim();
28
+
29
+ // Skip empty lines and comments
30
+ if (!trimmed || trimmed.startsWith("#")) {
31
+ continue;
32
+ }
33
+
34
+ // Check for list item
35
+ if (trimmed.startsWith("- ")) {
36
+ const value = trimmed.slice(2).trim();
37
+ if (currentKey === "sourceDirectories") {
38
+ config.sourceDirectories.push(value);
39
+ } else if (currentKey === "routeDirectories") {
40
+ if (!config.routeDirectories) {
41
+ config.routeDirectories = [];
42
+ }
43
+ config.routeDirectories.push(value);
44
+ }
45
+ continue;
46
+ }
47
+
48
+ // Check for key
49
+ const colonIndex = trimmed.indexOf(":");
50
+ if (colonIndex > 0) {
51
+ const key = trimmed.slice(0, colonIndex).trim();
52
+ const value = trimmed.slice(colonIndex + 1).trim();
53
+
54
+ if (key === "schemaPath" && value) {
55
+ config.schemaPath = value;
56
+ } else {
57
+ currentKey = key;
58
+ }
59
+ }
60
+ }
61
+
62
+ return config;
63
+ }
@@ -0,0 +1,61 @@
1
+ export type {
2
+ FrameworkDetection,
3
+ OrmDetection,
4
+ AuthDetection,
5
+ StackDetectionResult,
6
+ } from "./types";
7
+
8
+ import type { StackDetectionResult, FrameworkDetection, OrmDetection, AuthDetection } from "./types";
9
+ import { detect as detectNextjs } from "./nextjs";
10
+ import { detect as detectPrisma } from "./prisma";
11
+
12
+ // Registry of framework detectors
13
+ const frameworkDetectors: Array<() => typeof detectNextjs> = [
14
+ () => detectNextjs,
15
+ ];
16
+
17
+ // Registry of ORM detectors
18
+ const ormDetectors: Array<() => typeof detectPrisma> = [
19
+ () => detectPrisma,
20
+ ];
21
+
22
+ // Registry of auth detectors (none yet)
23
+ const authDetectors: Array<() => (projectPath: string) => Promise<AuthDetection | null>> = [];
24
+
25
+ export async function detectStack(projectPath: string): Promise<StackDetectionResult> {
26
+ let framework: FrameworkDetection | null = null;
27
+ let orm: OrmDetection | null = null;
28
+ let auth: AuthDetection | null = null;
29
+
30
+ // Run framework detectors
31
+ for (const getDetector of frameworkDetectors) {
32
+ const detector = getDetector();
33
+ const result = await detector(projectPath);
34
+ if (result && (!framework || result.confidence === "high")) {
35
+ framework = result;
36
+ if (result.confidence === "high") break;
37
+ }
38
+ }
39
+
40
+ // Run ORM detectors
41
+ for (const getDetector of ormDetectors) {
42
+ const detector = getDetector();
43
+ const result = await detector(projectPath);
44
+ if (result && (!orm || result.confidence === "high")) {
45
+ orm = result;
46
+ if (result.confidence === "high") break;
47
+ }
48
+ }
49
+
50
+ // Run auth detectors
51
+ for (const getDetector of authDetectors) {
52
+ const detector = getDetector();
53
+ const result = await detector(projectPath);
54
+ if (result && (!auth || result.confidence === "high")) {
55
+ auth = result;
56
+ if (result.confidence === "high") break;
57
+ }
58
+ }
59
+
60
+ return { framework, orm, auth };
61
+ }
@@ -0,0 +1,97 @@
1
+ import { readFile, access, readdir } from "fs/promises";
2
+ import { join } from "path";
3
+ import type { FrameworkDetection } from "./types";
4
+
5
+ export interface NextjsDetector {
6
+ name: "nextjs";
7
+ detect(projectPath: string): Promise<FrameworkDetection | null>;
8
+ }
9
+
10
+ async function fileExists(path: string): Promise<boolean> {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ async function dirExists(path: string): Promise<boolean> {
20
+ try {
21
+ await access(path);
22
+ const entries = await readdir(path);
23
+ return entries.length >= 0; // It's a directory if readdir works
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ async function readPackageJson(projectPath: string): Promise<Record<string, unknown> | null> {
30
+ try {
31
+ const content = await readFile(join(projectPath, "package.json"), "utf-8");
32
+ return JSON.parse(content);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export async function detect(projectPath: string): Promise<FrameworkDetection | null> {
39
+ const pkg = await readPackageJson(projectPath);
40
+
41
+ let version: string | undefined;
42
+ let confidence: "high" | "medium" | "low" = "low";
43
+ let detected = false;
44
+
45
+ // Check package.json for next dependency
46
+ if (pkg) {
47
+ const deps = (pkg.dependencies ?? {}) as Record<string, string>;
48
+ const devDeps = (pkg.devDependencies ?? {}) as Record<string, string>;
49
+
50
+ if (deps.next) {
51
+ version = deps.next;
52
+ confidence = "high";
53
+ detected = true;
54
+ } else if (devDeps.next) {
55
+ version = devDeps.next;
56
+ confidence = "high";
57
+ detected = true;
58
+ }
59
+ }
60
+
61
+ // Check for next.config.js or next.config.mjs
62
+ const configFiles = ["next.config.js", "next.config.mjs", "next.config.ts"];
63
+ for (const configFile of configFiles) {
64
+ if (await fileExists(join(projectPath, configFile))) {
65
+ detected = true;
66
+ if (confidence === "low") {
67
+ confidence = "high";
68
+ }
69
+ break;
70
+ }
71
+ }
72
+
73
+ if (!detected) {
74
+ return null;
75
+ }
76
+
77
+ // Detect router type
78
+ let router: "app" | "pages" | undefined;
79
+
80
+ const hasAppDir = await dirExists(join(projectPath, "app"));
81
+ const hasPagesDir = await dirExists(join(projectPath, "pages"));
82
+ const hasSrcAppDir = await dirExists(join(projectPath, "src", "app"));
83
+ const hasSrcPagesDir = await dirExists(join(projectPath, "src", "pages"));
84
+
85
+ if (hasAppDir || hasSrcAppDir) {
86
+ router = "app";
87
+ } else if (hasPagesDir || hasSrcPagesDir) {
88
+ router = "pages";
89
+ }
90
+
91
+ return {
92
+ name: "nextjs",
93
+ version,
94
+ confidence,
95
+ router,
96
+ };
97
+ }