audit-workshop-report-parity.mjs 5.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. /** Read-only audit of the public board against the local released Skill. */
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. import crypto from 'node:crypto';
  5. import { fileURLToPath } from 'node:url';
  6. import { loadPublicBoard, publicBoardAction } from '../src/services/workshop-data.js';
  7. import { buildModel } from '../src/core/kfs/build.js';
  8. import { reportCenter } from '../src/core/library/reports.js';
  9. import { workshopReview, workshopOutputRefs } from '../src/core/workshop-outputs.js';
  10. const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  11. const args = process.argv.slice(2), outIndex = args.indexOf('--out');
  12. if (outIndex < 0 || !args[outIndex + 1]) throw Error('Usage: node scripts/audit-workshop-report-parity.mjs --out <local-json-path>');
  13. const output = path.resolve(args[outIndex + 1]);
  14. const base = 'https://kangshifu.fmode.cn', appId = 'FKieRbIoGt_main';
  15. const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
  16. const raw = await loadPublicBoard({ url: base + '/api/functions', appId, fetchImpl: fetch });
  17. const groups = await publicBoardAction({ url: base + '/api/functions', appId, name: 'ksfGetPublicMemberGroups', params: {}, fetchImpl: fetch });
  18. const db = buildModel(raw), center = reportCenter(db);
  19. const view = workshopReview(db, groups, { mode: 'all', history: '1' });
  20. const productionAssets = await Promise.all(['src/core/library/types.js', 'src/core/library/reports.js', 'src/pages/view-workshop-outputs.js'].map(async asset => {
  21. const response = await fetch(base + '/' + asset, { signal: AbortSignal.timeout(15000) });
  22. if (!response.ok) throw Error(`Public asset failed: ${asset} (${response.status})`);
  23. const text = await response.text();
  24. if (!text.includes('export ')) throw Error(`Public asset is not JavaScript: ${asset}`);
  25. return { path: asset, sha256: hash(text), matchingLines: text.split(/\r?\n/)
  26. .filter(line => /assessment_report:|key: 'assessment'|import .*WORKSHOP|filter\('output'|filter\('reportType'/.test(line)) };
  27. }));
  28. const packageRoot = path.join(root, 'docs/ksf-workshop-skill-20261110-r13-r2/package/skills/ksf-workshop-runtime');
  29. const localRoot = path.join(root, 'skills/ksf-workshop-runtime');
  30. const manifest = fs.readFileSync(path.join(packageRoot, 'FILES.sha256'), 'utf8');
  31. const declared = manifest.trim().split(/\r?\n/).map(line => line.match(/^([a-f0-9]{64})\s+\*?(.+)$/)).filter(Boolean);
  32. const differingFiles = declared.filter(([, expected, file]) => !fs.existsSync(path.join(localRoot, file)) || hash(fs.readFileSync(path.join(localRoot, file))) !== expected).map(([, , file]) => file);
  33. const course = fs.readFileSync(path.join(localRoot, 'references/workshop-course-flow.md'), 'utf8');
  34. const curriculum = [...course.matchAll(/^\| (D[12]-(?:\d\d|N)) \| \d+ \| [^|]+ \| (S[1-5]) \| ([^\n]+)\|/gm)]
  35. .flatMap(([, module, stage, types]) => [...types.matchAll(/`(\w+)`/g)].map(([, type]) => ({ module, stage, type })));
  36. const tally = (rows, key) => rows.reduce((totals, row) => { const name = key(row); totals[name] = (totals[name] || 0) + 1; return totals; }, {});
  37. const result = {
  38. schema: 'ksf.workshop-report-parity-audit.v1', checkedAt: new Date().toISOString(), productionAsOf: raw.loadedAt,
  39. productionBusinessWrites: 0, productionConfigurationChanges: 0,
  40. scope: 'Public production data and assets; local released Skill and local board model. No production save or host execution.',
  41. skill: { release: 'ksf-workshop-skill-20261110-r13-r2', entrySha256: hash(fs.readFileSync(path.join(localRoot, 'SKILL.md'))),
  42. declaredFilesIncludingManifest: declared.length + 1, differingFiles,
  43. manifestMatchesReleased: hash(fs.readFileSync(path.join(localRoot, 'FILES.sha256'))) === hash(manifest), curriculum },
  44. productionAssets,
  45. publicCounts: Object.fromEntries(['project', 'artifact', 'insight', 'source', 'session', 'opportunity'].map(name => [name, raw[name].length])),
  46. reportCenter: { totalEntries: center.total, kinds: tally(center.entries, entry => entry.kind),
  47. allPublicArtifactsIncluded: db.artifacts.every(artifact => center.entries.some(entry => entry.kind === 'artifact' && entry.id === artifact.id)) },
  48. artifacts: { total: db.artifacts.length, withStructuredReport: db.artifacts.filter(artifact => artifact.report).length,
  49. withoutStructuredReport: db.artifacts.filter(artifact => !artifact.report).length,
  50. byType: tally(db.artifacts, artifact => artifact.type), byMode: tally(db.artifacts, artifact => artifact.dataMode || 'unknown'),
  51. missingSeries: db.artifacts.filter(artifact => !artifact.reportSeriesKey).length,
  52. sevenOutputLocatorCounts: Object.fromEntries(['market', 'social', 'evidence', 'opportunities', 'validation', 'value', 'proposal']
  53. .map(key => [key, db.artifacts.filter(artifact => workshopOutputRefs(artifact).refs.some(ref => ref.key === key)).length])) },
  54. localBoardModelAgainstProduction: { note: 'This is the local model, not evidence that the changed UI was deployed.',
  55. allModesAndHistoryCount: view.rows.length, nestedPersonalReportCount: view.hierarchy.flatMap(group => group.members.flatMap(member => member.reports)).length,
  56. groupCount: groups.groups.length, ungroupedReportCount: view.ungrouped, defaultLiveLatestCount: workshopReview(db, groups).rows.length },
  57. reportInventory: db.artifacts.map(artifact => ({ id: artifact.id, title: artifact.title, type: artifact.type, dataMode: artifact.dataMode || 'unknown',
  58. hasStructuredReport: Boolean(artifact.report), rawTextChars: artifact.rawText.length, reportSeriesKey: artifact.reportSeriesKey,
  59. version: artifact.version, chapterIds: (artifact.report?.chapters || []).map(chapter => chapter.id || '') })),
  60. };
  61. fs.mkdirSync(path.dirname(output), { recursive: true });
  62. fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n');
  63. console.log(JSON.stringify({ output, asOf: result.productionAsOf, reportCenter: result.reportCenter, artifacts: result.artifacts,
  64. localBoardModelAgainstProduction: result.localBoardModelAgainstProduction, skillDifferingFiles: differingFiles, productionBusinessWrites: 0 }, null, 2));