patram 0.0.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * @import { LoadPatramConfigResult, PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
3
+ */
4
+
5
+ import { readFile } from 'node:fs/promises';
6
+ import { resolve } from 'node:path';
7
+ import process from 'node:process';
8
+
9
+ import { z } from 'zod';
10
+
11
+ const CONFIG_FILE_NAME = '.patram.json';
12
+
13
+ const stored_query_schema = z
14
+ .object({
15
+ where: z.string().min(1, 'Stored query "where" must not be empty.'),
16
+ })
17
+ .strict();
18
+
19
+ const patram_repo_config_schema = z
20
+ .object({
21
+ include: z
22
+ .array(z.string().min(1, 'Include globs must not be empty.'))
23
+ .min(1, 'Include must contain at least one glob.'),
24
+ queries: z.record(z.string().min(1), stored_query_schema),
25
+ })
26
+ .strict();
27
+
28
+ /**
29
+ * Load and validate the repo Patram config.
30
+ *
31
+ * @param {string} [project_directory]
32
+ * @returns {Promise<LoadPatramConfigResult>}
33
+ */
34
+ export async function loadPatramConfig(project_directory = process.cwd()) {
35
+ const config_file_path = resolve(project_directory, CONFIG_FILE_NAME);
36
+ const config_source = await readConfigSource(config_file_path);
37
+
38
+ if (config_source === null) {
39
+ return createLoadResult(null, [createMissingConfigDiagnostic()]);
40
+ }
41
+
42
+ const parse_result = parseConfigJson(config_source);
43
+
44
+ if (!parse_result.success) {
45
+ return createLoadResult(null, [parse_result.diagnostic]);
46
+ }
47
+
48
+ const config_result = patram_repo_config_schema.safeParse(parse_result.value);
49
+
50
+ if (!config_result.success) {
51
+ return createLoadResult(
52
+ null,
53
+ config_result.error.issues.map(createValidationDiagnostic),
54
+ );
55
+ }
56
+
57
+ return createLoadResult(config_result.data, []);
58
+ }
59
+
60
+ /**
61
+ * @param {string} config_file_path
62
+ * @returns {Promise<string | null>}
63
+ */
64
+ async function readConfigSource(config_file_path) {
65
+ try {
66
+ return await readFile(config_file_path, 'utf8');
67
+ } catch (error) {
68
+ if (isMissingFileError(error)) {
69
+ return null;
70
+ }
71
+
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {string} config_source
78
+ * @returns {{ success: true, value: unknown } | { success: false, diagnostic: PatramDiagnostic }}
79
+ */
80
+ function parseConfigJson(config_source) {
81
+ try {
82
+ return {
83
+ success: true,
84
+ value: JSON.parse(config_source),
85
+ };
86
+ } catch (error) {
87
+ if (error instanceof SyntaxError) {
88
+ return {
89
+ diagnostic: createInvalidJsonDiagnostic(config_source, error),
90
+ success: false,
91
+ };
92
+ }
93
+
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * @param {PatramRepoConfig | null} config
100
+ * @param {PatramDiagnostic[]} diagnostics
101
+ * @returns {LoadPatramConfigResult}
102
+ */
103
+ function createLoadResult(config, diagnostics) {
104
+ return {
105
+ config,
106
+ config_path: CONFIG_FILE_NAME,
107
+ diagnostics,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * @returns {PatramDiagnostic}
113
+ */
114
+ function createMissingConfigDiagnostic() {
115
+ return {
116
+ code: 'config.not_found',
117
+ column: 1,
118
+ level: 'error',
119
+ line: 1,
120
+ message: 'Config file ".patram.json" was not found.',
121
+ path: CONFIG_FILE_NAME,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * @param {string} config_source
127
+ * @param {SyntaxError} error
128
+ * @returns {PatramDiagnostic}
129
+ */
130
+ function createInvalidJsonDiagnostic(config_source, error) {
131
+ const origin = getJsonSyntaxOrigin(config_source, error.message);
132
+
133
+ return {
134
+ code: 'config.invalid_json',
135
+ column: origin.column,
136
+ level: 'error',
137
+ line: origin.line,
138
+ message: 'Invalid JSON syntax.',
139
+ path: CONFIG_FILE_NAME,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * @param {import('zod').core.$ZodIssue} issue
145
+ * @returns {PatramDiagnostic}
146
+ */
147
+ function createValidationDiagnostic(issue) {
148
+ const issue_path = formatIssuePath(issue.path);
149
+
150
+ if (issue_path) {
151
+ return {
152
+ code: 'config.invalid',
153
+ column: 1,
154
+ level: 'error',
155
+ line: 1,
156
+ message: `Invalid config at "${issue_path}": ${issue.message}`,
157
+ path: CONFIG_FILE_NAME,
158
+ };
159
+ }
160
+
161
+ return {
162
+ code: 'config.invalid',
163
+ column: 1,
164
+ level: 'error',
165
+ line: 1,
166
+ message: `Invalid config: ${issue.message}`,
167
+ path: CONFIG_FILE_NAME,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * @param {unknown} error
173
+ * @returns {error is NodeJS.ErrnoException}
174
+ */
175
+ function isMissingFileError(error) {
176
+ if (!(error instanceof Error)) {
177
+ return false;
178
+ }
179
+
180
+ return 'code' in error && error.code === 'ENOENT';
181
+ }
182
+
183
+ /**
184
+ * @param {string} config_source
185
+ * @param {string} error_message
186
+ * @returns {{ line: number, column: number }}
187
+ */
188
+ function getJsonSyntaxOrigin(config_source, error_message) {
189
+ const position_match = error_message.match(/position (?<offset>\d+)/du);
190
+
191
+ if (position_match?.groups?.offset) {
192
+ const offset = Number.parseInt(position_match.groups.offset, 10);
193
+
194
+ return getLineAndColumnFromOffset(config_source, offset);
195
+ }
196
+
197
+ const token_match = error_message.match(/Unexpected token '(?<token>.)'/u);
198
+
199
+ if (token_match?.groups?.token) {
200
+ const offset = config_source.lastIndexOf(token_match.groups.token);
201
+
202
+ if (offset >= 0) {
203
+ return getLineAndColumnFromOffset(config_source, offset);
204
+ }
205
+ }
206
+
207
+ return {
208
+ column: 1,
209
+ line: 1,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * @param {(string | number | symbol | undefined)[]} issue_path
215
+ * @returns {string}
216
+ */
217
+ function formatIssuePath(issue_path) {
218
+ return issue_path.map(String).join('.');
219
+ }
220
+
221
+ /**
222
+ * @param {string} source_text
223
+ * @param {number} offset
224
+ * @returns {{ line: number, column: number }}
225
+ */
226
+ function getLineAndColumnFromOffset(source_text, offset) {
227
+ let line_number = 1;
228
+ let column_number = 1;
229
+
230
+ for (const character of source_text.slice(0, offset)) {
231
+ if (character === '\n') {
232
+ line_number += 1;
233
+ column_number = 1;
234
+ continue;
235
+ }
236
+
237
+ column_number += 1;
238
+ }
239
+
240
+ return {
241
+ column: column_number,
242
+ line: line_number,
243
+ };
244
+ }
@@ -0,0 +1,211 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterEach, expect, it } from 'vitest';
6
+
7
+ import { loadPatramConfig } from './load-patram-config.js';
8
+
9
+ const test_context = createTestContext();
10
+
11
+ afterEach(async () => {
12
+ await cleanupTestContext(test_context);
13
+ });
14
+
15
+ it('loads and validates .patram.json from a project directory', async () => {
16
+ test_context.project_directory = await createTempProjectDirectory();
17
+ await writeProjectConfig(
18
+ test_context.project_directory,
19
+ createValidConfigSource(),
20
+ );
21
+
22
+ const load_result = await loadPatramConfig(test_context.project_directory);
23
+
24
+ expect(load_result).toEqual({
25
+ config: {
26
+ include: ['docs/**/*.md'],
27
+ queries: {
28
+ pending: {
29
+ where: 'kind=task and status=pending',
30
+ },
31
+ },
32
+ },
33
+ config_path: '.patram.json',
34
+ diagnostics: [],
35
+ });
36
+ });
37
+
38
+ it('defaults to the current working directory', async () => {
39
+ test_context.project_directory = await createTempProjectDirectory();
40
+ await writeProjectConfig(
41
+ test_context.project_directory,
42
+ createValidConfigSource(),
43
+ );
44
+ process.chdir(test_context.project_directory);
45
+
46
+ const load_result = await loadPatramConfig();
47
+
48
+ expect(load_result.config?.queries.pending.where).toBe(
49
+ 'kind=task and status=pending',
50
+ );
51
+ expect(load_result.diagnostics).toEqual([]);
52
+ });
53
+
54
+ it('reports a missing config file', async () => {
55
+ test_context.project_directory = await createTempProjectDirectory();
56
+ const load_result = await loadPatramConfig(test_context.project_directory);
57
+
58
+ expect(load_result.config).toBeNull();
59
+ expect(load_result.diagnostics).toEqual([
60
+ {
61
+ code: 'config.not_found',
62
+ column: 1,
63
+ level: 'error',
64
+ line: 1,
65
+ message: 'Config file ".patram.json" was not found.',
66
+ path: '.patram.json',
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it('reports invalid JSON syntax', async () => {
72
+ test_context.project_directory = await createTempProjectDirectory();
73
+ await writeProjectConfig(test_context.project_directory, createBrokenJson());
74
+
75
+ const load_result = await loadPatramConfig(test_context.project_directory);
76
+
77
+ expect(load_result.config).toBeNull();
78
+ expect(load_result.diagnostics).toEqual([
79
+ {
80
+ code: 'config.invalid_json',
81
+ column: 1,
82
+ level: 'error',
83
+ line: 3,
84
+ message: 'Invalid JSON syntax.',
85
+ path: '.patram.json',
86
+ },
87
+ ]);
88
+ });
89
+
90
+ it('reports config validation diagnostics', async () => {
91
+ test_context.project_directory = await createTempProjectDirectory();
92
+ await writeProjectConfig(
93
+ test_context.project_directory,
94
+ createInvalidConfigSource(),
95
+ );
96
+
97
+ const load_result = await loadPatramConfig(test_context.project_directory);
98
+
99
+ expect(load_result.config).toBeNull();
100
+ expect(load_result.diagnostics).toEqual([
101
+ {
102
+ code: 'config.invalid',
103
+ column: 1,
104
+ level: 'error',
105
+ line: 1,
106
+ message:
107
+ 'Invalid config at "include": Include must contain at least one glob.',
108
+ path: '.patram.json',
109
+ },
110
+ {
111
+ code: 'config.invalid',
112
+ column: 1,
113
+ level: 'error',
114
+ line: 1,
115
+ message:
116
+ 'Invalid config at "queries.pending.where": Stored query "where" must not be empty.',
117
+ path: '.patram.json',
118
+ },
119
+ ]);
120
+ });
121
+
122
+ /**
123
+ * Create a temporary project directory.
124
+ *
125
+ * @returns {Promise<string>}
126
+ */
127
+ async function createTempProjectDirectory() {
128
+ return mkdtemp(join(tmpdir(), 'patram-load-config-'));
129
+ }
130
+
131
+ /**
132
+ * Write a project config file.
133
+ *
134
+ * @param {string} project_directory
135
+ * @param {string} config_source
136
+ */
137
+ async function writeProjectConfig(project_directory, config_source) {
138
+ await writeFile(join(project_directory, '.patram.json'), config_source);
139
+ }
140
+
141
+ /**
142
+ * Remove a temporary directory tree.
143
+ *
144
+ * @param {string} project_directory
145
+ */
146
+ async function removeDirectory(project_directory) {
147
+ await rm(project_directory, { force: true, recursive: true });
148
+ }
149
+
150
+ /**
151
+ * Create invalid JSON for parser tests.
152
+ *
153
+ * @returns {string}
154
+ */
155
+ function createBrokenJson() {
156
+ return ['{', ' "include": [', '}'].join('\n');
157
+ }
158
+
159
+ /**
160
+ * Create invalid config JSON.
161
+ *
162
+ * @returns {string}
163
+ */
164
+ function createInvalidConfigSource() {
165
+ return JSON.stringify({
166
+ include: [],
167
+ queries: {
168
+ pending: {
169
+ where: '',
170
+ },
171
+ },
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Create valid config JSON.
177
+ *
178
+ * @returns {string}
179
+ */
180
+ function createValidConfigSource() {
181
+ return JSON.stringify({
182
+ include: ['docs/**/*.md'],
183
+ queries: {
184
+ pending: {
185
+ where: 'kind=task and status=pending',
186
+ },
187
+ },
188
+ });
189
+ }
190
+
191
+ /**
192
+ * @returns {{ original_working_directory: string, project_directory: string | null }}
193
+ */
194
+ function createTestContext() {
195
+ return {
196
+ original_working_directory: process.cwd(),
197
+ project_directory: null,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * @param {{ original_working_directory: string, project_directory: string | null }} test_context
203
+ */
204
+ async function cleanupTestContext(test_context) {
205
+ process.chdir(test_context.original_working_directory);
206
+
207
+ if (test_context.project_directory) {
208
+ await removeDirectory(test_context.project_directory);
209
+ test_context.project_directory = null;
210
+ }
211
+ }
@@ -0,0 +1,23 @@
1
+ export interface StoredQueryConfig {
2
+ where: string;
3
+ }
4
+
5
+ export interface PatramRepoConfig {
6
+ include: string[];
7
+ queries: Record<string, StoredQueryConfig>;
8
+ }
9
+
10
+ export interface PatramDiagnostic {
11
+ code: string;
12
+ column: number;
13
+ level: 'error';
14
+ line: number;
15
+ message: string;
16
+ path: string;
17
+ }
18
+
19
+ export interface LoadPatramConfigResult {
20
+ config: PatramRepoConfig | null;
21
+ config_path: string;
22
+ diagnostics: PatramDiagnostic[];
23
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @import { PatramClaim, ParseClaimsInput, PatramClaimFields } from './parse-claims.types.ts';
3
+ */
4
+
5
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
6
+ const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/dgu;
7
+ const DIRECTIVE_PATTERN = /^([A-Z][A-Za-z ]+):\s+(.+)$/du;
8
+ const HEADING_PATTERN = /^#\s+(.+)$/du;
9
+
10
+ /**
11
+ * Parse a file into neutral Patram claims.
12
+ *
13
+ * @param {ParseClaimsInput} parse_input
14
+ * @returns {PatramClaim[]}
15
+ */
16
+ export function parseClaims(parse_input) {
17
+ const file_extension = getFileExtension(parse_input.path);
18
+
19
+ if (MARKDOWN_EXTENSIONS.has(file_extension)) {
20
+ return parseMarkdownClaims(parse_input);
21
+ }
22
+
23
+ return [];
24
+ }
25
+
26
+ /**
27
+ * @param {ParseClaimsInput} parse_input
28
+ * @returns {PatramClaim[]}
29
+ */
30
+ function parseMarkdownClaims(parse_input) {
31
+ const lines = parse_input.source.split('\n');
32
+
33
+ /** @type {PatramClaim[]} */
34
+ const claims = [];
35
+ const title_value = getMarkdownTitle(lines);
36
+
37
+ if (title_value) {
38
+ claims.push(
39
+ createClaim(parse_input.path, claims.length + 1, 'document.title', {
40
+ value: title_value,
41
+ }),
42
+ );
43
+ }
44
+
45
+ for (const [line_index, line] of lines.entries()) {
46
+ const line_number = line_index + 1;
47
+
48
+ collectMarkdownLinkClaims(parse_input.path, line, line_number, claims);
49
+ collectDirectiveClaims(parse_input.path, line, line_number, claims);
50
+ }
51
+
52
+ return claims;
53
+ }
54
+
55
+ /**
56
+ * @param {string} file_path
57
+ * @param {string} line
58
+ * @param {number} line_number
59
+ * @param {PatramClaim[]} claims
60
+ */
61
+ function collectMarkdownLinkClaims(file_path, line, line_number, claims) {
62
+ for (const link_match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
63
+ const link_text = link_match[1];
64
+ const target_value = link_match[2];
65
+ const column_number =
66
+ link_match.index === undefined ? 1 : link_match.index + 1;
67
+
68
+ claims.push(
69
+ createClaim(file_path, claims.length + 1, 'markdown.link', {
70
+ origin: {
71
+ column: column_number,
72
+ line: line_number,
73
+ path: file_path,
74
+ },
75
+ value: {
76
+ target: target_value,
77
+ text: link_text,
78
+ },
79
+ }),
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {string} file_path
86
+ * @param {string} line
87
+ * @param {number} line_number
88
+ * @param {PatramClaim[]} claims
89
+ */
90
+ function collectDirectiveClaims(file_path, line, line_number, claims) {
91
+ const directive_match = line.match(DIRECTIVE_PATTERN);
92
+
93
+ if (!directive_match) {
94
+ return;
95
+ }
96
+
97
+ const directive_name = normalizeDirectiveName(directive_match[1]);
98
+ const directive_value = directive_match[2].trim();
99
+
100
+ claims.push(
101
+ createClaim(file_path, claims.length + 1, 'directive', {
102
+ name: directive_name,
103
+ origin: {
104
+ column: 1,
105
+ line: line_number,
106
+ path: file_path,
107
+ },
108
+ parser: 'markdown',
109
+ value: directive_value,
110
+ }),
111
+ );
112
+ }
113
+
114
+ /**
115
+ * @param {string[]} lines
116
+ * @returns {string | null}
117
+ */
118
+ function getMarkdownTitle(lines) {
119
+ const first_line = lines[0].trim();
120
+
121
+ if (first_line.length === 0) {
122
+ return null;
123
+ }
124
+
125
+ const heading_match = first_line.match(HEADING_PATTERN);
126
+
127
+ if (heading_match) {
128
+ return heading_match[1].trim();
129
+ }
130
+
131
+ return first_line;
132
+ }
133
+
134
+ /**
135
+ * @param {string} file_path
136
+ * @param {number} claim_number
137
+ * @param {string} claim_type
138
+ * @param {PatramClaimFields} claim_fields
139
+ * @returns {PatramClaim}
140
+ */
141
+ function createClaim(file_path, claim_number, claim_type, claim_fields) {
142
+ const document_id = `doc:${file_path}`;
143
+ const origin = claim_fields.origin ?? {
144
+ column: 1,
145
+ line: 1,
146
+ path: file_path,
147
+ };
148
+
149
+ return {
150
+ ...claim_fields,
151
+ document_id,
152
+ id: `claim:${document_id}:${claim_number}`,
153
+ origin,
154
+ type: claim_type,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {string} directive_label
160
+ * @returns {string}
161
+ */
162
+ function normalizeDirectiveName(directive_label) {
163
+ return directive_label.trim().toLowerCase().replaceAll(/\s+/dgu, '_');
164
+ }
165
+
166
+ /**
167
+ * @param {string} file_path
168
+ * @returns {string}
169
+ */
170
+ function getFileExtension(file_path) {
171
+ const last_dot_index = file_path.lastIndexOf('.');
172
+
173
+ if (last_dot_index < 0) {
174
+ return '';
175
+ }
176
+
177
+ return file_path.slice(last_dot_index);
178
+ }