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
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // Local/private intake helper for the 2025+ rebaseline protocol.
3
+ //
4
+ // Reads JSONL rows, computes missing sha256 hashes from local text, and writes a
5
+ // public manifest that strips full text whenever redistribution is not allowed.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname, relative, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { canRedistributeText, hashText, validateRecord } from './rebaseline-summary.mjs';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const REPO_ROOT = resolve(__dirname, '..');
15
+
16
+ export const DEFAULT_INTAKE_INPUT = 'artifacts/rebaseline-2025/intake.local.jsonl';
17
+ export const DEFAULT_PUBLIC_OUTPUT = 'artifacts/rebaseline-2025/manifest.public.jsonl';
18
+ export const DEFAULT_PRIVATE_OUTPUT = 'artifacts/rebaseline-2025/private/generations.private.jsonl';
19
+
20
+ export function parseArgs(argv = process.argv.slice(2)) {
21
+ const args = {
22
+ input: DEFAULT_INTAKE_INPUT,
23
+ publicOutput: DEFAULT_PUBLIC_OUTPUT,
24
+ privateOutput: DEFAULT_PRIVATE_OUTPUT,
25
+ requireSourceReview: false,
26
+ dryRun: false,
27
+ json: false,
28
+ help: false,
29
+ };
30
+
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const arg = argv[i];
33
+ if (arg === '--input') args.input = argv[++i];
34
+ else if (arg === '--public-output') args.publicOutput = argv[++i];
35
+ else if (arg === '--private-output') args.privateOutput = argv[++i];
36
+ else if (arg === '--require-source-review') args.requireSourceReview = true;
37
+ else if (arg === '--dry-run') args.dryRun = true;
38
+ else if (arg === '--json') args.json = true;
39
+ else if (arg === '--help' || arg === '-h') args.help = true;
40
+ else throw new Error(`Unknown argument: ${arg}`);
41
+ }
42
+
43
+ return args;
44
+ }
45
+
46
+ export function loadIntakeRows(inputPath = DEFAULT_INTAKE_INPUT) {
47
+ const abs = resolveRepoPath(inputPath);
48
+ const result = {
49
+ path: abs,
50
+ relativePath: toRepoRelative(abs),
51
+ rows: [],
52
+ errors: [],
53
+ };
54
+
55
+ if (!existsSync(abs)) {
56
+ result.errors.push(`intake input not found: ${result.relativePath}`);
57
+ return result;
58
+ }
59
+
60
+ const lines = readFileSync(abs, 'utf8').split(/\r?\n/u);
61
+ for (let index = 0; index < lines.length; index++) {
62
+ const lineNumber = index + 1;
63
+ const line = lines[index].trim();
64
+ if (!line) continue;
65
+
66
+ try {
67
+ result.rows.push({ lineNumber, value: JSON.parse(line) });
68
+ } catch (error) {
69
+ result.errors.push(`line ${lineNumber}: invalid JSON (${error.message})`);
70
+ }
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ export function sanitizeIntakeRows(rows, options = {}) {
77
+ const publicRecords = [];
78
+ const privateRecords = [];
79
+ const errors = [];
80
+ const warnings = [];
81
+ const requireSourceReview = Boolean(options.requireSourceReview);
82
+
83
+ for (const row of rows) {
84
+ const lineNumber = row.lineNumber ?? '?';
85
+ const raw = row.value;
86
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
87
+ errors.push(`line ${lineNumber}: record must be a JSON object`);
88
+ continue;
89
+ }
90
+
91
+ const record = { ...raw };
92
+ const label = record.sample_id ? `line ${lineNumber} (${record.sample_id})` : `line ${lineNumber}`;
93
+ const carriesText = typeof record.text === 'string' && record.text.length > 0;
94
+
95
+ if (carriesText) {
96
+ const observedHash = hashText(record.text);
97
+ if (record.text_hash && record.text_hash !== observedHash) {
98
+ errors.push(`${label}: text_hash mismatch: expected ${observedHash}`);
99
+ } else {
100
+ record.text_hash = observedHash;
101
+ }
102
+ }
103
+
104
+ const publicRecord = { ...record };
105
+ const textAllowed = carriesText && canRedistributeText(record.redistribution);
106
+ if (carriesText && !textAllowed) {
107
+ delete publicRecord.text;
108
+ privateRecords.push(record);
109
+ }
110
+ if (!canRedistributeText(record.redistribution) && !record.source_review && !record.reviewer_notes) {
111
+ const message = `${label}: add source_review or reviewer_notes for non-public redistribution status`;
112
+ if (requireSourceReview) errors.push(message);
113
+ else warnings.push(message);
114
+ }
115
+
116
+ const checked = validateRecord(publicRecord);
117
+ errors.push(...checked.errors.map((message) => `${label}: ${message}`));
118
+ warnings.push(...checked.warnings.map((message) => `${label}: ${message}`));
119
+
120
+ if (checked.errors.length === 0) publicRecords.push(checked.record);
121
+ }
122
+
123
+ return { publicRecords, privateRecords, errors, warnings };
124
+ }
125
+
126
+ export function processIntake(options = {}) {
127
+ const loaded = loadIntakeRows(options.input || DEFAULT_INTAKE_INPUT);
128
+ if (loaded.errors.length) {
129
+ return {
130
+ input: loaded.relativePath,
131
+ publicRecords: [],
132
+ privateRecords: [],
133
+ errors: loaded.errors,
134
+ warnings: [],
135
+ };
136
+ }
137
+
138
+ return {
139
+ input: loaded.relativePath,
140
+ ...sanitizeIntakeRows(loaded.rows, { requireSourceReview: options.requireSourceReview }),
141
+ };
142
+ }
143
+
144
+ export function writeIntakeOutputs(result, options = {}) {
145
+ if (result.errors.length) {
146
+ throw new Error('refusing to write invalid rebaseline intake outputs');
147
+ }
148
+
149
+ const publicOutput = options.publicOutput || DEFAULT_PUBLIC_OUTPUT;
150
+ const privateOutput = options.privateOutput || DEFAULT_PRIVATE_OUTPUT;
151
+ const publicPath = resolveRepoPath(publicOutput);
152
+ const privatePath = resolveRepoPath(privateOutput);
153
+
154
+ writeJsonl(publicPath, result.publicRecords);
155
+ let privateWritten = null;
156
+ if (result.privateRecords.length > 0) {
157
+ writeJsonl(privatePath, result.privateRecords);
158
+ privateWritten = toRepoRelative(privatePath);
159
+ }
160
+
161
+ return {
162
+ publicOutput: toRepoRelative(publicPath),
163
+ privateOutput: privateWritten,
164
+ };
165
+ }
166
+
167
+ export function renderIntakeSummary(result, written = null) {
168
+ const lines = [
169
+ '# Rebaseline Intake Summary',
170
+ '',
171
+ `- Input: \`${result.input || 'not recorded'}\``,
172
+ `- Public rows: ${result.publicRecords.length}`,
173
+ `- Private rows: ${result.privateRecords.length}`,
174
+ `- Validation: **${result.errors.length ? 'FAIL' : 'PASS'}**`,
175
+ ];
176
+
177
+ if (written) {
178
+ lines.push(`- Public output: \`${written.publicOutput}\``);
179
+ lines.push(`- Private output: ${written.privateOutput ? `\`${written.privateOutput}\`` : 'not written'}`);
180
+ }
181
+
182
+ if (result.errors.length) {
183
+ lines.push('', '## Errors', ...result.errors.map((error) => `- ${escapeMarkdown(error)}`));
184
+ }
185
+ if (result.warnings.length) {
186
+ lines.push('', '## Warnings', ...result.warnings.map((warning) => `- ${escapeMarkdown(warning)}`));
187
+ }
188
+
189
+ return `${lines.join('\n')}\n`;
190
+ }
191
+
192
+ function writeJsonl(path, records) {
193
+ mkdirSync(dirname(path), { recursive: true });
194
+ writeFileSync(path, records.map((record) => JSON.stringify(record)).join('\n') + (records.length ? '\n' : ''));
195
+ }
196
+
197
+ function resolveRepoPath(path) {
198
+ return resolve(REPO_ROOT, path);
199
+ }
200
+
201
+ function toRepoRelative(path) {
202
+ return relative(REPO_ROOT, path) || path;
203
+ }
204
+
205
+ function escapeMarkdown(value) {
206
+ return String(value ?? '').replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
207
+ }
208
+
209
+ function printHelp() {
210
+ console.log(`Usage: node scripts/rebaseline-intake.mjs --input <intake.jsonl> [--public-output <manifest.jsonl>] [--private-output <private.jsonl>] [--require-source-review] [--dry-run] [--json]
211
+
212
+ Computes missing text_hash values and writes a sanitized public manifest.
213
+ Full text is kept in the public manifest only when redistribution is allowed.
214
+ Use --require-source-review before pilot reports to fail non-public rows that
215
+ lack source_review or reviewer_notes.
216
+ Default input: ${DEFAULT_INTAKE_INPUT}`);
217
+ }
218
+
219
+ function main() {
220
+ const args = parseArgs();
221
+ if (args.help) {
222
+ printHelp();
223
+ return;
224
+ }
225
+
226
+ const result = processIntake({ input: args.input, requireSourceReview: args.requireSourceReview });
227
+ const written = !args.dryRun && result.errors.length === 0
228
+ ? writeIntakeOutputs(result, { publicOutput: args.publicOutput, privateOutput: args.privateOutput })
229
+ : null;
230
+
231
+ if (args.json) {
232
+ console.log(JSON.stringify({ ...result, written }, null, 2));
233
+ } else {
234
+ console.log(renderIntakeSummary(result, written));
235
+ }
236
+
237
+ if (result.errors.length) process.exit(1);
238
+ }
239
+
240
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
241
+ main();
242
+ }
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ // Score local/private rebaseline rows with the deterministic analyzer.
3
+ //
4
+ // The input may contain raw text. The output is always public-safe metadata:
5
+ // text is stripped, text_hash is preserved/computed, and only score/outcome
6
+ // fields are added.
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, relative, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { analyzeText } from '../src/features/index.js';
13
+ import { hashText, validateRecord } from './rebaseline-summary.mjs';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const REPO_ROOT = resolve(__dirname, '..');
17
+
18
+ export const DEFAULT_SCORE_INPUT = 'artifacts/rebaseline-2025/private/generations.private.jsonl';
19
+ export const DEFAULT_SCORE_OUTPUT = 'artifacts/rebaseline-2025/manifest.scored.public.jsonl';
20
+
21
+ const POSITIVE_CLASSES = new Set(['ai-like', 'lightly-edited-ai', 'heavily-edited-ai']);
22
+
23
+ export function parseArgs(argv = process.argv.slice(2)) {
24
+ const args = {
25
+ input: DEFAULT_SCORE_INPUT,
26
+ output: DEFAULT_SCORE_OUTPUT,
27
+ scoredAt: null,
28
+ json: false,
29
+ help: false,
30
+ };
31
+
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const arg = argv[i];
34
+ if (arg === '--input') args.input = argv[++i];
35
+ else if (arg === '--output') args.output = argv[++i];
36
+ else if (arg === '--scored-at') args.scoredAt = argv[++i];
37
+ else if (arg === '--json') args.json = true;
38
+ else if (arg === '--help' || arg === '-h') args.help = true;
39
+ else throw new Error(`Unknown argument: ${arg}`);
40
+ }
41
+
42
+ return args;
43
+ }
44
+
45
+ export function loadScoreRows(inputPath = DEFAULT_SCORE_INPUT) {
46
+ const abs = resolveRepoPath(inputPath);
47
+ const result = {
48
+ path: abs,
49
+ relativePath: toRepoRelative(abs),
50
+ rows: [],
51
+ errors: [],
52
+ };
53
+
54
+ if (!existsSync(abs)) {
55
+ result.errors.push(`score input not found: ${result.relativePath}`);
56
+ return result;
57
+ }
58
+
59
+ const lines = readFileSync(abs, 'utf8').split(/\r?\n/u);
60
+ for (let index = 0; index < lines.length; index++) {
61
+ const lineNumber = index + 1;
62
+ const line = lines[index].trim();
63
+ if (!line) continue;
64
+
65
+ try {
66
+ result.rows.push({ lineNumber, value: JSON.parse(line) });
67
+ } catch (error) {
68
+ result.errors.push(`line ${lineNumber}: invalid JSON (${error.message})`);
69
+ }
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ export function scoreRows(rows, options = {}) {
76
+ const publicRecords = [];
77
+ const errors = [];
78
+ const warnings = [];
79
+
80
+ for (const row of rows) {
81
+ const lineNumber = row.lineNumber ?? '?';
82
+ const raw = row.value;
83
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
84
+ errors.push(`line ${lineNumber}: record must be a JSON object`);
85
+ continue;
86
+ }
87
+
88
+ const label = raw.sample_id ? `line ${lineNumber} (${raw.sample_id})` : `line ${lineNumber}`;
89
+ if (typeof raw.text !== 'string' || raw.text.length === 0) {
90
+ errors.push(`${label}: scoring requires local text`);
91
+ continue;
92
+ }
93
+
94
+ const record = { ...raw };
95
+ const observedHash = hashText(record.text);
96
+ if (record.text_hash && record.text_hash !== observedHash) {
97
+ errors.push(`${label}: text_hash mismatch: expected ${observedHash}`);
98
+ continue;
99
+ }
100
+ record.text_hash = observedHash;
101
+
102
+ const analysis = analyzeText(record.text, {
103
+ lang: record.language || 'en',
104
+ repoRoot: options.repoRoot || REPO_ROOT,
105
+ });
106
+
107
+ const publicRecord = { ...record };
108
+ delete publicRecord.text;
109
+
110
+ const checked = validateRecord(publicRecord);
111
+ warnings.push(...checked.warnings.map((message) => `${label}: ${message}`));
112
+ if (checked.errors.length) {
113
+ errors.push(...checked.errors.map((message) => `${label}: ${message}`));
114
+ continue;
115
+ }
116
+
117
+ const scoredRecord = {
118
+ ...checked.record,
119
+ expected_hot: POSITIVE_CLASSES.has(checked.record.class),
120
+ predicted_hot: Boolean(analysis.hot),
121
+ patina_score: scoreFromAnalysis(analysis),
122
+ score_review: scoreReview(analysis, options),
123
+ };
124
+
125
+ const finalCheck = validateRecord(scoredRecord);
126
+ warnings.push(...finalCheck.warnings.map((message) => `${label}: ${message}`));
127
+ if (finalCheck.errors.length) {
128
+ errors.push(...finalCheck.errors.map((message) => `${label}: ${message}`));
129
+ continue;
130
+ }
131
+
132
+ publicRecords.push(finalCheck.record);
133
+ }
134
+
135
+ return { publicRecords, errors, warnings };
136
+ }
137
+
138
+ export function processScore(options = {}) {
139
+ const loaded = loadScoreRows(options.input || DEFAULT_SCORE_INPUT);
140
+ if (loaded.errors.length) {
141
+ return {
142
+ input: loaded.relativePath,
143
+ publicRecords: [],
144
+ errors: loaded.errors,
145
+ warnings: [],
146
+ };
147
+ }
148
+
149
+ return {
150
+ input: loaded.relativePath,
151
+ ...scoreRows(loaded.rows, options),
152
+ };
153
+ }
154
+
155
+ export function writeScoreOutput(result, options = {}) {
156
+ if (result.errors.length) {
157
+ throw new Error('refusing to write invalid rebaseline score output');
158
+ }
159
+
160
+ const output = options.output || DEFAULT_SCORE_OUTPUT;
161
+ const outputPath = resolveRepoPath(output);
162
+ mkdirSync(dirname(outputPath), { recursive: true });
163
+ writeFileSync(
164
+ outputPath,
165
+ result.publicRecords.map((record) => JSON.stringify(record)).join('\n') + (result.publicRecords.length ? '\n' : '')
166
+ );
167
+
168
+ return { output: toRepoRelative(outputPath) };
169
+ }
170
+
171
+ export function renderScoreSummary(result, written = null) {
172
+ const hot = result.publicRecords.filter((record) => record.predicted_hot).length;
173
+ const lines = [
174
+ '# Rebaseline Score Summary',
175
+ '',
176
+ `- Input: \`${result.input || 'not recorded'}\``,
177
+ `- Public rows: ${result.publicRecords.length}`,
178
+ `- Predicted hot rows: ${hot}`,
179
+ `- Validation: **${result.errors.length ? 'FAIL' : 'PASS'}**`,
180
+ ];
181
+
182
+ if (written) lines.push(`- Public output: \`${written.output}\``);
183
+ if (result.errors.length) lines.push('', '## Errors', ...result.errors.map((error) => `- ${escapeMarkdown(error)}`));
184
+ if (result.warnings.length) lines.push('', '## Warnings', ...result.warnings.map((warning) => `- ${escapeMarkdown(warning)}`));
185
+
186
+ return `${lines.join('\n')}\n`;
187
+ }
188
+
189
+ function scoreFromAnalysis(analysis) {
190
+ const total = analysis.paragraphs.length;
191
+ if (!total) return 0;
192
+ const hot = analysis.paragraphs.filter((paragraph) => paragraph.hot).length;
193
+ return Math.round((hot / total) * 1000) / 10;
194
+ }
195
+
196
+ function scoreReview(analysis, options = {}) {
197
+ const review = {
198
+ scorer: 'patina deterministic analyzer',
199
+ paragraph_count: analysis.paragraphs.length,
200
+ hot_paragraph_count: analysis.paragraphs.filter((paragraph) => paragraph.hot).length,
201
+ trigger_counts: triggerCounts(analysis),
202
+ };
203
+ if (options.scoredAt) review.scored_at = options.scoredAt;
204
+ if (analysis.skipReason) review.skip_reason = analysis.skipReason;
205
+ return review;
206
+ }
207
+
208
+ function triggerCounts(analysis) {
209
+ const counts = {
210
+ burstiness_low: 0,
211
+ mattr_low: 0,
212
+ lexicon_hot: 0,
213
+ ko_diagnostics_hot: 0,
214
+ };
215
+
216
+ for (const paragraph of analysis.paragraphs) {
217
+ if (paragraph.burstiness?.band === 'low') counts.burstiness_low++;
218
+ if (paragraph.mattr?.band === 'low') counts.mattr_low++;
219
+ if (paragraph.lexicon?.hot) counts.lexicon_hot++;
220
+ if (paragraph.koDiagnostics?.hot) counts.ko_diagnostics_hot++;
221
+ }
222
+
223
+ return counts;
224
+ }
225
+
226
+ function resolveRepoPath(path) {
227
+ return resolve(REPO_ROOT, path);
228
+ }
229
+
230
+ function toRepoRelative(path) {
231
+ return relative(REPO_ROOT, path) || path;
232
+ }
233
+
234
+ function escapeMarkdown(value) {
235
+ return String(value ?? '').replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
236
+ }
237
+
238
+ function printHelp() {
239
+ console.log(`Usage: node scripts/rebaseline-score.mjs --input <private.jsonl> --output <public.jsonl> [--scored-at <date>] [--json]
240
+
241
+ Scores local/private rebaseline rows with the deterministic analyzer and writes
242
+ a public-safe JSONL manifest. The output never includes raw text.
243
+ Default input: ${DEFAULT_SCORE_INPUT}
244
+ Default output: ${DEFAULT_SCORE_OUTPUT}`);
245
+ }
246
+
247
+ function main() {
248
+ const args = parseArgs();
249
+ if (args.help) {
250
+ printHelp();
251
+ return;
252
+ }
253
+
254
+ const result = processScore(args);
255
+ const written = result.errors.length === 0 ? writeScoreOutput(result, { output: args.output }) : null;
256
+
257
+ if (args.json) {
258
+ console.log(JSON.stringify({ ...result, written }, null, 2));
259
+ } else {
260
+ console.log(renderScoreSummary(result, written));
261
+ }
262
+
263
+ if (result.errors.length) process.exit(1);
264
+ }
265
+
266
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
267
+ main();
268
+ }