356 lines
11 KiB
JavaScript
356 lines
11 KiB
JavaScript
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const { execSync } = require('child_process');
|
||
|
|
|
||
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||
|
|
|
||
|
|
function readJson(filePath) {
|
||
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
|
|
}
|
||
|
|
|
||
|
|
function ensureDir(dir) {
|
||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
function rmDir(dir) {
|
||
|
|
if (!fs.existsSync(dir)) return;
|
||
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePosix(p) {
|
||
|
|
return p.split(path.sep).join('/');
|
||
|
|
}
|
||
|
|
|
||
|
|
function isUnder(child, parent) {
|
||
|
|
const rel = path.relative(parent, child);
|
||
|
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||
|
|
}
|
||
|
|
|
||
|
|
function listFilesRec(dir) {
|
||
|
|
const out = [];
|
||
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
|
|
for (const ent of entries) {
|
||
|
|
const p = path.join(dir, ent.name);
|
||
|
|
if (ent.isDirectory()) out.push(...listFilesRec(p));
|
||
|
|
else if (ent.isFile()) out.push(p);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function globToRegex(glob) {
|
||
|
|
// Supports "*" within a single segment and "**" for any depth.
|
||
|
|
const norm = normalizePosix(glob);
|
||
|
|
const parts = norm.split('/').filter(p => p.length > 0);
|
||
|
|
const out = [];
|
||
|
|
|
||
|
|
for (const part of parts) {
|
||
|
|
if (part === '**') {
|
||
|
|
// any number of path segments
|
||
|
|
out.push('(?:.*)');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
// Escape regex special chars, then expand "*" wildcards within segment.
|
||
|
|
const esc = part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
|
||
|
|
out.push(esc);
|
||
|
|
}
|
||
|
|
|
||
|
|
const re = out.join('\\/');
|
||
|
|
return new RegExp(`^${re}$`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function matchesAnyGlobs(relPath, globs) {
|
||
|
|
const p = normalizePosix(relPath);
|
||
|
|
for (const g of globs || []) {
|
||
|
|
const re = globToRegex(g);
|
||
|
|
if (re.test(p)) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function copyFile(srcAbs, destAbs) {
|
||
|
|
ensureDir(path.dirname(destAbs));
|
||
|
|
fs.copyFileSync(srcAbs, destAbs);
|
||
|
|
}
|
||
|
|
|
||
|
|
function copyEntry(spec, outDirAbs) {
|
||
|
|
const copied = [];
|
||
|
|
|
||
|
|
// Directory glob
|
||
|
|
if (spec.includes('*')) {
|
||
|
|
const all = listFilesRec(REPO_ROOT);
|
||
|
|
const includeRe = globToRegex(spec);
|
||
|
|
for (const abs of all) {
|
||
|
|
const rel = normalizePosix(path.relative(REPO_ROOT, abs));
|
||
|
|
if (!includeRe.test(rel)) continue;
|
||
|
|
const destAbs = path.join(outDirAbs, rel);
|
||
|
|
copyFile(abs, destAbs);
|
||
|
|
copied.push(rel);
|
||
|
|
}
|
||
|
|
return copied;
|
||
|
|
}
|
||
|
|
|
||
|
|
const srcAbs = path.join(REPO_ROOT, spec);
|
||
|
|
if (!fs.existsSync(srcAbs)) return [];
|
||
|
|
|
||
|
|
const st = fs.statSync(srcAbs);
|
||
|
|
if (st.isFile()) {
|
||
|
|
const rel = normalizePosix(spec);
|
||
|
|
copyFile(srcAbs, path.join(outDirAbs, rel));
|
||
|
|
copied.push(rel);
|
||
|
|
return copied;
|
||
|
|
}
|
||
|
|
if (st.isDirectory()) {
|
||
|
|
const files = listFilesRec(srcAbs);
|
||
|
|
for (const abs of files) {
|
||
|
|
const rel = normalizePosix(path.relative(REPO_ROOT, abs));
|
||
|
|
copyFile(abs, path.join(outDirAbs, rel));
|
||
|
|
copied.push(rel);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return copied;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyRewrite(outDirAbs, rewrite) {
|
||
|
|
const rules = rewrite || {};
|
||
|
|
for (const [relFile, cfg] of Object.entries(rules)) {
|
||
|
|
const target = path.join(outDirAbs, relFile);
|
||
|
|
if (!fs.existsSync(target)) continue;
|
||
|
|
let content = fs.readFileSync(target, 'utf8');
|
||
|
|
const reps = (cfg && cfg.replace) || [];
|
||
|
|
for (const r of reps) {
|
||
|
|
const from = String(r.from || '');
|
||
|
|
const to = String(r.to || '');
|
||
|
|
if (!from) continue;
|
||
|
|
content = content.split(from).join(to);
|
||
|
|
}
|
||
|
|
fs.writeFileSync(target, content, 'utf8');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function rewritePackageJson(outDirAbs) {
|
||
|
|
const p = path.join(outDirAbs, 'package.json');
|
||
|
|
if (!fs.existsSync(p)) return;
|
||
|
|
try {
|
||
|
|
const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||
|
|
pkg.scripts = {
|
||
|
|
start: 'node index.js',
|
||
|
|
run: 'node index.js run',
|
||
|
|
solidify: 'node index.js solidify',
|
||
|
|
review: 'node index.js review',
|
||
|
|
'a2a:export': 'node scripts/a2a_export.js',
|
||
|
|
'a2a:ingest': 'node scripts/a2a_ingest.js',
|
||
|
|
'a2a:promote': 'node scripts/a2a_promote.js',
|
||
|
|
};
|
||
|
|
fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
||
|
|
} catch (e) {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseSemver(v) {
|
||
|
|
const m = String(v || '').trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||
|
|
if (!m) return null;
|
||
|
|
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatSemver(x) {
|
||
|
|
return `${x.major}.${x.minor}.${x.patch}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function bumpSemver(base, bump) {
|
||
|
|
const v = parseSemver(base);
|
||
|
|
if (!v) return null;
|
||
|
|
if (bump === 'major') return `${v.major + 1}.0.0`;
|
||
|
|
if (bump === 'minor') return `${v.major}.${v.minor + 1}.0`;
|
||
|
|
if (bump === 'patch') return `${v.major}.${v.minor}.${v.patch + 1}`;
|
||
|
|
return formatSemver(v);
|
||
|
|
}
|
||
|
|
|
||
|
|
function git(cmd) {
|
||
|
|
return execSync(cmd, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getBaseReleaseCommit() {
|
||
|
|
// Prefer last "prepare vX.Y.Z" commit; fallback to HEAD~50 range later.
|
||
|
|
try {
|
||
|
|
const hash = git('git log -n 1 --pretty=%H --grep="chore(release): prepare v"');
|
||
|
|
return hash || null;
|
||
|
|
} catch (e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getCommitSubjectsSince(baseCommit) {
|
||
|
|
try {
|
||
|
|
if (!baseCommit) {
|
||
|
|
const out = git('git log -n 30 --pretty=%s');
|
||
|
|
return out ? out.split('\n').filter(Boolean) : [];
|
||
|
|
}
|
||
|
|
const out = git(`git log ${baseCommit}..HEAD --pretty=%s`);
|
||
|
|
return out ? out.split('\n').filter(Boolean) : [];
|
||
|
|
} catch (e) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function inferBumpFromSubjects(subjects) {
|
||
|
|
const subs = (subjects || []).map(s => String(s));
|
||
|
|
const hasBreaking = subs.some(s => /\bBREAKING CHANGE\b/i.test(s) || /^[a-z]+(\(.+\))?!:/.test(s));
|
||
|
|
if (hasBreaking) return { bump: 'major', reason: 'breaking change marker in commit subject' };
|
||
|
|
|
||
|
|
const hasFeat = subs.some(s => /^feat(\(.+\))?:/i.test(s));
|
||
|
|
if (hasFeat) return { bump: 'minor', reason: 'feature commit detected (feat:)' };
|
||
|
|
|
||
|
|
const hasFix = subs.some(s => /^(fix|perf)(\(.+\))?:/i.test(s));
|
||
|
|
if (hasFix) return { bump: 'patch', reason: 'fix/perf commit detected' };
|
||
|
|
|
||
|
|
if (subs.length === 0) return { bump: 'none', reason: 'no commits since base release commit' };
|
||
|
|
return { bump: 'patch', reason: 'default to patch for non-breaking changes' };
|
||
|
|
}
|
||
|
|
|
||
|
|
function suggestVersion() {
|
||
|
|
const pkgPath = path.join(REPO_ROOT, 'package.json');
|
||
|
|
let baseVersion = null;
|
||
|
|
try {
|
||
|
|
baseVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version;
|
||
|
|
} catch (e) {}
|
||
|
|
|
||
|
|
const baseCommit = getBaseReleaseCommit();
|
||
|
|
const subjects = getCommitSubjectsSince(baseCommit);
|
||
|
|
const decision = inferBumpFromSubjects(subjects);
|
||
|
|
|
||
|
|
let suggested = null;
|
||
|
|
if (decision.bump === 'none') suggested = baseVersion;
|
||
|
|
else suggested = bumpSemver(baseVersion, decision.bump);
|
||
|
|
|
||
|
|
return { baseVersion, baseCommit, subjects, decision, suggestedVersion: suggested };
|
||
|
|
}
|
||
|
|
|
||
|
|
function writePrivateSemverNote(note) {
|
||
|
|
const privateDir = path.join(REPO_ROOT, 'memory');
|
||
|
|
ensureDir(privateDir);
|
||
|
|
fs.writeFileSync(path.join(privateDir, 'semver_suggestion.json'), JSON.stringify(note, null, 2) + '\n', 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function writePrivateSemverPrompt(note) {
|
||
|
|
const privateDir = path.join(REPO_ROOT, 'memory');
|
||
|
|
ensureDir(privateDir);
|
||
|
|
const subjects = Array.isArray(note.subjects) ? note.subjects : [];
|
||
|
|
const semverRule = [
|
||
|
|
'MAJOR.MINOR.PATCH',
|
||
|
|
'- MAJOR: incompatible changes',
|
||
|
|
'- MINOR: backward-compatible features',
|
||
|
|
'- PATCH: backward-compatible bug fixes',
|
||
|
|
].join('\n');
|
||
|
|
|
||
|
|
const prompt = [
|
||
|
|
'You are a release versioning assistant.',
|
||
|
|
'Decide the next version bump using SemVer rules below.',
|
||
|
|
'',
|
||
|
|
semverRule,
|
||
|
|
'',
|
||
|
|
`Base version: ${note.baseVersion || '(unknown)'}`,
|
||
|
|
`Base commit: ${note.baseCommit || '(unknown)'}`,
|
||
|
|
'',
|
||
|
|
'Recent commit subjects (newest first):',
|
||
|
|
...subjects.map(s => `- ${s}`),
|
||
|
|
'',
|
||
|
|
'Output JSON only:',
|
||
|
|
'{ "bump": "major|minor|patch|none", "suggestedVersion": "x.y.z", "reason": ["..."] }',
|
||
|
|
].join('\n');
|
||
|
|
|
||
|
|
fs.writeFileSync(path.join(privateDir, 'semver_prompt.md'), prompt + '\n', 'utf8');
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeDistVersion(outDirAbs, version) {
|
||
|
|
if (!version) return;
|
||
|
|
const p = path.join(outDirAbs, 'package.json');
|
||
|
|
if (!fs.existsSync(p)) return;
|
||
|
|
try {
|
||
|
|
const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||
|
|
pkg.version = version;
|
||
|
|
fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
||
|
|
} catch (e) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
function pruneExcluded(outDirAbs, excludeGlobs) {
|
||
|
|
const all = listFilesRec(outDirAbs);
|
||
|
|
for (const abs of all) {
|
||
|
|
const rel = normalizePosix(path.relative(outDirAbs, abs));
|
||
|
|
if (matchesAnyGlobs(rel, excludeGlobs)) {
|
||
|
|
fs.rmSync(abs, { force: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateNoPrivatePaths(outDirAbs) {
|
||
|
|
// Basic safeguard: forbid docs/ and memory/ in output.
|
||
|
|
const forbiddenPrefixes = ['docs/', 'memory/'];
|
||
|
|
const all = listFilesRec(outDirAbs);
|
||
|
|
for (const abs of all) {
|
||
|
|
const rel = normalizePosix(path.relative(outDirAbs, abs));
|
||
|
|
for (const pref of forbiddenPrefixes) {
|
||
|
|
if (rel.startsWith(pref)) {
|
||
|
|
throw new Error(`Build validation failed: forbidden path in output: ${rel}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const manifestPath = path.join(REPO_ROOT, 'public.manifest.json');
|
||
|
|
const manifest = readJson(manifestPath);
|
||
|
|
const outDir = String(manifest.outDir || 'dist-public');
|
||
|
|
const outDirAbs = path.join(REPO_ROOT, outDir);
|
||
|
|
|
||
|
|
// SemVer suggestion (private). This does not modify the source repo version.
|
||
|
|
const semver = suggestVersion();
|
||
|
|
writePrivateSemverNote(semver);
|
||
|
|
writePrivateSemverPrompt(semver);
|
||
|
|
|
||
|
|
rmDir(outDirAbs);
|
||
|
|
ensureDir(outDirAbs);
|
||
|
|
|
||
|
|
const include = manifest.include || [];
|
||
|
|
const exclude = manifest.exclude || [];
|
||
|
|
|
||
|
|
const copied = [];
|
||
|
|
for (const spec of include) {
|
||
|
|
copied.push(...copyEntry(spec, outDirAbs));
|
||
|
|
}
|
||
|
|
|
||
|
|
pruneExcluded(outDirAbs, exclude);
|
||
|
|
applyRewrite(outDirAbs, manifest.rewrite);
|
||
|
|
rewritePackageJson(outDirAbs);
|
||
|
|
|
||
|
|
// Prefer explicit version; otherwise use suggested version.
|
||
|
|
const releaseVersion = process.env.RELEASE_VERSION || semver.suggestedVersion;
|
||
|
|
if (releaseVersion) writeDistVersion(outDirAbs, releaseVersion);
|
||
|
|
|
||
|
|
validateNoPrivatePaths(outDirAbs);
|
||
|
|
|
||
|
|
// Write build manifest for private verification (do not include in dist-public/).
|
||
|
|
const buildInfo = {
|
||
|
|
built_at: new Date().toISOString(),
|
||
|
|
outDir,
|
||
|
|
files: copied.sort(),
|
||
|
|
};
|
||
|
|
const privateDir = path.join(REPO_ROOT, 'memory');
|
||
|
|
ensureDir(privateDir);
|
||
|
|
fs.writeFileSync(path.join(privateDir, 'public_build_info.json'), JSON.stringify(buildInfo, null, 2) + '\n', 'utf8');
|
||
|
|
|
||
|
|
process.stdout.write(`Built public output at ${outDir}\n`);
|
||
|
|
if (semver && semver.suggestedVersion) {
|
||
|
|
process.stdout.write(`Suggested version: ${semver.suggestedVersion}\n`);
|
||
|
|
process.stdout.write(`SemVer decision: ${semver.decision ? semver.decision.bump : 'unknown'}\n`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
main();
|
||
|
|
} catch (e) {
|
||
|
|
process.stderr.write(`${e.message}\n`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|