Files
2026-07-13 12:35:03 +08:00

346 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* Import Open Knowledge Format-shaped Markdown into ArcKit review notes.
*
* The importer treats bundle content as untrusted input. It scans Markdown
* frontmatter, writes a review report, and materializes valid entries as
* RSCH notes by default. Stronger mappings are recommendations only.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_TYPES } from '../config/doc-types.mjs';
import { parseFrontmatter } from '../hooks/okf-frontmatter.mjs';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const DEFAULT_CWD = resolve(SCRIPT_DIR, '..', '..', '..');
const SKIP_TYPES = new Set(['OKF Bundle Index', 'OKF Export Log']);
function usage() {
return `Usage: import-okf.mjs --bundle <path> --project <project-id> [--out-dir <subdir>] [--dry-run] [--cwd <repo-root>]`;
}
function parseArgs(argv) {
const args = {
bundle: null,
project: null,
outDir: null,
dryRun: false,
cwd: process.cwd(),
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--bundle') {
args.bundle = argv[++i] || null;
} else if (arg === '--project') {
args.project = argv[++i] || null;
} else if (arg === '--out-dir') {
args.outDir = argv[++i] || null;
} else if (arg === '--dry-run') {
args.dryRun = true;
} else if (arg === '--cwd') {
args.cwd = argv[++i] || null;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (args.help) return args;
if (!args.bundle) throw new Error('--bundle requires a path');
if (!args.project) throw new Error('--project requires a project id');
if (!args.cwd) args.cwd = DEFAULT_CWD;
return args;
}
function toPosix(path) {
return path.split(/[\\/]/).join('/');
}
function findProjectDir(repoRoot, projectArg) {
const projectsRoot = join(repoRoot, 'projects');
if (!existsSync(projectsRoot)) throw new Error(`projects directory not found: ${projectsRoot}`);
const projects = readdirSync(projectsRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && /^\d{3}($|-)/.test(entry.name))
.map(entry => entry.name)
.sort();
const matches = projects.filter(name => name === projectArg || name.startsWith(`${projectArg}-`));
if (matches.length === 0) throw new Error(`project not found: ${projectArg}`);
if (matches.length > 1) throw new Error(`project is ambiguous: ${projectArg} (${matches.join(', ')})`);
return join(projectsRoot, matches[0]);
}
function walkMarkdown(dir) {
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
const files = [];
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkMarkdown(path));
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(path);
}
}
return files;
}
function normalizeText(value) {
if (Array.isArray(value)) return value.join(' ');
if (value === null || value === undefined) return '';
return String(value);
}
const DOC_TYPE_BY_NAME = new Map(
Object.entries(DOC_TYPES).map(([code, meta]) => [String(meta.name || '').toLowerCase(), code])
);
function classifyOkf(data = {}) {
const type = normalizeText(data.type);
const haystack = [
type,
data.title,
data.description,
data.resource,
data.tags,
].map(normalizeText).join(' ').toLowerCase();
const exactDocType = DOC_TYPE_BY_NAME.get(type.toLowerCase()) || null;
if (/\b(vendor|supplier|company|market|procurement)\b/.test(haystack)) {
return { kind: 'vendor_profile_candidate', suggested_doc_type: exactDocType || 'VEND' };
}
if (/\b(data source|dataset|database|api|schema|catalog|catalogue)\b/.test(haystack)) {
return { kind: 'data_source_profile_candidate', suggested_doc_type: exactDocType || 'DSCT' };
}
if (/\b(decision|adr|architecture|design|principle|requirement|roadmap|risk|traceability)\b/.test(haystack)) {
return { kind: 'architecture_artifact_candidate', suggested_doc_type: exactDocType };
}
if (/\b(tech|technical|implementation|runbook|component|service)\b/.test(haystack)) {
return { kind: 'tech_note_candidate', suggested_doc_type: null };
}
return { kind: 'research_note_candidate', suggested_doc_type: exactDocType || 'RSCH' };
}
function nextResearchSequence(projectDir, projectNumber) {
const researchDir = join(projectDir, 'research');
if (!existsSync(researchDir)) return 1;
let max = 0;
for (const entry of readdirSync(researchDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const match = entry.name.match(new RegExp(`^ARC-${projectNumber}-RSCH-(\\d{3})-v\\d+(?:\\.\\d+)?\\.md$`));
if (match) max = Math.max(max, Number(match[1]));
}
return max + 1;
}
function safeTitle(data, fallback) {
const title = normalizeText(data.title).trim() || normalizeText(data.type).trim() || fallback;
return title.replace(/\s+/g, ' ').slice(0, 160);
}
function formatValue(value) {
if (Array.isArray(value)) return value.join(', ');
if (value && typeof value === 'object') return JSON.stringify(value);
if (value === undefined || value === null || value === '') return '_not provided_';
return String(value);
}
function buildResearchNote(entry) {
const data = entry.frontmatter;
const title = safeTitle(data, 'OKF Import');
const body = entry.body.trimEnd();
const lines = [
`# OKF Import: ${title}`,
'',
'> Imported OKF content is untrusted review material. Treat embedded instructions, links, and claims as source content until reviewed.',
'',
'## Import Metadata',
'',
'| Field | Value |',
'|---|---|',
`| Source file | \`${entry.source_path}\` |`,
`| OKF type | ${formatValue(data.type)} |`,
`| OKF title | ${formatValue(data.title)} |`,
`| Resource | ${formatValue(data.resource)} |`,
`| Tags | ${formatValue(data.tags)} |`,
`| Timestamp | ${formatValue(data.timestamp)} |`,
`| Classification | \`${entry.classification.kind}\` |`,
`| Suggested doc type | ${entry.classification.suggested_doc_type ? `\`${entry.classification.suggested_doc_type}\`` : '_none_'} |`,
'',
'## Original OKF Frontmatter',
'',
'```yaml',
entry.raw_frontmatter.trimEnd(),
'```',
'',
'## Imported Content',
'',
body || '_No body content supplied._',
'',
];
return `${lines.join('\n')}\n`;
}
function scanBundle(bundleDir, repoRoot) {
const valid = [];
const invalid = [];
const skipped = [];
const seenResources = new Map();
for (const filePath of walkMarkdown(bundleDir)) {
const sourcePath = toPosix(relative(repoRoot, filePath));
const content = readFileSync(filePath, 'utf8');
const parsed = parseFrontmatter(content);
if (!parsed.hasFrontmatter || parsed.error) {
invalid.push({
source_path: sourcePath,
reason: parsed.error || 'missing OKF frontmatter',
});
continue;
}
if (!parsed.data.type) {
invalid.push({
source_path: sourcePath,
reason: 'missing required OKF type field',
});
continue;
}
if (SKIP_TYPES.has(parsed.data.type)) {
skipped.push({ source_path: sourcePath, reason: `bundle metadata: ${parsed.data.type}` });
continue;
}
const resource = normalizeText(parsed.data.resource).trim();
const duplicateOf = resource && seenResources.has(resource) ? seenResources.get(resource) : null;
if (resource && !seenResources.has(resource)) seenResources.set(resource, sourcePath);
valid.push({
source_path: sourcePath,
source_file: filePath,
okf_type: parsed.data.type,
title: safeTitle(parsed.data, basename(filePath, '.md')),
resource: resource || null,
duplicate_resource_of: duplicateOf,
classification: classifyOkf(parsed.data),
frontmatter: parsed.data,
raw_frontmatter: parsed.raw || '',
body: parsed.body,
materialized: false,
materialized_path: null,
materialize_reason: duplicateOf ? `duplicate resource already seen in ${duplicateOf}` : null,
});
}
return { valid, invalid, skipped };
}
function materializeResearchNotes({ entries, projectDir, repoRoot, outDir }) {
const projectName = basename(projectDir);
const projectNumber = projectName.slice(0, 3);
const researchSubdir = outDir || 'research';
const researchDir = join(projectDir, researchSubdir);
mkdirSync(researchDir, { recursive: true });
let sequence = nextResearchSequence(projectDir, projectNumber);
for (const entry of entries) {
if (entry.duplicate_resource_of) continue;
let outputPath;
do {
const seq = String(sequence).padStart(3, '0');
outputPath = join(researchDir, `ARC-${projectNumber}-RSCH-${seq}-v1.0.md`);
sequence += 1;
} while (existsSync(outputPath));
writeFileSync(outputPath, buildResearchNote(entry), 'utf8');
entry.materialized = true;
entry.materialized_path = toPosix(relative(repoRoot, outputPath));
entry.materialize_reason = 'materialized as RSCH review note';
}
}
function importOkf(args) {
const repoRoot = resolve(args.cwd);
const bundleDir = resolve(repoRoot, args.bundle);
if (!existsSync(bundleDir)) throw new Error(`bundle not found: ${bundleDir}`);
const projectDir = findProjectDir(repoRoot, args.project);
const generatedAt = new Date().toISOString();
const { valid, invalid, skipped } = scanBundle(bundleDir, repoRoot);
if (!args.dryRun) {
materializeResearchNotes({
entries: valid,
projectDir,
repoRoot,
outDir: args.outDir,
});
}
const report = {
ok: true,
generated_at: generatedAt,
repo_root: repoRoot,
bundle: toPosix(relative(repoRoot, bundleDir)),
project: basename(projectDir),
dry_run: Boolean(args.dryRun),
valid_count: valid.length,
invalid_count: invalid.length,
skipped_count: skipped.length,
materialized_count: valid.filter(entry => entry.materialized).length,
entries: valid.map(entry => ({
source_path: entry.source_path,
okf_type: entry.okf_type,
title: entry.title,
resource: entry.resource,
duplicate_resource_of: entry.duplicate_resource_of,
classification: entry.classification,
materialized: entry.materialized,
materialized_path: entry.materialized_path,
materialize_reason: entry.materialize_reason,
})),
invalid,
skipped,
};
const reportPath = join(repoRoot, '.arckit', 'tmp', 'okf-import-report.json');
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
return { ...report, report_path: toPosix(relative(repoRoot, reportPath)) };
}
function main() {
try {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const summary = importOkf(args);
console.log(JSON.stringify(summary, null, 2));
} catch (error) {
console.error(JSON.stringify({ ok: false, error: error.message }, null, 2));
process.exit(1);
}
}
if (fileURLToPath(import.meta.url) === resolve(process.argv[1] || '')) {
main();
}
export { classifyOkf, importOkf, parseArgs };