fix(okf-check): recommended last-change field follows the bundle's okf_version
The gate carried a flat RECOMMENDED list ending in `timestamp`. Upstream retired
that field in v0.2: "`timestamp` is superseded by `generated.at`" (okf/SPEC.md
§13.1:802-803, read at frozen 3fcbb9f), one of the version's two breaking
changes — while :804 still lets a consumer "fall back to a legacy `timestamp`
when `generated` is absent".
A version-unconditional list cannot serve both readings. It either nags a
correct v0.2 bundle about a retired field, or goes silent about a field v0.1
still wants. So the list is now chosen by the bundle root's own okf_version:
< 0.2 (or absent/unshaped) keeps `timestamp`, >= 0.2 asks for `generated`.
Absence gets the legacy floor deliberately — §3 echoes a missing marker rather
than failing it, so it still needs a defined list.
Measured, not assumed:
- Every fixture in both corpora and both live emitters (okr, linkedin-studio)
still write `okf_version: 0.1`, so this changes NO verdict today. It is
written now because the upstream reading is fresh and pinned to a commit.
- The parity signature is conceptCount|untyped|okfVersion|okfVersionAccepted
(check-okf-parity.mjs:73-76) — warnings are not in it, so diverging from
okr's list here cannot red the parity gate. Confirmed: 9/9 fixtures pass.
- The compare is component-wise, NOT parseFloat: okf_version is version-SHAPED,
and parseFloat('0.10') is 0.1, which would sort 0.10 before 0.2 and hand a
newer bundle the retired field. Guarded by its own test.
spec §4 is updated in the same commit — a gate and the convention it enforces
must not disagree about which field they want.
Tests 98 -> 103 (okf-check 17 -> 22). All six suites green; check-versions
11 OK / 0 WARN / 0 ERROR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135YZBcCDvH3BgG5yEtuHCA
This commit is contained in:
parent
a67d7f5340
commit
fa3c5d8482
3 changed files with 136 additions and 4 deletions
|
|
@ -83,8 +83,17 @@ catalog exercising that, and a reader must not take it as upstream's requirement
|
|||
|
||||
## 4. Recommended fields (warnings, not errors)
|
||||
|
||||
`title`, `description`, `resource` (canonical source URI), `tags`, `timestamp`. Supply where cheap.
|
||||
`title`, `description`, `resource` (canonical source URI), `tags`, and a last-content-change
|
||||
marker whose name depends on the bundle's `okf_version` (below). Supply where cheap.
|
||||
|
||||
- **The last-change marker is version-dependent.** A bundle declaring `okf_version` **< 0.2**
|
||||
(or none at all) uses **`timestamp`**; **>= 0.2** uses **`generated`**. Upstream retired the
|
||||
first in favour of `generated: { by, at }` — "`timestamp` is superseded by `generated.at`",
|
||||
one of v0.2's two breaking changes (`okf/SPEC.md` §13.1:802-803, read at `3fcbb9f`) — while
|
||||
still permitting a consumer to "fall back to a legacy `timestamp` when `generated` is absent"
|
||||
(`:804`). The gate therefore asks each bundle for the marker its own version names, and never
|
||||
nags a correct bundle of either version. An absent or non-version-shaped `okf_version` gets
|
||||
the legacy floor: absence is echoed, not failed (§3), so it still needs a defined list.
|
||||
- **Canonical name is `resource`** (the OKF spec's name) — **not** `source`.
|
||||
- A field that would break a plugin's invariant may be omitted. Example: linkedin-studio omits
|
||||
`timestamp` (its serializer is pure/deterministic — a timestamp would break round-trip) and
|
||||
|
|
|
|||
|
|
@ -32,7 +32,23 @@ import { join, relative } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFrontmatter } from './okf-frontmatter.mjs';
|
||||
|
||||
const RECOMMENDED = ['resource', 'title', 'description', 'timestamp'];
|
||||
// Recommended fields (spec §4) — WARNINGS, never failures. The list is chosen by the bundle
|
||||
// root's own okf_version, because upstream retired one of them.
|
||||
//
|
||||
// okf/SPEC.md §13.1:802-805 (read at 3fcbb9f, 2026-07-31): "`timestamp` is superseded by
|
||||
// `generated.at`" — one of v0.2's two deliberate breaking changes — and :804 lets a consumer
|
||||
// "fall back to a legacy `timestamp` when `generated` is absent". A flat, version-unconditional
|
||||
// list cannot serve both: it either nags a correct v0.2 bundle about a retired field, or goes
|
||||
// silent about a field v0.1 still wants. So: pick by version, and let ABSENCE mean the legacy
|
||||
// floor (absence is echoed, never failed — §3 — so it still needs a defined list).
|
||||
//
|
||||
// `generated` is read by the same FLAT reader as every other key, so it sees upstream's own
|
||||
// flow form (`generated: { by, at }`, SPEC.md:236/371) as one string — present is all this axis
|
||||
// asks. A block-style `generated:` with the pair on following lines would read as absent and
|
||||
// warn. That is a known limit of the vendored reader, not a rule of this convention.
|
||||
const RECOMMENDED_V01 = ['resource', 'title', 'description', 'timestamp'];
|
||||
const RECOMMENDED_V02 = ['resource', 'title', 'description', 'generated'];
|
||||
const GENERATED_AT_FLOOR = '0.2';
|
||||
|
||||
// All concept files (.md except index.md) under root, recursively.
|
||||
function walkConcepts(root) {
|
||||
|
|
@ -100,10 +116,34 @@ function okfVersionShapeError(value) {
|
|||
+ "a plugin's own layout revision belongs in okf_layout (spec §12)";
|
||||
}
|
||||
|
||||
// Is `value` at least `floor`, comparing version COMPONENTS? okf_version is version-SHAPED
|
||||
// (UPSTREAM_VERSION_SHAPE), which is not the same thing as a decimal number: parseFloat('0.10')
|
||||
// is 0.1 and would sort 0.10 BEFORE 0.2, handing a newer bundle the retired field. A value that
|
||||
// is not version-shaped is not ordered at all — it already carries okfVersionError, and the
|
||||
// caller falls back to the legacy floor rather than guessing.
|
||||
function isAtLeast(value, floor) {
|
||||
if (value === null || !UPSTREAM_VERSION_SHAPE.test(value)) return false;
|
||||
const a = value.split('.').map(Number);
|
||||
const b = floor.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return x > y;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function recommendedFor(okfVersion) {
|
||||
return isAtLeast(okfVersion, GENERATED_AT_FLOOR) ? RECOMMENDED_V02 : RECOMMENDED_V01;
|
||||
}
|
||||
|
||||
export function checkBundle(root) {
|
||||
const concepts = walkConcepts(root);
|
||||
const missingType = [];
|
||||
const warnings = [];
|
||||
// Read the root marker BEFORE the concept loop: it selects which recommended list applies.
|
||||
const { value: okfVersion, placement: okfVersionPlacement } = rootOkfVersion(root);
|
||||
const RECOMMENDED = recommendedFor(okfVersion);
|
||||
for (const f of concepts) {
|
||||
const { get } = parseFrontmatter(readFileSync(f, 'utf8'));
|
||||
const rel = relative(root, f);
|
||||
|
|
@ -115,7 +155,6 @@ export function checkBundle(root) {
|
|||
if (!get(field)) warnings.push(`${rel}: missing recommended field "${field}"`);
|
||||
}
|
||||
}
|
||||
const { value: okfVersion, placement: okfVersionPlacement } = rootOkfVersion(root);
|
||||
return {
|
||||
scanned: concepts.length,
|
||||
missingType,
|
||||
|
|
|
|||
|
|
@ -299,3 +299,87 @@ test('§3 v0.3: unquoting does not weaken the shape rule — a quoted layout str
|
|||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- §13.1 (upstream v0.2): `timestamp` is superseded by `generated.at` ---
|
||||
// okf/SPEC.md:802-805 @ 3fcbb9f, read 2026-07-31. The supersession is one of v0.2's two
|
||||
// deliberate BREAKING changes (§13.1), and :804 explicitly lets a consumer "fall back to a
|
||||
// legacy `timestamp` when `generated` is absent". A flat, version-unconditional RECOMMENDED
|
||||
// list cannot be right for both versions at once: it either nags a correct v0.2 bundle about a
|
||||
// retired field, or goes silent about a field v0.1 still wants. So the list is chosen by the
|
||||
// bundle root's own okf_version.
|
||||
//
|
||||
// Measured when written: every fixture in both corpora and both live emitters (okr,
|
||||
// linkedin-studio) still write 0.1, so this changes no verdict today. It is written now
|
||||
// because the upstream reading is fresh and pinned to a frozen commit.
|
||||
|
||||
// A concept carrying every v0.1 recommended field except the one under test.
|
||||
function conceptMissing(field) {
|
||||
const fm = { type: 'Note', title: 'T', description: 'D', resource: 'about' };
|
||||
if (field !== 'timestamp') fm.timestamp = '2026-06-29';
|
||||
if (field !== 'generated') fm.generated = '{ by: human:ktg, at: 2026-06-29T00:00:00Z }';
|
||||
const body = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join('\n');
|
||||
return `---\n${body}\n---\n# Concept\n`;
|
||||
}
|
||||
|
||||
function warnsAbout(dir, versionLine, field) {
|
||||
writeFileSync(join(dir, 'index.md'), `${versionLine}\n\n# Bundle\n`);
|
||||
writeFileSync(join(dir, 'c.md'), conceptMissing(field));
|
||||
return checkBundle(dir).warnings.some((w) => w.includes(`"${field}"`));
|
||||
}
|
||||
|
||||
test('§13.1: a v0.1 bundle still wants `timestamp` (the legacy fallback of :804)', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
assert.ok(warnsAbout(dir, 'okf_version: 0.1', 'timestamp'), 'v0.1 keeps the legacy field');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('§13.1: a v0.2 bundle is NOT nagged about the retired `timestamp`', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
assert.equal(
|
||||
warnsAbout(dir, '---\nokf_version: "0.2"\n---', 'timestamp'),
|
||||
false,
|
||||
'a v0.2 bundle recording generated.at is correct, not deficient',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('§13.1: a v0.2 bundle wants `generated` in its place', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
assert.ok(warnsAbout(dir, '---\nokf_version: "0.2"\n---', 'generated'), 'v0.2 wants generated');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('§13.1: an ABSENT okf_version falls back to the v0.1 floor, not the v0.2 list', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
// Absence is echoed, never failed (§3) — so it must still get a defined recommended list.
|
||||
assert.ok(warnsAbout(dir, '# Bundle only', 'timestamp'), 'no marker -> legacy floor');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard on the comparison itself. okf_version is version-SHAPED (^\d+(\.\d+)*$),
|
||||
// so it is NOT a decimal number: parseFloat('0.10') is 0.1, which would sort 0.10 BEFORE 0.2
|
||||
// and hand a newer bundle the retired field. Compare component-wise or not at all.
|
||||
test('§13.1: 0.10 is NEWER than 0.2 — the version compare is not parseFloat', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
assert.equal(
|
||||
warnsAbout(dir, 'okf_version: 0.10', 'timestamp'),
|
||||
false,
|
||||
'0.10 > 0.2, so the retired field must not be demanded',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue