1916 lines
60 KiB
JavaScript
1916 lines
60 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 DEFAULT_USE_COMPAT = true;
|
|
const DEFAULT_COMPAT_FILE = 'tools/compare-command-constraints.compat.json';
|
|
|
|
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.
|
|
--show-compat Print compatibility-matched pairs.
|
|
--no-compat Disable compatibility alias rules (default: enabled).
|
|
--compat-file <path> Compatibility rules JSON file (default: tools/compare-command-constraints.compat.json).
|
|
--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 showCompat = args.includes('--show-compat');
|
|
const useCompat = DEFAULT_USE_COMPAT && !args.includes('--no-compat');
|
|
const compatFile = readArgValue('--compat-file') ?? DEFAULT_COMPAT_FILE;
|
|
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);
|
|
}
|
|
|
|
let compatibilityRules = [];
|
|
let loadedCompatFile = null;
|
|
|
|
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 stripWrappingQuotes = (value) => {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
if (
|
|
(text.startsWith('\'') && text.endsWith('\'')) ||
|
|
(text.startsWith('"') && text.endsWith('"')) ||
|
|
(text.startsWith('`') && text.endsWith('`'))
|
|
) {
|
|
return text.slice(1, -1);
|
|
}
|
|
return text;
|
|
};
|
|
|
|
const parseNumberishArg = (value) => {
|
|
const text = stripWrappingQuotes(value);
|
|
if (!/^[-+]?\d+(\.\d+)?$/.test(text)) {
|
|
return null;
|
|
}
|
|
const parsed = Number(text);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
|
|
const normalizeFieldToken = (value) => stripWrappingQuotes(value).replace(/_/g, '').toLowerCase();
|
|
const normalizeCompToken = (value) => stripWrappingQuotes(value).trim();
|
|
const normalizeRuleConstraintName = (side, name) => canonicalizeConstraintName(side, normalizeConstraintName(name));
|
|
|
|
const normalizeByTransform = (value, transform) => {
|
|
if (transform === 'field') {
|
|
return normalizeFieldToken(value);
|
|
}
|
|
if (transform === 'comp') {
|
|
return normalizeCompToken(value);
|
|
}
|
|
if (transform === 'raw') {
|
|
return String(value ?? '').trim();
|
|
}
|
|
if (transform === 'lower') {
|
|
return stripWrappingQuotes(value).toLowerCase();
|
|
}
|
|
return stripWrappingQuotes(value);
|
|
};
|
|
|
|
const toArrayOrEmpty = (value) => {
|
|
if (!value) {
|
|
return [];
|
|
}
|
|
return Array.isArray(value) ? value : [];
|
|
};
|
|
|
|
const toRuleNameSet = (side, spec, ruleId) => {
|
|
const names = [];
|
|
if (typeof spec.name === 'string') {
|
|
names.push(spec.name);
|
|
}
|
|
for (const item of toArrayOrEmpty(spec.nameAny)) {
|
|
if (typeof item === 'string') {
|
|
names.push(item);
|
|
}
|
|
}
|
|
if (names.length === 0) {
|
|
throw new Error(`Compatibility rule "${ruleId}" must have "${side}.name" or "${side}.nameAny".`);
|
|
}
|
|
const normalizedNames = names.map((name) => normalizeRuleConstraintName(side, name)).filter((name) => !!name);
|
|
if (normalizedNames.length === 0) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has no valid "${side}" constraint name.`);
|
|
}
|
|
return new Set(normalizedNames);
|
|
};
|
|
|
|
const parseRuleIndex = (value, ruleId, kind) => {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has invalid ${kind} index: ${String(value)}`);
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const compileRuleSideSpec = (side, sideSpec, ruleId) => {
|
|
if (!sideSpec || typeof sideSpec !== 'object' || Array.isArray(sideSpec)) {
|
|
throw new Error(`Compatibility rule "${ruleId}" is missing valid "${side}" spec.`);
|
|
}
|
|
|
|
const names = toRuleNameSet(side, sideSpec, ruleId);
|
|
const equals = toArrayOrEmpty(sideSpec.equals).map((cond, idx) => {
|
|
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has invalid ${side}.equals[${idx}]`);
|
|
}
|
|
const index = parseRuleIndex(cond.index, ruleId, `${side}.equals[${idx}]`);
|
|
const transform = typeof cond.transform === 'string' ? cond.transform : 'text';
|
|
const hasValue = Object.prototype.hasOwnProperty.call(cond, 'value');
|
|
const valueAnyRaw = toArrayOrEmpty(cond.valueAny);
|
|
const valueAny = valueAnyRaw.map((item) => normalizeByTransform(item, transform));
|
|
if (!hasValue && valueAny.length === 0) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has no value in ${side}.equals[${idx}]`);
|
|
}
|
|
const expected = hasValue ? normalizeByTransform(cond.value, transform) : null;
|
|
return {
|
|
index,
|
|
transform,
|
|
expected,
|
|
expectedAny: valueAny,
|
|
};
|
|
});
|
|
|
|
const numberEquals = toArrayOrEmpty(sideSpec.numberEquals).map((cond, idx) => {
|
|
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has invalid ${side}.numberEquals[${idx}]`);
|
|
}
|
|
const index = parseRuleIndex(cond.index, ruleId, `${side}.numberEquals[${idx}]`);
|
|
const expected = Number(cond.value);
|
|
if (!Number.isFinite(expected)) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has invalid number in ${side}.numberEquals[${idx}]`);
|
|
}
|
|
return { index, expected };
|
|
});
|
|
|
|
const numberBinds = toArrayOrEmpty(sideSpec.numberBinds).map((cond, idx) => {
|
|
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has invalid ${side}.numberBinds[${idx}]`);
|
|
}
|
|
const index = parseRuleIndex(cond.index, ruleId, `${side}.numberBinds[${idx}]`);
|
|
const bind = typeof cond.bind === 'string' ? cond.bind.trim() : '';
|
|
if (!bind) {
|
|
throw new Error(`Compatibility rule "${ruleId}" has empty bind in ${side}.numberBinds[${idx}]`);
|
|
}
|
|
return { index, bind };
|
|
});
|
|
|
|
return {
|
|
names,
|
|
equals,
|
|
numberEquals,
|
|
numberBinds,
|
|
};
|
|
};
|
|
|
|
const compileCompatibilityRules = (parsed) => {
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('Compatibility file must be a JSON object.');
|
|
}
|
|
const rawRules = toArrayOrEmpty(parsed.rules);
|
|
const compiled = [];
|
|
for (let idx = 0; idx < rawRules.length; idx += 1) {
|
|
const rule = rawRules[idx];
|
|
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
|
throw new Error(`Compatibility rule at index ${idx} must be an object.`);
|
|
}
|
|
const ruleId =
|
|
typeof rule.id === 'string' && rule.id.trim().length > 0 ? rule.id.trim() : `compat_rule_${idx + 1}`;
|
|
compiled.push({
|
|
id: ruleId,
|
|
noteTemplate: typeof rule.noteTemplate === 'string' ? rule.noteTemplate : '',
|
|
php: compileRuleSideSpec('php', rule.php, ruleId),
|
|
ts: compileRuleSideSpec('ts', rule.ts, ruleId),
|
|
});
|
|
}
|
|
return compiled;
|
|
};
|
|
|
|
const formatCompatibilityNote = (template, bindings) => {
|
|
if (!template) {
|
|
const keys = Object.keys(bindings);
|
|
if (keys.length === 0) {
|
|
return '';
|
|
}
|
|
return keys.map((key) => `${key}=${bindings[key]}`).join(', ');
|
|
}
|
|
return template.replace(/\{([A-Za-z0-9_]+)\}/g, (_match, key) => {
|
|
const value = bindings[key];
|
|
return value === undefined ? '' : String(value);
|
|
});
|
|
};
|
|
|
|
const matchRuleSide = (entry, sideSpec, initialBindings) => {
|
|
if (!sideSpec.names.has(entry.normalizedName)) {
|
|
return null;
|
|
}
|
|
const bindings = { ...initialBindings };
|
|
for (const eq of sideSpec.equals) {
|
|
if (eq.index >= entry.args.length) {
|
|
return null;
|
|
}
|
|
const actual = normalizeByTransform(entry.args[eq.index], eq.transform);
|
|
if (eq.expected !== null && actual === eq.expected) {
|
|
continue;
|
|
}
|
|
if (eq.expectedAny.length > 0 && eq.expectedAny.includes(actual)) {
|
|
continue;
|
|
}
|
|
return null;
|
|
}
|
|
for (const cond of sideSpec.numberEquals) {
|
|
if (cond.index >= entry.args.length) {
|
|
return null;
|
|
}
|
|
const actual = parseNumberishArg(entry.args[cond.index]);
|
|
if (actual === null || actual !== cond.expected) {
|
|
return null;
|
|
}
|
|
}
|
|
for (const bind of sideSpec.numberBinds) {
|
|
if (bind.index >= entry.args.length) {
|
|
return null;
|
|
}
|
|
const actual = parseNumberishArg(entry.args[bind.index]);
|
|
if (actual === null) {
|
|
return null;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(bindings, bind.bind) && bindings[bind.bind] !== actual) {
|
|
return null;
|
|
}
|
|
bindings[bind.bind] = actual;
|
|
}
|
|
return bindings;
|
|
};
|
|
|
|
const findCompatibilityMatch = (phpEntry, tsEntry) => {
|
|
for (const rule of compatibilityRules) {
|
|
const afterPhp = matchRuleSide(phpEntry, rule.php, {});
|
|
if (!afterPhp) {
|
|
continue;
|
|
}
|
|
const afterTs = matchRuleSide(tsEntry, rule.ts, afterPhp);
|
|
if (!afterTs) {
|
|
continue;
|
|
}
|
|
return {
|
|
ruleId: rule.id,
|
|
note: formatCompatibilityNote(rule.noteTemplate, afterTs),
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const loadCompatibilityRules = async () => {
|
|
compatibilityRules = [];
|
|
loadedCompatFile = null;
|
|
if (!useCompat) {
|
|
return;
|
|
}
|
|
const resolvedFile = path.resolve(ROOT_DIR, compatFile);
|
|
const text = await fs.readFile(resolvedFile, 'utf-8');
|
|
const parsed = JSON.parse(text);
|
|
compatibilityRules = compileCompatibilityRules(parsed);
|
|
loadedCompatFile = path.relative(ROOT_DIR, resolvedFile);
|
|
};
|
|
|
|
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 pairCompatibleMatches = (phpIndex, tsIndex, missing, extra) => {
|
|
const usedTs = new Set();
|
|
const compatible = [];
|
|
const unresolvedMissing = [];
|
|
|
|
for (const phpName of missing) {
|
|
const phpEntries = phpIndex.get(phpName) ?? [];
|
|
let selected = null;
|
|
|
|
for (const tsName of extra) {
|
|
if (usedTs.has(tsName)) {
|
|
continue;
|
|
}
|
|
const tsEntries = tsIndex.get(tsName) ?? [];
|
|
for (const phpEntry of phpEntries) {
|
|
for (const tsEntry of tsEntries) {
|
|
const matched = findCompatibilityMatch(phpEntry, tsEntry);
|
|
if (!matched) {
|
|
continue;
|
|
}
|
|
selected = {
|
|
phpName,
|
|
tsName,
|
|
ruleId: matched.ruleId,
|
|
note: matched.note,
|
|
phpEntry,
|
|
tsEntry,
|
|
};
|
|
break;
|
|
}
|
|
if (selected) {
|
|
break;
|
|
}
|
|
}
|
|
if (selected) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selected) {
|
|
unresolvedMissing.push(phpName);
|
|
continue;
|
|
}
|
|
usedTs.add(selected.tsName);
|
|
compatible.push(selected);
|
|
}
|
|
|
|
const unresolvedExtra = extra.filter((name) => !usedTs.has(name));
|
|
return { compatible, 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 = [];
|
|
let compatible = [];
|
|
|
|
if (!strict) {
|
|
if (useCompat) {
|
|
const compatPair = pairCompatibleMatches(phpIndex, tsIndex, missingInTs, extraInTs);
|
|
compatible = compatPair.compatible;
|
|
missingInTs = compatPair.unresolvedMissing;
|
|
extraInTs = compatPair.unresolvedExtra;
|
|
}
|
|
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,
|
|
compatible,
|
|
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,
|
|
compatibleMatch: comparedCommands.reduce((sum, command) => {
|
|
const activeKinds = mode === 'all' ? ['full', 'min'] : [mode];
|
|
let count = 0;
|
|
for (const kind of activeKinds) {
|
|
count += command[kind]?.compatible?.length ?? 0;
|
|
}
|
|
return sum + count;
|
|
}, 0),
|
|
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'}, compat: ${useCompat ? 'on' : 'off'}, similarity: ${similarityThreshold})`
|
|
);
|
|
if (useCompat) {
|
|
console.log(`Compatibility rules: ${compatibilityRules.length} (${loadedCompatFile ?? compatFile})`);
|
|
}
|
|
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(`Compatibility match: ${report.totals.compatibleMatch}`);
|
|
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.compatible.length > 0) {
|
|
console.log(` Compatibility match (${result.compatible.length}):`);
|
|
for (const compat of result.compatible) {
|
|
console.log(
|
|
` - PHP "${compat.phpName}" <-> TS "${compat.tsName}" via ${compat.ruleId} (${compat.note})`
|
|
);
|
|
}
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showCompat) {
|
|
const activeKinds = mode === 'all' ? ['full', 'min'] : [mode];
|
|
const compatLines = [];
|
|
for (const command of report.commands) {
|
|
for (const kind of activeKinds) {
|
|
const list = command[kind]?.compatible ?? [];
|
|
for (const compat of list) {
|
|
compatLines.push(
|
|
`${command.key} [${kind}] PHP "${compat.phpName}" <-> TS "${compat.tsName}" via ${compat.ruleId} (${compat.note})`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (compatLines.length > 0) {
|
|
console.log('\nCompatibility details:');
|
|
for (const line of compatLines) {
|
|
console.log(`- ${line}`);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const main = async () => {
|
|
await loadCompatibilityRules();
|
|
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);
|
|
});
|