@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,133 @@
1
+ import type { Checker, CheckResult, CheckerContext, CheckViolation } from "./types";
2
+ import type { Invariant } from "../generators/invariants";
3
+ import { Glob } from "bun";
4
+ import { readFile } from "fs/promises";
5
+ import { join, relative } from "path";
6
+
7
+ // PII field names to detect in log statements
8
+ const PII_PATTERNS = [
9
+ /\bemail\b/i,
10
+ /\bpassword\b/i,
11
+ /\bssn\b/i,
12
+ /\bsocialSecurityNumber\b/i,
13
+ /\bsocial_security_number\b/i,
14
+ /\bcreditCard\b/i,
15
+ /\bcredit_card\b/i,
16
+ /\bcardNumber\b/i,
17
+ /\bcard_number\b/i,
18
+ /\bcvv\b/i,
19
+ /\bpin\b/i,
20
+ /\bdateOfBirth\b/i,
21
+ /\bdate_of_birth\b/i,
22
+ /\bdob\b/i,
23
+ /\bphoneNumber\b/i,
24
+ /\bphone_number\b/i,
25
+ /\baddress\b/i,
26
+ /\bsecret\b/i,
27
+ /\btoken\b/i,
28
+ /\bapiKey\b/i,
29
+ /\bapi_key\b/i,
30
+ /\bprivateKey\b/i,
31
+ /\bprivate_key\b/i,
32
+ ];
33
+
34
+ // Log statement patterns
35
+ const LOG_PATTERNS = [
36
+ /console\.(log|warn|error|info|debug)\s*\([^)]*$/,
37
+ /console\.(log|warn|error|info|debug)\s*\(.*\)/,
38
+ /logger\.(log|warn|error|info|debug)\s*\(.*\)/,
39
+ /log\.(log|warn|error|info|debug)\s*\(.*\)/,
40
+ ];
41
+
42
+ interface LogViolation {
43
+ line: number;
44
+ piiField: string;
45
+ logContent: string;
46
+ }
47
+
48
+ function findPiiInLogStatements(content: string): LogViolation[] {
49
+ const violations: LogViolation[] = [];
50
+ const lines = content.split("\n");
51
+
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+
55
+ // Check if this line contains a log statement
56
+ const isLogStatement = LOG_PATTERNS.some((pattern) => pattern.test(line));
57
+
58
+ if (isLogStatement) {
59
+ // Check for PII patterns in the log statement
60
+ for (const piiPattern of PII_PATTERNS) {
61
+ if (piiPattern.test(line)) {
62
+ const match = line.match(piiPattern);
63
+ violations.push({
64
+ line: i + 1,
65
+ piiField: match ? match[0] : "unknown",
66
+ logContent: line.trim(),
67
+ });
68
+ break; // Only report one PII field per line
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ return violations;
75
+ }
76
+
77
+ async function scanSourceFiles(projectPath: string, sourceDirectories: string[]): Promise<string[]> {
78
+ const files: string[] = [];
79
+ const pattern = "**/*.{ts,tsx,js,jsx}";
80
+
81
+ for (const srcDir of sourceDirectories) {
82
+ const glob = new Glob(pattern);
83
+ const dirPath = join(projectPath, srcDir);
84
+
85
+ try {
86
+ for await (const file of glob.scan({ cwd: dirPath, absolute: true })) {
87
+ // Skip node_modules
88
+ if (!file.includes("node_modules")) {
89
+ files.push(file);
90
+ }
91
+ }
92
+ } catch {
93
+ // Directory doesn't exist, skip
94
+ }
95
+ }
96
+
97
+ return files;
98
+ }
99
+
100
+ export const loggingChecker: Checker = {
101
+ category: "logging",
102
+
103
+ async check(invariant: Invariant, context: CheckerContext): Promise<CheckResult> {
104
+ const violations: CheckViolation[] = [];
105
+ const sourceFiles = await scanSourceFiles(
106
+ context.projectPath,
107
+ context.config.sourceDirectories
108
+ );
109
+
110
+ for (const filePath of sourceFiles) {
111
+ try {
112
+ const content = await readFile(filePath, "utf-8");
113
+ const piiViolations = findPiiInLogStatements(content);
114
+
115
+ for (const violation of piiViolations) {
116
+ violations.push({
117
+ filePath: relative(context.projectPath, filePath),
118
+ line: violation.line,
119
+ message: `Log statement contains PII field "${violation.piiField}"`,
120
+ });
121
+ }
122
+ } catch {
123
+ // File read error, skip
124
+ }
125
+ }
126
+
127
+ return {
128
+ invariant,
129
+ status: violations.length > 0 ? "fail" : "pass",
130
+ violations,
131
+ };
132
+ },
133
+ };
@@ -0,0 +1,46 @@
1
+ import type { Checker, CheckResult, CheckerContext } from "./types";
2
+ import type { Invariant } from "../generators/invariants";
3
+ import { authChecker } from "./auth";
4
+ import { schemaChecker } from "./schema";
5
+ import { loggingChecker } from "./logging";
6
+
7
+ const checkers: Map<string, Checker> = new Map();
8
+
9
+ export function registerChecker(checker: Checker): void {
10
+ checkers.set(checker.category, checker);
11
+ }
12
+
13
+ export function getChecker(category: string): Checker | undefined {
14
+ return checkers.get(category);
15
+ }
16
+
17
+ export function hasChecker(category: string): boolean {
18
+ return checkers.has(category);
19
+ }
20
+
21
+ export function getAllCategories(): string[] {
22
+ return Array.from(checkers.keys());
23
+ }
24
+
25
+ export async function runChecker(
26
+ invariant: Invariant,
27
+ context: CheckerContext
28
+ ): Promise<CheckResult> {
29
+ const checker = getChecker(invariant.category);
30
+
31
+ if (!checker) {
32
+ return {
33
+ invariant,
34
+ status: "skipped",
35
+ violations: [],
36
+ message: `No checker registered for category "${invariant.category}"`,
37
+ };
38
+ }
39
+
40
+ return checker.check(invariant, context);
41
+ }
42
+
43
+ // Register built-in checkers
44
+ registerChecker(authChecker);
45
+ registerChecker(schemaChecker);
46
+ registerChecker(loggingChecker);
@@ -0,0 +1,157 @@
1
+ import type { Checker, CheckResult, CheckerContext, CheckViolation } from "./types";
2
+ import type { Invariant } from "../generators/invariants";
3
+ import { readFile, access } from "fs/promises";
4
+ import { join } from "path";
5
+
6
+ interface PrismaModel {
7
+ name: string;
8
+ fields: string[];
9
+ startLine: number;
10
+ }
11
+
12
+ function parsePrismaSchema(content: string): PrismaModel[] {
13
+ const models: PrismaModel[] = [];
14
+ const lines = content.split("\n");
15
+
16
+ let currentModel: PrismaModel | null = null;
17
+ let braceDepth = 0;
18
+
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const line = lines[i];
21
+ const trimmed = line.trim();
22
+
23
+ // Start of a model
24
+ const modelMatch = trimmed.match(/^model\s+(\w+)\s*\{/);
25
+ if (modelMatch) {
26
+ currentModel = {
27
+ name: modelMatch[1],
28
+ fields: [],
29
+ startLine: i + 1,
30
+ };
31
+ braceDepth = 1;
32
+ continue;
33
+ }
34
+
35
+ if (currentModel) {
36
+ // Track braces
37
+ for (const char of trimmed) {
38
+ if (char === "{") braceDepth++;
39
+ if (char === "}") braceDepth--;
40
+ }
41
+
42
+ // Parse field name (first word of the line that isn't a comment or directive)
43
+ if (!trimmed.startsWith("//") && !trimmed.startsWith("@@")) {
44
+ const fieldMatch = trimmed.match(/^(\w+)\s+/);
45
+ if (fieldMatch) {
46
+ currentModel.fields.push(fieldMatch[1]);
47
+ }
48
+ }
49
+
50
+ // End of model
51
+ if (braceDepth === 0) {
52
+ models.push(currentModel);
53
+ currentModel = null;
54
+ }
55
+ }
56
+ }
57
+
58
+ return models;
59
+ }
60
+
61
+ function checkModelTimestamps(model: PrismaModel): { hasCreatedAt: boolean; hasUpdatedAt: boolean } {
62
+ const fieldNames = model.fields.map((f) => f.toLowerCase());
63
+ return {
64
+ hasCreatedAt: fieldNames.includes("createdat"),
65
+ hasUpdatedAt: fieldNames.includes("updatedat"),
66
+ };
67
+ }
68
+
69
+ async function findSchemaPath(projectPath: string, configSchemaPath?: string): Promise<string | null> {
70
+ // Try config path first
71
+ if (configSchemaPath) {
72
+ const fullPath = join(projectPath, configSchemaPath);
73
+ try {
74
+ await access(fullPath);
75
+ return fullPath;
76
+ } catch {
77
+ // Config path doesn't exist, try defaults
78
+ }
79
+ }
80
+
81
+ // Try common default locations
82
+ const defaultPaths = [
83
+ "prisma/schema.prisma",
84
+ "schema.prisma",
85
+ ];
86
+
87
+ for (const defaultPath of defaultPaths) {
88
+ const fullPath = join(projectPath, defaultPath);
89
+ try {
90
+ await access(fullPath);
91
+ return fullPath;
92
+ } catch {
93
+ // Not found, try next
94
+ }
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ export const schemaChecker: Checker = {
101
+ category: "schema",
102
+
103
+ async check(invariant: Invariant, context: CheckerContext): Promise<CheckResult> {
104
+ const violations: CheckViolation[] = [];
105
+
106
+ // Find schema file
107
+ const schemaPath = await findSchemaPath(context.projectPath, context.config.schemaPath);
108
+
109
+ if (!schemaPath) {
110
+ return {
111
+ invariant,
112
+ status: "pass",
113
+ violations: [],
114
+ message: "No Prisma schema found",
115
+ };
116
+ }
117
+
118
+ try {
119
+ const content = await readFile(schemaPath, "utf-8");
120
+ const models = parsePrismaSchema(content);
121
+
122
+ for (const model of models) {
123
+ const timestamps = checkModelTimestamps(model);
124
+ const missingFields: string[] = [];
125
+
126
+ if (!timestamps.hasCreatedAt) {
127
+ missingFields.push("createdAt");
128
+ }
129
+ if (!timestamps.hasUpdatedAt) {
130
+ missingFields.push("updatedAt");
131
+ }
132
+
133
+ if (missingFields.length > 0) {
134
+ const relativePath = schemaPath.replace(context.projectPath + "/", "");
135
+ violations.push({
136
+ filePath: relativePath,
137
+ line: model.startLine,
138
+ message: `Model "${model.name}" is missing timestamp fields: ${missingFields.join(", ")}`,
139
+ });
140
+ }
141
+ }
142
+ } catch (error) {
143
+ return {
144
+ invariant,
145
+ status: "skipped",
146
+ violations: [],
147
+ message: `Failed to read schema: ${error instanceof Error ? error.message : "Unknown error"}`,
148
+ };
149
+ }
150
+
151
+ return {
152
+ invariant,
153
+ status: violations.length > 0 ? "fail" : "pass",
154
+ violations,
155
+ };
156
+ },
157
+ };
@@ -0,0 +1,30 @@
1
+ import type { Invariant } from "../generators/invariants";
2
+
3
+ export interface CheckViolation {
4
+ filePath: string;
5
+ line?: number;
6
+ message: string;
7
+ }
8
+
9
+ export interface CheckResult {
10
+ invariant: Invariant;
11
+ status: "pass" | "fail" | "skipped" | "tested" | "enforced";
12
+ violations: CheckViolation[];
13
+ message?: string;
14
+ }
15
+
16
+ export interface CheckerContext {
17
+ projectPath: string;
18
+ config: BantayConfig;
19
+ }
20
+
21
+ export interface BantayConfig {
22
+ sourceDirectories: string[];
23
+ routeDirectories?: string[];
24
+ schemaPath?: string;
25
+ }
26
+
27
+ export interface Checker {
28
+ category: string;
29
+ check(invariant: Invariant, context: CheckerContext): Promise<CheckResult>;
30
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env bun
2
+ import { runInit } from "./commands/init";
3
+ import { runCheck, formatCheckResults, formatCheckResultsJson } from "./commands/check";
4
+ import { checkAllPrerequisites } from "./prerequisites";
5
+ import {
6
+ handleAideAdd,
7
+ handleAideUpdate,
8
+ handleAideRemove,
9
+ handleAideLink,
10
+ handleAideShow,
11
+ handleAideValidate,
12
+ handleAideLock,
13
+ printAideHelp,
14
+ } from "./commands/aide";
15
+ import { exportInvariants, exportClaude, exportCursor, exportAll } from "./export";
16
+
17
+ const args = process.argv.slice(2);
18
+ const command = args[0];
19
+
20
+ async function main() {
21
+ if (!command || command === "help" || command === "--help" || command === "-h") {
22
+ printHelp();
23
+ process.exit(0);
24
+ }
25
+
26
+ // Check prerequisites before any command runs
27
+ await runPrerequisiteCheck();
28
+
29
+ if (command === "init") {
30
+ await handleInit(args.slice(1));
31
+ } else if (command === "check") {
32
+ await handleCheck(args.slice(1));
33
+ } else if (command === "aide") {
34
+ await handleAide(args.slice(1));
35
+ } else if (command === "ci") {
36
+ console.error("bantay ci: Not yet implemented");
37
+ process.exit(1);
38
+ } else if (command === "export") {
39
+ await handleExport(args.slice(1));
40
+ } else {
41
+ console.error(`Unknown command: ${command}`);
42
+ console.error('Run "bantay help" for usage information.');
43
+ process.exit(1);
44
+ }
45
+ }
46
+
47
+ async function runPrerequisiteCheck() {
48
+ console.error("Checking prerequisites...");
49
+ const prereqs = await checkAllPrerequisites();
50
+
51
+ if (!prereqs.passed) {
52
+ console.error("\nPrerequisite check failed:\n");
53
+ for (const { name, result } of prereqs.results) {
54
+ if (!result.available) {
55
+ console.error(` ${name}: FAILED`);
56
+ console.error(` ${result.error}`);
57
+ }
58
+ }
59
+ process.exit(1);
60
+ }
61
+ }
62
+
63
+ function printHelp() {
64
+ console.log(`
65
+ Bantay CLI - Enforce project invariants on every PR
66
+
67
+ Usage: bantay <command> [options]
68
+
69
+ Commands:
70
+ init Initialize Bantay in the current project
71
+ check Check all invariants against the codebase
72
+ aide Manage the aide entity tree (add, remove, link, show, validate, lock)
73
+ ci Generate CI workflow configuration
74
+ export Export invariants to agent context files
75
+
76
+ Options:
77
+ -h, --help Show this help message
78
+
79
+ Examples:
80
+ bantay init Initialize in current directory
81
+ bantay check Run full invariant check
82
+ bantay check --diff HEAD~1 Check only affected invariants
83
+ bantay aide show Show the aide entity tree
84
+ bantay aide add inv_test --parent invariants --prop "statement=Test"
85
+ bantay ci --github-actions Generate GitHub Actions workflow
86
+ bantay export all Export all targets
87
+ bantay export invariants Generate invariants.md from bantay.aide
88
+ bantay export claude Export to CLAUDE.md
89
+ bantay export cursor Export to .cursorrules
90
+
91
+ Run "bantay aide help" for aide subcommand details.
92
+ `);
93
+ }
94
+
95
+ async function handleInit(args: string[]) {
96
+ const projectPath = process.cwd();
97
+ const regenerateConfig = args.includes("--regenerate-config");
98
+ const dryRun = args.includes("--dry-run");
99
+
100
+ console.log("Initializing Bantay...\n");
101
+
102
+ if (dryRun) {
103
+ console.log("Dry run mode - no files will be created.");
104
+ process.exit(0);
105
+ }
106
+
107
+ try {
108
+ const result = await runInit(projectPath, { regenerateConfig });
109
+
110
+ // Display detection results
111
+ console.log("Stack Detection:");
112
+ if (result.detection.framework) {
113
+ console.log(` Framework: ${result.detection.framework.name} (${result.detection.framework.confidence} confidence)`);
114
+ if (result.detection.framework.router) {
115
+ console.log(` Router: ${result.detection.framework.router}`);
116
+ }
117
+ } else {
118
+ console.log(" Framework: Not detected");
119
+ }
120
+
121
+ if (result.detection.orm) {
122
+ console.log(` ORM: ${result.detection.orm.name} (${result.detection.orm.confidence} confidence)`);
123
+ if (result.detection.orm.schemaPath) {
124
+ console.log(` Schema: ${result.detection.orm.schemaPath}`);
125
+ }
126
+ } else {
127
+ console.log(" ORM: Not detected");
128
+ }
129
+
130
+ if (result.detection.auth) {
131
+ console.log(` Auth: ${result.detection.auth.name} (${result.detection.auth.confidence} confidence)`);
132
+ } else {
133
+ console.log(" Auth: Not detected");
134
+ }
135
+
136
+ console.log("");
137
+
138
+ // Display warnings
139
+ for (const warning of result.warnings) {
140
+ console.log(`Warning: ${warning}`);
141
+ }
142
+
143
+ // Display created files
144
+ if (result.filesCreated.length > 0) {
145
+ console.log("\nCreated files:");
146
+ for (const file of result.filesCreated) {
147
+ console.log(` - ${file}`);
148
+ }
149
+ }
150
+
151
+ console.log("\nBantay initialized successfully!");
152
+ console.log("Edit invariants.md to customize your project invariants.");
153
+ console.log('Run "bantay check" to verify invariants.');
154
+
155
+ process.exit(0);
156
+ } catch (error) {
157
+ console.error("Error initializing Bantay:", error);
158
+ process.exit(1);
159
+ }
160
+ }
161
+
162
+ async function handleCheck(args: string[]) {
163
+ const projectPath = process.cwd();
164
+
165
+ // Parse options
166
+ const idIndex = args.indexOf("--id");
167
+ const diffIndex = args.indexOf("--diff");
168
+ const jsonOutput = args.includes("--json");
169
+
170
+ const options: { id?: string; diff?: string } = {};
171
+
172
+ if (idIndex !== -1 && args[idIndex + 1]) {
173
+ options.id = args[idIndex + 1];
174
+ }
175
+
176
+ if (diffIndex !== -1) {
177
+ options.diff = args[diffIndex + 1] || "HEAD";
178
+ }
179
+
180
+ try {
181
+ const summary = await runCheck(projectPath, options);
182
+
183
+ if (jsonOutput) {
184
+ // JSON output to stdout, nothing to stderr
185
+ const jsonResult = await formatCheckResultsJson(summary, projectPath);
186
+ console.log(JSON.stringify(jsonResult, null, 2));
187
+ } else {
188
+ // Human-readable output to stderr
189
+ const output = formatCheckResults(summary);
190
+ console.error(output);
191
+ }
192
+
193
+ // Exit non-zero if any invariants failed
194
+ if (summary.failed > 0) {
195
+ process.exit(1);
196
+ }
197
+
198
+ process.exit(0);
199
+ } catch (error) {
200
+ if (error instanceof Error) {
201
+ console.error(`Error: ${error.message}`);
202
+ } else {
203
+ console.error("Error running check:", error);
204
+ }
205
+ process.exit(1);
206
+ }
207
+ }
208
+
209
+ async function handleExport(args: string[]) {
210
+ const projectPath = process.cwd();
211
+
212
+ // Parse target from args
213
+ // Formats: bantay export invariants, bantay export claude, bantay export cursor
214
+ // Or: bantay export --target claude
215
+ const targetIndex = args.indexOf("--target");
216
+ let target: string;
217
+
218
+ if (targetIndex !== -1 && args[targetIndex + 1]) {
219
+ target = args[targetIndex + 1];
220
+ } else if (args[0] && !args[0].startsWith("-")) {
221
+ target = args[0];
222
+ } else {
223
+ console.error("Usage: bantay export <target>");
224
+ console.error("");
225
+ console.error("Targets:");
226
+ console.error(" all Export all targets (invariants, claude, cursor)");
227
+ console.error(" invariants Generate invariants.md from bantay.aide");
228
+ console.error(" claude Export to CLAUDE.md with section markers");
229
+ console.error(" cursor Export to .cursorrules with section markers");
230
+ console.error("");
231
+ console.error("Examples:");
232
+ console.error(" bantay export invariants");
233
+ console.error(" bantay export claude");
234
+ console.error(" bantay export --target cursor");
235
+ process.exit(1);
236
+ }
237
+
238
+ const dryRun = args.includes("--dry-run");
239
+
240
+ try {
241
+ if (target === "all") {
242
+ const results = await exportAll(projectPath, { dryRun });
243
+ console.log("Exported all targets:");
244
+ for (const r of results) {
245
+ console.log(` ${r.target}: ${r.outputPath} (${r.bytesWritten} bytes)`);
246
+ }
247
+ } else if (target === "invariants") {
248
+ const result = await exportInvariants(projectPath, { dryRun });
249
+ console.log(`Exported invariants to ${result.outputPath}`);
250
+ console.log(` ${result.bytesWritten} bytes written`);
251
+ } else if (target === "claude") {
252
+ const result = await exportClaude(projectPath, { dryRun });
253
+ console.log(`Exported to ${result.outputPath}`);
254
+ console.log(` ${result.bytesWritten} bytes written`);
255
+ } else if (target === "cursor") {
256
+ const result = await exportCursor(projectPath, { dryRun });
257
+ console.log(`Exported to ${result.outputPath}`);
258
+ console.log(` ${result.bytesWritten} bytes written`);
259
+ } else {
260
+ console.error(`Unknown export target: ${target}`);
261
+ console.error('Valid targets: all, invariants, claude, cursor');
262
+ process.exit(1);
263
+ }
264
+
265
+ if (dryRun) {
266
+ console.log("\n(Dry run - no files were written)");
267
+ }
268
+
269
+ process.exit(0);
270
+ } catch (error) {
271
+ if (error instanceof Error) {
272
+ console.error(`Error: ${error.message}`);
273
+ } else {
274
+ console.error("Error running export:", error);
275
+ }
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ async function handleAide(args: string[]) {
281
+ const subcommand = args[0];
282
+
283
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
284
+ printAideHelp();
285
+ process.exit(0);
286
+ }
287
+
288
+ const subArgs = args.slice(1);
289
+
290
+ if (subcommand === "add") {
291
+ await handleAideAdd(subArgs);
292
+ } else if (subcommand === "update") {
293
+ await handleAideUpdate(subArgs);
294
+ } else if (subcommand === "remove") {
295
+ await handleAideRemove(subArgs);
296
+ } else if (subcommand === "link") {
297
+ await handleAideLink(subArgs);
298
+ } else if (subcommand === "show") {
299
+ await handleAideShow(subArgs);
300
+ } else if (subcommand === "validate") {
301
+ await handleAideValidate(subArgs);
302
+ } else if (subcommand === "lock") {
303
+ await handleAideLock(subArgs);
304
+ } else {
305
+ console.error(`Unknown aide subcommand: ${subcommand}`);
306
+ console.error('Run "bantay aide help" for usage information.');
307
+ process.exit(1);
308
+ }
309
+ }
310
+
311
+ main().catch((error) => {
312
+ console.error("Fatal error:", error);
313
+ process.exit(1);
314
+ });