patram 0.7.0 → 0.8.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.
@@ -50,6 +50,7 @@ export const COMMAND_NAMES = /** @type {const} */ ([
50
50
  'fields',
51
51
  'query',
52
52
  'queries',
53
+ 'refs',
53
54
  'show',
54
55
  ]);
55
56
 
@@ -146,12 +147,12 @@ const COMMAND_DEFINITIONS = {
146
147
  ]),
147
148
  examples: [
148
149
  'patram query active-plans',
149
- 'patram query --where "tracked_in=doc:docs/plans/v0/worktracking-agent-guidance.md"',
150
- 'patram query --where "status not in [done, dropped, superseded]"',
151
- 'patram query --where "$class=plan and none(in:tracked_in, $class=decision)"',
152
- 'patram query --where "count(in:decided_by, $class=task) = 0"',
150
+ "patram query --where 'tracked_in=doc:docs/plans/v0/worktracking-agent-guidance.md'",
151
+ "patram query --where 'status not in [done, dropped, superseded]'",
152
+ "patram query --where '$class=plan and none(in:tracked_in, $class=decision)'",
153
+ "patram query --where 'count(in:decided_by, $class=task) = 0'",
153
154
  'patram query ready-tasks --explain',
154
- 'patram query --where "$class=decision and status=accepted and count(in:decided_by, $class=task) = 0" --lint',
155
+ "patram query --where '$class=decision and status=accepted and count(in:decided_by, $class=task) = 0' --lint",
155
156
  'patram query active-plans --limit 10 --offset 20',
156
157
  ],
157
158
  extra_positionals_message:
@@ -161,12 +162,12 @@ const COMMAND_DEFINITIONS = {
161
162
  min_positionals: 0,
162
163
  missing_argument_examples: [
163
164
  'patram query active-plans',
164
- 'patram query --where "tracked_in=doc:docs/plans/v0/worktracking-agent-guidance.md"',
165
+ "patram query --where 'tracked_in=doc:docs/plans/v0/worktracking-agent-guidance.md'",
165
166
  ],
166
- missing_argument_label: '<name> or --where "<clause>"',
167
+ missing_argument_label: "<name> or --where '<clause>'",
167
168
  missing_usage_lines: [
168
169
  'patram query <name> [options]',
169
- 'patram query --where "<clause>" [options]',
170
+ "patram query --where '<clause>' [options]",
170
171
  ],
171
172
  option_column_width: 19,
172
173
  options: [
@@ -214,7 +215,7 @@ const COMMAND_DEFINITIONS = {
214
215
  ],
215
216
  usage_lines: [
216
217
  'patram query <name> [options]',
217
- 'patram query --where "<clause>" [options]',
218
+ "patram query --where '<clause>' [options]",
218
219
  ],
219
220
  },
220
221
  queries: {
@@ -243,6 +244,44 @@ const COMMAND_DEFINITIONS = {
243
244
  summary: 'List the stored queries defined in the project configuration.',
244
245
  usage_lines: ['patram queries [options]'],
245
246
  },
247
+ refs: {
248
+ allowed_option_names: new Set(['where']),
249
+ examples: [
250
+ 'patram refs docs/decisions/query-language.md',
251
+ "patram refs docs/decisions/query-language.md --where '$class=document'",
252
+ 'patram refs docs/decisions/query-language.md --json',
253
+ ],
254
+ extra_positionals_message: 'Refs accepts exactly one file path.',
255
+ help_topics: ['query-language'],
256
+ max_positionals: 1,
257
+ min_positionals: 1,
258
+ missing_argument_examples: [
259
+ 'patram refs docs/decisions/query-language.md',
260
+ "patram refs docs/patram.md --where '$class=document'",
261
+ ],
262
+ missing_argument_label: '<file>',
263
+ missing_usage_lines: ['patram refs <file>'],
264
+ option_column_width: 19,
265
+ options: [
266
+ {
267
+ description: 'Filter incoming source nodes with a where clause',
268
+ label: '--where <clause>',
269
+ },
270
+ {
271
+ description: 'Print plain text output',
272
+ label: '--plain',
273
+ },
274
+ {
275
+ description: 'Print JSON output',
276
+ label: '--json',
277
+ },
278
+ ],
279
+ related: ['show', 'query'],
280
+ root_summary: 'Inspect incoming graph references for one file',
281
+ summary:
282
+ 'Inspect incoming graph references for one file, grouped by relation.',
283
+ usage_lines: ['patram refs <file> [options]'],
284
+ },
246
285
  show: {
247
286
  allowed_option_names: new Set(),
248
287
  examples: ['patram show docs/patram.md', 'patram show lib/patram-cli.js'],
@@ -78,6 +78,7 @@ export function shouldPageCommandOutput(parsed_command, output_stream) {
78
78
  output_stream.isTTY === true &&
79
79
  (parsed_command.command_name === 'fields' ||
80
80
  parsed_command.command_name === 'query' ||
81
+ parsed_command.command_name === 'refs' ||
81
82
  parsed_command.command_name === 'show')
82
83
  );
83
84
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
3
+ * @import { PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
4
+ */
5
+
6
+ import {
7
+ normalizeRepoRelativePath,
8
+ resolveDocumentNodeId,
9
+ } from './document-node-identity.js';
10
+ import { queryGraph } from './query-graph.js';
11
+
12
+ /**
13
+ * Inspect incoming graph edges for one canonical target file.
14
+ *
15
+ * @param {BuildGraphResult} graph
16
+ * @param {string} target_file_path
17
+ * @param {PatramRepoConfig | undefined} repo_config
18
+ * @param {string | undefined} where_clause
19
+ * @returns {{
20
+ * diagnostics: PatramDiagnostic[],
21
+ * incoming: Record<string, GraphNode[]>,
22
+ * node: GraphNode,
23
+ * }}
24
+ */
25
+ export function inspectReverseReferences(
26
+ graph,
27
+ target_file_path,
28
+ repo_config,
29
+ where_clause,
30
+ ) {
31
+ const normalized_target_path = normalizeRepoRelativePath(target_file_path);
32
+ const target_node_id = resolveDocumentNodeId(
33
+ graph.document_node_ids,
34
+ normalized_target_path,
35
+ );
36
+ const target_node =
37
+ graph.nodes[target_node_id] ??
38
+ createFallbackTargetNode(target_node_id, normalized_target_path);
39
+ const allowed_source_node_ids = where_clause
40
+ ? resolveAllowedSourceNodeIds(graph, where_clause, repo_config)
41
+ : null;
42
+
43
+ if (allowed_source_node_ids && 'diagnostics' in allowed_source_node_ids) {
44
+ return {
45
+ diagnostics: allowed_source_node_ids.diagnostics,
46
+ incoming: {},
47
+ node: target_node,
48
+ };
49
+ }
50
+
51
+ return {
52
+ diagnostics: [],
53
+ incoming: collectIncomingGroups(
54
+ graph,
55
+ target_node.id,
56
+ allowed_source_node_ids,
57
+ ),
58
+ node: target_node,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * @param {BuildGraphResult} graph
64
+ * @param {string} target_node_id
65
+ * @param {Set<string> | null} allowed_source_node_ids
66
+ * @returns {Record<string, GraphNode[]>}
67
+ */
68
+ function collectIncomingGroups(graph, target_node_id, allowed_source_node_ids) {
69
+ /** @type {Map<string, Map<string, GraphNode>>} */
70
+ const grouped_incoming = new Map();
71
+
72
+ for (const graph_edge of graph.edges) {
73
+ if (graph_edge.to !== target_node_id) {
74
+ continue;
75
+ }
76
+
77
+ if (
78
+ allowed_source_node_ids &&
79
+ !allowed_source_node_ids.has(graph_edge.from)
80
+ ) {
81
+ continue;
82
+ }
83
+
84
+ const source_node = graph.nodes[graph_edge.from];
85
+
86
+ if (!source_node) {
87
+ continue;
88
+ }
89
+
90
+ let relation_sources = grouped_incoming.get(graph_edge.relation);
91
+
92
+ if (!relation_sources) {
93
+ relation_sources = new Map();
94
+ grouped_incoming.set(graph_edge.relation, relation_sources);
95
+ }
96
+
97
+ relation_sources.set(source_node.id, source_node);
98
+ }
99
+
100
+ return formatIncomingGroups(grouped_incoming);
101
+ }
102
+
103
+ /**
104
+ * @param {BuildGraphResult} graph
105
+ * @param {string} where_clause
106
+ * @param {PatramRepoConfig | undefined} repo_config
107
+ * @returns {{ diagnostics: PatramDiagnostic[] } | Set<string>}
108
+ */
109
+ function resolveAllowedSourceNodeIds(graph, where_clause, repo_config) {
110
+ const query_result = queryGraph(
111
+ graph,
112
+ where_clause,
113
+ repo_config ?? {
114
+ fields: {},
115
+ include: [],
116
+ queries: {},
117
+ },
118
+ );
119
+
120
+ if (query_result.diagnostics.length > 0) {
121
+ return {
122
+ diagnostics: query_result.diagnostics,
123
+ };
124
+ }
125
+
126
+ return new Set(query_result.nodes.map((graph_node) => graph_node.id));
127
+ }
128
+
129
+ /**
130
+ * @param {Map<string, Map<string, GraphNode>>} grouped_incoming
131
+ * @returns {Record<string, GraphNode[]>}
132
+ */
133
+ function formatIncomingGroups(grouped_incoming) {
134
+ /** @type {Record<string, GraphNode[]>} */
135
+ const incoming = {};
136
+
137
+ for (const relation_name of [...grouped_incoming.keys()].sort(
138
+ compareStrings,
139
+ )) {
140
+ const relation_sources = /** @type {Map<string, GraphNode>} */ (
141
+ grouped_incoming.get(relation_name)
142
+ );
143
+
144
+ incoming[relation_name] = [...relation_sources.values()].sort(
145
+ compareGraphNodes,
146
+ );
147
+ }
148
+
149
+ return incoming;
150
+ }
151
+
152
+ /**
153
+ * @param {string} node_id
154
+ * @param {string} target_path
155
+ * @returns {GraphNode}
156
+ */
157
+ function createFallbackTargetNode(node_id, target_path) {
158
+ return {
159
+ $class: 'document',
160
+ $id: node_id,
161
+ $path: target_path,
162
+ id: node_id,
163
+ path: target_path,
164
+ title: target_path,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * @param {GraphNode} left_node
170
+ * @param {GraphNode} right_node
171
+ * @returns {number}
172
+ */
173
+ function compareGraphNodes(left_node, right_node) {
174
+ return compareStrings(left_node.id, right_node.id);
175
+ }
176
+
177
+ /**
178
+ * @param {string} left_value
179
+ * @param {string} right_value
180
+ * @returns {number}
181
+ */
182
+ function compareStrings(left_value, right_value) {
183
+ return left_value.localeCompare(right_value, 'en');
184
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @import { OutputNodeItem } from './output-view.types.ts';
3
+ */
4
+
5
+ import { formatOutputNodeMetadataRows } from './format-output-metadata.js';
6
+ import { formatNodeHeader } from './format-node-header.js';
7
+ import { formatOutputItemBlock } from './format-output-item-block.js';
8
+
9
+ /**
10
+ * Layout grouped incoming references as plain text lines.
11
+ *
12
+ * @param {Record<string, OutputNodeItem[]>} incoming
13
+ * @param {{
14
+ * format_node_header?: (output_item: OutputNodeItem) => string,
15
+ * format_relation_header?: (relation_name: string, relation_count: number) => string,
16
+ * }} layout_options
17
+ * @returns {string[]}
18
+ */
19
+ export function layoutIncomingReferenceLines(incoming, layout_options = {}) {
20
+ const format_node_header =
21
+ layout_options.format_node_header ?? defaultNodeHeaderFormatter;
22
+ const format_relation_header =
23
+ layout_options.format_relation_header ?? defaultRelationHeaderFormatter;
24
+ /** @type {string[]} */
25
+ const output_lines = [];
26
+
27
+ for (const relation_name of Object.keys(incoming)) {
28
+ if (output_lines.length > 0) {
29
+ output_lines.push('');
30
+ }
31
+
32
+ const relation_sources = incoming[relation_name];
33
+
34
+ output_lines.push(
35
+ format_relation_header(relation_name, relation_sources.length),
36
+ );
37
+ output_lines.push(
38
+ ...layoutIncomingRelationSources(relation_sources, format_node_header),
39
+ );
40
+ }
41
+
42
+ return output_lines;
43
+ }
44
+
45
+ /**
46
+ * @param {OutputNodeItem} output_item
47
+ * @returns {string}
48
+ */
49
+ function defaultNodeHeaderFormatter(output_item) {
50
+ return formatNodeHeader(output_item);
51
+ }
52
+
53
+ /**
54
+ * @param {string} relation_name
55
+ * @param {number} relation_count
56
+ * @returns {string}
57
+ */
58
+ function defaultRelationHeaderFormatter(relation_name, relation_count) {
59
+ return `${relation_name} (${relation_count})`;
60
+ }
61
+
62
+ /**
63
+ * @param {OutputNodeItem[]} relation_sources
64
+ * @param {(output_item: OutputNodeItem) => string} format_node_header
65
+ * @returns {string[]}
66
+ */
67
+ function layoutIncomingRelationSources(relation_sources, format_node_header) {
68
+ /** @type {string[]} */
69
+ const output_lines = [];
70
+
71
+ for (const [item_index, output_item] of relation_sources.entries()) {
72
+ if (item_index > 0) {
73
+ output_lines.push('');
74
+ }
75
+
76
+ output_lines.push(
77
+ ...indentIncomingNodeBlock(
78
+ formatIncomingNodeBlock(output_item, format_node_header),
79
+ ),
80
+ );
81
+ }
82
+
83
+ return output_lines;
84
+ }
85
+
86
+ /**
87
+ * @param {OutputNodeItem} output_item
88
+ * @param {(output_item: OutputNodeItem) => string} format_node_header
89
+ * @returns {string}
90
+ */
91
+ function formatIncomingNodeBlock(output_item, format_node_header) {
92
+ return formatOutputItemBlock({
93
+ header: format_node_header(output_item),
94
+ metadata_rows: formatOutputNodeMetadataRows(output_item),
95
+ title: output_item.title,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * @param {string} block
101
+ * @returns {string[]}
102
+ */
103
+ function indentIncomingNodeBlock(block) {
104
+ return block.split('\n').map((line) => (line.length > 0 ? ` ${line}` : ''));
105
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Layout compact incoming-summary lines for `show`.
3
+ *
4
+ * @param {Record<string, number>} incoming_summary
5
+ * @returns {string[]}
6
+ */
7
+ export function layoutIncomingSummaryLines(incoming_summary) {
8
+ const relation_names = Object.keys(incoming_summary);
9
+ const output_lines = ['incoming refs:'];
10
+
11
+ if (relation_names.length === 0) {
12
+ output_lines.push(' none');
13
+ return output_lines;
14
+ }
15
+
16
+ for (const relation_name of relation_names) {
17
+ output_lines.push(` ${relation_name}: ${incoming_summary[relation_name]}`);
18
+ }
19
+
20
+ return output_lines;
21
+ }
@@ -13,7 +13,7 @@ export interface OutputMetadataField {
13
13
  }
14
14
  export interface OutputViewSummary {
15
15
  count: number;
16
- kind: 'resolved_link_list' | 'result_list' | 'stored_query_list';
16
+ kind: 'incoming_reference_list' | 'resolved_link_list' | 'result_list' | 'stored_query_list';
17
17
  }
18
18
  export interface QueryOutputViewSummary extends OutputViewSummary {
19
19
  kind: 'result_list';
@@ -67,13 +67,21 @@ export interface ShowOutputView {
67
67
  command: 'show';
68
68
  document?: OutputNodeItem;
69
69
  hints: string[];
70
+ incoming_summary: Record<string, number>;
70
71
  items: OutputResolvedLinkItem[];
71
72
  path: string;
72
73
  rendered_source: string;
73
74
  source: string;
74
75
  summary: OutputViewSummary;
75
76
  }
76
- export type OutputView = QueryOutputView | QueriesOutputView | ShowOutputView;
77
+ export interface RefsOutputView {
78
+ command: 'refs';
79
+ hints: string[];
80
+ incoming: Record<string, OutputNodeItem[]>;
81
+ node: OutputNodeItem;
82
+ summary: OutputViewSummary;
83
+ }
84
+ export type OutputView = QueryOutputView | QueriesOutputView | RefsOutputView | ShowOutputView;
77
85
  export interface ResolvedOutputMode {
78
86
  color_enabled: boolean;
79
87
  renderer_name: 'json' | 'plain' | 'rich';
@@ -19,7 +19,11 @@ export interface OutputMetadataField {
19
19
 
20
20
  export interface OutputViewSummary {
21
21
  count: number;
22
- kind: 'resolved_link_list' | 'result_list' | 'stored_query_list';
22
+ kind:
23
+ | 'incoming_reference_list'
24
+ | 'resolved_link_list'
25
+ | 'result_list'
26
+ | 'stored_query_list';
23
27
  }
24
28
 
25
29
  export interface QueryOutputViewSummary extends OutputViewSummary {
@@ -81,6 +85,7 @@ export interface ShowOutputView {
81
85
  command: 'show';
82
86
  document?: OutputNodeItem;
83
87
  hints: string[];
88
+ incoming_summary: Record<string, number>;
84
89
  items: OutputResolvedLinkItem[];
85
90
  path: string;
86
91
  rendered_source: string;
@@ -88,7 +93,19 @@ export interface ShowOutputView {
88
93
  summary: OutputViewSummary;
89
94
  }
90
95
 
91
- export type OutputView = QueryOutputView | QueriesOutputView | ShowOutputView;
96
+ export interface RefsOutputView {
97
+ command: 'refs';
98
+ hints: string[];
99
+ incoming: Record<string, OutputNodeItem[]>;
100
+ node: OutputNodeItem;
101
+ summary: OutputViewSummary;
102
+ }
103
+
104
+ export type OutputView =
105
+ | QueryOutputView
106
+ | QueriesOutputView
107
+ | RefsOutputView
108
+ | ShowOutputView;
92
109
 
93
110
  export interface ResolvedOutputMode {
94
111
  color_enabled: boolean;
@@ -160,6 +160,10 @@ export function buildCommandArguments(
160
160
  return ['--where', parsed_values.where];
161
161
  }
162
162
 
163
+ if (command_name === 'refs' && parsed_values.where !== undefined) {
164
+ return [command_positionals[0], '--where', parsed_values.where];
165
+ }
166
+
163
167
  return command_positionals;
164
168
  }
165
169
 
@@ -305,7 +309,7 @@ function findMissingOptionValue(option_tokens) {
305
309
  for (const token of option_tokens) {
306
310
  if (token.name === 'where' && typeof token.value !== 'string') {
307
311
  return {
308
- argument_label: '<name> or --where "<clause>"',
312
+ argument_label: "<name> or --where '<clause>'",
309
313
  code: 'missing_required_argument',
310
314
  command_name: 'query',
311
315
  };
@@ -401,11 +405,14 @@ function validateCommandPositionals(command_name, command_positionals) {
401
405
  };
402
406
  }
403
407
 
404
- if (command_name === 'show' && command_definition.missing_argument_label) {
408
+ if (
409
+ (command_name === 'refs' || command_name === 'show') &&
410
+ command_definition.missing_argument_label
411
+ ) {
405
412
  return {
406
413
  argument_label: command_definition.missing_argument_label,
407
414
  code: 'missing_required_argument',
408
- command_name: 'show',
415
+ command_name,
409
416
  };
410
417
  }
411
418
 
@@ -422,7 +429,7 @@ function validateCommandPositionals(command_name, command_positionals) {
422
429
 
423
430
  if (command_name === 'query' && command_positionals.length === 0) {
424
431
  return {
425
- argument_label: '<name> or --where "<clause>"',
432
+ argument_label: "<name> or --where '<clause>'",
426
433
  code: 'missing_required_argument',
427
434
  command_name: 'query',
428
435
  };
@@ -1,4 +1,10 @@
1
- export type CliCommandName = 'check' | 'fields' | 'query' | 'queries' | 'show';
1
+ export type CliCommandName =
2
+ | 'check'
3
+ | 'fields'
4
+ | 'query'
5
+ | 'queries'
6
+ | 'refs'
7
+ | 'show';
2
8
  export type CliHelpTopicName = 'query-language';
3
9
  export type CliHelpTargetKind = 'root' | 'command' | 'topic';
4
10
 
@@ -34,7 +40,7 @@ export type CliParseError =
34
40
  | {
35
41
  code: 'missing_required_argument';
36
42
  argument_label: string;
37
- command_name: 'query' | 'show';
43
+ command_name: 'query' | 'refs' | 'show';
38
44
  }
39
45
  | {
40
46
  code: 'option_not_valid_for_command';
package/lib/patram-cli.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  writeRenderedCommandOutput,
13
13
  } from './command-output.js';
14
14
  import { discoverFields } from './discover-fields.js';
15
+ import { inspectReverseReferences } from './inspect-reverse-references.js';
15
16
  import { listRepoFiles } from './list-source-files.js';
16
17
  import { listQueries } from './list-queries.js';
17
18
  import { loadPatramConfig } from './load-patram-config.js';
@@ -36,6 +37,7 @@ import {
36
37
  } from './resolve-check-target.js';
37
38
  import {
38
39
  createOutputView,
40
+ createRefsOutputView,
39
41
  createShowOutputView,
40
42
  } from './render-output-view.js';
41
43
  import { renderFieldDiscovery } from './render-field-discovery.js';
@@ -54,6 +56,7 @@ import { loadShowOutput } from './show-document.js';
54
56
  * Implements Command: ../docs/reference/commands/check.md
55
57
  * Implements Command: ../docs/reference/commands/query.md
56
58
  * Implements Command: ../docs/reference/commands/queries.md
59
+ * Implements Command: ../docs/reference/commands/refs.md
57
60
  * Implements Command: ../docs/reference/commands/show.md
58
61
  * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
59
62
  * Decided by: ../docs/decisions/cli-output-architecture.md
@@ -105,6 +108,10 @@ export async function main(cli_arguments, io_context) {
105
108
  return runQueriesCommand(parsed_command, io_context);
106
109
  }
107
110
 
111
+ if (parsed_command.command_name === 'refs') {
112
+ return runRefsCommand(parsed_command, io_context);
113
+ }
114
+
108
115
  if (parsed_command.command_name === 'show') {
109
116
  return runShowCommand(parsed_command, io_context);
110
117
  }
@@ -371,6 +378,52 @@ async function runQueriesCommand(parsed_command, io_context) {
371
378
  return 0;
372
379
  }
373
380
 
381
+ /**
382
+ * @param {ParsedCliCommandRequest} parsed_command
383
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
384
+ * @returns {Promise<number>}
385
+ */
386
+ async function runRefsCommand(parsed_command, io_context) {
387
+ const project_graph_result = await loadProjectGraph(process.cwd());
388
+
389
+ if (project_graph_result.diagnostics.length > 0) {
390
+ writeDiagnostics(io_context.stderr, project_graph_result.diagnostics);
391
+
392
+ return 1;
393
+ }
394
+
395
+ const refs_output = inspectReverseReferences(
396
+ project_graph_result.graph,
397
+ parsed_command.command_arguments[0],
398
+ project_graph_result.config,
399
+ resolveRefsWhereClause(parsed_command.command_arguments),
400
+ );
401
+
402
+ if (refs_output.diagnostics.length > 0) {
403
+ io_context.stderr.write(
404
+ renderInvalidWhereDiagnostic(refs_output.diagnostics[0]),
405
+ );
406
+
407
+ return 1;
408
+ }
409
+
410
+ const derived_summary_evaluator = createDerivedSummaryEvaluator(
411
+ project_graph_result.config,
412
+ project_graph_result.graph,
413
+ );
414
+
415
+ await writeCommandOutput(
416
+ io_context,
417
+ parsed_command,
418
+ createRefsOutputView(refs_output, {
419
+ derived_summary_evaluator,
420
+ repo_config: project_graph_result.config,
421
+ }),
422
+ );
423
+
424
+ return 0;
425
+ }
426
+
374
427
  /**
375
428
  * @param {ParsedCliCommandRequest} parsed_command
376
429
  * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
@@ -441,7 +494,7 @@ function createQueryOutputOptions(parsed_command, query_result, use_pager) {
441
494
  const offset = parsed_command.query_offset ?? 0;
442
495
 
443
496
  if (query_result.total_count === 0) {
444
- hints.push('Try: patram query --where "$class=task"');
497
+ hints.push("Try: patram query --where '$class=task'");
445
498
  }
446
499
 
447
500
  if (
@@ -500,6 +553,18 @@ function createQueryExecutionOptions(parsed_command, use_pager) {
500
553
  };
501
554
  }
502
555
 
556
+ /**
557
+ * @param {string[]} command_arguments
558
+ * @returns {string | undefined}
559
+ */
560
+ function resolveRefsWhereClause(command_arguments) {
561
+ if (command_arguments[1] !== '--where') {
562
+ return undefined;
563
+ }
564
+
565
+ return command_arguments.slice(2).join(' ').trim();
566
+ }
567
+
503
568
  /**
504
569
  * @param {import('./load-patram-config.types.ts').PatramDiagnostic} diagnostic
505
570
  * @returns {string}
@@ -308,7 +308,7 @@ function renderInvalidCommandOptionError(command_name, invalid_token) {
308
308
  }
309
309
 
310
310
  /**
311
- * @param {'query' | 'show'} command_name
311
+ * @param {'query' | 'refs' | 'show'} command_name
312
312
  * @param {string} argument_label
313
313
  * @returns {string}
314
314
  */
@@ -10,54 +10,113 @@
10
10
  */
11
11
  export function renderJsonOutput(output_view) {
12
12
  if (output_view.command === 'query') {
13
- return `${JSON.stringify(
14
- {
15
- results: output_view.items.map(formatJsonQueryItem),
16
- summary: {
17
- shown_count: output_view.summary.count,
18
- total_count: output_view.summary.total_count,
19
- offset: output_view.summary.offset,
20
- limit: output_view.summary.limit,
21
- },
22
- hints: output_view.hints,
23
- },
24
- null,
25
- 2,
26
- )}\n`;
13
+ return renderJsonQueryOutput(output_view);
27
14
  }
28
15
 
29
16
  if (output_view.command === 'queries') {
30
- return `${JSON.stringify(
31
- {
32
- queries: output_view.items.map(formatJsonStoredQuery),
33
- },
34
- null,
35
- 2,
36
- )}\n`;
17
+ return renderJsonQueriesOutput(output_view.items);
18
+ }
19
+
20
+ if (output_view.command === 'refs') {
21
+ return renderJsonRefsOutput(output_view);
37
22
  }
38
23
 
39
24
  if (output_view.command === 'show') {
40
- return `${JSON.stringify(
41
- {
42
- document: output_view.document
43
- ? formatJsonShowDocument(output_view.document)
44
- : undefined,
45
- source: output_view.source,
46
- resolved_links: output_view.items.map(formatJsonResolvedLink),
47
- },
48
- null,
49
- 2,
50
- )}\n`;
25
+ return renderJsonShowOutput(output_view);
51
26
  }
52
27
 
53
28
  throw new Error('Unsupported output view command.');
54
29
  }
55
30
 
31
+ /**
32
+ * @param {Extract<OutputView, { command: 'query' }>} output_view
33
+ * @returns {string}
34
+ */
35
+ function renderJsonQueryOutput(output_view) {
36
+ return `${JSON.stringify(
37
+ {
38
+ results: output_view.items.map(formatJsonQueryItem),
39
+ summary: {
40
+ shown_count: output_view.summary.count,
41
+ total_count: output_view.summary.total_count,
42
+ offset: output_view.summary.offset,
43
+ limit: output_view.summary.limit,
44
+ },
45
+ hints: output_view.hints,
46
+ },
47
+ null,
48
+ 2,
49
+ )}\n`;
50
+ }
51
+
52
+ /**
53
+ * @param {OutputStoredQueryItem[]} output_items
54
+ * @returns {string}
55
+ */
56
+ function renderJsonQueriesOutput(output_items) {
57
+ return `${JSON.stringify(
58
+ {
59
+ queries: output_items.map(formatJsonStoredQuery),
60
+ },
61
+ null,
62
+ 2,
63
+ )}\n`;
64
+ }
65
+
66
+ /**
67
+ * @param {Extract<OutputView, { command: 'refs' }>} output_view
68
+ * @returns {string}
69
+ */
70
+ function renderJsonRefsOutput(output_view) {
71
+ return `${JSON.stringify(
72
+ {
73
+ node: formatJsonNodeItem(output_view.node),
74
+ incoming: Object.fromEntries(
75
+ Object.entries(output_view.incoming).map(
76
+ ([relation_name, output_items]) => [
77
+ relation_name,
78
+ output_items.map(formatJsonNodeItem),
79
+ ],
80
+ ),
81
+ ),
82
+ },
83
+ null,
84
+ 2,
85
+ )}\n`;
86
+ }
87
+
88
+ /**
89
+ * @param {Extract<OutputView, { command: 'show' }>} output_view
90
+ * @returns {string}
91
+ */
92
+ function renderJsonShowOutput(output_view) {
93
+ return `${JSON.stringify(
94
+ {
95
+ document: output_view.document
96
+ ? formatJsonNodeItem(output_view.document)
97
+ : undefined,
98
+ incoming_summary: output_view.incoming_summary,
99
+ source: output_view.source,
100
+ resolved_links: output_view.items.map(formatJsonResolvedLink),
101
+ },
102
+ null,
103
+ 2,
104
+ )}\n`;
105
+ }
106
+
56
107
  /**
57
108
  * @param {OutputNodeItem} output_item
58
109
  * @returns {{ '$class': string, '$id': string, '$path'?: string, derived?: Record<string, boolean | number | string | null>, derived_summary?: string, fields: Record<string, string | string[]>, title: string }}
59
110
  */
60
111
  function formatJsonQueryItem(output_item) {
112
+ return formatJsonNodeItem(output_item);
113
+ }
114
+
115
+ /**
116
+ * @param {OutputNodeItem} output_item
117
+ * @returns {{ '$class': string, '$id': string, '$path'?: string, derived?: Record<string, boolean | number | string | null>, derived_summary?: string, fields: Record<string, string | string[]>, title: string }}
118
+ */
119
+ function formatJsonNodeItem(output_item) {
61
120
  /** @type {{ '$class': string, '$id': string, '$path'?: string, derived?: Record<string, boolean | number | string | null>, derived_summary?: string, fields: Record<string, string | string[]>, title: string }} */
62
121
  const query_item = {
63
122
  $class: output_item.node_kind,
@@ -128,33 +187,3 @@ function formatJsonResolvedLink(output_item) {
128
187
 
129
188
  return resolved_link;
130
189
  }
131
-
132
- /**
133
- * @param {OutputNodeItem} output_item
134
- * @returns {{ '$class': string, '$id': string, '$path'?: string, derived?: Record<string, boolean | number | string | null>, derived_summary?: string, fields: Record<string, string | string[]>, title: string }}
135
- */
136
- function formatJsonShowDocument(output_item) {
137
- /** @type {{ '$class': string, '$id': string, '$path'?: string, derived?: Record<string, boolean | number | string | null>, derived_summary?: string, fields: Record<string, string | string[]>, title: string }} */
138
- const document_summary = {
139
- $class: output_item.node_kind,
140
- $id: output_item.id,
141
- fields: output_item.fields,
142
- title: output_item.title,
143
- };
144
-
145
- if (output_item.path) {
146
- document_summary.$path = output_item.path;
147
- }
148
-
149
- if (output_item.derived_summary) {
150
- document_summary.derived_summary = output_item.derived_summary.name;
151
- document_summary.derived = Object.fromEntries(
152
- output_item.derived_summary.fields.map((field) => [
153
- field.name,
154
- field.value,
155
- ]),
156
- );
157
- }
158
-
159
- return document_summary;
160
- }
@@ -3,7 +3,7 @@
3
3
  * @import { DerivedSummaryEvaluator } from './derived-summary.js';
4
4
  * @import { PatramRepoConfig } from './load-patram-config.types.ts';
5
5
  * @import { ParsedCliArguments } from './parse-cli-arguments.types.ts';
6
- * @import { OutputDerivedSummary, OutputMetadataField, OutputNodeItem, OutputResolvedLinkItem, OutputResolvedLinkTarget, OutputStoredQueryItem, OutputView, ResolvedOutputMode, ShowOutputView } from './output-view.types.ts';
6
+ * @import { OutputDerivedSummary, OutputMetadataField, OutputNodeItem, OutputResolvedLinkItem, OutputResolvedLinkTarget, OutputStoredQueryItem, OutputView, RefsOutputView, ResolvedOutputMode, ShowOutputView } from './output-view.types.ts';
7
7
  */
8
8
  /* eslint-disable max-lines */
9
9
 
@@ -55,7 +55,7 @@ export function createOutputView(command_name, command_items, command_options) {
55
55
  /**
56
56
  * Create a shared output view for the show command.
57
57
  *
58
- * @param {{ path: string, rendered_source: string, resolved_links: Array<{ label: string, reference: number, target: { kind?: string, path: string, status?: string, title: string } }>, source: string }} show_output
58
+ * @param {{ incoming_summary: Record<string, number>, path: string, rendered_source: string, resolved_links: Array<{ label: string, reference: number, target: { kind?: string, path: string, status?: string, title: string } }>, source: string }} show_output
59
59
  * @param {{ derived_summary_evaluator?: DerivedSummaryEvaluator, document_node_ids?: BuildGraphResult['document_node_ids'], graph_nodes?: BuildGraphResult['nodes'], repo_config?: PatramRepoConfig }=} command_options
60
60
  * @returns {ShowOutputView}
61
61
  */
@@ -78,6 +78,7 @@ export function createShowOutputView(show_output, command_options = {}) {
78
78
  )
79
79
  : undefined,
80
80
  hints: [],
81
+ incoming_summary: show_output.incoming_summary,
81
82
  items: show_output.resolved_links.map((resolved_link) =>
82
83
  createResolvedLinkOutputItem(resolved_link, command_options),
83
84
  ),
@@ -91,6 +92,46 @@ export function createShowOutputView(show_output, command_options = {}) {
91
92
  };
92
93
  }
93
94
 
95
+ /**
96
+ * Create a shared output view for the refs command.
97
+ *
98
+ * @param {{ incoming: Record<string, GraphNode[]>, node: GraphNode }} refs_output
99
+ * @param {{ derived_summary_evaluator?: DerivedSummaryEvaluator, repo_config?: PatramRepoConfig }=} command_options
100
+ * @returns {RefsOutputView}
101
+ */
102
+ export function createRefsOutputView(refs_output, command_options = {}) {
103
+ /** @type {Record<string, OutputNodeItem[]>} */
104
+ const incoming = {};
105
+
106
+ for (const relation_name of Object.keys(refs_output.incoming)) {
107
+ incoming[relation_name] = refs_output.incoming[relation_name].map(
108
+ (graph_node) =>
109
+ createOutputNodeItem(
110
+ graph_node,
111
+ command_options.derived_summary_evaluator?.evaluate(graph_node) ??
112
+ null,
113
+ command_options.repo_config?.fields ?? {},
114
+ ),
115
+ );
116
+ }
117
+
118
+ return {
119
+ command: 'refs',
120
+ hints: [],
121
+ incoming,
122
+ node: createOutputNodeItem(
123
+ refs_output.node,
124
+ command_options.derived_summary_evaluator?.evaluate(refs_output.node) ??
125
+ null,
126
+ command_options.repo_config?.fields ?? {},
127
+ ),
128
+ summary: {
129
+ count: countIncomingReferenceItems(incoming),
130
+ kind: 'incoming_reference_list',
131
+ },
132
+ };
133
+ }
134
+
94
135
  /**
95
136
  * Render one shared output view through the resolved renderer.
96
137
  *
@@ -130,7 +171,7 @@ function createQueryOutputView(graph_nodes, command_options = {}) {
130
171
  command: 'query',
131
172
  hints:
132
173
  command_options.hints ??
133
- (total_count === 0 ? ['Try: patram query --where "$class=task"'] : []),
174
+ (total_count === 0 ? ["Try: patram query --where '$class=task'"] : []),
134
175
  items: graph_nodes.map((graph_node) =>
135
176
  createOutputNodeItem(
136
177
  graph_node,
@@ -168,6 +209,20 @@ function createStoredQueriesOutputView(stored_queries) {
168
209
  };
169
210
  }
170
211
 
212
+ /**
213
+ * @param {Record<string, OutputNodeItem[]>} incoming
214
+ * @returns {number}
215
+ */
216
+ function countIncomingReferenceItems(incoming) {
217
+ let count = 0;
218
+
219
+ for (const output_items of Object.values(incoming)) {
220
+ count += output_items.length;
221
+ }
222
+
223
+ return count;
224
+ }
225
+
171
226
  /**
172
227
  * @param {GraphNode} graph_node
173
228
  * @param {OutputDerivedSummary | null} derived_summary
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { OutputNodeItem, OutputResolvedLinkItem, OutputStoredQueryItem, OutputView, QueryOutputView, ShowOutputView } from './output-view.types.ts';
2
+ * @import { OutputNodeItem, OutputResolvedLinkItem, OutputStoredQueryItem, OutputView, QueryOutputView, RefsOutputView, ShowOutputView } from './output-view.types.ts';
3
3
  */
4
4
 
5
5
  import {
@@ -8,6 +8,8 @@ import {
8
8
  } from './format-output-metadata.js';
9
9
  import { formatNodeHeader } from './format-node-header.js';
10
10
  import { formatOutputItemBlock } from './format-output-item-block.js';
11
+ import { layoutIncomingReferenceLines } from './layout-incoming-references.js';
12
+ import { layoutIncomingSummaryLines } from './layout-incoming-summary-lines.js';
11
13
  import { layoutStoredQueries } from './layout-stored-queries.js';
12
14
 
13
15
  /**
@@ -25,6 +27,10 @@ export function renderPlainOutput(output_view) {
25
27
  return renderPlainStoredQueries(output_view.items);
26
28
  }
27
29
 
30
+ if (output_view.command === 'refs') {
31
+ return renderPlainRefsOutput(output_view);
32
+ }
33
+
28
34
  if (output_view.command === 'show') {
29
35
  return renderPlainShowOutput(output_view);
30
36
  }
@@ -85,8 +91,13 @@ function renderPlainShowOutput(output_view) {
85
91
  const document_summary = output_view.document
86
92
  ? formatPlainNodeItem(output_view.document)
87
93
  : '';
94
+ const incoming_summary = renderPlainIncomingSummary(output_view);
88
95
 
89
- if (document_summary.length === 0 && output_view.items.length === 0) {
96
+ if (
97
+ document_summary.length === 0 &&
98
+ output_view.items.length === 0 &&
99
+ incoming_summary.length === 0
100
+ ) {
90
101
  return `${rendered_source}\n`;
91
102
  }
92
103
 
@@ -99,7 +110,28 @@ function renderPlainShowOutput(output_view) {
99
110
 
100
111
  summary_items.push(...output_view.items.map(formatPlainResolvedLinkItem));
101
112
 
102
- return `${rendered_source}\n\n----------------\n${summary_items.join('\n\n')}\n`;
113
+ const summary_blocks = summary_items.filter((summary_item) => summary_item);
114
+
115
+ if (incoming_summary.length > 0) {
116
+ summary_blocks.push(incoming_summary);
117
+ }
118
+
119
+ return `${rendered_source}\n\n----------------\n${summary_blocks.join('\n\n')}\n`;
120
+ }
121
+
122
+ /**
123
+ * @param {RefsOutputView} output_view
124
+ * @returns {string}
125
+ */
126
+ function renderPlainRefsOutput(output_view) {
127
+ const node_summary = formatPlainNodeItem(output_view.node);
128
+ const output_lines = layoutIncomingReferenceLines(output_view.incoming);
129
+
130
+ if (output_lines.length === 0) {
131
+ return `${node_summary}\n\nNo incoming references.\n`;
132
+ }
133
+
134
+ return `${node_summary}\n\n${output_lines.join('\n')}\n`;
103
135
  }
104
136
 
105
137
  /**
@@ -135,6 +167,17 @@ function formatPlainResolvedLinkItem(output_item) {
135
167
  });
136
168
  }
137
169
 
170
+ /**
171
+ * @param {ShowOutputView} output_view
172
+ * @returns {string}
173
+ */
174
+ function renderPlainIncomingSummary(output_view) {
175
+ const output_lines = layoutIncomingSummaryLines(output_view.incoming_summary);
176
+ output_lines.push('', `Hint: patram refs ${output_view.path}`);
177
+
178
+ return output_lines.join('\n');
179
+ }
180
+
138
181
  /**
139
182
  * @param {string} value
140
183
  * @returns {string}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @import { CliColorMode } from './parse-cli-arguments.types.ts';
3
- * @import { OutputNodeItem, OutputResolvedLinkItem, OutputStoredQueryItem, OutputView, QueryOutputView, ShowOutputView } from './output-view.types.ts';
3
+ * @import { OutputNodeItem, OutputResolvedLinkItem, OutputStoredQueryItem, OutputView, QueryOutputView, RefsOutputView, ShowOutputView } from './output-view.types.ts';
4
4
  */
5
5
 
6
6
  import { Ansis } from 'ansis';
@@ -11,6 +11,8 @@ import {
11
11
  } from './format-output-metadata.js';
12
12
  import { formatNodeHeader } from './format-node-header.js';
13
13
  import { formatOutputItemBlock } from './format-output-item-block.js';
14
+ import { layoutIncomingReferenceLines } from './layout-incoming-references.js';
15
+ import { layoutIncomingSummaryLines } from './layout-incoming-summary-lines.js';
14
16
  import { layoutStoredQueries } from './layout-stored-queries.js';
15
17
  import { renderRichSource } from './render-rich-source.js';
16
18
 
@@ -34,6 +36,9 @@ export async function renderRichOutput(output_view, render_options) {
34
36
  return renderRichStoredQueries(output_view.items, ansi);
35
37
  }
36
38
 
39
+ if (output_view.command === 'refs') {
40
+ return renderRichRefsOutput(output_view, ansi);
41
+ }
37
42
  if (output_view.command === 'show') {
38
43
  return renderRichShowOutput(output_view, render_options, ansi);
39
44
  }
@@ -105,10 +110,7 @@ async function renderRichShowOutput(output_view, render_options, ansi) {
105
110
  const document_summary = output_view.document
106
111
  ? formatRichNodeItem(output_view.document, ansi)
107
112
  : '';
108
-
109
- if (document_summary.length === 0 && output_view.items.length === 0) {
110
- return `${rendered_source}\n`;
111
- }
113
+ const incoming_summary = renderRichIncomingSummary(output_view, ansi);
112
114
 
113
115
  /** @type {string[]} */
114
116
  const summary_items = [];
@@ -121,9 +123,34 @@ async function renderRichShowOutput(output_view, render_options, ansi) {
121
123
  ...output_view.items.map((item) => formatRichResolvedLinkItem(item, ansi)),
122
124
  );
123
125
 
126
+ if (incoming_summary.length > 0) {
127
+ summary_items.push(incoming_summary);
128
+ }
129
+
124
130
  return `${rendered_source}\n\n${ansi.gray(FULL_WIDTH_DIVIDER)}\n\n${summary_items.join('\n\n')}\n`;
125
131
  }
126
132
 
133
+ /**
134
+ * @param {RefsOutputView} output_view
135
+ * @param {Ansis} ansi
136
+ * @returns {string}
137
+ */
138
+ function renderRichRefsOutput(output_view, ansi) {
139
+ const node_summary = formatRichNodeItem(output_view.node, ansi);
140
+ const output_lines = layoutIncomingReferenceLines(output_view.incoming, {
141
+ format_node_header(output_item) {
142
+ return ansi.green(formatNodeHeader(output_item));
143
+ },
144
+ format_relation_header(relation_name, relation_count) {
145
+ return `${ansi.bold(relation_name)} ${ansi.gray(`(${relation_count})`)}`;
146
+ },
147
+ });
148
+
149
+ return output_lines.length === 0
150
+ ? `${node_summary}\n\n${ansi.yellow('No incoming references.')}\n`
151
+ : `${node_summary}\n\n${output_lines.join('\n')}\n`;
152
+ }
153
+
127
154
  /**
128
155
  * @param {OutputNodeItem} output_item
129
156
  * @param {Ansis} ansi
@@ -162,6 +189,24 @@ function formatRichResolvedLinkItem(output_item, ansi) {
162
189
  });
163
190
  }
164
191
 
192
+ /**
193
+ * @param {ShowOutputView} output_view
194
+ * @param {Ansis} ansi
195
+ * @returns {string}
196
+ */
197
+ function renderRichIncomingSummary(output_view, ansi) {
198
+ const output_lines = layoutIncomingSummaryLines(output_view.incoming_summary);
199
+ output_lines[0] = ansi.bold(output_lines[0]);
200
+
201
+ if (Object.keys(output_view.incoming_summary).length === 0) {
202
+ output_lines[1] = ` ${ansi.gray('none')}`;
203
+ }
204
+
205
+ output_lines.push('', ansi.gray(`Hint: patram refs ${output_view.path}`));
206
+
207
+ return output_lines.join('\n');
208
+ }
209
+
165
210
  /**
166
211
  * @param {boolean} color_enabled
167
212
  * @returns {Ansis}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared reverse-reference graph fixtures for tests.
3
+ */
4
+
5
+ export function createDecisionNode() {
6
+ return {
7
+ $class: 'decision',
8
+ $id: 'decision:query-language',
9
+ $path: 'docs/decisions/query-language.md',
10
+ id: 'decision:query-language',
11
+ path: 'docs/decisions/query-language.md',
12
+ status: 'accepted',
13
+ title: 'Query Language',
14
+ };
15
+ }
16
+
17
+ export function createReconcileNode() {
18
+ return {
19
+ $class: 'document',
20
+ $id: 'doc:lib/reconcile.js',
21
+ $path: 'lib/reconcile.js',
22
+ id: 'doc:lib/reconcile.js',
23
+ path: 'lib/reconcile.js',
24
+ title: 'Reconciler entrypoint.',
25
+ };
26
+ }
27
+
28
+ export function createResumeNode() {
29
+ return {
30
+ $class: 'document',
31
+ $id: 'doc:lib/resume.js',
32
+ $path: 'lib/resume.js',
33
+ id: 'doc:lib/resume.js',
34
+ path: 'lib/resume.js',
35
+ title: 'Resume entrypoint.',
36
+ };
37
+ }
38
+
39
+ export function createTaskNode() {
40
+ return {
41
+ $class: 'task',
42
+ $id: 'task:reverse-reference-inspection',
43
+ $path: 'docs/tasks/v0/reverse-reference-inspection.md',
44
+ id: 'task:reverse-reference-inspection',
45
+ path: 'docs/tasks/v0/reverse-reference-inspection.md',
46
+ status: 'ready',
47
+ title: 'Implement reverse reference inspection',
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {string} edge_id
53
+ * @param {string} from_id
54
+ * @param {string} origin_path
55
+ * @param {string} relation_name
56
+ * @param {string} to_id
57
+ */
58
+ export function createGraphEdge(
59
+ edge_id,
60
+ from_id,
61
+ origin_path,
62
+ relation_name,
63
+ to_id,
64
+ ) {
65
+ return {
66
+ from: from_id,
67
+ id: edge_id,
68
+ origin: {
69
+ column: 1,
70
+ line: 1,
71
+ path: origin_path,
72
+ },
73
+ relation: relation_name,
74
+ to: to_id,
75
+ };
76
+ }
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable max-lines */
2
2
  /**
3
- * @import { GraphNode } from './build-graph.types.ts';
3
+ * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
4
4
  * @import { PatramClaim } from './parse-claims.types.ts';
5
5
  * @import { PatramDiagnostic } from './load-patram-config.types.ts';
6
6
  */
@@ -9,6 +9,7 @@ import { readFile } from 'node:fs/promises';
9
9
  import { posix, relative, resolve } from 'node:path';
10
10
 
11
11
  import { resolveDocumentNodeId } from './build-graph-identity.js';
12
+ import { inspectReverseReferences } from './inspect-reverse-references.js';
12
13
  import { parseSourceFile } from './parse-claims.js';
13
14
 
14
15
  /**
@@ -30,11 +31,12 @@ import { parseSourceFile } from './parse-claims.js';
30
31
  /**
31
32
  * @param {string} requested_file_path
32
33
  * @param {string} project_directory
33
- * @param {import('./build-graph.types.ts').BuildGraphResult} graph
34
+ * @param {BuildGraphResult} graph
34
35
  * @returns {Promise<
35
36
  * | {
36
37
  * success: true;
37
38
  * value: {
39
+ * incoming_summary: Record<string, number>;
38
40
  * path: string;
39
41
  * rendered_source: string;
40
42
  * resolved_links: Array<{
@@ -93,6 +95,7 @@ export async function loadShowOutput(
93
95
  source_file_path,
94
96
  source_text,
95
97
  parse_result.claims,
98
+ graph,
96
99
  graph.document_node_ids,
97
100
  graph.nodes,
98
101
  ),
@@ -103,14 +106,16 @@ export async function loadShowOutput(
103
106
  * @param {string} source_file_path
104
107
  * @param {string} source_text
105
108
  * @param {PatramClaim[]} claims
109
+ * @param {BuildGraphResult} graph
106
110
  * @param {import('./build-graph.types.ts').BuildGraphResult['document_node_ids']} document_node_ids
107
111
  * @param {Record<string, GraphNode>} graph_nodes
108
- * @returns {{ path: string, rendered_source: string, resolved_links: Array<{ label: string, reference: number, target: { kind?: string, path: string, status?: string, title: string } }>, source: string }}
112
+ * @returns {{ incoming_summary: Record<string, number>, path: string, rendered_source: string, resolved_links: Array<{ label: string, reference: number, target: { kind?: string, path: string, status?: string, title: string } }>, source: string }}
109
113
  */
110
114
  function createShowOutput(
111
115
  source_file_path,
112
116
  source_text,
113
117
  claims,
118
+ graph,
114
119
  document_node_ids,
115
120
  graph_nodes,
116
121
  ) {
@@ -125,8 +130,15 @@ function createShowOutput(
125
130
  graph_nodes,
126
131
  ),
127
132
  );
133
+ const reverse_references = inspectReverseReferences(
134
+ graph,
135
+ source_file_path,
136
+ undefined,
137
+ undefined,
138
+ );
128
139
 
129
140
  return {
141
+ incoming_summary: summarizeIncomingReferences(reverse_references.incoming),
130
142
  path: source_file_path,
131
143
  rendered_source: renderResolvedSource(
132
144
  source_text,
@@ -269,12 +281,23 @@ function getScalarGraphField(field_value) {
269
281
  * @returns {{ kind?: string, path: string, status?: string, title: string }}
270
282
  */
271
283
  function createResolvedLinkTarget(target_node, target_path, fallback_title) {
272
- return {
273
- kind: getResolvedLinkTargetKind(target_node),
284
+ /** @type {{ kind?: string, path: string, status?: string, title: string }} */
285
+ const resolved_target = {
274
286
  path: getResolvedLinkTargetPath(target_node, target_path),
275
- status: getScalarGraphField(target_node?.status),
276
287
  title: getScalarGraphField(target_node?.title) ?? fallback_title,
277
288
  };
289
+ const target_kind = getResolvedLinkTargetKind(target_node);
290
+ const target_status = getScalarGraphField(target_node?.status);
291
+
292
+ if (target_kind) {
293
+ resolved_target.kind = target_kind;
294
+ }
295
+
296
+ if (target_status) {
297
+ resolved_target.status = target_status;
298
+ }
299
+
300
+ return resolved_target;
278
301
  }
279
302
 
280
303
  /**
@@ -355,6 +378,21 @@ function trimTrailingLineBreaks(value) {
355
378
  return value.replace(/\n+$/du, '');
356
379
  }
357
380
 
381
+ /**
382
+ * @param {Record<string, GraphNode[]>} incoming
383
+ * @returns {Record<string, number>}
384
+ */
385
+ function summarizeIncomingReferences(incoming) {
386
+ /** @type {Record<string, number>} */
387
+ const incoming_summary = {};
388
+
389
+ for (const relation_name of Object.keys(incoming)) {
390
+ incoming_summary[relation_name] = incoming[relation_name].length;
391
+ }
392
+
393
+ return incoming_summary;
394
+ }
395
+
358
396
  /**
359
397
  * @param {unknown} error
360
398
  * @returns {error is NodeJS.ErrnoException}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patram",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "./lib/patram.js",
6
6
  "types": "./lib/patram.d.ts",