axmap-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
package/app/lib/ssot.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 코퍼스 기준(SSOT)을 화면의 판정 문턱값에 붙인다.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 지금까지 문턱값은 전부 **눈으로 고른 상수**였다.
|
|
5
|
+
*
|
|
6
|
+
* splitOver 300 "300줄 넘으면 큰 파일"
|
|
7
|
+
* hubCap 6 "6개 넘게 공유되면 허브"
|
|
8
|
+
*
|
|
9
|
+
* 근거가 없다. 저장소 하나만 보고는 정할 수가 없기 때문이다.
|
|
10
|
+
* 그래서 AWS 의 코퍼스가 수천 개 공개 저장소를 돌며 이 값들을 **분포**로 바꾼다.
|
|
11
|
+
* 여기는 그 분포를 받아 이 저장소에 맞는 칸을 골라 쓰는 자리다.
|
|
12
|
+
*
|
|
13
|
+
* 실제로 둘 다 코퍼스보다 빡빡했다 (python/mid, 저장소 119개 기준):
|
|
14
|
+
*
|
|
15
|
+
* 파일 길이 p90 = 554줄 ← 우리는 300 을 "길다" 고 불렀다
|
|
16
|
+
* import 차수 p90 = 10.5 ← 우리는 6 을 "허브" 라고 불렀다
|
|
17
|
+
*
|
|
18
|
+
* 평범한 파일을 길다고 하고 평범한 채널을 허브라고 부르고 있었다는 뜻이다.
|
|
19
|
+
*
|
|
20
|
+
* ## 애매하면 기본값으로 떨어지되, 조용히 떨어지지 않는다
|
|
21
|
+
*
|
|
22
|
+
* 표본이 모자라거나 파서 커버리지가 낮으면 코퍼스 값을 쓰지 않는다.
|
|
23
|
+
* 한 번 거짓말한 지침은 그 다음부터 전부 무시당한다.
|
|
24
|
+
* 다만 **떨어졌다는 사실과 이유를 반드시 함께 낸다** — 화면이 근거를 표시해야
|
|
25
|
+
* 사람이 그 숫자를 믿을지 말지 정할 수 있다.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import fs from 'node:fs'
|
|
29
|
+
import os from 'node:os'
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
import { cellKey } from '../../corpus/stats.mjs'
|
|
32
|
+
|
|
33
|
+
/** 코퍼스가 답하지 못할 때 쓰는 값. 지금까지 눈으로 고른 그 값이다. */
|
|
34
|
+
export const DEFAULTS = { splitOver: 300, hubCap: 6 }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 결합 판정에 요구하는 최소 파서 커버리지.
|
|
38
|
+
*
|
|
39
|
+
* 🔴 우리가 못 읽는 언어에서는 결합이 **0 으로 보인다.** 그 0 을 근거로
|
|
40
|
+
* "아름답게 분리돼 있군요" 라고 말하면 새빨간 거짓말이다. 루비 커버리지는 8% 다.
|
|
41
|
+
*
|
|
42
|
+
* 길이(splitOver)는 파서와 무관하게 줄만 세면 되므로 이 게이트를 받지 않는다.
|
|
43
|
+
* 차수(hubCap)는 import 를 읽어야 나오므로 받는다.
|
|
44
|
+
*/
|
|
45
|
+
export const MIN_COVERAGE_FOR_COUPLING = 0.6
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 코퍼스 분포 + 이 저장소의 성격 → 쓸 문턱값과 그 근거.
|
|
49
|
+
*
|
|
50
|
+
* 순수 함수다. 네트워크도 시계도 보지 않는다 — 그래야 표본 부족·커버리지 미달
|
|
51
|
+
* 같은 경계를 실제 서버를 띄우지 않고 검증할 수 있다.
|
|
52
|
+
*
|
|
53
|
+
* @param {object|null} baseline `/ssot/baseline` 응답 (없으면 null)
|
|
54
|
+
* @param {{lang: string|null, commits: number|null}} repo
|
|
55
|
+
* @returns {{splitOver: number, hubCap: number, cell: string|null,
|
|
56
|
+
* splitFrom: 'corpus'|'default', hubFrom: 'corpus'|'default',
|
|
57
|
+
* why: string|null, repos: number|null, at: string|null,
|
|
58
|
+
* parseCoverage: number|null}}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveThresholds(baseline, { lang, commits }, defaults = DEFAULTS) {
|
|
61
|
+
const fallback = (why) => ({
|
|
62
|
+
...defaults,
|
|
63
|
+
cell: null,
|
|
64
|
+
splitFrom: 'default',
|
|
65
|
+
hubFrom: 'default',
|
|
66
|
+
why,
|
|
67
|
+
repos: null,
|
|
68
|
+
at: baseline?.at ?? null,
|
|
69
|
+
parseCoverage: null,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (!baseline || !baseline.cells) return fallback('코퍼스 기준을 받지 못했다')
|
|
73
|
+
|
|
74
|
+
// 커밋 400개 미만이면 bucketOf 가 null 을 낸다. 히스토리가 얕으면 분포에
|
|
75
|
+
// 넣을 수 없다는 뜻이지, 0 으로 치면 되는 게 아니다.
|
|
76
|
+
const key = cellKey(lang, commits ?? 0)
|
|
77
|
+
if (!key) {
|
|
78
|
+
return fallback(
|
|
79
|
+
lang ? `커밋 ${commits ?? 0}개 — 분포에 넣기엔 히스토리가 얕다 (하한 400)` : '주 언어를 정하지 못했다',
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cell = baseline.cells[key]
|
|
84
|
+
if (!cell) return fallback(`${key} 칸이 아직 없다`)
|
|
85
|
+
if (!cell.usable) return fallback(`${key}: ${cell.why ?? '표본 부족'}`)
|
|
86
|
+
|
|
87
|
+
const cov = typeof cell.parseCoverage === 'number' ? cell.parseCoverage : null
|
|
88
|
+
|
|
89
|
+
// 분위수 하나가 망가진 것을 전체 실패로 만들지 않는다. 대신 그 항목만 떨어뜨린다.
|
|
90
|
+
const q = (metric) => {
|
|
91
|
+
const v = cell[metric]?.p90
|
|
92
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines = q('fileLines')
|
|
96
|
+
const deg = q('deg')
|
|
97
|
+
|
|
98
|
+
const couplingOk = cov !== null && cov >= MIN_COVERAGE_FOR_COUPLING
|
|
99
|
+
|
|
100
|
+
const notes = []
|
|
101
|
+
if (!lines) notes.push('파일 길이 분위수가 없다')
|
|
102
|
+
/**
|
|
103
|
+
* 🔴 보류하는 것만으로는 부족하다. **왜** 보류인지 말해야 한다.
|
|
104
|
+
*
|
|
105
|
+
* 커버리지는 코퍼스 레코드 수만 개의 평균이다. 그래서 오늘 c·rust·ruby
|
|
106
|
+
* 파서를 붙여도, 그 파서로 다시 잰 저장소가 쌓이기 전까지는 옛 숫자가
|
|
107
|
+
* 나온다. 그 상태에서 "결합 판정 보류" 만 뜨면 사용자는 도구가 그 언어를
|
|
108
|
+
* 영영 못 본다고 읽는다 — 실제로는 **재측정 중**인데.
|
|
109
|
+
*
|
|
110
|
+
* 코퍼스가 `coverage.from` 으로 알려준다 (corpus/stats.mjs):
|
|
111
|
+
* current 지금 파서로 잰 것만으로 낸 값
|
|
112
|
+
* mixed 모자라서 옛 기록이 섞였다
|
|
113
|
+
* stale 지금 파서로 잰 것이 아직 없다
|
|
114
|
+
*/
|
|
115
|
+
const cvi = cell.coverage ?? null
|
|
116
|
+
if (!couplingOk) {
|
|
117
|
+
const pct = cov === null ? '미상' : `${Math.round(cov * 100)}%`
|
|
118
|
+
let extra = ''
|
|
119
|
+
/**
|
|
120
|
+
* 🔴 "아직 못 쟀다" 와 "읽을 파서가 없다" 는 다른 말이다.
|
|
121
|
+
*
|
|
122
|
+
* 앞엣것은 기다리면 풀린다. 뒤엣것은 저장소를 아무리 더 모아도 안 풀린다 —
|
|
123
|
+
* 파서를 만들어야 한다. 둘을 같은 문구로 쓰면 사람이 기다리기만 한다.
|
|
124
|
+
*/
|
|
125
|
+
if (cvi?.from === 'unsupported') extra = ' (이 언어를 읽는 파서가 아직 없다 — 더 모아도 안 올라간다)'
|
|
126
|
+
else if (cvi?.from === 'stale') extra = ' (지금 파서로 다시 잰 저장소가 아직 없다 — 재측정 중)'
|
|
127
|
+
else if (cvi?.from === 'mixed') extra = ` (지금 파서로 잰 것은 ${cvi.repos}개뿐 — ${cvi.needs}개부터 이 값을 쓴다)`
|
|
128
|
+
notes.push(`파서 커버리지 ${pct} — 결합 판정 보류${extra}`)
|
|
129
|
+
}
|
|
130
|
+
else if (!deg) notes.push('import 차수 분위수가 없다')
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
// 상위 10% 를 "길다" 고 부른다. 같은 언어·같은 규모의 저장소들 기준이다.
|
|
134
|
+
splitOver: lines ? Math.round(lines) : defaults.splitOver,
|
|
135
|
+
/**
|
|
136
|
+
* 🔴 근사다. 근거를 남긴다.
|
|
137
|
+
*
|
|
138
|
+
* hubCap 은 **채널 하나를 몇 개 파일이 공유하는가**를 재고,
|
|
139
|
+
* 코퍼스의 deg 는 **파일 하나에 붙은 import 엣지 수**를 잰다. 단위가 다르다.
|
|
140
|
+
*
|
|
141
|
+
* 다만 채널 하나를 N 개 파일이 공유하면 analyze.mjs 가 그 사이를 거의
|
|
142
|
+
* 완전 그래프로 잇는다(analyze.mjs:625~). 그래서 참여 파일의 차수가 대략
|
|
143
|
+
* N-1 이 된다. 채널 크기를 deg 의 p90 근처에서 자르면 "이 저장소류에서
|
|
144
|
+
* 상위 10% 밖의 차수를 만드는 채널" 을 걷어내는 것과 비슷해진다.
|
|
145
|
+
*
|
|
146
|
+
* 정확히 하려면 코퍼스가 채널 크기 자체를 재야 한다. 아직 안 잰다.
|
|
147
|
+
* 그때까지는 이 근사와 그 한계를 화면에 같이 낸다.
|
|
148
|
+
*/
|
|
149
|
+
hubCap: couplingOk && deg ? Math.max(1, Math.round(deg)) : defaults.hubCap,
|
|
150
|
+
cell: key,
|
|
151
|
+
splitFrom: lines ? 'corpus' : 'default',
|
|
152
|
+
hubFrom: couplingOk && deg ? 'corpus' : 'default',
|
|
153
|
+
why: notes.length ? notes.join(' · ') : null,
|
|
154
|
+
repos: cell.repos ?? null,
|
|
155
|
+
at: baseline.at ?? null,
|
|
156
|
+
parseCoverage: cov,
|
|
157
|
+
// 이 커버리지가 지금 파서 것인지, 옛 기록이 섞인 것인지. 화면이 쓴다.
|
|
158
|
+
coverageFrom: cvi?.from ?? null,
|
|
159
|
+
coverageRepos: cvi?.repos ?? null,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 이 저장소의 주 언어를 정한다 — 파일 수가 가장 많은 확장자.
|
|
165
|
+
*
|
|
166
|
+
* 코퍼스가 GitHub 의 `language` 필드로 칸을 나눴으므로 이름을 그것에 맞춘다.
|
|
167
|
+
* 모르는 확장자만 있으면 **추측하지 않고 null** 을 낸다.
|
|
168
|
+
*/
|
|
169
|
+
const LANG_BY_EXT = {
|
|
170
|
+
'.py': 'python', '.ts': 'typescript', '.tsx': 'typescript',
|
|
171
|
+
'.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
172
|
+
'.go': 'go', '.java': 'java', '.rs': 'rust', '.rb': 'ruby',
|
|
173
|
+
'.php': 'php', '.kt': 'kotlin', '.swift': 'swift', '.scala': 'scala',
|
|
174
|
+
'.cs': 'c#', '.c': 'c', '.h': 'c', '.cc': 'c++', '.cpp': 'c++', '.hpp': 'c++',
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// 받아오기 — 여기서부터는 부수효과다
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
export const SSOT_URL = process.env.AXMAP_SSOT_URL ?? 'https://i15e101.p.ssafy.io/ssot/baseline'
|
|
182
|
+
|
|
183
|
+
/** 저장소를 더럽히지 않게 OS 임시 디렉터리에 둔다. 지워져도 다시 받으면 그만이다. */
|
|
184
|
+
export const CACHE_PATH = path.join(os.tmpdir(), 'axmap-ssot-baseline.json')
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 코퍼스 기준을 받아온다. 못 받으면 캐시, 캐시도 없으면 null.
|
|
188
|
+
*
|
|
189
|
+
* 🔴 **없다고 서버가 죽으면 안 된다.** 이 도구는 비행기 안에서도 떠야 한다.
|
|
190
|
+
* 기준이 없으면 눈으로 고른 기본값으로 돌아가고, 그 사실을 화면이 말한다.
|
|
191
|
+
* 조용히 기본값을 쓰는 것과 기본값이라고 말하는 것은 완전히 다르다.
|
|
192
|
+
*
|
|
193
|
+
* 캐시를 **먼저 쓰지 않고 나중에 쓰는** 이유: 코퍼스는 지금도 수집 중이라
|
|
194
|
+
* 매 세션 최신을 받는 것이 이 루프의 목적이다. 캐시는 오프라인 대비일 뿐이다.
|
|
195
|
+
*/
|
|
196
|
+
export async function fetchBaseline({ url = SSOT_URL, cachePath = CACHE_PATH, timeoutMs = 5000 } = {}) {
|
|
197
|
+
try {
|
|
198
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
199
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
200
|
+
const j = await r.json()
|
|
201
|
+
// 모양을 확인하고 저장한다. 쓰레기를 캐시하면 오프라인일 때 더 나빠진다.
|
|
202
|
+
if (!j || typeof j !== 'object' || !j.cells) throw new Error('cells 가 없다')
|
|
203
|
+
try { fs.writeFileSync(cachePath, JSON.stringify(j)) } catch { /* 캐시 실패는 치명적이지 않다 */ }
|
|
204
|
+
return { baseline: j, from: 'network' }
|
|
205
|
+
} catch (e) {
|
|
206
|
+
try {
|
|
207
|
+
const j = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
|
|
208
|
+
return { baseline: j, from: 'cache', error: e.message }
|
|
209
|
+
} catch {
|
|
210
|
+
return { baseline: null, from: 'none', error: e.message }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function dominantLang(paths) {
|
|
216
|
+
const n = new Map()
|
|
217
|
+
for (const p of paths) {
|
|
218
|
+
const dot = p.lastIndexOf('.')
|
|
219
|
+
if (dot < 0) continue
|
|
220
|
+
const lang = LANG_BY_EXT[p.slice(dot).toLowerCase()]
|
|
221
|
+
if (!lang) continue
|
|
222
|
+
n.set(lang, (n.get(lang) ?? 0) + 1)
|
|
223
|
+
}
|
|
224
|
+
if (!n.size) return null
|
|
225
|
+
return [...n].sort((a, b) => b[1] - a[1])[0][0]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 이 값이 코퍼스 분포에서 어디쯤인가.
|
|
230
|
+
*
|
|
231
|
+
* 🔴 날 숫자는 아무것도 말하지 않는다. `go.mod 865회` 를 보고 신입이 할 수
|
|
232
|
+
* 있는 판단은 없다 — 865가 많은 건지 원래 그런 건지 알 방법이 없기 때문이다.
|
|
233
|
+
* 같은 언어·규모 저장소 수천 개의 분포와 대면 그제야 **객관적 진술**이 된다.
|
|
234
|
+
*
|
|
235
|
+
* 🔴 정밀도를 지어내지 않는다. 분위점(p10·p25·…·p99) 사이를 선형 보간해서
|
|
236
|
+
* "상위 3.7%" 라고 쓰면 있지도 않은 정밀도를 주장하는 것이다. 실제로 가진
|
|
237
|
+
* 것은 몇 개의 점뿐이므로 **띠로만** 말한다.
|
|
238
|
+
*
|
|
239
|
+
* @param {object|null} dist `{p10,p25,p50,p75,p90,p95,p99,n}` 꼴
|
|
240
|
+
* @param {number|null} value
|
|
241
|
+
* @returns {{band: string, label: string, n: number|null}|null}
|
|
242
|
+
*/
|
|
243
|
+
export function bandOf(dist, value) {
|
|
244
|
+
if (!dist || typeof value !== 'number' || !Number.isFinite(value)) return null
|
|
245
|
+
const p = ['p99', 'p95', 'p90', 'p75', 'p50', 'p25'].map((k) => [k, dist[k]])
|
|
246
|
+
// 분위가 하나라도 비면 판단하지 않는다. 반쪽 분포로 매긴 등수는 등수가 아니다.
|
|
247
|
+
if (p.some(([, v]) => typeof v !== 'number')) return null
|
|
248
|
+
const n = typeof dist.n === 'number' ? dist.n : null
|
|
249
|
+
/**
|
|
250
|
+
* 중앙값의 몇 배인가.
|
|
251
|
+
*
|
|
252
|
+
* 🔴 띠만으로는 변별이 안 된다. "가장 자주 바뀌는 곳" 목록은 정의상 전부
|
|
253
|
+
* 상위 1% 라서 여섯 줄이 똑같은 말을 한다. 배수는 그 안에서 차이를
|
|
254
|
+
* 낸다 — 865회는 중앙값(3.1)의 279배고, 115회는 37배다. 같은 띠지만
|
|
255
|
+
* 같은 얘기가 아니다.
|
|
256
|
+
*
|
|
257
|
+
* 비율일 뿐 보간이 아니다. 관측한 두 수(값·중앙값)를 나눈 것이라
|
|
258
|
+
* 없는 정밀도를 주장하지 않는다.
|
|
259
|
+
*/
|
|
260
|
+
const vsMedian = dist.p50 > 0 ? value / dist.p50 : null
|
|
261
|
+
const at = (band, label) => ({ band, label, n, vsMedian })
|
|
262
|
+
if (value >= dist.p99) return at('top1', '상위 1%')
|
|
263
|
+
if (value >= dist.p95) return at('top5', '상위 5%')
|
|
264
|
+
if (value >= dist.p90) return at('top10', '상위 10%')
|
|
265
|
+
if (value >= dist.p75) return at('top25', '상위 25%')
|
|
266
|
+
if (value >= dist.p50) return at('mid', '중앙값 위')
|
|
267
|
+
if (value >= dist.p25) return at('low', '중앙값 아래')
|
|
268
|
+
return at('bottom', '하위 25%')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 지금 저장소에 해당하는 코퍼스 셀. 없으면 null. */
|
|
272
|
+
export function cellFor(baseline, { lang, commits }) {
|
|
273
|
+
if (!baseline?.cells || !lang) return null
|
|
274
|
+
const key = cellKey(lang, commits ?? 0)
|
|
275
|
+
const c = baseline.cells[key]
|
|
276
|
+
return c?.usable ? { key, ...c } : null
|
|
277
|
+
}
|