patina-cli 3.11.0 → 4.0.0

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.
Files changed (193) hide show
  1. package/.patina.default.yaml +29 -29
  2. package/CHANGELOG.md +53 -0
  3. package/NOTICE +21 -0
  4. package/README.md +117 -224
  5. package/README_JA.md +134 -77
  6. package/README_KR.md +132 -74
  7. package/README_ZH.md +137 -80
  8. package/SKILL.md +11 -20
  9. package/artifacts/rebaseline-2025/README.md +147 -0
  10. package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
  11. package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
  12. package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
  13. package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
  14. package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
  15. package/assets/brand/patina-badge.svg +18 -0
  16. package/assets/brand/patina-mark.svg +8 -0
  17. package/assets/demo/README.md +79 -0
  18. package/core/scoring.md +12 -12
  19. package/core/standalone-prompt.md +3 -1
  20. package/core/stylometry.md +93 -22
  21. package/docs/API.md +1554 -0
  22. package/docs/AUTHENTICATION.md +50 -26
  23. package/docs/AUTHENTICATION_KR.md +54 -29
  24. package/docs/BRANDING.md +9 -8
  25. package/docs/CLI.md +55 -14
  26. package/docs/COOKBOOK.md +8 -21
  27. package/docs/DEMO.md +32 -5
  28. package/docs/EXIT-CODES.md +2 -3
  29. package/docs/FALSE-POSITIVES.md +63 -0
  30. package/docs/FAQ.md +9 -1
  31. package/docs/FAQ_KR.md +3 -1
  32. package/docs/FLAG-PARITY.md +33 -47
  33. package/docs/ISSUE-WAVES.md +57 -0
  34. package/docs/PATTERNS-EN.md +67 -3
  35. package/docs/PATTERNS-JA.md +68 -2
  36. package/docs/PATTERNS-KO.md +70 -7
  37. package/docs/PATTERNS-ZH.md +67 -3
  38. package/docs/PATTERNS.md +5 -5
  39. package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
  40. package/docs/ROADMAP.md +46 -66
  41. package/docs/TRANSLATIONESE-KO.md +51 -0
  42. package/docs/audits/2026-05-deep-research.md +3 -1
  43. package/docs/benchmarks/README.md +51 -0
  44. package/docs/benchmarks/detector-comparison.json +69 -9
  45. package/docs/benchmarks/detector-comparison.md +10 -5
  46. package/docs/benchmarks/katfish-ko-latest.json +657 -0
  47. package/docs/benchmarks/katfish-ko-latest.md +77 -0
  48. package/docs/benchmarks/latest.json +1183 -108
  49. package/docs/benchmarks/latest.md +84 -60
  50. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
  51. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
  52. package/docs/benchmarks/rebaseline-latest.json +381 -0
  53. package/docs/benchmarks/rebaseline-latest.md +121 -0
  54. package/docs/benchmarks/register-stratified-latest.json +164 -0
  55. package/docs/benchmarks/register-stratified-latest.md +99 -0
  56. package/docs/benchmarks/register-stratified.md +43 -0
  57. package/docs/integrations/github-action.md +44 -11
  58. package/docs/integrations/playground.md +58 -0
  59. package/docs/integrations/pre-commit.md +5 -5
  60. package/docs/integrations/release.md +5 -3
  61. package/docs/integrations/static-sites.md +83 -0
  62. package/docs/research/2025-rebaseline-plan.md +71 -2
  63. package/docs/research/2026-rebaseline.md +102 -0
  64. package/docs/research/adversarial-mps.md +41 -0
  65. package/docs/research/ai-human-metrics.md +35 -23
  66. package/docs/research/human-eval-panel.md +42 -0
  67. package/docs/research/judge-agreement.md +24 -0
  68. package/docs/research/ko-2025-corpus-sources.md +135 -0
  69. package/docs/research/lexicon-freshness-audit.md +64 -0
  70. package/docs/research/zh-ja-lexicon-calibration.md +60 -0
  71. package/docs/social/patina-launch-copy.md +173 -100
  72. package/docs/social/patina-launch-execution.md +94 -0
  73. package/docs/social/patina-launch-korean-first.md +83 -0
  74. package/docs/social/signs-of-ai-writing.md +26 -0
  75. package/docs/social/signs-of-ai-writing_KR.md +26 -0
  76. package/lexicon/ai-en.md +21 -24
  77. package/lexicon/ai-ja.md +158 -0
  78. package/lexicon/ai-ko.md +9 -9
  79. package/lexicon/ai-zh.md +158 -0
  80. package/lexicon/provenance/ai-en.json +970 -0
  81. package/lexicon/provenance/ai-ja.json +542 -0
  82. package/lexicon/provenance/ai-ko.json +866 -0
  83. package/lexicon/provenance/ai-zh.json +542 -0
  84. package/package.json +49 -8
  85. package/patterns/en-communication.md +5 -0
  86. package/patterns/en-content.md +5 -0
  87. package/patterns/en-filler.md +5 -0
  88. package/patterns/en-language.md +29 -1
  89. package/patterns/en-structure.md +5 -0
  90. package/patterns/en-style.md +5 -0
  91. package/patterns/en-viral-hook.md +42 -2
  92. package/patterns/ja-communication.md +5 -0
  93. package/patterns/ja-content.md +5 -0
  94. package/patterns/ja-filler.md +5 -0
  95. package/patterns/ja-language.md +33 -1
  96. package/patterns/ja-structure.md +12 -0
  97. package/patterns/ja-style.md +5 -0
  98. package/patterns/ja-viral-hook.md +41 -2
  99. package/patterns/ko-communication.md +5 -0
  100. package/patterns/ko-content.md +5 -0
  101. package/patterns/ko-filler.md +5 -0
  102. package/patterns/ko-language.md +33 -1
  103. package/patterns/ko-structure.md +25 -6
  104. package/patterns/ko-style.md +5 -0
  105. package/patterns/ko-viral-hook.md +38 -2
  106. package/patterns/zh-communication.md +5 -0
  107. package/patterns/zh-content.md +5 -0
  108. package/patterns/zh-filler.md +5 -0
  109. package/patterns/zh-language.md +37 -1
  110. package/patterns/zh-structure.md +12 -0
  111. package/patterns/zh-style.md +5 -0
  112. package/patterns/zh-viral-hook.md +38 -2
  113. package/playground/README.md +55 -0
  114. package/playground/analytics.js +4 -0
  115. package/playground/analyzer.js +883 -0
  116. package/playground/app.js +157 -0
  117. package/playground/data/lexicons.js +343 -0
  118. package/playground/index.html +138 -0
  119. package/playground/styles.css +267 -0
  120. package/profiles/namuwiki.md +111 -0
  121. package/scripts/adversarial-mps-report.mjs +201 -0
  122. package/scripts/badge-json.mjs +79 -0
  123. package/scripts/benchmark-report.mjs +56 -9
  124. package/scripts/check-release-metadata.mjs +0 -2
  125. package/scripts/detector-comparison.mjs +7 -7
  126. package/scripts/generate-playground-data.mjs +77 -0
  127. package/scripts/katfish-calibration.mjs +464 -0
  128. package/scripts/lexicon-freshness.mjs +485 -0
  129. package/scripts/lint.mjs +1 -1
  130. package/scripts/precommit-score.mjs +4 -3
  131. package/scripts/prose-score.mjs +81 -5
  132. package/scripts/rebaseline-intake.mjs +242 -0
  133. package/scripts/rebaseline-score.mjs +268 -0
  134. package/scripts/rebaseline-summary.mjs +773 -0
  135. package/scripts/rebaseline-web-collect.mjs +410 -0
  136. package/scripts/update-benchmark-ranges.mjs +1 -0
  137. package/src/api.js +69 -105
  138. package/src/auth.js +50 -2
  139. package/src/backends/claude-cli.js +19 -4
  140. package/src/backends/codex-cli.js +19 -3
  141. package/src/backends/contract.js +230 -1
  142. package/src/backends/gemini-cli.js +18 -5
  143. package/src/backends/index.js +87 -12
  144. package/src/backends/kimi-cli.js +161 -0
  145. package/src/cli.js +577 -567
  146. package/src/commands/doctor.js +2 -2
  147. package/src/config.js +29 -0
  148. package/src/errors.js +53 -1
  149. package/src/features/discourse-tells.js +68 -0
  150. package/src/features/index.js +82 -8
  151. package/src/features/lexicon.js +40 -6
  152. package/src/features/markup-leakage.js +69 -0
  153. package/src/features/segment.js +41 -0
  154. package/src/features/signal-strength.js +81 -0
  155. package/src/features/stylometry.js +231 -1
  156. package/src/features/translationese.js +127 -0
  157. package/src/loader.js +76 -0
  158. package/src/logger.js +22 -23
  159. package/src/model-defaults.js +55 -0
  160. package/src/ouroboros.js +31 -0
  161. package/src/output.js +102 -90
  162. package/src/prompt-builder.js +103 -68
  163. package/src/providers.js +51 -4
  164. package/src/scoring.js +210 -2
  165. package/src/security.js +75 -0
  166. package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
  167. package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
  168. package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
  169. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
  170. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
  171. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
  172. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
  175. package/tests/quality/README.md +188 -11
  176. package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
  177. package/tests/quality/benchmark.mjs +39 -1
  178. package/tests/quality/dogfood.mjs +5 -3
  179. package/tests/quality/live-fixtures.jsonl +2 -0
  180. package/tests/quality/live-quality.mjs +596 -0
  181. package/tests/quality/ranking-metrics.mjs +136 -0
  182. package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
  183. package/vercel.json +53 -0
  184. package/SKILL-MAX.md +0 -455
  185. package/docs/internal/HARNESS.md +0 -14
  186. package/docs/internal/README.md +0 -14
  187. package/docs/internal/WARP.md +0 -23
  188. package/patina-max/SKILL.md +0 -523
  189. package/patina-max/composite.py +0 -457
  190. package/src/cache.js +0 -106
  191. package/src/commands/init.js +0 -208
  192. package/src/manifest.js +0 -162
  193. package/src/max-mode.js +0 -207
@@ -43,14 +43,17 @@ function readResults() {
43
43
 
44
44
  function validateResultsSchema(results) {
45
45
  const missing = [];
46
- if (results?.schemaVersion !== 2) missing.push('schemaVersion=2');
46
+ if (results?.schemaVersion !== 3) missing.push('schemaVersion=3');
47
47
  if (typeof results?.fixtureSchemaVersion !== 'number') missing.push('fixtureSchemaVersion');
48
48
  if (typeof results?.nodeVersion !== 'string') missing.push('nodeVersion');
49
49
  if (typeof results?.overall?.ci_low !== 'number') missing.push('overall.ci_low');
50
50
  if (typeof results?.overall?.ci_high !== 'number') missing.push('overall.ci_high');
51
51
  if (typeof results?.overall?.n !== 'number') missing.push('overall.n');
52
+ if (!isNumberOrNull(results?.ranking?.overall?.roc_auc)) missing.push('ranking.overall.roc_auc');
53
+ if (!isNumberOrNull(results?.ranking?.overall?.pr_auc)) missing.push('ranking.overall.pr_auc');
54
+ if (!results?.ranking?.overall?.bestF1) missing.push('ranking.overall.bestF1');
52
55
  for (const [lang, summary] of Object.entries(results?.perLanguage || {})) {
53
- for (const detector of ['burstiness', 'mattr', 'lexicon']) {
56
+ for (const detector of ['burstiness', 'koDiagnostics', 'mattr', 'lexicon']) {
54
57
  if (!summary.byDetector?.[detector]) missing.push(`perLanguage.${lang}.byDetector.${detector}`);
55
58
  }
56
59
  }
@@ -59,6 +62,10 @@ function validateResultsSchema(results) {
59
62
  }
60
63
  }
61
64
 
65
+ function isNumberOrNull(value) {
66
+ return value === null || typeof value === 'number';
67
+ }
68
+
62
69
  function pct(value) {
63
70
  return `${((value ?? 0) * 100).toFixed(1)}%`;
64
71
  }
@@ -67,6 +74,14 @@ function num(value, digits = 3) {
67
74
  return Number(value ?? 0).toFixed(digits).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
68
75
  }
69
76
 
77
+ function optionalPct(value) {
78
+ return value === null || value === undefined ? '—' : pct(value);
79
+ }
80
+
81
+ function optionalNum(value, digits = 3) {
82
+ return value === null || value === undefined ? '—' : num(value, digits);
83
+ }
84
+
70
85
  function bool(value) {
71
86
  return value ? 'hot' : 'cold';
72
87
  }
@@ -103,6 +118,18 @@ function detectorRows(perLanguage = {}) {
103
118
  return rows;
104
119
  }
105
120
 
121
+ function rankingRows(ranking = {}) {
122
+ const rows = [];
123
+ if (ranking.overall) rows.push(['overall', ranking.overall]);
124
+ for (const [lang, summary] of Object.entries(ranking.perLanguage || {}).sort(([a], [b]) => a.localeCompare(b))) {
125
+ rows.push([lang, summary]);
126
+ }
127
+ return rows.map(([scope, summary]) => {
128
+ const best = summary.bestF1 || {};
129
+ return `| ${cell(scope)} | ${summary.n} | ${summary.positives} | ${summary.negatives} | ${optionalNum(summary.roc_auc)} | ${optionalNum(summary.pr_auc)} | ${optionalNum(best.threshold)} | ${optionalPct(best.precision)} | ${optionalPct(best.recall)} | ${optionalNum(best.f1, 2)} | ${optionalPct(best.accuracy)} |`;
130
+ });
131
+ }
132
+
106
133
  function classRows(fixtures = []) {
107
134
  const counts = new Map();
108
135
  for (const f of fixtures) {
@@ -130,7 +157,10 @@ function sampleSizeSummary(fixtures = []) {
130
157
  function fixtureRows(fixtures = []) {
131
158
  return fixtures.map((f) => {
132
159
  const hits = cell((f.lexicon_hits || []).slice(0, 4).join(', '));
133
- return `| ${cell(f.fixture_id)} | ${cell(f.lang)} | ${cell(f.class)} | ${bool(f.expected_hot)} | ${bool(f.predicted_hot)} | ${resultMark(f.correct)} | ${num(f.cv)} ${cell(f.cv_band)} | ${num(f.mattr)} ${cell(f.mattr_band)} | ${num(f.lexicon_density)} | ${hits} |`;
160
+ const koDiag = f.ko_diagnostics_hot
161
+ ? `hot: ${(f.ko_diagnostics_reasons || []).join(', ')}`
162
+ : 'cold';
163
+ return `| ${cell(f.fixture_id)} | ${cell(f.lang)} | ${cell(f.class)} | ${bool(f.expected_hot)} | ${bool(f.predicted_hot)} | ${resultMark(f.correct)} | ${num(f.signal_score)} | ${num(f.cv)} ${cell(f.cv_band)} | ${num(f.mattr)} ${cell(f.mattr_band)} | ${num(f.lexicon_density)} | ${cell(koDiag)} | ${hits} |`;
134
164
  });
135
165
  }
136
166
 
@@ -180,7 +210,7 @@ This is the latest checked-in report for patina's deterministic suspect-zone ben
180
210
  - Regression ranges: \`tests/fixtures/suspect-zones/expected-ranges.json\` (refresh with \`npm run benchmark:ranges\`)
181
211
  - Reproduce: \`npm run benchmark:report\`
182
212
  - Raw JSON: [latest.json](latest.json)
183
- - Detector comparison harness: [detector-comparison.md](detector-comparison.md)
213
+ - Detector comparison protocol: [detector-comparison.md](detector-comparison.md)
184
214
  - 2025+ re-baseline plan: [docs/research/2025-rebaseline-plan.md](../research/2025-rebaseline-plan.md)
185
215
 
186
216
  ## Language breakdown
@@ -195,6 +225,16 @@ ${languageRows(results.perLanguage).join('\n')}
195
225
  |---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
196
226
  ${detectorRows(results.perLanguage).join('\n')}
197
227
 
228
+ ## Ranking diagnostics
229
+
230
+ Signal-score ranking shows whether the diagnostic \`signal_score\` separates hot
231
+ fixtures from natural fixtures before any threshold is chosen. It is computed
232
+ only on the checked-in fixture corpus and is not a broader model-era claim.
233
+
234
+ | scope | fixtures | positives | negatives | ROC-AUC | PR-AUC | best threshold | precision | recall | best F1 | accuracy |
235
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
236
+ ${rankingRows(results.ranking).join('\n')}
237
+
198
238
  ## Sample sizes
199
239
 
200
240
  | lang | class | fixtures |
@@ -207,14 +247,15 @@ ${misclassificationSection(results.fixtures)}
207
247
 
208
248
  ## Fixture log
209
249
 
210
- | fixture | lang | class | expected | predicted | ok | CV band | MATTR band | lexicon/1k | sample lexicon hits |
211
- |---|---|---|---|---|---:|---:|---:|---:|---|
250
+ | fixture | lang | class | expected | predicted | ok | signal | CV band | MATTR band | lexicon/1k | KO diagnostic | sample lexicon hits |
251
+ |---|---|---|---|---|---:|---:|---:|---:|---:|---|---|
212
252
  ${fixtureRows(results.fixtures).join('\n')}
213
253
 
214
254
  ## How to read this
215
255
 
216
- - **Hot** means at least one deterministic signal crossed the benchmark threshold: low burstiness CV, low MATTR, or AI-lexicon density.
256
+ - **Hot** means at least one deterministic signal crossed the benchmark threshold: low burstiness CV, low MATTR, AI-lexicon density, or the conservative Korean diagnostic composite.
217
257
  - **Cold** means the fixture did not cross those thresholds.
258
+ - **Signal** is the 0–100 diagnostic strength of the strongest deterministic trigger. It supports ranking diagnostics but does not replace the binary hot/cold regression gate.
218
259
  - The report is meant for regression tracking and contributor discussion, not for authorship accusation.
219
260
  - This deterministic corpus is intentionally small (${results.fixtureCount} fixtures across ${languageList}); do not treat 100% fixture accuracy as generalization to new models, genres, or edited AI text.
220
261
  - Confidence intervals use Wilson score intervals for the checked-in fixture set; external threshold sweeps and 2025+ model rebaselines are separate research follow-ups tracked in [2025+ Re-baseline Plan](../research/2025-rebaseline-plan.md).
@@ -230,7 +271,7 @@ function main() {
230
271
  if (!runBenchmarkFirst) benchmarkStatus = statusFromResults(results);
231
272
 
232
273
  const report = {
233
- reportVersion: 2,
274
+ reportVersion: 3,
234
275
  benchmarkCommand: benchmarkCommand.join(' '),
235
276
  benchmarkStatus,
236
277
  note: 'Deterministic suspect-zone benchmark; not an authorship detector.',
@@ -249,4 +290,10 @@ function main() {
249
290
  if (benchmarkStatus !== 0) process.exitCode = benchmarkStatus;
250
291
  }
251
292
 
252
- main();
293
+ const isDirectRun = process.argv[1]
294
+ ? resolve(process.cwd(), process.argv[1]) === fileURLToPath(import.meta.url)
295
+ : false;
296
+
297
+ if (isDirectRun) main();
298
+
299
+ export { renderMarkdown, validateResultsSchema };
@@ -10,8 +10,6 @@ expect(pkg.bin?.patina === 'bin/patina.js', 'package.json bin.patina must point
10
10
  expect(pkg.bin?.['patina-score'] === 'scripts/precommit-score.mjs', 'package.json bin.patina-score must point to scripts/precommit-score.mjs');
11
11
  expect(existsSync('bin/patina.js'), 'bin/patina.js must exist');
12
12
  expect(readVersionField('SKILL.md') === version, 'SKILL.md version must match package.json');
13
- expect(readVersionField('SKILL-MAX.md') === version, 'SKILL-MAX.md version must match package.json');
14
- expect(readVersionField('patina-max/SKILL.md') === version, 'patina-max/SKILL.md version must match package.json');
15
13
  expect(readVersionField('.patina.default.yaml') === version, '.patina.default.yaml version must match package.json');
16
14
  expect(readFileSync('README.md', 'utf8').includes(`version: "${version}"`), 'README.md config example version must match package.json');
17
15
  expect(new RegExp(`^## ${escapeRegex(version)} — \\d{4}-\\d{2}-\\d{2}`, 'm').test(readFileSync('CHANGELOG.md', 'utf8')), 'CHANGELOG.md must contain a release heading for package.json version');
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Offline detector-comparison harness for the suspect-zone benchmark.
2
+ // Offline detector-comparison protocol for the suspect-zone benchmark.
3
3
  //
4
4
  // Default mode compares Patina's deterministic in-tree analyzer against the
5
5
  // checked-in fixture labels. Pass --input <manual-results.json> to merge scores
@@ -42,8 +42,8 @@ function runBenchmark() {
42
42
 
43
43
  function readBenchmarkResults() {
44
44
  const results = JSON.parse(readFileSync(RESULTS_PATH, 'utf8'));
45
- if (results?.schemaVersion !== 2 || !Array.isArray(results?.fixtures)) {
46
- throw new Error(`${relative(REPO_ROOT, RESULTS_PATH)} is not a benchmark schema v2 result`);
45
+ if (results?.schemaVersion !== 3 || !Array.isArray(results?.fixtures)) {
46
+ throw new Error(`${relative(REPO_ROOT, RESULTS_PATH)} is not a benchmark schema v3 result`);
47
47
  }
48
48
  return results;
49
49
  }
@@ -121,7 +121,7 @@ function builtInDetector(results) {
121
121
  name: 'Patina deterministic suspect-zone analyzer',
122
122
  kind: 'in-tree',
123
123
  mode: 'offline',
124
- threshold: 'burstiness low OR MATTR low OR lexicon density > threshold',
124
+ threshold: 'burstiness low OR MATTR low OR lexicon density > threshold with min hits OR koDiagnostics hot',
125
125
  },
126
126
  ],
127
127
  rows: results.fixtures.map((fixture) => ({
@@ -205,9 +205,9 @@ function fixtureRows(rows) {
205
205
  }
206
206
 
207
207
  function renderMarkdown(report) {
208
- return `# Detector Comparison Harness
208
+ return `# Detector Comparison Protocol
209
209
 
210
- This report is generated offline from the checked-in suspect-zone fixtures. It is a comparison harness, not a vendor ranking claim.
210
+ This report is generated offline from the checked-in suspect-zone fixtures. It is a comparison protocol, not a vendor ranking claim.
211
211
 
212
212
  ## Current run
213
213
 
@@ -251,7 +251,7 @@ function main() {
251
251
  generatedAt: new Date().toISOString(),
252
252
  fixtureCount: results.fixtureCount,
253
253
  benchmarkGeneratedAt: results.generatedAt,
254
- note: 'Offline comparison harness. Built-in Patina row uses deterministic suspect-zone analyzer; third-party rows are manual opt-in only.',
254
+ note: 'Offline comparison protocol. Built-in Patina row uses deterministic suspect-zone analyzer; third-party rows are manual opt-in only.',
255
255
  manualInput: manual ? relative(REPO_ROOT, manual.path) : null,
256
256
  detectors,
257
257
  summaries: computed.summaries,
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // Generate the browser-safe lexicon bundle for the static playground.
3
+ // Keep this dependency-free so Vercel can serve committed assets without a build.
4
+
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const REPO_ROOT = resolve(__dirname, '..');
11
+ const LANGS = ['en', 'ko', 'zh', 'ja'];
12
+
13
+ function parseFrontmatter(raw) {
14
+ if (!raw.startsWith('---')) return { meta: {}, body: raw };
15
+ const end = raw.indexOf('\n---', 3);
16
+ if (end === -1) return { meta: {}, body: raw };
17
+ const yaml = raw.slice(3, end).trim();
18
+ const meta = {};
19
+ for (const line of yaml.split('\n')) {
20
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/u);
21
+ if (match) meta[match[1]] = match[2].trim();
22
+ }
23
+ return { meta, body: raw.slice(end + 4) };
24
+ }
25
+
26
+ function parseLexiconBody(body) {
27
+ const strict = [];
28
+ const phrases = [];
29
+ let mode = null;
30
+ for (const rawLine of body.split('\n')) {
31
+ const line = rawLine.trim();
32
+ if (line.startsWith('## ')) {
33
+ const heading = line.toLowerCase();
34
+ if (heading.includes('strict matches')) mode = 'strict';
35
+ else if (heading.includes('multi-word phrases')) mode = 'phrases';
36
+ else mode = null;
37
+ continue;
38
+ }
39
+ if (!mode || !line.startsWith('- ')) continue;
40
+ const entry = line.slice(2).trim().normalize('NFC');
41
+ if (entry && !entry.startsWith('_')) (mode === 'strict' ? strict : phrases).push(entry);
42
+ }
43
+ return { strict, phrases };
44
+ }
45
+
46
+ const lexicons = {};
47
+ for (const lang of LANGS) {
48
+ const source = `lexicon/ai-${lang}.md`;
49
+ const raw = readFileSync(resolve(REPO_ROOT, source), 'utf8');
50
+ const { meta, body } = parseFrontmatter(raw);
51
+ const parsed = parseLexiconBody(body);
52
+ lexicons[lang] = {
53
+ lang,
54
+ source,
55
+ version: meta.version || null,
56
+ strict: parsed.strict,
57
+ phrases: parsed.phrases,
58
+ };
59
+ }
60
+
61
+ const output = `// Generated by scripts/generate-playground-data.mjs. Do not edit by hand.\n` +
62
+ `// Source: lexicon/ai-{en,ko,zh,ja}.md\n\n` +
63
+ `export const PLAYGROUND_LEXICONS = ${JSON.stringify(lexicons, null, 2)};\n`;
64
+
65
+ const target = resolve(REPO_ROOT, 'playground/data/lexicons.js');
66
+ if (process.argv.includes('--check')) {
67
+ const current = readFileSync(target, 'utf8');
68
+ if (current !== output) {
69
+ console.error('playground/data/lexicons.js is stale; run npm run playground:data');
70
+ process.exitCode = 1;
71
+ } else {
72
+ console.log('playground/data/lexicons.js is up to date');
73
+ }
74
+ } else {
75
+ writeFileSync(target, output, 'utf8');
76
+ console.log('Wrote playground/data/lexicons.js');
77
+ }