|
| 1 | +#!/usr/bin/env node |
| 2 | +'use strict' |
| 3 | + |
| 4 | +const fs = require('fs') |
| 5 | +const path = require('path') |
| 6 | +const { execFileSync } = require('child_process') |
| 7 | +const { resolveProfileDir } = require('../hooks/_runtime/workspace-layout.cjs') |
| 8 | +const { |
| 9 | + extractWorkflowCurrentTruth, |
| 10 | + parseProfileCurrentTruth, |
| 11 | + validateDevCodexCurrentTruth |
| 12 | +} = require('./lib/profile-current-truth') |
| 13 | +const { buildCandidateIdentity } = require('./lib/validation-dag') |
| 14 | + |
| 15 | +const ROOT = path.resolve(__dirname, '..') |
| 16 | +const TRUTH_HEADING_RE = /^## ProfileCurrentTruthV1[ \t]*\n```json\n[\s\S]*?\n```(?:\n|$)/m |
| 17 | + |
| 18 | +function argValue(args, name) { |
| 19 | + const index = args.indexOf(name) |
| 20 | + if (index === -1 || index + 1 >= args.length) return '' |
| 21 | + return args[index + 1] |
| 22 | +} |
| 23 | + |
| 24 | +function readJson(filePath) { |
| 25 | + return JSON.parse(fs.readFileSync(filePath, 'utf8')) |
| 26 | +} |
| 27 | + |
| 28 | +function currentGitHead(repoRoot) { |
| 29 | + return execFileSync('git', ['rev-parse', 'HEAD'], { |
| 30 | + cwd: repoRoot, |
| 31 | + encoding: 'utf8', |
| 32 | + windowsHide: true, |
| 33 | + stdio: ['ignore', 'pipe', 'pipe'] |
| 34 | + }).trim() |
| 35 | +} |
| 36 | + |
| 37 | +function isoNoMillis(date = new Date()) { |
| 38 | + return date.toISOString().replace(/\.\d{3}Z$/, 'Z') |
| 39 | +} |
| 40 | + |
| 41 | +function buildRefreshedCurrentTruthRecord(record, input) { |
| 42 | + const packageVersion = input.packageVersion || record.sourceVersion |
| 43 | + const npmLatest = record.npmLatest || packageVersion |
| 44 | + const gitHead = input.gitHead || record.gitHead |
| 45 | + const candidateId = input.candidateId || record.sourceCandidate?.candidateId || '' |
| 46 | + const ciMatrix = input.ciMatrix || record.ciMatrix |
| 47 | + const identityMatches = record.sourceVersion === packageVersion && |
| 48 | + record.npmLatest === npmLatest && |
| 49 | + record.gitHead === gitHead && |
| 50 | + record.sourceCandidate?.candidateId === candidateId && |
| 51 | + JSON.stringify(record.ciMatrix || null) === JSON.stringify(ciMatrix || null) |
| 52 | + const now = input.now || (identityMatches && record.asOf ? record.asOf : isoNoMillis()) |
| 53 | + const releaseCommit = record.releaseCommit || record.gitHead |
| 54 | + const exactReleasedHead = gitHead === releaseCommit && packageVersion === npmLatest |
| 55 | + |
| 56 | + const next = { |
| 57 | + ...record, |
| 58 | + sourceVersion: packageVersion, |
| 59 | + npmLatest, |
| 60 | + gitHead, |
| 61 | + releaseCommit, |
| 62 | + ciMatrix, |
| 63 | + asOf: now |
| 64 | + } |
| 65 | + |
| 66 | + if (exactReleasedHead && record.sourceCandidate?.status === 'RELEASED') { |
| 67 | + next.releaseState = record.releaseState || `${packageVersion} released` |
| 68 | + next.sourceCandidate = { |
| 69 | + ...record.sourceCandidate, |
| 70 | + candidateId, |
| 71 | + remoteCi: { |
| 72 | + ...record.sourceCandidate.remoteCi, |
| 73 | + head: gitHead |
| 74 | + } |
| 75 | + } |
| 76 | + return next |
| 77 | + } |
| 78 | + |
| 79 | + delete next.candidate |
| 80 | + next.releaseState = `${npmLatest}发行事实保持有效;当前HEAD及工作区为尚未选定下一版本的本地工作态,资格与远端CI待验证` |
| 81 | + next.sourceCandidate = { |
| 82 | + schemaVersion: 'SourceCandidateTruthV1', |
| 83 | + ...(record.sourceCandidate || {}), |
| 84 | + candidateId, |
| 85 | + status: 'LOCAL_PENDING', |
| 86 | + localQualification: { |
| 87 | + status: 'UNVERIFIED', |
| 88 | + runId: 'not-qualified-working-source', |
| 89 | + observedAt: identityMatches |
| 90 | + ? (record.sourceCandidate?.localQualification?.observedAt || now) |
| 91 | + : now |
| 92 | + }, |
| 93 | + remoteCi: { |
| 94 | + status: 'UNVERIFIED', |
| 95 | + runId: 'not-pushed', |
| 96 | + head: gitHead, |
| 97 | + observedAt: identityMatches |
| 98 | + ? (record.sourceCandidate?.remoteCi?.observedAt || now) |
| 99 | + : now |
| 100 | + }, |
| 101 | + releaseAuthorized: false |
| 102 | + } |
| 103 | + return next |
| 104 | +} |
| 105 | + |
| 106 | +function replaceCurrentTruthBlock(markdown, record) { |
| 107 | + const nextBlock = `## ProfileCurrentTruthV1\n\`\`\`json\n${JSON.stringify(record, null, 2)}\n\`\`\`\n` |
| 108 | + if (!TRUTH_HEADING_RE.test(markdown)) { |
| 109 | + throw new Error('ProfileCurrentTruthV1 block not found or not a strict json fence') |
| 110 | + } |
| 111 | + return markdown.replace(TRUTH_HEADING_RE, nextBlock) |
| 112 | +} |
| 113 | + |
| 114 | +function refreshProfileCurrentTruth(options = {}) { |
| 115 | + const repoRoot = path.resolve(options.repoRoot || ROOT) |
| 116 | + const profileDir = path.resolve(options.profileDir || resolveProfileDir(repoRoot)) |
| 117 | + const releaseProfilePath = path.join(profileDir, '05-发布规范.md') |
| 118 | + const releaseProfileText = fs.readFileSync(releaseProfilePath, 'utf8') |
| 119 | + const parsed = parseProfileCurrentTruth(releaseProfileText, { required: true }) |
| 120 | + if (!parsed.valid || !parsed.record) { |
| 121 | + const error = new Error(`current Profile truth is invalid: ${parsed.errors.join('; ')}`) |
| 122 | + error.errors = parsed.errors |
| 123 | + throw error |
| 124 | + } |
| 125 | + |
| 126 | + const packageVersion = readJson(path.join(repoRoot, 'package.json')).version |
| 127 | + const gitHead = currentGitHead(repoRoot) |
| 128 | + const candidateId = buildCandidateIdentity({ repoRoot }).candidateId |
| 129 | + const workflowText = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'ci.yml'), 'utf8') |
| 130 | + const validationManifest = readJson(path.join(repoRoot, 'scripts', 'validation-manifest.json')) |
| 131 | + const nextRecord = buildRefreshedCurrentTruthRecord(parsed.record, { |
| 132 | + packageVersion, |
| 133 | + gitHead, |
| 134 | + candidateId, |
| 135 | + ciMatrix: extractWorkflowCurrentTruth(workflowText, validationManifest), |
| 136 | + now: options.now |
| 137 | + }) |
| 138 | + const nextText = replaceCurrentTruthBlock(releaseProfileText, nextRecord) |
| 139 | + const changed = nextText !== releaseProfileText |
| 140 | + |
| 141 | + const validation = validateDevCodexCurrentTruth({ |
| 142 | + releaseProfileText: nextText, |
| 143 | + overviewProfileText: fs.readFileSync(path.join(profileDir, '01-项目信息.md'), 'utf8'), |
| 144 | + testProfileText: fs.readFileSync(path.join(profileDir, '04-测试规范.md'), 'utf8'), |
| 145 | + docsProfileText: fs.readFileSync(path.join(profileDir, '07-用户文档与契约规范.md'), 'utf8'), |
| 146 | + packageVersion, |
| 147 | + gitHead, |
| 148 | + candidateId, |
| 149 | + requireSourceCandidate: true, |
| 150 | + workflowText, |
| 151 | + validationManifest |
| 152 | + }) |
| 153 | + if (!validation.valid) { |
| 154 | + const error = new Error(`refreshed ProfileCurrentTruthV1 is invalid: ${validation.errors.join('; ')}`) |
| 155 | + error.errors = validation.errors |
| 156 | + throw error |
| 157 | + } |
| 158 | + |
| 159 | + if (!options.check && changed) fs.writeFileSync(releaseProfilePath, nextText, 'utf8') |
| 160 | + return { releaseProfilePath, changed, record: nextRecord } |
| 161 | +} |
| 162 | + |
| 163 | +function main() { |
| 164 | + const args = process.argv.slice(2) |
| 165 | + const result = refreshProfileCurrentTruth({ |
| 166 | + repoRoot: argValue(args, '--project-root') || ROOT, |
| 167 | + profileDir: argValue(args, '--profile-dir') || '', |
| 168 | + check: args.includes('--check') |
| 169 | + }) |
| 170 | + const action = result.changed ? (args.includes('--check') ? 'needs-refresh' : 'refreshed') : 'already-current' |
| 171 | + console.log(`[profile-current:refresh] ${action}: ${result.releaseProfilePath}`) |
| 172 | + if (args.includes('--check') && result.changed) process.exitCode = 1 |
| 173 | +} |
| 174 | + |
| 175 | +if (require.main === module) { |
| 176 | + try { |
| 177 | + main() |
| 178 | + } catch (error) { |
| 179 | + console.error(`[profile-current:refresh] ${error.message}`) |
| 180 | + process.exitCode = 1 |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +module.exports = { |
| 185 | + buildRefreshedCurrentTruthRecord, |
| 186 | + refreshProfileCurrentTruth, |
| 187 | + replaceCurrentTruthBlock |
| 188 | +} |
0 commit comments