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,16 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Returns a 16-character lowercase hex string that uniquely identifies a
4
+ * section within the dedup index. The ID is derived from the SHA-256 of
5
+ * "<filePath>#<anchor>:<heading>", truncated to the first 16 hex characters.
6
+ *
7
+ * Locked derivation (single source of truth — do not reimplement elsewhere):
8
+ *
9
+ * sha256(filePath + "#" + anchor + ":" + heading)[:16 hex chars]
10
+ */
11
+ export function derive(filePath, anchor, heading) {
12
+ const hex = createHash("sha256")
13
+ .update(filePath + "#" + anchor + ":" + heading)
14
+ .digest("hex");
15
+ return hex.slice(0, 16);
16
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { derive } from "./sectionid.js";
4
+ describe("derive", () => {
5
+ // A section ID must be stable across runs, or every dedup index built from
6
+ // the same source would key sections differently and never match.
7
+ it("is deterministic for identical inputs", () => {
8
+ const a = derive("docs/foo.md", "my-heading", "My Heading");
9
+ const b = derive("docs/foo.md", "my-heading", "My Heading");
10
+ expect(a).toBe(b);
11
+ });
12
+ // The ID shape (exactly 16 lowercase hex chars) is a contract other layers
13
+ // rely on when storing/looking up IDs; a wider or non-hex ID is a regression.
14
+ it("returns exactly 16 lowercase hex characters", () => {
15
+ const id = derive("docs/foo.md", "my-heading", "My Heading");
16
+ expect(id).toHaveLength(16);
17
+ expect(id).toMatch(/^[0-9a-f]{16}$/);
18
+ });
19
+ // Anchor, file path, and heading text are all part of the hash input, so
20
+ // distinct sections — including collision-suffix anchors like
21
+ // "installation" vs "installation-1" — must derive distinct IDs.
22
+ it("maps distinct inputs to distinct IDs (anchor, file, and heading all count)", () => {
23
+ const cases = [
24
+ ["docs/foo.md", "installation", "Installation"],
25
+ ["docs/foo.md", "installation-1", "Installation"], // collision-suffix anchor
26
+ ["docs/bar.md", "installation", "Installation"], // different file
27
+ ["docs/foo.md", "installation", "Getting Started"], // different heading text
28
+ ];
29
+ const seen = new Map();
30
+ for (const [filePath, anchor, heading] of cases) {
31
+ const id = derive(filePath, anchor, heading);
32
+ const key = `${filePath}#${anchor}:${heading}`;
33
+ const prior = seen.get(id);
34
+ expect(prior, `collision: ${prior} and ${key} both produce ${id}`).toBeUndefined();
35
+ seen.set(id, key);
36
+ }
37
+ });
38
+ // Pins the exact sha256-based derivation (digest, encoding, truncation) so
39
+ // any accidental change to the hashing formula is caught immediately.
40
+ it("matches sha256(<filePath>#<anchor>:<heading>)[:16] exactly", () => {
41
+ const want = createHash("sha256")
42
+ .update("docs/guide.md#overview:Overview")
43
+ .digest("hex")
44
+ .slice(0, 16);
45
+ expect(derive("docs/guide.md", "overview", "Overview")).toBe(want);
46
+ // Cross-check the locked value to guard against both sides drifting together.
47
+ expect(want).toBe("f2912ce983e8d07d");
48
+ });
49
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * MalformedRefError is thrown by parseApiRef when raw is not a valid {{api:…}} token.
3
+ *
4
+ * Mirrors Go's sentinel `ErrMalformedRef` (var ErrMalformedRef = errors.New(...)),
5
+ * ported as a named Error subclass per the porting conventions.
6
+ */
7
+ export class MalformedRefError extends Error {
8
+ constructor(raw) {
9
+ super(`api: malformed reference: ${JSON.stringify(raw)}`);
10
+ this.name = "MalformedRefError";
11
+ }
12
+ }
@@ -0,0 +1,2 @@
1
+ export { MalformedRefError } from "./errors.js";
2
+ export { isMethod, parseApiRef } from "./parser.js";
@@ -0,0 +1,81 @@
1
+ import { MalformedRefError } from "./errors.js";
2
+ /**
3
+ * apiTokenRE matches a whole {{api:…}} token. The body permits single-level "{var}"
4
+ * groups (OpenAPI path templates) so `{{api: GET /x/{id}}}` matches up to the final
5
+ * "}}", unlike a naive `[^}]*`.
6
+ */
7
+ const apiTokenRE = /\{\{api:[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\}/;
8
+ /** methodRE validates the HTTP method (case-insensitive in source, normalised upper). */
9
+ const methodRE = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/i;
10
+ /** isMethod reports whether s (case-insensitive) is a supported HTTP method. */
11
+ export function isMethod(s) {
12
+ return methodRE.test(s);
13
+ }
14
+ /** validQualifierKinds is the set of allowed qualifier kinds. */
15
+ const validQualifierKinds = new Set(["param", "body", "response", "header"]);
16
+ /**
17
+ * parseApiRef parses one {{api: METHOD /path [KIND:dotted.name]}} token. Method is
18
+ * upper-cased; Path must start with "/". The optional third field must be KIND:name
19
+ * where KIND ∈ {param,body,response,header} and name is a non-empty dotted path with
20
+ * no empty segments. Any other shape throws MalformedRefError.
21
+ */
22
+ export function parseApiRef(raw) {
23
+ if (!apiTokenRE.test(raw) || !raw.startsWith("{{api:")) {
24
+ throw new MalformedRefError(raw);
25
+ }
26
+ // Strip the "{{api:" prefix and the trailing "}}" suffix (Go TrimPrefix/TrimSuffix).
27
+ let body = raw.slice("{{api:".length);
28
+ if (body.endsWith("}}")) {
29
+ body = body.slice(0, -2);
30
+ }
31
+ const fields = splitFields(body); // collapses surrounding/duplicate spaces
32
+ if (fields.length < 2 || fields.length > 3) {
33
+ throw new MalformedRefError(raw);
34
+ }
35
+ const method = fields[0];
36
+ const pathPart = fields[1];
37
+ if (!methodRE.test(method) || !pathPart.startsWith("/")) {
38
+ throw new MalformedRefError(raw);
39
+ }
40
+ const ref = {
41
+ Method: method.toUpperCase(),
42
+ Path: pathPart,
43
+ Qualifier: null,
44
+ };
45
+ if (fields.length === 3) {
46
+ ref.Qualifier = parseQualifier(fields[2], raw);
47
+ }
48
+ return ref;
49
+ }
50
+ /**
51
+ * splitFields mirrors Go's strings.Fields: splits around runs of whitespace and
52
+ * drops leading/trailing empties.
53
+ */
54
+ function splitFields(s) {
55
+ return s.split(/\s+/).filter((f) => f.length > 0);
56
+ }
57
+ /**
58
+ * parseQualifier parses the third field of an {{api:…}} token into a Qualifier.
59
+ * raw is the full original token string, used only for error messages.
60
+ */
61
+ function parseQualifier(field, raw) {
62
+ const idx = field.indexOf(":");
63
+ if (idx < 0) {
64
+ throw new MalformedRefError(raw);
65
+ }
66
+ const kind = field.slice(0, idx);
67
+ const name = field.slice(idx + 1);
68
+ if (!validQualifierKinds.has(kind)) {
69
+ throw new MalformedRefError(raw);
70
+ }
71
+ if (name === "") {
72
+ throw new MalformedRefError(raw);
73
+ }
74
+ const segs = name.split(".");
75
+ for (const seg of segs) {
76
+ if (seg === "") {
77
+ throw new MalformedRefError(raw);
78
+ }
79
+ }
80
+ return { Kind: kind, Path: segs };
81
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isMethod, MalformedRefError, parseApiRef } from "./index.js";
3
+ const q = (kind, ...path) => ({ Kind: kind, Path: path });
4
+ describe("parseApiRef", () => {
5
+ // A 2-field token is a bare endpoint citation; Qualifier must stay null so callers
6
+ // distinguish "cite the whole operation" from "cite one param/body/response/header".
7
+ it("parses 2-field tokens with no qualifier, normalising the method to upper-case", () => {
8
+ const cases = [
9
+ ["{{api: GET /api/admin/teams}}", { Method: "GET", Path: "/api/admin/teams", Qualifier: null }],
10
+ // case-insensitive method, OpenAPI path template {id} survives the regex.
11
+ ["{{api: get /x/{id}}}", { Method: "GET", Path: "/x/{id}", Qualifier: null }],
12
+ // no space after the colon is still a valid token.
13
+ ["{{api:POST /a/b}}", { Method: "POST", Path: "/a/b", Qualifier: null }],
14
+ ["{{api: GET /x}}", { Method: "GET", Path: "/x", Qualifier: null }],
15
+ ];
16
+ for (const [input, want] of cases) {
17
+ expect(parseApiRef(input), input).toEqual(want);
18
+ }
19
+ });
20
+ // The third field selects a sub-part of the operation; the dotted name must split
21
+ // into ordered segments so a guard can walk into nested schema properties.
22
+ it("parses 3-field tokens into a Kind + dotted-path segments qualifier", () => {
23
+ const cases = [
24
+ ["{{api: GET /x/{id} param:teamId}}", { Method: "GET", Path: "/x/{id}", Qualifier: q("param", "teamId") }],
25
+ ["{{api: POST /x body:user.address.city}}", { Method: "POST", Path: "/x", Qualifier: q("body", "user", "address", "city") }],
26
+ ["{{api: GET /x response:items.id}}", { Method: "GET", Path: "/x", Qualifier: q("response", "items", "id") }],
27
+ // header names keep their literal casing/hyphens; they are not dotted-split beyond ".".
28
+ ["{{api: GET /x header:X-Trace}}", { Method: "GET", Path: "/x", Qualifier: q("header", "X-Trace") }],
29
+ ];
30
+ for (const [input, want] of cases) {
31
+ expect(parseApiRef(input), input).toEqual(want);
32
+ }
33
+ });
34
+ // Every malformed shape must surface as the sentinel error so the guard reports a
35
+ // "malformed ref" violation rather than silently accepting a broken citation.
36
+ it("throws MalformedRefError for every malformed token shape", () => {
37
+ const bad = [
38
+ "{{api: FETCH /x}}", // unsupported method
39
+ "{{api: GET x}}", // path missing leading slash
40
+ "{{api: GET }}", // missing path field
41
+ "{{code:x}}", // wrong token kind entirely
42
+ "{{api: GET /x foo:y}}", // unknown qualifier kind
43
+ "{{api: GET /x body:a..b}}", // empty segment in dotted name
44
+ "{{api: GET /x body:}}", // empty qualifier name
45
+ "{{api: GET /x bodynocolon}}", // qualifier with no colon
46
+ "{{api: GET /x body:a extra}}", // 4 fields (too many)
47
+ ];
48
+ for (const input of bad) {
49
+ expect(() => parseApiRef(input), input).toThrow(MalformedRefError);
50
+ }
51
+ });
52
+ });
53
+ describe("isMethod", () => {
54
+ it("accepts supported HTTP methods and rejects others", () => {
55
+ expect(isMethod("GET")).toBe(true);
56
+ expect(isMethod("FETCH")).toBe(false);
57
+ });
58
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * MalformedRefError is thrown by parseCodeRef when the input string does not
3
+ * match the {{code:…}} grammar. Callers use `err instanceof MalformedRefError`
4
+ * to distinguish parse failures from resolver or operational errors without
5
+ * string-matching.
6
+ *
7
+ * The Go original is the `ErrMalformedRef` sentinel, wrapped with the offending
8
+ * raw text via `fmt.Errorf("%w: %q", ErrMalformedRef, raw)`. We mirror that
9
+ * message shape so the raw text is always surfaced.
10
+ */
11
+ export class MalformedRefError extends Error {
12
+ constructor(raw) {
13
+ super(`code: malformed ref: ${JSON.stringify(raw)}`);
14
+ this.name = "MalformedRefError";
15
+ }
16
+ }
@@ -0,0 +1,2 @@
1
+ export { MalformedRefError } from "./errors.js";
2
+ export { parseCodeRef } from "./parser.js";
@@ -0,0 +1,54 @@
1
+ import { MalformedRefError } from "./errors.js";
2
+ /**
3
+ * codeRefRE matches the full {{code:…}} token grammar.
4
+ *
5
+ * Capture groups:
6
+ * m[1] = path (non-empty; no whitespace, "#", or "}")
7
+ * m[2] = symbol (Go identifier: letter/underscore followed by word chars)
8
+ * m[3] = ".member" (optional member selector, e.g. ".get")
9
+ * m[4] = "(sig)" (optional signature, e.g. "(String[], int)" or "()")
10
+ * m[5] = "@params.name" (optional param selector)
11
+ *
12
+ * All three optional facets (member, signature, param) are independent and may
13
+ * compose freely.
14
+ */
15
+ const codeRefRE = /^\{\{code:([A-Za-z0-9_.][^\s#}]*)#([A-Za-z_]\w*)(\.[A-Za-z_][\w.]*)?(\([^)]*\))?(@params\.[A-Za-z_]\w*)?\}\}$/;
16
+ /**
17
+ * parseCodeRef parses a raw {{code:…}} token into a typed CodeRef.
18
+ *
19
+ * Accepted forms:
20
+ * {{code:pkg/foo.go#Bar}} → no facets
21
+ * {{code:pkg/foo.go#Bar.field}} → Member: "field"
22
+ * {{code:pkg/foo.go#Bar@params.x}} → Param: "x"
23
+ * {{code:pkg/foo.go#Bar.a.b.c}} → Member: "a.b.c"
24
+ * {{code:p.java#C.get(String[], int)}} → Member: "get", Signature: ["String[]","int"]
25
+ * {{code:p.java#C.get()}} → Member: "get", Signature: []
26
+ * {{code:p.java#C.get(String[])@params.id}} → Member: "get", Signature: ["String[]"], Param: "id"
27
+ *
28
+ * Any other form throws a MalformedRefError with the offending raw text
29
+ * included. Callers check with `err instanceof MalformedRefError`.
30
+ */
31
+ export function parseCodeRef(raw) {
32
+ const m = codeRefRE.exec(raw);
33
+ if (m === null) {
34
+ throw new MalformedRefError(raw);
35
+ }
36
+ // Groups 1 and 2 are non-optional in the pattern, so they always match.
37
+ const ref = {
38
+ Path: m[1],
39
+ Symbol: m[2],
40
+ Member: m[3] !== undefined ? m[3].slice(1) : "", // strip leading "."
41
+ Param: m[5] !== undefined ? m[5].slice("@params.".length) : "",
42
+ };
43
+ const sig = m[4]; // includes the surrounding parens, e.g. "(String[], int)"
44
+ if (sig !== undefined) {
45
+ const inner = sig.slice(1, -1); // strip the "(" and ")"
46
+ if (inner.trim() === "") {
47
+ ref.Signature = []; // "()" — zero-arg overload
48
+ }
49
+ else {
50
+ ref.Signature = inner.split(",").map((p) => p.trim());
51
+ }
52
+ }
53
+ return ref;
54
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { MalformedRefError } from "./errors.js";
3
+ import { parseCodeRef } from "./parser.js";
4
+ // parseCodeRef is the sole entry point for turning a raw {{code:…}} token into a
5
+ // typed CodeRef. It must enforce the grammar — path, symbol, and the optional
6
+ // member/param/signature facets — so downstream consumers (codeq's resolver in
7
+ // the check pipeline) can trust the typed result without re-validating.
8
+ // Rejection must produce an instanceof-matchable MalformedRefError so callers
9
+ // can distinguish parse failures from resolver/operational errors.
10
+ describe("parseCodeRef", () => {
11
+ // The facets (member, param, nested member) are the contract the resolver
12
+ // relies on; mis-splitting any of them would resolve the wrong symbol.
13
+ test("accepts the grammar variants and extracts each facet", () => {
14
+ const cases = [
15
+ {
16
+ in: "{{code:pkg/foo.go#Bar}}",
17
+ want: { Path: "pkg/foo.go", Symbol: "Bar", Member: "", Param: "" },
18
+ },
19
+ {
20
+ in: "{{code:pkg/foo.go#Bar.field}}",
21
+ want: { Path: "pkg/foo.go", Symbol: "Bar", Member: "field", Param: "" },
22
+ },
23
+ {
24
+ in: "{{code:pkg/foo.go#Bar@params.x}}",
25
+ want: { Path: "pkg/foo.go", Symbol: "Bar", Member: "", Param: "x" },
26
+ },
27
+ {
28
+ // A dotted member chain stays a single Member string; the resolver walks it.
29
+ in: "{{code:pkg/foo.go#Bar.nested.deep}}",
30
+ want: { Path: "pkg/foo.go", Symbol: "Bar", Member: "nested.deep", Param: "" },
31
+ },
32
+ ];
33
+ for (const tc of cases) {
34
+ expect(parseCodeRef(tc.in)).toEqual(tc.want);
35
+ }
36
+ });
37
+ // Malformed shapes must be rejected loudly, not silently coerced, or the guard
38
+ // would pass on a token it could never resolve.
39
+ test("rejects malformed shapes with MalformedRefError carrying the raw text", () => {
40
+ const cases = [
41
+ "{{code:#Bar}}", // empty path
42
+ "{{code:pkg/foo.go#}}", // empty symbol
43
+ "{{code:+pkg/foo.go#Bar}}", // leading sigil no longer accepted
44
+ "{{code:}}", // empty everything
45
+ "{{ code:pkg/foo.go#Bar }}", // surrounding whitespace not allowed
46
+ ];
47
+ for (const input of cases) {
48
+ let thrown;
49
+ try {
50
+ parseCodeRef(input);
51
+ }
52
+ catch (e) {
53
+ thrown = e;
54
+ }
55
+ expect(thrown, `parseCodeRef(${input}) should have thrown`).toBeInstanceOf(MalformedRefError);
56
+ // The raw text must appear in the message so callers can surface it.
57
+ expect(thrown.message).toContain(input);
58
+ }
59
+ });
60
+ // Overload disambiguation depends on the exact split of the parenthesised type
61
+ // list AND on member/sig/param composing (the old member-XOR-param rule is
62
+ // dropped). Each split element is whitespace-trimmed.
63
+ test("parses the (signature) facet and composes it with member and param", () => {
64
+ const cases = [
65
+ {
66
+ in: "{{code:p.java#C.get(String[], Integer, AbRequest)}}",
67
+ want: {
68
+ Path: "p.java",
69
+ Symbol: "C",
70
+ Member: "get",
71
+ Param: "",
72
+ Signature: ["String[]", "Integer", "AbRequest"],
73
+ },
74
+ },
75
+ {
76
+ in: "{{code:p.java#C.get(String[])@params.id}}",
77
+ want: {
78
+ Path: "p.java",
79
+ Symbol: "C",
80
+ Member: "get",
81
+ Param: "id",
82
+ Signature: ["String[]"],
83
+ },
84
+ },
85
+ {
86
+ // A bare-symbol signature (no member) is valid: F itself is the overload set.
87
+ in: "{{code:p.go#F([]string, int)}}",
88
+ want: {
89
+ Path: "p.go",
90
+ Symbol: "F",
91
+ Member: "",
92
+ Param: "",
93
+ Signature: ["[]string", "int"],
94
+ },
95
+ },
96
+ ];
97
+ for (const tc of cases) {
98
+ expect(parseCodeRef(tc.in)).toEqual(tc.want);
99
+ }
100
+ });
101
+ // The empty-arg "()" overload must be distinguishable from the name-only case:
102
+ // "()" → defined-but-empty Signature; no parens → undefined Signature. The
103
+ // resolver treats these differently (zero-arg overload vs. match-any-arity).
104
+ test("distinguishes the zero-arg () overload from a name-only ref", () => {
105
+ const zeroArg = parseCodeRef("{{code:p.java#C.get()}}");
106
+ expect(zeroArg.Signature).toEqual([]);
107
+ expect(zeroArg.Member).toBe("get");
108
+ const nameOnly = parseCodeRef("{{code:p.go#Bar}}");
109
+ expect(nameOnly.Signature).toBeUndefined();
110
+ });
111
+ });
@@ -0,0 +1,6 @@
1
+ // Package code provides the CodeRef type, parseCodeRef function, and
2
+ // MalformedRefError for the code guard.
3
+ //
4
+ // Import direction: guard/code is a leaf package; it does not import guard or
5
+ // any other package to avoid cycles.
6
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = "0.0.0-ts";
@@ -0,0 +1,5 @@
1
+ import { expect, test } from "vitest";
2
+ import { VERSION } from "./index.js";
3
+ test("VERSION is the placeholder TS version", () => {
4
+ expect(VERSION).toBe("0.0.0-ts");
5
+ });
@@ -0,0 +1,92 @@
1
+ import { InvalidPatternError } from "./errors.js";
2
+ // validatePattern reports whether a boundary pattern is a syntactically valid
3
+ // doublestar glob. It is intended to be called once at load time (config load)
4
+ // so that a malformed boundary: field surfaces immediately rather than at first
5
+ // check.
6
+ //
7
+ // Folder-prefix patterns (those ending in "/") are not glob patterns and are
8
+ // accepted verbatim. All other patterns are validated with the same algorithm
9
+ // doublestar.ValidatePattern uses, which accepts exactly the same set of
10
+ // patterns that the check-time matcher will accept.
11
+ //
12
+ // Go used github.com/bmatcuk/doublestar's ValidatePattern. picomatch (the TS
13
+ // glob library) is lenient and "repairs" malformed patterns rather than
14
+ // rejecting them, so it cannot stand in for the validator. The algorithm below
15
+ // is a direct port of doublestar's doValidatePattern with separator '/'.
16
+ /**
17
+ * Throws {@link InvalidPatternError} (containing the offending pattern literal)
18
+ * when `pattern` is not a syntactically valid boundary glob. Returns void on
19
+ * success. Folder-prefix patterns (ending in "/") are always accepted.
20
+ */
21
+ export function validatePattern(pattern) {
22
+ // Folder prefixes are accepted verbatim; they are not glob-validated.
23
+ if (pattern.endsWith("/")) {
24
+ return;
25
+ }
26
+ if (!isValidGlob(pattern)) {
27
+ throw new InvalidPatternError(pattern);
28
+ }
29
+ }
30
+ // isValidGlob is a direct port of doublestar.doValidatePattern(s, '/').
31
+ // It walks the pattern byte-by-byte, tracking bracket character classes,
32
+ // escapes, and {alternation} nesting, and returns false on any malformed
33
+ // construct. Operating on UTF-16 code units matches the byte-oriented Go scan
34
+ // for the ASCII metacharacters it inspects (\, [, ], ^, !, {, }).
35
+ function isValidGlob(s) {
36
+ let altDepth = 0;
37
+ const l = s.length;
38
+ // Outer loop. `continue VALIDATE` in Go => labeled continue here.
39
+ validate: for (let i = 0; i < l; i++) {
40
+ const c = s[i];
41
+ switch (c) {
42
+ case "\\": {
43
+ // separator is '/', not '\\', so always skip the next byte.
44
+ i++;
45
+ if (i >= l) {
46
+ // unclosed escape
47
+ return false;
48
+ }
49
+ continue;
50
+ }
51
+ case "[": {
52
+ i++;
53
+ if (i >= l) {
54
+ // class didn't end
55
+ return false;
56
+ }
57
+ if (s[i] === "^" || s[i] === "!") {
58
+ i++;
59
+ }
60
+ if (i >= l || s[i] === "]") {
61
+ // class didn't end or empty character class
62
+ return false;
63
+ }
64
+ for (; i < l; i++) {
65
+ if (s[i] === "\\") {
66
+ i++;
67
+ }
68
+ else if (s[i] === "]") {
69
+ // looks good
70
+ continue validate;
71
+ }
72
+ }
73
+ // class didn't end
74
+ return false;
75
+ }
76
+ case "{": {
77
+ altDepth++;
78
+ continue;
79
+ }
80
+ case "}": {
81
+ if (altDepth === 0) {
82
+ // alt end without a corresponding start
83
+ return false;
84
+ }
85
+ altDepth--;
86
+ continue;
87
+ }
88
+ }
89
+ }
90
+ // valid as long as all alts are closed
91
+ return altDepth === 0;
92
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { InvalidPatternError } from "./errors.js";
3
+ import { validatePattern } from "./boundary.js";
4
+ // WHY: malformed boundary patterns must be caught at load time (config load)
5
+ // rather than at first use inside check resolution, so the user gets a clear
6
+ // error immediately instead of a confusing failure when a doc is first checked.
7
+ // picomatch silently "repairs" these patterns, so the validator can't lean on
8
+ // it — this guards the hand-ported doublestar validation algorithm.
9
+ describe("validatePattern", () => {
10
+ const valid = [
11
+ "docs/plans/*-[0-9]*.md",
12
+ "docs/plans/*-plan.md",
13
+ "docs/adr/*.md",
14
+ "docs/", // folder prefix: ends in "/"
15
+ ];
16
+ for (const pattern of valid) {
17
+ test(`accepts valid catalog pattern ${JSON.stringify(pattern)}`, () => {
18
+ expect(() => validatePattern(pattern)).not.toThrow();
19
+ });
20
+ }
21
+ // WHY: each accepted shape exercises a distinct branch of the hand-ported
22
+ // doublestar validator — negated/escaped/closed character classes and balanced
23
+ // alternations. A regression in any of those branches would either reject a
24
+ // legitimate catalog pattern (blocking a valid config) or fall through to the
25
+ // lenient picomatch behaviour the port exists to avoid.
26
+ const validBranches = [
27
+ "docs/[^x].md", // negated class via "^"
28
+ "docs/[!x].md", // negated class via "!"
29
+ "docs/[\\]].md", // escaped "]" inside the class, then real close
30
+ "docs/[abc].md", // ordinary closed character class
31
+ "docs/{a,b}.md", // balanced alternation
32
+ "docs/{a,{b,c}}.md", // nested balanced alternations
33
+ "docs/\\*.md", // escaped metacharacter (mid-pattern escape)
34
+ ];
35
+ for (const pattern of validBranches) {
36
+ test(`accepts ${JSON.stringify(pattern)} (well-formed glob construct)`, () => {
37
+ expect(() => validatePattern(pattern)).not.toThrow();
38
+ });
39
+ }
40
+ const malformed = [
41
+ "docs/[", // unclosed bracket
42
+ "docs/[a-", // incomplete range
43
+ "docs/\\", // unclosed escape
44
+ "docs/[]", // empty character class
45
+ "docs/[^", // negated class that never closes
46
+ "docs/[a\\", // escape at end inside a class
47
+ "docs/a}", // alternation close with no matching open
48
+ "docs/{a", // alternation open that never closes
49
+ ];
50
+ for (const pattern of malformed) {
51
+ test(`rejects malformed pattern ${JSON.stringify(pattern)} with InvalidPatternError naming the literal`, () => {
52
+ let thrown;
53
+ try {
54
+ validatePattern(pattern);
55
+ }
56
+ catch (e) {
57
+ thrown = e;
58
+ }
59
+ expect(thrown).toBeInstanceOf(InvalidPatternError);
60
+ // The error message must contain the offending pattern literal so the
61
+ // user can see exactly which boundary entry is broken.
62
+ expect(thrown.message).toContain(pattern);
63
+ });
64
+ }
65
+ });
@@ -0,0 +1,56 @@
1
+ // Sentinel error types for the repo package. Each mirrors a Go sentinel
2
+ // (var ErrFoo = errors.New(...)) and is ported as a named Error subclass so
3
+ // callers can discriminate with `instanceof` the way Go callers use errors.Is.
4
+ /**
5
+ * Thrown by {@link validatePattern} when a boundary pattern contains malformed
6
+ * bracket expressions or unclosed escapes. Mirrors Go's ErrInvalidPattern.
7
+ */
8
+ export class InvalidPatternError extends Error {
9
+ constructor(pattern) {
10
+ super(`repo: invalid boundary pattern: ${JSON.stringify(pattern)}`);
11
+ this.name = "InvalidPatternError";
12
+ }
13
+ }
14
+ /**
15
+ * Mirrors Go's ErrAmbiguousBoundary. Surfaced (wrapped) by check.ResolveError
16
+ * when two boundaries of equal specificity both match the same file. Declared
17
+ * here so the dependent check layer can throw/wrap it; not raised by repo
18
+ * itself.
19
+ */
20
+ export class AmbiguousBoundaryError extends Error {
21
+ constructor(message = "repo: boundary pattern matches multiple doc types") {
22
+ super(message);
23
+ this.name = "AmbiguousBoundaryError";
24
+ }
25
+ }
26
+ /**
27
+ * Thrown by {@link planHeaderFilename} and {@link packetFilename} when the task
28
+ * name slug does not match ^[a-z0-9][a-z0-9-]*[a-z0-9]$. Mirrors Go's
29
+ * ErrInvalidTaskName.
30
+ */
31
+ export class InvalidTaskNameError extends Error {
32
+ constructor(name, pattern) {
33
+ super(`repo: invalid task name slug: ${JSON.stringify(name)} (must match ${pattern})`);
34
+ this.name = "InvalidTaskNameError";
35
+ }
36
+ }
37
+ /**
38
+ * Thrown by {@link packetFilename} when the packet index is outside 1..99.
39
+ * Mirrors Go's ErrPacketCountOutOfRange.
40
+ */
41
+ export class PacketCountOutOfRangeError extends Error {
42
+ constructor(packet) {
43
+ super(`repo: packet count out of range (1..99): ${packet} (1..99)`);
44
+ this.name = "PacketCountOutOfRangeError";
45
+ }
46
+ }
47
+ /**
48
+ * Mirrors Go's ErrFileExists. Declared for the dependent write-with-backup
49
+ * layer; not raised by repo itself.
50
+ */
51
+ export class FileExistsError extends Error {
52
+ constructor(message = "repo: file already exists") {
53
+ super(message);
54
+ this.name = "FileExistsError";
55
+ }
56
+ }