mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:58:17 +08:00
244 lines
7.7 KiB
JavaScript
244 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
require('ts-node/register/transpile-only');
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const sqlite3 = require('sqlite3');
|
|
const { QueryTypes, Sequelize } = require('sequelize');
|
|
const {
|
|
LegacySequelizeShadowTerminalDifferenceSource,
|
|
} = require('../back/runtime/adapters/legacy-sequelize/legacyShadowTerminalDifferenceSource');
|
|
const {
|
|
LegacyShadowTerminalDifferenceAuditor,
|
|
} = require('../back/runtime/application/legacyShadowTerminalDifferenceAuditor');
|
|
const {
|
|
createLegacyLogArtifactId,
|
|
} = require('../back/runtime/compatibility/legacyTaskRevision');
|
|
|
|
const SUPPORTED_ORIGINS = new Set([
|
|
'boot',
|
|
'manual',
|
|
'scheduled_node',
|
|
'scheduled_system',
|
|
'script',
|
|
'subscription',
|
|
'system',
|
|
]);
|
|
|
|
function numericArgument(name, value, minimum, maximum) {
|
|
if (!/^\d+$/.test(value)) {
|
|
throw new Error(`${name} must be an integer`);
|
|
}
|
|
const parsed = Number(value);
|
|
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
throw new Error(`${name} must be between ${minimum} and ${maximum}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const options = {
|
|
database: path.resolve(process.cwd(), 'data/db/database.sqlite'),
|
|
profile: 'edge',
|
|
projectId: 'default',
|
|
origins: [],
|
|
minimumSettlingAgeMs: 5 * 60_000,
|
|
correlationToleranceMs: 2_000,
|
|
json: false,
|
|
failOnDifference: false,
|
|
};
|
|
for (const argument of argv) {
|
|
if (argument === '--') continue;
|
|
if (argument === '--json') {
|
|
options.json = true;
|
|
} else if (argument === '--fail-on-difference') {
|
|
options.failOnDifference = true;
|
|
} else if (argument.startsWith('--database=')) {
|
|
const value = argument.slice('--database='.length);
|
|
if (!value) throw new Error('--database must not be empty');
|
|
options.database = path.resolve(value);
|
|
} else if (argument.startsWith('--profile=')) {
|
|
const value = argument.slice('--profile='.length);
|
|
if (value !== 'edge' && value !== 'standalone') {
|
|
throw new Error('--profile must be edge or standalone');
|
|
}
|
|
options.profile = value;
|
|
} else if (argument.startsWith('--project=')) {
|
|
const value = argument.slice('--project='.length);
|
|
if (!value || value.length > 128) {
|
|
throw new Error('--project length must be between 1 and 128');
|
|
}
|
|
options.projectId = value;
|
|
} else if (argument.startsWith('--origin=')) {
|
|
const values = argument
|
|
.slice('--origin='.length)
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
if (values.length === 0) throw new Error('--origin must not be empty');
|
|
for (const value of values) {
|
|
if (!SUPPORTED_ORIGINS.has(value)) {
|
|
throw new Error(`unsupported Shadow origin: ${value}`);
|
|
}
|
|
if (!options.origins.includes(value)) options.origins.push(value);
|
|
}
|
|
} else if (argument.startsWith('--window-start-ms=')) {
|
|
options.windowStartMs = numericArgument(
|
|
'--window-start-ms',
|
|
argument.slice('--window-start-ms='.length),
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
);
|
|
} else if (argument.startsWith('--window-end-ms=')) {
|
|
options.windowEndMs = numericArgument(
|
|
'--window-end-ms',
|
|
argument.slice('--window-end-ms='.length),
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
);
|
|
} else if (argument.startsWith('--observed-at-ms=')) {
|
|
options.observedAtMs = numericArgument(
|
|
'--observed-at-ms',
|
|
argument.slice('--observed-at-ms='.length),
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
);
|
|
} else if (argument.startsWith('--minimum-settling-age-ms=')) {
|
|
options.minimumSettlingAgeMs = numericArgument(
|
|
'--minimum-settling-age-ms',
|
|
argument.slice('--minimum-settling-age-ms='.length),
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
);
|
|
} else if (argument.startsWith('--correlation-tolerance-ms=')) {
|
|
options.correlationToleranceMs = numericArgument(
|
|
'--correlation-tolerance-ms',
|
|
argument.slice('--correlation-tolerance-ms='.length),
|
|
0,
|
|
60_000,
|
|
);
|
|
} else {
|
|
throw new Error(`Unsupported argument: ${argument}`);
|
|
}
|
|
}
|
|
if (options.origins.length === 0) {
|
|
throw new Error('at least one --origin is required');
|
|
}
|
|
if (options.windowStartMs === undefined) {
|
|
throw new Error('--window-start-ms is required');
|
|
}
|
|
if (options.windowEndMs === undefined) {
|
|
throw new Error('--window-end-ms is required');
|
|
}
|
|
if (options.windowStartMs >= options.windowEndMs) {
|
|
throw new Error('measurement window must be non-empty');
|
|
}
|
|
return options;
|
|
}
|
|
|
|
async function assertRequiredSchema(database) {
|
|
const rows = await database.query(
|
|
`SELECT name
|
|
FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name IN ('Runs', 'RunAttempts', 'RunningInstances')`,
|
|
{ type: QueryTypes.SELECT },
|
|
);
|
|
const names = new Set(rows.map((row) => row.name));
|
|
for (const required of ['Runs', 'RunAttempts', 'RunningInstances']) {
|
|
if (!names.has(required)) {
|
|
throw new Error(
|
|
`Database is not ready for Legacy Shadow terminal audit: missing ${required}`,
|
|
);
|
|
}
|
|
}
|
|
const columns = await database
|
|
.getQueryInterface()
|
|
.describeTable('RunningInstances');
|
|
for (const required of ['run_id', 'attempt_id']) {
|
|
if (!columns[required]) {
|
|
throw new Error(
|
|
`Database is not ready for Legacy Shadow terminal audit: missing RunningInstances.${required}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderText(report) {
|
|
const lines = [
|
|
'QingLong 3.0 Legacy Shadow terminal audit',
|
|
`profile: ${report.profile}`,
|
|
`window: [${report.window.startInclusiveMs}, ${report.window.endExclusiveMs})`,
|
|
`window closed: ${report.window.closed}`,
|
|
`coverage: ${report.coverage.direction} (${report.coverage.legacyWithoutShadow})`,
|
|
`assessment: ${report.assessment}`,
|
|
`pages/scanned: ${report.pages}/${report.scanned}`,
|
|
`remaining: ${report.remaining}`,
|
|
`evidence complete: ${report.evidenceComplete}`,
|
|
];
|
|
for (const [category, count] of Object.entries(report.counts)) {
|
|
if (count > 0) lines.push(`${category}: ${count}`);
|
|
}
|
|
if (report.terminalAgreementPermille !== undefined) {
|
|
lines.push(
|
|
`terminal agreement: ${report.terminalAgreementPermille}/1000`,
|
|
`fully comparable: ${report.fullyComparablePermille}/1000`,
|
|
);
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
if (nodeMajor < 24) {
|
|
throw new Error(
|
|
'Legacy Shadow terminal audit requires Node.js 24 or newer',
|
|
);
|
|
}
|
|
if (!fs.existsSync(options.database)) {
|
|
throw new Error(`Database does not exist: ${options.database}`);
|
|
}
|
|
const database = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage: options.database,
|
|
logging: false,
|
|
dialectOptions: { mode: sqlite3.OPEN_READONLY },
|
|
pool: { max: 1, min: 0, idle: 1_000, acquire: 5_000 },
|
|
});
|
|
try {
|
|
await assertRequiredSchema(database);
|
|
const auditor = new LegacyShadowTerminalDifferenceAuditor(
|
|
new LegacySequelizeShadowTerminalDifferenceSource(
|
|
database,
|
|
createLegacyLogArtifactId,
|
|
),
|
|
);
|
|
const report = await auditor.run(options);
|
|
process.stdout.write(
|
|
options.json ? `${JSON.stringify(report)}\n` : renderText(report),
|
|
);
|
|
if (options.failOnDifference && report.assessment !== 'matched') {
|
|
process.exitCode = 1;
|
|
}
|
|
} finally {
|
|
await database.close();
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
process.stderr.write(
|
|
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
assertRequiredSchema,
|
|
parseArguments,
|
|
renderText,
|
|
};
|