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
4 changes: 3 additions & 1 deletion .claude/skills/new-entry/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ Companion skill: [publish-entry](../publish-entry/SKILL.md) moves the draft live
1. **Read the issue.** Pull `shortName`, `objectType`, `preferredName` (en, optionally de),
`definition` (en, optionally de), and every optional field the requester filled in
(`valueDataType`, `unit`, `quantityKind`, `definitionStandard`, `testStandard`, `legalBasis`,
`exampleValue`, enumeration values, collection members, `identicalTo`). Use `legalBasis` for
`exampleValue`, enumeration values, collection members, `identicalTo`, plus the kind-specific
block — `itemType`, `symbol`, `ucumCode`, `dimension`, `value` — of which only the one matching
`objectType` should be filled, and step 4 says which). Use `legalBasis` for
a law/regulation citation (e.g. an ELI reference) and `definitionStandard`/`testStandard`
only for a technical/testing standard — don't conflate the two.

Expand Down
51 changes: 51 additions & 0 deletions .github/ISSUE_TEMPLATE/dictionary-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,57 @@ body:
description: For units/quantities — URI of the Quantity entry, or its name if new.
validations:
required: false
- type: markdown
attributes:
value: >-
**Kind-specific fields.** Only the one matching your `objectType` applies — leave the rest
blank. Each is required in the envelope for its kind, so a request without it cannot be
drafted; the form cannot mark them required because it can't see which kind you picked.
- type: input
id: item-type
attributes:
label: itemType — for MultiValuedDataElement
description: URI of the entry describing a single item of the list — or its name if new.
placeholder: "https://material-identity.eu/def/<uuid> — or: new entry needed: alloyingElement"
validations:
required: false
- type: input
id: symbol
attributes:
label: symbol — for MeasurementUnit
description: The printed symbol. Give the English form; drafting expands it to a language map.
placeholder: MPa
validations:
required: false
- type: input
id: ucum-code
attributes:
label: ucumCode — for MeasurementUnit
description: >-
The UCUM code for the unit, which enables automatic conversion. Its syntax is validated
automatically, so leave it blank rather than guessing — note UCUM is case-sensitive and
spells some units unexpectedly (`Cel` for degree Celsius, `[psi]`, `mm2`).
placeholder: MPa
validations:
required: false
- type: input
id: dimension
attributes:
label: dimension — for Quantity
description: Dimensional formula in M/L/T/I/Θ/N/J, exponents with `^`.
placeholder: M L^-1 T^-2
validations:
required: false
- type: input
id: value
attributes:
label: value — for Value
description: >-
The literal value this entry stands for. Only for a standalone Value request — members of
an enumeration go in the "Enumeration values" field below instead.
placeholder: EAF
validations:
required: false
- type: input
id: definition-standard
attributes:
Expand Down
103 changes: 103 additions & 0 deletions test/request-form.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';

// The dictionary-request form is the only way a requester states what they want, and triage
// generates the draft YAML from it. When the schema grows a kind-specific requirement and the
// form does not, the form silently stops being able to express a valid request for that kind —
// which is exactly what happened to itemType, symbol, ucumCode, dimension and value (#114).
// So this asserts the two stay in step rather than trusting anyone to remember.

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const FORM_PATH = '.github/ISSUE_TEMPLATE/dictionary-request.yml';

interface FormField {
type: string;
id?: string;
attributes?: { label?: string; options?: string[] };
}

const schema = JSON.parse(readFileSync(join(root, 'schema', 'dictionary-entry.schema.json'), 'utf8')) as {
properties: Record<string, SchemaNode>;
$defs?: Record<string, SchemaNode>;
allOf?: { if?: { properties?: { objectType?: { const?: string } } }; then?: ThenClause }[];
};

interface SchemaNode {
$ref?: string;
properties?: Record<string, SchemaNode>;
}

/** Follow a local `#/$defs/<name>` reference one level — enough for this schema's shape. */
function resolve(node: SchemaNode | undefined): SchemaNode | undefined {
const name = node?.$ref?.replace('#/$defs/', '');
return name ? schema.$defs?.[name] : node;
}
const form = parse(readFileSync(join(root, FORM_PATH), 'utf8')) as { body: FormField[] };

interface ThenClause {
required?: string[];
properties?: Record<string, { required?: string[] }>;
}

/** Every property a `then` clause demands, nested ones as "parent.child" (crossReferences.ucumCode). */
function requiredBy(then: ThenClause): string[] {
const direct = then.required ?? [];
const nested = Object.entries(then.properties ?? {}).flatMap(([parent, sub]) =>
(sub.required ?? []).map((child) => `${parent}.${child}`),
);
// a `required: [crossReferences]` alongside the nested rule adds nothing a requester can act on
return [...direct.filter((p) => !nested.some((n) => n.startsWith(`${p}.`))), ...nested];
}

/** Schema property name → the form input id that captures it. */
function formId(property: string): string {
const leaf = property.includes('.') ? property.slice(property.indexOf('.') + 1) : property;
return leaf.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
}

/** Where the form's own wording diverges from the envelope's field name. */
const RENAMED: Record<string, string> = { elements: 'collection-members' };

test('every objectType the form offers is one the form can complete (#114)', () => {
const ids = new Set(form.body.filter((f) => f.id).map((f) => f.id as string));
const offered = new Set(
form.body.find((f) => f.id === 'object-type')?.attributes?.options ?? [],
);
assert.ok(offered.size > 0, 'the objectType dropdown should list its kinds');

const missing: string[] = [];
for (const rule of schema.allOf ?? []) {
const kind = rule.if?.properties?.objectType?.const;
if (!kind || !rule.then) continue;
assert.ok(offered.has(kind), `schema constrains ${kind}, which the form does not offer`);
for (const property of requiredBy(rule.then)) {
const id = RENAMED[property] ?? formId(property);
if (!ids.has(id)) missing.push(`${kind} requires ${property} — no form input "${id}"`);
}
}
assert.deepEqual(missing, [], `${FORM_PATH} cannot capture:\n ${missing.join('\n ')}`);
});

test('the form only asks for fields the envelope actually has (#114)', () => {
// The reverse drift: an input whose id matches no schema property produces a value with
// nowhere to go, and triage has to guess whether it was renamed or dropped.
const properties = new Set(Object.keys(schema.properties).map(formId));
for (const nested of Object.keys(resolve(schema.properties.crossReferences)?.properties ?? {})) {
properties.add(formId(nested));
}
assert.ok(properties.has('ucum-code'), 'the $ref into $defs should have resolved');
// Inputs that are deliberately about the request, not the entry.
const NOT_ENVELOPE = new Set([
'short-name', 'object-type', 'justification', 'preferred-name-en', 'preferred-name-de',
'definition-en', 'definition-de', 'value-data-type', 'collection-members', 'identical-to',
]);

const orphans = form.body
.filter((f) => f.id && !NOT_ENVELOPE.has(f.id) && !properties.has(f.id))
.map((f) => `${f.id} (${f.attributes?.label ?? 'no label'})`);
assert.deepEqual(orphans, [], `form inputs with no matching envelope field: ${orphans.join(', ')}`);
});