docsgov 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +242 -0
  2. package/dist/apispec/apispec.js +401 -0
  3. package/dist/apispec/apispec.test.js +444 -0
  4. package/dist/apispec/errors.js +17 -0
  5. package/dist/apispec/index.js +2 -0
  6. package/dist/check/doclinks.js +167 -0
  7. package/dist/check/index.js +8 -0
  8. package/dist/check/run.js +391 -0
  9. package/dist/check/run.test.js +513 -0
  10. package/dist/check/suggest.js +134 -0
  11. package/dist/check/suggest.test.js +92 -0
  12. package/dist/check/tokens.js +125 -0
  13. package/dist/cmd/main.js +330 -0
  14. package/dist/cmd/main.test.js +422 -0
  15. package/dist/codeq/cache.js +71 -0
  16. package/dist/codeq/cache.test.js +67 -0
  17. package/dist/codeq/errors.js +52 -0
  18. package/dist/codeq/grammars/tree-sitter-go.wasm +0 -0
  19. package/dist/codeq/grammars/tree-sitter-java.wasm +0 -0
  20. package/dist/codeq/grammars/tree-sitter-javascript.wasm +0 -0
  21. package/dist/codeq/grammars/tree-sitter-tsx.wasm +0 -0
  22. package/dist/codeq/grammars/tree-sitter-typescript.wasm +0 -0
  23. package/dist/codeq/index.js +11 -0
  24. package/dist/codeq/resolve.test.js +109 -0
  25. package/dist/codeq/resolver.js +128 -0
  26. package/dist/codeq/resolver.test.js +124 -0
  27. package/dist/codeq/resolvers/go.js +242 -0
  28. package/dist/codeq/resolvers/go.test.js +143 -0
  29. package/dist/codeq/resolvers/java.js +349 -0
  30. package/dist/codeq/resolvers/java.test.js +138 -0
  31. package/dist/codeq/resolvers/java_queries.js +63 -0
  32. package/dist/codeq/resolvers/javascript.js +412 -0
  33. package/dist/codeq/resolvers/javascript.test.js +125 -0
  34. package/dist/codeq/resolvers/javascript_queries.js +46 -0
  35. package/dist/codeq/resolvers/typescript.js +366 -0
  36. package/dist/codeq/resolvers/typescript.test.js +180 -0
  37. package/dist/codeq/resolvers/typescript_queries.js +78 -0
  38. package/dist/codeq/signature.js +50 -0
  39. package/dist/codeq/signature.test.js +50 -0
  40. package/dist/codeq/suggest.js +96 -0
  41. package/dist/codeq/treesitter.js +122 -0
  42. package/dist/codeq/treesitter.test.js +118 -0
  43. package/dist/config/config.js +74 -0
  44. package/dist/config/config.test.js +98 -0
  45. package/dist/config/fs.js +116 -0
  46. package/dist/config/glob.js +82 -0
  47. package/dist/config/glob.test.js +61 -0
  48. package/dist/config/index.js +4 -0
  49. package/dist/dedup/analyzer/analyzer.js +533 -0
  50. package/dist/dedup/analyzer/analyzer.test.js +530 -0
  51. package/dist/dedup/analyzer/canonical.js +74 -0
  52. package/dist/dedup/analyzer/canonical.test.js +70 -0
  53. package/dist/dedup/analyzer/cosine_clusters.js +169 -0
  54. package/dist/dedup/analyzer/cosine_clusters.test.js +131 -0
  55. package/dist/dedup/analyzer/distinctive.js +85 -0
  56. package/dist/dedup/analyzer/distinctive.test.js +49 -0
  57. package/dist/dedup/analyzer/exact_clusters.js +63 -0
  58. package/dist/dedup/analyzer/exact_clusters.test.js +81 -0
  59. package/dist/dedup/analyzer/index.js +14 -0
  60. package/dist/dedup/analyzer/multiplicity.js +110 -0
  61. package/dist/dedup/analyzer/multiplicity.test.js +123 -0
  62. package/dist/dedup/analyzer/order.js +22 -0
  63. package/dist/dedup/analyzer/partial_overlaps.js +65 -0
  64. package/dist/dedup/analyzer/partial_overlaps.test.js +161 -0
  65. package/dist/dedup/analyzer/preview.js +84 -0
  66. package/dist/dedup/analyzer/preview.test.js +46 -0
  67. package/dist/dedup/analyzer/safety.js +27 -0
  68. package/dist/dedup/analyzer/safety.test.js +39 -0
  69. package/dist/dedup/config.js +18 -0
  70. package/dist/dedup/configload.js +299 -0
  71. package/dist/dedup/configload.test.js +410 -0
  72. package/dist/dedup/dedup.index.test.js +203 -0
  73. package/dist/dedup/dedup.js +143 -0
  74. package/dist/dedup/dedup.test.js +212 -0
  75. package/dist/dedup/dedupcfg/config.js +112 -0
  76. package/dist/dedup/dedupcfg/config.test.js +70 -0
  77. package/dist/dedup/dedupcfg/index.js +1 -0
  78. package/dist/dedup/deduptypes/index.js +1 -0
  79. package/dist/dedup/deduptypes/types.js +9 -0
  80. package/dist/dedup/deduptypes/types.test.js +34 -0
  81. package/dist/dedup/embedder/cache.js +23 -0
  82. package/dist/dedup/embedder/cache.test.js +50 -0
  83. package/dist/dedup/embedder/constants.js +10 -0
  84. package/dist/dedup/embedder/embedder.js +76 -0
  85. package/dist/dedup/embedder/embedder.mock.test.js +128 -0
  86. package/dist/dedup/embedder/embedder.test.js +96 -0
  87. package/dist/dedup/embedder/errors.js +20 -0
  88. package/dist/dedup/embedder/errors.test.js +35 -0
  89. package/dist/dedup/embedder/index.js +4 -0
  90. package/dist/dedup/embedder/session.js +78 -0
  91. package/dist/dedup/embedder/session.test.js +172 -0
  92. package/dist/dedup/gitignore.js +97 -0
  93. package/dist/dedup/gitignore.test.js +98 -0
  94. package/dist/dedup/index.js +11 -0
  95. package/dist/dedup/indexdb/errors.js +48 -0
  96. package/dist/dedup/indexdb/index.js +6 -0
  97. package/dist/dedup/indexdb/indexdb.js +302 -0
  98. package/dist/dedup/indexdb/indexdb.test.js +739 -0
  99. package/dist/dedup/indexdb/load.js +110 -0
  100. package/dist/dedup/indexdb/migrations.js +58 -0
  101. package/dist/dedup/indexdb/schema.js +83 -0
  102. package/dist/dedup/indexer/index.js +9 -0
  103. package/dist/dedup/indexer/indexer.js +501 -0
  104. package/dist/dedup/indexer/indexer.test.js +510 -0
  105. package/dist/dedup/indexer/links.js +89 -0
  106. package/dist/dedup/mdsection/anchor.js +60 -0
  107. package/dist/dedup/mdsection/anchor.test.js +39 -0
  108. package/dist/dedup/mdsection/blocks.js +409 -0
  109. package/dist/dedup/mdsection/blocks.test.js +359 -0
  110. package/dist/dedup/mdsection/index.js +4 -0
  111. package/dist/dedup/mdsection/parse.js +21 -0
  112. package/dist/dedup/mdsection/section.js +234 -0
  113. package/dist/dedup/mdsection/section.test.js +221 -0
  114. package/dist/dedup/report/floatfmt.js +71 -0
  115. package/dist/dedup/report/floatfmt.test.js +42 -0
  116. package/dist/dedup/report/index.js +8 -0
  117. package/dist/dedup/report/quote.js +77 -0
  118. package/dist/dedup/report/quote.test.js +67 -0
  119. package/dist/dedup/report/text.js +251 -0
  120. package/dist/dedup/report/text.test.js +420 -0
  121. package/dist/dedup/report_types.js +8 -0
  122. package/dist/dedup/sectionid/index.js +1 -0
  123. package/dist/dedup/sectionid/sectionid.js +16 -0
  124. package/dist/dedup/sectionid/sectionid.test.js +49 -0
  125. package/dist/guard/api/errors.js +12 -0
  126. package/dist/guard/api/index.js +2 -0
  127. package/dist/guard/api/parser.js +81 -0
  128. package/dist/guard/api/parser.test.js +58 -0
  129. package/dist/guard/api/types.js +1 -0
  130. package/dist/guard/code/errors.js +16 -0
  131. package/dist/guard/code/index.js +2 -0
  132. package/dist/guard/code/parser.js +54 -0
  133. package/dist/guard/code/parser.test.js +111 -0
  134. package/dist/guard/code/types.js +6 -0
  135. package/dist/index.js +1 -0
  136. package/dist/index.test.js +5 -0
  137. package/dist/repo/boundary.js +92 -0
  138. package/dist/repo/boundary.test.js +65 -0
  139. package/dist/repo/errors.js +56 -0
  140. package/dist/repo/errors.test.js +85 -0
  141. package/dist/repo/exists.test.js +72 -0
  142. package/dist/repo/filename.js +46 -0
  143. package/dist/repo/filename.test.js +39 -0
  144. package/dist/repo/fs.js +53 -0
  145. package/dist/repo/index.js +7 -0
  146. package/dist/repo/overlay.js +36 -0
  147. package/dist/repo/overlay.test.js +80 -0
  148. package/dist/repo/repo.js +353 -0
  149. package/dist/repo/repo.test.js +255 -0
  150. package/dist/repo/testutil.js +27 -0
  151. package/dist/repo/write.test.js +125 -0
  152. package/dist/report/color.js +73 -0
  153. package/dist/report/index.js +1 -0
  154. package/dist/report/report.js +112 -0
  155. package/dist/report/report.test.js +368 -0
  156. package/dist/violation/index.js +1 -0
  157. package/dist/violation/types.js +22 -0
  158. package/dist/violation/types.test.js +70 -0
  159. package/package.json +48 -0
@@ -0,0 +1,353 @@
1
+ // Package repo is the OS boundary for docgov. It is the only package that
2
+ // touches the real filesystem directly and owns OS path handling. Everything
3
+ // else works with slash paths and the FS interface.
4
+ //
5
+ // Responsibilities:
6
+ // - Walk CWD up to the nearest ancestor containing a .docgov/ directory.
7
+ // - Fall back to the supplied directory when no .docgov/ exists (gen/add
8
+ // bootstrapping mode).
9
+ // - Return a Repo that exposes an FS and the OS root path.
10
+ // - CRLF -> LF normalisation on every file read.
11
+ import * as nodefs from "node:fs/promises";
12
+ import * as path from "node:path";
13
+ import { isNotExist, RealFS } from "./fs.js";
14
+ import { OverlayFS } from "./overlay.js";
15
+ // sentinel is the directory name that marks a docgov repository root.
16
+ const sentinel = ".docgov";
17
+ /**
18
+ * Holds the resolved repository root. It is the sole owner of the OS path; all
19
+ * callers work through the {@link FS} interface or the slash-path readFile
20
+ * helper.
21
+ */
22
+ export class Repo {
23
+ rootPath;
24
+ fsys;
25
+ /** @internal Use {@link find} or {@link Repo.withOverlay}. */
26
+ constructor(rootPath, fsys) {
27
+ this.rootPath = rootPath;
28
+ this.fsys = fsys;
29
+ }
30
+ /** The absolute OS path of the repository root. */
31
+ root() {
32
+ return this.rootPath;
33
+ }
34
+ /**
35
+ * The {@link FS} rooted at the repository root. All names passed to its
36
+ * methods must use forward slashes (the io/fs convention).
37
+ */
38
+ fs() {
39
+ return this.fsys;
40
+ }
41
+ /**
42
+ * Reads the named file (slash path, relative to repo root) and returns its
43
+ * contents with CRLF sequences normalised to LF. Throws on I/O failure.
44
+ */
45
+ async readFile(name) {
46
+ const data = await this.fsys.readFile(name);
47
+ return normalizeCRLF(data);
48
+ }
49
+ /**
50
+ * Reports whether a regular file exists at `repoRel` (a slash path relative
51
+ * to the repo root) with exact case for every path segment. Returns false for
52
+ * directories — the docs guard checks file references, not directories.
53
+ *
54
+ * Exact-case check: a host filesystem stat case-folds on macOS and Windows,
55
+ * so it would wrongly accept "Docs/ADR/0001.md" when the real path is
56
+ * "docs/adr/0001.md". This walks each directory's entries and matches each
57
+ * segment by exact string equality, so a case mismatch at any segment returns
58
+ * false regardless of host behaviour.
59
+ *
60
+ * I/O errors: a genuinely missing path returns false. Any other directory
61
+ * read error is propagated (Rule 12 — fail loud) so callers can distinguish
62
+ * "absent" from "broken repo".
63
+ */
64
+ async exists(repoRel) {
65
+ // Normalise: strip leading slash, then clean using slash semantics so the
66
+ // logical slash path is never contaminated by OS-specific separators.
67
+ let rel = repoRel.startsWith("/") ? repoRel.slice(1) : repoRel;
68
+ rel = path.posix.normalize(rel);
69
+ if (rel === "." || rel === "") {
70
+ return false;
71
+ }
72
+ const segments = rel.split("/");
73
+ let current = this.fsys;
74
+ for (let i = 0; i < segments.length; i++) {
75
+ const seg = segments[i];
76
+ let entries;
77
+ try {
78
+ entries = await current.readDir(".");
79
+ }
80
+ catch (err) {
81
+ if (isNotExist(err)) {
82
+ return false;
83
+ }
84
+ throw err;
85
+ }
86
+ // Find an entry matching this segment with exact case.
87
+ const found = entries.find((e) => e.name() === seg);
88
+ if (!found) {
89
+ // No exact-case match at this segment.
90
+ return false;
91
+ }
92
+ const isLast = i === segments.length - 1;
93
+ if (isLast) {
94
+ // The final segment must be a regular file (not a directory).
95
+ return !found.isDir();
96
+ }
97
+ // Not the final segment: descend into the subdirectory.
98
+ if (!found.isDir()) {
99
+ // Expected a directory but found a file — path not valid.
100
+ return false;
101
+ }
102
+ current = current.sub(seg);
103
+ }
104
+ return false;
105
+ }
106
+ /**
107
+ * Reports whether a regular file exists at `slashPath` (slash path relative
108
+ * to the repo root). Returns false for directories and for any path that
109
+ * cannot be resolved — I/O errors are treated as "not present" so the caller
110
+ * falls through to the subsequent write, which surfaces the error with richer
111
+ * context. This intentionally differs from {@link exists}, which propagates
112
+ * I/O errors so the docs guard can distinguish "absent" from "broken repo".
113
+ */
114
+ async fileExists(slashPath) {
115
+ let abs;
116
+ try {
117
+ abs = this.toAbs(slashPath);
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ try {
123
+ const info = await nodefs.stat(abs);
124
+ return info.isFile();
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ /**
131
+ * Returns a new Repo identical to this one except that reads of `slashPath`
132
+ * (via {@link fs} or {@link readFile}) return `content` instead of the
133
+ * on-disk bytes. All other paths delegate to the base FS. The base Repo is
134
+ * not modified.
135
+ */
136
+ withOverlay(slashPath, content) {
137
+ const ov = new OverlayFS(this.fsys, slashPath, content);
138
+ return new Repo(this.rootPath, ov);
139
+ }
140
+ // --- write capability (the sole owner of all OS writes) ---
141
+ /**
142
+ * Writes `data` to the file at `slashPath` (slash-relative to the repo root),
143
+ * creating all parent directories as needed. Throws if `slashPath` would
144
+ * escape the repo root via ".." traversal.
145
+ */
146
+ async writeFile(slashPath, data) {
147
+ const abs = this.toAbs(slashPath);
148
+ try {
149
+ await nodefs.mkdir(path.dirname(abs), { recursive: true });
150
+ }
151
+ catch (err) {
152
+ throw new Error(`repo.writeFile: mkdir parents for ${JSON.stringify(slashPath)}: ${errMsg(err)}`);
153
+ }
154
+ try {
155
+ await nodefs.writeFile(abs, data);
156
+ }
157
+ catch (err) {
158
+ throw new Error(`repo.writeFile: write ${JSON.stringify(slashPath)}: ${errMsg(err)}`);
159
+ }
160
+ }
161
+ /**
162
+ * Renames the directory at `oldSlashPath` to `newSlashPath` (both
163
+ * slash-relative to the repo root). The parent of `newSlashPath` must already
164
+ * exist (os.Rename semantics).
165
+ */
166
+ async renameDir(oldSlashPath, newSlashPath) {
167
+ const oldAbs = this.toAbs(oldSlashPath);
168
+ const newAbs = this.toAbs(newSlashPath);
169
+ try {
170
+ await nodefs.rename(oldAbs, newAbs);
171
+ }
172
+ catch (err) {
173
+ throw new Error(`repo.renameDir: rename ${JSON.stringify(oldSlashPath)} -> ${JSON.stringify(newSlashPath)}: ${errMsg(err)}`);
174
+ }
175
+ }
176
+ /**
177
+ * Removes the directory tree rooted at `slashPath` (slash-relative to the
178
+ * repo root). A no-op when the path does not exist, matching the
179
+ * write-with-backup stale-.bak cleanup use-case.
180
+ */
181
+ async removeAll(slashPath) {
182
+ const abs = this.toAbs(slashPath);
183
+ try {
184
+ await nodefs.rm(abs, { recursive: true, force: true });
185
+ }
186
+ catch (err) {
187
+ throw new Error(`repo.removeAll: remove ${JSON.stringify(slashPath)}: ${errMsg(err)}`);
188
+ }
189
+ }
190
+ /**
191
+ * Reports whether a directory exists at `slashPath` (slash-relative to the
192
+ * repo root). Returns false for any stat error (including not-found).
193
+ */
194
+ async dirExists(slashPath) {
195
+ let abs;
196
+ try {
197
+ abs = this.toAbs(slashPath);
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ try {
203
+ const info = await nodefs.stat(abs);
204
+ return info.isDirectory();
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /**
211
+ * Writes `content` to a temp file that is a sibling of the file at
212
+ * `slashPath` (same directory, ".docgov-flip.tmp" suffix), then returns a
213
+ * {@link PendingWrite}. The caller calls commit to either rename the temp
214
+ * into place (ok=true) or remove it (ok=false). The temp file is always in
215
+ * the same directory as the target so the rename stays atomic on a single
216
+ * filesystem.
217
+ */
218
+ async atomicWriteFile(slashPath, content) {
219
+ const abs = this.toAbs(slashPath);
220
+ try {
221
+ await nodefs.mkdir(path.dirname(abs), { recursive: true });
222
+ }
223
+ catch (err) {
224
+ throw new Error(`repo.atomicWriteFile: mkdir parents for ${JSON.stringify(slashPath)}: ${errMsg(err)}`);
225
+ }
226
+ const tmpAbs = abs + ".docgov-flip.tmp";
227
+ try {
228
+ await nodefs.writeFile(tmpAbs, content);
229
+ }
230
+ catch (err) {
231
+ throw new Error(`repo.atomicWriteFile: write temp for ${JSON.stringify(slashPath)}: ${errMsg(err)}`);
232
+ }
233
+ return new PendingWrite(tmpAbs, abs);
234
+ }
235
+ /**
236
+ * Converts a slash-relative path to an absolute OS path under the repo root.
237
+ * Throws if the result would escape the repo root (".." traversal).
238
+ */
239
+ toAbs(slashPath) {
240
+ // Convert slash path to OS-native segments, then join under root.
241
+ const abs = path.join(this.rootPath, ...slashPath.split("/"));
242
+ // Verify the result is still within the repo root. path.join collapses ".."
243
+ // elements, so checking the prefix is sufficient.
244
+ let rootWithSep = this.rootPath;
245
+ if (!rootWithSep.endsWith(path.sep)) {
246
+ rootWithSep += path.sep;
247
+ }
248
+ if (abs !== this.rootPath && !abs.startsWith(rootWithSep)) {
249
+ throw new Error(`repo: path ${JSON.stringify(slashPath)} escapes repository root ${JSON.stringify(this.rootPath)}`);
250
+ }
251
+ return abs;
252
+ }
253
+ }
254
+ /**
255
+ * Holds the paths needed to complete or roll back an atomic write started by
256
+ * {@link Repo.atomicWriteFile}. Call commit(true) to rename the temp file into
257
+ * place, or commit(false) to remove the temp file and leave the original
258
+ * untouched.
259
+ */
260
+ export class PendingWrite {
261
+ tmpAbs;
262
+ finalAbs;
263
+ /** @internal */
264
+ constructor(tmpAbs, finalAbs) {
265
+ this.tmpAbs = tmpAbs;
266
+ this.finalAbs = finalAbs;
267
+ }
268
+ /**
269
+ * Finalises (ok=true: atomic rename into place) or rolls back (ok=false:
270
+ * remove the temp, leave the original untouched) the pending write.
271
+ */
272
+ async commit(ok) {
273
+ if (!ok) {
274
+ try {
275
+ await nodefs.rm(this.tmpAbs);
276
+ }
277
+ catch (err) {
278
+ if (!isNotExist(err)) {
279
+ throw new Error(`repo.PendingWrite.commit(rollback): remove temp ${JSON.stringify(this.tmpAbs)}: ${errMsg(err)}`);
280
+ }
281
+ }
282
+ return;
283
+ }
284
+ try {
285
+ await nodefs.rename(this.tmpAbs, this.finalAbs);
286
+ }
287
+ catch (err) {
288
+ throw new Error(`repo.PendingWrite.commit(commit): rename ${JSON.stringify(this.tmpAbs)} -> ${JSON.stringify(this.finalAbs)}: ${errMsg(err)}`);
289
+ }
290
+ }
291
+ /**
292
+ * Exposes the temp path so tests can assert the atomicity invariant — that
293
+ * the temp file is a sibling of the target, not in the OS temp dir. Mirrors
294
+ * Go's TmpAbsForTest export-test helper.
295
+ */
296
+ tmpAbsForTest() {
297
+ return this.tmpAbs;
298
+ }
299
+ }
300
+ /**
301
+ * Walks up from `dir` to locate the nearest ancestor containing a .docgov/
302
+ * directory. If none is found it falls back to `dir` itself (so gen and add can
303
+ * bootstrap a repo that has no .docgov/ yet). `dir` is resolved to an absolute
304
+ * OS path first.
305
+ */
306
+ export async function find(dir) {
307
+ const abs = path.resolve(dir);
308
+ let current = abs;
309
+ for (;;) {
310
+ const candidate = path.join(current, sentinel);
311
+ let isDir = false;
312
+ try {
313
+ const info = await nodefs.stat(candidate);
314
+ isDir = info.isDirectory();
315
+ }
316
+ catch {
317
+ isDir = false;
318
+ }
319
+ if (isDir) {
320
+ // Found the sentinel directory.
321
+ return new Repo(current, new RealFS(current));
322
+ }
323
+ const parent = path.dirname(current);
324
+ if (parent === current) {
325
+ // Reached the filesystem root without finding .docgov/. Fall back to the
326
+ // original dir (bootstrapping mode).
327
+ return new Repo(abs, new RealFS(abs));
328
+ }
329
+ current = parent;
330
+ }
331
+ }
332
+ // normalizeCRLF replaces every CRLF (0x0d 0x0a) byte sequence with a single LF,
333
+ // matching Go's bytes.ReplaceAll(data, "\r\n", "\n"). Operates on raw bytes so
334
+ // no text decoding is involved.
335
+ function normalizeCRLF(data) {
336
+ // Fast path: no CR present.
337
+ if (!data.includes(0x0d)) {
338
+ return data;
339
+ }
340
+ const out = [];
341
+ for (let i = 0; i < data.length; i++) {
342
+ if (data[i] === 0x0d && i + 1 < data.length && data[i + 1] === 0x0a) {
343
+ out.push(0x0a);
344
+ i++; // skip the LF; the CRLF pair becomes a single LF
345
+ continue;
346
+ }
347
+ out.push(data[i]);
348
+ }
349
+ return Uint8Array.from(out);
350
+ }
351
+ function errMsg(err) {
352
+ return err instanceof Error ? err.message : String(err);
353
+ }
@@ -0,0 +1,255 @@
1
+ import * as nodefs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { describe, expect, test } from "vitest";
4
+ import { find } from "./repo.js";
5
+ import { makeDir } from "./testutil.js";
6
+ const dec = new TextDecoder();
7
+ describe("find: repo-root discovery", () => {
8
+ // WHY: when a .docgov/ exists in the ancestor chain, that ancestor is the
9
+ // repo root — governance config lives relative to it.
10
+ test("returns the ancestor that owns .docgov/ when CWD is the root", async () => {
11
+ const root = await makeDir(".docgov");
12
+ const r = await find(root);
13
+ expect(r.root()).toBe(root);
14
+ // The FS must be usable — list the sentinel dir through it.
15
+ const entries = await r.fs().readDir(".");
16
+ expect(entries.map((e) => e.name())).toContain(".docgov");
17
+ });
18
+ // WHY: gen/add must be able to bootstrap a repo that has no .docgov/ yet;
19
+ // falling back to the supplied dir is what makes that possible.
20
+ test("falls back to the supplied dir when no .docgov/ exists (bootstrapping)", async () => {
21
+ const root = await makeDir(); // no .docgov
22
+ const r = await find(root);
23
+ expect(r.root()).toBe(root);
24
+ });
25
+ // WHY: a check run from a nested working directory must resolve the same repo
26
+ // root as a run from the top — the walk-up is what guarantees that.
27
+ test("walks up from a nested directory to the .docgov/ owner", async () => {
28
+ const root = await makeDir(".docgov", "a/b/c");
29
+ const nested = path.join(root, "a", "b", "c");
30
+ const r = await find(nested);
31
+ expect(r.root()).toBe(root);
32
+ });
33
+ });
34
+ // WHY: callers must never see carriage returns; normalisation at the read
35
+ // boundary keeps every downstream parser CRLF-agnostic and reports
36
+ // byte-identical across platforms.
37
+ test("readFile normalises CRLF to LF so callers never see CR", async () => {
38
+ const root = await makeDir(".docgov");
39
+ await nodefs.writeFile(path.join(root, "test.txt"), "line one\r\nline two\r\n");
40
+ const r = await find(root);
41
+ const got = dec.decode(await r.readFile("test.txt"));
42
+ expect(got).not.toContain("\r");
43
+ expect(got).toBe("line one\nline two\n");
44
+ });
45
+ const enc = new TextEncoder();
46
+ describe("exists: exact-case file presence for the docs guard", () => {
47
+ // WHY: the docs guard validates file references; a real regular file at the
48
+ // exact path must report present so a valid reference is not flagged broken.
49
+ test("returns true for an existing regular file (exact case)", async () => {
50
+ const root = await makeDir(".docgov", "docs/adr");
51
+ const r = await find(root);
52
+ await r.writeFile("docs/adr/0001.md", enc.encode("x\n"));
53
+ expect(await r.exists("docs/adr/0001.md")).toBe(true);
54
+ });
55
+ // WHY: host filesystems case-fold on macOS/Windows; exists() must NOT, or a
56
+ // wrong-case reference ("Docs/...") would be wrongly accepted and ship broken.
57
+ test("returns false when a path segment differs only in case", async () => {
58
+ const root = await makeDir(".docgov", "docs/adr");
59
+ const r = await find(root);
60
+ await r.writeFile("docs/adr/0001.md", enc.encode("x\n"));
61
+ expect(await r.exists("Docs/adr/0001.md")).toBe(false);
62
+ expect(await r.exists("docs/adr/0001.MD")).toBe(false);
63
+ });
64
+ // WHY: the guard checks file references, not directory references; a reference
65
+ // that resolves to a directory must be rejected as not-a-file.
66
+ test("returns false when the final segment is a directory", async () => {
67
+ const root = await makeDir(".docgov", "docs/adr");
68
+ const r = await find(root);
69
+ expect(await r.exists("docs/adr")).toBe(false);
70
+ });
71
+ // WHY: a reference whose interior segment is a file (not a dir) cannot be
72
+ // descended into; treating it as present would mask a malformed reference.
73
+ test("returns false when an interior segment is a file, not a directory", async () => {
74
+ const root = await makeDir(".docgov", "docs");
75
+ const r = await find(root);
76
+ await r.writeFile("docs/a.md", enc.encode("x\n"));
77
+ // a.md is a file, so "a.md/inner.md" cannot descend.
78
+ expect(await r.exists("docs/a.md/inner.md")).toBe(false);
79
+ });
80
+ // WHY: a missing leaf in an existing directory is the common "broken
81
+ // reference" case the guard must catch — it must report absent, not error.
82
+ test("returns false for a missing final segment", async () => {
83
+ const root = await makeDir(".docgov", "docs");
84
+ const r = await find(root);
85
+ expect(await r.exists("docs/missing.md")).toBe(false);
86
+ });
87
+ // WHY: an empty or root-only path is never a file reference; returning false
88
+ // keeps the guard from treating "." as a present file.
89
+ test("returns false for the root / empty path", async () => {
90
+ const root = await makeDir(".docgov");
91
+ const r = await find(root);
92
+ expect(await r.exists("/")).toBe(false);
93
+ expect(await r.exists("")).toBe(false);
94
+ });
95
+ // WHY: a leading slash is a slash-relative reference, not an absolute OS path;
96
+ // it must be stripped so the lookup still resolves under the repo root.
97
+ test("strips a leading slash before resolving", async () => {
98
+ const root = await makeDir(".docgov", "docs");
99
+ const r = await find(root);
100
+ await r.writeFile("docs/a.md", enc.encode("x\n"));
101
+ expect(await r.exists("/docs/a.md")).toBe(true);
102
+ });
103
+ });
104
+ describe("fileExists / dirExists: write-with-backup precondition checks", () => {
105
+ // WHY: the write-with-backup layer asks "is there already a file here?"; a
106
+ // genuine file must report true so it backs up instead of clobbering.
107
+ test("fileExists is true for a file and false for a directory or missing path", async () => {
108
+ const root = await makeDir(".docgov", "docs");
109
+ const r = await find(root);
110
+ await r.writeFile("docs/a.md", enc.encode("x\n"));
111
+ expect(await r.fileExists("docs/a.md")).toBe(true);
112
+ expect(await r.fileExists("docs")).toBe(false); // a directory
113
+ expect(await r.fileExists("docs/missing.md")).toBe(false);
114
+ });
115
+ // WHY: fileExists swallows resolution faults as "not present" (unlike exists)
116
+ // so the caller falls through to the write that surfaces a richer error; an
117
+ // escaping path must therefore be reported absent, not throw here.
118
+ test("fileExists returns false for a path that escapes the repo root", async () => {
119
+ const root = await makeDir(".docgov");
120
+ const r = await find(root);
121
+ expect(await r.fileExists("../outside.md")).toBe(false);
122
+ });
123
+ // WHY: dirExists gates directory operations (rename targets, group dirs); a
124
+ // real directory must report true and a file or missing path false.
125
+ test("dirExists is true for a directory and false for a file or missing path", async () => {
126
+ const root = await makeDir(".docgov", "docs");
127
+ const r = await find(root);
128
+ await r.writeFile("docs/a.md", enc.encode("x\n"));
129
+ expect(await r.dirExists("docs")).toBe(true);
130
+ expect(await r.dirExists("docs/a.md")).toBe(false); // a file
131
+ expect(await r.dirExists("docs/missing")).toBe(false);
132
+ });
133
+ // WHY: same swallow-faults contract — an escaping directory path must be
134
+ // reported absent rather than throwing, so callers branch on a boolean.
135
+ test("dirExists returns false for a path that escapes the repo root", async () => {
136
+ const root = await makeDir(".docgov");
137
+ const r = await find(root);
138
+ expect(await r.dirExists("../outside")).toBe(false);
139
+ });
140
+ });
141
+ describe("writeFile: the sole owner of OS writes", () => {
142
+ // WHY: docgov generates files into nested doc dirs that may not exist yet;
143
+ // writeFile must create parents so generation does not fail on a fresh repo.
144
+ test("creates missing parent directories", async () => {
145
+ const root = await makeDir(".docgov");
146
+ const r = await find(root);
147
+ await r.writeFile("docs/deep/nested/a.md", enc.encode("hello\n"));
148
+ expect(dec.decode(await r.readFile("docs/deep/nested/a.md"))).toBe("hello\n");
149
+ });
150
+ // WHY: writeFile is the single guarded write boundary; a path escaping the
151
+ // root must be refused (via toAbs) so generation can never scribble outside
152
+ // the repo. The error names the offending path for diagnosis.
153
+ test("refuses a path that escapes the repo root", async () => {
154
+ const root = await makeDir(".docgov");
155
+ const r = await find(root);
156
+ await expect(r.writeFile("../escape.md", enc.encode("x"))).rejects.toThrow(/escapes repository root/);
157
+ });
158
+ });
159
+ describe("renameDir / removeAll: directory lifecycle", () => {
160
+ // WHY: rename is used to move a generated group dir into place; it must move
161
+ // the tree atomically so a half-renamed dir never appears.
162
+ test("renameDir moves a directory tree to a new name", async () => {
163
+ const root = await makeDir(".docgov", "old");
164
+ const r = await find(root);
165
+ await r.writeFile("old/a.md", enc.encode("x\n"));
166
+ await r.renameDir("old", "new");
167
+ expect(await r.dirExists("old")).toBe(false);
168
+ expect(await r.fileExists("new/a.md")).toBe(true);
169
+ });
170
+ // WHY: a failed rename (e.g. missing source) must throw with context naming
171
+ // both paths so the operator can see which move broke — Rule 12 fail loud.
172
+ test("renameDir throws with both paths when the source is missing", async () => {
173
+ const root = await makeDir(".docgov");
174
+ const r = await find(root);
175
+ await expect(r.renameDir("missing", "dest")).rejects.toThrow(/repo\.renameDir/);
176
+ });
177
+ // WHY: removeAll backs the stale-.bak cleanup; it must delete an existing tree
178
+ // so a leftover backup dir does not block the next write.
179
+ test("removeAll deletes an existing directory tree", async () => {
180
+ const root = await makeDir(".docgov", "junk");
181
+ const r = await find(root);
182
+ await r.writeFile("junk/a.md", enc.encode("x\n"));
183
+ await r.removeAll("junk");
184
+ expect(await r.dirExists("junk")).toBe(false);
185
+ });
186
+ // WHY: the cleanup use-case calls removeAll even when nothing is there; it
187
+ // must be a no-op (force:true), never throw, so cleanup is idempotent.
188
+ test("removeAll is a no-op for a missing path", async () => {
189
+ const root = await makeDir(".docgov");
190
+ const r = await find(root);
191
+ await expect(r.removeAll("nope")).resolves.toBeUndefined();
192
+ });
193
+ });
194
+ describe("atomicWriteFile + PendingWrite: crash-safe flips", () => {
195
+ // WHY: the flip writes to a SIBLING temp so the rename stays on one filesystem
196
+ // and is atomic; if the temp landed in the OS temp dir the rename could cross
197
+ // devices and silently degrade to a non-atomic copy.
198
+ test("writes a sibling temp file, not one in the OS temp dir", async () => {
199
+ const root = await makeDir(".docgov", "docs");
200
+ const r = await find(root);
201
+ const pending = await r.atomicWriteFile("docs/a.md", enc.encode("new\n"));
202
+ const tmp = pending.tmpAbsForTest();
203
+ expect(tmp).toBe(path.join(root, "docs", "a.md.docgov-flip.tmp"));
204
+ // The target itself is not yet written before commit.
205
+ expect(await r.fileExists("docs/a.md")).toBe(false);
206
+ await pending.commit(true);
207
+ });
208
+ // WHY: commit(true) is what makes the new content visible; after it the target
209
+ // holds the new bytes and the temp is gone (renamed into place).
210
+ test("commit(true) renames the temp into place", async () => {
211
+ const root = await makeDir(".docgov", "docs");
212
+ const r = await find(root);
213
+ const pending = await r.atomicWriteFile("docs/a.md", enc.encode("committed\n"));
214
+ await pending.commit(true);
215
+ expect(dec.decode(await r.readFile("docs/a.md"))).toBe("committed\n");
216
+ expect(await r.fileExists(pending.tmpAbsForTest())).toBe(false);
217
+ });
218
+ // WHY: commit(false) is the rollback path; it must remove the temp and leave
219
+ // any original untouched, so an aborted flip changes nothing on disk.
220
+ test("commit(false) removes the temp and leaves the original untouched", async () => {
221
+ const root = await makeDir(".docgov", "docs");
222
+ const r = await find(root);
223
+ await r.writeFile("docs/a.md", enc.encode("original\n"));
224
+ const pending = await r.atomicWriteFile("docs/a.md", enc.encode("new\n"));
225
+ await pending.commit(false);
226
+ expect(dec.decode(await r.readFile("docs/a.md"))).toBe("original\n");
227
+ expect(await nodefs.stat(pending.tmpAbsForTest()).then(() => true).catch(() => false)).toBe(false);
228
+ });
229
+ // WHY: rollback must be idempotent — if the temp is already gone, commit(false)
230
+ // must swallow the ENOENT (not re-throw), so a double-rollback never crashes
231
+ // the cleanup path.
232
+ test("commit(false) is a no-op when the temp is already removed", async () => {
233
+ const root = await makeDir(".docgov", "docs");
234
+ const r = await find(root);
235
+ const pending = await r.atomicWriteFile("docs/a.md", enc.encode("x\n"));
236
+ await nodefs.rm(pending.tmpAbsForTest()); // remove it out from under commit
237
+ await expect(pending.commit(false)).resolves.toBeUndefined();
238
+ });
239
+ // WHY: atomicWriteFile must create the target's parent dirs, mirroring
240
+ // writeFile, so a flip into a not-yet-existing doc dir succeeds.
241
+ test("creates missing parent directories for the temp", async () => {
242
+ const root = await makeDir(".docgov");
243
+ const r = await find(root);
244
+ const pending = await r.atomicWriteFile("docs/new/a.md", enc.encode("y\n"));
245
+ await pending.commit(true);
246
+ expect(dec.decode(await r.readFile("docs/new/a.md"))).toBe("y\n");
247
+ });
248
+ // WHY: atomicWriteFile is also a guarded write; an escaping path must be
249
+ // refused before any temp is created.
250
+ test("refuses a path that escapes the repo root", async () => {
251
+ const root = await makeDir(".docgov");
252
+ const r = await find(root);
253
+ await expect(r.atomicWriteFile("../escape.md", enc.encode("x"))).rejects.toThrow(/escapes repository root/);
254
+ });
255
+ });
@@ -0,0 +1,27 @@
1
+ import * as nodefs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach } from "vitest";
5
+ // makeDir is the TS analogue of the Go test helper of the same name: it creates
6
+ // a fresh temp directory and any requested subdirectories (slash-separated,
7
+ // relative to the root) and returns the root OS path. Created roots are removed
8
+ // after each test.
9
+ const createdRoots = [];
10
+ afterEach(async () => {
11
+ for (const root of createdRoots.splice(0)) {
12
+ await nodefs.rm(root, { recursive: true, force: true });
13
+ }
14
+ });
15
+ export async function makeDir(...dirs) {
16
+ const root = await nodefs.mkdtemp(path.join(os.tmpdir(), "docgov-repo-"));
17
+ // Resolve symlinks (macOS /var -> /private/var) so toAbs prefix checks and
18
+ // root equality assertions compare canonical paths.
19
+ const realRoot = await nodefs.realpath(root);
20
+ createdRoots.push(realRoot);
21
+ for (const d of dirs) {
22
+ await nodefs.mkdir(path.join(realRoot, ...d.split("/")), {
23
+ recursive: true,
24
+ });
25
+ }
26
+ return realRoot;
27
+ }