Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions scripts/check_workspace_protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { deepStrictEqual } from "node:assert/strict";
import { it, mock } from "node:test";
import { join } from "@std/path";
import { walkPackageManifests } from "./check_workspace_protocol.ts";

async function withProject(
run: (root: string, parent: string, lock: string) => Promise<void>,
) {
const root = await Deno.makeTempDir();
const parent = join(root, "packages", "vocab");
const lock = join(parent, ".vocab-codegen.lock");
try {
await Deno.mkdir(lock, { recursive: true });
await Deno.writeTextFile(
join(parent, "package.json"),
JSON.stringify({ dependencies: { "@fedify/fedify": "workspace:" } }),
);
await run(root, parent, lock);
} finally {
mock.restoreAll();
await Deno.remove(root, { recursive: true });
}
}

it("skips codegen locks while still yielding real package manifests", async () => {
await withProject(async (root, parent, lock) => {
await Deno.writeTextFile(join(lock, "package.json"), "{}");
const readDir = Deno.readDir;
mock.method(Deno, "readDir", (path: string | URL) => {
if (path === lock) throw new Error("Lock must not be traversed");
return readDir(path);
});
const entries = await Array.fromAsync(walkPackageManifests(root));
deepStrictEqual(entries.map((entry) => entry.path), [
join(parent, "package.json"),
]);
});
});

it("continues checking manifests after codegen releases its lock", async () => {
await withProject(async (root, parent, lock) => {
const readDir = Deno.readDir;
let released = false;
mock.method(Deno, "readDir", async function* (path: string | URL) {
if (path !== parent) {
yield* readDir(path);
return;
}
const entries = await Array.fromAsync(readDir(path));
// Visit the disappearing lock before the real manifest deterministically.
entries.sort((a, b) =>
Number(b.name === ".vocab-codegen.lock") -
Number(a.name === ".vocab-codegen.lock")
);
for (const entry of entries) {
if (entry.name === ".vocab-codegen.lock") {
await Deno.remove(lock, { recursive: true });
released = true;
}
yield entry;
}
});
const entries = await Array.fromAsync(walkPackageManifests(root));
deepStrictEqual(released, true);
deepStrictEqual(entries.map((entry) => entry.path), [
join(parent, "package.json"),
]);
});
});
99 changes: 53 additions & 46 deletions scripts/check_workspace_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@ const DEPENDENCY_FIELDS = [
"optionalDependencies",
] as const;

const projectRoot = resolve(dirname(fromFileUrl(import.meta.url)), "..");

let found = false;
for await (
const entry of walk(projectRoot, {
/** Walk package manifests without entering temporary codegen locks. */
export function walkPackageManifests(projectRoot: string) {
return walk(projectRoot, {
includeDirs: false,
// Match the path separator with a character class so these patterns stay
// valid on Windows too, where @std/path's SEPARATOR is a backslash and
Expand All @@ -31,55 +29,64 @@ for await (
skip: [
/(?:^|[/\\])node_modules(?:[/\\]|$)/,
/(?:^|[/\\])\.git(?:[/\\]|$)/,
// Codegen removes its lock on completion, even when generation is skipped.
/(?:^|[/\\])\.vocab-codegen\.lock(?:[/\\]|$)/,
],
})
) {
let manifest: Record<string, unknown>;
try {
const parsed = JSON.parse(await Deno.readTextFile(entry.path));
// A package.json could be `null`, a string, a number, or an array; skip
// anything that is not a plain object so the field lookups below are safe.
if (
parsed === null || typeof parsed !== "object" || Array.isArray(parsed)
) {
});
}

if (import.meta.main) {
const projectRoot = resolve(dirname(fromFileUrl(import.meta.url)), "..");

let found = false;
for await (const entry of walkPackageManifests(projectRoot)) {
let manifest: Record<string, unknown>;
try {
const parsed = JSON.parse(await Deno.readTextFile(entry.path));
// A package.json could be `null`, a string, a number, or an array; skip
// anything that is not a plain object so the field lookups below are safe.
if (
parsed === null || typeof parsed !== "object" || Array.isArray(parsed)
) {
continue;
}
manifest = parsed as Record<string, unknown>;
} catch {
continue;
}
manifest = parsed as Record<string, unknown>;
} catch {
continue;
}

const invalid: string[] = [];
for (const field of DEPENDENCY_FIELDS) {
const deps = manifest[field];
// typeof [] is "object", so exclude arrays explicitly before iterating.
if (deps == null || typeof deps !== "object" || Array.isArray(deps)) {
continue;
const invalid: string[] = [];
for (const field of DEPENDENCY_FIELDS) {
const deps = manifest[field];
// typeof [] is "object", so exclude arrays explicitly before iterating.
if (deps == null || typeof deps !== "object" || Array.isArray(deps)) {
continue;
}
for (
const [name, spec] of Object.entries(deps as Record<string, unknown>)
) {
if (spec === "workspace:") invalid.push(name);
}
}
for (
const [name, spec] of Object.entries(deps as Record<string, unknown>)
) {
if (spec === "workspace:") invalid.push(name);

if (invalid.length > 0) {
if (!found) {
console.error(
"Error: Found invalid workspace: specifiers (missing *, ^, or ~):",
);
console.error("");
found = true;
}
console.error(`${relative(projectRoot, entry.path)}:`);
for (const name of invalid) console.error(` ${name}`);
}
}

if (invalid.length > 0) {
if (!found) {
console.error(
"Error: Found invalid workspace: specifiers (missing *, ^, or ~):",
);
console.error("");
found = true;
}
console.error(`${relative(projectRoot, entry.path)}:`);
for (const name of invalid) console.error(` ${name}`);
if (found) {
console.error("");
console.error("Valid formats: workspace:*, workspace:^, workspace:~");
Deno.exit(1);
}
}

if (found) {
console.error("");
console.error("Valid formats: workspace:*, workspace:^, workspace:~");
Deno.exit(1);
console.log("All workspace: specifiers are valid");
}

console.log("All workspace: specifiers are valid");
Loading