@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Zachary Cancio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Bantay
2
+
3
+ Write down the rules your system must never break. We enforce them on every PR.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ bunx @bantay/cli init # Detect stack, generate invariants.md
9
+ bantay check # Verify all invariants
10
+ bantay export claude # Export to CLAUDE.md for agent context
11
+ bantay ci --github-actions # Generate CI workflow
12
+ ```
13
+
14
+ ## What invariants.md looks like
15
+
16
+ ```markdown
17
+ ## Auth
18
+ - [inv_auth_on_routes] auth | All API routes check authentication before processing
19
+
20
+ ## Schema
21
+ - [inv_timestamps] schema | All tables have createdAt and updatedAt columns
22
+
23
+ ## Logging
24
+ - [inv_no_pii_logs] logging | No PII (email, phone, SSN) appears in log output
25
+ ```
26
+
27
+ Each invariant has a stable ID, category, and statement. `bantay check` evaluates them against your codebase using static analysis.
28
+
29
+ ## Three-tier checkers
30
+
31
+ | Tier | Location | Example |
32
+ |------|----------|---------|
33
+ | **Built-in** | Ships with `@bantay/cli` | `auth-on-routes`, `timestamps-on-tables` |
34
+ | **Community** | npm packages | `@bantay/checker-stripe`, `@bantay/checker-posthog` |
35
+ | **Project** | `.bantay/checkers/*.ts` | Custom rules for your codebase |
36
+
37
+ All tiers implement the same interface. Resolution order: project > community > built-in.
38
+
39
+ ## The .aide spec
40
+
41
+ Bantay uses a `.aide` file as its source of truth. `invariants.md`, `CLAUDE.md`, and `.cursorrules` are generated exports.
42
+
43
+ See [bantay.aide](./bantay.aide) for the living spec.
44
+
45
+ ## Commands
46
+
47
+ ```
48
+ bantay init Initialize in current project
49
+ bantay check Check all invariants
50
+ bantay check --diff HEAD~1 Check only affected invariants
51
+ bantay check --id inv_auth Check single invariant
52
+ bantay export invariants Generate invariants.md from .aide
53
+ bantay export claude Export to CLAUDE.md
54
+ bantay export cursor Export to .cursorrules
55
+ bantay export all Export all targets
56
+ bantay ci --github-actions Generate GitHub Actions workflow
57
+ bantay aide show View the .aide entity tree
58
+ bantay aide validate Validate .aide syntax
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@bantay/cli",
3
+ "version": "0.1.0",
4
+ "description": "Write down the rules your system must never break. We enforce them on every PR.",
5
+ "type": "module",
6
+ "bin": {
7
+ "bantay": "./src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "test": "bun test",
16
+ "build": "bun build ./src/cli.ts --outdir ./dist --target bun",
17
+ "dev": "bun run ./src/cli.ts"
18
+ },
19
+ "keywords": [
20
+ "invariants",
21
+ "cli",
22
+ "testing",
23
+ "ci",
24
+ "static-analysis",
25
+ "code-quality"
26
+ ],
27
+ "author": "Zachary Cancio",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/zcancio/bantay.git"
32
+ },
33
+ "dependencies": {
34
+ "js-yaml": "^4.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest"
38
+ },
39
+ "peerDependencies": {
40
+ "typescript": "^5"
41
+ },
42
+ "engines": {
43
+ "node": ">=18",
44
+ "bun": ">=1.0"
45
+ }
46
+ }
@@ -0,0 +1,301 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import * as yaml from "js-yaml";
3
+ import {
4
+ type AideTree,
5
+ type Entity,
6
+ type Relationship,
7
+ type AddEntityOptions,
8
+ type RemoveEntityOptions,
9
+ type AddRelationshipOptions,
10
+ type RelationshipType,
11
+ VALID_RELATIONSHIP_TYPES,
12
+ ID_PREFIX_CONVENTIONS,
13
+ SCENARIO_PREFIX,
14
+ } from "./types";
15
+
16
+ // Re-export types
17
+ export type { AideTree, Entity, Relationship } from "./types";
18
+
19
+ /**
20
+ * Read and parse a .aide YAML file
21
+ */
22
+ export async function read(path: string): Promise<AideTree> {
23
+ const content = await readFile(path, "utf-8");
24
+ const parsed = yaml.load(content) as {
25
+ entities?: Record<string, unknown>;
26
+ relationships?: unknown[];
27
+ };
28
+
29
+ const entities: Record<string, Entity> = {};
30
+ const relationships: Relationship[] = [];
31
+
32
+ // Parse entities
33
+ if (parsed.entities) {
34
+ for (const [id, value] of Object.entries(parsed.entities)) {
35
+ const entity = value as Record<string, unknown>;
36
+ entities[id] = {
37
+ display: entity.display as string | undefined,
38
+ parent: entity.parent as string | undefined,
39
+ props: entity.props as Record<string, unknown> | undefined,
40
+ };
41
+ }
42
+ }
43
+
44
+ // Parse relationships
45
+ if (parsed.relationships && Array.isArray(parsed.relationships)) {
46
+ for (const rel of parsed.relationships) {
47
+ const r = rel as Record<string, unknown>;
48
+ relationships.push({
49
+ from: r.from as string,
50
+ to: r.to as string,
51
+ type: r.type as RelationshipType,
52
+ cardinality: r.cardinality as string,
53
+ } as Relationship);
54
+ }
55
+ }
56
+
57
+ return { entities, relationships };
58
+ }
59
+
60
+ /**
61
+ * Write an aide tree to a .aide YAML file
62
+ */
63
+ export async function write(path: string, tree: AideTree): Promise<void> {
64
+ const content = yaml.dump(
65
+ {
66
+ entities: tree.entities,
67
+ relationships: tree.relationships,
68
+ },
69
+ {
70
+ indent: 2,
71
+ lineWidth: -1, // Don't wrap lines
72
+ noRefs: true,
73
+ sortKeys: false,
74
+ quotingType: '"',
75
+ forceQuotes: false,
76
+ }
77
+ );
78
+ await writeFile(path, content, "utf-8");
79
+ }
80
+
81
+ /**
82
+ * Add an entity to the tree
83
+ */
84
+ export function addEntity(tree: AideTree, options: AddEntityOptions): AideTree {
85
+ const { parent, display, props } = options;
86
+ let { id } = options;
87
+
88
+ // Validate parent exists if specified
89
+ if (parent && !tree.entities[parent]) {
90
+ throw new Error(`Parent entity "${parent}" not found`);
91
+ }
92
+
93
+ // Auto-generate ID if not provided
94
+ if (!id) {
95
+ id = generateEntityId(tree, parent);
96
+ }
97
+
98
+ // Check for duplicate ID
99
+ if (tree.entities[id]) {
100
+ throw new Error(`Entity "${id}" already exists`);
101
+ }
102
+
103
+ // Create new entity
104
+ const entity: Entity = {};
105
+ if (display) entity.display = display;
106
+ if (parent) entity.parent = parent;
107
+ if (props) entity.props = props;
108
+
109
+ // Return new tree with entity added
110
+ return {
111
+ entities: {
112
+ ...tree.entities,
113
+ [id]: entity,
114
+ },
115
+ relationships: [...tree.relationships],
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Remove an entity from the tree
121
+ */
122
+ export function removeEntity(
123
+ tree: AideTree,
124
+ id: string,
125
+ options: RemoveEntityOptions = {}
126
+ ): AideTree {
127
+ const { force = false } = options;
128
+
129
+ // Check entity exists
130
+ if (!tree.entities[id]) {
131
+ throw new Error(`Entity "${id}" not found`);
132
+ }
133
+
134
+ // Find relationships involving this entity
135
+ const involvedRelationships = tree.relationships.filter(
136
+ (r) => r.from === id || r.to === id
137
+ );
138
+
139
+ if (involvedRelationships.length > 0 && !force) {
140
+ throw new Error(
141
+ `Cannot remove "${id}": relationships exist. Use force=true to remove anyway.`
142
+ );
143
+ }
144
+
145
+ // Find all child entities (cascade)
146
+ const idsToRemove = new Set<string>([id]);
147
+ findChildEntities(tree, id, idsToRemove);
148
+
149
+ // Remove entities
150
+ const newEntities: Record<string, Entity> = {};
151
+ for (const [entityId, entity] of Object.entries(tree.entities)) {
152
+ if (!idsToRemove.has(entityId)) {
153
+ newEntities[entityId] = entity;
154
+ }
155
+ }
156
+
157
+ // Remove relationships involving removed entities
158
+ const newRelationships = tree.relationships.filter(
159
+ (r) => !idsToRemove.has(r.from) && !idsToRemove.has(r.to)
160
+ );
161
+
162
+ return {
163
+ entities: newEntities,
164
+ relationships: newRelationships,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Add a relationship to the tree
170
+ */
171
+ export function addRelationship(
172
+ tree: AideTree,
173
+ options: AddRelationshipOptions
174
+ ): AideTree {
175
+ const { from, to, type, cardinality } = options;
176
+
177
+ // Validate 'from' entity exists
178
+ if (!tree.entities[from]) {
179
+ throw new Error(`"from" entity "${from}" not found`);
180
+ }
181
+
182
+ // Validate 'to' entity exists
183
+ if (!tree.entities[to]) {
184
+ throw new Error(`"to" entity "${to}" not found`);
185
+ }
186
+
187
+ // Validate relationship type
188
+ if (!VALID_RELATIONSHIP_TYPES.includes(type)) {
189
+ throw new Error(
190
+ `Invalid relationship type "${type}". Valid types: ${VALID_RELATIONSHIP_TYPES.join(", ")}`
191
+ );
192
+ }
193
+
194
+ const relationship: Relationship = { from, to, type, cardinality };
195
+
196
+ return {
197
+ entities: { ...tree.entities },
198
+ relationships: [...tree.relationships, relationship],
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Validate the aide tree and return an array of error messages
204
+ */
205
+ export function validate(tree: AideTree): string[] {
206
+ const errors: string[] = [];
207
+
208
+ // Check for orphaned relationships (from entity missing)
209
+ for (const rel of tree.relationships) {
210
+ if (!tree.entities[rel.from]) {
211
+ errors.push(
212
+ `Orphaned relationship: "from" entity "${rel.from}" not found`
213
+ );
214
+ }
215
+ if (!tree.entities[rel.to]) {
216
+ errors.push(`Orphaned relationship: "to" entity "${rel.to}" not found`);
217
+ }
218
+
219
+ // Validate relationship type
220
+ if (!VALID_RELATIONSHIP_TYPES.includes(rel.type)) {
221
+ errors.push(
222
+ `Invalid relationship type "${rel.type}" in relationship from "${rel.from}" to "${rel.to}"`
223
+ );
224
+ }
225
+ }
226
+
227
+ // Check for missing parent references
228
+ for (const [id, entity] of Object.entries(tree.entities)) {
229
+ if (entity.parent && !tree.entities[entity.parent]) {
230
+ errors.push(`Entity "${id}" has missing parent "${entity.parent}"`);
231
+ }
232
+ }
233
+
234
+ return errors;
235
+ }
236
+
237
+ // --- Helper Functions ---
238
+
239
+ /**
240
+ * Find all child entities recursively
241
+ */
242
+ function findChildEntities(
243
+ tree: AideTree,
244
+ parentId: string,
245
+ collected: Set<string>
246
+ ): void {
247
+ for (const [id, entity] of Object.entries(tree.entities)) {
248
+ if (entity.parent === parentId && !collected.has(id)) {
249
+ collected.add(id);
250
+ findChildEntities(tree, id, collected);
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Generate an entity ID based on parent conventions
257
+ */
258
+ function generateEntityId(tree: AideTree, parent?: string): string {
259
+ if (!parent) {
260
+ // Generate a generic ID
261
+ let counter = 1;
262
+ while (tree.entities[`entity_${counter}`]) {
263
+ counter++;
264
+ }
265
+ return `entity_${counter}`;
266
+ }
267
+
268
+ // Check if parent has a known prefix convention
269
+ let prefix = ID_PREFIX_CONVENTIONS[parent];
270
+
271
+ // If parent is a CUJ (starts with cuj_), generate scenario prefix
272
+ if (!prefix && parent.startsWith("cuj_")) {
273
+ prefix = SCENARIO_PREFIX;
274
+ }
275
+
276
+ // If parent itself has a prefix, use the same prefix
277
+ if (!prefix) {
278
+ for (const [container, containerPrefix] of Object.entries(
279
+ ID_PREFIX_CONVENTIONS
280
+ )) {
281
+ if (tree.entities[parent]?.parent === container) {
282
+ prefix = containerPrefix;
283
+ break;
284
+ }
285
+ }
286
+ }
287
+
288
+ // Default to a generic prefix based on parent
289
+ if (!prefix) {
290
+ prefix = `${parent}_`;
291
+ }
292
+
293
+ // Find next available number for this prefix
294
+ let counter = 1;
295
+ while (tree.entities[`${prefix}${counter}`]) {
296
+ counter++;
297
+ }
298
+
299
+ return `${prefix}${counter}`;
300
+ }
301
+
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Valid relationship types in an aide file
3
+ */
4
+ export const VALID_RELATIONSHIP_TYPES = [
5
+ "protected_by",
6
+ "depends_on",
7
+ "implements",
8
+ "delegates_to",
9
+ "weakens",
10
+ ] as const;
11
+
12
+ export type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number];
13
+
14
+ /**
15
+ * Valid cardinality values
16
+ */
17
+ export const VALID_CARDINALITIES = [
18
+ "one_to_one",
19
+ "one_to_many",
20
+ "many_to_one",
21
+ "many_to_many",
22
+ ] as const;
23
+
24
+ export type Cardinality = (typeof VALID_CARDINALITIES)[number];
25
+
26
+ /**
27
+ * A relationship between two entities in the aide tree
28
+ */
29
+ export interface Relationship {
30
+ from: string;
31
+ to: string;
32
+ type: RelationshipType;
33
+ cardinality: Cardinality;
34
+ }
35
+
36
+ /**
37
+ * An entity in the aide tree
38
+ */
39
+ export interface Entity {
40
+ display?: string;
41
+ parent?: string;
42
+ props?: Record<string, unknown>;
43
+ }
44
+
45
+ /**
46
+ * The complete aide tree structure
47
+ */
48
+ export interface AideTree {
49
+ entities: Record<string, Entity>;
50
+ relationships: Relationship[];
51
+ }
52
+
53
+ /**
54
+ * Options for adding an entity
55
+ */
56
+ export interface AddEntityOptions {
57
+ id?: string;
58
+ parent?: string;
59
+ display?: string;
60
+ props?: Record<string, unknown>;
61
+ }
62
+
63
+ /**
64
+ * Options for removing an entity
65
+ */
66
+ export interface RemoveEntityOptions {
67
+ force?: boolean;
68
+ }
69
+
70
+ /**
71
+ * Options for adding a relationship
72
+ */
73
+ export interface AddRelationshipOptions {
74
+ from: string;
75
+ to: string;
76
+ type: RelationshipType;
77
+ cardinality: Cardinality;
78
+ }
79
+
80
+ /**
81
+ * Prefix conventions for auto-generating entity IDs based on parent
82
+ */
83
+ export const ID_PREFIX_CONVENTIONS: Record<string, string> = {
84
+ cujs: "cuj_",
85
+ invariants: "inv_",
86
+ constraints: "con_",
87
+ foundations: "found_",
88
+ wisdom: "wis_",
89
+ };
90
+
91
+ /**
92
+ * Prefix for entities under a CUJ (scenarios)
93
+ */
94
+ export const SCENARIO_PREFIX = "sc_";
@@ -0,0 +1,111 @@
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
+ // Auth patterns to look for in route files
8
+ const AUTH_PATTERNS = [
9
+ // Next.js auth patterns
10
+ /auth\(\)/,
11
+ /getServerSession/,
12
+ /useSession/,
13
+ /withAuth/,
14
+ /requireAuth/,
15
+ /checkAuth/,
16
+ /isAuthenticated/,
17
+ /currentUser/,
18
+ /getUser/,
19
+ // Clerk patterns
20
+ /auth\(\)\.protect/,
21
+ /clerkMiddleware/,
22
+ /SignedIn/,
23
+ /SignedOut/,
24
+ // Auth.js / NextAuth patterns
25
+ /getSession/,
26
+ /unstable_getServerSession/,
27
+ // Supabase patterns
28
+ /supabase\.auth/,
29
+ /createServerClient/,
30
+ // Generic patterns
31
+ /middleware.*auth/i,
32
+ /protected/i,
33
+ ];
34
+
35
+ // Route handler exports that need auth
36
+ const ROUTE_EXPORTS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
37
+
38
+ async function findRouteFiles(
39
+ projectPath: string,
40
+ routeDirectories: string[]
41
+ ): Promise<string[]> {
42
+ const routeFiles: string[] = [];
43
+
44
+ for (const routeDir of routeDirectories) {
45
+ const pattern = "**/{route,page}.{ts,tsx,js,jsx}";
46
+ const glob = new Glob(pattern);
47
+
48
+ const dirPath = join(projectPath, routeDir);
49
+
50
+ try {
51
+ for await (const file of glob.scan({ cwd: dirPath, absolute: true })) {
52
+ routeFiles.push(file);
53
+ }
54
+ } catch {
55
+ // Directory doesn't exist, skip
56
+ }
57
+ }
58
+
59
+ return routeFiles;
60
+ }
61
+
62
+ function hasAuthCheck(content: string): boolean {
63
+ return AUTH_PATTERNS.some((pattern) => pattern.test(content));
64
+ }
65
+
66
+ function isApiRoute(content: string): boolean {
67
+ // Check if file exports HTTP method handlers
68
+ return ROUTE_EXPORTS.some((method) => {
69
+ const pattern = new RegExp(`export\\s+(async\\s+)?function\\s+${method}\\b`);
70
+ return pattern.test(content);
71
+ });
72
+ }
73
+
74
+ export const authChecker: Checker = {
75
+ category: "auth",
76
+
77
+ async check(invariant: Invariant, context: CheckerContext): Promise<CheckResult> {
78
+ const violations: CheckViolation[] = [];
79
+ const routeDirs = context.config.routeDirectories ?? ["app/api", "pages/api"];
80
+
81
+ const routeFiles = await findRouteFiles(context.projectPath, routeDirs);
82
+
83
+ for (const filePath of routeFiles) {
84
+ try {
85
+ const content = await readFile(filePath, "utf-8");
86
+
87
+ // Only check API routes (files with HTTP method exports)
88
+ if (!isApiRoute(content)) {
89
+ continue;
90
+ }
91
+
92
+ // Check if any auth pattern is present
93
+ if (!hasAuthCheck(content)) {
94
+ violations.push({
95
+ filePath: relative(context.projectPath, filePath),
96
+ line: 1,
97
+ message: "API route missing authentication check",
98
+ });
99
+ }
100
+ } catch {
101
+ // File read error, skip
102
+ }
103
+ }
104
+
105
+ return {
106
+ invariant,
107
+ status: violations.length > 0 ? "fail" : "pass",
108
+ violations,
109
+ };
110
+ },
111
+ };