@bantay/cli 0.2.0 → 0.3.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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "fs";
2
- import { readFile, writeFile } from "fs/promises";
2
+ import { readFile, writeFile, readdir } from "fs/promises";
3
+ import { basename } from "path";
3
4
  import {
4
5
  read,
5
6
  write,
@@ -11,7 +12,37 @@ import {
11
12
  } from "../aide";
12
13
  import type { RelationshipType, Cardinality } from "../aide/types";
13
14
 
14
- const DEFAULT_AIDE_PATH = "bantay.aide";
15
+ /**
16
+ * Entity type classification based on ID prefix
17
+ */
18
+ const ENTITY_TYPE_PREFIXES: Record<string, string> = {
19
+ "cuj_": "cuj",
20
+ "sc_": "scenario",
21
+ "inv_": "invariant",
22
+ "con_": "constraint",
23
+ "found_": "foundation",
24
+ "wis_": "wisdom",
25
+ };
26
+
27
+ /**
28
+ * Get entity type from ID prefix
29
+ */
30
+ function getEntityType(id: string): string {
31
+ for (const [prefix, type] of Object.entries(ENTITY_TYPE_PREFIXES)) {
32
+ if (id.startsWith(prefix)) {
33
+ return type;
34
+ }
35
+ }
36
+ return "entity";
37
+ }
38
+
39
+ /**
40
+ * Parsed lock file structure
41
+ */
42
+ interface LockFile {
43
+ entities: Record<string, string>;
44
+ relationships: string[];
45
+ }
15
46
 
16
47
  /**
17
48
  * Parse command-line arguments for the aide commands
@@ -36,10 +67,161 @@ function parseOptions(args: string[]): { options: AideCommandOptions; rest: stri
36
67
  }
37
68
 
38
69
  /**
39
- * Get the aide file path, defaulting to bantay.aide in cwd
70
+ * Discover .aide files in the current directory
71
+ * Returns: { found: string[], error?: string }
72
+ */
73
+ async function discoverAideFiles(cwd: string): Promise<{ found: string[]; error?: string }> {
74
+ try {
75
+ const files = await readdir(cwd);
76
+ const aideFiles = files.filter((f) => f.endsWith(".aide"));
77
+ return { found: aideFiles };
78
+ } catch (error) {
79
+ return { found: [], error: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get the aide file path with auto-discovery
85
+ * - If --aide flag is provided, use that
86
+ * - Otherwise, glob for *.aide in cwd
87
+ * - If exactly one found, use it
88
+ * - If multiple found, error
89
+ * - If none found, error
90
+ */
91
+ async function getAidePath(options: AideCommandOptions): Promise<string> {
92
+ const cwd = process.cwd();
93
+
94
+ // If explicit path provided, use it (resolve relative to cwd)
95
+ if (options.aidePath) {
96
+ if (options.aidePath.startsWith("/")) {
97
+ return options.aidePath;
98
+ }
99
+ return `${cwd}/${options.aidePath}`;
100
+ }
101
+
102
+ // Auto-discover
103
+ const { found, error } = await discoverAideFiles(cwd);
104
+
105
+ if (error) {
106
+ console.error(`Error discovering aide files: ${error}`);
107
+ process.exit(1);
108
+ }
109
+
110
+ if (found.length === 0) {
111
+ console.error("No .aide file found. Run 'bantay aide init' to create one.");
112
+ process.exit(1);
113
+ }
114
+
115
+ if (found.length > 1) {
116
+ console.error("Multiple .aide files found. Specify one with --aide <path>");
117
+ console.error("Found:");
118
+ for (const f of found) {
119
+ console.error(` - ${f}`);
120
+ }
121
+ process.exit(1);
122
+ }
123
+
124
+ return `${cwd}/${found[0]}`;
125
+ }
126
+
127
+ /**
128
+ * Generate skeleton aide file content
40
129
  */
41
- function getAidePath(options: AideCommandOptions): string {
42
- return options.aidePath || `${process.cwd()}/${DEFAULT_AIDE_PATH}`;
130
+ function generateAideSkeleton(projectName: string): string {
131
+ return `entities:
132
+ ${projectName}:
133
+ display: page
134
+ props:
135
+ title: ${projectName}
136
+ description: Project description
137
+ version: "0.1"
138
+ cujs:
139
+ display: table
140
+ parent: ${projectName}
141
+ props:
142
+ title: Critical User Journeys
143
+ _pattern: roster
144
+ _group_by: area
145
+ _sort_by: tier
146
+ _sort_order: asc
147
+ invariants:
148
+ display: checklist
149
+ parent: ${projectName}
150
+ props:
151
+ title: Invariants
152
+ _pattern: roster
153
+ _group_by: category
154
+ constraints:
155
+ display: list
156
+ parent: ${projectName}
157
+ props:
158
+ title: Architectural Constraints
159
+ _pattern: flat_list
160
+ _group_by: domain
161
+ foundations:
162
+ display: list
163
+ parent: ${projectName}
164
+ props:
165
+ title: Design Foundations
166
+ _pattern: flat_list
167
+ wisdom:
168
+ display: list
169
+ parent: ${projectName}
170
+ props:
171
+ title: Project Wisdom
172
+ _pattern: flat_list
173
+ relationships: []
174
+ `;
175
+ }
176
+
177
+ /**
178
+ * Handle bantay aide init
179
+ * Usage: bantay aide init [--name <name>]
180
+ * Creates a new .aide file with skeleton structure
181
+ */
182
+ export async function handleAideInit(args: string[]): Promise<void> {
183
+ const cwd = process.cwd();
184
+ const dirName = basename(cwd);
185
+
186
+ // Parse --name option
187
+ let name: string | undefined;
188
+ for (let i = 0; i < args.length; i++) {
189
+ if (args[i] === "--name" || args[i] === "-n") {
190
+ name = args[++i];
191
+ }
192
+ }
193
+
194
+ // Determine the aide filename
195
+ const aideName = name || dirName;
196
+ const aideFilename = `${aideName}.aide`;
197
+ const aidePath = `${cwd}/${aideFilename}`;
198
+
199
+ // Check if any .aide file already exists
200
+ const { found } = await discoverAideFiles(cwd);
201
+
202
+ if (found.length > 0) {
203
+ // Check if the specific file we want to create exists
204
+ if (found.includes(aideFilename)) {
205
+ console.error(`Error: ${aideFilename} already exists.`);
206
+ console.error("Use a different name with --name <name> or delete the existing file.");
207
+ process.exit(1);
208
+ }
209
+
210
+ // If we're trying to create a new aide file but others exist, warn
211
+ console.error(`Error: .aide file already exists: ${found[0]}`);
212
+ console.error("Use --name <name> to create a differently named file, or use the existing one.");
213
+ process.exit(1);
214
+ }
215
+
216
+ // Generate and write the skeleton
217
+ const content = generateAideSkeleton(aideName);
218
+ await writeFile(aidePath, content, "utf-8");
219
+
220
+ console.log(`Created ${aideFilename}`);
221
+ console.log(`\nNext steps:`);
222
+ console.log(` 1. Edit ${aideFilename} to add your project's CUJs, invariants, and constraints`);
223
+ console.log(` 2. Run 'bantay aide validate' to check the file`);
224
+ console.log(` 3. Run 'bantay export all' to generate invariants.md and agent context files`);
43
225
  }
44
226
 
45
227
  /**
@@ -48,13 +230,7 @@ function getAidePath(options: AideCommandOptions): string {
48
230
  */
49
231
  export async function handleAideAdd(args: string[]): Promise<void> {
50
232
  const { options, rest } = parseOptions(args);
51
- const aidePath = getAidePath(options);
52
-
53
- if (!existsSync(aidePath)) {
54
- console.error(`Error: Aide file not found: ${aidePath}`);
55
- console.error("Run 'bantay init' to create a project first.");
56
- process.exit(1);
57
- }
233
+ const aidePath = await getAidePath(options);
58
234
 
59
235
  // Parse add-specific options
60
236
  let id: string | undefined;
@@ -101,12 +277,7 @@ export async function handleAideAdd(args: string[]): Promise<void> {
101
277
  */
102
278
  export async function handleAideRemove(args: string[]): Promise<void> {
103
279
  const { options, rest } = parseOptions(args);
104
- const aidePath = getAidePath(options);
105
-
106
- if (!existsSync(aidePath)) {
107
- console.error(`Error: Aide file not found: ${aidePath}`);
108
- process.exit(1);
109
- }
280
+ const aidePath = await getAidePath(options);
110
281
 
111
282
  const id = rest.find((arg) => !arg.startsWith("-"));
112
283
  const force = rest.includes("--force") || rest.includes("-f");
@@ -135,12 +306,7 @@ export async function handleAideRemove(args: string[]): Promise<void> {
135
306
  */
136
307
  export async function handleAideLink(args: string[]): Promise<void> {
137
308
  const { options, rest } = parseOptions(args);
138
- const aidePath = getAidePath(options);
139
-
140
- if (!existsSync(aidePath)) {
141
- console.error(`Error: Aide file not found: ${aidePath}`);
142
- process.exit(1);
143
- }
309
+ const aidePath = await getAidePath(options);
144
310
 
145
311
  let from: string | undefined;
146
312
  let to: string | undefined;
@@ -192,12 +358,7 @@ export async function handleAideLink(args: string[]): Promise<void> {
192
358
  */
193
359
  export async function handleAideShow(args: string[]): Promise<void> {
194
360
  const { options, rest } = parseOptions(args);
195
- const aidePath = getAidePath(options);
196
-
197
- if (!existsSync(aidePath)) {
198
- console.error(`Error: Aide file not found: ${aidePath}`);
199
- process.exit(1);
200
- }
361
+ const aidePath = await getAidePath(options);
201
362
 
202
363
  let entityId: string | undefined;
203
364
  let format = "tree";
@@ -245,12 +406,7 @@ export async function handleAideShow(args: string[]): Promise<void> {
245
406
  */
246
407
  export async function handleAideValidate(args: string[]): Promise<void> {
247
408
  const { options } = parseOptions(args);
248
- const aidePath = getAidePath(options);
249
-
250
- if (!existsSync(aidePath)) {
251
- console.error(`Error: Aide file not found: ${aidePath}`);
252
- process.exit(1);
253
- }
409
+ const aidePath = await getAidePath(options);
254
410
 
255
411
  try {
256
412
  const tree = await read(aidePath);
@@ -282,14 +438,9 @@ export async function handleAideValidate(args: string[]): Promise<void> {
282
438
  */
283
439
  export async function handleAideLock(args: string[]): Promise<void> {
284
440
  const { options } = parseOptions(args);
285
- const aidePath = getAidePath(options);
441
+ const aidePath = await getAidePath(options);
286
442
  const lockPath = `${aidePath}.lock`;
287
443
 
288
- if (!existsSync(aidePath)) {
289
- console.error(`Error: Aide file not found: ${aidePath}`);
290
- process.exit(1);
291
- }
292
-
293
444
  try {
294
445
  const tree = await read(aidePath);
295
446
  const errors = validate(tree);
@@ -321,12 +472,7 @@ export async function handleAideLock(args: string[]): Promise<void> {
321
472
  */
322
473
  export async function handleAideUpdate(args: string[]): Promise<void> {
323
474
  const { options, rest } = parseOptions(args);
324
- const aidePath = getAidePath(options);
325
-
326
- if (!existsSync(aidePath)) {
327
- console.error(`Error: Aide file not found: ${aidePath}`);
328
- process.exit(1);
329
- }
475
+ const aidePath = await getAidePath(options);
330
476
 
331
477
  let id: string | undefined;
332
478
  const propsToSet: Record<string, unknown> = {};
@@ -512,6 +658,234 @@ function computeRelationshipHash(rel: { from: string; to: string; type: string;
512
658
  return Math.abs(hash).toString(16).padStart(8, "0");
513
659
  }
514
660
 
661
+ /**
662
+ * Handle bantay aide diff
663
+ * Usage: bantay aide diff [--json]
664
+ * Compare bantay.aide against bantay.aide.lock and show changes
665
+ */
666
+ export async function handleAideDiff(args: string[]): Promise<void> {
667
+ const { options } = parseOptions(args);
668
+ const aidePath = await getAidePath(options);
669
+ const lockPath = `${aidePath}.lock`;
670
+ const jsonOutput = args.includes("--json");
671
+
672
+ // Check that lock file exists
673
+ if (!existsSync(lockPath)) {
674
+ console.error(`Error: Lock file not found: ${lockPath}`);
675
+ console.error("Run 'bantay aide lock' to create a lock file first.");
676
+ process.exit(1);
677
+ }
678
+
679
+ try {
680
+ // Read and parse current aide file
681
+ const tree = await read(aidePath);
682
+
683
+ // Read and parse lock file
684
+ const lockContent = await readFile(lockPath, "utf-8");
685
+ const lock = parseLockFile(lockContent);
686
+
687
+ // Compute current hashes
688
+ const currentHashes: Record<string, string> = {};
689
+ for (const [id, entity] of Object.entries(tree.entities)) {
690
+ currentHashes[id] = computeEntityHash(id, entity);
691
+ }
692
+
693
+ // Compute current relationship keys
694
+ const currentRelationships = new Set<string>();
695
+ for (const rel of tree.relationships) {
696
+ currentRelationships.add(`${rel.from}:${rel.to}:${rel.type}`);
697
+ }
698
+
699
+ // Parse lock relationships into set
700
+ const lockRelationships = new Set<string>(lock.relationships);
701
+
702
+ // Find differences
703
+ const added: string[] = [];
704
+ const removed: string[] = [];
705
+ const modified: string[] = [];
706
+
707
+ // Find added and modified entities
708
+ for (const id of Object.keys(currentHashes)) {
709
+ if (!(id in lock.entities)) {
710
+ added.push(id);
711
+ } else if (lock.entities[id] !== currentHashes[id]) {
712
+ modified.push(id);
713
+ }
714
+ }
715
+
716
+ // Find removed entities
717
+ for (const id of Object.keys(lock.entities)) {
718
+ if (!(id in currentHashes)) {
719
+ removed.push(id);
720
+ }
721
+ }
722
+
723
+ // Find relationship changes
724
+ const addedRelationships: string[] = [];
725
+ const removedRelationships: string[] = [];
726
+
727
+ for (const rel of currentRelationships) {
728
+ if (!lockRelationships.has(rel)) {
729
+ addedRelationships.push(rel);
730
+ }
731
+ }
732
+
733
+ for (const rel of lockRelationships) {
734
+ if (!currentRelationships.has(rel)) {
735
+ removedRelationships.push(rel);
736
+ }
737
+ }
738
+
739
+ // Check if there are any changes
740
+ const hasChanges =
741
+ added.length > 0 ||
742
+ removed.length > 0 ||
743
+ modified.length > 0 ||
744
+ addedRelationships.length > 0 ||
745
+ removedRelationships.length > 0;
746
+
747
+ if (jsonOutput) {
748
+ // JSON output
749
+ const result = {
750
+ hasChanges,
751
+ added: added.map((id) => ({ id, type: getEntityType(id) })),
752
+ removed: removed.map((id) => ({ id, type: getEntityType(id) })),
753
+ modified: modified.map((id) => ({ id, type: getEntityType(id) })),
754
+ relationships: {
755
+ added: addedRelationships.map((r) => {
756
+ const [from, to, type] = r.split(":");
757
+ return { from, to, type };
758
+ }),
759
+ removed: removedRelationships.map((r) => {
760
+ const [from, to, type] = r.split(":");
761
+ return { from, to, type };
762
+ }),
763
+ },
764
+ summary: {
765
+ entitiesAdded: added.length,
766
+ entitiesRemoved: removed.length,
767
+ entitiesModified: modified.length,
768
+ relationshipsAdded: addedRelationships.length,
769
+ relationshipsRemoved: removedRelationships.length,
770
+ },
771
+ };
772
+ console.log(JSON.stringify(result, null, 2));
773
+ } else {
774
+ // Human-readable output
775
+ if (!hasChanges) {
776
+ console.log("No changes since last lock.");
777
+ process.exit(0);
778
+ }
779
+
780
+ console.log("Changes since last lock:\n");
781
+
782
+ if (added.length > 0) {
783
+ console.log("ADDED");
784
+ for (const id of added.sort()) {
785
+ console.log(` + ${id} (${getEntityType(id)})`);
786
+ }
787
+ console.log("");
788
+ }
789
+
790
+ if (modified.length > 0) {
791
+ console.log("MODIFIED");
792
+ for (const id of modified.sort()) {
793
+ console.log(` ~ ${id} (${getEntityType(id)})`);
794
+ }
795
+ console.log("");
796
+ }
797
+
798
+ if (removed.length > 0) {
799
+ console.log("REMOVED");
800
+ for (const id of removed.sort()) {
801
+ console.log(` - ${id} (${getEntityType(id)})`);
802
+ }
803
+ console.log("");
804
+ }
805
+
806
+ if (addedRelationships.length > 0 || removedRelationships.length > 0) {
807
+ console.log("RELATIONSHIPS");
808
+ for (const rel of addedRelationships) {
809
+ const [from, to, type] = rel.split(":");
810
+ console.log(` + ${from} --[${type}]--> ${to}`);
811
+ }
812
+ for (const rel of removedRelationships) {
813
+ const [from, to, type] = rel.split(":");
814
+ console.log(` - ${from} --[${type}]--> ${to}`);
815
+ }
816
+ console.log("");
817
+ }
818
+
819
+ // Summary
820
+ console.log("Summary:");
821
+ const parts: string[] = [];
822
+ if (added.length > 0) parts.push(`${added.length} added`);
823
+ if (modified.length > 0) parts.push(`${modified.length} modified`);
824
+ if (removed.length > 0) parts.push(`${removed.length} removed`);
825
+ if (addedRelationships.length > 0)
826
+ parts.push(`${addedRelationships.length} relationship(s) added`);
827
+ if (removedRelationships.length > 0)
828
+ parts.push(`${removedRelationships.length} relationship(s) removed`);
829
+ console.log(` ${parts.join(", ")}`);
830
+ }
831
+
832
+ // Exit with code 1 if changes exist
833
+ process.exit(hasChanges ? 1 : 0);
834
+ } catch (error) {
835
+ console.error(`Error comparing aide files: ${error instanceof Error ? error.message : error}`);
836
+ process.exit(1);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Parse lock file content into structured format
842
+ */
843
+ function parseLockFile(content: string): LockFile {
844
+ const result: LockFile = {
845
+ entities: {},
846
+ relationships: [],
847
+ };
848
+
849
+ let section = "";
850
+ const lines = content.split("\n");
851
+
852
+ for (const line of lines) {
853
+ const trimmed = line.trim();
854
+
855
+ // Skip comments and empty lines
856
+ if (trimmed.startsWith("#") || trimmed === "") {
857
+ continue;
858
+ }
859
+
860
+ // Section headers
861
+ if (trimmed === "entities:") {
862
+ section = "entities";
863
+ continue;
864
+ }
865
+ if (trimmed === "relationships:") {
866
+ section = "relationships";
867
+ continue;
868
+ }
869
+
870
+ // Parse content based on section
871
+ if (section === "entities") {
872
+ // Format: " entity_id: hash"
873
+ const match = trimmed.match(/^(\w+):\s*(\w+)$/);
874
+ if (match) {
875
+ result.entities[match[1]] = match[2];
876
+ }
877
+ } else if (section === "relationships") {
878
+ // Format: " - from:to:type: hash"
879
+ const match = trimmed.match(/^-\s*(\w+):(\w+):(\w+):\s*\w+$/);
880
+ if (match) {
881
+ result.relationships.push(`${match[1]}:${match[2]}:${match[3]}`);
882
+ }
883
+ }
884
+ }
885
+
886
+ return result;
887
+ }
888
+
515
889
  /**
516
890
  * Print help for aide subcommands
517
891
  */
@@ -522,6 +896,7 @@ bantay aide - Manage the aide entity tree
522
896
  Usage: bantay aide <subcommand> [options]
523
897
 
524
898
  Subcommands:
899
+ init Create a new .aide file with skeleton structure
525
900
  add Add an entity to the tree
526
901
  update Update an entity's properties
527
902
  remove Remove an entity from the tree
@@ -529,6 +904,10 @@ Subcommands:
529
904
  show Display the entity tree or a specific entity
530
905
  validate Validate the aide file
531
906
  lock Generate a lock file
907
+ diff Compare aide against lock file
908
+
909
+ Init Options:
910
+ --name, -n Name for the aide file (default: current directory name)
532
911
 
533
912
  Add Options:
534
913
  <id> Entity ID (optional, auto-generated if parent provided)
@@ -556,6 +935,9 @@ Show Options:
556
935
  [id] Specific entity to show (optional)
557
936
  --format, -f Output format: tree (default) or json
558
937
 
938
+ Diff Options:
939
+ --json Output as JSON
940
+
559
941
  Global Options:
560
942
  --aide, -a Path to aide file (default: bantay.aide)
561
943
 
@@ -567,5 +949,7 @@ Examples:
567
949
  bantay aide show invariants
568
950
  bantay aide validate
569
951
  bantay aide lock
952
+ bantay aide diff
953
+ bantay aide diff --json
570
954
  `);
571
955
  }
@@ -6,7 +6,7 @@ import { runChecker, hasChecker } from "../checkers/registry";
6
6
  import { loadConfig } from "../config";
7
7
  import { getGitDiff, shouldCheckInvariant } from "../diff";
8
8
  import type { CheckResult, CheckerContext } from "../checkers/types";
9
- import { read as readAide } from "../aide";
9
+ import { read as readAide, tryResolveAidePath } from "../aide";
10
10
 
11
11
  export interface CheckOptions {
12
12
  id?: string;
@@ -44,16 +44,21 @@ interface InvariantEnforcementInfo {
44
44
  }
45
45
 
46
46
  /**
47
- * Load checker and test paths from bantay.aide for each invariant
47
+ * Load checker and test paths from .aide file for each invariant
48
48
  */
49
49
  async function getEnforcementInfo(
50
50
  projectPath: string
51
51
  ): Promise<Map<string, InvariantEnforcementInfo>> {
52
52
  const info = new Map<string, InvariantEnforcementInfo>();
53
- const aidePath = join(projectPath, "bantay.aide");
53
+
54
+ // Try to discover the aide file
55
+ const resolved = await tryResolveAidePath(projectPath);
56
+ if (!resolved) {
57
+ return info; // No aide file found, return empty map
58
+ }
54
59
 
55
60
  try {
56
- const tree = await readAide(aidePath);
61
+ const tree = await readAide(resolved.path);
57
62
  for (const [id, entity] of Object.entries(tree.entities)) {
58
63
  if (id.startsWith("inv_")) {
59
64
  const enforcement: InvariantEnforcementInfo = {};