@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,92 @@
1
+ /**
2
+ * Export invariants.md from the aide tree
3
+ */
4
+
5
+ import { readFile, writeFile } from "fs/promises";
6
+ import { join } from "path";
7
+ import { read as readAide } from "../aide";
8
+ import { extractInvariants, groupBy } from "./aide-reader";
9
+ import type { ExportOptions, ExportResult, ExtractedInvariant } from "./types";
10
+
11
+ /**
12
+ * Generate invariants.md content from the aide tree
13
+ */
14
+ export function generateInvariantsMd(invariants: ExtractedInvariant[]): string {
15
+ const lines: string[] = [];
16
+
17
+ lines.push("# Invariants");
18
+ lines.push("");
19
+ lines.push("Rules this project must never break. Generated from bantay.aide.");
20
+ lines.push("");
21
+
22
+ // Group by category
23
+ const byCategory = groupBy(invariants, (inv) => inv.category);
24
+
25
+ // Sort categories for consistent output
26
+ const sortedCategories = Array.from(byCategory.keys()).sort();
27
+
28
+ for (const category of sortedCategories) {
29
+ const categoryInvariants = byCategory.get(category) || [];
30
+
31
+ lines.push(`## ${formatCategoryTitle(category)}`);
32
+ lines.push("");
33
+
34
+ for (const inv of categoryInvariants) {
35
+ // Format: - [ ] ID: statement
36
+ const checkbox = inv.done ? "[x]" : "[ ]";
37
+ lines.push(`- ${checkbox} **${inv.id}**: ${inv.statement}`);
38
+
39
+ // Add threat signal as sub-item if present
40
+ if (inv.threatSignal) {
41
+ lines.push(` - Threat: ${inv.threatSignal}`);
42
+ }
43
+ }
44
+
45
+ lines.push("");
46
+ }
47
+
48
+ return lines.join("\n");
49
+ }
50
+
51
+ /**
52
+ * Format a category ID into a readable title
53
+ * e.g., "correctness" -> "Correctness"
54
+ */
55
+ function formatCategoryTitle(category: string): string {
56
+ return category
57
+ .split("_")
58
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
59
+ .join(" ");
60
+ }
61
+
62
+ /**
63
+ * Export invariants.md from the aide file
64
+ */
65
+ export async function exportInvariants(
66
+ projectPath: string,
67
+ options: ExportOptions = {}
68
+ ): Promise<ExportResult> {
69
+ const aidePath = options.aidePath || join(projectPath, "bantay.aide");
70
+ const outputPath = options.outputPath || join(projectPath, "invariants.md");
71
+
72
+ // Read the aide tree
73
+ const tree = await readAide(aidePath);
74
+
75
+ // Extract invariants
76
+ const invariants = extractInvariants(tree);
77
+
78
+ // Generate content
79
+ const content = generateInvariantsMd(invariants);
80
+
81
+ // Write unless dry run
82
+ if (!options.dryRun) {
83
+ await writeFile(outputPath, content, "utf-8");
84
+ }
85
+
86
+ return {
87
+ target: "invariants",
88
+ outputPath,
89
+ content,
90
+ bytesWritten: Buffer.byteLength(content, "utf-8"),
91
+ };
92
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Export types for bantay export command
3
+ */
4
+
5
+ /**
6
+ * An invariant extracted from the aide tree
7
+ */
8
+ export interface ExtractedInvariant {
9
+ id: string;
10
+ statement: string;
11
+ category: string;
12
+ threatSignal?: string;
13
+ done?: boolean;
14
+ }
15
+
16
+ /**
17
+ * A constraint extracted from the aide tree
18
+ */
19
+ export interface ExtractedConstraint {
20
+ id: string;
21
+ text: string;
22
+ domain: string;
23
+ rationale?: string;
24
+ }
25
+
26
+ /**
27
+ * A foundation extracted from the aide tree
28
+ */
29
+ export interface ExtractedFoundation {
30
+ id: string;
31
+ text: string;
32
+ }
33
+
34
+ /**
35
+ * A wisdom entry extracted from the aide tree
36
+ */
37
+ export interface ExtractedWisdom {
38
+ id: string;
39
+ text: string;
40
+ }
41
+
42
+ /**
43
+ * Export target types
44
+ */
45
+ export type ExportTarget = "invariants" | "claude" | "cursor" | "codex";
46
+
47
+ /**
48
+ * Options for export commands
49
+ */
50
+ export interface ExportOptions {
51
+ aidePath?: string;
52
+ outputPath?: string;
53
+ dryRun?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Result of an export operation
58
+ */
59
+ export interface ExportResult {
60
+ target: ExportTarget;
61
+ outputPath: string;
62
+ content: string;
63
+ bytesWritten: number;
64
+ }
65
+
66
+ /**
67
+ * Section markers for agent context files
68
+ */
69
+ export const SECTION_START = "<!-- bantay:start -->";
70
+ export const SECTION_END = "<!-- bantay:end -->";
@@ -0,0 +1,170 @@
1
+ import type { StackDetectionResult } from "../detectors";
2
+
3
+ export interface BantayConfig {
4
+ source: {
5
+ include: string[];
6
+ exclude?: string[];
7
+ };
8
+ schema?: {
9
+ prisma?: string;
10
+ };
11
+ routes?: {
12
+ include: string[];
13
+ };
14
+ }
15
+
16
+ export async function generateConfig(
17
+ stack: StackDetectionResult,
18
+ _projectPath: string
19
+ ): Promise<BantayConfig> {
20
+ const config: BantayConfig = {
21
+ source: {
22
+ include: ["src/**/*"],
23
+ exclude: ["node_modules/**", "dist/**", ".next/**", "build/**"],
24
+ },
25
+ };
26
+
27
+ // Add app directory for Next.js app router
28
+ if (stack.framework?.name === "nextjs") {
29
+ if (stack.framework.router === "app") {
30
+ config.source.include.push("app/**/*");
31
+ config.routes = {
32
+ include: ["app/**/route.ts", "app/**/route.js"],
33
+ };
34
+ } else if (stack.framework.router === "pages") {
35
+ config.source.include.push("pages/**/*");
36
+ config.routes = {
37
+ include: ["pages/api/**/*.ts", "pages/api/**/*.js"],
38
+ };
39
+ }
40
+ }
41
+
42
+ // Add Prisma schema path
43
+ if (stack.orm?.name === "prisma" && stack.orm.schemaPath) {
44
+ config.schema = {
45
+ prisma: stack.orm.schemaPath,
46
+ };
47
+ }
48
+
49
+ return config;
50
+ }
51
+
52
+ export function configToYaml(config: BantayConfig): string {
53
+ const lines: string[] = [
54
+ "# Bantay configuration",
55
+ "# Edit this file to customize invariant checking",
56
+ "",
57
+ ];
58
+
59
+ // Source section
60
+ lines.push("source:");
61
+ lines.push(" include:");
62
+ for (const pattern of config.source.include) {
63
+ lines.push(` - ${pattern}`);
64
+ }
65
+
66
+ if (config.source.exclude && config.source.exclude.length > 0) {
67
+ lines.push(" exclude:");
68
+ for (const pattern of config.source.exclude) {
69
+ lines.push(` - ${pattern}`);
70
+ }
71
+ }
72
+
73
+ // Schema section
74
+ if (config.schema) {
75
+ lines.push("");
76
+ lines.push("schema:");
77
+ if (config.schema.prisma) {
78
+ lines.push(` prisma: ${config.schema.prisma}`);
79
+ }
80
+ }
81
+
82
+ // Routes section
83
+ if (config.routes) {
84
+ lines.push("");
85
+ lines.push("routes:");
86
+ lines.push(" include:");
87
+ for (const pattern of config.routes.include) {
88
+ lines.push(` - ${pattern}`);
89
+ }
90
+ }
91
+
92
+ lines.push("");
93
+ return lines.join("\n");
94
+ }
95
+
96
+ export function parseConfig(yaml: string): BantayConfig {
97
+ const config: BantayConfig = {
98
+ source: {
99
+ include: [],
100
+ },
101
+ };
102
+
103
+ const lines = yaml.split("\n");
104
+ let currentSection: string | null = null;
105
+ let currentSubsection: string | null = null;
106
+
107
+ for (const line of lines) {
108
+ const trimmed = line.trim();
109
+
110
+ // Skip comments and empty lines
111
+ if (trimmed.startsWith("#") || trimmed === "") {
112
+ continue;
113
+ }
114
+
115
+ // Check for top-level sections
116
+ if (!line.startsWith(" ") && !line.startsWith("\t")) {
117
+ if (trimmed.startsWith("source:")) {
118
+ currentSection = "source";
119
+ currentSubsection = null;
120
+ } else if (trimmed.startsWith("schema:")) {
121
+ currentSection = "schema";
122
+ currentSubsection = null;
123
+ } else if (trimmed.startsWith("routes:")) {
124
+ currentSection = "routes";
125
+ currentSubsection = null;
126
+ }
127
+ continue;
128
+ }
129
+
130
+ // Check for subsections
131
+ if (trimmed.startsWith("include:")) {
132
+ currentSubsection = "include";
133
+ continue;
134
+ } else if (trimmed.startsWith("exclude:")) {
135
+ currentSubsection = "exclude";
136
+ continue;
137
+ }
138
+
139
+ // Handle list items
140
+ if (trimmed.startsWith("- ")) {
141
+ const value = trimmed.slice(2);
142
+
143
+ if (currentSection === "source" && currentSubsection === "include") {
144
+ config.source.include.push(value);
145
+ } else if (currentSection === "source" && currentSubsection === "exclude") {
146
+ config.source.exclude = config.source.exclude ?? [];
147
+ config.source.exclude.push(value);
148
+ } else if (currentSection === "routes" && currentSubsection === "include") {
149
+ config.routes = config.routes ?? { include: [] };
150
+ config.routes.include.push(value);
151
+ }
152
+ continue;
153
+ }
154
+
155
+ // Handle key-value pairs
156
+ if (trimmed.includes(":")) {
157
+ const [key, ...rest] = trimmed.split(":");
158
+ const value = rest.join(":").trim();
159
+
160
+ if (currentSection === "schema") {
161
+ if (key.trim() === "prisma") {
162
+ config.schema = config.schema ?? {};
163
+ config.schema.prisma = value;
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ return config;
170
+ }
@@ -0,0 +1,161 @@
1
+ import type { StackDetectionResult } from "../detectors";
2
+
3
+ export interface Invariant {
4
+ id: string;
5
+ category: string;
6
+ statement: string;
7
+ }
8
+
9
+ interface InvariantTemplate {
10
+ id: string;
11
+ category: string;
12
+ statement: string;
13
+ }
14
+
15
+ // Universal invariants that apply to all projects
16
+ const universalInvariants: InvariantTemplate[] = [
17
+ {
18
+ id: "INV-001",
19
+ category: "security",
20
+ statement: "No secrets or credentials committed to version control",
21
+ },
22
+ {
23
+ id: "INV-002",
24
+ category: "security",
25
+ statement: "All user input must be validated before processing",
26
+ },
27
+ ];
28
+
29
+ // Next.js specific invariants
30
+ const nextjsInvariants: InvariantTemplate[] = [
31
+ {
32
+ id: "INV-010",
33
+ category: "auth",
34
+ statement: "All API routes must check authentication before processing requests (auth-on-routes)",
35
+ },
36
+ {
37
+ id: "INV-011",
38
+ category: "auth",
39
+ statement: "Protected pages must redirect unauthenticated users",
40
+ },
41
+ ];
42
+
43
+ // Prisma specific invariants
44
+ const prismaInvariants: InvariantTemplate[] = [
45
+ {
46
+ id: "INV-020",
47
+ category: "schema",
48
+ statement: "All database tables must have createdAt and updatedAt timestamps (timestamps-on-tables)",
49
+ },
50
+ {
51
+ id: "INV-021",
52
+ category: "schema",
53
+ statement: "All database tables must use soft-delete pattern with deletedAt column (soft-delete)",
54
+ },
55
+ {
56
+ id: "INV-022",
57
+ category: "schema",
58
+ statement: "No raw SQL queries - use Prisma client methods only (no-raw-sql)",
59
+ },
60
+ ];
61
+
62
+ function collectInvariants(stack: StackDetectionResult): InvariantTemplate[] {
63
+ const invariants: InvariantTemplate[] = [...universalInvariants];
64
+
65
+ if (stack.framework?.name === "nextjs") {
66
+ invariants.push(...nextjsInvariants);
67
+ }
68
+
69
+ if (stack.orm?.name === "prisma") {
70
+ invariants.push(...prismaInvariants);
71
+ }
72
+
73
+ return invariants;
74
+ }
75
+
76
+ function groupByCategory(invariants: InvariantTemplate[]): Map<string, InvariantTemplate[]> {
77
+ const grouped = new Map<string, InvariantTemplate[]>();
78
+
79
+ for (const inv of invariants) {
80
+ const existing = grouped.get(inv.category) ?? [];
81
+ existing.push(inv);
82
+ grouped.set(inv.category, existing);
83
+ }
84
+
85
+ return grouped;
86
+ }
87
+
88
+ function formatCategoryName(category: string): string {
89
+ return category.charAt(0).toUpperCase() + category.slice(1);
90
+ }
91
+
92
+ export async function generateInvariants(stack: StackDetectionResult): Promise<string> {
93
+ const invariants = collectInvariants(stack);
94
+ const grouped = groupByCategory(invariants);
95
+
96
+ const lines: string[] = [
97
+ "# Project Invariants",
98
+ "",
99
+ "This file defines the invariants that must hold for this project.",
100
+ "Each invariant is checked by `bantay check` on every PR.",
101
+ "",
102
+ ];
103
+
104
+ for (const [category, invs] of grouped) {
105
+ lines.push(`## ${formatCategoryName(category)}`);
106
+ lines.push("");
107
+
108
+ for (const inv of invs) {
109
+ lines.push(`- [${inv.id}] ${inv.category} | ${inv.statement}`);
110
+ }
111
+
112
+ lines.push("");
113
+ }
114
+
115
+ return lines.join("\n");
116
+ }
117
+
118
+ export function parseInvariants(markdown: string): Invariant[] {
119
+ const invariants: Invariant[] = [];
120
+
121
+ // Pattern 1: Old format - [INV-XXX] category | statement
122
+ const oldRegex = /^-\s*\[([A-Z]+-\d{3})\]\s*(\w+)\s*\|\s*(.+)$/gm;
123
+
124
+ // Pattern 2: Aide-generated format - [ ] **inv_id**: statement
125
+ // Category is determined by the preceding ## Header
126
+ const lines = markdown.split("\n");
127
+ let currentCategory = "uncategorized";
128
+
129
+ for (const line of lines) {
130
+ // Check for category header (## Category Name)
131
+ const headerMatch = line.match(/^##\s+(.+)$/);
132
+ if (headerMatch) {
133
+ // Convert "Auditability" to "auditability"
134
+ currentCategory = headerMatch[1].trim().toLowerCase().replace(/\s+/g, "_");
135
+ continue;
136
+ }
137
+
138
+ // Check for old format
139
+ const oldMatch = line.match(/^-\s*\[([A-Z]+-\d{3})\]\s*(\w+)\s*\|\s*(.+)$/);
140
+ if (oldMatch) {
141
+ invariants.push({
142
+ id: oldMatch[1],
143
+ category: oldMatch[2],
144
+ statement: oldMatch[3].trim(),
145
+ });
146
+ continue;
147
+ }
148
+
149
+ // Check for aide-generated format: - [ ] **inv_id**: statement or - [x] **inv_id**: statement
150
+ const aideMatch = line.match(/^-\s*\[[x ]\]\s*\*\*([^*]+)\*\*:\s*(.+)$/);
151
+ if (aideMatch) {
152
+ invariants.push({
153
+ id: aideMatch[1],
154
+ category: currentCategory,
155
+ statement: aideMatch[2].trim(),
156
+ });
157
+ }
158
+ }
159
+
160
+ return invariants;
161
+ }
@@ -0,0 +1,33 @@
1
+ export interface PrerequisiteResult {
2
+ available: boolean;
3
+ version?: string;
4
+ error?: string;
5
+ }
6
+
7
+ export async function checkBunRuntime(): Promise<PrerequisiteResult> {
8
+ // Check if we're running in Bun by checking for Bun global
9
+ if (typeof Bun !== "undefined" && Bun.version) {
10
+ return {
11
+ available: true,
12
+ version: Bun.version,
13
+ };
14
+ }
15
+
16
+ return {
17
+ available: false,
18
+ error: "Bun runtime not found. Install from https://bun.sh",
19
+ };
20
+ }
21
+
22
+ export async function checkAllPrerequisites(): Promise<{
23
+ passed: boolean;
24
+ results: { name: string; result: PrerequisiteResult }[];
25
+ }> {
26
+ const bunResult = await checkBunRuntime();
27
+
28
+ const results = [{ name: "Bun Runtime", result: bunResult }];
29
+
30
+ const passed = results.every((r) => r.result.available);
31
+
32
+ return { passed, results };
33
+ }