@ankhorage/paradox 0.1.26 → 0.2.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.
- package/CHANGELOG.md +14 -0
- package/README.md +53 -61
- package/dist/analyze/analyze.d.ts +2 -2
- package/dist/analyze/analyze.js +51 -35
- package/dist/analyze/badges.d.ts +2 -1
- package/dist/analyze/badges.js +14 -4
- package/dist/analyze/components.js +12 -11
- package/dist/analyze/documentation/collectDocumentationCommentsAsync.d.ts +11 -0
- package/dist/analyze/documentation/collectDocumentationCommentsAsync.js +72 -0
- package/dist/analyze/documentation/findings.d.ts +5 -0
- package/dist/analyze/documentation/findings.js +17 -0
- package/dist/analyze/documentation/validateDocumentationPolicyAsync.d.ts +13 -0
- package/dist/analyze/documentation/validateDocumentationPolicyAsync.js +150 -0
- package/dist/analyze/documentation/validateReferencesAsync.d.ts +11 -0
- package/dist/analyze/documentation/validateReferencesAsync.js +148 -0
- package/dist/analyze/exports.d.ts +4 -0
- package/dist/analyze/exports.js +14 -7
- package/dist/analyze/readmeConfig.d.ts +1 -3
- package/dist/analyze/readmeConfig.js +8 -19
- package/dist/analyze/readmeUsage.d.ts +7 -10
- package/dist/analyze/readmeUsage.js +124 -48
- package/dist/analyze/semantic/docBlocks.js +18 -48
- package/dist/analyze/semantic/exports.js +1 -3
- package/dist/analyze/semantic/model.d.ts +0 -2
- package/dist/analyze/semantic/paradoxComment.d.ts +1 -11
- package/dist/analyze/semantic/paradoxComment.js +1 -43
- package/dist/analyze/semantic/tagRegistry.js +2 -1
- package/dist/analyze/sourceFunctions.js +4 -4
- package/dist/analyze/types.d.ts +31 -24
- package/dist/analyze/usage.d.ts +2 -2
- package/dist/analyze/usage.js +3 -33
- package/dist/analyze/utils/getExportMetadata.js +11 -40
- package/dist/analyze/utils/parseParadoxComment.d.ts +12 -9
- package/dist/analyze/utils/parseParadoxComment.js +66 -78
- package/dist/cli/index.d.ts +3 -2
- package/dist/cli/index.js +3 -2
- package/dist/cli/standalone.js +11 -0
- package/dist/config/defineParadoxConfig.d.ts +1 -1
- package/dist/doc-tags/registry.d.ts +28 -32
- package/dist/doc-tags/registry.js +35 -39
- package/dist/index.d.ts +1 -1
- package/dist/model/buildModel.d.ts +26 -19
- package/dist/model/buildModel.js +33 -99
- package/dist/model/types.d.ts +27 -20
- package/dist/paths/policy.d.ts +1 -1
- package/dist/render/renderers/diagrams.js +1 -7
- package/dist/render/renderers/html.js +93 -70
- package/dist/render/renderers/markdown.js +139 -86
- package/dist/render/toFileStem.d.ts +2 -0
- package/dist/render/toFileStem.js +8 -0
- package/dist/{config/types.d.ts → types/config.d.ts} +3 -5
- package/dist/write/write.d.ts +1 -1
- package/package.json +9 -5
- package/dist/analyze/readmeCli.d.ts +0 -9
- package/dist/analyze/readmeCli.js +0 -33
- package/dist/analyze/utils/getLeadingParadoxComment.d.ts +0 -10
- package/dist/analyze/utils/getLeadingParadoxComment.js +0 -16
- /package/dist/{config/types.js → types/config.js} +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DOCUMENTATION_POLICY } from '@ankhorage/policy/documentation';
|
|
4
|
+
import { Node } from 'ts-morph';
|
|
5
|
+
import { getParadoxComment } from '../utils/getParadoxComment.js';
|
|
6
|
+
import { parseParadoxComment } from '../utils/parseParadoxComment.js';
|
|
7
|
+
import { createDocumentationFinding } from './findings.js';
|
|
8
|
+
import { validateReferencesAsync } from './validateReferencesAsync.js';
|
|
9
|
+
/***
|
|
10
|
+
* Evaluates package documentation evidence against the canonical Ankhorage documentation policy.
|
|
11
|
+
*/
|
|
12
|
+
export async function validateDocumentationPolicyAsync(options) {
|
|
13
|
+
return [
|
|
14
|
+
...validateCommentRules(options.comments),
|
|
15
|
+
...validateUsageRules(options.comments),
|
|
16
|
+
...(await validateConfigRulesAsync(options.root, options.project)),
|
|
17
|
+
...validatePublicApiRules(options.exports),
|
|
18
|
+
...(await validateReferencesAsync(options.root, options.project, options.comments, {
|
|
19
|
+
validateSeeUrlAsync: options.validateSeeUrlAsync,
|
|
20
|
+
})),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
/***
|
|
24
|
+
* Validates generic comment syntax rules shared by all documentation contexts.
|
|
25
|
+
*/
|
|
26
|
+
function validateCommentRules(comments) {
|
|
27
|
+
return comments.flatMap((comment) => {
|
|
28
|
+
const unsupported = comment.parsed.unsupportedTags.map((tag) => finding('documentation.comment.tag.unsupported', `Unsupported Paradox tag: @${tag}`, comment));
|
|
29
|
+
const code = comment.parsed.hasCodeBlock
|
|
30
|
+
? [
|
|
31
|
+
finding('documentation.comment.code-block', 'Paradox comments must not contain code blocks.', comment),
|
|
32
|
+
]
|
|
33
|
+
: [];
|
|
34
|
+
const configLocation = comment.parsed.isConfig && comment.sourcePath !== DOCUMENTATION_POLICY.config.path
|
|
35
|
+
? [
|
|
36
|
+
finding('documentation.config.location', `@config is allowed only in ${DOCUMENTATION_POLICY.config.path}.`, comment),
|
|
37
|
+
]
|
|
38
|
+
: [];
|
|
39
|
+
return [...unsupported, ...code, ...configLocation];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/***
|
|
43
|
+
* Validates canonical usage locations and the single README-promoted programmatic example.
|
|
44
|
+
*/
|
|
45
|
+
function validateUsageRules(comments) {
|
|
46
|
+
const usageComments = comments.filter((comment) => comment.parsed.isUsage);
|
|
47
|
+
const findings = usageComments.flatMap((comment) => validateUsageComment(comment));
|
|
48
|
+
const readmeExamples = usageComments.filter((comment) => isBelow(comment.sourcePath, DOCUMENTATION_POLICY.paths.examplesRoot) &&
|
|
49
|
+
comment.parsed.isReadme);
|
|
50
|
+
if (readmeExamples.length !== DOCUMENTATION_POLICY.readmeUsage.exactCount) {
|
|
51
|
+
findings.push(createDocumentationFinding('documentation.usage.readme.unique', `Expected exactly one @usage + @readme example; found ${readmeExamples.length}.`));
|
|
52
|
+
}
|
|
53
|
+
for (const comment of readmeExamples) {
|
|
54
|
+
if (!hasExactlyOneRequiredTag(comment, DOCUMENTATION_POLICY.readmeUsage.requiredTags) ||
|
|
55
|
+
comment.parsed.description === null) {
|
|
56
|
+
findings.push(finding('documentation.usage.readme.metadata', 'README usage requires non-empty @title and prose.', comment));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
61
|
+
/***
|
|
62
|
+
* Validates one usage comment location and forbidden CLI README promotion.
|
|
63
|
+
*/
|
|
64
|
+
function validateUsageComment(comment) {
|
|
65
|
+
const allowed = DOCUMENTATION_POLICY.paths.usageRoots.some((root) => isBelow(comment.sourcePath, root));
|
|
66
|
+
const location = allowed
|
|
67
|
+
? []
|
|
68
|
+
: [
|
|
69
|
+
finding('documentation.usage.location', '@usage is allowed only below examples/** or src/cli/**.', comment),
|
|
70
|
+
];
|
|
71
|
+
const cliReadme = isBelow(comment.sourcePath, DOCUMENTATION_POLICY.paths.cliRoot) && comment.parsed.isReadme
|
|
72
|
+
? [
|
|
73
|
+
finding('documentation.usage.readme.cli', '@usage + @readme is not allowed below src/cli/**.', comment),
|
|
74
|
+
]
|
|
75
|
+
: [];
|
|
76
|
+
return [...location, ...cliReadme];
|
|
77
|
+
}
|
|
78
|
+
/***
|
|
79
|
+
* Validates the canonical configuration file and its one README configuration root.
|
|
80
|
+
*/
|
|
81
|
+
async function validateConfigRulesAsync(root, project) {
|
|
82
|
+
const configPath = join(root, DOCUMENTATION_POLICY.config.path);
|
|
83
|
+
if (!(await fileExistsAsync(configPath))) {
|
|
84
|
+
return [
|
|
85
|
+
createDocumentationFinding('documentation.config.file', `Missing canonical config schema: ${DOCUMENTATION_POLICY.config.path}`),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
const sourceFile = project.getSourceFile(configPath) ?? project.addSourceFileAtPath(configPath);
|
|
89
|
+
const roots = sourceFile.getStatements().flatMap((statement) => {
|
|
90
|
+
if (!Node.isInterfaceDeclaration(statement) && !Node.isTypeAliasDeclaration(statement)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const raw = getParadoxComment(statement);
|
|
94
|
+
if (raw === null)
|
|
95
|
+
return [];
|
|
96
|
+
const parsed = parseParadoxComment(raw);
|
|
97
|
+
return parsed.isConfig && parsed.isReadme ? [{ statement, parsed }] : [];
|
|
98
|
+
});
|
|
99
|
+
const findings = [];
|
|
100
|
+
if (roots.length !== DOCUMENTATION_POLICY.config.exactCount) {
|
|
101
|
+
findings.push(createDocumentationFinding('documentation.config.readme.unique', `Expected exactly one @config + @readme root in ${DOCUMENTATION_POLICY.config.path}; found ${roots.length}.`, DOCUMENTATION_POLICY.config.path));
|
|
102
|
+
}
|
|
103
|
+
for (const rootEntry of roots) {
|
|
104
|
+
if (!hasExactlyOneRequiredTag(rootEntry, DOCUMENTATION_POLICY.config.requiredTags) ||
|
|
105
|
+
rootEntry.parsed.description === null) {
|
|
106
|
+
findings.push(createDocumentationFinding('documentation.config.readme.metadata', 'README configuration requires non-empty @title and prose.', DOCUMENTATION_POLICY.config.path, rootEntry.statement.getStartLineNumber()));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return findings;
|
|
110
|
+
}
|
|
111
|
+
/***
|
|
112
|
+
* Warns when a public callable export has no Paradox description.
|
|
113
|
+
*/
|
|
114
|
+
function validatePublicApiRules(exports) {
|
|
115
|
+
return exports.flatMap((entry) => entry.signatures.length > 0 && entry.description === null
|
|
116
|
+
? [
|
|
117
|
+
createDocumentationFinding('documentation.public-function.description', `Public function ${entry.name} has no Paradox description.`, entry.sourceLocation.filePath, entry.sourceLocation.line),
|
|
118
|
+
]
|
|
119
|
+
: []);
|
|
120
|
+
}
|
|
121
|
+
/***
|
|
122
|
+
* Checks that each policy-required tag appears exactly once on one parsed documentation item.
|
|
123
|
+
*/
|
|
124
|
+
function hasExactlyOneRequiredTag(entry, requiredTags) {
|
|
125
|
+
return requiredTags.every((name) => entry.parsed.tags.filter((tag) => tag.name === name).length === 1);
|
|
126
|
+
}
|
|
127
|
+
/***
|
|
128
|
+
* Creates a finding at one collected comment location.
|
|
129
|
+
*/
|
|
130
|
+
function finding(ruleId, message, comment) {
|
|
131
|
+
return createDocumentationFinding(ruleId, message, comment.sourcePath, comment.line);
|
|
132
|
+
}
|
|
133
|
+
/***
|
|
134
|
+
* Checks whether a source path is contained in one canonical root.
|
|
135
|
+
*/
|
|
136
|
+
function isBelow(sourcePath, root) {
|
|
137
|
+
return sourcePath === root || sourcePath.startsWith(`${root}/`);
|
|
138
|
+
}
|
|
139
|
+
/***
|
|
140
|
+
* Checks whether a required canonical file exists.
|
|
141
|
+
*/
|
|
142
|
+
async function fileExistsAsync(path) {
|
|
143
|
+
try {
|
|
144
|
+
await access(path);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Project } from 'ts-morph';
|
|
2
|
+
import type { AnalysisDocumentationFinding } from '../types.js';
|
|
3
|
+
import type { CollectedDocumentationComment } from './collectDocumentationCommentsAsync.js';
|
|
4
|
+
type SeeUrlValidator = (url: string) => Promise<unknown>;
|
|
5
|
+
/***
|
|
6
|
+
* Validates external and executable references carried by documentation comments.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateReferencesAsync(root: string, project: Project, comments: readonly CollectedDocumentationComment[], options?: {
|
|
9
|
+
validateSeeUrlAsync?: SeeUrlValidator;
|
|
10
|
+
}): Promise<AnalysisDocumentationFinding[]>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { DOCUMENTATION_POLICY } from '@ankhorage/policy/documentation';
|
|
4
|
+
import { validatePublicHttpsUrlAsync } from '@ankhorage/utility/node/http';
|
|
5
|
+
import { Node } from 'ts-morph';
|
|
6
|
+
import { createDocumentationFinding } from './findings.js';
|
|
7
|
+
/***
|
|
8
|
+
* Validates external and executable references carried by documentation comments.
|
|
9
|
+
*/
|
|
10
|
+
export async function validateReferencesAsync(root, project, comments, options = {}) {
|
|
11
|
+
const validateSeeUrlAsync = options.validateSeeUrlAsync ?? validatePublicHttpsUrlAsync;
|
|
12
|
+
const findings = await Promise.all(comments.map(async (comment) => [
|
|
13
|
+
...(await validateSeeReferencesAsync(comment, validateSeeUrlAsync)),
|
|
14
|
+
...(await validateSecurityReferencesAsync(root, project, comment)),
|
|
15
|
+
]));
|
|
16
|
+
return findings.flat();
|
|
17
|
+
}
|
|
18
|
+
/***
|
|
19
|
+
* Validates every @see value through syntax policy and the hardened public HTTPS utility.
|
|
20
|
+
*/
|
|
21
|
+
async function validateSeeReferencesAsync(comment, validateSeeUrlAsync) {
|
|
22
|
+
const seeTags = comment.parsed.tags.filter((tag) => tag.name === 'see');
|
|
23
|
+
const findings = await Promise.all(seeTags.map(async (tag) => {
|
|
24
|
+
const url = parseSeeUrl(tag.value);
|
|
25
|
+
if (url === null) {
|
|
26
|
+
return [
|
|
27
|
+
referenceFinding('documentation.see.value', '@see requires a public HTTPS URL without credentials.', comment),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
await validateSeeUrlAsync(url);
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return [
|
|
36
|
+
referenceFinding('documentation.see.reachable', `@see target is not safely reachable: ${readErrorMessage(error)}`, comment),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
}));
|
|
40
|
+
return findings.flat();
|
|
41
|
+
}
|
|
42
|
+
/***
|
|
43
|
+
* Parses one @see value according to the policy-owned URL syntax contract.
|
|
44
|
+
*/
|
|
45
|
+
function parseSeeUrl(value) {
|
|
46
|
+
if (value === null)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
if (url.protocol !== DOCUMENTATION_POLICY.see.protocol)
|
|
51
|
+
return null;
|
|
52
|
+
if (url.username || url.password || url.hostname.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/***
|
|
61
|
+
* Validates every @security reference against one exact colocated executable test.
|
|
62
|
+
*/
|
|
63
|
+
async function validateSecurityReferencesAsync(root, project, comment) {
|
|
64
|
+
const securityTags = comment.parsed.tags.filter((tag) => tag.name === 'security');
|
|
65
|
+
return (await Promise.all(securityTags.map((tag) => validateSecurityReferenceAsync(root, project, comment, tag.value)))).flat();
|
|
66
|
+
}
|
|
67
|
+
/***
|
|
68
|
+
* Resolves one colocated security test reference and requires exactly one matching test declaration.
|
|
69
|
+
*/
|
|
70
|
+
async function validateSecurityReferenceAsync(root, project, comment, value) {
|
|
71
|
+
const reference = parseSecurityReference(value);
|
|
72
|
+
if (reference === null) {
|
|
73
|
+
return [securityFinding('Invalid @security test reference.', comment)];
|
|
74
|
+
}
|
|
75
|
+
const testPath = join(root, dirname(comment.sourcePath), reference.fileName);
|
|
76
|
+
if (!(await fileExistsAsync(testPath))) {
|
|
77
|
+
return [securityFinding(`Missing colocated security test: ${reference.fileName}`, comment)];
|
|
78
|
+
}
|
|
79
|
+
const sourceFile = project.getSourceFile(testPath) ?? project.addSourceFileAtPath(testPath);
|
|
80
|
+
const matches = sourceFile
|
|
81
|
+
.getDescendants()
|
|
82
|
+
.filter((node) => isNamedTestCall(node, reference.testName));
|
|
83
|
+
return matches.length === 1
|
|
84
|
+
? []
|
|
85
|
+
: [
|
|
86
|
+
securityFinding(`Expected exactly one test named "${reference.testName}" in ${reference.fileName}; found ${matches.length}.`, comment),
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
/***
|
|
90
|
+
* Parses a same-directory test filename and exact test name from an @security value.
|
|
91
|
+
*/
|
|
92
|
+
function parseSecurityReference(value) {
|
|
93
|
+
if (value === null)
|
|
94
|
+
return null;
|
|
95
|
+
const separator = value.indexOf('#');
|
|
96
|
+
if (separator <= 0 || separator === value.length - 1)
|
|
97
|
+
return null;
|
|
98
|
+
const fileName = value.slice(0, separator).trim();
|
|
99
|
+
const testName = value.slice(separator + 1).trim();
|
|
100
|
+
if (basename(fileName) !== fileName || !/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(fileName)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return testName.length > 0 ? { fileName, testName } : null;
|
|
104
|
+
}
|
|
105
|
+
/***
|
|
106
|
+
* Checks whether a ts-morph node is test()/it() with the exact static string name.
|
|
107
|
+
*/
|
|
108
|
+
function isNamedTestCall(node, expectedName) {
|
|
109
|
+
if (!Node.isCallExpression(node))
|
|
110
|
+
return false;
|
|
111
|
+
const callee = node.getExpression().getText();
|
|
112
|
+
if (callee !== 'test' && callee !== 'it')
|
|
113
|
+
return false;
|
|
114
|
+
const [name] = node.getArguments();
|
|
115
|
+
if (!Node.isStringLiteral(name) && !Node.isNoSubstitutionTemplateLiteral(name))
|
|
116
|
+
return false;
|
|
117
|
+
return name.getLiteralText() === expectedName;
|
|
118
|
+
}
|
|
119
|
+
/***
|
|
120
|
+
* Creates the canonical security-reference finding at a comment location.
|
|
121
|
+
*/
|
|
122
|
+
function securityFinding(message, comment) {
|
|
123
|
+
return referenceFinding('documentation.security.reference', message, comment);
|
|
124
|
+
}
|
|
125
|
+
/***
|
|
126
|
+
* Creates a reference finding at the owning comment location.
|
|
127
|
+
*/
|
|
128
|
+
function referenceFinding(ruleId, message, comment) {
|
|
129
|
+
return createDocumentationFinding(ruleId, message, comment.sourcePath, comment.line);
|
|
130
|
+
}
|
|
131
|
+
/***
|
|
132
|
+
* Checks whether a referenced test file exists.
|
|
133
|
+
*/
|
|
134
|
+
async function fileExistsAsync(path) {
|
|
135
|
+
try {
|
|
136
|
+
await access(path);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/***
|
|
144
|
+
* Converts an unknown thrown value into a stable diagnostic string.
|
|
145
|
+
*/
|
|
146
|
+
function readErrorMessage(error) {
|
|
147
|
+
return error instanceof Error ? error.message : String(error);
|
|
148
|
+
}
|
package/dist/analyze/exports.js
CHANGED
|
@@ -16,16 +16,19 @@ export function analyzeExports(project, options) {
|
|
|
16
16
|
for (const symbol of exported) {
|
|
17
17
|
const resolved = resolveExportSymbol(symbol);
|
|
18
18
|
const decl = getFirstDeclaration(resolved.getDeclarations());
|
|
19
|
-
if (decl === null)
|
|
19
|
+
if (decl === null)
|
|
20
20
|
continue;
|
|
21
|
-
}
|
|
22
21
|
const rawComment = getParadoxComment(decl);
|
|
23
22
|
const parsed = rawComment ? parseParadoxComment(rawComment) : createEmptyMetadata();
|
|
24
23
|
const name = resolved.getName();
|
|
25
24
|
if (parsed.isConfig) {
|
|
26
25
|
config = {
|
|
27
26
|
exportName: name,
|
|
27
|
+
title: parsed.title,
|
|
28
|
+
description: parsed.description,
|
|
28
29
|
isReadme: parsed.isReadme,
|
|
30
|
+
see: parsed.see,
|
|
31
|
+
security: parsed.security,
|
|
29
32
|
};
|
|
30
33
|
}
|
|
31
34
|
const metadata = getExportMetadata({
|
|
@@ -39,9 +42,11 @@ export function analyzeExports(project, options) {
|
|
|
39
42
|
exportsByName.set(name, existing
|
|
40
43
|
? {
|
|
41
44
|
...existing,
|
|
45
|
+
title: existing.title ?? parsed.title,
|
|
42
46
|
description: existing.description ?? parsed.description,
|
|
43
47
|
isReadme: existing.isReadme || parsed.isReadme,
|
|
44
|
-
|
|
48
|
+
see: uniqueSorted([...existing.see, ...parsed.see]),
|
|
49
|
+
security: uniqueSorted([...existing.security, ...parsed.security]),
|
|
45
50
|
exportPaths: uniqueSorted([...existing.exportPaths, ...metadata.exportPaths]),
|
|
46
51
|
relatedSymbols: uniqueSorted([
|
|
47
52
|
...existing.relatedSymbols,
|
|
@@ -56,9 +61,11 @@ export function analyzeExports(project, options) {
|
|
|
56
61
|
: {
|
|
57
62
|
name,
|
|
58
63
|
node: decl,
|
|
64
|
+
title: parsed.title,
|
|
59
65
|
description: parsed.description,
|
|
60
66
|
isReadme: parsed.isReadme,
|
|
61
|
-
|
|
67
|
+
see: parsed.see,
|
|
68
|
+
security: parsed.security,
|
|
62
69
|
kind: inferKind(decl),
|
|
63
70
|
...metadata,
|
|
64
71
|
});
|
|
@@ -117,10 +124,10 @@ function toPosixPath(path) {
|
|
|
117
124
|
function createEmptyMetadata() {
|
|
118
125
|
return {
|
|
119
126
|
description: null,
|
|
127
|
+
title: null,
|
|
120
128
|
isConfig: false,
|
|
121
129
|
isReadme: false,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
returns: null,
|
|
130
|
+
see: [],
|
|
131
|
+
security: [],
|
|
125
132
|
};
|
|
126
133
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
export interface AnalysisReadmeConfig {
|
|
2
|
-
description: string | null;
|
|
3
2
|
language: string;
|
|
4
3
|
code: string;
|
|
5
4
|
sourcePath: string;
|
|
6
5
|
}
|
|
7
6
|
/***
|
|
8
|
-
* Collects
|
|
9
|
-
* leading Paradox comment is marked with both @config and @readme.
|
|
7
|
+
* Collects the concrete Paradox configuration instance as a README configuration example.
|
|
10
8
|
*/
|
|
11
9
|
export declare function analyzeReadmeConfig(options: {
|
|
12
10
|
root: string;
|
|
@@ -1,36 +1,22 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname, relative } from 'node:path';
|
|
3
|
-
import { getLeadingParadoxComment } from './utils/getLeadingParadoxComment.js';
|
|
4
3
|
/***
|
|
5
|
-
* Collects
|
|
6
|
-
* leading Paradox comment is marked with both @config and @readme.
|
|
4
|
+
* Collects the concrete Paradox configuration instance as a README configuration example.
|
|
7
5
|
*/
|
|
8
6
|
export async function analyzeReadmeConfig(options) {
|
|
9
7
|
if (options.configFilePath === null)
|
|
10
8
|
return null;
|
|
11
9
|
const source = await readFile(options.configFilePath, 'utf-8');
|
|
12
|
-
const comment = getLeadingParadoxComment(source);
|
|
13
|
-
if (comment === null)
|
|
14
|
-
return null;
|
|
15
|
-
if (!comment.parsed.isConfig || !comment.parsed.isReadme)
|
|
16
|
-
return null;
|
|
17
10
|
const sourcePath = toPosixPath(relative(options.root, options.configFilePath));
|
|
18
11
|
return {
|
|
19
|
-
description: comment.parsed.description,
|
|
20
12
|
language: getLanguage(sourcePath),
|
|
21
|
-
code:
|
|
13
|
+
code: source.trim(),
|
|
22
14
|
sourcePath,
|
|
23
15
|
};
|
|
24
16
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (before.length === 0)
|
|
29
|
-
return after;
|
|
30
|
-
if (after.length === 0)
|
|
31
|
-
return before;
|
|
32
|
-
return `${before}\n\n${after}`;
|
|
33
|
-
}
|
|
17
|
+
/***
|
|
18
|
+
* Returns the Markdown fence language for a configuration source path.
|
|
19
|
+
*/
|
|
34
20
|
function getLanguage(sourcePath) {
|
|
35
21
|
const extension = extname(sourcePath).toLowerCase();
|
|
36
22
|
if (extension === '.ts')
|
|
@@ -39,6 +25,9 @@ function getLanguage(sourcePath) {
|
|
|
39
25
|
return 'js';
|
|
40
26
|
return '';
|
|
41
27
|
}
|
|
28
|
+
/***
|
|
29
|
+
* Normalizes filesystem separators for stable documentation paths.
|
|
30
|
+
*/
|
|
42
31
|
function toPosixPath(path) {
|
|
43
32
|
return path.replaceAll('\\', '/');
|
|
44
33
|
}
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
title: string | null;
|
|
3
|
-
description: string | null;
|
|
4
|
-
language: string;
|
|
5
|
-
code: string;
|
|
6
|
-
sourcePath: string;
|
|
7
|
-
}
|
|
1
|
+
import type { AnalysisUsageEntry } from './types.js';
|
|
8
2
|
/***
|
|
9
|
-
* Collects
|
|
3
|
+
* Collects every canonical usage declaration from examples and CLI source roots.
|
|
10
4
|
*/
|
|
11
5
|
export declare function analyzeReadmeUsage(options: {
|
|
12
6
|
root: string;
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
}): Promise<AnalysisUsageEntry[]>;
|
|
8
|
+
/***
|
|
9
|
+
* Counts real example directories directly below the canonical examples root.
|
|
10
|
+
*/
|
|
11
|
+
export declare function countExampleDirectoriesAsync(root: string): Promise<number>;
|
|
@@ -1,60 +1,108 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { extname,
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
import { DOCUMENTATION_POLICY } from '@ankhorage/policy/documentation';
|
|
4
|
+
import { Project } from 'ts-morph';
|
|
5
|
+
import { getParadoxComment } from './utils/getParadoxComment.js';
|
|
3
6
|
import { parseParadoxComment } from './utils/parseParadoxComment.js';
|
|
4
|
-
const
|
|
7
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
5
8
|
/***
|
|
6
|
-
* Collects
|
|
9
|
+
* Collects every canonical usage declaration from examples and CLI source roots.
|
|
7
10
|
*/
|
|
8
11
|
export async function analyzeReadmeUsage(options) {
|
|
9
|
-
const
|
|
10
|
-
|
|
12
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
13
|
+
const files = (await Promise.all(DOCUMENTATION_POLICY.paths.usageRoots.map((usageRoot) => collectSourceFilesAsync(join(options.root, usageRoot)))))
|
|
14
|
+
.flat()
|
|
15
|
+
.sort((left, right) => left.localeCompare(right));
|
|
16
|
+
return files.flatMap((filePath) => analyzeUsageFile(options.root, project, filePath));
|
|
11
17
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
/***
|
|
19
|
+
* Collects supported source files recursively below one canonical usage root.
|
|
20
|
+
*/
|
|
21
|
+
async function collectSourceFilesAsync(root) {
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (isMissingPathError(error))
|
|
28
|
+
return [];
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
32
|
+
const path = join(root, entry.name);
|
|
33
|
+
if (entry.isDirectory())
|
|
34
|
+
return collectSourceFilesAsync(path);
|
|
35
|
+
return entry.isFile() && SOURCE_EXTENSIONS.has(extname(entry.name)) ? [path] : [];
|
|
36
|
+
}));
|
|
37
|
+
return nested.flat();
|
|
38
|
+
}
|
|
39
|
+
/***
|
|
40
|
+
* Extracts usage-marked top-level statements from one real source file.
|
|
41
|
+
*/
|
|
42
|
+
function analyzeUsageFile(root, project, filePath) {
|
|
43
|
+
const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath);
|
|
44
|
+
const sourcePath = toPosixPath(relative(root, filePath));
|
|
45
|
+
return sourceFile.getStatements().flatMap((statement) => {
|
|
46
|
+
const comment = getParadoxComment(statement);
|
|
47
|
+
if (comment === null)
|
|
48
|
+
return [];
|
|
49
|
+
const parsed = parseParadoxComment(comment);
|
|
50
|
+
if (!parsed.isUsage)
|
|
51
|
+
return [];
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
area: sourcePath.startsWith(`${DOCUMENTATION_POLICY.paths.examplesRoot}/`)
|
|
55
|
+
? 'examples'
|
|
56
|
+
: 'cli',
|
|
57
|
+
title: parsed.title ?? deriveUsageTitle(sourcePath),
|
|
58
|
+
description: parsed.description,
|
|
59
|
+
language: getLanguage(sourcePath),
|
|
60
|
+
code: getStatementCode(statement),
|
|
61
|
+
sourcePath,
|
|
62
|
+
isReadme: parsed.isReadme,
|
|
63
|
+
see: parsed.see,
|
|
64
|
+
security: parsed.security,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
26
67
|
});
|
|
27
68
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (!comment.includes(USAGE_TAG))
|
|
34
|
-
continue;
|
|
35
|
-
matches.push({
|
|
36
|
-
comment,
|
|
37
|
-
start: match.index,
|
|
38
|
-
end: match.index + comment.length,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
return matches;
|
|
69
|
+
/***
|
|
70
|
+
* Returns the exact source statement owned by a usage comment.
|
|
71
|
+
*/
|
|
72
|
+
function getStatementCode(statement) {
|
|
73
|
+
return statement.getText().trim();
|
|
42
74
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
return
|
|
50
|
-
|
|
75
|
+
/***
|
|
76
|
+
* Derives a deterministic title from the canonical example or CLI structure.
|
|
77
|
+
*/
|
|
78
|
+
function deriveUsageTitle(sourcePath) {
|
|
79
|
+
const parts = sourcePath.split('/');
|
|
80
|
+
if (parts[0] === DOCUMENTATION_POLICY.paths.examplesRoot) {
|
|
81
|
+
return titleCase(parts[1] ?? 'usage');
|
|
82
|
+
}
|
|
83
|
+
if (sourcePath === `${DOCUMENTATION_POLICY.paths.cliRoot}/index.ts`) {
|
|
84
|
+
return 'CLI';
|
|
85
|
+
}
|
|
86
|
+
const commandsIndex = parts.indexOf('commands');
|
|
87
|
+
const commandParts = commandsIndex === -1 ? [parts.at(-1) ?? 'cli'] : parts.slice(commandsIndex + 1);
|
|
88
|
+
return commandParts
|
|
89
|
+
.map((part) => part.replace(/\.[^.]+$/, ''))
|
|
90
|
+
.map(titleCase)
|
|
91
|
+
.join(' ');
|
|
51
92
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
93
|
+
/***
|
|
94
|
+
* Converts a kebab-case structural segment into presentation words.
|
|
95
|
+
*/
|
|
96
|
+
function titleCase(value) {
|
|
97
|
+
return value
|
|
98
|
+
.split('-')
|
|
99
|
+
.filter((word) => word.length > 0)
|
|
100
|
+
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
|
|
101
|
+
.join(' ');
|
|
57
102
|
}
|
|
103
|
+
/***
|
|
104
|
+
* Returns the Markdown fence language for a usage source path.
|
|
105
|
+
*/
|
|
58
106
|
function getLanguage(sourcePath) {
|
|
59
107
|
const extension = extname(sourcePath).toLowerCase();
|
|
60
108
|
if (extension === '.tsx')
|
|
@@ -63,10 +111,38 @@ function getLanguage(sourcePath) {
|
|
|
63
111
|
return 'ts';
|
|
64
112
|
if (extension === '.jsx')
|
|
65
113
|
return 'jsx';
|
|
66
|
-
if (extension === '.js')
|
|
114
|
+
if (extension === '.js' || extension === '.mjs' || extension === '.cjs')
|
|
67
115
|
return 'js';
|
|
68
116
|
return '';
|
|
69
117
|
}
|
|
118
|
+
/***
|
|
119
|
+
* Counts real example directories directly below the canonical examples root.
|
|
120
|
+
*/
|
|
121
|
+
export async function countExampleDirectoriesAsync(root) {
|
|
122
|
+
try {
|
|
123
|
+
const entries = await readdir(join(root, DOCUMENTATION_POLICY.paths.examplesRoot), {
|
|
124
|
+
withFileTypes: true,
|
|
125
|
+
});
|
|
126
|
+
return entries.filter((entry) => entry.isDirectory()).length;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (isMissingPathError(error))
|
|
130
|
+
return 0;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/***
|
|
135
|
+
* Checks whether a filesystem error reports a missing path.
|
|
136
|
+
*/
|
|
137
|
+
function isMissingPathError(error) {
|
|
138
|
+
return (error instanceof Error &&
|
|
139
|
+
'code' in error &&
|
|
140
|
+
typeof error.code === 'string' &&
|
|
141
|
+
error.code === 'ENOENT');
|
|
142
|
+
}
|
|
143
|
+
/***
|
|
144
|
+
* Normalizes filesystem separators for stable documentation paths.
|
|
145
|
+
*/
|
|
70
146
|
function toPosixPath(path) {
|
|
71
147
|
return path.replaceAll('\\', '/');
|
|
72
148
|
}
|