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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,15 @@ pnpm install
# Validate all YAML files
pnpm validate

# Validate only the files you touched: a fast pre-flight while editing.
# It skips cross-file checks (duplicate IDs, supersedes targets and
# cycles), so run the full `pnpm validate` above before committing.
pnpm validate --files data/hardware/some-entry.yaml

# Format all YAML files, or only the ones you touched
pnpm format
pnpm format data/hardware/some-entry.yaml

# Build SQLite database locally
pnpm build

Expand Down
2 changes: 1 addition & 1 deletion schema/json/hardware.json
Original file line number Diff line number Diff line change
Expand Up @@ -2394,7 +2394,7 @@
"Left",
"Right"
],
"description": "Physical position on the device. Defined in schema/io-positions.yaml."
"description": "Physical position on the device. Defined in schema/io-positions.yaml. Required on every io entry except on played instruments (the Instruments category group in schema/category-groups.yaml), whose single output jack has no panel position. Enforced by pnpm validate as E199, not by this schema."
},
"columnPosition": {
"type": "number"
Expand Down
66 changes: 54 additions & 12 deletions scripts/format-yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
* Moves `id` field to the top of each YAML file, then runs Prettier.
* Replaces plain `prettier --write` for YAML formatting.
*
* Usage: tsx scripts/format-yaml.ts
* Usage:
* tsx scripts/format-yaml.ts # whole catalog
* tsx scripts/format-yaml.ts data/hardware/a.yaml # just these files
*
* Formatting has no cross-file dependency, so scoping it to the files an
* import touched turns a whole-catalog pass into an instant one.
*/

import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { parseDocument } from "yaml";
Expand Down Expand Up @@ -54,24 +59,61 @@ function moveIdToTop(filePath: string): boolean {
return true;
}

let reordered = 0;

for (const collection of COLLECTIONS) {
const files = getYamlFiles(path.join(DATA_DIR, collection));
for (const file of files) {
if (moveIdToTop(file)) {
reordered++;
/**
* Files to format: the paths given on the command line, or every
* collection when none are. Formatting is per-file with no cross-file
* dependency, so scoping it to the handful of files an import touched
* turns a whole-catalog pass into an instant one.
*/
function filesToFormat(): { files: string[]; scoped: boolean } {
const args = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
if (args.length > 0) {
const resolved = args.map((arg) => path.resolve(process.cwd(), arg));
const missing = resolved.filter((file) => !fs.existsSync(file));
if (missing.length > 0) {
console.error(`No such file(s):\n${missing.map((f) => ` ${f}`).join("\n")}`);
process.exit(1);
}
// Only YAML: moveIdToTop parses every file it is handed, so a stray
// .md or .json argument would crash inside the YAML document parser
// rather than report anything useful. The unscoped path cannot hit
// this because getYamlFiles() already filters by extension.
const notYaml = resolved.filter((file) => !/\.ya?ml$/.test(file));
if (notYaml.length > 0) {
console.error(
`Not YAML (this script only formats .yaml/.yml):\n${notYaml.map((f) => ` ${f}`).join("\n")}`
);
process.exit(1);
}
return { files: resolved, scoped: true };
}
return {
files: COLLECTIONS.flatMap((collection) => getYamlFiles(path.join(DATA_DIR, collection))),
scoped: false,
};
}

const { files, scoped } = filesToFormat();

let reordered = 0;
for (const file of files) {
if (moveIdToTop(file)) {
reordered++;
}
}

if (reordered > 0) {
console.log(`Moved id to top in ${reordered} files`);
}

// Run Prettier on all YAML files
console.log("Running Prettier...");
execSync('prettier --write "data/**/*.yaml"', {
console.log(scoped ? `Running Prettier on ${files.length} file(s)...` : "Running Prettier...");
// Prettier is given explicit paths when scoped so it never walks the
// whole data tree; the glob stays the default for a full run. These are
// passed as argv rather than interpolated into a command string: the
// scoped paths come from argv, and with no shell in between there is no
// quoting to get wrong. Prettier expands the glob itself.
const targets = scoped ? files.map((file) => path.relative(REPO_ROOT, file)) : ["data/**/*.yaml"];
execFileSync("prettier", ["--write", ...targets], {
cwd: REPO_ROOT,
stdio: "inherit",
});
12 changes: 11 additions & 1 deletion scripts/generate-json-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,17 @@ const ioSchema = {
position: {
type: "string",
enum: ctx.ioPositions,
description: "Physical position on the device. Defined in schema/io-positions.yaml.",
// Not listed in `required`: the rule is conditional on the entry's
// primaryCategory, which JSON Schema cannot express here without
// duplicating the Instruments category group. `pnpm validate`
// enforces it (E199), so a file that omits position passes this
// schema and still fails validation — say so rather than let an
// editor or an external contributor read it as optional.
description:
"Physical position on the device. Defined in schema/io-positions.yaml. " +
"Required on every io entry except on played instruments (the Instruments " +
"category group in schema/category-groups.yaml), whose single output jack " +
"has no panel position. Enforced by pnpm validate as E199, not by this schema.",
},
columnPosition: { type: "number" },
rowPosition: { type: "number" },
Expand Down
104 changes: 104 additions & 0 deletions scripts/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1941,10 +1941,114 @@ function writeConsoleOutput(result: ValidationResult): void {
console.log();
}

// =============================================================================
// SCOPED VALIDATION (--files)
// =============================================================================

const COLLECTION_SCHEMAS: Record<string, z.ZodType> = {
manufacturers: ManufacturerSchema,
software: SoftwareSchema,
content: ContentSchema,
hardware: HardwareSchema,
accessories: AccessorySchema,
};

/**
* Validate only the named files, for the edit-fix loop during an import.
*
* This is a pre-flight, not a replacement for the full run. Per-file rules
* (schema shape, enums, io, name hygiene) are identical, and manufacturer
* references still resolve because manufacturer slugs come from filenames
* rather than file contents. What it cannot see is anything cross-file:
* duplicate IDs, supersedes targets, and supersedes cycles. The full
* `pnpm validate` still runs in the pre-commit hook and in CI, so nothing
* reaches main unchecked.
*/
function validateScoped(paths: string[]): number {
const allManufacturers = new Set(
getYamlFiles(path.join(DATA_DIR, "manufacturers")).map((file) =>
path.basename(file, path.extname(file))
)
);

const errors: ValidationError[] = [];
let checked = 0;

for (const given of paths) {
const file = path.resolve(process.cwd(), given);
if (!fs.existsSync(file)) {
console.error(`❌ No such file: ${given}`);
return 1;
}
// JSON is valid YAML, so without this a stray .json inside a collection
// directory would parse, validate and exit 0 — reporting success for a
// file the catalog never reads.
if (!/\.ya?ml$/.test(file)) {
console.error(`❌ Not YAML (this mode only validates .yaml/.yml): ${given}`);
return 1;
}
const collection = path.basename(path.dirname(file));
const schema = COLLECTION_SCHEMAS[collection];
if (!schema) {
console.error(
`❌ ${given} is not inside a known collection ` +
`(${Object.keys(COLLECTION_SCHEMAS).join(", ")}).`
);
return 1;
}
// No validSupersedesIds: the id set is a cross-file fact this mode
// deliberately does not build, and validateFile skips the check.
const error = validateFile(file, schema, allManufacturers);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (error) errors.push(error);
checked += 1;
}

// Deliberately not writeConsoleOutput: its Stats block reports whole-catalog
// counts, which would read as an empty catalog here.
console.log(`\n📋 Scoped validation — ${checked} file(s)\n`);
console.log("─".repeat(70));
if (errors.length === 0) {
console.log("✅ No errors in the checked files.\n");
} else {
console.log("❌ Validation failed!\n");
for (const error of errors) {
console.log(`\n📄 ${error.file}`);
if (error.details && error.details.length > 0) {
for (const detail of error.details) {
const lineInfo = detail.line ? `:${detail.line}` : "";
console.log(` ${detail.code ?? ""}${lineInfo}: ${detail.message}`);
console.log(` Path: ${detail.path}`);
if (detail.docsUrl) console.log(` Docs: ${detail.docsUrl}`);
}
} else {
for (const msg of error.errors) console.log(` ⚠️ ${msg}`);
}
}
console.log();
}
console.log("─".repeat(70));
console.log(
"Cross-file checks (duplicate IDs, supersedes targets and cycles) are skipped.\n" +
"Run 'pnpm validate' before committing."
);
console.log();
return errors.length === 0 ? 0 : 1;
}

// =============================================================================
// MAIN
// =============================================================================

const filesFlagIndex = process.argv.indexOf("--files");
if (filesFlagIndex !== -1) {
const scopedPaths = process.argv.slice(filesFlagIndex + 1).filter((arg) => !arg.startsWith("-"));
if (scopedPaths.length === 0) {
console.error("❌ --files needs at least one path.");
process.exit(1);
}
process.exit(validateScoped(scopedPaths));
}

const result = validate();
const idResult = validateIds();

Expand Down