@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 +12 -0
- package/README.md +63 -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 +118 -27
- package/dist/render/renderers/markdown.js +39 -0
- package/package.json +1 -1
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
|
-
         
|
|
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
|
-
- [
|
|
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"]
|
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,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
|
|
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
|
|
77
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
lines.push(
|
|
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',
|