@typra/emitter 0.2.6 → 0.2.7
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/README.md +22 -1
- package/dist/src/cleanup/generated-file.d.ts +11 -0
- package/dist/src/consumer-smoke.d.ts +2 -0
- package/dist/src/consumer-smoke.js +81 -0
- package/dist/src/contract-surface.d.ts +2 -0
- package/dist/src/contract-surface.js +2 -0
- package/dist/src/emitter.js +4 -1
- package/dist/src/hydration-seams.d.ts +20 -0
- package/dist/src/hydration-seams.js +32 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/lib.d.ts +2 -0
- package/dist/src/lib.js +12 -0
- package/dist/src/verify/index.d.ts +140 -0
- package/dist/src/verify/index.js +667 -0
- package/dist/src/verify-cli.d.ts +2 -0
- package/dist/src/verify-cli.js +53 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -55,12 +55,21 @@ npx tsp compile ./path/to/main.tsp --config ./tspconfig.yaml
|
|
|
55
55
|
|
|
56
56
|
## CLI
|
|
57
57
|
|
|
58
|
-
The package includes
|
|
58
|
+
The package includes `typra-generate`, `typra-verify`, and a generic
|
|
59
|
+
`typra-consumer-smoke` harness:
|
|
59
60
|
|
|
60
61
|
```powershell
|
|
61
62
|
npx typra-generate --help
|
|
63
|
+
npx typra-verify --baseline ./baseline --current ./generated
|
|
64
|
+
npx typra-consumer-smoke --config ./typra-smoke.json
|
|
62
65
|
```
|
|
63
66
|
|
|
67
|
+
`typra-verify` compares committed `.typra-generated` metadata against current
|
|
68
|
+
generated metadata and prints deterministic review summaries for exports,
|
|
69
|
+
protocols, files, package/module identity, toolchain, protected paths, schema
|
|
70
|
+
evolution, stale cleanup dry-runs, hydration seams, and breaking-change
|
|
71
|
+
classification. It never deletes files.
|
|
72
|
+
|
|
64
73
|
## Supported output
|
|
65
74
|
|
|
66
75
|
Typra includes emitters for:
|
|
@@ -83,6 +92,18 @@ Generated source files include Typra markers, and the emitter records a
|
|
|
83
92
|
generated-file manifest for each output root. Stale-file deletion is not enabled
|
|
84
93
|
yet, so Typra will not remove hand-authored runtime files.
|
|
85
94
|
|
|
95
|
+
Consumers can declare hand-authored boundaries in verifier config:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"protectedPaths": ["src/adapters/**"],
|
|
100
|
+
"hydrationZones": ["src/extensions/**"]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The emitter records hydration seam metadata for generated protocol adapters, but
|
|
105
|
+
runtime behavior remains hand-authored by the consuming project.
|
|
106
|
+
|
|
86
107
|
## Links
|
|
87
108
|
|
|
88
109
|
- Repository: <https://github.com/sethjuarez/typra>
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { EmitContext } from "@typespec/compiler";
|
|
2
2
|
import { TypraEmitterOptions } from "../lib.js";
|
|
3
|
+
export interface GeneratedManifestEntry {
|
|
4
|
+
outputRoot: string;
|
|
5
|
+
path: string;
|
|
6
|
+
marker: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface GeneratedManifest {
|
|
9
|
+
emitter: "typra-emitter";
|
|
10
|
+
version: 1;
|
|
11
|
+
generatedAt: string;
|
|
12
|
+
files: GeneratedManifestEntry[];
|
|
13
|
+
}
|
|
3
14
|
export declare function emitGeneratedFile(context: EmitContext<TypraEmitterOptions>, filePath: string, content: string, options?: {
|
|
4
15
|
marker?: boolean;
|
|
5
16
|
outputRoot?: string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { verifyTypraMetadata } from "./verify/index.js";
|
|
6
|
+
const HELP = `
|
|
7
|
+
typra-consumer-smoke - Run a generic Typra consumer smoke harness
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
npx typra-consumer-smoke --config typra-smoke.json
|
|
11
|
+
|
|
12
|
+
Config shape:
|
|
13
|
+
{
|
|
14
|
+
"install": ["npm ci"],
|
|
15
|
+
"generate": ["npx tsp compile ./typespec/main.tsp --config ./tspconfig.yaml"],
|
|
16
|
+
"verify": { "baseline": "./baseline", "current": "./generated" },
|
|
17
|
+
"smoke": ["npm test"]
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
async function main() {
|
|
21
|
+
const { values } = parseArgs({
|
|
22
|
+
options: {
|
|
23
|
+
config: { type: "string" },
|
|
24
|
+
help: { type: "boolean", short: "h" },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
if (values.help) {
|
|
28
|
+
console.log(HELP);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
if (!values.config) {
|
|
32
|
+
console.error("Error: --config is required.\n");
|
|
33
|
+
console.log(HELP);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const config = readConfig(values.config);
|
|
37
|
+
runCommands("install", config.install ?? []);
|
|
38
|
+
runCommands("generate", config.generate ?? []);
|
|
39
|
+
if (config.verify) {
|
|
40
|
+
const result = verifyTypraMetadata({
|
|
41
|
+
baselineRoot: config.verify.baseline,
|
|
42
|
+
currentRoot: config.verify.current,
|
|
43
|
+
configPath: config.verify.config,
|
|
44
|
+
});
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
console.error(JSON.stringify(result, null, 2));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
runCommands("smoke", config.smoke ?? []);
|
|
51
|
+
}
|
|
52
|
+
function readConfig(configPath) {
|
|
53
|
+
if (!existsSync(configPath)) {
|
|
54
|
+
throw new Error(`Missing consumer smoke config: ${configPath}`);
|
|
55
|
+
}
|
|
56
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
57
|
+
for (const key of ["install", "generate", "smoke"]) {
|
|
58
|
+
if (config[key] !== undefined && (!Array.isArray(config[key]) || config[key]?.some((entry) => typeof entry !== "string"))) {
|
|
59
|
+
throw new Error(`Invalid consumer smoke config: ${key} must be an array of commands.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (config.verify && (typeof config.verify.baseline !== "string" || typeof config.verify.current !== "string")) {
|
|
63
|
+
throw new Error("Invalid consumer smoke config: verify.baseline and verify.current are required strings.");
|
|
64
|
+
}
|
|
65
|
+
return config;
|
|
66
|
+
}
|
|
67
|
+
function runCommands(label, commands) {
|
|
68
|
+
for (const command of commands) {
|
|
69
|
+
console.log(`[typra-consumer-smoke:${label}] ${command}`);
|
|
70
|
+
execFileSync(command, {
|
|
71
|
+
cwd: process.cwd(),
|
|
72
|
+
shell: true,
|
|
73
|
+
stdio: "inherit",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
|
81
|
+
//# sourceMappingURL=consumer-smoke.js.map
|
|
@@ -34,6 +34,8 @@ function buildTargetSurface(rootNamespace, target, nodes) {
|
|
|
34
34
|
.map((node) => ({
|
|
35
35
|
name: node.typeName.name,
|
|
36
36
|
group: node.group || "",
|
|
37
|
+
symbol: node.typeName.name,
|
|
38
|
+
source: sourceFor(targetName, node, node.group || ""),
|
|
37
39
|
methods: node.methods
|
|
38
40
|
.map((method) => ({
|
|
39
41
|
name: method.name,
|
package/dist/src/emitter.js
CHANGED
|
@@ -9,6 +9,7 @@ import { generateRust } from "./languages/rust/driver.js";
|
|
|
9
9
|
import { emitGeneratedFile, emitGeneratedManifest } from "./cleanup/generated-file.js";
|
|
10
10
|
import { buildExportSurfaceSnapshot, emitExportSurfaceSnapshot } from "./contract-surface.js";
|
|
11
11
|
import { reportTypeSpecCompatibility, shouldBlockUnsupportedTypeSpecToolchain } from "./compatibility.js";
|
|
12
|
+
import { buildHydrationBoundarySnapshot, emitHydrationBoundarySnapshot } from "./hydration-seams.js";
|
|
12
13
|
/**
|
|
13
14
|
* Filter nodes based on omit-models option.
|
|
14
15
|
* Matches against model name (e.g., "AgentManifest") or fully qualified name (e.g., "Prompty.AgentManifest")
|
|
@@ -164,7 +165,9 @@ export async function $onEmit(context) {
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
await emitGeneratedFile(context, resolvePath(context.emitterOutputDir, "json-ast", "model.json"), JSON.stringify(model.getSanitizedObject(), null, 2), { marker: false });
|
|
167
|
-
|
|
168
|
+
const exportSurfaceSnapshot = buildExportSurfaceSnapshot(rootObject, rootNamespace, rootAlias, targets, exportSurfaceNodes, toolchain);
|
|
169
|
+
await emitExportSurfaceSnapshot(context, exportSurfaceSnapshot);
|
|
170
|
+
await emitHydrationBoundarySnapshot(context, buildHydrationBoundarySnapshot(exportSurfaceSnapshot, options));
|
|
168
171
|
await emitGeneratedManifest(context);
|
|
169
172
|
}
|
|
170
173
|
//# sourceMappingURL=emitter.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { EmitContext } from "@typespec/compiler";
|
|
2
|
+
import { ExportSurfaceSnapshot } from "./contract-surface.js";
|
|
3
|
+
import { TypraEmitterOptions } from "./lib.js";
|
|
4
|
+
export interface HydrationSeam {
|
|
5
|
+
contract: string;
|
|
6
|
+
target: string;
|
|
7
|
+
group: string;
|
|
8
|
+
symbol: string;
|
|
9
|
+
generatedSource: string;
|
|
10
|
+
seamKind: "protocol-adapter";
|
|
11
|
+
}
|
|
12
|
+
export interface HydrationBoundarySnapshot {
|
|
13
|
+
emitter: "typra-emitter";
|
|
14
|
+
version: 1;
|
|
15
|
+
protectedPaths: string[];
|
|
16
|
+
hydrationZones: string[];
|
|
17
|
+
seams: HydrationSeam[];
|
|
18
|
+
}
|
|
19
|
+
export declare function buildHydrationBoundarySnapshot(exportSurface: ExportSurfaceSnapshot, options: Pick<TypraEmitterOptions, "protected-paths" | "hydration-zones">): HydrationBoundarySnapshot;
|
|
20
|
+
export declare function emitHydrationBoundarySnapshot(context: EmitContext<TypraEmitterOptions>, snapshot: HydrationBoundarySnapshot): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { emitFile, resolvePath } from "@typespec/compiler";
|
|
2
|
+
export function buildHydrationBoundarySnapshot(exportSurface, options) {
|
|
3
|
+
return {
|
|
4
|
+
emitter: "typra-emitter",
|
|
5
|
+
version: 1,
|
|
6
|
+
protectedPaths: uniqueSorted(options["protected-paths"] ?? []),
|
|
7
|
+
hydrationZones: uniqueSorted(options["hydration-zones"] ?? []),
|
|
8
|
+
seams: exportSurface.targets
|
|
9
|
+
.flatMap((target) => target.protocols.map((protocol) => ({
|
|
10
|
+
contract: protocol.name,
|
|
11
|
+
target: target.target,
|
|
12
|
+
group: protocol.group,
|
|
13
|
+
symbol: protocol.symbol,
|
|
14
|
+
generatedSource: protocol.source,
|
|
15
|
+
seamKind: "protocol-adapter",
|
|
16
|
+
})))
|
|
17
|
+
.sort((left, right) => seamKey(left).localeCompare(seamKey(right))),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function emitHydrationBoundarySnapshot(context, snapshot) {
|
|
21
|
+
await emitFile(context.program, {
|
|
22
|
+
path: resolvePath(context.emitterOutputDir, ".typra-generated", "hydration-seams.json"),
|
|
23
|
+
content: `${JSON.stringify(snapshot, null, 2)}\n`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function uniqueSorted(values) {
|
|
27
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
|
28
|
+
}
|
|
29
|
+
function seamKey(seam) {
|
|
30
|
+
return `${seam.target}:${seam.group}:${seam.contract}:${seam.symbol}`;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=hydration-seams.js.map
|
package/dist/src/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { $onEmit, GeneratorOptions, filterNodes } from "./emitter.js";
|
|
|
2
2
|
export { $lib } from "./lib.js";
|
|
3
3
|
export { $sample, $abstract, $coerce, $factory, $method, $knownAs, $defaultFor, $protocol } from "./decorators.js";
|
|
4
4
|
export { generate, GenerateOptions, GenerateResult, TargetLanguage, TargetOptions } from "./generate.js";
|
|
5
|
+
export { compareTypraMetadata, formatVerifySummary, loadTypraMetadata, loadVerifyConfig, verifyTypraMetadata, TypraMetadataSet, TypraVerifyConfig, TypraVerifyFailure, TypraVerifyResult, TypraVerifySummary, } from "./verify/index.js";
|
|
6
|
+
export { buildHydrationBoundarySnapshot, emitHydrationBoundarySnapshot, HydrationBoundarySnapshot, HydrationSeam, } from "./hydration-seams.js";
|
package/dist/src/index.js
CHANGED
|
@@ -2,4 +2,6 @@ export { $onEmit, filterNodes } from "./emitter.js";
|
|
|
2
2
|
export { $lib } from "./lib.js";
|
|
3
3
|
export { $sample, $abstract, $coerce, $factory, $method, $knownAs, $defaultFor, $protocol } from "./decorators.js";
|
|
4
4
|
export { generate } from "./generate.js";
|
|
5
|
+
export { compareTypraMetadata, formatVerifySummary, loadTypraMetadata, loadVerifyConfig, verifyTypraMetadata, } from "./verify/index.js";
|
|
6
|
+
export { buildHydrationBoundarySnapshot, emitHydrationBoundarySnapshot, } from "./hydration-seams.js";
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface TypraEmitterOptions {
|
|
|
19
19
|
"schema-output-dir"?: string;
|
|
20
20
|
"additional-roots"?: string[];
|
|
21
21
|
"allow-unsupported-typespec-version"?: boolean;
|
|
22
|
+
"protected-paths"?: string[];
|
|
23
|
+
"hydration-zones"?: string[];
|
|
22
24
|
}
|
|
23
25
|
export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
24
26
|
[code: string]: import("@typespec/compiler").DiagnosticMessages;
|
package/dist/src/lib.js
CHANGED
|
@@ -88,6 +88,18 @@ const TypraEmitterOptionsSchema = {
|
|
|
88
88
|
nullable: true,
|
|
89
89
|
default: false,
|
|
90
90
|
description: "Allow generation with an unvalidated TypeSpec compiler/json-schema version. Unsupported versions report a warning instead of an error."
|
|
91
|
+
},
|
|
92
|
+
"protected-paths": {
|
|
93
|
+
type: "array",
|
|
94
|
+
items: { type: "string" },
|
|
95
|
+
nullable: true,
|
|
96
|
+
description: "Hand-authored paths Typra must not own. Recorded for verifier boundary checks; generation still does not delete files."
|
|
97
|
+
},
|
|
98
|
+
"hydration-zones": {
|
|
99
|
+
type: "array",
|
|
100
|
+
items: { type: "string" },
|
|
101
|
+
nullable: true,
|
|
102
|
+
description: "Hand-authored extension zones adjacent to generated output. Recorded as verifier boundary metadata; Typra does not generate runtime behavior."
|
|
91
103
|
}
|
|
92
104
|
},
|
|
93
105
|
required: ["root-object"],
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ExportSurfaceSnapshot } from "../contract-surface.js";
|
|
2
|
+
import { GeneratedManifest } from "../cleanup/generated-file.js";
|
|
3
|
+
import { HydrationBoundarySnapshot } from "../hydration-seams.js";
|
|
4
|
+
export interface TypraMetadataSet {
|
|
5
|
+
exportSurface: ExportSurfaceSnapshot;
|
|
6
|
+
manifest: GeneratedManifest;
|
|
7
|
+
model?: SchemaNode;
|
|
8
|
+
hydration?: HydrationBoundarySnapshot;
|
|
9
|
+
}
|
|
10
|
+
export interface TypraVerifyConfig {
|
|
11
|
+
protectedPaths?: string[];
|
|
12
|
+
hydrationZones?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface TypraVerifyFailure {
|
|
15
|
+
code: string;
|
|
16
|
+
message: string;
|
|
17
|
+
blocking: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface TypraVerifySummary {
|
|
20
|
+
exports: {
|
|
21
|
+
added: number;
|
|
22
|
+
removed: number;
|
|
23
|
+
changed: number;
|
|
24
|
+
};
|
|
25
|
+
protocols: {
|
|
26
|
+
added: number;
|
|
27
|
+
removed: number;
|
|
28
|
+
changed: number;
|
|
29
|
+
};
|
|
30
|
+
files: {
|
|
31
|
+
added: number;
|
|
32
|
+
deleted: number;
|
|
33
|
+
ownershipChanged: number;
|
|
34
|
+
};
|
|
35
|
+
packageNamesChanged: number;
|
|
36
|
+
modulesChanged: number;
|
|
37
|
+
toolchain: {
|
|
38
|
+
changed: number;
|
|
39
|
+
unsupported: number;
|
|
40
|
+
};
|
|
41
|
+
protectedPathTouches: number;
|
|
42
|
+
hydrationZoneTouches: number;
|
|
43
|
+
staleCleanupCandidates: number;
|
|
44
|
+
schema: {
|
|
45
|
+
addedTypes: number;
|
|
46
|
+
removedTypes: number;
|
|
47
|
+
addedOptionalProperties: number;
|
|
48
|
+
addedRequiredProperties: number;
|
|
49
|
+
removedProperties: number;
|
|
50
|
+
requirednessChanged: number;
|
|
51
|
+
propertyTypesChanged: number;
|
|
52
|
+
wireNamesChanged: number;
|
|
53
|
+
discriminatorsChanged: number;
|
|
54
|
+
enumValuesChanged: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export interface TypraVerifyResult {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
breakingChange: "patch" | "minor" | "major";
|
|
60
|
+
summary: TypraVerifySummary;
|
|
61
|
+
failures: TypraVerifyFailure[];
|
|
62
|
+
schemaEvolution: SchemaEvolutionChange[];
|
|
63
|
+
conformanceMap: ConformanceMapEntry[];
|
|
64
|
+
staleCleanupDryRun: StaleCleanupCandidate[];
|
|
65
|
+
hydrationBoundaries: HydrationBoundaryReport;
|
|
66
|
+
}
|
|
67
|
+
export interface SchemaNode {
|
|
68
|
+
typeName?: {
|
|
69
|
+
namespace?: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
};
|
|
72
|
+
base?: {
|
|
73
|
+
namespace?: string;
|
|
74
|
+
name?: string;
|
|
75
|
+
};
|
|
76
|
+
isAbstract?: boolean;
|
|
77
|
+
isProtocol?: boolean;
|
|
78
|
+
discriminator?: string;
|
|
79
|
+
childTypes?: SchemaNode[];
|
|
80
|
+
properties?: SchemaProperty[];
|
|
81
|
+
}
|
|
82
|
+
export interface SchemaProperty {
|
|
83
|
+
name?: string;
|
|
84
|
+
typeName?: {
|
|
85
|
+
namespace?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
};
|
|
88
|
+
isOptional?: boolean;
|
|
89
|
+
knownAs?: Array<{
|
|
90
|
+
provider?: string;
|
|
91
|
+
name?: string;
|
|
92
|
+
}>;
|
|
93
|
+
allowedValues?: string[];
|
|
94
|
+
enumName?: string | null;
|
|
95
|
+
isOpenEnum?: boolean;
|
|
96
|
+
isScalar?: boolean;
|
|
97
|
+
isCollection?: boolean;
|
|
98
|
+
isAny?: boolean;
|
|
99
|
+
isDict?: boolean;
|
|
100
|
+
type?: SchemaNode;
|
|
101
|
+
}
|
|
102
|
+
export interface SchemaEvolutionChange {
|
|
103
|
+
kind: "type-added" | "type-removed" | "property-added-optional" | "property-added-required" | "property-removed" | "property-requiredness-changed" | "property-type-changed" | "property-wire-name-changed" | "type-discriminator-changed" | "property-enum-values-changed";
|
|
104
|
+
path: string;
|
|
105
|
+
severity: "patch" | "minor" | "major";
|
|
106
|
+
message: string;
|
|
107
|
+
}
|
|
108
|
+
export interface ConformanceMapEntry {
|
|
109
|
+
contract: string;
|
|
110
|
+
protocol: boolean;
|
|
111
|
+
targets: Array<{
|
|
112
|
+
target: string;
|
|
113
|
+
symbol: string;
|
|
114
|
+
source: string;
|
|
115
|
+
packageName?: string;
|
|
116
|
+
namespace?: string;
|
|
117
|
+
outputRoot: string;
|
|
118
|
+
modules: string[];
|
|
119
|
+
exported: boolean;
|
|
120
|
+
}>;
|
|
121
|
+
}
|
|
122
|
+
export interface StaleCleanupCandidate {
|
|
123
|
+
path: string;
|
|
124
|
+
reasons: string[];
|
|
125
|
+
safe: boolean;
|
|
126
|
+
}
|
|
127
|
+
export interface HydrationBoundaryReport {
|
|
128
|
+
protectedPaths: string[];
|
|
129
|
+
hydrationZones: string[];
|
|
130
|
+
seams: HydrationBoundarySnapshot["seams"];
|
|
131
|
+
}
|
|
132
|
+
export declare function verifyTypraMetadata(options: {
|
|
133
|
+
baselineRoot: string;
|
|
134
|
+
currentRoot: string;
|
|
135
|
+
configPath?: string;
|
|
136
|
+
}): TypraVerifyResult;
|
|
137
|
+
export declare function loadTypraMetadata(root: string): TypraMetadataSet;
|
|
138
|
+
export declare function loadVerifyConfig(configPath: string): TypraVerifyConfig;
|
|
139
|
+
export declare function compareTypraMetadata(baseline: TypraMetadataSet, current: TypraMetadataSet, config?: TypraVerifyConfig): TypraVerifyResult;
|
|
140
|
+
export declare function formatVerifySummary(result: TypraVerifyResult): string;
|
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const EMPTY_SUMMARY = {
|
|
4
|
+
exports: { added: 0, removed: 0, changed: 0 },
|
|
5
|
+
protocols: { added: 0, removed: 0, changed: 0 },
|
|
6
|
+
files: { added: 0, deleted: 0, ownershipChanged: 0 },
|
|
7
|
+
packageNamesChanged: 0,
|
|
8
|
+
modulesChanged: 0,
|
|
9
|
+
toolchain: { changed: 0, unsupported: 0 },
|
|
10
|
+
protectedPathTouches: 0,
|
|
11
|
+
hydrationZoneTouches: 0,
|
|
12
|
+
staleCleanupCandidates: 0,
|
|
13
|
+
schema: {
|
|
14
|
+
addedTypes: 0,
|
|
15
|
+
removedTypes: 0,
|
|
16
|
+
addedOptionalProperties: 0,
|
|
17
|
+
addedRequiredProperties: 0,
|
|
18
|
+
removedProperties: 0,
|
|
19
|
+
requirednessChanged: 0,
|
|
20
|
+
propertyTypesChanged: 0,
|
|
21
|
+
wireNamesChanged: 0,
|
|
22
|
+
discriminatorsChanged: 0,
|
|
23
|
+
enumValuesChanged: 0,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export function verifyTypraMetadata(options) {
|
|
27
|
+
return compareTypraMetadata(loadTypraMetadata(options.baselineRoot), loadTypraMetadata(options.currentRoot), options.configPath ? loadVerifyConfig(options.configPath) : undefined);
|
|
28
|
+
}
|
|
29
|
+
export function loadTypraMetadata(root) {
|
|
30
|
+
return {
|
|
31
|
+
exportSurface: readJson(metadataFile(root, "export-surfaces.json")),
|
|
32
|
+
manifest: readJson(metadataFile(root, "manifest.json")),
|
|
33
|
+
model: readOptionalJson(modelFile(root)),
|
|
34
|
+
hydration: readOptionalJson(metadataFile(root, "hydration-seams.json")),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function loadVerifyConfig(configPath) {
|
|
38
|
+
const config = readJson(configPath);
|
|
39
|
+
if (config.protectedPaths !== undefined && !Array.isArray(config.protectedPaths)) {
|
|
40
|
+
throw new Error(`Invalid Typra verifier config: protectedPaths must be an array.`);
|
|
41
|
+
}
|
|
42
|
+
if (config.protectedPaths?.some((entry) => typeof entry !== "string")) {
|
|
43
|
+
throw new Error(`Invalid Typra verifier config: protectedPaths entries must be strings.`);
|
|
44
|
+
}
|
|
45
|
+
if (config.hydrationZones !== undefined && !Array.isArray(config.hydrationZones)) {
|
|
46
|
+
throw new Error(`Invalid Typra verifier config: hydrationZones must be an array.`);
|
|
47
|
+
}
|
|
48
|
+
if (config.hydrationZones?.some((entry) => typeof entry !== "string")) {
|
|
49
|
+
throw new Error(`Invalid Typra verifier config: hydrationZones entries must be strings.`);
|
|
50
|
+
}
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
export function compareTypraMetadata(baseline, current, config = {}) {
|
|
54
|
+
const summary = cloneSummary();
|
|
55
|
+
const failures = [];
|
|
56
|
+
compareSnapshotIdentity(baseline.exportSurface, current.exportSurface, summary, failures);
|
|
57
|
+
compareToolchain(baseline.exportSurface, current.exportSurface, summary, failures);
|
|
58
|
+
compareExports(baseline.exportSurface, current.exportSurface, summary, failures);
|
|
59
|
+
compareProtocols(baseline.exportSurface, current.exportSurface, summary, failures);
|
|
60
|
+
compareManifest(baseline.manifest, current.manifest, summary, failures);
|
|
61
|
+
compareHydrationBoundaryMetadata(baseline.hydration, current.hydration, failures);
|
|
62
|
+
compareProtectedPaths(current.manifest, baseline.hydration, current.hydration, config, summary, failures);
|
|
63
|
+
compareHydrationZones(current.manifest, current.hydration, config, summary);
|
|
64
|
+
const schemaEvolution = compareSchemaEvolution(baseline.model, current.model, summary, failures);
|
|
65
|
+
const conformanceMap = buildConformanceMap(current.exportSurface);
|
|
66
|
+
const staleCleanupDryRun = buildStaleCleanupDryRun(baseline.manifest, current.manifest, config, baseline.hydration, current.hydration);
|
|
67
|
+
summary.staleCleanupCandidates = staleCleanupDryRun.length;
|
|
68
|
+
const hydrationBoundaries = buildHydrationBoundaryReport(current.hydration, config);
|
|
69
|
+
failures.sort(compareFailures);
|
|
70
|
+
const breakingChange = classifyBreakingChange(summary, schemaEvolution, failures);
|
|
71
|
+
return {
|
|
72
|
+
ok: failures.every((failure) => !failure.blocking),
|
|
73
|
+
breakingChange,
|
|
74
|
+
summary,
|
|
75
|
+
failures,
|
|
76
|
+
schemaEvolution,
|
|
77
|
+
conformanceMap,
|
|
78
|
+
staleCleanupDryRun,
|
|
79
|
+
hydrationBoundaries,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function formatVerifySummary(result) {
|
|
83
|
+
const lines = [
|
|
84
|
+
`Typra verify: ${result.ok ? "passed" : "failed"}`,
|
|
85
|
+
`exports: +${result.summary.exports.added} / -${result.summary.exports.removed} / changed ${result.summary.exports.changed}`,
|
|
86
|
+
`protocols: +${result.summary.protocols.added} / -${result.summary.protocols.removed} / changed ${result.summary.protocols.changed}`,
|
|
87
|
+
`files: +${result.summary.files.added} / deleted ${result.summary.files.deleted} / ownership changed ${result.summary.files.ownershipChanged}`,
|
|
88
|
+
`package names changed: ${result.summary.packageNamesChanged}`,
|
|
89
|
+
`modules changed: ${result.summary.modulesChanged}`,
|
|
90
|
+
`toolchain changed: ${result.summary.toolchain.changed} / unsupported ${result.summary.toolchain.unsupported}`,
|
|
91
|
+
`protected path touches: ${result.summary.protectedPathTouches}`,
|
|
92
|
+
`hydration zone touches: ${result.summary.hydrationZoneTouches}`,
|
|
93
|
+
`stale cleanup dry-run candidates: ${result.summary.staleCleanupCandidates}`,
|
|
94
|
+
`schema: types +${result.summary.schema.addedTypes} / -${result.summary.schema.removedTypes}, required fields +${result.summary.schema.addedRequiredProperties}, optional fields +${result.summary.schema.addedOptionalProperties}, requiredness changed ${result.summary.schema.requirednessChanged}, property types changed ${result.summary.schema.propertyTypesChanged}, wire names changed ${result.summary.schema.wireNamesChanged}, discriminators changed ${result.summary.schema.discriminatorsChanged}, enum values changed ${result.summary.schema.enumValuesChanged}`,
|
|
95
|
+
`breaking change classification: ${result.breakingChange}`,
|
|
96
|
+
];
|
|
97
|
+
const blocking = result.failures.filter((failure) => failure.blocking).sort(compareFailures);
|
|
98
|
+
if (blocking.length > 0) {
|
|
99
|
+
lines.push("blocking failures:");
|
|
100
|
+
for (const failure of blocking) {
|
|
101
|
+
lines.push(`- [${failure.code}] ${failure.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return `${lines.join("\n")}\n`;
|
|
105
|
+
}
|
|
106
|
+
function compareSnapshotIdentity(baseline, current, summary, failures) {
|
|
107
|
+
if (baseline.emitter !== current.emitter || baseline.version !== current.version) {
|
|
108
|
+
addFailure(failures, "snapshot.identity", `Snapshot identity changed from ${baseline.emitter}@${baseline.version} to ${current.emitter}@${current.version}.`);
|
|
109
|
+
}
|
|
110
|
+
if (stableStringify(baseline.root) !== stableStringify(current.root)) {
|
|
111
|
+
addFailure(failures, "snapshot.root", `Root metadata changed from ${stableStringify(baseline.root)} to ${stableStringify(current.root)}.`);
|
|
112
|
+
}
|
|
113
|
+
const baselineTargets = mapTargets(baseline.targets);
|
|
114
|
+
const currentTargets = mapTargets(current.targets);
|
|
115
|
+
for (const target of sortedUnion([...baselineTargets.keys()], [...currentTargets.keys()])) {
|
|
116
|
+
const left = baselineTargets.get(target);
|
|
117
|
+
const right = currentTargets.get(target);
|
|
118
|
+
if (!left || !right) {
|
|
119
|
+
summary.packageNamesChanged += 1;
|
|
120
|
+
addFailure(failures, "target.set", `Target set changed for ${target}.`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if ((left.packageName ?? "") !== (right.packageName ?? "")) {
|
|
124
|
+
summary.packageNamesChanged += 1;
|
|
125
|
+
addFailure(failures, "target.package", `${target} package name changed from ${left.packageName ?? "<none>"} to ${right.packageName ?? "<none>"}.`);
|
|
126
|
+
}
|
|
127
|
+
if ((left.namespace ?? "") !== (right.namespace ?? "")) {
|
|
128
|
+
summary.packageNamesChanged += 1;
|
|
129
|
+
addFailure(failures, "target.namespace", `${target} namespace changed from ${left.namespace ?? "<none>"} to ${right.namespace ?? "<none>"}.`);
|
|
130
|
+
}
|
|
131
|
+
if (stableStringify(left.modules) !== stableStringify(right.modules)) {
|
|
132
|
+
summary.modulesChanged += 1;
|
|
133
|
+
addFailure(failures, "target.modules", `${target} module list changed.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function compareToolchain(baseline, current, summary, failures) {
|
|
138
|
+
const baselinePackages = new Map(baseline.toolchain.packages.map((entry) => [entry.name, entry]));
|
|
139
|
+
const currentPackages = new Map(current.toolchain.packages.map((entry) => [entry.name, entry]));
|
|
140
|
+
for (const name of sortedUnion([...baselinePackages.keys()], [...currentPackages.keys()])) {
|
|
141
|
+
const left = baselinePackages.get(name);
|
|
142
|
+
const right = currentPackages.get(name);
|
|
143
|
+
if (!left || !right || stableStringify(left) !== stableStringify(right)) {
|
|
144
|
+
summary.toolchain.changed += 1;
|
|
145
|
+
addFailure(failures, "toolchain.changed", `${name} toolchain metadata changed.`);
|
|
146
|
+
}
|
|
147
|
+
if (right && !right.supported) {
|
|
148
|
+
summary.toolchain.unsupported += 1;
|
|
149
|
+
addFailure(failures, "toolchain.unsupported", `${name}@${right.version} is outside supported range ${right.supportedRange}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function compareExports(baseline, current, summary, failures) {
|
|
154
|
+
const baselineExports = mapExports(baseline.targets);
|
|
155
|
+
const currentExports = mapExports(current.targets);
|
|
156
|
+
for (const key of sortedUnion([...baselineExports.keys()], [...currentExports.keys()])) {
|
|
157
|
+
const left = baselineExports.get(key);
|
|
158
|
+
const right = currentExports.get(key);
|
|
159
|
+
if (!left && right) {
|
|
160
|
+
summary.exports.added += 1;
|
|
161
|
+
}
|
|
162
|
+
else if (left && !right) {
|
|
163
|
+
summary.exports.removed += 1;
|
|
164
|
+
addFailure(failures, "exports.removed", `${key} was removed.`);
|
|
165
|
+
}
|
|
166
|
+
else if (left && right && exportChanged(left.entry, right.entry)) {
|
|
167
|
+
summary.exports.changed += 1;
|
|
168
|
+
addFailure(failures, "exports.changed", `${key} changed from ${exportSignature(left.entry)} to ${exportSignature(right.entry)}.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function compareProtocols(baseline, current, summary, failures) {
|
|
173
|
+
const baselineProtocols = mapProtocols(baseline.targets);
|
|
174
|
+
const currentProtocols = mapProtocols(current.targets);
|
|
175
|
+
for (const key of sortedUnion([...baselineProtocols.keys()], [...currentProtocols.keys()])) {
|
|
176
|
+
const left = baselineProtocols.get(key);
|
|
177
|
+
const right = currentProtocols.get(key);
|
|
178
|
+
if (!left && right) {
|
|
179
|
+
summary.protocols.added += 1;
|
|
180
|
+
}
|
|
181
|
+
else if (left && !right) {
|
|
182
|
+
summary.protocols.removed += 1;
|
|
183
|
+
addFailure(failures, "protocols.removed", `${key} was removed.`);
|
|
184
|
+
}
|
|
185
|
+
else if (left && right && protocolSignature(left.protocol) !== protocolSignature(right.protocol)) {
|
|
186
|
+
summary.protocols.changed += 1;
|
|
187
|
+
addFailure(failures, "protocols.changed", `${key} signature changed.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function compareManifest(baseline, current, summary, failures) {
|
|
192
|
+
if (baseline.emitter !== current.emitter || baseline.version !== current.version) {
|
|
193
|
+
addFailure(failures, "manifest.identity", `Manifest identity changed from ${baseline.emitter}@${baseline.version} to ${current.emitter}@${current.version}.`);
|
|
194
|
+
}
|
|
195
|
+
const baselineFiles = new Map(baseline.files.map((entry) => [normalizePath(entry.path), entry]));
|
|
196
|
+
const currentFiles = new Map(current.files.map((entry) => [normalizePath(entry.path), entry]));
|
|
197
|
+
for (const filePath of sortedUnion([...baselineFiles.keys()], [...currentFiles.keys()])) {
|
|
198
|
+
const left = baselineFiles.get(filePath);
|
|
199
|
+
const right = currentFiles.get(filePath);
|
|
200
|
+
if (!left && right) {
|
|
201
|
+
summary.files.added += 1;
|
|
202
|
+
}
|
|
203
|
+
else if (left && !right) {
|
|
204
|
+
summary.files.deleted += 1;
|
|
205
|
+
addFailure(failures, "files.deleted", `${filePath} was deleted from generated manifest.`);
|
|
206
|
+
}
|
|
207
|
+
else if (left && right && manifestOwnershipChanged(left, right)) {
|
|
208
|
+
summary.files.ownershipChanged += 1;
|
|
209
|
+
addFailure(failures, "files.ownership", `${filePath} generated ownership metadata changed.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function compareProtectedPaths(manifest, baselineHydration, hydration, config, summary, failures) {
|
|
214
|
+
const protectedPaths = getProtectedPathPatterns(config, baselineHydration, hydration);
|
|
215
|
+
if (protectedPaths.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
for (const entry of manifest.files) {
|
|
218
|
+
const filePath = normalizePath(entry.path);
|
|
219
|
+
if (protectedPaths.some((pattern) => pattern.test(filePath))) {
|
|
220
|
+
summary.protectedPathTouches += 1;
|
|
221
|
+
addFailure(failures, "protected-path.touch", `${filePath} matches a protected path.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function compareHydrationBoundaryMetadata(baseline, current, failures) {
|
|
226
|
+
if (!baseline && !current)
|
|
227
|
+
return;
|
|
228
|
+
if (!baseline && current) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (baseline && !current) {
|
|
232
|
+
addFailure(failures, "hydration-boundary.changed", "Hydration boundary metadata was removed.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!baseline || !current)
|
|
236
|
+
return;
|
|
237
|
+
if (baseline.emitter !== current.emitter || baseline.version !== current.version) {
|
|
238
|
+
addFailure(failures, "hydration-boundary.changed", "Hydration boundary metadata identity changed.");
|
|
239
|
+
}
|
|
240
|
+
if (stableStringify(baseline.protectedPaths) !== stableStringify(current.protectedPaths)) {
|
|
241
|
+
addFailure(failures, "hydration-boundary.protected-paths", "Hydration boundary protected paths changed.");
|
|
242
|
+
}
|
|
243
|
+
if (stableStringify(baseline.hydrationZones) !== stableStringify(current.hydrationZones)) {
|
|
244
|
+
addFailure(failures, "hydration-boundary.zones", "Hydration zones changed.");
|
|
245
|
+
}
|
|
246
|
+
if (stableStringify(baseline.seams) !== stableStringify(current.seams)) {
|
|
247
|
+
addFailure(failures, "hydration-boundary.seams", "Hydration seams changed.");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function compareHydrationZones(manifest, hydration, config, summary) {
|
|
251
|
+
const configuredZones = [...(config.hydrationZones ?? []), ...(hydration?.hydrationZones ?? [])].map((entry) => globToRegExp(normalizePath(entry)));
|
|
252
|
+
if (configuredZones.length === 0)
|
|
253
|
+
return;
|
|
254
|
+
for (const entry of manifest.files) {
|
|
255
|
+
const filePath = normalizePath(entry.path);
|
|
256
|
+
if (configuredZones.some((pattern) => pattern.test(filePath))) {
|
|
257
|
+
summary.hydrationZoneTouches += 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function compareSchemaEvolution(baseline, current, summary, failures) {
|
|
262
|
+
if (!baseline && !current)
|
|
263
|
+
return [];
|
|
264
|
+
if (!baseline || !current) {
|
|
265
|
+
addFailure(failures, "schema.missing-model", `Schema evolution could not run because ${baseline ? "current" : "baseline"} json-ast/model.json is missing.`);
|
|
266
|
+
return [
|
|
267
|
+
{
|
|
268
|
+
kind: baseline ? "type-removed" : "type-added",
|
|
269
|
+
path: "json-ast/model.json",
|
|
270
|
+
severity: "major",
|
|
271
|
+
message: `${baseline ? "Current" : "Baseline"} json-ast/model.json is missing.`,
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
const changes = [];
|
|
276
|
+
const baselineTypes = flattenSchemaTypes(baseline);
|
|
277
|
+
const currentTypes = flattenSchemaTypes(current);
|
|
278
|
+
for (const typeName of sortedUnion([...baselineTypes.keys()], [...currentTypes.keys()])) {
|
|
279
|
+
const left = baselineTypes.get(typeName);
|
|
280
|
+
const right = currentTypes.get(typeName);
|
|
281
|
+
if (!left && right) {
|
|
282
|
+
summary.schema.addedTypes += 1;
|
|
283
|
+
changes.push({
|
|
284
|
+
kind: "type-added",
|
|
285
|
+
path: typeName,
|
|
286
|
+
severity: "minor",
|
|
287
|
+
message: `${typeName} was added.`,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (left && !right) {
|
|
292
|
+
summary.schema.removedTypes += 1;
|
|
293
|
+
changes.push({
|
|
294
|
+
kind: "type-removed",
|
|
295
|
+
path: typeName,
|
|
296
|
+
severity: "major",
|
|
297
|
+
message: `${typeName} was removed.`,
|
|
298
|
+
});
|
|
299
|
+
addFailure(failures, "schema.type-removed", `${typeName} was removed.`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (!left || !right)
|
|
303
|
+
continue;
|
|
304
|
+
if ((left.discriminator ?? "") !== (right.discriminator ?? "")) {
|
|
305
|
+
summary.schema.discriminatorsChanged += 1;
|
|
306
|
+
changes.push({
|
|
307
|
+
kind: "type-discriminator-changed",
|
|
308
|
+
path: typeName,
|
|
309
|
+
severity: "major",
|
|
310
|
+
message: `${typeName} discriminator changed from ${left.discriminator ?? "<none>"} to ${right.discriminator ?? "<none>"}.`,
|
|
311
|
+
});
|
|
312
|
+
addFailure(failures, "schema.discriminator", `${typeName} discriminator changed.`);
|
|
313
|
+
}
|
|
314
|
+
compareSchemaProperties(typeName, left, right, summary, failures, changes);
|
|
315
|
+
}
|
|
316
|
+
return changes.sort((left, right) => `${left.kind}:${left.path}`.localeCompare(`${right.kind}:${right.path}`));
|
|
317
|
+
}
|
|
318
|
+
function compareSchemaProperties(typeName, baseline, current, summary, failures, changes) {
|
|
319
|
+
const baselineProperties = mapSchemaProperties(baseline);
|
|
320
|
+
const currentProperties = mapSchemaProperties(current);
|
|
321
|
+
for (const propertyName of sortedUnion([...baselineProperties.keys()], [...currentProperties.keys()])) {
|
|
322
|
+
const left = baselineProperties.get(propertyName);
|
|
323
|
+
const right = currentProperties.get(propertyName);
|
|
324
|
+
const pathName = `${typeName}.${propertyName}`;
|
|
325
|
+
if (!left && right) {
|
|
326
|
+
if (right.isOptional) {
|
|
327
|
+
summary.schema.addedOptionalProperties += 1;
|
|
328
|
+
changes.push({
|
|
329
|
+
kind: "property-added-optional",
|
|
330
|
+
path: pathName,
|
|
331
|
+
severity: "minor",
|
|
332
|
+
message: `${pathName} optional property was added.`,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
summary.schema.addedRequiredProperties += 1;
|
|
337
|
+
changes.push({
|
|
338
|
+
kind: "property-added-required",
|
|
339
|
+
path: pathName,
|
|
340
|
+
severity: "major",
|
|
341
|
+
message: `${pathName} required property was added.`,
|
|
342
|
+
});
|
|
343
|
+
addFailure(failures, "schema.required-added", `${pathName} required property was added.`);
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (left && !right) {
|
|
348
|
+
summary.schema.removedProperties += 1;
|
|
349
|
+
changes.push({
|
|
350
|
+
kind: "property-removed",
|
|
351
|
+
path: pathName,
|
|
352
|
+
severity: "major",
|
|
353
|
+
message: `${pathName} property was removed.`,
|
|
354
|
+
});
|
|
355
|
+
addFailure(failures, "schema.property-removed", `${pathName} property was removed.`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!left || !right)
|
|
359
|
+
continue;
|
|
360
|
+
if ((left.isOptional ?? false) !== (right.isOptional ?? false)) {
|
|
361
|
+
summary.schema.requirednessChanged += 1;
|
|
362
|
+
changes.push({
|
|
363
|
+
kind: "property-requiredness-changed",
|
|
364
|
+
path: pathName,
|
|
365
|
+
severity: "major",
|
|
366
|
+
message: `${pathName} requiredness changed.`,
|
|
367
|
+
});
|
|
368
|
+
addFailure(failures, "schema.requiredness", `${pathName} requiredness changed.`);
|
|
369
|
+
}
|
|
370
|
+
if (stableStringify(propertyTypeSignature(left)) !== stableStringify(propertyTypeSignature(right))) {
|
|
371
|
+
summary.schema.propertyTypesChanged += 1;
|
|
372
|
+
changes.push({
|
|
373
|
+
kind: "property-type-changed",
|
|
374
|
+
path: pathName,
|
|
375
|
+
severity: "major",
|
|
376
|
+
message: `${pathName} type shape changed.`,
|
|
377
|
+
});
|
|
378
|
+
addFailure(failures, "schema.property-type", `${pathName} type shape changed.`);
|
|
379
|
+
}
|
|
380
|
+
if (stableStringify(normalizeKnownAs(left.knownAs)) !== stableStringify(normalizeKnownAs(right.knownAs))) {
|
|
381
|
+
summary.schema.wireNamesChanged += 1;
|
|
382
|
+
changes.push({
|
|
383
|
+
kind: "property-wire-name-changed",
|
|
384
|
+
path: pathName,
|
|
385
|
+
severity: "major",
|
|
386
|
+
message: `${pathName} wire-name mappings changed.`,
|
|
387
|
+
});
|
|
388
|
+
addFailure(failures, "schema.wire-name", `${pathName} wire-name mappings changed.`);
|
|
389
|
+
}
|
|
390
|
+
if (stableStringify(enumSignature(left)) !== stableStringify(enumSignature(right))) {
|
|
391
|
+
summary.schema.enumValuesChanged += 1;
|
|
392
|
+
changes.push({
|
|
393
|
+
kind: "property-enum-values-changed",
|
|
394
|
+
path: pathName,
|
|
395
|
+
severity: "major",
|
|
396
|
+
message: `${pathName} enum values changed.`,
|
|
397
|
+
});
|
|
398
|
+
addFailure(failures, "schema.enum", `${pathName} enum values changed.`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function buildConformanceMap(snapshot) {
|
|
403
|
+
const contracts = new Map();
|
|
404
|
+
for (const target of snapshot.targets) {
|
|
405
|
+
for (const entry of target.exports) {
|
|
406
|
+
const key = `${entry.protocol ? "protocol" : "type"}:${entry.name}`;
|
|
407
|
+
const mapEntry = contracts.get(key) ?? {
|
|
408
|
+
contract: entry.name,
|
|
409
|
+
protocol: entry.protocol,
|
|
410
|
+
targets: [],
|
|
411
|
+
};
|
|
412
|
+
mapEntry.targets.push({
|
|
413
|
+
target: target.target,
|
|
414
|
+
symbol: entry.name,
|
|
415
|
+
source: entry.source,
|
|
416
|
+
packageName: target.packageName,
|
|
417
|
+
namespace: target.namespace,
|
|
418
|
+
outputRoot: target.outputRoot,
|
|
419
|
+
modules: target.modules,
|
|
420
|
+
exported: target.rootExports.includes(entry.name),
|
|
421
|
+
});
|
|
422
|
+
contracts.set(key, mapEntry);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return Array.from(contracts.values())
|
|
426
|
+
.map((entry) => ({
|
|
427
|
+
...entry,
|
|
428
|
+
targets: entry.targets.sort((left, right) => left.target.localeCompare(right.target)),
|
|
429
|
+
}))
|
|
430
|
+
.sort((left, right) => `${left.protocol}:${left.contract}`.localeCompare(`${right.protocol}:${right.contract}`));
|
|
431
|
+
}
|
|
432
|
+
function buildStaleCleanupDryRun(baseline, current, config, baselineHydration, hydration) {
|
|
433
|
+
const currentFiles = new Set(current.files.map((entry) => normalizePath(entry.path)));
|
|
434
|
+
const protectedPaths = getProtectedPathPatterns(config, baselineHydration, hydration);
|
|
435
|
+
const hydrationZones = [...(config.hydrationZones ?? []), ...(baselineHydration?.hydrationZones ?? []), ...(hydration?.hydrationZones ?? [])]
|
|
436
|
+
.map((entry) => globToRegExp(normalizePath(entry)));
|
|
437
|
+
return baseline.files
|
|
438
|
+
.filter((entry) => !currentFiles.has(normalizePath(entry.path)))
|
|
439
|
+
.map((entry) => {
|
|
440
|
+
const filePath = normalizePath(entry.path);
|
|
441
|
+
const reasons = [
|
|
442
|
+
"present in prior generated manifest",
|
|
443
|
+
entry.marker ? "prior entry was marked generated" : "prior entry was not marked generated",
|
|
444
|
+
`scoped to output root ${normalizePath(entry.outputRoot)}`,
|
|
445
|
+
];
|
|
446
|
+
const protectedMatch = protectedPaths.some((pattern) => pattern.test(filePath));
|
|
447
|
+
const hydrationZoneMatch = hydrationZones.some((pattern) => pattern.test(filePath));
|
|
448
|
+
reasons.push(protectedMatch ? "blocked by protected path" : "not protected");
|
|
449
|
+
if (hydrationZoneMatch)
|
|
450
|
+
reasons.push("inside hydration zone");
|
|
451
|
+
return {
|
|
452
|
+
path: filePath,
|
|
453
|
+
reasons,
|
|
454
|
+
safe: entry.marker && !protectedMatch && !hydrationZoneMatch,
|
|
455
|
+
};
|
|
456
|
+
})
|
|
457
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
458
|
+
}
|
|
459
|
+
function buildHydrationBoundaryReport(hydration, config) {
|
|
460
|
+
return {
|
|
461
|
+
protectedPaths: uniqueSorted([...(config.protectedPaths ?? []), ...(hydration?.protectedPaths ?? [])]),
|
|
462
|
+
hydrationZones: uniqueSorted([...(config.hydrationZones ?? []), ...(hydration?.hydrationZones ?? [])]),
|
|
463
|
+
seams: [...(hydration?.seams ?? [])].sort((left, right) => `${left.target}:${left.group}:${left.contract}:${left.symbol}`.localeCompare(`${right.target}:${right.group}:${right.contract}:${right.symbol}`)),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function getProtectedPathPatterns(config, baselineHydration, hydration) {
|
|
467
|
+
return [...(config.protectedPaths ?? []), ...(baselineHydration?.protectedPaths ?? []), ...(hydration?.protectedPaths ?? [])]
|
|
468
|
+
.map((entry) => globToRegExp(normalizePath(entry)));
|
|
469
|
+
}
|
|
470
|
+
function classifyBreakingChange(summary, schemaEvolution, failures) {
|
|
471
|
+
if (failures.some((failure) => failure.blocking) || schemaEvolution.some((change) => change.severity === "major")) {
|
|
472
|
+
return "major";
|
|
473
|
+
}
|
|
474
|
+
if (summary.exports.added > 0 ||
|
|
475
|
+
summary.protocols.added > 0 ||
|
|
476
|
+
summary.files.added > 0 ||
|
|
477
|
+
summary.schema.addedOptionalProperties > 0 ||
|
|
478
|
+
summary.schema.addedTypes > 0) {
|
|
479
|
+
return "minor";
|
|
480
|
+
}
|
|
481
|
+
return "patch";
|
|
482
|
+
}
|
|
483
|
+
function metadataFile(root, fileName) {
|
|
484
|
+
const direct = path.join(root, fileName);
|
|
485
|
+
if (existsSync(direct))
|
|
486
|
+
return direct;
|
|
487
|
+
return path.join(root, ".typra-generated", fileName);
|
|
488
|
+
}
|
|
489
|
+
function modelFile(root) {
|
|
490
|
+
const direct = path.join(root, "json-ast", "model.json");
|
|
491
|
+
if (existsSync(direct))
|
|
492
|
+
return direct;
|
|
493
|
+
const sibling = path.join(root, "..", "json-ast", "model.json");
|
|
494
|
+
if (existsSync(sibling))
|
|
495
|
+
return sibling;
|
|
496
|
+
return direct;
|
|
497
|
+
}
|
|
498
|
+
function readJson(filePath) {
|
|
499
|
+
if (!existsSync(filePath)) {
|
|
500
|
+
throw new Error(`Missing Typra verifier input: ${filePath}`);
|
|
501
|
+
}
|
|
502
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
503
|
+
}
|
|
504
|
+
function readOptionalJson(filePath) {
|
|
505
|
+
if (!existsSync(filePath)) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
509
|
+
}
|
|
510
|
+
function mapTargets(targets) {
|
|
511
|
+
return new Map(targets.map((target) => [target.target, target]));
|
|
512
|
+
}
|
|
513
|
+
function mapExports(targets) {
|
|
514
|
+
const entries = new Map();
|
|
515
|
+
for (const target of targets) {
|
|
516
|
+
for (const entry of target.exports) {
|
|
517
|
+
entries.set(`${target.target}:${entry.group}:${entry.name}`, { target: target.target, entry });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return entries;
|
|
521
|
+
}
|
|
522
|
+
function mapProtocols(targets) {
|
|
523
|
+
const entries = new Map();
|
|
524
|
+
for (const target of targets) {
|
|
525
|
+
for (const protocol of target.protocols) {
|
|
526
|
+
entries.set(`${target.target}:${protocol.group}:${protocol.name}`, { target: target.target, protocol });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return entries;
|
|
530
|
+
}
|
|
531
|
+
function exportChanged(left, right) {
|
|
532
|
+
return left.kind !== right.kind || left.source !== right.source || left.protocol !== right.protocol;
|
|
533
|
+
}
|
|
534
|
+
function exportSignature(entry) {
|
|
535
|
+
return stableStringify({
|
|
536
|
+
kind: entry.kind,
|
|
537
|
+
protocol: entry.protocol,
|
|
538
|
+
source: entry.source,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function protocolSignature(protocol) {
|
|
542
|
+
return stableStringify({
|
|
543
|
+
methods: protocol.methods,
|
|
544
|
+
source: protocol.source,
|
|
545
|
+
symbol: protocol.symbol,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
function manifestOwnershipChanged(left, right) {
|
|
549
|
+
return left.marker !== right.marker || normalizePath(left.outputRoot) !== normalizePath(right.outputRoot);
|
|
550
|
+
}
|
|
551
|
+
function addFailure(failures, code, message) {
|
|
552
|
+
failures.push({ code, message, blocking: true });
|
|
553
|
+
}
|
|
554
|
+
function cloneSummary() {
|
|
555
|
+
return JSON.parse(JSON.stringify(EMPTY_SUMMARY));
|
|
556
|
+
}
|
|
557
|
+
function sortedUnion(left, right) {
|
|
558
|
+
return Array.from(new Set([...left, ...right])).sort((a, b) => a.localeCompare(b));
|
|
559
|
+
}
|
|
560
|
+
function compareFailures(left, right) {
|
|
561
|
+
const byCode = left.code.localeCompare(right.code);
|
|
562
|
+
if (byCode !== 0)
|
|
563
|
+
return byCode;
|
|
564
|
+
return left.message.localeCompare(right.message);
|
|
565
|
+
}
|
|
566
|
+
function normalizePath(filePath) {
|
|
567
|
+
return filePath.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\.\//, "");
|
|
568
|
+
}
|
|
569
|
+
function flattenSchemaTypes(root) {
|
|
570
|
+
const types = new Map();
|
|
571
|
+
const visit = (node) => {
|
|
572
|
+
if (!node)
|
|
573
|
+
return;
|
|
574
|
+
const key = schemaTypeKey(node);
|
|
575
|
+
if (key && !types.has(key)) {
|
|
576
|
+
types.set(key, node);
|
|
577
|
+
}
|
|
578
|
+
for (const child of node.childTypes ?? [])
|
|
579
|
+
visit(child);
|
|
580
|
+
for (const property of node.properties ?? [])
|
|
581
|
+
visit(property.type);
|
|
582
|
+
};
|
|
583
|
+
visit(root);
|
|
584
|
+
return types;
|
|
585
|
+
}
|
|
586
|
+
function schemaTypeKey(node) {
|
|
587
|
+
const namespace = node.typeName?.namespace ?? "";
|
|
588
|
+
const name = node.typeName?.name ?? "";
|
|
589
|
+
return namespace ? `${namespace}.${name}` : name;
|
|
590
|
+
}
|
|
591
|
+
function mapSchemaProperties(node) {
|
|
592
|
+
return new Map((node.properties ?? []).filter((property) => !!property.name).map((property) => [property.name, property]));
|
|
593
|
+
}
|
|
594
|
+
function normalizeKnownAs(knownAs) {
|
|
595
|
+
return (knownAs ?? [])
|
|
596
|
+
.map((entry) => ({
|
|
597
|
+
provider: entry.provider ?? "",
|
|
598
|
+
name: entry.name ?? "",
|
|
599
|
+
}))
|
|
600
|
+
.sort((left, right) => `${left.provider}:${left.name}`.localeCompare(`${right.provider}:${right.name}`));
|
|
601
|
+
}
|
|
602
|
+
function propertyTypeSignature(property) {
|
|
603
|
+
return {
|
|
604
|
+
typeName: {
|
|
605
|
+
namespace: property.typeName?.namespace ?? "",
|
|
606
|
+
name: property.typeName?.name ?? "",
|
|
607
|
+
},
|
|
608
|
+
isScalar: property.isScalar ?? false,
|
|
609
|
+
isCollection: property.isCollection ?? false,
|
|
610
|
+
isAny: property.isAny ?? false,
|
|
611
|
+
isDict: property.isDict ?? false,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function enumSignature(property) {
|
|
615
|
+
return {
|
|
616
|
+
allowedValues: [...(property.allowedValues ?? [])].sort((left, right) => left.localeCompare(right)),
|
|
617
|
+
enumName: property.enumName ?? "",
|
|
618
|
+
isOpenEnum: property.isOpenEnum ?? false,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function uniqueSorted(values) {
|
|
622
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
|
623
|
+
}
|
|
624
|
+
function globToRegExp(pattern) {
|
|
625
|
+
let source = "^";
|
|
626
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
627
|
+
const char = pattern[index];
|
|
628
|
+
const next = pattern[index + 1];
|
|
629
|
+
const afterNext = pattern[index + 2];
|
|
630
|
+
if (char === "*" && next === "*" && afterNext === "/") {
|
|
631
|
+
source += "(?:.*/)?";
|
|
632
|
+
index += 2;
|
|
633
|
+
}
|
|
634
|
+
else if (char === "*" && next === "*") {
|
|
635
|
+
source += ".*";
|
|
636
|
+
index += 1;
|
|
637
|
+
}
|
|
638
|
+
else if (char === "*") {
|
|
639
|
+
source += "[^/]*";
|
|
640
|
+
}
|
|
641
|
+
else if (char === "?") {
|
|
642
|
+
source += "[^/]";
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
source += escapeRegExp(char);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return new RegExp(`${source}$`);
|
|
649
|
+
}
|
|
650
|
+
function escapeRegExp(value) {
|
|
651
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
652
|
+
}
|
|
653
|
+
function stableStringify(value) {
|
|
654
|
+
return JSON.stringify(sortValue(value));
|
|
655
|
+
}
|
|
656
|
+
function sortValue(value) {
|
|
657
|
+
if (Array.isArray(value)) {
|
|
658
|
+
return value.map(sortValue);
|
|
659
|
+
}
|
|
660
|
+
if (value && typeof value === "object") {
|
|
661
|
+
return Object.fromEntries(Object.entries(value)
|
|
662
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
663
|
+
.map(([key, entry]) => [key, sortValue(entry)]));
|
|
664
|
+
}
|
|
665
|
+
return value;
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { formatVerifySummary, verifyTypraMetadata } from "./verify/index.js";
|
|
4
|
+
const HELP = `
|
|
5
|
+
typra-verify - Verify Typra generated metadata drift
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
npx typra-verify --baseline <dir> --current <dir> [options]
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--baseline <dir> Baseline output root or .typra-generated directory (required)
|
|
12
|
+
--current <dir> Current output root or .typra-generated directory (required)
|
|
13
|
+
--config <file> Optional verifier config JSON with protectedPaths
|
|
14
|
+
--json Print machine-readable JSON result
|
|
15
|
+
-h, --help Show this help message
|
|
16
|
+
`;
|
|
17
|
+
async function main() {
|
|
18
|
+
const { values } = parseArgs({
|
|
19
|
+
options: {
|
|
20
|
+
baseline: { type: "string" },
|
|
21
|
+
current: { type: "string" },
|
|
22
|
+
config: { type: "string" },
|
|
23
|
+
json: { type: "boolean", default: false },
|
|
24
|
+
help: { type: "boolean", short: "h" },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
if (values.help) {
|
|
28
|
+
console.log(HELP);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
if (!values.baseline || !values.current) {
|
|
32
|
+
console.error("Error: --baseline and --current are required.\n");
|
|
33
|
+
console.log(HELP);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const result = verifyTypraMetadata({
|
|
37
|
+
baselineRoot: values.baseline,
|
|
38
|
+
currentRoot: values.current,
|
|
39
|
+
configPath: values.config,
|
|
40
|
+
});
|
|
41
|
+
if (values.json) {
|
|
42
|
+
console.log(JSON.stringify(result, null, 2));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
process.stdout.write(formatVerifySummary(result));
|
|
46
|
+
}
|
|
47
|
+
process.exit(result.ok ? 0 : 1);
|
|
48
|
+
}
|
|
49
|
+
main().catch((error) => {
|
|
50
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
//# sourceMappingURL=verify-cli.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typra/emitter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Generic TypeSpec emitter for generating multi-runtime model surfaces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"types": "dist/src/index.d.ts",
|
|
14
14
|
"tspMain": "src/lib/main.tsp",
|
|
15
15
|
"bin": {
|
|
16
|
-
"typra-generate": "dist/src/cli.js"
|
|
16
|
+
"typra-generate": "dist/src/cli.js",
|
|
17
|
+
"typra-verify": "dist/src/verify-cli.js",
|
|
18
|
+
"typra-consumer-smoke": "dist/src/consumer-smoke.js"
|
|
17
19
|
},
|
|
18
20
|
"exports": {
|
|
19
21
|
".": {
|
|
@@ -24,6 +26,10 @@
|
|
|
24
26
|
"types": "./dist/src/generate.d.ts",
|
|
25
27
|
"default": "./dist/src/generate.js"
|
|
26
28
|
},
|
|
29
|
+
"./verify": {
|
|
30
|
+
"types": "./dist/src/verify/index.d.ts",
|
|
31
|
+
"default": "./dist/src/verify/index.js"
|
|
32
|
+
},
|
|
27
33
|
"./testing": {
|
|
28
34
|
"types": "./dist/src/testing/index.d.ts",
|
|
29
35
|
"default": "./dist/src/testing/index.js"
|