okfcli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/SPEC.md +243 -0
- package/dist/bin/okfcli.d.ts +2 -0
- package/dist/bin/okfcli.js +9 -0
- package/dist/src/cli.d.ts +7 -0
- package/dist/src/cli.js +122 -0
- package/dist/src/commands/init.d.ts +13 -0
- package/dist/src/commands/init.js +78 -0
- package/dist/src/commands/new.d.ts +10 -0
- package/dist/src/commands/new.js +53 -0
- package/dist/src/commands/validate.d.ts +7 -0
- package/dist/src/commands/validate.js +49 -0
- package/dist/src/commands/verify.d.ts +12 -0
- package/dist/src/commands/verify.js +77 -0
- package/dist/src/core/frontmatter.d.ts +7 -0
- package/dist/src/core/frontmatter.js +27 -0
- package/dist/src/core/profile.d.ts +12 -0
- package/dist/src/core/profile.js +101 -0
- package/dist/src/core/trust.d.ts +10 -0
- package/dist/src/core/trust.js +42 -0
- package/dist/src/core/types.d.ts +61 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/util.d.ts +17 -0
- package/dist/src/core/util.js +67 -0
- package/dist/src/generate/agents.d.ts +24 -0
- package/dist/src/generate/agents.js +128 -0
- package/dist/src/generate/targets.d.ts +26 -0
- package/dist/src/generate/targets.js +18 -0
- package/dist/src/generate/templates.d.ts +13 -0
- package/dist/src/generate/templates.js +54 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +7 -0
- package/dist/src/wiki/fixes.d.ts +2 -0
- package/dist/src/wiki/fixes.js +45 -0
- package/dist/src/wiki/indexer.d.ts +5 -0
- package/dist/src/wiki/indexer.js +47 -0
- package/dist/src/wiki/pages.d.ts +19 -0
- package/dist/src/wiki/pages.js +70 -0
- package/dist/src/wiki/root.d.ts +1 -0
- package/dist/src/wiki/root.js +29 -0
- package/dist/src/wiki/rules.d.ts +3 -0
- package/dist/src/wiki/rules.js +150 -0
- package/package.json +65 -4
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { AGENT_NAMES, AGENT_TARGETS } from './targets.js';
|
|
3
|
+
import { typeAlias } from '../core/profile.js';
|
|
4
|
+
import { UserError, readIfExists, writeWithStatus } from '../core/util.js';
|
|
5
|
+
// Files generated for the coding agents (SPEC.md section 10; paths per agent in targets.ts):
|
|
6
|
+
// - a marked block in the instructions file the agent always loads
|
|
7
|
+
// - three agent skills (open standard: agentskills.io)
|
|
8
|
+
// They belong to okfcli: running `okfcli init` again regenerates them.
|
|
9
|
+
export const BLOCK_START = '<!-- okfcli:start -->';
|
|
10
|
+
export const BLOCK_END = '<!-- okfcli:end -->';
|
|
11
|
+
export function pointerBlock(wiki) {
|
|
12
|
+
return [
|
|
13
|
+
BLOCK_START,
|
|
14
|
+
'## Project wiki',
|
|
15
|
+
'',
|
|
16
|
+
`This project keeps its knowledge (architecture, patterns, conventions, workflows, testing) in an OKF wiki at \`${wiki}/\`.`,
|
|
17
|
+
'',
|
|
18
|
+
`- Before changing code, read \`${wiki}/index.md\` and the pages relevant to the task (skill \`okf-consult-wiki\`).`,
|
|
19
|
+
'- When a change affects documented rules, patterns or conventions, update the wiki (skill `okf-update-wiki`). Use the `okfcli` CLI to create pages; never edit `index.md` files by hand or write `verified` entries.',
|
|
20
|
+
'- Never mark a page as reviewed on your own: only a person reviews pages (skill `okf-review-wiki`).',
|
|
21
|
+
BLOCK_END,
|
|
22
|
+
'',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
const skill = (name, description, body, version) => `---\nname: ${name}\ndescription: ${description}\n---\n<!-- Generated by okfcli ${version}. Regenerated by "okfcli init": do not edit. -->\n\n${body.trim()}\n`;
|
|
26
|
+
function typeList(profile) {
|
|
27
|
+
return profile.folders.map((f) => ` - \`${typeAlias(f.type)}\` -> \`${f.name}/\` (sections: ${f.sections.map((s) => `\`# ${s}\``).join(', ') || 'none'})`).join('\n');
|
|
28
|
+
}
|
|
29
|
+
export function skillFiles({ wiki, profile, version }) {
|
|
30
|
+
// Quoted only when needed, so paths with spaces still work in the commands agents run.
|
|
31
|
+
const wikiArg = /^[\w./@+-]+$/.test(wiki) ? wiki : JSON.stringify(wiki);
|
|
32
|
+
const consult = skill('okf-consult-wiki', 'Find project knowledge in the wiki before writing or changing code: architecture, patterns, conventions, workflows and testing rules. Use at the start of any task that touches the codebase.', `
|
|
33
|
+
# Consult the project wiki
|
|
34
|
+
|
|
35
|
+
The wiki lives in \`${wiki}/\`. Read only what the task needs.
|
|
36
|
+
|
|
37
|
+
1. Read \`${wiki}/index.md\`. It lists the folders: overview, architecture, patterns, conventions, workflows, testing, and any optional ones (features, modules, interfaces, operations, troubleshooting).
|
|
38
|
+
2. Open the \`index.md\` of the folders that matter for the task, then the pages it points to.
|
|
39
|
+
3. For "how do I do X" questions start with \`workflows/\`; each workflow links to the \`patterns/\` it relies on. Rules and structure are in \`architecture/\` and \`conventions/\`; how to run and write tests is in \`testing/\`.
|
|
40
|
+
4. Follow what the pages say. If the wiki is silent or contradicts the code, say so instead of guessing, and propose a wiki update (skill \`okf-update-wiki\`).
|
|
41
|
+
5. A page without a \`verified\` entry has not been reviewed by a person: use it, but flag the uncertainty when the answer depends on it.
|
|
42
|
+
`, version);
|
|
43
|
+
const update = skill('okf-update-wiki', 'Update the project wiki after a change affects documented knowledge, or document something new. Uses the okfcli CLI so structure and indexes stay correct.', `
|
|
44
|
+
# Update the project wiki
|
|
45
|
+
|
|
46
|
+
The wiki lives in \`${wiki}/\`. The \`okfcli\` CLI does the mechanical work; you only write content.
|
|
47
|
+
|
|
48
|
+
1. Check whether the change touches knowledge that is already documented. If so, **edit that page in place**. Do not create a duplicate, and do not add a page that only records a decision: put the reason in the page's \`# Why\` section.
|
|
49
|
+
2. For new knowledge, create the page with the CLI, not by hand:
|
|
50
|
+
|
|
51
|
+
\`okfcli new <type> <name> --description "<one sentence>" --wiki ${wikiArg}\`
|
|
52
|
+
|
|
53
|
+
It creates the file with its frontmatter and required sections, and registers it in the folder index. Types:
|
|
54
|
+
|
|
55
|
+
${typeList(profile)}
|
|
56
|
+
|
|
57
|
+
\`<name>\` is kebab-case and relative to the folder (for example \`state-management\` or \`ui/forms\`).
|
|
58
|
+
3. Fill in every section and delete the \`<!-- TODO -->\` comments. Write in the language the existing pages use.
|
|
59
|
+
4. Never edit \`index.md\` files and never write \`verified\` entries: you are not a reviewer. New and edited pages stay unreviewed until a person verifies them.
|
|
60
|
+
5. If something no longer applies, set \`status: deprecated\` in that page's frontmatter instead of deleting it.
|
|
61
|
+
6. Run \`okfcli validate --fix --wiki ${wikiArg}\`, then fix whatever it still reports (it names the file, the rule and how to fix it).
|
|
62
|
+
`, version);
|
|
63
|
+
const review = skill('okf-review-wiki', 'Walk a person through the wiki pages nobody has reviewed yet and record their review. Use when asked to review, verify or sign off wiki pages.', `
|
|
64
|
+
# Review wiki pages with a person
|
|
65
|
+
|
|
66
|
+
Reviewing is a human act. You prepare the review; the person decides.
|
|
67
|
+
|
|
68
|
+
1. Run \`okfcli validate --json --wiki ${wikiArg}\`. The pages to review are the issues with rule \`frontmatter.verified\` (never reviewed) or \`frontmatter.verified-outdated\` (changed after being verified). The \`review\` field has the totals.
|
|
69
|
+
2. Pick one page at a time (ask the person where to start if there are many). Summarise it, check it against the code, and point out anything that looks wrong or missing. Fix problems with the person's agreement.
|
|
70
|
+
3. A page with unfilled \`<!-- TODO -->\` comments is not ready: complete it first.
|
|
71
|
+
4. Ask the person explicitly whether they confirm the page. Only after a clear yes, run:
|
|
72
|
+
|
|
73
|
+
\`okfcli verify <path> --by human:<id> --wiki ${wikiArg}\`
|
|
74
|
+
|
|
75
|
+
where \`<id>\` identifies that person (ask if you do not know it).
|
|
76
|
+
5. Never use a \`human:\` actor for yourself, never verify a page the person has not confirmed, and never write \`verified\` entries by hand.
|
|
77
|
+
6. If the person does not confirm a page, leave it as it is and move on.
|
|
78
|
+
7. Finish with \`okfcli validate --wiki ${wikiArg}\` and report how many pages are still unreviewed.
|
|
79
|
+
`, version);
|
|
80
|
+
return [
|
|
81
|
+
{ path: 'okf-consult-wiki/SKILL.md', text: consult },
|
|
82
|
+
{ path: 'okf-update-wiki/SKILL.md', text: update },
|
|
83
|
+
{ path: 'okf-review-wiki/SKILL.md', text: review },
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
// Inserts the block into the file text, or replaces it if the markers are already there.
|
|
87
|
+
export function upsertBlock(current, block, file) {
|
|
88
|
+
if (current === null)
|
|
89
|
+
return block;
|
|
90
|
+
const start = current.indexOf(BLOCK_START);
|
|
91
|
+
if (start === -1)
|
|
92
|
+
return `${current.replace(/\s+$/, '')}${current.trim() ? '\n\n' : ''}${block}`;
|
|
93
|
+
const end = current.indexOf(BLOCK_END, start);
|
|
94
|
+
if (end === -1) {
|
|
95
|
+
throw new UserError(`${file} has "${BLOCK_START}" without "${BLOCK_END}". Fix or remove the marker, then run "okfcli init" again.`);
|
|
96
|
+
}
|
|
97
|
+
return current.slice(0, start) + block.trimEnd() + current.slice(end + BLOCK_END.length);
|
|
98
|
+
}
|
|
99
|
+
// Which of the candidate files carries the pointer block: the one that already
|
|
100
|
+
// has it, else the first that exists, else the first candidate (created).
|
|
101
|
+
export function pointerFile(projectRoot, candidates) {
|
|
102
|
+
const withBlock = candidates.find((f) => readIfExists(join(projectRoot, f))?.includes(BLOCK_START));
|
|
103
|
+
return withBlock ?? candidates.find((f) => readIfExists(join(projectRoot, f)) !== null) ?? candidates[0];
|
|
104
|
+
}
|
|
105
|
+
// Writes (or refreshes) the agent files of the chosen agents. Agents that share
|
|
106
|
+
// a destination (several read AGENTS.md or .agents/skills) write it only once.
|
|
107
|
+
export function writeAgentFiles({ projectRoot, wiki, profile, version, agents }) {
|
|
108
|
+
// Fixed order (not the caller's), so the result never depends on how the flags were typed.
|
|
109
|
+
const targets = AGENT_NAMES.filter((a) => agents.includes(a)).map((a) => AGENT_TARGETS[a]);
|
|
110
|
+
const abs = (rel) => join(projectRoot, rel);
|
|
111
|
+
const files = new Map();
|
|
112
|
+
for (const { instructions } of targets) {
|
|
113
|
+
const path = pointerFile(projectRoot, instructions);
|
|
114
|
+
if (files.has(path))
|
|
115
|
+
continue;
|
|
116
|
+
let current = readIfExists(abs(path));
|
|
117
|
+
// A new CLAUDE.md hides AGENTS.md from Claude Code, so it imports it.
|
|
118
|
+
if (current === null && instructions[0] === 'CLAUDE.md' && (files.has('AGENTS.md') || readIfExists(abs('AGENTS.md')) !== null)) {
|
|
119
|
+
current = '@AGENTS.md\n';
|
|
120
|
+
}
|
|
121
|
+
files.set(path, upsertBlock(current, pointerBlock(wiki), path));
|
|
122
|
+
}
|
|
123
|
+
for (const dir of new Set(targets.map((t) => t.skillsDir))) {
|
|
124
|
+
for (const skill of skillFiles({ wiki, profile, version }))
|
|
125
|
+
files.set(`${dir}/${skill.path}`, skill.text);
|
|
126
|
+
}
|
|
127
|
+
return [...files].map(([path, text]) => ({ path, status: writeWithStatus(abs(path), text) }));
|
|
128
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const AGENT_TARGETS: {
|
|
2
|
+
readonly copilot: {
|
|
3
|
+
readonly skillsDir: '.github/skills';
|
|
4
|
+
readonly instructions: readonly ["AGENTS.md", ".github/copilot-instructions.md"];
|
|
5
|
+
};
|
|
6
|
+
readonly claude: {
|
|
7
|
+
readonly skillsDir: '.claude/skills';
|
|
8
|
+
readonly instructions: readonly ["CLAUDE.md", ".claude/CLAUDE.md"];
|
|
9
|
+
};
|
|
10
|
+
readonly codex: {
|
|
11
|
+
readonly skillsDir: '.agents/skills';
|
|
12
|
+
readonly instructions: readonly ["AGENTS.md"];
|
|
13
|
+
};
|
|
14
|
+
readonly opencode: {
|
|
15
|
+
readonly skillsDir: '.agents/skills';
|
|
16
|
+
readonly instructions: readonly ["AGENTS.md"];
|
|
17
|
+
};
|
|
18
|
+
readonly cursor: {
|
|
19
|
+
readonly skillsDir: '.agents/skills';
|
|
20
|
+
readonly instructions: readonly ["AGENTS.md"];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type AgentName = keyof typeof AGENT_TARGETS;
|
|
24
|
+
export declare const AGENT_NAMES: AgentName[];
|
|
25
|
+
/** Used when `init` is run without any agent flag. */
|
|
26
|
+
export declare const DEFAULT_AGENTS: AgentName[];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Where each coding agent reads the project instructions and the skills.
|
|
2
|
+
// This table is the single source of truth: the CLI flags, the help text and
|
|
3
|
+
// the validation all derive from it, so supporting a tool is one line.
|
|
4
|
+
//
|
|
5
|
+
// - skillsDir: folder with <skill>/SKILL.md (open standard: agentskills.io)
|
|
6
|
+
// - instructions: files that carry the pointer block, in order of preference
|
|
7
|
+
// (the first is created when none of them exists)
|
|
8
|
+
export const AGENT_TARGETS = {
|
|
9
|
+
copilot: { skillsDir: '.github/skills', instructions: ['AGENTS.md', '.github/copilot-instructions.md'] },
|
|
10
|
+
// Claude Code reads AGENTS.md only when the project has no CLAUDE.md at all.
|
|
11
|
+
claude: { skillsDir: '.claude/skills', instructions: ['CLAUDE.md', '.claude/CLAUDE.md'] },
|
|
12
|
+
codex: { skillsDir: '.agents/skills', instructions: ['AGENTS.md'] },
|
|
13
|
+
opencode: { skillsDir: '.agents/skills', instructions: ['AGENTS.md'] },
|
|
14
|
+
cursor: { skillsDir: '.agents/skills', instructions: ['AGENTS.md'] },
|
|
15
|
+
};
|
|
16
|
+
export const AGENT_NAMES = Object.keys(AGENT_TARGETS);
|
|
17
|
+
/** Used when `init` is run without any agent flag. */
|
|
18
|
+
export const DEFAULT_AGENTS = ['copilot'];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Folder } from '../core/types.js';
|
|
2
|
+
export interface TemplatePage {
|
|
3
|
+
path: string;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const sectionSkeleton: (section: string) => string;
|
|
7
|
+
export declare function renderPage({ folder, title, description }: {
|
|
8
|
+
folder: Pick<Folder, 'type' | 'sections'>;
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}): string;
|
|
12
|
+
export declare function testingStrategyPage(): TemplatePage;
|
|
13
|
+
export declare function requiredPages(): TemplatePage[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { yamlScalar } from '../core/util.js';
|
|
2
|
+
import { coreFolder } from '../core/profile.js';
|
|
3
|
+
// What a section looks like until someone writes it. Used by new pages and by `validate --fix`.
|
|
4
|
+
export const sectionSkeleton = (section) => `# ${section}\n\n<!-- TODO: ${section} -->\n`;
|
|
5
|
+
export function renderPage({ folder, title, description }) {
|
|
6
|
+
const front = ['---', `type: ${yamlScalar(folder.type)}`, `title: ${yamlScalar(title)}`, `description: ${yamlScalar(description)}`, '---'];
|
|
7
|
+
const sections = folder.sections.map(sectionSkeleton);
|
|
8
|
+
return `${front.join('\n')}\n\n${sections.join('\n')}`;
|
|
9
|
+
}
|
|
10
|
+
// Scaffold of the testing strategy page. `init` and `validate --fix` create it
|
|
11
|
+
// when testing/ has no page; it is not a required name, any testing page counts.
|
|
12
|
+
export function testingStrategyPage() {
|
|
13
|
+
return {
|
|
14
|
+
path: 'testing/strategy.md',
|
|
15
|
+
text: renderPage({ folder: coreFolder('testing'), title: 'Testing strategy', description: 'How testing is done in this project.' }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// Required core pages (SPEC.md section 3). Created by `init`.
|
|
19
|
+
export function requiredPages() {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
path: 'overview/project.md',
|
|
23
|
+
text: renderPage({
|
|
24
|
+
folder: coreFolder('overview'),
|
|
25
|
+
title: 'Project',
|
|
26
|
+
description: 'What the project is and who it is for.',
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
path: 'workflows/update-wiki.md',
|
|
31
|
+
text: [
|
|
32
|
+
renderPage({
|
|
33
|
+
folder: { type: 'Workflow', sections: [] },
|
|
34
|
+
title: 'Update the wiki',
|
|
35
|
+
description: 'When and how this wiki is updated.',
|
|
36
|
+
}).trimEnd(),
|
|
37
|
+
'',
|
|
38
|
+
UPDATE_WIKI_BODY,
|
|
39
|
+
].join('\n'),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
const UPDATE_WIKI_BODY = `# Goal
|
|
44
|
+
|
|
45
|
+
Keep the wiki faithful to the project with as little manual work as possible. The \`okfcli\` CLI does the mechanical parts (indexes, frontmatter, validation); whoever edits only writes content.
|
|
46
|
+
|
|
47
|
+
# Steps
|
|
48
|
+
|
|
49
|
+
1. If a change affects a rule, a pattern or a convention, edit the existing page. Do not create a new page for something that is already documented.
|
|
50
|
+
2. If a new page is needed, create it with \`okfcli new <type> <name> --description "..."\`. It registers the page in the index. Do not edit \`index.md\` by hand.
|
|
51
|
+
3. Fill in the page sections. Explain the reason in \`# Why\` when the page asks for it.
|
|
52
|
+
4. If something no longer applies, mark its page with \`status: deprecated\` instead of just deleting it.
|
|
53
|
+
5. Run \`okfcli validate\` and fix whatever it reports.
|
|
54
|
+
`;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { run } from './cli.js';
|
|
2
|
+
export { loadProfile } from './core/profile.js';
|
|
3
|
+
export { validateWiki } from './wiki/rules.js';
|
|
4
|
+
export { parseFrontmatter } from './core/frontmatter.js';
|
|
5
|
+
export { contentHash, trustState } from './core/trust.js';
|
|
6
|
+
export type { Folder, Frontmatter, Issue, Page, Profile, Severity, TrustTier } from './core/types.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public API. The CLI is the main interface; these are the pieces other tools
|
|
2
|
+
// (editors, CI scripts) are most likely to reuse.
|
|
3
|
+
export { run } from './cli.js';
|
|
4
|
+
export { loadProfile } from './core/profile.js';
|
|
5
|
+
export { validateWiki } from './wiki/rules.js';
|
|
6
|
+
export { parseFrontmatter } from './core/frontmatter.js';
|
|
7
|
+
export { contentHash, trustState } from './core/trust.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CORE_FOLDERS, folderByName } from '../core/profile.js';
|
|
4
|
+
import { loadWiki, missingSections, scanBody } from './pages.js';
|
|
5
|
+
import { regenerateIndexes } from './indexer.js';
|
|
6
|
+
import { requiredPages, sectionSkeleton, testingStrategyPage } from '../generate/templates.js';
|
|
7
|
+
import { writeText } from '../core/util.js';
|
|
8
|
+
// Repairs only what can be decided mechanically. Returns a list of what was done.
|
|
9
|
+
export function applyFixes(root, profile) {
|
|
10
|
+
const done = [];
|
|
11
|
+
for (const folder of CORE_FOLDERS) {
|
|
12
|
+
if (!existsSync(join(root, folder.name))) {
|
|
13
|
+
mkdirSync(join(root, folder.name), { recursive: true });
|
|
14
|
+
done.push(`Created folder ${folder.name}/`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const page of requiredPages()) {
|
|
18
|
+
if (!existsSync(join(root, page.path))) {
|
|
19
|
+
writeText(join(root, page.path), page.text);
|
|
20
|
+
done.push(`Recreated ${page.path}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!loadWiki(root).concepts.some((c) => c.folder === 'testing')) {
|
|
24
|
+
const page = testingStrategyPage();
|
|
25
|
+
writeText(join(root, page.path), page.text);
|
|
26
|
+
done.push(`Created ${page.path}`);
|
|
27
|
+
}
|
|
28
|
+
for (const page of loadWiki(root).concepts) {
|
|
29
|
+
const folder = page.folder ? folderByName(profile, page.folder) : undefined;
|
|
30
|
+
if (!folder || !page.data)
|
|
31
|
+
continue;
|
|
32
|
+
const { headings } = scanBody(page.body);
|
|
33
|
+
const missing = missingSections(folder, headings);
|
|
34
|
+
if (!missing.length)
|
|
35
|
+
continue;
|
|
36
|
+
const file = join(root, page.rel);
|
|
37
|
+
const text = readFileSync(file, 'utf8').replace(/\s+$/, '');
|
|
38
|
+
const add = missing.map(sectionSkeleton).join('\n');
|
|
39
|
+
writeText(file, `${text}\n\n${add}`);
|
|
40
|
+
done.push(`Added ${missing.map((s) => `"# ${s}"`).join(', ')} to ${page.rel}`);
|
|
41
|
+
}
|
|
42
|
+
for (const rel of regenerateIndexes(root, profile))
|
|
43
|
+
done.push(`Regenerated ${rel}`);
|
|
44
|
+
return done;
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseFrontmatter } from '../core/frontmatter.js';
|
|
4
|
+
import { listDir, wikiDirs } from './pages.js';
|
|
5
|
+
import { humanize, readIfExists, toPosix, writeText, RESERVED } from '../core/util.js';
|
|
6
|
+
import { OKF_VERSION, folderByName } from '../core/profile.js';
|
|
7
|
+
const entryLine = (title, url, description) => `* [${title}](${url})${description ? ` - ${description}` : ''}`;
|
|
8
|
+
// Single line: multi-line descriptions would break the list format.
|
|
9
|
+
const oneLine = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
10
|
+
export function renderIndex(root, relDir, profile) {
|
|
11
|
+
const absDir = join(root, relDir);
|
|
12
|
+
const { md, dirs } = listDir(absDir);
|
|
13
|
+
const pages = md.filter((name) => !RESERVED.has(name));
|
|
14
|
+
const folderMeta = (name) => (relDir === '' ? folderByName(profile, name) : undefined);
|
|
15
|
+
const prefix = relDir === '' ? '' : `/${relDir}`;
|
|
16
|
+
const ownMeta = relDir === '' ? undefined : folderByName(profile, relDir);
|
|
17
|
+
const heading = relDir === '' ? 'Index' : ownMeta?.title ?? humanize(relDir.split('/').pop() ?? relDir);
|
|
18
|
+
const lines = [`# ${heading}`, ''];
|
|
19
|
+
const subs = dirs.map((d) => {
|
|
20
|
+
const meta = folderMeta(d);
|
|
21
|
+
return entryLine(meta?.title ?? humanize(d), `${prefix}/${d}/index.md`, oneLine(meta?.description));
|
|
22
|
+
});
|
|
23
|
+
const files = pages.map((p) => {
|
|
24
|
+
const { data } = parseFrontmatter(readFileSync(join(absDir, p), 'utf8'));
|
|
25
|
+
const title = oneLine(data?.title) || humanize(p.replace(/\.md$/, ''));
|
|
26
|
+
return entryLine(title, `${prefix}/${p}`, oneLine(data?.description));
|
|
27
|
+
});
|
|
28
|
+
const entries = [...subs, ...files];
|
|
29
|
+
lines.push(...(entries.length ? entries : ['_No pages yet._']), '');
|
|
30
|
+
const body = lines.join('\n');
|
|
31
|
+
return relDir === '' ? `---\nokf_version: "${OKF_VERSION}"\n---\n${body}` : body;
|
|
32
|
+
}
|
|
33
|
+
// Regenerates every index.md. With check=true it writes nothing and only reports.
|
|
34
|
+
export function regenerateIndexes(root, profile, { check = false } = {}) {
|
|
35
|
+
const changed = [];
|
|
36
|
+
for (const rel of ['', ...wikiDirs(root)]) {
|
|
37
|
+
const file = join(root, rel, 'index.md');
|
|
38
|
+
const text = renderIndex(root, rel, profile);
|
|
39
|
+
const current = readIfExists(file);
|
|
40
|
+
if (current !== null && current.replace(/\r\n/g, '\n') === text)
|
|
41
|
+
continue;
|
|
42
|
+
if (!check)
|
|
43
|
+
writeText(file, text);
|
|
44
|
+
changed.push(toPosix(rel ? `${rel}/index.md` : 'index.md'));
|
|
45
|
+
}
|
|
46
|
+
return changed;
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Folder, Page } from '../core/types.js';
|
|
2
|
+
export declare function listDir(absDir: string): {
|
|
3
|
+
md: string[];
|
|
4
|
+
dirs: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function wikiDirs(root: string): string[];
|
|
7
|
+
export declare function loadWiki(root: string): {
|
|
8
|
+
concepts: Page[];
|
|
9
|
+
indexes: {
|
|
10
|
+
rel: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}[];
|
|
13
|
+
dirs: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare const missingSections: (folder: Pick<Folder, 'sections'>, headings: string[]) => string[];
|
|
16
|
+
export declare function scanBody(body: string): {
|
|
17
|
+
headings: string[];
|
|
18
|
+
links: string[];
|
|
19
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseFrontmatter } from '../core/frontmatter.js';
|
|
4
|
+
import { RESERVED } from '../core/util.js';
|
|
5
|
+
// The one place that decides what is part of the wiki: hidden entries and
|
|
6
|
+
// node_modules are skipped. Names are sorted by code unit, so output is the
|
|
7
|
+
// same on every machine. `md` holds regular .md files (reserved ones included).
|
|
8
|
+
export function listDir(absDir) {
|
|
9
|
+
const entries = readdirSync(absDir, { withFileTypes: true }).filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules');
|
|
10
|
+
const names = (keep) => entries.filter(keep).map((e) => e.name).sort();
|
|
11
|
+
return {
|
|
12
|
+
md: names((e) => e.isFile() && e.name.endsWith('.md')),
|
|
13
|
+
dirs: names((e) => e.isDirectory()),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
// Every directory of the wiki, relative to the root, parents before children.
|
|
17
|
+
export function wikiDirs(root) {
|
|
18
|
+
const result = [];
|
|
19
|
+
const walk = (rel) => {
|
|
20
|
+
for (const d of listDir(join(root, rel)).dirs) {
|
|
21
|
+
const child = rel ? `${rel}/${d}` : d;
|
|
22
|
+
result.push(child);
|
|
23
|
+
walk(child);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
walk('');
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
// Loads every markdown file of the wiki.
|
|
30
|
+
export function loadWiki(root) {
|
|
31
|
+
const concepts = [];
|
|
32
|
+
const indexes = [];
|
|
33
|
+
const dirs = wikiDirs(root);
|
|
34
|
+
for (const dir of ['', ...dirs]) {
|
|
35
|
+
for (const name of listDir(join(root, dir)).md) {
|
|
36
|
+
const rel = dir ? `${dir}/${name}` : name;
|
|
37
|
+
const text = readFileSync(join(root, rel), 'utf8');
|
|
38
|
+
if (name === 'index.md')
|
|
39
|
+
indexes.push({ rel, text });
|
|
40
|
+
else if (!RESERVED.has(name)) {
|
|
41
|
+
const { data, body } = parseFrontmatter(text);
|
|
42
|
+
concepts.push({ rel, text, data, body, folder: dir ? (dir.split('/')[0] ?? null) : null });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { concepts, indexes, dirs };
|
|
47
|
+
}
|
|
48
|
+
// The sections a page of this folder still lacks, given its level-1 headings.
|
|
49
|
+
export const missingSections = (folder, headings) => folder.sections.filter((s) => !headings.includes(s));
|
|
50
|
+
// Level-1 headings and markdown links, ignoring fenced code blocks.
|
|
51
|
+
export function scanBody(body) {
|
|
52
|
+
const headings = [];
|
|
53
|
+
const links = [];
|
|
54
|
+
let fenced = false;
|
|
55
|
+
for (const line of body.split('\n')) {
|
|
56
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
57
|
+
fenced = !fenced;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (fenced)
|
|
61
|
+
continue;
|
|
62
|
+
const h = /^# (.+?)\s*$/.exec(line);
|
|
63
|
+
if (h?.[1])
|
|
64
|
+
headings.push(h[1]);
|
|
65
|
+
for (const m of line.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g))
|
|
66
|
+
if (m[1])
|
|
67
|
+
links.push(m[1]);
|
|
68
|
+
}
|
|
69
|
+
return { headings, links };
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findWikiRoot(cwd: string, explicit?: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { PROFILE_FILE } from '../core/profile.js';
|
|
4
|
+
import { UserError } from '../core/util.js';
|
|
5
|
+
// The wiki root is the folder that contains .okf-profile.yaml.
|
|
6
|
+
export function findWikiRoot(cwd, explicit) {
|
|
7
|
+
if (explicit) {
|
|
8
|
+
const root = resolve(cwd, explicit);
|
|
9
|
+
if (!existsSync(join(root, PROFILE_FILE))) {
|
|
10
|
+
throw new UserError(`${explicit} is not an okfcli wiki (${PROFILE_FILE} is missing).`);
|
|
11
|
+
}
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
let dir = resolve(cwd);
|
|
15
|
+
for (;;) {
|
|
16
|
+
if (existsSync(join(dir, PROFILE_FILE)))
|
|
17
|
+
return dir;
|
|
18
|
+
// Wiki in ./wiki or ./docs/wiki relative to the current directory.
|
|
19
|
+
for (const sub of ['wiki', 'docs/wiki']) {
|
|
20
|
+
if (existsSync(join(dir, sub, PROFILE_FILE)))
|
|
21
|
+
return join(dir, sub);
|
|
22
|
+
}
|
|
23
|
+
const parent = dirname(dir);
|
|
24
|
+
if (parent === dir)
|
|
25
|
+
break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
throw new UserError(`No okfcli wiki found from ${cwd}. Run "okfcli init" or use --wiki <folder>.`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, posix } from 'node:path';
|
|
3
|
+
import { CORE_FOLDERS, folderByName } from '../core/profile.js';
|
|
4
|
+
import { loadWiki, missingSections, scanBody } from './pages.js';
|
|
5
|
+
import { regenerateIndexes } from './indexer.js';
|
|
6
|
+
import { KEBAB } from '../core/util.js';
|
|
7
|
+
import { requiredPages } from '../generate/templates.js';
|
|
8
|
+
import { countTodos, trustState } from '../core/trust.js';
|
|
9
|
+
export const DEFAULT_SEVERITY = {
|
|
10
|
+
'okf.frontmatter': 'error',
|
|
11
|
+
'okf.type': 'error',
|
|
12
|
+
'okf.index': 'error',
|
|
13
|
+
'structure.core-folder': 'error',
|
|
14
|
+
'structure.required-page': 'error',
|
|
15
|
+
'structure.testing-page': 'error',
|
|
16
|
+
'structure.unknown-folder': 'warning',
|
|
17
|
+
'page.type': 'error',
|
|
18
|
+
'page.sections': 'error',
|
|
19
|
+
'page.todo': 'warning',
|
|
20
|
+
'frontmatter.title': 'error',
|
|
21
|
+
'frontmatter.description': 'error',
|
|
22
|
+
'frontmatter.status': 'error',
|
|
23
|
+
'frontmatter.verified': 'warning',
|
|
24
|
+
'frontmatter.verified-outdated': 'warning',
|
|
25
|
+
'name.kebab-case': 'error',
|
|
26
|
+
'index.out-of-date': 'error',
|
|
27
|
+
'link.broken': 'warning',
|
|
28
|
+
'page.orphan': 'warning',
|
|
29
|
+
};
|
|
30
|
+
const STATUSES = ['draft', 'stable', 'deprecated'];
|
|
31
|
+
const isBlank = (v) => v === undefined || v === null || String(v).trim() === '';
|
|
32
|
+
// Returns the list of issues: { rule, severity, file, message, fix }.
|
|
33
|
+
export function validateWiki(root, profile) {
|
|
34
|
+
const issues = [];
|
|
35
|
+
const add = (rule, file, message, fix) => {
|
|
36
|
+
const severity = profile.severity[rule] ?? DEFAULT_SEVERITY[rule];
|
|
37
|
+
if (severity === 'off' || severity === undefined)
|
|
38
|
+
return;
|
|
39
|
+
issues.push({ rule, severity, file, message, fix });
|
|
40
|
+
};
|
|
41
|
+
const wiki = loadWiki(root);
|
|
42
|
+
// Structure
|
|
43
|
+
for (const folder of CORE_FOLDERS) {
|
|
44
|
+
if (!existsSync(join(root, folder.name))) {
|
|
45
|
+
add('structure.core-folder', `${folder.name}/`, `Core folder "${folder.name}/" is missing.`, 'Run "okfcli validate --fix" to create it.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const page of requiredPages()) {
|
|
49
|
+
if (!existsSync(join(root, page.path))) {
|
|
50
|
+
add('structure.required-page', page.path, `Required page ${page.path} is missing.`, 'Run "okfcli validate --fix" to recreate it.');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (existsSync(join(root, 'testing')) && !wiki.concepts.some((c) => c.folder === 'testing')) {
|
|
54
|
+
add('structure.testing-page', 'testing/', 'testing/ has no strategy page.', 'Run "okfcli validate --fix" to scaffold testing/strategy.md, or create one with "okfcli new testing <name>".');
|
|
55
|
+
}
|
|
56
|
+
// Names
|
|
57
|
+
for (const rel of [...wiki.dirs, ...wiki.concepts.map((c) => c.rel.replace(/\.md$/, ''))]) {
|
|
58
|
+
const bad = rel.split('/').find((seg) => !KEBAB.test(seg));
|
|
59
|
+
if (bad) {
|
|
60
|
+
add('name.kebab-case', rel, `"${bad}" is not kebab-case.`, 'Rename it using lowercase letters, digits and hyphens, then run "okfcli validate --fix".');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Reserved files
|
|
64
|
+
for (const idx of wiki.indexes) {
|
|
65
|
+
const hasFront = /^---\r?\n/.test(idx.text);
|
|
66
|
+
if (hasFront) {
|
|
67
|
+
const keys = [...idx.text.matchAll(/^([A-Za-z_][\w-]*):/gm)].map((m) => m[1]);
|
|
68
|
+
const allowed = idx.rel === 'index.md' && keys.every((k) => k === 'okf_version');
|
|
69
|
+
if (!allowed) {
|
|
70
|
+
add('okf.index', idx.rel, 'index.md must not have frontmatter (only the root one may carry okf_version).', 'Run "okfcli validate --fix" to regenerate it.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Concept pages
|
|
75
|
+
const linkedFrom = new Map();
|
|
76
|
+
for (const page of wiki.concepts) {
|
|
77
|
+
const { rel, data, body } = page;
|
|
78
|
+
if (!data) {
|
|
79
|
+
add('okf.frontmatter', rel, 'Missing or unparseable YAML frontmatter.', 'Start the file with a "---" block containing at least type, title and description.');
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isBlank(data.type)) {
|
|
83
|
+
add('okf.type', rel, 'Frontmatter has no "type".', 'Add a "type:" field.');
|
|
84
|
+
}
|
|
85
|
+
const folder = page.folder ? folderByName(profile, page.folder) : undefined;
|
|
86
|
+
if (!folder) {
|
|
87
|
+
add('structure.unknown-folder', rel, page.folder ? `Folder "${page.folder}/" is not part of the profile.` : 'Pages must live inside a profile folder, not at the wiki root.', 'Move the page to a profile folder, or declare the folder in extra_folders of .okf-profile.yaml.');
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
if (!isBlank(data.type) && data.type !== folder.type) {
|
|
91
|
+
add('page.type', rel, `type is "${String(data.type)}" but pages in ${folder.name}/ must be "${folder.type}".`, `Set "type: ${folder.type}" or move the page.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (isBlank(data.title))
|
|
95
|
+
add('frontmatter.title', rel, 'Frontmatter has no "title".', 'Add a "title:" field.');
|
|
96
|
+
if (isBlank(data.description))
|
|
97
|
+
add('frontmatter.description', rel, 'Frontmatter has no "description".', 'Add a one-sentence "description:" field.');
|
|
98
|
+
if (data.status !== undefined && !STATUSES.includes(String(data.status))) {
|
|
99
|
+
add('frontmatter.status', rel, `status "${String(data.status)}" is not valid.`, `Use one of: ${STATUSES.join(', ')}.`);
|
|
100
|
+
}
|
|
101
|
+
if (data.status !== 'deprecated') {
|
|
102
|
+
const { tier, outdated, last } = trustState(data, body);
|
|
103
|
+
const review = `Review the page and run "okfcli verify ${rel} --by human:<id>".`;
|
|
104
|
+
if (outdated) {
|
|
105
|
+
add('frontmatter.verified-outdated', rel, `Page changed after it was verified (last by ${String(last?.by ?? 'unknown')} on ${String(last?.at ?? '?').slice(0, 10)}).`, review);
|
|
106
|
+
}
|
|
107
|
+
else if (tier === 'unverified') {
|
|
108
|
+
add('frontmatter.verified', rel, 'Page has not been reviewed.', review);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const { headings, links } = scanBody(body);
|
|
112
|
+
if (folder) {
|
|
113
|
+
for (const section of missingSections(folder, headings)) {
|
|
114
|
+
add('page.sections', rel, `Missing required section "# ${section}".`, 'Run "okfcli validate --fix" to add the skeleton, then fill it in.');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const todos = countTodos(body);
|
|
118
|
+
if (todos)
|
|
119
|
+
add('page.todo', rel, `${todos} unfilled TODO placeholder${todos > 1 ? 's' : ''}.`, 'Write the section content and remove the "<!-- TODO -->" comment.');
|
|
120
|
+
for (const target of links) {
|
|
121
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#'))
|
|
122
|
+
continue;
|
|
123
|
+
const path = target.split('#')[0] ?? '';
|
|
124
|
+
if (!path.endsWith('.md'))
|
|
125
|
+
continue;
|
|
126
|
+
const resolved = path.startsWith('/') ? path.slice(1) : posix.normalize(posix.join(posix.dirname(rel), path));
|
|
127
|
+
if (!existsSync(join(root, resolved))) {
|
|
128
|
+
add('link.broken', rel, `Broken link to ${target}.`, 'Fix the path or remove the link.');
|
|
129
|
+
}
|
|
130
|
+
else if (resolved !== rel) {
|
|
131
|
+
const from = linkedFrom.get(resolved) ?? new Set();
|
|
132
|
+
from.add(rel);
|
|
133
|
+
linkedFrom.set(resolved, from);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const entryPoints = new Set(requiredPages().map((p) => p.path));
|
|
138
|
+
for (const page of wiki.concepts) {
|
|
139
|
+
if (page.data && !entryPoints.has(page.rel) && !linkedFrom.has(page.rel)) {
|
|
140
|
+
add('page.orphan', page.rel, 'No other page links to this one.', 'Link it from a related page (for example the workflow or architecture page that uses it).');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Indexes
|
|
144
|
+
for (const rel of regenerateIndexes(root, profile, { check: true })) {
|
|
145
|
+
add('index.out-of-date', rel, 'This index does not match the pages in its folder (or is missing).', 'Run "okfcli validate --fix" to regenerate it.');
|
|
146
|
+
}
|
|
147
|
+
const order = { error: 0, warning: 1 };
|
|
148
|
+
issues.sort((a, b) => order[a.severity] - order[b.severity] || a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
149
|
+
return issues;
|
|
150
|
+
}
|