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.
- package/README.md +242 -0
- package/dist/apispec/apispec.js +401 -0
- package/dist/apispec/apispec.test.js +444 -0
- package/dist/apispec/errors.js +17 -0
- package/dist/apispec/index.js +2 -0
- package/dist/check/doclinks.js +167 -0
- package/dist/check/index.js +8 -0
- package/dist/check/run.js +391 -0
- package/dist/check/run.test.js +513 -0
- package/dist/check/suggest.js +134 -0
- package/dist/check/suggest.test.js +92 -0
- package/dist/check/tokens.js +125 -0
- package/dist/cmd/main.js +330 -0
- package/dist/cmd/main.test.js +422 -0
- package/dist/codeq/cache.js +71 -0
- package/dist/codeq/cache.test.js +67 -0
- package/dist/codeq/errors.js +52 -0
- package/dist/codeq/grammars/tree-sitter-go.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-java.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/codeq/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/codeq/index.js +11 -0
- package/dist/codeq/resolve.test.js +109 -0
- package/dist/codeq/resolver.js +128 -0
- package/dist/codeq/resolver.test.js +124 -0
- package/dist/codeq/resolvers/go.js +242 -0
- package/dist/codeq/resolvers/go.test.js +143 -0
- package/dist/codeq/resolvers/java.js +349 -0
- package/dist/codeq/resolvers/java.test.js +138 -0
- package/dist/codeq/resolvers/java_queries.js +63 -0
- package/dist/codeq/resolvers/javascript.js +412 -0
- package/dist/codeq/resolvers/javascript.test.js +125 -0
- package/dist/codeq/resolvers/javascript_queries.js +46 -0
- package/dist/codeq/resolvers/typescript.js +366 -0
- package/dist/codeq/resolvers/typescript.test.js +180 -0
- package/dist/codeq/resolvers/typescript_queries.js +78 -0
- package/dist/codeq/signature.js +50 -0
- package/dist/codeq/signature.test.js +50 -0
- package/dist/codeq/suggest.js +96 -0
- package/dist/codeq/treesitter.js +122 -0
- package/dist/codeq/treesitter.test.js +118 -0
- package/dist/config/config.js +74 -0
- package/dist/config/config.test.js +98 -0
- package/dist/config/fs.js +116 -0
- package/dist/config/glob.js +82 -0
- package/dist/config/glob.test.js +61 -0
- package/dist/config/index.js +4 -0
- package/dist/dedup/analyzer/analyzer.js +533 -0
- package/dist/dedup/analyzer/analyzer.test.js +530 -0
- package/dist/dedup/analyzer/canonical.js +74 -0
- package/dist/dedup/analyzer/canonical.test.js +70 -0
- package/dist/dedup/analyzer/cosine_clusters.js +169 -0
- package/dist/dedup/analyzer/cosine_clusters.test.js +131 -0
- package/dist/dedup/analyzer/distinctive.js +85 -0
- package/dist/dedup/analyzer/distinctive.test.js +49 -0
- package/dist/dedup/analyzer/exact_clusters.js +63 -0
- package/dist/dedup/analyzer/exact_clusters.test.js +81 -0
- package/dist/dedup/analyzer/index.js +14 -0
- package/dist/dedup/analyzer/multiplicity.js +110 -0
- package/dist/dedup/analyzer/multiplicity.test.js +123 -0
- package/dist/dedup/analyzer/order.js +22 -0
- package/dist/dedup/analyzer/partial_overlaps.js +65 -0
- package/dist/dedup/analyzer/partial_overlaps.test.js +161 -0
- package/dist/dedup/analyzer/preview.js +84 -0
- package/dist/dedup/analyzer/preview.test.js +46 -0
- package/dist/dedup/analyzer/safety.js +27 -0
- package/dist/dedup/analyzer/safety.test.js +39 -0
- package/dist/dedup/config.js +18 -0
- package/dist/dedup/configload.js +299 -0
- package/dist/dedup/configload.test.js +410 -0
- package/dist/dedup/dedup.index.test.js +203 -0
- package/dist/dedup/dedup.js +143 -0
- package/dist/dedup/dedup.test.js +212 -0
- package/dist/dedup/dedupcfg/config.js +112 -0
- package/dist/dedup/dedupcfg/config.test.js +70 -0
- package/dist/dedup/dedupcfg/index.js +1 -0
- package/dist/dedup/deduptypes/index.js +1 -0
- package/dist/dedup/deduptypes/types.js +9 -0
- package/dist/dedup/deduptypes/types.test.js +34 -0
- package/dist/dedup/embedder/cache.js +23 -0
- package/dist/dedup/embedder/cache.test.js +50 -0
- package/dist/dedup/embedder/constants.js +10 -0
- package/dist/dedup/embedder/embedder.js +76 -0
- package/dist/dedup/embedder/embedder.mock.test.js +128 -0
- package/dist/dedup/embedder/embedder.test.js +96 -0
- package/dist/dedup/embedder/errors.js +20 -0
- package/dist/dedup/embedder/errors.test.js +35 -0
- package/dist/dedup/embedder/index.js +4 -0
- package/dist/dedup/embedder/session.js +78 -0
- package/dist/dedup/embedder/session.test.js +172 -0
- package/dist/dedup/gitignore.js +97 -0
- package/dist/dedup/gitignore.test.js +98 -0
- package/dist/dedup/index.js +11 -0
- package/dist/dedup/indexdb/errors.js +48 -0
- package/dist/dedup/indexdb/index.js +6 -0
- package/dist/dedup/indexdb/indexdb.js +302 -0
- package/dist/dedup/indexdb/indexdb.test.js +739 -0
- package/dist/dedup/indexdb/load.js +110 -0
- package/dist/dedup/indexdb/migrations.js +58 -0
- package/dist/dedup/indexdb/schema.js +83 -0
- package/dist/dedup/indexer/index.js +9 -0
- package/dist/dedup/indexer/indexer.js +501 -0
- package/dist/dedup/indexer/indexer.test.js +510 -0
- package/dist/dedup/indexer/links.js +89 -0
- package/dist/dedup/mdsection/anchor.js +60 -0
- package/dist/dedup/mdsection/anchor.test.js +39 -0
- package/dist/dedup/mdsection/blocks.js +409 -0
- package/dist/dedup/mdsection/blocks.test.js +359 -0
- package/dist/dedup/mdsection/index.js +4 -0
- package/dist/dedup/mdsection/parse.js +21 -0
- package/dist/dedup/mdsection/section.js +234 -0
- package/dist/dedup/mdsection/section.test.js +221 -0
- package/dist/dedup/report/floatfmt.js +71 -0
- package/dist/dedup/report/floatfmt.test.js +42 -0
- package/dist/dedup/report/index.js +8 -0
- package/dist/dedup/report/quote.js +77 -0
- package/dist/dedup/report/quote.test.js +67 -0
- package/dist/dedup/report/text.js +251 -0
- package/dist/dedup/report/text.test.js +420 -0
- package/dist/dedup/report_types.js +8 -0
- package/dist/dedup/sectionid/index.js +1 -0
- package/dist/dedup/sectionid/sectionid.js +16 -0
- package/dist/dedup/sectionid/sectionid.test.js +49 -0
- package/dist/guard/api/errors.js +12 -0
- package/dist/guard/api/index.js +2 -0
- package/dist/guard/api/parser.js +81 -0
- package/dist/guard/api/parser.test.js +58 -0
- package/dist/guard/api/types.js +1 -0
- package/dist/guard/code/errors.js +16 -0
- package/dist/guard/code/index.js +2 -0
- package/dist/guard/code/parser.js +54 -0
- package/dist/guard/code/parser.test.js +111 -0
- package/dist/guard/code/types.js +6 -0
- package/dist/index.js +1 -0
- package/dist/index.test.js +5 -0
- package/dist/repo/boundary.js +92 -0
- package/dist/repo/boundary.test.js +65 -0
- package/dist/repo/errors.js +56 -0
- package/dist/repo/errors.test.js +85 -0
- package/dist/repo/exists.test.js +72 -0
- package/dist/repo/filename.js +46 -0
- package/dist/repo/filename.test.js +39 -0
- package/dist/repo/fs.js +53 -0
- package/dist/repo/index.js +7 -0
- package/dist/repo/overlay.js +36 -0
- package/dist/repo/overlay.test.js +80 -0
- package/dist/repo/repo.js +353 -0
- package/dist/repo/repo.test.js +255 -0
- package/dist/repo/testutil.js +27 -0
- package/dist/repo/write.test.js +125 -0
- package/dist/report/color.js +73 -0
- package/dist/report/index.js +1 -0
- package/dist/report/report.js +112 -0
- package/dist/report/report.test.js +368 -0
- package/dist/violation/index.js +1 -0
- package/dist/violation/types.js +22 -0
- package/dist/violation/types.test.js +70 -0
- package/package.json +48 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { AmbiguousBoundaryError, FileExistsError, InvalidPatternError, InvalidTaskNameError, PacketCountOutOfRangeError, } from "./errors.js";
|
|
3
|
+
// WHY: these are the repo package's discriminable sentinels — callers branch on
|
|
4
|
+
// `instanceof` the way Go callers use errors.Is. If a class stopped extending
|
|
5
|
+
// Error, dropped its stable `name`, or stopped quoting the offending value into
|
|
6
|
+
// its message, callers could neither catch it specifically nor show the user
|
|
7
|
+
// which input was wrong. Each test pins the discriminability (instanceof + name)
|
|
8
|
+
// and that the load-bearing value is embedded in the message.
|
|
9
|
+
describe("InvalidPatternError", () => {
|
|
10
|
+
// WHY: validatePattern throws this for a malformed boundary glob; the message
|
|
11
|
+
// must carry the offending literal so the user can find the broken catalog
|
|
12
|
+
// entry, and instanceof must work so config-load can report it distinctly.
|
|
13
|
+
test("is an Error, names itself, and quotes the offending pattern", () => {
|
|
14
|
+
const err = new InvalidPatternError("docs/[");
|
|
15
|
+
expect(err).toBeInstanceOf(Error);
|
|
16
|
+
expect(err).toBeInstanceOf(InvalidPatternError);
|
|
17
|
+
expect(err.name).toBe("InvalidPatternError");
|
|
18
|
+
// JSON.stringify is the Go %q analogue used in the message.
|
|
19
|
+
expect(err.message).toContain(JSON.stringify("docs/["));
|
|
20
|
+
expect(err.message).toContain("invalid boundary pattern");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("AmbiguousBoundaryError", () => {
|
|
24
|
+
// WHY: the check layer wraps this when two equal-specificity boundaries match
|
|
25
|
+
// the same file; a stable default message lets the operator recognise the
|
|
26
|
+
// condition, and instanceof lets check.ResolveError discriminate it.
|
|
27
|
+
test("defaults to the boundary-ambiguity message and is discriminable", () => {
|
|
28
|
+
const err = new AmbiguousBoundaryError();
|
|
29
|
+
expect(err).toBeInstanceOf(Error);
|
|
30
|
+
expect(err).toBeInstanceOf(AmbiguousBoundaryError);
|
|
31
|
+
expect(err.name).toBe("AmbiguousBoundaryError");
|
|
32
|
+
expect(err.message).toBe("repo: boundary pattern matches multiple doc types");
|
|
33
|
+
});
|
|
34
|
+
// WHY: callers may attach a richer context message; the override must replace
|
|
35
|
+
// the default rather than be ignored, or the operator loses the specifics.
|
|
36
|
+
test("accepts a custom message override", () => {
|
|
37
|
+
const err = new AmbiguousBoundaryError("two boundaries hit docs/x.md");
|
|
38
|
+
expect(err.message).toBe("two boundaries hit docs/x.md");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("InvalidTaskNameError", () => {
|
|
42
|
+
// WHY: planHeaderFilename/packetFilename reject a bad slug with this; both the
|
|
43
|
+
// rejected name AND the required pattern must appear so the user can fix the
|
|
44
|
+
// slug without guessing the rule.
|
|
45
|
+
test("quotes the bad name and includes the required pattern", () => {
|
|
46
|
+
const pattern = "^[a-z0-9][a-z0-9-]*[a-z0-9]$";
|
|
47
|
+
const err = new InvalidTaskNameError("Bad Name", pattern);
|
|
48
|
+
expect(err).toBeInstanceOf(Error);
|
|
49
|
+
expect(err).toBeInstanceOf(InvalidTaskNameError);
|
|
50
|
+
expect(err.name).toBe("InvalidTaskNameError");
|
|
51
|
+
expect(err.message).toContain(JSON.stringify("Bad Name"));
|
|
52
|
+
expect(err.message).toContain(pattern);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("PacketCountOutOfRangeError", () => {
|
|
56
|
+
// WHY: packetFilename rejects an index outside 1..99 with this; the offending
|
|
57
|
+
// number must be in the message so the user sees what they passed, and
|
|
58
|
+
// instanceof lets callers branch on the range failure specifically.
|
|
59
|
+
test("includes the out-of-range packet number and the 1..99 bound", () => {
|
|
60
|
+
const err = new PacketCountOutOfRangeError(100);
|
|
61
|
+
expect(err).toBeInstanceOf(Error);
|
|
62
|
+
expect(err).toBeInstanceOf(PacketCountOutOfRangeError);
|
|
63
|
+
expect(err.name).toBe("PacketCountOutOfRangeError");
|
|
64
|
+
expect(err.message).toContain("100");
|
|
65
|
+
expect(err.message).toContain("1..99");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("FileExistsError", () => {
|
|
69
|
+
// WHY: the write-with-backup layer throws this when a target already exists;
|
|
70
|
+
// instanceof is how that layer distinguishes "already there" from a real I/O
|
|
71
|
+
// fault, and the default message documents the condition.
|
|
72
|
+
test("defaults to the file-exists message and is discriminable", () => {
|
|
73
|
+
const err = new FileExistsError();
|
|
74
|
+
expect(err).toBeInstanceOf(Error);
|
|
75
|
+
expect(err).toBeInstanceOf(FileExistsError);
|
|
76
|
+
expect(err.name).toBe("FileExistsError");
|
|
77
|
+
expect(err.message).toBe("repo: file already exists");
|
|
78
|
+
});
|
|
79
|
+
// WHY: callers may name the conflicting path; the override must win so the
|
|
80
|
+
// operator sees which file blocked the write.
|
|
81
|
+
test("accepts a custom message override", () => {
|
|
82
|
+
const err = new FileExistsError("repo: docs/a.md already exists");
|
|
83
|
+
expect(err.message).toBe("repo: docs/a.md already exists");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as nodefs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { find } from "./repo.js";
|
|
6
|
+
import { makeDir } from "./testutil.js";
|
|
7
|
+
describe("exists: exact-case file presence for the docs guard", () => {
|
|
8
|
+
// WHY: the docs guard calls exists to verify a referenced file is present.
|
|
9
|
+
// A false negative would flag a valid reference as a violation.
|
|
10
|
+
test("returns true for a file present at the exact-case path", async () => {
|
|
11
|
+
const root = await makeDir(".docgov", "docs/adr");
|
|
12
|
+
await nodefs.writeFile(path.join(root, "docs", "adr", "0001-foo.md"), "hello");
|
|
13
|
+
const r = await find(root);
|
|
14
|
+
expect(await r.exists("docs/adr/0001-foo.md")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
// WHY: a reference to a non-existent file is the most common docs guard
|
|
17
|
+
// violation; exists must detect absence.
|
|
18
|
+
test("returns false for a missing file", async () => {
|
|
19
|
+
const root = await makeDir(".docgov", "docs/adr");
|
|
20
|
+
const r = await find(root);
|
|
21
|
+
expect(await r.exists("docs/adr/nonexistent.md")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
// WHY: a host stat case-folds on macOS/Windows, so a plain stat would wrongly
|
|
24
|
+
// pass case-mismatched references. The segment-by-segment walk matches each
|
|
25
|
+
// entry by exact case so reports are byte-identical across platforms.
|
|
26
|
+
test("rejects a wrong-case filename segment", async () => {
|
|
27
|
+
const root = await makeDir(".docgov", "docs/adr");
|
|
28
|
+
await nodefs.writeFile(path.join(root, "docs", "adr", "0001-foo.md"), "hello");
|
|
29
|
+
const r = await find(root);
|
|
30
|
+
expect(await r.exists("docs/adr/0001-FOO.md")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
// WHY: the exact-case rule must hold at EVERY segment, not just the filename;
|
|
33
|
+
// a wrong-case directory prefix must fail too.
|
|
34
|
+
test("rejects a wrong-case directory segment", async () => {
|
|
35
|
+
const root = await makeDir(".docgov", "docs/adr");
|
|
36
|
+
await nodefs.writeFile(path.join(root, "docs", "adr", "0001-foo.md"), "hello");
|
|
37
|
+
const r = await find(root);
|
|
38
|
+
expect(await r.exists("Docs/adr/0001-foo.md")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
// WHY: the docs guard checks FILE references; a directory path is not a valid
|
|
41
|
+
// file reference and must not pass.
|
|
42
|
+
test("returns false for a directory path", async () => {
|
|
43
|
+
const root = await makeDir(".docgov", "docs/adr");
|
|
44
|
+
const r = await find(root);
|
|
45
|
+
expect(await r.exists("docs/adr")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
// WHY: absence must be (false, no error), not an error — otherwise the guard
|
|
48
|
+
// would abort on every reference to a file that doesn't exist yet, which is
|
|
49
|
+
// the guard's core purpose.
|
|
50
|
+
test("treats a missing path as false without throwing", async () => {
|
|
51
|
+
const root = await makeDir(".docgov");
|
|
52
|
+
const r = await find(root);
|
|
53
|
+
await expect(r.exists("does/not/exist.md")).resolves.toBe(false);
|
|
54
|
+
});
|
|
55
|
+
// WHY: Rule 12 (fail loud). A permission/IO fault masquerading as "absent"
|
|
56
|
+
// would silently pass guard checks that should surface the fault. Root
|
|
57
|
+
// bypasses permission bits, so this can only be exercised as a non-root user.
|
|
58
|
+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
59
|
+
const ioErrorTest = isRoot || os.platform() === "win32" ? test.skip : test;
|
|
60
|
+
ioErrorTest("propagates a real I/O error rather than swallowing it as absent", async () => {
|
|
61
|
+
const root = await makeDir(".docgov", "docs");
|
|
62
|
+
const r = await find(root);
|
|
63
|
+
const docsPath = path.join(root, "docs");
|
|
64
|
+
await nodefs.chmod(docsPath, 0o000);
|
|
65
|
+
try {
|
|
66
|
+
await expect(r.exists("docs/secret.md")).rejects.toThrow();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await nodefs.chmod(docsPath, 0o755);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { InvalidTaskNameError, PacketCountOutOfRangeError } from "./errors.js";
|
|
2
|
+
// taskNameRE is the canonical slug pattern for plan task names: it matches
|
|
3
|
+
// strings that start and end with a lowercase alphanumeric character and
|
|
4
|
+
// contain only lowercase alphanumerics and hyphens in between.
|
|
5
|
+
const taskNameRE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
6
|
+
// Source literal used in the error message, matching Go's regexp String().
|
|
7
|
+
const taskNameRESrc = "^[a-z0-9][a-z0-9-]*[a-z0-9]$";
|
|
8
|
+
// validateTaskName throws InvalidTaskNameError when name does not match
|
|
9
|
+
// taskNameRE. Package-private: callers use planHeaderFilename / packetFilename.
|
|
10
|
+
function validateTaskName(name) {
|
|
11
|
+
if (!taskNameRE.test(name)) {
|
|
12
|
+
throw new InvalidTaskNameError(name, taskNameRESrc);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// formatDate renders a Date as YYYYMMDD using its UTC components, matching the
|
|
16
|
+
// Go reference layout "20060102" applied to a time.Time. Callers in Go pass a
|
|
17
|
+
// UTC time.Time; using the UTC getters keeps the slug stable across the host's
|
|
18
|
+
// local timezone.
|
|
19
|
+
function formatDate(date) {
|
|
20
|
+
const y = date.getUTCFullYear().toString().padStart(4, "0");
|
|
21
|
+
const m = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
|
22
|
+
const d = date.getUTCDate().toString().padStart(2, "0");
|
|
23
|
+
return `${y}${m}${d}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns the repo-relative slash path for the plan header file:
|
|
27
|
+
* docs/plans/YYYYMMDD-<taskName>-plan.md. Throws {@link InvalidTaskNameError}
|
|
28
|
+
* when taskName does not match the required slug pattern.
|
|
29
|
+
*/
|
|
30
|
+
export function planHeaderFilename(date, taskName) {
|
|
31
|
+
validateTaskName(taskName);
|
|
32
|
+
return `docs/plans/${formatDate(date)}-${taskName}-plan.md`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns the repo-relative slash path for the Nth packet file:
|
|
36
|
+
* docs/plans/YYYYMMDD-<taskName>-N.md. Throws {@link InvalidTaskNameError} when
|
|
37
|
+
* taskName is invalid, and {@link PacketCountOutOfRangeError} when packet is
|
|
38
|
+
* outside 1..99.
|
|
39
|
+
*/
|
|
40
|
+
export function packetFilename(date, taskName, packet) {
|
|
41
|
+
validateTaskName(taskName);
|
|
42
|
+
if (packet < 1 || packet > 99) {
|
|
43
|
+
throw new PacketCountOutOfRangeError(packet);
|
|
44
|
+
}
|
|
45
|
+
return `docs/plans/${formatDate(date)}-${taskName}-${packet}.md`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { InvalidTaskNameError, PacketCountOutOfRangeError } from "./errors.js";
|
|
3
|
+
import { packetFilename, planHeaderFilename } from "./filename.js";
|
|
4
|
+
// WHY: the filename builders are the canonical source of truth for how plan and
|
|
5
|
+
// packet files are named on disk. Slug validation prevents accidental creation
|
|
6
|
+
// of files whose names break downstream tooling; range validation keeps the
|
|
7
|
+
// packet index within the declared bounds (1..99).
|
|
8
|
+
describe("filename builders", () => {
|
|
9
|
+
// 2026-05-28 UTC; the Go test uses time.Date(...,time.UTC) and "20060102".
|
|
10
|
+
const today = new Date(Date.UTC(2026, 4, 28));
|
|
11
|
+
test("planHeaderFilename formats date + slug into the plans path", () => {
|
|
12
|
+
expect(planHeaderFilename(today, "code-guard")).toBe("docs/plans/20260528-code-guard-plan.md");
|
|
13
|
+
});
|
|
14
|
+
test("packetFilename index 1 lands at the canonical packet path", () => {
|
|
15
|
+
expect(packetFilename(today, "code-guard", 1)).toBe("docs/plans/20260528-code-guard-1.md");
|
|
16
|
+
});
|
|
17
|
+
test("packetFilename index 99 is the inclusive upper bound", () => {
|
|
18
|
+
expect(packetFilename(today, "code-guard", 99)).toBe("docs/plans/20260528-code-guard-99.md");
|
|
19
|
+
});
|
|
20
|
+
test("packetFilename index 0 is out of range", () => {
|
|
21
|
+
expect(() => packetFilename(today, "code-guard", 0)).toThrow(PacketCountOutOfRangeError);
|
|
22
|
+
});
|
|
23
|
+
test("packetFilename index 100 is out of range", () => {
|
|
24
|
+
expect(() => packetFilename(today, "code-guard", 100)).toThrow(PacketCountOutOfRangeError);
|
|
25
|
+
});
|
|
26
|
+
// The slug must start and end with a lowercase alphanumeric and contain only
|
|
27
|
+
// lowercase alphanumerics and hyphens between — each of these must be rejected.
|
|
28
|
+
const badSlugs = [
|
|
29
|
+
["rejects uppercase", "BadName"],
|
|
30
|
+
["rejects leading dash", "-leading"],
|
|
31
|
+
["rejects trailing dash", "trailing-"],
|
|
32
|
+
["rejects spaces", "with spaces"],
|
|
33
|
+
];
|
|
34
|
+
for (const [why, slug] of badSlugs) {
|
|
35
|
+
test(`planHeaderFilename ${why}`, () => {
|
|
36
|
+
expect(() => planHeaderFilename(today, slug)).toThrow(InvalidTaskNameError);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
package/dist/repo/fs.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as nodefs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Reports whether `err` represents a not-found condition — the Node analogue of
|
|
5
|
+
* Go's errors.Is(err, fs.ErrNotExist). Used by callers (e.g. Exists) that treat
|
|
6
|
+
* absence as (false, no error) but propagate every other I/O fault.
|
|
7
|
+
*/
|
|
8
|
+
export function isNotExist(err) {
|
|
9
|
+
return (typeof err === "object" &&
|
|
10
|
+
err !== null &&
|
|
11
|
+
"code" in err &&
|
|
12
|
+
err.code === "ENOENT");
|
|
13
|
+
}
|
|
14
|
+
class RealDirEntry {
|
|
15
|
+
entryName;
|
|
16
|
+
dir;
|
|
17
|
+
constructor(entryName, dir) {
|
|
18
|
+
this.entryName = entryName;
|
|
19
|
+
this.dir = dir;
|
|
20
|
+
}
|
|
21
|
+
name() {
|
|
22
|
+
return this.entryName;
|
|
23
|
+
}
|
|
24
|
+
isDir() {
|
|
25
|
+
return this.dir;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** An FS backed by the real filesystem, rooted at an absolute OS path. */
|
|
29
|
+
export class RealFS {
|
|
30
|
+
osRoot;
|
|
31
|
+
constructor(osRoot) {
|
|
32
|
+
this.osRoot = osRoot;
|
|
33
|
+
}
|
|
34
|
+
/** Maps a slash path (relative to this FS root) to an absolute OS path. */
|
|
35
|
+
osPath(name) {
|
|
36
|
+
if (name === "." || name === "") {
|
|
37
|
+
return this.osRoot;
|
|
38
|
+
}
|
|
39
|
+
return path.join(this.osRoot, ...name.split("/"));
|
|
40
|
+
}
|
|
41
|
+
async readFile(name) {
|
|
42
|
+
return nodefs.readFile(this.osPath(name));
|
|
43
|
+
}
|
|
44
|
+
async readDir(name) {
|
|
45
|
+
const entries = await nodefs.readdir(this.osPath(name), {
|
|
46
|
+
withFileTypes: true,
|
|
47
|
+
});
|
|
48
|
+
return entries.map((e) => new RealDirEntry(e.name, e.isDirectory()));
|
|
49
|
+
}
|
|
50
|
+
sub(name) {
|
|
51
|
+
return new RealFS(this.osPath(name));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public surface of the repo package — the OS boundary for docgov.
|
|
2
|
+
export { Repo, PendingWrite, find } from "./repo.js";
|
|
3
|
+
export { RealFS, isNotExist } from "./fs.js";
|
|
4
|
+
export { OverlayFS } from "./overlay.js";
|
|
5
|
+
export { validatePattern } from "./boundary.js";
|
|
6
|
+
export { planHeaderFilename, packetFilename } from "./filename.js";
|
|
7
|
+
export { InvalidPatternError, AmbiguousBoundaryError, InvalidTaskNameError, PacketCountOutOfRangeError, FileExistsError, } from "./errors.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// overlay.ts provides OverlayFS, the TS analogue of Go's overlayFS: an FS that
|
|
2
|
+
// intercepts reads of exactly one slash path and returns injected bytes,
|
|
3
|
+
// delegating every other read to a base FS.
|
|
4
|
+
//
|
|
5
|
+
// Safety contract (mirrors the Go doc comment):
|
|
6
|
+
// - WithOverlay does NOT mutate the receiver Repo.
|
|
7
|
+
// - The returned Repo shares the base root and write methods; only FS reads
|
|
8
|
+
// of the one overlaid path are intercepted.
|
|
9
|
+
// - Non-overlaid paths are served from the base FS — the overlay is a
|
|
10
|
+
// shallow one-path intercept only.
|
|
11
|
+
//
|
|
12
|
+
// Directory listing (readDir) and descent (sub) always delegate to base: the
|
|
13
|
+
// overlaid file IS present in the base FS on disk, so a walk enumerates it; only
|
|
14
|
+
// the single overlaid file path is intercepted for content.
|
|
15
|
+
export class OverlayFS {
|
|
16
|
+
base;
|
|
17
|
+
overlaidPath;
|
|
18
|
+
content;
|
|
19
|
+
constructor(base, overlaidPath, content) {
|
|
20
|
+
this.base = base;
|
|
21
|
+
this.overlaidPath = overlaidPath;
|
|
22
|
+
this.content = content;
|
|
23
|
+
}
|
|
24
|
+
async readFile(name) {
|
|
25
|
+
if (name === this.overlaidPath) {
|
|
26
|
+
return this.content;
|
|
27
|
+
}
|
|
28
|
+
return this.base.readFile(name);
|
|
29
|
+
}
|
|
30
|
+
async readDir(name) {
|
|
31
|
+
return this.base.readDir(name);
|
|
32
|
+
}
|
|
33
|
+
sub(name) {
|
|
34
|
+
return this.base.sub(name);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { find } from "./repo.js";
|
|
3
|
+
import { makeDir } from "./testutil.js";
|
|
4
|
+
// WHY these tests matter: withOverlay lets callers present an in-memory version
|
|
5
|
+
// of one file to the check pipeline while the real on-disk file stays untouched.
|
|
6
|
+
// If it delegates reads incorrectly (base content for the overlaid path, or
|
|
7
|
+
// overlay content for other paths), the safety invariant breaks — the check
|
|
8
|
+
// pipeline would see wrong data.
|
|
9
|
+
const enc = new TextEncoder();
|
|
10
|
+
const dec = new TextDecoder();
|
|
11
|
+
describe("withOverlay: single-path in-memory intercept", () => {
|
|
12
|
+
// Core contract: the check sees the flipped bytes even though the real file
|
|
13
|
+
// on disk still holds the original.
|
|
14
|
+
test("overlaid path returns the injected content, not the on-disk bytes", async () => {
|
|
15
|
+
const root = await makeDir(".docgov", "docs");
|
|
16
|
+
const r = await find(root);
|
|
17
|
+
await r.writeFile("docs/a.md", enc.encode("---\nstatus: planning\n---\n"));
|
|
18
|
+
const injected = enc.encode("---\nstatus: implemented\n---\n");
|
|
19
|
+
const ov = r.withOverlay("docs/a.md", injected);
|
|
20
|
+
expect(dec.decode(await ov.readFile("docs/a.md"))).toBe("---\nstatus: implemented\n---\n");
|
|
21
|
+
});
|
|
22
|
+
// The resolver reads source files (non-overlaid); if the overlay intercepted
|
|
23
|
+
// those, the resolver would see wrong data.
|
|
24
|
+
test("non-overlaid path delegates to the base on-disk content", async () => {
|
|
25
|
+
const root = await makeDir(".docgov", "docs", "pkg");
|
|
26
|
+
const r = await find(root);
|
|
27
|
+
await r.writeFile("docs/a.md", enc.encode("original\n"));
|
|
28
|
+
await r.writeFile("pkg/foo.go", enc.encode("package foo\n"));
|
|
29
|
+
const ov = r.withOverlay("docs/a.md", enc.encode("injected\n"));
|
|
30
|
+
expect(dec.decode(await ov.readFile("pkg/foo.go"))).toBe("package foo\n");
|
|
31
|
+
});
|
|
32
|
+
// The check pipeline reads files via fs() too (direct reads and the markdown
|
|
33
|
+
// walk); the overlay FS must agree with overlay readFile.
|
|
34
|
+
test("fs() serves the injected content for the overlaid path", async () => {
|
|
35
|
+
const root = await makeDir(".docgov", "docs");
|
|
36
|
+
const r = await find(root);
|
|
37
|
+
await r.writeFile("docs/a.md", enc.encode("on-disk\n"));
|
|
38
|
+
const injected = enc.encode("overlay\n");
|
|
39
|
+
const ov = r.withOverlay("docs/a.md", injected);
|
|
40
|
+
expect(dec.decode(await ov.fs().readFile("docs/a.md"))).toBe("overlay\n");
|
|
41
|
+
});
|
|
42
|
+
// If withOverlay mutated the base repo's FS, the real file would be
|
|
43
|
+
// effectively overwritten in memory — the exact problem being avoided.
|
|
44
|
+
test("creating an overlay does not mutate the base repo", async () => {
|
|
45
|
+
const root = await makeDir(".docgov", "docs");
|
|
46
|
+
const r = await find(root);
|
|
47
|
+
await r.writeFile("docs/a.md", enc.encode("status: planning\n"));
|
|
48
|
+
r.withOverlay("docs/a.md", enc.encode("status: implemented\n"));
|
|
49
|
+
expect(dec.decode(await r.readFile("docs/a.md"))).toBe("status: planning\n");
|
|
50
|
+
});
|
|
51
|
+
// WHY: the markdown walk lists directories through the overlay FS; the overlay
|
|
52
|
+
// is a one-path content intercept only, so readDir must still enumerate the
|
|
53
|
+
// real on-disk tree (including the overlaid file). If readDir intercepted
|
|
54
|
+
// anything, the walk would miss or duplicate files.
|
|
55
|
+
test("readDir delegates to the base so the overlaid file is still enumerated", async () => {
|
|
56
|
+
const root = await makeDir(".docgov", "docs");
|
|
57
|
+
const r = await find(root);
|
|
58
|
+
await r.writeFile("docs/a.md", enc.encode("on-disk\n"));
|
|
59
|
+
await r.writeFile("docs/b.md", enc.encode("other\n"));
|
|
60
|
+
// Call readDir on the OverlayFS itself (overlaid path is "docs/a.md"), not
|
|
61
|
+
// on a sub-FS, so the overlay's own readDir is exercised. The overlaid file
|
|
62
|
+
// must still appear because the overlay is a content-only intercept.
|
|
63
|
+
const ov = r.withOverlay("docs/a.md", enc.encode("overlay\n"));
|
|
64
|
+
const names = (await ov.fs().readDir("docs")).map((e) => e.name());
|
|
65
|
+
expect(names).toEqual(expect.arrayContaining(["a.md", "b.md"]));
|
|
66
|
+
});
|
|
67
|
+
// WHY: descending via sub() must reach the real subtree, not a copy that drops
|
|
68
|
+
// the overlay's content rule — a sub() that diverged from base would make a
|
|
69
|
+
// nested read return the wrong bytes for non-overlaid files.
|
|
70
|
+
test("sub descends into the base subtree", async () => {
|
|
71
|
+
const root = await makeDir(".docgov", "docs", "pkg");
|
|
72
|
+
const r = await find(root);
|
|
73
|
+
await r.writeFile("docs/a.md", enc.encode("on-disk\n"));
|
|
74
|
+
await r.writeFile("pkg/foo.go", enc.encode("package foo\n"));
|
|
75
|
+
// sub() on the OverlayFS itself returns the base subtree FS.
|
|
76
|
+
const ov = r.withOverlay("docs/a.md", enc.encode("overlay\n"));
|
|
77
|
+
const pkg = ov.fs().sub("pkg");
|
|
78
|
+
expect(dec.decode(await pkg.readFile("foo.go"))).toBe("package foo\n");
|
|
79
|
+
});
|
|
80
|
+
});
|