zopia 0.3.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 +354 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/bin/zopia.js +20 -0
- package/docs/01-overview.md +94 -0
- package/docs/02-targets.md +55 -0
- package/docs/03-roadmap.md +205 -0
- package/docs/04-architecture.md +345 -0
- package/docs/05-concepts.md +239 -0
- package/docs/06-conversions.md +493 -0
- package/docs/07-api-docs.md +337 -0
- package/docs/08-components.md +223 -0
- package/docs/09-configuration.md +167 -0
- package/docs/10-usage.md +208 -0
- package/docs/11-testing.md +267 -0
- package/docs/12-standards.md +242 -0
- package/docs/README.md +42 -0
- package/docs/publish-workflow.yml.example +48 -0
- package/package.json +77 -0
- package/src/api-docs-navigation.ts +353 -0
- package/src/cli-command.ts +537 -0
- package/src/cli.ts +4 -0
- package/src/config.ts +190 -0
- package/src/conversions/api-docs-facade.ts +42 -0
- package/src/conversions/api-docs-generate.ts +567 -0
- package/src/conversions/api-docs-layout.ts +39 -0
- package/src/conversions/api-docs-plan.ts +130 -0
- package/src/conversions/api-docs-presets.ts +246 -0
- package/src/conversions/json-schema-to-zod.ts +931 -0
- package/src/conversions/manifest-staleness.ts +211 -0
- package/src/conversions/manifest-to-openapi.ts +1861 -0
- package/src/conversions/manifest-writer.ts +778 -0
- package/src/conversions/openapi-contracts.ts +333 -0
- package/src/conversions/openapi-external-ref.ts +233 -0
- package/src/conversions/openapi-ir.ts +74 -0
- package/src/conversions/openapi-ref.ts +38 -0
- package/src/conversions/openapi-to-api-docs-public.ts +466 -0
- package/src/conversions/openapi-to-api-docs.ts +203 -0
- package/src/conversions/openapi.ts +80 -0
- package/src/conversions/reverse-security.ts +68 -0
- package/src/conversions/yaml.ts +876 -0
- package/src/conversions/zod-to-json-schema.ts +536 -0
- package/src/diff.ts +353 -0
- package/src/errors.ts +114 -0
- package/src/index.ts +80 -0
- package/src/validation.ts +299 -0
- package/src/warnings.ts +164 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { lstat, readFile, realpath, rmdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { asZopiaError } from '../errors';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import type { ApiDocsMode } from './api-docs-layout';
|
|
5
|
+
import {
|
|
6
|
+
validateZopiaManifest,
|
|
7
|
+
ZOPIA_MANIFEST_FILE,
|
|
8
|
+
type GeneratedZopiaManifest,
|
|
9
|
+
type ZopiaManifest,
|
|
10
|
+
} from './manifest-writer';
|
|
11
|
+
|
|
12
|
+
/** Current source and generation settings compared with an existing manifest. */
|
|
13
|
+
export interface ZopiaManifestGenerationIdentity {
|
|
14
|
+
/** Canonical SHA-256 identity of the normalized source document. */
|
|
15
|
+
sourceSha256: string;
|
|
16
|
+
/** Requested endpoint layout. */
|
|
17
|
+
mode: ApiDocsMode;
|
|
18
|
+
/** Whether component modules are requested. */
|
|
19
|
+
insertComponents: boolean;
|
|
20
|
+
/** Whether endpoint modules should import emitted components. */
|
|
21
|
+
useComponentAsReference: boolean;
|
|
22
|
+
/** Whether this generation should retain a manifest. */
|
|
23
|
+
manifest: boolean;
|
|
24
|
+
/** Whether endpoint custom companion modules were requested. */
|
|
25
|
+
custom?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stable reason why an existing generated tree is stale. */
|
|
29
|
+
export type ZopiaManifestStalenessReason =
|
|
30
|
+
| 'invalid-manifest'
|
|
31
|
+
| 'source-changed'
|
|
32
|
+
| 'mode-changed'
|
|
33
|
+
| 'component-options-changed'
|
|
34
|
+
| 'custom-companions-changed'
|
|
35
|
+
| 'generated-files-missing'
|
|
36
|
+
| 'manifest-disabled';
|
|
37
|
+
|
|
38
|
+
/** Result of inspecting the manifest already present in an output directory. */
|
|
39
|
+
export interface ZopiaManifestStaleness {
|
|
40
|
+
/** Whether no existing manifest, a matching manifest, or stale metadata was found. */
|
|
41
|
+
status: 'absent' | 'current' | 'stale';
|
|
42
|
+
/** Deterministically ordered causes of staleness. */
|
|
43
|
+
reasons: ZopiaManifestStalenessReason[];
|
|
44
|
+
/** Files safely claimed by a valid current-format manifest. */
|
|
45
|
+
ownedFiles: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isMissing = (error: unknown): boolean => Boolean(error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT');
|
|
49
|
+
const isNotEmpty = (error: unknown): boolean => Boolean(error && typeof error === 'object' && ['ENOTEMPTY', 'EEXIST'].includes(String((error as { code?: unknown }).code)));
|
|
50
|
+
|
|
51
|
+
function compareText(left: string, right: string): number {
|
|
52
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isManifestComponent = (value: unknown): value is { kind?: string; file?: unknown } => typeof value === 'object' && value !== null;
|
|
56
|
+
|
|
57
|
+
function collectOwnedFiles(manifest: GeneratedZopiaManifest): string[] {
|
|
58
|
+
const files = new Set<string>([ZOPIA_MANIFEST_FILE]);
|
|
59
|
+
for (const api of manifest.apis) files.add(api.file);
|
|
60
|
+
for (const webhook of manifest.webhooks ?? []) files.add(webhook.file);
|
|
61
|
+
for (const component of manifest.components) if (component.file !== null) files.add(component.file);
|
|
62
|
+
if (manifest.options.insertComponents) {
|
|
63
|
+
files.add('components/index.ts');
|
|
64
|
+
if (manifest.components?.some((component) => isManifestComponent(component) && component.kind === 'parameter' && typeof component.file === 'string' && component.file)) files.add('components/parameters/index.ts');
|
|
65
|
+
if (manifest.components?.some((component) => isManifestComponent(component) && component.kind === 'response' && typeof component.file === 'string' && component.file)) files.add('components/responses/index.ts');
|
|
66
|
+
}
|
|
67
|
+
return [...files].sort(compareText);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function hasMissingOwnedFiles(outputDir: string, ownedFiles: readonly string[]): Promise<boolean> {
|
|
71
|
+
const root = resolve(outputDir);
|
|
72
|
+
const rootReal = await realpath(root);
|
|
73
|
+
for (const file of ownedFiles) {
|
|
74
|
+
if (file === ZOPIA_MANIFEST_FILE) continue;
|
|
75
|
+
const candidate = resolve(root, ...file.split('/'));
|
|
76
|
+
if (!isInside(root, candidate)) return true;
|
|
77
|
+
try {
|
|
78
|
+
const actual = await realpath(candidate);
|
|
79
|
+
if (!isInside(rootReal, actual) || !(await stat(actual)).isFile()) return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isMissing(error)) return true;
|
|
82
|
+
throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: outputDir, hint: 'check output-directory permissions and symlinks' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Inspect an existing manifest without executing generated modules.
|
|
90
|
+
*
|
|
91
|
+
* @param outputDir Generated api-docs directory to inspect.
|
|
92
|
+
* @param identity Current source identity and generation settings.
|
|
93
|
+
* @returns Existing manifest state, staleness reasons, and safely owned files.
|
|
94
|
+
* @throws {@link ZopiaError} when filesystem inspection fails.
|
|
95
|
+
*/
|
|
96
|
+
export async function inspectZopiaManifestStaleness(outputDir: string, identity: ZopiaManifestGenerationIdentity): Promise<ZopiaManifestStaleness> {
|
|
97
|
+
const file = join(resolve(outputDir), ZOPIA_MANIFEST_FILE);
|
|
98
|
+
let source: string;
|
|
99
|
+
try { source = await readFile(file, 'utf8'); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (isMissing(error)) return { status: 'absent', reasons: [], ownedFiles: [] };
|
|
102
|
+
throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: outputDir, hint: 'check output-directory permissions and symlinks' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let manifest: ZopiaManifest;
|
|
106
|
+
try {
|
|
107
|
+
manifest = JSON.parse(source) as ZopiaManifest;
|
|
108
|
+
validateZopiaManifest(manifest);
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
status: 'stale',
|
|
112
|
+
reasons: ['invalid-manifest', ...(!identity.manifest ? ['manifest-disabled' as const] : [])],
|
|
113
|
+
ownedFiles: [ZOPIA_MANIFEST_FILE],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const generated = manifest as GeneratedZopiaManifest;
|
|
118
|
+
const ownedFiles = collectOwnedFiles(generated);
|
|
119
|
+
const reasons: ZopiaManifestStalenessReason[] = [];
|
|
120
|
+
if (generated.source.sha256 !== identity.sourceSha256) reasons.push('source-changed');
|
|
121
|
+
if (generated.mode !== identity.mode) reasons.push('mode-changed');
|
|
122
|
+
if (generated.options.insertComponents !== identity.insertComponents || generated.options.useComponentAsReference !== identity.useComponentAsReference) reasons.push('component-options-changed');
|
|
123
|
+
if ((generated.options.custom === true) !== (identity.custom === true)) reasons.push('custom-companions-changed');
|
|
124
|
+
if (await hasMissingOwnedFiles(outputDir, ownedFiles)) reasons.push('generated-files-missing');
|
|
125
|
+
if (!identity.manifest) reasons.push('manifest-disabled');
|
|
126
|
+
return {
|
|
127
|
+
status: reasons.length ? 'stale' : 'current',
|
|
128
|
+
reasons,
|
|
129
|
+
ownedFiles,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format one deterministic user-facing explanation for a stale manifest.
|
|
135
|
+
*
|
|
136
|
+
* @param reasons Stable staleness reason codes to explain.
|
|
137
|
+
* @returns Single-line explanation including cleanup behavior.
|
|
138
|
+
*/
|
|
139
|
+
export function formatManifestStaleness(reasons: readonly ZopiaManifestStalenessReason[]): string {
|
|
140
|
+
const labels: Record<ZopiaManifestStalenessReason, string> = {
|
|
141
|
+
'invalid-manifest': 'the existing manifest is invalid or unreadable',
|
|
142
|
+
'source-changed': 'the source document changed',
|
|
143
|
+
'mode-changed': 'the layout mode changed',
|
|
144
|
+
'component-options-changed': 'component generation options changed',
|
|
145
|
+
'custom-companions-changed': 'custom companion modules were enabled or disabled',
|
|
146
|
+
'generated-files-missing': 'manifest-owned generated files are missing or unsafe',
|
|
147
|
+
'manifest-disabled': 'manifest output was disabled',
|
|
148
|
+
};
|
|
149
|
+
const details = reasons.map((reason) => labels[reason]).join('; ');
|
|
150
|
+
const cleanup = reasons.includes('invalid-manifest')
|
|
151
|
+
? 'generation will replace or remove the manifest, but unknown obsolete files cannot be removed safely'
|
|
152
|
+
: 'manifest-owned obsolete files will be removed after generation';
|
|
153
|
+
return `the existing generated tree is stale: ${details}; ${cleanup}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isInside(root: string, candidate: string): boolean {
|
|
157
|
+
const fromRoot = relative(root, candidate);
|
|
158
|
+
return fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function removeOwnedFile(root: string, rootReal: string, file: string): Promise<boolean> {
|
|
162
|
+
const candidate = resolve(root, ...file.split('/'));
|
|
163
|
+
if (!isInside(root, candidate) || candidate === root) return false;
|
|
164
|
+
const parent = dirname(candidate);
|
|
165
|
+
let parentReal: string;
|
|
166
|
+
try { parentReal = await realpath(parent); }
|
|
167
|
+
catch (error) { if (isMissing(error)) return false; throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: root, hint: 'check output-directory permissions and symlinks' }); }
|
|
168
|
+
if (!isInside(rootReal, parentReal) && parentReal !== rootReal) return false;
|
|
169
|
+
|
|
170
|
+
let metadata;
|
|
171
|
+
try { metadata = await lstat(candidate); }
|
|
172
|
+
catch (error) { if (isMissing(error)) return false; throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: root, hint: 'check output-directory permissions and symlinks' }); }
|
|
173
|
+
if (metadata.isDirectory()) return false;
|
|
174
|
+
await rm(candidate, { force: true });
|
|
175
|
+
|
|
176
|
+
let directory = parent;
|
|
177
|
+
while (directory !== root && isInside(root, directory)) {
|
|
178
|
+
let directoryMetadata;
|
|
179
|
+
try { directoryMetadata = await lstat(directory); }
|
|
180
|
+
catch (error) { if (isMissing(error)) break; throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: root, hint: 'check output-directory permissions and symlinks' }); }
|
|
181
|
+
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) break;
|
|
182
|
+
try { await rmdir(directory); }
|
|
183
|
+
catch (error) { if (isMissing(error) || isNotEmpty(error)) break; throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: root, hint: 'check output-directory permissions and symlinks' }); }
|
|
184
|
+
directory = dirname(directory);
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Remove only obsolete files claimed by a previously validated manifest.
|
|
191
|
+
*
|
|
192
|
+
* @param outputDir Generated api-docs directory containing the owned files.
|
|
193
|
+
* @param previousOwnedFiles Portable paths claimed by the previous manifest.
|
|
194
|
+
* @param nextOwnedFiles Portable paths retained by the next generated tree.
|
|
195
|
+
* @returns Deterministically ordered paths that were safely removed.
|
|
196
|
+
* @throws {@link ZopiaError} when safe filesystem cleanup fails.
|
|
197
|
+
*/
|
|
198
|
+
export async function removeObsoleteManifestFiles(outputDir: string, previousOwnedFiles: readonly string[], nextOwnedFiles: Iterable<string>): Promise<string[]> {
|
|
199
|
+
if (previousOwnedFiles.length === 0) return [];
|
|
200
|
+
const root = resolve(outputDir);
|
|
201
|
+
let rootReal: string;
|
|
202
|
+
try { rootReal = await realpath(root); }
|
|
203
|
+
catch (error) { if (isMissing(error)) return []; throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to inspect or update the generated tree', { at: root, hint: 'check output-directory permissions and symlinks' }); }
|
|
204
|
+
const retained = new Set(nextOwnedFiles);
|
|
205
|
+
const removed: string[] = [];
|
|
206
|
+
for (const file of [...new Set(previousOwnedFiles)].sort(compareText)) {
|
|
207
|
+
if (retained.has(file)) continue;
|
|
208
|
+
if (await removeOwnedFile(root, rootReal, file)) removed.push(file);
|
|
209
|
+
}
|
|
210
|
+
return removed;
|
|
211
|
+
}
|