waymark-docs 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to Waymark are documented in this file.
4
4
 
5
+ ## [0.2.2](https://github.com/ysfaran/waymark/compare/v0.2.1...v0.2.2) (2026-09-18)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **cli:** align help text with documentation ([#10](https://github.com/ysfaran/waymark/issues/10)) ([dbabc14](https://github.com/ysfaran/waymark/commit/dbabc14244d775cc8a38166d11a386674e282610))
11
+
12
+ ## [0.2.1](https://github.com/ysfaran/waymark/compare/v0.2.0...v0.2.1) (2026-08-13)
13
+
14
+
15
+ ### Documentation
16
+
17
+ * **readme:** explain where Waymark fits ([ffe25dc](https://github.com/ysfaran/waymark/commit/ffe25dc15f1651519ad34e9e67f7fee90c69ef62))
18
+
5
19
  ## [0.2.0](https://github.com/ysfaran/waymark/compare/v0.1.1...v0.2.0) (2026-08-03)
6
20
 
7
21
 
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # 🪧 Waymark
2
2
 
3
- Find the right repository docs for your coding agent.
3
+ [![npm version](https://img.shields.io/npm/v/waymark-docs.svg)](https://www.npmjs.com/package/waymark-docs)
4
4
 
5
- Waymark is a small, offline-first CLI built for AI agents. Add structured
6
- frontmatter to Markdown or MDX files, then let the agent select the kinds and
7
- tags relevant to its task. Waymark returns deterministic matches without
8
- ranking results or maintaining an index.
5
+ Waymark is a small, offline-first CLI that helps coding agents find the right
6
+ repository docs for each task. Add structured frontmatter to existing Markdown
7
+ or MDX files, and agents can discover only relevant paths before opening a
8
+ document without reading a large index or following linked navigation files
9
+ that load unrelated context. It is as easy to set up as file-based navigation,
10
+ but remains deterministic and token-efficient; unlike RAG or MCP-backed
11
+ retrieval, it needs no ranking, maintained index or retrieval infrastructure.
9
12
 
10
- This makes documentation discovery token-efficient: agents spend less time
11
- searching unrelated files and more time working with the context they need.
13
+ ![Waymark reduces the effort required to find relevant context without retrieval infrastructure.](https://raw.githubusercontent.com/ysfaran/waymark/main/docs/assets/why-waymark.svg)
12
14
 
13
15
  ## Table of contents
14
16
 
@@ -35,7 +37,7 @@ npm install --save-dev waymark-docs
35
37
 
36
38
  ## Quick start
37
39
 
38
- 1. **Create a Waymark configuration.**
40
+ 1. **Create a Waymark configuration**
39
41
 
40
42
  Run `init` in the repository root:
41
43
 
@@ -43,7 +45,7 @@ npm install --save-dev waymark-docs
43
45
  npx waymark init
44
46
  ```
45
47
 
46
- 2. **Define searchable metadata.**
48
+ 2. **Define searchable metadata**
47
49
 
48
50
  Add the document kinds and tags that agents can search:
49
51
 
@@ -53,11 +55,11 @@ npm install --save-dev waymark-docs
53
55
  convention: Read before changing code to follow required repository practices
54
56
 
55
57
  tags:
56
- architecture: System boundaries, component relationships, and dependencies
58
+ architecture: System boundaries, component relationships and dependencies
57
59
  typescript: TypeScript-related documentation
58
60
  ```
59
61
 
60
- 3. **Register a document.**
62
+ 3. **Register a document**
61
63
 
62
64
  Add Waymark metadata to a Markdown or MDX file. For example, save this as
63
65
  `docs/conventions/typescript.md`:
@@ -71,7 +73,7 @@ npm install --save-dev waymark-docs
71
73
  # TypeScript conventions
72
74
  ```
73
75
 
74
- 4. **Validate the repository.**
76
+ 4. **Validate the repository**
75
77
 
76
78
  Check the configuration and discovered documents:
77
79
 
@@ -88,16 +90,29 @@ npm install --save-dev waymark-docs
88
90
  Tags: 2
89
91
  ```
90
92
 
91
- 5. **Discover the document.**
93
+ 5. **Discover documents**
92
94
 
93
- Find the registered convention by kind and tag:
95
+ For a quick check, run `npx waymark find` with relevant kind and tag filters:
94
96
 
95
97
  ```sh
96
98
  npx waymark find --kinds convention --tags typescript --show description
97
99
  ```
98
100
 
99
101
  ```text
100
- docs/conventions/typescript.md — TypeScript conventions for this repository
102
+ docs/conventions/typescript.md: TypeScript conventions for this repository
103
+ ```
104
+
105
+ To have agents use Waymark continuously, add this to `AGENTS.md`,
106
+ `CLAUDE.md` or an equivalent file:
107
+
108
+ ```md
109
+ ## Context Discovery
110
+
111
+ Before working on a non-trivial task, run `npx waymark status --show kind,tags`,
112
+ then use `npx waymark find` with relevant comma-separated `--kinds` and
113
+ `--tags` values, using `--show kind,tags,description` to inspect results.
114
+ Use `--query` for literal text searches and `--filter` for boolean expressions
115
+ over metadata when you need more detailed results.
101
116
  ```
102
117
 
103
118
  Waymark uses `waymark.yml` by default and also recognizes `waymark.yaml`. It
@@ -122,8 +137,10 @@ Create a starter configuration in the current directory.
122
137
  ```text
123
138
  Usage: waymark init [options]
124
139
 
140
+ Create a starter Waymark configuration
141
+
125
142
  Options:
126
- -h, --help Display help for the command
143
+ -h, --help display help for command
127
144
  ```
128
145
 
129
146
  ```sh
@@ -152,7 +169,7 @@ ignore:
152
169
  - vendor/**
153
170
  ```
154
171
 
155
- Ignore patterns are relative to the repository root and support `*`, `?`, and
172
+ Ignore patterns are relative to the repository root and support `*`, `?` and
156
173
  `**` wildcards. Waymark also honors `.gitignore` automatically.
157
174
 
158
175
  `init` never overwrites an existing configuration and does not allow a nested
@@ -162,15 +179,17 @@ configuration beneath another Waymark root.
162
179
 
163
180
  Validate the Waymark configuration and all discovered Waymark Documents, then
164
181
  print the repository root and counts for registered documents, unregistered
165
- documents, kinds, and tags. Invalid repositories produce diagnostics and a
182
+ documents, kinds and tags. Invalid repositories produce diagnostics and a
166
183
  non-zero exit code, which makes this command suitable for CI.
167
184
 
168
185
  ```text
169
186
  Usage: waymark status [options]
170
187
 
188
+ Validate and summarize the Waymark repository
189
+
171
190
  Options:
172
- -s, --show <fields> Show declared kind and tag details (kind,tags)
173
- -h, --help Display help for the command
191
+ -s, --show <fields> show declared kind and tag details (kind,tags)
192
+ -h, --help display help for command
174
193
  ```
175
194
 
176
195
  Validate the repository:
@@ -199,16 +218,21 @@ Find registered Waymark Documents across the repository. With no filters,
199
218
  ```text
200
219
  Usage: waymark find [options]
201
220
 
221
+ Discover Waymark Documents
222
+
202
223
  Options:
203
- -k, --kinds <identifiers> Match any kind (comma-separated, repeatable)
204
- -t, --tags <identifiers> Match any tag (comma-separated, repeatable)
205
- -T, --require-tags <identifiers> Require every tag (comma-separated, repeatable)
206
- -f, --filter <expression> Match a Boolean metadata filter
207
- -q, --query <text> Match a literal content query
208
- -s, --show <fields> Show kind, tags, and description
209
- --json Return a flat JSON array
210
- --tree Return a directory tree
211
- -h, --help Display help for the command
224
+ -k, --kinds <identifiers> match any kind (comma-separated, repeatable)
225
+ -t, --tags <identifiers> match any tag (comma-separated, repeatable)
226
+ -T, --require-tags <identifiers> require every tag (comma-separated,
227
+ repeatable)
228
+ -f, --filter <expression> match a boolean filter expression
229
+ -q, --query <text> match literal text content
230
+ (case-insensitive)
231
+ -s, --show <fields> show kind, tags and description
232
+ (comma-separated)
233
+ --json return a flat JSON array
234
+ --tree output documents as directory tree
235
+ -h, --help display help for command
212
236
  ```
213
237
 
214
238
  Simple filter values use OR within an option. Different options combine with
@@ -230,20 +254,20 @@ npx waymark find --kinds adr --kinds convention
230
254
  ```
231
255
 
232
256
  Use `--query` for a case-insensitive literal search of document bodies. It can
233
- be combined with either simple or Boolean metadata filters:
257
+ be combined with either simple or boolean metadata filters:
234
258
 
235
259
  ```sh
236
260
  npx waymark find --kinds convention --query "dependency injection"
237
261
  ```
238
262
 
239
- Use `--filter` for advanced metadata expressions with `kind:`, `tag:`, `NOT`,
240
- `AND`, `OR`, and parentheses:
263
+ Use `--filter` for advanced boolean expressions over metadata with `kind:`,
264
+ `tag:`, `NOT`, `AND`, `OR` and parentheses:
241
265
 
242
266
  ```sh
243
267
  npx waymark find --filter '(kind:adr OR kind:convention) AND tag:typescript AND NOT tag:architecture'
244
268
  ```
245
269
 
246
- `--filter` cannot be combined with `--kinds`, `--tags`, or `--require-tags`.
270
+ `--filter` cannot be combined with `--kinds`, `--tags` or `--require-tags`.
247
271
 
248
272
  Add metadata fields to the default line-oriented output with `--show`:
249
273
 
@@ -275,13 +299,15 @@ Paths are returned relative to the repository root in deterministic order.
275
299
  ```text
276
300
  Usage: waymark ls [options] [directory]
277
301
 
302
+ Inventory document registration in a directory
303
+
278
304
  Arguments:
279
- directory Directory to inspect (defaults to the current directory)
305
+ directory directory to inspect (defaults to the current directory)
280
306
 
281
307
  Options:
282
- -R, --recursive Inspect directories recursively
283
- -u, --unregistered List only unregistered documents
284
- -h, --help Display help for the command
308
+ -R, --recursive inspect directories recursively
309
+ -u, --unregistered list only unregistered documents
310
+ -h, --help display help for command
285
311
  ```
286
312
 
287
313
  List registered documents directly inside `docs`:
@@ -302,17 +328,13 @@ Find Markdown and MDX files that are missing Waymark metadata:
302
328
  npx waymark ls -R --unregistered docs
303
329
  ```
304
330
 
305
- `ls` respects `.gitignore`, Waymark ignore patterns, and Git directory boundaries.
331
+ `ls` respects `.gitignore`, Waymark ignore patterns and Git directory boundaries.
306
332
  The selected directory must be inside the repository root.
307
333
 
308
334
  ### `waymark help`
309
335
 
310
336
  Show the command list or detailed help for one command:
311
337
 
312
- ```text
313
- Usage: waymark help [command]
314
- ```
315
-
316
338
  ```sh
317
339
  npx waymark --help
318
340
  npx waymark help find
@@ -5,19 +5,20 @@ import { filterDocuments, scanDocuments, } from "../documents/index.js";
5
5
  export function createFindCommand() {
6
6
  return new Command("find")
7
7
  .description("Discover Waymark Documents")
8
- .option("-k, --kinds <identifiers>", "Match any Document Kind (comma-separated, repeatable)", collectOptionValue, [])
9
- .option("-t, --tags <identifiers>", "Match any Document Tag (comma-separated, repeatable)", collectOptionValue, [])
10
- .option("-T, --require-tags <identifiers>", "Require every Document Tag (comma-separated, repeatable)", collectOptionValue, [])
11
- .option("-f, --filter <expression>", "Match a Boolean Metadata Filter")
12
- .option("-q, --query <text>", "Match a literal Content Query")
13
- .option("-s, --show <fields>", "Show kind, tags, and description (comma-separated)")
14
- .option("--json", "Return a flat JSON array")
15
- .option("--tree", "Return a directory-tree presentation")
8
+ .option("-k, --kinds <identifiers>", "match any kind (comma-separated, repeatable)", collectOptionValue)
9
+ .option("-t, --tags <identifiers>", "match any tag (comma-separated, repeatable)", collectOptionValue)
10
+ .option("-T, --require-tags <identifiers>", "require every tag (comma-separated, repeatable)", collectOptionValue)
11
+ .option("-f, --filter <expression>", "match a boolean filter expression")
12
+ .option("-q, --query <text>", "match literal text content (case-insensitive)")
13
+ .option("-s, --show <fields>", "show kind, tags and description (comma-separated)")
14
+ .option("--json", "return a flat JSON array")
15
+ .option("--tree", "output documents as directory tree")
16
16
  .action(async (options) => {
17
+ const kinds = options.kinds ?? [];
18
+ const tags = options.tags ?? [];
19
+ const requiredTags = options.requireTags ?? [];
17
20
  if (options.filter !== undefined &&
18
- (options.kinds.length > 0 ||
19
- options.tags.length > 0 ||
20
- options.requireTags.length > 0)) {
21
+ (kinds.length > 0 || tags.length > 0 || requiredTags.length > 0)) {
21
22
  throw new Error("--filter cannot be combined with --kinds, --tags, or --require-tags.");
22
23
  }
23
24
  if (options.json && options.tree) {
@@ -25,27 +26,45 @@ export function createFindCommand() {
25
26
  }
26
27
  const shownFields = parseShownFields(options.show);
27
28
  const loadedConfiguration = await loadConfiguration(process.cwd());
28
- if (loadedConfiguration.kind === "malformed") {
29
+ if (loadedConfiguration.kind === "invalid") {
29
30
  throwDiagnostics(loadedConfiguration.diagnostics);
30
31
  }
31
32
  const { configuration, rootPath } = loadedConfiguration;
33
+ const metadataCriteria = options.filter === undefined
34
+ ? {
35
+ method: "filter-groups",
36
+ kinds: parseIdentifierOptions({
37
+ optionName: "--kinds",
38
+ values: kinds,
39
+ declarations: configuration.kinds,
40
+ declarationName: "kind",
41
+ }),
42
+ tags: parseIdentifierOptions({
43
+ optionName: "--tags",
44
+ values: tags,
45
+ declarations: configuration.tags,
46
+ declarationName: "tag",
47
+ }),
48
+ requiredTags: parseIdentifierOptions({
49
+ optionName: "--require-tags",
50
+ values: requiredTags,
51
+ declarations: configuration.tags,
52
+ declarationName: "tag",
53
+ }),
54
+ }
55
+ : {
56
+ method: "filter-expression",
57
+ expression: options.filter,
58
+ };
32
59
  const documentScan = await scanDocuments({ rootPath, configuration });
33
- const diagnostics = [
34
- ...loadedConfiguration.diagnostics,
35
- ...(documentScan.kind === "invalid" ? documentScan.diagnostics : []),
36
- ].sort(compareDiagnostics);
37
- if (loadedConfiguration.diagnostics.length > 0 ||
38
- documentScan.kind === "invalid") {
39
- throwDiagnostics(diagnostics);
60
+ if (documentScan.kind === "invalid") {
61
+ throwDiagnostics(documentScan.diagnostics.sort(compareDiagnostics));
40
62
  }
41
63
  const matchingDocuments = filterDocuments({
42
64
  documents: documentScan.documents,
43
65
  configuration,
44
66
  criteria: {
45
- kinds: options.kinds,
46
- tags: options.tags,
47
- requiredTags: options.requireTags,
48
- filter: options.filter,
67
+ metadata: metadataCriteria,
49
68
  query: options.query,
50
69
  },
51
70
  });
@@ -169,7 +188,25 @@ function createTreeDirectory() {
169
188
  };
170
189
  }
171
190
  function collectOptionValue(value, previous) {
172
- return [...previous, value];
191
+ return [...(previous ?? []), value];
192
+ }
193
+ function parseIdentifierOptions({ optionName, values, declarations, declarationName, }) {
194
+ const identifiers = new Set();
195
+ for (const value of values) {
196
+ for (const identifier of value.split(",")) {
197
+ if (identifier === "") {
198
+ throw new Error(`${optionName} contains an empty identifier.`);
199
+ }
200
+ if (identifiers.has(identifier)) {
201
+ throw new Error(`${optionName} contains duplicate identifier "${identifier}".`);
202
+ }
203
+ if (!declarations.has(identifier)) {
204
+ throw new Error(`${optionName} contains undeclared ${declarationName} "${identifier}".`);
205
+ }
206
+ identifiers.add(identifier);
207
+ }
208
+ }
209
+ return identifiers;
173
210
  }
174
211
  function compareText(left, right) {
175
212
  return left < right ? -1 : left > right ? 1 : 0;
@@ -6,15 +6,12 @@ import { scanDocuments } from "../documents/index.js";
6
6
  export function createLsCommand() {
7
7
  return new Command("ls")
8
8
  .description("Inventory document registration in a directory")
9
- .argument("[directory]", "Directory to inspect")
10
- .option("-R, --recursive", "Inspect directories recursively")
11
- .option("-u, --unregistered", "List only Unregistered Documents")
9
+ .argument("[directory]", "directory to inspect (defaults to the current directory)")
10
+ .option("-R, --recursive", "inspect directories recursively")
11
+ .option("-u, --unregistered", "list only unregistered documents")
12
12
  .action(async (directory, options) => {
13
13
  const loadedConfiguration = await loadConfiguration(process.cwd());
14
- if (loadedConfiguration.kind === "malformed") {
15
- throwDiagnostics(loadedConfiguration.diagnostics);
16
- }
17
- if (loadedConfiguration.diagnostics.length > 0) {
14
+ if (loadedConfiguration.kind === "invalid") {
18
15
  throwDiagnostics(loadedConfiguration.diagnostics);
19
16
  }
20
17
  const rootPath = loadedConfiguration.rootPath;
@@ -5,24 +5,19 @@ import { scanDocuments } from "../documents/index.js";
5
5
  export function createStatusCommand() {
6
6
  return new Command("status")
7
7
  .description("Validate and summarize the Waymark repository")
8
- .option("-s, --show <fields>", "Show declared kind and tag details (kind,tags)")
8
+ .option("-s, --show <fields>", "show declared kind and tag details (kind,tags)")
9
9
  .action(async (options) => {
10
10
  const shownFields = parseShownFields(options.show);
11
11
  const loadedConfiguration = await loadConfiguration(process.cwd());
12
- if (loadedConfiguration.kind === "malformed") {
12
+ if (loadedConfiguration.kind === "invalid") {
13
13
  process.stdout.write(`Root: ${loadedConfiguration.rootPath}\n` + "Status: invalid\n");
14
14
  throwDiagnostics(loadedConfiguration.diagnostics);
15
15
  }
16
16
  const { configuration, rootPath } = loadedConfiguration;
17
17
  const documentScan = await scanDocuments({ rootPath, configuration });
18
- const diagnostics = [
19
- ...loadedConfiguration.diagnostics,
20
- ...(documentScan.kind === "invalid" ? documentScan.diagnostics : []),
21
- ].sort(compareDiagnostics);
22
- if (loadedConfiguration.diagnostics.length > 0 ||
23
- documentScan.kind === "invalid") {
18
+ if (documentScan.kind === "invalid") {
24
19
  process.stdout.write(`Root: ${rootPath}\n` + "Status: invalid\n");
25
- throwDiagnostics(diagnostics);
20
+ throwDiagnostics(documentScan.diagnostics.sort(compareDiagnostics));
26
21
  }
27
22
  let output = `Root: ${rootPath}\n` +
28
23
  "Status: valid\n" +
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  export declare const defaultConfigFileName = "waymark.yml";
2
3
  export declare const allowedConfigFileNames: readonly ["waymark.yml", "waymark.yaml"];
3
4
  export type ConfigurationDeclaration = {
@@ -8,22 +9,33 @@ export type ConfigurationDiagnostic = {
8
9
  field: string;
9
10
  message: string;
10
11
  };
11
- export type Configuration = {
12
+ declare const configurationSchema: z.ZodPipe<z.ZodObject<{
13
+ "require-namespace": z.ZodDefault<z.ZodBoolean>;
14
+ kinds: z.ZodPipe<z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodTransform<Map<string, ConfigurationDeclaration>, Record<string, unknown>>>;
15
+ tags: z.ZodPipe<z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodTransform<Map<string, ConfigurationDeclaration>, Record<string, unknown>>>;
16
+ ignore: z.ZodDefault<z.ZodArray<z.ZodString>>;
17
+ }, z.core.$strict>, z.ZodTransform<{
12
18
  requireNamespace: boolean;
13
19
  kinds: Map<string, ConfigurationDeclaration>;
14
20
  tags: Map<string, ConfigurationDeclaration>;
15
21
  ignorePatterns: string[];
16
- };
22
+ }, {
23
+ "require-namespace": boolean;
24
+ kinds: Map<string, ConfigurationDeclaration>;
25
+ tags: Map<string, ConfigurationDeclaration>;
26
+ ignore: string[];
27
+ }>>;
28
+ export type Configuration = z.infer<typeof configurationSchema>;
17
29
  export type ConfigurationLoadResult = {
18
30
  kind: "loaded";
19
31
  rootPath: string;
20
32
  configuration: Configuration;
21
- diagnostics: ConfigurationDiagnostic[];
22
33
  } | {
23
- kind: "malformed";
34
+ kind: "invalid";
24
35
  rootPath: string;
25
36
  diagnostics: ConfigurationDiagnostic[];
26
37
  };
27
38
  export declare function loadConfiguration(startingDirectoryPath: string): Promise<ConfigurationLoadResult>;
28
39
  export declare function findConfigurationPath(startingDirectoryPath: string): Promise<string | undefined>;
29
40
  export declare function findConfigurationPathInDirectory(directoryPath: string): Promise<string | undefined>;
41
+ export {};
@@ -1,12 +1,80 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { parse } from "yaml";
4
+ import { z } from "zod";
5
+ import { compareDiagnostics } from "../diagnostics.js";
4
6
  import { pathExists } from "../filesystem.js";
5
7
  export const defaultConfigFileName = "waymark.yml";
6
8
  export const allowedConfigFileNames = [
7
9
  defaultConfigFileName,
8
10
  "waymark.yaml",
9
11
  ];
12
+ const ignorePatternSchema = z
13
+ .string({ error: "Ignore glob must be a non-empty string." })
14
+ .superRefine((pattern, context) => {
15
+ if (pattern.trim() === "") {
16
+ context.addIssue({
17
+ code: "custom",
18
+ message: "Ignore glob must be a non-empty string.",
19
+ });
20
+ return;
21
+ }
22
+ if (pattern.startsWith("!")) {
23
+ context.addIssue({
24
+ code: "custom",
25
+ message: "Negation is not supported in ignore globs.",
26
+ });
27
+ return;
28
+ }
29
+ if (/[![\]{}()]/.test(pattern)) {
30
+ context.addIssue({
31
+ code: "custom",
32
+ message: "Only *, ?, and ** wildcard syntax is supported.",
33
+ });
34
+ }
35
+ });
36
+ const configurationDeclarationsSchema = z
37
+ .record(z.string(), z.unknown(), { error: "Expected a mapping." })
38
+ .superRefine((declarations, context) => {
39
+ for (const [identifier, description] of Object.entries(declarations)) {
40
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(identifier)) {
41
+ context.addIssue({
42
+ code: "custom",
43
+ path: [identifier],
44
+ message: "Identifier must use lowercase kebab-case.",
45
+ });
46
+ }
47
+ if (typeof description !== "string" || description.trim() === "") {
48
+ context.addIssue({
49
+ code: "custom",
50
+ path: [identifier],
51
+ message: "Description must be a non-empty string.",
52
+ });
53
+ }
54
+ }
55
+ })
56
+ .transform((declarations) => new Map(Object.entries(declarations)
57
+ .filter((entry) => typeof entry[1] === "string")
58
+ .map(([identifier, description]) => [identifier, { description }])));
59
+ const configurationSchema = z
60
+ .strictObject({
61
+ "require-namespace": z
62
+ .boolean({ error: "Expected a boolean." })
63
+ .default(false),
64
+ kinds: configurationDeclarationsSchema,
65
+ tags: configurationDeclarationsSchema,
66
+ ignore: z
67
+ .array(ignorePatternSchema, {
68
+ error: "Expected a sequence of ignore globs.",
69
+ })
70
+ .default([]),
71
+ }, { error: "Expected a mapping." })
72
+ .transform((configuration) => ({
73
+ requireNamespace: configuration["require-namespace"],
74
+ kinds: configuration.kinds,
75
+ tags: configuration.tags,
76
+ ignorePatterns: configuration.ignore,
77
+ }));
10
78
  export async function loadConfiguration(startingDirectoryPath) {
11
79
  const configurationPath = await findConfigurationPath(startingDirectoryPath);
12
80
  if (!configurationPath) {
@@ -20,7 +88,7 @@ export async function loadConfiguration(startingDirectoryPath) {
20
88
  }
21
89
  catch {
22
90
  return {
23
- kind: "malformed",
91
+ kind: "invalid",
24
92
  rootPath,
25
93
  diagnostics: [
26
94
  {
@@ -31,16 +99,21 @@ export async function loadConfiguration(startingDirectoryPath) {
31
99
  ],
32
100
  };
33
101
  }
34
- const diagnostics = [];
102
+ const configurationResult = configurationSchema.safeParse(parsedConfiguration);
103
+ if (!configurationResult.success) {
104
+ return {
105
+ kind: "invalid",
106
+ rootPath,
107
+ diagnostics: createConfigurationDiagnostics({
108
+ error: configurationResult.error,
109
+ configurationFileName: loadedConfigurationFileName,
110
+ }),
111
+ };
112
+ }
35
113
  return {
36
114
  kind: "loaded",
37
115
  rootPath,
38
- configuration: validateConfiguration({
39
- value: parsedConfiguration,
40
- configurationFileName: loadedConfigurationFileName,
41
- diagnostics,
42
- }),
43
- diagnostics,
116
+ configuration: configurationResult.data,
44
117
  };
45
118
  }
46
119
  export async function findConfigurationPath(startingDirectoryPath) {
@@ -70,133 +143,25 @@ export async function findConfigurationPathInDirectory(directoryPath) {
70
143
  }
71
144
  return configurationPaths[0];
72
145
  }
73
- function validateConfiguration({ value, configurationFileName, diagnostics, }) {
74
- if (!isRecord(value)) {
75
- diagnostics.push({
76
- path: configurationFileName,
77
- field: "configuration",
78
- message: "Expected a mapping.",
79
- });
80
- }
81
- const configuration = isRecord(value) ? value : {};
82
- const allowedFields = new Set([
83
- "require-namespace",
84
- "kinds",
85
- "tags",
86
- "ignore",
87
- ]);
88
- for (const field of Object.keys(configuration)) {
89
- if (!allowedFields.has(field)) {
90
- diagnostics.push({
91
- path: configurationFileName,
92
- field,
93
- message: "Unknown field.",
94
- });
146
+ function createConfigurationDiagnostics({ error, configurationFileName, }) {
147
+ const diagnostics = [];
148
+ for (const issue of error.issues) {
149
+ const field = issue.path.join(".") || "configuration";
150
+ if (issue.code === "unrecognized_keys") {
151
+ for (const key of issue.keys) {
152
+ diagnostics.push({
153
+ path: configurationFileName,
154
+ field: [...issue.path, key].join("."),
155
+ message: "Unknown field.",
156
+ });
157
+ }
158
+ continue;
95
159
  }
96
- }
97
- if (configuration["require-namespace"] !== undefined &&
98
- typeof configuration["require-namespace"] !== "boolean") {
99
- diagnostics.push({
100
- path: configurationFileName,
101
- field: "require-namespace",
102
- message: "Expected a boolean.",
103
- });
104
- }
105
- return {
106
- requireNamespace: configuration["require-namespace"] === true,
107
- kinds: validateDeclarations({
108
- value: configuration.kinds,
109
- field: "kinds",
110
- configurationFileName,
111
- diagnostics,
112
- }),
113
- tags: validateDeclarations({
114
- value: configuration.tags,
115
- field: "tags",
116
- configurationFileName,
117
- diagnostics,
118
- }),
119
- ignorePatterns: validateIgnorePatterns({
120
- value: configuration.ignore,
121
- configurationFileName,
122
- diagnostics,
123
- }),
124
- };
125
- }
126
- function validateDeclarations({ value, field, configurationFileName, diagnostics, }) {
127
- if (!isRecord(value)) {
128
160
  diagnostics.push({
129
161
  path: configurationFileName,
130
162
  field,
131
- message: "Expected a mapping.",
132
- });
133
- return new Map();
134
- }
135
- const declarations = new Map();
136
- for (const [identifier, description] of Object.entries(value)) {
137
- const diagnosticField = `${field}.${identifier}`;
138
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(identifier)) {
139
- diagnostics.push({
140
- path: configurationFileName,
141
- field: diagnosticField,
142
- message: "Identifier must use lowercase kebab-case.",
143
- });
144
- }
145
- if (typeof description !== "string" || description.trim() === "") {
146
- diagnostics.push({
147
- path: configurationFileName,
148
- field: diagnosticField,
149
- message: "Description must be a non-empty string.",
150
- });
151
- }
152
- declarations.set(identifier, {
153
- description: typeof description === "string" ? description : "",
154
- });
155
- }
156
- return declarations;
157
- }
158
- function validateIgnorePatterns({ value, configurationFileName, diagnostics, }) {
159
- if (value === undefined)
160
- return [];
161
- if (!Array.isArray(value)) {
162
- diagnostics.push({
163
- path: configurationFileName,
164
- field: "ignore",
165
- message: "Expected a sequence of ignore globs.",
163
+ message: issue.message,
166
164
  });
167
- return [];
168
165
  }
169
- const ignorePatterns = [];
170
- for (const [index, pattern] of value.entries()) {
171
- const field = `ignore.${index}`;
172
- if (typeof pattern !== "string" || pattern.trim() === "") {
173
- diagnostics.push({
174
- path: configurationFileName,
175
- field,
176
- message: "Ignore glob must be a non-empty string.",
177
- });
178
- continue;
179
- }
180
- if (pattern.startsWith("!")) {
181
- diagnostics.push({
182
- path: configurationFileName,
183
- field,
184
- message: "Negation is not supported in ignore globs.",
185
- });
186
- continue;
187
- }
188
- if (/[![\]{}()]/.test(pattern)) {
189
- diagnostics.push({
190
- path: configurationFileName,
191
- field,
192
- message: "Only *, ?, and ** wildcard syntax is supported.",
193
- });
194
- continue;
195
- }
196
- ignorePatterns.push(pattern);
197
- }
198
- return ignorePatterns;
199
- }
200
- function isRecord(value) {
201
- return typeof value === "object" && value !== null && !Array.isArray(value);
166
+ return diagnostics.sort(compareDiagnostics);
202
167
  }
@@ -1,8 +1,6 @@
1
+ import { z } from "zod";
1
2
  import type { ConfigurationDeclaration } from "../configuration/index.js";
2
- type ClassifiedDocument = {
3
- kind: string;
4
- description: string;
5
- tags: string[];
3
+ type ClassifiedDocument = z.infer<ReturnType<typeof createDocumentSchema>> & {
6
4
  body: string;
7
5
  };
8
6
  type DocumentDiagnostic = {
@@ -26,4 +24,12 @@ export declare function classifyDocument({ source, requireNamespace, declaredKin
26
24
  declaredTags: Map<string, ConfigurationDeclaration>;
27
25
  path: string;
28
26
  }): DocumentClassification;
27
+ declare function createDocumentSchema({ declaredKinds, declaredTags, }: {
28
+ declaredKinds: Map<string, ConfigurationDeclaration>;
29
+ declaredTags: Map<string, ConfigurationDeclaration>;
30
+ }): z.ZodObject<{
31
+ kind: z.ZodString;
32
+ description: z.ZodString;
33
+ tags: z.ZodPipe<z.ZodDefault<z.ZodArray<z.ZodUnknown>>, z.ZodTransform<string[], unknown[]>>;
34
+ }, z.core.$strict>;
29
35
  export {};
@@ -1,4 +1,6 @@
1
1
  import { parse } from "yaml";
2
+ import { z } from "zod";
3
+ const rawFrontmatterSchema = z.record(z.string(), z.unknown());
2
4
  export function classifyDocument({ source, requireNamespace, declaredKinds, declaredTags, path, }) {
3
5
  const frontmatter = readFrontmatter(source);
4
6
  if (frontmatter.kind === "malformed") {
@@ -13,11 +15,14 @@ export function classifyDocument({ source, requireNamespace, declaredKinds, decl
13
15
  ],
14
16
  };
15
17
  }
16
- if (frontmatter.kind === "none" || !isRecord(frontmatter.value)) {
18
+ if (frontmatter.kind === "none") {
17
19
  return { kind: "unregistered" };
18
20
  }
21
+ const frontmatterResult = rawFrontmatterSchema.safeParse(frontmatter.value);
22
+ if (!frontmatterResult.success)
23
+ return { kind: "unregistered" };
19
24
  return validateRegistration({
20
- frontmatter: frontmatter.value,
25
+ frontmatter: frontmatterResult.data,
21
26
  body: frontmatter.body,
22
27
  requireNamespace,
23
28
  declaredKinds,
@@ -52,152 +57,126 @@ function validateRegistration({ frontmatter, body, requireNamespace, declaredKin
52
57
  if (!requireNamespace && !hasNamespace && !hasRecognizedFlatMetadata) {
53
58
  return { kind: "unregistered" };
54
59
  }
55
- const diagnostics = [];
56
60
  if (hasNamespace && hasRecognizedFlatMetadata) {
57
- diagnostics.push({
58
- path,
59
- field: "waymark",
60
- message: "Flat and namespaced metadata cannot both be declared.",
61
- });
62
- return { kind: "invalid", diagnostics };
61
+ return {
62
+ kind: "invalid",
63
+ diagnostics: [
64
+ {
65
+ path,
66
+ field: "waymark",
67
+ message: "Flat and namespaced metadata cannot both be declared.",
68
+ },
69
+ ],
70
+ };
63
71
  }
64
72
  const namespaced = hasNamespace;
65
- const value = namespaced ? frontmatter.waymark : frontmatter;
73
+ const value = namespaced
74
+ ? frontmatter.waymark
75
+ : {
76
+ kind: frontmatter.kind,
77
+ description: frontmatter.description,
78
+ tags: frontmatter.tags,
79
+ };
66
80
  const fieldPrefix = namespaced ? "waymark." : "";
67
- if (!isRecord(value)) {
68
- diagnostics.push({
69
- path,
70
- field: namespaced ? "waymark" : "frontmatter",
71
- message: "Expected a mapping.",
72
- });
73
- return { kind: "invalid", diagnostics };
74
- }
75
- if (namespaced) {
76
- const allowedFields = new Set(["kind", "description", "tags"]);
77
- for (const field of Object.keys(value)) {
78
- if (!allowedFields.has(field)) {
79
- diagnostics.push({
80
- path,
81
- field: `${fieldPrefix}${field}`,
82
- message: "Unknown field.",
83
- });
84
- }
85
- }
86
- }
87
- const context = { path, declaredKinds, declaredTags, diagnostics };
88
- const kind = validateDocumentKind({
89
- value: value.kind,
90
- field: `${fieldPrefix}kind`,
91
- context,
92
- });
93
- const description = validateDocumentDescription({
94
- value: value.description,
95
- field: `${fieldPrefix}description`,
96
- context,
97
- });
98
- const tags = validateDocumentTags({
99
- value: value.tags,
100
- field: `${fieldPrefix}tags`,
101
- context,
102
- });
103
- if (diagnostics.length > 0 ||
104
- kind === undefined ||
105
- description === undefined ||
106
- tags === undefined) {
107
- return { kind: "invalid", diagnostics };
81
+ const metadataResult = createDocumentSchema({
82
+ declaredKinds,
83
+ declaredTags,
84
+ }).safeParse(value);
85
+ if (!metadataResult.success) {
86
+ return {
87
+ kind: "invalid",
88
+ diagnostics: createDocumentDiagnostics({
89
+ error: metadataResult.error,
90
+ path,
91
+ fieldPrefix,
92
+ rootField: namespaced ? "waymark" : "frontmatter",
93
+ }),
94
+ };
108
95
  }
109
96
  return {
110
97
  kind: "registered",
111
- document: { kind, description, tags, body },
98
+ document: { ...metadataResult.data, body },
112
99
  };
113
100
  }
114
- function validateDocumentKind({ value, field, context, }) {
115
- if (value === undefined) {
116
- context.diagnostics.push({
117
- path: context.path,
118
- field,
119
- message: "Document kind is required.",
120
- });
121
- return undefined;
122
- }
123
- if (typeof value !== "string" || value.trim() === "") {
124
- context.diagnostics.push({
125
- path: context.path,
126
- field,
127
- message: "Document kind must be a non-empty string.",
128
- });
129
- return undefined;
130
- }
131
- if (!context.declaredKinds.has(value)) {
132
- context.diagnostics.push({
133
- path: context.path,
134
- field,
135
- message: `Undeclared kind "${value}".`,
136
- });
137
- }
138
- return value;
139
- }
140
- function validateDocumentDescription({ value, field, context, }) {
141
- if (value === undefined) {
142
- context.diagnostics.push({
143
- path: context.path,
144
- field,
145
- message: "Document Description is required.",
146
- });
147
- return undefined;
148
- }
149
- if (typeof value !== "string" || value.trim() === "") {
150
- context.diagnostics.push({
151
- path: context.path,
152
- field,
153
- message: "Document Description must be a non-empty string.",
154
- });
155
- return undefined;
156
- }
157
- return value;
101
+ function createDocumentSchema({ declaredKinds, declaredTags, }) {
102
+ return z.strictObject({
103
+ kind: z
104
+ .string({
105
+ error: (issue) => issue.input === undefined
106
+ ? "Document kind is required."
107
+ : "Document kind must be a non-empty string.",
108
+ })
109
+ .refine((kind) => kind.trim() !== "", {
110
+ error: "Document kind must be a non-empty string.",
111
+ })
112
+ .superRefine((kind, context) => {
113
+ if (kind.trim() !== "" && !declaredKinds.has(kind)) {
114
+ context.addIssue({
115
+ code: "custom",
116
+ message: `Undeclared kind "${kind}".`,
117
+ });
118
+ }
119
+ }),
120
+ description: z
121
+ .string({
122
+ error: (issue) => issue.input === undefined
123
+ ? "Document Description is required."
124
+ : "Document Description must be a non-empty string.",
125
+ })
126
+ .refine((description) => description.trim() !== "", {
127
+ error: "Document Description must be a non-empty string.",
128
+ }),
129
+ tags: z
130
+ .array(z.unknown(), { error: "Expected a YAML sequence of tags." })
131
+ .default([])
132
+ .superRefine((tags, context) => {
133
+ const seenTags = new Set();
134
+ for (const tag of tags) {
135
+ if (typeof tag !== "string" || tag.trim() === "") {
136
+ context.addIssue({
137
+ code: "custom",
138
+ message: "Every tag must be a non-empty string.",
139
+ });
140
+ continue;
141
+ }
142
+ if (seenTags.has(tag)) {
143
+ context.addIssue({
144
+ code: "custom",
145
+ message: `Duplicate tag "${tag}".`,
146
+ });
147
+ continue;
148
+ }
149
+ seenTags.add(tag);
150
+ if (!declaredTags.has(tag)) {
151
+ context.addIssue({
152
+ code: "custom",
153
+ message: `Undeclared tag "${tag}".`,
154
+ });
155
+ }
156
+ }
157
+ })
158
+ .transform((tags) => tags.filter((tag) => typeof tag === "string")),
159
+ }, { error: "Expected a mapping." });
158
160
  }
159
- function validateDocumentTags({ value, field, context, }) {
160
- if (value === undefined)
161
- return [];
162
- if (!Array.isArray(value)) {
163
- context.diagnostics.push({
164
- path: context.path,
165
- field,
166
- message: "Expected a YAML sequence of tags.",
167
- });
168
- return undefined;
169
- }
170
- const tags = [];
171
- const seenTags = new Set();
172
- for (const tag of value) {
173
- if (typeof tag !== "string" || tag.trim() === "") {
174
- context.diagnostics.push({
175
- path: context.path,
176
- field,
177
- message: "Every tag must be a non-empty string.",
178
- });
179
- continue;
180
- }
181
- if (seenTags.has(tag)) {
182
- context.diagnostics.push({
183
- path: context.path,
184
- field,
185
- message: `Duplicate tag "${tag}".`,
186
- });
161
+ function createDocumentDiagnostics({ error, path, fieldPrefix, rootField, }) {
162
+ const diagnostics = [];
163
+ for (const issue of error.issues) {
164
+ if (issue.code === "unrecognized_keys") {
165
+ for (const key of issue.keys) {
166
+ diagnostics.push({
167
+ path,
168
+ field: `${fieldPrefix}${key}`,
169
+ message: "Unknown field.",
170
+ });
171
+ }
187
172
  continue;
188
173
  }
189
- seenTags.add(tag);
190
- tags.push(tag);
191
- if (!context.declaredTags.has(tag)) {
192
- context.diagnostics.push({
193
- path: context.path,
194
- field,
195
- message: `Undeclared tag "${tag}".`,
196
- });
197
- }
174
+ const field = issue.path.find((segment) => typeof segment === "string");
175
+ diagnostics.push({
176
+ path,
177
+ field: field === undefined ? rootField : `${fieldPrefix}${field}`,
178
+ message: issue.message,
179
+ });
198
180
  }
199
- return tags;
200
- }
201
- function isRecord(value) {
202
- return typeof value === "object" && value !== null && !Array.isArray(value);
181
+ return diagnostics;
203
182
  }
@@ -4,11 +4,17 @@ type FilterableDocument = {
4
4
  kind: string;
5
5
  tags: string[];
6
6
  };
7
+ type MetadataCriteria = {
8
+ method: "filter-groups";
9
+ kinds: Set<string>;
10
+ tags: Set<string>;
11
+ requiredTags: Set<string>;
12
+ } | {
13
+ method: "filter-expression";
14
+ expression: string;
15
+ };
7
16
  type DocumentCriteria = {
8
- kinds: string[];
9
- tags: string[];
10
- requiredTags: string[];
11
- filter?: string;
17
+ metadata: MetadataCriteria;
12
18
  query?: string;
13
19
  };
14
20
  export declare function filterDocuments({ documents, configuration, criteria, }: {
@@ -1,36 +1,18 @@
1
1
  export function filterDocuments({ documents, configuration, criteria, }) {
2
- const kinds = parseIdentifierOptions({
3
- optionName: "--kinds",
4
- values: criteria.kinds,
5
- declarations: configuration.kinds,
6
- declarationName: "kind",
7
- });
8
- const tags = parseIdentifierOptions({
9
- optionName: "--tags",
10
- values: criteria.tags,
11
- declarations: configuration.tags,
12
- declarationName: "tag",
13
- });
14
- const requiredTags = parseIdentifierOptions({
15
- optionName: "--require-tags",
16
- values: criteria.requiredTags,
17
- declarations: configuration.tags,
18
- declarationName: "tag",
19
- });
20
- const matchesMetadataFilter = criteria.filter === undefined
21
- ? undefined
22
- : parseMetadataFilter({
23
- expression: criteria.filter,
2
+ const metadata = criteria.metadata;
3
+ const matchesMetadata = metadata.method === "filter-expression"
4
+ ? parseMetadataFilter({
5
+ expression: metadata.expression,
24
6
  declaredKinds: new Set(configuration.kinds.keys()),
25
7
  declaredTags: new Set(configuration.tags.keys()),
26
- });
8
+ })
9
+ : (document) => (metadata.kinds.size === 0 || metadata.kinds.has(document.kind)) &&
10
+ (metadata.tags.size === 0 ||
11
+ document.tags.some((tag) => metadata.tags.has(tag))) &&
12
+ [...metadata.requiredTags].every((tag) => document.tags.includes(tag));
27
13
  const normalizedQuery = criteria.query?.toLowerCase();
28
14
  return documents
29
- .filter((document) => (kinds.size === 0 || kinds.has(document.kind)) &&
30
- (tags.size === 0 || document.tags.some((tag) => tags.has(tag))) &&
31
- [...requiredTags].every((tag) => document.tags.includes(tag)) &&
32
- (matchesMetadataFilter === undefined ||
33
- matchesMetadataFilter(document)) &&
15
+ .filter((document) => matchesMetadata(document) &&
34
16
  (normalizedQuery === undefined ||
35
17
  document.body.toLowerCase().includes(normalizedQuery)))
36
18
  .sort((left, right) => compareText(left.path, right.path));
@@ -189,24 +171,6 @@ function describeToken(token) {
189
171
  return '"("';
190
172
  return `"${token.type.toUpperCase()}"`;
191
173
  }
192
- function parseIdentifierOptions({ optionName, values, declarations, declarationName, }) {
193
- const identifiers = new Set();
194
- for (const value of values) {
195
- for (const identifier of value.split(",")) {
196
- if (identifier === "") {
197
- throw new Error(`${optionName} contains an empty identifier.`);
198
- }
199
- if (identifiers.has(identifier)) {
200
- throw new Error(`${optionName} contains duplicate identifier "${identifier}".`);
201
- }
202
- if (!declarations.has(identifier)) {
203
- throw new Error(`${optionName} contains undeclared ${declarationName} "${identifier}".`);
204
- }
205
- identifiers.add(identifier);
206
- }
207
- }
208
- return identifiers;
209
- }
210
174
  function compareText(left, right) {
211
175
  return left < right ? -1 : left > right ? 1 : 0;
212
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waymark-docs",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Agent-focused CLI that surfaces repository documentation to improve coding-agent performance.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -45,7 +45,8 @@
45
45
  "dependencies": {
46
46
  "commander": "14.0.3",
47
47
  "globby": "16.2.2",
48
- "yaml": "2.9.0"
48
+ "yaml": "2.9.0",
49
+ "zod": "4.4.3"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/node": "24.10.10",