patram 0.0.2 → 0.1.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.
Files changed (51) hide show
  1. package/bin/patram.js +25 -147
  2. package/lib/build-graph-identity.js +238 -0
  3. package/lib/build-graph.js +143 -77
  4. package/lib/check-graph.js +23 -7
  5. package/lib/claim-helpers.js +55 -0
  6. package/lib/command-output.js +83 -0
  7. package/lib/layout-stored-queries.js +213 -0
  8. package/lib/list-queries.js +18 -0
  9. package/lib/list-source-files.js +50 -15
  10. package/lib/load-patram-config.js +106 -18
  11. package/lib/load-patram-config.types.ts +9 -0
  12. package/lib/load-project-graph.js +124 -0
  13. package/lib/output-view.types.ts +73 -0
  14. package/lib/parse-claims.js +38 -158
  15. package/lib/parse-claims.types.ts +7 -0
  16. package/lib/parse-cli-arguments-helpers.js +273 -0
  17. package/lib/parse-cli-arguments.js +114 -0
  18. package/lib/parse-cli-arguments.types.ts +24 -0
  19. package/lib/parse-cli-color-options.js +44 -0
  20. package/lib/parse-cli-query-pagination.js +49 -0
  21. package/lib/parse-jsdoc-blocks.js +184 -0
  22. package/lib/parse-jsdoc-claims.js +280 -0
  23. package/lib/parse-jsdoc-prose.js +111 -0
  24. package/lib/parse-markdown-claims.js +242 -0
  25. package/lib/parse-markdown-directives.js +136 -0
  26. package/lib/parse-where-clause.js +312 -0
  27. package/lib/patram-cli.js +337 -0
  28. package/lib/patram-config.js +3 -1
  29. package/lib/patram-config.types.ts +2 -1
  30. package/lib/query-graph.js +256 -0
  31. package/lib/render-check-output.js +315 -0
  32. package/lib/render-json-output.js +108 -0
  33. package/lib/render-output-view.js +193 -0
  34. package/lib/render-plain-output.js +237 -0
  35. package/lib/render-rich-output.js +293 -0
  36. package/lib/render-rich-source.js +1333 -0
  37. package/lib/resolve-check-target.js +190 -0
  38. package/lib/resolve-output-mode.js +60 -0
  39. package/lib/resolve-patram-graph-config.js +88 -0
  40. package/lib/resolve-where-clause.js +51 -0
  41. package/lib/show-document.js +311 -0
  42. package/lib/source-file-defaults.js +28 -0
  43. package/lib/write-paged-output.js +87 -0
  44. package/package.json +21 -10
  45. package/bin/patram.test.js +0 -184
  46. package/lib/build-graph.test.js +0 -141
  47. package/lib/check-graph.test.js +0 -103
  48. package/lib/list-source-files.test.js +0 -101
  49. package/lib/load-patram-config.test.js +0 -211
  50. package/lib/parse-claims.test.js +0 -113
  51. package/lib/patram-config.test.js +0 -147
@@ -3,17 +3,33 @@
3
3
  * @import { PatramDiagnostic } from './load-patram-config.types.ts';
4
4
  */
5
5
 
6
+ /**
7
+ * Graph validation.
8
+ *
9
+ * Reports broken document links and missing edge nodes after graph
10
+ * materialization.
11
+ *
12
+ * Kind: graph
13
+ * Status: active
14
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
15
+ * Decided by: ../docs/decisions/check-link-target-existence.md
16
+ * Implements: ../docs/tasks/v0/check-command.md
17
+ * @patram
18
+ * @see {@link ./build-graph.js}
19
+ * @see {@link ../docs/decisions/check-link-target-existence.md}
20
+ */
21
+
6
22
  /**
7
23
  * Check a materialized graph for broken document links and missing edge nodes.
8
24
  *
9
25
  * @param {BuildGraphResult} graph
10
- * @param {string[]} source_file_paths
26
+ * @param {string[]} existing_file_paths
11
27
  * @returns {PatramDiagnostic[]}
12
28
  */
13
- export function checkGraph(graph, source_file_paths) {
29
+ export function checkGraph(graph, existing_file_paths) {
14
30
  /** @type {PatramDiagnostic[]} */
15
31
  const diagnostics = [];
16
- const source_file_path_set = new Set(source_file_paths);
32
+ const existing_file_path_set = new Set(existing_file_paths);
17
33
 
18
34
  for (const graph_edge of graph.edges) {
19
35
  const source_node = graph.nodes[graph_edge.from];
@@ -34,7 +50,7 @@ export function checkGraph(graph, source_file_paths) {
34
50
  diagnostics,
35
51
  graph_edge,
36
52
  target_node,
37
- source_file_path_set,
53
+ existing_file_path_set,
38
54
  );
39
55
  }
40
56
 
@@ -78,13 +94,13 @@ function collectMissingNodeDiagnostics(
78
94
  * @param {PatramDiagnostic[]} diagnostics
79
95
  * @param {GraphEdge} graph_edge
80
96
  * @param {GraphNode} target_node
81
- * @param {Set<string>} source_file_path_set
97
+ * @param {Set<string>} existing_file_path_set
82
98
  */
83
99
  function collectBrokenLinkDiagnostics(
84
100
  diagnostics,
85
101
  graph_edge,
86
102
  target_node,
87
- source_file_path_set,
103
+ existing_file_path_set,
88
104
  ) {
89
105
  if (graph_edge.relation !== 'links_to') {
90
106
  return;
@@ -94,7 +110,7 @@ function collectBrokenLinkDiagnostics(
94
110
  return;
95
111
  }
96
112
 
97
- if (source_file_path_set.has(target_node.path)) {
113
+ if (existing_file_path_set.has(target_node.path)) {
98
114
  return;
99
115
  }
100
116
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @import { PatramClaim, PatramClaimFields } from './parse-claims.types.ts';
3
+ */
4
+
5
+ const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/du;
6
+
7
+ /**
8
+ * @param {string} target_value
9
+ * @returns {boolean}
10
+ */
11
+ export function isPathLikeTarget(target_value) {
12
+ if (target_value.startsWith('#')) {
13
+ return false;
14
+ }
15
+
16
+ return !URI_SCHEME_PATTERN.test(target_value);
17
+ }
18
+
19
+ /**
20
+ * @param {string} file_path
21
+ * @param {number} claim_number
22
+ * @param {string} claim_type
23
+ * @param {PatramClaimFields} claim_fields
24
+ * @returns {PatramClaim}
25
+ */
26
+ export function createClaim(file_path, claim_number, claim_type, claim_fields) {
27
+ const document_id = `doc:${file_path}`;
28
+ const origin = claim_fields.origin ?? {
29
+ column: 1,
30
+ line: 1,
31
+ path: file_path,
32
+ };
33
+
34
+ return {
35
+ ...claim_fields,
36
+ document_id,
37
+ id: `claim:${document_id}:${claim_number}`,
38
+ origin,
39
+ type: claim_type,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * @param {string} file_path
45
+ * @returns {string}
46
+ */
47
+ export function getFileExtension(file_path) {
48
+ const last_dot_index = file_path.lastIndexOf('.');
49
+
50
+ if (last_dot_index < 0) {
51
+ return '';
52
+ }
53
+
54
+ return file_path.slice(last_dot_index);
55
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @import { ParsedCliArguments } from './parse-cli-arguments.types.ts';
3
+ * @import { OutputView } from './output-view.types.ts';
4
+ */
5
+
6
+ import process from 'node:process';
7
+
8
+ import { renderOutputView } from './render-output-view.js';
9
+ import { resolveOutputMode } from './resolve-output-mode.js';
10
+ import { writePagedOutput } from './write-paged-output.js';
11
+
12
+ /**
13
+ * TTY and pager output control.
14
+ *
15
+ * Resolves the final output mode and switches between direct stdout writes and
16
+ * interactive pager output.
17
+ *
18
+ * Kind: output
19
+ * Status: active
20
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
21
+ * Decided by: ../docs/decisions/tty-pager-output.md
22
+ * @patram
23
+ * @see {@link ./render-output-view.js}
24
+ * @see {@link ../docs/decisions/tty-pager-output.md}
25
+ */
26
+
27
+ /**
28
+ * @param {{ stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
29
+ * @param {ParsedCliArguments} parsed_command
30
+ * @param {OutputView} output_view
31
+ * @returns {Promise<void>}
32
+ */
33
+ export async function writeCommandOutput(
34
+ io_context,
35
+ parsed_command,
36
+ output_view,
37
+ ) {
38
+ const rendered_output = await renderOutputView(
39
+ output_view,
40
+ resolveOutputMode(parsed_command, {
41
+ is_tty: io_context.stdout.isTTY === true,
42
+ no_color: process.env.NO_COLOR !== undefined,
43
+ term: process.env.TERM,
44
+ }),
45
+ parsed_command,
46
+ );
47
+
48
+ if (shouldPageCommandOutput(parsed_command, io_context.stdout)) {
49
+ await writeInteractiveOutput(io_context, rendered_output);
50
+
51
+ return;
52
+ }
53
+
54
+ io_context.stdout.write(rendered_output);
55
+ }
56
+
57
+ /**
58
+ * @param {ParsedCliArguments} parsed_command
59
+ * @param {{ isTTY?: boolean }} output_stream
60
+ * @returns {boolean}
61
+ */
62
+ export function shouldPageCommandOutput(parsed_command, output_stream) {
63
+ return (
64
+ output_stream.isTTY === true &&
65
+ (parsed_command.command_name === 'query' ||
66
+ parsed_command.command_name === 'show')
67
+ );
68
+ }
69
+
70
+ /**
71
+ * @param {{ write_paged_output?: (output_text: string) => Promise<void> }} io_context
72
+ * @param {string} rendered_output
73
+ * @returns {Promise<void>}
74
+ */
75
+ async function writeInteractiveOutput(io_context, rendered_output) {
76
+ if (io_context.write_paged_output) {
77
+ await io_context.write_paged_output(rendered_output);
78
+
79
+ return;
80
+ }
81
+
82
+ await writePagedOutput(rendered_output);
83
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @import { OutputStoredQueryItem } from './output-view.types.ts';
3
+ */
4
+
5
+ import { parseWhereClause } from './parse-where-clause.js';
6
+
7
+ const MAX_STORED_QUERY_WIDTH = 100;
8
+ const MIN_TERM_COLUMN_WIDTH = 20;
9
+ const STORED_QUERY_COLUMN_GAP = 2;
10
+
11
+ /**
12
+ * @typedef {'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain'} StoredQuerySegmentKind
13
+ */
14
+
15
+ /**
16
+ * @typedef {{ kind: StoredQuerySegmentKind, text: string }} StoredQuerySegment
17
+ */
18
+
19
+ /**
20
+ * Layout stored queries into styled lines shared by plain and rich renderers.
21
+ *
22
+ * @param {OutputStoredQueryItem[]} output_items
23
+ * @returns {StoredQuerySegment[][]}
24
+ */
25
+ export function layoutStoredQueries(output_items) {
26
+ if (output_items.length === 0) {
27
+ return [];
28
+ }
29
+
30
+ const name_column_width = Math.max(
31
+ ...output_items.map((output_item) => output_item.name.length),
32
+ );
33
+ const term_column_width = Math.max(
34
+ MIN_TERM_COLUMN_WIDTH,
35
+ MAX_STORED_QUERY_WIDTH - name_column_width - STORED_QUERY_COLUMN_GAP,
36
+ );
37
+
38
+ return output_items.flatMap((output_item) =>
39
+ layoutStoredQuery(output_item, name_column_width, term_column_width),
40
+ );
41
+ }
42
+
43
+ /**
44
+ * @param {OutputStoredQueryItem} output_item
45
+ * @param {number} name_column_width
46
+ * @param {number} term_column_width
47
+ * @returns {StoredQuerySegment[][]}
48
+ */
49
+ function layoutStoredQuery(output_item, name_column_width, term_column_width) {
50
+ const term_lines = wrapPhrases(
51
+ createStoredQueryPhrases(output_item.where),
52
+ term_column_width,
53
+ );
54
+ const continuation_prefix = ' '.repeat(
55
+ name_column_width + STORED_QUERY_COLUMN_GAP,
56
+ );
57
+
58
+ return term_lines.map((line_segments, line_index) => {
59
+ if (line_index === 0) {
60
+ return [
61
+ {
62
+ kind: 'name',
63
+ text: output_item.name.padEnd(name_column_width, ' '),
64
+ },
65
+ {
66
+ kind: 'plain',
67
+ text: ' '.repeat(STORED_QUERY_COLUMN_GAP),
68
+ },
69
+ ...line_segments,
70
+ ];
71
+ }
72
+
73
+ return [
74
+ {
75
+ kind: 'plain',
76
+ text: continuation_prefix,
77
+ },
78
+ ...line_segments,
79
+ ];
80
+ });
81
+ }
82
+
83
+ /**
84
+ * @param {string} where_clause
85
+ * @returns {StoredQuerySegment[][]}
86
+ */
87
+ function createStoredQueryPhrases(where_clause) {
88
+ const parse_result = parseWhereClause(where_clause);
89
+
90
+ if (!parse_result.success) {
91
+ return createFallbackPhrases(where_clause);
92
+ }
93
+
94
+ return parse_result.clauses.map((clause, clause_index) =>
95
+ createClausePhrase(clause, clause_index > 0),
96
+ );
97
+ }
98
+
99
+ /**
100
+ * @param {{ is_negated: boolean, term: { kind: 'field', field_name: 'id' | 'kind' | 'path' | 'status' | 'title', operator: '=' | '^=' | '~', value: string } | { kind: 'relation', relation_name: string } | { kind: 'relation_target', relation_name: string, target_id: string } }} clause
101
+ * @param {boolean} should_prefix_and
102
+ * @returns {StoredQuerySegment[]}
103
+ */
104
+ function createClausePhrase(clause, should_prefix_and) {
105
+ /** @type {StoredQuerySegment[]} */
106
+ const phrase = [];
107
+
108
+ if (should_prefix_and) {
109
+ phrase.push({ kind: 'keyword', text: 'and' });
110
+ phrase.push({ kind: 'plain', text: ' ' });
111
+ }
112
+
113
+ if (clause.is_negated) {
114
+ phrase.push({ kind: 'keyword', text: 'not' });
115
+ phrase.push({ kind: 'plain', text: ' ' });
116
+ }
117
+
118
+ phrase.push(...createTermSegments(clause.term));
119
+
120
+ return phrase;
121
+ }
122
+
123
+ /**
124
+ * @param {{ kind: 'field', field_name: 'id' | 'kind' | 'path' | 'status' | 'title', operator: '=' | '^=' | '~', value: string } | { kind: 'relation', relation_name: string } | { kind: 'relation_target', relation_name: string, target_id: string }} term
125
+ * @returns {StoredQuerySegment[]}
126
+ */
127
+ function createTermSegments(term) {
128
+ if (term.kind === 'field') {
129
+ return [
130
+ { kind: 'field_name', text: term.field_name },
131
+ { kind: 'operator', text: term.operator },
132
+ { kind: 'literal', text: term.value },
133
+ ];
134
+ }
135
+
136
+ if (term.kind === 'relation_target') {
137
+ return [
138
+ { kind: 'field_name', text: term.relation_name },
139
+ { kind: 'operator', text: '=' },
140
+ { kind: 'literal', text: term.target_id },
141
+ ];
142
+ }
143
+
144
+ return [
145
+ { kind: 'field_name', text: term.relation_name },
146
+ { kind: 'operator', text: ':*' },
147
+ ];
148
+ }
149
+
150
+ /**
151
+ * @param {string} where_clause
152
+ * @returns {StoredQuerySegment[][]}
153
+ */
154
+ function createFallbackPhrases(where_clause) {
155
+ const tokens = where_clause.match(/\S+/gu) ?? [];
156
+
157
+ if (tokens.length === 0) {
158
+ return [[{ kind: 'literal', text: where_clause }]];
159
+ }
160
+
161
+ return tokens.map((token) => [{ kind: 'literal', text: token }]);
162
+ }
163
+
164
+ /**
165
+ * @param {StoredQuerySegment[][]} phrases
166
+ * @param {number} term_column_width
167
+ * @returns {StoredQuerySegment[][]}
168
+ */
169
+ function wrapPhrases(phrases, term_column_width) {
170
+ /** @type {StoredQuerySegment[][]} */
171
+ const lines = [];
172
+ /** @type {StoredQuerySegment[]} */
173
+ let current_line = [];
174
+ let current_width = 0;
175
+
176
+ for (const phrase of phrases) {
177
+ const phrase_width = measureSegments(phrase);
178
+
179
+ if (current_line.length === 0) {
180
+ current_line = [...phrase];
181
+ current_width = phrase_width;
182
+ continue;
183
+ }
184
+
185
+ if (current_width + 1 + phrase_width > term_column_width) {
186
+ lines.push(current_line);
187
+ current_line = [...phrase];
188
+ current_width = phrase_width;
189
+ continue;
190
+ }
191
+
192
+ current_line.push({ kind: 'plain', text: ' ' });
193
+ current_line.push(...phrase);
194
+ current_width += 1 + phrase_width;
195
+ }
196
+
197
+ if (current_line.length > 0) {
198
+ lines.push(current_line);
199
+ }
200
+
201
+ return lines;
202
+ }
203
+
204
+ /**
205
+ * @param {StoredQuerySegment[]} segments
206
+ * @returns {number}
207
+ */
208
+ function measureSegments(segments) {
209
+ return segments.reduce(
210
+ (total_width, segment) => total_width + segment.text.length,
211
+ 0,
212
+ );
213
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @import { StoredQueryConfig } from './load-patram-config.types.ts';
3
+ */
4
+
5
+ /**
6
+ * List stored queries in stable name order.
7
+ *
8
+ * @param {Record<string, StoredQueryConfig>} stored_queries
9
+ * @returns {{ name: string, where: string }[]}
10
+ */
11
+ export function listQueries(stored_queries) {
12
+ return Object.entries(stored_queries)
13
+ .sort(([left_name], [right_name]) => left_name.localeCompare(right_name))
14
+ .map(([name, stored_query]) => ({
15
+ name,
16
+ where: stored_query.where,
17
+ }));
18
+ }
@@ -1,6 +1,21 @@
1
- import { glob } from 'node:fs/promises';
1
+ import { globby } from 'globby';
2
2
  import process from 'node:process';
3
3
 
4
+ /**
5
+ * Source file scanning.
6
+ *
7
+ * Expands include globs into stable repo-relative file lists for indexing and
8
+ * broken-link validation.
9
+ *
10
+ * Kind: scan
11
+ * Status: active
12
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
13
+ * Decided by: ../docs/decisions/source-scan.md
14
+ * @patram
15
+ * @see {@link ./load-project-graph.js}
16
+ * @see {@link ../docs/decisions/source-scan.md}
17
+ */
18
+
4
19
  /**
5
20
  * List source files matched by Patram include globs.
6
21
  *
@@ -12,26 +27,46 @@ export async function listSourceFiles(
12
27
  include_patterns,
13
28
  project_directory = process.cwd(),
14
29
  ) {
15
- /** @type {Set<string>} */
16
- const source_file_paths = new Set();
30
+ const source_file_paths = await listMatchingFiles(
31
+ include_patterns,
32
+ project_directory,
33
+ );
34
+
35
+ return [...new Set(source_file_paths)].sort(comparePaths);
36
+ }
17
37
 
18
- for (const include_pattern of include_patterns) {
19
- for await (const matched_path of glob(include_pattern, {
20
- cwd: project_directory,
21
- })) {
22
- source_file_paths.add(normalizeRepoRelativePath(matched_path));
23
- }
24
- }
38
+ /**
39
+ * List repo files available for broken-link validation.
40
+ *
41
+ * @param {string} [project_directory]
42
+ * @returns {Promise<string[]>}
43
+ */
44
+ export async function listRepoFiles(project_directory = process.cwd()) {
45
+ const repo_file_paths = await listMatchingFiles(['**/*'], project_directory, {
46
+ dot: true,
47
+ });
25
48
 
26
- return [...source_file_paths].sort(comparePaths);
49
+ return [...new Set(repo_file_paths)].sort(comparePaths);
27
50
  }
28
51
 
29
52
  /**
30
- * @param {string} source_path
31
- * @returns {string}
53
+ * @param {string[]} include_patterns
54
+ * @param {string} project_directory
55
+ * @param {{ dot?: boolean }} [options]
56
+ * @returns {Promise<string[]>}
32
57
  */
33
- function normalizeRepoRelativePath(source_path) {
34
- return source_path.replaceAll('\\', '/');
58
+ async function listMatchingFiles(
59
+ include_patterns,
60
+ project_directory,
61
+ options = {},
62
+ ) {
63
+ return globby(include_patterns, {
64
+ cwd: project_directory,
65
+ dot: options.dot ?? false,
66
+ expandDirectories: false,
67
+ gitignore: true,
68
+ onlyFiles: true,
69
+ });
35
70
  }
36
71
 
37
72
  /**
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  /**
2
3
  * @import { LoadPatramConfigResult, PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
3
4
  */
@@ -8,6 +9,25 @@ import process from 'node:process';
8
9
 
9
10
  import { z } from 'zod';
10
11
 
12
+ import { parsePatramConfig } from './patram-config.js';
13
+ import { DEFAULT_INCLUDE_PATTERNS } from './source-file-defaults.js';
14
+
15
+ /**
16
+ * Repo config loading.
17
+ *
18
+ * Reads `.patram.json`, applies defaults, and validates repo config and graph
19
+ * schema before command execution.
20
+ *
21
+ * Kind: config
22
+ * Status: active
23
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
24
+ * Decided by: ../docs/decisions/single-config-file.md
25
+ * Decided by: ../docs/decisions/optional-config-default-scan.md
26
+ * @patram
27
+ * @see {@link ./resolve-patram-graph-config.js}
28
+ * @see {@link ../docs/decisions/single-config-file.md}
29
+ */
30
+
11
31
  const CONFIG_FILE_NAME = '.patram.json';
12
32
 
13
33
  const stored_query_schema = z
@@ -20,8 +40,12 @@ const patram_repo_config_schema = z
20
40
  .object({
21
41
  include: z
22
42
  .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),
43
+ .min(1, 'Include must contain at least one glob.')
44
+ .default(DEFAULT_INCLUDE_PATTERNS),
45
+ kinds: z.unknown().optional(),
46
+ mappings: z.unknown().optional(),
47
+ queries: z.record(z.string().min(1), stored_query_schema).default({}),
48
+ relations: z.unknown().optional(),
25
49
  })
26
50
  .strict();
27
51
 
@@ -36,7 +60,7 @@ export async function loadPatramConfig(project_directory = process.cwd()) {
36
60
  const config_source = await readConfigSource(config_file_path);
37
61
 
38
62
  if (config_source === null) {
39
- return createLoadResult(null, [createMissingConfigDiagnostic()]);
63
+ return createLoadResult(createDefaultRepoConfig(), []);
40
64
  }
41
65
 
42
66
  const parse_result = parseConfigJson(config_source);
@@ -54,7 +78,13 @@ export async function loadPatramConfig(project_directory = process.cwd()) {
54
78
  );
55
79
  }
56
80
 
57
- return createLoadResult(config_result.data, []);
81
+ const graph_schema_diagnostics = validateGraphSchema(config_result.data);
82
+
83
+ if (graph_schema_diagnostics.length > 0) {
84
+ return createLoadResult(null, graph_schema_diagnostics);
85
+ }
86
+
87
+ return createLoadResult(normalizeRepoConfig(config_result.data), []);
58
88
  }
59
89
 
60
90
  /**
@@ -108,20 +138,6 @@ function createLoadResult(config, diagnostics) {
108
138
  };
109
139
  }
110
140
 
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
141
  /**
126
142
  * @param {string} config_source
127
143
  * @param {SyntaxError} error
@@ -168,6 +184,78 @@ function createValidationDiagnostic(issue) {
168
184
  };
169
185
  }
170
186
 
187
+ /**
188
+ * @param {{ include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
189
+ * @returns {PatramDiagnostic[]}
190
+ */
191
+ function validateGraphSchema(repo_config) {
192
+ if (
193
+ repo_config.kinds === undefined &&
194
+ repo_config.mappings === undefined &&
195
+ repo_config.relations === undefined
196
+ ) {
197
+ return [];
198
+ }
199
+
200
+ try {
201
+ parsePatramConfig({
202
+ kinds: repo_config.kinds ?? {},
203
+ mappings: repo_config.mappings ?? {},
204
+ relations: repo_config.relations ?? {},
205
+ });
206
+ } catch (error) {
207
+ if (error instanceof z.ZodError) {
208
+ return error.issues.map(createValidationDiagnostic);
209
+ }
210
+
211
+ throw error;
212
+ }
213
+
214
+ return [];
215
+ }
216
+
217
+ /**
218
+ * @returns {PatramRepoConfig}
219
+ */
220
+ function createDefaultRepoConfig() {
221
+ return {
222
+ include: [...DEFAULT_INCLUDE_PATTERNS],
223
+ queries: {},
224
+ };
225
+ }
226
+
227
+ /**
228
+ * @param {{ include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
229
+ * @returns {PatramRepoConfig}
230
+ */
231
+ function normalizeRepoConfig(repo_config) {
232
+ /** @type {PatramRepoConfig} */
233
+ const normalized_config = {
234
+ include: [...repo_config.include],
235
+ queries: { ...repo_config.queries },
236
+ };
237
+
238
+ if (repo_config.kinds !== undefined && repo_config.kinds !== null) {
239
+ normalized_config.kinds = /** @type {PatramRepoConfig['kinds']} */ (
240
+ repo_config.kinds
241
+ );
242
+ }
243
+
244
+ if (repo_config.mappings !== undefined && repo_config.mappings !== null) {
245
+ normalized_config.mappings = /** @type {PatramRepoConfig['mappings']} */ (
246
+ repo_config.mappings
247
+ );
248
+ }
249
+
250
+ if (repo_config.relations !== undefined && repo_config.relations !== null) {
251
+ normalized_config.relations = /** @type {PatramRepoConfig['relations']} */ (
252
+ repo_config.relations
253
+ );
254
+ }
255
+
256
+ return normalized_config;
257
+ }
258
+
171
259
  /**
172
260
  * @param {unknown} error
173
261
  * @returns {error is NodeJS.ErrnoException}