346 lines
11 KiB
JavaScript
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 };
|