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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maximilian Antoni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/patram.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @import { PatramClaim } from '../lib/parse-claims.types.ts';
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+ import { resolve } from 'node:path';
9
+ import process from 'node:process';
10
+ import { pathToFileURL } from 'node:url';
11
+
12
+ import { buildGraph } from '../lib/build-graph.js';
13
+ import { checkGraph } from '../lib/check-graph.js';
14
+ import { listSourceFiles } from '../lib/list-source-files.js';
15
+ import { loadPatramConfig } from '../lib/load-patram-config.js';
16
+ import { parseClaims } from '../lib/parse-claims.js';
17
+ import { parsePatramConfig } from '../lib/patram-config.js';
18
+
19
+ const CHECK_GRAPH_CONFIG = parsePatramConfig({
20
+ kinds: {
21
+ document: {
22
+ builtin: true,
23
+ label: 'Document',
24
+ },
25
+ },
26
+ mappings: {
27
+ 'document.title': {
28
+ node: {
29
+ field: 'title',
30
+ kind: 'document',
31
+ },
32
+ },
33
+ 'markdown.link': {
34
+ emit: {
35
+ relation: 'links_to',
36
+ target: 'path',
37
+ target_kind: 'document',
38
+ },
39
+ },
40
+ },
41
+ relations: {
42
+ links_to: {
43
+ builtin: true,
44
+ from: ['document'],
45
+ to: ['document'],
46
+ },
47
+ },
48
+ });
49
+
50
+ if (isEntrypoint(import.meta.url, process.argv[1])) {
51
+ process.exitCode = await main(process.argv.slice(2), {
52
+ stderr: process.stderr,
53
+ stdout: process.stdout,
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Run the Patram CLI.
59
+ *
60
+ * @param {string[]} cli_arguments
61
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { write(chunk: string): boolean } }} io_context
62
+ * @returns {Promise<number>}
63
+ */
64
+ export async function main(cli_arguments, io_context) {
65
+ const command_name = cli_arguments[0];
66
+
67
+ if (command_name === 'check') {
68
+ return runCheckCommand(cli_arguments.slice(1), io_context);
69
+ }
70
+
71
+ io_context.stderr.write('Unknown command.\n');
72
+
73
+ return 1;
74
+ }
75
+
76
+ /**
77
+ * @param {string[]} command_arguments
78
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { write(chunk: string): boolean } }} io_context
79
+ * @returns {Promise<number>}
80
+ */
81
+ async function runCheckCommand(command_arguments, io_context) {
82
+ const project_directory = command_arguments[0] ?? process.cwd();
83
+ const load_result = await loadPatramConfig(project_directory);
84
+
85
+ if (load_result.diagnostics.length > 0) {
86
+ writeDiagnostics(io_context.stderr, load_result.diagnostics);
87
+
88
+ return 1;
89
+ }
90
+
91
+ const repo_config = load_result.config;
92
+
93
+ if (!repo_config) {
94
+ throw new Error('Expected a valid Patram repo config.');
95
+ }
96
+
97
+ const source_file_paths = await listSourceFiles(
98
+ repo_config.include,
99
+ project_directory,
100
+ );
101
+ const claims = await collectClaims(source_file_paths, project_directory);
102
+ const graph = buildGraph(CHECK_GRAPH_CONFIG, claims);
103
+ const diagnostics = checkGraph(graph, source_file_paths);
104
+
105
+ if (diagnostics.length > 0) {
106
+ writeDiagnostics(io_context.stderr, diagnostics);
107
+
108
+ return 1;
109
+ }
110
+
111
+ return 0;
112
+ }
113
+
114
+ /**
115
+ * @param {string[]} source_file_paths
116
+ * @param {string} project_directory
117
+ * @returns {Promise<PatramClaim[]>}
118
+ */
119
+ async function collectClaims(source_file_paths, project_directory) {
120
+ /** @type {PatramClaim[]} */
121
+ const claims = [];
122
+
123
+ for (const source_file_path of source_file_paths) {
124
+ const source_text = await readFile(
125
+ resolve(project_directory, source_file_path),
126
+ 'utf8',
127
+ );
128
+
129
+ claims.push(
130
+ ...parseClaims({
131
+ path: source_file_path,
132
+ source: source_text,
133
+ }),
134
+ );
135
+ }
136
+
137
+ return claims;
138
+ }
139
+
140
+ /**
141
+ * @param {{ write(chunk: string): boolean }} output_stream
142
+ * @param {import('../lib/load-patram-config.types.ts').PatramDiagnostic[]} diagnostics
143
+ */
144
+ function writeDiagnostics(output_stream, diagnostics) {
145
+ for (const diagnostic of diagnostics) {
146
+ output_stream.write(formatDiagnostic(diagnostic));
147
+ }
148
+ }
149
+
150
+ /**
151
+ * @param {import('../lib/load-patram-config.types.ts').PatramDiagnostic} diagnostic
152
+ * @returns {string}
153
+ */
154
+ function formatDiagnostic(diagnostic) {
155
+ return `${diagnostic.path}:${diagnostic.line}:${diagnostic.column} ${diagnostic.level} ${diagnostic.code} ${diagnostic.message}\n`;
156
+ }
157
+
158
+ /**
159
+ * @param {string} module_url
160
+ * @param {string | undefined} process_entry_path
161
+ * @returns {boolean}
162
+ */
163
+ function isEntrypoint(module_url, process_entry_path) {
164
+ if (!process_entry_path) {
165
+ return false;
166
+ }
167
+
168
+ return module_url === pathToFileURL(process_entry_path).href;
169
+ }
@@ -0,0 +1,184 @@
1
+ import { mkdtemp, mkdir, 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 { main } from './patram.js';
8
+
9
+ const test_context = createTestContext();
10
+
11
+ afterEach(async () => {
12
+ await cleanupTestContext(test_context);
13
+ });
14
+
15
+ it('prints config diagnostics for check failures', async () => {
16
+ test_context.project_directory = await createTempProjectDirectory();
17
+ const io_context = createIoContext();
18
+
19
+ const exit_code = await main(['check', test_context.project_directory], {
20
+ stderr: io_context.stderr,
21
+ stdout: io_context.stdout,
22
+ });
23
+
24
+ expect(exit_code).toBe(1);
25
+ expect(io_context.stderr_chunks).toEqual([
26
+ '.patram.json:1:1 error config.not_found Config file ".patram.json" was not found.\n',
27
+ ]);
28
+ expect(io_context.stdout_chunks).toEqual([]);
29
+ });
30
+
31
+ it('prints broken link diagnostics for check failures', async () => {
32
+ test_context.project_directory = await createTempProjectDirectory();
33
+ const io_context = createIoContext();
34
+
35
+ await writeProjectConfig(test_context.project_directory);
36
+ await writeProjectFile(
37
+ test_context.project_directory,
38
+ 'docs/patram.md',
39
+ createBrokenLinkSource(),
40
+ );
41
+
42
+ const exit_code = await main(['check', test_context.project_directory], {
43
+ stderr: io_context.stderr,
44
+ stdout: io_context.stdout,
45
+ });
46
+
47
+ expect(exit_code).toBe(1);
48
+ expect(io_context.stderr_chunks).toEqual([
49
+ 'docs/patram.md:3:5 error graph.link_broken Document link target "docs/missing.md" was not found.\n',
50
+ ]);
51
+ });
52
+
53
+ it('defaults check to the current working directory and exits 0 on valid input', async () => {
54
+ test_context.project_directory = await createTempProjectDirectory();
55
+ const io_context = createIoContext();
56
+
57
+ await writeProjectConfig(test_context.project_directory);
58
+ await writeProjectFile(
59
+ test_context.project_directory,
60
+ 'docs/patram.md',
61
+ createValidLinkSource(),
62
+ );
63
+ await writeProjectFile(
64
+ test_context.project_directory,
65
+ 'docs/guide.md',
66
+ '# Guide\n',
67
+ );
68
+ process.chdir(test_context.project_directory);
69
+
70
+ const exit_code = await main(['check'], {
71
+ stderr: io_context.stderr,
72
+ stdout: io_context.stdout,
73
+ });
74
+
75
+ expect(exit_code).toBe(0);
76
+ expect(io_context.stderr_chunks).toEqual([]);
77
+ expect(io_context.stdout_chunks).toEqual([]);
78
+ });
79
+
80
+ /**
81
+ * @returns {string}
82
+ */
83
+ function createBrokenLinkSource() {
84
+ return ['# Patram', '', 'See [missing](./missing.md).'].join('\n');
85
+ }
86
+
87
+ /**
88
+ * @returns {string}
89
+ */
90
+ function createValidLinkSource() {
91
+ return ['# Patram', '', 'See [guide](./guide.md).'].join('\n');
92
+ }
93
+
94
+ /**
95
+ * @returns {{ original_working_directory: string, project_directory: string | null }}
96
+ */
97
+ function createTestContext() {
98
+ return {
99
+ original_working_directory: process.cwd(),
100
+ project_directory: null,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * @returns {{ stderr: { write(chunk: string): boolean }, stderr_chunks: string[], stdout: { write(chunk: string): boolean }, stdout_chunks: string[] }}
106
+ */
107
+ function createIoContext() {
108
+ /** @type {string[]} */
109
+ const stderr_chunks = [];
110
+ /** @type {string[]} */
111
+ const stdout_chunks = [];
112
+
113
+ return {
114
+ stderr: {
115
+ /**
116
+ * @param {string} chunk
117
+ * @returns {boolean}
118
+ */
119
+ write(chunk) {
120
+ stderr_chunks.push(chunk);
121
+
122
+ return true;
123
+ },
124
+ },
125
+ stderr_chunks,
126
+ stdout: {
127
+ /**
128
+ * @param {string} chunk
129
+ * @returns {boolean}
130
+ */
131
+ write(chunk) {
132
+ stdout_chunks.push(chunk);
133
+
134
+ return true;
135
+ },
136
+ },
137
+ stdout_chunks,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * @returns {Promise<string>}
143
+ */
144
+ async function createTempProjectDirectory() {
145
+ return mkdtemp(join(tmpdir(), 'patram-check-command-'));
146
+ }
147
+
148
+ /**
149
+ * @param {{ original_working_directory: string, project_directory: string | null }} test_context
150
+ */
151
+ async function cleanupTestContext(test_context) {
152
+ process.chdir(test_context.original_working_directory);
153
+
154
+ if (test_context.project_directory) {
155
+ await rm(test_context.project_directory, { force: true, recursive: true });
156
+ test_context.project_directory = null;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * @param {string} project_directory
162
+ */
163
+ async function writeProjectConfig(project_directory) {
164
+ await writeFile(
165
+ join(project_directory, '.patram.json'),
166
+ JSON.stringify({
167
+ include: ['docs/**/*.md'],
168
+ queries: {},
169
+ }),
170
+ );
171
+ }
172
+
173
+ /**
174
+ * @param {string} project_directory
175
+ * @param {string} relative_path
176
+ * @param {string} source_text
177
+ */
178
+ async function writeProjectFile(project_directory, relative_path, source_text) {
179
+ const file_path = join(project_directory, relative_path);
180
+ const directory_path = file_path.slice(0, file_path.lastIndexOf('/'));
181
+
182
+ await mkdir(directory_path, { recursive: true });
183
+ await writeFile(file_path, source_text);
184
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * @import { BuildGraphResult, GraphEdge, GraphNode } from './build-graph.types.ts';
3
+ * @import { PatramClaim } from './parse-claims.types.ts';
4
+ * @import { MappingDefinition, PatramConfig } from './patram-config.types.ts';
5
+ */
6
+
7
+ import { posix } from 'node:path';
8
+
9
+ /**
10
+ * Build a Patram graph from semantic config and parsed claims.
11
+ *
12
+ * @param {PatramConfig} patram_config
13
+ * @param {PatramClaim[]} claims
14
+ * @returns {BuildGraphResult}
15
+ */
16
+ export function buildGraph(patram_config, claims) {
17
+ /** @type {Map<string, GraphNode>} */
18
+ const graph_nodes = new Map();
19
+ /** @type {GraphEdge[]} */
20
+ const graph_edges = [];
21
+ let edge_number = 0;
22
+
23
+ for (const claim of claims) {
24
+ const source_document_node = upsertNode(
25
+ graph_nodes,
26
+ 'document',
27
+ normalizeRepoRelativePath(claim.origin.path),
28
+ );
29
+ const mapping_definition = resolveMappingDefinition(
30
+ patram_config.mappings,
31
+ claim,
32
+ );
33
+
34
+ if (!mapping_definition) {
35
+ continue;
36
+ }
37
+
38
+ if (mapping_definition.node) {
39
+ applyNodeMapping(graph_nodes, mapping_definition.node, claim);
40
+ }
41
+
42
+ if (!mapping_definition.emit) {
43
+ continue;
44
+ }
45
+
46
+ const target_key = resolveTargetKey(mapping_definition.emit.target, claim);
47
+ const target_node = upsertNode(
48
+ graph_nodes,
49
+ mapping_definition.emit.target_kind,
50
+ target_key,
51
+ );
52
+
53
+ edge_number += 1;
54
+ graph_edges.push({
55
+ from: source_document_node.id,
56
+ id: `edge:${edge_number}`,
57
+ origin: claim.origin,
58
+ relation: mapping_definition.emit.relation,
59
+ to: target_node.id,
60
+ });
61
+ }
62
+
63
+ return {
64
+ edges: graph_edges,
65
+ nodes: Object.fromEntries(
66
+ [...graph_nodes.entries()].sort(compareNodeEntries),
67
+ ),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * @param {Record<string, MappingDefinition>} mappings
73
+ * @param {PatramClaim} claim
74
+ * @returns {MappingDefinition | null}
75
+ */
76
+ function resolveMappingDefinition(mappings, claim) {
77
+ if (claim.type === 'directive') {
78
+ return resolveDirectiveMapping(mappings, claim);
79
+ }
80
+
81
+ return mappings[claim.type] ?? null;
82
+ }
83
+
84
+ /**
85
+ * @param {Record<string, MappingDefinition>} mappings
86
+ * @param {PatramClaim} claim
87
+ * @returns {MappingDefinition | null}
88
+ */
89
+ function resolveDirectiveMapping(mappings, claim) {
90
+ if (!claim.parser || !claim.name) {
91
+ return null;
92
+ }
93
+
94
+ return mappings[`${claim.parser}.directive.${claim.name}`] ?? null;
95
+ }
96
+
97
+ /**
98
+ * @param {Map<string, GraphNode>} graph_nodes
99
+ * @param {{ field: string, kind: string }} node_mapping
100
+ * @param {PatramClaim} claim
101
+ */
102
+ function applyNodeMapping(graph_nodes, node_mapping, claim) {
103
+ const source_key = normalizeRepoRelativePath(claim.origin.path);
104
+ const graph_node = upsertNode(graph_nodes, node_mapping.kind, source_key);
105
+
106
+ graph_node[node_mapping.field] = getStringClaimValue(claim);
107
+ }
108
+
109
+ /**
110
+ * @param {'path'} target_type
111
+ * @param {PatramClaim} claim
112
+ * @returns {string}
113
+ */
114
+ function resolveTargetKey(target_type, claim) {
115
+ if (target_type !== 'path') {
116
+ throw new Error(`Unsupported target type "${target_type}".`);
117
+ }
118
+
119
+ const source_directory = posix.dirname(
120
+ normalizeRepoRelativePath(claim.origin.path),
121
+ );
122
+ const raw_target = getPathTargetValue(claim);
123
+
124
+ return normalizeRepoRelativePath(posix.join(source_directory, raw_target));
125
+ }
126
+
127
+ /**
128
+ * @param {PatramClaim} claim
129
+ * @returns {string}
130
+ */
131
+ function getPathTargetValue(claim) {
132
+ if (typeof claim.value === 'string') {
133
+ return claim.value;
134
+ }
135
+
136
+ return claim.value.target;
137
+ }
138
+
139
+ /**
140
+ * @param {PatramClaim} claim
141
+ * @returns {string}
142
+ */
143
+ function getStringClaimValue(claim) {
144
+ if (typeof claim.value === 'string') {
145
+ return claim.value;
146
+ }
147
+
148
+ throw new Error(`Claim "${claim.id}" does not carry a string value.`);
149
+ }
150
+
151
+ /**
152
+ * @param {Map<string, GraphNode>} graph_nodes
153
+ * @param {string} kind_name
154
+ * @param {string} node_key
155
+ * @returns {GraphNode}
156
+ */
157
+ function upsertNode(graph_nodes, kind_name, node_key) {
158
+ const node_id = getNodeId(kind_name, node_key);
159
+ const existing_node = graph_nodes.get(node_id);
160
+
161
+ if (existing_node) {
162
+ return existing_node;
163
+ }
164
+
165
+ const graph_node = createNode(node_id, kind_name, node_key);
166
+
167
+ graph_nodes.set(node_id, graph_node);
168
+
169
+ return graph_node;
170
+ }
171
+
172
+ /**
173
+ * @param {string} node_id
174
+ * @param {string} kind_name
175
+ * @param {string} node_key
176
+ * @returns {GraphNode}
177
+ */
178
+ function createNode(node_id, kind_name, node_key) {
179
+ if (kind_name === 'document') {
180
+ return {
181
+ id: node_id,
182
+ kind: kind_name,
183
+ path: node_key,
184
+ };
185
+ }
186
+
187
+ return {
188
+ id: node_id,
189
+ key: node_key,
190
+ kind: kind_name,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * @param {string} kind_name
196
+ * @param {string} node_key
197
+ * @returns {string}
198
+ */
199
+ function getNodeId(kind_name, node_key) {
200
+ if (kind_name === 'document') {
201
+ return `doc:${node_key}`;
202
+ }
203
+
204
+ return `${kind_name}:${node_key}`;
205
+ }
206
+
207
+ /**
208
+ * @param {string} source_path
209
+ * @returns {string}
210
+ */
211
+ function normalizeRepoRelativePath(source_path) {
212
+ return posix.normalize(source_path.replaceAll('\\', '/'));
213
+ }
214
+
215
+ /**
216
+ * @param {[string, GraphNode]} left_entry
217
+ * @param {[string, GraphNode]} right_entry
218
+ * @returns {number}
219
+ */
220
+ function compareNodeEntries(left_entry, right_entry) {
221
+ return left_entry[0].localeCompare(right_entry[0], 'en');
222
+ }