patram 0.0.2 → 0.2.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.
Files changed (67) hide show
  1. package/bin/patram.js +25 -147
  2. package/lib/build-graph-identity.js +270 -0
  3. package/lib/build-graph.js +156 -77
  4. package/lib/check-graph.js +23 -7
  5. package/lib/claim-helpers.js +55 -0
  6. package/lib/cli-help-metadata.js +552 -0
  7. package/lib/command-output.js +83 -0
  8. package/lib/derived-summary.js +278 -0
  9. package/lib/format-derived-summary-row.js +9 -0
  10. package/lib/format-node-header.js +19 -0
  11. package/lib/format-output-item-block.js +22 -0
  12. package/lib/format-output-metadata.js +62 -0
  13. package/lib/layout-stored-queries.js +361 -0
  14. package/lib/list-queries.js +18 -0
  15. package/lib/list-source-files.js +50 -15
  16. package/lib/load-patram-config.js +505 -18
  17. package/lib/load-patram-config.types.ts +40 -0
  18. package/lib/load-project-graph.js +124 -0
  19. package/lib/output-view.types.ts +88 -0
  20. package/lib/parse-claims.js +38 -158
  21. package/lib/parse-claims.types.ts +7 -0
  22. package/lib/parse-cli-arguments-helpers.js +446 -0
  23. package/lib/parse-cli-arguments.js +266 -0
  24. package/lib/parse-cli-arguments.types.ts +69 -0
  25. package/lib/parse-cli-color-options.js +44 -0
  26. package/lib/parse-cli-query-pagination.js +49 -0
  27. package/lib/parse-jsdoc-blocks.js +184 -0
  28. package/lib/parse-jsdoc-claims.js +280 -0
  29. package/lib/parse-jsdoc-prose.js +111 -0
  30. package/lib/parse-markdown-claims.js +242 -0
  31. package/lib/parse-markdown-directives.js +136 -0
  32. package/lib/parse-where-clause.js +707 -0
  33. package/lib/parse-where-clause.types.ts +70 -0
  34. package/lib/patram-cli.js +464 -0
  35. package/lib/patram-config.js +3 -1
  36. package/lib/patram-config.types.ts +2 -1
  37. package/lib/patram.js +6 -0
  38. package/lib/query-graph.js +368 -0
  39. package/lib/query-inspection.js +523 -0
  40. package/lib/render-check-output.js +315 -0
  41. package/lib/render-cli-help.js +419 -0
  42. package/lib/render-json-output.js +161 -0
  43. package/lib/render-output-view.js +222 -0
  44. package/lib/render-plain-output.js +182 -0
  45. package/lib/render-rich-output.js +240 -0
  46. package/lib/render-rich-source.js +1333 -0
  47. package/lib/resolve-check-target.js +190 -0
  48. package/lib/resolve-output-mode.js +60 -0
  49. package/lib/resolve-patram-graph-config.js +88 -0
  50. package/lib/resolve-where-clause.js +66 -0
  51. package/lib/show-document.js +311 -0
  52. package/lib/source-file-defaults.js +28 -0
  53. package/lib/tagged-fenced-block-error.js +17 -0
  54. package/lib/tagged-fenced-block-markdown.js +111 -0
  55. package/lib/tagged-fenced-block-metadata.js +97 -0
  56. package/lib/tagged-fenced-block-parser.js +292 -0
  57. package/lib/tagged-fenced-blocks.js +100 -0
  58. package/lib/tagged-fenced-blocks.types.ts +38 -0
  59. package/lib/write-paged-output.js +87 -0
  60. package/package.json +28 -12
  61. package/bin/patram.test.js +0 -184
  62. package/lib/build-graph.test.js +0 -141
  63. package/lib/check-graph.test.js +0 -103
  64. package/lib/list-source-files.test.js +0 -101
  65. package/lib/load-patram-config.test.js +0 -211
  66. package/lib/parse-claims.test.js +0 -113
  67. package/lib/patram-config.test.js +0 -147
@@ -0,0 +1,361 @@
1
+ /* eslint-disable max-lines */
2
+ /**
3
+ * @import { OutputStoredQueryItem } from './output-view.types.ts';
4
+ */
5
+
6
+ import { parseWhereClause } from './parse-where-clause.js';
7
+
8
+ const MAX_STORED_QUERY_WIDTH = 100;
9
+ const MIN_TERM_COLUMN_WIDTH = 20;
10
+ const STORED_QUERY_COLUMN_GAP = 2;
11
+
12
+ /**
13
+ * @typedef {'field_name' | 'keyword' | 'literal' | 'name' | 'operator' | 'plain'} StoredQuerySegmentKind
14
+ */
15
+
16
+ /**
17
+ * @typedef {{ kind: StoredQuerySegmentKind, text: string }} StoredQuerySegment
18
+ */
19
+
20
+ /**
21
+ * Layout stored queries into styled lines shared by plain and rich renderers.
22
+ *
23
+ * @param {OutputStoredQueryItem[]} output_items
24
+ * @returns {StoredQuerySegment[][]}
25
+ */
26
+ export function layoutStoredQueries(output_items) {
27
+ if (output_items.length === 0) {
28
+ return [];
29
+ }
30
+
31
+ const name_column_width = Math.max(
32
+ ...output_items.map((output_item) => output_item.name.length),
33
+ );
34
+ const term_column_width = Math.max(
35
+ MIN_TERM_COLUMN_WIDTH,
36
+ MAX_STORED_QUERY_WIDTH - name_column_width - STORED_QUERY_COLUMN_GAP,
37
+ );
38
+
39
+ return output_items.flatMap((output_item) =>
40
+ layoutStoredQuery(output_item, name_column_width, term_column_width),
41
+ );
42
+ }
43
+
44
+ /**
45
+ * @param {OutputStoredQueryItem} output_item
46
+ * @param {number} name_column_width
47
+ * @param {number} term_column_width
48
+ * @returns {StoredQuerySegment[][]}
49
+ */
50
+ function layoutStoredQuery(output_item, name_column_width, term_column_width) {
51
+ const term_lines = wrapPhrases(
52
+ createStoredQueryPhrases(output_item.where),
53
+ term_column_width,
54
+ );
55
+ const continuation_prefix = ' '.repeat(
56
+ name_column_width + STORED_QUERY_COLUMN_GAP,
57
+ );
58
+
59
+ return term_lines.map((line_segments, line_index) => {
60
+ if (line_index === 0) {
61
+ return [
62
+ {
63
+ kind: 'name',
64
+ text: output_item.name.padEnd(name_column_width, ' '),
65
+ },
66
+ {
67
+ kind: 'plain',
68
+ text: ' '.repeat(STORED_QUERY_COLUMN_GAP),
69
+ },
70
+ ...line_segments,
71
+ ];
72
+ }
73
+
74
+ return [
75
+ {
76
+ kind: 'plain',
77
+ text: continuation_prefix,
78
+ },
79
+ ...line_segments,
80
+ ];
81
+ });
82
+ }
83
+
84
+ /**
85
+ * @param {string} where_clause
86
+ * @returns {StoredQuerySegment[][]}
87
+ */
88
+ function createStoredQueryPhrases(where_clause) {
89
+ const parse_result = parseWhereClause(where_clause);
90
+
91
+ if (!parse_result.success) {
92
+ return createFallbackPhrases(where_clause);
93
+ }
94
+
95
+ return parse_result.clauses.map((clause, clause_index) =>
96
+ createClausePhrase(clause, clause_index > 0),
97
+ );
98
+ }
99
+
100
+ /**
101
+ * @param {{
102
+ * is_negated: boolean,
103
+ * term:
104
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }
105
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }
106
+ * | { kind: 'relation', relation_name: string }
107
+ * | { kind: 'relation_target', relation_name: string, target_id: string }
108
+ * | {
109
+ * aggregate_name: 'any' | 'count' | 'none',
110
+ * clauses: unknown[],
111
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
112
+ * kind: 'aggregate',
113
+ * traversal: { direction: 'in' | 'out', relation_name: string },
114
+ * value?: number,
115
+ * },
116
+ * }} clause
117
+ * @param {boolean} should_prefix_and
118
+ * @returns {StoredQuerySegment[]}
119
+ */
120
+ function createClausePhrase(clause, should_prefix_and) {
121
+ /** @type {StoredQuerySegment[]} */
122
+ const phrase = [];
123
+
124
+ if (should_prefix_and) {
125
+ phrase.push({ kind: 'keyword', text: 'and' });
126
+ phrase.push({ kind: 'plain', text: ' ' });
127
+ }
128
+
129
+ if (clause.is_negated) {
130
+ phrase.push({ kind: 'keyword', text: 'not' });
131
+ phrase.push({ kind: 'plain', text: ' ' });
132
+ }
133
+
134
+ phrase.push(...createTermSegments(clause.term));
135
+
136
+ return phrase;
137
+ }
138
+
139
+ /**
140
+ * @param {
141
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }
142
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }
143
+ * | { kind: 'relation', relation_name: string }
144
+ * | { kind: 'relation_target', relation_name: string, target_id: string }
145
+ * | {
146
+ * aggregate_name: 'any' | 'count' | 'none',
147
+ * clauses: { is_negated: boolean, term: unknown }[],
148
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
149
+ * kind: 'aggregate',
150
+ * traversal: { direction: 'in' | 'out', relation_name: string },
151
+ * value?: number,
152
+ * }
153
+ * } term
154
+ * @returns {StoredQuerySegment[]}
155
+ */
156
+ function createTermSegments(term) {
157
+ if (term.kind === 'aggregate') {
158
+ return createAggregateSegments(term);
159
+ }
160
+
161
+ if (term.kind === 'field') {
162
+ return [
163
+ { kind: 'field_name', text: term.field_name },
164
+ { kind: 'operator', text: term.operator },
165
+ { kind: 'literal', text: term.value },
166
+ ];
167
+ }
168
+
169
+ if (term.kind === 'field_set') {
170
+ return createFieldSetSegments(term);
171
+ }
172
+
173
+ if (term.kind === 'relation_target') {
174
+ return [
175
+ { kind: 'field_name', text: term.relation_name },
176
+ { kind: 'operator', text: '=' },
177
+ { kind: 'literal', text: term.target_id },
178
+ ];
179
+ }
180
+
181
+ return [
182
+ { kind: 'field_name', text: term.relation_name },
183
+ { kind: 'operator', text: ':*' },
184
+ ];
185
+ }
186
+
187
+ /**
188
+ * @param {{ field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }} term
189
+ * @returns {StoredQuerySegment[]}
190
+ */
191
+ function createFieldSetSegments(term) {
192
+ return [
193
+ { kind: 'field_name', text: term.field_name },
194
+ { kind: 'plain', text: ' ' },
195
+ { kind: 'operator', text: term.operator },
196
+ { kind: 'plain', text: ' ' },
197
+ { kind: 'operator', text: '[' },
198
+ ...createListSegments(term.values),
199
+ { kind: 'operator', text: ']' },
200
+ ];
201
+ }
202
+
203
+ /**
204
+ * @param {{
205
+ * aggregate_name: 'any' | 'count' | 'none',
206
+ * clauses: { is_negated: boolean, term: unknown }[],
207
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
208
+ * kind: 'aggregate',
209
+ * traversal: { direction: 'in' | 'out', relation_name: string },
210
+ * value?: number,
211
+ * }} term
212
+ * @returns {StoredQuerySegment[]}
213
+ */
214
+ function createAggregateSegments(term) {
215
+ /** @type {StoredQuerySegment[]} */
216
+ const segments = [
217
+ { kind: 'field_name', text: term.aggregate_name },
218
+ { kind: 'operator', text: '(' },
219
+ ...createTraversalSegments(term.traversal),
220
+ { kind: 'operator', text: ', ' },
221
+ ...createNestedClauseSegments(term.clauses),
222
+ { kind: 'operator', text: ')' },
223
+ ];
224
+
225
+ if (term.aggregate_name === 'count') {
226
+ segments.push({ kind: 'plain', text: ' ' });
227
+ segments.push({ kind: 'operator', text: term.comparison ?? '=' });
228
+ segments.push({ kind: 'plain', text: ' ' });
229
+ segments.push({ kind: 'literal', text: String(term.value ?? 0) });
230
+ }
231
+
232
+ return segments;
233
+ }
234
+
235
+ /**
236
+ * @param {{ direction: 'in' | 'out', relation_name: string }} traversal
237
+ * @returns {StoredQuerySegment[]}
238
+ */
239
+ function createTraversalSegments(traversal) {
240
+ return [
241
+ { kind: 'field_name', text: traversal.direction },
242
+ { kind: 'operator', text: ':' },
243
+ { kind: 'field_name', text: traversal.relation_name },
244
+ ];
245
+ }
246
+
247
+ /**
248
+ * @param {{ is_negated: boolean, term: unknown }[]} clauses
249
+ * @returns {StoredQuerySegment[]}
250
+ */
251
+ function createNestedClauseSegments(clauses) {
252
+ return clauses.flatMap((clause, clause_index) => {
253
+ const clause_phrase = createClausePhrase(
254
+ /** @type {{
255
+ * is_negated: boolean,
256
+ * term:
257
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }
258
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }
259
+ * | { kind: 'relation', relation_name: string }
260
+ * | { kind: 'relation_target', relation_name: string, target_id: string }
261
+ * | {
262
+ * aggregate_name: 'any' | 'count' | 'none',
263
+ * clauses: { is_negated: boolean, term: unknown }[],
264
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
265
+ * kind: 'aggregate',
266
+ * traversal: { direction: 'in' | 'out', relation_name: string },
267
+ * value?: number,
268
+ * },
269
+ * }} */ (clause),
270
+ clause_index > 0,
271
+ );
272
+
273
+ if (clause_index === 0) {
274
+ return clause_phrase;
275
+ }
276
+
277
+ return [{ kind: 'plain', text: ' ' }, ...clause_phrase];
278
+ });
279
+ }
280
+
281
+ /**
282
+ * @param {string[]} values
283
+ * @returns {StoredQuerySegment[]}
284
+ */
285
+ function createListSegments(values) {
286
+ return values.flatMap((value, value_index) => {
287
+ if (value_index === 0) {
288
+ return [{ kind: 'literal', text: value }];
289
+ }
290
+
291
+ return [
292
+ { kind: 'operator', text: ', ' },
293
+ { kind: 'literal', text: value },
294
+ ];
295
+ });
296
+ }
297
+
298
+ /**
299
+ * @param {string} where_clause
300
+ * @returns {StoredQuerySegment[][]}
301
+ */
302
+ function createFallbackPhrases(where_clause) {
303
+ const tokens = where_clause.match(/\S+/gu) ?? [];
304
+
305
+ if (tokens.length === 0) {
306
+ return [[{ kind: 'literal', text: where_clause }]];
307
+ }
308
+
309
+ return tokens.map((token) => [{ kind: 'literal', text: token }]);
310
+ }
311
+
312
+ /**
313
+ * @param {StoredQuerySegment[][]} phrases
314
+ * @param {number} term_column_width
315
+ * @returns {StoredQuerySegment[][]}
316
+ */
317
+ function wrapPhrases(phrases, term_column_width) {
318
+ /** @type {StoredQuerySegment[][]} */
319
+ const lines = [];
320
+ /** @type {StoredQuerySegment[]} */
321
+ let current_line = [];
322
+ let current_width = 0;
323
+
324
+ for (const phrase of phrases) {
325
+ const phrase_width = measureSegments(phrase);
326
+
327
+ if (current_line.length === 0) {
328
+ current_line = [...phrase];
329
+ current_width = phrase_width;
330
+ continue;
331
+ }
332
+
333
+ if (current_width + 1 + phrase_width > term_column_width) {
334
+ lines.push(current_line);
335
+ current_line = [...phrase];
336
+ current_width = phrase_width;
337
+ continue;
338
+ }
339
+
340
+ current_line.push({ kind: 'plain', text: ' ' });
341
+ current_line.push(...phrase);
342
+ current_width += 1 + phrase_width;
343
+ }
344
+
345
+ if (current_line.length > 0) {
346
+ lines.push(current_line);
347
+ }
348
+
349
+ return lines;
350
+ }
351
+
352
+ /**
353
+ * @param {StoredQuerySegment[]} segments
354
+ * @returns {number}
355
+ */
356
+ function measureSegments(segments) {
357
+ return segments.reduce(
358
+ (total_width, segment) => total_width + segment.text.length,
359
+ 0,
360
+ );
361
+ }
@@ -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
  /**