waymark-docs 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.
@@ -0,0 +1,212 @@
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,
24
+ declaredKinds: new Set(configuration.kinds.keys()),
25
+ declaredTags: new Set(configuration.tags.keys()),
26
+ });
27
+ const normalizedQuery = criteria.query?.toLowerCase();
28
+ 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)) &&
34
+ (normalizedQuery === undefined ||
35
+ document.body.toLowerCase().includes(normalizedQuery)))
36
+ .sort((left, right) => compareText(left.path, right.path));
37
+ }
38
+ export function parseMetadataFilter({ expression, declaredKinds, declaredTags, }) {
39
+ const tokens = tokenize(expression, declaredKinds, declaredTags);
40
+ let nextTokenIndex = 0;
41
+ function peek() {
42
+ return tokens[nextTokenIndex];
43
+ }
44
+ function consume() {
45
+ const token = tokens[nextTokenIndex];
46
+ if (!token) {
47
+ throw syntaxError(expression.length, 'Expected a kind: or tag: predicate, or "(".');
48
+ }
49
+ nextTokenIndex += 1;
50
+ return token;
51
+ }
52
+ function parsePrimary() {
53
+ const token = consume();
54
+ if (token.type === "kind" || token.type === "tag") {
55
+ return {
56
+ type: token.type,
57
+ identifier: token.identifier,
58
+ };
59
+ }
60
+ if (token.type === "left-parenthesis") {
61
+ const expressionNode = parseOr();
62
+ const closingToken = peek();
63
+ if (closingToken?.type !== "right-parenthesis") {
64
+ throw syntaxError(closingToken?.position ?? expression.length, 'Expected ")".');
65
+ }
66
+ consume();
67
+ return expressionNode;
68
+ }
69
+ throw syntaxError(token.position, 'Expected a kind: or tag: predicate, or "(".');
70
+ }
71
+ function parseNot() {
72
+ if (peek()?.type !== "not")
73
+ return parsePrimary();
74
+ consume();
75
+ return { type: "not", operand: parseNot() };
76
+ }
77
+ function parseAnd() {
78
+ let node = parseNot();
79
+ while (peek()?.type === "and") {
80
+ consume();
81
+ node = { type: "and", left: node, right: parseNot() };
82
+ }
83
+ return node;
84
+ }
85
+ function parseOr() {
86
+ let node = parseAnd();
87
+ while (peek()?.type === "or") {
88
+ consume();
89
+ node = { type: "or", left: node, right: parseAnd() };
90
+ }
91
+ return node;
92
+ }
93
+ if (tokens.length === 0) {
94
+ throw syntaxError(0, "Expression cannot be empty.");
95
+ }
96
+ const root = parseOr();
97
+ const remainingToken = peek();
98
+ if (remainingToken) {
99
+ const message = remainingToken.type === "kind" ||
100
+ remainingToken.type === "tag" ||
101
+ remainingToken.type === "not" ||
102
+ remainingToken.type === "left-parenthesis"
103
+ ? "Expected AND or OR before this token."
104
+ : `Unexpected ${describeToken(remainingToken)}.`;
105
+ throw syntaxError(remainingToken.position, message);
106
+ }
107
+ return (document) => evaluate(root, document);
108
+ }
109
+ function tokenize(expression, declaredKinds, declaredTags) {
110
+ const tokens = [];
111
+ let position = 0;
112
+ while (position < expression.length) {
113
+ if (/\s/.test(expression[position] ?? "")) {
114
+ position += 1;
115
+ continue;
116
+ }
117
+ if (expression[position] === "(") {
118
+ tokens.push({ type: "left-parenthesis", position });
119
+ position += 1;
120
+ continue;
121
+ }
122
+ if (expression[position] === ")") {
123
+ tokens.push({ type: "right-parenthesis", position });
124
+ position += 1;
125
+ continue;
126
+ }
127
+ const tokenPosition = position;
128
+ while (position < expression.length &&
129
+ !/\s|\(|\)/.test(expression[position] ?? "")) {
130
+ position += 1;
131
+ }
132
+ const value = expression.slice(tokenPosition, position);
133
+ const operator = value.toUpperCase();
134
+ if (operator === "NOT") {
135
+ tokens.push({ type: "not", position: tokenPosition });
136
+ continue;
137
+ }
138
+ if (operator === "AND") {
139
+ tokens.push({ type: "and", position: tokenPosition });
140
+ continue;
141
+ }
142
+ if (operator === "OR") {
143
+ tokens.push({ type: "or", position: tokenPosition });
144
+ continue;
145
+ }
146
+ const predicate = /^(kind|tag):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(value);
147
+ if (!predicate) {
148
+ throw syntaxError(tokenPosition, `Unsupported token "${value}". Expected kind:<identifier>, tag:<identifier>, NOT, AND, OR, or parentheses.`);
149
+ }
150
+ const [, predicateType, identifier] = predicate;
151
+ if (predicateType !== "kind" && predicateType !== "tag") {
152
+ throw new Error("Metadata Filter parser invariant failed.");
153
+ }
154
+ if (!identifier)
155
+ throw new Error("Metadata Filter parser invariant failed.");
156
+ const declarations = predicateType === "kind" ? declaredKinds : declaredTags;
157
+ if (!declarations.has(identifier)) {
158
+ throw syntaxError(tokenPosition, `Undeclared ${predicateType} "${identifier}".`);
159
+ }
160
+ tokens.push({
161
+ type: predicateType,
162
+ identifier,
163
+ position: tokenPosition,
164
+ });
165
+ }
166
+ return tokens;
167
+ }
168
+ function evaluate(node, document) {
169
+ switch (node.type) {
170
+ case "kind":
171
+ return document.kind === node.identifier;
172
+ case "tag":
173
+ return document.tags.includes(node.identifier);
174
+ case "not":
175
+ return !evaluate(node.operand, document);
176
+ case "and":
177
+ return evaluate(node.left, document) && evaluate(node.right, document);
178
+ case "or":
179
+ return evaluate(node.left, document) || evaluate(node.right, document);
180
+ }
181
+ }
182
+ function syntaxError(position, message) {
183
+ return new Error(`Metadata Filter syntax error at position ${position + 1}: ${message}`);
184
+ }
185
+ function describeToken(token) {
186
+ if (token.type === "right-parenthesis")
187
+ return '")"';
188
+ if (token.type === "left-parenthesis")
189
+ return '"("';
190
+ return `"${token.type.toUpperCase()}"`;
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
+ }
210
+ function compareText(left, right) {
211
+ return left < right ? -1 : left > right ? 1 : 0;
212
+ }
@@ -0,0 +1,3 @@
1
+ export { filterDocuments } from "./filter.js";
2
+ export { scanDocuments } from "./scan.js";
3
+ export type { DocumentScanDiagnostic, DocumentScanResult, WaymarkDocument, } from "./scan.js";
@@ -0,0 +1,2 @@
1
+ export { filterDocuments } from "./filter.js";
2
+ export { scanDocuments } from "./scan.js";
@@ -0,0 +1,33 @@
1
+ import { type Configuration } from "../configuration/index.js";
2
+ export type WaymarkDocument = {
3
+ path: string;
4
+ kind: string;
5
+ description: string;
6
+ tags: string[];
7
+ body: string;
8
+ };
9
+ export type DocumentScanDiagnostic = {
10
+ path: string;
11
+ field: string;
12
+ message: string;
13
+ };
14
+ type DocumentScanScope = {
15
+ directoryPath: string;
16
+ recursive: boolean;
17
+ };
18
+ export type DocumentScanResult = {
19
+ kind: "valid";
20
+ documents: WaymarkDocument[];
21
+ unregisteredDocuments: string[];
22
+ kindUsageCounts: Map<string, number>;
23
+ tagUsageCounts: Map<string, number>;
24
+ } | {
25
+ kind: "invalid";
26
+ diagnostics: DocumentScanDiagnostic[];
27
+ };
28
+ export declare function scanDocuments({ rootPath, configuration, scope, }: {
29
+ rootPath: string;
30
+ configuration: Configuration;
31
+ scope?: DocumentScanScope;
32
+ }): Promise<DocumentScanResult>;
33
+ export {};
@@ -0,0 +1,134 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
+ import { convertPathToPattern, globby } from "globby";
4
+ import { configurationFileName, } from "../configuration/index.js";
5
+ import { isErrorWithCode } from "../filesystem.js";
6
+ import { classifyDocument } from "./classify.js";
7
+ export async function scanDocuments({ rootPath, configuration, scope, }) {
8
+ const { rootPath: scanRootPath, scope: resolvedScope } = await resolveDocumentScanScope({ rootPath, scope });
9
+ const discoveredPaths = await globby(createCandidatePatterns(resolvedScope), {
10
+ cwd: scanRootPath,
11
+ gitignore: true,
12
+ ignore: ["**/.git", "**/.git/**", ...configuration.exclusions],
13
+ dot: true,
14
+ followSymbolicLinks: false,
15
+ braceExpansion: false,
16
+ extglob: false,
17
+ });
18
+ const documents = [];
19
+ const kindUsageCounts = createUsageCounts(configuration.kinds);
20
+ const tagUsageCounts = createUsageCounts(configuration.tags);
21
+ const diagnostics = [];
22
+ const unregisteredDocuments = [];
23
+ for (const path of discoveredPaths.sort(compareText)) {
24
+ if (path === configurationFileName)
25
+ continue;
26
+ if (path.endsWith(`/${configurationFileName}`)) {
27
+ diagnostics.push({
28
+ path,
29
+ field: "configuration",
30
+ message: "Nested Waymark configurations are not allowed.",
31
+ });
32
+ continue;
33
+ }
34
+ const source = await readFile(join(scanRootPath, path), "utf8");
35
+ const classification = classifyDocument({
36
+ source,
37
+ requireNamespace: configuration.requireNamespace,
38
+ declaredKinds: configuration.kinds,
39
+ declaredTags: configuration.tags,
40
+ path,
41
+ });
42
+ if (classification.kind === "unregistered") {
43
+ unregisteredDocuments.push(path);
44
+ continue;
45
+ }
46
+ if (classification.kind === "invalid") {
47
+ diagnostics.push(...classification.diagnostics);
48
+ continue;
49
+ }
50
+ const document = { path, ...classification.document };
51
+ documents.push(document);
52
+ incrementUsageCount(kindUsageCounts, document.kind);
53
+ for (const tag of document.tags)
54
+ incrementUsageCount(tagUsageCounts, tag);
55
+ }
56
+ if (diagnostics.length > 0) {
57
+ return {
58
+ kind: "invalid",
59
+ diagnostics: diagnostics.sort(compareDiagnostics),
60
+ };
61
+ }
62
+ return {
63
+ kind: "valid",
64
+ documents,
65
+ unregisteredDocuments,
66
+ kindUsageCounts,
67
+ tagUsageCounts,
68
+ };
69
+ }
70
+ async function resolveDocumentScanScope({ rootPath, scope, }) {
71
+ if (!scope)
72
+ return { rootPath, scope };
73
+ let directoryStats;
74
+ try {
75
+ directoryStats = await lstat(scope.directoryPath);
76
+ }
77
+ catch (error) {
78
+ if (isErrorWithCode(error, "ENOENT")) {
79
+ throw new Error(`Directory does not exist: ${scope.directoryPath}`);
80
+ }
81
+ throw error;
82
+ }
83
+ if (!directoryStats.isDirectory()) {
84
+ throw new Error(`Not a directory: ${scope.directoryPath}`);
85
+ }
86
+ const canonicalRootPath = await realpath(rootPath);
87
+ const canonicalDirectoryPath = await realpath(scope.directoryPath);
88
+ const relativeDirectoryPath = relative(canonicalRootPath, canonicalDirectoryPath);
89
+ if (relativeDirectoryPath !== "" &&
90
+ (relativeDirectoryPath === ".." ||
91
+ relativeDirectoryPath.startsWith(`..${sep}`) ||
92
+ isAbsolute(relativeDirectoryPath))) {
93
+ throw new Error(`Directory is outside the configuration root: ${scope.directoryPath}`);
94
+ }
95
+ return {
96
+ rootPath: canonicalRootPath,
97
+ scope: {
98
+ relativeDirectoryPath,
99
+ recursive: scope.recursive,
100
+ },
101
+ };
102
+ }
103
+ function createCandidatePatterns(scope) {
104
+ if (!scope) {
105
+ return ["**/*.md", "**/*.mdx", `**/${configurationFileName}`];
106
+ }
107
+ const directoryPattern = convertPathToPattern(scope.relativeDirectoryPath);
108
+ const directoryPrefix = directoryPattern === "" ? "" : `${directoryPattern}/`;
109
+ const candidatePrefix = scope.recursive
110
+ ? `${directoryPrefix}**/`
111
+ : directoryPrefix;
112
+ return [
113
+ `${candidatePrefix}*.md`,
114
+ `${candidatePrefix}*.mdx`,
115
+ `${candidatePrefix}${configurationFileName}`,
116
+ ];
117
+ }
118
+ function createUsageCounts(declarations) {
119
+ return new Map([...declarations.keys()].map((identifier) => [identifier, 0]));
120
+ }
121
+ function incrementUsageCount(usageCounts, identifier) {
122
+ const currentCount = usageCounts.get(identifier);
123
+ if (currentCount !== undefined) {
124
+ usageCounts.set(identifier, currentCount + 1);
125
+ }
126
+ }
127
+ function compareDiagnostics(left, right) {
128
+ return (compareText(left.path, right.path) ||
129
+ compareText(left.field, right.field) ||
130
+ compareText(left.message, right.message));
131
+ }
132
+ function compareText(left, right) {
133
+ return left < right ? -1 : left > right ? 1 : 0;
134
+ }
@@ -0,0 +1,2 @@
1
+ export declare function pathExists(path: string): Promise<boolean>;
2
+ export declare function isErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException;
@@ -0,0 +1,15 @@
1
+ import { lstat } from "node:fs/promises";
2
+ export async function pathExists(path) {
3
+ try {
4
+ await lstat(path);
5
+ return true;
6
+ }
7
+ catch (error) {
8
+ if (isErrorWithCode(error, "ENOENT"))
9
+ return false;
10
+ throw error;
11
+ }
12
+ }
13
+ export function isErrorWithCode(error, code) {
14
+ return error instanceof Error && "code" in error && error.code === code;
15
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "waymark-docs",
3
+ "version": "0.1.0",
4
+ "description": "Find repository documentation relevant to coding agents",
5
+ "keywords": [
6
+ "ai-agents",
7
+ "cli",
8
+ "documentation",
9
+ "markdown",
10
+ "repository"
11
+ ],
12
+ "homepage": "https://github.com/ysfaran/waymark#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ysfaran/waymark/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Yusuf Aran",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/ysfaran/waymark.git",
21
+ "directory": "cli"
22
+ },
23
+ "bin": {
24
+ "waymark": "dist/cli.js"
25
+ },
26
+ "files": [
27
+ "CHANGELOG.md",
28
+ "dist"
29
+ ],
30
+ "type": "module",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "pnpm clean && tsc --project tsconfig.json && chmod +x dist/cli.js",
36
+ "clean": "rm -rf dist",
37
+ "dev": "tsx src/cli.ts",
38
+ "prepack": "cp ../README.md ../CHANGELOG.md ../LICENSE . && pnpm build",
39
+ "postpack": "rm -f README.md CHANGELOG.md LICENSE",
40
+ "typecheck": "tsc --project tsconfig.json --noEmit"
41
+ },
42
+ "dependencies": {
43
+ "commander": "14.0.3",
44
+ "globby": "16.2.2",
45
+ "yaml": "2.9.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "24.10.10",
49
+ "tsx": "4.20.6",
50
+ "typescript": "7.0.2"
51
+ },
52
+ "engines": {
53
+ "node": ">=24"
54
+ }
55
+ }