@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +53 -61
  3. package/dist/analyze/analyze.d.ts +2 -2
  4. package/dist/analyze/analyze.js +51 -35
  5. package/dist/analyze/badges.d.ts +2 -1
  6. package/dist/analyze/badges.js +14 -4
  7. package/dist/analyze/components.js +12 -11
  8. package/dist/analyze/documentation/collectDocumentationCommentsAsync.d.ts +11 -0
  9. package/dist/analyze/documentation/collectDocumentationCommentsAsync.js +72 -0
  10. package/dist/analyze/documentation/findings.d.ts +5 -0
  11. package/dist/analyze/documentation/findings.js +17 -0
  12. package/dist/analyze/documentation/validateDocumentationPolicyAsync.d.ts +13 -0
  13. package/dist/analyze/documentation/validateDocumentationPolicyAsync.js +150 -0
  14. package/dist/analyze/documentation/validateReferencesAsync.d.ts +11 -0
  15. package/dist/analyze/documentation/validateReferencesAsync.js +148 -0
  16. package/dist/analyze/exports.d.ts +4 -0
  17. package/dist/analyze/exports.js +14 -7
  18. package/dist/analyze/readmeConfig.d.ts +1 -3
  19. package/dist/analyze/readmeConfig.js +8 -19
  20. package/dist/analyze/readmeUsage.d.ts +7 -10
  21. package/dist/analyze/readmeUsage.js +124 -48
  22. package/dist/analyze/semantic/docBlocks.js +18 -48
  23. package/dist/analyze/semantic/exports.js +1 -3
  24. package/dist/analyze/semantic/model.d.ts +0 -2
  25. package/dist/analyze/semantic/paradoxComment.d.ts +1 -11
  26. package/dist/analyze/semantic/paradoxComment.js +1 -43
  27. package/dist/analyze/semantic/tagRegistry.js +2 -1
  28. package/dist/analyze/sourceFunctions.js +4 -4
  29. package/dist/analyze/types.d.ts +31 -24
  30. package/dist/analyze/usage.d.ts +2 -2
  31. package/dist/analyze/usage.js +3 -33
  32. package/dist/analyze/utils/getExportMetadata.js +11 -40
  33. package/dist/analyze/utils/parseParadoxComment.d.ts +12 -9
  34. package/dist/analyze/utils/parseParadoxComment.js +66 -78
  35. package/dist/cli/index.d.ts +3 -2
  36. package/dist/cli/index.js +3 -2
  37. package/dist/cli/standalone.js +11 -0
  38. package/dist/config/defineParadoxConfig.d.ts +1 -1
  39. package/dist/doc-tags/registry.d.ts +28 -32
  40. package/dist/doc-tags/registry.js +35 -39
  41. package/dist/index.d.ts +1 -1
  42. package/dist/model/buildModel.d.ts +26 -19
  43. package/dist/model/buildModel.js +33 -99
  44. package/dist/model/types.d.ts +27 -20
  45. package/dist/paths/policy.d.ts +1 -1
  46. package/dist/render/renderers/diagrams.js +1 -7
  47. package/dist/render/renderers/html.js +93 -70
  48. package/dist/render/renderers/markdown.js +139 -86
  49. package/dist/render/toFileStem.d.ts +2 -0
  50. package/dist/render/toFileStem.js +8 -0
  51. package/dist/{config/types.d.ts → types/config.d.ts} +3 -5
  52. package/dist/write/write.d.ts +1 -1
  53. package/package.json +9 -5
  54. package/dist/analyze/readmeCli.d.ts +0 -9
  55. package/dist/analyze/readmeCli.js +0 -33
  56. package/dist/analyze/utils/getLeadingParadoxComment.d.ts +0 -10
  57. package/dist/analyze/utils/getLeadingParadoxComment.js +0 -16
  58. /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
+ }
@@ -4,7 +4,11 @@ interface AnalyzeExportsResult {
4
4
  exports: AnalysisExport[];
5
5
  config: {
6
6
  exportName: string;
7
+ title: string | null;
8
+ description: string | null;
7
9
  isReadme: boolean;
10
+ see: string[];
11
+ security: string[];
8
12
  } | null;
9
13
  }
10
14
  /***
@@ -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
- examples: existing.examples.length > 0 ? existing.examples : parsed.examples,
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
- examples: parsed.examples,
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
- examples: [],
123
- params: {},
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 a README configuration example from the actual Paradox config file when its
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 a README configuration example from the actual Paradox config file when its
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: removeRange(source, comment.start, comment.end).trim(),
13
+ code: source.trim(),
22
14
  sourcePath,
23
15
  };
24
16
  }
25
- function removeRange(source, start, end) {
26
- const before = source.slice(0, start).trimEnd();
27
- const after = source.slice(end).trimStart();
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
- export interface AnalysisReadmeUsage {
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 README usage examples from configured real source files.
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
- entrypoints: readonly string[];
14
- }): Promise<AnalysisReadmeUsage[]>;
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 { readFile } from 'node:fs/promises';
2
- import { extname, isAbsolute, join, relative } from 'node:path';
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 USAGE_TAG = `${String.fromCharCode(64)}usage`;
7
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
5
8
  /***
6
- * Collects README usage examples from configured real source files.
9
+ * Collects every canonical usage declaration from examples and CLI source roots.
7
10
  */
8
11
  export async function analyzeReadmeUsage(options) {
9
- const entries = await Promise.all(options.entrypoints.map(async (entrypoint) => analyzeUsageEntrypoint(options.root, entrypoint)));
10
- return entries.flat().sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
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
- async function analyzeUsageEntrypoint(root, entrypoint) {
13
- const absolutePath = isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint);
14
- const source = await readFile(absolutePath, 'utf-8');
15
- const sourcePath = toPosixPath(relative(root, absolutePath));
16
- const matches = findUsageComments(source);
17
- return matches.map((match) => {
18
- const parsed = parseParadoxComment(match.comment);
19
- return {
20
- title: getUsageTitle(parsed.description, sourcePath),
21
- description: parsed.description,
22
- language: getLanguage(sourcePath),
23
- code: removeRange(source, match.start, match.end).trim(),
24
- sourcePath,
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
- function findUsageComments(source) {
29
- const matches = [];
30
- const pattern = /\/\*\*\*[\s\S]*?\*\//g;
31
- for (const match of source.matchAll(pattern)) {
32
- const [comment] = match;
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
- function removeRange(source, start, end) {
44
- const before = source.slice(0, start).trimEnd();
45
- const after = source.slice(end).trimStart();
46
- if (before.length === 0)
47
- return after;
48
- if (after.length === 0)
49
- return before;
50
- return `${before}\n\n${after}`;
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
- function getUsageTitle(description, sourcePath) {
53
- if (description === null)
54
- return sourcePath;
55
- const [firstLine = sourcePath] = description.split('\n');
56
- return firstLine.trim() || sourcePath;
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
  }