Files
core2026/tools/compare-command-constraints.mjs
T

1555 lines
47 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import ts from 'typescript';
const ROOT_DIR = process.cwd();
const PHP_ROOT = path.join(ROOT_DIR, 'legacy', 'hwe', 'sammo', 'Command');
const TS_ROOT = path.join(ROOT_DIR, 'packages', 'logic', 'src', 'actions');
const DEFAULT_MODE = 'all';
const DEFAULT_SIMILARITY = 0.6;
const HELP_TEXT = `
Usage: node tools/compare-command-constraints.mjs [options]
Compare pair:
TS buildConstraints <-> PHP fullConditionConstraints
TS buildMinConstraints <-> PHP minConditionConstraints
Options:
--mode all|full|min Compare all (default), only full, or only min constraints.
--include <regex> Include command keys matching regex (e.g. "General/che_훈련").
--strict Compare with argument signatures.
--json Print JSON report.
--show-matches Print matched command keys.
--similarity <0..1> Near-match threshold for non-strict mode (default: 0.6).
--help Show this help.
`;
const args = process.argv.slice(2);
if (args.includes('--help')) {
console.log(HELP_TEXT.trim());
process.exit(0);
}
const readArgValue = (flag) => {
const idx = args.indexOf(flag);
if (idx < 0) {
return null;
}
return args[idx + 1] ?? null;
};
const mode = readArgValue('--mode') ?? DEFAULT_MODE;
if (!['all', 'full', 'min'].includes(mode)) {
console.error(`Invalid --mode: ${mode}`);
console.error(HELP_TEXT.trim());
process.exit(1);
}
const includePattern = readArgValue('--include');
const includeRegex = includePattern ? new RegExp(includePattern) : null;
const strict = args.includes('--strict');
const asJson = args.includes('--json');
const showMatches = args.includes('--show-matches');
const similarityArg = readArgValue('--similarity');
const similarityThreshold = similarityArg === null ? DEFAULT_SIMILARITY : Number(similarityArg);
if (!Number.isFinite(similarityThreshold) || similarityThreshold < 0 || similarityThreshold > 1) {
console.error(`Invalid --similarity: ${similarityArg}`);
process.exit(1);
}
const collectFiles = async (dir) => {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectFiles(fullPath)));
} else {
files.push(fullPath);
}
}
return files;
};
const maskPhpComments = (text) => {
const chars = [...text];
let i = 0;
let quote = null;
let escaped = false;
const maskRange = (start, end) => {
for (let idx = start; idx < end; idx += 1) {
if (chars[idx] !== '\n') {
chars[idx] = ' ';
}
}
};
while (i < chars.length) {
const ch = chars[i];
const next = i + 1 < chars.length ? chars[i + 1] : '';
if (quote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === quote) {
quote = null;
}
i += 1;
continue;
}
if (ch === '\'' || ch === '"') {
quote = ch;
i += 1;
continue;
}
if (ch === '/' && next === '/') {
const start = i;
i += 2;
while (i < chars.length && chars[i] !== '\n') {
i += 1;
}
maskRange(start, i);
continue;
}
if (ch === '#') {
const start = i;
i += 1;
while (i < chars.length && chars[i] !== '\n') {
i += 1;
}
maskRange(start, i);
continue;
}
if (ch === '/' && next === '*') {
const start = i;
i += 2;
while (i < chars.length - 1) {
if (chars[i] === '*' && chars[i + 1] === '/') {
i += 2;
break;
}
i += 1;
}
maskRange(start, i);
continue;
}
i += 1;
}
return chars.join('');
};
const buildLineIndex = (text) => {
const starts = [0];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') {
starts.push(i + 1);
}
}
return starts;
};
const getLineNumber = (lineStarts, index) => {
let low = 0;
let high = lineStarts.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (lineStarts[mid] <= index && (mid === lineStarts.length - 1 || lineStarts[mid + 1] > index)) {
return mid + 1;
}
if (lineStarts[mid] > index) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return 1;
};
const scanToDelimiter = (text, startIndex, delimiter) => {
let i = startIndex;
let depth = 0;
let quote = null;
let escaped = false;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === quote) {
quote = null;
}
i += 1;
continue;
}
if (ch === '\'' || ch === '"') {
quote = ch;
i += 1;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth += 1;
} else if (ch === ')' || ch === ']' || ch === '}') {
if (depth > 0) {
depth -= 1;
}
} else if (ch === delimiter && depth === 0) {
return { value: text.slice(startIndex, i), endIndex: i + 1 };
}
i += 1;
}
return { value: text.slice(startIndex), endIndex: text.length };
};
const scanToParenEnd = (text, startIndex) => {
let i = startIndex;
let depth = 0;
let quote = null;
let escaped = false;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === quote) {
quote = null;
}
i += 1;
continue;
}
if (ch === '\'' || ch === '"') {
quote = ch;
i += 1;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth += 1;
} else if (ch === ')' || ch === ']' || ch === '}') {
if (depth > 0) {
depth -= 1;
} else if (ch === ')') {
return { value: text.slice(startIndex, i), endIndex: i + 1 };
}
}
i += 1;
}
return { value: text.slice(startIndex), endIndex: text.length };
};
const splitTopLevel = (text, delimiter) => {
const parts = [];
let current = '';
let depth = 0;
let quote = null;
let escaped = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (quote) {
current += ch;
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === quote) {
quote = null;
}
continue;
}
if (ch === '\'' || ch === '"') {
quote = ch;
current += ch;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') {
depth += 1;
} else if (ch === ')' || ch === ']' || ch === '}') {
if (depth > 0) {
depth -= 1;
}
} else if (ch === delimiter && depth === 0) {
parts.push(current);
current = '';
continue;
}
current += ch;
}
if (current) {
parts.push(current);
}
return parts;
};
const normalizeConstraintName = (name) => {
const trimmed = String(name ?? '').trim();
if (!trimmed) {
return '';
}
const base = trimmed.includes('::') ? trimmed.split('::').pop() : trimmed;
return base[0]?.toLowerCase() + base.slice(1);
};
const ALWAYS_FAIL_TS_ALIASES = new Set([
'denywithreason',
'notselfdestgeneral',
'targetmustnotbelord',
'reqminimumtreatyterm',
'reqfuturetreatyterm',
'reqvalidstrategiccommandtype',
'hasroutetodestcity',
'requirecapitalcity',
'reqaidwithinlimit',
]);
const canonicalizeConstraintName = (side, normalizedName) => {
if (side === 'ts' && ALWAYS_FAIL_TS_ALIASES.has(normalizedName.toLowerCase())) {
return 'alwaysFail';
}
return normalizedName;
};
const splitNameTokens = (name) => {
if (!name) {
return [];
}
const spaced = String(name)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[^A-Za-z0-9가-힣]+/g, ' ')
.trim()
.toLowerCase();
const rawTokens = spaced ? spaced.split(/\s+/g) : [];
const tokens = [];
for (let i = 0; i < rawTokens.length; i += 1) {
const token = rawTokens[i];
if (!token) {
continue;
}
if (token === 'different') {
tokens.push('notsame');
continue;
}
if (token === 'exists' || token === 'exist') {
tokens.push('exist');
continue;
}
if (token === 'not' && rawTokens[i + 1] === 'same') {
tokens.push('notsame');
i += 1;
continue;
}
tokens.push(token);
}
return tokens;
};
const nameSimilarity = (a, b) => {
const setA = new Set(splitNameTokens(a));
const setB = new Set(splitNameTokens(b));
if (setA.size === 0 || setB.size === 0) {
return 0;
}
let common = 0;
for (const item of setA) {
if (setB.has(item)) {
common += 1;
}
}
const unionSize = new Set([...setA, ...setB]).size;
return unionSize > 0 ? common / unionSize : 0;
};
const normalizePhpArgument = (argText) => {
let out = String(argText ?? '').trim();
if (!out) {
return '';
}
out = out.replace(/\s+/g, ' ');
out = out.replace(/\$this->arg\[[^\]]+\]/g, '$arg');
out = out.replace(/\$this->[A-Za-z_][A-Za-z0-9_]*/g, '$this');
out = out.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, '$');
out = out.replace(/\s*\.\s*/g, '.');
return out;
};
const normalizeTsExpression = (expr) => {
if (!expr) {
return '';
}
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
return `'${expr.text}'`;
}
if (ts.isNumericLiteral(expr)) {
return expr.text;
}
if (expr.kind === ts.SyntaxKind.TrueKeyword) {
return 'true';
}
if (expr.kind === ts.SyntaxKind.FalseKeyword) {
return 'false';
}
if (expr.kind === ts.SyntaxKind.NullKeyword) {
return 'null';
}
if (ts.isIdentifier(expr)) {
return '$';
}
if (ts.isPropertyAccessExpression(expr)) {
return '$';
}
if (ts.isElementAccessExpression(expr)) {
return '$';
}
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
return 'fn';
}
if (ts.isObjectLiteralExpression(expr)) {
const keys = [];
for (const prop of expr.properties) {
if (ts.isPropertyAssignment(prop)) {
if (ts.isIdentifier(prop.name)) {
keys.push(prop.name.text);
} else if (ts.isStringLiteral(prop.name)) {
keys.push(prop.name.text);
}
}
}
return `{${keys.sort().join(',')}}`;
}
if (ts.isArrayLiteralExpression(expr)) {
return `[${expr.elements.length}]`;
}
if (ts.isTemplateExpression(expr)) {
return '`template`';
}
if (ts.isParenthesizedExpression(expr)) {
return normalizeTsExpression(expr.expression);
}
if (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) {
return normalizeTsExpression(expr.expression);
}
if (ts.isConditionalExpression(expr)) {
return `cond(${normalizeTsExpression(expr.whenTrue)}|${normalizeTsExpression(expr.whenFalse)})`;
}
if (ts.isCallExpression(expr)) {
const callee = ts.isIdentifier(expr.expression)
? expr.expression.text
: ts.isPropertyAccessExpression(expr.expression)
? expr.expression.name.text
: 'call';
return `${callee}(${expr.arguments.map((arg) => normalizeTsExpression(arg)).join(',')})`;
}
return 'expr';
};
const makeConstraintEntry = ({ side, kind, name, args, file, line, raw }) => {
const normalizedName = canonicalizeConstraintName(side, normalizeConstraintName(name));
const argsKey = args?.length ? args.join('|') : '';
return {
side,
kind,
name,
normalizedName,
args: args ?? [],
argsKey,
signature: `${normalizedName}(${argsKey})`,
file,
line,
raw: String(raw ?? '').trim(),
};
};
const tryParsePhpConstraintCallAt = (text, startIndex, side, kind, file, lineStarts, baseOffset = 0) => {
const helperPrefix = 'ConstraintHelper::';
if (!text.startsWith(helperPrefix, startIndex)) {
return null;
}
const nameStart = startIndex + helperPrefix.length;
let i = nameStart;
while (i < text.length && /[A-Za-z0-9_]/.test(text[i])) {
i += 1;
}
const rawName = text.slice(nameStart, i);
while (i < text.length && /\s/.test(text[i])) {
i += 1;
}
if (text[i] !== '(') {
return null;
}
const { value: argBody, endIndex } = scanToParenEnd(text, i + 1);
const argParts = splitTopLevel(argBody, ',')
.map((arg) => normalizePhpArgument(arg))
.filter((arg) => arg.length > 0);
const line = getLineNumber(lineStarts, startIndex + baseOffset);
const raw = text.slice(startIndex, endIndex);
return {
entry: makeConstraintEntry({
side,
kind,
name: rawName,
args: argParts,
file,
line,
raw,
}),
endIndex,
};
};
const extractPhpConstraintsFromExpression = (text, side, kind, file, lineStarts, baseOffset = 0) => {
const entries = [];
let i = 0;
while (i < text.length) {
const parsed = tryParsePhpConstraintCallAt(text, i, side, kind, file, lineStarts, baseOffset);
if (parsed) {
entries.push(parsed.entry);
i = parsed.endIndex;
continue;
}
i += 1;
}
return entries;
};
const extractPhpFileConstraints = (file, text) => {
const lineStarts = buildLineIndex(text);
const byKind = {
full: [],
min: [],
};
const assigned = {
full: false,
min: false,
};
const regex = /\$this->(fullConditionConstraints|minConditionConstraints)\s*(\[\s*\])?\s*=/g;
while (true) {
const match = regex.exec(text);
if (!match) {
break;
}
const target = match[1] === 'fullConditionConstraints' ? 'full' : 'min';
assigned[target] = true;
const start = match.index + match[0].length;
const { value, endIndex } = scanToDelimiter(text, start, ';');
const expr = value.trim();
if (!expr) {
regex.lastIndex = endIndex;
continue;
}
if (/^\$this->(fullConditionConstraints|minConditionConstraints)\s*$/i.test(expr)) {
const src = /minConditionConstraints/.test(expr) ? 'min' : 'full';
byKind[target].push(...byKind[src]);
regex.lastIndex = endIndex;
continue;
}
const extracted = extractPhpConstraintsFromExpression(
value,
'php',
target,
path.relative(ROOT_DIR, file),
lineStarts,
start
);
byKind[target].push(...extracted);
regex.lastIndex = endIndex;
}
const classMatch =
/class\s+([\\\p{L}_][\\\p{L}\p{N}_]*)\s+extends\s+([\\\p{L}_][\\\p{L}\p{N}_]*)/mu.exec(text);
const parentClass = classMatch?.[2]?.split('\\').pop() ?? null;
return {
...byKind,
assigned,
parentClass,
};
};
const resolvePhpInheritance = (byCommand) => {
const resolved = new Map();
const visiting = new Set();
const resolveOne = (key) => {
if (resolved.has(key)) {
return resolved.get(key);
}
const current = byCommand.get(key);
if (!current) {
const empty = { full: [], min: [], assigned: { full: true, min: true }, parentClass: null };
resolved.set(key, empty);
return empty;
}
if (visiting.has(key)) {
return current;
}
visiting.add(key);
let parentResolved = null;
if (current.parentClass) {
const dir = key.split('/')[0];
const parentKey = `${dir}/${current.parentClass}`;
if (byCommand.has(parentKey)) {
parentResolved = resolveOne(parentKey);
}
}
const merged = {
...current,
full:
current.assigned?.full === true
? current.full
: parentResolved?.full
? [...parentResolved.full]
: current.full,
min:
current.assigned?.min === true
? current.min
: parentResolved?.min
? [...parentResolved.min]
: current.min,
};
resolved.set(key, merged);
visiting.delete(key);
return merged;
};
for (const key of byCommand.keys()) {
resolveOne(key);
}
return resolved;
};
const readStringLikeProperty = (objLiteral, keyName) => {
for (const prop of objLiteral.properties) {
if (!ts.isPropertyAssignment(prop)) {
continue;
}
const key = ts.isIdentifier(prop.name)
? prop.name.text
: ts.isStringLiteral(prop.name)
? prop.name.text
: null;
if (key !== keyName) {
continue;
}
if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) {
return prop.initializer.text;
}
}
return null;
};
const isConstraintLikeArrow = (expr) => {
if (!ts.isArrowFunction(expr) && !ts.isFunctionExpression(expr)) {
return false;
}
if (expr.type && /Constraint/.test(expr.type.getText())) {
return true;
}
if (!expr.body) {
return false;
}
if (ts.isObjectLiteralExpression(expr.body)) {
return readStringLikeProperty(expr.body, 'name') !== null;
}
if (ts.isBlock(expr.body)) {
for (const stmt of expr.body.statements) {
if (!ts.isReturnStatement(stmt) || !stmt.expression) {
continue;
}
if (ts.isObjectLiteralExpression(stmt.expression)) {
if (readStringLikeProperty(stmt.expression, 'name') !== null) {
return true;
}
}
}
}
return false;
};
const collectTsConstraintFactories = (sourceFile) => {
const imported = new Set();
const local = new Set();
const visitImports = (node) => {
if (ts.isImportDeclaration(node)) {
const moduleText = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : '';
if (moduleText.includes('/constraints/')) {
const bindings = node.importClause?.namedBindings;
if (bindings && ts.isNamedImports(bindings)) {
for (const element of bindings.elements) {
imported.add(element.name.text);
}
}
}
}
ts.forEachChild(node, visitImports);
};
const visitLocal = (node) => {
if (ts.isFunctionDeclaration(node) && node.name) {
if (node.type && /Constraint/.test(node.type.getText(sourceFile))) {
local.add(node.name.text);
} else if (node.body) {
for (const stmt of node.body.statements) {
if (!ts.isReturnStatement(stmt) || !stmt.expression) {
continue;
}
if (ts.isObjectLiteralExpression(stmt.expression)) {
if (readStringLikeProperty(stmt.expression, 'name') !== null) {
local.add(node.name.text);
}
}
}
}
}
if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || !decl.initializer) {
continue;
}
if (isConstraintLikeArrow(decl.initializer)) {
local.add(decl.name.text);
}
}
}
ts.forEachChild(node, visitLocal);
};
visitImports(sourceFile);
visitLocal(sourceFile);
return new Set([...imported, ...local]);
};
const extractTsCallEntry = (callExpr, sourceFile, side, kind, file) => {
let callName = null;
if (ts.isIdentifier(callExpr.expression)) {
callName = callExpr.expression.text;
} else if (ts.isPropertyAccessExpression(callExpr.expression)) {
callName = callExpr.expression.name.text;
}
if (!callName) {
return null;
}
const args = callExpr.arguments.map((arg) => normalizeTsExpression(arg)).filter((item) => item.length > 0);
const { line } = sourceFile.getLineAndCharacterOfPosition(callExpr.getStart(sourceFile));
return makeConstraintEntry({
side,
kind,
name: callName,
args,
file,
line: line + 1,
raw: callExpr.getText(sourceFile),
});
};
const extractTsObjectConstraintEntry = (objExpr, sourceFile, side, kind, file) => {
const name = readStringLikeProperty(objExpr, 'name');
if (!name) {
return null;
}
const { line } = sourceFile.getLineAndCharacterOfPosition(objExpr.getStart(sourceFile));
return makeConstraintEntry({
side,
kind,
name,
args: [],
file,
line: line + 1,
raw: objExpr.getText(sourceFile),
});
};
const extractTsConstraintEntriesFromExpr = (expr, sourceFile, side, kind, file, factorySet, variableMap, visited) => {
const results = [];
if (!expr) {
return results;
}
if (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) {
return extractTsConstraintEntriesFromExpr(
expr.expression,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
visited
);
}
if (ts.isArrayLiteralExpression(expr)) {
for (const element of expr.elements) {
if (ts.isSpreadElement(element)) {
results.push(
...extractTsConstraintEntriesFromExpr(
element.expression,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
visited
)
);
continue;
}
results.push(
...extractTsConstraintEntriesFromExpr(
element,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
visited
)
);
}
return results;
}
if (ts.isConditionalExpression(expr)) {
results.push(
...extractTsConstraintEntriesFromExpr(
expr.whenTrue,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
visited
)
);
results.push(
...extractTsConstraintEntriesFromExpr(
expr.whenFalse,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
visited
)
);
return results;
}
if (ts.isIdentifier(expr)) {
const key = expr.text;
if (visited.has(key)) {
return results;
}
const saved = variableMap.get(key);
if (!saved) {
return results;
}
visited.add(key);
results.push(...saved);
visited.delete(key);
return results;
}
if (ts.isObjectLiteralExpression(expr)) {
const entry = extractTsObjectConstraintEntry(expr, sourceFile, side, kind, file);
if (entry) {
results.push(entry);
}
return results;
}
if (ts.isCallExpression(expr)) {
let callName = null;
if (ts.isIdentifier(expr.expression)) {
callName = expr.expression.text;
} else if (ts.isPropertyAccessExpression(expr.expression)) {
callName = expr.expression.name.text;
}
if (!callName) {
return results;
}
const looksConstraintFactory =
factorySet.has(callName) ||
/^(req|not|be|exists|exist|friendly|different|always|near|allow|disallow|must|check|occupied|supplied|hasRoute|available)/i.test(
callName
);
if (looksConstraintFactory) {
const entry = extractTsCallEntry(expr, sourceFile, side, kind, file);
if (entry) {
results.push(entry);
}
}
return results;
}
return results;
};
const dedupeEntries = (entries) => {
const map = new Map();
for (const entry of entries) {
const key = `${entry.signature}|${entry.file}|${entry.line}`;
if (!map.has(key)) {
map.set(key, entry);
}
}
return [...map.values()];
};
const extractTsMethodConstraints = (methodDecl, sourceFile, side, kind, file, factorySet) => {
const results = [];
const variableMap = new Map();
const saveVarEntries = (name, entries) => {
const prev = variableMap.get(name) ?? [];
prev.push(...entries);
variableMap.set(name, dedupeEntries(prev));
};
const visit = (node) => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
const varName = node.name.text;
const entries = extractTsConstraintEntriesFromExpr(
node.initializer,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
new Set()
);
if (entries.length > 0 || /constraint/i.test(varName)) {
saveVarEntries(varName, entries);
}
}
if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isIdentifier(node.left)
) {
const varName = node.left.text;
const entries = extractTsConstraintEntriesFromExpr(
node.right,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
new Set()
);
if (entries.length > 0 || variableMap.has(varName) || /constraint/i.test(varName)) {
variableMap.set(varName, dedupeEntries(entries));
}
}
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const target = node.expression.expression;
const methodName = node.expression.name.text;
if (methodName === 'push' && ts.isIdentifier(target)) {
const arrayName = target.text;
if (variableMap.has(arrayName) || /constraint/i.test(arrayName)) {
const pushed = [];
for (const arg of node.arguments) {
pushed.push(
...extractTsConstraintEntriesFromExpr(
arg,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
new Set()
)
);
}
if (pushed.length > 0) {
saveVarEntries(arrayName, pushed);
}
}
}
}
if (ts.isReturnStatement(node) && node.expression) {
const returnEntries = extractTsConstraintEntriesFromExpr(
node.expression,
sourceFile,
side,
kind,
file,
factorySet,
variableMap,
new Set()
);
results.push(...returnEntries);
}
ts.forEachChild(node, visit);
};
if (methodDecl.body) {
for (const stmt of methodDecl.body.statements) {
visit(stmt);
}
}
return dedupeEntries(results);
};
const normalizeTsImportToFile = (baseFile, specifier) => {
if (!specifier.startsWith('.')) {
return null;
}
const resolved = path.resolve(path.dirname(baseFile), specifier);
if (resolved.endsWith('.js')) {
return `${resolved.slice(0, -3)}.ts`;
}
if (resolved.endsWith('.ts')) {
return resolved;
}
return `${resolved}.ts`;
};
const extractTsFileConstraints = (file, text) => {
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const factorySet = collectTsConstraintFactories(sourceFile);
const relFile = path.relative(ROOT_DIR, file);
const imports = new Map();
const visitImport = (node) => {
if (ts.isImportDeclaration(node)) {
const moduleText = ts.isStringLiteral(node.moduleSpecifier) ? node.moduleSpecifier.text : null;
if (moduleText) {
const named = node.importClause?.namedBindings;
if (named && ts.isNamedImports(named)) {
for (const element of named.elements) {
imports.set(element.name.text, moduleText);
}
}
if (node.importClause?.name) {
imports.set(node.importClause.name.text, moduleText);
}
}
}
ts.forEachChild(node, visitImport);
};
visitImport(sourceFile);
const byKind = {
full: [],
min: [],
};
const assigned = {
full: false,
min: false,
};
let parentFile = null;
const visit = (node) => {
if (!parentFile && ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
const callee = node.initializer.expression;
if (ts.isIdentifier(callee)) {
const moduleText = imports.get(callee.text);
if (moduleText) {
parentFile = normalizeTsImportToFile(file, moduleText);
}
}
}
if (ts.isClassDeclaration(node)) {
const className = node.name?.text ?? null;
if (className === 'ActionDefinition' && node.heritageClauses) {
for (const heritage of node.heritageClauses) {
if (heritage.token !== ts.SyntaxKind.ExtendsKeyword) {
continue;
}
const typeNode = heritage.types[0];
if (!typeNode) {
continue;
}
const expr = typeNode.expression;
if (ts.isIdentifier(expr)) {
const moduleText = imports.get(expr.text);
if (moduleText) {
parentFile = normalizeTsImportToFile(file, moduleText);
}
}
}
}
}
if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) {
const name = node.name.text;
if (name === 'buildConstraints') {
assigned.full = true;
byKind.full.push(
...extractTsMethodConstraints(node, sourceFile, 'ts', 'full', relFile, factorySet)
);
}
if (name === 'buildMinConstraints') {
assigned.min = true;
byKind.min.push(
...extractTsMethodConstraints(node, sourceFile, 'ts', 'min', relFile, factorySet)
);
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
byKind.full = dedupeEntries(byKind.full);
byKind.min = dedupeEntries(byKind.min);
return {
...byKind,
assigned,
parentFile,
};
};
const includeCommand = (key) => {
if (!includeRegex) {
return true;
}
return includeRegex.test(key);
};
const loadPhpConstraints = async () => {
const files = (await collectFiles(PHP_ROOT)).filter((file) => file.endsWith('.php'));
const byCommand = new Map();
for (const file of files) {
const baseName = path.basename(file, '.php');
if (['BaseCommand', 'GeneralCommand', 'NationCommand'].includes(baseName)) {
continue;
}
const relPath = path.relative(PHP_ROOT, file);
const dirName = relPath.split(path.sep)[0];
if (!['General', 'Nation'].includes(dirName)) {
continue;
}
const key = `${dirName}/${baseName}`;
if (!includeCommand(key)) {
continue;
}
const text = await fs.readFile(file, 'utf-8');
const extracted = extractPhpFileConstraints(file, maskPhpComments(text));
byCommand.set(key, extracted);
}
return resolvePhpInheritance(byCommand);
};
const loadTsConstraints = async () => {
const files = (await collectFiles(TS_ROOT)).filter((file) => file.endsWith('.ts'));
const byFile = new Map();
const commandFileByKey = new Map();
for (const file of files) {
const text = await fs.readFile(file, 'utf-8');
const extracted = extractTsFileConstraints(file, text);
byFile.set(file, extracted);
const baseName = path.basename(file, '.ts');
if (!/^che_|^cr_|^event_|^휴식$/.test(baseName)) {
continue;
}
const relPath = path.relative(TS_ROOT, file).split(path.sep).join('/');
let key = null;
if (relPath.startsWith('turn/general/') || relPath.startsWith('instant/general/')) {
key = `General/${baseName}`;
} else if (relPath.startsWith('turn/nation/') || relPath.startsWith('instant/nation/')) {
key = `Nation/${baseName}`;
}
if (!key || !includeCommand(key)) {
continue;
}
commandFileByKey.set(key, file);
}
const resolvedByFile = new Map();
const resolving = new Set();
const resolveFile = (file) => {
if (resolvedByFile.has(file)) {
return resolvedByFile.get(file);
}
const current = byFile.get(file);
if (!current) {
const empty = {
full: [],
min: [],
assigned: { full: true, min: true },
parentFile: null,
};
resolvedByFile.set(file, empty);
return empty;
}
if (resolving.has(file)) {
return current;
}
resolving.add(file);
let parentResolved = null;
if (current.parentFile && byFile.has(current.parentFile)) {
parentResolved = resolveFile(current.parentFile);
}
const merged = {
...current,
full:
current.assigned?.full === true
? current.full
: parentResolved?.full
? [...parentResolved.full]
: current.full,
min:
current.assigned?.min === true
? current.min
: parentResolved?.min
? [...parentResolved.min]
: current.min,
};
resolvedByFile.set(file, merged);
resolving.delete(file);
return merged;
};
const byCommand = new Map();
for (const [key, file] of commandFileByKey.entries()) {
byCommand.set(key, resolveFile(file));
}
return byCommand;
};
const indexByName = (entries) => {
const map = new Map();
for (const entry of entries) {
const key = strict ? entry.signature : entry.normalizedName;
const list = map.get(key) ?? [];
list.push(entry);
map.set(key, list);
}
return map;
};
const pairNearMatches = (missing, extra) => {
const usedTs = new Set();
const near = [];
const unresolvedMissing = [];
for (const phpName of missing) {
let best = null;
for (const tsName of extra) {
if (usedTs.has(tsName)) {
continue;
}
const score = nameSimilarity(phpName, tsName);
if (!best || score > best.score) {
best = { phpName, tsName, score };
}
}
if (best && best.score >= similarityThreshold) {
near.push(best);
usedTs.add(best.tsName);
} else {
unresolvedMissing.push(phpName);
}
}
const unresolvedExtra = extra.filter((name) => !usedTs.has(name));
return { near, unresolvedMissing, unresolvedExtra };
};
const compareConstraintSet = (phpEntries, tsEntries, kind) => {
const phpIndex = indexByName(phpEntries);
const tsIndex = indexByName(tsEntries);
const phpKeys = [...phpIndex.keys()].sort();
const tsKeys = [...tsIndex.keys()].sort();
let missingInTs = phpKeys.filter((key) => !tsIndex.has(key));
let extraInTs = tsKeys.filter((key) => !phpIndex.has(key));
let near = [];
if (!strict) {
const nearPair = pairNearMatches(missingInTs, extraInTs);
near = nearPair.near;
missingInTs = nearPair.unresolvedMissing;
extraInTs = nearPair.unresolvedExtra;
}
let status = 'MATCH';
if (missingInTs.length > 0 || extraInTs.length > 0) {
status = 'MISMATCH';
} else if (near.length > 0) {
status = 'NEAR_MATCH';
}
const argDiffs = [];
if (!strict) {
const common = phpKeys.filter((key) => tsIndex.has(key));
for (const key of common) {
const phpArgSet = new Set((phpIndex.get(key) ?? []).map((entry) => entry.argsKey));
const tsArgSet = new Set((tsIndex.get(key) ?? []).map((entry) => entry.argsKey));
const phpArgs = [...phpArgSet].sort();
const tsArgs = [...tsArgSet].sort();
if (phpArgs.length === 1 && tsArgs.length === 1 && phpArgs[0] !== tsArgs[0]) {
argDiffs.push({
name: key,
php: phpArgs[0],
ts: tsArgs[0],
});
}
}
}
return {
kind,
status,
phpCount: phpEntries.length,
tsCount: tsEntries.length,
phpUnique: phpKeys.length,
tsUnique: tsKeys.length,
missingInTs,
extraInTs,
near,
argDiffs,
phpIndex,
tsIndex,
};
};
const buildReport = (phpByCommand, tsByCommand) => {
const allKeys = [...new Set([...phpByCommand.keys(), ...tsByCommand.keys()])].sort();
const commands = [];
const missingCommandInTs = [];
const missingCommandInPhp = [];
for (const key of allKeys) {
const php = phpByCommand.get(key);
const tsEntry = tsByCommand.get(key);
if (!php) {
missingCommandInPhp.push(key);
continue;
}
if (!tsEntry) {
missingCommandInTs.push(key);
continue;
}
const fullResult = compareConstraintSet(php.full ?? [], tsEntry.full ?? [], 'full');
const minResult = compareConstraintSet(php.min ?? [], tsEntry.min ?? [], 'min');
let overallStatus = 'MATCH';
const activeResults = [];
if (mode === 'all' || mode === 'full') {
activeResults.push(fullResult);
}
if (mode === 'all' || mode === 'min') {
activeResults.push(minResult);
}
if (activeResults.some((result) => result.status === 'MISMATCH')) {
overallStatus = 'MISMATCH';
} else if (activeResults.some((result) => result.status === 'NEAR_MATCH')) {
overallStatus = 'NEAR_MATCH';
}
commands.push({
key,
status: overallStatus,
full: fullResult,
min: minResult,
});
}
const comparedCommands = commands.filter((command) => {
if (mode === 'full') {
return true;
}
if (mode === 'min') {
return true;
}
return true;
});
const totals = {
phpCommands: phpByCommand.size,
tsCommands: tsByCommand.size,
comparedCommands: comparedCommands.length,
match: comparedCommands.filter((item) => item.status === 'MATCH').length,
nearMatch: comparedCommands.filter((item) => item.status === 'NEAR_MATCH').length,
mismatch: comparedCommands.filter((item) => item.status === 'MISMATCH').length,
missingCommandInTs: missingCommandInTs.length,
missingCommandInPhp: missingCommandInPhp.length,
};
return {
totals,
missingCommandInTs,
missingCommandInPhp,
commands: comparedCommands,
};
};
const renderEntryBucket = (index, name) => {
const list = index.get(name) ?? [];
if (list.length === 0) {
return '-';
}
const first = list[0];
const extra = list.length > 1 ? ` (+${list.length - 1} more)` : '';
if (strict) {
return `${first.file}:${first.line}${extra} | ${name}`;
}
return `${first.file}:${first.line}${extra} | ${first.name}`;
};
const printReport = (report) => {
console.log(
`Compare command constraints (mode: ${mode}, strict: ${strict ? 'on' : 'off'}, similarity: ${similarityThreshold})`
);
console.log(`PHP commands: ${report.totals.phpCommands}`);
console.log(`TS commands: ${report.totals.tsCommands}`);
console.log(`Compared commands: ${report.totals.comparedCommands}`);
console.log(`Match: ${report.totals.match}`);
console.log(`Near match: ${report.totals.nearMatch}`);
console.log(`Mismatch: ${report.totals.mismatch}`);
console.log(`Missing command in TS: ${report.totals.missingCommandInTs}`);
console.log(`Missing command in PHP: ${report.totals.missingCommandInPhp}`);
if (report.missingCommandInTs.length > 0) {
console.log('\nMissing command in TS:');
for (const key of report.missingCommandInTs) {
console.log(`- ${key}`);
}
}
if (report.missingCommandInPhp.length > 0) {
console.log('\nMissing command in PHP:');
for (const key of report.missingCommandInPhp) {
console.log(`- ${key}`);
}
}
const mismatches = report.commands.filter((item) => item.status !== 'MATCH');
if (mismatches.length > 0) {
console.log('\nDetails:');
for (const command of mismatches) {
console.log(`\n== ${command.key} [${command.status}] ==`);
const kinds = mode === 'all' ? ['full', 'min'] : [mode];
for (const kind of kinds) {
const result = command[kind];
if (!result) {
continue;
}
if (result.status === 'MATCH' && result.argDiffs.length === 0) {
continue;
}
console.log(`- ${kind}: ${result.status}`);
if (result.missingInTs.length > 0) {
console.log(` PHP only (${result.missingInTs.length}):`);
for (const name of result.missingInTs) {
console.log(` - ${renderEntryBucket(result.phpIndex, name)}`);
}
}
if (result.extraInTs.length > 0) {
console.log(` TS only (${result.extraInTs.length}):`);
for (const name of result.extraInTs) {
console.log(` - ${renderEntryBucket(result.tsIndex, name)}`);
}
}
if (result.near.length > 0) {
console.log(` Near match (${result.near.length}):`);
for (const pair of result.near) {
console.log(` - PHP "${pair.phpName}" ~ TS "${pair.tsName}" (score ${pair.score.toFixed(2)})`);
}
}
if (result.argDiffs.length > 0) {
console.log(` Arg diff hints (${result.argDiffs.length}):`);
for (const diff of result.argDiffs) {
console.log(` - ${diff.name}: PHP(${diff.php}) vs TS(${diff.ts})`);
}
}
}
}
} else if (showMatches) {
console.log('\nAll compared commands matched.');
}
if (showMatches) {
const matched = report.commands.filter((item) => item.status === 'MATCH').map((item) => item.key);
if (matched.length > 0) {
console.log('\nMatched commands:');
for (const key of matched) {
console.log(`- ${key}`);
}
}
}
};
const main = async () => {
const [phpByCommand, tsByCommand] = await Promise.all([loadPhpConstraints(), loadTsConstraints()]);
const report = buildReport(phpByCommand, tsByCommand);
if (asJson) {
const jsonFriendly = {
...report,
commands: report.commands.map((command) => ({
key: command.key,
status: command.status,
full: {
...command.full,
phpIndex: undefined,
tsIndex: undefined,
},
min: {
...command.min,
phpIndex: undefined,
tsIndex: undefined,
},
})),
};
console.log(JSON.stringify(jsonFriendly, null, 2));
return;
}
printReport(report);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});