@ankhorage/paradox 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +23 -2
- package/dist/analyze/analyze.js +3 -0
- package/dist/analyze/sequenceScenarios.d.ts +14 -0
- package/dist/analyze/sequenceScenarios.js +174 -0
- package/dist/analyze/types.d.ts +9 -0
- package/dist/cli.js +9 -0
- package/dist/model/buildModel.d.ts +8 -0
- package/dist/model/buildModel.js +8 -0
- package/dist/model/types.d.ts +9 -0
- package/dist/render/renderers/diagrams.js +90 -28
- package/dist/render/renderers/markdown.js +39 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# @ankhorage/paradox
|
|
5
5
|
|
|
6
|
-
         
|
|
7
7
|
|
|
8
8
|
Deterministic documentation generator for TypeScript packages.
|
|
9
9
|
|
|
@@ -13,6 +13,20 @@ 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
|
+
|
|
16
30
|
## Documentation Tags
|
|
17
31
|
|
|
18
32
|
<details>
|
|
@@ -68,7 +82,6 @@ export default defineParadoxConfig({
|
|
|
68
82
|
- [Architecture overview](./paradox/diagrams/architecture-overview.mmd)
|
|
69
83
|
- [Module relationships](./paradox/diagrams/module-relationships.mmd)
|
|
70
84
|
- [Export graph](./paradox/diagrams/export-graph.mmd)
|
|
71
|
-
- [Entrypoint sequence](./paradox/diagrams/entrypoint-sequence.mmd)
|
|
72
85
|
|
|
73
86
|
## Architecture preview
|
|
74
87
|
|
|
@@ -90,6 +103,7 @@ graph TD
|
|
|
90
103
|
module_src_analyze_analyze_ts --> module_src_analyze_semantic_createTypeScriptProgram_ts
|
|
91
104
|
module_src_analyze_analyze_ts --> module_src_analyze_semantic_exports_ts
|
|
92
105
|
module_src_analyze_analyze_ts --> module_src_analyze_semantic_graphs_ts
|
|
106
|
+
module_src_analyze_analyze_ts --> module_src_analyze_sequenceScenarios_ts
|
|
93
107
|
module_src_analyze_analyze_ts --> module_src_analyze_types_ts
|
|
94
108
|
module_src_analyze_analyze_ts --> module_src_analyze_usage_ts
|
|
95
109
|
module_src_analyze_analyze_ts --> module_src_config_types_ts
|
|
@@ -165,6 +179,13 @@ graph TD
|
|
|
165
179
|
package__ankhorage_paradox -.-> module_src_analyze_semantic_tagRegistry_ts
|
|
166
180
|
module_src_analyze_semantic_utils_ts["src/analyze/semantic/utils.ts"]
|
|
167
181
|
package__ankhorage_paradox -.-> module_src_analyze_semantic_utils_ts
|
|
182
|
+
module_src_analyze_sequenceScenarios_ts["src/analyze/sequenceScenarios.ts"]
|
|
183
|
+
package__ankhorage_paradox -.-> module_src_analyze_sequenceScenarios_ts
|
|
184
|
+
module_src_analyze_sequenceScenarios_ts --> module_src_analyze_semantic_utils_ts
|
|
185
|
+
module_src_analyze_sequenceScenarios_ts --> module_src_analyze_types_ts
|
|
186
|
+
module_src_analyze_sequenceScenarios_ts --> module_src_analyze_usage_ts
|
|
187
|
+
module_src_analyze_sequenceScenarios_ts --> module_src_analyze_utils_getParadoxComment_ts
|
|
188
|
+
module_src_analyze_sequenceScenarios_ts --> module_src_analyze_utils_parseParadoxComment_ts
|
|
168
189
|
module_src_analyze_types_ts["src/analyze/types.ts"]
|
|
169
190
|
package__ankhorage_paradox -.-> module_src_analyze_types_ts
|
|
170
191
|
module_src_analyze_usage_ts["src/analyze/usage.ts"]
|
package/dist/analyze/analyze.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/analyze/types.d.ts
CHANGED
|
@@ -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: {
|
package/dist/model/buildModel.js
CHANGED
|
@@ -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],
|
package/dist/model/types.d.ts
CHANGED
|
@@ -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,5 @@
|
|
|
1
|
+
const MAX_SEQUENCE_CALL_EDGES = 12;
|
|
2
|
+
const MAX_SEQUENCE_PARTICIPANTS = 8;
|
|
1
3
|
/***
|
|
2
4
|
* Generates deterministic Mermaid diagrams for the documentation app.
|
|
3
5
|
*/
|
|
@@ -18,11 +20,7 @@ export function renderDiagramArtifacts(model) {
|
|
|
18
20
|
title: 'Export graph',
|
|
19
21
|
content: renderExportGraph(model),
|
|
20
22
|
},
|
|
21
|
-
|
|
22
|
-
path: 'diagrams/entrypoint-sequence.mmd',
|
|
23
|
-
title: 'Entrypoint sequence',
|
|
24
|
-
content: renderEntrypointSequence(model),
|
|
25
|
-
},
|
|
23
|
+
...renderSequenceArtifacts(model),
|
|
26
24
|
];
|
|
27
25
|
}
|
|
28
26
|
function renderArchitectureOverview(model) {
|
|
@@ -71,34 +69,88 @@ function renderExportGraph(model) {
|
|
|
71
69
|
}
|
|
72
70
|
return `${lines.join('\n')}\n`;
|
|
73
71
|
}
|
|
74
|
-
function
|
|
72
|
+
function renderSequenceArtifacts(model) {
|
|
73
|
+
return model.sequenceScenarios.flatMap((scenario) => {
|
|
74
|
+
const content = renderSequenceScenario(model, scenario);
|
|
75
|
+
if (content === null)
|
|
76
|
+
return [];
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
path: `diagrams/sequences/${toFileStem(scenario.name)}.mmd`,
|
|
80
|
+
title: `${scenario.name} sequence`,
|
|
81
|
+
content,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function renderSequenceScenario(model, scenario) {
|
|
75
87
|
const lines = ['sequenceDiagram'];
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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`;
|
|
88
|
+
const reachableEdges = collectReachableCallEdges(model.graphs.calls, scenario.symbolName);
|
|
89
|
+
const participants = collectSequenceParticipants(reachableEdges);
|
|
90
|
+
if (reachableEdges.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
if (reachableEdges.length > MAX_SEQUENCE_CALL_EDGES ||
|
|
93
|
+
participants.length > MAX_SEQUENCE_PARTICIPANTS) {
|
|
94
|
+
return null;
|
|
86
95
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
participants.set(module.path, `participant ${toMermaidId(`participant-${module.path}`)} as ${module.path}`);
|
|
96
|
+
for (const participant of participants) {
|
|
97
|
+
lines.push(` participant ${toMermaidId(`participant-${participant}`)} as ${escapeLabel(participant)}`);
|
|
90
98
|
}
|
|
91
|
-
lines.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
renderCallFlow(lines, reachableEdges, scenario.symbolName);
|
|
100
|
+
return `${lines.join('\n')}\n`;
|
|
101
|
+
}
|
|
102
|
+
function collectReachableCallEdges(callEdges, root) {
|
|
103
|
+
const outgoing = groupCallsBySource(callEdges);
|
|
104
|
+
const visited = new Set();
|
|
105
|
+
const ordered = [];
|
|
106
|
+
visitCallEdges(root, outgoing, visited, ordered);
|
|
107
|
+
return ordered;
|
|
108
|
+
}
|
|
109
|
+
function visitCallEdges(fromSymbol, outgoing, visited, ordered) {
|
|
110
|
+
for (const edge of outgoing.get(fromSymbol) ?? []) {
|
|
111
|
+
const key = getCallEdgeKey(edge);
|
|
112
|
+
if (visited.has(key))
|
|
113
|
+
continue;
|
|
114
|
+
visited.add(key);
|
|
115
|
+
ordered.push(edge);
|
|
116
|
+
visitCallEdges(edge.toSymbol, outgoing, visited, ordered);
|
|
97
117
|
}
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
}
|
|
119
|
+
function renderCallFlow(lines, callEdges, root) {
|
|
120
|
+
const outgoing = groupCallsBySource(callEdges);
|
|
121
|
+
const visited = new Set();
|
|
122
|
+
renderNestedCalls(lines, root, outgoing, visited);
|
|
123
|
+
}
|
|
124
|
+
function renderNestedCalls(lines, fromSymbol, outgoing, visited) {
|
|
125
|
+
for (const edge of outgoing.get(fromSymbol) ?? []) {
|
|
126
|
+
const key = getCallEdgeKey(edge);
|
|
127
|
+
if (visited.has(key))
|
|
128
|
+
continue;
|
|
129
|
+
visited.add(key);
|
|
130
|
+
const fromId = toMermaidId(`participant-${edge.fromSymbol}`);
|
|
131
|
+
const toId = toMermaidId(`participant-${edge.toSymbol}`);
|
|
132
|
+
lines.push(` ${fromId}->>${toId}: ${formatCallLabel(edge.callExpression)}`);
|
|
133
|
+
renderNestedCalls(lines, edge.toSymbol, outgoing, visited);
|
|
134
|
+
lines.push(` ${toId}-->>${fromId}: return`);
|
|
100
135
|
}
|
|
101
|
-
|
|
136
|
+
}
|
|
137
|
+
function groupCallsBySource(callEdges) {
|
|
138
|
+
const grouped = new Map();
|
|
139
|
+
for (const edge of callEdges) {
|
|
140
|
+
const existing = grouped.get(edge.fromSymbol) ?? [];
|
|
141
|
+
existing.push(edge);
|
|
142
|
+
grouped.set(edge.fromSymbol, existing);
|
|
143
|
+
}
|
|
144
|
+
return grouped;
|
|
145
|
+
}
|
|
146
|
+
function collectSequenceParticipants(callEdges) {
|
|
147
|
+
return uniqueSorted(callEdges.flatMap((edge) => [edge.fromSymbol, edge.toSymbol]));
|
|
148
|
+
}
|
|
149
|
+
function getCallEdgeKey(edge) {
|
|
150
|
+
return `${edge.fromSymbol}->${edge.toSymbol}@${edge.sourcePath}:${edge.callExpression}`;
|
|
151
|
+
}
|
|
152
|
+
function formatCallLabel(callExpression) {
|
|
153
|
+
return callExpression.endsWith(')') ? callExpression : `${callExpression}()`;
|
|
102
154
|
}
|
|
103
155
|
function renderFallbackEdge(modules, prefix) {
|
|
104
156
|
if (modules.length === 0) {
|
|
@@ -109,6 +161,16 @@ function renderFallbackEdge(modules, prefix) {
|
|
|
109
161
|
return ` ${toMermaidId(`${prefix}-${previous.path}`)} -.-> ${toMermaidId(`${prefix}-${module.path}`)}`;
|
|
110
162
|
});
|
|
111
163
|
}
|
|
164
|
+
function uniqueSorted(values) {
|
|
165
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
166
|
+
}
|
|
167
|
+
function toFileStem(value) {
|
|
168
|
+
return value
|
|
169
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
170
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
171
|
+
.replace(/^-+|-+$/g, '')
|
|
172
|
+
.toLowerCase();
|
|
173
|
+
}
|
|
112
174
|
function toMermaidId(value) {
|
|
113
175
|
return value.replace(/[^A-Za-z0-9_]/g, '_');
|
|
114
176
|
}
|
|
@@ -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',
|