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.
- package/bin/patram.js +25 -147
- package/lib/build-graph-identity.js +238 -0
- package/lib/build-graph.js +143 -77
- package/lib/check-graph.js +23 -7
- package/lib/claim-helpers.js +55 -0
- package/lib/command-output.js +83 -0
- package/lib/layout-stored-queries.js +213 -0
- package/lib/list-queries.js +18 -0
- package/lib/list-source-files.js +50 -15
- package/lib/load-patram-config.js +106 -18
- package/lib/load-patram-config.types.ts +9 -0
- package/lib/load-project-graph.js +124 -0
- package/lib/output-view.types.ts +73 -0
- package/lib/parse-claims.js +38 -158
- package/lib/parse-claims.types.ts +7 -0
- package/lib/parse-cli-arguments-helpers.js +273 -0
- package/lib/parse-cli-arguments.js +114 -0
- package/lib/parse-cli-arguments.types.ts +24 -0
- package/lib/parse-cli-color-options.js +44 -0
- package/lib/parse-cli-query-pagination.js +49 -0
- package/lib/parse-jsdoc-blocks.js +184 -0
- package/lib/parse-jsdoc-claims.js +280 -0
- package/lib/parse-jsdoc-prose.js +111 -0
- package/lib/parse-markdown-claims.js +242 -0
- package/lib/parse-markdown-directives.js +136 -0
- package/lib/parse-where-clause.js +312 -0
- package/lib/patram-cli.js +337 -0
- package/lib/patram-config.js +3 -1
- package/lib/patram-config.types.ts +2 -1
- package/lib/query-graph.js +256 -0
- package/lib/render-check-output.js +315 -0
- package/lib/render-json-output.js +108 -0
- package/lib/render-output-view.js +193 -0
- package/lib/render-plain-output.js +237 -0
- package/lib/render-rich-output.js +293 -0
- package/lib/render-rich-source.js +1333 -0
- package/lib/resolve-check-target.js +190 -0
- package/lib/resolve-output-mode.js +60 -0
- package/lib/resolve-patram-graph-config.js +88 -0
- package/lib/resolve-where-clause.js +51 -0
- package/lib/show-document.js +311 -0
- package/lib/source-file-defaults.js +28 -0
- package/lib/write-paged-output.js +87 -0
- package/package.json +21 -10
- package/bin/patram.test.js +0 -184
- package/lib/build-graph.test.js +0 -141
- package/lib/check-graph.test.js +0 -103
- package/lib/list-source-files.test.js +0 -101
- package/lib/load-patram-config.test.js +0 -211
- package/lib/parse-claims.test.js +0 -113
- package/lib/patram-config.test.js +0 -147
package/lib/check-graph.js
CHANGED
|
@@ -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[]}
|
|
26
|
+
* @param {string[]} existing_file_paths
|
|
11
27
|
* @returns {PatramDiagnostic[]}
|
|
12
28
|
*/
|
|
13
|
-
export function checkGraph(graph,
|
|
29
|
+
export function checkGraph(graph, existing_file_paths) {
|
|
14
30
|
/** @type {PatramDiagnostic[]} */
|
|
15
31
|
const diagnostics = [];
|
|
16
|
-
const
|
|
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
|
-
|
|
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>}
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/lib/list-source-files.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 [...
|
|
49
|
+
return [...new Set(repo_file_paths)].sort(comparePaths);
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
/**
|
|
30
|
-
* @param {string}
|
|
31
|
-
* @
|
|
53
|
+
* @param {string[]} include_patterns
|
|
54
|
+
* @param {string} project_directory
|
|
55
|
+
* @param {{ dot?: boolean }} [options]
|
|
56
|
+
* @returns {Promise<string[]>}
|
|
32
57
|
*/
|
|
33
|
-
function
|
|
34
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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}
|