feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -0,0 +1,138 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(import.meta.url);
const webRoot = path.resolve(path.dirname(scriptPath), "..");
const sourceRoot = path.join(webRoot, "src");
const productionExtensions = new Set([".css", ".ts", ".tsx"]);
// 광고 차단 목록은 이 접두사의 CSS class/id를 일반 규칙으로 숨길 수 있다.
// 외부 목록을 빌드 시 내려받지 않고, 결정적인 로컬 계약으로 위험 namespace를 금지한다.
const dangerousUiIdentifier =
/^(?:ad|ads|advert|advertisement|advertising|sponsor|sponsored)(?:[-_]|$)/i;
function lineAt(source, index) {
return source.slice(0, index).split("\n").length;
}
function recordFinding(findings, seen, source, filePath, token, index) {
if (!dangerousUiIdentifier.test(token)) return;
const line = lineAt(source, index);
const key = `${filePath}:${line}:${token}`;
if (seen.has(key)) return;
seen.add(key);
findings.push({ filePath, line, token });
}
export function findDangerousUiIdentifiers(source, filePath) {
const findings = [];
const seen = new Set();
const extension = path.extname(filePath).toLowerCase();
if (extension === ".css") {
for (const match of source.matchAll(/([.#])([A-Za-z_][A-Za-z0-9_-]*)/g)) {
recordFinding(findings, seen, source, filePath, match[2], match.index + 1);
}
}
// TS/TSX/HTML의 정적 문자열에서 class/id 후보를 찾는다. `/admin` 같은 경로는
// `admin`으로 분리되어 허용되고, `upload-card`처럼 중간에 ad가 있는 이름도 허용된다.
for (const literal of source.matchAll(/(["'`])([^"'`\r\n]*)\1/g)) {
const content = literal[2];
const contentOffset = literal.index + 1;
for (const tokenMatch of content.matchAll(/[A-Za-z_][A-Za-z0-9_-]*/g)) {
recordFinding(
findings,
seen,
source,
filePath,
tokenMatch[0],
contentOffset + tokenMatch.index,
);
}
}
return findings;
}
function runSelfTest() {
const rejected = [
['<main className="ad-root ad-section">', "fixture.tsx", ["ad-root", "ad-section"]],
[".ads-panel, #sponsor-slot { display: block; }", "fixture.css", ["ads-panel", "sponsor-slot"]],
];
const accepted = [
['<main className="vgops-root upload-card head-faceless">', "fixture.tsx"],
[".vgops-root .brow-sad-left { display: block; }", "fixture.css"],
['const route = "/admin";', "fixture.ts"],
];
for (const [source, filePath, expectedTokens] of rejected) {
const actualTokens = findDangerousUiIdentifiers(source, filePath).map(
(finding) => finding.token,
);
for (const expectedToken of expectedTokens) {
if (!actualTokens.includes(expectedToken)) {
throw new Error(`자체 검사 실패: ${expectedToken} 위험 식별자를 잡지 못했습니다.`);
}
}
}
for (const [source, filePath] of accepted) {
const findings = findDangerousUiIdentifiers(source, filePath);
if (findings.length > 0) {
throw new Error(`자체 검사 실패: 안전한 식별자를 거부했습니다. ${JSON.stringify(findings)}`);
}
}
}
async function listProductionFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await listProductionFiles(absolutePath)));
} else if (productionExtensions.has(path.extname(entry.name).toLowerCase())) {
files.push(absolutePath);
}
}
return files;
}
async function checkProductionSources() {
const files = [...(await listProductionFiles(sourceRoot)), path.join(webRoot, "index.html")];
const findings = [];
for (const absolutePath of files) {
const source = await fs.readFile(absolutePath, "utf8");
const relativePath = path.relative(webRoot, absolutePath).replaceAll("\\", "/");
findings.push(...findDangerousUiIdentifiers(source, relativePath));
}
if (findings.length > 0) {
console.error(
[
"광고 차단 필터 안전성 검사 실패",
...findings.map(
({ filePath, line, token }) =>
`- ${filePath}:${line}: ${JSON.stringify(token)}은(는) EasyList 계열 일반 숨김 규칙과 충돌할 수 있습니다. 제품 전용 중립 namespace를 사용하세요.`,
),
].join("\n"),
);
process.exitCode = 1;
return;
}
console.log(
`광고 차단 필터 안전성 검사 통과: 프로덕션 파일 ${files.length}개에서 위험 UI namespace가 없습니다.`,
);
}
runSelfTest();
if (process.argv.includes("--self-test")) {
console.log("광고 차단 필터 안전성 자체 검사 통과");
} else {
await checkProductionSources();
}