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/core.ts ADDED
@@ -0,0 +1,2051 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import {
4
+ access,
5
+ lstat,
6
+ mkdir,
7
+ readdir,
8
+ readFile,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { basename, dirname, join, resolve } from "node:path";
12
+ import Ajv2020 from "ajv/dist/2020";
13
+ import addFormats from "ajv-formats";
14
+ import { parse, stringify } from "yaml";
15
+
16
+ export const cliVersion = "0.3.0";
17
+ export const currentSchemaVersion = "0.2.0";
18
+ export const schemaUrl =
19
+ "https://dandylyons.github.io/treedocs/schemas/0.2.0/treedocs.schema.json";
20
+ export const stateFileName = "treedocs.yaml";
21
+
22
+ export class TreedocsError extends Error {
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = "TreedocsError";
26
+ }
27
+ }
28
+
29
+ export type CheckSeverity = "error" | "warn";
30
+
31
+ export interface ProjectMetadata {
32
+ name?: string;
33
+ version?: string;
34
+ last_updated?: string;
35
+ [key: string]: string | undefined;
36
+ }
37
+
38
+ export interface TreedocsConfig {
39
+ exclude?: string[];
40
+ use_gitignore?: boolean;
41
+ max_description_length?: number;
42
+ indent_size?: number;
43
+ align_columns?: boolean;
44
+ check_severity?: CheckSeverity;
45
+ auto_init_empty?: boolean;
46
+ theme?: string;
47
+ icons?: boolean;
48
+ ai_provider?: string;
49
+ ai_model?: string;
50
+ }
51
+
52
+ export interface ResolvedTreedocsConfig {
53
+ exclude: string[];
54
+ use_gitignore: boolean;
55
+ max_description_length: number;
56
+ indent_size: number;
57
+ align_columns: boolean;
58
+ check_severity: CheckSeverity;
59
+ auto_init_empty: boolean;
60
+ theme?: string;
61
+ icons: boolean;
62
+ ai_provider?: string;
63
+ ai_model?: string;
64
+ }
65
+
66
+ export interface EntryDocumentation {
67
+ description?: string;
68
+ references: string[];
69
+ }
70
+
71
+ export interface TreeEntry {
72
+ documentation?: EntryDocumentation;
73
+ link?: string;
74
+ children: Tree;
75
+ isDirectory: boolean;
76
+ }
77
+
78
+ export type Tree = Record<string, TreeEntry>;
79
+
80
+ export interface TreedocsFile {
81
+ schemaVersion: string;
82
+ project: ProjectMetadata;
83
+ overrides?: TreedocsConfig;
84
+ signature?: string;
85
+ tree: Tree;
86
+ }
87
+
88
+ export interface TreeScanResult {
89
+ tree: Tree;
90
+ normalizedPaths: string[];
91
+ nestedBoundaries: string[];
92
+ signature: string;
93
+ }
94
+
95
+ export interface SyncChanges {
96
+ addedPaths: string[];
97
+ removedPaths: string[];
98
+ changedTypePaths: string[];
99
+ }
100
+
101
+ export interface SyncResult {
102
+ file: TreedocsFile;
103
+ saved: boolean;
104
+ signatureUnchanged: boolean;
105
+ changes: SyncChanges;
106
+ missingDescriptions: string[];
107
+ }
108
+
109
+ export interface CheckReport {
110
+ schemaWarnings: string[];
111
+ schemaErrors: string[];
112
+ storedSignature?: string;
113
+ currentSignature: string;
114
+ missingDescriptions: string[];
115
+ missingPaths: string[];
116
+ extraPaths: string[];
117
+ changedPaths: string[];
118
+ nestedBoundaries: string[];
119
+ shadowedPaths: string[];
120
+ severity: CheckSeverity;
121
+ hasSignatureDrift: boolean;
122
+ hasIssues: boolean;
123
+ shouldFail: boolean;
124
+ requiresSyncBeforeFill: boolean;
125
+ }
126
+
127
+ export interface InspectReport {
128
+ path: string;
129
+ entry: TreeEntry;
130
+ linkResolution: LinkResolution;
131
+ recursiveOutput?: string;
132
+ }
133
+
134
+ export type LinkResolution =
135
+ | { type: "none" }
136
+ | { type: "external"; url: string }
137
+ | { type: "resolved"; path: string; chain: string[]; entry: TreeEntry }
138
+ | { type: "broken"; target: string; chain: string[] }
139
+ | { type: "cycle"; chain: string[] };
140
+
141
+ interface RepositoryPaths {
142
+ root: string;
143
+ stateFile: string;
144
+ projectConfig: string;
145
+ projectIgnore: string;
146
+ gitignore: string;
147
+ }
148
+
149
+ interface LoadedConfiguration {
150
+ config: ResolvedTreedocsConfig;
151
+ ignorePatterns: string[];
152
+ }
153
+
154
+ type EntryStatus = "clean" | "warning" | "error";
155
+
156
+ const defaultConfig: ResolvedTreedocsConfig = {
157
+ exclude: [],
158
+ use_gitignore: true,
159
+ max_description_length: 120,
160
+ indent_size: 2,
161
+ align_columns: false,
162
+ check_severity: "error",
163
+ auto_init_empty: false,
164
+ icons: false,
165
+ };
166
+
167
+ const standardExcludedNames = new Set([
168
+ ".git",
169
+ ".build",
170
+ ".swiftpm",
171
+ ".treedocs",
172
+ ".agents",
173
+ ".opencode",
174
+ "node_modules",
175
+ stateFileName,
176
+ ]);
177
+
178
+ export const missingDescriptionNextStep =
179
+ 'Add missing descriptions with `treedocs update <path> "..."` or by editing `treedocs.yaml`, then run `treedocs check`.';
180
+
181
+ export function normalizeRelativePath(raw: string): string {
182
+ return normalizePathComponents(raw.replaceAll("\\", "/").split("/")).join(
183
+ "/",
184
+ );
185
+ }
186
+
187
+ export function relativePathComponents(raw: string): string[] {
188
+ return normalizePathComponents(raw.replaceAll("\\", "/").split("/"));
189
+ }
190
+
191
+ export function resolveLinkTarget(raw: string, currentPath: string): string {
192
+ if (raw.startsWith("./") || raw.startsWith("../")) {
193
+ const baseComponents = relativePathComponents(currentPath).slice(0, -1);
194
+ return normalizePathComponents([
195
+ ...baseComponents,
196
+ ...raw.replaceAll("\\", "/").split("/"),
197
+ ]).join("/");
198
+ }
199
+
200
+ return normalizeRelativePath(raw);
201
+ }
202
+
203
+ function normalizePathComponents(rawComponents: string[]): string[] {
204
+ const result: string[] = [];
205
+ for (const component of rawComponents) {
206
+ if (component === "" || component === ".") {
207
+ continue;
208
+ }
209
+ if (component === "..") {
210
+ if (result.length > 0) {
211
+ result.pop();
212
+ }
213
+ continue;
214
+ }
215
+ result.push(component);
216
+ }
217
+ return result;
218
+ }
219
+
220
+ function trimmedNilIfEmpty(value: string | undefined): string | undefined {
221
+ if (value === undefined) {
222
+ return undefined;
223
+ }
224
+ const trimmed = value.trim();
225
+ return trimmed.length === 0 ? undefined : trimmed;
226
+ }
227
+
228
+ function parseString(value: unknown): string | undefined {
229
+ switch (typeof value) {
230
+ case "string":
231
+ return value;
232
+ case "number":
233
+ case "boolean":
234
+ case "bigint":
235
+ return String(value);
236
+ default:
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ function parseBoolean(value: unknown): boolean | undefined {
242
+ if (typeof value === "boolean") {
243
+ return value;
244
+ }
245
+ if (typeof value === "number") {
246
+ return value !== 0;
247
+ }
248
+ if (typeof value !== "string") {
249
+ return undefined;
250
+ }
251
+
252
+ switch (value.toLowerCase()) {
253
+ case "true":
254
+ case "yes":
255
+ case "1":
256
+ return true;
257
+ case "false":
258
+ case "no":
259
+ case "0":
260
+ return false;
261
+ default:
262
+ return undefined;
263
+ }
264
+ }
265
+
266
+ function parseInteger(value: unknown): number | undefined {
267
+ if (typeof value === "number" && Number.isInteger(value)) {
268
+ return value;
269
+ }
270
+ if (typeof value === "string" && /^-?\d+$/.test(value)) {
271
+ return Number.parseInt(value, 10);
272
+ }
273
+ return undefined;
274
+ }
275
+
276
+ function parseStringArray(value: unknown): string[] | undefined {
277
+ if (!Array.isArray(value)) {
278
+ return undefined;
279
+ }
280
+ return value.flatMap((item) => {
281
+ const parsed = parseString(item);
282
+ return parsed === undefined ? [] : [parsed];
283
+ });
284
+ }
285
+
286
+ function parseSeverity(value: unknown): CheckSeverity | undefined {
287
+ const parsed = parseString(value)?.toLowerCase();
288
+ return parsed === "error" || parsed === "warn" ? parsed : undefined;
289
+ }
290
+
291
+ function isRecord(value: unknown): value is Record<string, unknown> {
292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
293
+ }
294
+
295
+ function documentationIsEmpty(documentation: EntryDocumentation | undefined) {
296
+ return (
297
+ documentation === undefined ||
298
+ (documentation.description === undefined &&
299
+ documentation.references.length === 0)
300
+ );
301
+ }
302
+
303
+ export function makeDocumentation(
304
+ description?: string,
305
+ references: string[] = [],
306
+ ): EntryDocumentation | undefined {
307
+ if (description === undefined && references.length === 0) {
308
+ return undefined;
309
+ }
310
+ return {
311
+ description: description === undefined ? undefined : description.trim(),
312
+ references: [...references],
313
+ };
314
+ }
315
+
316
+ export function makeTreeEntry(options: {
317
+ description?: string;
318
+ documentation?: EntryDocumentation;
319
+ references?: string[];
320
+ link?: string;
321
+ children?: Tree;
322
+ isDirectory?: boolean;
323
+ }): TreeEntry {
324
+ const documentation =
325
+ options.documentation ??
326
+ makeDocumentation(options.description, options.references ?? []);
327
+ const normalizedLink = trimmedNilIfEmpty(options.link);
328
+ return {
329
+ documentation: documentationIsEmpty(documentation)
330
+ ? undefined
331
+ : documentation,
332
+ link: normalizedLink,
333
+ children: options.children ?? {},
334
+ isDirectory: options.isDirectory ?? false,
335
+ };
336
+ }
337
+
338
+ export function entryDescription(entry: TreeEntry): string | undefined {
339
+ return entry.documentation?.description;
340
+ }
341
+
342
+ export function entryReferences(entry: TreeEntry): string[] {
343
+ return entry.documentation?.references ?? [];
344
+ }
345
+
346
+ export function entryNeedsDescription(entry: TreeEntry): boolean {
347
+ const description = entryDescription(entry);
348
+ return (description === undefined || description.length === 0) && !entry.link;
349
+ }
350
+
351
+ function entryHasReferences(entry: TreeEntry): boolean {
352
+ return entryReferences(entry).length > 0;
353
+ }
354
+
355
+ export function insertEntry(
356
+ entry: TreeEntry,
357
+ components: string[],
358
+ tree: Tree,
359
+ ) {
360
+ const [first, ...remaining] = components;
361
+ if (first === undefined) {
362
+ return;
363
+ }
364
+
365
+ if (remaining.length === 0) {
366
+ const existing = tree[first];
367
+ if (existing) {
368
+ existing.documentation = entry.documentation ?? existing.documentation;
369
+ existing.link = entry.link ?? existing.link;
370
+ existing.isDirectory = existing.isDirectory || entry.isDirectory;
371
+ for (const [key, value] of Object.entries(entry.children)) {
372
+ existing.children[key] = value;
373
+ }
374
+ return;
375
+ }
376
+ tree[first] = entry;
377
+ return;
378
+ }
379
+
380
+ const parent =
381
+ tree[first] ?? makeTreeEntry({ children: {}, isDirectory: true });
382
+ parent.isDirectory = true;
383
+ insertEntry(entry, remaining, parent.children);
384
+ tree[first] = parent;
385
+ }
386
+
387
+ export function entryAt(
388
+ relativePath: string,
389
+ tree: Tree,
390
+ ): TreeEntry | undefined {
391
+ const components = relativePathComponents(relativePath);
392
+ if (components.length === 0) {
393
+ return makeTreeEntry({ children: tree, isDirectory: true });
394
+ }
395
+
396
+ let currentTree = tree;
397
+ let currentEntry: TreeEntry | undefined;
398
+ for (const component of components) {
399
+ const nextEntry = currentTree[component];
400
+ if (!nextEntry) {
401
+ return undefined;
402
+ }
403
+ currentEntry = nextEntry;
404
+ currentTree = nextEntry.children;
405
+ }
406
+ return currentEntry;
407
+ }
408
+
409
+ export function updateEntry(
410
+ relativePath: string,
411
+ tree: Tree,
412
+ mutate: (entry: TreeEntry) => void,
413
+ ): boolean {
414
+ const components = relativePathComponents(relativePath);
415
+ const [first, ...remaining] = components;
416
+ if (first === undefined) {
417
+ return false;
418
+ }
419
+
420
+ const entry = tree[first];
421
+ if (!entry) {
422
+ return false;
423
+ }
424
+
425
+ if (remaining.length === 0) {
426
+ mutate(entry);
427
+ return true;
428
+ }
429
+
430
+ return updateEntry(remaining.join("/"), entry.children, mutate);
431
+ }
432
+
433
+ export function flattenTree(
434
+ tree: Tree,
435
+ prefix = "",
436
+ ): Array<[string, TreeEntry]> {
437
+ const result: Array<[string, TreeEntry]> = [];
438
+ for (const key of Object.keys(tree).sort()) {
439
+ const entry = tree[key];
440
+ if (!entry) {
441
+ continue;
442
+ }
443
+ const path = prefix ? `${prefix}/${key}` : key;
444
+ result.push([path, entry]);
445
+ if (entry.isDirectory) {
446
+ result.push(...flattenTree(entry.children, path));
447
+ }
448
+ }
449
+ return result;
450
+ }
451
+
452
+ export function collectNormalizedPaths(
453
+ tree: Tree,
454
+ prefix = "",
455
+ result: string[] = [],
456
+ ): string[] {
457
+ for (const key of Object.keys(tree).sort()) {
458
+ const entry = tree[key];
459
+ if (!entry) {
460
+ continue;
461
+ }
462
+ const path = prefix ? `${prefix}/${key}` : key;
463
+ result.push(entry.isDirectory ? `${path}/` : path);
464
+ if (entry.isDirectory) {
465
+ collectNormalizedPaths(entry.children, path, result);
466
+ }
467
+ }
468
+ return result;
469
+ }
470
+
471
+ export function treeSignature(normalizedPaths: string[]): string {
472
+ const payload = [...normalizedPaths].sort().join("\n");
473
+ return `sha256:${createHash("sha256").update(payload).digest("hex")}`;
474
+ }
475
+
476
+ export function missingDescriptionPaths(tree: Tree): string[] {
477
+ return flattenTree(tree)
478
+ .filter(([, entry]) => entryNeedsDescription(entry))
479
+ .map(([path]) => path);
480
+ }
481
+
482
+ export function missingPaths(stored: Tree, scanned: Tree): string[] {
483
+ return sortedDifference(pathSet(scanned), pathSet(stored));
484
+ }
485
+
486
+ export function extraPaths(stored: Tree, scanned: Tree): string[] {
487
+ return sortedDifference(pathSet(stored), pathSet(scanned));
488
+ }
489
+
490
+ export function changedPaths(stored: Tree, scanned: Tree): string[] {
491
+ const storedEntries = entryMap(stored);
492
+ const scannedEntries = entryMap(scanned);
493
+ return [...storedEntries.keys()]
494
+ .filter((path) => {
495
+ const storedEntry = storedEntries.get(path);
496
+ const scannedEntry = scannedEntries.get(path);
497
+ return (
498
+ storedEntry !== undefined &&
499
+ scannedEntry !== undefined &&
500
+ storedEntry.isDirectory !== scannedEntry.isDirectory
501
+ );
502
+ })
503
+ .sort();
504
+ }
505
+
506
+ export function shadowedPaths(
507
+ stored: Tree,
508
+ nestedBoundaries: string[],
509
+ ): string[] {
510
+ return [...pathSet(stored)]
511
+ .filter((path) =>
512
+ nestedBoundaries.some((boundary) => path.startsWith(`${boundary}/`)),
513
+ )
514
+ .sort();
515
+ }
516
+
517
+ export function mergePreservingMetadata(scanned: Tree, existing: Tree): Tree {
518
+ const merged: Tree = {};
519
+ for (const key of Object.keys(scanned).sort()) {
520
+ const scannedEntry = scanned[key];
521
+ if (!scannedEntry) {
522
+ continue;
523
+ }
524
+ merged[key] = mergeEntry(scannedEntry, existing[key]);
525
+ }
526
+ return merged;
527
+ }
528
+
529
+ export function applyDescriptions(
530
+ descriptions: Record<string, string>,
531
+ tree: Tree,
532
+ ): string[] {
533
+ const updatedPaths: string[] = [];
534
+ for (const path of Object.keys(descriptions).sort()) {
535
+ const description = trimmedNilIfEmpty(descriptions[path]);
536
+ if (description === undefined) {
537
+ continue;
538
+ }
539
+ if (
540
+ updateEntry(path, tree, (entry) => {
541
+ entry.documentation = makeDocumentation(
542
+ description,
543
+ entryReferences(entry),
544
+ );
545
+ })
546
+ ) {
547
+ updatedPaths.push(path);
548
+ }
549
+ }
550
+ return updatedPaths;
551
+ }
552
+
553
+ export function firstPathMatching(
554
+ query: string,
555
+ tree: Tree,
556
+ ): string | undefined {
557
+ const normalizedQuery = query.toLowerCase();
558
+ const entries = flattenTree(tree).sort(([left], [right]) =>
559
+ left.localeCompare(right),
560
+ );
561
+ const allPaths = entries.map(([path]) => path);
562
+
563
+ return (
564
+ allPaths.find((path) => path.toLowerCase() === normalizedQuery) ??
565
+ allPaths.find((path) => path.toLowerCase().startsWith(normalizedQuery)) ??
566
+ allPaths.find((path) => path.toLowerCase().includes(normalizedQuery)) ??
567
+ entries.find(([, entry]) =>
568
+ entryDescription(entry)?.toLowerCase().includes(normalizedQuery),
569
+ )?.[0]
570
+ );
571
+ }
572
+
573
+ function mergeEntry(
574
+ scanned: TreeEntry,
575
+ existing: TreeEntry | undefined,
576
+ ): TreeEntry {
577
+ const merged = makeTreeEntry({
578
+ documentation: scanned.documentation,
579
+ link: scanned.link,
580
+ children: { ...scanned.children },
581
+ isDirectory: scanned.isDirectory,
582
+ });
583
+
584
+ if (existing && existing.isDirectory === scanned.isDirectory) {
585
+ merged.documentation = existing.documentation;
586
+ merged.link = existing.link;
587
+ if (scanned.isDirectory) {
588
+ merged.children = mergePreservingMetadata(
589
+ scanned.children,
590
+ existing.children,
591
+ );
592
+ }
593
+ }
594
+
595
+ return merged;
596
+ }
597
+
598
+ function pathSet(tree: Tree): Set<string> {
599
+ return new Set(flattenTree(tree).map(([path]) => path));
600
+ }
601
+
602
+ function entryMap(tree: Tree): Map<string, TreeEntry> {
603
+ return new Map(flattenTree(tree));
604
+ }
605
+
606
+ function sortedDifference(left: Set<string>, right: Set<string>): string[] {
607
+ return [...left].filter((path) => !right.has(path)).sort();
608
+ }
609
+
610
+ export function treeEntryFromYaml(value: unknown): TreeEntry {
611
+ if (typeof value === "string") {
612
+ return makeTreeEntry({ description: value });
613
+ }
614
+ if (typeof value === "number" || typeof value === "boolean") {
615
+ return makeTreeEntry({ description: String(value) });
616
+ }
617
+ if (!isRecord(value)) {
618
+ throw new TreedocsError("Invalid tree entry in treedocs.yaml.");
619
+ }
620
+
621
+ const reservedLeafKeys = new Set(["_description", "_references", "_link"]);
622
+ const hasDirectoryMarker = Object.hasOwn(value, "_doc");
623
+ const childKeys = Object.keys(value).filter(
624
+ (key) => !reservedLeafKeys.has(key) && key !== "_doc",
625
+ );
626
+ const isDirectory = hasDirectoryMarker || childKeys.length > 0;
627
+
628
+ if (isDirectory) {
629
+ const children: Tree = {};
630
+ for (const childKey of childKeys) {
631
+ insertEntry(
632
+ treeEntryFromYaml(value[childKey]),
633
+ relativePathComponents(childKey),
634
+ children,
635
+ );
636
+ }
637
+ return makeTreeEntry({
638
+ documentation: documentationFromYaml(value._doc),
639
+ link: parseString(value._link),
640
+ children,
641
+ isDirectory: true,
642
+ });
643
+ }
644
+
645
+ return makeTreeEntry({
646
+ documentation: documentationFromYaml(value),
647
+ link: parseString(value._link),
648
+ });
649
+ }
650
+
651
+ export function documentationFromYaml(
652
+ value: unknown,
653
+ ): EntryDocumentation | undefined {
654
+ if (value === undefined || value === null) {
655
+ return undefined;
656
+ }
657
+ if (
658
+ typeof value === "string" ||
659
+ typeof value === "number" ||
660
+ typeof value === "boolean"
661
+ ) {
662
+ return makeDocumentation(String(value));
663
+ }
664
+ if (!isRecord(value)) {
665
+ throw new TreedocsError("Invalid documentation entry in treedocs.yaml.");
666
+ }
667
+ return makeDocumentation(
668
+ parseString(value._description),
669
+ parseStringArray(value._references) ?? [],
670
+ );
671
+ }
672
+
673
+ export function treeEntryToYamlValue(entry: TreeEntry): unknown {
674
+ if (entry.isDirectory) {
675
+ const mapping: Record<string, unknown> = {};
676
+ if (!documentationIsEmpty(entry.documentation)) {
677
+ mapping._doc = documentationToYamlValue(entry.documentation);
678
+ }
679
+ if (entry.link) {
680
+ mapping._link = entry.link;
681
+ }
682
+ for (const key of Object.keys(entry.children).sort()) {
683
+ mapping[key] = treeEntryToYamlValue(entry.children[key]);
684
+ }
685
+ return mapping;
686
+ }
687
+
688
+ if (!entry.link && entryReferences(entry).length === 0) {
689
+ const description = entryDescription(entry);
690
+ if (description !== undefined) {
691
+ return description;
692
+ }
693
+ }
694
+
695
+ const mapping: Record<string, unknown> = {};
696
+ const description = entryDescription(entry);
697
+ if (description !== undefined) {
698
+ mapping._description = description;
699
+ }
700
+ const references = entryReferences(entry);
701
+ if (references.length > 0) {
702
+ mapping._references = references;
703
+ }
704
+ if (entry.link) {
705
+ mapping._link = entry.link;
706
+ }
707
+ return mapping;
708
+ }
709
+
710
+ function documentationToYamlValue(
711
+ documentation: EntryDocumentation | undefined,
712
+ ): unknown {
713
+ if (!documentation) {
714
+ return undefined;
715
+ }
716
+ if (
717
+ documentation.references.length === 0 &&
718
+ documentation.description !== undefined
719
+ ) {
720
+ return documentation.description;
721
+ }
722
+ const mapping: Record<string, unknown> = {};
723
+ if (documentation.description !== undefined) {
724
+ mapping._description = documentation.description;
725
+ }
726
+ if (documentation.references.length > 0) {
727
+ mapping._references = documentation.references;
728
+ }
729
+ return mapping;
730
+ }
731
+
732
+ export function treedocsFileFromYaml(yaml: string): TreedocsFile {
733
+ if (yaml.trim().length === 0) {
734
+ return {
735
+ schemaVersion: currentSchemaVersion,
736
+ project: {},
737
+ tree: {},
738
+ };
739
+ }
740
+
741
+ const parsed = parse(yaml) as unknown;
742
+ if (parsed === null || parsed === undefined) {
743
+ return {
744
+ schemaVersion: currentSchemaVersion,
745
+ project: {},
746
+ tree: {},
747
+ };
748
+ }
749
+ if (!isRecord(parsed)) {
750
+ throw new TreedocsError("treedocs.yaml must contain a root mapping.");
751
+ }
752
+
753
+ const tree: Tree = {};
754
+ if (isRecord(parsed.tree)) {
755
+ for (const [key, value] of Object.entries(parsed.tree)) {
756
+ insertEntry(treeEntryFromYaml(value), relativePathComponents(key), tree);
757
+ }
758
+ }
759
+
760
+ return {
761
+ schemaVersion: parseString(parsed.schema_version) ?? currentSchemaVersion,
762
+ project: projectMetadataFromYaml(parsed.project),
763
+ overrides: configFromYaml(parsed.overrides),
764
+ signature: parseString(parsed.signature),
765
+ tree,
766
+ };
767
+ }
768
+
769
+ export function treedocsFileToYaml(file: TreedocsFile): string {
770
+ const root: Record<string, unknown> = {
771
+ schema_version: file.schemaVersion || currentSchemaVersion,
772
+ };
773
+
774
+ if (!projectMetadataIsEmpty(file.project)) {
775
+ root.project = projectMetadataToYamlValue(file.project);
776
+ }
777
+
778
+ const overrides = configToYamlValue(file.overrides);
779
+ if (Object.keys(overrides).length > 0) {
780
+ root.overrides = overrides;
781
+ }
782
+
783
+ if (file.signature) {
784
+ root.signature = file.signature;
785
+ }
786
+
787
+ const tree: Record<string, unknown> = {};
788
+ for (const key of Object.keys(file.tree).sort()) {
789
+ tree[key] = treeEntryToYamlValue(file.tree[key]);
790
+ }
791
+ root.tree = tree;
792
+
793
+ return stringify(root, { indent: 2, lineWidth: 0 });
794
+ }
795
+
796
+ export function serializedTreedocsDocument(file: TreedocsFile): string {
797
+ return `# yaml-language-server: $schema=${schemaUrl}\n${treedocsFileToYaml(file)}`;
798
+ }
799
+
800
+ function projectMetadataFromYaml(value: unknown): ProjectMetadata {
801
+ if (typeof value === "string" && value.trim().length > 0) {
802
+ return { name: value };
803
+ }
804
+ if (!isRecord(value)) {
805
+ return {};
806
+ }
807
+
808
+ const project: ProjectMetadata = {};
809
+ for (const [key, rawValue] of Object.entries(value)) {
810
+ const parsed = parseString(rawValue);
811
+ if (parsed !== undefined) {
812
+ project[key] = parsed;
813
+ }
814
+ }
815
+
816
+ project.name = trimmedNilIfEmpty(project.name);
817
+ project.version = trimmedNilIfEmpty(project.version);
818
+ project.last_updated = trimmedNilIfEmpty(project.last_updated);
819
+ return project;
820
+ }
821
+
822
+ function projectMetadataIsEmpty(project: ProjectMetadata): boolean {
823
+ return Object.values(project).every((value) => value === undefined);
824
+ }
825
+
826
+ function projectMetadataToYamlValue(
827
+ project: ProjectMetadata,
828
+ ): Record<string, string> {
829
+ const mapping: Record<string, string> = {};
830
+ for (const key of ["name", "version", "last_updated"]) {
831
+ const value = project[key];
832
+ if (value !== undefined) {
833
+ mapping[key] = value;
834
+ }
835
+ }
836
+ for (const key of Object.keys(project).sort()) {
837
+ if (key === "name" || key === "version" || key === "last_updated") {
838
+ continue;
839
+ }
840
+ const value = project[key];
841
+ if (value !== undefined) {
842
+ mapping[key] = value;
843
+ }
844
+ }
845
+ return mapping;
846
+ }
847
+
848
+ export function configFromYaml(value: unknown): TreedocsConfig | undefined {
849
+ if (value === undefined || value === null) {
850
+ return undefined;
851
+ }
852
+ if (!isRecord(value)) {
853
+ throw new TreedocsError("Invalid treedocs config: expected a mapping.");
854
+ }
855
+ return {
856
+ exclude: parseStringArray(value.exclude),
857
+ use_gitignore: parseBoolean(value.use_gitignore),
858
+ max_description_length: parseInteger(value.max_description_length),
859
+ indent_size: parseInteger(value.indent_size),
860
+ align_columns: parseBoolean(value.align_columns),
861
+ check_severity: parseSeverity(value.check_severity),
862
+ auto_init_empty: parseBoolean(value.auto_init_empty),
863
+ theme: parseString(value.theme),
864
+ icons: parseBoolean(value.icons),
865
+ ai_provider: parseString(value.ai_provider),
866
+ ai_model: parseString(value.ai_model),
867
+ };
868
+ }
869
+
870
+ export function configToYamlValue(
871
+ config: TreedocsConfig | undefined,
872
+ ): Record<string, unknown> {
873
+ const mapping: Record<string, unknown> = {};
874
+ if (!config) {
875
+ return mapping;
876
+ }
877
+ for (const key of [
878
+ "exclude",
879
+ "use_gitignore",
880
+ "max_description_length",
881
+ "indent_size",
882
+ "align_columns",
883
+ "check_severity",
884
+ "auto_init_empty",
885
+ "theme",
886
+ "icons",
887
+ "ai_provider",
888
+ "ai_model",
889
+ ] as const) {
890
+ const value = config[key];
891
+ if (value !== undefined) {
892
+ mapping[key] = value;
893
+ }
894
+ }
895
+ return mapping;
896
+ }
897
+
898
+ export function mergeConfig(
899
+ base: TreedocsConfig,
900
+ overrides: TreedocsConfig | undefined,
901
+ ): TreedocsConfig {
902
+ return {
903
+ ...base,
904
+ ...Object.fromEntries(
905
+ Object.entries(overrides ?? {}).filter(
906
+ ([, value]) => value !== undefined,
907
+ ),
908
+ ),
909
+ };
910
+ }
911
+
912
+ export function resolveConfig(config: TreedocsConfig): ResolvedTreedocsConfig {
913
+ return {
914
+ exclude: config.exclude ?? defaultConfig.exclude,
915
+ use_gitignore: config.use_gitignore ?? defaultConfig.use_gitignore,
916
+ max_description_length:
917
+ config.max_description_length ?? defaultConfig.max_description_length,
918
+ indent_size: config.indent_size ?? defaultConfig.indent_size,
919
+ align_columns: config.align_columns ?? defaultConfig.align_columns,
920
+ check_severity: config.check_severity ?? defaultConfig.check_severity,
921
+ auto_init_empty: config.auto_init_empty ?? defaultConfig.auto_init_empty,
922
+ theme: config.theme,
923
+ icons: config.icons ?? defaultConfig.icons,
924
+ ai_provider: config.ai_provider,
925
+ ai_model: config.ai_model,
926
+ };
927
+ }
928
+
929
+ export async function loadConfiguration(
930
+ root: string,
931
+ stateOverrides?: TreedocsConfig,
932
+ globalConfigPath = defaultGlobalConfigPath(),
933
+ ): Promise<LoadedConfiguration> {
934
+ const repositoryPaths = pathsForRoot(root);
935
+ const globalConfig = await loadConfigIfExists(globalConfigPath);
936
+ const projectConfig = await loadConfigIfExists(repositoryPaths.projectConfig);
937
+ const config = resolveConfig(
938
+ mergeConfig(
939
+ mergeConfig(defaultConfig, globalConfig),
940
+ mergeConfig(projectConfig ?? {}, stateOverrides),
941
+ ),
942
+ );
943
+ const ignorePatterns = [
944
+ ...config.exclude,
945
+ ...(config.use_gitignore
946
+ ? await loadIgnoreFileIfExists(repositoryPaths.gitignore)
947
+ : []),
948
+ ...(await loadIgnoreFileIfExists(repositoryPaths.projectIgnore)),
949
+ ];
950
+
951
+ return { config, ignorePatterns };
952
+ }
953
+
954
+ async function loadConfigIfExists(
955
+ path: string | undefined,
956
+ ): Promise<TreedocsConfig | undefined> {
957
+ if (!path || !(await pathExists(path))) {
958
+ return undefined;
959
+ }
960
+ return configFromYaml(parse(await readFile(path, "utf8")));
961
+ }
962
+
963
+ async function loadIgnoreFileIfExists(path: string): Promise<string[]> {
964
+ if (!(await pathExists(path))) {
965
+ return [];
966
+ }
967
+ return (await readFile(path, "utf8"))
968
+ .split(/\r?\n/u)
969
+ .map((line) => line.trim())
970
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
971
+ }
972
+
973
+ function defaultGlobalConfigPath(): string | undefined {
974
+ const home = process.env.HOME;
975
+ return home ? join(home, ".config/treedocs/config.yaml") : undefined;
976
+ }
977
+
978
+ export class IgnoreMatcher {
979
+ constructor(private readonly patterns: string[]) {}
980
+
981
+ shouldIgnore(relativePath: string, isDirectory: boolean): boolean {
982
+ const normalized = normalizeRelativePath(relativePath);
983
+ if (!normalized) {
984
+ return false;
985
+ }
986
+
987
+ const components = normalized.split("/");
988
+ if (components.some((component) => standardExcludedNames.has(component))) {
989
+ return true;
990
+ }
991
+
992
+ let ignored = false;
993
+ for (const pattern of this.patterns) {
994
+ const rawPattern = trimmedNilIfEmpty(pattern);
995
+ if (!rawPattern) {
996
+ continue;
997
+ }
998
+ const isNegated = rawPattern.startsWith("!");
999
+ const effectivePattern = isNegated ? rawPattern.slice(1) : rawPattern;
1000
+ if (
1001
+ matchesIgnorePattern(
1002
+ effectivePattern,
1003
+ normalized,
1004
+ components.at(-1) ?? "",
1005
+ isDirectory,
1006
+ )
1007
+ ) {
1008
+ ignored = !isNegated;
1009
+ }
1010
+ }
1011
+ return ignored;
1012
+ }
1013
+ }
1014
+
1015
+ function matchesIgnorePattern(
1016
+ pattern: string,
1017
+ path: string,
1018
+ basenameValue: string,
1019
+ isDirectory: boolean,
1020
+ ): boolean {
1021
+ const rawPattern = trimmedNilIfEmpty(pattern);
1022
+ if (!rawPattern) {
1023
+ return false;
1024
+ }
1025
+
1026
+ const directoryPattern = rawPattern.endsWith("/");
1027
+ const unwrapped = directoryPattern ? rawPattern.slice(0, -1) : rawPattern;
1028
+ const anchored = unwrapped.startsWith("/");
1029
+ const normalizedPattern = anchored ? unwrapped.slice(1) : unwrapped;
1030
+
1031
+ if (directoryPattern && !isDirectory && path === normalizedPattern) {
1032
+ return false;
1033
+ }
1034
+
1035
+ if (containsGlob(normalizedPattern)) {
1036
+ if (globMatches(normalizedPattern, path)) {
1037
+ return true;
1038
+ }
1039
+ if (!anchored && globMatches(normalizedPattern, basenameValue)) {
1040
+ return true;
1041
+ }
1042
+ }
1043
+
1044
+ if (anchored) {
1045
+ return (
1046
+ path === normalizedPattern || path.startsWith(`${normalizedPattern}/`)
1047
+ );
1048
+ }
1049
+
1050
+ if (normalizedPattern.includes("/")) {
1051
+ return (
1052
+ path === normalizedPattern ||
1053
+ path.startsWith(`${normalizedPattern}/`) ||
1054
+ path.endsWith(`/${normalizedPattern}`)
1055
+ );
1056
+ }
1057
+
1058
+ return (
1059
+ basenameValue === normalizedPattern ||
1060
+ path.split("/").includes(normalizedPattern)
1061
+ );
1062
+ }
1063
+
1064
+ function containsGlob(pattern: string): boolean {
1065
+ return pattern.includes("*") || pattern.includes("?");
1066
+ }
1067
+
1068
+ function globMatches(pattern: string, value: string): boolean {
1069
+ const expression = pattern
1070
+ .split("")
1071
+ .map((character) => {
1072
+ if (character === "*") {
1073
+ return "[^/]*";
1074
+ }
1075
+ if (character === "?") {
1076
+ return "[^/]";
1077
+ }
1078
+ return character.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&");
1079
+ })
1080
+ .join("");
1081
+ return new RegExp(`^${expression}$`, "u").test(value);
1082
+ }
1083
+
1084
+ export async function scanTree(
1085
+ root: string,
1086
+ ignoreMatcher: IgnoreMatcher,
1087
+ ): Promise<TreeScanResult> {
1088
+ const nestedBoundaries: string[] = [];
1089
+ const tree = await buildTree(root, "", ignoreMatcher, nestedBoundaries);
1090
+ const normalizedPaths = collectNormalizedPaths(tree);
1091
+ return {
1092
+ tree,
1093
+ normalizedPaths,
1094
+ nestedBoundaries: nestedBoundaries.sort(),
1095
+ signature: treeSignature(normalizedPaths),
1096
+ };
1097
+ }
1098
+
1099
+ async function buildTree(
1100
+ root: string,
1101
+ relativePath: string,
1102
+ ignoreMatcher: IgnoreMatcher,
1103
+ nestedBoundaries: string[],
1104
+ ): Promise<Tree> {
1105
+ const absolutePath = relativePath ? join(root, relativePath) : root;
1106
+ const childNames = (await readdir(absolutePath)).sort();
1107
+ const result: Tree = {};
1108
+
1109
+ for (const childName of childNames) {
1110
+ const childRelativePath = relativePath
1111
+ ? `${relativePath}/${childName}`
1112
+ : childName;
1113
+ const childAbsolutePath = join(root, childRelativePath);
1114
+ const stats = await lstat(childAbsolutePath);
1115
+ const isDirectory = stats.isDirectory();
1116
+
1117
+ if (ignoreMatcher.shouldIgnore(childRelativePath, isDirectory)) {
1118
+ continue;
1119
+ }
1120
+
1121
+ if (isDirectory) {
1122
+ if (existsSync(join(childAbsolutePath, stateFileName))) {
1123
+ nestedBoundaries.push(childRelativePath);
1124
+ result[childName] = makeTreeEntry({
1125
+ description: "",
1126
+ children: {},
1127
+ isDirectory: true,
1128
+ });
1129
+ continue;
1130
+ }
1131
+ result[childName] = makeTreeEntry({
1132
+ description: "",
1133
+ children: await buildTree(
1134
+ root,
1135
+ childRelativePath,
1136
+ ignoreMatcher,
1137
+ nestedBoundaries,
1138
+ ),
1139
+ isDirectory: true,
1140
+ });
1141
+ continue;
1142
+ }
1143
+
1144
+ result[childName] = makeTreeEntry({ description: "" });
1145
+ }
1146
+
1147
+ return result;
1148
+ }
1149
+
1150
+ export async function resolveRepositoryPaths(
1151
+ rootPath: string,
1152
+ ): Promise<RepositoryPaths> {
1153
+ const root = resolve(rootPath);
1154
+ let stats: Awaited<ReturnType<typeof lstat>>;
1155
+ try {
1156
+ stats = await lstat(root);
1157
+ } catch {
1158
+ throw new TreedocsError(`Path does not exist: ${root}`);
1159
+ }
1160
+ if (!stats.isDirectory()) {
1161
+ throw new TreedocsError(`Path is not a directory: ${root}`);
1162
+ }
1163
+ return pathsForRoot(root);
1164
+ }
1165
+
1166
+ function pathsForRoot(root: string): RepositoryPaths {
1167
+ return {
1168
+ root,
1169
+ stateFile: join(root, stateFileName),
1170
+ projectConfig: join(root, ".treedocs/config.yaml"),
1171
+ projectIgnore: join(root, ".treedocs/.treedocs_ignore"),
1172
+ gitignore: join(root, ".gitignore"),
1173
+ };
1174
+ }
1175
+
1176
+ export async function loadTreedocsFile(path: string): Promise<TreedocsFile> {
1177
+ if (!(await pathExists(path))) {
1178
+ throw new TreedocsError(
1179
+ `Missing treedocs state file at ${path}. Run \`treedocs init\` first.`,
1180
+ );
1181
+ }
1182
+ const yaml = await readFile(path, "utf8");
1183
+ validateTreedocsYaml(yaml);
1184
+ return treedocsFileFromYaml(yaml);
1185
+ }
1186
+
1187
+ export async function loadTreedocsFileWithoutValidation(
1188
+ path: string,
1189
+ ): Promise<TreedocsFile> {
1190
+ return treedocsFileFromYaml(await readFile(path, "utf8"));
1191
+ }
1192
+
1193
+ export async function saveTreedocsFile(
1194
+ file: TreedocsFile,
1195
+ path: string,
1196
+ ): Promise<void> {
1197
+ const document = serializedTreedocsDocument(file);
1198
+ validateTreedocsYaml(document);
1199
+ await mkdir(dirname(path), { recursive: true });
1200
+ await writeFile(path, document, "utf8");
1201
+ }
1202
+
1203
+ let validateSchema: ReturnType<Ajv2020["compile"]> | undefined;
1204
+
1205
+ export function validateTreedocsYaml(yaml: string): void {
1206
+ const parsed = parse(yaml) as unknown;
1207
+ if (!isRecord(parsed)) {
1208
+ throw new TreedocsError(
1209
+ "Schema validation failed: # must be an object with project, signature, and tree.",
1210
+ );
1211
+ }
1212
+
1213
+ const schemaVersion = parseString(parsed.schema_version);
1214
+ if (!schemaVersion) {
1215
+ throw new TreedocsError(
1216
+ "Schema validation failed: missing required root schema_version.",
1217
+ );
1218
+ }
1219
+ if (schemaVersion !== currentSchemaVersion) {
1220
+ throw new TreedocsError(
1221
+ `Unsupported treedocs.yaml schema_version "${schemaVersion}". This CLI supports: ${currentSchemaVersion}.`,
1222
+ );
1223
+ }
1224
+
1225
+ const validator = getSchemaValidator();
1226
+ if (!validator(parsed)) {
1227
+ throw new TreedocsError(
1228
+ `Schema validation failed: ${formatValidationErrors(
1229
+ validator.errors ?? [],
1230
+ )}`,
1231
+ );
1232
+ }
1233
+ }
1234
+
1235
+ function getSchemaValidator(): ReturnType<Ajv2020["compile"]> {
1236
+ if (validateSchema) {
1237
+ return validateSchema;
1238
+ }
1239
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
1240
+ addFormats(ajv);
1241
+ validateSchema = ajv.compile(JSON.parse(readBundledSchema()));
1242
+ return validateSchema;
1243
+ }
1244
+
1245
+ function readBundledSchema(): string {
1246
+ const candidates = [
1247
+ join(import.meta.dir, "../site/schemas/0.2.0/treedocs.schema.json"),
1248
+ join(process.cwd(), "site/schemas/0.2.0/treedocs.schema.json"),
1249
+ ];
1250
+ for (const candidate of candidates) {
1251
+ if (existsSync(candidate)) {
1252
+ return readFileSync(candidate, "utf8");
1253
+ }
1254
+ }
1255
+ throw new TreedocsError(
1256
+ 'Unable to find bundled canonical schema for schema_version "0.2.0".',
1257
+ );
1258
+ }
1259
+
1260
+ function formatValidationErrors(
1261
+ errors: NonNullable<ReturnType<Ajv2020["compile"]>["errors"]>,
1262
+ ): string {
1263
+ if (errors.length === 0) {
1264
+ return "document does not match the bundled treedocs schema.";
1265
+ }
1266
+ return errors
1267
+ .map((error) => {
1268
+ const location = error.instancePath ? `#${error.instancePath}` : "#";
1269
+ return `${location}: ${error.message ?? "invalid value"}`;
1270
+ })
1271
+ .join("; ");
1272
+ }
1273
+
1274
+ export async function schemaValidationErrors(path: string): Promise<string[]> {
1275
+ try {
1276
+ validateTreedocsYaml(await readFile(path, "utf8"));
1277
+ return [];
1278
+ } catch (error) {
1279
+ return [error instanceof Error ? error.message : String(error)];
1280
+ }
1281
+ }
1282
+
1283
+ export function resolveLink(path: string, tree: Tree): LinkResolution {
1284
+ const startingEntry = entryAt(path, tree);
1285
+ if (!startingEntry?.link) {
1286
+ return { type: "none" };
1287
+ }
1288
+ if (isExternalLink(startingEntry.link)) {
1289
+ return { type: "external", url: startingEntry.link };
1290
+ }
1291
+
1292
+ const visited = new Set([path]);
1293
+ const chain = [path];
1294
+ let currentPath = resolveLinkTarget(startingEntry.link, path);
1295
+
1296
+ while (true) {
1297
+ chain.push(currentPath);
1298
+ const currentEntry = entryAt(currentPath, tree);
1299
+ if (!currentEntry) {
1300
+ return { type: "broken", target: currentPath, chain };
1301
+ }
1302
+
1303
+ if (!currentEntry.link) {
1304
+ return {
1305
+ type: "resolved",
1306
+ path: currentPath,
1307
+ chain,
1308
+ entry: currentEntry,
1309
+ };
1310
+ }
1311
+
1312
+ if (isExternalLink(currentEntry.link)) {
1313
+ return { type: "external", url: currentEntry.link };
1314
+ }
1315
+ if (visited.has(currentPath)) {
1316
+ return { type: "cycle", chain };
1317
+ }
1318
+
1319
+ visited.add(currentPath);
1320
+ currentPath = resolveLinkTarget(currentEntry.link, currentPath);
1321
+ }
1322
+ }
1323
+
1324
+ function isExternalLink(link: string): boolean {
1325
+ return link.startsWith("http://") || link.startsWith("https://");
1326
+ }
1327
+
1328
+ export class TreeRenderer {
1329
+ render(
1330
+ tree: Tree,
1331
+ options: {
1332
+ subtreePath?: string;
1333
+ config: ResolvedTreedocsConfig;
1334
+ statusOverrides?: Record<string, EntryStatus>;
1335
+ },
1336
+ ): string {
1337
+ const subtreePath = trimmedNilIfEmpty(
1338
+ options.subtreePath
1339
+ ? normalizeRelativePath(options.subtreePath)
1340
+ : undefined,
1341
+ );
1342
+ const statusOverrides = options.statusOverrides ?? {};
1343
+ const rootLabel: string =
1344
+ subtreePath && entryAt(subtreePath, tree)?.isDirectory
1345
+ ? `${subtreePath}/`
1346
+ : (subtreePath ?? ".");
1347
+ const rootEntry = subtreePath ? entryAt(subtreePath, tree) : undefined;
1348
+ if (subtreePath && !rootEntry) {
1349
+ throw new TreedocsError(
1350
+ `Path not found in treedocs tree: ${subtreePath}`,
1351
+ );
1352
+ }
1353
+
1354
+ const renderedTree = rootEntry?.isDirectory
1355
+ ? rootEntry.children
1356
+ : subtreePath
1357
+ ? {}
1358
+ : tree;
1359
+ const rootPath = subtreePath ?? "";
1360
+ const rows = [
1361
+ this.rootRow(
1362
+ rootLabel,
1363
+ rootEntry,
1364
+ rootPath,
1365
+ options.config,
1366
+ statusOverrides,
1367
+ ),
1368
+ ...this.flattenForRender(
1369
+ renderedTree,
1370
+ "",
1371
+ rootPath,
1372
+ options.config,
1373
+ statusOverrides,
1374
+ ),
1375
+ ];
1376
+ return this.renderRows(rows, options.config);
1377
+ }
1378
+
1379
+ renderExploration(
1380
+ tree: Tree,
1381
+ options: {
1382
+ expandedPaths: string[];
1383
+ config: ResolvedTreedocsConfig;
1384
+ statusOverrides?: Record<string, EntryStatus>;
1385
+ },
1386
+ ): string {
1387
+ const normalizedPaths =
1388
+ options.expandedPaths.length === 0
1389
+ ? [""]
1390
+ : options.expandedPaths.map(normalizeRelativePath);
1391
+ const expandedPathSet = new Set(normalizedPaths);
1392
+ for (const path of expandedPathSet) {
1393
+ if (!entryAt(path, tree)) {
1394
+ throw new TreedocsError(`Path not found in treedocs tree: ${path}`);
1395
+ }
1396
+ }
1397
+
1398
+ const hint =
1399
+ "Expand collapsed folders with `treedocs explore <subpath> [subpath ...]`.";
1400
+ const rows: RenderRow[] = [
1401
+ { label: hint, visibleLabelLength: hint.length },
1402
+ this.rootRow(
1403
+ ".",
1404
+ undefined,
1405
+ "",
1406
+ options.config,
1407
+ options.statusOverrides ?? {},
1408
+ ),
1409
+ ...this.flattenForExploration(
1410
+ tree,
1411
+ "",
1412
+ "",
1413
+ expandedPathSet,
1414
+ options.config,
1415
+ options.statusOverrides ?? {},
1416
+ ),
1417
+ ];
1418
+ return this.renderRows(rows, options.config);
1419
+ }
1420
+
1421
+ private renderRows(
1422
+ rows: RenderRow[],
1423
+ config: ResolvedTreedocsConfig,
1424
+ ): string {
1425
+ const labelWidth = config.align_columns
1426
+ ? Math.max(...rows.map((row) => row.visibleLabelLength), 0)
1427
+ : 0;
1428
+ return rows
1429
+ .map((row) => {
1430
+ const padding = config.align_columns
1431
+ ? " ".repeat(Math.max(labelWidth - row.visibleLabelLength, 0))
1432
+ : "";
1433
+ return row.description
1434
+ ? `${row.label}${padding} ${row.description}`
1435
+ : row.label;
1436
+ })
1437
+ .join("\n");
1438
+ }
1439
+
1440
+ private rootRow(
1441
+ label: string,
1442
+ entry: TreeEntry | undefined,
1443
+ path: string,
1444
+ config: ResolvedTreedocsConfig,
1445
+ statusOverrides: Record<string, EntryStatus>,
1446
+ ): RenderRow {
1447
+ const status =
1448
+ statusOverrides[path] ?? (entry ? entryStatus(entry, config) : "clean");
1449
+ const decorated = decorateLabel(statusLabel(label, status), entry);
1450
+ const plain = decorateLabel(statusLabel(label, status), entry);
1451
+ return {
1452
+ label: decorated,
1453
+ visibleLabelLength: plain.length,
1454
+ description: entry ? descriptionText(entry, config) : undefined,
1455
+ };
1456
+ }
1457
+
1458
+ private flattenForRender(
1459
+ tree: Tree,
1460
+ prefix: string,
1461
+ pathPrefix: string,
1462
+ config: ResolvedTreedocsConfig,
1463
+ statusOverrides: Record<string, EntryStatus>,
1464
+ ): RenderRow[] {
1465
+ const lines: RenderRow[] = [];
1466
+ const keys = Object.keys(tree).sort();
1467
+ for (const [index, key] of keys.entries()) {
1468
+ const entry = tree[key];
1469
+ const isLast = index === keys.length - 1;
1470
+ const connector = isLast ? "└── " : "├── ";
1471
+ const childPrefix = prefix + (isLast ? " " : "│ ");
1472
+ const path = pathPrefix ? `${pathPrefix}/${key}` : key;
1473
+ const marker = entry.isDirectory ? `${key}/` : key;
1474
+ const status = statusOverrides[path] ?? entryStatus(entry, config);
1475
+ const label = statusLabel(marker, status);
1476
+ const decorated = decorateLabel(label, entry);
1477
+ lines.push({
1478
+ label: prefix + connector + decorated,
1479
+ visibleLabelLength: (prefix + connector + decorateLabel(label, entry))
1480
+ .length,
1481
+ description: descriptionText(entry, config),
1482
+ });
1483
+ if (entry.isDirectory) {
1484
+ lines.push(
1485
+ ...this.flattenForRender(
1486
+ entry.children,
1487
+ childPrefix,
1488
+ path,
1489
+ config,
1490
+ statusOverrides,
1491
+ ),
1492
+ );
1493
+ }
1494
+ }
1495
+ return lines;
1496
+ }
1497
+
1498
+ private flattenForExploration(
1499
+ tree: Tree,
1500
+ prefix: string,
1501
+ pathPrefix: string,
1502
+ expandedPaths: Set<string>,
1503
+ config: ResolvedTreedocsConfig,
1504
+ statusOverrides: Record<string, EntryStatus>,
1505
+ ): RenderRow[] {
1506
+ const lines: RenderRow[] = [];
1507
+ const keys = visibleExplorationKeys(tree, pathPrefix, expandedPaths);
1508
+ for (const [index, key] of keys.entries()) {
1509
+ const entry = tree[key];
1510
+ const isLast = index === keys.length - 1;
1511
+ const connector = isLast ? "└── " : "├── ";
1512
+ const childPrefix = prefix + (isLast ? " " : "│ ");
1513
+ const path = pathPrefix ? `${pathPrefix}/${key}` : key;
1514
+ const marker = entry.isDirectory ? `${key}/` : key;
1515
+ const status = statusOverrides[path] ?? entryStatus(entry, config);
1516
+ const shouldDescend =
1517
+ entry.isDirectory &&
1518
+ shouldDescendDuringExploration(path, expandedPaths);
1519
+ const countSuffix =
1520
+ entry.isDirectory && !shouldDescend
1521
+ ? ` ${collapsedItemCount(entry)}`
1522
+ : "";
1523
+ const label = `${decorateLabel(statusLabel(marker, status), entry)}${countSuffix}`;
1524
+ lines.push({
1525
+ label: prefix + connector + label,
1526
+ visibleLabelLength: (prefix + connector + label).length,
1527
+ description: descriptionText(entry, config),
1528
+ });
1529
+ if (shouldDescend) {
1530
+ lines.push(
1531
+ ...this.flattenForExploration(
1532
+ entry.children,
1533
+ childPrefix,
1534
+ path,
1535
+ expandedPaths,
1536
+ config,
1537
+ statusOverrides,
1538
+ ),
1539
+ );
1540
+ }
1541
+ }
1542
+ return lines;
1543
+ }
1544
+ }
1545
+
1546
+ interface RenderRow {
1547
+ label: string;
1548
+ visibleLabelLength: number;
1549
+ description?: string;
1550
+ }
1551
+
1552
+ function entryStatus(
1553
+ entry: TreeEntry,
1554
+ config: ResolvedTreedocsConfig,
1555
+ ): EntryStatus {
1556
+ if (!entryNeedsDescription(entry)) {
1557
+ return "clean";
1558
+ }
1559
+ return config.check_severity === "warn" ? "warning" : "error";
1560
+ }
1561
+
1562
+ function statusLabel(label: string, status: EntryStatus): string {
1563
+ switch (status) {
1564
+ case "warning":
1565
+ return `? ${label}`;
1566
+ case "error":
1567
+ return `! ${label}`;
1568
+ default:
1569
+ return label;
1570
+ }
1571
+ }
1572
+
1573
+ function decorateLabel(label: string, entry: TreeEntry | undefined): string {
1574
+ const suffixes: string[] = [];
1575
+ if (entryHasReferences(entry ?? makeTreeEntry({}))) {
1576
+ suffixes.push("[ref]");
1577
+ }
1578
+ if (entry?.link) {
1579
+ suffixes.push(`[link->${entry.link}]`);
1580
+ }
1581
+ return suffixes.length === 0 ? label : `${label} ${suffixes.join(" ")}`;
1582
+ }
1583
+
1584
+ function descriptionText(
1585
+ entry: TreeEntry,
1586
+ config: ResolvedTreedocsConfig,
1587
+ ): string | undefined {
1588
+ const description = entryDescription(entry);
1589
+ if (description === undefined) {
1590
+ return undefined;
1591
+ }
1592
+ const maxLength = Math.max(config.max_description_length, 0);
1593
+ if (maxLength === 0 || description.length <= maxLength) {
1594
+ return description;
1595
+ }
1596
+ if (maxLength <= 3) {
1597
+ return description.slice(0, maxLength);
1598
+ }
1599
+ return `${description.slice(0, maxLength - 3)}...`;
1600
+ }
1601
+
1602
+ function visibleExplorationKeys(
1603
+ tree: Tree,
1604
+ pathPrefix: string,
1605
+ expandedPaths: Set<string>,
1606
+ ): string[] {
1607
+ const keys = Object.keys(tree).sort();
1608
+ if (!pathPrefix || expandedPaths.has(pathPrefix)) {
1609
+ return keys;
1610
+ }
1611
+ return keys.filter((key) => {
1612
+ const childPath = `${pathPrefix}/${key}`;
1613
+ return (
1614
+ expandedPaths.has(childPath) ||
1615
+ [...expandedPaths].some((path) => path.startsWith(`${childPath}/`))
1616
+ );
1617
+ });
1618
+ }
1619
+
1620
+ function shouldDescendDuringExploration(
1621
+ path: string,
1622
+ expandedPaths: Set<string>,
1623
+ ): boolean {
1624
+ return (
1625
+ expandedPaths.has(path) ||
1626
+ [...expandedPaths].some((expandedPath) =>
1627
+ expandedPath.startsWith(`${path}/`),
1628
+ )
1629
+ );
1630
+ }
1631
+
1632
+ function collapsedItemCount(entry: TreeEntry): string {
1633
+ const count = Object.keys(entry.children).length;
1634
+ return `(${count} ${count === 1 ? "item" : "items"})`;
1635
+ }
1636
+
1637
+ export class TreedocsService {
1638
+ constructor(private readonly globalConfigPath?: string) {}
1639
+
1640
+ async initialize(rootPath: string, force: boolean): Promise<TreedocsFile> {
1641
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1642
+ if ((await pathExists(repositoryPaths.stateFile)) && !force) {
1643
+ throw new TreedocsError(
1644
+ "treedocs.yaml already exists. Re-run with `--force` to overwrite it.",
1645
+ );
1646
+ }
1647
+
1648
+ const loaded = await loadConfiguration(
1649
+ repositoryPaths.root,
1650
+ undefined,
1651
+ this.globalConfigPath,
1652
+ );
1653
+ const scan = await scanTree(
1654
+ repositoryPaths.root,
1655
+ new IgnoreMatcher(loaded.ignorePatterns),
1656
+ );
1657
+ const file: TreedocsFile = {
1658
+ schemaVersion: currentSchemaVersion,
1659
+ project: {
1660
+ name: basename(repositoryPaths.root),
1661
+ version: "0.0.0",
1662
+ last_updated: currentDateString(),
1663
+ },
1664
+ signature: scan.signature,
1665
+ tree: scan.tree,
1666
+ };
1667
+ await saveTreedocsFile(file, repositoryPaths.stateFile);
1668
+ return file;
1669
+ }
1670
+
1671
+ async sync(rootPath: string): Promise<TreedocsFile> {
1672
+ return (await this.syncResult(rootPath)).file;
1673
+ }
1674
+
1675
+ async syncResult(rootPath: string): Promise<SyncResult> {
1676
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1677
+ const current = await loadTreedocsFile(repositoryPaths.stateFile);
1678
+ const loaded = await loadConfiguration(
1679
+ repositoryPaths.root,
1680
+ current.overrides,
1681
+ this.globalConfigPath,
1682
+ );
1683
+ const scan = await scanTree(
1684
+ repositoryPaths.root,
1685
+ new IgnoreMatcher(loaded.ignorePatterns),
1686
+ );
1687
+ const changes: SyncChanges = {
1688
+ addedPaths: missingPaths(current.tree, scan.tree),
1689
+ removedPaths: extraPaths(current.tree, scan.tree),
1690
+ changedTypePaths: changedPaths(current.tree, scan.tree),
1691
+ };
1692
+ const merged: TreedocsFile = {
1693
+ schemaVersion: current.schemaVersion,
1694
+ project: current.project,
1695
+ overrides: current.overrides,
1696
+ signature: scan.signature,
1697
+ tree: mergePreservingMetadata(scan.tree, current.tree),
1698
+ };
1699
+ await saveTreedocsFile(merged, repositoryPaths.stateFile);
1700
+ return {
1701
+ file: merged,
1702
+ saved: true,
1703
+ signatureUnchanged: current.signature === scan.signature,
1704
+ changes,
1705
+ missingDescriptions: missingDescriptionPaths(merged.tree),
1706
+ };
1707
+ }
1708
+
1709
+ async check(rootPath: string): Promise<CheckReport> {
1710
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1711
+ const schemaErrors = await schemaValidationErrors(
1712
+ repositoryPaths.stateFile,
1713
+ );
1714
+ const current = await loadTreedocsFileWithoutValidation(
1715
+ repositoryPaths.stateFile,
1716
+ );
1717
+ const loaded = await loadConfiguration(
1718
+ repositoryPaths.root,
1719
+ current.overrides,
1720
+ this.globalConfigPath,
1721
+ );
1722
+ const scan = await scanTree(
1723
+ repositoryPaths.root,
1724
+ new IgnoreMatcher(loaded.ignorePatterns),
1725
+ );
1726
+ return makeCheckReport({
1727
+ schemaWarnings: [],
1728
+ schemaErrors,
1729
+ storedSignature: current.signature,
1730
+ currentSignature: scan.signature,
1731
+ missingDescriptions: missingDescriptionPaths(current.tree),
1732
+ missingPaths: missingPaths(current.tree, scan.tree),
1733
+ extraPaths: extraPaths(current.tree, scan.tree),
1734
+ changedPaths: changedPaths(current.tree, scan.tree),
1735
+ nestedBoundaries: scan.nestedBoundaries,
1736
+ shadowedPaths: shadowedPaths(current.tree, scan.nestedBoundaries),
1737
+ severity: loaded.config.check_severity,
1738
+ });
1739
+ }
1740
+
1741
+ async inspect(
1742
+ rootPath: string,
1743
+ path: string,
1744
+ recursive: boolean,
1745
+ ): Promise<InspectReport> {
1746
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1747
+ const file = await loadTreedocsFile(repositoryPaths.stateFile);
1748
+ const normalizedPath = normalizeRelativePath(path);
1749
+ const entry = entryAt(normalizedPath, file.tree);
1750
+ if (!entry) {
1751
+ throw new TreedocsError(
1752
+ `Path not found in treedocs tree: ${normalizedPath}`,
1753
+ );
1754
+ }
1755
+
1756
+ let recursiveOutput: string | undefined;
1757
+ if (recursive && entry.isDirectory) {
1758
+ const loaded = await loadConfiguration(
1759
+ repositoryPaths.root,
1760
+ file.overrides,
1761
+ this.globalConfigPath,
1762
+ );
1763
+ recursiveOutput = new TreeRenderer().render(file.tree, {
1764
+ subtreePath: normalizedPath,
1765
+ config: loaded.config,
1766
+ });
1767
+ }
1768
+
1769
+ return {
1770
+ path: normalizedPath,
1771
+ entry,
1772
+ linkResolution: resolveLink(normalizedPath, file.tree),
1773
+ recursiveOutput,
1774
+ };
1775
+ }
1776
+
1777
+ async update(
1778
+ rootPath: string,
1779
+ options: {
1780
+ path: string;
1781
+ description?: string;
1782
+ addReferences: string[];
1783
+ removeReferences: string[];
1784
+ link?: string;
1785
+ clearLink: boolean;
1786
+ },
1787
+ ): Promise<TreedocsFile> {
1788
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1789
+ const file = await loadTreedocsFile(repositoryPaths.stateFile);
1790
+ const normalizedPath = normalizeRelativePath(options.path);
1791
+ const updated = updateEntry(normalizedPath, file.tree, (entry) => {
1792
+ const description =
1793
+ options.description === undefined
1794
+ ? entryDescription(entry)
1795
+ : trimmedNilIfEmpty(options.description);
1796
+ const references = [...entryReferences(entry)];
1797
+ for (const reference of options.addReferences) {
1798
+ if (!references.includes(reference)) {
1799
+ references.push(reference);
1800
+ }
1801
+ }
1802
+ const remainingReferences = references.filter(
1803
+ (reference) => !options.removeReferences.includes(reference),
1804
+ );
1805
+ entry.documentation = makeDocumentation(description, remainingReferences);
1806
+
1807
+ if (options.clearLink) {
1808
+ entry.link = undefined;
1809
+ } else if (options.link !== undefined) {
1810
+ entry.link = trimmedNilIfEmpty(options.link);
1811
+ }
1812
+ });
1813
+
1814
+ if (!updated) {
1815
+ throw new TreedocsError(
1816
+ `Path not found in treedocs tree: ${normalizedPath}`,
1817
+ );
1818
+ }
1819
+
1820
+ const loaded = await loadConfiguration(
1821
+ repositoryPaths.root,
1822
+ file.overrides,
1823
+ this.globalConfigPath,
1824
+ );
1825
+ const scan = await scanTree(
1826
+ repositoryPaths.root,
1827
+ new IgnoreMatcher(loaded.ignorePatterns),
1828
+ );
1829
+ file.signature = scan.signature;
1830
+ await saveTreedocsFile(file, repositoryPaths.stateFile);
1831
+ return file;
1832
+ }
1833
+
1834
+ async renderTree(
1835
+ rootPath: string,
1836
+ subtreePath?: string,
1837
+ fullDescriptions = false,
1838
+ ): Promise<string> {
1839
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1840
+ const file = await loadTreedocsFile(repositoryPaths.stateFile);
1841
+ const loaded = await loadConfiguration(
1842
+ repositoryPaths.root,
1843
+ file.overrides,
1844
+ this.globalConfigPath,
1845
+ );
1846
+ return new TreeRenderer().render(file.tree, {
1847
+ subtreePath,
1848
+ config: renderConfig(loaded.config, fullDescriptions),
1849
+ });
1850
+ }
1851
+
1852
+ async explore(
1853
+ rootPath: string,
1854
+ expandedPaths: string[],
1855
+ fullDescriptions = false,
1856
+ ): Promise<string> {
1857
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1858
+ const file = await loadTreedocsFile(repositoryPaths.stateFile);
1859
+ const loaded = await loadConfiguration(
1860
+ repositoryPaths.root,
1861
+ file.overrides,
1862
+ this.globalConfigPath,
1863
+ );
1864
+ return new TreeRenderer().renderExploration(file.tree, {
1865
+ expandedPaths,
1866
+ config: renderConfig(loaded.config, fullDescriptions),
1867
+ });
1868
+ }
1869
+
1870
+ async show(
1871
+ rootPath: string,
1872
+ path: string,
1873
+ checkFirst: boolean,
1874
+ fullDescriptions = false,
1875
+ ): Promise<string> {
1876
+ const lines: string[] = [];
1877
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1878
+ const file = await loadTreedocsFile(repositoryPaths.stateFile);
1879
+ const loaded = await loadConfiguration(
1880
+ repositoryPaths.root,
1881
+ file.overrides,
1882
+ this.globalConfigPath,
1883
+ );
1884
+ const normalizedPath = normalizeRelativePath(path);
1885
+ let displayTree = file.tree;
1886
+ const statusOverrides: Record<string, EntryStatus> = {};
1887
+
1888
+ if (checkFirst) {
1889
+ const report = await this.check(rootPath);
1890
+ if (report.hasIssues) {
1891
+ const subtreeHasIssues = hasScopedIssues(report, normalizedPath);
1892
+ lines.push(
1893
+ scopedDiscrepancyText(normalizedPath, subtreeHasIssues, file.tree),
1894
+ );
1895
+ if (report.hasSignatureDrift) {
1896
+ const scan = await scanTree(
1897
+ repositoryPaths.root,
1898
+ new IgnoreMatcher(loaded.ignorePatterns),
1899
+ );
1900
+ displayTree = mergePreservingMetadata(scan.tree, file.tree);
1901
+ const status: EntryStatus =
1902
+ report.severity === "warn" ? "warning" : "error";
1903
+ for (const path of report.missingPaths) {
1904
+ if (isPathInside(path, normalizedPath)) {
1905
+ statusOverrides[path] = status;
1906
+ }
1907
+ }
1908
+ }
1909
+ } else {
1910
+ lines.push("The treedocs below is up to date with the filesystem.");
1911
+ }
1912
+ }
1913
+
1914
+ const config = renderConfig(loaded.config, fullDescriptions);
1915
+ const resolution = resolveLink(normalizedPath, file.tree);
1916
+ if (resolution.type === "external") {
1917
+ lines.push(
1918
+ `External alias: ${displayPath(normalizedPath)} -> ${resolution.url}`,
1919
+ );
1920
+ } else if (resolution.type === "resolved") {
1921
+ lines.push(
1922
+ new TreeRenderer().render(displayTree, {
1923
+ subtreePath: resolution.path,
1924
+ config,
1925
+ statusOverrides,
1926
+ }),
1927
+ );
1928
+ } else if (resolution.type === "broken") {
1929
+ throw new TreedocsError(
1930
+ `Broken link: ${resolution.chain.join(" -> ")} (missing target: ${resolution.target})`,
1931
+ );
1932
+ } else if (resolution.type === "cycle") {
1933
+ throw new TreedocsError(
1934
+ `Link cycle detected: ${resolution.chain.join(" -> ")}`,
1935
+ );
1936
+ } else {
1937
+ lines.push(
1938
+ new TreeRenderer().render(displayTree, {
1939
+ subtreePath: path,
1940
+ config,
1941
+ statusOverrides,
1942
+ }),
1943
+ );
1944
+ }
1945
+
1946
+ return lines.join("\n");
1947
+ }
1948
+
1949
+ async findPath(rootPath: string, query: string): Promise<string | undefined> {
1950
+ const repositoryPaths = await resolveRepositoryPaths(rootPath);
1951
+ const file = await loadTreedocsFile(repositoryPaths.stateFile);
1952
+ return firstPathMatching(query, file.tree);
1953
+ }
1954
+ }
1955
+
1956
+ function renderConfig(
1957
+ config: ResolvedTreedocsConfig,
1958
+ fullDescriptions: boolean,
1959
+ ): ResolvedTreedocsConfig {
1960
+ return fullDescriptions ? { ...config, max_description_length: 0 } : config;
1961
+ }
1962
+
1963
+ function makeCheckReport(
1964
+ report: Omit<
1965
+ CheckReport,
1966
+ "hasSignatureDrift" | "hasIssues" | "shouldFail" | "requiresSyncBeforeFill"
1967
+ >,
1968
+ ): CheckReport {
1969
+ const hasSignatureDrift = report.storedSignature !== report.currentSignature;
1970
+ const hasIssues =
1971
+ report.schemaErrors.length > 0 ||
1972
+ hasSignatureDrift ||
1973
+ report.missingDescriptions.length > 0 ||
1974
+ report.missingPaths.length > 0 ||
1975
+ report.extraPaths.length > 0 ||
1976
+ report.changedPaths.length > 0 ||
1977
+ report.shadowedPaths.length > 0;
1978
+ const shouldFail = hasIssues && report.severity === "error";
1979
+ const requiresSyncBeforeFill =
1980
+ report.schemaErrors.length > 0 ||
1981
+ hasSignatureDrift ||
1982
+ report.missingPaths.length > 0 ||
1983
+ report.extraPaths.length > 0 ||
1984
+ report.changedPaths.length > 0 ||
1985
+ report.shadowedPaths.length > 0;
1986
+ return {
1987
+ ...report,
1988
+ hasSignatureDrift,
1989
+ hasIssues,
1990
+ shouldFail,
1991
+ requiresSyncBeforeFill,
1992
+ };
1993
+ }
1994
+
1995
+ function hasScopedIssues(report: CheckReport, subtreePath: string): boolean {
1996
+ if (!subtreePath) {
1997
+ return report.hasIssues;
1998
+ }
1999
+ return [
2000
+ ...report.missingDescriptions,
2001
+ ...report.missingPaths,
2002
+ ...report.extraPaths,
2003
+ ...report.changedPaths,
2004
+ ...report.shadowedPaths,
2005
+ ].some((path) => isPathInside(path, subtreePath));
2006
+ }
2007
+
2008
+ function scopedDiscrepancyText(
2009
+ subtreePath: string,
2010
+ subtreeHasIssues: boolean,
2011
+ tree: Tree,
2012
+ ): string {
2013
+ if (!subtreePath) {
2014
+ return "Warning: treedocs discrepancies found. Run `treedocs check` for the full diagnostic report.";
2015
+ }
2016
+ if (subtreeHasIssues) {
2017
+ return "Warning: this subtree has treedocs discrepancies. Run `treedocs check` for the full diagnostic report.";
2018
+ }
2019
+ return `Note: treedocs has drift elsewhere in this repo; \`${displayFocusedPath(
2020
+ subtreePath,
2021
+ tree,
2022
+ )}\` is current. Run \`treedocs check\` or \`treedocs sync\`.`;
2023
+ }
2024
+
2025
+ function isPathInside(path: string, subtreePath: string): boolean {
2026
+ return (
2027
+ !subtreePath || path === subtreePath || path.startsWith(`${subtreePath}/`)
2028
+ );
2029
+ }
2030
+
2031
+ function displayPath(path: string): string {
2032
+ return path || ".";
2033
+ }
2034
+
2035
+ function displayFocusedPath(path: string, tree: Tree): string {
2036
+ const entry = entryAt(path, tree);
2037
+ return entry?.isDirectory ? `${displayPath(path)}/` : displayPath(path);
2038
+ }
2039
+
2040
+ function currentDateString(): string {
2041
+ return new Date().toISOString().slice(0, 10);
2042
+ }
2043
+
2044
+ async function pathExists(path: string): Promise<boolean> {
2045
+ try {
2046
+ await access(path);
2047
+ return true;
2048
+ } catch {
2049
+ return false;
2050
+ }
2051
+ }