@pipelex/sdk 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/client.d.ts +85 -14
- package/dist/client.js +107 -31
- package/dist/client.js.map +1 -1
- package/dist/codegen-check.d.ts +153 -0
- package/dist/codegen-check.js +518 -0
- package/dist/codegen-check.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +149 -16
- package/dist/product-models.d.ts +13 -0
- package/dist/runs.d.ts +5 -4
- package/dist/runs.js.map +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline codegen drift check — pure hashing, no engine, no network, no API key.
|
|
3
|
+
*
|
|
4
|
+
* `client.codegen()` returns stamped artifacts plus a `codegen.lock`; a consumer that
|
|
5
|
+
* commits that tree needs a CI gate over it. This module is that gate — it proves the tree
|
|
6
|
+
* and its lock still agree with each other. Whether the tree still matches what the METHOD
|
|
7
|
+
* resolves to is a second, separate question: answering it needs the engine, so the check
|
|
8
|
+
* cannot, and a caller closes it by comparing {@link CodegenCheckReport.crateFingerprint}
|
|
9
|
+
* against a live `codegen()` response. It never regenerates: regeneration is a **dev action**
|
|
10
|
+
* (it needs the engine), the check is the **CI action** (it needs only hashes), so a
|
|
11
|
+
* template improvement upstream never reddens a consumer's CI.
|
|
12
|
+
*
|
|
13
|
+
* The check is a mirror of pipelex's `run_codegen_check`, and deliberately so: the
|
|
14
|
+
* spec pins the algorithm as "pure hashing, so any client (the CLI, an SDK, a short
|
|
15
|
+
* CI script) implements it identically". A verdict computed here must equal the one
|
|
16
|
+
* `pipelex codegen check` computes over the same bytes — including the drift detail
|
|
17
|
+
* strings, which are kept verbatim so a consumer switching between the CLI and this
|
|
18
|
+
* SDK reads the same report.
|
|
19
|
+
*
|
|
20
|
+
* **Pure by design.** No filesystem, no fetch, no Node builtins: the caller walks its
|
|
21
|
+
* own tree and hands in the text; this module owns the verdict. That is the same
|
|
22
|
+
* split pipelex draws between `build_stamped_projection` (pure) and
|
|
23
|
+
* `write_stamped_projection` (local materialization). Hashing goes through WebCrypto rather
|
|
24
|
+
* than `node:crypto` so this module adds no Node builtin to the barrel's import graph — which
|
|
25
|
+
* is not by itself the same as the barrel being browser-bundleable, since `upload.ts` still
|
|
26
|
+
* names `node:fs/promises` and a browser-targeting bundler must mark `node:*` external.
|
|
27
|
+
*
|
|
28
|
+
* @see {@link runCodegenCheck} for the caller's obligations, which are load-bearing:
|
|
29
|
+
* an incomplete file list or re-encoded text produces a *wrong verdict*, not an error.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The file suffixes codegen stamps — the mirror of pipelex's `STAMPABLE_SUFFIXES`.
|
|
33
|
+
*
|
|
34
|
+
* Exported so a caller's tree walk filters exactly like the check does: a file whose
|
|
35
|
+
* suffix is not here can never be an artifact and can never be an orphan, so it is
|
|
36
|
+
* skipped rather than rejected (this is what lets a consumer park a sidecar such as
|
|
37
|
+
* `sources.json` beside the lock).
|
|
38
|
+
*/
|
|
39
|
+
export declare const STAMPABLE_ARTIFACT_SUFFIXES: readonly string[];
|
|
40
|
+
/** Whether `path` names a file type codegen stamps (and therefore one the check considers). */
|
|
41
|
+
export declare function isStampableArtifactPath(path: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* A no-verdict condition: the lock is malformed, or a path (in the lock or in the
|
|
44
|
+
* supplied tree) is not a safe canonical artifact path.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately NOT a drift and NOT a `PipelineRequestError`: nothing was requested
|
|
47
|
+
* over the wire, and the check could not produce a verdict at all. A CLI consumer
|
|
48
|
+
* maps a drift report to its exit-1 path and this error to exit 2, per the codegen
|
|
49
|
+
* spec's exit-code policy.
|
|
50
|
+
*/
|
|
51
|
+
export declare class CodegenLockError extends Error {
|
|
52
|
+
constructor(message: string, options?: {
|
|
53
|
+
cause?: unknown;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** The kind of drift found for one artifact — the canonical `DriftCategory` values. */
|
|
57
|
+
export type CodegenDriftCategory = "missing" | "modified" | "hand-edited" | "orphan";
|
|
58
|
+
/** One drifting artifact: its path (relative to the lock), the category, and a human detail. */
|
|
59
|
+
export interface CodegenDrift {
|
|
60
|
+
path: string;
|
|
61
|
+
category: CodegenDriftCategory;
|
|
62
|
+
detail: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One file of the tree being checked.
|
|
66
|
+
*
|
|
67
|
+
* Structurally identical to `GeneratedArtifact`, so a `codegen()` response's
|
|
68
|
+
* `artifacts` array feeds straight in with no mapping.
|
|
69
|
+
*/
|
|
70
|
+
export interface CodegenTreeFile {
|
|
71
|
+
/** Relative to the lock's directory, forward slashes, canonical — no `./`, no `..`. */
|
|
72
|
+
path: string;
|
|
73
|
+
/**
|
|
74
|
+
* The file's text as read — no BOM stripping, no re-encoding, no reformatting.
|
|
75
|
+
*
|
|
76
|
+
* Line endings are the one thing normalized for you (`\r\n` and lone `\r` become
|
|
77
|
+
* `\n`), mirroring the reference reader — see {@link runCodegenCheck}.
|
|
78
|
+
*/
|
|
79
|
+
content: string;
|
|
80
|
+
}
|
|
81
|
+
/** What {@link runCodegenCheck} needs: the lock's text and the tree it governs. */
|
|
82
|
+
export interface CodegenCheckInput {
|
|
83
|
+
/** The `codegen.lock` content, verbatim. A missing lock is the caller's own concern. */
|
|
84
|
+
lockContent: string;
|
|
85
|
+
/** Every file under the lock's directory, recursively — see {@link runCodegenCheck}. */
|
|
86
|
+
files: readonly CodegenTreeFile[];
|
|
87
|
+
}
|
|
88
|
+
/** The structured verdict of one offline check. */
|
|
89
|
+
export interface CodegenCheckReport {
|
|
90
|
+
/** Locked-artifact drifts first (sorted by path), then orphans (sorted by path). */
|
|
91
|
+
drifts: CodegenDrift[];
|
|
92
|
+
/** Whether the tree is in sync — exactly `drifts.length === 0`. */
|
|
93
|
+
isCurrent: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The lock header's crate fingerprint. Surfaced so a caller can compare a committed
|
|
96
|
+
* tree against a live `codegen()` response's `crate_fingerprint` — a comparison the
|
|
97
|
+
* check itself deliberately never makes, since it would need the engine.
|
|
98
|
+
*/
|
|
99
|
+
crateFingerprint: string;
|
|
100
|
+
/** The lock header's pipelex engine version, surfaced for the same reason. */
|
|
101
|
+
engineVersion: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Run the offline drift check over one generated tree.
|
|
105
|
+
*
|
|
106
|
+
* The algorithm, per the codegen spec's "Offline check algorithm":
|
|
107
|
+
*
|
|
108
|
+
* 1. For each artifact in the lock, locate it in `files` and recompute the hash of the
|
|
109
|
+
* body below its stamp; absent is a `missing` drift, a mismatch is a drift.
|
|
110
|
+
* 2. A locked file whose stamp is gone, unparseable, or self-inconsistent is `hand-edited`.
|
|
111
|
+
* 3. A stamped file the lock does not track is an `orphan` (the stale-artifact class a
|
|
112
|
+
* per-file stamp cannot catch on its own).
|
|
113
|
+
*
|
|
114
|
+
* At most **one** drift is reported per locked path, and `hand-edited` outranks
|
|
115
|
+
* `modified`: a hand edit trips both conditions and is reported once, as the hand edit.
|
|
116
|
+
*
|
|
117
|
+
* **The caller's obligations.** Each one, unmet, yields a *wrong verdict* rather than an
|
|
118
|
+
* error, so none of them can be left implicit:
|
|
119
|
+
*
|
|
120
|
+
* - **Pass the file's text as read, without reformatting it.** The hash is over exact UTF-8
|
|
121
|
+
* bytes and the stamp parser requires the text to *start with* the begin-marker line, so
|
|
122
|
+
* re-encoding, inserting a BOM, or running a formatter over an artifact reports
|
|
123
|
+
* `hand-edited`. Line endings are the one exception: `\r\n` and lone `\r` are normalized
|
|
124
|
+
* to `\n` first, mirroring the universal-newline translation the reference reader applies
|
|
125
|
+
* (see `normalizeNewlines`) — so a Windows checkout under `core.autocrlf=true` is NOT a
|
|
126
|
+
* false hand-edit here, just as it is not one for the CLI. Committing generated artifacts
|
|
127
|
+
* with a `.gitattributes` entry (e.g. `src/generated/** -text`) is still worth doing for
|
|
128
|
+
* diff hygiene; it is simply no longer load-bearing for the verdict.
|
|
129
|
+
* - **Walk the whole tree, recursively, from the lock's directory**, and pass paths
|
|
130
|
+
* relative to it. An incomplete list yields a wrong verdict in either direction: an
|
|
131
|
+
* omitted *locked* file is reported `missing` though it sits on disk, and an omitted
|
|
132
|
+
* *orphan* is never seen at all — `isCurrent: true`, a false negative on precisely the
|
|
133
|
+
* drift class orphan detection exists for. Filter with {@link isStampableArtifactPath}
|
|
134
|
+
* to walk exactly what the check considers. Pruning vendor/VCS directories and skipping
|
|
135
|
+
* symlinks is walk policy and stays with the caller (moot for a per-method generated
|
|
136
|
+
* directory, which holds nothing else).
|
|
137
|
+
* - **Decode the bytes yourself, and decode strictly.** `content` is already a `string`, so
|
|
138
|
+
* pipelex's "not valid UTF-8 → hand-edited" branch is unreachable here — and note the obvious
|
|
139
|
+
* reader does not hand it back to you: `readFile(path, "utf8")` substitutes U+FFFD for invalid
|
|
140
|
+
* bytes and never throws, so a corrupted artifact whose body legitimately contains U+FFFD can
|
|
141
|
+
* still hash to the locked value and report current. Decode with
|
|
142
|
+
* `new TextDecoder("utf-8", { fatal: true })` if that matters to you, and report a file you
|
|
143
|
+
* could not decode yourself — or omit it and accept a `missing` drift for it.
|
|
144
|
+
*
|
|
145
|
+
* What the check deliberately does **not** do: it never compares a stamp's
|
|
146
|
+
* `crate_fingerprint` against the lock's, and it never re-resolves the crate. Both need
|
|
147
|
+
* the engine, which is the whole point of the offline split.
|
|
148
|
+
*
|
|
149
|
+
* @throws {CodegenLockError} for a no-verdict condition — a malformed lock, a lock whose
|
|
150
|
+
* `lock_version` this build cannot read, an unsafe or duplicate artifact path in the lock, or a
|
|
151
|
+
* non-canonical or duplicate path in `files`.
|
|
152
|
+
*/
|
|
153
|
+
export declare function runCodegenCheck(input: CodegenCheckInput): Promise<CodegenCheckReport>;
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline codegen drift check — pure hashing, no engine, no network, no API key.
|
|
3
|
+
*
|
|
4
|
+
* `client.codegen()` returns stamped artifacts plus a `codegen.lock`; a consumer that
|
|
5
|
+
* commits that tree needs a CI gate over it. This module is that gate — it proves the tree
|
|
6
|
+
* and its lock still agree with each other. Whether the tree still matches what the METHOD
|
|
7
|
+
* resolves to is a second, separate question: answering it needs the engine, so the check
|
|
8
|
+
* cannot, and a caller closes it by comparing {@link CodegenCheckReport.crateFingerprint}
|
|
9
|
+
* against a live `codegen()` response. It never regenerates: regeneration is a **dev action**
|
|
10
|
+
* (it needs the engine), the check is the **CI action** (it needs only hashes), so a
|
|
11
|
+
* template improvement upstream never reddens a consumer's CI.
|
|
12
|
+
*
|
|
13
|
+
* The check is a mirror of pipelex's `run_codegen_check`, and deliberately so: the
|
|
14
|
+
* spec pins the algorithm as "pure hashing, so any client (the CLI, an SDK, a short
|
|
15
|
+
* CI script) implements it identically". A verdict computed here must equal the one
|
|
16
|
+
* `pipelex codegen check` computes over the same bytes — including the drift detail
|
|
17
|
+
* strings, which are kept verbatim so a consumer switching between the CLI and this
|
|
18
|
+
* SDK reads the same report.
|
|
19
|
+
*
|
|
20
|
+
* **Pure by design.** No filesystem, no fetch, no Node builtins: the caller walks its
|
|
21
|
+
* own tree and hands in the text; this module owns the verdict. That is the same
|
|
22
|
+
* split pipelex draws between `build_stamped_projection` (pure) and
|
|
23
|
+
* `write_stamped_projection` (local materialization). Hashing goes through WebCrypto rather
|
|
24
|
+
* than `node:crypto` so this module adds no Node builtin to the barrel's import graph — which
|
|
25
|
+
* is not by itself the same as the barrel being browser-bundleable, since `upload.ts` still
|
|
26
|
+
* names `node:fs/promises` and a browser-targeting bundler must mark `node:*` external.
|
|
27
|
+
*
|
|
28
|
+
* @see {@link runCodegenCheck} for the caller's obligations, which are load-bearing:
|
|
29
|
+
* an incomplete file list or re-encoded text produces a *wrong verdict*, not an error.
|
|
30
|
+
*/
|
|
31
|
+
import { parse as parseToml } from "smol-toml";
|
|
32
|
+
// ── Stamp grammar (mirror of pipelex's `codegen/stamp.py`) ───────────────
|
|
33
|
+
const STAMP_BEGIN_MARKER = ">>> pipelex-codegen-stamp >>>";
|
|
34
|
+
const STAMP_END_MARKER = "<<< pipelex-codegen-stamp <<<";
|
|
35
|
+
/** The line-comment syntax each stampable file type is stamped in. */
|
|
36
|
+
const COMMENT_PREFIX_BY_SUFFIX = { ".py": "#", ".ts": "//" };
|
|
37
|
+
/**
|
|
38
|
+
* The file suffixes codegen stamps — the mirror of pipelex's `STAMPABLE_SUFFIXES`.
|
|
39
|
+
*
|
|
40
|
+
* Exported so a caller's tree walk filters exactly like the check does: a file whose
|
|
41
|
+
* suffix is not here can never be an artifact and can never be an orphan, so it is
|
|
42
|
+
* skipped rather than rejected (this is what lets a consumer park a sidecar such as
|
|
43
|
+
* `sources.json` beside the lock).
|
|
44
|
+
*/
|
|
45
|
+
export const STAMPABLE_ARTIFACT_SUFFIXES = Object.freeze(Object.keys(COMMENT_PREFIX_BY_SUFFIX).sort());
|
|
46
|
+
/** Whether `path` names a file type codegen stamps (and therefore one the check considers). */
|
|
47
|
+
export function isStampableArtifactPath(path) {
|
|
48
|
+
return Object.hasOwn(COMMENT_PREFIX_BY_SUFFIX, suffixOf(path));
|
|
49
|
+
}
|
|
50
|
+
/** The last dot-suffix of a path's final component (`""` for a dotfile or a suffixless name). */
|
|
51
|
+
function suffixOf(path) {
|
|
52
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
53
|
+
const dotIndex = name.lastIndexOf(".");
|
|
54
|
+
return dotIndex <= 0 ? "" : name.slice(dotIndex);
|
|
55
|
+
}
|
|
56
|
+
/** The comment prefix for a path's file type, or `undefined` when the type is not stampable. */
|
|
57
|
+
function commentPrefixFor(path) {
|
|
58
|
+
return COMMENT_PREFIX_BY_SUFFIX[suffixOf(path)];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Split a stamped file into its recorded content hash and the body below the stamp,
|
|
62
|
+
* or `null` when no parseable stamp opens the text.
|
|
63
|
+
*
|
|
64
|
+
* Faithful to `parse_stamped` with one deliberate relaxation: the projection axes
|
|
65
|
+
* (`kind` / `target`) must be *present and well-formed*, but their values are not
|
|
66
|
+
* checked against this SDK's vocabulary. pipelex validates them against its own
|
|
67
|
+
* enums, which cannot lag its own emitter; an SDK copy can, and rejecting an
|
|
68
|
+
* unknown-but-valid future `kind` would report every artifact as `hand-edited`.
|
|
69
|
+
* For today's vocabulary the two are identical.
|
|
70
|
+
*/
|
|
71
|
+
function parseStamped(text, commentPrefix) {
|
|
72
|
+
const beginLine = `${commentPrefix} ${STAMP_BEGIN_MARKER}`;
|
|
73
|
+
const endLine = `${commentPrefix} ${STAMP_END_MARKER}`;
|
|
74
|
+
if (!text.startsWith(beginLine)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const endIndex = text.indexOf(`\n${endLine}\n`);
|
|
78
|
+
if (endIndex === -1) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const headerRegion = text.slice(beginLine.length + 1, endIndex);
|
|
82
|
+
const body = text.slice(endIndex + endLine.length + 2);
|
|
83
|
+
// Every line the emitter writes inside the fence carries the comment prefix, so anything else in
|
|
84
|
+
// there was injected by hand — and it would otherwise verify as pristine, since the hash covers
|
|
85
|
+
// only the body BELOW the fence. An executable line hiding inside a "DO NOT EDIT" block is not a
|
|
86
|
+
// valid stamp.
|
|
87
|
+
//
|
|
88
|
+
// The wide split is load-bearing here rather than incidental. U+2028 and U+2029 terminate a `//`
|
|
89
|
+
// comment in ECMAScript, so under a `\n`-only split a `.ts` header carrying a raw U+2028 followed
|
|
90
|
+
// by a statement is ONE prefixed line to this gate and TWO lines to the JavaScript engine: the
|
|
91
|
+
// check would report the file current while the engine runs the injected statement. The reference
|
|
92
|
+
// splits on the same set for the same reason (`str.splitlines()` in `parse_stamped`).
|
|
93
|
+
//
|
|
94
|
+
// Nothing legitimate is rejected — the emitter writes `<prefix> <key>: <value>` lines and nothing
|
|
95
|
+
// else, and no caller supplies the two fields that could carry arbitrary text. If either ever
|
|
96
|
+
// does, the line terminators get escaped AT THE EMITTER; this gate stays correct either way,
|
|
97
|
+
// because the emitter never writes a raw one.
|
|
98
|
+
if (pythonSplitLines(headerRegion).some((line) => !line.startsWith(commentPrefix))) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const fields = parseStampFields(headerRegion, commentPrefix);
|
|
102
|
+
const projection = fields.get("projection");
|
|
103
|
+
if (projection === undefined || !isWellFormedProjection(projection)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!isJsonObject(fields.get("options") ?? "{}")) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return { contentHash: fields.get("content_hash") ?? "", body };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The line boundaries Python's `str.splitlines()` breaks on — deliberately not just `\n`.
|
|
113
|
+
*
|
|
114
|
+
* `normalizeNewlines` has already folded `\r\n` and `\r` away, but `splitlines` also breaks on
|
|
115
|
+
* VT, FF, FS/GS/RS, NEL and the Unicode line/paragraph separators, and those survive it.
|
|
116
|
+
* Splitting on the narrower set diverged in both directions: a header carrying U+2028 inside a
|
|
117
|
+
* field value parsed here as one long value (current) where the reference truncates it
|
|
118
|
+
* (hand-edited), and a header whose lines were JOINED by U+2028 parsed there and not here.
|
|
119
|
+
*/
|
|
120
|
+
// FS/GS/RS are line boundaries to Python's `splitlines()`, so matching them is the point.
|
|
121
|
+
// eslint-disable-next-line no-control-regex
|
|
122
|
+
const PYTHON_LINE_BOUNDARY = /\r\n|[\n\r\v\f\u001c\u001d\u001e\u0085\u2028\u2029]/u;
|
|
123
|
+
/**
|
|
124
|
+
* Split like Python's `str.splitlines()` — including its trailing-empty rule.
|
|
125
|
+
*
|
|
126
|
+
* `String.prototype.split` keeps a trailing empty segment when the text ends with a boundary, and
|
|
127
|
+
* yields `[""]` for the empty string, where `splitlines` drops the segment and yields `[]`. That
|
|
128
|
+
* difference is invisible to a field loop that skips lines without a `:`, but the prefix gate in
|
|
129
|
+
* `parseStamped` reads EVERY segment — so a header whose last line ends with a line boundary would
|
|
130
|
+
* be rejected here and accepted by the CLI, which is the SDK-is-stricter direction this module
|
|
131
|
+
* exists to avoid.
|
|
132
|
+
*/
|
|
133
|
+
function pythonSplitLines(text) {
|
|
134
|
+
if (text === "") {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const lines = text.split(PYTHON_LINE_BOUNDARY);
|
|
138
|
+
if (lines[lines.length - 1] === "") {
|
|
139
|
+
lines.pop();
|
|
140
|
+
}
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
/** The characters `str.isspace()` is true for — what Python's `str.strip()` removes. */
|
|
144
|
+
const PYTHON_SPACE_CLASS = "\\t\\n\\v\\f\\r\\u001c-\\u001f\\u0020\\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
|
|
145
|
+
const PYTHON_STRIP = new RegExp("^[" + PYTHON_SPACE_CLASS + "]+|[" + PYTHON_SPACE_CLASS + "]+" + "$", "gu");
|
|
146
|
+
/**
|
|
147
|
+
* Strip the whitespace Python's `str.strip()` strips — deliberately not JavaScript's `trim()`.
|
|
148
|
+
*
|
|
149
|
+
* The two sets disagree in both directions and each disagreement can flip a verdict. JS strips
|
|
150
|
+
* U+FEFF where Python does not, so a BOM planted before a recorded `content_hash` vanished here
|
|
151
|
+
* and the stamp still verified while the CLI reported `hand-edited`; Python strips U+001C-U+001F
|
|
152
|
+
* and U+0085 where JS does not. Mirroring `str.isspace()` is what keeps a parsed field value
|
|
153
|
+
* byte-identical across the two readers.
|
|
154
|
+
*/
|
|
155
|
+
function pythonStrip(text) {
|
|
156
|
+
return text.replace(PYTHON_STRIP, "");
|
|
157
|
+
}
|
|
158
|
+
/** Whether `text` opens with a stamp block in the given comment syntax (the orphan predicate). */
|
|
159
|
+
function hasStamp(text, commentPrefix) {
|
|
160
|
+
return text.startsWith(`${commentPrefix} ${STAMP_BEGIN_MARKER}`);
|
|
161
|
+
}
|
|
162
|
+
function parseStampFields(headerRegion, commentPrefix) {
|
|
163
|
+
const fields = new Map();
|
|
164
|
+
// The same split rule as the gate in `parseStamped`, and it has to stay the same one: a narrower
|
|
165
|
+
// split here would rejoin a line the gate had already split, so a field value would swallow the
|
|
166
|
+
// injected text the gate exists to catch.
|
|
167
|
+
for (const rawLine of pythonSplitLines(headerRegion)) {
|
|
168
|
+
// `parseStamped` has already rejected every line without the prefix, so stripping it is
|
|
169
|
+
// unconditional. The tolerant fallback this replaces is what let an uncommented line through.
|
|
170
|
+
const stripped = pythonStrip(rawLine.slice(commentPrefix.length));
|
|
171
|
+
const separatorIndex = stripped.indexOf(":");
|
|
172
|
+
if (separatorIndex === -1) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
fields.set(pythonStrip(stripped.slice(0, separatorIndex)), pythonStrip(stripped.slice(separatorIndex + 1)));
|
|
176
|
+
}
|
|
177
|
+
return fields;
|
|
178
|
+
}
|
|
179
|
+
/** `<kind> / <target>` with an optional trailing ` / <pipe_ref>` for a per-pipe projection. */
|
|
180
|
+
function isWellFormedProjection(projection) {
|
|
181
|
+
const parts = projection.split("/").map((segment) => pythonStrip(segment));
|
|
182
|
+
return parts.length >= 2 && parts[0] !== "" && parts[1] !== "";
|
|
183
|
+
}
|
|
184
|
+
function isJsonObject(raw) {
|
|
185
|
+
let loaded;
|
|
186
|
+
try {
|
|
187
|
+
loaded = JSON.parse(raw);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return isRecord(loaded);
|
|
193
|
+
}
|
|
194
|
+
// ── Content hash (mirror of `compute_content_hash`) ──────────────────────
|
|
195
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
196
|
+
/**
|
|
197
|
+
* The canonical content hash of a generated body: lowercase SHA-256 hex over its UTF-8 bytes.
|
|
198
|
+
*
|
|
199
|
+
* WebCrypto rather than `node:crypto`, so the SDK barrel stays importable from a browser
|
|
200
|
+
* bundle. (`crypto.subtle` is secure-context-only in a browser — https or localhost — which
|
|
201
|
+
* never binds a CI helper but is worth knowing before calling this from a page.)
|
|
202
|
+
*/
|
|
203
|
+
async function computeContentHash(body) {
|
|
204
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(body));
|
|
205
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
206
|
+
}
|
|
207
|
+
// ── Errors ──────────────────────────────────────────────────────────────
|
|
208
|
+
/**
|
|
209
|
+
* A no-verdict condition: the lock is malformed, or a path (in the lock or in the
|
|
210
|
+
* supplied tree) is not a safe canonical artifact path.
|
|
211
|
+
*
|
|
212
|
+
* Deliberately NOT a drift and NOT a `PipelineRequestError`: nothing was requested
|
|
213
|
+
* over the wire, and the check could not produce a verdict at all. A CLI consumer
|
|
214
|
+
* maps a drift report to its exit-1 path and this error to exit 2, per the codegen
|
|
215
|
+
* spec's exit-code policy.
|
|
216
|
+
*/
|
|
217
|
+
export class CodegenLockError extends Error {
|
|
218
|
+
constructor(message, options) {
|
|
219
|
+
super(message, options);
|
|
220
|
+
this.name = "CodegenLockError";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const MISSING_DETAIL = "Locked artifact is absent on disk.";
|
|
224
|
+
const NO_STAMP_DETAIL = "Stamp header is missing or unparseable.";
|
|
225
|
+
const STAMP_MISMATCH_DETAIL = "Body was edited below the stamp (stamp hash no longer matches).";
|
|
226
|
+
const MODIFIED_DETAIL = "Body no longer matches the locked hash — regenerate.";
|
|
227
|
+
const ORPHAN_DETAIL = "Stamped generated file not tracked by the lock — stale; remove or regenerate.";
|
|
228
|
+
/**
|
|
229
|
+
* Run the offline drift check over one generated tree.
|
|
230
|
+
*
|
|
231
|
+
* The algorithm, per the codegen spec's "Offline check algorithm":
|
|
232
|
+
*
|
|
233
|
+
* 1. For each artifact in the lock, locate it in `files` and recompute the hash of the
|
|
234
|
+
* body below its stamp; absent is a `missing` drift, a mismatch is a drift.
|
|
235
|
+
* 2. A locked file whose stamp is gone, unparseable, or self-inconsistent is `hand-edited`.
|
|
236
|
+
* 3. A stamped file the lock does not track is an `orphan` (the stale-artifact class a
|
|
237
|
+
* per-file stamp cannot catch on its own).
|
|
238
|
+
*
|
|
239
|
+
* At most **one** drift is reported per locked path, and `hand-edited` outranks
|
|
240
|
+
* `modified`: a hand edit trips both conditions and is reported once, as the hand edit.
|
|
241
|
+
*
|
|
242
|
+
* **The caller's obligations.** Each one, unmet, yields a *wrong verdict* rather than an
|
|
243
|
+
* error, so none of them can be left implicit:
|
|
244
|
+
*
|
|
245
|
+
* - **Pass the file's text as read, without reformatting it.** The hash is over exact UTF-8
|
|
246
|
+
* bytes and the stamp parser requires the text to *start with* the begin-marker line, so
|
|
247
|
+
* re-encoding, inserting a BOM, or running a formatter over an artifact reports
|
|
248
|
+
* `hand-edited`. Line endings are the one exception: `\r\n` and lone `\r` are normalized
|
|
249
|
+
* to `\n` first, mirroring the universal-newline translation the reference reader applies
|
|
250
|
+
* (see `normalizeNewlines`) — so a Windows checkout under `core.autocrlf=true` is NOT a
|
|
251
|
+
* false hand-edit here, just as it is not one for the CLI. Committing generated artifacts
|
|
252
|
+
* with a `.gitattributes` entry (e.g. `src/generated/** -text`) is still worth doing for
|
|
253
|
+
* diff hygiene; it is simply no longer load-bearing for the verdict.
|
|
254
|
+
* - **Walk the whole tree, recursively, from the lock's directory**, and pass paths
|
|
255
|
+
* relative to it. An incomplete list yields a wrong verdict in either direction: an
|
|
256
|
+
* omitted *locked* file is reported `missing` though it sits on disk, and an omitted
|
|
257
|
+
* *orphan* is never seen at all — `isCurrent: true`, a false negative on precisely the
|
|
258
|
+
* drift class orphan detection exists for. Filter with {@link isStampableArtifactPath}
|
|
259
|
+
* to walk exactly what the check considers. Pruning vendor/VCS directories and skipping
|
|
260
|
+
* symlinks is walk policy and stays with the caller (moot for a per-method generated
|
|
261
|
+
* directory, which holds nothing else).
|
|
262
|
+
* - **Decode the bytes yourself, and decode strictly.** `content` is already a `string`, so
|
|
263
|
+
* pipelex's "not valid UTF-8 → hand-edited" branch is unreachable here — and note the obvious
|
|
264
|
+
* reader does not hand it back to you: `readFile(path, "utf8")` substitutes U+FFFD for invalid
|
|
265
|
+
* bytes and never throws, so a corrupted artifact whose body legitimately contains U+FFFD can
|
|
266
|
+
* still hash to the locked value and report current. Decode with
|
|
267
|
+
* `new TextDecoder("utf-8", { fatal: true })` if that matters to you, and report a file you
|
|
268
|
+
* could not decode yourself — or omit it and accept a `missing` drift for it.
|
|
269
|
+
*
|
|
270
|
+
* What the check deliberately does **not** do: it never compares a stamp's
|
|
271
|
+
* `crate_fingerprint` against the lock's, and it never re-resolves the crate. Both need
|
|
272
|
+
* the engine, which is the whole point of the offline split.
|
|
273
|
+
*
|
|
274
|
+
* @throws {CodegenLockError} for a no-verdict condition — a malformed lock, a lock whose
|
|
275
|
+
* `lock_version` this build cannot read, an unsafe or duplicate artifact path in the lock, or a
|
|
276
|
+
* non-canonical or duplicate path in `files`.
|
|
277
|
+
*/
|
|
278
|
+
export async function runCodegenCheck(input) {
|
|
279
|
+
const lock = parseLock(normalizeNewlines(input.lockContent));
|
|
280
|
+
const contentByPath = indexTreeFiles(input.files);
|
|
281
|
+
const drifts = [];
|
|
282
|
+
for (const [path, lockedHash] of sortedEntries(lock.hashByPath)) {
|
|
283
|
+
const content = contentByPath.get(path);
|
|
284
|
+
if (content === undefined) {
|
|
285
|
+
drifts.push({ path, category: "missing", detail: MISSING_DETAIL });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const drift = await checkPresentArtifact(path, content, lockedHash);
|
|
289
|
+
if (drift !== null) {
|
|
290
|
+
drifts.push(drift);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const [path, content] of sortedEntries(contentByPath)) {
|
|
294
|
+
if (lock.hashByPath.has(path)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const commentPrefix = commentPrefixFor(path);
|
|
298
|
+
if (commentPrefix !== undefined && hasStamp(content, commentPrefix)) {
|
|
299
|
+
drifts.push({ path, category: "orphan", detail: ORPHAN_DETAIL });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
drifts,
|
|
304
|
+
isCurrent: drifts.length === 0,
|
|
305
|
+
crateFingerprint: lock.crateFingerprint,
|
|
306
|
+
engineVersion: lock.engineVersion,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The drift for one locked artifact that is present, or `null` when it is current.
|
|
311
|
+
*
|
|
312
|
+
* The precedence is the point: an early return makes `hand-edited` outrank `modified`,
|
|
313
|
+
* so a hand edit (which trips both the stamp check and the lock check) is reported once.
|
|
314
|
+
*/
|
|
315
|
+
async function checkPresentArtifact(path, content, lockedHash) {
|
|
316
|
+
// Never `undefined`: a lock path is suffix-validated when the lock is parsed.
|
|
317
|
+
const commentPrefix = commentPrefixFor(path);
|
|
318
|
+
const parsed = commentPrefix === undefined ? null : parseStamped(content, commentPrefix);
|
|
319
|
+
if (parsed === null) {
|
|
320
|
+
return { path, category: "hand-edited", detail: NO_STAMP_DETAIL };
|
|
321
|
+
}
|
|
322
|
+
const bodyHash = await computeContentHash(parsed.body);
|
|
323
|
+
if (parsed.contentHash !== bodyHash) {
|
|
324
|
+
return { path, category: "hand-edited", detail: STAMP_MISMATCH_DETAIL };
|
|
325
|
+
}
|
|
326
|
+
if (bodyHash !== lockedHash) {
|
|
327
|
+
return { path, category: "modified", detail: MODIFIED_DETAIL };
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
/** Index the supplied tree by path, rejecting unsafe and duplicate paths. */
|
|
332
|
+
function indexTreeFiles(files) {
|
|
333
|
+
const contentByPath = new Map();
|
|
334
|
+
for (const file of files) {
|
|
335
|
+
// The suffix rule is NOT applied here: a non-stampable file (a `sources.json`
|
|
336
|
+
// sidecar, say) is legitimately present in the tree and is simply skipped.
|
|
337
|
+
validateCanonicalPath(file.path);
|
|
338
|
+
if (contentByPath.has(file.path)) {
|
|
339
|
+
raisePathError(file.path, "Duplicate artifact path");
|
|
340
|
+
}
|
|
341
|
+
contentByPath.set(file.path, normalizeNewlines(file.content));
|
|
342
|
+
}
|
|
343
|
+
return contentByPath;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The lock format version this build reads — mirrors the reference's `CODEGEN_LOCK_VERSION`.
|
|
347
|
+
*
|
|
348
|
+
* Any change to the lock's key set, or to the meaning of an existing key, bumps it on both sides.
|
|
349
|
+
*/
|
|
350
|
+
const CODEGEN_LOCK_VERSION = 1;
|
|
351
|
+
const LOCK_KEYS = new Set(["lock_version", "crate_fingerprint", "engine_version", "artifacts"]);
|
|
352
|
+
const LOCK_ARTIFACT_KEYS = new Set(["path", "content_hash"]);
|
|
353
|
+
/**
|
|
354
|
+
* Parse and validate a `codegen.lock`.
|
|
355
|
+
*
|
|
356
|
+
* Strict on shape, mirroring the reference model's `extra="forbid"`: an unknown key is
|
|
357
|
+
* a malformed lock, not a field to ignore. The lock is a Pipelex-owned artifact written
|
|
358
|
+
* by a versioned engine, so an unrecognized shape means the two disagree about what the
|
|
359
|
+
* hashes cover — better a loud no-verdict than a confident wrong one.
|
|
360
|
+
*/
|
|
361
|
+
function parseLock(lockContent) {
|
|
362
|
+
let data;
|
|
363
|
+
try {
|
|
364
|
+
data = parseToml(lockContent);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
throw new CodegenLockError(`Malformed codegen lock: ${describeError(err)}`, { cause: err });
|
|
368
|
+
}
|
|
369
|
+
if (!isRecord(data)) {
|
|
370
|
+
throw new CodegenLockError("Malformed codegen lock: the top level is not a TOML table.");
|
|
371
|
+
}
|
|
372
|
+
rejectUnknownLockVersion(data);
|
|
373
|
+
rejectUnknownKeys(data, LOCK_KEYS, "codegen lock");
|
|
374
|
+
const crateFingerprint = readString(data, "crate_fingerprint", "codegen lock");
|
|
375
|
+
const engineVersion = readString(data, "engine_version", "codegen lock");
|
|
376
|
+
const rawArtifacts = data["artifacts"] ?? [];
|
|
377
|
+
if (!Array.isArray(rawArtifacts)) {
|
|
378
|
+
throw new CodegenLockError("Malformed codegen lock: 'artifacts' must be an array of tables.");
|
|
379
|
+
}
|
|
380
|
+
const hashByPath = new Map();
|
|
381
|
+
for (const rawArtifact of rawArtifacts) {
|
|
382
|
+
if (!isRecord(rawArtifact)) {
|
|
383
|
+
throw new CodegenLockError("Malformed codegen lock: each artifact must be a TOML table.");
|
|
384
|
+
}
|
|
385
|
+
rejectUnknownKeys(rawArtifact, LOCK_ARTIFACT_KEYS, "codegen lock artifact");
|
|
386
|
+
const path = readString(rawArtifact, "path", "codegen lock artifact");
|
|
387
|
+
const contentHash = readString(rawArtifact, "content_hash", "codegen lock artifact");
|
|
388
|
+
validateArtifactPath(path);
|
|
389
|
+
if (hashByPath.has(path)) {
|
|
390
|
+
raisePathError(path, "Duplicate artifact path");
|
|
391
|
+
}
|
|
392
|
+
hashByPath.set(path, contentHash);
|
|
393
|
+
}
|
|
394
|
+
return { crateFingerprint, engineVersion, hashByPath };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Refuse a lock whose format version this build cannot read, BEFORE its key set is validated.
|
|
398
|
+
*
|
|
399
|
+
* The ordering is the whole point of the field, and the easiest thing to get wrong. Run the other
|
|
400
|
+
* way round, `rejectUnknownKeys` rejects a future lock as a shape error over a key the writer was
|
|
401
|
+
* entitled to add — so the reader reports an opaque complaint about that key instead of naming the
|
|
402
|
+
* version and saying which side to upgrade, which is precisely the unactionable no-verdict the
|
|
403
|
+
* field was introduced to remove.
|
|
404
|
+
*/
|
|
405
|
+
function rejectUnknownLockVersion(data) {
|
|
406
|
+
// No key at all means the lock was written before the field existed, which is version 1 by
|
|
407
|
+
// definition — nothing on disk needs migrating. It still faces the comparison below rather than
|
|
408
|
+
// returning early, or the day the constant moves every legacy lock would skip the gate and be
|
|
409
|
+
// validated against a schema it was never written for.
|
|
410
|
+
const rawVersion = data["lock_version"] ?? 1;
|
|
411
|
+
// `typeof` excludes a TOML boolean, which is the JavaScript shape of the reference's
|
|
412
|
+
// `bool`-is-an-`int` guard (there, `True == 1` would have read as version 1).
|
|
413
|
+
//
|
|
414
|
+
// A TOML float is NOT excluded, and cannot be: smol-toml decodes `1.0` to the same `1` as the
|
|
415
|
+
// integer, so this reader accepts a spelling the reference calls malformed. No emitter writes it,
|
|
416
|
+
// and the divergence is a no-verdict there against version 1 here.
|
|
417
|
+
const isVersionNumber = typeof rawVersion === "number" && Number.isInteger(rawVersion);
|
|
418
|
+
if (isVersionNumber && rawVersion === CODEGEN_LOCK_VERSION) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const reason = isVersionNumber && rawVersion > CODEGEN_LOCK_VERSION
|
|
422
|
+
? `it declares lock_version ${rawVersion} — upgrade @pipelex/sdk to a build that reads it`
|
|
423
|
+
: `lock_version ${describeLockVersion(rawVersion)} is not a known codegen lock format version`;
|
|
424
|
+
throw new CodegenLockError(`Unsupported codegen lock version: this build reads lock_version ${CODEGEN_LOCK_VERSION}, but ${reason}.`);
|
|
425
|
+
}
|
|
426
|
+
/** Python's `!r` for the values TOML can put here, so both readers name a bad version the same way. */
|
|
427
|
+
function describeLockVersion(rawVersion) {
|
|
428
|
+
return typeof rawVersion === "string" ? `'${rawVersion}'` : String(rawVersion);
|
|
429
|
+
}
|
|
430
|
+
function rejectUnknownKeys(record, allowed, what) {
|
|
431
|
+
for (const key of Object.keys(record)) {
|
|
432
|
+
if (!allowed.has(key)) {
|
|
433
|
+
throw new CodegenLockError(`Malformed ${what}: unexpected key '${key}'.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function readString(record, key, what) {
|
|
438
|
+
const value = record[key];
|
|
439
|
+
if (typeof value !== "string") {
|
|
440
|
+
throw new CodegenLockError(`Malformed ${what}: '${key}' must be a string.`);
|
|
441
|
+
}
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
// ── Path validation (mirror of `validate_artifact_path` / `validate_artifact_paths`) ──
|
|
445
|
+
const CONTROL_CHARACTER = /\p{C}/u;
|
|
446
|
+
/**
|
|
447
|
+
* A Windows drive prefix as `PureWindowsPath` sees one: ANY single leading character followed
|
|
448
|
+
* by `:`, not only an ASCII letter. `1:models.py` and `_:models.py` are drives to the reference
|
|
449
|
+
* and were accepted here, making this validator the permissive side of the pair.
|
|
450
|
+
*
|
|
451
|
+
* The `s` flag is load-bearing, not decoration: without it `.` skips the line terminators, and
|
|
452
|
+
* U+2028 / U+2029 are separators rather than `\p{C}` controls, so they sail past the control
|
|
453
|
+
* check above and would be the last two drive prefixes this side still accepted. (`\n` and `\r`
|
|
454
|
+
* are the other characters `.` excludes, and those the control check already rejects.)
|
|
455
|
+
*/
|
|
456
|
+
const WINDOWS_DRIVE = /^.:/su;
|
|
457
|
+
/**
|
|
458
|
+
* Validate one canonical relative path: the safety rules, without the suffix rule.
|
|
459
|
+
*
|
|
460
|
+
* These are the rules that keep a path from meaning something other than what it spells —
|
|
461
|
+
* an absolute path, a drive prefix, a `..` escape, a backslash a Windows caller would
|
|
462
|
+
* resolve differently, a control character. They apply to every path the check sees.
|
|
463
|
+
*/
|
|
464
|
+
function validateCanonicalPath(path) {
|
|
465
|
+
if (path === "") {
|
|
466
|
+
raisePathError(path, "path is empty");
|
|
467
|
+
}
|
|
468
|
+
if (path.includes("\\")) {
|
|
469
|
+
raisePathError(path, "backslashes are not allowed; use forward slashes");
|
|
470
|
+
}
|
|
471
|
+
if (CONTROL_CHARACTER.test(path)) {
|
|
472
|
+
raisePathError(path, "control characters are not allowed");
|
|
473
|
+
}
|
|
474
|
+
if (path.startsWith("/") || WINDOWS_DRIVE.test(path)) {
|
|
475
|
+
raisePathError(path, "absolute paths and drive prefixes are not allowed");
|
|
476
|
+
}
|
|
477
|
+
if (path.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
478
|
+
raisePathError(path, "empty, '.', and '..' path components are not allowed");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** Validate one path the lock tracks: canonical, and of a suffix codegen actually stamps. */
|
|
482
|
+
function validateArtifactPath(path) {
|
|
483
|
+
validateCanonicalPath(path);
|
|
484
|
+
if (!isStampableArtifactPath(path)) {
|
|
485
|
+
const expected = STAMPABLE_ARTIFACT_SUFFIXES.join(", ");
|
|
486
|
+
raisePathError(path, `unsupported artifact suffix (expected one of: ${expected})`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function raisePathError(path, reason) {
|
|
490
|
+
throw new CodegenLockError(`Unsafe codegen artifact path '${path}': ${reason}.`);
|
|
491
|
+
}
|
|
492
|
+
// ── Small helpers ───────────────────────────────────────────────────────
|
|
493
|
+
/**
|
|
494
|
+
* Translate `\r\n` and lone `\r` to `\n`, the way the reference implementation's reader does.
|
|
495
|
+
*
|
|
496
|
+
* NOT a convenience: it is what keeps this check and `pipelex codegen check` in agreement.
|
|
497
|
+
* pipelex reads artifacts with `Path.read_text()`, whose default `newline=None` applies
|
|
498
|
+
* Python universal-newline translation — so the reference check never sees a `\r`, and its
|
|
499
|
+
* verdict is over line-ending-normalized text rather than raw bytes. The same default on the
|
|
500
|
+
* write side means pipelex running on Windows *emits* CRLF artifacts whose locked hashes were
|
|
501
|
+
* computed over LF. Hashing raw bytes here would therefore report every artifact of a
|
|
502
|
+
* Windows-generated (or `core.autocrlf=true`-checked-out) tree as `hand-edited`, while the CLI
|
|
503
|
+
* called it current — a false positive on the exact tree a consumer's CI is gating.
|
|
504
|
+
*/
|
|
505
|
+
function normalizeNewlines(text) {
|
|
506
|
+
return text.replace(/\r\n?/g, "\n");
|
|
507
|
+
}
|
|
508
|
+
function isRecord(value) {
|
|
509
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
510
|
+
}
|
|
511
|
+
/** Entries in ascending path order — the deterministic order drifts are reported in. */
|
|
512
|
+
function sortedEntries(map) {
|
|
513
|
+
return [...map.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
514
|
+
}
|
|
515
|
+
function describeError(err) {
|
|
516
|
+
return err instanceof Error ? err.message : String(err);
|
|
517
|
+
}
|
|
518
|
+
//# sourceMappingURL=codegen-check.js.map
|