@coldtea/pr-lens-schema 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/apply.d.ts +42 -0
  4. package/dist/apply.d.ts.map +1 -0
  5. package/dist/apply.js +315 -0
  6. package/dist/apply.js.map +1 -0
  7. package/dist/config.d.ts +58 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +61 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/errors.d.ts +24 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +12 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/examples/baseline.d.ts +18 -0
  16. package/dist/examples/baseline.d.ts.map +1 -0
  17. package/dist/examples/baseline.js +433 -0
  18. package/dist/examples/baseline.js.map +1 -0
  19. package/dist/examples/index.d.ts +1194 -0
  20. package/dist/examples/index.d.ts.map +1 -0
  21. package/dist/examples/index.js +20 -0
  22. package/dist/examples/index.js.map +1 -0
  23. package/dist/examples/minimal.d.ts +4 -0
  24. package/dist/examples/minimal.d.ts.map +1 -0
  25. package/dist/examples/minimal.js +25 -0
  26. package/dist/examples/minimal.js.map +1 -0
  27. package/dist/examples/postmark-refactor.d.ts +16 -0
  28. package/dist/examples/postmark-refactor.d.ts.map +1 -0
  29. package/dist/examples/postmark-refactor.js +468 -0
  30. package/dist/examples/postmark-refactor.js.map +1 -0
  31. package/dist/graph.d.ts +568 -0
  32. package/dist/graph.d.ts.map +1 -0
  33. package/dist/graph.js +266 -0
  34. package/dist/graph.js.map +1 -0
  35. package/dist/index.d.ts +12 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +12 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/integrity.d.ts +20 -0
  40. package/dist/integrity.d.ts.map +1 -0
  41. package/dist/integrity.js +167 -0
  42. package/dist/integrity.js.map +1 -0
  43. package/dist/manifest.d.ts +65 -0
  44. package/dist/manifest.d.ts.map +1 -0
  45. package/dist/manifest.js +60 -0
  46. package/dist/manifest.js.map +1 -0
  47. package/dist/patch.d.ts +824 -0
  48. package/dist/patch.d.ts.map +1 -0
  49. package/dist/patch.js +84 -0
  50. package/dist/patch.js.map +1 -0
  51. package/dist/primitives.d.ts +92 -0
  52. package/dist/primitives.d.ts.map +1 -0
  53. package/dist/primitives.js +120 -0
  54. package/dist/primitives.js.map +1 -0
  55. package/dist/utils.d.ts +6 -0
  56. package/dist/utils.d.ts.map +1 -0
  57. package/dist/utils.js +8 -0
  58. package/dist/utils.js.map +1 -0
  59. package/dist/validate.d.ts +18 -0
  60. package/dist/validate.d.ts.map +1 -0
  61. package/dist/validate.js +95 -0
  62. package/dist/validate.js.map +1 -0
  63. package/dist/version.d.ts +21 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +24 -0
  66. package/dist/version.js.map +1 -0
  67. package/examples/broadcast-baseline.graph.json +289 -0
  68. package/examples/broadcast-baseline.patch.json +321 -0
  69. package/examples/minimal.graph.json +45 -0
  70. package/examples/postmark-refactor.graph.json +580 -0
  71. package/examples/postmark-refactor.render-manifest.json +67 -0
  72. package/examples/pr-lens.config.json +32 -0
  73. package/json-schema/config.schema.json +153 -0
  74. package/json-schema/graph-doc.schema.json +1032 -0
  75. package/json-schema/patch-doc.schema.json +1383 -0
  76. package/json-schema/render-manifest.schema.json +183 -0
  77. package/package.json +66 -0
  78. package/src/apply.ts +399 -0
  79. package/src/config.ts +69 -0
  80. package/src/errors.ts +34 -0
  81. package/src/examples/baseline.ts +437 -0
  82. package/src/examples/index.ts +25 -0
  83. package/src/examples/minimal.ts +26 -0
  84. package/src/examples/postmark-refactor.ts +480 -0
  85. package/src/graph.ts +331 -0
  86. package/src/index.ts +82 -0
  87. package/src/integrity.ts +216 -0
  88. package/src/manifest.ts +64 -0
  89. package/src/patch.ts +100 -0
  90. package/src/primitives.ts +146 -0
  91. package/src/utils.ts +7 -0
  92. package/src/validate.ts +132 -0
  93. package/src/version.ts +31 -0
package/src/patch.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ import { Flow, GraphEdge, GraphNode, Lane, Stats } from "./graph.js";
3
+ import { FullSha, Id, SchemaVersionField, Summary } from "./primitives.js";
4
+
5
+ /**
6
+ * Update payloads are the element minus its id: a patch never renames the
7
+ * thing it addresses. Every field is optional and only the supplied fields
8
+ * are written, so two producers can patch different facets of one element
9
+ * without clobbering each other.
10
+ */
11
+ export const LanePatch = Lane.omit({ id: true }).partial();
12
+ export const NodePatch = GraphNode.omit({ id: true }).partial();
13
+ export const EdgePatch = GraphEdge.omit({ id: true }).partial();
14
+ export const FlowPatch = Flow.omit({ id: true }).partial();
15
+
16
+ /**
17
+ * Operations that evolve a stored graph — in practice the baseline map, which
18
+ * is updated by each merged pull request rather than re-extracted wholesale.
19
+ *
20
+ * `remove_*` deletes the element from the map. That is a different statement
21
+ * from `delta: "removed"`, which says an element still exists in the map but
22
+ * is being deleted by the change under review.
23
+ */
24
+ export const PatchOp = z
25
+ .discriminatedUnion("op", [
26
+ z.strictObject({ op: z.literal("add_lane"), lane: Lane }),
27
+ z.strictObject({ op: z.literal("update_lane"), id: Id, patch: LanePatch }),
28
+ z.strictObject({ op: z.literal("remove_lane"), id: Id }),
29
+
30
+ z.strictObject({ op: z.literal("add_node"), node: GraphNode }),
31
+ z.strictObject({ op: z.literal("update_node"), id: Id, patch: NodePatch }),
32
+ z.strictObject({ op: z.literal("remove_node"), id: Id }),
33
+
34
+ z.strictObject({ op: z.literal("add_edge"), edge: GraphEdge }),
35
+ z.strictObject({ op: z.literal("update_edge"), id: Id, patch: EdgePatch }),
36
+ z.strictObject({ op: z.literal("remove_edge"), id: Id }),
37
+
38
+ z.strictObject({ op: z.literal("add_flow"), flow: Flow }),
39
+ z.strictObject({ op: z.literal("update_flow"), id: Id, patch: FlowPatch }),
40
+ z.strictObject({ op: z.literal("remove_flow"), id: Id }),
41
+
42
+ z.strictObject({ op: z.literal("set_stats"), stats: Stats }),
43
+ ])
44
+ .describe("A single change to a stored graph document.");
45
+ export type PatchOp = z.infer<typeof PatchOp>;
46
+
47
+ export const PATCH_OPS = [
48
+ "add_lane",
49
+ "update_lane",
50
+ "remove_lane",
51
+ "add_node",
52
+ "update_node",
53
+ "remove_node",
54
+ "add_edge",
55
+ "update_edge",
56
+ "remove_edge",
57
+ "add_flow",
58
+ "update_flow",
59
+ "remove_flow",
60
+ "set_stats",
61
+ ] as const;
62
+
63
+ /**
64
+ * A patch has to move the map: the same commit at both ends describes no
65
+ * transition, and such a patch could be applied over and over.
66
+ *
67
+ * The rule lives here as a predicate because a zod refinement does not
68
+ * survive into the inferred type — a caller holding a `PatchDoc` it built
69
+ * itself has to be held to the same rule as one that came from a parser.
70
+ */
71
+ export const targetDescribesATransition = (target: {
72
+ fromSha: string;
73
+ toSha: string;
74
+ }): boolean => target.fromSha !== target.toSha;
75
+
76
+ /** An ordered batch of operations against one stored graph. */
77
+ export const PatchDoc = z
78
+ .strictObject({
79
+ schemaVersion: SchemaVersionField,
80
+ kind: z.literal("patch"),
81
+ generatedAt: z.iso.datetime().optional(),
82
+ summary: Summary.optional().describe("Why the map is changing, in prose."),
83
+ target: z
84
+ .strictObject({
85
+ graphId: Id.describe("Id of the stored graph being patched."),
86
+ fromSha: FullSha.describe("Commit the stored graph reflects before the operations run."),
87
+ toSha: FullSha.describe("Commit it reflects once they have."),
88
+ })
89
+ .refine(targetDescribesATransition, {
90
+ message: "a patch has to move the map to a different commit",
91
+ path: ["toSha"],
92
+ })
93
+ .describe(
94
+ "Which stored graph these operations belong to, and which commits they carry it between. All three are required, and the commits must differ: they are what stops a patch landing on the wrong map, on a stale one, or twice.",
95
+ ),
96
+ ops: z.array(PatchOp).min(1).max(512).describe("Applied in array order."),
97
+ })
98
+ .describe("A PR Lens patch document.");
99
+ export type PatchDoc = z.infer<typeof PatchDoc>;
100
+ export type PatchDocInput = z.input<typeof PatchDoc>;
@@ -0,0 +1,146 @@
1
+ import { z } from "zod";
2
+ import { SCHEMA_VERSION, SUPPORTED_VERSION_PATTERN } from "./version.js";
3
+
4
+ /**
5
+ * Identifiers are authored by an extraction model, so they are constrained to
6
+ * a shape that survives being embedded in an SVG id, a URL fragment and a
7
+ * GitHub comment anchor without escaping.
8
+ */
9
+ export const Id = z
10
+ .string()
11
+ .min(1)
12
+ .max(128)
13
+ .regex(
14
+ /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/,
15
+ "must start alphanumeric and contain only letters, digits and . _ : / -",
16
+ )
17
+ .describe("Stable identifier, unique within its collection in a document.");
18
+ export type Id = z.infer<typeof Id>;
19
+
20
+ /**
21
+ * The loose shape is checked here and the supported range is checked by the
22
+ * parser, which can say which version it implements. The exported JSON
23
+ * Schemas carry the range instead, since they have no parser behind them.
24
+ */
25
+ export const SchemaVersionField = z
26
+ .string()
27
+ .regex(/^\d+\.\d+\.\d+$/, "must be a semver string, e.g. 0.1.0")
28
+ .meta({ pattern: SUPPORTED_VERSION_PATTERN })
29
+ .describe(`Contract version the document targets. Current: ${SCHEMA_VERSION}.`);
30
+
31
+ /** Non-empty single-line label rendered on a card, lane header or edge. */
32
+ export const Label = z.string().min(1).max(120).describe("Short display label.");
33
+
34
+ /** Prose shown in drill-down bodies; kept short enough to stay scannable. */
35
+ export const Summary = z
36
+ .string()
37
+ .min(1)
38
+ .max(2000)
39
+ .describe("One or two sentences of plain prose. No markdown headings.");
40
+
41
+ export const Sha = z
42
+ .string()
43
+ .regex(/^[0-9a-f]{7,40}$/, "must be a lowercase hex git object name")
44
+ .describe("Git commit sha, abbreviated or full.");
45
+
46
+ /**
47
+ * Abbreviations are fine for something a human reads, but not for deciding
48
+ * whether two records mean the same commit: two abbreviations of different
49
+ * lengths compare unequal, and a short one can collide as a repository grows.
50
+ * Anything a machine compares uses the full name.
51
+ */
52
+ export const FullSha = z
53
+ .string()
54
+ .regex(/^[0-9a-f]{40}$/, "must be a full 40-character lowercase hex git object name")
55
+ .describe("Git commit sha, in full.");
56
+
57
+ /**
58
+ * The two lenses PR Lens ships. The enum is additive: a future contract
59
+ * version may introduce further lenses, and consumers must treat an unknown
60
+ * lens as "skip this view" rather than as a hard failure.
61
+ */
62
+ export const Lens = z.enum(["architecture", "data-flow"]).describe("Rendering lens.");
63
+ export type Lens = z.infer<typeof Lens>;
64
+
65
+ export const LENSES = Lens.options;
66
+
67
+ /** The two renders that make a `<picture>` pair. */
68
+ export const Theme = z.enum(["light", "dark"]).describe("Which colour scheme a render targets.");
69
+ export type Theme = z.infer<typeof Theme>;
70
+
71
+ export const THEMES = Theme.options;
72
+
73
+ /**
74
+ * A render is one asset per view per theme, so these two caps are one rule
75
+ * rather than two numbers that happen to sit near each other: a document with
76
+ * more views than a manifest can carry at every theme is one whose full
77
+ * render could never be described, however well formed it looks.
78
+ *
79
+ * Deriving the view cap from the asset budget keeps the relationship in one
80
+ * place — raise the budget, or add a theme, and the other end moves with it
81
+ * instead of every surface rediscovering the arithmetic.
82
+ *
83
+ * The cap is deliberately the worst case, every theme rendered, rather than
84
+ * what some particular render would emit. A renderer asked for one theme
85
+ * could describe twice as many views, but then whether a document is
86
+ * renderable would depend on how it was asked to be rendered, and the promise
87
+ * this package exists to make — if it parses, it renders — would need a
88
+ * second rule at a second boundary to stay true.
89
+ */
90
+ export const MAX_RENDER_ASSETS = 256;
91
+
92
+ export const MAX_VIEWS = MAX_RENDER_ASSETS / THEMES.length;
93
+
94
+ /**
95
+ * How an element relates to the base branch. `unchanged` elements are the
96
+ * context a reader needs to judge blast radius, so they are first-class
97
+ * rather than omitted.
98
+ */
99
+ export const Delta = z
100
+ .enum(["added", "modified", "removed", "unchanged"])
101
+ .describe("Change state relative to the base commit.");
102
+ export type Delta = z.infer<typeof Delta>;
103
+
104
+ export const DELTAS = Delta.options;
105
+
106
+ /**
107
+ * One rule both representations share: no absolute path in any spelling a
108
+ * platform recognises, and no `..` segment. A path that breaks it cannot
109
+ * produce a diff permalink, whatever else it might mean.
110
+ */
111
+ const REPOSITORY_PATH = /^(?!\/)(?![A-Za-z]:)(?!.*\\)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/;
112
+
113
+ /**
114
+ * A pointer into the head tree, used to build diff permalinks. Line numbers
115
+ * are 1-based and refer to the head revision except on `removed` elements,
116
+ * where they refer to the base revision.
117
+ */
118
+ export const FileRef = z
119
+ .strictObject({
120
+ path: z
121
+ .string()
122
+ .min(1)
123
+ .max(1024)
124
+ .regex(
125
+ REPOSITORY_PATH,
126
+ "must be a repository-relative POSIX path, without a drive letter, a backslash or a '..' segment",
127
+ )
128
+ .describe("Repository-relative path, POSIX separators."),
129
+ startLine: z.int().min(1).optional().describe("1-based first line."),
130
+ endLine: z.int().min(1).optional().describe("1-based last line, inclusive."),
131
+ revision: z
132
+ .enum(["head", "base"])
133
+ .optional()
134
+ .describe("Which side of the diff the lines refer to. Defaults to head."),
135
+ })
136
+ .meta({ dependentRequired: { endLine: ["startLine"] } })
137
+ .refine((f) => f.endLine === undefined || f.startLine !== undefined, {
138
+ message: "endLine requires startLine",
139
+ path: ["endLine"],
140
+ })
141
+ .refine((f) => f.endLine === undefined || f.startLine === undefined || f.endLine >= f.startLine, {
142
+ message: "endLine must be greater than or equal to startLine",
143
+ path: ["endLine"],
144
+ })
145
+ .describe("A file (and optional line range) backing an element.");
146
+ export type FileRef = z.infer<typeof FileRef>;
package/src/utils.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Ends a switch over a closed set. Adding a variant then fails to compile at
3
+ * every consumer instead of silently falling through at runtime.
4
+ */
5
+ export const assertNever = (value: never, message = "Unhandled variant"): never => {
6
+ throw new Error(`${message}: ${JSON.stringify(value)}`);
7
+ };
@@ -0,0 +1,132 @@
1
+ import type { z } from "zod";
2
+ import { Config } from "./config.js";
3
+ import { formatIssues, PrLensSchemaError, type Parsed, type SchemaIssue } from "./errors.js";
4
+ import { GraphDoc } from "./graph.js";
5
+ import { graphIntegrityIssues } from "./integrity.js";
6
+ import { RenderManifest } from "./manifest.js";
7
+ import { PatchDoc } from "./patch.js";
8
+ import { isSupportedVersion, SCHEMA_VERSION } from "./version.js";
9
+
10
+ const formatPath = (path: readonly PropertyKey[]): string =>
11
+ path.reduce<string>((acc, segment) => {
12
+ if (typeof segment === "number") return `${acc}[${segment}]`;
13
+ return acc === "" ? String(segment) : `${acc}.${String(segment)}`;
14
+ }, "");
15
+
16
+ const toIssues = (error: z.ZodError): SchemaIssue[] =>
17
+ error.issues.map((issue) => ({
18
+ code: "INVALID_DOCUMENT" as const,
19
+ path: formatPath(issue.path),
20
+ message: issue.message,
21
+ }));
22
+
23
+ const versionIssues = (version: string, path: string): SchemaIssue[] =>
24
+ isSupportedVersion(version)
25
+ ? []
26
+ : [
27
+ {
28
+ code: "UNSUPPORTED_SCHEMA_VERSION",
29
+ path,
30
+ message: `document targets schema version ${version}; this package implements ${SCHEMA_VERSION}`,
31
+ },
32
+ ];
33
+
34
+ const fail = (label: string, issues: SchemaIssue[]): PrLensSchemaError => {
35
+ const first = issues[0];
36
+ return new PrLensSchemaError(
37
+ first ? first.code : "INVALID_DOCUMENT",
38
+ `invalid ${label}:\n${formatIssues(issues)}`,
39
+ issues,
40
+ );
41
+ };
42
+
43
+ /**
44
+ * Validating a recursive structure recurses, so a document nested deeply
45
+ * enough exhausts the stack while zod is still walking it — before any check
46
+ * of ours can count anything. A `safeParse` that throws would be a worse
47
+ * failure than the document it was handed, so the stack running out is
48
+ * reported as what it is: a document too deep to read.
49
+ *
50
+ * Every `RangeError` is read that way, not only the ones provoked by depth.
51
+ * That is deliberate: failing closed keeps the promise that these functions
52
+ * return a verdict rather than throwing one, and nesting is the only way a
53
+ * document is known to provoke one. Narrowing this to rethrow would trade
54
+ * that promise for a diagnosis.
55
+ */
56
+ const attemptParse = <Schema extends z.ZodType>(
57
+ schema: Schema,
58
+ input: unknown,
59
+ ): { read: true; value: z.infer<Schema> } | { read: false; issues: SchemaIssue[] } => {
60
+ try {
61
+ const result = schema.safeParse(input);
62
+ return result.success
63
+ ? { read: true, value: result.data }
64
+ : { read: false, issues: toIssues(result.error) };
65
+ } catch (error) {
66
+ if (!(error instanceof RangeError)) throw error;
67
+ return {
68
+ read: false,
69
+ issues: [
70
+ {
71
+ code: "INVALID_DOCUMENT",
72
+ path: "",
73
+ message: "document nests too deeply to read",
74
+ },
75
+ ],
76
+ };
77
+ }
78
+ };
79
+
80
+ const parseDocument = <Schema extends z.ZodType>(
81
+ schema: Schema,
82
+ label: string,
83
+ input: unknown,
84
+ extraIssues: (value: z.infer<Schema>) => SchemaIssue[],
85
+ ): Parsed<z.infer<Schema>> => {
86
+ const result = attemptParse(schema, input);
87
+ if (!result.read) return { ok: false, error: fail(label, result.issues) };
88
+
89
+ const issues = extraIssues(result.value);
90
+ if (issues.length > 0) return { ok: false, error: fail(label, issues) };
91
+
92
+ return { ok: true, value: result.value };
93
+ };
94
+
95
+ const unwrap = <T>(parsed: Parsed<T>): T => {
96
+ if (parsed.ok) return parsed.value;
97
+ throw parsed.error;
98
+ };
99
+
100
+ /**
101
+ * Structure, contract version and referential integrity in one pass. A
102
+ * document that survives this is safe to render without further checking.
103
+ */
104
+ export const safeParseGraphDoc = (input: unknown): Parsed<GraphDoc> =>
105
+ parseDocument(GraphDoc, "graph document", input, (doc) => [
106
+ ...versionIssues(doc.schemaVersion, "schemaVersion"),
107
+ ...graphIntegrityIssues(doc),
108
+ ]);
109
+
110
+ export const parseGraphDoc = (input: unknown): GraphDoc => unwrap(safeParseGraphDoc(input));
111
+
112
+ export const safeParsePatchDoc = (input: unknown): Parsed<PatchDoc> =>
113
+ parseDocument(PatchDoc, "patch document", input, (doc) =>
114
+ versionIssues(doc.schemaVersion, "schemaVersion"),
115
+ );
116
+
117
+ export const parsePatchDoc = (input: unknown): PatchDoc => unwrap(safeParsePatchDoc(input));
118
+
119
+ export const safeParseConfig = (input: unknown): Parsed<Config> =>
120
+ parseDocument(Config, "config", input, (config) =>
121
+ versionIssues(config.schemaVersion, "schemaVersion"),
122
+ );
123
+
124
+ export const parseConfig = (input: unknown): Config => unwrap(safeParseConfig(input));
125
+
126
+ export const safeParseRenderManifest = (input: unknown): Parsed<RenderManifest> =>
127
+ parseDocument(RenderManifest, "render manifest", input, (manifest) =>
128
+ versionIssues(manifest.schemaVersion, "schemaVersion"),
129
+ );
130
+
131
+ export const parseRenderManifest = (input: unknown): RenderManifest =>
132
+ unwrap(safeParseRenderManifest(input));
package/src/version.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Version of the PR Lens document contract.
3
+ *
4
+ * Every document carries this string so a consumer can refuse, migrate, or
5
+ * degrade gracefully when it meets a document it was not built for. Bumped
6
+ * with semver semantics: patch/minor releases only ever add optional fields
7
+ * or widen an enum, a major release may remove or retype a field.
8
+ */
9
+ export const SCHEMA_VERSION = "0.1.0" as const;
10
+
11
+ export type SchemaVersion = typeof SCHEMA_VERSION;
12
+
13
+ const [currentMajor = "0", currentMinor = "0"] = SCHEMA_VERSION.split(".");
14
+
15
+ const readableMinors = Array.from({ length: Number(currentMinor) + 1 }, (_, minor) => minor);
16
+
17
+ /**
18
+ * Which versions this package reads, as a pattern so the rule can be carried
19
+ * into the exported JSON Schemas rather than restated there.
20
+ *
21
+ * Below 1.0 a minor bump is allowed to break, so only the exact major.minor
22
+ * is accepted; from 1.0 on, the major must match and a newer minor is
23
+ * readable because minor releases only add optional fields.
24
+ */
25
+ export const SUPPORTED_VERSION_PATTERN =
26
+ currentMajor === "0"
27
+ ? `^0\\.${currentMinor}\\.\\d+$`
28
+ : `^${currentMajor}\\.(${readableMinors.join("|")})\\.\\d+$`;
29
+
30
+ export const isSupportedVersion = (version: string): boolean =>
31
+ new RegExp(SUPPORTED_VERSION_PATTERN).test(version);