mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-28 05:22:59 +08:00
[codex] lint craft references in guard (#4239)
* chore: lint craft references * fix: validate bundled plugin craft references --------- Co-authored-by: Jeshua09090 <jeshuaelpro@outlok.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Future Craft Sections
|
||||
|
||||
These slugs are intentionally referenced by skills before their matching
|
||||
`craft/<slug>.md` sections ship. `pnpm lint:craft` treats them as planned
|
||||
forward references while still failing on unlisted typos.
|
||||
|
||||
- motion-discipline
|
||||
- pixel-discipline
|
||||
- typographic-rhythm
|
||||
@@ -41,6 +41,12 @@ od:
|
||||
Allowed values match the file names in this directory minus the `.md`
|
||||
extension. Unknown values are silently ignored (forward-compatible).
|
||||
|
||||
Run `pnpm lint:craft` after adding or changing `od.craft.requires`. The
|
||||
repository guard reports unresolved slugs with their manifest paths, so typos
|
||||
cannot silently drop a craft section from the runtime prompt. If a slug is an
|
||||
intentional forward reference, list it in `craft/FUTURE_SECTIONS.md` until the
|
||||
matching `craft/<slug>.md` file ships.
|
||||
|
||||
### Why silent fallback instead of fail-fast?
|
||||
|
||||
A skeptical reader will ask: "If a skill requests a planned-but-not-yet-vendored
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"tools-pack": "pnpm exec tools-pack",
|
||||
"tools-serve": "pnpm exec tools-serve",
|
||||
"nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts",
|
||||
"guard": "tsx ./scripts/guard.ts && tsx ./scripts/check-ui-p0-path-parity.ts && node --import tsx --test scripts/style-policy.test.ts scripts/product-neutrality.test.ts scripts/web-import-isolation.test.ts scripts/check-cross-app-imports.test.ts scripts/approve-fork-pr-workflows.test.ts scripts/postinstall.test.ts",
|
||||
"guard": "tsx ./scripts/guard.ts && tsx ./scripts/check-ui-p0-path-parity.ts && node --import tsx --test scripts/style-policy.test.ts scripts/product-neutrality.test.ts scripts/web-import-isolation.test.ts scripts/check-cross-app-imports.test.ts scripts/approve-fork-pr-workflows.test.ts scripts/postinstall.test.ts scripts/lint-craft-references.test.ts",
|
||||
"lint:craft": "tsx ./scripts/lint-craft-references.ts",
|
||||
"i18n:check": "tsx ./scripts/i18n-check.ts",
|
||||
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
|
||||
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts",
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"od": {
|
||||
"context": {
|
||||
"craft": ["typograpy"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
checkDesignSystemTokenFixtureSync,
|
||||
checkDesignSystemUnknownTokens,
|
||||
} from "./check-tokens-fixture-sync.ts";
|
||||
import { checkCraftReferences } from "./lint-craft-references.ts";
|
||||
import { collectCssHardcodedColorMatches, cssWideAndSpecialColorKeywords, realNamedColors } from "./style-policy.ts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
@@ -1241,6 +1242,7 @@ const checks: GuardCheck[] = [
|
||||
{ name: "web import isolation", run: checkWebImportIsolation },
|
||||
{ name: "tools layout", run: checkToolsLayout },
|
||||
{ name: "style policy", run: checkStylePolicy },
|
||||
{ name: "craft references", run: checkCraftReferences },
|
||||
{ name: "design system manifests", run: checkDesignSystemManifests },
|
||||
{ name: "design system package quality", run: checkDesignSystemPackageQuality },
|
||||
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
collectCraftReferences,
|
||||
extractCraftRequiresSlugs,
|
||||
findCraftReferenceViolations,
|
||||
} from "./lint-craft-references.ts";
|
||||
|
||||
test("craft reference parser reads only od.craft.requires and preserves malformed entries", () => {
|
||||
const inlineSource = `---
|
||||
od:
|
||||
design_system:
|
||||
requires: true
|
||||
craft:
|
||||
requires: [typography, typo_graphy, 42]
|
||||
---
|
||||
`;
|
||||
const blockSource = `---
|
||||
od:
|
||||
craft:
|
||||
requires:
|
||||
- form-validation
|
||||
- state_coverage
|
||||
---
|
||||
`;
|
||||
|
||||
assert.deepEqual(extractCraftRequiresSlugs(inlineSource), ["typography", "typo_graphy", 42]);
|
||||
assert.deepEqual(extractCraftRequiresSlugs(blockSource), ["form-validation", "state_coverage"]);
|
||||
});
|
||||
|
||||
test("craft reference violations allow present and planned slugs while reporting typos", () => {
|
||||
const violations = findCraftReferenceViolations(
|
||||
[
|
||||
{ manifestPath: "skills/example/SKILL.md", slug: "typography" },
|
||||
{ manifestPath: "skills/example/SKILL.md", slug: "motion-discipline" },
|
||||
{ manifestPath: "skills/example/SKILL.md", slug: "typograpy" },
|
||||
{ manifestPath: "skills/example/SKILL.md", slug: "typo_graphy" },
|
||||
{ manifestPath: "skills/example/SKILL.md", slug: 42 },
|
||||
],
|
||||
new Set(["typography"]),
|
||||
new Set(["motion-discipline"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(violations, [
|
||||
{
|
||||
kind: "unresolved",
|
||||
manifestPath: "skills/example/SKILL.md",
|
||||
slug: "typograpy",
|
||||
},
|
||||
{
|
||||
kind: "invalid",
|
||||
manifestPath: "skills/example/SKILL.md",
|
||||
slug: "typo_graphy",
|
||||
},
|
||||
{
|
||||
kind: "invalid",
|
||||
manifestPath: "skills/example/SKILL.md",
|
||||
slug: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("bad bundled scenario manifest craft references are reported", async () => {
|
||||
const fixtureRoot = fileURLToPath(new URL("./fixtures/lint-craft-references", import.meta.url));
|
||||
const references = await collectCraftReferences(fixtureRoot);
|
||||
|
||||
assert.deepEqual(findCraftReferenceViolations(references, new Set(), new Set()), [
|
||||
{
|
||||
kind: "unresolved",
|
||||
manifestPath: "plugins/_official/scenarios/bad-scenario/open-design.json",
|
||||
slug: "typograpy",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { parseFrontmatter } from "../packages/plugin-runtime/src/parsers/frontmatter.ts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const craftRoot = path.join(repoRoot, "craft");
|
||||
const futureSectionsPath = path.join(craftRoot, "FUTURE_SECTIONS.md");
|
||||
const skillManifestRoots = [
|
||||
"skills",
|
||||
"design-templates",
|
||||
"plugins/_official/examples",
|
||||
"docs/examples",
|
||||
];
|
||||
const pluginManifestRoot = "plugins/_official";
|
||||
const slugPattern = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export type CraftReference = {
|
||||
manifestPath: string;
|
||||
slug: unknown;
|
||||
};
|
||||
|
||||
export type CraftReferenceViolation = CraftReference & {
|
||||
kind: "invalid" | "unresolved";
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAbsenceError(error: unknown): boolean {
|
||||
return isRecord(error) && (error["code"] === "ENOENT" || error["code"] === "ENOTDIR");
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isAbsenceError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toRepositoryPath(root: string, filePath: string): string {
|
||||
return path.relative(root, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
export function extractCraftRequiresSlugs(source: string): unknown[] {
|
||||
const { data } = parseFrontmatter(source);
|
||||
const od = data["od"];
|
||||
if (!isRecord(od)) return [];
|
||||
|
||||
const craft = od["craft"];
|
||||
if (!isRecord(craft)) return [];
|
||||
|
||||
const requires = craft["requires"];
|
||||
return Array.isArray(requires) ? [...requires] : [];
|
||||
}
|
||||
|
||||
export function extractPluginManifestContextCraftSlugs(source: string): unknown[] {
|
||||
const manifest: unknown = JSON.parse(source);
|
||||
if (!isRecord(manifest)) return [];
|
||||
|
||||
const od = manifest["od"];
|
||||
if (!isRecord(od)) return [];
|
||||
|
||||
const context = od["context"];
|
||||
if (!isRecord(context)) return [];
|
||||
|
||||
const craft = context["craft"];
|
||||
return Array.isArray(craft) ? [...craft] : [];
|
||||
}
|
||||
|
||||
export function findCraftReferenceViolations(
|
||||
references: CraftReference[],
|
||||
existingSlugs: ReadonlySet<string>,
|
||||
futureSlugs: ReadonlySet<string>,
|
||||
): CraftReferenceViolation[] {
|
||||
const violations: CraftReferenceViolation[] = [];
|
||||
|
||||
for (const reference of references) {
|
||||
if (typeof reference.slug !== "string" || !slugPattern.test(reference.slug)) {
|
||||
violations.push({ ...reference, kind: "invalid" });
|
||||
continue;
|
||||
}
|
||||
if (!existingSlugs.has(reference.slug) && !futureSlugs.has(reference.slug)) {
|
||||
violations.push({ ...reference, kind: "unresolved" });
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function collectNamedManifests(directory: string, fileName: string): Promise<string[]> {
|
||||
if (!(await pathExists(directory))) return [];
|
||||
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const manifests: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
manifests.push(...(await collectNamedManifests(fullPath, fileName)));
|
||||
} else if (entry.isFile() && entry.name === fileName) {
|
||||
manifests.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
export async function collectCraftReferences(root: string = repoRoot): Promise<CraftReference[]> {
|
||||
const skillManifests = (
|
||||
await Promise.all(
|
||||
skillManifestRoots.map((manifestRoot) =>
|
||||
collectNamedManifests(path.join(root, manifestRoot), "SKILL.md"),
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
const pluginManifests = await collectNamedManifests(
|
||||
path.join(root, pluginManifestRoot),
|
||||
"open-design.json",
|
||||
);
|
||||
const references: CraftReference[] = [];
|
||||
|
||||
for (const manifestPath of skillManifests) {
|
||||
const source = await readFile(manifestPath, "utf8");
|
||||
for (const slug of extractCraftRequiresSlugs(source)) {
|
||||
references.push({
|
||||
manifestPath: toRepositoryPath(root, manifestPath),
|
||||
slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const manifestPath of pluginManifests) {
|
||||
const source = await readFile(manifestPath, "utf8");
|
||||
for (const slug of extractPluginManifestContextCraftSlugs(source)) {
|
||||
references.push({
|
||||
manifestPath: toRepositoryPath(root, manifestPath),
|
||||
slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return references.sort((left, right) => left.manifestPath.localeCompare(right.manifestPath));
|
||||
}
|
||||
|
||||
async function collectExistingCraftSlugs(): Promise<Set<string>> {
|
||||
const entries = await readdir(craftRoot, { withFileTypes: true });
|
||||
const slugs = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || path.extname(entry.name) !== ".md") continue;
|
||||
|
||||
const slug = path.basename(entry.name, ".md");
|
||||
if (slug === "README" || slug === "FUTURE_SECTIONS") continue;
|
||||
if (slugPattern.test(slug)) slugs.add(slug);
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}
|
||||
|
||||
function extractFutureCraftSlugs(source: string): Set<string> {
|
||||
const slugs = new Set<string>();
|
||||
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const match = /^\s*[-*]\s+`?([a-z0-9][a-z0-9-]*)`?\s*$/.exec(line);
|
||||
if (match?.[1]) slugs.add(match[1]);
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}
|
||||
|
||||
async function collectFutureCraftSlugs(): Promise<Set<string>> {
|
||||
if (!(await pathExists(futureSectionsPath))) return new Set();
|
||||
return extractFutureCraftSlugs(await readFile(futureSectionsPath, "utf8"));
|
||||
}
|
||||
|
||||
function formatSlug(slug: unknown): string {
|
||||
return typeof slug === "string" ? `'${slug}'` : JSON.stringify(slug);
|
||||
}
|
||||
|
||||
function printViolations(violations: CraftReferenceViolation[]): void {
|
||||
const invalid = violations.filter((violation) => violation.kind === "invalid");
|
||||
const unresolved = violations.filter((violation) => violation.kind === "unresolved");
|
||||
|
||||
if (invalid.length > 0) {
|
||||
console.error("Invalid craft reference entries:");
|
||||
for (const violation of invalid) {
|
||||
console.error(`- ${violation.manifestPath}: ${formatSlug(violation.slug)}`);
|
||||
}
|
||||
console.error("Craft slugs must be strings containing lowercase letters, digits, and hyphens only.");
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
console.error("Unresolved craft reference slugs:");
|
||||
for (const violation of unresolved) {
|
||||
console.error(`- ${violation.manifestPath}: ${formatSlug(violation.slug)}`);
|
||||
}
|
||||
console.error("Add craft/<slug>.md, fix the typo, or list an intentional forward reference in craft/FUTURE_SECTIONS.md.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCraftReferences(): Promise<boolean> {
|
||||
const references = await collectCraftReferences();
|
||||
const existingSlugs = await collectExistingCraftSlugs();
|
||||
const futureSlugs = await collectFutureCraftSlugs();
|
||||
const violations = findCraftReferenceViolations(references, existingSlugs, futureSlugs);
|
||||
const manifestCount = new Set(references.map((reference) => reference.manifestPath)).size;
|
||||
|
||||
if (violations.length > 0) {
|
||||
printViolations(violations);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Craft reference check passed: ${references.length} references across ${manifestCount} manifests resolve or are explicitly planned.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
||||
if (isMain && !(await checkCraftReferences())) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user