Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,22 @@ jobs:
- name: Check environment-variable documentation coverage
run: bash scripts/check-doc-env-coverage.sh

# Korean prose gate: translation-ese and terminology rules over the
# public docs surface (scripts/ko-style; decisions recorded in
# docs/korean-style-guide.md). S1/S2 findings fail the job. The
# generated fixture proves the linter actually detects violations,
# so a broken rule catalog cannot pass silently.
- name: Check Korean documentation style
run: |
node scripts/ko-style/lint.mjs --all --no-baseline
fixture="$RUNNER_TEMP/ko-style-selftest.md"
printf '%s\n' "스캐너에 의해 생성된 결과가 되어지게 됩니다." "examples 디렉토리에 예제를 추가하고, 지원 종료 뱃지를 확인하세요." > "$fixture"
if node scripts/ko-style/lint.mjs --files "$fixture" --no-baseline >/dev/null 2>&1; then
echo "FAIL: the ko-style self-test fixture did not trip the linter."
exit 1
fi
echo "Self-test OK: the fixture trips the linter as expected."

# Deterministic internal-link gate for the getting-started pages.
- name: Check onboarding internal links
run: bash scripts/check-doc-links.sh
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Closes #42

## 이슈 및 토론

### 버그 리포트
### 버그 제보

[GitHub Issues](https://github.com/sktelecom/bomlens/issues)를 이용해 주세요. 아래 정보를 포함하면 빠른 해결에 도움이 됩니다.

Expand Down
2 changes: 1 addition & 1 deletion docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ SBOM_SCANNER_IMAGE=sbom-scanner:local ./tests/test-scan.sh
- Rust 프로젝트
- Docker 이미지
- 바이너리 파일
- RootFS 디렉토리
- RootFS 디렉터리

### 수동 테스트

Expand Down
2 changes: 1 addition & 1 deletion docs/contribute/testing.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ DEBUG_MODE=true ./tests/cases/test-nodejs.sh
1. `DEBUG_MODE=true` 로 재실행하여 상세 로그를 확인합니다.
2. 실패한 언어의 예제 디렉터리에서 `scan-sbom.sh`를 직접 실행합니다.
3. Docker 이미지를 최신 버전으로 업데이트합니다: `docker pull ghcr.io/sktelecom/bomlens:latest`
4. 해결되지 않으면 [GitHub Issues](https://github.com/sktelecom/bomlens/issues)에 환경 정보와 로그를 첨부해 리포트해 주세요.
4. 해결되지 않으면 [GitHub Issues](https://github.com/sktelecom/bomlens/issues)에 환경 정보와 로그를 첨부해 제보해 주세요.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/guides/ai-model.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ BomLens는 50개 요소를 51개 검사로 보여줍니다. 모델 개방성(가
- EU 인공지능법(AI Act) — Annex IV 기술문서 항목(Regulation (EU) 2024/1689, Article 11(1)).
- AI 기본법(한국) — 투명성(제31조), 안전성과 위험관리(제32조), 고영향 인공지능(제33·34조), 영향평가(제35조) 조항. 이 법은 기본법 성격이라 매핑이 EU 쪽보다 성깁니다.

크로스워크는 준수 판정이 아니라 준비를 돕는 자료입니다. BomLens는 어느 규제의 준수도 인증하지 않으며, 리포트 안에도 그 문구를 명시합니다. 각 매핑에는 그 연결의 해석 근거를 함께 담아 사람이 판단할 수 있게 했고, 크로스워크가 검사 상태나 전체 판정을 바꾸지는 않습니다. 같은 G7 결과를 규제별로 다시 묶어, 프레임워크마다 매핑된 요소 중 충족, 공백, 검토 필요가 몇 건인지 셀 뿐입니다.
크로스워크는 준수 판정이 아니라 준비를 돕는 자료입니다. BomLens는 어느 규제의 준수도 인증하지 않으며, 보고서 안에도 그 문구를 명시합니다. 각 매핑에는 그 연결의 해석 근거를 함께 담아 사람이 판단할 수 있게 했고, 크로스워크가 검사 상태나 전체 판정을 바꾸지는 않습니다. 같은 G7 결과를 규제별로 다시 묶어, 프레임워크마다 매핑된 요소 중 충족, 공백, 검토 필요가 몇 건인지 셀 뿐입니다.

매핑은 G7 요소 id를 키로 삼아 `docker/lib/regulation-crosswalk.json`에 있습니다. 의도적으로 보수적으로 잡아, 근거가 분명한 요소만 매핑합니다(현재 51개 검사 중 23개). 요소 이름이 바뀌어도 크로스워크가 조용히 어긋나지 않도록, 매핑된 모든 id를 레지스트리와 대조하는 테스트를 둡니다.

Expand Down Expand Up @@ -112,7 +112,7 @@ G7 블록의 머리에는 "N / 38 충족" 같은 수치가 옵니다. 분모는

## 한계

- 결과는 HuggingFace 모델 카드만큼만 충실합니다. 카드가 빈약하면 ML-BOM도 빈약하고, G7 검사도 카드에 문서화된 범위를 반영할 뿐 모델 자체를 감사하지는 않습니다. 리포트 생성은 도구의 몫이고, 해석과 검토 전용 13개 요소를 채우는 일은 사람의 몫입니다.
- 결과는 HuggingFace 모델 카드만큼만 충실합니다. 카드가 빈약하면 ML-BOM도 빈약하고, G7 검사도 카드에 문서화된 범위를 반영할 뿐 모델 자체를 감사하지는 않습니다. 보고서 생성은 도구의 몫이고, 해석과 검토 전용 13개 요소를 채우는 일은 사람의 몫입니다.
- 적합성 보고서는 EU 인공지능법을 비롯한 어떤 규제의 준수도 인증하지 않습니다. 문서화 공백을 드러내 사람이 메울 수 있게 할 뿐입니다.
- 메타데이터를 네트워크로 가져오므로, 비공개·게이트 모델은 접근 권한(환경의 HuggingFace 토큰)이 필요하며 오프라인 사용은 지원하지 않습니다.
- 모델 id는 `org/model` 형식이어야 합니다. 컬렉션 이름이나 전체 URL은 해석되지 않습니다.
Expand Down
4 changes: 4 additions & 0 deletions docs/korean-style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@
이 가이드의 패턴 분류와 보존·변경 한도 원칙은 오픈소스
[`epoko77-ai/im-not-ai`](https://github.com/epoko77-ai/im-not-ai)(MIT License)의
한국어 AI 문체 taxonomy를 우리 문서에 맞게 추린 것이다.

번역투와 용어 규칙 중 정규식으로 판별 가능한 것들은 `scripts/ko-style/`의
린터가 CI에서 검사한다(공개 문서 대상, S1/S2 위반 시 실패). 이 가이드 자신은
반례를 인용해야 하므로 검사 대상에서 빠진다.
2 changes: 1 addition & 1 deletion docs/reference/cli.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,6 @@ docker system prune -f

1. `VERBOSE=true ./tests/test-scan.sh` 로 상세 로그를 확인합니다.
2. Docker 이미지를 최신 버전으로 업데이트합니다: `docker pull ghcr.io/sktelecom/bomlens:latest`
3. 해결되지 않으면 [GitHub Issues](https://github.com/sktelecom/bomlens/issues)에 환경 정보와 로그를 첨부해 리포트해 주세요.
3. 해결되지 않으면 [GitHub Issues](https://github.com/sktelecom/bomlens/issues)에 환경 정보와 로그를 첨부해 제보해 주세요.

모드별 사용법은 [입력 시나리오 가이드](../guides/by-input.ko.md), 산출물 종류는 [산출물 레퍼런스](artifacts.ko.md), 언어 감지는 [지원 생태계](ecosystems.ko.md)를 참고하세요.
288 changes: 288 additions & 0 deletions scripts/ko-style/lint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
#!/usr/bin/env node
/**
* ko-style lint — Korean translation-ese (번역투) and terminology linter.
*
* Applies the per-line regex catalog in patterns.json to the public Korean
* documentation surface, after masking regions where rules must not fire:
* code fences, inline code, links/images, bare URLs, HTML comments, YAML
* front-matter, and table rows.
*
* Scope (SCOPE below): the published docs and READMEs. Internal maintainer
* notes (docs/maintainers/), the changelog, license inventories, and
* generated artifacts are deliberately out of scope.
*
* severity: S1 = clear error, S2 = strong recommendation, S3 = advisory.
* The CI gate fails on S1/S2 (see --fail-on). The terminology decisions the
* rules encode are recorded in docs/korean-style-guide.md.
*
* Usage:
* node scripts/ko-style/lint.mjs [--all|--files <p> …] [--format text|json]
* [--fail-on S1|S2|S3] [--no-baseline]
* [--write-baseline]
*
* The rule taxonomy follows epoko77-ai/im-not-ai (MIT), adapted to this
* repository's documents.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Repo root = two levels up from scripts/ko-style/.
export const REPO_ROOT = path.resolve(__dirname, "..", "..");
const PATTERNS_PATH = path.join(__dirname, "patterns.json");
const BASELINE_PATH = path.join(REPO_ROOT, ".ko-style-baseline.json");

// The public documentation surface this gate guards. Directories are walked
// recursively for *.md; files are taken as-is.
const SCOPE = [
"docs",
"README.md",
"CONTRIBUTING.md",
"examples",
"docker",
"electron",
];
// Kept out of the gate: internal maintainer notes (historical records quote
// pre-fix wording), the changelog (quotes history), license inventories, and
// the style guide itself (it must quote the very anti-patterns it bans).
const EXCLUDE = [
"docs/maintainers",
"docs/korean-style-guide.md",
"CHANGELOG.md",
"THIRD_PARTY_LICENSES.md",
];

const IGNORE_DIRS = new Set([
"node_modules", ".git", "dist", "build", ".next", "out",
"vendor", ".venv", "venv", "coverage", ".cache", "test-workspace",
]);

const SEVERITY_RANK = { S1: 3, S2: 2, S3: 1 };

/** Compile the catalog once into { id, category, severity, re, … } entries. */
export function loadRules() {
const raw = JSON.parse(fs.readFileSync(PATTERNS_PATH, "utf8"));
return raw.rules.map((r) => {
if (!SEVERITY_RANK[r.severity]) {
throw new Error(`rule '${r.id}': invalid severity '${r.severity}'`);
}
return { ...r, re: new RegExp(r.pattern, "gu") };
});
}

/** Replace every match with same-length spaces, keeping columns aligned. */
function maskOut(line, re) {
return line.replace(re, (m) => " ".repeat(m.length));
}

/** Blank every open…close span (inclusive) via a single left-to-right scan. */
function maskDelimited(line, open, close) {
let out = "";
let i = 0;
for (;;) {
const start = line.indexOf(open, i);
if (start === -1) return out + line.slice(i);
const end = line.indexOf(close, start + open.length);
if (end === -1) return out + line.slice(i);
const stop = end + close.length;
out += line.slice(i, start) + " ".repeat(stop - start);
i = stop;
}
}

/** Strip the parts of a single (non-fenced) line where rules must not fire. */
function maskLine(line) {
let out = line;
out = maskDelimited(out, "<!--", "-->");
out = maskOut(out, /`[^`]*`/g);
// Mask whole links/images including display text, so interpuncts in link
// runs are not misread as decorative lists.
out = maskOut(out, /!?\[[^\]]*\]\([^)]*\)/g);
out = maskOut(out, /https?:\/\/\S+/g);
out = maskOut(out, /<https?:\/\/[^>]*>/g);
return out;
}

/** Lint already-read text. Returns an array of findings. */
export function lintText(text, relPath, rules) {
const findings = [];
const lines = text.split("\n");
let inFence = false;
let inComment = false;
let inFrontMatter = false;

for (let i = 0; i < lines.length; i++) {
const line = lines[i];

if (i === 0 && line.trim() === "---") {
inFrontMatter = true;
continue;
}
if (inFrontMatter) {
if (line.trim() === "---") inFrontMatter = false;
continue;
}

if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;

// Skip table rows — bold/status marks (✅❌) inside cells are data.
if (/^\s*\|/.test(line)) continue;

if (inComment) {
if (line.includes("-->")) inComment = false;
continue;
}
if (line.includes("<!--") && !line.includes("-->")) {
inComment = true;
continue;
}

const masked = maskLine(line);
if (masked.trim() === "") continue;

for (const rule of rules) {
rule.re.lastIndex = 0;
let m;
while ((m = rule.re.exec(masked)) !== null) {
findings.push({
doc: relPath,
line: i + 1,
col: m.index + 1,
id: rule.id,
category: rule.category,
severity: rule.severity,
message: rule.message,
suggestion: rule.suggestion,
text: line.slice(m.index, m.index + m[0].length),
});
if (m[0].length === 0) rule.re.lastIndex++;
}
}
}
return findings;
}

/** Lint one file by path. */
export function lintFile(absPath, relPath, rules = loadRules()) {
return lintText(fs.readFileSync(absPath, "utf8"), relPath, rules);
}

/** Recursively collect *.md under a root (absolute paths). */
function walkMarkdown(root) {
const out = [];
if (!fs.existsSync(root)) return out;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
out.push(...walkMarkdown(path.join(root, entry.name)));
} else if (entry.name.endsWith(".md")) {
out.push(path.join(root, entry.name));
}
}
return out;
}

function excluded(rel) {
return EXCLUDE.some((e) => rel === e || rel.startsWith(e + "/"));
}

/** All in-scope markdown files as {abs, rel}. */
function scopeTargets() {
const out = [];
for (const s of SCOPE) {
const abs = path.join(REPO_ROOT, s);
if (!fs.existsSync(abs)) continue;
const files = fs.statSync(abs).isDirectory() ? walkMarkdown(abs) : [abs];
for (const f of files) {
const rel = path.relative(REPO_ROOT, f);
if (!excluded(rel)) out.push({ abs: f, rel });
}
}
return out;
}

/** A stable signature for baseline matching (line-number independent). */
export function signature(f) {
return `${f.doc}|${f.id}|${f.text.trim()}`;
}

function loadBaseline() {
if (!fs.existsSync(BASELINE_PATH)) return new Set();
const arr = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
return new Set(Array.isArray(arr) ? arr : arr.signatures || []);
}

// ───── CLI ──────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const opts = { mode: "all", files: [], format: "text", failOn: "S2", baseline: true, writeBaseline: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--all") opts.mode = "all";
else if (a === "--files") opts.mode = "files";
else if (a === "--format") opts.format = argv[++i];
else if (a === "--fail-on") opts.failOn = argv[++i];
else if (a === "--no-baseline") opts.baseline = false;
else if (a === "--write-baseline") opts.writeBaseline = true;
else if (a.startsWith("--")) throw new Error(`unknown flag '${a}'`);
else opts.files.push(a);
}
return opts;
}

function resolveTargets(opts) {
if (opts.mode === "all") return scopeTargets();
return opts.files
.map((p) => (path.isAbsolute(p) ? p : path.join(REPO_ROOT, p)))
.filter((abs) => abs.endsWith(".md") && fs.existsSync(abs))
.map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs) }));
}

function main() {
const opts = parseArgs(process.argv.slice(2));
const rules = loadRules();
const targets = resolveTargets(opts);

let findings = [];
for (const t of targets) findings.push(...lintFile(t.abs, t.rel, rules));

if (opts.writeBaseline) {
const sigs = [...new Set(findings.map(signature))].sort();
fs.writeFileSync(BASELINE_PATH, JSON.stringify(sigs, null, 2) + "\n");
console.log(`ko-style: wrote baseline with ${sigs.length} signature(s) to ${path.relative(REPO_ROOT, BASELINE_PATH)}`);
return;
}

if (opts.baseline) {
const base = loadBaseline();
findings = findings.filter((f) => !base.has(signature(f)));
}

if (opts.format === "json") {
console.log(JSON.stringify({ files: targets.length, findings }, null, 2));
} else {
for (const f of findings) {
console.log(
`${f.doc}:${f.line}:${f.col} [${f.severity} ${f.category}] ${f.message}\n` +
` “${f.text}” → ${f.suggestion}`,
);
}
const counts = { S1: 0, S2: 0, S3: 0 };
for (const f of findings) counts[f.severity]++;
console.log(
`\nko-style: ${targets.length} file(s), ${findings.length} finding(s) ` +
`(S1 ${counts.S1} / S2 ${counts.S2} / S3 ${counts.S3}).`,
);
}

const threshold = SEVERITY_RANK[opts.failOn] || SEVERITY_RANK.S2;
const blocking = findings.some((f) => SEVERITY_RANK[f.severity] >= threshold);
process.exit(blocking ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();
Loading
Loading