diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a46c79b3..6718c3f1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -59,6 +59,7 @@ import { runIngest } from "./commands/ingest.js"; import { runTagSync } from "./commands/tag-sync.js"; import { runTagReconcile } from "./commands/tag-reconcile.js"; import { runPagePublish } from "./commands/page-publish.js"; +import { runPageRenderHtml } from "./commands/page-render-html.js"; import { runProjectPagePublish } from "./commands/project-page-publish.js"; import { runSyncStatus, runSyncPush, runSyncPull, runSyncLock, runSyncUnlock, runSyncPeers, runSyncLintDelta } from "./commands/sync.js"; import { runSyncJournalList, runSyncJournalClearStale } from "./commands/sync-journal.js"; @@ -789,6 +790,21 @@ pageCmd ); }); +pageCmd + .command("render-html [vault]") + .description("render an opted-in typed page to a self-contained HTML file") + .requiredOption("--out ", "output HTML path (must be outside the vault)") + .option("--wiki ", "wiki profile name") + .action(async (page, vault, opts) => { + const resolved = await resolveVaultArg(vault, opts.wiki); + if (!resolved.ok) return emit({ exitCode: resolved.exitCode, result: resolved.payload }); + return emit(await runPageRenderHtml({ + vault: resolved.vault, + page, + out: opts.out, + }), resolved.vault, { postCommit: false }); + }); + const projectPageCmd = program.command("project-page").description("publish project-local architecture pages"); projectPageCmd @@ -2054,6 +2070,7 @@ program .requiredOption("--title ", "page title") .option("--tags <csv>", "comma-separated tags") .option("--provenance <provenance>", "provenance (research|project)") + .option("--html", "stamp html_render: true on the typed stub", false) .option("--dry-run", "preview without writing files", false) .action(async (source, opts) => { const tags = typeof opts.tags === "string" @@ -2067,6 +2084,7 @@ program title: opts.title, tags, provenance: opts.provenance, + html: !!opts.html, dryRun: true, }), opts.vault); return; @@ -2081,6 +2099,7 @@ program title: opts.title, tags, provenance: opts.provenance, + html: !!opts.html, dryRun: false, }) ); diff --git a/packages/cli/src/commands/health.ts b/packages/cli/src/commands/health.ts index 23ec52a7..fd1cd88b 100644 --- a/packages/cli/src/commands/health.ts +++ b/packages/cli/src/commands/health.ts @@ -11,6 +11,7 @@ import { type DoctorOutput, } from "./doctor.js"; import { runLint, type LintBucketSummary, type LintSummaryOutput, type LintSeverity } from "./lint.js"; +import { isAdvisoryLintWarning } from "../lint/helpers.js"; import { inventorySources } from "../utils/source-lifecycle.js"; import { VAULT_SYNC_FILTER_REQUIRED_EXCLUDES } from "../utils/vault-hygiene-ignores.js"; @@ -645,7 +646,13 @@ export async function runHealth(input: HealthInput): Promise<{ exitCode: number; checks: doctor.result.data.checks.map(check => ({ id: check.id, status: check.status, detail: check.detail })), }; const lintComponent: LintComponent = { - status: statusFromCounts({ error: lint.result.data.summary.errors, warnings: lint.result.data.summary.warnings, info: lint.result.data.summary.info }), + status: statusFromCounts({ + error: lint.result.data.summary.errors, + warnings: lint.result.data.buckets + .filter((bucket) => bucket.severity === "warning" && !isAdvisoryLintWarning(bucket.kind)) + .reduce((n, bucket) => n + bucket.count, 0), + info: lint.result.data.summary.info, + }), blocking: true, summary: lint.result.data.summary, buckets: lint.result.data.buckets, diff --git a/packages/cli/src/commands/ingest.ts b/packages/cli/src/commands/ingest.ts index a6a5e66f..de39f7b2 100644 --- a/packages/cli/src/commands/ingest.ts +++ b/packages/cli/src/commands/ingest.ts @@ -36,6 +36,7 @@ export interface IngestInput { tags?: string[]; provenance?: string; dryRun?: boolean; + html?: boolean; } export interface IngestOutput { @@ -95,7 +96,8 @@ function buildTypedContent( type: string, tags: string[], rawRelPath: string, - provenance: string | undefined + provenance: string | undefined, + html?: boolean ): string { const aliases: string[] = []; const sourcesYaml = ` - ${rawRelPath}`; @@ -120,6 +122,10 @@ function buildTypedContent( if (provenance) { fmLines.push(`provenance: ${provenance}`); } + fmLines.push("output_standard: v2"); + if (html) { + fmLines.push("html_render: true"); + } fmLines.push("---"); fmLines.push(""); @@ -412,7 +418,8 @@ export async function runIngest( input.type, tags, rawRelPath, - input.provenance + input.provenance, + input.html ); // The publisher resolves and validates the final target path, so create the @@ -513,6 +520,7 @@ export async function runIngest( tags, rawRelPath, input.provenance, + input.html, ); const refreshedPublication = preparePagePublicationFromContent({ vault: input.vault, diff --git a/packages/cli/src/commands/page-render-html.ts b/packages/cli/src/commands/page-render-html.ts new file mode 100644 index 00000000..c90a4e51 --- /dev/null +++ b/packages/cli/src/commands/page-render-html.ts @@ -0,0 +1,489 @@ +import { lstat, mkdir, readFile, realpath } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { err, ok, ExitCode, type Result } from "@skillwiki/shared"; +import { extractFrontmatter, splitFrontmatter } from "../parsers/frontmatter.js"; +import { atomicWriteText } from "../utils/atomic-write.js"; +import { resolveExistingRegularFileInsideVault } from "../utils/vault-path-safety.js"; + +export interface PageRenderHtmlInput { + vault: string; + page: string; + out: string; +} + +export interface PageRenderHtmlOutput { + page: string; + out: string; + bytes: number; + humanHint: string; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +const SAFE_HREF_RE = /^(https?:\/\/|mailto:|#)/i; + +function escapeAttr(text: string): string { + return escapeHtml(text).replace(/'/g, "'"); +} + +function slugifyHeading(text: string, used: Map<string, number>): string { + const base = text + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "section"; + const n = used.get(base) ?? 0; + used.set(base, n + 1); + return n === 0 ? base : `${base}-${n + 1}`; +} + +export function renderInlineMarkdown(raw: string): string { + const re = /(`[^`]+`)|(\[\[(?:[^\]]+)\]\])|(!\[[^\]]*\]\([^)]+\))|(\[[^\]]+\]\([^)]+\))|(\*\*[^*]+\*\*)|(\*[^*]+\*)/g; + const parts: string[] = []; + let last = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(raw)) !== null) { + parts.push(escapeHtml(raw.slice(last, match.index))); + const token = match[0]; + if (token.startsWith("`")) { + parts.push(`<code>${escapeHtml(token.slice(1, -1))}</code>`); + } else if (token.startsWith("[[")) { + const inner = token.slice(2, -2); + const pipe = inner.indexOf("|"); + const label = pipe === -1 ? inner : inner.slice(pipe + 1); + parts.push(escapeHtml(label.trim())); + } else if (token.startsWith("![")) { + const alt = token.match(/^!\[([^\]]*)\]/)?.[1] ?? ""; + parts.push(escapeHtml(alt)); + } else if (token.startsWith("[")) { + const linked = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (linked && SAFE_HREF_RE.test(linked[2]!)) { + parts.push(`<a href="${escapeAttr(linked[2]!)}">${escapeHtml(linked[1]!)}</a>`); + } else if (linked) { + parts.push(escapeHtml(linked[1]!)); + } else { + parts.push(escapeHtml(token)); + } + } else if (token.startsWith("**")) { + parts.push(`<strong>${escapeHtml(token.slice(2, -2))}</strong>`); + } else { + parts.push(`<em>${escapeHtml(token.slice(1, -1))}</em>`); + } + last = match.index + token.length; + } + parts.push(escapeHtml(raw.slice(last))); + return parts.join(""); +} + +type Block = + | { kind: "heading"; level: number; text: string } + | { kind: "paragraph"; text: string } + | { kind: "list"; ordered: boolean; items: string[] } + | { kind: "table"; rows: string[][] } + | { kind: "code"; language: string; text: string }; + +function parseTableRow(line: string): string[] { + const trimmed = line.trim(); + const inner = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed; + const withoutEnd = inner.endsWith("|") ? inner.slice(0, -1) : inner; + return withoutEnd.split("|").map((cell) => cell.trim()); +} + +function isTableSeparator(line: string): boolean { + return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); +} + +export function parseMarkdownBlocks(body: string): Block[] { + const lines = body.split(/\r?\n/); + const blocks: Block[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + const marker = fence[2]!; + const language = fence[3]!.trim(); + const content: string[] = []; + i++; + while (i < lines.length) { + const close = lines[i]!.match(/^(\s*)(`{3,}|~{3,})\s*$/); + if (close && close[2]![0] === marker[0] && close[2]!.length >= marker.length) break; + content.push(lines[i]!); + i++; + } + blocks.push({ kind: "code", language, text: content.join("\n") }); + i++; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(\S.*)$/); + if (heading) { + blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]!.trim() }); + i++; + continue; + } + + if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1]!)) { + const rows = [parseTableRow(line)]; + i += 2; + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]!) && !isTableSeparator(lines[i]!)) { + rows.push(parseTableRow(lines[i]!)); + i++; + } + blocks.push({ kind: "table", rows }); + continue; + } + + const ordered = line.match(/^\s*\d+\.\s+(.*)$/); + const unordered = line.match(/^\s*[-*+]\s+(.*)$/); + if (ordered || unordered) { + const isOrdered = Boolean(ordered); + const itemRe = isOrdered ? /^\s*\d+\.\s+(.*)$/ : /^\s*[-*+]\s+(.*)$/; + const items: string[] = []; + while (i < lines.length) { + const item = lines[i]!.match(itemRe); + if (!item) break; + const chunks = [item[1]!]; + i++; + while (i < lines.length && /^\s+\S/.test(lines[i]!) && !itemRe.test(lines[i]!) && !/^(#{1,6})\s+/.test(lines[i]!)) { + chunks.push(lines[i]!.trim()); + i++; + } + items.push(chunks.join(" ")); + } + blocks.push({ kind: "list", ordered: isOrdered, items }); + continue; + } + + if (!line.trim()) { + i++; + continue; + } + + const para: string[] = [line.trim()]; + i++; + while (i < lines.length) { + const next = lines[i]!; + if (!next.trim()) break; + if (/^(#{1,6})\s+/.test(next) || /^(\s*)(`{3,}|~{3,})/.test(next) || /^\s*\|.*\|\s*$/.test(next) || /^\s*\d+\.\s+/.test(next) || /^\s*[-*+]\s+/.test(next)) { + break; + } + para.push(next.trim()); + i++; + } + blocks.push({ kind: "paragraph", text: para.join(" ") }); + } + + return blocks; +} + +function renderBlock(block: Block): string { + switch (block.kind) { + case "heading": { + const tag = `h${Math.min(block.level, 6)}`; + return `<${tag}>${renderInlineMarkdown(block.text)}</${tag}>`; + } + case "paragraph": + return `<p>${renderInlineMarkdown(block.text)}</p>`; + case "list": { + const tag = block.ordered ? "ol" : "ul"; + const items = block.items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join(""); + return `<${tag}>${items}</${tag}>`; + } + case "table": { + if (block.rows.length === 0) return ""; + const [header, ...body] = block.rows; + const thead = `<thead><tr>${header!.map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`).join("")}</tr></thead>`; + const tbody = body.length > 0 + ? `<tbody>${body.map((row) => `<tr>${row.map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`).join("")}</tr>`).join("")}</tbody>` + : ""; + return `<table>${thead}${tbody}</table>`; + } + case "code": { + const cls = block.language.toLowerCase() === "mermaid" ? " class=\"mermaid\"" : ""; + return `<pre${cls}><code>${escapeHtml(block.text)}</code></pre>`; + } + } +} + +interface Section { + id: string; + title: string; + blocks: Block[]; +} + +function groupSections(blocks: Block[]): { title: string; intro: Block[]; sections: Section[] } { + const used = new Map<string, number>(); + let title = "Page"; + const intro: Block[] = []; + const sections: Section[] = []; + let current: Section | null = null; + + for (const block of blocks) { + if (block.kind === "heading" && block.level === 1 && title === "Page") { + title = block.text; + continue; + } + if (block.kind === "heading" && block.level === 2) { + current = { id: slugifyHeading(block.text, used), title: block.text, blocks: [] }; + sections.push(current); + continue; + } + if (current) current.blocks.push(block); + else intro.push(block); + } + + return { title, intro, sections }; +} + +export function renderPageHtml(title: string, body: string): string { + const parsed = parseMarkdownBlocks(body); + const grouped = groupSections(parsed); + const pageTitle = grouped.title !== "Page" ? grouped.title : title; + const sections = grouped.sections.length > 0 + ? grouped.sections + : [{ id: "content", title: pageTitle, blocks: grouped.intro }]; + const introHtml = grouped.sections.length > 0 && grouped.intro.length > 0 + ? grouped.intro.map(renderBlock).join("\n") + : ""; + + const toc = sections + .map((section) => `<li><a href="#${escapeAttr(section.id)}">${escapeHtml(section.title)}</a></li>`) + .join(""); + const tabs = sections + .map((section, index) => + `<button type="button" role="tab" aria-selected="${index === 0 ? "true" : "false"}" aria-controls="${escapeAttr(section.id)}" data-tab="${escapeAttr(section.id)}">${escapeHtml(section.title)}</button>` + ) + .join(""); + const panels = sections + .map((section, index) => { + const inner = section.blocks.map(renderBlock).join("\n"); + return `<section id="${escapeAttr(section.id)}" class="tab-panel" role="tabpanel"${index === 0 ? "" : " hidden"}><details open><summary>${escapeHtml(section.title)}</summary>\n${inner}\n</details></section>`; + }) + .join("\n"); + + return `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>${escapeHtml(pageTitle)} + + + + +${introHtml} +
${tabs}
+${panels} + + + +`; +} + +function isInsideCanonical(root: string, candidate: string): boolean { + const rel = relative(root, candidate).split(sep).join("/"); + return rel === "" || (rel !== ".." && !rel.startsWith("../")); +} + +function outInsideVaultError(vault: string, out: string, message: string) { + return err("HTML_RENDER_OUT_INSIDE_VAULT", { vault, out, message }); +} + +async function canonicalizeWithMissingSuffix(abs: string): Promise { + try { + await lstat(abs); + return await realpath(abs); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const missing: string[] = []; + let current = abs; + while (true) { + missing.unshift(basename(current)); + const parent = dirname(current); + try { + await lstat(parent); + return join(await realpath(parent), ...missing); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (parent === current) return join(parent, ...missing); + current = parent; + } + } +} + +async function resolveOutOutsideVault(vault: string, out: string): Promise> { + let vaultReal: string; + try { + vaultReal = await realpath(vault); + } catch (error) { + return err("VAULT_PATH_INVALID", { message: "vault realpath failed", cause: String(error) }); + } + + const abs = resolve(out); + try { + const info = await lstat(abs); + if (info.isSymbolicLink()) { + return outInsideVaultError(vault, out, "--out must be a regular path outside the vault"); + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return err("WRITE_FAILED", { path: abs, message: String(error) }); + } + } + + let canonical: string; + try { + canonical = await canonicalizeWithMissingSuffix(abs); + } catch (error: unknown) { + return err("WRITE_FAILED", { path: abs, message: String(error) }); + } + + if (isInsideCanonical(vaultReal, canonical)) { + return outInsideVaultError(vault, out, "--out must be outside the vault"); + } + return ok(canonical); +} + +export async function runPageRenderHtml( + input: PageRenderHtmlInput, +): Promise<{ exitCode: number; result: Result }> { + if (!input.out || input.out.trim().length === 0) { + return { + exitCode: ExitCode.USAGE, + result: err("USAGE", { message: "--out is required" }), + }; + } + + const pagePath = await resolveExistingRegularFileInsideVault(input.vault, input.page); + if (!pagePath.ok) { + return { + exitCode: pagePath.error === "FILE_NOT_FOUND" ? ExitCode.FILE_NOT_FOUND : ExitCode.VAULT_PATH_INVALID, + result: pagePath, + }; + } + + const outPath = await resolveOutOutsideVault(input.vault, input.out); + if (!outPath.ok) { + const exitCode = outPath.error === "HTML_RENDER_OUT_INSIDE_VAULT" + ? ExitCode.SCHEME_REJECTED + : outPath.error === "WRITE_FAILED" + ? ExitCode.WRITE_FAILED + : ExitCode.VAULT_PATH_INVALID; + return { exitCode, result: outPath }; + } + + let text: string; + try { + text = await readFile(pagePath.data, "utf8"); + } catch { + return { + exitCode: ExitCode.FILE_NOT_FOUND, + result: err("FILE_NOT_FOUND", { path: input.page }), + }; + } + + const fm = extractFrontmatter(text); + if (!fm.ok) { + return { + exitCode: ExitCode.INVALID_FRONTMATTER, + result: fm, + }; + } + if (fm.data.html_render !== true) { + return { + exitCode: ExitCode.HTML_RENDER_NOT_OPTED_IN, + result: err("HTML_RENDER_NOT_OPTED_IN", { + page: input.page, + message: "page frontmatter must set html_render: true", + }), + }; + } + + const split = splitFrontmatter(text); + if (!split.ok) { + return { exitCode: ExitCode.MISSING_CLOSING_DELIMITER, result: split }; + } + + const title = typeof fm.data.title === "string" ? fm.data.title : input.page; + const html = renderPageHtml(title, split.data.body); + const outAbs = outPath.data; + + try { + await mkdir(dirname(outAbs), { recursive: true }); + } catch (error: unknown) { + return { + exitCode: ExitCode.WRITE_FAILED, + result: err("WRITE_FAILED", { path: outAbs, message: String(error) }), + }; + } + + const written = await atomicWriteText(outAbs, html); + if (!written.ok) { + return { exitCode: ExitCode.WRITE_FAILED, result: written }; + } + + return { + exitCode: ExitCode.OK, + result: ok({ + page: input.page, + out: outAbs, + bytes: Buffer.byteLength(html, "utf8"), + humanHint: `Wrote ${outAbs} (${Buffer.byteLength(html, "utf8")} bytes)`, + }), + }; +} diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index 74d0e141..3c5d682b 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { execFileSync } from "node:child_process"; import { ok, err, ExitCode, type Result } from "@skillwiki/shared"; import { runLint, runSyncLintDelta } from "./lint.js"; +import { isAdvisoryLintWarning } from "../lint/helpers.js"; export { runSyncLintDelta, type SyncLintDeltaInput, type SyncLintDeltaOutput } from "./lint.js"; import { fixPathTooLong } from "./path-too-long.js"; import { appendLastOp, readLastOp, clearLastOp } from "../utils/last-op.js"; @@ -641,10 +642,14 @@ export async function runSyncPull(input: SyncPullInput): Promise<{ exitCode: num // 5. Run lint after pull let lintErrors = 0; let lintWarnings = 0; + let blockingLintWarnings = 0; const lintResult = await runLint({ vault, days: 90, lines: 200, logThreshold: 500 }); if (lintResult.result.ok) { lintErrors = lintResult.result.data.summary.errors; lintWarnings = lintResult.result.data.summary.warnings; + blockingLintWarnings = lintResult.result.data.by_severity.warning + .filter((bucket) => !isAdvisoryLintWarning(bucket.kind)) + .reduce((n, bucket) => n + bucket.items.length, 0); } const hintParts: string[] = []; @@ -656,7 +661,7 @@ export async function runSyncPull(input: SyncPullInput): Promise<{ exitCode: num const exitCode = lintErrors > 0 ? ExitCode.LINT_HAS_ERRORS - : lintWarnings > 0 + : blockingLintWarnings > 0 ? ExitCode.LINT_HAS_WARNINGS : ExitCode.OK; diff --git a/packages/cli/src/lint/helpers.ts b/packages/cli/src/lint/helpers.ts index 828dbade..8c01f698 100644 --- a/packages/cli/src/lint/helpers.ts +++ b/packages/cli/src/lint/helpers.ts @@ -48,6 +48,17 @@ export const WARNING_ORDER = [ "missing_overview", "missing_diagram", "cycle_traps", + "ste_sentence_length", + "ste_hedge_words", + "ste_diagram_uncertainty", + "ste_comparison_visual", +] as const; + +export const ADVISORY_WARNING_ORDER = [ + "ste_sentence_length", + "ste_hedge_words", + "ste_diagram_uncertainty", + "ste_comparison_visual", ] as const; export const INFO_ORDER = [ @@ -164,6 +175,22 @@ export function severityForBucket(kind: string): LintSeverity { return "info"; } +export function isAdvisoryLintWarning(kind: string): boolean { + return (ADVISORY_WARNING_ORDER as readonly string[]).includes(kind); +} + +export function lintExitCode( + summary: { errors: number; warnings: number; info: number }, + warningBuckets: Bucket[] +): ExitCodeValue { + if (summary.errors > 0) return ExitCode.LINT_HAS_ERRORS; + const blockingWarnings = warningBuckets + .filter((bucket) => !isAdvisoryLintWarning(bucket.kind)) + .reduce((n, bucket) => n + bucket.items.length, 0); + if (blockingWarnings > 0 || summary.info > 0) return ExitCode.LINT_HAS_WARNINGS; + return ExitCode.OK; +} + export function lintReadVault(input: LintInput | LintSummaryInput): LintReadVault { if (input.fix) { return { readPath: input.vault, readMirror: false }; @@ -257,9 +284,7 @@ export function outputForOnlyBucket( warnings: filtered.warning.reduce((n, b) => n + b.items.length, 0), info: filtered.info.reduce((n, b) => n + b.items.length, 0), }; - let exitCode: number = ExitCode.OK; - if (summary.errors > 0) exitCode = ExitCode.LINT_HAS_ERRORS; - else if (summary.warnings > 0 || summary.info > 0) exitCode = ExitCode.LINT_HAS_WARNINGS; + const exitCode = lintExitCode(summary, filtered.warning); const vault = lintVaultOutput(input, readVault); const hintLines = [ ...readMirrorHintLines(vault), diff --git a/packages/cli/src/lint/rules.ts b/packages/cli/src/lint/rules.ts index 4e33737f..7b26b0ad 100644 --- a/packages/cli/src/lint/rules.ts +++ b/packages/cli/src/lint/rules.ts @@ -64,6 +64,13 @@ import { summarizeLintOutput, walkMarkdownFiles, } from "./helpers.js"; +import { + comparisonLacksVisual, + hasSteHedgeViolation, + hasSteSentenceLengthViolation, + isOutputStandardV2, + mermaidBlocksMissingLeastCertain, +} from "./ste.js"; async function collectCliRefsPages(vault: string): Promise> { if (!existsSync(join(vault, "SCHEMA.md"))) { @@ -1648,6 +1655,91 @@ export const cycleTrapsRule: LintRuleModule = { }, }; +export const steSentenceLengthRule: LintRuleModule = { + id: "ste_sentence_length", + severity: "warning", + async run(ctx: LintRuleContext): Promise { + const items: string[] = []; + await mapWithConcurrency(ctx.scan.typedKnowledge, vaultIoConcurrency(), async (page) => { + try { + const text = await readPageCached(page, ctx.pageTextCache); + const fm = extractFrontmatter(text); + if (!fm.ok || !isOutputStandardV2(fm.data)) return; + const split = splitFrontmatter(text); + if (!split.ok) return; + if (hasSteSentenceLengthViolation(split.data.body)) items.push(page.relPath); + } catch { + // Skip unreadable pages + } + }); + return { buckets: items.length > 0 ? { ste_sentence_length: items } : {} }; + }, +}; + +export const steHedgeWordsRule: LintRuleModule = { + id: "ste_hedge_words", + severity: "warning", + async run(ctx: LintRuleContext): Promise { + const items: string[] = []; + await mapWithConcurrency(ctx.scan.typedKnowledge, vaultIoConcurrency(), async (page) => { + try { + const text = await readPageCached(page, ctx.pageTextCache); + const fm = extractFrontmatter(text); + if (!fm.ok || !isOutputStandardV2(fm.data)) return; + const split = splitFrontmatter(text); + if (!split.ok) return; + if (hasSteHedgeViolation(split.data.body)) items.push(page.relPath); + } catch { + // Skip unreadable pages + } + }); + return { buckets: items.length > 0 ? { ste_hedge_words: items } : {} }; + }, +}; + +export const steDiagramUncertaintyRule: LintRuleModule = { + id: "ste_diagram_uncertainty", + severity: "warning", + async run(ctx: LintRuleContext): Promise { + const items: string[] = []; + await mapWithConcurrency(ctx.scan.typedKnowledge, vaultIoConcurrency(), async (page) => { + try { + const text = await readPageCached(page, ctx.pageTextCache); + const fm = extractFrontmatter(text); + if (!fm.ok || !isOutputStandardV2(fm.data)) return; + const split = splitFrontmatter(text); + if (!split.ok) return; + if (mermaidBlocksMissingLeastCertain(split.data.body)) items.push(page.relPath); + } catch { + // Skip unreadable pages + } + }); + return { buckets: items.length > 0 ? { ste_diagram_uncertainty: items } : {} }; + }, +}; + +export const steComparisonVisualRule: LintRuleModule = { + id: "ste_comparison_visual", + severity: "warning", + async run(ctx: LintRuleContext): Promise { + const items: string[] = []; + await mapWithConcurrency(ctx.scan.typedKnowledge, vaultIoConcurrency(), async (page) => { + try { + const text = await readPageCached(page, ctx.pageTextCache); + const fm = extractFrontmatter(text); + if (!fm.ok || !isOutputStandardV2(fm.data)) return; + if (fm.data.type !== "comparison") return; + const split = splitFrontmatter(text); + if (!split.ok) return; + if (comparisonLacksVisual(split.data.body)) items.push(page.relPath); + } catch { + // Skip unreadable pages + } + }); + return { buckets: items.length > 0 ? { ste_comparison_visual: items } : {} }; + }, +}; + // Registered rule list in default execution order export const LINT_RULES: readonly LintRuleModule[] = [ brokenWikilinksRule, @@ -1684,4 +1776,8 @@ export const LINT_RULES: readonly LintRuleModule[] = [ cliRefsRule, staleSectionsRule, cycleTrapsRule, + steSentenceLengthRule, + steHedgeWordsRule, + steDiagramUncertaintyRule, + steComparisonVisualRule, ]; diff --git a/packages/cli/src/lint/runner.ts b/packages/cli/src/lint/runner.ts index 114e4f23..b4c31688 100644 --- a/packages/cli/src/lint/runner.ts +++ b/packages/cli/src/lint/runner.ts @@ -30,8 +30,8 @@ import { lintVaultOutput, outputForOnlyBucket, readMirrorHintLines, - severityForBucket, summarizeLintOutput, + lintExitCode, } from "./helpers.js"; import { collectLintErrorFingerprints } from "./fingerprints.js"; import { extractGitTree } from "../utils/git-archive.js"; @@ -173,9 +173,7 @@ export class LintRunner { info: infoOut.reduce((n, b) => n + b.items.length, 0), }; - let exitCode: number = ExitCode.OK; - if (summary.errors > 0) exitCode = ExitCode.LINT_HAS_ERRORS; - else if (summary.warnings > 0 || summary.info > 0) exitCode = ExitCode.LINT_HAS_WARNINGS; + const exitCode = lintExitCode(summary, warningOut); const vault = lintVaultOutput(input, readVault); const hintLines: string[] = []; diff --git a/packages/cli/src/lint/ste.ts b/packages/cli/src/lint/ste.ts new file mode 100644 index 00000000..1dbe3c87 --- /dev/null +++ b/packages/cli/src/lint/ste.ts @@ -0,0 +1,388 @@ +const URL_RE = /https?:\/\/[^\s)<>]+/gi; +const CITATION_RE = /\^\[raw\/[^\]]+\]/g; +const FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/; +const HEADING_RE = /^(#{1,6})\s+(\S.*)$/; +const ORDERED_ITEM_RE = /^(\s*)\d+\.\s+(.*)$/; +const UNORDERED_ITEM_RE = /^(\s*)[-*+]\s+(.*)$/; +const TABLE_ROW_RE = /^\s*\|.*\|\s*$/; +const BLOCKQUOTE_RE = /^\s*>/; +const LEAST_CERTAIN_RE = /^\s*\*\*Least certain:\*\*\s*$/; +const UNCERTAINTY_HEADING_RE = /^##\s+Uncertainty\s*$/i; +const STEPS_HEADING_RE = /^(Steps|Procedure)\b/i; +const LEAST_CERTAIN_FOLLOWING_RE = /^\s*\*\*Least certain:\*\*\s*\n(?:[ \t]*[-*+][ \t]+\S.*\n?)+/; +const HEDGE_PHRASES = ["appears to", "could be"] as const; +const HEDGE_WORDS = [ + "might", + "may", + "perhaps", + "possibly", + "probably", + "likely", + "seems", + "arguably", + "somewhat", +] as const; + +export const STE_PROSE_WORD_LIMIT = 25; +export const STE_INSTRUCTION_WORD_LIMIT = 20; + +export function isOutputStandardV2(fm: Record): boolean { + return fm.output_standard === "v2"; +} + +export function stripUrlsAndCitations(text: string): string { + return text.replace(URL_RE, " ").replace(CITATION_RE, " "); +} + +export function countWords(text: string): number { + const cleaned = stripUrlsAndCitations(text).trim(); + if (!cleaned) return 0; + return cleaned.split(/\s+/).filter(Boolean).length; +} + +export function splitSentences(text: string): string[] { + const normalized = text.replace(/\s+/g, " ").trim(); + if (!normalized) return []; + return normalized.split(/(?<=[.!?])\s+/).map((part) => part.trim()).filter(Boolean); +} + +function hedgePattern(): RegExp { + const phrases = HEDGE_PHRASES.map((p) => p.replace(/\s+/g, "\\s+")); + const words = HEDGE_WORDS.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return new RegExp(`\\b(?:${[...phrases, ...words].join("|")})\\b`, "gi"); +} + +export function findHedgeMatches(text: string): string[] { + const matches = text.match(hedgePattern()); + if (!matches) return []; + return [...new Set(matches.map((m) => m.toLowerCase().replace(/\s+/g, " ")))]; +} + +interface FenceState { + open: boolean; + marker: string; +} + +function toggleFence(line: string, fence: FenceState): boolean { + const match = line.match(FENCE_OPEN); + if (!match) return false; + const marker = match[2]!; + if (!fence.open) { + fence.open = true; + fence.marker = marker; + return true; + } + if (marker[0] === fence.marker[0] && marker.length >= fence.marker.length && match[3]!.trim() === "") { + fence.open = false; + fence.marker = ""; + return true; + } + return false; +} + +function isTableSeparator(line: string): boolean { + return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); +} + +export function hasMarkdownTable(body: string): boolean { + const fence: FenceState = { open: false, marker: "" }; + const lines = body.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (toggleFence(line, fence) || fence.open) continue; + if (TABLE_ROW_RE.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1]!)) { + return true; + } + } + return false; +} + +function fenceLanguage(line: string): string { + const match = line.match(FENCE_OPEN); + if (!match) return ""; + return match[3]!.trim().split(/\s+/)[0]?.toLowerCase() ?? ""; +} + +export function hasMermaidBlock(body: string): boolean { + const fence: FenceState = { open: false, marker: "" }; + for (const line of body.split(/\r?\n/)) { + if (!fence.open && fenceLanguage(line) === "mermaid") return true; + toggleFence(line, fence); + } + return false; +} + +export function mermaidBlocksMissingLeastCertain(body: string): boolean { + const lines = body.split(/\r?\n/); + const fence: FenceState = { open: false, marker: "" }; + let i = 0; + while (i < lines.length) { + const line = lines[i]!; + const startingMermaid = !fence.open && fenceLanguage(line) === "mermaid"; + if (toggleFence(line, fence)) { + if (startingMermaid) { + i++; + while (i < lines.length && fence.open) { + toggleFence(lines[i]!, fence); + i++; + } + const after = lines.slice(i).join("\n"); + if (!LEAST_CERTAIN_FOLLOWING_RE.test(after)) return true; + continue; + } + i++; + continue; + } + i++; + } + return false; +} + +export function comparisonLacksVisual(body: string): boolean { + return !hasMarkdownTable(body) && !hasMermaidBlock(body); +} + +function headingLevel(line: string): { level: number; title: string } | null { + const match = line.match(HEADING_RE); + if (!match) return null; + return { level: match[1]!.length, title: match[2]!.trim() }; +} + +function collectListItem(lines: string[], start: number, markerRe: RegExp): { text: string; next: number } { + const first = lines[start]!.replace(markerRe, "$2"); + const chunks = [first]; + let i = start + 1; + while (i < lines.length) { + const line = lines[i]!; + if (!line.trim()) break; + if (HEADING_RE.test(line) || FENCE_OPEN.test(line) || ORDERED_ITEM_RE.test(line) || UNORDERED_ITEM_RE.test(line) || TABLE_ROW_RE.test(line) || BLOCKQUOTE_RE.test(line)) { + break; + } + if (/^\s+\S/.test(line)) { + chunks.push(line.trim()); + i++; + continue; + } + break; + } + return { text: chunks.join(" "), next: i }; +} + +export function findLongUnits(body: string): Array<{ text: string; words: number; limit: number }> { + const lines = body.split(/\r?\n/); + const fence: FenceState = { open: false, marker: "" }; + let underStepsUntilLevel: number | null = null; + const findings: Array<{ text: string; words: number; limit: number }> = []; + let paragraph: string[] = []; + + const flushParagraph = () => { + if (paragraph.length === 0) return; + const text = paragraph.join(" "); + paragraph = []; + for (const sentence of splitSentences(text)) { + const words = countWords(sentence); + if (words > STE_PROSE_WORD_LIMIT) { + findings.push({ text: sentence, words, limit: STE_PROSE_WORD_LIMIT }); + } + } + }; + + const checkInstruction = (text: string) => { + const words = countWords(text); + if (words > STE_INSTRUCTION_WORD_LIMIT) { + findings.push({ text, words, limit: STE_INSTRUCTION_WORD_LIMIT }); + } + }; + + const checkProseChunk = (text: string) => { + for (const sentence of splitSentences(text)) { + const words = countWords(sentence); + if (words > STE_PROSE_WORD_LIMIT) { + findings.push({ text: sentence, words, limit: STE_PROSE_WORD_LIMIT }); + } + } + }; + + let i = 0; + while (i < lines.length) { + const line = lines[i]!; + if (toggleFence(line, fence)) { + flushParagraph(); + i++; + continue; + } + if (fence.open) { + i++; + continue; + } + + const heading = headingLevel(line); + if (heading) { + flushParagraph(); + if (STEPS_HEADING_RE.test(heading.title)) { + underStepsUntilLevel = heading.level; + } else if (underStepsUntilLevel !== null && heading.level <= underStepsUntilLevel) { + underStepsUntilLevel = null; + } + i++; + continue; + } + + if (BLOCKQUOTE_RE.test(line) || TABLE_ROW_RE.test(line) || isTableSeparator(line)) { + flushParagraph(); + i++; + continue; + } + + const ordered = line.match(ORDERED_ITEM_RE); + if (ordered) { + flushParagraph(); + const item = collectListItem(lines, i, ORDERED_ITEM_RE); + checkInstruction(item.text); + i = item.next; + continue; + } + + const unordered = line.match(UNORDERED_ITEM_RE); + if (unordered) { + flushParagraph(); + const item = collectListItem(lines, i, UNORDERED_ITEM_RE); + if (underStepsUntilLevel !== null) checkInstruction(item.text); + else checkProseChunk(item.text); + i = item.next; + continue; + } + + if (!line.trim()) { + flushParagraph(); + i++; + continue; + } + + paragraph.push(line.trim()); + i++; + } + flushParagraph(); + return findings; +} + +export function hasSteSentenceLengthViolation(body: string): boolean { + return findLongUnits(body).length > 0; +} + +function isEscapedBacktick(text: string, index: number): boolean { + let slashes = 0; + for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) slashes++; + return slashes % 2 === 1; +} + +function backtickRunLength(text: string, index: number): number { + let n = 0; + while (index + n < text.length && text[index + n] === "`") n++; + return n; +} + +/** CommonMark inline code spans: a run of length N closes on the next exact-N run. Escaped backticks and unclosed runs stay literal. */ +function stripInlineCodeSpans(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + if (text[i] === "`" && !isEscapedBacktick(text, i)) { + const n = backtickRunLength(text, i); + let j = i + n; + let closed = false; + while (j < text.length) { + if (text[j] === "`") { + const m = backtickRunLength(text, j); + if (m === n) { + out += " "; + i = j + m; + closed = true; + break; + } + j += m; + continue; + } + j++; + } + if (!closed) { + out += text.slice(i, i + n); + i += n; + } + continue; + } + out += text[i]; + i++; + } + return out; +} + +function isLeastCertainListItemLine(line: string): boolean { + return UNORDERED_ITEM_RE.test(line) || ORDERED_ITEM_RE.test(line) || /^\s+\S/.test(line); +} + +export function findHedgesOutsideAllowedRegions(body: string): string[] { + const lines = body.split(/\r?\n/); + const fence: FenceState = { open: false, marker: "" }; + let uncertaintyUntilLevel: number | null = null; + let skippingLeastCertainList = false; + let leastCertainSawBlank = false; + const found: string[] = []; + + const endLeastCertainList = () => { + skippingLeastCertainList = false; + leastCertainSawBlank = false; + }; + + for (const line of lines) { + if (toggleFence(line, fence)) { + endLeastCertainList(); + continue; + } + if (fence.open) continue; + + const heading = headingLevel(line); + if (heading) { + endLeastCertainList(); + if (UNCERTAINTY_HEADING_RE.test(line)) { + uncertaintyUntilLevel = heading.level; + } else if (uncertaintyUntilLevel !== null && heading.level <= uncertaintyUntilLevel) { + uncertaintyUntilLevel = null; + } + continue; + } + + if (uncertaintyUntilLevel !== null || BLOCKQUOTE_RE.test(line)) { + endLeastCertainList(); + continue; + } + + if (LEAST_CERTAIN_RE.test(line)) { + skippingLeastCertainList = true; + leastCertainSawBlank = false; + continue; + } + + if (skippingLeastCertainList) { + if (!line.trim()) { + leastCertainSawBlank = true; + continue; + } + if (isLeastCertainListItemLine(line) || !leastCertainSawBlank) { + leastCertainSawBlank = false; + continue; + } + endLeastCertainList(); + } + + found.push(...findHedgeMatches(stripInlineCodeSpans(line))); + } + + return [...new Set(found)]; +} + +export function hasSteHedgeViolation(body: string): boolean { + return findHedgesOutsideAllowedRegions(body).length > 0; +} + +export { HEDGE_PHRASES, HEDGE_WORDS }; diff --git a/packages/cli/src/mcp/lint-bucket.ts b/packages/cli/src/mcp/lint-bucket.ts index 0ff13c7d..32263ede 100644 --- a/packages/cli/src/mcp/lint-bucket.ts +++ b/packages/cli/src/mcp/lint-bucket.ts @@ -10,7 +10,8 @@ const WARNING_KINDS = new Set([ "index_link_format", "stale_page", "page_too_large", "log_rotate_needed", "orphans", "compound_refs", "legacy_citation_style", "orphaned_citations", "duplicate_frontmatter", "work_item_health", "orphaned_project_pages", "missing_overview", "missing_diagram", - "cycle_traps", + "cycle_traps", "ste_sentence_length", "ste_hedge_words", "ste_diagram_uncertainty", + "ste_comparison_visual", ]); function bucketSeverity(kind: string): LintSeverity { diff --git a/packages/cli/src/utils/cli-surface.ts b/packages/cli/src/utils/cli-surface.ts index 89a01ea9..115e8083 100644 --- a/packages/cli/src/utils/cli-surface.ts +++ b/packages/cli/src/utils/cli-surface.ts @@ -105,7 +105,7 @@ export function buildCliSurface(): Map> { program.command("observe").requiredOption("--text ").option("--kind ").option("--project ").option("--severity ").option("--capture-budget ").option("--wiki "); program.command("session-brief").option("--project ").option("--write").option("--agent-memory-run-state ").option("--wiki "); program.command("memory"); // has subcommands - program.command("ingest").requiredOption("--vault ").requiredOption("--type ").requiredOption("--title ").option("--tags <csv>").option("--provenance <provenance>").option("--dry-run"); + program.command("ingest").requiredOption("--vault <path>").requiredOption("--type <type>").requiredOption("--title <title>").option("--tags <csv>").option("--provenance <provenance>").option("--html").option("--dry-run"); program.command("fleet"); // has subcommands program.command("page"); // has subcommands program.command("project-page"); // has subcommands @@ -226,6 +226,9 @@ export function buildCliSurface(): Map<string, Set<string>> { .option("--write") .option("--approve <token>") .option("--wiki <name>"); + pageCmd.command("render-html") + .requiredOption("--out <file>") + .option("--wiki <name>"); const projectPageCmd = program.commands.find(c => c.name() === "project-page")!; projectPageCmd.command("publish") diff --git a/packages/cli/test/commands/cli-surface-drift.test.ts b/packages/cli/test/commands/cli-surface-drift.test.ts index 35337672..9637324f 100644 --- a/packages/cli/test/commands/cli-surface-drift.test.ts +++ b/packages/cli/test/commands/cli-surface-drift.test.ts @@ -50,7 +50,7 @@ describe("cli-surface drift detection", () => { { parent: "clip-outbox", subs: ["status", "watch", "install-or-migrate"] }, { parent: "config", subs: ["get", "set", "list", "path"] }, { parent: "compound", subs: ["promote", "list", "delete"] }, - { parent: "page", subs: ["publish"] }, + { parent: "page", subs: ["publish", "render-html"] }, { parent: "project-page", subs: ["publish"] }, { parent: "claims", subs: ["audit"] }, { parent: "tag", subs: ["reconcile"] }, diff --git a/packages/cli/test/commands/ingest.test.ts b/packages/cli/test/commands/ingest.test.ts index ab9a3e58..f567999a 100644 --- a/packages/cli/test/commands/ingest.test.ts +++ b/packages/cli/test/commands/ingest.test.ts @@ -150,6 +150,28 @@ describe("ingest", () => { expect(typedContent).toContain("## Sources"); expect(typedContent).toContain("^[raw/articles/test-concept.md]"); expect(typedContent).toContain("- raw/articles/test-concept.md"); + expect(typedContent).toContain("output_standard: v2"); + expect(typedContent).not.toContain("html_render:"); + }); + + it("stamps html_render true when html is requested", async () => { + const v = vault(); + const srcDir = mkdtempSync(join(tmpdir(), "sw-src-")); + const srcFile = join(srcDir, "source.txt"); + writeFileSync(srcFile, "HTML opt-in source content for ingest."); + + const r = await runIngest({ + source: srcFile, + vault: v, + type: "comparison", + title: "Html Opt In", + html: true, + }); + + expect(r.exitCode).toBe(0); + const typedContent = readFileSync(join(v, "comparisons/html-opt-in.md"), "utf8"); + expect(typedContent).toContain("output_standard: v2"); + expect(typedContent).toContain("html_render: true"); }); it("ingests with --dry-run and creates no files", async () => { diff --git a/packages/cli/test/commands/page-render-html.test.ts b/packages/cli/test/commands/page-render-html.test.ts new file mode 100644 index 00000000..06feff76 --- /dev/null +++ b/packages/cli/test/commands/page-render-html.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ExitCode } from "@skillwiki/shared"; +import { runPageRenderHtml, renderPageHtml } from "../../src/commands/page-render-html.js"; + +const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "output-standard-v2"); + +const SCHEMA = `# Vault Schema + +## Tag Taxonomy + +\`\`\`yaml +taxonomy: + - documentation +\`\`\` +`; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function vault(): string { + const v = mkdtempSync(join(tmpdir(), "vault-html-")); + writeFileSync(join(v, "SCHEMA.md"), SCHEMA); + writeFileSync(join(v, "index.md"), "# Index\n"); + writeFileSync(join(v, "log.md"), "# Vault Log\n"); + for (const d of ["entities", "concepts", "comparisons", "queries", "raw/articles"]) { + mkdirSync(join(v, d), { recursive: true }); + } + return v; +} + +function page(opts: { html?: boolean; body?: string }): string { + return [ + "---", + "title: Render Target", + `created: ${today()}`, + `updated: ${today()}`, + "type: comparison", + "tags: [documentation]", + "sources: [raw/articles/x.md]", + "provenance: research", + "output_standard: v2", + opts.html ? "html_render: true" : "", + "---", + "", + opts.body ?? "# Render Target\n\n## Overview\n\nHello world.\n\n## Details\n\nMore text.\n", + ].filter((line) => line !== "").join("\n"); +} + +describe("page render-html", () => { + it("refuses pages that did not opt in", async () => { + const v = vault(); + writeFileSync(join(v, "comparisons/plain.md"), page({ html: false })); + const out = join(tmpdir(), `sw-html-${Date.now()}.html`); + const res = await runPageRenderHtml({ vault: v, page: "comparisons/plain.md", out }); + expect(res.exitCode).toBe(ExitCode.HTML_RENDER_NOT_OPTED_IN); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_NOT_OPTED_IN"); + expect(existsSync(out)).toBe(false); + }); + + it("refuses an --out path inside the vault", async () => { + const v = vault(); + writeFileSync(join(v, "comparisons/ok.md"), page({ html: true })); + const res = await runPageRenderHtml({ + vault: v, + page: "comparisons/ok.md", + out: join(v, "rendered.html"), + }); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_OUT_INSIDE_VAULT"); + expect(existsSync(join(v, "rendered.html"))).toBe(false); + }); + + it("refuses --out via a symlinked alias of the vault before any write", async () => { + const root = mkdtempSync(join(tmpdir(), "vault-html-alias-")); + const v = join(root, "vault"); + mkdirSync(v); + writeFileSync(join(v, "SCHEMA.md"), SCHEMA); + writeFileSync(join(v, "index.md"), "# Index\n"); + writeFileSync(join(v, "log.md"), "# Vault Log\n"); + mkdirSync(join(v, "comparisons"), { recursive: true }); + writeFileSync(join(v, "comparisons/ok.md"), page({ html: true })); + const alias = join(root, "alias"); + symlinkSync(v, alias); + + const res = await runPageRenderHtml({ + vault: v, + page: "comparisons/ok.md", + out: join(alias, "written-inside-vault.html"), + }); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_OUT_INSIDE_VAULT"); + expect(existsSync(join(v, "written-inside-vault.html"))).toBe(false); + expect(existsSync(join(alias, "written-inside-vault.html"))).toBe(false); + }); + + it("refuses --out in the real vault when the vault argument is a symlink alias", async () => { + const root = mkdtempSync(join(tmpdir(), "vault-html-valias-")); + const v = join(root, "vault"); + mkdirSync(v); + writeFileSync(join(v, "SCHEMA.md"), SCHEMA); + writeFileSync(join(v, "index.md"), "# Index\n"); + writeFileSync(join(v, "log.md"), "# Vault Log\n"); + mkdirSync(join(v, "comparisons"), { recursive: true }); + writeFileSync(join(v, "comparisons/ok.md"), page({ html: true })); + const alias = join(root, "alias"); + symlinkSync(v, alias); + + const res = await runPageRenderHtml({ + vault: alias, + page: "comparisons/ok.md", + out: join(v, "written-via-vault-alias.html"), + }); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_OUT_INSIDE_VAULT"); + expect(existsSync(join(v, "written-via-vault-alias.html"))).toBe(false); + }); + + it("refuses a relative --out path that resolves inside the vault from cwd", async () => { + const v = vault(); + writeFileSync(join(v, "comparisons/ok.md"), page({ html: true })); + const outAbs = join(v, "via-relative.html"); + const outRel = relative(process.cwd(), outAbs); + expect(outRel.length).toBeGreaterThan(0); + const res = await runPageRenderHtml({ + vault: v, + page: "comparisons/ok.md", + out: outRel, + }); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_OUT_INSIDE_VAULT"); + expect(existsSync(outAbs)).toBe(false); + }); + + it("refuses a nonexistent nested --out suffix under a vault alias before mkdir", async () => { + const root = mkdtempSync(join(tmpdir(), "vault-html-nested-")); + const v = join(root, "vault"); + mkdirSync(v); + writeFileSync(join(v, "SCHEMA.md"), SCHEMA); + writeFileSync(join(v, "index.md"), "# Index\n"); + writeFileSync(join(v, "log.md"), "# Vault Log\n"); + mkdirSync(join(v, "comparisons"), { recursive: true }); + writeFileSync(join(v, "comparisons/ok.md"), page({ html: true })); + const alias = join(root, "alias"); + symlinkSync(v, alias); + + const res = await runPageRenderHtml({ + vault: v, + page: "comparisons/ok.md", + out: join(alias, "nested", "deeper", "file.html"), + }); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_OUT_INSIDE_VAULT"); + expect(existsSync(join(v, "nested"))).toBe(false); + expect(existsSync(join(alias, "nested"))).toBe(false); + }); + + it("refuses --out when the path itself is an existing symlink", async () => { + const v = vault(); + writeFileSync(join(v, "comparisons/ok.md"), page({ html: true })); + const destDir = mkdtempSync(join(tmpdir(), "vault-html-outlink-")); + const target = join(destDir, "target.html"); + const linked = join(destDir, "linked.html"); + writeFileSync(target, "old\n"); + symlinkSync(target, linked); + const res = await runPageRenderHtml({ + vault: v, + page: "comparisons/ok.md", + out: linked, + }); + expect(res.result.ok).toBe(false); + if (!res.result.ok) expect(res.result.error).toBe("HTML_RENDER_OUT_INSIDE_VAULT"); + expect(readFileSync(target, "utf8")).toBe("old\n"); + }); + + it("renders the comparison sample to a self-contained file", async () => { + const v = vault(); + const src = readFileSync(join(FIXTURE_DIR, "comparison-sample.md"), "utf8") + .replace(/^created: .+$/m, `created: ${today()}`) + .replace(/^updated: .+$/m, `updated: ${today()}`); + writeFileSync(join(v, "comparisons/comparison-sample.md"), src); + const out = join(mkdtempSync(join(tmpdir(), "sw-html-out-")), "comparison.html"); + const res = await runPageRenderHtml({ + vault: v, + page: "comparisons/comparison-sample.md", + out, + }); + expect(res.exitCode).toBe(ExitCode.OK); + expect(res.result.ok).toBe(true); + const html = readFileSync(out, "utf8"); + expect(html).not.toMatch(/<script\s+src/i); + expect(html).not.toMatch(/<link\s+rel=["']stylesheet["']/i); + expect(html).not.toMatch(/<(script|link|img)\b[^>]+\b(?:src|href)=["']https?:\/\//i); + expect(html).toContain('class="toc"'); + expect(html).toContain('role="tablist"'); + expect(html).toContain("<details"); + expect(html).not.toContain("cdn.jsdelivr"); + expect(html).not.toContain("mermaid.min.js"); + }); + + it("keeps mermaid as readable source and inlines CSS/JS only", () => { + const html = renderPageHtml("Demo", [ + "# Demo", + "", + "## Overview", + "", + "A short paragraph with **bold**, *italic*, `code`, [[Wiki Page]], and a [link](https://example.com).", + "", + "```mermaid", + "flowchart TD", + " A --> B", + "```", + "", + "## Details", + "", + "- one", + "- two", + ].join("\n")); + expect(html).toContain('<pre class="mermaid">'); + expect(html).toContain("flowchart TD"); + expect(html).toContain("Wiki Page"); + expect(html).not.toContain("[[Wiki Page]]"); + expect(html).toContain("<strong>bold</strong>"); + expect(html).not.toMatch(/<script\s+src/i); + expect(html).not.toMatch(/<link\s+rel=["']stylesheet["']/i); + expect(html).toContain("<style>"); + expect(html).toContain("<script>"); + expect(html).toContain('id="toc"'); + expect(html).toContain("<details"); + }); + + it("drops links with unsafe schemes and keeps their label", () => { + const html = renderPageHtml("Demo", [ + "# Demo", + "", + "## Overview", + "", + "Click [here](javascript:alert(1)) or [docs](https://example.com).", + ].join("\n")); + expect(html).not.toContain("javascript:"); + expect(html).toContain("here"); + expect(html).toContain('<a href="https://example.com">docs</a>'); + }); + + it("wires TOC and hash navigation through the same activate function as tab clicks", () => { + const html = renderPageHtml("Demo", [ + "# Demo", + "", + "## Overview", + "", + "First panel.", + "", + "## Comparison", + "", + "Second panel.", + ].join("\n")); + expect(html).toContain("function activate"); + expect(html).toContain("nav.toc a[href^='#']"); + expect(html).toContain("hashchange"); + expect(html).toContain("location.hash"); + expect(html).toContain("fromHash"); + expect(html).toContain('id="comparison"'); + expect(html).toMatch(/<section id="comparison"[^>]* hidden/); + + const script = html.match(/<script>\s*([\s\S]*?)\s*<\/script>/)?.[1]; + expect(script).toBeTruthy(); + + type El = { + id: string; + hidden: boolean; + attrs: Record<string, string>; + listeners: Record<string, Array<() => void>>; + getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; + addEventListener(type: string, fn: () => void): void; + }; + const makeEl = (init: Record<string, string | boolean>): El => { + const el: El = { + id: String(init.id ?? ""), + hidden: Boolean(init.hidden), + attrs: Object.fromEntries( + Object.entries(init).filter(([, value]) => typeof value === "string") as Array<[string, string]>, + ), + listeners: {}, + getAttribute(name) { + return el.attrs[name] ?? null; + }, + setAttribute(name, value) { + el.attrs[name] = value; + }, + addEventListener(type, fn) { + el.listeners[type] = el.listeners[type] ?? []; + el.listeners[type]!.push(fn); + }, + }; + if (typeof init.hidden === "boolean") el.hidden = init.hidden; + return el; + }; + const run = (initialHash: string) => { + const tabs = [ + makeEl({ "data-tab": "overview", "aria-selected": "true" }), + makeEl({ "data-tab": "comparison", "aria-selected": "false" }), + ]; + const panels = [ + makeEl({ id: "overview", hidden: false }), + makeEl({ id: "comparison", hidden: true }), + ]; + const toc = [ + makeEl({ href: "#overview" }), + makeEl({ href: "#comparison" }), + ]; + let hash = initialHash; + const windowListeners: Record<string, Array<() => void>> = {}; + const document = { + querySelectorAll(sel: string) { + if (sel === '[role="tab"]') return tabs; + if (sel === ".tab-panel") return panels; + if (sel === "nav.toc a[href^='#']") return toc; + return []; + }, + }; + const windowObj = { + addEventListener(type: string, fn: () => void) { + windowListeners[type] = windowListeners[type] ?? []; + windowListeners[type]!.push(fn); + }, + }; + const location = { get hash() { return hash; } }; + new Function("document", "window", "location", script!)(document, windowObj, location); + return { + tabs, + panels, + toc, + setHash(next: string) { + hash = next; + for (const fn of windowListeners.hashchange ?? []) fn(); + }, + }; + }; + + const initial = run("#comparison"); + expect(initial.panels[1]!.hidden).toBe(false); + expect(initial.panels[0]!.hidden).toBe(true); + expect(initial.tabs[1]!.getAttribute("aria-selected")).toBe("true"); + expect(initial.tabs[0]!.getAttribute("aria-selected")).toBe("false"); + + const clicked = run(""); + clicked.toc[1]!.listeners.click![0]!(); + expect(clicked.panels[1]!.hidden).toBe(false); + expect(clicked.panels[0]!.hidden).toBe(true); + expect(clicked.tabs[1]!.getAttribute("aria-selected")).toBe("true"); + clicked.setHash("#overview"); + expect(clicked.panels[0]!.hidden).toBe(false); + expect(clicked.tabs[0]!.getAttribute("aria-selected")).toBe("true"); + clicked.tabs[1]!.listeners.click![0]!(); + expect(clicked.panels[1]!.hidden).toBe(false); + expect(clicked.tabs[1]!.getAttribute("aria-selected")).toBe("true"); + }); +}); diff --git a/packages/cli/test/fixtures/output-standard-v2/comparison-sample.md b/packages/cli/test/fixtures/output-standard-v2/comparison-sample.md new file mode 100644 index 00000000..6ceb4093 --- /dev/null +++ b/packages/cli/test/fixtures/output-standard-v2/comparison-sample.md @@ -0,0 +1,41 @@ +--- +title: Output Standard Comparison Sample +created: 2026-10-16 +updated: 2026-10-16 +type: comparison +tags: + - documentation +sources: + - raw/articles/output-standard-v2-comparison.md +confidence: high +provenance: research +output_standard: v2 +html_render: true +--- + +# Output Standard Comparison Sample + +> **TL;DR:** Comparison pages use a table by default. HTML render is opt-in. + +## Overview + +A comparison page lines up two or more options. + +Use a Markdown table when HTML is off. + +Set html_render to true only for hands-on exploration. + +## Comparison + +| Content type | Format | Rung | Policy | +| --- | --- | --- | --- | +| Exact procedure | STE text | 1 | default | +| Relationships | Mermaid plus text | 2 | default | +| Comparison | HTML page | 3 | opt-in | +| Concept from zero | Video | 4 | out of scope | + +Formats stack. HTML is always opt-in. Video is outside the current scope. + +## Sources + +^[raw/articles/output-standard-v2-comparison.md] diff --git a/packages/cli/test/fixtures/output-standard-v2/concept-sample.md b/packages/cli/test/fixtures/output-standard-v2/concept-sample.md new file mode 100644 index 00000000..5d2adf18 --- /dev/null +++ b/packages/cli/test/fixtures/output-standard-v2/concept-sample.md @@ -0,0 +1,44 @@ +--- +title: Output Standard Concept Sample +created: 2026-10-16 +updated: 2026-10-16 +type: concept +tags: + - documentation +sources: + - raw/articles/output-standard-v2-concept.md +confidence: high +provenance: research +output_standard: v2 +--- + +# Output Standard Concept Sample + +> **TL;DR:** v2 pages use STE text and add a Mermaid diagram for structure. + +## Overview + +v2 pages use short active sentences. + +Use at most 20 words for each instruction. Use at most 25 words for each description. + +## Structure + +Add one Mermaid diagram when the page shows flow or hierarchy. + +Keep a text summary beside the diagram. + +```mermaid +flowchart TD + stamp[Stamp v2] --> write[Write STE text] + write --> diagram[Add diagram if structure] +``` + +**Least certain:** +- `stamp --> write` edge: this arrow is an interpretive grouping of stamp and write as one sequence +- `Write STE text` label: this node is a simplified step for the whole STE pass +- `write --> diagram` edge: this arrow omits the case where the page has no structure + +## Sources + +^[raw/articles/output-standard-v2-concept.md] diff --git a/packages/cli/test/fixtures/output-standard-v2/concepts-ingest-output-standard-v2.md b/packages/cli/test/fixtures/output-standard-v2/concepts-ingest-output-standard-v2.md new file mode 100644 index 00000000..a5b32a2a --- /dev/null +++ b/packages/cli/test/fixtures/output-standard-v2/concepts-ingest-output-standard-v2.md @@ -0,0 +1,69 @@ +--- +title: Ingest output standard v2 +created: 2026-10-16 +updated: 2026-10-16 +type: concept +tags: + - documentation +sources: + - raw/articles/ingest-output-standard-v2.md +confidence: high +provenance: project +provenance_projects: + - "[[llm-wiki]]" +output_standard: v2 +--- + +# Ingest output standard v2 + +> **TL;DR:** New ingest stubs stamp output_standard v2. Agents write Simplified Technical English (STE) text. CLI lint reports advisory warnings on marked pages only. + +## Overview + +Ingest output standard v2 is an authoring contract for typed SkillWiki pages. + +`skillwiki ingest` stamps `output_standard: v2` on each new typed stub. + +The CLI checks marked pages. It does not rewrite prose. It does not block publish. + +## Rungs + +Use STE text for an exact procedure that a reader will re-read later. + +Add one Mermaid diagram when the page shows flow, components, timeline, hierarchy, or comparison. + +Set `html_render: true` with `skillwiki ingest --html` or by hand. + +Explainer video is out of scope. Use STE text plus a diagram. + +```mermaid +flowchart TD + procedure[Exact procedure] --> ste[STE text] + relations[Relationships] --> diagram[Mermaid plus text] + comparison[Comparison] --> html[HTML opt-in] + concept[Concept from zero] --> video[Video out of scope] +``` + +**Least certain:** +- `comparison --> html` edge: this arrow is a mapping the source does not state as a required path +- `concept --> video` edge: this node is an interpretive grouping of the out-of-scope video rung +- `relations --> diagram` edge: this branch is a simplified split of table and diagram formats + +## Format table + +| Content type | Format | Rung | Policy | +| --- | --- | --- | --- | +| Exact procedure (re-read later) | STE text | 1 | default | +| Relationships (structure / flow / hierarchy) | Mermaid diagram + text | 2 | default | +| Comparison / hands-on exploration | HTML interactive page | 3 | opt-in; otherwise STE + table or diagram | +| Concept from zero | Explainer video | 4 | out of scope; use STE + diagram | + +Formats stack. HTML is always opt-in. Video is outside the current scope. + +## Uncertainty + +The source reports no open uncertainty. + +## Sources + +^[raw/articles/ingest-output-standard-v2.md] diff --git a/packages/cli/test/fixtures/output-standard-v2/entity-sample.md b/packages/cli/test/fixtures/output-standard-v2/entity-sample.md new file mode 100644 index 00000000..68a7b171 --- /dev/null +++ b/packages/cli/test/fixtures/output-standard-v2/entity-sample.md @@ -0,0 +1,33 @@ +--- +title: Output Standard Entity Sample +created: 2026-10-16 +updated: 2026-10-16 +type: entity +tags: + - documentation +sources: + - raw/articles/output-standard-v2-entity.md +confidence: high +provenance: research +output_standard: v2 +--- + +# Output Standard Entity Sample + +> **TL;DR:** This entity names the ingest output standard v2 marker. + +## Overview + +This entity names the ingest output standard v2 marker. + +The marker is the frontmatter key output_standard with value v2. + +## Details + +Use the marker on new typed pages from skillwiki ingest. + +The CLI stamps the marker on every new ingest stub. + +## Sources + +^[raw/articles/output-standard-v2-entity.md] diff --git a/packages/cli/test/lint/output-standard-v2.test.ts b/packages/cli/test/lint/output-standard-v2.test.ts new file mode 100644 index 00000000..ba49896d --- /dev/null +++ b/packages/cli/test/lint/output-standard-v2.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ExitCode } from "@skillwiki/shared"; +import { runLint } from "../../src/lint/runner.js"; +import { LINT_RULES } from "../../src/lint/rules.js"; + +const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "output-standard-v2"); + +const SCHEMA = `# Vault Schema + +## Tag Taxonomy + +\`\`\`yaml +taxonomy: + - documentation + - model +\`\`\` +`; + +const V2_BUCKETS = [ + "ste_sentence_length", + "ste_hedge_words", + "ste_diagram_uncertainty", + "ste_comparison_visual", +] as const; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function stampDates(text: string): string { + const d = today(); + return text.replace(/^created: .+$/m, `created: ${d}`).replace(/^updated: .+$/m, `updated: ${d}`); +} + +function fm(opts: { + title: string; + type?: string; + v2?: boolean; + html?: boolean; + extra?: string; +}): string { + const lines = [ + "---", + `title: ${opts.title}`, + `created: ${today()}`, + `updated: ${today()}`, + `type: ${opts.type ?? "concept"}`, + "tags: [documentation]", + "sources: [raw/articles/x.md]", + "provenance: research", + ]; + if (opts.v2) lines.push("output_standard: v2"); + if (opts.html) lines.push("html_render: true"); + if (opts.extra) lines.push(opts.extra); + lines.push("---", ""); + return lines.join("\n"); +} + +function vault(): string { + const v = mkdtempSync(join(tmpdir(), "vault-v2-")); + writeFileSync(join(v, "SCHEMA.md"), SCHEMA); + writeFileSync(join(v, "index.md"), "# Index\n"); + writeFileSync(join(v, "log.md"), "# Vault Log\n"); + for (const d of ["entities", "concepts", "comparisons", "queries", "raw/articles"]) { + mkdirSync(join(v, d), { recursive: true }); + } + writeFileSync(join(v, "raw/articles/x.md"), "source\n"); + return v; +} + +function v2Findings(result: { by_severity: { warning: Array<{ kind: string; items: unknown[] }> } }): Record<string, unknown[]> { + const out: Record<string, unknown[]> = {}; + for (const bucket of result.by_severity.warning) { + if ((V2_BUCKETS as readonly string[]).includes(bucket.kind)) out[bucket.kind] = bucket.items; + } + return out; +} + +async function lintVault(v: string, only?: string) { + return runLint({ + vault: v, + days: 90, + lines: 200, + logThreshold: 500, + only, + }); +} + +describe("output standard v2 lint", () => { + it("registers the four v2 warning rules", () => { + const ids = LINT_RULES.map((rule) => rule.id); + for (const id of V2_BUCKETS) expect(ids).toContain(id); + }); + + it("yields zero v2 findings on unmarked pages", async () => { + const v = vault(); + const long = Array.from({ length: 26 }, (_, i) => `w${i + 1}`).join(" ") + "."; + writeFileSync(join(v, "concepts/unmarked.md"), fm({ title: "Unmarked" }) + `## Overview\n\n${long}\nThis might break.\n\n\`\`\`mermaid\nflowchart TD\n A --> B\n\`\`\`\n`); + writeFileSync(join(v, "comparisons/plain.md"), fm({ title: "Plain", type: "comparison" }) + "## Overview\n\nNo table.\n"); + + const res = await lintVault(v); + expect(res.result.ok).toBe(true); + if (!res.result.ok) return; + expect(v2Findings(res.result.data)).toEqual({}); + }); + + it("fires each v2 bucket on a bad marked fixture", async () => { + const v = vault(); + const long = Array.from({ length: 26 }, (_, i) => `w${i + 1}`).join(" ") + "."; + writeFileSync(join(v, "concepts/long.md"), fm({ title: "Long", v2: true }) + `## Overview\n\n${long}\n`); + writeFileSync(join(v, "concepts/hedge.md"), fm({ title: "Hedge", v2: true }) + "## Overview\n\nThis might break later.\n"); + writeFileSync(join(v, "concepts/diagram.md"), fm({ title: "Diagram", v2: true }) + "## Overview\n\n```mermaid\nflowchart TD\n A --> B\n```\n"); + writeFileSync(join(v, "comparisons/novisual.md"), fm({ title: "No Visual", type: "comparison", v2: true }) + "## Overview\n\nNo table.\n"); + + const res = await lintVault(v); + expect(res.result.ok).toBe(true); + if (!res.result.ok) return; + const findings = v2Findings(res.result.data); + expect(findings.ste_sentence_length?.some((item) => String(item).includes("concepts/long.md"))).toBe(true); + expect(findings.ste_hedge_words?.some((item) => String(item).includes("concepts/hedge.md"))).toBe(true); + expect(findings.ste_diagram_uncertainty?.some((item) => String(item).includes("concepts/diagram.md"))).toBe(true); + expect(findings.ste_comparison_visual?.some((item) => String(item).includes("comparisons/novisual.md"))).toBe(true); + }); + + it("does not change the lint exit code when v2 findings are present", async () => { + const long = Array.from({ length: 26 }, (_, i) => `w${i + 1}`).join(" ") + "."; + const unmarked = vault(); + writeFileSync(join(unmarked, "concepts/page.md"), fm({ title: "Page" }) + `## Overview\n\n${long}\n`); + const marked = vault(); + writeFileSync(join(marked, "concepts/page.md"), fm({ title: "Page", v2: true }) + `## Overview\n\n${long}\n`); + + const unmarkedRes = await lintVault(unmarked); + const markedRes = await lintVault(marked); + expect(markedRes.exitCode).toBe(unmarkedRes.exitCode); + + const onlyMarked = await lintVault(marked, "ste_sentence_length"); + expect(onlyMarked.result.ok).toBe(true); + if (!onlyMarked.result.ok) return; + expect(onlyMarked.result.data.by_severity.warning[0]?.items.length).toBeGreaterThan(0); + expect(onlyMarked.exitCode).toBe(ExitCode.OK); + }); + + it("yields zero v2 findings on the four sample fixtures", async () => { + const v = vault(); + const samples = [ + ["entities/entity-sample.md", "entity-sample.md"], + ["concepts/concept-sample.md", "concept-sample.md"], + ["comparisons/comparison-sample.md", "comparison-sample.md"], + ] as const; + for (const [dest, file] of samples) { + writeFileSync(join(v, dest), stampDates(readFileSync(join(FIXTURE_DIR, file), "utf8"))); + } + writeFileSync(join(v, "concepts/ingest-output-standard-v2.md"), stampDates(readFileSync(join(FIXTURE_DIR, "concepts-ingest-output-standard-v2.md"), "utf8"))); + writeFileSync(join(v, "raw/articles/output-standard-v2-entity.md"), "source\n"); + writeFileSync(join(v, "raw/articles/output-standard-v2-concept.md"), "source\n"); + writeFileSync(join(v, "raw/articles/output-standard-v2-comparison.md"), "source\n"); + writeFileSync(join(v, "raw/articles/ingest-output-standard-v2.md"), "source\n"); + + const res = await lintVault(v); + expect(res.result.ok).toBe(true); + if (!res.result.ok) return; + expect(v2Findings(res.result.data)).toEqual({}); + }); +}); diff --git a/packages/cli/test/lint/runner.test.ts b/packages/cli/test/lint/runner.test.ts index 07e66d94..9aea7560 100644 --- a/packages/cli/test/lint/runner.test.ts +++ b/packages/cli/test/lint/runner.test.ts @@ -38,6 +38,10 @@ describe("LintRunner rule enumeration and registration", () => { expect(rules.map((r) => r.id)).toContain("cli_refs"); expect(rules.map((r) => r.id)).toContain("file_source_url"); expect(rules.map((r) => r.id)).toContain("path_too_long"); + expect(rules.map((r) => r.id)).toContain("ste_sentence_length"); + expect(rules.map((r) => r.id)).toContain("ste_hedge_words"); + expect(rules.map((r) => r.id)).toContain("ste_diagram_uncertainty"); + expect(rules.map((r) => r.id)).toContain("ste_comparison_visual"); }); it("finds rule for primary bucket id and produced buckets", () => { diff --git a/packages/cli/test/lint/ste.test.ts b/packages/cli/test/lint/ste.test.ts new file mode 100644 index 00000000..5399cac2 --- /dev/null +++ b/packages/cli/test/lint/ste.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { + comparisonLacksVisual, + countWords, + findHedgesOutsideAllowedRegions, + findLongUnits, + hasMarkdownTable, + hasMermaidBlock, + hasSteHedgeViolation, + hasSteSentenceLengthViolation, + isOutputStandardV2, + mermaidBlocksMissingLeastCertain, + splitSentences, + stripUrlsAndCitations, +} from "../../src/lint/ste.js"; + +describe("STE helpers", () => { + it("splits sentences on terminal punctuation", () => { + expect(splitSentences("Short one. Short two!")).toEqual(["Short one.", "Short two!"]); + }); + + it("counts words after stripping URLs and citation markers", () => { + const text = "See https://example.com/path for the note ^[raw/articles/x.md] today."; + expect(stripUrlsAndCitations(text)).not.toMatch(/https?:\/\//); + expect(stripUrlsAndCitations(text)).not.toContain("^[raw/articles/x.md]"); + expect(countWords(text)).toBe(5); + }); + + it("flags prose over 25 words and ordered items over 20 words", () => { + const longProse = Array.from({ length: 26 }, (_, i) => `w${i + 1}`).join(" ") + "."; + const longItem = Array.from({ length: 21 }, (_, i) => `s${i + 1}`).join(" ") + "."; + expect(countWords(longProse)).toBe(26); + const findings = findLongUnits(`${longProse}\n\n1. ${longItem}\n`); + expect(findings.some((f) => f.limit === 25)).toBe(true); + expect(findings.some((f) => f.limit === 20)).toBe(true); + }); + + it("flags unordered items under Steps headings with the 20-word cap", () => { + const item = Array.from({ length: 21 }, (_, i) => `s${i + 1}`).join(" ") + "."; + expect(countWords(item)).toBe(21); + expect(hasSteSentenceLengthViolation(`## Steps\n\n- ${item}\n`)).toBe(true); + expect(hasSteSentenceLengthViolation(`## Other\n\n- ${item}\n`)).toBe(false); + }); + + it("ignores frontmatter-equivalent regions: headings, fences, tables, quotes, URLs, citations", () => { + const long = Array.from({ length: 26 }, (_, i) => `w${i + 1}`).join(" ") + "."; + const body = [ + `## ${long}`, + "", + "```", + long, + "```", + "", + `| ${long} | x |`, + "| --- | --- |", + `| ${long} | y |`, + "", + `> ${long}`, + "", + `See ${"https://example.com/" + "word/".repeat(30)} now.`, + "", + `Done ^[raw/articles/${"long-path/".repeat(20)}x.md].`, + ].join("\n"); + expect(hasSteSentenceLengthViolation(body)).toBe(false); + }); + + it("scans hedges outside Uncertainty and Least certain lists", () => { + expect(findHedgesOutsideAllowedRegions("This might break.\n")).toEqual(["might"]); + expect(hasSteHedgeViolation("It appears to work.\n")).toBe(true); + expect(hasSteHedgeViolation("## Uncertainty\n\nThis might break.\n")).toBe(false); + expect(hasSteHedgeViolation("**Least certain:**\n- This might break.\n")).toBe(false); + expect(hasSteHedgeViolation("```\nmight\n```\n")).toBe(false); + expect(hasSteHedgeViolation("> This might break.\n")).toBe(false); + }); + + it("skips inline code, nested Uncertainty headings, and wrapped Least certain items", () => { + expect(findHedgesOutsideAllowedRegions("Use `may` as a variable.\n")).toEqual([]); + expect(findHedgesOutsideAllowedRegions("Use ``may`` as a variable.")).toEqual([]); + expect(findHedgesOutsideAllowedRegions("## Uncertainty\n### Limits\nThis may fail.\n")).toEqual([]); + expect(findHedgesOutsideAllowedRegions("**Least certain:**\n- Edge A\n may fail.\n")).toEqual([]); + expect(findHedgesOutsideAllowedRegions("**Least certain:**\n- Edge A\nmay fail.")).toEqual([]); + expect(findHedgesOutsideAllowedRegions("## Uncertainty\n### Limits\nThis may fail.\n## Sources\nThis may fail.\n")).toEqual(["may"]); + expect(findHedgesOutsideAllowedRegions("This may fail.\n")).toEqual(["may"]); + expect(findHedgesOutsideAllowedRegions("This \\`may\\` fail.")).toEqual(["may"]); + expect(findHedgesOutsideAllowedRegions("Use `may as a variable.")).toEqual(["may"]); + expect(findHedgesOutsideAllowedRegions("**Least certain:**\n- Edge A\n\nThis may fail.\n")).toEqual(["may"]); + }); + + it("requires a Least certain list after each mermaid block", () => { + expect(hasMermaidBlock("```mermaid\nflowchart TD\n A --> B\n```\n")).toBe(true); + expect(hasMermaidBlock("~~~mermaid\nflowchart TD\n A --> B\n~~~\n")).toBe(true); + expect(mermaidBlocksMissingLeastCertain("```mermaid\nflowchart TD\n A --> B\n```\n")).toBe(true); + expect(mermaidBlocksMissingLeastCertain( + "```mermaid\nflowchart TD\n A --> B\n```\n\n**Least certain:**\n- Edge A to B\n" + )).toBe(false); + expect(mermaidBlocksMissingLeastCertain("~~~mermaid\nflowchart TD\n A --> B\n~~~\n")).toBe(true); + expect(mermaidBlocksMissingLeastCertain( + "~~~mermaid\nflowchart TD\n A --> B\n~~~\n\n**Least certain:**\n- Edge A to B\n" + )).toBe(false); + }); + + it("detects comparison visuals", () => { + expect(hasMarkdownTable("| a | b |\n| --- | --- |\n| 1 | 2 |\n")).toBe(true); + expect(comparisonLacksVisual("No table and no diagram.\n")).toBe(true); + expect(comparisonLacksVisual("| a | b |\n| --- | --- |\n| 1 | 2 |\n")).toBe(false); + expect(comparisonLacksVisual("```mermaid\nflowchart TD\n A --> B\n```\n")).toBe(false); + }); + + it("recognizes the v2 marker", () => { + expect(isOutputStandardV2({ output_standard: "v2" })).toBe(true); + expect(isOutputStandardV2({})).toBe(false); + expect(isOutputStandardV2({ output_standard: "v1" })).toBe(false); + }); +}); diff --git a/packages/cli/test/utils/cli-surface.test.ts b/packages/cli/test/utils/cli-surface.test.ts index f795492a..e0035596 100644 --- a/packages/cli/test/utils/cli-surface.test.ts +++ b/packages/cli/test/utils/cli-surface.test.ts @@ -21,6 +21,7 @@ describe("buildCliSurface", () => { it("registers nested subcommands with dot keys", () => { expect(surface.has("graph.build")).toBe(true); expect(surface.has("page.publish")).toBe(true); + expect(surface.has("page.render-html")).toBe(true); expect(surface.has("tag.reconcile")).toBe(true); expect(surface.has("sync.status")).toBe(true); expect(surface.has("config.list")).toBe(true); @@ -55,6 +56,8 @@ describe("buildCliSurface", () => { expect(surface.get("page.publish")!.has("--log-note")).toBe(true); expect(surface.get("page.publish")!.has("--write")).toBe(true); expect(surface.get("page.publish")!.has("--wiki")).toBe(true); + expect(surface.get("page.render-html")!.has("--out")).toBe(true); + expect(surface.get("ingest")!.has("--html")).toBe(true); }); it("subcommand flag sets inherit parent + root flags", () => { diff --git a/packages/codex-skills/skills/wiki-ingest/SKILL.md b/packages/codex-skills/skills/wiki-ingest/SKILL.md index 31ec9ed8..9dde0c2d 100644 --- a/packages/codex-skills/skills/wiki-ingest/SKILL.md +++ b/packages/codex-skills/skills/wiki-ingest/SKILL.md @@ -42,6 +42,53 @@ Run `skillwiki lang` at the start. Generate page-body prose, narrative sections, 5. `skillwiki sources compile published <raw-path> [vault] --pages "<typed/a.md,typed/b.md>" --reason "<why>"` then `--write --approve <token>` 6. Leave the review `open` unless the human resolves it with `skillwiki sources review <raw-path> --status accepted|needs-fix|dismissed --reason "<why>"` Goal, satellite, and headless sessions may list pending / compile status / reviews but must not claim or publish compile turns. +## Output standard v2 +`skillwiki ingest` stamps `output_standard: v2` on every new typed stub. v2 is an authoring contract. CLI lint reports advisory warnings on marked pages only. Publish, health, and lint exit stay unchanged by these checks. + +### Rung 1 — STE-80 (default) +STE is Simplified Technical English. +- Use at most 20 words for each instruction. Use at most 25 words for each description. +- One topic per paragraph. Each paragraph <= 6 sentences. +- One word, one meaning. Prefer simple verbs: use, start, stop, find, check. +- Keep technical terms (API, cache, Docker). Define each term once at first use. +- No hedges except in `## Uncertainty`. Never drop facts to shorten. +- Hedge list: might, may, perhaps, possibly, probably, likely, seems, appears to, arguably, somewhat, could be. +- Use `## Uncertainty` only for uncertainty the source itself reports. If the source reports none, omit the section or write "The source reports no open uncertainty." + +### Rung 2 — diagrams (default when content has structure) +Add one Mermaid diagram when the page has flow, components, timeline, hierarchy, or comparison. Keep a text summary beside the diagram. Directly after the diagram, add `**Least certain:**` and a bullet list of labels or edges. + +The `**Least certain:**` bullets name diagram labels or edges. State why that part of the diagram is the least certain representation. Use one of these reasons: an interpretive grouping, a simplified step, an omitted condition, or a mapping the source does not state directly. + +Never add doubt about a fact the source states as settled. Never turn a decision, constraint, or policy into an open question. + +If a diagram has fewer than 3 labels and edges in total, list all of them. Otherwise list the 3 least-certain labels or edges. + +A diagram must not present a shortened executable procedure. For safety or recovery flows, keep all required steps or point the node to the full text procedure. + +### Rung 3 — HTML (opt-in) +Set `html_render: true` by hand or with `skillwiki ingest --html`. Render outside the vault: + +```bash +skillwiki page render-html <page> [vault] --out <file> +``` + +HTML is always opt-in. The renderer writes one self-contained file (TOC, tabs per H2, `<details>`, inline CSS and JS). Mermaid stays as readable source. No network fetch. + +### Rung 4 — video +Explainer video is out of scope. Use STE text plus a diagram. + +### Format table + +| Content type | Format | Rung | Policy | +| --- | --- | --- | --- | +| Exact procedure (re-read later) | STE text | 1 | default | +| Relationships (structure / flow / hierarchy) | Mermaid diagram + text | 2 | default | +| Comparison / hands-on exploration | HTML interactive page | 3 | opt-in; otherwise STE + table or diagram | +| Concept from zero | Explainer video | 4 | out of scope; use STE + diagram | + +Rules: formats stack; HTML is always opt-in; video is outside the current scope. + ## Provenance defaults - Default `provenance: research`. - If cwd is inside `projects/{slug}/`, set `provenance: project` and add `provenance_projects: ["[[slug]]"]`. diff --git a/packages/codex-skills/skills/wiki-lint/SKILL.md b/packages/codex-skills/skills/wiki-lint/SKILL.md index 4c612138..89198b9b 100644 --- a/packages/codex-skills/skills/wiki-lint/SKILL.md +++ b/packages/codex-skills/skills/wiki-lint/SKILL.md @@ -19,6 +19,13 @@ Standard four reads. 6. If `log_rotate_needed` is present and the user consents, run `skillwiki log-rotate <vault> --apply`. Otherwise leave alone. 7. **Post-migration verification**: If the user recently migrated **vault content** (e.g., moved entity/concept pages to another vault), re-run `skillwiki lint <vault> --summary` and verify that broken_wikilinks count decreased. Remaining broken links for migrated content indicate pages still referencing the moved files — these should be cleaned up (remove citations or migrate the referencing pages too). This step is content moves only — not HTTP MCP client/plugin upgrades (those use `skillwiki doctor --check-mcp`). 8. Append a `log.md` entry summarizing lint counts only when the user asked to record the maintenance result. Do not log routine `health` reports by default. +## Output standard v2 WARN buckets +These four buckets apply only to typed pages whose frontmatter has `output_standard: v2`. They are advisory WARN findings. They do not change the lint exit code, publish, health exit, or release CI on content. +- `ste_sentence_length`: prose sentences > 25 words; ordered-list items and list items under Steps/Procedure headings > 20 words. +- `ste_hedge_words`: whole-word hedges outside `## Uncertainty`, outside the `**Least certain:**` list, and outside code and blockquotes. +- `ste_diagram_uncertainty`: a Mermaid block with no `**Least certain:**` bullet list after it. +- `ste_comparison_visual`: a `type: comparison` page with neither a Markdown table nor a Mermaid block. +Drill with `skillwiki lint <vault> --only <bucket>`. ## Stop conditions None — lint reports all findings even on per-page errors. ## Forbidden diff --git a/packages/shared/src/exit-codes.test.ts b/packages/shared/src/exit-codes.test.ts index b065b994..7a18de40 100644 --- a/packages/shared/src/exit-codes.test.ts +++ b/packages/shared/src/exit-codes.test.ts @@ -56,6 +56,7 @@ describe("exit-codes", () => { expect(ExitCode.TARGET_DRIFT).toBe(58); expect(ExitCode.RECOVERY_EVIDENCE_MISSING).toBe(59); expect(ExitCode.SYNC_PARTIAL_DECLINE).toBe(60); + expect(ExitCode.HTML_RENDER_NOT_OPTED_IN).toBe(61); expect(ExitCode.SYNC_LOCK_HELD).toBe(48); expect(ExitCode.LOG_APPEND_LOCK_HELD).toBe(49); }); @@ -74,5 +75,6 @@ describe("exit-codes", () => { expect(exitCodeName(58)).toBe("TARGET_DRIFT"); expect(exitCodeName(59)).toBe("RECOVERY_EVIDENCE_MISSING"); expect(exitCodeName(60)).toBe("SYNC_PARTIAL_DECLINE"); + expect(exitCodeName(61)).toBe("HTML_RENDER_NOT_OPTED_IN"); }); }); diff --git a/packages/shared/src/exit-codes.ts b/packages/shared/src/exit-codes.ts index e7b9277b..f70d09d9 100644 --- a/packages/shared/src/exit-codes.ts +++ b/packages/shared/src/exit-codes.ts @@ -60,6 +60,7 @@ export const ExitCode = { TARGET_DRIFT: 58, RECOVERY_EVIDENCE_MISSING: 59, SYNC_PARTIAL_DECLINE: 60, + HTML_RENDER_NOT_OPTED_IN: 61, } as const; export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; @@ -126,6 +127,7 @@ const NAMES: Record<number, string> = { 58: "TARGET_DRIFT", 59: "RECOVERY_EVIDENCE_MISSING", 60: "SYNC_PARTIAL_DECLINE", + 61: "HTML_RENDER_NOT_OPTED_IN", }; export function exitCodeName(code: number): string { diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 19a5b863..4ffa8d0f 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -23,7 +23,9 @@ export const TypedKnowledgeSchema = z.object({ provenance: z.enum(["research", "project", "mixed"]).optional(), provenance_projects: z.array(wikilink).optional(), work_items: z.array(wikilink).optional(), - stale_ttl: z.number().int().positive().optional() + stale_ttl: z.number().int().positive().optional(), + output_standard: z.literal("v2").optional(), + html_render: z.boolean().optional() }).superRefine((v, ctx) => { if (v.provenance && v.provenance !== "research" && (!v.provenance_projects || v.provenance_projects.length === 0)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["provenance_projects"], message: "required when provenance != research" }); diff --git a/packages/shared/src/schemas.typed-knowledge.test.ts b/packages/shared/src/schemas.typed-knowledge.test.ts index a0b045c8..2fc19cff 100644 --- a/packages/shared/src/schemas.typed-knowledge.test.ts +++ b/packages/shared/src/schemas.typed-knowledge.test.ts @@ -61,4 +61,29 @@ describe("TypedKnowledgeSchema", () => { expect(TypedKnowledgeSchema.parse(valid).stale_ttl).toBeUndefined(); }); }); + + describe("output_standard and html_render", () => { + it("accepts output_standard v2", () => { + expect(TypedKnowledgeSchema.parse({ ...valid, output_standard: "v2" }).output_standard).toBe("v2"); + }); + + it("rejects other output_standard values", () => { + expect(() => TypedKnowledgeSchema.parse({ ...valid, output_standard: "v1" })).toThrow(); + }); + + it("accepts html_render boolean", () => { + expect(TypedKnowledgeSchema.parse({ ...valid, html_render: true }).html_render).toBe(true); + expect(TypedKnowledgeSchema.parse({ ...valid, html_render: false }).html_render).toBe(false); + }); + + it("rejects html_render string", () => { + expect(() => TypedKnowledgeSchema.parse({ ...valid, html_render: "true" as unknown as boolean })).toThrow(); + }); + + it("omits both keys when absent", () => { + const parsed = TypedKnowledgeSchema.parse(valid); + expect(parsed.output_standard).toBeUndefined(); + expect(parsed.html_render).toBeUndefined(); + }); + }); }); diff --git a/packages/skills/skills/wiki-ingest/SKILL.md b/packages/skills/skills/wiki-ingest/SKILL.md index 31ec9ed8..9dde0c2d 100644 --- a/packages/skills/skills/wiki-ingest/SKILL.md +++ b/packages/skills/skills/wiki-ingest/SKILL.md @@ -42,6 +42,53 @@ Run `skillwiki lang` at the start. Generate page-body prose, narrative sections, 5. `skillwiki sources compile published <raw-path> [vault] --pages "<typed/a.md,typed/b.md>" --reason "<why>"` then `--write --approve <token>` 6. Leave the review `open` unless the human resolves it with `skillwiki sources review <raw-path> --status accepted|needs-fix|dismissed --reason "<why>"` Goal, satellite, and headless sessions may list pending / compile status / reviews but must not claim or publish compile turns. +## Output standard v2 +`skillwiki ingest` stamps `output_standard: v2` on every new typed stub. v2 is an authoring contract. CLI lint reports advisory warnings on marked pages only. Publish, health, and lint exit stay unchanged by these checks. + +### Rung 1 — STE-80 (default) +STE is Simplified Technical English. +- Use at most 20 words for each instruction. Use at most 25 words for each description. +- One topic per paragraph. Each paragraph <= 6 sentences. +- One word, one meaning. Prefer simple verbs: use, start, stop, find, check. +- Keep technical terms (API, cache, Docker). Define each term once at first use. +- No hedges except in `## Uncertainty`. Never drop facts to shorten. +- Hedge list: might, may, perhaps, possibly, probably, likely, seems, appears to, arguably, somewhat, could be. +- Use `## Uncertainty` only for uncertainty the source itself reports. If the source reports none, omit the section or write "The source reports no open uncertainty." + +### Rung 2 — diagrams (default when content has structure) +Add one Mermaid diagram when the page has flow, components, timeline, hierarchy, or comparison. Keep a text summary beside the diagram. Directly after the diagram, add `**Least certain:**` and a bullet list of labels or edges. + +The `**Least certain:**` bullets name diagram labels or edges. State why that part of the diagram is the least certain representation. Use one of these reasons: an interpretive grouping, a simplified step, an omitted condition, or a mapping the source does not state directly. + +Never add doubt about a fact the source states as settled. Never turn a decision, constraint, or policy into an open question. + +If a diagram has fewer than 3 labels and edges in total, list all of them. Otherwise list the 3 least-certain labels or edges. + +A diagram must not present a shortened executable procedure. For safety or recovery flows, keep all required steps or point the node to the full text procedure. + +### Rung 3 — HTML (opt-in) +Set `html_render: true` by hand or with `skillwiki ingest --html`. Render outside the vault: + +```bash +skillwiki page render-html <page> [vault] --out <file> +``` + +HTML is always opt-in. The renderer writes one self-contained file (TOC, tabs per H2, `<details>`, inline CSS and JS). Mermaid stays as readable source. No network fetch. + +### Rung 4 — video +Explainer video is out of scope. Use STE text plus a diagram. + +### Format table + +| Content type | Format | Rung | Policy | +| --- | --- | --- | --- | +| Exact procedure (re-read later) | STE text | 1 | default | +| Relationships (structure / flow / hierarchy) | Mermaid diagram + text | 2 | default | +| Comparison / hands-on exploration | HTML interactive page | 3 | opt-in; otherwise STE + table or diagram | +| Concept from zero | Explainer video | 4 | out of scope; use STE + diagram | + +Rules: formats stack; HTML is always opt-in; video is outside the current scope. + ## Provenance defaults - Default `provenance: research`. - If cwd is inside `projects/{slug}/`, set `provenance: project` and add `provenance_projects: ["[[slug]]"]`. diff --git a/packages/skills/skills/wiki-lint/SKILL.md b/packages/skills/skills/wiki-lint/SKILL.md index 4c612138..89198b9b 100644 --- a/packages/skills/skills/wiki-lint/SKILL.md +++ b/packages/skills/skills/wiki-lint/SKILL.md @@ -19,6 +19,13 @@ Standard four reads. 6. If `log_rotate_needed` is present and the user consents, run `skillwiki log-rotate <vault> --apply`. Otherwise leave alone. 7. **Post-migration verification**: If the user recently migrated **vault content** (e.g., moved entity/concept pages to another vault), re-run `skillwiki lint <vault> --summary` and verify that broken_wikilinks count decreased. Remaining broken links for migrated content indicate pages still referencing the moved files — these should be cleaned up (remove citations or migrate the referencing pages too). This step is content moves only — not HTTP MCP client/plugin upgrades (those use `skillwiki doctor --check-mcp`). 8. Append a `log.md` entry summarizing lint counts only when the user asked to record the maintenance result. Do not log routine `health` reports by default. +## Output standard v2 WARN buckets +These four buckets apply only to typed pages whose frontmatter has `output_standard: v2`. They are advisory WARN findings. They do not change the lint exit code, publish, health exit, or release CI on content. +- `ste_sentence_length`: prose sentences > 25 words; ordered-list items and list items under Steps/Procedure headings > 20 words. +- `ste_hedge_words`: whole-word hedges outside `## Uncertainty`, outside the `**Least certain:**` list, and outside code and blockquotes. +- `ste_diagram_uncertainty`: a Mermaid block with no `**Least certain:**` bullet list after it. +- `ste_comparison_visual`: a `type: comparison` page with neither a Markdown table nor a Mermaid block. +Drill with `skillwiki lint <vault> --only <bucket>`. ## Stop conditions None — lint reports all findings even on per-page errors. ## Forbidden diff --git a/packages/skills/wiki-ingest/SKILL.md b/packages/skills/wiki-ingest/SKILL.md index 31ec9ed8..9dde0c2d 100644 --- a/packages/skills/wiki-ingest/SKILL.md +++ b/packages/skills/wiki-ingest/SKILL.md @@ -42,6 +42,53 @@ Run `skillwiki lang` at the start. Generate page-body prose, narrative sections, 5. `skillwiki sources compile published <raw-path> [vault] --pages "<typed/a.md,typed/b.md>" --reason "<why>"` then `--write --approve <token>` 6. Leave the review `open` unless the human resolves it with `skillwiki sources review <raw-path> --status accepted|needs-fix|dismissed --reason "<why>"` Goal, satellite, and headless sessions may list pending / compile status / reviews but must not claim or publish compile turns. +## Output standard v2 +`skillwiki ingest` stamps `output_standard: v2` on every new typed stub. v2 is an authoring contract. CLI lint reports advisory warnings on marked pages only. Publish, health, and lint exit stay unchanged by these checks. + +### Rung 1 — STE-80 (default) +STE is Simplified Technical English. +- Use at most 20 words for each instruction. Use at most 25 words for each description. +- One topic per paragraph. Each paragraph <= 6 sentences. +- One word, one meaning. Prefer simple verbs: use, start, stop, find, check. +- Keep technical terms (API, cache, Docker). Define each term once at first use. +- No hedges except in `## Uncertainty`. Never drop facts to shorten. +- Hedge list: might, may, perhaps, possibly, probably, likely, seems, appears to, arguably, somewhat, could be. +- Use `## Uncertainty` only for uncertainty the source itself reports. If the source reports none, omit the section or write "The source reports no open uncertainty." + +### Rung 2 — diagrams (default when content has structure) +Add one Mermaid diagram when the page has flow, components, timeline, hierarchy, or comparison. Keep a text summary beside the diagram. Directly after the diagram, add `**Least certain:**` and a bullet list of labels or edges. + +The `**Least certain:**` bullets name diagram labels or edges. State why that part of the diagram is the least certain representation. Use one of these reasons: an interpretive grouping, a simplified step, an omitted condition, or a mapping the source does not state directly. + +Never add doubt about a fact the source states as settled. Never turn a decision, constraint, or policy into an open question. + +If a diagram has fewer than 3 labels and edges in total, list all of them. Otherwise list the 3 least-certain labels or edges. + +A diagram must not present a shortened executable procedure. For safety or recovery flows, keep all required steps or point the node to the full text procedure. + +### Rung 3 — HTML (opt-in) +Set `html_render: true` by hand or with `skillwiki ingest --html`. Render outside the vault: + +```bash +skillwiki page render-html <page> [vault] --out <file> +``` + +HTML is always opt-in. The renderer writes one self-contained file (TOC, tabs per H2, `<details>`, inline CSS and JS). Mermaid stays as readable source. No network fetch. + +### Rung 4 — video +Explainer video is out of scope. Use STE text plus a diagram. + +### Format table + +| Content type | Format | Rung | Policy | +| --- | --- | --- | --- | +| Exact procedure (re-read later) | STE text | 1 | default | +| Relationships (structure / flow / hierarchy) | Mermaid diagram + text | 2 | default | +| Comparison / hands-on exploration | HTML interactive page | 3 | opt-in; otherwise STE + table or diagram | +| Concept from zero | Explainer video | 4 | out of scope; use STE + diagram | + +Rules: formats stack; HTML is always opt-in; video is outside the current scope. + ## Provenance defaults - Default `provenance: research`. - If cwd is inside `projects/{slug}/`, set `provenance: project` and add `provenance_projects: ["[[slug]]"]`. diff --git a/packages/skills/wiki-lint/SKILL.md b/packages/skills/wiki-lint/SKILL.md index 4c612138..89198b9b 100644 --- a/packages/skills/wiki-lint/SKILL.md +++ b/packages/skills/wiki-lint/SKILL.md @@ -19,6 +19,13 @@ Standard four reads. 6. If `log_rotate_needed` is present and the user consents, run `skillwiki log-rotate <vault> --apply`. Otherwise leave alone. 7. **Post-migration verification**: If the user recently migrated **vault content** (e.g., moved entity/concept pages to another vault), re-run `skillwiki lint <vault> --summary` and verify that broken_wikilinks count decreased. Remaining broken links for migrated content indicate pages still referencing the moved files — these should be cleaned up (remove citations or migrate the referencing pages too). This step is content moves only — not HTTP MCP client/plugin upgrades (those use `skillwiki doctor --check-mcp`). 8. Append a `log.md` entry summarizing lint counts only when the user asked to record the maintenance result. Do not log routine `health` reports by default. +## Output standard v2 WARN buckets +These four buckets apply only to typed pages whose frontmatter has `output_standard: v2`. They are advisory WARN findings. They do not change the lint exit code, publish, health exit, or release CI on content. +- `ste_sentence_length`: prose sentences > 25 words; ordered-list items and list items under Steps/Procedure headings > 20 words. +- `ste_hedge_words`: whole-word hedges outside `## Uncertainty`, outside the `**Least certain:**` list, and outside code and blockquotes. +- `ste_diagram_uncertainty`: a Mermaid block with no `**Least certain:**` bullet list after it. +- `ste_comparison_visual`: a `type: comparison` page with neither a Markdown table nor a Mermaid block. +Drill with `skillwiki lint <vault> --only <bucket>`. ## Stop conditions None — lint reports all findings even on per-page errors. ## Forbidden diff --git a/skills/wiki-ingest/SKILL.md b/skills/wiki-ingest/SKILL.md index 31ec9ed8..9dde0c2d 100644 --- a/skills/wiki-ingest/SKILL.md +++ b/skills/wiki-ingest/SKILL.md @@ -42,6 +42,53 @@ Run `skillwiki lang` at the start. Generate page-body prose, narrative sections, 5. `skillwiki sources compile published <raw-path> [vault] --pages "<typed/a.md,typed/b.md>" --reason "<why>"` then `--write --approve <token>` 6. Leave the review `open` unless the human resolves it with `skillwiki sources review <raw-path> --status accepted|needs-fix|dismissed --reason "<why>"` Goal, satellite, and headless sessions may list pending / compile status / reviews but must not claim or publish compile turns. +## Output standard v2 +`skillwiki ingest` stamps `output_standard: v2` on every new typed stub. v2 is an authoring contract. CLI lint reports advisory warnings on marked pages only. Publish, health, and lint exit stay unchanged by these checks. + +### Rung 1 — STE-80 (default) +STE is Simplified Technical English. +- Use at most 20 words for each instruction. Use at most 25 words for each description. +- One topic per paragraph. Each paragraph <= 6 sentences. +- One word, one meaning. Prefer simple verbs: use, start, stop, find, check. +- Keep technical terms (API, cache, Docker). Define each term once at first use. +- No hedges except in `## Uncertainty`. Never drop facts to shorten. +- Hedge list: might, may, perhaps, possibly, probably, likely, seems, appears to, arguably, somewhat, could be. +- Use `## Uncertainty` only for uncertainty the source itself reports. If the source reports none, omit the section or write "The source reports no open uncertainty." + +### Rung 2 — diagrams (default when content has structure) +Add one Mermaid diagram when the page has flow, components, timeline, hierarchy, or comparison. Keep a text summary beside the diagram. Directly after the diagram, add `**Least certain:**` and a bullet list of labels or edges. + +The `**Least certain:**` bullets name diagram labels or edges. State why that part of the diagram is the least certain representation. Use one of these reasons: an interpretive grouping, a simplified step, an omitted condition, or a mapping the source does not state directly. + +Never add doubt about a fact the source states as settled. Never turn a decision, constraint, or policy into an open question. + +If a diagram has fewer than 3 labels and edges in total, list all of them. Otherwise list the 3 least-certain labels or edges. + +A diagram must not present a shortened executable procedure. For safety or recovery flows, keep all required steps or point the node to the full text procedure. + +### Rung 3 — HTML (opt-in) +Set `html_render: true` by hand or with `skillwiki ingest --html`. Render outside the vault: + +```bash +skillwiki page render-html <page> [vault] --out <file> +``` + +HTML is always opt-in. The renderer writes one self-contained file (TOC, tabs per H2, `<details>`, inline CSS and JS). Mermaid stays as readable source. No network fetch. + +### Rung 4 — video +Explainer video is out of scope. Use STE text plus a diagram. + +### Format table + +| Content type | Format | Rung | Policy | +| --- | --- | --- | --- | +| Exact procedure (re-read later) | STE text | 1 | default | +| Relationships (structure / flow / hierarchy) | Mermaid diagram + text | 2 | default | +| Comparison / hands-on exploration | HTML interactive page | 3 | opt-in; otherwise STE + table or diagram | +| Concept from zero | Explainer video | 4 | out of scope; use STE + diagram | + +Rules: formats stack; HTML is always opt-in; video is outside the current scope. + ## Provenance defaults - Default `provenance: research`. - If cwd is inside `projects/{slug}/`, set `provenance: project` and add `provenance_projects: ["[[slug]]"]`. diff --git a/skills/wiki-lint/SKILL.md b/skills/wiki-lint/SKILL.md index 4c612138..89198b9b 100644 --- a/skills/wiki-lint/SKILL.md +++ b/skills/wiki-lint/SKILL.md @@ -19,6 +19,13 @@ Standard four reads. 6. If `log_rotate_needed` is present and the user consents, run `skillwiki log-rotate <vault> --apply`. Otherwise leave alone. 7. **Post-migration verification**: If the user recently migrated **vault content** (e.g., moved entity/concept pages to another vault), re-run `skillwiki lint <vault> --summary` and verify that broken_wikilinks count decreased. Remaining broken links for migrated content indicate pages still referencing the moved files — these should be cleaned up (remove citations or migrate the referencing pages too). This step is content moves only — not HTTP MCP client/plugin upgrades (those use `skillwiki doctor --check-mcp`). 8. Append a `log.md` entry summarizing lint counts only when the user asked to record the maintenance result. Do not log routine `health` reports by default. +## Output standard v2 WARN buckets +These four buckets apply only to typed pages whose frontmatter has `output_standard: v2`. They are advisory WARN findings. They do not change the lint exit code, publish, health exit, or release CI on content. +- `ste_sentence_length`: prose sentences > 25 words; ordered-list items and list items under Steps/Procedure headings > 20 words. +- `ste_hedge_words`: whole-word hedges outside `## Uncertainty`, outside the `**Least certain:**` list, and outside code and blockquotes. +- `ste_diagram_uncertainty`: a Mermaid block with no `**Least certain:**` bullet list after it. +- `ste_comparison_visual`: a `type: comparison` page with neither a Markdown table nor a Mermaid block. +Drill with `skillwiki lint <vault> --only <bucket>`. ## Stop conditions None — lint reports all findings even on per-page errors. ## Forbidden