chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
+481
View File
@@ -0,0 +1,481 @@
/**
* Read and validate an `emdash-plugin.jsonc` manifest from disk.
*
* Failure modes, each with a distinct error code for scriptable consumers
* (`validate --json`, programmatic API users):
*
* - `MANIFEST_NOT_FOUND` — file doesn't exist at the resolved path.
* - `MANIFEST_TOO_LARGE` — file exceeds `MANIFEST_MAX_BYTES`. Reading
* stops at the cap; the file is never fully buffered.
* - `MANIFEST_PARSE_ERROR` — JSONC parse failure (trailing comma, missing
* bracket, control char in string, duplicate keys). Includes line +
* column from `jsonc-parser`'s offset.
* - `MANIFEST_VALIDATION_ERROR` — JSONC parsed cleanly but the value
* failed the Zod schema. Includes the field path and the offending
* value's location in the source where possible.
*
* The line/column mapping is critical for editor-side workflows: a user
* running `emdash-plugin validate` from a CI step wants the same kind of
* pointer they'd get from `tsc` or `eslint`, not a Zod issue tree.
*/
import { open } from "node:fs/promises";
import { resolve } from "node:path";
import { parseTree, type Node, type ParseError, printParseErrorCode } from "jsonc-parser";
import type { ZodIssue } from "zod";
import { ManifestSchema, type Manifest } from "./schema.js";
/**
* Conventional manifest filename. Lives next to the plugin's `package.json`.
*/
export const MANIFEST_FILENAME = "emdash-plugin.jsonc";
/**
* Hard cap on the bytes we'll buffer for a manifest. The largest real-world
* v1 manifest under the current schema is a few hundred bytes; even a heavily-
* populated future version with all the long-form sections from issue #1030
* (5 sections × 20 KB cap each from the lexicon) tops out under 128 KB. We
* pick 1 MiB so accidental mis-targets (`--manifest ./large.tar`) fail fast
* with a clear error rather than OOMing the CLI.
*/
export const MANIFEST_MAX_BYTES = 1024 * 1024;
export type ManifestErrorCode =
| "MANIFEST_NOT_FOUND"
| "MANIFEST_TOO_LARGE"
| "MANIFEST_PARSE_ERROR"
| "MANIFEST_VALIDATION_ERROR";
export class ManifestError extends Error {
override readonly name = "ManifestError";
readonly code: ManifestErrorCode;
/** Resolved absolute path of the manifest file. */
readonly path: string;
/**
* Issues for `MANIFEST_VALIDATION_ERROR`. One per failed rule, each
* carrying a JSON pointer-style path and an optional source location.
* Empty for the other error codes.
*/
readonly issues: ManifestIssue[];
constructor(
code: ManifestErrorCode,
message: string,
path: string,
issues: ManifestIssue[] = [],
) {
super(message);
this.code = code;
this.path = path;
this.issues = issues;
}
}
export interface ManifestIssue {
/** Dotted/bracketed JSON path, e.g. `authors[0].email`. */
path: string;
message: string;
/** 1-indexed line and column in the manifest source, when known. */
location?: { line: number; column: number };
}
export interface LoadManifestResult {
manifest: Manifest;
/** Resolved absolute path. */
path: string;
}
/**
* Load and validate a manifest at `path`. `path` may be a directory (in
* which case `emdash-plugin.jsonc` is appended) or a file.
*
* Throws `ManifestError` on every failure path. Successful return guarantees
* the manifest is schema-valid (but normalisation to the publish-input
* shape still needs `./translate.ts`).
*/
export async function loadManifest(path: string): Promise<LoadManifestResult> {
const resolved = resolve(path);
// Heuristic: paths that end in `.jsonc` or `.json` are treated as
// files; everything else is treated as a directory. We don't `stat`
// to disambiguate because the error path "missing file" should be the
// same regardless of which form the caller passed.
const filePath =
resolved.endsWith(".jsonc") || resolved.endsWith(".json")
? resolved
: resolve(resolved, MANIFEST_FILENAME);
// Bounded read: open the file and read at most MANIFEST_MAX_BYTES+1
// bytes. The extra byte is a sentinel — if we get it, the file is
// definitely over the cap regardless of what `stat` would say.
// This closes the stat-then-readFile race where a concurrent writer
// could grow the file between size check and buffer.
const source = await readBoundedUtf8(filePath);
return parseAndValidate(source, filePath);
}
/**
* Read a UTF-8 file with a hard cap of `MANIFEST_MAX_BYTES` bytes.
* Throws `ManifestError(MANIFEST_TOO_LARGE)` if the file exceeds the cap,
* `ManifestError(MANIFEST_NOT_FOUND)` for ENOENT.
*
* We allocate a buffer of `MANIFEST_MAX_BYTES + 1` and read into it; if
* the read fills the whole buffer, the file is at least one byte over
* the limit and we reject. This avoids the TOCTOU window of a separate
* `stat` call: a concurrent writer can grow the file between syscalls,
* but it can never make our buffer larger than what we allocated up
* front.
*
* `read` returns a single chunk synchronously from kernel buffers when
* available; for files of our cap size (1 MiB) this is one syscall on
* Linux/macOS. We loop in case the kernel returns a short read.
*/
async function readBoundedUtf8(filePath: string): Promise<string> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(filePath, "r");
} catch (error) {
if (isNodeNotFoundError(error)) {
throw new ManifestError(
"MANIFEST_NOT_FOUND",
`No manifest at ${filePath}. Create one with: emdash-plugin init`,
filePath,
);
}
throw error;
}
try {
// One extra byte so we can detect oversize without reading
// arbitrarily much.
const buffer = Buffer.allocUnsafe(MANIFEST_MAX_BYTES + 1);
let totalRead = 0;
while (totalRead < buffer.length) {
const { bytesRead } = await handle.read(
buffer,
totalRead,
buffer.length - totalRead,
totalRead,
);
if (bytesRead === 0) break;
totalRead += bytesRead;
}
if (totalRead > MANIFEST_MAX_BYTES) {
throw new ManifestError(
"MANIFEST_TOO_LARGE",
`Manifest at ${filePath} is larger than the ${MANIFEST_MAX_BYTES}-byte cap. Check that you pointed --manifest at the right file.`,
filePath,
);
}
return buffer.subarray(0, totalRead).toString("utf8");
} finally {
await handle.close().catch(() => {
// Closing a handle should never fail in practice; if it
// does, swallow it — the read result is already in hand.
});
}
}
/**
* Variant for callers that already have the source text in hand (tests,
* editor integrations that read the buffer). The `path` argument is used
* for error messages only.
*/
export function parseAndValidateManifest(source: string, path: string): LoadManifestResult {
return parseAndValidate(source, path);
}
// ──────────────────────────────────────────────────────────────────────────
// Internals
// ──────────────────────────────────────────────────────────────────────────
function parseAndValidate(source: string, filePath: string): LoadManifestResult {
const parseErrors: ParseError[] = [];
// `parseTree` gives us both the parsed value AND the syntax tree, so we
// can map a Zod issue's path back to a source offset. `parse` alone
// loses that information.
const root = parseTree(source, parseErrors, {
// Comments are part of the JSONC contract. Trailing commas are
// allowed because they reduce diff noise when the user adds a new
// field at the end.
disallowComments: false,
allowTrailingComma: true,
allowEmptyContent: false,
});
if (parseErrors.length > 0) {
const first = parseErrors[0]!;
const { line, column } = offsetToLineCol(source, first.offset);
throw new ManifestError(
"MANIFEST_PARSE_ERROR",
`${filePath}:${line}:${column}: ${printParseErrorCode(first.error)}`,
filePath,
);
}
if (!root) {
// Shouldn't be reachable when `allowEmptyContent: false` is set and
// `parseErrors` is empty, but `parseTree`'s return type is nullable.
throw new ManifestError("MANIFEST_PARSE_ERROR", `${filePath}: file is empty`, filePath);
}
// Reject duplicate keys before validation. `nodeToValue` is last-wins
// (matches JSON.parse semantics) which silently shadows earlier keys
// — review-hostile for a security-sensitive document like this. We
// scan once for duplicates and surface them as a parse error with a
// source location, so a `git diff` reviewer can't be fooled by a
// "publisher": "<honest>" at the top of the file that gets overridden
// by "publisher": "<hostile>" further down.
const duplicate = findDuplicateKey(root);
if (duplicate) {
const { line, column } = offsetToLineCol(source, duplicate.offset);
throw new ManifestError(
"MANIFEST_PARSE_ERROR",
`${filePath}:${line}:${column}: duplicate key "${duplicate.key}". Each property may only be declared once.`,
filePath,
);
}
const value = nodeToValue(root);
const result = ManifestSchema.safeParse(value);
if (!result.success) {
const issues = result.error.issues.map((issue) => zodIssueToManifestIssue(issue, source, root));
const summary = issues
.map((i) => {
const loc = i.location ? `:${i.location.line}:${i.location.column}` : "";
return `${filePath}${loc}: ${i.path ? `${i.path}: ` : ""}${i.message}`;
})
.join("\n");
throw new ManifestError(
"MANIFEST_VALIDATION_ERROR",
`Manifest validation failed:\n${summary}`,
filePath,
issues,
);
}
return { manifest: result.data, path: filePath };
}
/**
* Map a Zod issue to a manifest issue. The path translation strips the
* leading `$` that some Zod versions prepend and produces the JSONC-style
* `authors[0].email` syntax users will recognise.
*
* Zod 4 types path segments as `PropertyKey` (string | number | symbol).
* Symbols cannot appear in a JSON-parsed value's path (JSON has no symbol
* keys), so we narrow defensively and treat any stray symbol as an
* opaque "<symbol>" string in the displayed path.
*
* Special-cases `unrecognized_keys` (typo'd field names): Zod reports the
* issue at the parent path with the offending key(s) in `issue.keys`.
* Without special handling, the line:col points at the parent object's
* opening brace, not the actual typo. We resolve the first listed key
* inside the parent and use ITS source offset, so a `"licens": "MIT"`
* mistake gets the pointer landing on the bad key's line and column.
*/
function zodIssueToManifestIssue(issue: ZodIssue, source: string, root: Node): ManifestIssue {
const path = narrowZodPath(issue.path);
const pathStr = formatZodPath(path);
let offset: number | undefined;
if (issue.code === "unrecognized_keys") {
const keys = (issue as ZodIssue & { keys?: readonly string[] }).keys;
const firstKey = keys?.[0];
if (firstKey !== undefined) {
const parent = findNodeAtPath(root, path);
if (parent?.type === "object" && parent.children) {
const prop: Node | undefined = parent.children.find(
(c) =>
c.type === "property" &&
c.children?.[0]?.type === "string" &&
c.children[0].value === firstKey,
);
const keyNode = prop?.children?.[0];
if (keyNode) offset = keyNode.offset;
}
}
}
if (offset === undefined) {
offset = findNodeAtPath(root, path)?.offset;
}
const location = offset !== undefined ? offsetToLineCol(source, offset) : undefined;
return location
? { path: pathStr, message: issue.message, location }
: { path: pathStr, message: issue.message };
}
/**
* Coerce a Zod 4 issue path (`PropertyKey[]`) to the string|number form
* the rest of the loader uses. A symbol segment is impossible for JSONC
* input, but we render it defensively rather than crashing.
*/
function narrowZodPath(path: ReadonlyArray<PropertyKey>): Array<string | number> {
return path.map((segment) => {
if (typeof segment === "string" || typeof segment === "number") return segment;
return segment.toString();
});
}
/**
* Format a Zod path array as `authors[0].email`. Numbers become bracketed
* indices; strings become dot-prefixed (except the first).
*/
function formatZodPath(path: ReadonlyArray<string | number>): string {
let out = "";
for (const segment of path) {
if (typeof segment === "number") {
out += `[${segment}]`;
} else {
out += out.length === 0 ? segment : `.${segment}`;
}
}
return out;
}
/**
* Walk the JSONC syntax tree to find the node at a given path. When the
* path traverses into a missing key or a wrong-shape value, returns the
* deepest ancestor that DID exist — so the resulting line:col still
* points at something useful (the parent object, where the missing
* property "should have been"). This matters most for two error
* classes:
*
* - Missing required key: Zod's path is `[key]`, the value doesn't
* exist; returning the root object's offset puts the pointer at the
* opening brace, which an editor highlights as "issue with this
* object".
* - Unknown key (typo): Zod's path is `[wrongKey]`, the value doesn't
* exist in the parent. Same parent-fallback gives the pointer the
* line of the parent object.
*
* Both cases used to return undefined and lose the line:col entirely.
*/
function findNodeAtPath(root: Node, path: ReadonlyArray<string | number>): Node | undefined {
let current: Node | undefined = root;
let lastResolved: Node | undefined = root;
for (const segment of path) {
if (!current) return lastResolved;
if (typeof segment === "number") {
if (current.type !== "array" || !current.children) return current;
const next: Node | undefined = current.children[segment];
if (!next) return current;
current = next;
} else {
if (current.type !== "object" || !current.children) return current;
const prop: Node | undefined = current.children.find(
(c) =>
c.type === "property" &&
c.children?.[0]?.type === "string" &&
c.children[0].value === segment,
);
// `property` node's children are [keyNode, valueNode]. We want
// the value for further traversal. If the property is missing
// entirely (e.g. typo'd key, missing required field), fall
// back to the current object so the caller gets a source
// location for the containing structure.
const next: Node | undefined = prop?.children?.[1];
if (!next) return current;
current = next;
}
lastResolved = current;
}
return current;
}
/**
* Recursively scan an object node for duplicate property names. Returns
* the FIRST duplicate found (innermost-first within the recursion, but
* order across siblings is the order in the source) with its offset for
* line:column reporting.
*
* We scan the entire tree, not just the root: duplicate keys inside
* `author: { ... }` or `security: { ... }` are equally review-hostile.
*/
function findDuplicateKey(node: Node): { key: string; offset: number } | undefined {
if (node.type === "object" && node.children) {
const seen = new Set<string>();
for (const prop of node.children) {
if (prop.type !== "property") continue;
const keyNode = prop.children?.[0];
if (!keyNode || keyNode.type !== "string" || typeof keyNode.value !== "string") {
continue;
}
if (seen.has(keyNode.value)) {
return { key: keyNode.value, offset: keyNode.offset };
}
seen.add(keyNode.value);
const valueNode = prop.children?.[1];
if (valueNode) {
const nested = findDuplicateKey(valueNode);
if (nested) return nested;
}
}
} else if (node.type === "array" && node.children) {
for (const child of node.children) {
const nested = findDuplicateKey(child);
if (nested) return nested;
}
}
return undefined;
}
/**
* Convert a JSONC syntax-tree node to its plain JavaScript value. The
* `parseTree` API doesn't return values directly; this walks the tree.
*
* We can't use `jsonc-parser`'s `parse()` (which would give us the value
* directly) because we need the tree anyway for error-location mapping,
* and parsing twice doubles the work for a file we're about to validate.
*/
function nodeToValue(node: Node): unknown {
switch (node.type) {
case "object": {
const obj: Record<string, unknown> = {};
for (const prop of node.children ?? []) {
if (prop.type !== "property") continue;
const [keyNode, valueNode] = prop.children ?? [];
if (!keyNode || keyNode.type !== "string" || !valueNode) continue;
if (typeof keyNode.value !== "string") continue;
obj[keyNode.value] = nodeToValue(valueNode);
}
return obj;
}
case "array":
return (node.children ?? []).map((child) => nodeToValue(child));
case "string":
case "number":
case "boolean":
case "null":
return node.value;
default:
return undefined;
}
}
/**
* Convert a byte offset in `source` into 1-indexed line + column. Matches
* the convention `tsc` and `eslint` use for error pointers.
*/
function offsetToLineCol(source: string, offset: number): { line: number; column: number } {
let line = 1;
let column = 1;
const max = Math.min(offset, source.length);
for (let i = 0; i < max; i++) {
if (source.charCodeAt(i) === 10 /* \n */) {
line++;
column = 1;
} else {
column++;
}
}
return { line, column };
}
function isNodeNotFoundError(error: unknown): boolean {
return (
error instanceof Error && "code" in error && (error as { code: unknown }).code === "ENOENT"
);
}