@coldtea/pr-lens-cli 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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/dist/args.d.ts +18 -0
  4. package/dist/args.d.ts.map +1 -0
  5. package/dist/args.js +49 -0
  6. package/dist/args.js.map +1 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +8 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +97 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/commands/analyze.d.ts +4 -0
  16. package/dist/commands/analyze.d.ts.map +1 -0
  17. package/dist/commands/analyze.js +158 -0
  18. package/dist/commands/analyze.js.map +1 -0
  19. package/dist/commands/comment.d.ts +4 -0
  20. package/dist/commands/comment.d.ts.map +1 -0
  21. package/dist/commands/comment.js +63 -0
  22. package/dist/commands/comment.js.map +1 -0
  23. package/dist/commands/export.d.ts +4 -0
  24. package/dist/commands/export.d.ts.map +1 -0
  25. package/dist/commands/export.js +44 -0
  26. package/dist/commands/export.js.map +1 -0
  27. package/dist/commands/render.d.ts +4 -0
  28. package/dist/commands/render.d.ts.map +1 -0
  29. package/dist/commands/render.js +108 -0
  30. package/dist/commands/render.js.map +1 -0
  31. package/dist/commands/validate.d.ts +4 -0
  32. package/dist/commands/validate.d.ts.map +1 -0
  33. package/dist/commands/validate.js +49 -0
  34. package/dist/commands/validate.js.map +1 -0
  35. package/dist/comment.d.ts +16 -0
  36. package/dist/comment.d.ts.map +1 -0
  37. package/dist/comment.js +148 -0
  38. package/dist/comment.js.map +1 -0
  39. package/dist/config-file.d.ts +10 -0
  40. package/dist/config-file.d.ts.map +1 -0
  41. package/dist/config-file.js +19 -0
  42. package/dist/config-file.js.map +1 -0
  43. package/dist/corrections.d.ts +16 -0
  44. package/dist/corrections.d.ts.map +1 -0
  45. package/dist/corrections.js +80 -0
  46. package/dist/corrections.js.map +1 -0
  47. package/dist/document.d.ts +23 -0
  48. package/dist/document.d.ts.map +1 -0
  49. package/dist/document.js +75 -0
  50. package/dist/document.js.map +1 -0
  51. package/dist/errors.d.ts +15 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +14 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/extract.d.ts +54 -0
  56. package/dist/extract.d.ts.map +1 -0
  57. package/dist/extract.js +84 -0
  58. package/dist/extract.js.map +1 -0
  59. package/dist/git.d.ts +40 -0
  60. package/dist/git.d.ts.map +1 -0
  61. package/dist/git.js +104 -0
  62. package/dist/git.js.map +1 -0
  63. package/dist/io.d.ts +6 -0
  64. package/dist/io.d.ts.map +1 -0
  65. package/dist/io.js +35 -0
  66. package/dist/io.js.map +1 -0
  67. package/dist/prompt.d.ts +23 -0
  68. package/dist/prompt.d.ts.map +1 -0
  69. package/dist/prompt.js +69 -0
  70. package/dist/prompt.js.map +1 -0
  71. package/dist/providers/gemini.d.ts +3 -0
  72. package/dist/providers/gemini.d.ts.map +1 -0
  73. package/dist/providers/gemini.js +46 -0
  74. package/dist/providers/gemini.js.map +1 -0
  75. package/dist/providers/http.d.ts +5 -0
  76. package/dist/providers/http.d.ts.map +1 -0
  77. package/dist/providers/http.js +26 -0
  78. package/dist/providers/http.js.map +1 -0
  79. package/dist/providers/index.d.ts +48 -0
  80. package/dist/providers/index.d.ts.map +1 -0
  81. package/dist/providers/index.js +71 -0
  82. package/dist/providers/index.js.map +1 -0
  83. package/dist/providers/openai.d.ts +17 -0
  84. package/dist/providers/openai.d.ts.map +1 -0
  85. package/dist/providers/openai.js +42 -0
  86. package/dist/providers/openai.js.map +1 -0
  87. package/dist/snapshot.d.ts +15 -0
  88. package/dist/snapshot.d.ts.map +1 -0
  89. package/dist/snapshot.js +77 -0
  90. package/dist/snapshot.js.map +1 -0
  91. package/dist/terminal.d.ts +6 -0
  92. package/dist/terminal.d.ts.map +1 -0
  93. package/dist/terminal.js +5 -0
  94. package/dist/terminal.js.map +1 -0
  95. package/dist/version.d.ts +4 -0
  96. package/dist/version.d.ts.map +1 -0
  97. package/dist/version.js +4 -0
  98. package/dist/version.js.map +1 -0
  99. package/package.json +57 -0
  100. package/src/args.ts +52 -0
  101. package/src/bin.ts +10 -0
  102. package/src/cli.ts +120 -0
  103. package/src/commands/analyze.ts +206 -0
  104. package/src/commands/comment.ts +77 -0
  105. package/src/commands/export.ts +61 -0
  106. package/src/commands/render.ts +125 -0
  107. package/src/commands/validate.ts +57 -0
  108. package/src/comment.ts +190 -0
  109. package/src/config-file.ts +26 -0
  110. package/src/corrections.ts +86 -0
  111. package/src/document.ts +119 -0
  112. package/src/errors.ts +36 -0
  113. package/src/extract.ts +136 -0
  114. package/src/git.ts +151 -0
  115. package/src/io.ts +38 -0
  116. package/src/prompt.ts +87 -0
  117. package/src/providers/gemini.ts +61 -0
  118. package/src/providers/http.ts +54 -0
  119. package/src/providers/index.ts +124 -0
  120. package/src/providers/openai.ts +68 -0
  121. package/src/snapshot.ts +102 -0
  122. package/src/terminal.ts +9 -0
  123. package/src/version.ts +4 -0
package/src/comment.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { assertNever } from "@coldtea/pr-lens-schema";
2
+ import type { GraphDoc, Lens, RenderAsset, RenderManifest, View } from "@coldtea/pr-lens-schema";
3
+ import { PrLensCliError } from "./errors.js";
4
+
5
+ /**
6
+ * How the comment is recognised on a second run, so a pull request keeps one
7
+ * PR Lens comment instead of collecting one per push. Nothing else may spell
8
+ * this string: a marker that drifts orphans every comment already posted.
9
+ */
10
+ export const COMMENT_MARKER = "<!-- pr-lens -->";
11
+
12
+ const PROJECT_URL = "https://github.com/coldteadotai/pr-lens";
13
+ const COLDTEA_URL = "https://coldtea.ai";
14
+
15
+ export type CommentOptions = {
16
+ graph: GraphDoc;
17
+ manifest: RenderManifest;
18
+ /** Prefix for assets the manifest records as local paths, e.g. a raw content URL. */
19
+ assetBaseUrl: string | undefined;
20
+ branding: boolean;
21
+ };
22
+
23
+ const escape = (value: string): string =>
24
+ value
25
+ .replaceAll("&", "&amp;")
26
+ .replaceAll("<", "&lt;")
27
+ .replaceAll(">", "&gt;")
28
+ .replaceAll('"', "&quot;");
29
+
30
+ /**
31
+ * Model-authored prose, rendered as the words it is.
32
+ *
33
+ * Every string in the document was written by a model reading a diff, and a
34
+ * pull request can carry whatever text an author likes into that diff. So
35
+ * none of it may reach GitHub as markup: escaping the HTML is only half the
36
+ * job, because markdown would still turn `[Security update](http://…)` into a
37
+ * link that looks like ours. Each string therefore lands inside an HTML
38
+ * element — inside one, markdown is not parsed at all — on a single line, so
39
+ * a blank line cannot end the block and let the rest through.
40
+ *
41
+ * The zero-width space after an @ or a # is the last piece: those are matched
42
+ * after markdown, on the rendered text, and would otherwise notify a person
43
+ * or cross-link an issue on the say-so of a diff.
44
+ */
45
+ const text = (value: string): string =>
46
+ escape(value.replace(/\s+/g, " ").trim()).replace(/([@#])(?=[\w-])/g, "$1&#8203;");
47
+
48
+ const href = (asset: RenderAsset, assetBaseUrl: string | undefined): string => {
49
+ if (asset.url !== undefined) return asset.url;
50
+ if (asset.path === undefined || assetBaseUrl === undefined)
51
+ throw new PrLensCliError(
52
+ "USAGE",
53
+ `asset '${asset.id}' is a local file, so the comment has nowhere to point`,
54
+ "pass --asset-base-url with the location the rendered SVGs are published at",
55
+ );
56
+
57
+ return `${assetBaseUrl.replace(/\/+$/, "")}/${asset.path.replace(/^\/+/, "")}`;
58
+ };
59
+
60
+ type ThemePair = { light: RenderAsset | undefined; dark: RenderAsset | undefined };
61
+
62
+ const pairsByLens = (assets: readonly RenderAsset[]): Map<Lens, ThemePair> => {
63
+ const pairs = new Map<Lens, ThemePair>();
64
+ for (const asset of assets) {
65
+ const pair = pairs.get(asset.lens) ?? { light: undefined, dark: undefined };
66
+ pairs.set(asset.lens, { ...pair, [asset.theme]: asset });
67
+ }
68
+ return pairs;
69
+ };
70
+
71
+ /**
72
+ * A `<picture>` is what makes one comment readable in both GitHub themes: the
73
+ * dark source is swapped in by the browser, with the light asset as the `img`
74
+ * every other reader — email, mobile, an old client — falls back to.
75
+ *
76
+ * The whole thing is a link to the image itself, because a comment column is
77
+ * about 830 pixels wide and a diagram of a system with several lanes is
78
+ * several times that. It arrives scaled to fit, which is right for scanning,
79
+ * and one click gives a reader the size the labels were drawn at.
80
+ */
81
+ const picture = (pair: ThemePair, alt: string, assetBaseUrl: string | undefined): string => {
82
+ const fallback = pair.light ?? pair.dark;
83
+ if (fallback === undefined) return "";
84
+
85
+ const source = escape(href(fallback, assetBaseUrl));
86
+ const image = `<img alt="${text(alt)}" src="${source}" width="${fallback.width}">`;
87
+
88
+ const shown =
89
+ pair.dark === undefined || pair.light === undefined
90
+ ? image
91
+ : [
92
+ "<picture>",
93
+ ` <source media="(prefers-color-scheme: dark)" srcset="${escape(href(pair.dark, assetBaseUrl))}">`,
94
+ ` ${image}`,
95
+ "</picture>",
96
+ ].join("\n");
97
+
98
+ return `<a href="${source}">${shown}</a>`;
99
+ };
100
+
101
+ const lensLabel = (lens: Lens): string => {
102
+ switch (lens) {
103
+ case "architecture":
104
+ return "Architecture";
105
+ case "data-flow":
106
+ return "Data flow";
107
+ default:
108
+ return assertNever(lens, "Unhandled lens");
109
+ }
110
+ };
111
+
112
+ const statsLine = (graph: GraphDoc): string => {
113
+ const { stats } = graph;
114
+ if (stats === undefined) return "";
115
+
116
+ const chips = [
117
+ stats.filesChanged === undefined
118
+ ? undefined
119
+ : `${stats.filesChanged} ${stats.filesChanged === 1 ? "file" : "files"}`,
120
+ stats.additions === undefined ? undefined : `+${stats.additions}`,
121
+ stats.deletions === undefined ? undefined : `−${stats.deletions}`,
122
+ ...stats.chips.map((chip) => `${chip.label} ${chip.value}`),
123
+ ].filter((chip): chip is string => chip !== undefined);
124
+
125
+ return `<p>${chips.map((chip) => `<code>${text(chip)}</code>`).join(" · ")}</p>`;
126
+ };
127
+
128
+ const viewSection = (
129
+ view: View,
130
+ assets: Map<string, RenderAsset[]>,
131
+ assetBaseUrl: string | undefined,
132
+ ): string => {
133
+ const own = assets.get(view.id) ?? [];
134
+ const pair = pairsByLens(own).get(view.lens);
135
+
136
+ const body = [
137
+ view.summary === undefined ? "" : `<p>${text(view.summary)}</p>`,
138
+ pair === undefined ? "" : picture(pair, view.title, assetBaseUrl),
139
+ ...view.children.map((child) => viewSection(child, assets, assetBaseUrl)),
140
+ ].filter((part) => part !== "");
141
+
142
+ return [
143
+ `<details${view.defaultOpen ? " open" : ""}>`,
144
+ `<summary><b>${text(view.title)}</b></summary>`,
145
+ "",
146
+ ...body.flatMap((part) => [part, ""]),
147
+ "</details>",
148
+ ].join("\n");
149
+ };
150
+
151
+ /** Assets keyed by the view they belong to; the root render has no view. */
152
+ const ROOT = "";
153
+
154
+ const byView = (manifest: RenderManifest): Map<string, RenderAsset[]> => {
155
+ const grouped = new Map<string, RenderAsset[]>();
156
+ for (const asset of manifest.assets) {
157
+ const key = asset.view ?? ROOT;
158
+ grouped.set(key, [...(grouped.get(key) ?? []), asset]);
159
+ }
160
+ return grouped;
161
+ };
162
+
163
+ export const composeComment = (options: CommentOptions): string => {
164
+ const { graph, manifest, assetBaseUrl, branding } = options;
165
+ const assets = byView(manifest);
166
+ const roots = pairsByLens(assets.get(ROOT) ?? []);
167
+
168
+ const diagrams = graph.lenses.flatMap((lens) => {
169
+ const pair = roots.get(lens);
170
+ return pair === undefined ? [] : [picture(pair, `${graph.title} — ${lensLabel(lens)}`, assetBaseUrl)];
171
+ });
172
+
173
+ const footer = branding
174
+ ? `<sub>◈ Rendered by <a href="${PROJECT_URL}">PR Lens</a> · from the team behind <a href="${COLDTEA_URL}">Coldtea</a></sub>`
175
+ : "";
176
+
177
+ return [
178
+ COMMENT_MARKER,
179
+ `<h3>${text(graph.title)}</h3>`,
180
+ graph.summary === undefined ? "" : `<p>${text(graph.summary)}</p>`,
181
+ statsLine(graph),
182
+ ...diagrams,
183
+ ...graph.views.map((view) => viewSection(view, assets, assetBaseUrl)),
184
+ footer === "" ? "" : "---",
185
+ footer,
186
+ ]
187
+ .filter((block) => block !== "")
188
+ .join("\n\n")
189
+ .concat("\n");
190
+ };
@@ -0,0 +1,26 @@
1
+ import type { Config } from "@coldtea/pr-lens-schema";
2
+ import { access } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readConfig } from "./document.js";
5
+
6
+ /** Where a repository puts its corrections, in the order they are looked for. */
7
+ export const CONFIG_PATHS = [".github/pr-lens.yml", ".github/pr-lens.yaml"] as const;
8
+
9
+ export type LoadedConfig = { path: string; config: Config };
10
+
11
+ export const loadConfig = async (path: string): Promise<LoadedConfig> => ({
12
+ path,
13
+ config: await readConfig(path),
14
+ });
15
+
16
+ export const discoverConfig = async (root: string): Promise<LoadedConfig | undefined> => {
17
+ for (const candidate of CONFIG_PATHS) {
18
+ const path = join(root, candidate);
19
+ const found = await access(path).then(
20
+ () => true,
21
+ () => false,
22
+ );
23
+ if (found) return loadConfig(path);
24
+ }
25
+ return undefined;
26
+ };
@@ -0,0 +1,86 @@
1
+ import { applyCorrections, graphContentHash } from "@coldtea/pr-lens-renderer";
2
+ import type { Config, GraphDoc, MapCorrections } from "@coldtea/pr-lens-schema";
3
+
4
+ const NOTHING: MapCorrections = { rename: [], exclude: [], lane: [], group: [] };
5
+
6
+ /**
7
+ * A label, lane id and group id that appear nowhere in this document.
8
+ *
9
+ * It is what makes the question answerable at all: a rename to the label a
10
+ * node already carries moves nothing, and so does a lane pin into the lane a
11
+ * node is already in. Asking with a value the document cannot already hold
12
+ * separates "no node matches this" from "every node it matches already agrees
13
+ * with it" — and the difference matters, because the second is a live
14
+ * correction holding the line against the next inference.
15
+ */
16
+ const unusedEverywhere = (graph: GraphDoc): string => {
17
+ const taken = new Set([
18
+ ...graph.nodes.map((node) => node.label),
19
+ ...graph.nodes.flatMap((node) => (node.group === undefined ? [] : [node.group])),
20
+ ...graph.nodes.map((node) => node.lane),
21
+ ...graph.lanes.map((lane) => lane.id),
22
+ ]);
23
+
24
+ let candidate = "pr-lens-probe";
25
+ for (let suffix = 1; taken.has(candidate); suffix += 1) candidate = `pr-lens-probe-${suffix}`;
26
+ return candidate;
27
+ };
28
+
29
+ type Probe = { what: string; match: string; only: MapCorrections };
30
+
31
+ /**
32
+ * Each correction on its own, aimed somewhere the document is not, so that
33
+ * matching it always shows. Excluding is the one that needs no probe: a node
34
+ * a rule matches is always removed.
35
+ */
36
+ const probes = (map: MapCorrections, probe: string): Probe[] => [
37
+ ...map.rename.map((rule) => ({
38
+ what: "rename",
39
+ match: rule.match,
40
+ only: { ...NOTHING, rename: [{ match: rule.match, to: probe }] },
41
+ })),
42
+ ...map.exclude.map((selector) => ({
43
+ what: "exclude",
44
+ match: selector,
45
+ only: { ...NOTHING, exclude: [selector] },
46
+ })),
47
+ ...map.lane.map((rule) => ({
48
+ what: "lane",
49
+ match: rule.match,
50
+ only: { ...NOTHING, lane: [{ match: rule.match, lane: probe }] },
51
+ })),
52
+ ...map.group.map((rule) => ({
53
+ what: "group",
54
+ match: rule.match,
55
+ only: { ...NOTHING, group: [{ match: rule.match, group: probe }] },
56
+ })),
57
+ ];
58
+
59
+ /**
60
+ * Which of a repository's corrections match no node in this document.
61
+ *
62
+ * A selector that matches nothing is a config that has drifted since it was
63
+ * written — the file it named was moved or deleted — and saying so is the
64
+ * difference between a correction that stopped working and one that never
65
+ * did. It is never a reason to delete a correction that does match: that one
66
+ * is what keeps holding as the code moves.
67
+ *
68
+ * Which nodes a selector matches is the renderer's answer to give, so it is
69
+ * asked rather than recomputed. A second opinion about what a selector means
70
+ * is how the two drift apart.
71
+ */
72
+ export const unmatchedCorrections = (graph: GraphDoc, config: Config): string[] => {
73
+ const before = graphContentHash(graph);
74
+ const probe = unusedEverywhere(graph);
75
+
76
+ return probes(config.map, probe).flatMap(({ what, match, only }) => {
77
+ try {
78
+ if (graphContentHash(applyCorrections(graph, only)) !== before) return [];
79
+ } catch {
80
+ // A correction that emptied the document is a correction that matched.
81
+ return [];
82
+ }
83
+
84
+ return [`${what} '${match}' changed nothing — no node in this document matches it`];
85
+ });
86
+ };
@@ -0,0 +1,119 @@
1
+ import {
2
+ assertNever,
3
+ formatIssues,
4
+ safeParseConfig,
5
+ safeParseGraphDoc,
6
+ safeParsePatchDoc,
7
+ safeParseRenderManifest,
8
+ type Config,
9
+ type GraphDoc,
10
+ type Parsed,
11
+ type PatchDoc,
12
+ type PrLensSchemaError,
13
+ type RenderManifest,
14
+ } from "@coldtea/pr-lens-schema";
15
+ import { extname } from "node:path";
16
+ import { parse as parseYaml } from "yaml";
17
+ import { PrLensCliError } from "./errors.js";
18
+ import { readTextFile } from "./io.js";
19
+
20
+ export type DocumentKind = "graph" | "patch" | "render-manifest" | "config";
21
+
22
+ export type ValidatedDocument =
23
+ | { kind: "graph"; document: GraphDoc }
24
+ | { kind: "patch"; document: PatchDoc }
25
+ | { kind: "render-manifest"; document: RenderManifest }
26
+ | { kind: "config"; document: Config };
27
+
28
+ export const describeDocumentKind = (kind: DocumentKind): string => {
29
+ switch (kind) {
30
+ case "graph":
31
+ return "graph document";
32
+ case "patch":
33
+ return "patch document";
34
+ case "render-manifest":
35
+ return "render manifest";
36
+ case "config":
37
+ return "config";
38
+ default:
39
+ return assertNever(kind, "Unhandled document kind");
40
+ }
41
+ };
42
+
43
+ export const invalidDocument = (
44
+ path: string,
45
+ kind: DocumentKind,
46
+ error: PrLensSchemaError,
47
+ ): PrLensCliError =>
48
+ new PrLensCliError(
49
+ "INVALID_DOCUMENT",
50
+ `${path} is not a valid ${describeDocumentKind(kind)} [${error.code}]`,
51
+ formatIssues(error.issues),
52
+ );
53
+
54
+ const take = <T>(path: string, kind: DocumentKind, parsed: Parsed<T>): T => {
55
+ if (!parsed.ok) throw invalidDocument(path, kind, parsed.error);
56
+ return parsed.value;
57
+ };
58
+
59
+ /**
60
+ * YAML is a superset of JSON, so one parser reads both and a hand-written
61
+ * config gets the same treatment whichever spelling a repository chose.
62
+ */
63
+ const readDocumentSource = async (path: string): Promise<unknown> => {
64
+ const text = await readTextFile(path);
65
+ try {
66
+ return parseYaml(text);
67
+ } catch (error) {
68
+ const detail = error instanceof Error ? error.message : String(error);
69
+ const syntax = extname(path) === ".json" ? "JSON" : "YAML";
70
+ throw new PrLensCliError("UNREADABLE_FILE", `${path} is not valid ${syntax}`, detail);
71
+ }
72
+ };
73
+
74
+ export const readGraphDoc = async (path: string): Promise<GraphDoc> =>
75
+ take(path, "graph", safeParseGraphDoc(await readDocumentSource(path)));
76
+
77
+ export const readRenderManifest = async (path: string): Promise<RenderManifest> =>
78
+ take(path, "render-manifest", safeParseRenderManifest(await readDocumentSource(path)));
79
+
80
+ export const readConfig = async (path: string): Promise<Config> =>
81
+ take(path, "config", safeParseConfig(await readDocumentSource(path)));
82
+
83
+ /**
84
+ * `kind` names the document. Only a config lacks one, because it is the only
85
+ * one of the four a person writes by hand, and a discriminant in a file a
86
+ * person maintains buys nothing.
87
+ */
88
+ const documentKindOf = (path: string, value: unknown): DocumentKind => {
89
+ if (typeof value !== "object" || value === null || Array.isArray(value))
90
+ throw new PrLensCliError("UNKNOWN_DOCUMENT", `${path} does not hold a PR Lens document`);
91
+
92
+ const kind = "kind" in value ? value.kind : undefined;
93
+ if (kind === undefined) return "config";
94
+ if (kind === "graph" || kind === "patch" || kind === "render-manifest") return kind;
95
+
96
+ throw new PrLensCliError(
97
+ "UNKNOWN_DOCUMENT",
98
+ `${path} declares an unknown document kind ${JSON.stringify(kind)}`,
99
+ "expected 'graph', 'patch' or 'render-manifest' — or no 'kind' at all, for a config",
100
+ );
101
+ };
102
+
103
+ export const validateDocumentFile = async (path: string): Promise<ValidatedDocument> => {
104
+ const source = await readDocumentSource(path);
105
+ const kind = documentKindOf(path, source);
106
+
107
+ switch (kind) {
108
+ case "graph":
109
+ return { kind, document: take(path, kind, safeParseGraphDoc(source)) };
110
+ case "patch":
111
+ return { kind, document: take(path, kind, safeParsePatchDoc(source)) };
112
+ case "render-manifest":
113
+ return { kind, document: take(path, kind, safeParseRenderManifest(source)) };
114
+ case "config":
115
+ return { kind, document: take(path, kind, safeParseConfig(source)) };
116
+ default:
117
+ return assertNever(kind, "Unhandled document kind");
118
+ }
119
+ };
package/src/errors.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Every failure a run can end on, as a code a caller — a script, a workflow
3
+ * step, or a future command — can branch on. Messages are for humans and may
4
+ * be reworded; codes are the contract.
5
+ */
6
+ export type CliErrorCode =
7
+ | "USAGE"
8
+ | "UNREADABLE_FILE"
9
+ | "UNKNOWN_DOCUMENT"
10
+ | "INVALID_DOCUMENT"
11
+ | "GIT_FAILED"
12
+ | "EMPTY_DIFF"
13
+ | "REPOSITORY_UNKNOWN"
14
+ | "MISSING_API_KEY"
15
+ | "PROVIDER_FAILED"
16
+ | "MODEL_OUTPUT_INVALID"
17
+ | "RENDER_FAILED";
18
+
19
+ export class PrLensCliError extends Error {
20
+ readonly code: CliErrorCode;
21
+ /** Extra lines printed under the message: validation issues, a git stderr, a hint. */
22
+ readonly details: string | undefined;
23
+
24
+ constructor(code: CliErrorCode, message: string, details?: string) {
25
+ super(message);
26
+ this.name = "PrLensCliError";
27
+ this.code = code;
28
+ this.details = details;
29
+ }
30
+ }
31
+
32
+ export const usageError = (message: string, details?: string): PrLensCliError =>
33
+ new PrLensCliError("USAGE", message, details);
34
+
35
+ export const formatError = (error: PrLensCliError): string =>
36
+ [`✗ ${error.message} [${error.code}]`, error.details ?? ""].filter((line) => line !== "").join("\n");
package/src/extract.ts ADDED
@@ -0,0 +1,136 @@
1
+ import {
2
+ SCHEMA_VERSION,
3
+ safeParseGraphDoc,
4
+ type GraphDoc,
5
+ type GraphDocInput,
6
+ type Lens,
7
+ } from "@coldtea/pr-lens-schema";
8
+ import { z } from "zod";
9
+ import { PrLensCliError } from "./errors.js";
10
+ import type { JsonCompletion, Turn } from "./providers/index.js";
11
+ import { buildJsonRepairPrompt, buildRepairPrompt } from "./prompt.js";
12
+
13
+ /** One repair round. A model that cannot fix a named path in one go does not fix it in three. */
14
+ const MAX_ATTEMPTS = 2;
15
+
16
+ const JsonObject = z.record(z.string(), z.unknown());
17
+
18
+ /**
19
+ * The fields the repository already knows the answer to. They are written
20
+ * over whatever the model emitted rather than merged with it: a commit sha, a
21
+ * line count or the lens set that was asked for is a fact, and a fact the
22
+ * model is free to restate is a fact that eventually disagrees with itself.
23
+ */
24
+ export type KnownFields = {
25
+ provenance: GraphDocInput["provenance"];
26
+ stats: { filesChanged: number; additions: number; deletions: number };
27
+ lenses: readonly Lens[];
28
+ generatedAt: string;
29
+ };
30
+
31
+ /**
32
+ * Stamping the lens set is what makes `--lens` a request rather than a
33
+ * suggestion: a document whose flows or views need a lens that was not asked
34
+ * for now fails validation, and the correction round removes them.
35
+ */
36
+ export const applyKnownFields = (body: Record<string, unknown>, known: KnownFields): unknown => {
37
+ const authored = JsonObject.safeParse(body.stats);
38
+ return {
39
+ ...body,
40
+ schemaVersion: SCHEMA_VERSION,
41
+ kind: "graph",
42
+ generatedAt: known.generatedAt,
43
+ provenance: known.provenance,
44
+ lenses: [...known.lenses],
45
+ stats: { ...(authored.success ? authored.data : {}), ...known.stats },
46
+ };
47
+ };
48
+
49
+ const FENCE = /^```(?:json)?\s*\n([\s\S]*?)\n?```\s*$/;
50
+
51
+ export type JsonObjectRead =
52
+ | { ok: true; value: Record<string, unknown> }
53
+ | { ok: false; reason: string };
54
+
55
+ /**
56
+ * Providers asked for JSON still fence it now and then, and some prepend a
57
+ * line of prose. Both are recoverable, and a run that already spent the tokens
58
+ * should not fail on packaging.
59
+ *
60
+ * A failure is returned rather than thrown, because truncated or malformed
61
+ * JSON is a weak model's most common answer and deserves the same correction
62
+ * round a structurally wrong document gets.
63
+ */
64
+ export const readJsonObject = (text: string): JsonObjectRead => {
65
+ const fenced = FENCE.exec(text.trim());
66
+ const candidate = fenced?.[1] ?? text;
67
+ const start = candidate.indexOf("{");
68
+ const end = candidate.lastIndexOf("}");
69
+
70
+ let reason = "the answer holds no JSON object";
71
+
72
+ const attempts = [candidate, start >= 0 && end > start ? candidate.slice(start, end + 1) : ""];
73
+ for (const attempt of attempts) {
74
+ if (attempt === "") continue;
75
+ try {
76
+ const parsed = JsonObject.safeParse(JSON.parse(attempt));
77
+ if (parsed.success) return { ok: true, value: parsed.data };
78
+ reason = "the answer is JSON, but not an object";
79
+ } catch (error) {
80
+ reason = error instanceof Error ? error.message : String(error);
81
+ }
82
+ }
83
+
84
+ return { ok: false, reason };
85
+ };
86
+
87
+ export type Complete = (request: JsonCompletion) => Promise<string>;
88
+
89
+ export type Extraction = { document: GraphDoc; attempts: number };
90
+
91
+ export const extractGraph = async (
92
+ options: {
93
+ system: string;
94
+ user: string;
95
+ maxOutputTokens: number;
96
+ known: KnownFields;
97
+ onAttempt?: (attempt: number) => void;
98
+ },
99
+ complete: Complete,
100
+ ): Promise<Extraction> => {
101
+ const turns: Turn[] = [{ role: "user", text: options.user }];
102
+
103
+ const giveUp = (message: string, details: string): PrLensCliError =>
104
+ new PrLensCliError("MODEL_OUTPUT_INVALID", message, details);
105
+
106
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
107
+ options.onAttempt?.(attempt);
108
+ const last = attempt === MAX_ATTEMPTS;
109
+
110
+ const text = await complete({
111
+ system: options.system,
112
+ turns,
113
+ maxOutputTokens: options.maxOutputTokens,
114
+ });
115
+
116
+ const read = readJsonObject(text);
117
+ if (!read.ok) {
118
+ if (last) throw giveUp("the model did not answer with a JSON object, twice", read.reason);
119
+ turns.push({ role: "model", text }, { role: "user", text: buildJsonRepairPrompt(read.reason) });
120
+ continue;
121
+ }
122
+
123
+ const parsed = safeParseGraphDoc(applyKnownFields(read.value, options.known));
124
+ if (parsed.ok) return { document: parsed.value, attempts: attempt };
125
+
126
+ if (last)
127
+ throw giveUp(
128
+ `the model produced a document that does not validate, twice [${parsed.error.code}]`,
129
+ parsed.error.message,
130
+ );
131
+
132
+ turns.push({ role: "model", text }, { role: "user", text: buildRepairPrompt(parsed.error) });
133
+ }
134
+
135
+ throw giveUp("extraction produced no document", "no attempt returned an answer");
136
+ };