patina-cli 3.11.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.
- package/.patina.default.yaml +211 -0
- package/CHANGELOG.md +265 -0
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/README_JA.md +254 -0
- package/README_KR.md +253 -0
- package/README_ZH.md +254 -0
- package/SKILL-MAX.md +455 -0
- package/SKILL.md +730 -0
- package/assets/brand/patina-icon.svg +9 -0
- package/assets/brand/patina-logo.svg +17 -0
- package/assets/social/patina-before-after.svg +46 -0
- package/assets/social/patina-og.svg +31 -0
- package/bin/patina.js +9 -0
- package/core/scoring.md +657 -0
- package/core/standalone-prompt.md +364 -0
- package/core/stylometry.md +754 -0
- package/core/voice.md +163 -0
- package/docs/AUTHENTICATION.md +105 -0
- package/docs/AUTHENTICATION_KR.md +105 -0
- package/docs/BRANDING.md +37 -0
- package/docs/CLI.md +80 -0
- package/docs/COMPARISON.md +38 -0
- package/docs/COOKBOOK.md +173 -0
- package/docs/DEMO.md +40 -0
- package/docs/ETHICS.md +27 -0
- package/docs/EXAMPLES.md +130 -0
- package/docs/EXAMPLES_KR.md +130 -0
- package/docs/EXIT-CODES.md +25 -0
- package/docs/FAQ.md +67 -0
- package/docs/FAQ_KR.md +65 -0
- package/docs/FLAG-PARITY.md +53 -0
- package/docs/GLOSSARY.md +123 -0
- package/docs/PATTERNS-EN.md +718 -0
- package/docs/PATTERNS-JA.md +706 -0
- package/docs/PATTERNS-KO.md +707 -0
- package/docs/PATTERNS-ZH.md +706 -0
- package/docs/PATTERNS.md +22 -0
- package/docs/ROADMAP.md +315 -0
- package/docs/audits/2026-05-deep-research.md +290 -0
- package/docs/benchmarks/detector-comparison.json +442 -0
- package/docs/benchmarks/detector-comparison.md +65 -0
- package/docs/benchmarks/latest.json +988 -0
- package/docs/benchmarks/latest.md +112 -0
- package/docs/integrations/docker.md +19 -0
- package/docs/integrations/github-action.md +59 -0
- package/docs/integrations/pre-commit.md +77 -0
- package/docs/integrations/release.md +43 -0
- package/docs/internal/HARNESS.md +14 -0
- package/docs/internal/README.md +14 -0
- package/docs/internal/WARP.md +23 -0
- package/docs/research/2025-rebaseline-plan.md +89 -0
- package/docs/research/ai-human-metrics.md +380 -0
- package/docs/social/gstack-cardnews.html +236 -0
- package/docs/social/gstack-cardnews.md +88 -0
- package/docs/social/gstack-thread.md +106 -0
- package/docs/social/patina-launch-copy.md +227 -0
- package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
- package/lexicon/ai-en.md +162 -0
- package/lexicon/ai-ko.md +159 -0
- package/package.json +100 -0
- package/patina-max/SKILL.md +523 -0
- package/patina-max/composite.py +457 -0
- package/patterns/en-communication.md +89 -0
- package/patterns/en-content.md +133 -0
- package/patterns/en-filler.md +113 -0
- package/patterns/en-language.md +163 -0
- package/patterns/en-structure.md +173 -0
- package/patterns/en-style.md +139 -0
- package/patterns/en-viral-hook.md +211 -0
- package/patterns/ja-communication.md +101 -0
- package/patterns/ja-content.md +153 -0
- package/patterns/ja-filler.md +123 -0
- package/patterns/ja-language.md +190 -0
- package/patterns/ja-structure.md +142 -0
- package/patterns/ja-style.md +147 -0
- package/patterns/ja-viral-hook.md +216 -0
- package/patterns/ko-communication.md +98 -0
- package/patterns/ko-content.md +154 -0
- package/patterns/ko-filler.md +105 -0
- package/patterns/ko-language.md +182 -0
- package/patterns/ko-structure.md +147 -0
- package/patterns/ko-style.md +146 -0
- package/patterns/ko-viral-hook.md +211 -0
- package/patterns/zh-communication.md +101 -0
- package/patterns/zh-content.md +153 -0
- package/patterns/zh-filler.md +118 -0
- package/patterns/zh-language.md +173 -0
- package/patterns/zh-structure.md +145 -0
- package/patterns/zh-style.md +159 -0
- package/patterns/zh-viral-hook.md +216 -0
- package/profiles/academic.md +53 -0
- package/profiles/blog.md +81 -0
- package/profiles/casual-conversation.md +105 -0
- package/profiles/code-comment.md +104 -0
- package/profiles/commit-message.md +99 -0
- package/profiles/default.md +62 -0
- package/profiles/email.md +52 -0
- package/profiles/formal.md +98 -0
- package/profiles/instructional.md +80 -0
- package/profiles/legal.md +57 -0
- package/profiles/marketing.md +56 -0
- package/profiles/medical.md +53 -0
- package/profiles/narrative.md +79 -0
- package/profiles/release-notes.md +98 -0
- package/profiles/social.md +56 -0
- package/profiles/technical.md +53 -0
- package/scripts/benchmark-report.mjs +252 -0
- package/scripts/check-release-metadata.mjs +48 -0
- package/scripts/detector-comparison.mjs +267 -0
- package/scripts/lint.mjs +40 -0
- package/scripts/precommit-score.mjs +31 -0
- package/scripts/prose-score.mjs +186 -0
- package/scripts/update-benchmark-ranges.mjs +108 -0
- package/src/api.js +330 -0
- package/src/auth.js +105 -0
- package/src/backends/claude-cli.js +112 -0
- package/src/backends/codex-cli.js +121 -0
- package/src/backends/contract.js +21 -0
- package/src/backends/gemini-cli.js +135 -0
- package/src/backends/index.js +159 -0
- package/src/cache.js +106 -0
- package/src/cli.js +1280 -0
- package/src/commands/doctor.js +229 -0
- package/src/commands/init.js +208 -0
- package/src/config.js +126 -0
- package/src/errors.js +53 -0
- package/src/features/index.js +96 -0
- package/src/features/lexicon.js +90 -0
- package/src/features/segment.js +49 -0
- package/src/features/stylometry.js +50 -0
- package/src/loader.js +103 -0
- package/src/logger.js +70 -0
- package/src/manifest.js +162 -0
- package/src/max-mode.js +207 -0
- package/src/ouroboros.js +233 -0
- package/src/output.js +480 -0
- package/src/prompt-builder.js +409 -0
- package/src/providers.js +100 -0
- package/src/scoring.js +531 -0
- package/src/security.js +133 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
- package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
- package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
- package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
- package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
- package/tests/quality/README.md +121 -0
- package/tests/quality/benchmark.mjs +306 -0
- package/tests/quality/detectors.manual.example.json +31 -0
- package/tests/quality/dogfood.mjs +44 -0
package/scripts/lint.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// No-dependency lint smoke for CI: syntax-check every committed JS/MJS file.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { dirname, relative, 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 ROOTS = ['bin', 'scripts', 'src', 'tests'];
|
|
12
|
+
const EXT_RE = /\.(?:js|mjs)$/;
|
|
13
|
+
|
|
14
|
+
function walk(dir, out = []) {
|
|
15
|
+
for (const entry of readdirSync(dir)) {
|
|
16
|
+
const path = resolve(dir, entry);
|
|
17
|
+
const st = statSync(path);
|
|
18
|
+
if (st.isDirectory()) walk(path, out);
|
|
19
|
+
else if (EXT_RE.test(entry)) out.push(path);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = ROOTS.flatMap((dir) => walk(resolve(REPO_ROOT, dir))).sort();
|
|
25
|
+
let failed = false;
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const result = spawnSync(process.execPath, ['--check', file], {
|
|
28
|
+
cwd: REPO_ROOT,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
});
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
failed = true;
|
|
33
|
+
console.error(`node --check failed: ${relative(REPO_ROOT, file)}`);
|
|
34
|
+
if (result.stdout) console.error(result.stdout.trim());
|
|
35
|
+
if (result.stderr) console.error(result.stderr.trim());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (failed) process.exitCode = 1;
|
|
40
|
+
else console.log(`Syntax OK (${files.length} files)`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { formatMarkdownReport, scoreFiles, summarizeRows } from './prose-score.mjs';
|
|
3
|
+
|
|
4
|
+
function parseArgs(argv) {
|
|
5
|
+
const out = { files: [], gate: 30, lang: 'auto', maxFiles: 200 };
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
const arg = argv[i];
|
|
8
|
+
if (arg === '--gate' || arg === '--score-threshold') out.gate = Number(argv[++i]);
|
|
9
|
+
else if (arg === '--lang') out.lang = argv[++i] || 'auto';
|
|
10
|
+
else if (arg === '--max-files') out.maxFiles = Number(argv[++i]);
|
|
11
|
+
else if (!arg.startsWith('-')) out.files.push(arg);
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isFinite(out.gate) || out.gate < 0 || out.gate > 100) {
|
|
14
|
+
throw new Error(`--gate expects a number from 0 to 100, got ${out.gate}`);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
21
|
+
const rows = scoreFiles(opts.files, opts);
|
|
22
|
+
const summary = summarizeRows(rows);
|
|
23
|
+
console.log(formatMarkdownReport(rows, { gate: opts.gate, title: 'Patina pre-commit prose score' }));
|
|
24
|
+
if (summary.failedCount > 0) {
|
|
25
|
+
console.error(`\npatina-score: ${summary.failedCount}/${summary.fileCount} file(s) exceeded gate ${opts.gate}.`);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(`patina-score: ${error.message}`);
|
|
30
|
+
process.exitCode = 2;
|
|
31
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { analyzeText } from '../src/features/index.js';
|
|
6
|
+
import { loadLexicon } from '../src/features/lexicon.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
export const DEFAULT_REPO_ROOT = resolve(__dirname, '..');
|
|
10
|
+
export const DEFAULT_PROSE_EXTENSIONS = ['.md', '.mdx', '.txt', '.rst', '.adoc'];
|
|
11
|
+
|
|
12
|
+
const lexiconCache = new Map();
|
|
13
|
+
|
|
14
|
+
export function parseBoolean(value, defaultValue = false) {
|
|
15
|
+
if (value === undefined || value === null || value === '') return defaultValue;
|
|
16
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseFileList(value = '') {
|
|
20
|
+
return String(value)
|
|
21
|
+
.split(/[\n,]+/)
|
|
22
|
+
.map((item) => item.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isProsePath(file, extensions = DEFAULT_PROSE_EXTENSIONS) {
|
|
27
|
+
const lower = file.toLowerCase();
|
|
28
|
+
return extensions.some((ext) => lower.endsWith(ext));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stripNonProse(markdown) {
|
|
32
|
+
return String(markdown || '')
|
|
33
|
+
.replace(/^---\n[\s\S]*?\n---\s*/, '\n')
|
|
34
|
+
.replace(/```[\s\S]*?```/g, '\n')
|
|
35
|
+
.replace(/~~~[\s\S]*?~~~/g, '\n')
|
|
36
|
+
.replace(/`[^`]*`/g, ' ')
|
|
37
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
38
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
39
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, '\n')
|
|
40
|
+
.replace(/<[^>]+>/g, ' ')
|
|
41
|
+
.replace(/^\s*\|.*\|\s*$/gm, '\n')
|
|
42
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
43
|
+
.replace(/^\s{0,3}>\s?/gm, '')
|
|
44
|
+
.replace(/^\s*[-*+]\s+\[[ xX]\]\s+/gm, '')
|
|
45
|
+
.replace(/^\s*[-*+]\s+/gm, '')
|
|
46
|
+
.replace(/^\s*\d+[.)]\s+/gm, '')
|
|
47
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function detectLanguage(file, text = '', requested = 'auto') {
|
|
52
|
+
const normalized = String(requested || 'auto').toLowerCase();
|
|
53
|
+
if (['ko', 'en', 'zh', 'ja'].includes(normalized)) return normalized;
|
|
54
|
+
|
|
55
|
+
const path = String(file || '').toLowerCase();
|
|
56
|
+
if (/(^|[._/-])kr([._/-]|$)|(^|[._/-])ko([._/-]|$)|korean/.test(path)) return 'ko';
|
|
57
|
+
if (/(^|[._/-])ja([._/-]|$)|japanese/.test(path)) return 'ja';
|
|
58
|
+
if (/(^|[._/-])zh([._/-]|$)|chinese/.test(path)) return 'zh';
|
|
59
|
+
|
|
60
|
+
const sample = String(text || '').slice(0, 12000);
|
|
61
|
+
const hangul = (sample.match(/[\uac00-\ud7af]/g) || []).length;
|
|
62
|
+
const kana = (sample.match(/[\u3040-\u30ff]/g) || []).length;
|
|
63
|
+
const cjk = (sample.match(/[\u4e00-\u9fff]/g) || []).length;
|
|
64
|
+
const latin = (sample.match(/[A-Za-z]/g) || []).length;
|
|
65
|
+
const cjkTotal = hangul + kana + cjk;
|
|
66
|
+
if (latin >= 80 && latin > cjkTotal * 2) return 'en';
|
|
67
|
+
if (hangul >= 8 && hangul >= kana && hangul >= cjk) return 'ko';
|
|
68
|
+
if (kana >= 8) return 'ja';
|
|
69
|
+
if (cjk >= 8) return 'zh';
|
|
70
|
+
return 'en';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getLexicon(lang, repoRoot) {
|
|
74
|
+
const key = `${repoRoot}\0${lang}`;
|
|
75
|
+
if (!lexiconCache.has(key)) lexiconCache.set(key, loadLexicon(lang, repoRoot));
|
|
76
|
+
return lexiconCache.get(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function scoreText(text, { file = '', lang = 'auto', gate = 30, repoRoot = DEFAULT_REPO_ROOT } = {}) {
|
|
80
|
+
const prose = stripNonProse(text);
|
|
81
|
+
const resolvedLang = detectLanguage(file, prose, lang);
|
|
82
|
+
const result = analyzeText(prose, {
|
|
83
|
+
lang: resolvedLang,
|
|
84
|
+
repoRoot,
|
|
85
|
+
lexicon: getLexicon(resolvedLang, repoRoot),
|
|
86
|
+
});
|
|
87
|
+
const paragraphCount = result.paragraphs.length;
|
|
88
|
+
const hotCount = result.paragraphs.filter((p) => p.hot).length;
|
|
89
|
+
const score = paragraphCount ? (hotCount / paragraphCount) * 100 : 0;
|
|
90
|
+
return {
|
|
91
|
+
file,
|
|
92
|
+
lang: resolvedLang,
|
|
93
|
+
paragraphCount,
|
|
94
|
+
hotCount,
|
|
95
|
+
score,
|
|
96
|
+
gate,
|
|
97
|
+
overGate: score > gate,
|
|
98
|
+
skipped: paragraphCount === 0,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isInside(base, candidate) {
|
|
103
|
+
const rel = relative(base, candidate);
|
|
104
|
+
return rel === '' || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function normalizeFiles(files, {
|
|
108
|
+
cwd = process.cwd(),
|
|
109
|
+
extensions = DEFAULT_PROSE_EXTENSIONS,
|
|
110
|
+
maxFiles = 50,
|
|
111
|
+
} = {}) {
|
|
112
|
+
const base = resolve(cwd);
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const raw of files) {
|
|
116
|
+
if (!raw) continue;
|
|
117
|
+
const absolute = resolve(base, raw);
|
|
118
|
+
if (!isInside(base, absolute)) continue;
|
|
119
|
+
if (seen.has(absolute)) continue;
|
|
120
|
+
seen.add(absolute);
|
|
121
|
+
if (!existsSync(absolute)) continue;
|
|
122
|
+
if (!statSync(absolute).isFile()) continue;
|
|
123
|
+
const rel = relative(base, absolute);
|
|
124
|
+
if (!isProsePath(rel, extensions)) continue;
|
|
125
|
+
out.push(rel);
|
|
126
|
+
if (out.length >= maxFiles) break;
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function scoreFiles(files, {
|
|
132
|
+
cwd = process.cwd(),
|
|
133
|
+
repoRoot = DEFAULT_REPO_ROOT,
|
|
134
|
+
lang = 'auto',
|
|
135
|
+
gate = 30,
|
|
136
|
+
extensions = DEFAULT_PROSE_EXTENSIONS,
|
|
137
|
+
maxFiles = 50,
|
|
138
|
+
} = {}) {
|
|
139
|
+
return normalizeFiles(files, { cwd, extensions, maxFiles }).map((file) => {
|
|
140
|
+
const body = readFileSync(resolve(cwd, file), 'utf8');
|
|
141
|
+
return scoreText(body, { file, lang, gate, repoRoot });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function statusIcon(row) {
|
|
146
|
+
if (row.skipped) return 'skip';
|
|
147
|
+
return row.overGate ? 'fail' : 'pass';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatMarkdownReport(rows, { gate = 30, title = 'Patina prose hotspot report' } = {}) {
|
|
151
|
+
const lines = [
|
|
152
|
+
`# ${title}`,
|
|
153
|
+
'',
|
|
154
|
+
`Gate: **${Number(gate).toFixed(0)}%** hot prose paragraphs. This deterministic check flags editing hotspots; it is not an authorship verdict.`,
|
|
155
|
+
'',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
if (rows.length === 0) {
|
|
159
|
+
lines.push('No changed prose files were found.');
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push('| status | file | lang | paragraphs | hot | score |');
|
|
164
|
+
lines.push('|---|---|---:|---:|---:|---:|');
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
lines.push(
|
|
167
|
+
`| ${statusIcon(row)} | ${escapeCell(row.file)} | ${row.lang} | ${row.paragraphCount} | ${row.hotCount} | ${row.score.toFixed(1)}% |`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function summarizeRows(rows) {
|
|
174
|
+
const maxScore = rows.reduce((max, row) => Math.max(max, row.score), 0);
|
|
175
|
+
const failed = rows.filter((row) => row.overGate);
|
|
176
|
+
return {
|
|
177
|
+
fileCount: rows.length,
|
|
178
|
+
failedCount: failed.length,
|
|
179
|
+
maxScore,
|
|
180
|
+
failed,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function escapeCell(value) {
|
|
185
|
+
return String(value).replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim();
|
|
186
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Refresh deterministic metric ranges for suspect-zone benchmark fixtures.
|
|
3
|
+
//
|
|
4
|
+
// This script intentionally does not call tests/quality/benchmark.mjs, because
|
|
5
|
+
// the benchmark itself validates these ranges. It reads fixtures, runs the
|
|
6
|
+
// in-tree analyzer, and writes a tight but reviewable regression baseline.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, resolve, relative } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import yaml from 'js-yaml';
|
|
12
|
+
|
|
13
|
+
import { analyzeText } from '../src/features/index.js';
|
|
14
|
+
import { loadLexicon } from '../src/features/lexicon.js';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
18
|
+
const FIXTURES_ROOT = resolve(REPO_ROOT, 'tests/fixtures/suspect-zones');
|
|
19
|
+
const OUT_PATH = resolve(FIXTURES_ROOT, 'expected-ranges.json');
|
|
20
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
|
21
|
+
const TOLERANCES = {
|
|
22
|
+
cv: 0.025,
|
|
23
|
+
mattr: 0.03,
|
|
24
|
+
lexiconDensity: 5,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function parseFixture(path) {
|
|
28
|
+
const raw = readFileSync(path, 'utf8');
|
|
29
|
+
const m = raw.match(FRONTMATTER_RE);
|
|
30
|
+
if (!m) throw new Error(`Missing frontmatter: ${path}`);
|
|
31
|
+
return { meta: yaml.load(m[1]), body: m[2].trim(), path };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listFixtures() {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const lang of readdirSync(FIXTURES_ROOT)) {
|
|
37
|
+
const langDir = resolve(FIXTURES_ROOT, lang);
|
|
38
|
+
if (!statSync(langDir).isDirectory()) continue;
|
|
39
|
+
for (const cls of readdirSync(langDir)) {
|
|
40
|
+
const clsDir = resolve(langDir, cls);
|
|
41
|
+
if (!statSync(clsDir).isDirectory()) continue;
|
|
42
|
+
for (const file of readdirSync(clsDir)) {
|
|
43
|
+
if (file.endsWith('.md')) out.push(resolve(clsDir, file));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out.sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function round(n, digits = 3) {
|
|
51
|
+
return Math.round(n * 10 ** digits) / 10 ** digits;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function range(value, tolerance, floor = 0) {
|
|
55
|
+
const low = Math.max(floor, round(value - tolerance));
|
|
56
|
+
const high = round(value + tolerance);
|
|
57
|
+
return [low, high];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function detectorHot(result) {
|
|
61
|
+
return {
|
|
62
|
+
burstiness: result.paragraphs.some((p) => p.burstiness?.band === 'low'),
|
|
63
|
+
mattr: result.paragraphs.some((p) => p.mattr?.band === 'low'),
|
|
64
|
+
lexicon: result.paragraphs.some((p) => p.lexicon?.hot),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function main() {
|
|
69
|
+
const lexicons = {};
|
|
70
|
+
const metrics = {};
|
|
71
|
+
for (const path of listFixtures()) {
|
|
72
|
+
const { meta, body } = parseFixture(path);
|
|
73
|
+
const lang = meta.language;
|
|
74
|
+
if (!lexicons[lang]) lexicons[lang] = loadLexicon(lang, REPO_ROOT);
|
|
75
|
+
const result = analyzeText(body, { lang, lexicon: lexicons[lang] });
|
|
76
|
+
const p = result.paragraphs[0] || {};
|
|
77
|
+
const cv = round(p.burstiness?.cv ?? 0);
|
|
78
|
+
const mattr = round(p.mattr?.value ?? 0);
|
|
79
|
+
const lexiconDensity = round(p.lexicon?.density ?? 0);
|
|
80
|
+
metrics[meta.fixture_id] = {
|
|
81
|
+
path: relative(REPO_ROOT, path),
|
|
82
|
+
lang,
|
|
83
|
+
class: meta.class,
|
|
84
|
+
expected_hot: meta.expected_hot,
|
|
85
|
+
predicted_hot: result.hot,
|
|
86
|
+
detectors: detectorHot(result),
|
|
87
|
+
cv_band: p.burstiness?.band,
|
|
88
|
+
cv_range: range(cv, TOLERANCES.cv),
|
|
89
|
+
mattr_band: p.mattr?.band,
|
|
90
|
+
mattr_range: range(mattr, TOLERANCES.mattr),
|
|
91
|
+
lexicon_density_range: range(lexiconDensity, TOLERANCES.lexiconDensity),
|
|
92
|
+
lexicon_hits: p.lexicon?.hits ?? [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const out = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
generatedBy: 'node scripts/update-benchmark-ranges.mjs',
|
|
99
|
+
generatedAt: new Date().toISOString(),
|
|
100
|
+
fixtureCount: Object.keys(metrics).length,
|
|
101
|
+
tolerances: TOLERANCES,
|
|
102
|
+
metrics,
|
|
103
|
+
};
|
|
104
|
+
writeFileSync(OUT_PATH, `${JSON.stringify(out, null, 2)}\n`);
|
|
105
|
+
console.log(`Wrote ${relative(REPO_ROOT, OUT_PATH)} (${out.fixtureCount} fixtures)`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main();
|
package/src/api.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { validateBaseURL } from './security.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT = 120000;
|
|
4
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
5
|
+
const DEFAULT_BASE_BACKOFF_MS = 1000;
|
|
6
|
+
const DEFAULT_MAX_BACKOFF_MS = 30000;
|
|
7
|
+
export const DEFAULT_TEMPERATURE = 0.7;
|
|
8
|
+
|
|
9
|
+
// Status codes that warrant a retry. Network errors (no status, AbortError)
|
|
10
|
+
// are also retryable; auth / validation 4xxs are not.
|
|
11
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
12
|
+
|
|
13
|
+
// Subclassed error so the retry loop can read `.status` + `.retryAfter`
|
|
14
|
+
// without re-parsing strings.
|
|
15
|
+
export class HttpError extends Error {
|
|
16
|
+
constructor(status, body, retryAfter) {
|
|
17
|
+
super(`HTTP ${status}: ${truncate(body)}`);
|
|
18
|
+
this.name = 'HttpError';
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.body = body;
|
|
21
|
+
this.retryAfter = retryAfter;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function truncate(text, max = 256) {
|
|
26
|
+
if (typeof text !== 'string') return '';
|
|
27
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function abortError(message = 'The operation was aborted') {
|
|
31
|
+
const err = new Error(message);
|
|
32
|
+
err.name = 'AbortError';
|
|
33
|
+
return err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function remainingBudgetMs(deadline, now) {
|
|
37
|
+
if (deadline === undefined || deadline === null) return Infinity;
|
|
38
|
+
return Math.max(0, deadline - now());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function throwIfAborted(signal) {
|
|
42
|
+
if (signal?.aborted) {
|
|
43
|
+
throw abortError('External abort signal canceled LLM API call');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sleepWithSignal(sleep, ms, signal) {
|
|
48
|
+
if (ms <= 0) return Promise.resolve();
|
|
49
|
+
if (!signal) return sleep(ms);
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const onAbort = () => {
|
|
52
|
+
cleanup();
|
|
53
|
+
reject(abortError('External abort signal canceled LLM API retry sleep'));
|
|
54
|
+
};
|
|
55
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
56
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
57
|
+
sleep(ms).then(
|
|
58
|
+
() => {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve();
|
|
61
|
+
},
|
|
62
|
+
(err) => {
|
|
63
|
+
cleanup();
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isRetryable(err) {
|
|
71
|
+
if (!err) return false;
|
|
72
|
+
if (err.name === 'AbortError') return true;
|
|
73
|
+
if (typeof err.status === 'number') return RETRYABLE_STATUS.has(err.status);
|
|
74
|
+
// Heuristic for fetch network errors (no status set).
|
|
75
|
+
return err.name === 'TypeError' || err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Honors Retry-After (seconds or HTTP-date). Falls back to exponential
|
|
79
|
+
// backoff with up to 50% jitter, capped at maxDelay.
|
|
80
|
+
export function computeBackoffMs(attempt, retryAfter, opts = {}) {
|
|
81
|
+
const {
|
|
82
|
+
base = DEFAULT_BASE_BACKOFF_MS,
|
|
83
|
+
max = DEFAULT_MAX_BACKOFF_MS,
|
|
84
|
+
now = () => Date.now(),
|
|
85
|
+
random = Math.random,
|
|
86
|
+
} = opts;
|
|
87
|
+
|
|
88
|
+
if (retryAfter) {
|
|
89
|
+
const asNumber = Number(retryAfter);
|
|
90
|
+
if (Number.isFinite(asNumber) && asNumber >= 0) {
|
|
91
|
+
return Math.min(asNumber * 1000, max);
|
|
92
|
+
}
|
|
93
|
+
const asDateMs = Date.parse(retryAfter);
|
|
94
|
+
if (Number.isFinite(asDateMs)) {
|
|
95
|
+
return Math.max(0, Math.min(asDateMs - now(), max));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const exp = Math.min(base * 2 ** attempt, max);
|
|
100
|
+
const jitter = random() * exp * 0.5;
|
|
101
|
+
return Math.min(exp + jitter, max);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Bounded-concurrency semaphore. `max <= 0` yields a no-op gate for callers
|
|
105
|
+
// that explicitly opt into unlimited fanout.
|
|
106
|
+
export function createSemaphore(max) {
|
|
107
|
+
if (!max || max <= 0) {
|
|
108
|
+
return { acquire: () => Promise.resolve(() => {}) };
|
|
109
|
+
}
|
|
110
|
+
let active = 0;
|
|
111
|
+
const queue = [];
|
|
112
|
+
const drain = () => {
|
|
113
|
+
if (active < max && queue.length) {
|
|
114
|
+
active++;
|
|
115
|
+
const resolve = queue.shift();
|
|
116
|
+
resolve(() => {
|
|
117
|
+
active--;
|
|
118
|
+
drain();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
acquire() {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
queue.push(resolve);
|
|
126
|
+
if (active < max) drain();
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function callLLM({
|
|
133
|
+
prompt,
|
|
134
|
+
apiKey,
|
|
135
|
+
baseURL = 'https://api.openai.com/v1',
|
|
136
|
+
model = 'gpt-4o',
|
|
137
|
+
temperature = DEFAULT_TEMPERATURE,
|
|
138
|
+
seed,
|
|
139
|
+
timeout = DEFAULT_TIMEOUT,
|
|
140
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
141
|
+
deadline,
|
|
142
|
+
signal,
|
|
143
|
+
allowInsecureBaseURL = false,
|
|
144
|
+
onResponse,
|
|
145
|
+
cache,
|
|
146
|
+
// Allows tests to inject a deterministic delay function.
|
|
147
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
148
|
+
now = () => Date.now(),
|
|
149
|
+
}) {
|
|
150
|
+
validateBaseURL(baseURL, { allowInsecure: allowInsecureBaseURL });
|
|
151
|
+
const url = `${baseURL}/chat/completions`;
|
|
152
|
+
const body = {
|
|
153
|
+
model,
|
|
154
|
+
messages: [{ role: 'user', content: prompt }],
|
|
155
|
+
temperature,
|
|
156
|
+
};
|
|
157
|
+
if (seed !== undefined && seed !== null) body.seed = seed;
|
|
158
|
+
|
|
159
|
+
const cached = cache?.get?.({ prompt, model, temperature, baseURL });
|
|
160
|
+
if (cached) {
|
|
161
|
+
onResponse?.({
|
|
162
|
+
provider: 'cache',
|
|
163
|
+
model: cached.responseModel ?? cached.model ?? model,
|
|
164
|
+
requestedModel: model,
|
|
165
|
+
temperature,
|
|
166
|
+
seed: seed ?? null,
|
|
167
|
+
usage: cached.usage ?? null,
|
|
168
|
+
rawResponse: null,
|
|
169
|
+
content: cached.content,
|
|
170
|
+
cache: { hit: true, key: cached.key, path: cached.path },
|
|
171
|
+
});
|
|
172
|
+
return cached.content;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let lastError;
|
|
176
|
+
let attemptsMade = 0;
|
|
177
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
178
|
+
try {
|
|
179
|
+
throwIfAborted(signal);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
lastError = err;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
const remainingBeforeAttempt = remainingBudgetMs(deadline, now);
|
|
185
|
+
if (remainingBeforeAttempt <= 0) {
|
|
186
|
+
lastError = new Error('LLM API deadline exceeded before the next retry attempt');
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
let timer;
|
|
192
|
+
let signalCleanup = () => {};
|
|
193
|
+
try {
|
|
194
|
+
const attemptTimeout = Math.min(timeout, remainingBeforeAttempt);
|
|
195
|
+
timer = setTimeout(() => controller.abort(), attemptTimeout);
|
|
196
|
+
if (signal) {
|
|
197
|
+
const onAbort = () => controller.abort();
|
|
198
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
199
|
+
signalCleanup = () => signal.removeEventListener('abort', onAbort);
|
|
200
|
+
}
|
|
201
|
+
attemptsMade++;
|
|
202
|
+
|
|
203
|
+
const response = await fetch(url, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: {
|
|
206
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
207
|
+
'Content-Type': 'application/json',
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify(body),
|
|
210
|
+
signal: controller.signal,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const errorText = await response.text();
|
|
215
|
+
throw new HttpError(
|
|
216
|
+
response.status,
|
|
217
|
+
errorText,
|
|
218
|
+
response.headers.get('retry-after')
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const data = await response.json();
|
|
223
|
+
const content = data.choices?.[0]?.message?.content;
|
|
224
|
+
if (!content) {
|
|
225
|
+
throw new Error('Empty response from LLM API');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const metadata = {
|
|
229
|
+
provider: 'openai-http',
|
|
230
|
+
model: data.model ?? model,
|
|
231
|
+
requestedModel: model,
|
|
232
|
+
temperature,
|
|
233
|
+
seed: seed ?? null,
|
|
234
|
+
usage: data.usage ?? null,
|
|
235
|
+
rawResponse: data,
|
|
236
|
+
content,
|
|
237
|
+
cache: cache ? { hit: false } : null,
|
|
238
|
+
};
|
|
239
|
+
cache?.set?.({ prompt, model, temperature, baseURL }, content, metadata);
|
|
240
|
+
onResponse?.(metadata);
|
|
241
|
+
|
|
242
|
+
return content;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
lastError = err;
|
|
245
|
+
if (signal?.aborted) break;
|
|
246
|
+
const remainingAfterAttempt = remainingBudgetMs(deadline, now);
|
|
247
|
+
if (remainingAfterAttempt <= 0) {
|
|
248
|
+
lastError = new Error(`LLM API deadline exceeded after attempt ${attempt + 1}: ${err.message}`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (attempt < maxRetries && isRetryable(err)) {
|
|
252
|
+
const delay = computeBackoffMs(attempt, err.retryAfter, {
|
|
253
|
+
max: Math.min(DEFAULT_MAX_BACKOFF_MS, remainingAfterAttempt),
|
|
254
|
+
now,
|
|
255
|
+
});
|
|
256
|
+
await sleepWithSignal(sleep, delay, signal);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// Non-retryable or out of attempts — bail out.
|
|
260
|
+
break;
|
|
261
|
+
} finally {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
signalCleanup();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const err = new Error(`LLM API failed after ${attemptsMade || 1} attempts: ${lastError?.message ?? 'unknown'}`);
|
|
268
|
+
if (lastError?.name === 'AbortError') err.name = 'AbortError';
|
|
269
|
+
if (typeof lastError?.status === 'number') err.status = lastError.status;
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function callLLMMultiple({
|
|
274
|
+
prompt,
|
|
275
|
+
models,
|
|
276
|
+
apiKey,
|
|
277
|
+
baseURL = 'https://api.openai.com/v1',
|
|
278
|
+
temperature = DEFAULT_TEMPERATURE,
|
|
279
|
+
seed,
|
|
280
|
+
timeout = DEFAULT_TIMEOUT,
|
|
281
|
+
allowInsecureBaseURL = false,
|
|
282
|
+
deadline,
|
|
283
|
+
signal,
|
|
284
|
+
maxConcurrency,
|
|
285
|
+
onStart,
|
|
286
|
+
onComplete,
|
|
287
|
+
onResponse,
|
|
288
|
+
cache,
|
|
289
|
+
callLLM: callLLMImpl = callLLM,
|
|
290
|
+
sleep,
|
|
291
|
+
now = () => Date.now(),
|
|
292
|
+
}) {
|
|
293
|
+
validateBaseURL(baseURL, { allowInsecure: allowInsecureBaseURL });
|
|
294
|
+
const effectiveMaxConcurrency =
|
|
295
|
+
maxConcurrency === undefined || maxConcurrency === null
|
|
296
|
+
? Math.min(models.length, 3)
|
|
297
|
+
: maxConcurrency;
|
|
298
|
+
const sem = createSemaphore(effectiveMaxConcurrency);
|
|
299
|
+
const promises = models.map(async (model) => {
|
|
300
|
+
const release = await sem.acquire();
|
|
301
|
+
if (onStart) onStart(model);
|
|
302
|
+
try {
|
|
303
|
+
const result = await callLLMImpl({
|
|
304
|
+
prompt,
|
|
305
|
+
apiKey,
|
|
306
|
+
baseURL,
|
|
307
|
+
model,
|
|
308
|
+
temperature,
|
|
309
|
+
seed,
|
|
310
|
+
timeout,
|
|
311
|
+
deadline,
|
|
312
|
+
signal,
|
|
313
|
+
allowInsecureBaseURL,
|
|
314
|
+
onResponse,
|
|
315
|
+
cache,
|
|
316
|
+
sleep,
|
|
317
|
+
now,
|
|
318
|
+
});
|
|
319
|
+
if (onComplete) onComplete(model, true);
|
|
320
|
+
return { model, result, ok: true };
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (onComplete) onComplete(model, false);
|
|
323
|
+
return { model, error: err.message, ok: false };
|
|
324
|
+
} finally {
|
|
325
|
+
release();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return Promise.all(promises);
|
|
330
|
+
}
|