humanizer-lint 0.2.3 → 0.2.4

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/SKILL.md CHANGED
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: Node.js >= 18.3.0 (zero external dependencies)
6
6
  allowed-tools: Bash(node:*) Read Write
7
7
  metadata:
8
- version: "0.2.3"
8
+ version: "0.2.4"
9
9
  author: "yanstd888"
10
10
  domain: "writing"
11
11
  ---
package/package.json CHANGED
@@ -1,25 +1,26 @@
1
- {
2
- "name": "humanizer-lint",
3
- "version": "0.2.3",
4
- "description": "Closed-loop stylometric linter and anti-AI-slop diagnostic engine for Indonesian and English prose",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=18.3.0"
8
- },
9
- "bin": {
10
- "humanizer-lint": "./scripts/analyze.mjs",
11
- "humanizer-fingerprint": "./scripts/fingerprint.mjs"
12
- },
13
- "files": [
14
- "SKILL.md",
15
- "README.md",
16
- "LICENSE",
17
- "scripts/",
18
- "rules/",
19
- "profiles/"
20
- ],
21
- "scripts": {
22
- "test": "node --test tests/test-runner.mjs"
23
- },
24
- "license": "MIT"
25
- }
1
+ {
2
+ "name": "humanizer-lint",
3
+ "version": "0.2.4",
4
+ "description": "Closed-loop stylometric linter and anti-AI-slop diagnostic engine for Indonesian and English prose",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18.3.0"
8
+ },
9
+ "bin": {
10
+ "humanizer-lint": "./scripts/analyze.mjs",
11
+ "humanizer-fingerprint": "./scripts/fingerprint.mjs"
12
+ },
13
+ "files": [
14
+ "SKILL.md",
15
+ "README.md",
16
+ "LICENSE",
17
+ "scripts/",
18
+ "rules/",
19
+ "profiles/"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test tests/test-runner.mjs",
23
+ "build:demo": "node demo/build.mjs"
24
+ },
25
+ "license": "MIT"
26
+ }
@@ -1,408 +1,87 @@
1
- #!/usr/bin/env node
2
- import { readFile, readFileSync, realpathSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { parseArgs } from 'node:util';
5
-
6
- import { sanitizeMarkdown } from './utils/sanitizer.mjs';
7
- import { calculateMetrics, mean, punctuationRates } from './utils/metrics.mjs';
8
- import { tokenizeText } from './utils/tokenizer.mjs';
9
-
10
- const RULES = {
11
- id: JSON.parse(readFileSync(new URL('../rules/id-slop.json', import.meta.url), 'utf8')),
12
- en: JSON.parse(readFileSync(new URL('../rules/en-slop.json', import.meta.url), 'utf8')),
13
- };
14
- const DEFAULT_PROFILE = JSON.parse(readFileSync(new URL('../profiles/default-human.json', import.meta.url), 'utf8'));
15
- const ID_WORDS = new Set(['yang', 'dan', 'di', 'ini', 'untuk', 'dengan', 'dari', 'pada', 'dalam', 'tidak', 'akan', 'atau', 'juga', 'oleh', 'adalah', 'sebagai', 'bahwa', 'dapat', 'karena', 'tersebut']);
16
- const EN_WORDS = new Set(['the', 'and', 'of', 'to', 'in', 'is', 'that', 'for', 'it', 'with', 'as', 'on', 'are', 'this', 'by', 'be', 'at', 'or', 'from', 'an']);
17
-
18
- function round(value, places = 4) {
19
- const factor = 10 ** places;
20
- return Math.round(value * factor) / factor;
21
- }
22
-
23
- function detectLanguage(words) {
24
- let id = 0;
25
- let en = 0;
26
- for (const word of words) {
27
- if (ID_WORDS.has(word)) id += 1;
28
- if (EN_WORDS.has(word)) en += 1;
29
- }
30
- return en > id ? 'en' : 'id';
31
- }
32
-
33
- function locationFor(sentence) {
34
- return `Paragraph ${sentence.paragraphIndex}, Sentence ${sentence.sentenceIndex}`;
35
- }
36
-
37
- function regexFor(pattern) {
38
- return new RegExp(pattern, 'giu');
39
- }
40
-
41
- function patternViolations(tokenized, rules, wordCount) {
42
- const violations = [];
43
- for (const rule of rules.patterns) {
44
- const candidates = [];
45
- const sentences = rule.scope === 'paragraph-start'
46
- ? tokenized.paragraphs.map((paragraph) => paragraph.sentences[0]).filter(Boolean)
47
- : tokenized.sentences;
48
-
49
- for (const sentence of sentences) {
50
- const regex = regexFor(rule.pattern);
51
- for (const match of sentence.text.matchAll(regex)) candidates.push({ sentence, match: match[0] });
52
- }
53
-
54
- const allowed = rule.maxOccurrences == null
55
- ? 0
56
- : rule.maxOccurrences * Math.max(1, Math.ceil(wordCount / (rule.perWords || wordCount || 1)));
57
- for (const candidate of candidates.slice(allowed)) {
58
- violations.push({
59
- type: rule.type,
60
- ruleId: rule.id,
61
- location: locationFor(candidate.sentence),
62
- match: candidate.match,
63
- fixInstruction: rule.fixInstruction,
64
- });
65
- }
66
- }
67
- return violations;
68
- }
69
-
70
- function monotonyViolations(tokenized) {
71
- const violations = [];
72
- for (const paragraph of tokenized.paragraphs) {
73
- for (let index = 0; index <= paragraph.sentences.length - 3; index += 1) {
74
- const window = paragraph.sentences.slice(index, index + 3);
75
- const lengths = window.map((sentence) => sentence.wordCount);
76
- if (Math.abs(lengths[1] - lengths[0]) <= 3 && Math.abs(lengths[2] - lengths[1]) <= 3) {
77
- violations.push({
78
- type: 'MONOTONOUS_RHYTHM',
79
- location: `Paragraph ${paragraph.paragraphIndex}, Sentences ${window[0].sentenceIndex}-${window[2].sentenceIndex}`,
80
- lengths,
81
- fixInstruction: `Sentence lengths are uniform (${lengths.join(', ')} words). Split one sentence into a short statement or merge two into a longer clause.`,
82
- });
83
- }
84
- }
85
- }
86
- return violations;
87
- }
88
-
89
- function structuralViolations(tokenized, metrics) {
90
- const violations = [];
91
- if (metrics.wordCount >= 150 && metrics.compressionRatio < 0.42) {
92
- violations.push({
93
- type: 'LOW_ENTROPY_REPETITION',
94
- location: 'Whole text',
95
- fixInstruction: 'Remove repeated templates and vary concrete sentence structures.',
96
- });
97
- }
98
-
99
- for (let index = 0; index <= tokenized.paragraphs.length - 3; index += 1) {
100
- const window = tokenized.paragraphs.slice(index, index + 3);
101
- const counts = window.map((paragraph) => paragraph.sentences.length);
102
- if (counts[0] > 0 && counts.every((count) => count === counts[0])) {
103
- violations.push({
104
- type: 'SYMMETRIC_PARAGRAPHS',
105
- location: `Paragraphs ${window[0].paragraphIndex}-${window[2].paragraphIndex}`,
106
- counts,
107
- fixInstruction: 'Merge or split a paragraph where the content supports a different rhythm.',
108
- });
109
- }
110
- }
111
-
112
- for (const paragraph of tokenized.paragraphs) {
113
- const starters = new Map();
114
- for (const sentence of paragraph.sentences) {
115
- const starter = sentence.words[0];
116
- if (!starter) continue;
117
- const group = starters.get(starter) ?? [];
118
- group.push(sentence.sentenceIndex);
119
- starters.set(starter, group);
120
- }
121
- for (const [starter, indexes] of starters) {
122
- if (indexes.length >= 3) {
123
- violations.push({
124
- type: 'REPETITIVE_STARTERS',
125
- location: `Paragraph ${paragraph.paragraphIndex}, Sentences ${indexes.join(', ')}`,
126
- match: starter,
127
- fixInstruction: 'Change one sentence opening or combine sentences where the subject repeats.',
128
- });
129
- }
130
- }
131
- }
132
- return violations;
133
- }
134
-
135
- function statisticalViolations(metrics, thresholds, language) {
136
- const violations = [];
137
- const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
138
- if (metrics.burstinessCV < cvTarget) {
139
- violations.push({
140
- type: 'LOW_BURSTINESS',
141
- location: 'Whole text',
142
- actual: round(metrics.burstinessCV),
143
- target: cvTarget,
144
- fixInstruction: 'Vary sentence lengths by splitting one uniform sentence and merging another where the meaning supports it.',
145
- });
146
- }
147
- if (metrics.sentenceCount >= 8 && (metrics.shortSentenceRatio < thresholds.shortSentenceRatio || metrics.longSentenceRatio < thresholds.longSentenceRatio)) {
148
- violations.push({
149
- type: 'MISSING_SENTENCE_RANGE',
150
- location: 'Whole text',
151
- shortSentenceRatio: round(metrics.shortSentenceRatio),
152
- longSentenceRatio: round(metrics.longSentenceRatio),
153
- fixInstruction: 'Add a supported sentence of 6 words or fewer and a supported sentence of 26 words or more until both distribution thresholds pass.',
154
- });
155
- }
156
- if (metrics.adjacentDelta < thresholds.adjacentDelta) {
157
- violations.push({
158
- type: 'LOW_ADJACENT_DELTA',
159
- location: 'Whole text',
160
- actual: round(metrics.adjacentDelta),
161
- target: thresholds.adjacentDelta,
162
- fixInstruction: 'Change neighboring sentence lengths by splitting or merging only where the content allows it.',
163
- });
164
- }
165
- if (metrics.mattr < thresholds.mattr[language]) {
166
- violations.push({
167
- type: 'LOW_LEXICAL_DIVERSITY',
168
- location: 'Whole text',
169
- actual: round(metrics.mattr),
170
- target: thresholds.mattr[language],
171
- fixInstruction: 'Replace repeated generic wording with precise terms already supported by the source.',
172
- });
173
- }
174
- return violations;
175
- }
176
-
177
- function morphologyViolations(tokenized, morphology) {
178
- if (!morphology) return { violations: [], nominalizationRatio: 0, passiveRatio: 0 };
179
- const words = tokenized.words;
180
- const nominalizationPattern = new RegExp(morphology.nominalizationPattern, 'iu');
181
- const nominalizationWhitelist = new Set(morphology.nominalizationWhitelist);
182
- const nominalizations = words.filter((word) => nominalizationPattern.test(word) && !nominalizationWhitelist.has(word));
183
- const passivePattern = new RegExp(morphology.passivePattern, 'iu');
184
- const loanwordExclusion = new RegExp(morphology.loanwordExclusionPattern, 'iu');
185
- const loanwordSuffixExclusion = new RegExp(morphology.loanwordSuffixExclusionPattern ?? '$^', 'iu');
186
- const passives = words.filter((word) => passivePattern.test(word) && !loanwordExclusion.test(word) && !loanwordSuffixExclusion.test(word));
187
- const nominalizationRatio = words.length ? nominalizations.length / words.length : 0;
188
- const passiveRatio = words.length ? passives.length / words.length : 0;
189
- const violations = [];
190
-
191
- if (nominalizationRatio > morphology.nominalizationMaxRatio) {
192
- violations.push({
193
- type: 'EXCESSIVE_NOMINALIZATION',
194
- location: 'Whole text',
195
- ratio: round(nominalizationRatio),
196
- matches: [...new Set(nominalizations)],
197
- fixInstruction: 'Replace abstract nominalizations with concrete actors and verbs.',
198
- });
199
- }
200
- if (passiveRatio > morphology.passiveMaxRatio) {
201
- violations.push({
202
- type: 'PASSIVE_BLOATING',
203
- location: 'Whole text',
204
- ratio: round(passiveRatio),
205
- matches: [...new Set(passives)],
206
- fixInstruction: 'Name the actor and use an active verb where the actor is known.',
207
- });
208
- }
209
-
210
- const openingPattern = new RegExp(morphology.subordinateOpeningPattern, 'iu');
211
- let foundConsecutiveOpening = false;
212
- for (const paragraph of tokenized.paragraphs) {
213
- for (let index = 1; index < paragraph.sentences.length; index += 1) {
214
- const previous = paragraph.sentences[index - 1];
215
- const current = paragraph.sentences[index];
216
- if (!openingPattern.test(previous.text) || !openingPattern.test(current.text)) continue;
217
- violations.push({
218
- type: 'PASSIVE_BLOATING',
219
- location: `Paragraph ${paragraph.paragraphIndex}, Sentences ${previous.sentenceIndex}-${current.sentenceIndex}`,
220
- match: 'Two or more consecutive sentences start with translationese subordinate clauses',
221
- fixInstruction: 'Rewrite to start directly with the active subject (Subjek + Predikat).',
222
- });
223
- foundConsecutiveOpening = true;
224
- break;
225
- }
226
- if (foundConsecutiveOpening) break;
227
- }
228
- return { violations, nominalizationRatio, passiveRatio };
229
- }
230
-
231
- function voiceDeviation(metrics, tokenized, text, profile) {
232
- const targets = profile?.targets ?? profile?.metrics;
233
- if (!targets) return 0;
234
- const actual = {
235
- meanSentenceLength: metrics.meanSentenceLength,
236
- burstinessCV: metrics.burstinessCV,
237
- adjacentDelta: metrics.adjacentDelta,
238
- mattr: metrics.mattr,
239
- averageParagraphSentenceCount: mean(tokenized.paragraphs.map((paragraph) => paragraph.sentences.length)),
240
- };
241
- const deviations = [];
242
- for (const key of Object.keys(actual)) {
243
- if (typeof targets[key] !== 'number') continue;
244
- deviations.push(Math.min(3, Math.abs(actual[key] - targets[key]) / Math.max(Math.abs(targets[key]), 0.01)));
245
- }
246
- const targetPunctuation = targets.punctuationRates;
247
- if (targetPunctuation) {
248
- const actualPunctuation = punctuationRates(text, metrics.wordCount);
249
- for (const key of Object.keys(targetPunctuation)) {
250
- deviations.push(Math.min(3, Math.abs(actualPunctuation[key] - targetPunctuation[key]) / Math.max(Math.abs(targetPunctuation[key]), 0.01)));
251
- }
252
- }
253
- const result = deviations.length ? mean(deviations) : 0;
254
- return result < 0.0005 ? 0 : result;
255
- }
256
-
257
- function scoreResult(metrics, violations, language, thresholds) {
258
- const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
259
- let burstinessPenalty = metrics.burstinessCV < cvTarget ? Math.min(25, Math.round((cvTarget - metrics.burstinessCV) * 100)) : 0;
260
- if (metrics.sentenceCount >= 8 && (metrics.shortSentenceRatio < thresholds.shortSentenceRatio || metrics.longSentenceRatio < thresholds.longSentenceRatio)) {
261
- burstinessPenalty = Math.min(25, burstinessPenalty + 8);
262
- }
263
- const rhythmPenalty = Math.min(20, violations.filter((violation) => violation.type === 'MONOTONOUS_RHYTHM').length * 6);
264
- const lexicalPenalty = Math.min(30, violations.reduce((total, violation) => {
265
- if (violation.type === 'SLOP_PHRASE' || violation.type === 'DEFINITIONAL_OPENING') return total + 5;
266
- if (violation.type === 'STRUCTURAL_CLICHE') return total + 4;
267
- return total;
268
- }, 0));
269
- const morphologyPenalty = Math.min(15,
270
- (violations.some((violation) => violation.type === 'EXCESSIVE_NOMINALIZATION') ? 7.5 : 0)
271
- + Math.min(15, violations.filter((violation) => violation.type === 'PASSIVE_BLOATING').length * 7.5));
272
- const vocabularyPenalty = metrics.mattr < thresholds.mattr[language] ? 5 : 0;
273
- const entropyPenalty = violations.some((violation) => ['LOW_ENTROPY_REPETITION', 'SYMMETRIC_PARAGRAPHS', 'REPETITIVE_STARTERS'].includes(violation.type)) ? 5 : 0;
274
- const profilePenalty = violations.some((violation) => violation.type === 'VOICE_PROFILE_DEVIATION') ? 5 : 0;
275
- const vocabularyAndProfilePenalty = Math.min(10, vocabularyPenalty + entropyPenalty + profilePenalty);
276
- return Math.max(0, Math.round(100 - burstinessPenalty - rhythmPenalty - lexicalPenalty - morphologyPenalty - vocabularyAndProfilePenalty));
277
- }
278
-
279
- export function analyzeText(input, options = {}) {
280
- const sanitized = sanitizeMarkdown(input);
281
- const initial = tokenizeText(sanitized, options.language ?? 'auto');
282
- const language = options.language && options.language !== 'auto' ? options.language : detectLanguage(initial.words);
283
- if (!RULES[language]) throw new Error(`Unsupported language: ${language}`);
284
- const tokenized = language === (options.language ?? 'auto') ? initial : tokenizeText(sanitized, language);
285
- const profile = options.profile ?? DEFAULT_PROFILE;
286
- const thresholds = { ...DEFAULT_PROFILE.thresholds, ...(profile.thresholds ?? {}) };
287
- const metrics = calculateMetrics(tokenized, sanitized);
288
- const patternMatches = patternViolations(tokenized, RULES[language], metrics.wordCount);
289
-
290
- if (metrics.wordCount < thresholds.minimumWords || metrics.sentenceCount < thresholds.minimumSentences) {
291
- const violations = patternMatches.filter((violation) => violation.type === 'SLOP_PHRASE');
292
- return {
293
- status: 'SKIP',
294
- score: 100,
295
- language,
296
- warning: 'Text too short for statistical stylometry (< 3 sentences)',
297
- metrics: Object.fromEntries(Object.entries({ ...metrics, nominalizationRatio: 0, passiveRatio: 0, voiceProfileDeviation: 0 }).map(([key, value]) => [key, typeof value === 'number' ? round(value) : value])),
298
- violations,
299
- };
300
- }
301
-
302
- const morphology = morphologyViolations(tokenized, RULES[language].morphology);
303
- metrics.nominalizationRatio = morphology.nominalizationRatio;
304
- metrics.passiveRatio = morphology.passiveRatio;
305
- metrics.voiceProfileDeviation = voiceDeviation(metrics, tokenized, sanitized, profile);
306
- const profileTargets = profile.targets ?? profile.metrics ?? {};
307
- const targetMean = Number(profileTargets.meanSentenceLength);
308
- const meanDeviation = targetMean > 0 ? Math.abs(metrics.meanSentenceLength - targetMean) / targetMean : 0;
309
- const profileViolations = options.profile != null && meanDeviation > 0.3 ? [{
310
- type: 'VOICE_PROFILE_DEVIATION',
311
- location: 'Global Document',
312
- match: 'Mean sentence length deviates >30% from target voice profile',
313
- fixInstruction: 'Adjust overall sentence complexity so mean sentence length approaches the target profile.',
314
- }] : [];
315
- const violations = [
316
- ...patternMatches,
317
- ...monotonyViolations(tokenized),
318
- ...structuralViolations(tokenized, metrics),
319
- ...morphology.violations,
320
- ...profileViolations,
321
- ...statisticalViolations(metrics, thresholds, language),
322
- ];
323
- const score = scoreResult(metrics, violations, language, thresholds);
324
- const cvTarget = metrics.sentenceCount >= 8 ? thresholds.burstinessCVLong : thresholds.burstinessCVShort;
325
- const forbidden = violations.some((violation) => violation.type === 'SLOP_PHRASE' || violation.type === 'DEFINITIONAL_OPENING');
326
- const rhythmPass = metrics.adjacentDelta >= thresholds.adjacentDelta;
327
- const distributionPass = metrics.sentenceCount < 8 || (metrics.shortSentenceRatio >= thresholds.shortSentenceRatio && metrics.longSentenceRatio >= thresholds.longSentenceRatio);
328
- const status = score >= thresholds.passScore && !forbidden && metrics.burstinessCV >= cvTarget && rhythmPass && distributionPass ? 'PASS' : 'FAIL';
329
-
330
- return {
331
- status,
332
- score,
333
- language,
334
- metrics: Object.fromEntries(Object.entries(metrics).map(([key, value]) => [key, typeof value === 'number' ? round(value) : value])),
335
- violations,
336
- };
337
- }
338
-
339
- function formatHuman(result) {
340
- const lines = [`Status: ${result.status}`, `Score: ${result.score}`, `Language: ${result.language}`];
341
- if (result.warning) lines.push(`Warning: ${result.warning}`);
342
- lines.push('', 'Metrics:');
343
- for (const [name, value] of Object.entries(result.metrics)) lines.push(` ${name}: ${value}`);
344
- if (result.violations.length) lines.push('', 'Violations:');
345
- for (const violation of result.violations) lines.push(` ${violation.type} | ${violation.location} | ${violation.fixInstruction}`);
346
- return `${lines.join('\n')}\n`;
347
- }
348
-
349
- async function readStdin() {
350
- const chunks = [];
351
- for await (const chunk of process.stdin) chunks.push(chunk);
352
- return Buffer.concat(chunks.map((chunk) => Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))).toString('utf8');
353
- }
354
-
355
- export async function runAnalyzeCli(argv, io = {}) {
356
- const stdout = io.stdout ?? ((value) => process.stdout.write(value));
357
- const stderr = io.stderr ?? ((value) => process.stderr.write(value));
358
- const stdin = io.stdin ?? readStdin;
359
- try {
360
- const { values } = parseArgs({
361
- args: argv,
362
- options: {
363
- file: { type: 'string' },
364
- stdin: { type: 'boolean' },
365
- lang: { type: 'string', default: 'auto' },
366
- profile: { type: 'string' },
367
- json: { type: 'boolean' },
368
- help: { type: 'boolean' },
369
- },
370
- strict: true,
371
- allowPositionals: false,
372
- });
373
- if (values.help) {
374
- stdout('Usage: humanizer-lint (--file <path> | --stdin) [--lang id|en|auto] [--profile <path>] [--json]\n');
375
- return 0;
376
- }
377
- if (Boolean(values.file) === Boolean(values.stdin)) throw new Error('Choose exactly one input: --file or --stdin');
378
- if (!['id', 'en', 'auto'].includes(values.lang)) throw new Error('--lang must be id, en, or auto');
379
- if (values.stdin && io.stdin == null && process.stdin.isTTY) throw new Error('--stdin requires piped input');
380
- const text = values.file ? await new Promise((resolve, reject) => readFile(values.file, 'utf8', (error, data) => error ? reject(error) : resolve(data))) : await stdin();
381
- const profile = values.profile ? JSON.parse(await new Promise((resolve, reject) => readFile(values.profile, 'utf8', (error, data) => error ? reject(error) : resolve(data)))) : undefined;
382
- const result = analyzeText(text, { language: values.lang, profile });
383
- stdout(values.json ? `${JSON.stringify(result, null, 2)}\n` : formatHuman(result));
384
- return result.status === 'FAIL' ? 1 : 0;
385
- } catch (error) {
386
- stderr(`ERROR: ${error.message}\n`);
387
- return 2;
388
- }
389
- }
390
-
391
- function currentModulePath() {
392
- let value = decodeURIComponent(new URL(import.meta.url).pathname);
393
- if (/^\/[A-Za-z]:/.test(value)) value = value.slice(1);
394
- return path.resolve(value);
395
- }
396
-
397
- function isMainModule() {
398
- if (!process.argv[1]) return false;
399
- try {
400
- return realpathSync(process.argv[1]) === realpathSync(currentModulePath());
401
- } catch {
402
- return false;
403
- }
404
- }
405
-
406
- if (isMainModule()) {
407
- process.exitCode = await runAnalyzeCli(process.argv.slice(2));
408
- }
1
+ #!/usr/bin/env node
2
+ import { readFile, readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+
6
+ import { createAnalyzer } from './core/analyzer-core.mjs';
7
+ import { calculateMetrics } from './utils/metrics.mjs';
8
+
9
+ const RULES = {
10
+ id: JSON.parse(readFileSync(new URL('../rules/id-slop.json', import.meta.url), 'utf8')),
11
+ en: JSON.parse(readFileSync(new URL('../rules/en-slop.json', import.meta.url), 'utf8')),
12
+ };
13
+ const DEFAULT_PROFILE = JSON.parse(readFileSync(new URL('../profiles/default-human.json', import.meta.url), 'utf8'));
14
+ const { analyzeText } = createAnalyzer({ rules: RULES, defaultProfile: DEFAULT_PROFILE, calculateMetrics });
15
+
16
+ export { analyzeText };
17
+
18
+ function formatHuman(result) {
19
+ const lines = [`Status: ${result.status}`, `Score: ${result.score}`, `Language: ${result.language}`];
20
+ if (result.warning) lines.push(`Warning: ${result.warning}`);
21
+ lines.push('', 'Metrics:');
22
+ for (const [name, value] of Object.entries(result.metrics)) lines.push(` ${name}: ${value}`);
23
+ if (result.violations.length) lines.push('', 'Violations:');
24
+ for (const violation of result.violations) lines.push(` ${violation.type} | ${violation.location} | ${violation.fixInstruction}`);
25
+ return `${lines.join('\n')}\n`;
26
+ }
27
+
28
+ async function readStdin() {
29
+ const chunks = [];
30
+ for await (const chunk of process.stdin) chunks.push(chunk);
31
+ return Buffer.concat(chunks.map((chunk) => Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))).toString('utf8');
32
+ }
33
+
34
+ export async function runAnalyzeCli(argv, io = {}) {
35
+ const stdout = io.stdout ?? ((value) => process.stdout.write(value));
36
+ const stderr = io.stderr ?? ((value) => process.stderr.write(value));
37
+ const stdin = io.stdin ?? readStdin;
38
+ try {
39
+ const { values } = parseArgs({
40
+ args: argv,
41
+ options: {
42
+ file: { type: 'string' },
43
+ stdin: { type: 'boolean' },
44
+ lang: { type: 'string', default: 'auto' },
45
+ profile: { type: 'string' },
46
+ json: { type: 'boolean' },
47
+ help: { type: 'boolean' },
48
+ },
49
+ strict: true,
50
+ allowPositionals: false,
51
+ });
52
+ if (values.help) {
53
+ stdout('Usage: humanizer-lint (--file <path> | --stdin) [--lang id|en|auto] [--profile <path>] [--json]\n');
54
+ return 0;
55
+ }
56
+ if (Boolean(values.file) === Boolean(values.stdin)) throw new Error('Choose exactly one input: --file or --stdin');
57
+ if (!['id', 'en', 'auto'].includes(values.lang)) throw new Error('--lang must be id, en, or auto');
58
+ if (values.stdin && io.stdin == null && process.stdin.isTTY) throw new Error('--stdin requires piped input');
59
+ const text = values.file ? await new Promise((resolve, reject) => readFile(values.file, 'utf8', (error, data) => error ? reject(error) : resolve(data))) : await stdin();
60
+ const profile = values.profile ? JSON.parse(await new Promise((resolve, reject) => readFile(values.profile, 'utf8', (error, data) => error ? reject(error) : resolve(data)))) : undefined;
61
+ const result = analyzeText(text, { language: values.lang, profile });
62
+ stdout(values.json ? `${JSON.stringify(result, null, 2)}\n` : formatHuman(result));
63
+ return result.status === 'FAIL' ? 1 : 0;
64
+ } catch (error) {
65
+ stderr(`ERROR: ${error.message}\n`);
66
+ return 2;
67
+ }
68
+ }
69
+
70
+ function currentModulePath() {
71
+ let value = decodeURIComponent(new URL(import.meta.url).pathname);
72
+ if (/^\/[A-Za-z]:/.test(value)) value = value.slice(1);
73
+ return path.resolve(value);
74
+ }
75
+
76
+ function isMainModule() {
77
+ if (!process.argv[1]) return false;
78
+ try {
79
+ return realpathSync(process.argv[1]) === realpathSync(currentModulePath());
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ if (isMainModule()) {
86
+ process.exitCode = await runAnalyzeCli(process.argv.slice(2));
87
+ }