Adopt and maintain an Open Knowledge Format v0.2 documentation bundle: frontmatter, generated indexes, a fail-closed conformance check, and coverage that names every unindexed document
72
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
#!/usr/bin/env node
import { readdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const VERSION = '1.5.0';
const OKF_VERSION = '0.2';
export { VERSION, OKF_VERSION, GEN_MARKER };
const USAGE = `usage:
okf.mjs index <bundle-root> [--stdout] [--describe <dir>=<text>]...
okf.mjs check <bundle-root>
okf.mjs coverage <bundle-root>
okf.mjs wire <bundle-root> [--stdout]
okf.mjs --version
<bundle-root>/.okfignore, if present, lists paths this skill does not own: one
bundle-root-relative path per line, trailing / for a directory, # for a comment.
They are not enumerated, not checked, and never stamped.
check reads the corpus the walk finds; coverage reads git ls-files instead, so a
document the walk never reaches is named rather than agreed with. Only an index the
root index reaches may vouch for a document; generated ones nothing links to are
named as orphan-index and their rows do not count. A tracked index whose row points
at a document git is not tracking is named as dangling-row: 'git commit -a' would
carry the index and leave the document behind.
exit: 0 ok | 1 violations | 77 nothing evaluated | 64 usage`;
const RESERVED = new Set(['index.md', 'log.md']);
const PROJECT_META = new Set([
'readme.md', 'license.md', 'licence.md', 'contributing.md', 'code_of_conduct.md',
'security.md', 'changelog.md', 'claude.md', 'agents.md', 'gemini.md',
'third-party-notices.md', 'notice.md', 'authors.md', 'maintainers.md',
]);
const SKIP_DIRS = new Set(['node_modules', '__pycache__']);
// Claude Code owns every .md under these, at a plugin root: a file in commands/ IS a slash
// command, a file in agents/ IS an agent definition, and a skill folder's contract is that
// SKILL.md is the entry point with the loader's own frontmatter schema. See PAYLOAD_DIRS below.
const PAYLOAD_DIRS = new Set(['commands', 'agents', 'skills']);
const PLUGIN_MANIFESTS = ['plugin.json', 'marketplace.json'];
const IGNORE_FILE = '.okfignore';
const GEN_MARKER = '<!-- generated by okf.mjs - do not edit; regenerate after changing frontmatter -->';
const SUBDIR_HEADING = 'Subdirectories';
const UNTYPED_HEADING = 'Other';
// A title may contain brackets — "Clarifying questions: [Feature Name]" is an ordinary
// heading — so they are escaped on the way out and understood on the way back in. Left
// raw, the row is still readable to a human and unparseable to every consumer here: the
// round-trip description store loses it, and the coverage check reports the document as
// indexed by nobody.
const ENTRY_RE = /^\* \[((?:\\.|[^\\\]])*)\]\(([^)]*)\)(?: - (.*))?$/;
const escapeTitle = (title) => title.replace(/[\\\[\]]/g, (c) => `\\${c}`);
const unescapeTitle = (title) => title.replace(/\\(.)/g, '$1');
const KEY_RE = /^([A-Za-z0-9_.-]+):(?:[ \t]+(.*))?$/;
const PROFILE_RE = /^profile:[ \t]*(\S.*?)[ \t]*$/;
const NESTED_RE = /^[ \t]+(?:- )?(?:[A-Za-z0-9_.-]+:(?:[ \t].*)?|- .*|\{.*\}|.+)$/;
const BLOCK_RE = /^([|>])[1-9]?[+-]?$/;
const DESC_MAX = 160;
const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
function gated(raw, label, reports) {
const text = oneLine(raw);
if (text.length <= DESC_MAX) return text;
reports.push(`${label} (${text.length} chars, max ${DESC_MAX})`);
return '';
}
const ENTRY_OPEN = '<!-- okf:entry -->';
const ENTRY_CLOSE = '<!-- /okf:entry -->';
const ENTRY_BLOCK_RE = /<!-- okf:entry -->[\s\S]*?<!-- \/okf:entry -->/;
const ENTRY_BLOCK = `${ENTRY_OPEN}
## Documentation
Start at [index.md](index.md). Every documentation folder carries a generated \`index.md\` listing
each document's title and one-line description — answer "which doc covers X" and "does a doc for Y
exist" from that index in one read, and open a document only after the index names it. Do not grep
\`docs/\` for a document's identity; grep stays correct only for a literal phrase inside a body that
the index cannot carry.
${ENTRY_CLOSE}`;
const PROSE_ENTRY_FILES = ['CLAUDE.md', 'AGENTS.md'];
const IMPORT_ENTRY_FILE = 'GEMINI.md';
const IMPORT_LINE = '@index.md';
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const relPath = (root, path) => relative(root, path).split(sep).join('/');
export function readIgnores(root) {
const state = { entries: [], skipped: [], nested: [], payload: [], present: false };
let text;
try { text = readFileSync(join(root, IGNORE_FILE), 'utf8'); } catch { return state; }
state.present = true;
text.split('\n').forEach((raw, i) => {
const line = raw.replace(/\r$/, '').trim();
if (!line || line.startsWith('#')) return;
const isDir = line.endsWith('/');
const target = (isDir ? line.slice(0, -1) : line).replace(/^\.\//, '').replace(/\/+$/, '');
if (!target) return;
state.entries.push({ target, isDir, lineno: i + 1, hits: 0 });
});
return state;
}
function ignoreHit(state, rel, isDir) {
for (const entry of state.entries) {
if (entry.isDir !== isDir || entry.target !== rel) continue;
entry.hits += 1;
state.skipped.push(`${rel}${isDir ? '/' : ''} (${IGNORE_FILE}:${entry.lineno})`);
return true;
}
return false;
}
// The walk prunes a declared directory by never descending into it. A path list
// arriving from outside the walk (coverage) has no such moment, so a directory
// line has to be re-read as "this path, or anything beneath it".
export function ignoredFile(state, rel) {
for (const entry of state.entries) {
const hit = entry.isDir ? rel.startsWith(`${entry.target}/`) : rel === entry.target;
if (!hit) continue;
entry.hits += 1;
if (!entry.isDir || entry.hits === 1) {
state.skipped.push(`${entry.target}${entry.isDir ? '/' : ''} (${IGNORE_FILE}:${entry.lineno})`);
}
return true;
}
return false;
}
function reportIgnores(state) {
for (const rel of [...new Set(state.payload)].sort(byCodepoint)) {
process.stderr.write(
`plugin-payload: ${rel}/ (Claude Code owns this directory's markdown; not indexed, not checked)\n`);
}
for (const rel of [...new Set(state.nested)].sort(byCodepoint)) {
process.stderr.write(`separate-repo: ${rel}/ (own git work tree; not descended into, nothing written there)\n`);
}
for (const line of [...state.skipped].sort(byCodepoint)) {
process.stderr.write(`ignored: ${line}\n`);
}
for (const entry of state.entries) {
if (entry.hits) continue;
process.stderr.write(
`unused-ignore: ${entry.target}${entry.isDir ? '/' : ''} (${IGNORE_FILE}:${entry.lineno})\n`);
}
}
export function declaredProfile(root) {
for (const candidate of [join(root, 'docs', 'okf.yaml'), join(root, 'okf.yaml')]) {
let text;
try { text = readFileSync(candidate, 'utf8'); } catch { continue; }
for (const line of text.split('\n')) {
const m = PROFILE_RE.exec(line.replace(/\r$/, ''));
if (m) return [candidate, m[1]];
}
}
return [null, null];
}
export function scalar(raw) {
if (raw === undefined || raw === null) return '';
return raw.trim();
}
function unquote(value) {
const q = value[0];
if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) return value.slice(1, -1);
return value;
}
function scalarViolation(key, value, lineno) {
if (!value) return null;
const q = value[0];
if ((q === '"' || q === "'") && !(value.length >= 2 && value.endsWith(q))) {
return `line ${lineno}: unterminated quoted value for '${key}'`;
}
if (value[0] === '[' && !value.endsWith(']')) {
return `line ${lineno}: unterminated flow sequence for '${key}'`;
}
if (value[0] === '{' && !value.endsWith('}')) {
return `line ${lineno}: unterminated flow mapping for '${key}'`;
}
return null;
}
function readBlockScalar(lines, start, style) {
const body = [];
let i = start;
for (; i < lines.length; i += 1) {
const line = lines[i].replace(/\r$/, '');
if (!line.trim()) { body.push(''); continue; }
if (line[0] !== ' ' && line[0] !== '\t') break;
body.push(line.replace(/^[ \t]+/, ''));
}
while (body.length && !body[body.length - 1]) body.pop();
if (style === '|') return [body.join('\n'), i];
const folded = [];
for (const line of body) {
if (!line) { folded.push('\n'); continue; }
if (folded.length && folded[folded.length - 1] !== '\n') folded.push(' ');
folded.push(line);
}
return [folded.join(''), i];
}
export function parseBlock(block) {
const data = {};
let seenKey = false;
const lines = block.split('\n');
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].replace(/\r$/, '');
const lineno = i + 2;
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (line[0] === ' ' || line[0] === '\t') {
if (!seenKey) return [null, `line ${lineno}: indented content before any key`];
if (!NESTED_RE.test(line)) return [null, `line ${lineno}: unparseable nested content`];
continue;
}
if (trimmed.startsWith('- ')) {
return [null, `line ${lineno}: top-level sequence; frontmatter must be a mapping`];
}
const m = KEY_RE.exec(line);
if (!m) return [null, `line ${lineno}: not a mapping entry`];
const key = m[1];
if (Object.prototype.hasOwnProperty.call(data, key)) {
return [null, `line ${lineno}: duplicate key '${key}'`];
}
const value = scalar(m[2]);
const sigil = BLOCK_RE.exec(value);
if (sigil) {
const [text, next] = readBlockScalar(lines, i + 1, sigil[1]);
data[key] = text;
seenKey = true;
i = next - 1;
continue;
}
const bad = scalarViolation(key, value, lineno);
if (bad) return [null, bad];
data[key] = unquote(value);
seenKey = true;
}
return [data, null];
}
// The first H1 of the body, skipping fenced blocks — a shell comment and a heading
// are the same three characters, and the fences are where shell comments live.
function firstHeading(lines, start) {
let fenced = false;
for (let i = start; i < lines.length; i += 1) {
const line = lines[i];
if (/^\s{0,3}(?:```|~~~)/.test(line)) { fenced = !fenced; continue; }
if (fenced) continue;
const m = /^# +(.+?)\s*$/.exec(line);
if (m) return m[1].replace(/\s+#+\s*$/, '').trim();
}
return '';
}
// [frontmatter, error, first-heading]. The heading is returned even when the
// frontmatter is absent or unparseable, because a row still has to be rendered.
export function readDoc(path) {
let text;
try { text = readFileSync(path, 'utf8'); }
catch (err) { return [null, `unreadable: ${err.message}`, '']; }
const lines = text.split('\n').map((l) => l.replace(/\r$/, ''));
if (!lines.length || lines[0].trim() !== '---') {
return [null, 'no YAML frontmatter block', firstHeading(lines, 0)];
}
for (let i = 1; i < lines.length; i += 1) {
if (lines[i].trim() === '---') {
const [data, err] = parseBlock(lines.slice(1, i).join('\n'));
return [data, err, firstHeading(lines, i + 1)];
}
}
return [null, 'unterminated YAML frontmatter block', ''];
}
// Two different questions, deliberately not one predicate.
//
// isConcept answers "must this document carry the required keys" — the job it has
// always had, unchanged in name and in bytes, because installs elsewhere read it
// that way. `check` is its only caller now.
//
// isListable answers "does this document get a row". Everything tracked does,
// because a reader looking for a document cannot know in advance that the corpus
// filed it as project furniture. A row costs a line and imposes nothing: the title
// degrades to the first heading, then to the filename, so a file with no
// frontmatter is listed exactly as well as one with.
const isConcept = (name) => {
const low = name.toLowerCase();
return name.endsWith('.md') && !RESERVED.has(low) && !PROJECT_META.has(low);
};
const isListable = (name) => name.endsWith('.md') && !RESERVED.has(name.toLowerCase());
// A directory holding a `.git` entry is another repository's working tree — a submodule
// (where `.git` is a FILE containing a gitdir: pointer) or a nested clone. The parent only
// pins a submodule by SHA; writing inside one edits a repository the caller does not own,
// and it is invisible twice over: nothing in the output distinguishes those files from the
// caller's own, and `coverage` cannot catch it because git reports a submodule as a single
// gitlink. So the boundary is refused structurally, not left to a per-repo .okfignore line
// nobody can add before the first run. `existsSync` on `.git` needs no git dependency.
const isRepoBoundary = (dirpath) => existsSync(join(dirpath, '.git'));
// A directory holding plugin.json or marketplace.json - at the package root or under
// .claude-plugin/, both of which Claude Code loads - is a plugin root, and its
// commands/, agents/ and skills/ children are payload the loader parses, not
// documentation. Writing an index.md there puts a document where the loader expects executable
// payload; demanding OKF frontmatter there asks a SKILL.md to carry keys that are not its schema
// and a progressively-disclosed reference file to pay context for keys nobody reads. Neither is a
// per-repo preference, so the boundary is refused structurally, exactly as isRepoBoundary is -
// an .okfignore line cannot be written before the first run has already done the damage.
//
// The anchor is the manifest rather than the directory name on purpose: a docs/commands/ folder
// documenting a CLI is ordinary knowledge, and a name-only rule would silently swallow it.
//
// Both manifest locations are probed because Claude Code accepts both: an installed plugin in
// ~/.claude/plugins/cache/ carries plugin.json at its package root with no .claude-plugin/ at all,
// and a marketplace entry pointing at such a directory loads its commands, skills and hooks
// normally. A probe that recognised only the nested form was stricter than the loader it models,
// which is the same mistake in the opposite direction: it let payload back in.
const isPluginRoot = (dirpath) => PLUGIN_MANIFESTS.some(
(m) => existsSync(join(dirpath, '.claude-plugin', m)) || existsSync(join(dirpath, m)));
// The walk prunes a payload directory by never descending into it. A path list arriving from
// outside the walk (coverage) has no such moment, so each tracked path re-derives its own
// nearest payload ancestor; the plugin-root probe is memoised because siblings share it.
function payloadOwner(root, rel, cache) {
const parts = rel.split('/');
for (let i = 0; i < parts.length - 1; i += 1) {
if (!PAYLOAD_DIRS.has(parts[i])) continue;
const parent = parts.slice(0, i).join('/');
let isRoot = cache.get(parent);
if (isRoot === undefined) {
isRoot = isPluginRoot(join(root, parent));
cache.set(parent, isRoot);
}
if (isRoot) return parts.slice(0, i + 1).join('/');
}
return null;
}
function walk(root, ignores) {
const out = [];
const keep = (dirpath, name, isDir) =>
!ignoreHit(ignores, relPath(root, join(dirpath, name)), isDir);
const crossesPayload = (dirpath, name) => {
if (!PAYLOAD_DIRS.has(name) || !isPluginRoot(dirpath)) return false;
ignores.payload.push(relPath(root, join(dirpath, name)));
return true;
};
const crossesBoundary = (dirpath, name) => {
if (!isRepoBoundary(join(dirpath, name))) return false;
ignores.nested.push(relPath(root, join(dirpath, name)));
return true;
};
const visit = (dirpath) => {
let entries;
try { entries = readdirSync(dirpath, { withFileTypes: true }); } catch { return; }
const dirnames = entries
.filter((e) => e.isDirectory())
.filter((e) => keep(dirpath, e.name, true))
.filter((e) => !e.name.startsWith('.') && !SKIP_DIRS.has(e.name))
.filter((e) => !crossesPayload(dirpath, e.name))
.filter((e) => !crossesBoundary(dirpath, e.name))
.map((e) => e.name).sort(byCodepoint);
const filenames = entries
.filter((e) => e.isFile())
.filter((e) => keep(dirpath, e.name, false))
.map((e) => e.name).sort(byCodepoint);
out.push([dirpath, dirnames, filenames]);
for (const d of dirnames) visit(join(dirpath, d));
};
visit(root);
return out;
}
function indexable(root, ignores) {
const tree = walk(root, ignores);
const carries = new Map();
for (let i = tree.length - 1; i >= 0; i -= 1) {
const [dirpath, dirnames, filenames] = tree[i];
const own = filenames.some(isListable);
const below = dirnames.some((d) => carries.get(join(dirpath, d)));
carries.set(dirpath, own || below);
}
const ordered = [...tree].reverse().filter(([d]) => carries.get(d));
return [ordered, carries];
}
function priorDescriptions(dirpath) {
const found = new Map();
let lines;
try { lines = readFileSync(join(dirpath, 'index.md'), 'utf8').split('\n'); } catch { return found; }
let inSubdirs = false;
for (const line of lines) {
if (line.startsWith('# ')) { inSubdirs = line.slice(2).trim() === SUBDIR_HEADING; continue; }
if (!inSubdirs) continue;
const m = ENTRY_RE.exec(line);
if (m && m[3]) found.set(join(dirpath, unescapeTitle(m[1])), m[3].trim());
}
return found;
}
// Only a file this tool wrote may be rewritten by it. The generation marker is the evidence,
// and it is the file's own claim rather than a convention held somewhere else.
//
// A hand-maintained index, or one a dialect's own generator produces, carries rows v0.2 does
// not project — an id, a status, a richer description — and regenerating replaces that catalog
// with a poorer one, silently, destroying the very lookup the index exists for. That used to be
// prevented by refusing profiled repositories outright, which was the wrong instrument (FR-OKF-3)
// and took this protection with it when it went. This is the right-sized replacement: it turns on
// evidence in the file, so it also covers a hand-written index in a repository with no manifest
// at all, which the old refusal never did.
const ownsIndex = (target) => {
let text;
try { text = readFileSync(target, 'utf8'); } catch { return true; }
return text.includes(GEN_MARKER);
};
// A rewrite that changes nothing is not free: it churns mtime, so a watcher fires, a build
// re-runs, and `git status` shows a file the run did not actually change. Comparing first
// makes `index` safe to call on every edit, which is what the regen hook does.
const currentText = (target) => {
try { return readFileSync(target, 'utf8'); } catch { return null; }
};
// Which dialect this repository's catalog is written in, and the two places that can say so:
// the manifest declares it, and the root index carries what the generator that wrote it stamped.
// The higher of the two wins, because either one being newer means a richer generator has been
// here and this one would be a downgrade.
const versionOrder = (a, b) => {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const x = pa[i] || 0;
const y = pb[i] || 0;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
};
export function declaredOkfVersion(root) {
let best = null;
const consider = (source, raw) => {
const value = unquote(String(raw ?? '').trim());
if (!/^\d+(?:\.\d+)*$/.test(value)) return;
if (!best || versionOrder(value, best[1]) > 0) best = [source, value];
};
for (const candidate of [join(root, 'docs', 'okf.yaml'), join(root, 'okf.yaml')]) {
let text;
try { text = readFileSync(candidate, 'utf8'); } catch { continue; }
for (const line of text.split('\n')) {
const m = /^okf_version:[ \t]*(\S.*?)[ \t]*$/.exec(line.replace(/\r$/, ''));
if (m) consider(relPath(root, candidate), m[1]);
}
}
const [fm] = readDoc(join(root, 'index.md'));
if (fm && fm.okf_version) consider('index.md', fm.okf_version);
return best || [null, null];
}
function renderRows(heading, rows) {
const out = [`# ${heading}`, ''];
const sorted = [...rows].sort((a, b) => byCodepoint(a[0].toLowerCase(), b[0].toLowerCase()));
for (const [title, link, desc] of sorted) {
out.push(`* [${escapeTitle(title)}](${link})${desc ? ` - ${desc}` : ''}`);
}
return out.join('\n');
}
function render(sections, subdirs, isRoot) {
const blocks = [];
for (const heading of [...sections.keys()].sort(byCodepoint)) {
blocks.push(renderRows(heading, sections.get(heading)));
}
if (subdirs.length) blocks.push(renderRows(SUBDIR_HEADING, subdirs));
const body = `${GEN_MARKER}\n\n${blocks.join('\n\n')}\n`;
return isRoot ? `---\nokf_version: "${OKF_VERSION}"\n---\n\n${body}` : body;
}
export function cmdIndex(root, toStdout, described, ignores) {
const [dirs, carries] = indexable(root, ignores);
if (!dirs.length) {
process.stderr.write(`okf.mjs: no documents found under ${root} - nothing written\n`);
reportIgnores(ignores);
return 77;
}
const relOf = (p) => relPath(root, p);
const overlong = [];
const resolved = new Map();
for (const [dirpath] of dirs) {
for (const [k, v] of priorDescriptions(dirpath)) resolved.set(k, oneLine(v));
}
for (const [k, v] of described) {
const text = gated(v, relOf(k) || '.', overlong);
if (text) resolved.set(k, text);
else resolved.delete(k);
}
const pending = new Set();
const foreign = [];
let written = 0;
for (const [dirpath, dirnames, filenames] of dirs) {
const sections = new Map();
for (const name of filenames) {
if (!isListable(name)) continue;
const [fm, , h1] = readDoc(join(dirpath, name));
const front = fm || {};
const heading = String(front.type || '').trim() || UNTYPED_HEADING;
const title = String(front.title || '').trim() || h1 || name.slice(0, -3);
const desc = gated(front.description, relOf(join(dirpath, name)), overlong);
if (!sections.has(heading)) sections.set(heading, []);
sections.get(heading).push([title, name, desc]);
}
const subdirs = [];
for (const name of dirnames) {
const child = join(dirpath, name);
if (!carries.get(child)) continue;
subdirs.push([name, `${name}/index.md`, resolved.get(child) || '']);
}
if (!sections.size && !subdirs.length) continue;
if (dirpath !== root && !resolved.get(dirpath)) {
const entries = [...sections.values()].flat();
if (entries.length === 1 && entries[0][2] && !subdirs.length) {
resolved.set(dirpath, entries[0][2]);
} else {
pending.add(relative(root, dirpath).split(sep).join('/'));
}
}
const text = render(sections, subdirs, dirpath === root);
const target = join(dirpath, 'index.md');
if (!ownsIndex(target)) {
foreign.push(relOf(target));
continue;
}
if (toStdout) {
process.stdout.write(`==> ${relative(root, target).split(sep).join('/')} <==\n${text}\n`);
} else if (currentText(target) !== text) {
writeFileSync(target, text, 'utf8');
written += 1;
}
}
if (!toStdout) process.stderr.write(`okf.mjs: wrote ${written} index file(s)\n`);
reportIgnores(ignores);
for (const rel of [...pending].sort(byCodepoint)) {
process.stderr.write(`needs-description: ${rel}\n`);
}
for (const report of [...overlong].sort(byCodepoint)) {
process.stderr.write(`long-description: ${report}\n`);
}
for (const rel of [...foreign].sort(byCodepoint)) {
process.stderr.write(`foreign-index: ${rel} (not written by okf.mjs - left untouched)\n`);
}
if (foreign.length) {
process.stderr.write(
`okf.mjs: ${foreign.length} index file(s) carry no generation marker, so another hand or another tool maintains them\n`);
process.stderr.write(
`okf.mjs: read one before replacing it - a dialect's rows can carry an id, a status or a shape v${OKF_VERSION} does not project, and overwriting is a lossy downgrade. Delete the file to hand this tool the directory, or name it in ${IGNORE_FILE} to leave it with its owner\n`);
}
return 0;
}
export function cmdCheck(root, ignores) {
const violations = [];
const notes = [];
let scanned = 0;
const rootIndex = join(root, 'index.md');
for (const [dirpath, , filenames] of walk(root, ignores)) {
for (const name of filenames) {
if (!name.endsWith('.md')) continue;
const path = join(dirpath, name);
const rel = relPath(root, path);
const low = name.toLowerCase();
if (low === 'log.md') {
notes.push(`${rel}: log.md duplicates git history (this skill removes it; not a §11 failure)`);
continue;
}
if (low === 'index.md') {
if (path !== rootIndex) {
const first = readFileSync(path, 'utf8').split('\n')[0].trim();
if (first === '---') {
violations.push(`${rel}: index.md carries frontmatter; only the bundle-root index may (§8, §12)`);
}
}
continue;
}
if (!isConcept(name)) continue;
scanned += 1;
const [fm, err] = readDoc(path);
if (err) { violations.push(`${rel}: ${err}`); continue; }
if (!String(fm.type || '').trim()) {
violations.push(`${rel}: missing or empty required key 'type' (§4.1, §11)`);
}
const desc = oneLine(fm.description);
if (desc.length > DESC_MAX) {
notes.push(`${rel}: description is ${desc.length} chars (max ${DESC_MAX}); the index omits it rather than truncating (§4.1 is one sentence)`);
}
}
}
for (const note of notes) process.stdout.write(`note: ${note}\n`);
reportIgnores(ignores);
if (scanned === 0) {
process.stderr.write(`okf.mjs: evaluated 0 concept documents under ${root} - nothing was verified\n`);
return 77;
}
for (const line of violations) process.stdout.write(`${line}\n`);
process.stdout.write(`okf.mjs: ${scanned} document(s) evaluated, ${violations.length} violation(s)\n`);
return violations.length ? 1 : 0;
}
// git ls-files, resolved from the repository root: the one enumerator here that is
// complete, honours ignore rules, and costs nothing. Returns null when there is no
// repository to ask, which is a refusal to verify, not a clean bill.
//
// `--cached --others --exclude-standard`, and the choice is load-bearing rather than
// incidental. Plain `--cached` enumerates the *index*, so a document written but not yet
// staged is invisible — and that is exactly the document at risk, the one being added right
// now. A check blind to it passes, the commit lands with no row for it, and nothing
// complains, which is the same self-consistent silence this command exists to break.
// `--others` adds the working tree; `--exclude-standard` keeps the repo's own ignore rules
// authoritative, so build output and vendored trees stay out.
function trackedMarkdown(root) {
const git = (args) => execFileSync('git', args, {
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
});
try { git(['rev-parse', '--show-toplevel']); } catch { return null; }
const list = (args) => {
const out = git(['ls-files', '-z', ...args, '--', '*.md']);
return [...new Set(out.split('\0').filter(Boolean).map((p) => p.split(sep).join('/')))];
};
try {
// `all` is what must be indexed; `cached` is what `git commit -a` will actually carry.
// The gap between them is a real hazard, not a bookkeeping detail — see dangling rows below.
return { all: list(['--cached', '--others', '--exclude-standard']), cached: new Set(list(['--cached'])) };
} catch { return null; }
}
function normalizeLink(base, link) {
const parts = (base === '.' ? [] : base.split('/')).concat(link.split('/'));
const out = [];
for (const part of parts) {
if (!part || part === '.') continue;
if (part === '..') { if (!out.length) return null; out.pop(); continue; }
out.push(part);
}
return out.join('/');
}
function rowTargets(root, rel) {
const targets = new Set();
let lines;
try { lines = readFileSync(join(root, rel), 'utf8').split('\n'); } catch { return targets; }
const base = dirname(rel);
for (const line of lines) {
const m = ENTRY_RE.exec(line.replace(/\r$/, ''));
if (!m) continue;
const link = m[2].split('#')[0].trim();
if (!link || link.startsWith('/') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(link)) continue;
const target = normalizeLink(base, decodeURIComponent(link));
if (target) targets.add(target);
}
return targets;
}
function listedDocuments(root, indexes) {
const listed = new Set();
for (const rel of indexes) for (const t of rowTargets(root, rel)) listed.add(t);
return listed;
}
// A row pointing at a document git will not commit.
//
// `index` indexes the working tree, so a document written and not yet added is listed on
// purpose — that is the whole reason the regeneration hook can run on the edit that created it.
// The hazard is what happens next: `git commit -a` stages modifications to TRACKED files, so the
// updated index goes in and the document it now vouches for does not. The commit is self-
// consistent and wrong, and every enumerator here reads the working tree, where both files are
// present, so nothing else can see it.
//
// Scoped to indexes that are themselves tracked, because an untracked index is not going into
// that commit either — there is no half-commit to warn about, only a bundle nobody has added yet.
function danglingRows(root, indexes, cached) {
const found = [];
for (const rel of indexes) {
if (!cached.has(rel)) continue;
for (const target of rowTargets(root, rel)) {
if (!target.endsWith('.md') || cached.has(target)) continue;
found.push(`${target} (listed by ${rel})`);
}
}
return [...new Set(found)].sort(byCodepoint);
}
// Which indexes are allowed to vouch for a document.
//
// `index` writes but never deletes: narrow what gets indexed - exclude a path, prune a payload
// directory, remove the last document from a folder - and the index.md it stops maintaining stays
// on disk with its rows intact. Counting those rows is a fail-open, and a quiet one, because an
// orphan is not an unreached document but a reached *nothing*: coverage reports zero findings
// before and after the debris is removed. Worse, a document listed ONLY by an orphan is credited
// as indexed while nothing a reader can follow leads to it, which is the exact question this
// command exists to answer.
//
// So an index vouches for its rows only if the root index reaches it, by the chain of
// Subdirectories links the format is built on. With no root index at all there is a larger
// finding already in flight, so every index is trusted rather than piling a second report on it.
function reachableIndexes(root, indexes) {
const have = new Set(indexes);
if (!have.has('index.md')) return indexes;
const seen = new Set(['index.md']);
const queue = ['index.md'];
while (queue.length) {
const rel = queue.shift();
let lines;
try { lines = readFileSync(join(root, rel), 'utf8').split('\n'); } catch { continue; }
const dir = dirname(rel);
for (const line of lines) {
const m = ENTRY_RE.exec(line.replace(/\r$/, ''));
if (!m) continue;
const link = m[2].split('#')[0].trim();
if (!link || link.startsWith('/') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(link)) continue;
const target = normalizeLink(dir, decodeURIComponent(link));
if (target && have.has(target) && !seen.has(target)) { seen.add(target); queue.push(target); }
}
}
return [...seen];
}
// Only debris this tool left behind is named. A hand-written index nobody links to is somebody
// else's file and a different conversation; the generation marker is the same evidence `ownsIndex`
// uses before overwriting one.
const wasGenerated = (root, rel) => {
try { return readFileSync(join(root, rel), 'utf8').includes(GEN_MARKER); } catch { return false; }
};
// The completeness half, and the reason it cannot reuse the walk.
//
// `check` regenerates from the same enumerator that wrote the committed index, so a
// document the walk never reaches is missing from both sides and compares equal. It
// is a projection checked against itself, and it cannot report a missing input by
// construction. Anchoring to the tracked-file list gives the comparison a second,
// independent source — which is the only way a document nobody indexed can be named.
export function cmdCoverage(root, ignores) {
const enumerated = trackedMarkdown(root);
if (!enumerated) {
process.stderr.write(`okf.mjs: ${root} is not inside a git work tree (or git is unavailable) - nothing was verified\n`);
return 77;
}
const { all: tracked, cached } = enumerated;
// Where an index could live is derived from the tracked documents themselves, not
// from the walk — an index inside a directory the walk prunes still counts, and a
// directory the walk never reached still owes one.
const dirs = new Set(['.']);
for (const rel of tracked) {
for (let d = dirname(rel); d !== '.' && d !== sep && d !== ''; d = dirname(d)) dirs.add(d);
}
const indexes = [...dirs].sort(byCodepoint)
.map((d) => (d === '.' ? 'index.md' : `${d}/index.md`))
.filter((p) => existsSync(join(root, p)));
const reachable = reachableIndexes(root, indexes);
const listed = listedDocuments(root, reachable);
const orphans = indexes.filter((p) => !reachable.includes(p) && wasGenerated(root, p));
const dangling = danglingRows(root, reachable, cached);
const required = [];
const payloadCache = new Map();
const payload = new Set();
for (const rel of tracked) {
if (RESERVED.has(rel.slice(rel.lastIndexOf('/') + 1).toLowerCase())) continue;
if (ignoredFile(ignores, rel)) continue;
const owner = payloadOwner(root, rel, payloadCache);
if (owner) { payload.add(owner); continue; }
required.push(rel);
}
for (const owner of payload) ignores.payload.push(owner);
reportIgnores(ignores);
if (!required.length) {
process.stderr.write(`okf.mjs: git tracks no document under ${root} - nothing was verified\n`);
return 77;
}
const missing = required.filter((rel) => !listed.has(rel)).sort(byCodepoint);
for (const rel of missing) process.stdout.write(`unindexed: ${rel}\n`);
for (const rel of orphans) process.stdout.write(`orphan-index: ${rel}\n`);
for (const line of dangling) process.stdout.write(`dangling-row: ${line}\n`);
process.stdout.write(
`okf.mjs: ${required.length} tracked document(s) in ${reachable.length} index file(s), ${missing.length} reachable only by ls\n`);
if (orphans.length) {
process.stdout.write(
`okf.mjs: ${orphans.length} generated index file(s) are linked from no index, so \`index\` no longer maintains them and their rows go stale unseen\n`);
process.stdout.write(
`okf.mjs: delete them - their rows were not counted here, so a document only they listed is named above\n`);
}
if (dangling.length) {
process.stdout.write(
`okf.mjs: ${dangling.length} index row(s) point at a document git is not tracking, so \`git commit -a\` commits the index without it\n`);
process.stdout.write(
'okf.mjs: git add the document(s) named above, or remove the row by deleting the document and regenerating\n');
}
// The walk skips dot-directories on purpose — they hold tooling, not knowledge, and
// descending into one reaches .git, .venv and every editor's cache. But naming a document
// there without naming the remedy leaves exactly one obvious next move, hand-writing an
// index.md, which is the drift this command exists to find and which nothing would ever
// regenerate. So say what the two real answers are.
const ancestors = (rel) => {
const parts = rel.split('/').slice(0, -1);
return parts.map((_, i) => parts.slice(0, i + 1).join('/'));
};
const hidden = missing.filter((rel) => ancestors(rel).some((p) => p.split('/').pop().startsWith('.')));
const nested = missing.filter((rel) => ancestors(rel).some((p) => isRepoBoundary(join(root, p))));
if (hidden.length) {
process.stdout.write(
`okf.mjs: ${hidden.length} of those sit under a dot-directory, which the walk does not descend into - \`index\` will never write one there\n`);
process.stdout.write(
`okf.mjs: move the document out, or name the path in ${IGNORE_FILE}; do not hand-write an index.md, because nothing regenerates it and it goes stale unseen\n`);
}
if (nested.length) {
process.stdout.write(
`okf.mjs: ${nested.length} of those sit inside a separate git work tree, which the walk refuses to enter - it is not this repository's to write into\n`);
process.stdout.write(
`okf.mjs: name the path in ${IGNORE_FILE}; index it from inside that repository if it needs one, never from here\n`);
}
return missing.length || orphans.length || dangling.length ? 1 : 0;
}
export function cmdWire(root, toStdout) {
if (!existsSync(join(root, 'index.md'))) {
process.stderr.write(`okf.mjs: no index.md at ${root} - run \`index\` first\n`);
return 1;
}
const touched = [];
for (const name of PROSE_ENTRY_FILES) {
const path = join(root, name);
let text = '';
try { text = readFileSync(path, 'utf8'); } catch { text = ''; }
let updated;
let action;
if (ENTRY_BLOCK_RE.test(text)) {
updated = text.replace(ENTRY_BLOCK_RE, () => ENTRY_BLOCK);
action = 'updated';
} else {
updated = (text.trim() ? `${text.replace(/\n+$/, '')}\n\n` : '') + ENTRY_BLOCK + '\n';
action = 'added';
}
if (updated === text) action = 'unchanged';
if (toStdout) process.stdout.write(`==> ${name} (${action}) <==\n${ENTRY_BLOCK}\n`);
else if (action !== 'unchanged') writeFileSync(path, updated, 'utf8');
touched.push(`${name} ${action}`);
}
const geminiPath = join(root, IMPORT_ENTRY_FILE);
if (existsSync(geminiPath)) {
const text = readFileSync(geminiPath, 'utf8');
if (text.split(/\s+/).includes(IMPORT_LINE)) {
touched.push(`${IMPORT_ENTRY_FILE} unchanged`);
} else {
if (!toStdout) writeFileSync(geminiPath, `${text.replace(/\n+$/, '')}\n${IMPORT_LINE}\n`, 'utf8');
touched.push(`${IMPORT_ENTRY_FILE} added`);
}
}
process.stderr.write(`okf.mjs: ${touched.join(', ')}\n`);
return 0;
}
function usage(message) {
if (message) process.stderr.write(`okf.mjs: ${message}\n`);
process.stderr.write(`${USAGE}\n`);
return 64;
}
export function main(argv) {
if (argv.includes('--version')) {
process.stdout.write(`okf.mjs ${VERSION} (OKF v${OKF_VERSION})\n`);
return 0;
}
if (!argv.length) return usage('no command given');
const [command, ...rest] = argv;
if (!['index', 'check', 'coverage', 'wire'].includes(command)) {
return usage(`unknown command '${command}'`);
}
let toStdout = false;
const described = new Map();
const positional = [];
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (arg === '--stdout') toStdout = true;
else if (arg === '--describe') {
i += 1;
if (i >= rest.length) return usage('--describe needs <dir>=<text>');
if (!rest[i].includes('=')) return usage(`--describe expects <dir>=<text>, got '${rest[i]}'`);
const at = rest[i].indexOf('=');
described.set(resolve(rest[i].slice(0, at)), rest[i].slice(at + 1).trim());
} else if (arg.startsWith('--')) return usage(`unknown option '${arg}'`);
else positional.push(arg);
}
if (positional.length !== 1) return usage(`expected exactly one <bundle-root>, got ${positional.length}`);
const root = resolve(positional[0]);
try { if (!statSync(root).isDirectory()) throw new Error('not a dir'); }
catch { return usage(`not a directory: ${positional[0]}`); }
// A profile names an enforcement dialect — which documents must carry which keys.
// It is reported because it changes how `check` should be read, and it no longer
// stops anything: it never registered an index generator to collide with, so
// refusing to index on its account left the corpus with no index at all.
const [manifest, profile] = declaredProfile(root);
if (profile) {
process.stderr.write(
`okf.mjs: ${relPath(root, manifest)} declares profile ${profile} - proceeding; a profile scopes which documents carry required keys, not which are enumerated\n`);
}
if (command === 'wire') {
if (described.size) return usage('wire does not take --describe');
return cmdWire(root, toStdout);
}
const ignores = readIgnores(root);
if (command === 'check' || command === 'coverage') {
if (toStdout || described.size) return usage(`${command} takes no options`);
return command === 'check' ? cmdCheck(root, ignores) : cmdCoverage(root, ignores);
}
return cmdIndex(root, toStdout, described, ignores);
}
// Importable core plus a CLI, and the guard is what makes the split real: a bare
// `process.exit(main(...))` at the tail runs — and terminates the host process — the moment
// anything imports this file, which is why the regen hook could not reuse the generator it
// is a client of. argv[1] is absent when Node is fed a script on stdin, so it is checked.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main(process.argv.slice(2)));
}