@ankhorage/paradox 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 9dc0fd2: Add sequence diagram of bin script to README.md
8
+
9
+ ## 0.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 3a7c982: Render sequence diagrams from call flow instead of import topology.
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/paradox
5
5
 
6
- ![license: MIT](./paradox/badges/license.svg) ![npm: v0.0.10](./paradox/badges/npm.svg) ![runtime: bun](./paradox/badges/runtime.svg) ![typescript: strict](./paradox/badges/typescript.svg) ![eslint: checked](./paradox/badges/eslint.svg) ![prettier: checked](./paradox/badges/prettier.svg) ![build: checked](./paradox/badges/build.svg) ![tests: checked](./paradox/badges/tests.svg) ![docs: paradox](./paradox/badges/docs.svg)
6
+ ![license: MIT](./paradox/badges/license.svg) ![npm: v0.1.1](./paradox/badges/npm.svg) ![runtime: bun](./paradox/badges/runtime.svg) ![typescript: strict](./paradox/badges/typescript.svg) ![eslint: checked](./paradox/badges/eslint.svg) ![prettier: checked](./paradox/badges/prettier.svg) ![build: checked](./paradox/badges/build.svg) ![tests: checked](./paradox/badges/tests.svg) ![docs: paradox](./paradox/badges/docs.svg)
7
7
 
8
8
  Deterministic documentation generator for TypeScript packages.
9
9
 
@@ -13,6 +13,59 @@ Deterministic documentation generator for TypeScript packages.
13
13
  bunx @ankhorage/paradox
14
14
  ```
15
15
 
16
+ ## CLI
17
+
18
+ ### paradox
19
+
20
+ Runs the Paradox CLI.
21
+
22
+ The command discovers the nearest Paradox config, resolves the package and output roots,
23
+ analyzes the package, builds the documentation model, renders all documentation artifacts,
24
+ and writes them to the configured output directory.
25
+
26
+ ```bash
27
+ bunx @ankhorage/paradox
28
+ ```
29
+
30
+ <details>
31
+ <summary>paradox sequence</summary>
32
+
33
+ Diagram: [paradox sequence](./paradox/diagrams/sequences/paradox.mmd)
34
+
35
+ ```mermaid
36
+ sequenceDiagram
37
+ participant participant_analyze as analyze
38
+ participant participant_buildModel as buildModel
39
+ participant participant_dirname as dirname
40
+ participant participant_findParadoxConfigFile as findParadoxConfigFile
41
+ participant participant_loadParadoxConfig as loadParadoxConfig
42
+ participant participant_main as main
43
+ participant participant_render as render
44
+ participant participant_resolveOutputRoot as resolveOutputRoot
45
+ participant participant_resolvePackageRoot as resolvePackageRoot
46
+ participant participant_write as write
47
+ participant_main->>participant_findParadoxConfigFile: findParadoxConfigFile()
48
+ participant_findParadoxConfigFile-->>participant_main: return
49
+ participant_main->>participant_dirname: dirname()
50
+ participant_dirname-->>participant_main: return
51
+ participant_main->>participant_loadParadoxConfig: loadParadoxConfig()
52
+ participant_loadParadoxConfig-->>participant_main: return
53
+ participant_main->>participant_resolvePackageRoot: resolvePackageRoot()
54
+ participant_resolvePackageRoot-->>participant_main: return
55
+ participant_main->>participant_resolveOutputRoot: resolveOutputRoot()
56
+ participant_resolveOutputRoot-->>participant_main: return
57
+ participant_main->>participant_analyze: analyze()
58
+ participant_analyze-->>participant_main: return
59
+ participant_main->>participant_buildModel: buildModel()
60
+ participant_buildModel-->>participant_main: return
61
+ participant_main->>participant_render: render()
62
+ participant_render-->>participant_main: return
63
+ participant_main->>participant_write: write()
64
+ participant_write-->>participant_main: return
65
+ ```
66
+
67
+ </details>
68
+
16
69
  ## Documentation Tags
17
70
 
18
71
  <details>
@@ -68,7 +121,7 @@ export default defineParadoxConfig({
68
121
  - [Architecture overview](./paradox/diagrams/architecture-overview.mmd)
69
122
  - [Module relationships](./paradox/diagrams/module-relationships.mmd)
70
123
  - [Export graph](./paradox/diagrams/export-graph.mmd)
71
- - [Entrypoint sequence](./paradox/diagrams/entrypoint-sequence.mmd)
124
+ - [paradox sequence](./paradox/diagrams/sequences/paradox.mmd)
72
125
 
73
126
  ## Architecture preview
74
127
 
@@ -90,6 +143,7 @@ graph TD
90
143
  module_src_analyze_analyze_ts --> module_src_analyze_semantic_createTypeScriptProgram_ts
91
144
  module_src_analyze_analyze_ts --> module_src_analyze_semantic_exports_ts
92
145
  module_src_analyze_analyze_ts --> module_src_analyze_semantic_graphs_ts
146
+ module_src_analyze_analyze_ts --> module_src_analyze_sequenceScenarios_ts
93
147
  module_src_analyze_analyze_ts --> module_src_analyze_types_ts
94
148
  module_src_analyze_analyze_ts --> module_src_analyze_usage_ts
95
149
  module_src_analyze_analyze_ts --> module_src_config_types_ts
@@ -165,6 +219,13 @@ graph TD
165
219
  package__ankhorage_paradox -.-> module_src_analyze_semantic_tagRegistry_ts
166
220
  module_src_analyze_semantic_utils_ts["src/analyze/semantic/utils.ts"]
167
221
  package__ankhorage_paradox -.-> module_src_analyze_semantic_utils_ts
222
+ module_src_analyze_sequenceScenarios_ts["src/analyze/sequenceScenarios.ts"]
223
+ package__ankhorage_paradox -.-> module_src_analyze_sequenceScenarios_ts
224
+ module_src_analyze_sequenceScenarios_ts --> module_src_analyze_semantic_utils_ts
225
+ module_src_analyze_sequenceScenarios_ts --> module_src_analyze_types_ts
226
+ module_src_analyze_sequenceScenarios_ts --> module_src_analyze_usage_ts
227
+ module_src_analyze_sequenceScenarios_ts --> module_src_analyze_utils_getParadoxComment_ts
228
+ module_src_analyze_sequenceScenarios_ts --> module_src_analyze_utils_parseParadoxComment_ts
168
229
  module_src_analyze_types_ts["src/analyze/types.ts"]
169
230
  package__ankhorage_paradox -.-> module_src_analyze_types_ts
170
231
  module_src_analyze_usage_ts["src/analyze/usage.ts"]
@@ -8,6 +8,7 @@ import { createProject } from './project.js';
8
8
  import { createTypeScriptProgram } from './semantic/createTypeScriptProgram.js';
9
9
  import { collectTypeMembers, resolveTypeReference } from './semantic/exports.js';
10
10
  import { collectCallGraph, collectComponentCompositionGraph, collectImportGraph, } from './semantic/graphs.js';
11
+ import { analyzeSequenceScenarios } from './sequenceScenarios.js';
11
12
  import { createUsageFromPackageJson } from './usage.js';
12
13
  export async function analyze(config, runtime) {
13
14
  const root = runtime.packageRoot;
@@ -20,6 +21,7 @@ export async function analyze(config, runtime) {
20
21
  const { config: configMetadata, exports } = analyzeExports(project, { root, entrypoints });
21
22
  const components = analyzeComponents(exports, { program });
22
23
  const modules = analyzeModules(project, { root, entrypoints });
24
+ const sequenceScenarios = analyzeSequenceScenarios({ project, root, pkg, exports });
23
25
  const configExport = configMetadata
24
26
  ? (exports.find((entry) => entry.name === configMetadata.exportName) ?? null)
25
27
  : null;
@@ -50,6 +52,7 @@ export async function analyze(config, runtime) {
50
52
  entrypoints: entrypoints.map((entrypoint) => entrypoint.replaceAll('\\', '/')).sort(),
51
53
  modules,
52
54
  badges,
55
+ sequenceScenarios,
53
56
  usage,
54
57
  config: configMetadata
55
58
  ? {
@@ -0,0 +1,14 @@
1
+ import { type Project } from 'ts-morph';
2
+ import type { AnalysisExport, AnalysisSequenceScenario } from './types.js';
3
+ import type { PackageJsonModel } from './usage.js';
4
+ interface AnalyzeSequenceScenariosOptions {
5
+ project: Project;
6
+ root: string;
7
+ pkg: PackageJsonModel;
8
+ exports: readonly AnalysisExport[];
9
+ }
10
+ /***
11
+ * Finds scenario roots that can be rendered as sequence diagrams.
12
+ */
13
+ export declare function analyzeSequenceScenarios({ project, root, pkg, exports, }: AnalyzeSequenceScenariosOptions): AnalysisSequenceScenario[];
14
+ export {};
@@ -0,0 +1,174 @@
1
+ import { isAbsolute, join, normalize } from 'node:path';
2
+ import { Node as MorphNode, } from 'ts-morph';
3
+ import { relativeToRoot, toPosixPath } from './semantic/utils.js';
4
+ import { getParadoxComment } from './utils/getParadoxComment.js';
5
+ import { parseParadoxComment } from './utils/parseParadoxComment.js';
6
+ /***
7
+ * Finds scenario roots that can be rendered as sequence diagrams.
8
+ */
9
+ export function analyzeSequenceScenarios({ project, root, pkg, exports, }) {
10
+ return uniqueScenarios([
11
+ ...analyzeBinSequenceScenarios(project, root, pkg),
12
+ ...analyzeExportSequenceScenarios(exports),
13
+ ]);
14
+ }
15
+ function analyzeBinSequenceScenarios(project, root, pkg) {
16
+ return getBinEntries(pkg).flatMap((entry) => {
17
+ const sourceFile = resolveBinSourceFile(project, root, entry.targetPath);
18
+ if (!sourceFile)
19
+ return [];
20
+ const callableRoot = findTopLevelInvokedLocalCallable(sourceFile);
21
+ if (callableRoot === null)
22
+ return [];
23
+ return [
24
+ {
25
+ kind: 'bin',
26
+ name: entry.name,
27
+ sourcePath: relativeToRoot(root, sourceFile.getFilePath()),
28
+ symbolName: callableRoot.symbolName,
29
+ description: callableRoot.description,
30
+ isReadme: callableRoot.isReadme,
31
+ },
32
+ ];
33
+ });
34
+ }
35
+ function analyzeExportSequenceScenarios(exports) {
36
+ return exports.flatMap((entry) => {
37
+ if (entry.signatures.length === 0)
38
+ return [];
39
+ return [
40
+ {
41
+ kind: 'export',
42
+ name: entry.name,
43
+ sourcePath: entry.modulePath,
44
+ symbolName: entry.name,
45
+ description: entry.description,
46
+ isReadme: entry.isReadme,
47
+ },
48
+ ];
49
+ });
50
+ }
51
+ function getBinEntries(pkg) {
52
+ if (pkg.bin == null)
53
+ return [];
54
+ if (typeof pkg.bin === 'string') {
55
+ return [
56
+ {
57
+ name: getPackageBaseName(pkg.name),
58
+ targetPath: pkg.bin,
59
+ },
60
+ ];
61
+ }
62
+ return Object.entries(pkg.bin)
63
+ .map(([name, targetPath]) => ({ name, targetPath }))
64
+ .sort((left, right) => left.name.localeCompare(right.name));
65
+ }
66
+ function resolveBinSourceFile(project, root, targetPath) {
67
+ for (const candidate of getBinSourceCandidates(targetPath)) {
68
+ const sourceFile = getSourceFileByRelativePath(project, root, candidate);
69
+ if (sourceFile)
70
+ return sourceFile;
71
+ }
72
+ return null;
73
+ }
74
+ function getBinSourceCandidates(targetPath) {
75
+ const normalized = toPosixPath(targetPath).replace(/^\.\//, '');
76
+ const candidates = [];
77
+ if (/^src\/.*\.tsx?$/.test(normalized)) {
78
+ candidates.push(normalized);
79
+ }
80
+ if (/^dist\/.*\.jsx?$/.test(normalized)) {
81
+ candidates.push(normalized.replace(/^dist\//, 'src/').replace(/\.jsx?$/, '.ts'));
82
+ candidates.push(normalized.replace(/^dist\//, 'src/').replace(/\.jsx?$/, '.tsx'));
83
+ }
84
+ if (/\.jsx?$/.test(normalized)) {
85
+ candidates.push(normalized.replace(/\.jsx?$/, '.ts'));
86
+ candidates.push(normalized.replace(/\.jsx?$/, '.tsx'));
87
+ }
88
+ return uniqueSorted(candidates);
89
+ }
90
+ function getSourceFileByRelativePath(project, root, relativePath) {
91
+ const absolutePath = normalize(isAbsolute(relativePath) ? relativePath : join(root, relativePath));
92
+ return project.getSourceFile(absolutePath) ?? null;
93
+ }
94
+ function findTopLevelInvokedLocalCallable(sourceFile) {
95
+ const candidates = [];
96
+ sourceFile.forEachDescendant((node) => {
97
+ if (!MorphNode.isCallExpression(node))
98
+ return;
99
+ if (isInsideCallable(node))
100
+ return;
101
+ const callableDeclaration = getLocalFunctionDeclarationForCall(sourceFile, node);
102
+ if (callableDeclaration !== null) {
103
+ candidates.push(callableDeclaration);
104
+ }
105
+ });
106
+ const uniqueCandidates = uniqueByFunctionName(candidates);
107
+ const declaration = uniqueCandidates.length === 1 ? uniqueCandidates[0] : undefined;
108
+ if (declaration === undefined)
109
+ return null;
110
+ const parsedComment = getParsedParadoxComment(declaration);
111
+ return {
112
+ symbolName: declaration.getName() ?? 'main',
113
+ description: parsedComment.description,
114
+ isReadme: parsedComment.isReadme,
115
+ };
116
+ }
117
+ function getParsedParadoxComment(declaration) {
118
+ const comment = getParadoxComment(declaration);
119
+ if (comment === null) {
120
+ return { description: null, isReadme: false };
121
+ }
122
+ const parsed = parseParadoxComment(comment);
123
+ return {
124
+ description: parsed.description,
125
+ isReadme: parsed.isReadme,
126
+ };
127
+ }
128
+ function getLocalFunctionDeclarationForCall(sourceFile, node) {
129
+ const expression = node.getExpression();
130
+ const symbol = expression.getSymbol() ?? expression.getType().getSymbol();
131
+ if (!symbol)
132
+ return null;
133
+ for (const declaration of symbol.getDeclarations()) {
134
+ if (declaration.getSourceFile().getFilePath() !== sourceFile.getFilePath())
135
+ continue;
136
+ if (!MorphNode.isFunctionDeclaration(declaration))
137
+ continue;
138
+ return declaration;
139
+ }
140
+ return null;
141
+ }
142
+ function isInsideCallable(node) {
143
+ return Boolean(node.getFirstAncestor((candidate) => MorphNode.isFunctionDeclaration(candidate) ||
144
+ MorphNode.isMethodDeclaration(candidate) ||
145
+ MorphNode.isFunctionExpression(candidate) ||
146
+ MorphNode.isArrowFunction(candidate) ||
147
+ MorphNode.isClassDeclaration(candidate)));
148
+ }
149
+ function getPackageBaseName(packageName) {
150
+ return packageName.split('/').pop() ?? packageName;
151
+ }
152
+ function uniqueScenarios(scenarios) {
153
+ const seen = new Set();
154
+ return scenarios.filter((scenario) => {
155
+ const key = `${scenario.kind}:${scenario.name}:${scenario.sourcePath}:${scenario.symbolName}`;
156
+ if (seen.has(key))
157
+ return false;
158
+ seen.add(key);
159
+ return true;
160
+ });
161
+ }
162
+ function uniqueByFunctionName(declarations) {
163
+ const seen = new Set();
164
+ return declarations.filter((declaration) => {
165
+ const name = declaration.getName();
166
+ if (name === undefined || seen.has(name))
167
+ return false;
168
+ seen.add(name);
169
+ return true;
170
+ });
171
+ }
172
+ function uniqueSorted(values) {
173
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
174
+ }
@@ -87,6 +87,14 @@ export interface AnalysisModule {
87
87
  dependencies: string[];
88
88
  exports: string[];
89
89
  }
90
+ export interface AnalysisSequenceScenario {
91
+ kind: 'bin' | 'export';
92
+ name: string;
93
+ sourcePath: string;
94
+ symbolName: string;
95
+ description: string | null;
96
+ isReadme: boolean;
97
+ }
90
98
  interface AnalysisTypeMember {
91
99
  name: string;
92
100
  type: string;
@@ -136,6 +144,7 @@ export interface AnalysisResult {
136
144
  entrypoints: string[];
137
145
  modules: AnalysisModule[];
138
146
  badges: AnalysisBadge[];
147
+ sequenceScenarios: AnalysisSequenceScenario[];
139
148
  usage: AnalysisUsage | null;
140
149
  config: {
141
150
  exportName: string;
package/dist/cli.js CHANGED
@@ -5,6 +5,15 @@ import { buildModel } from './model/buildModel.js';
5
5
  import { findParadoxConfigFile, loadParadoxConfig, resolveOutputRoot, resolvePackageRoot, } from './paths/policy.js';
6
6
  import { render } from './render/render.js';
7
7
  import { write } from './write/write.js';
8
+ /***
9
+ * Runs the Paradox CLI.
10
+ *
11
+ * The command discovers the nearest Paradox config, resolves the package and output roots,
12
+ * analyzes the package, builds the documentation model, renders all documentation artifacts,
13
+ * and writes them to the configured output directory.
14
+ *
15
+ * @readme
16
+ */
8
17
  async function main() {
9
18
  const cwd = process.cwd();
10
19
  const configFilePath = await findParadoxConfigFile(cwd);
@@ -80,6 +80,14 @@ interface BuildModelInput {
80
80
  description: string | null;
81
81
  }[];
82
82
  }[];
83
+ sequenceScenarios: {
84
+ kind: 'bin' | 'export';
85
+ name: string;
86
+ sourcePath: string;
87
+ symbolName: string;
88
+ description: string | null;
89
+ isReadme: boolean;
90
+ }[];
83
91
  usage: {
84
92
  packageName: string;
85
93
  commands: {
@@ -46,6 +46,14 @@ export function buildModel(analysis) {
46
46
  .sort((left, right) => left.path.localeCompare(right.path)),
47
47
  exports,
48
48
  components: sortByName(analysis.components.map((component) => mapComponent(component, exportsByName.get(component.name)))),
49
+ sequenceScenarios: sortByName(analysis.sequenceScenarios.map((scenario) => ({
50
+ kind: scenario.kind,
51
+ name: scenario.name,
52
+ sourcePath: scenario.sourcePath,
53
+ symbolName: scenario.symbolName,
54
+ description: scenario.description,
55
+ isReadme: scenario.isReadme,
56
+ }))),
49
57
  graphs: {
50
58
  imports: [...analysis.graphs.imports],
51
59
  calls: [...analysis.graphs.calls],
@@ -12,6 +12,7 @@ export interface DocumentationModel {
12
12
  modules: ModuleModel[];
13
13
  exports: ExportModel[];
14
14
  components: ComponentModel[];
15
+ sequenceScenarios: SequenceScenarioModel[];
15
16
  graphs: GraphModel;
16
17
  }
17
18
  export interface GeneratedBadge {
@@ -59,6 +60,14 @@ export interface ComponentModel {
59
60
  exportPaths: string[];
60
61
  props: PropModel[];
61
62
  }
63
+ export interface SequenceScenarioModel {
64
+ kind: 'bin' | 'export';
65
+ name: string;
66
+ sourcePath: string;
67
+ symbolName: string;
68
+ description: string | null;
69
+ isReadme: boolean;
70
+ }
62
71
  interface ExampleModel {
63
72
  title: string | null;
64
73
  language: string | null;
@@ -1,3 +1,6 @@
1
+ const MAX_SEQUENCE_CALL_EDGES = 12;
2
+ const MAX_SEQUENCE_PARTICIPANTS = 8;
3
+ const MAX_BIN_SEQUENCE_PARTICIPANTS = 12;
1
4
  /***
2
5
  * Generates deterministic Mermaid diagrams for the documentation app.
3
6
  */
@@ -18,11 +21,7 @@ export function renderDiagramArtifacts(model) {
18
21
  title: 'Export graph',
19
22
  content: renderExportGraph(model),
20
23
  },
21
- {
22
- path: 'diagrams/entrypoint-sequence.mmd',
23
- title: 'Entrypoint sequence',
24
- content: renderEntrypointSequence(model),
25
- },
24
+ ...renderSequenceArtifacts(model),
26
25
  ];
27
26
  }
28
27
  function renderArchitectureOverview(model) {
@@ -71,35 +70,117 @@ function renderExportGraph(model) {
71
70
  }
72
71
  return `${lines.join('\n')}\n`;
73
72
  }
74
- function renderEntrypointSequence(model) {
73
+ function renderSequenceArtifacts(model) {
74
+ return model.sequenceScenarios.flatMap((scenario) => {
75
+ const content = renderSequenceScenario(model, scenario);
76
+ if (content === null)
77
+ return [];
78
+ return [
79
+ {
80
+ path: `diagrams/sequences/${toFileStem(scenario.name)}.mmd`,
81
+ title: `${scenario.name} sequence`,
82
+ content,
83
+ },
84
+ ];
85
+ });
86
+ }
87
+ function renderSequenceScenario(model, scenario) {
88
+ if (scenario.kind === 'bin') {
89
+ return renderBinSequenceScenario(model, scenario);
90
+ }
91
+ return renderNestedSequenceScenario(model, scenario);
92
+ }
93
+ function renderBinSequenceScenario(model, scenario) {
94
+ const callEdges = collectDirectCallEdges(model.graphs.calls, scenario.symbolName);
95
+ const participants = collectSequenceParticipants(callEdges);
96
+ if (callEdges.length === 0)
97
+ return null;
98
+ if (participants.length > MAX_BIN_SEQUENCE_PARTICIPANTS)
99
+ return null;
75
100
  const lines = ['sequenceDiagram'];
76
- const callEdges = model.graphs.calls;
77
- if (callEdges.length > 0) {
78
- const participants = new Map();
79
- for (const edge of callEdges) {
80
- participants.set(edge.fromSymbol, `participant ${toMermaidId(`participant-${edge.fromSymbol}`)} as ${edge.fromSymbol}`);
81
- participants.set(edge.toSymbol, `participant ${toMermaidId(`participant-${edge.toSymbol}`)} as ${edge.toSymbol}`);
82
- }
83
- lines.push(...participants.values());
84
- lines.push(...callEdges.map((edge) => ` ${toMermaidId(`participant-${edge.fromSymbol}`)}->>${toMermaidId(`participant-${edge.toSymbol}`)}: ${edge.callExpression}`));
85
- return `${lines.join('\n')}\n`;
101
+ for (const participant of participants) {
102
+ lines.push(` participant ${toMermaidId(`participant-${participant}`)} as ${escapeLabel(participant)}`);
86
103
  }
87
- const participants = new Map();
88
- for (const module of model.modules) {
89
- participants.set(module.path, `participant ${toMermaidId(`participant-${module.path}`)} as ${module.path}`);
104
+ for (const edge of callEdges) {
105
+ const fromId = toMermaidId(`participant-${edge.fromSymbol}`);
106
+ const toId = toMermaidId(`participant-${edge.toSymbol}`);
107
+ lines.push(` ${fromId}->>${toId}: ${formatCallLabel(edge.callExpression)}`);
108
+ lines.push(` ${toId}-->>${fromId}: return`);
90
109
  }
91
- lines.push(...participants.values());
92
- const interactions = model.modules.flatMap((module) => module.dependencies.map((dependency) => ` ${toMermaidId(`participant-${module.path}`)}->>${toMermaidId(`participant-${dependency}`)}: imports`));
93
- if (interactions.length === 0) {
94
- const packageId = toMermaidId(`participant-${model.packageId}`);
95
- lines.push(` participant ${packageId} as ${model.packageName}`);
96
- lines.push(` Note over ${packageId}: No internal module relationships detected.`);
110
+ return `${lines.join('\n')}\n`;
111
+ }
112
+ function renderNestedSequenceScenario(model, scenario) {
113
+ const lines = ['sequenceDiagram'];
114
+ const reachableEdges = collectReachableCallEdges(model.graphs.calls, scenario.symbolName);
115
+ const participants = collectSequenceParticipants(reachableEdges);
116
+ if (reachableEdges.length === 0)
117
+ return null;
118
+ if (reachableEdges.length > MAX_SEQUENCE_CALL_EDGES ||
119
+ participants.length > MAX_SEQUENCE_PARTICIPANTS) {
120
+ return null;
97
121
  }
98
- else {
99
- lines.push(...interactions);
122
+ for (const participant of participants) {
123
+ lines.push(` participant ${toMermaidId(`participant-${participant}`)} as ${escapeLabel(participant)}`);
100
124
  }
125
+ renderCallFlow(lines, reachableEdges, scenario.symbolName);
101
126
  return `${lines.join('\n')}\n`;
102
127
  }
128
+ function collectDirectCallEdges(callEdges, root) {
129
+ return callEdges.filter((edge) => edge.fromSymbol === root);
130
+ }
131
+ function collectReachableCallEdges(callEdges, root) {
132
+ const outgoing = groupCallsBySource(callEdges);
133
+ const visited = new Set();
134
+ const ordered = [];
135
+ visitCallEdges(root, outgoing, visited, ordered);
136
+ return ordered;
137
+ }
138
+ function visitCallEdges(fromSymbol, outgoing, visited, ordered) {
139
+ for (const edge of outgoing.get(fromSymbol) ?? []) {
140
+ const key = getCallEdgeKey(edge);
141
+ if (visited.has(key))
142
+ continue;
143
+ visited.add(key);
144
+ ordered.push(edge);
145
+ visitCallEdges(edge.toSymbol, outgoing, visited, ordered);
146
+ }
147
+ }
148
+ function renderCallFlow(lines, callEdges, root) {
149
+ const outgoing = groupCallsBySource(callEdges);
150
+ const visited = new Set();
151
+ renderNestedCalls(lines, root, outgoing, visited);
152
+ }
153
+ function renderNestedCalls(lines, fromSymbol, outgoing, visited) {
154
+ for (const edge of outgoing.get(fromSymbol) ?? []) {
155
+ const key = getCallEdgeKey(edge);
156
+ if (visited.has(key))
157
+ continue;
158
+ visited.add(key);
159
+ const fromId = toMermaidId(`participant-${edge.fromSymbol}`);
160
+ const toId = toMermaidId(`participant-${edge.toSymbol}`);
161
+ lines.push(` ${fromId}->>${toId}: ${formatCallLabel(edge.callExpression)}`);
162
+ renderNestedCalls(lines, edge.toSymbol, outgoing, visited);
163
+ lines.push(` ${toId}-->>${fromId}: return`);
164
+ }
165
+ }
166
+ function groupCallsBySource(callEdges) {
167
+ const grouped = new Map();
168
+ for (const edge of callEdges) {
169
+ const existing = grouped.get(edge.fromSymbol) ?? [];
170
+ existing.push(edge);
171
+ grouped.set(edge.fromSymbol, existing);
172
+ }
173
+ return grouped;
174
+ }
175
+ function collectSequenceParticipants(callEdges) {
176
+ return uniqueSorted(callEdges.flatMap((edge) => [edge.fromSymbol, edge.toSymbol]));
177
+ }
178
+ function getCallEdgeKey(edge) {
179
+ return `${edge.fromSymbol}->${edge.toSymbol}@${edge.sourcePath}:${edge.callExpression}`;
180
+ }
181
+ function formatCallLabel(callExpression) {
182
+ return callExpression.endsWith(')') ? callExpression : `${callExpression}()`;
183
+ }
103
184
  function renderFallbackEdge(modules, prefix) {
104
185
  if (modules.length === 0) {
105
186
  return [' empty["No modules analyzed"]'];
@@ -109,6 +190,16 @@ function renderFallbackEdge(modules, prefix) {
109
190
  return ` ${toMermaidId(`${prefix}-${previous.path}`)} -.-> ${toMermaidId(`${prefix}-${module.path}`)}`;
110
191
  });
111
192
  }
193
+ function uniqueSorted(values) {
194
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
195
+ }
196
+ function toFileStem(value) {
197
+ return value
198
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
199
+ .replace(/[^A-Za-z0-9]+/g, '-')
200
+ .replace(/^-+|-+$/g, '')
201
+ .toLowerCase();
202
+ }
112
203
  function toMermaidId(value) {
113
204
  return value.replace(/[^A-Za-z0-9_]/g, '_');
114
205
  }
@@ -32,6 +32,7 @@ function renderReadme(model, outputDir, badges, diagrams) {
32
32
  }
33
33
  lines.push('```', '');
34
34
  }
35
+ renderCliScenarios(lines, model, outputDir, diagrams);
35
36
  renderDocumentationTags(lines);
36
37
  if (model.config?.isReadme) {
37
38
  renderConfiguration(lines, model);
@@ -42,6 +43,37 @@ function renderReadme(model, outputDir, badges, diagrams) {
42
43
  renderReadmeApi(lines, model);
43
44
  return `${lines.join('\n').trimEnd()}\n`;
44
45
  }
46
+ function renderCliScenarios(lines, model, outputDir, diagrams) {
47
+ const scenarios = model.sequenceScenarios.filter((scenario) => scenario.kind === 'bin' && scenario.isReadme);
48
+ if (scenarios.length === 0)
49
+ return;
50
+ lines.push('## CLI', '');
51
+ for (const scenario of scenarios) {
52
+ lines.push(`### ${scenario.name}`, '');
53
+ if (scenario.description !== null) {
54
+ lines.push(scenario.description, '');
55
+ }
56
+ const command = model.usage?.commands.find((item) => item.name === scenario.name);
57
+ if (command !== undefined) {
58
+ lines.push('```bash');
59
+ lines.push(command.command);
60
+ lines.push('```', '');
61
+ }
62
+ const diagram = findScenarioDiagram(diagrams, scenario);
63
+ if (diagram !== undefined) {
64
+ lines.push('<details>');
65
+ lines.push(`<summary>${scenario.name} sequence</summary>`, '');
66
+ lines.push(`Diagram: [${diagram.title}](./${outputDir}/${diagram.path})`, '');
67
+ lines.push('```mermaid');
68
+ lines.push(diagram.content.trimEnd());
69
+ lines.push('```', '');
70
+ lines.push('</details>', '');
71
+ }
72
+ }
73
+ }
74
+ function findScenarioDiagram(diagrams, scenario) {
75
+ return diagrams.find((diagram) => diagram.path === `diagrams/sequences/${toFileStem(scenario.name)}.mmd`);
76
+ }
45
77
  function renderDocumentationTags(lines) {
46
78
  lines.push('## Documentation Tags', '');
47
79
  for (const tag of DOCUMENTATION_TAGS) {
@@ -325,6 +357,13 @@ function badgeLabel(model, badgePath) {
325
357
  const badge = model.badges.find((entry) => entry.id === id);
326
358
  return badge ? `${badge.label}: ${badge.value}` : badgePath;
327
359
  }
360
+ function toFileStem(value) {
361
+ return value
362
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
363
+ .replace(/[^A-Za-z0-9]+/g, '-')
364
+ .replace(/^-+|-+$/g, '')
365
+ .toLowerCase();
366
+ }
328
367
  const CATEGORY_ORDER = [
329
368
  'Configuration',
330
369
  'Primitives',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/paradox",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Deterministic documentation generator for TypeScript packages.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {