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
@@ -0,0 +1,280 @@
1
+ /** @import * as $k$$l$load$j$patram$j$config$k$types$k$ts from './load-patram-config.types.ts'; */
2
+ /**
3
+ * @import { ParseClaimsInput, ParseSourceFileResult, PatramClaim, PatramClaimFields } from './parse-claims.types.ts';
4
+ */
5
+
6
+ import {
7
+ createClaim,
8
+ getFileExtension,
9
+ isPathLikeTarget,
10
+ } from './claim-helpers.js';
11
+ import { normalizeDirectiveName } from './parse-markdown-directives.js';
12
+ import { collectJsdocBlocks } from './parse-jsdoc-blocks.js';
13
+ import {
14
+ createJsdocProseClaimEntries,
15
+ pushJsdocParagraph,
16
+ } from './parse-jsdoc-prose.js';
17
+ import { JSDOC_SOURCE_FILE_EXTENSIONS } from './source-file-defaults.js';
18
+
19
+ /**
20
+ * JSDoc @patram claim parsing.
21
+ *
22
+ * Activates one source anchor block, extracts directives and links, and
23
+ * derives title and description claims from prose.
24
+ *
25
+ * Kind: parse
26
+ * Status: active
27
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
28
+ * Decided by: ../docs/decisions/jsdoc-metadata-directive-syntax.md
29
+ * @patram
30
+ * @see {@link ./parse-claims.js}
31
+ * @see {@link ../docs/decisions/jsdoc-metadata-directive-syntax.md}
32
+ */
33
+
34
+ const JSDOC_EXTENSIONS = new Set(JSDOC_SOURCE_FILE_EXTENSIONS);
35
+ const JSDOC_LINK_TAG_PATTERN = /^@(link|see)\s+(.+)$/du;
36
+ const JSDOC_TAG_PATTERN = /^@[A-Za-z]+(?:\s|$)/du;
37
+ const JSDOC_VISIBLE_DIRECTIVE_PATTERN = /^([A-Z][A-Za-z _-]*):\s+(.+)$/du;
38
+
39
+ /**
40
+ * Parse JSDoc metadata claims from one source file.
41
+ *
42
+ * @param {ParseClaimsInput} parse_input
43
+ * @returns {ParseSourceFileResult}
44
+ */
45
+ export function parseJsdocClaims(parse_input) {
46
+ if (!JSDOC_EXTENSIONS.has(getFileExtension(parse_input.path))) {
47
+ return {
48
+ claims: [],
49
+ diagnostics: [],
50
+ };
51
+ }
52
+
53
+ const jsdoc_blocks = collectJsdocBlocks(parse_input.source);
54
+ const active_blocks = jsdoc_blocks.filter(
55
+ (jsdoc_block) => jsdoc_block.activation_line !== null,
56
+ );
57
+
58
+ if (active_blocks.length === 0) {
59
+ return {
60
+ claims: [],
61
+ diagnostics: [],
62
+ };
63
+ }
64
+
65
+ const claim_entries = collectJsdocClaimEntries(
66
+ parse_input.path,
67
+ active_blocks[0],
68
+ ).sort(compareClaimEntries);
69
+
70
+ return {
71
+ claims: claim_entries.map((claim_entry, claim_index) =>
72
+ createClaim(
73
+ parse_input.path,
74
+ claim_index + 1,
75
+ claim_entry.claim_type,
76
+ claim_entry.claim_fields,
77
+ ),
78
+ ),
79
+ diagnostics: active_blocks
80
+ .slice(1)
81
+ .map((active_block) =>
82
+ createMultiplePatramBlocksDiagnostic(parse_input.path, active_block),
83
+ ),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * @param {string} file_path
89
+ * @param {{ lines: Array<{ column: number, content: string, line: number }> }} jsdoc_block
90
+ * @returns {Array<{ claim_fields: PatramClaimFields, claim_type: string, order: number }>}
91
+ */
92
+ function collectJsdocClaimEntries(file_path, jsdoc_block) {
93
+ /** @type {Array<{ claim_fields: PatramClaimFields, claim_type: string, order: number }>} */
94
+ const claim_entries = [];
95
+ /** @type {Array<Array<{ column: number, content: string, line: number }>>} */
96
+ const prose_paragraphs = [];
97
+ /** @type {Array<{ column: number, content: string, line: number }>} */
98
+ let current_paragraph_lines = [];
99
+
100
+ for (const block_line of jsdoc_block.lines) {
101
+ if (block_line.content.length === 0) {
102
+ pushJsdocParagraph(prose_paragraphs, current_paragraph_lines);
103
+ current_paragraph_lines = [];
104
+ continue;
105
+ }
106
+
107
+ const directive_fields = matchJsdocDirectiveFields(file_path, block_line);
108
+
109
+ if (directive_fields) {
110
+ pushJsdocParagraph(prose_paragraphs, current_paragraph_lines);
111
+ current_paragraph_lines = [];
112
+ claim_entries.push({
113
+ claim_fields: directive_fields,
114
+ claim_type: 'directive',
115
+ order: 0,
116
+ });
117
+ continue;
118
+ }
119
+
120
+ const jsdoc_link_claim_entry = createJsdocLinkClaimEntry(
121
+ file_path,
122
+ block_line,
123
+ );
124
+
125
+ if (jsdoc_link_claim_entry) {
126
+ pushJsdocParagraph(prose_paragraphs, current_paragraph_lines);
127
+ current_paragraph_lines = [];
128
+ claim_entries.push(jsdoc_link_claim_entry);
129
+ continue;
130
+ }
131
+
132
+ if (JSDOC_TAG_PATTERN.test(block_line.content)) {
133
+ pushJsdocParagraph(prose_paragraphs, current_paragraph_lines);
134
+ current_paragraph_lines = [];
135
+ continue;
136
+ }
137
+
138
+ current_paragraph_lines.push(block_line);
139
+ }
140
+
141
+ pushJsdocParagraph(prose_paragraphs, current_paragraph_lines);
142
+ claim_entries.push(
143
+ ...createJsdocProseClaimEntries(file_path, prose_paragraphs),
144
+ );
145
+
146
+ return claim_entries;
147
+ }
148
+
149
+ /**
150
+ * @param {string} file_path
151
+ * @param {{ column: number, content: string, line: number }} block_line
152
+ * @returns {PatramClaimFields | null}
153
+ */
154
+ function matchJsdocDirectiveFields(file_path, block_line) {
155
+ const directive_match = block_line.content.match(
156
+ JSDOC_VISIBLE_DIRECTIVE_PATTERN,
157
+ );
158
+
159
+ if (!directive_match) {
160
+ return null;
161
+ }
162
+
163
+ return {
164
+ name: normalizeDirectiveName(directive_match[1]),
165
+ origin: {
166
+ column: block_line.column,
167
+ line: block_line.line,
168
+ path: file_path,
169
+ },
170
+ parser: 'jsdoc',
171
+ value: directive_match[2].trim(),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * @param {string} file_path
177
+ * @param {{ column: number, content: string, line: number }} block_line
178
+ * @returns {{ claim_fields: PatramClaimFields, claim_type: string, order: number } | null}
179
+ */
180
+ function createJsdocLinkClaimEntry(file_path, block_line) {
181
+ const link_match = block_line.content.match(JSDOC_LINK_TAG_PATTERN);
182
+
183
+ if (!link_match) {
184
+ return null;
185
+ }
186
+
187
+ const link_value = parseJsdocLinkValue(link_match[2].trim());
188
+
189
+ if (!link_value) {
190
+ return null;
191
+ }
192
+
193
+ return {
194
+ claim_fields: {
195
+ origin: {
196
+ column: block_line.column,
197
+ line: block_line.line,
198
+ path: file_path,
199
+ },
200
+ value: link_value,
201
+ },
202
+ claim_type: 'jsdoc.link',
203
+ order: 0,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * @param {string} raw_value
209
+ * @returns {{ target: string, text: string } | null}
210
+ */
211
+ function parseJsdocLinkValue(raw_value) {
212
+ const inline_link_match = raw_value.match(
213
+ /^\{@link\s+([^}\s]+)(?:\s+([^}]+))?\}$/du,
214
+ );
215
+
216
+ if (inline_link_match) {
217
+ const target_value = inline_link_match[1];
218
+ const label_value = inline_link_match[2]?.trim();
219
+
220
+ if (!isPathLikeTarget(target_value)) {
221
+ return null;
222
+ }
223
+
224
+ return {
225
+ target: target_value,
226
+ text: label_value && label_value.length > 0 ? label_value : target_value,
227
+ };
228
+ }
229
+
230
+ const [target_value, ...label_parts] = raw_value.split(/\s+/du);
231
+
232
+ if (!target_value || !isPathLikeTarget(target_value)) {
233
+ return null;
234
+ }
235
+
236
+ return {
237
+ target: target_value,
238
+ text: label_parts.length > 0 ? label_parts.join(' ') : target_value,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * @param {{ claim_fields: PatramClaimFields, claim_type: string, order: number }} left_entry
244
+ * @param {{ claim_fields: PatramClaimFields, claim_type: string, order: number }} right_entry
245
+ * @returns {number}
246
+ */
247
+ function compareClaimEntries(left_entry, right_entry) {
248
+ const left_origin = left_entry.claim_fields.origin;
249
+ const right_origin = right_entry.claim_fields.origin;
250
+
251
+ if (!left_origin || !right_origin) {
252
+ return left_entry.order - right_entry.order;
253
+ }
254
+
255
+ if (left_origin.line !== right_origin.line) {
256
+ return left_origin.line - right_origin.line;
257
+ }
258
+
259
+ if (left_origin.column !== right_origin.column) {
260
+ return left_origin.column - right_origin.column;
261
+ }
262
+
263
+ return left_entry.order - right_entry.order;
264
+ }
265
+
266
+ /**
267
+ * @param {string} file_path
268
+ * @param {{ activation_column: number | null, activation_line: number | null }} jsdoc_block
269
+ * @returns {$k$$l$load$j$patram$j$config$k$types$k$ts.PatramDiagnostic}
270
+ */
271
+ function createMultiplePatramBlocksDiagnostic(file_path, jsdoc_block) {
272
+ return {
273
+ code: 'jsdoc.multiple_patram_blocks',
274
+ column: jsdoc_block.activation_column ?? 1,
275
+ level: 'error',
276
+ line: jsdoc_block.activation_line ?? 1,
277
+ message: `File "${file_path}" contains multiple JSDoc blocks with "@patram".`,
278
+ path: file_path,
279
+ };
280
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @import { PatramClaimFields } from './parse-claims.types.ts';
3
+ */
4
+
5
+ const JSDOC_SENTENCE_PATTERN = /^(.+?[.!?])(?:\s+|$)([\s\S]*)$/du;
6
+ const JSDOC_TITLE_LENGTH_LIMIT = 120;
7
+
8
+ /**
9
+ * @param {Array<Array<{ column: number, content: string, line: number }>>} prose_paragraphs
10
+ * @param {Array<{ column: number, content: string, line: number }>} paragraph_lines
11
+ */
12
+ export function pushJsdocParagraph(prose_paragraphs, paragraph_lines) {
13
+ if (paragraph_lines.length > 0) {
14
+ prose_paragraphs.push(paragraph_lines);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * @param {string} file_path
20
+ * @param {Array<Array<{ column: number, content: string, line: number }>>} prose_paragraphs
21
+ * @returns {Array<{ claim_fields: PatramClaimFields, claim_type: string, order: number }>}
22
+ */
23
+ export function createJsdocProseClaimEntries(file_path, prose_paragraphs) {
24
+ if (prose_paragraphs.length === 0) {
25
+ return [];
26
+ }
27
+
28
+ const first_paragraph = prose_paragraphs[0];
29
+ const first_origin = {
30
+ column: first_paragraph[0].column,
31
+ line: first_paragraph[0].line,
32
+ path: file_path,
33
+ };
34
+ const title_result = splitJsdocParagraphTitle(
35
+ first_paragraph.map((line) => line.content).join(' '),
36
+ );
37
+ /** @type {Array<{ claim_fields: PatramClaimFields, claim_type: string, order: number }>} */
38
+ const claim_entries = [
39
+ {
40
+ claim_fields: {
41
+ origin: first_origin,
42
+ value: title_result.title,
43
+ },
44
+ claim_type: 'document.title',
45
+ order: 0,
46
+ },
47
+ ];
48
+ /** @type {string[]} */
49
+ const description_parts = [];
50
+ /** @type {{ column: number, line: number, path: string } | null} */
51
+ let description_origin = null;
52
+
53
+ if (title_result.remainder.length > 0) {
54
+ description_parts.push(title_result.remainder);
55
+ description_origin = first_origin;
56
+ }
57
+
58
+ for (const paragraph_lines of prose_paragraphs.slice(1)) {
59
+ if (description_origin === null) {
60
+ description_origin = {
61
+ column: paragraph_lines[0].column,
62
+ line: paragraph_lines[0].line,
63
+ path: file_path,
64
+ };
65
+ }
66
+
67
+ description_parts.push(
68
+ paragraph_lines.map((line) => line.content).join(' '),
69
+ );
70
+ }
71
+
72
+ if (description_origin && description_parts.length > 0) {
73
+ claim_entries.push({
74
+ claim_fields: {
75
+ origin: description_origin,
76
+ value: description_parts.join('\n\n'),
77
+ },
78
+ claim_type: 'document.description',
79
+ order: 1,
80
+ });
81
+ }
82
+
83
+ return claim_entries;
84
+ }
85
+
86
+ /**
87
+ * @param {string} paragraph_text
88
+ * @returns {{ remainder: string, title: string }}
89
+ */
90
+ function splitJsdocParagraphTitle(paragraph_text) {
91
+ if (paragraph_text.length <= JSDOC_TITLE_LENGTH_LIMIT) {
92
+ return {
93
+ remainder: '',
94
+ title: paragraph_text,
95
+ };
96
+ }
97
+
98
+ const sentence_match = paragraph_text.match(JSDOC_SENTENCE_PATTERN);
99
+
100
+ if (!sentence_match) {
101
+ return {
102
+ remainder: '',
103
+ title: paragraph_text,
104
+ };
105
+ }
106
+
107
+ return {
108
+ remainder: sentence_match[2].trim(),
109
+ title: sentence_match[1].trim(),
110
+ };
111
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @import { PatramClaim, ParseClaimsInput, PatramClaimFields } from './parse-claims.types.ts';
3
+ */
4
+
5
+ import { createClaim, isPathLikeTarget } from './claim-helpers.js';
6
+ import {
7
+ matchHiddenDirectiveFields,
8
+ matchVisibleDirectiveFields,
9
+ parseFrontMatterDirectiveFields,
10
+ } from './parse-markdown-directives.js';
11
+
12
+ /**
13
+ * Markdown claim parsing.
14
+ *
15
+ * Extracts document titles, directives, and links from markdown source while
16
+ * ignoring fenced-code link noise.
17
+ *
18
+ * Kind: parse
19
+ * Status: active
20
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
21
+ * Decided by: ../docs/decisions/markdown-metadata-directive-syntax.md
22
+ * Decided by: ../docs/decisions/markdown-link-claim-scope.md
23
+ * @patram
24
+ * @see {@link ./parse-claims.js}
25
+ * @see {@link ../docs/decisions/markdown-metadata-directive-syntax.md}
26
+ */
27
+
28
+ const HEADING_PATTERN = /^#\s+(.+)$/du;
29
+ const MARKDOWN_FENCE_PATTERN = /^([`~]{3,})/du;
30
+ const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/dgu;
31
+
32
+ /**
33
+ * @param {ParseClaimsInput} parse_input
34
+ * @returns {PatramClaim[]}
35
+ */
36
+ export function parseMarkdownClaims(parse_input) {
37
+ const lines = parse_input.source.split('\n');
38
+
39
+ /** @type {PatramClaim[]} */
40
+ const claims = [];
41
+ const front_matter_result = parseFrontMatterDirectiveFields(
42
+ parse_input.path,
43
+ lines,
44
+ );
45
+ /** @type {{ character: string, length: number } | null} */
46
+ let open_fence = null;
47
+ const title_result = getMarkdownTitle(lines, front_matter_result.body_start);
48
+
49
+ if (title_result) {
50
+ claims.push(
51
+ createClaim(parse_input.path, claims.length + 1, 'document.title', {
52
+ origin: {
53
+ column: 1,
54
+ line: title_result.line,
55
+ path: parse_input.path,
56
+ },
57
+ value: title_result.value,
58
+ }),
59
+ );
60
+ }
61
+
62
+ for (const directive_fields of front_matter_result.directive_fields) {
63
+ claims.push(
64
+ createClaim(parse_input.path, claims.length + 1, 'directive', {
65
+ ...directive_fields,
66
+ }),
67
+ );
68
+ }
69
+
70
+ for (const [line_index, line] of lines.entries()) {
71
+ if (line_index < front_matter_result.body_start) {
72
+ continue;
73
+ }
74
+
75
+ const line_number = line_index + 1;
76
+
77
+ if (open_fence) {
78
+ if (isClosingMarkdownFence(line, open_fence)) {
79
+ open_fence = null;
80
+ }
81
+
82
+ continue;
83
+ }
84
+
85
+ open_fence = parseOpeningMarkdownFence(line);
86
+
87
+ if (open_fence) {
88
+ continue;
89
+ }
90
+
91
+ collectMarkdownLinkClaims(parse_input.path, line, line_number, claims);
92
+ collectVisibleDirectiveClaims(parse_input.path, line, line_number, claims);
93
+ collectHiddenDirectiveClaims(parse_input.path, line, line_number, claims);
94
+ }
95
+
96
+ return claims;
97
+ }
98
+
99
+ /**
100
+ * @param {string} file_path
101
+ * @param {string} line
102
+ * @param {number} line_number
103
+ * @param {PatramClaim[]} claims
104
+ */
105
+ function collectMarkdownLinkClaims(file_path, line, line_number, claims) {
106
+ for (const link_match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
107
+ const link_text = link_match[1];
108
+ const target_value = link_match[2];
109
+
110
+ if (!isPathLikeTarget(target_value)) {
111
+ continue;
112
+ }
113
+
114
+ const column_number =
115
+ link_match.index === undefined ? 1 : link_match.index + 1;
116
+
117
+ claims.push(
118
+ createClaim(file_path, claims.length + 1, 'markdown.link', {
119
+ origin: {
120
+ column: column_number,
121
+ line: line_number,
122
+ path: file_path,
123
+ },
124
+ value: {
125
+ target: target_value,
126
+ text: link_text,
127
+ },
128
+ }),
129
+ );
130
+ }
131
+ }
132
+
133
+ /**
134
+ * @param {string} file_path
135
+ * @param {string} line
136
+ * @param {number} line_number
137
+ * @param {PatramClaim[]} claims
138
+ */
139
+ function collectVisibleDirectiveClaims(file_path, line, line_number, claims) {
140
+ const directive_fields = matchVisibleDirectiveFields(
141
+ file_path,
142
+ line,
143
+ line_number,
144
+ );
145
+
146
+ if (directive_fields) {
147
+ pushDirectiveClaim(file_path, claims, directive_fields);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * @param {string} file_path
153
+ * @param {string} line
154
+ * @param {number} line_number
155
+ * @param {PatramClaim[]} claims
156
+ */
157
+ function collectHiddenDirectiveClaims(file_path, line, line_number, claims) {
158
+ const directive_fields = matchHiddenDirectiveFields(
159
+ file_path,
160
+ line,
161
+ line_number,
162
+ );
163
+
164
+ if (directive_fields) {
165
+ pushDirectiveClaim(file_path, claims, directive_fields);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * @param {string[]} lines
171
+ * @param {number} start_line_index
172
+ * @returns {{ line: number, value: string } | null}
173
+ */
174
+ function getMarkdownTitle(lines, start_line_index) {
175
+ const first_line = lines[start_line_index];
176
+
177
+ if (first_line === undefined) {
178
+ return null;
179
+ }
180
+
181
+ const trimmed_line = first_line.trim();
182
+
183
+ if (trimmed_line.length === 0) {
184
+ return null;
185
+ }
186
+
187
+ const heading_match = trimmed_line.match(HEADING_PATTERN);
188
+
189
+ if (heading_match) {
190
+ return {
191
+ line: start_line_index + 1,
192
+ value: heading_match[1].trim(),
193
+ };
194
+ }
195
+
196
+ return {
197
+ line: start_line_index + 1,
198
+ value: trimmed_line,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * @param {string} line
204
+ * @returns {{ character: string, length: number } | null}
205
+ */
206
+ function parseOpeningMarkdownFence(line) {
207
+ const trimmed_line = line.trimStart();
208
+ const fence_match = trimmed_line.match(MARKDOWN_FENCE_PATTERN);
209
+
210
+ if (!fence_match) {
211
+ return null;
212
+ }
213
+
214
+ return {
215
+ character: fence_match[1][0],
216
+ length: fence_match[1].length,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * @param {string} line
222
+ * @param {{ character: string, length: number }} open_fence
223
+ * @returns {boolean}
224
+ */
225
+ function isClosingMarkdownFence(line, open_fence) {
226
+ const trimmed_line = line.trimStart();
227
+
228
+ return trimmed_line.startsWith(
229
+ open_fence.character.repeat(open_fence.length),
230
+ );
231
+ }
232
+
233
+ /**
234
+ * @param {string} file_path
235
+ * @param {PatramClaim[]} claims
236
+ * @param {PatramClaimFields} directive_fields
237
+ */
238
+ function pushDirectiveClaim(file_path, claims, directive_fields) {
239
+ claims.push(
240
+ createClaim(file_path, claims.length + 1, 'directive', directive_fields),
241
+ );
242
+ }