waymark-docs 0.1.1 → 0.2.1

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.1](https://github.com/ysfaran/waymark/compare/v0.2.0...v0.2.1) (2026-08-13)
6
+
7
+
8
+ ### Documentation
9
+
10
+ * **readme:** explain where Waymark fits ([ffe25dc](https://github.com/ysfaran/waymark/commit/ffe25dc15f1651519ad34e9e67f7fee90c69ef62))
11
+
12
+ ## [0.2.0](https://github.com/ysfaran/waymark/compare/v0.1.1...v0.2.0) (2026-08-03)
13
+
14
+
15
+ ### Features
16
+
17
+ * **cli:** change require-tags shorthand to -T ([#3](https://github.com/ysfaran/waymark/issues/3)) ([70a95f6](https://github.com/ysfaran/waymark/commit/70a95f6f876e4b48040d778d15bc9189c6020646))
18
+
5
19
  ## [0.1.1](https://github.com/ysfaran/waymark/compare/v0.1.0...v0.1.1) (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,23 +37,19 @@ npm install --save-dev waymark-docs
35
37
 
36
38
  ## Quick start
37
39
 
38
- 1. Create a Waymark configuration in the repository root:
40
+ 1. **Create a Waymark configuration.**
41
+
42
+ Run `init` in the repository root:
39
43
 
40
44
  ```sh
41
45
  npx waymark init
42
46
  ```
43
47
 
44
- 2. Define the document kinds and tags that agents can search:
45
-
46
- ```yaml
47
- # When true, document metadata must be nested under a `waymark` frontmatter key.
48
- require-namespace: false
48
+ 2. **Define searchable metadata.**
49
49
 
50
- # Skip generated or vendored documentation during discovery.
51
- ignore:
52
- - docs/generated/**
53
- - vendor/**
50
+ Add the document kinds and tags that agents can search:
54
51
 
52
+ ```yaml
55
53
  kinds:
56
54
  adr: Read to understand past architectural decisions and their constraints
57
55
  convention: Read before changing code to follow required repository practices
@@ -61,11 +59,10 @@ npm install --save-dev waymark-docs
61
59
  typescript: TypeScript-related documentation
62
60
  ```
63
61
 
64
- Ignore patterns are relative to the repository root and support `*`, `?`,
65
- and `**` wildcards. Waymark also honors `.gitignore` automatically.
62
+ 3. **Register a document.**
66
63
 
67
- 3. Register a Markdown or MDX file by adding Waymark metadata. For example,
68
- save this as `docs/conventions/typescript.md`:
64
+ Add Waymark metadata to a Markdown or MDX file. For example, save this as
65
+ `docs/conventions/typescript.md`:
69
66
 
70
67
  ```yaml
71
68
  ---
@@ -76,7 +73,9 @@ npm install --save-dev waymark-docs
76
73
  # TypeScript conventions
77
74
  ```
78
75
 
79
- 4. Validate the repository:
76
+ 4. **Validate the repository.**
77
+
78
+ Check the configuration and discovered documents:
80
79
 
81
80
  ```sh
82
81
  npx waymark status
@@ -91,7 +90,9 @@ npm install --save-dev waymark-docs
91
90
  Tags: 2
92
91
  ```
93
92
 
94
- 5. Discover the document:
93
+ 5. **Discover the document.**
94
+
95
+ Find the registered convention by kind and tag:
95
96
 
96
97
  ```sh
97
98
  npx waymark find --kinds convention --tags typescript --show description
@@ -143,6 +144,19 @@ tags:
143
144
  example-tag: Explain the topic represented by this tag
144
145
  ```
145
146
 
147
+ To skip generated or vendored documentation during discovery, add `ignore`
148
+ patterns:
149
+
150
+ ```yaml
151
+ # Skip generated or vendored documentation during discovery.
152
+ ignore:
153
+ - docs/generated/**
154
+ - vendor/**
155
+ ```
156
+
157
+ Ignore patterns are relative to the repository root and support `*`, `?`, and
158
+ `**` wildcards. Waymark also honors `.gitignore` automatically.
159
+
146
160
  `init` never overwrites an existing configuration and does not allow a nested
147
161
  configuration beneath another Waymark root.
148
162
 
@@ -190,7 +204,7 @@ Usage: waymark find [options]
190
204
  Options:
191
205
  -k, --kinds <identifiers> Match any kind (comma-separated, repeatable)
192
206
  -t, --tags <identifiers> Match any tag (comma-separated, repeatable)
193
- -r, --require-tags <identifiers> Require every tag (comma-separated, repeatable)
207
+ -T, --require-tags <identifiers> Require every tag (comma-separated, repeatable)
194
208
  -f, --filter <expression> Match a Boolean metadata filter
195
209
  -q, --query <text> Match a literal content query
196
210
  -s, --show <fields> Show kind, tags, and description
@@ -7,7 +7,7 @@ export function createFindCommand() {
7
7
  .description("Discover Waymark Documents")
8
8
  .option("-k, --kinds <identifiers>", "Match any Document Kind (comma-separated, repeatable)", collectOptionValue, [])
9
9
  .option("-t, --tags <identifiers>", "Match any Document Tag (comma-separated, repeatable)", collectOptionValue, [])
10
- .option("-r, --require-tags <identifiers>", "Require every Document Tag (comma-separated, repeatable)", collectOptionValue, [])
10
+ .option("-T, --require-tags <identifiers>", "Require every Document Tag (comma-separated, repeatable)", collectOptionValue, [])
11
11
  .option("-f, --filter <expression>", "Match a Boolean Metadata Filter")
12
12
  .option("-q, --query <text>", "Match a literal Content Query")
13
13
  .option("-s, --show <fields>", "Show kind, tags, and description (comma-separated)")
@@ -25,27 +25,45 @@ export function createFindCommand() {
25
25
  }
26
26
  const shownFields = parseShownFields(options.show);
27
27
  const loadedConfiguration = await loadConfiguration(process.cwd());
28
- if (loadedConfiguration.kind === "malformed") {
28
+ if (loadedConfiguration.kind === "invalid") {
29
29
  throwDiagnostics(loadedConfiguration.diagnostics);
30
30
  }
31
31
  const { configuration, rootPath } = loadedConfiguration;
32
+ const metadataCriteria = options.filter === undefined
33
+ ? {
34
+ method: "filter-groups",
35
+ kinds: parseIdentifierOptions({
36
+ optionName: "--kinds",
37
+ values: options.kinds,
38
+ declarations: configuration.kinds,
39
+ declarationName: "kind",
40
+ }),
41
+ tags: parseIdentifierOptions({
42
+ optionName: "--tags",
43
+ values: options.tags,
44
+ declarations: configuration.tags,
45
+ declarationName: "tag",
46
+ }),
47
+ requiredTags: parseIdentifierOptions({
48
+ optionName: "--require-tags",
49
+ values: options.requireTags,
50
+ declarations: configuration.tags,
51
+ declarationName: "tag",
52
+ }),
53
+ }
54
+ : {
55
+ method: "filter-expression",
56
+ expression: options.filter,
57
+ };
32
58
  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);
59
+ if (documentScan.kind === "invalid") {
60
+ throwDiagnostics(documentScan.diagnostics.sort(compareDiagnostics));
40
61
  }
41
62
  const matchingDocuments = filterDocuments({
42
63
  documents: documentScan.documents,
43
64
  configuration,
44
65
  criteria: {
45
- kinds: options.kinds,
46
- tags: options.tags,
47
- requiredTags: options.requireTags,
48
- filter: options.filter,
66
+ metadata: metadataCriteria,
49
67
  query: options.query,
50
68
  },
51
69
  });
@@ -171,6 +189,24 @@ function createTreeDirectory() {
171
189
  function collectOptionValue(value, previous) {
172
190
  return [...previous, value];
173
191
  }
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
+ }
174
210
  function compareText(left, right) {
175
211
  return left < right ? -1 : left > right ? 1 : 0;
176
212
  }
@@ -11,10 +11,7 @@ export function createLsCommand() {
11
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;
@@ -9,20 +9,15 @@ export function createStatusCommand() {
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.1.1",
3
+ "version": "0.2.1",
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",