diff --git a/schemas/tags_schema.json b/schemas/tags_schema.json new file mode 100644 index 000000000..aa12e8325 --- /dev/null +++ b/schemas/tags_schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Tags", + "description": "Maps each namespaced registry to its namespaced tags and their resolved, namespaced members. Nested #tag references are flattened by the generator, so members are always concrete entries.", + "type": "object", + "properties": { + "minecraft:block": { + "$ref": "#/$defs/tagMap", + "description": "Tags over the block registry (e.g. minecraft:mineable/pickaxe, minecraft:needs_iron_tool)." + }, + "minecraft:item": { + "$ref": "#/$defs/tagMap", + "description": "Tags over the item registry (e.g. minecraft:pickaxes, minecraft:logs)." + } + }, + "required": ["minecraft:block", "minecraft:item"], + "patternProperties": { + "^[a-z0-9_.-]+:[a-z0-9/._-]+$": { + "$ref": "#/$defs/tagMap", + "description": "Any additional namespaced registry the generator emits, including path-shaped registries such as minecraft:worldgen/biome." + } + }, + "additionalProperties": false, + "$defs": { + "tagMap": { + "type": "object", + "minProperties": 1, + "description": "Maps a namespaced tag name to its resolved, namespaced member names. Empty member lists are valid because vanilla ships empty tags.", + "patternProperties": { + "^[a-z0-9_.-]+:[a-z0-9/._-]+$": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9_.-]+:[a-z0-9/._-]+$" + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + } +} diff --git a/tools/js/test/audit_tags.js b/tools/js/test/audit_tags.js new file mode 100644 index 000000000..f0dbd5b5d --- /dev/null +++ b/tools/js/test/audit_tags.js @@ -0,0 +1,289 @@ +/* eslint-env mocha */ + +const assert = require('assert') +const fs = require('fs') +const path = require('path') + +const root = path.join(__dirname, '../../..') +const dataRoot = path.join(root, 'data') +const pcDataRoot = path.join(dataRoot, 'pc') +const dataPaths = require('../../../data/dataPaths.json') +const registryFiles = { + 'minecraft:block': 'blocks', + 'minecraft:item': 'items', + 'minecraft:entity_type': 'entities', + 'minecraft:worldgen/biome': 'biomes', + 'minecraft:enchantment': 'enchantments' +} +const resourceLocation = /^[a-z0-9_.-]+:[a-z0-9/._-]+$/ + +function assertObject (value, label) { + assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`) +} + +function assertSorted (values, label) { + assert.deepStrictEqual(values, values.slice().sort(), `${label} must be sorted`) +} + +function auditTagDocument (tags, options = {}) { + const label = options.label || 'tags.json' + const official = options.official || false + const registryMembers = options.registryMembers || {} + + assertObject(tags, label) + const registries = Object.keys(tags) + assertSorted(registries, `${label} registry keys`) + + for (const registry of registries) { + assert(resourceLocation.test(registry), `${label}: invalid registry ${registry}`) + if (official) assert(registry.startsWith('minecraft:'), `${label}: non-vanilla registry ${registry}`) + const tagMap = tags[registry] + assertObject(tagMap, `${label} ${registry}`) + + const tagNames = Object.keys(tagMap) + assert(tagNames.length > 0, `${label} ${registry} must contain at least one named tag`) + assertSorted(tagNames, `${label} ${registry} tag keys`) + + for (const tagName of tagNames) { + assert(resourceLocation.test(tagName), `${label}: invalid tag ${tagName}`) + if (official) assert(tagName.startsWith('minecraft:'), `${label}: non-vanilla tag ${tagName}`) + const members = tagMap[tagName] + assert(Array.isArray(members), `${label} ${tagName} must map to an array`) + assertSorted(members, `${label} ${tagName} members`) + assert.equal(new Set(members).size, members.length, `${label} ${tagName} has duplicate members`) + + for (const member of members) { + assert.equal(typeof member, 'string', `${label} ${tagName} has a non-string member`) + assert(!member.startsWith('#'), `${label} ${tagName} contains unresolved tag ${member}`) + assert(resourceLocation.test(member), `${label} ${tagName} has invalid member ${member}`) + if (official) assert(member.startsWith('minecraft:'), `${label}: non-vanilla member ${member}`) + if (registryMembers[registry]) { + assert(registryMembers[registry].has(member), `${label} ${tagName} has dangling member ${member}`) + } + } + } + } + + if (official) { + for (const required of ['minecraft:block', 'minecraft:item']) { + assert(Object.prototype.hasOwnProperty.call(tags, required), + `${label}: missing required registry ${required}`) + } + } +} + +function namesForRegistry (dataPath, registry, label) { + const fileName = registryFiles[registry] + if (!fileName) return null + + const target = dataPath[fileName] + assert(target, `${label}: missing ${fileName} dataPath for ${registry}`) + const file = path.join(dataRoot, target, fileName + '.json') + assert(fs.existsSync(file), `${label}: missing ${file}`) + const entries = JSON.parse(fs.readFileSync(file)) + assert(Array.isArray(entries), `${label}: ${fileName}.json must be an array`) + + return new Set(entries.map(entry => { + assert(entry && typeof entry.name === 'string', `${label}: ${fileName}.json entry has no name`) + return entry.name.includes(':') ? entry.name : 'minecraft:' + entry.name + })) +} + +function findTagsFiles (dir, files = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) { + findTagsFiles(file, files) + } else if (entry.name === 'tags.json') { + files.push(path.resolve(file)) + } + } + return files +} + +function getPcDataPaths (allDataPaths) { + return allDataPaths.pc || {} +} + +describe('audit tags', function () { + it('keeps dataPaths and repository tags files consistent', function () { + const referenced = new Set() + + for (const version in getPcDataPaths(dataPaths)) { + const dataPath = dataPaths.pc[version] + if (!dataPath.tags) continue + + const label = `pc ${version}` + const tagsFile = path.resolve(dataRoot, dataPath.tags, 'tags.json') + assert(fs.existsSync(tagsFile), `${label}: missing ${tagsFile}`) + referenced.add(tagsFile) + + const tags = JSON.parse(fs.readFileSync(tagsFile)) + const registryMembers = {} + for (const registry of Object.keys(tags)) { + const names = namesForRegistry(dataPath, registry, label) + if (names) registryMembers[registry] = names + } + auditTagDocument(tags, { label, official: true, registryMembers }) + } + + for (const tagsFile of findTagsFiles(pcDataRoot)) { + assert(referenced.has(tagsFile), `tags file is not referenced by dataPaths: ${tagsFile}`) + } + }) + + it('does not apply the Java tag contract to non-PC data paths', function () { + const paths = getPcDataPaths({ + pc: {}, + bedrock: { future: { tags: 'bedrock/future' } } + }) + assert.deepStrictEqual(paths, {}) + }) + + function validTags () { + return { + 'minecraft:block': { + 'minecraft:mineable/axe': ['minecraft:oak_log'], + 'minecraft:mineable/pickaxe': ['minecraft:granite', 'minecraft:stone'] + }, + 'minecraft:fluid': { + 'minecraft:water': ['minecraft:flowing_water', 'minecraft:water'] + }, + 'minecraft:item': { + 'minecraft:pickaxes': ['minecraft:diamond_pickaxe'] + } + } + } + + function members () { + return { + 'minecraft:block': new Set(['minecraft:granite', 'minecraft:oak_log', 'minecraft:stone']), + 'minecraft:item': new Set(['minecraft:diamond_pickaxe']) + } + } + + it('accepts sorted data and structurally audits unmapped registries', function () { + auditTagDocument(validTags(), { official: true, registryMembers: members() }) + }) + + it('rejects registries without named tags while preserving empty member arrays', function () { + const tags = validTags() + tags['minecraft:block'] = {} + + assert.throws( + () => auditTagDocument(tags, { official: true }), + /must contain at least one named tag/ + ) + + tags['minecraft:block'] = { 'minecraft:incorrect_for_diamond_tool': [] } + auditTagDocument(tags, { official: true }) + }) + + it('audits PC artifacts for required block and item registries', function () { + const tags = validTags() + delete tags['minecraft:block'] + + assert.throws( + () => auditTagDocument(tags, { label: 'pc fixture', official: true }), + /missing required registry minecraft:block/ + ) + }) + + it('leaves the goat horn instrument registry structurally audited and unmapped', function () { + assert.equal(namesForRegistry({}, 'minecraft:instrument', 'test'), null) + const baseTags = validTags() + const tags = { + 'minecraft:block': baseTags['minecraft:block'], + 'minecraft:fluid': baseTags['minecraft:fluid'], + 'minecraft:instrument': { + 'minecraft:goat_horns': ['minecraft:ponder_goat_horn'] + }, + 'minecraft:item': baseTags['minecraft:item'] + } + auditTagDocument(tags, { official: true }) + }) + + it('rejects malformed resource locations', function () { + assert.throws( + () => auditTagDocument({ 'minecraft:bad path': {} }), + /invalid registry/ + ) + assert.throws( + () => auditTagDocument({ + 'minecraft:block': { 'minecraft:bad path': [] } + }), + /invalid tag/ + ) + assert.throws( + () => auditTagDocument({ + 'minecraft:block': { 'minecraft:stone': ['minecraft:bad path'] } + }), + /invalid member/ + ) + }) + + it('rejects non-vanilla namespaces in official data', function () { + assert.throws( + () => auditTagDocument({ 'example:block': {} }, { official: true }), + /non-vanilla registry/ + ) + assert.throws( + () => auditTagDocument({ + 'minecraft:block': { 'example:mineable/pickaxe': [] } + }, { official: true }), + /non-vanilla tag/ + ) + assert.throws( + () => auditTagDocument({ + 'minecraft:block': { 'minecraft:mineable/pickaxe': ['example:stone'] } + }, { official: true }), + /non-vanilla member/ + ) + }) + + it('rejects unsorted registry keys', function () { + const tags = { + 'minecraft:item': {}, + 'minecraft:block': {} + } + assert.throws(() => auditTagDocument(tags), /registry keys must be sorted/) + }) + + it('rejects unsorted tag keys', function () { + const tags = validTags() + tags['minecraft:block'] = { + 'minecraft:mineable/pickaxe': ['minecraft:stone'], + 'minecraft:mineable/axe': ['minecraft:oak_log'] + } + assert.throws(() => auditTagDocument(tags), /tag keys must be sorted/) + }) + + it('rejects unsorted member arrays', function () { + const tags = validTags() + tags['minecraft:block']['minecraft:mineable/pickaxe'] = ['minecraft:stone', 'minecraft:granite'] + assert.throws(() => auditTagDocument(tags), /members must be sorted/) + }) + + it('rejects duplicate members in PC tag documents', function () { + const tags = validTags() + tags['minecraft:block']['minecraft:mineable/pickaxe'] = ['minecraft:stone', 'minecraft:stone'] + assert.throws(() => auditTagDocument(tags, { label: 'pc fixture', official: true }), /duplicate members/) + }) + + it('rejects unresolved tag members', function () { + const tags = validTags() + tags['minecraft:block']['minecraft:mineable/pickaxe'] = ['#minecraft:base_stone_overworld'] + assert.throws(() => auditTagDocument(tags), /unresolved tag/) + }) + + it('rejects dangling members for mapped registries', function () { + const tags = validTags() + tags['minecraft:block']['minecraft:mineable/pickaxe'] = ['minecraft:andesite', 'minecraft:stone'] + assert.throws( + () => auditTagDocument(tags, { registryMembers: members() }), + /dangling member minecraft:andesite/ + ) + }) +}) + +module.exports = { auditTagDocument } diff --git a/tools/js/test/tags_schema.js b/tools/js/test/tags_schema.js new file mode 100644 index 000000000..7f5570cf4 --- /dev/null +++ b/tools/js/test/tags_schema.js @@ -0,0 +1,119 @@ +/* eslint-env mocha */ + +const assert = require('assert') +const Ajv = require('ajv') +const schema = require('../../../schemas/tags_schema.json') + +function baseTags () { + return { + 'minecraft:block': { + 'minecraft:mineable/pickaxe': ['minecraft:granite', 'minecraft:stone'] + }, + 'minecraft:item': { + 'minecraft:pickaxes': ['minecraft:diamond_pickaxe'] + } + } +} + +function tagsWith (changes) { + return Object.assign(baseTags(), changes) +} + +describe('tags schema', function () { + const validator = new Ajv({ verbose: true }) + + it('accepts namespaced registries, tags, and members', function () { + assert.ok(validator.validate(schema, baseTags()), JSON.stringify(validator.errors, null, 2)) + }) + + it('accepts path-shaped registries, empty tags, and modded namespaces', function () { + const tags = baseTags() + tags['minecraft:block']['minecraft:incorrect_for_diamond_tool'] = [] + tags['minecraft:worldgen/biome'] = { + 'minecraft:is_overworld': ['minecraft:plains'] + } + tags['example:block'] = { + 'example:mineable/hammer': ['example:ore'] + } + tags['minecraft:fluid'] = { + 'minecraft:water': ['minecraft:flowing_water', 'minecraft:water'] + } + tags['minecraft:entity_type'] = { + 'minecraft:raiders': ['minecraft:pillager'] + } + assert.ok(validator.validate(schema, tags), JSON.stringify(validator.errors, null, 2)) + }) + + const invalid = [ + { + name: 'missing required item registry', + value: { 'minecraft:block': { 'minecraft:logs': [] } } + }, + { + name: 'missing required block registry', + value: { 'minecraft:item': { 'minecraft:pickaxes': [] } } + }, + { + name: 'empty registry tag map', + value: tagsWith({ 'minecraft:block': {} }) + }, + { + name: 'registry without namespace', + value: tagsWith({ block: {} }) + }, + { + name: 'malformed registry namespace', + value: tagsWith({ 'mine craft:block': {} }) + }, + { + name: 'uppercase registry namespace', + value: tagsWith({ 'Minecraft:block': {} }) + }, + { + name: 'malformed registry path', + value: tagsWith({ 'minecraft:block path': {} }) + }, + { + name: 'tag without namespace', + value: tagsWith({ 'minecraft:block': { stone: [] } }) + }, + { + name: 'uppercase tag path', + value: tagsWith({ 'minecraft:block': { 'minecraft:Mineable/pickaxe': [] } }) + }, + { + name: 'member without namespace', + value: tagsWith({ 'minecraft:block': { 'minecraft:stone': ['stone'] } }) + }, + { + name: 'uppercase member path', + value: tagsWith({ 'minecraft:block': { 'minecraft:stone': ['minecraft:Stone'] } }) + }, + { + name: 'duplicate member', + value: tagsWith({ 'minecraft:block': { 'minecraft:stone': ['minecraft:stone', 'minecraft:stone'] } }) + }, + { + name: 'unresolved tag reference', + value: tagsWith({ 'minecraft:block': { 'minecraft:stone': ['#minecraft:base_stone_overworld'] } }) + }, + { + name: 'null registry', + value: tagsWith({ 'minecraft:block': null }) + }, + { + name: 'null tag members', + value: tagsWith({ 'minecraft:block': { 'minecraft:mineable/pickaxe': null } }) + }, + { + name: 'non-array tag members', + value: tagsWith({ 'minecraft:block': { 'minecraft:stone': 'minecraft:stone' } }) + } + ] + + for (const test of invalid) { + it('rejects ' + test.name, function () { + assert.equal(validator.validate(schema, test.value), false) + }) + } +}) diff --git a/tools/js/test/test.js b/tools/js/test/test.js index 9b57ae421..a1005abda 100644 --- a/tools/js/test/test.js +++ b/tools/js/test/test.js @@ -18,7 +18,7 @@ after('the test suite stays fast', function () { assert.ok(ms < 40 * 1000, `the test suite took ${Math.round(ms)}ms, expected < 40s`) }) -const data = ['attributes', 'biomes', 'commands', 'instruments', 'items', 'materials', 'blocks', 'blockCollisionShapes', 'recipes', 'windows', 'entities', 'protocol', 'version', 'effects', 'enchantments', 'language', 'foods', 'particles', 'blockLoot', 'entityLoot', 'mapIcons', 'tints', 'blockMappings', 'sounds', 'blockStates'] +const data = ['attributes', 'biomes', 'commands', 'instruments', 'items', 'materials', 'blocks', 'blockCollisionShapes', 'recipes', 'windows', 'entities', 'protocol', 'version', 'effects', 'enchantments', 'language', 'foods', 'particles', 'blockLoot', 'entityLoot', 'mapIcons', 'tints', 'blockMappings', 'sounds', 'blockStates', 'tags'] require('./version_iterator')(function (p, versionString) { describe('minecraft-data schemas ' + versionString, function () {