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,299 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { asZopiaError, ZopiaError, type ZopiaErrorCode } from './errors';
|
|
5
|
+
import type { ZopiaWarningCode } from './warnings';
|
|
6
|
+
import { normalizeOpenApiDocument, type OpenApiDocument } from './conversions/openapi';
|
|
7
|
+
import { bundleExternalOpenApiRefs } from './conversions/openapi-external-ref';
|
|
8
|
+
import { readOpenApiSourceInput, validateOpenApiReferences } from './conversions/openapi-to-api-docs-public';
|
|
9
|
+
import { collectOpenApiOperations, collectOpenApiWebhookOperations } from './conversions/openapi-to-api-docs';
|
|
10
|
+
import { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles } from './conversions/api-docs-plan';
|
|
11
|
+
import { apiDocsToOpenApi } from './conversions/manifest-to-openapi';
|
|
12
|
+
import { validateZopiaManifest, ZOPIA_MANIFEST_FILE, type ZopiaManifest } from './conversions/manifest-writer';
|
|
13
|
+
|
|
14
|
+
/** Stable lint codes emitted only by the validator ({@link validateZopia}). */
|
|
15
|
+
export const ZOPIA_VALIDATION_CODES = Object.freeze([
|
|
16
|
+
'ZOPIA_VALIDATE_UNREACHABLE_COMPONENT',
|
|
17
|
+
'ZOPIA_VALIDATE_KM_API_DRIFT',
|
|
18
|
+
] as const);
|
|
19
|
+
|
|
20
|
+
/** Stable, machine-readable validator lint code. */
|
|
21
|
+
export type ZopiaValidationCode = typeof ZOPIA_VALIDATION_CODES[number];
|
|
22
|
+
|
|
23
|
+
/** One deterministic validator finding, either a hard failure or a lint observation. */
|
|
24
|
+
export interface ZopiaValidationIssue {
|
|
25
|
+
/** Whether the finding blocks validity (`error`) or is advisory (`warning`). */
|
|
26
|
+
severity: 'error' | 'warning';
|
|
27
|
+
/** Stable code: a validator lint code, or the caught engine {@link ZopiaErrorCode} / warning code. */
|
|
28
|
+
code: ZopiaValidationCode | ZopiaErrorCode | ZopiaWarningCode;
|
|
29
|
+
/** JSON Pointer or file location associated with the finding, when known. */
|
|
30
|
+
at?: string;
|
|
31
|
+
/** Human-readable finding description. */
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Deterministic validator outcome for one target. */
|
|
36
|
+
export interface ZopiaValidationResult {
|
|
37
|
+
/** Whether no error-severity issues were found. */
|
|
38
|
+
ok: boolean;
|
|
39
|
+
/** How the validator classified the target. */
|
|
40
|
+
kind: 'spec' | 'docs';
|
|
41
|
+
/** The input as the validator understood it (path, or a placeholder for inline text/objects). */
|
|
42
|
+
target: string;
|
|
43
|
+
/** Findings sorted by severity (errors first), pointer, code, and message. */
|
|
44
|
+
diagnostics: ZopiaValidationIssue[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Options for {@link validateZopia}. */
|
|
48
|
+
export interface ZopiaValidateOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Force target classification instead of auto-detecting from the input.
|
|
51
|
+
*
|
|
52
|
+
* @default 'auto'
|
|
53
|
+
*/
|
|
54
|
+
kind?: 'spec' | 'docs' | 'auto';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
58
|
+
|
|
59
|
+
function sortDiagnostics(issues: ZopiaValidationIssue[]): ZopiaValidationIssue[] {
|
|
60
|
+
return [...issues].sort((left, right) => {
|
|
61
|
+
const key = (issue: ZopiaValidationIssue): string => [issue.severity === 'error' ? '0' : '1', issue.at ?? '', issue.code, issue.message].join('\u0000');
|
|
62
|
+
const a = key(left);
|
|
63
|
+
const b = key(right);
|
|
64
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function issueError(error: unknown): ZopiaValidationIssue {
|
|
69
|
+
const typed = error instanceof ZopiaError ? error : asZopiaError(error, 'ZOPIA_SPEC_INVALID', 'validation failure');
|
|
70
|
+
return { severity: 'error', code: typed.code, ...(typed.at === undefined ? {} : { at: typed.at }), message: typed.message.slice(`${typed.code}: `.length) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One `#/components/schemas/…`/`#/definitions/…` reference plus whether it can seed reachability. */
|
|
74
|
+
interface SchemaRefOccurrence {
|
|
75
|
+
/** Referenced component name with `~`-escapes unescaped. */
|
|
76
|
+
name: string;
|
|
77
|
+
/** Whether the occurrence lives outside the schema map itself and may act as a reachability root. */
|
|
78
|
+
root: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const decodePointerSegment = (segment: string): string => segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
82
|
+
|
|
83
|
+
function walkSchemaRefs(value: unknown, inSchemaMap: boolean, inExample: boolean, stack: Set<object>, out: SchemaRefOccurrence[], pattern: RegExp, schemaKey: string): void {
|
|
84
|
+
if (!value || typeof value !== 'object' || stack.has(value as object)) return;
|
|
85
|
+
stack.add(value as object);
|
|
86
|
+
try {
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
for (const child of value) walkSchemaRefs(child, inSchemaMap, inExample, stack, out, pattern, schemaKey);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const object = value as Record<string, unknown>;
|
|
92
|
+
// Track the path (not the whole graph) so a subtree shared between an example and
|
|
93
|
+
// a schema position in an in-memory document still reports the schema occurrence.
|
|
94
|
+
if (!inExample && typeof object.$ref === 'string') {
|
|
95
|
+
const match = pattern.exec(object.$ref);
|
|
96
|
+
if (match) out.push({ name: decodePointerSegment(match[1]), root: !inSchemaMap });
|
|
97
|
+
}
|
|
98
|
+
for (const [key, child] of Object.entries(object)) {
|
|
99
|
+
const childInSchemaMap = inSchemaMap || key === schemaKey;
|
|
100
|
+
const childInExample = inExample || key === 'example' || key === 'examples';
|
|
101
|
+
walkSchemaRefs(child, childInSchemaMap, childInExample, stack, out, pattern, schemaKey);
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
stack.delete(value as object);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* List components never reachable from any operation-side `$ref` (transitively).
|
|
110
|
+
* Examples do not seed reachability; schemas reachable only through other
|
|
111
|
+
* unreachable schemas stay unreachable.
|
|
112
|
+
*/
|
|
113
|
+
function lintUnreachableComponents(document: OpenApiDocument, version: string): ZopiaValidationIssue[] {
|
|
114
|
+
const isSwagger = version === '2.0';
|
|
115
|
+
const mapPointer = isSwagger ? '/definitions' : '/components/schemas';
|
|
116
|
+
const schemas = isSwagger
|
|
117
|
+
? document.definitions
|
|
118
|
+
: isRecord(document.components) ? document.components.schemas : undefined;
|
|
119
|
+
if (!isRecord(schemas)) return [];
|
|
120
|
+
const pattern = isSwagger ? /^#\/definitions\/([^/]+)$/ : /^#\/components\/schemas\/([^/]+)$/;
|
|
121
|
+
const schemaKey = isSwagger ? 'definitions' : 'schemas';
|
|
122
|
+
const occurrences: SchemaRefOccurrence[] = [];
|
|
123
|
+
walkSchemaRefs(document, false, false, new Set(), occurrences, pattern, schemaKey);
|
|
124
|
+
const reached = new Set<string>();
|
|
125
|
+
const queue = occurrences.filter((occurrence) => occurrence.root).map((occurrence) => occurrence.name);
|
|
126
|
+
while (queue.length) {
|
|
127
|
+
const name = queue.pop()!;
|
|
128
|
+
if (reached.has(name)) continue;
|
|
129
|
+
reached.add(name);
|
|
130
|
+
const schema = schemas[name];
|
|
131
|
+
if (schema === undefined) continue;
|
|
132
|
+
const nested: SchemaRefOccurrence[] = [];
|
|
133
|
+
walkSchemaRefs(schema, true, false, new Set(), nested, pattern, schemaKey);
|
|
134
|
+
for (const occurrence of nested) queue.push(occurrence.name);
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(schemas)
|
|
137
|
+
.filter((name) => !reached.has(name) && name !== '')
|
|
138
|
+
.map((name) => ({
|
|
139
|
+
severity: 'warning' as const,
|
|
140
|
+
code: 'ZOPIA_VALIDATE_UNREACHABLE_COMPONENT' as const,
|
|
141
|
+
at: `#${mapPointer}/${name.replace(/~/g, '~0').replace(/\//g, '~1')}`,
|
|
142
|
+
message: `component is never referenced: ${name}`,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const KMAPI_DRIFT = 'ZOPIA_VALIDATE_KM_API_DRIFT' as const;
|
|
147
|
+
|
|
148
|
+
/** Read the km-api peer range zopia was built with. */
|
|
149
|
+
function zopiaKmApiPeerRange(): string | undefined {
|
|
150
|
+
try {
|
|
151
|
+
const text = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
152
|
+
const peer = (JSON.parse(text) as { peerDependencies?: Record<string, unknown> }).peerDependencies?.['km-api'];
|
|
153
|
+
return typeof peer === 'string' ? peer : undefined;
|
|
154
|
+
} catch { return undefined; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Resolve the installed km-api version visible from the validated tree (no exports-map dependence). */
|
|
158
|
+
function installedKmApiVersion(treeRoot: string): string | undefined {
|
|
159
|
+
let directory = resolve(treeRoot);
|
|
160
|
+
for (;;) {
|
|
161
|
+
try {
|
|
162
|
+
const candidate = join(directory, 'node_modules', 'km-api', 'package.json');
|
|
163
|
+
const version = (JSON.parse(readFileSync(candidate, 'utf8')) as { version?: unknown }).version;
|
|
164
|
+
if (typeof version === 'string') return version;
|
|
165
|
+
} catch { /* keep walking upward */ }
|
|
166
|
+
const parent = dirname(directory);
|
|
167
|
+
if (parent === directory) return undefined;
|
|
168
|
+
directory = parent;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Minimal caret-range check (`^x.y.z`), matching the form zopia declares. */
|
|
173
|
+
function satisfiesCaretRange(version: string, range: string): boolean {
|
|
174
|
+
const base = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.replace(/^[^0-9]*/, ''));
|
|
175
|
+
const wanted = /^\^0*(\d+)(?:\.0*(\d+))?(?:\.0*(\d+))?/.exec(range.trim());
|
|
176
|
+
if (!base || !wanted) return true; // unparsable: never accuse drift on data we cannot read
|
|
177
|
+
const [major, minor, patch] = [Number(base[1]), Number(base[2]), Number(base[3])];
|
|
178
|
+
const [baseMajor, baseMinor, basePatch] = [Number(wanted[1]), Number(wanted[2] ?? 0), Number(wanted[3] ?? 0)];
|
|
179
|
+
if (baseMajor > 0) return major === baseMajor && (minor > baseMinor || (minor === baseMinor && patch >= basePatch));
|
|
180
|
+
return major === 0 && minor === baseMinor && patch >= basePatch;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function kmApiDriftDiagnostics(treeRoot: string): ZopiaValidationIssue[] {
|
|
184
|
+
const range = zopiaKmApiPeerRange();
|
|
185
|
+
if (range === undefined) return [];
|
|
186
|
+
const version = installedKmApiVersion(treeRoot);
|
|
187
|
+
if (version === undefined) {
|
|
188
|
+
return [{ severity: 'warning', code: KMAPI_DRIFT, at: treeRoot, message: `km-api could not be resolved near the generated tree; generated modules require peer ${range}` }];
|
|
189
|
+
}
|
|
190
|
+
if (!satisfiesCaretRange(version, range)) {
|
|
191
|
+
return [{ severity: 'warning', code: KMAPI_DRIFT, at: treeRoot, message: `installed km-api ${version} is outside the required peer range ${range}` }];
|
|
192
|
+
}
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function validateDocsTarget(path: string): Promise<ZopiaValidationResult> {
|
|
197
|
+
const issues: ZopiaValidationIssue[] = [];
|
|
198
|
+
const stats = existsSync(path) ? await lstat(path) : undefined;
|
|
199
|
+
const manifestFile = stats?.isDirectory() === true ? join(path, ZOPIA_MANIFEST_FILE) : path;
|
|
200
|
+
const treeRoot = stats?.isDirectory() === true ? path : dirname(path);
|
|
201
|
+
let manifest: ZopiaManifest | undefined;
|
|
202
|
+
if (!existsSync(manifestFile)) {
|
|
203
|
+
issues.push({ severity: 'error', code: 'ZOPIA_DOCS_MISSING_MANIFEST', at: manifestFile, message: 'manifest file not found; generate api docs first or pass the manifest path' });
|
|
204
|
+
} else {
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(await readFile(manifestFile, 'utf8')) as ZopiaManifest;
|
|
207
|
+
validateZopiaManifest(parsed);
|
|
208
|
+
manifest = parsed;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
issues.push(issueError(error));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (manifest !== undefined) {
|
|
214
|
+
try {
|
|
215
|
+
const reversed = await apiDocsToOpenApi(manifestFile);
|
|
216
|
+
for (const warning of reversed.warnings) issues.push({ severity: 'warning', code: warning.code, ...(warning.at === undefined ? {} : { at: warning.at }), message: warning.message });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
issues.push(issueError(error));
|
|
219
|
+
}
|
|
220
|
+
issues.push(...kmApiDriftDiagnostics(treeRoot));
|
|
221
|
+
}
|
|
222
|
+
const diagnostics = sortDiagnostics(issues);
|
|
223
|
+
return { ok: !diagnostics.some((issue) => issue.severity === 'error'), kind: 'docs', target: path, diagnostics };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function validateSpecTarget(input: string | Record<string, unknown>, target: string): Promise<ZopiaValidationResult> {
|
|
227
|
+
const issues: ZopiaValidationIssue[] = [];
|
|
228
|
+
let document: OpenApiDocument;
|
|
229
|
+
try {
|
|
230
|
+
let read = await readOpenApiSourceInput(input);
|
|
231
|
+
const bundled = read.sourceFile ? await bundleExternalOpenApiRefs(read.document, read.sourceFile) : read.document;
|
|
232
|
+
document = normalizeOpenApiDocument(bundled).document;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
issues.push(issueError(error));
|
|
235
|
+
const diagnostics = sortDiagnostics(issues);
|
|
236
|
+
return { ok: false, kind: 'spec', target, diagnostics };
|
|
237
|
+
}
|
|
238
|
+
try { validateOpenApiReferences(document); }
|
|
239
|
+
catch (error) {
|
|
240
|
+
issues.push(issueError(error));
|
|
241
|
+
const diagnostics = sortDiagnostics(issues);
|
|
242
|
+
return { ok: false, kind: 'spec', target, diagnostics };
|
|
243
|
+
}
|
|
244
|
+
const collectorsOk = { paths: false, webhooks: false };
|
|
245
|
+
try { collectOpenApiOperations(document); collectorsOk.paths = true; }
|
|
246
|
+
catch (error) { issues.push(issueError(error)); }
|
|
247
|
+
try { collectOpenApiWebhookOperations(document); collectorsOk.webhooks = true; }
|
|
248
|
+
catch (error) { issues.push(issueError(error)); }
|
|
249
|
+
if (collectorsOk.paths) {
|
|
250
|
+
try {
|
|
251
|
+
const plans = planApiDocsFiles(document);
|
|
252
|
+
if (collectorsOk.webhooks) {
|
|
253
|
+
try { assertUniqueOperationIdsAcrossScopes(plans, planWebhookDocsFiles(document)); }
|
|
254
|
+
catch (error) { issues.push(issueError(error)); }
|
|
255
|
+
}
|
|
256
|
+
} catch (error) { issues.push(issueError(error)); }
|
|
257
|
+
}
|
|
258
|
+
if (collectorsOk.paths) issues.push(...lintUnreachableComponents(document, document.openapi ?? document.swagger ?? '3.1'));
|
|
259
|
+
const diagnostics = sortDiagnostics(issues);
|
|
260
|
+
return { ok: !diagnostics.some((issue) => issue.severity === 'error'), kind: 'spec', target, diagnostics };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Lint or validate a Swagger/OpenAPI spec or a generated api-docs tree (Phase 3).
|
|
265
|
+
*
|
|
266
|
+
* Specs are checked for dialect validity, broken local references, endpoint
|
|
267
|
+
* planning failures (name collisions, cross-namespace duplicate operationIds),
|
|
268
|
+
* and components no operation can reach. Generated trees are checked for a
|
|
269
|
+
* readable manifest, a successful reverse dry-run, and km-api peer drift.
|
|
270
|
+
* Unlike the engines, validation never fails fast: every detectable problem is
|
|
271
|
+
* reported as a deterministic diagnostic and the result only signals validity
|
|
272
|
+
* through `ok`.
|
|
273
|
+
*
|
|
274
|
+
* @param input Spec object/text/path, generated docs directory, or manifest file path.
|
|
275
|
+
* @param options Optional target classification override.
|
|
276
|
+
* @returns Validation outcome with sorted diagnostics; `ok` ignores warning-severity findings.
|
|
277
|
+
* @throws {@link ZopiaError} `ZOPIA_CONFIG_INVALID` when the input or options are unusable.
|
|
278
|
+
* @example
|
|
279
|
+
* ```ts
|
|
280
|
+
* import { validateZopia } from 'zopia';
|
|
281
|
+
*
|
|
282
|
+
* const result = await validateZopia('openapi.yaml');
|
|
283
|
+
* for (const issue of result.diagnostics) console.error(issue.code, issue.at, issue.message);
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export async function validateZopia(input: string | Record<string, unknown>, options: ZopiaValidateOptions = {}): Promise<ZopiaValidationResult> {
|
|
287
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'validate options must be an object', { at: 'options' });
|
|
288
|
+
const kind = options.kind ?? 'auto';
|
|
289
|
+
if (kind !== 'spec' && kind !== 'docs' && kind !== 'auto') throw new ZopiaError('ZOPIA_CONFIG_INVALID', `invalid validate kind: ${String(kind)}`, { at: 'kind', hint: "use 'spec', 'docs', or 'auto'" });
|
|
290
|
+
const resolvedKind = kind === 'auto'
|
|
291
|
+
? (typeof input === 'string' && ((existsSync(input) && (await lstat(input)).isDirectory()) || /(?:^|\/)\.zopia-manifest\.json$/i.test(input)) ? 'docs' : 'spec' as const)
|
|
292
|
+
: kind;
|
|
293
|
+
if (resolvedKind === 'docs') {
|
|
294
|
+
if (typeof input !== 'string' || input.trim() === '') throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'docs validation requires a docs directory or manifest path', { at: 'input', hint: 'pass the generated api-docs directory or its .zopia-manifest.json' });
|
|
295
|
+
return validateDocsTarget(input);
|
|
296
|
+
}
|
|
297
|
+
const target = typeof input === 'string' ? (input.trimStart().startsWith('{') || input.includes('\n:') ? '(inline document)' : input) : '(in-memory document)';
|
|
298
|
+
return validateSpecTarget(input, target);
|
|
299
|
+
}
|
package/src/warnings.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { asZopiaError, ZopiaError } from './errors';
|
|
2
|
+
|
|
3
|
+
/** Stable warning codes emitted by zopia's conversion pipelines. */
|
|
4
|
+
export const ZOPIA_WARNING_CODES = [
|
|
5
|
+
'ZOPIA_WARN_CONTENT_ENCODING',
|
|
6
|
+
'ZOPIA_WARN_CUSTOM_FORMAT',
|
|
7
|
+
'ZOPIA_WARN_DEFAULT_INFO',
|
|
8
|
+
'ZOPIA_WARN_DEFAULT_SECURITY',
|
|
9
|
+
'ZOPIA_WARN_DIALECT_DOWNGRADE',
|
|
10
|
+
'ZOPIA_WARN_FROZEN_SUBTREE',
|
|
11
|
+
'ZOPIA_WARN_INT64',
|
|
12
|
+
'ZOPIA_WARN_INVALID_SCHEMA',
|
|
13
|
+
'ZOPIA_WARN_LEGACY_EXCLUSIVE_BOUND',
|
|
14
|
+
'ZOPIA_WARN_MULTI_CONTENT',
|
|
15
|
+
'ZOPIA_WARN_NOT',
|
|
16
|
+
'ZOPIA_WARN_ONE_OF',
|
|
17
|
+
'ZOPIA_WARN_PRESET_PRIMARY_TAG',
|
|
18
|
+
'ZOPIA_WARN_REF',
|
|
19
|
+
'ZOPIA_WARN_SERVER_VARIABLES',
|
|
20
|
+
'ZOPIA_WARN_STALE_TREE',
|
|
21
|
+
'ZOPIA_WARN_UNIQUE_ITEMS',
|
|
22
|
+
'ZOPIA_WARN_UNREPRESENTABLE',
|
|
23
|
+
'ZOPIA_WARN_WEBHOOKS',
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
/** Stable, machine-readable warning code. */
|
|
27
|
+
export type ZopiaWarningCode = typeof ZOPIA_WARNING_CODES[number];
|
|
28
|
+
|
|
29
|
+
/** A non-fatal, structured diagnostic produced by a lossy conversion. */
|
|
30
|
+
export interface ZopiaWarning {
|
|
31
|
+
/** Stable, machine-readable warning code. */
|
|
32
|
+
code: ZopiaWarningCode;
|
|
33
|
+
/** JSON Pointer identifying the affected source or output location, when available. */
|
|
34
|
+
at?: string;
|
|
35
|
+
/** Human-readable description of the approximation or loss. */
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cleanText = (value: string): string => value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').trim();
|
|
40
|
+
const displayLocation = (value: string): string => value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
41
|
+
const warningKey = (warning: ZopiaWarning): string => JSON.stringify([warning.at ?? null, warning.code, warning.message]);
|
|
42
|
+
|
|
43
|
+
function normalizeWarning(warning: ZopiaWarning): ZopiaWarning {
|
|
44
|
+
if (!warning || typeof warning !== 'object' || Array.isArray(warning)) throw new ZopiaError('ZOPIA_WARNING_INVALID', 'warning must be an object', { at: 'warning' });
|
|
45
|
+
if (!ZOPIA_WARNING_CODES.includes(warning.code)) throw new ZopiaError('ZOPIA_WARNING_INVALID', `Unknown zopia warning code: ${String(warning.code)}`, { at: 'code', hint: 'use a code from ZOPIA_WARNING_CODES' });
|
|
46
|
+
if (warning.at !== undefined && (typeof warning.at !== 'string' || warning.at.length === 0)) throw new ZopiaError('ZOPIA_WARNING_INVALID', 'warning location must be a non-empty string', { at: 'at', hint: 'omit the location or provide a JSON Pointer' });
|
|
47
|
+
if (typeof warning.message !== 'string' || cleanText(warning.message).length === 0) throw new ZopiaError('ZOPIA_WARNING_INVALID', 'warning message must be a non-empty string', { at: 'message', hint: 'provide a concise warning description' });
|
|
48
|
+
return { code: warning.code, ...(warning.at === undefined ? {} : { at: warning.at }), message: cleanText(warning.message) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return detached, deduplicated warnings in deterministic location/code/message order.
|
|
53
|
+
*
|
|
54
|
+
* @param warnings Structured warnings to validate, normalize, and sort.
|
|
55
|
+
* @returns A detached deterministic array with exact duplicates removed.
|
|
56
|
+
* @throws {@link ZopiaError} when a warning or the iterable is invalid.
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeZopiaWarnings(warnings: Iterable<ZopiaWarning>): ZopiaWarning[] {
|
|
59
|
+
try {
|
|
60
|
+
if (warnings === null || warnings === undefined || typeof (warnings as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== 'function') throw new ZopiaError('ZOPIA_WARNING_INVALID', 'warnings must be iterable', { at: 'warnings' });
|
|
61
|
+
const unique = new Map<string, ZopiaWarning>();
|
|
62
|
+
for (const input of warnings) {
|
|
63
|
+
const warning = normalizeWarning(input);
|
|
64
|
+
unique.set(warningKey(warning), warning);
|
|
65
|
+
}
|
|
66
|
+
return [...unique.values()].sort((left, right) => {
|
|
67
|
+
const a = warningKey(left); const b = warningKey(right);
|
|
68
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw asZopiaError(error, 'ZOPIA_WARNING_INVALID', 'unable to normalize warnings', { at: 'warnings' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prefix a warning's JSON Pointer with a containing source location.
|
|
77
|
+
*
|
|
78
|
+
* @param warning Structured warning to validate and relocate.
|
|
79
|
+
* @param base Containing JSON Pointer beginning with `#`.
|
|
80
|
+
* @returns A normalized warning beneath `base`.
|
|
81
|
+
* @throws {@link ZopiaError} when the warning or base pointer is invalid.
|
|
82
|
+
*/
|
|
83
|
+
export function rebaseZopiaWarning(warning: ZopiaWarning, base: string): ZopiaWarning {
|
|
84
|
+
if (typeof base !== 'string' || !base.startsWith('#')) throw new ZopiaError('ZOPIA_WARNING_INVALID', `invalid warning base pointer: ${base}`, { at: 'base', hint: "use a JSON Pointer beginning with '#'" });
|
|
85
|
+
const value = normalizeWarning(warning);
|
|
86
|
+
const suffix = value.at === undefined || value.at === '#' ? '' : value.at.startsWith('#/') ? value.at.slice(1) : value.at;
|
|
87
|
+
return normalizeWarning({ ...value, at: `${base}${suffix}` });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format a warning for stderr or logs without multiline injection.
|
|
92
|
+
*
|
|
93
|
+
* @param warning Structured warning to validate and format.
|
|
94
|
+
* @returns A stable single-line diagnostic.
|
|
95
|
+
* @throws {@link ZopiaError} when the warning is invalid.
|
|
96
|
+
*/
|
|
97
|
+
export function formatZopiaWarning(warning: ZopiaWarning): string {
|
|
98
|
+
const value = normalizeWarning(warning);
|
|
99
|
+
return `${value.code}${value.at ? ` ${displayLocation(value.at)}` : ''}: ${value.message}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format the canonical generated-code marker required by D-12.
|
|
104
|
+
*
|
|
105
|
+
* @param warning Structured warning to validate and format.
|
|
106
|
+
* @param subject Generated-code subject associated with the warning.
|
|
107
|
+
* @returns A stable single-line `@zopia:warn` source comment.
|
|
108
|
+
* @throws {@link ZopiaError} when the warning or subject is invalid.
|
|
109
|
+
*/
|
|
110
|
+
export function formatZopiaWarningComment(warning: ZopiaWarning, subject: string): string {
|
|
111
|
+
const value = normalizeWarning(warning);
|
|
112
|
+
if (typeof subject !== 'string') throw new ZopiaError('ZOPIA_WARNING_INVALID', 'warning subject must be a string', { at: 'subject' });
|
|
113
|
+
const label = cleanText(subject) || 'schema';
|
|
114
|
+
return `// @zopia:warn ${value.code} ${label} — ${value.message}${value.at ? ` (${displayLocation(value.at)})` : ''}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Deterministic warning accumulator shared by public engine wrappers. */
|
|
118
|
+
export class ZopiaWarningCollector {
|
|
119
|
+
readonly #warnings = new Map<string, ZopiaWarning>();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Add one warning after validating and normalizing it.
|
|
123
|
+
*
|
|
124
|
+
* @param input Structured warning to add.
|
|
125
|
+
* @returns Nothing.
|
|
126
|
+
* @throws {@link ZopiaError} when the warning is invalid.
|
|
127
|
+
*/
|
|
128
|
+
add(input: ZopiaWarning): void {
|
|
129
|
+
const warning = normalizeWarning(input);
|
|
130
|
+
this.#warnings.set(warningKey(warning), warning);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Add all warnings from an iterable.
|
|
135
|
+
*
|
|
136
|
+
* @param inputs Structured warnings to normalize and add.
|
|
137
|
+
* @returns Nothing.
|
|
138
|
+
* @throws {@link ZopiaError} when the iterable or any warning is invalid.
|
|
139
|
+
*/
|
|
140
|
+
addAll(inputs: Iterable<ZopiaWarning>): void {
|
|
141
|
+
for (const warning of normalizeZopiaWarnings(inputs)) this.add(warning);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Add warnings rebased beneath one containing JSON Pointer.
|
|
146
|
+
*
|
|
147
|
+
* @param inputs Structured warnings to normalize and add.
|
|
148
|
+
* @param base Containing JSON Pointer beginning with `#`.
|
|
149
|
+
* @returns Nothing.
|
|
150
|
+
* @throws {@link ZopiaError} when the iterable, warning, or base pointer is invalid.
|
|
151
|
+
*/
|
|
152
|
+
addRebased(inputs: Iterable<ZopiaWarning>, base: string): void {
|
|
153
|
+
for (const warning of normalizeZopiaWarnings(inputs)) this.add(rebaseZopiaWarning(warning, base));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Return a detached, deduplicated, deterministically sorted snapshot.
|
|
158
|
+
*
|
|
159
|
+
* @returns Current normalized warnings in stable order.
|
|
160
|
+
*/
|
|
161
|
+
toArray(): ZopiaWarning[] {
|
|
162
|
+
return normalizeZopiaWarnings(this.#warnings.values());
|
|
163
|
+
}
|
|
164
|
+
}
|