extract-manifest.js 5.64 KB
#!/usr/bin/env node
/**
 * extract-manifest.js
 *
 * 验证版抽取器:扫 docs/prd/newfunction/sdd-*.html 把所有
 * MVCP 定义(section[data-def-name][data-mvcp])抽成 build-manifest.json。
 *
 * P1 正式版会改用 cheerio。本版只用 regex,零依赖,便于离线验证 data-* 锚点完整性。
 */
const fs = require('fs');
const path = require('path');

const DOCS_DIR = path.resolve(__dirname, '../docs/prd/newfunction');
const MVCP_QUADRANTS = ['model', 'view', 'control', 'process'];

const manifest = {
  meta: {
    source: 'docs/prd/newfunction',
    prdRev: 'v0.2',
    extractedAt: new Date().toISOString(),
  },
  model:   [],
  view:    [],
  control: [],
  process: [],
};

const issues = [];

function stripTags(s) {
  return s.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
}

function extractAttr(openTag, attr) {
  const m = openTag.match(new RegExp(`${attr}\\s*=\\s*"([^"]*)"`));
  return m ? m[1] : null;
}

/**
 * Find all top-level <section data-def-name="..." ...>...</section> blocks.
 * Uses a depth counter to handle the (rare) nested section case correctly.
 */
function findDefSections(html) {
  const blocks = [];
  // Scan for opening tags with data-def-name
  const reOpen = /<section\b([^>]*data-def-name="[^"]+"[^>]*)>/g;
  let m;
  while ((m = reOpen.exec(html)) !== null) {
    const start = m.index;
    const openTag = m[0];
    const openAttrs = m[1];
    // Walk forward, tracking <section> depth, until matching </section>
    let depth = 1;
    let pos = reOpen.lastIndex;
    const reTag = /<(\/?)section\b[^>]*>/g;
    reTag.lastIndex = pos;
    let t;
    let endIdx = -1;
    while ((t = reTag.exec(html)) !== null) {
      if (t[1] === '/') {
        depth--;
        if (depth === 0) { endIdx = t.index + t[0].length; break; }
      } else {
        depth++;
      }
    }
    if (endIdx < 0) {
      issues.push(`Unclosed <section data-def-name=...> at offset ${start}`);
      continue;
    }
    blocks.push({
      openAttrs,
      defName: extractAttr(openAttrs, 'data-def-name'),
      mvcp:    extractAttr(openAttrs, 'data-mvcp'),
      body:    html.slice(start, endIdx),
    });
    reOpen.lastIndex = endIdx;
  }
  return blocks;
}

function extractPath(body) {
  // <code class="path ...">...</code>  — class may be "path", "path mono", "mono path", etc.
  const re = /<code\s+class="([^"]*\bpath\b[^"]*)"[^>]*>([\s\S]*?)<\/code>/;
  const m = body.match(re);
  return m ? stripTags(m[2]) : null;
}

function extractShape(body) {
  // <pre class="...shape...">...</pre>  — class may be "shape", "code shape", etc.
  const re = /<pre\s+class="([^"]*\bshape\b[^"]*)"[^>]*>([\s\S]*?)<\/pre>/;
  const m = body.match(re);
  return m ? stripTags(m[2]) : null;
}

function extractListByClass(body, className) {
  // <ul class="X">...</ul>  → list of <li>...</li> texts
  const reUl = new RegExp(`<ul\\s+class="${className}"[^>]*>([\\s\\S]*?)<\\/ul>`);
  const um = body.match(reUl);
  if (!um) return [];
  const liRe = /<li\b[^>]*>([\s\S]*?)<\/li>/g;
  const out = [];
  let lm;
  while ((lm = liRe.exec(um[1])) !== null) out.push(stripTags(lm[1]));
  return out;
}

// ── Scan SDD MVCP pages ──
for (const quadrant of MVCP_QUADRANTS) {
  const file = path.join(DOCS_DIR, `sdd-${quadrant}.html`);
  if (!fs.existsSync(file)) {
    issues.push(`Missing file: sdd-${quadrant}.html`);
    continue;
  }
  const html = fs.readFileSync(file, 'utf8');
  const blocks = findDefSections(html);
  if (blocks.length === 0) {
    issues.push(`sdd-${quadrant}.html: 0 definitions found`);
    continue;
  }
  for (const b of blocks) {
    if (!b.defName) {
      issues.push(`sdd-${quadrant}.html: section without data-def-name`);
      continue;
    }
    if (b.mvcp !== quadrant) {
      issues.push(`sdd-${quadrant}.html: section "${b.defName}" has data-mvcp="${b.mvcp}" (expected ${quadrant})`);
    }
    const def = {
      name:     b.defName,
      path:     extractPath(b.body),
      shape:    extractShape(b.body),
      contract: extractListByClass(b.body, 'contract'),
      tests:    extractListByClass(b.body, 'tests'),
    };
    // Per-field completeness
    if (!def.path)  issues.push(`  · ${def.name} · missing path (<code class="path">)`);
    if (!def.shape) issues.push(`  · ${def.name} · missing shape (<pre class="...shape">)`);
    // contract / tests are optional
    manifest[quadrant].push(def);
  }
}

// ── Output ──
const outPath = path.resolve(__dirname, '../build-manifest.json');
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2));

const totals = MVCP_QUADRANTS.map(q => `${q}=${manifest[q].length}`).join(' · ');
const sum = MVCP_QUADRANTS.reduce((s, q) => s + manifest[q].length, 0);

console.log('\n=== build-manifest extraction report ===');
console.log(`Source : ${DOCS_DIR}`);
console.log(`Output : ${outPath}`);
console.log(`Totals : ${totals}   →   sum=${sum}`);

if (issues.length === 0) {
  console.log('\n✓ All MVCP definitions extracted cleanly. No issues.');
} else {
  console.log(`\n⚠ ${issues.length} issue(s):`);
  for (const i of issues) console.log('  ' + i);
}

// Per-field completeness summary
console.log('\n--- Per-field completeness ---');
for (const q of MVCP_QUADRANTS) {
  const defs = manifest[q];
  const withPath     = defs.filter(d => d.path).length;
  const withShape    = defs.filter(d => d.shape).length;
  const withContract = defs.filter(d => d.contract.length > 0).length;
  const withTests    = defs.filter(d => d.tests.length > 0).length;
  console.log(`${q.padEnd(8)} | ${defs.length} defs | path ${withPath}/${defs.length} | shape ${withShape}/${defs.length} | contract ${withContract}/${defs.length} | tests ${withTests}/${defs.length}`);
}