treedocs 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.
package/src/cli.ts ADDED
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ type CheckReport,
4
+ cliVersion,
5
+ entryDescription,
6
+ entryReferences,
7
+ missingDescriptionNextStep,
8
+ type SyncChanges,
9
+ stateFileName,
10
+ TreedocsError,
11
+ TreedocsService,
12
+ } from "./core.ts";
13
+
14
+ interface GlobalOptions {
15
+ path: string;
16
+ nonInteractive: boolean;
17
+ }
18
+
19
+ interface ParsedArguments {
20
+ command: string;
21
+ values: string[];
22
+ options: Map<string, string[]>;
23
+ globals: GlobalOptions;
24
+ }
25
+
26
+ const commands = new Set([
27
+ "init",
28
+ "sync",
29
+ "check",
30
+ "show",
31
+ "inspect",
32
+ "update",
33
+ "ls",
34
+ "explore",
35
+ "path",
36
+ "help",
37
+ ]);
38
+
39
+ export async function main(
40
+ rawArguments = process.argv.slice(2),
41
+ ): Promise<number> {
42
+ try {
43
+ const rewrittenArguments = rewriteArguments(rawArguments);
44
+ if (rewrittenArguments.includes("--version")) {
45
+ console.log(cliVersion);
46
+ return 0;
47
+ }
48
+ if (
49
+ rewrittenArguments.length === 0 ||
50
+ rewrittenArguments[0] === "--help" ||
51
+ rewrittenArguments[0] === "-h" ||
52
+ rewrittenArguments[0] === "help"
53
+ ) {
54
+ printRootHelp();
55
+ return 0;
56
+ }
57
+
58
+ const parsed = parseArguments(rewrittenArguments);
59
+ if (parsed.options.has("help") || parsed.options.has("h")) {
60
+ printCommandHelp(parsed.command);
61
+ return 0;
62
+ }
63
+
64
+ const service = new TreedocsService();
65
+ await runCommand(parsed, service);
66
+ return typeof process.exitCode === "number" ? process.exitCode : 0;
67
+ } catch (error) {
68
+ if (error instanceof TreedocsError) {
69
+ console.error(error.message);
70
+ return 1;
71
+ }
72
+ if (error instanceof Error) {
73
+ console.error(error.message);
74
+ return 1;
75
+ }
76
+ console.error(String(error));
77
+ return 1;
78
+ }
79
+ }
80
+
81
+ async function runCommand(
82
+ parsed: ParsedArguments,
83
+ service: TreedocsService,
84
+ ): Promise<void> {
85
+ switch (parsed.command) {
86
+ case "init":
87
+ await runInit(parsed, service);
88
+ return;
89
+ case "sync":
90
+ await runSync(parsed, service);
91
+ return;
92
+ case "check":
93
+ await runCheck(parsed, service);
94
+ return;
95
+ case "show":
96
+ await runShow(parsed, service);
97
+ return;
98
+ case "ls":
99
+ await runLs(parsed, service);
100
+ return;
101
+ case "explore":
102
+ await runExplore(parsed, service);
103
+ return;
104
+ case "inspect":
105
+ await runInspect(parsed, service);
106
+ return;
107
+ case "update":
108
+ await runUpdate(parsed, service);
109
+ return;
110
+ case "path":
111
+ await runPath(parsed, service);
112
+ return;
113
+ default:
114
+ throw new TreedocsError(`Unknown command: ${parsed.command}`);
115
+ }
116
+ }
117
+
118
+ async function runInit(
119
+ parsed: ParsedArguments,
120
+ service: TreedocsService,
121
+ ): Promise<void> {
122
+ const file = await service.initialize(
123
+ parsed.globals.path,
124
+ hasFlag(parsed, "force"),
125
+ );
126
+ console.log(
127
+ `Initialized ${stateFileName} (${file.signature ?? "no signature"})`,
128
+ );
129
+ }
130
+
131
+ async function runSync(
132
+ parsed: ParsedArguments,
133
+ service: TreedocsService,
134
+ ): Promise<void> {
135
+ void parsed.globals.nonInteractive;
136
+ const result = await service.syncResult(parsed.globals.path);
137
+ if (result.signatureUnchanged) {
138
+ console.log("No change found.");
139
+ } else {
140
+ for (const message of changeSummaryMessages(result.changes)) {
141
+ console.log(message);
142
+ }
143
+ }
144
+ console.log(
145
+ `Synced ${stateFileName} (${result.file.signature ?? "no signature"})`,
146
+ );
147
+ for (const message of remainingIssueMessages(result.missingDescriptions)) {
148
+ console.log(message);
149
+ }
150
+ }
151
+
152
+ async function runCheck(
153
+ parsed: ParsedArguments,
154
+ service: TreedocsService,
155
+ ): Promise<void> {
156
+ const report = await service.check(parsed.globals.path);
157
+ printSection("Schema warnings", report.schemaWarnings);
158
+ printSection("Schema validation failures", report.schemaErrors);
159
+
160
+ if (report.hasSignatureDrift) {
161
+ console.log(
162
+ `Stale tree: stored signature ${report.storedSignature ?? "<missing>"} does not match current signature ${report.currentSignature}`,
163
+ );
164
+ }
165
+
166
+ printSection("Missing paths", report.missingPaths);
167
+ printSection("Extra documented paths", report.extraPaths);
168
+ printSection("Changed paths", report.changedPaths);
169
+ printSection("Nested documentation boundaries", report.nestedBoundaries);
170
+ printSection("Shadowed child-owned paths", report.shadowedPaths);
171
+ printSection("Missing descriptions", report.missingDescriptions);
172
+
173
+ if (!report.hasIssues && report.schemaWarnings.length === 0) {
174
+ console.log("Tree is up to date.");
175
+ return;
176
+ }
177
+
178
+ printSection("Next steps", nextSteps(report));
179
+ if (report.shouldFail) {
180
+ process.exitCode = 1;
181
+ }
182
+ }
183
+
184
+ async function runShow(
185
+ parsed: ParsedArguments,
186
+ service: TreedocsService,
187
+ ): Promise<void> {
188
+ const targetPath = parsed.values[0] ?? ".";
189
+ const output = await service.show(
190
+ parsed.globals.path,
191
+ targetPath,
192
+ !hasFlag(parsed, "no-check"),
193
+ hasFlag(parsed, "full-descriptions") || hasFlag(parsed, "f"),
194
+ );
195
+ console.log(output);
196
+ }
197
+
198
+ async function runLs(
199
+ parsed: ParsedArguments,
200
+ service: TreedocsService,
201
+ ): Promise<void> {
202
+ const output = await service.renderTree(
203
+ parsed.globals.path,
204
+ parsed.values[0],
205
+ hasFlag(parsed, "full-descriptions") || hasFlag(parsed, "f"),
206
+ );
207
+ console.log(output);
208
+ }
209
+
210
+ async function runExplore(
211
+ parsed: ParsedArguments,
212
+ service: TreedocsService,
213
+ ): Promise<void> {
214
+ const output = await service.explore(
215
+ parsed.globals.path,
216
+ parsed.values,
217
+ hasFlag(parsed, "full-descriptions") || hasFlag(parsed, "f"),
218
+ );
219
+ console.log(output);
220
+ }
221
+
222
+ async function runInspect(
223
+ parsed: ParsedArguments,
224
+ service: TreedocsService,
225
+ ): Promise<void> {
226
+ const targetPath = requiredValue(parsed, "inspect requires a path.");
227
+ const report = await service.inspect(
228
+ parsed.globals.path,
229
+ targetPath,
230
+ hasFlag(parsed, "recursive"),
231
+ );
232
+
233
+ console.log(`Path: ${report.path}`);
234
+ console.log(`Type: ${report.entry.isDirectory ? "directory" : "file"}`);
235
+ console.log(`Description: ${entryDescription(report.entry) ?? "<missing>"}`);
236
+ const references = entryReferences(report.entry);
237
+ if (references.length > 0) {
238
+ console.log("References:");
239
+ for (const reference of references) {
240
+ console.log(`- ${reference}`);
241
+ }
242
+ }
243
+
244
+ switch (report.linkResolution.type) {
245
+ case "external":
246
+ console.log(`Link: external -> ${report.linkResolution.url}`);
247
+ break;
248
+ case "resolved":
249
+ console.log(`Link: ${report.linkResolution.chain.join(" -> ")}`);
250
+ console.log(`Resolved: ${report.linkResolution.path}`);
251
+ break;
252
+ case "broken":
253
+ console.log(`Link: ${report.linkResolution.chain.join(" -> ")}`);
254
+ console.log(`Broken target: ${report.linkResolution.target}`);
255
+ break;
256
+ case "cycle":
257
+ console.log(`Link cycle: ${report.linkResolution.chain.join(" -> ")}`);
258
+ break;
259
+ case "none":
260
+ break;
261
+ }
262
+
263
+ if (report.recursiveOutput) {
264
+ console.log("Tree:");
265
+ console.log(report.recursiveOutput);
266
+ }
267
+ }
268
+
269
+ async function runUpdate(
270
+ parsed: ParsedArguments,
271
+ service: TreedocsService,
272
+ ): Promise<void> {
273
+ const targetPath = requiredValue(parsed, "update requires a path.");
274
+ const description = parsed.values[1];
275
+ const addReferences = valuesFor(parsed, "add-reference");
276
+ const removeReferences = valuesFor(parsed, "remove-reference");
277
+ const link = valueFor(parsed, "link");
278
+ const clearLink = hasFlag(parsed, "clear-link");
279
+
280
+ if (
281
+ description === undefined &&
282
+ addReferences.length === 0 &&
283
+ removeReferences.length === 0 &&
284
+ link === undefined &&
285
+ !clearLink
286
+ ) {
287
+ throw new TreedocsError(
288
+ "No update requested. Provide a description or one of the reference/link flags.",
289
+ );
290
+ }
291
+
292
+ const file = await service.update(parsed.globals.path, {
293
+ path: targetPath,
294
+ description,
295
+ addReferences,
296
+ removeReferences,
297
+ link,
298
+ clearLink,
299
+ });
300
+ console.log(`Updated ${targetPath} (${file.signature ?? "no signature"})`);
301
+ }
302
+
303
+ async function runPath(
304
+ parsed: ParsedArguments,
305
+ service: TreedocsService,
306
+ ): Promise<void> {
307
+ const query = requiredValue(parsed, "path requires a query.");
308
+ const match = await service.findPath(parsed.globals.path, query);
309
+ if (!match) {
310
+ process.exitCode = 1;
311
+ return;
312
+ }
313
+ console.log(match);
314
+ }
315
+
316
+ export function rewriteArguments(argumentsToRewrite: string[]): string[] {
317
+ if (argumentsToRewrite.length === 0) {
318
+ return ["show", "."];
319
+ }
320
+
321
+ const pathIndex = firstPathArgumentIndex(argumentsToRewrite);
322
+ if (pathIndex === undefined) {
323
+ return argumentsToRewrite;
324
+ }
325
+
326
+ const rewritten = [...argumentsToRewrite];
327
+ rewritten.splice(pathIndex, 0, "show");
328
+ return rewritten;
329
+ }
330
+
331
+ function firstPathArgumentIndex(argumentsToScan: string[]): number | undefined {
332
+ let index = 0;
333
+ while (index < argumentsToScan.length) {
334
+ const argument = argumentsToScan[index];
335
+ if (commands.has(argument) || argument === "--help" || argument === "-h") {
336
+ return undefined;
337
+ }
338
+ if (argument === "--path" || argument === "-p") {
339
+ index += 2;
340
+ continue;
341
+ }
342
+ if (argument.startsWith("--path=")) {
343
+ index += 1;
344
+ continue;
345
+ }
346
+ if (argument.startsWith("-")) {
347
+ return undefined;
348
+ }
349
+ return index;
350
+ }
351
+ return undefined;
352
+ }
353
+
354
+ function parseArguments(argumentsToParse: string[]): ParsedArguments {
355
+ const globals: GlobalOptions = { path: ".", nonInteractive: false };
356
+ let commandIndex = 0;
357
+ while (commandIndex < argumentsToParse.length) {
358
+ const argument = argumentsToParse[commandIndex];
359
+ if (argument === "--path" || argument === "-p") {
360
+ globals.path = argumentsToParse[commandIndex + 1] ?? ".";
361
+ commandIndex += 2;
362
+ continue;
363
+ }
364
+ if (argument.startsWith("--path=")) {
365
+ globals.path = argument.slice("--path=".length);
366
+ commandIndex += 1;
367
+ continue;
368
+ }
369
+ if (argument === "--non-interactive" || argument === "-n") {
370
+ globals.nonInteractive = true;
371
+ commandIndex += 1;
372
+ continue;
373
+ }
374
+ break;
375
+ }
376
+
377
+ const command = argumentsToParse[commandIndex];
378
+ const rest = argumentsToParse.slice(commandIndex + 1);
379
+ if (!command) {
380
+ return {
381
+ command: "show",
382
+ values: ["."],
383
+ options: new Map(),
384
+ globals,
385
+ };
386
+ }
387
+
388
+ const options = new Map<string, string[]>();
389
+ const values: string[] = [];
390
+
391
+ for (let index = 0; index < rest.length; index += 1) {
392
+ const argument = rest[index];
393
+ if (argument === "--path" || argument === "-p") {
394
+ globals.path = rest[index + 1] ?? ".";
395
+ index += 1;
396
+ continue;
397
+ }
398
+ if (argument.startsWith("--path=")) {
399
+ globals.path = argument.slice("--path=".length);
400
+ continue;
401
+ }
402
+ if (argument === "--non-interactive" || argument === "-n") {
403
+ globals.nonInteractive = true;
404
+ continue;
405
+ }
406
+ if (argument === "-f") {
407
+ addOptionValue(options, "f", "true");
408
+ continue;
409
+ }
410
+ if (argument.startsWith("--")) {
411
+ const [optionName, inlineValue] = splitLongOption(argument);
412
+ if (isValueOption(optionName)) {
413
+ const consumed = collectOptionValues(rest, index, inlineValue);
414
+ for (const value of consumed.values) {
415
+ addOptionValue(options, optionName, value);
416
+ }
417
+ index = consumed.nextIndex;
418
+ continue;
419
+ }
420
+ addOptionValue(options, optionName, inlineValue ?? "true");
421
+ continue;
422
+ }
423
+ if (argument.startsWith("-") && argument.length > 1) {
424
+ for (const flag of argument.slice(1)) {
425
+ addOptionValue(options, flag, "true");
426
+ }
427
+ continue;
428
+ }
429
+ values.push(argument);
430
+ }
431
+
432
+ return { command, values, options, globals };
433
+ }
434
+
435
+ function splitLongOption(argument: string): [string, string | undefined] {
436
+ const withoutPrefix = argument.slice(2);
437
+ const equalsIndex = withoutPrefix.indexOf("=");
438
+ if (equalsIndex === -1) {
439
+ return [withoutPrefix, undefined];
440
+ }
441
+ return [
442
+ withoutPrefix.slice(0, equalsIndex),
443
+ withoutPrefix.slice(equalsIndex + 1),
444
+ ];
445
+ }
446
+
447
+ function isValueOption(optionName: string): boolean {
448
+ return ["add-reference", "remove-reference", "link"].includes(optionName);
449
+ }
450
+
451
+ function collectOptionValues(
452
+ argumentsToParse: string[],
453
+ optionIndex: number,
454
+ inlineValue: string | undefined,
455
+ ): { values: string[]; nextIndex: number } {
456
+ if (inlineValue !== undefined) {
457
+ return { values: [inlineValue], nextIndex: optionIndex };
458
+ }
459
+
460
+ const values: string[] = [];
461
+ let index = optionIndex + 1;
462
+ while (
463
+ index < argumentsToParse.length &&
464
+ !argumentsToParse[index].startsWith("-")
465
+ ) {
466
+ values.push(argumentsToParse[index]);
467
+ index += 1;
468
+ }
469
+ return { values, nextIndex: index - 1 };
470
+ }
471
+
472
+ function addOptionValue(
473
+ options: Map<string, string[]>,
474
+ name: string,
475
+ value: string,
476
+ ): void {
477
+ const values = options.get(name) ?? [];
478
+ values.push(value);
479
+ options.set(name, values);
480
+ }
481
+
482
+ function hasFlag(parsed: ParsedArguments, name: string): boolean {
483
+ return parsed.options.has(name);
484
+ }
485
+
486
+ function valuesFor(parsed: ParsedArguments, name: string): string[] {
487
+ return parsed.options.get(name) ?? [];
488
+ }
489
+
490
+ function valueFor(parsed: ParsedArguments, name: string): string | undefined {
491
+ return valuesFor(parsed, name).at(-1);
492
+ }
493
+
494
+ function requiredValue(parsed: ParsedArguments, message: string): string {
495
+ const value = parsed.values[0];
496
+ if (!value) {
497
+ throw new TreedocsError(message);
498
+ }
499
+ return value;
500
+ }
501
+
502
+ function printSection(title: string, values: string[]): void {
503
+ if (values.length === 0) {
504
+ return;
505
+ }
506
+ console.log(`${title}:`);
507
+ for (const value of [...values].sort()) {
508
+ console.log(`- ${value}`);
509
+ }
510
+ }
511
+
512
+ function nextSteps(report: CheckReport): string[] {
513
+ if (!report.shouldFail) {
514
+ return [];
515
+ }
516
+
517
+ const steps: string[] = [];
518
+ if (
519
+ report.schemaErrors.length > 0 ||
520
+ report.hasSignatureDrift ||
521
+ report.missingPaths.length > 0 ||
522
+ report.extraPaths.length > 0 ||
523
+ report.changedPaths.length > 0 ||
524
+ report.shadowedPaths.length > 0
525
+ ) {
526
+ steps.push(
527
+ "Run `treedocs sync` to reconcile filesystem changes and refresh the stored signature.",
528
+ );
529
+ }
530
+ if (report.shadowedPaths.length > 0) {
531
+ steps.push(
532
+ "Nested `treedocs.yaml` files own their descendants; `treedocs sync` keeps only delegated boundary folders in the parent tree.",
533
+ );
534
+ }
535
+ if (report.missingDescriptions.length > 0) {
536
+ steps.push(missingDescriptionNextStep);
537
+ }
538
+ return steps;
539
+ }
540
+
541
+ function changeSummaryMessages(changes: SyncChanges, pathLimit = 5): string[] {
542
+ const messages = ["Changes found:"];
543
+ messages.push(...summaryLines("+", "Added", changes.addedPaths, pathLimit));
544
+ messages.push(
545
+ ...summaryLines("-", "Removed", changes.removedPaths, pathLimit),
546
+ );
547
+ messages.push(
548
+ ...summaryLines("-", "Changed type", changes.changedTypePaths, pathLimit),
549
+ );
550
+ return messages.length === 1 ? [] : messages;
551
+ }
552
+
553
+ function summaryLines(
554
+ marker: string,
555
+ title: string,
556
+ paths: string[],
557
+ pathLimit: number,
558
+ ): string[] {
559
+ if (paths.length === 0) {
560
+ return [];
561
+ }
562
+ const shownPaths = paths.slice(0, pathLimit).join(", ");
563
+ const remainingCount = paths.length - Math.min(paths.length, pathLimit);
564
+ const suffix = remainingCount > 0 ? `, +${remainingCount} more` : "";
565
+ return [`${marker} ${title}: ${paths.length} (${shownPaths}${suffix})`];
566
+ }
567
+
568
+ function remainingIssueMessages(missingDescriptions: string[]): string[] {
569
+ if (missingDescriptions.length === 0) {
570
+ return [];
571
+ }
572
+ return [
573
+ "Missing descriptions:",
574
+ ...missingDescriptions.sort().map((path) => `- ${path}`),
575
+ "Next steps:",
576
+ `- ${missingDescriptionNextStep}`,
577
+ ];
578
+ }
579
+
580
+ function printRootHelp(): void {
581
+ console.log(`treedocs ${cliVersion}`);
582
+ console.log(
583
+ "Generate and maintain a YAML documentation tree for a repository.",
584
+ );
585
+ console.log("");
586
+ console.log("Usage:");
587
+ console.log(" treedocs <command> [options]");
588
+ console.log(" treedocs <path>");
589
+ console.log("");
590
+ console.log("Commands:");
591
+ console.log(" init Scan the repository and create treedocs.yaml");
592
+ console.log(" sync Reconcile treedocs.yaml with the filesystem");
593
+ console.log(
594
+ " check Report drift, schema issues, and missing descriptions",
595
+ );
596
+ console.log(" show Render a documented path with inline descriptions");
597
+ console.log(" ls Render the full tree or a subtree");
598
+ console.log(
599
+ " explore Render a shallow tree with selected paths expanded",
600
+ );
601
+ console.log(" inspect Show details for one documented path");
602
+ console.log(" update Update a description, references, or link");
603
+ console.log(" path Return the first documented path matching a query");
604
+ console.log("");
605
+ console.log("Global Options:");
606
+ console.log(" -p, --path <path> Repository root");
607
+ console.log(" -n, --non-interactive Accepted for script compatibility");
608
+ console.log(" --version Show version");
609
+ console.log(" -h, --help Show help");
610
+ }
611
+
612
+ function printCommandHelp(command: string): void {
613
+ switch (command) {
614
+ case "init":
615
+ console.log("Usage: treedocs init [--force] [-p <path>]");
616
+ break;
617
+ case "sync":
618
+ console.log("Usage: treedocs sync [-n] [-p <path>]");
619
+ break;
620
+ case "check":
621
+ console.log("Usage: treedocs check [-p <path>]");
622
+ break;
623
+ case "show":
624
+ console.log("Usage: treedocs show <path> [--no-check] [-f]");
625
+ break;
626
+ case "ls":
627
+ console.log("Usage: treedocs ls [path] [-f]");
628
+ break;
629
+ case "explore":
630
+ console.log("Usage: treedocs explore [path ...] [-f]");
631
+ break;
632
+ case "inspect":
633
+ console.log("Usage: treedocs inspect <path> [--recursive]");
634
+ break;
635
+ case "update":
636
+ console.log(
637
+ "Usage: treedocs update <path> [description] [--add-reference <ref>...] [--remove-reference <ref>...] [--link <target>] [--clear-link]",
638
+ );
639
+ break;
640
+ case "path":
641
+ console.log("Usage: treedocs path <query>");
642
+ break;
643
+ default:
644
+ printRootHelp();
645
+ }
646
+ }
647
+
648
+ if (import.meta.main) {
649
+ process.exit(await main());
650
+ }