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:
Kjell Tore Guttormsen 2026-07-31 21:27:31 +02:00
commit fa3c5d8482
3 changed files with 136 additions and 4 deletions

View file

@ -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,