refresh-software-client-list.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { spawnSync } = require('child_process');
  5. const ROOT = path.resolve(__dirname, '..');
  6. function main() {
  7. const outputsRoot = path.resolve(arg('--outputs') || arg('--output-root') || path.join(ROOT, 'outputs'));
  8. const outputDir = path.resolve(arg('--output') || path.join(outputsRoot, 'software-client-latest'));
  9. const input = arg('--input') || latestManualReviewSample(outputsRoot);
  10. const csv = path.join(outputDir, 'software-client-list.final.csv');
  11. const markdown = path.join(outputDir, 'software-client-list.final.md');
  12. const refreshSummary = path.join(outputDir, 'software-client-refresh-summary.json');
  13. fs.mkdirSync(outputDir, { recursive: true });
  14. if (!input) {
  15. const existingSummary = csv.replace(/\.csv$/i, '.summary.json');
  16. const reusable = fs.existsSync(csv) && fs.existsSync(markdown) && fs.existsSync(existingSummary);
  17. const summary = {
  18. generatedAt: new Date().toISOString(),
  19. passed: reusable,
  20. skipped: true,
  21. reason: 'no_manual_review_sample_found',
  22. outputsRoot,
  23. outputDir,
  24. csv,
  25. markdown,
  26. summary: existingSummary
  27. };
  28. fs.writeFileSync(refreshSummary, JSON.stringify(summary, null, 2), 'utf8');
  29. console.log(JSON.stringify({ ...summary, refreshSummary }, null, 2));
  30. if (!reusable) process.exitCode = 1;
  31. return;
  32. }
  33. runNode(['scripts/export-software-table.js', '--input', input, '--output', csv]);
  34. runNode(['scripts/export-software-markdown.js', '--input', csv, '--output', markdown]);
  35. const clientSummaryPath = csv.replace(/\.csv$/i, '.summary.json');
  36. const clientSummary = readJson(clientSummaryPath);
  37. const summary = {
  38. generatedAt: new Date().toISOString(),
  39. passed: true,
  40. skipped: false,
  41. input,
  42. outputsRoot,
  43. outputDir,
  44. csv,
  45. markdown,
  46. summary: clientSummaryPath,
  47. sourceRows: Number(clientSummary.sourceRows || 0),
  48. outputRows: Number(clientSummary.outputRows || 0),
  49. sourceDuplicateRemovedCount: Number(clientSummary.sourceDuplicateRemovedCount || 0),
  50. finalDuplicateGroupCount: Number(clientSummary.finalDuplicateGroupCount || 0),
  51. duplicateCountMeaning: clientSummary.duplicateCountMeaning || '',
  52. rankContinuous: Boolean(clientSummary.rankContinuous)
  53. };
  54. fs.writeFileSync(refreshSummary, JSON.stringify(summary, null, 2), 'utf8');
  55. console.log(JSON.stringify({ ...summary, refreshSummary }, null, 2));
  56. }
  57. function runNode(args) {
  58. const result = spawnSync(process.execPath, args, {
  59. cwd: ROOT,
  60. encoding: 'utf8',
  61. shell: false
  62. });
  63. if (result.stdout) process.stdout.write(result.stdout);
  64. if (result.stderr) process.stderr.write(result.stderr);
  65. if (result.status !== 0) throw new Error(`${args[0]} failed with exit code ${result.status}`);
  66. }
  67. function latestManualReviewSample(root) {
  68. const files = listFiles(root).filter(file => path.basename(file) === 'manual-review-sample.csv');
  69. const candidates = files
  70. .filter(isRefreshCandidateSample)
  71. .map(file => ({
  72. file,
  73. sourceRows: manualReviewSampleRowCount(file),
  74. mtimeMs: fs.statSync(file).mtimeMs
  75. }))
  76. .filter(item => item.sourceRows > 0)
  77. .sort((a, b) => b.sourceRows - a.sourceRows || b.mtimeMs - a.mtimeMs);
  78. return candidates[0]?.file || '';
  79. }
  80. function isRefreshCandidateSample(file) {
  81. const normalized = file.replace(/\\/g, '/').toLowerCase();
  82. const parent = path.basename(path.dirname(file));
  83. return /^overnight-quality-\d+$/.test(parent) && !normalized.includes('smoke');
  84. }
  85. function manualReviewSampleRowCount(file) {
  86. const rows = parseCsv(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  87. return rows.slice(1).filter(row => row.some(cell => String(cell || '').trim())).length;
  88. }
  89. function listFiles(dir) {
  90. if (!fs.existsSync(dir)) return [];
  91. return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
  92. const full = path.join(dir, entry.name);
  93. return entry.isDirectory() ? listFiles(full) : [full];
  94. });
  95. }
  96. function readJson(file) {
  97. return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
  98. }
  99. function parseCsv(text) {
  100. const rows = [];
  101. let row = [];
  102. let cell = '';
  103. let quoted = false;
  104. for (let index = 0; index < text.length; index += 1) {
  105. const char = text[index];
  106. if (char === '\r') continue;
  107. if (char === '"' && quoted && text[index + 1] === '"') {
  108. cell += '"';
  109. index += 1;
  110. } else if (char === '"') {
  111. quoted = !quoted;
  112. } else if (char === ',' && !quoted) {
  113. row.push(cell);
  114. cell = '';
  115. } else if (char === '\n' && !quoted) {
  116. row.push(cell);
  117. rows.push(row);
  118. row = [];
  119. cell = '';
  120. } else {
  121. cell += char;
  122. }
  123. }
  124. if (cell || row.length) {
  125. row.push(cell);
  126. rows.push(row);
  127. }
  128. return rows;
  129. }
  130. function arg(name) {
  131. const index = process.argv.indexOf(name);
  132. return index >= 0 ? process.argv[index + 1] : '';
  133. }
  134. if (require.main === module) main();