@roughen/cli 0.3.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/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/roughen.mjs +204 -0
- package/lib/config.mjs +86 -0
- package/lib/jsx.mjs +494 -0
- package/lib/lint-file.mjs +86 -0
- package/lib/review.mjs +60 -0
- package/lib/site.mjs +409 -0
- package/lib/verify.mjs +299 -0
- package/package.json +40 -0
package/lib/verify.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFile, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { judgeRevision, countBanned, lint, revisableRules, revisionLengthTolerance } from '@roughen/core';
|
|
5
|
+
import { loadConfig, copyOptions } from './config.mjs';
|
|
6
|
+
import { formatFor } from './lint-file.mjs';
|
|
7
|
+
import { parseSource, extractCopy, virtualDocuments, groupText, countWords } from './jsx.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `roughen verify`: did a rewrite keep its facts and leave the code alone?
|
|
11
|
+
* Compares each file before and after, string by string, with the same copy
|
|
12
|
+
* reader and the same judgment (core's judgeRevision) every other surface
|
|
13
|
+
* uses. Read-only: it reads files and asks git for the old versions.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const positional = new Set(['loc', 'start', 'end', 'extra', 'range', 'leadingComments', 'trailingComments', 'innerComments', 'comments', 'tokens']);
|
|
17
|
+
const isString = (node) => node && (node.type === 'StringLiteral' || node.type === 'JSXText' || node.type === 'TemplateElement' || node.type === 'DirectiveLiteral');
|
|
18
|
+
const stringValue = (node) => node.type === 'TemplateElement' ? node.value.raw : node.value;
|
|
19
|
+
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
|
|
20
|
+
/** A file's rewritten copy may change by 15% or by this many words, whichever is more. */
|
|
21
|
+
const lengthSlackWords = 10;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every string-bearing node in traversal order, with where it sits: the
|
|
25
|
+
* property key, JSX attribute or element, and whether it's an import path.
|
|
26
|
+
* Two ASTs with the same shape list their strings in the same order.
|
|
27
|
+
*/
|
|
28
|
+
function strings(ast) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const visit = (node, context) => {
|
|
31
|
+
if (!node || typeof node.type !== 'string') return;
|
|
32
|
+
if (isString(node)) { out.push({ node, text: stringValue(node), context }); return; }
|
|
33
|
+
let next = context;
|
|
34
|
+
if (node.type === 'ObjectProperty' && !node.computed) next = { ...context, key: node.key.name ?? node.key.value, attribute: null };
|
|
35
|
+
else if (node.type === 'JSXAttribute') next = { ...context, attribute: node.name.name ?? `${node.name.namespace?.name}:${node.name.name?.name}`, key: null };
|
|
36
|
+
else if (node.type === 'JSXElement') next = { ...context, tag: node.openingElement.name.name ?? null, attribute: null, key: null };
|
|
37
|
+
else if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && node.source) || node.type === 'ImportExpression'
|
|
38
|
+
|| (node.type === 'CallExpression' && (node.callee?.type === 'Import' || node.callee?.name === 'require'))) next = { ...context, import: true };
|
|
39
|
+
for (const key of Object.keys(node)) {
|
|
40
|
+
if (positional.has(key)) continue;
|
|
41
|
+
const value = node[key];
|
|
42
|
+
if (Array.isArray(value)) for (const item of value) visit(item, next);
|
|
43
|
+
else if (value && typeof value === 'object') visit(value, next);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
visit(ast.program, {});
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The first place two ASTs differ other than in string contents, as an offset into `after`, or -1. */
|
|
51
|
+
function firstCodeDifference(before, after) {
|
|
52
|
+
const differs = (a, b, holder) => {
|
|
53
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
54
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return holder;
|
|
55
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
56
|
+
if (i >= a.length || i >= b.length) return b[i]?.start ?? b[b.length - 1]?.end ?? holder;
|
|
57
|
+
const found = differs(a[i], b[i], b[i]?.start ?? holder);
|
|
58
|
+
if (found >= 0) return found;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
if (a && typeof a === 'object' && b && typeof b === 'object') {
|
|
63
|
+
if (a.type !== b.type) return b.start ?? holder;
|
|
64
|
+
const at = b.start ?? holder;
|
|
65
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
if (positional.has(key)) continue;
|
|
68
|
+
if (isString(a) && (key === 'value' || key === 'raw' || key === 'cooked')) continue;
|
|
69
|
+
const found = differs(a[key], b[key], at);
|
|
70
|
+
if (found >= 0) return found;
|
|
71
|
+
}
|
|
72
|
+
return -1;
|
|
73
|
+
}
|
|
74
|
+
return a === b ? -1 : holder;
|
|
75
|
+
};
|
|
76
|
+
return differs(before.program, after.program, 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lineAt = (source, offset) => source.slice(0, Math.max(0, offset)).split('\n').length;
|
|
80
|
+
const excerpt = (text, limit = 120) => { const value = normalize(text); return value.length > limit ? `${value.slice(0, limit - 1)}…` : value; };
|
|
81
|
+
const where = (context) => context.attribute ? `attribute ${context.attribute}` : context.key ? `key ${context.key}` : context.tag ? `<${context.tag}> text` : 'a string';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Banned characters and patterns in a file's copy (body and short: what a
|
|
85
|
+
* reader sees, minus verbatim quotes), so a before and after compare.
|
|
86
|
+
*/
|
|
87
|
+
function bannedIn(source, fragments, voice) {
|
|
88
|
+
const { body, short } = virtualDocuments(source, fragments);
|
|
89
|
+
return countBanned(`${body.text}\n\n${short.text}`, voice, 'text');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verifies one JS/TS/JSX file's copy edit. `config` is the file's loaded
|
|
94
|
+
* roughen config (voice bans, register, copy keys).
|
|
95
|
+
*/
|
|
96
|
+
export function verifyCopy(before, after, { file, config = {} }) {
|
|
97
|
+
const report = { file, format: 'copy', changedStrings: 0, errors: [], warnings: [] };
|
|
98
|
+
const error = (line, message, extra = {}) => report.errors.push({ line, message, ...extra });
|
|
99
|
+
const warn = (line, message, extra = {}) => report.warnings.push({ line, message, ...extra });
|
|
100
|
+
let beforeAst;
|
|
101
|
+
try { beforeAst = parseSource(before, file); } catch (caught) { report.skipped = `the old version doesn't parse (${caught.message}), so there's nothing to compare against`; return report; }
|
|
102
|
+
let afterAst;
|
|
103
|
+
try { afterAst = parseSource(after, file); } catch (caught) { error(caught.loc?.line ?? 1, `The file no longer parses: ${caught.message}`); return report; }
|
|
104
|
+
const beforeStrings = strings(beforeAst); const afterStrings = strings(afterAst);
|
|
105
|
+
const difference = firstCodeDifference(beforeAst, afterAst);
|
|
106
|
+
if (difference >= 0) {
|
|
107
|
+
error(lineAt(after, difference), 'Code changed, not just copy: the file differs from the original outside its strings. A copy edit changes text only; revert the code change or review it separately.');
|
|
108
|
+
// A renamed key or a changed attribute leaves the strings in step; anything else can't be paired.
|
|
109
|
+
if (beforeStrings.length !== afterStrings.length) return report;
|
|
110
|
+
warn(lineAt(after, difference), 'The strings were still compared in order, since the code change left their number the same.');
|
|
111
|
+
}
|
|
112
|
+
const options = copyOptions(file, config);
|
|
113
|
+
const beforeFragments = extractCopy(before, file, { ...options, ast: beforeAst });
|
|
114
|
+
const afterFragments = extractCopy(after, file, { ...options, ast: afterAst });
|
|
115
|
+
const byStart = (fragments) => new Map(fragments.map((fragment) => [fragment.start, fragment]));
|
|
116
|
+
const beforeAt = byStart(beforeFragments); const afterAt = byStart(afterFragments);
|
|
117
|
+
const fragmentFor = (map, node) => map.get(node.type === 'StringLiteral' || node.type === 'DirectiveLiteral' ? node.start + 1 : node.start) ?? null;
|
|
118
|
+
const voice = { bannedCharacters: config.voice?.bannedCharacters, bannedPatterns: config.voice?.bannedPatterns };
|
|
119
|
+
const groups = new Map(); // before group id → the pair of group texts to judge
|
|
120
|
+
for (let i = 0; i < beforeStrings.length; i++) {
|
|
121
|
+
const was = beforeStrings[i]; const now = afterStrings[i];
|
|
122
|
+
if (normalize(was.text) === normalize(now.text)) continue;
|
|
123
|
+
report.changedStrings++;
|
|
124
|
+
const line = lineAt(after, now.node.start);
|
|
125
|
+
const texts = { before: excerpt(was.text), after: excerpt(now.text) };
|
|
126
|
+
if (was.context.import) { error(line, 'An import path changed.', texts); continue; }
|
|
127
|
+
const old = fragmentFor(beforeAt, was.node);
|
|
128
|
+
const fresh = fragmentFor(afterAt, now.node);
|
|
129
|
+
if (!old && !fresh) { error(line, `A string that isn't copy changed (${where(now.context)}). Copy edits don't touch code values; if it is copy, add its key to copy.body in roughen.config.`, texts); continue; }
|
|
130
|
+
if (!old) warn(line, `Roughen reads this string as copy only after the edit (${where(now.context)}); check it renders as text.`, texts);
|
|
131
|
+
const role = old?.role ?? fresh.role;
|
|
132
|
+
if (role === 'protected') { error(line, `A protected string changed (${old?.verbatim ? 'quoted words' : old?.key ?? where(now.context)}). H1s, titles and headlines, FAQ questions, keywords, schema names, quotes and citations stay exactly as written.`, texts); continue; }
|
|
133
|
+
if (role === 'short' && /^h[2-6]$/.test(old?.key ?? '')) warn(line, `A heading changed (${old.key}). Headings are allowed to change, but check they still match the page's outline and search intent.`, texts);
|
|
134
|
+
const id = old ? `b${old.group}` : `a${fresh.group}`;
|
|
135
|
+
if (!groups.has(id)) {
|
|
136
|
+
const beforeText = old ? groupText(before, beforeFragments.filter((fragment) => fragment.group === old.group)) : was.text;
|
|
137
|
+
const afterText = fresh ? groupText(after, afterFragments.filter((fragment) => fragment.group === fresh.group)) : now.text;
|
|
138
|
+
groups.set(id, { line, beforeText, afterText });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Each changed string (or JSX sentence split by inline elements) must keep its facts.
|
|
142
|
+
let wordsBefore = 0; let wordsAfter = 0;
|
|
143
|
+
for (const { line, beforeText, afterText } of groups.values()) {
|
|
144
|
+
const words = countWords(beforeText);
|
|
145
|
+
wordsBefore += words; wordsAfter += countWords(afterText);
|
|
146
|
+
const judgment = judgeRevision(beforeText, afterText, { format: 'text', rules: [], voice, lengthTolerance: Math.max(revisionLengthTolerance, words ? 2 / words : 1) });
|
|
147
|
+
const texts = { before: excerpt(beforeText), after: excerpt(afterText) };
|
|
148
|
+
const { missing, added } = judgment.preservation;
|
|
149
|
+
const lost = [...missing.numbers.map((item) => `number ${item}`), ...missing.links.map((item) => `link ${item}`), ...missing.quotes.map((item) => `quotation ${excerpt(item, 60)}`)];
|
|
150
|
+
const invented = [...added.numbers.map((item) => `number ${item}`), ...added.links.map((item) => `link ${item}`)];
|
|
151
|
+
if (lost.length) error(line, `Facts missing after the edit: ${lost.join(', ')}.`, texts);
|
|
152
|
+
if (invented.length) error(line, `The edit adds facts the original didn't have: ${invented.join(', ')}.`, texts);
|
|
153
|
+
if (judgment.bannedAfter > judgment.bannedBefore) error(line, `The edit adds ${judgment.bannedAfter - judgment.bannedBefore} banned character(s) or pattern(s).`, texts);
|
|
154
|
+
else {
|
|
155
|
+
const dashes = (text) => (text.match(/—/g) ?? []).length;
|
|
156
|
+
if (dashes(afterText) > dashes(beforeText)) warn(line, `The edit adds ${dashes(afterText) - dashes(beforeText)} em dash(es).`, texts);
|
|
157
|
+
}
|
|
158
|
+
if (Math.abs(judgment.preservation.lengthRatio - 1) > Math.max(revisionLengthTolerance, words ? 2 / words : 1)) warn(line, `Length is ${Math.round(judgment.preservation.lengthRatio * 100)}% of the original string.`, texts);
|
|
159
|
+
}
|
|
160
|
+
report.changedWords = { before: wordsBefore, after: wordsAfter };
|
|
161
|
+
// Across everything the edit rewrote: within 15%, with ten words of slack so one reworded label isn't a failure.
|
|
162
|
+
if (wordsBefore && Math.abs(wordsAfter - wordsBefore) > Math.max(revisionLengthTolerance * wordsBefore, lengthSlackWords)) {
|
|
163
|
+
error(1, `The rewritten copy is ${Math.round((wordsAfter / wordsBefore) * 100)}% of its original length (${wordsBefore} words before, ${wordsAfter} after); keep it within ${Math.round(revisionLengthTolerance * 100)}%.`);
|
|
164
|
+
}
|
|
165
|
+
// The file's habits, measured on its body copy the way the linter measures them.
|
|
166
|
+
const bodyBefore = virtualDocuments(before, beforeFragments).body.text;
|
|
167
|
+
const bodyAfter = virtualDocuments(after, afterFragments).body.text;
|
|
168
|
+
const rules = firingRules(bodyBefore, bodyAfter, 'text', config.register);
|
|
169
|
+
const habits = judgeRevision(bodyBefore, bodyAfter, { format: 'text', rules, register: config.register });
|
|
170
|
+
report.habits = { rules, before: habits.flaggedBefore, after: habits.flaggedAfter };
|
|
171
|
+
report.banned = { before: bannedIn(before, beforeFragments, voice), after: bannedIn(after, afterFragments, voice) };
|
|
172
|
+
judgeHabits(report, error, warn);
|
|
173
|
+
return report;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Rules that ask for a rewrite of either version: a habit the edit introduced counts too. */
|
|
177
|
+
function firingRules(before, after, format, register) {
|
|
178
|
+
return [...new Set([...revisableRules(lint(before, { format, register })), ...revisableRules(lint(after, { format, register }))])];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Shared by copy and prose files: habits and bans may only go down. */
|
|
182
|
+
function judgeHabits(report, error, warn) {
|
|
183
|
+
const { habits, banned } = report;
|
|
184
|
+
if (banned.after > banned.before) error(1, `The file has more banned characters or patterns than before (${banned.before} → ${banned.after}).`);
|
|
185
|
+
if (habits.after > habits.before) error(1, `The edit adds flagged habits (${habits.rules.join(', ')}): ${habits.before} before, ${habits.after} after.`);
|
|
186
|
+
else if (report.changedStrings && habits.rules.length && habits.after === habits.before) warn(1, `The flagged habits didn't decrease (${habits.rules.join(', ')}: ${habits.before} before and after).`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Verifies a Markdown, MDX, HTML or text file with core's judgment, whole. */
|
|
190
|
+
export function verifyProse(before, after, { file, format, config = {} }) {
|
|
191
|
+
const report = { file, format, changedStrings: before === after ? 0 : 1, errors: [], warnings: [] };
|
|
192
|
+
const error = (line, message) => report.errors.push({ line, message });
|
|
193
|
+
const warn = (line, message) => report.warnings.push({ line, message });
|
|
194
|
+
const voice = { bannedCharacters: config.voice?.bannedCharacters, bannedPatterns: config.voice?.bannedPatterns };
|
|
195
|
+
const judgment = judgeRevision(before, after, { format, register: config.register, voice, rules: firingRules(before, after, format, config.register) });
|
|
196
|
+
for (const reason of judgment.preservation.reasons) error(1, reason);
|
|
197
|
+
report.habits = { rules: judgment.rules, before: judgment.flaggedBefore, after: judgment.flaggedAfter };
|
|
198
|
+
report.banned = { before: judgment.bannedBefore, after: judgment.bannedAfter };
|
|
199
|
+
judgeHabits(report, error, warn);
|
|
200
|
+
return report;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One file, either kind. */
|
|
204
|
+
export function verifySource(before, after, { file, config = {} }) {
|
|
205
|
+
const format = formatFor(file);
|
|
206
|
+
if (!format) return { file, skipped: 'not a file Roughen reads', errors: [], warnings: [], changedStrings: 0 };
|
|
207
|
+
if (before === after) return { file, format, changedStrings: 0, errors: [], warnings: [] };
|
|
208
|
+
return format === 'copy' ? verifyCopy(before, after, { file, config }) : verifyProse(before, after, { file, format, config });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function git(cwd, args) {
|
|
212
|
+
return execFileSync('git', ['-c', 'core.quotepath=off', ...args], { cwd, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether `ref` names a commit in the repository at `cwd`. */
|
|
216
|
+
export function isCommit(cwd, ref) {
|
|
217
|
+
try { git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]); return true; } catch { return false; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Verifies every changed file under `paths` (default: the repository)
|
|
222
|
+
* against `ref`: the before is the file at that commit, the after is the
|
|
223
|
+
* working tree. New and deleted files are listed, not judged.
|
|
224
|
+
*/
|
|
225
|
+
export async function verifyGit({ paths = ['.'], ref = 'HEAD', cwd = process.cwd(), configPath } = {}) {
|
|
226
|
+
// git reports its top level with symlinks resolved (/var is /private/var on macOS), so paths must be too.
|
|
227
|
+
const real = async (item) => {
|
|
228
|
+
const absolute = path.resolve(cwd, item);
|
|
229
|
+
try { return await realpath(absolute); } catch { try { return path.join(await realpath(path.dirname(absolute)), path.basename(absolute)); } catch { return absolute; } }
|
|
230
|
+
};
|
|
231
|
+
const resolved = await Promise.all((paths.length ? paths : ['.']).map(real));
|
|
232
|
+
const start = resolved[0];
|
|
233
|
+
let startDir = start;
|
|
234
|
+
try { if (!(await lstat(start)).isDirectory()) startDir = path.dirname(start); } catch { startDir = path.dirname(start); }
|
|
235
|
+
let root;
|
|
236
|
+
try { root = git(startDir, ['rev-parse', '--show-toplevel']).trim(); } catch { throw new Error(`${start} isn't inside a git repository; use --before and --after to compare two files`); }
|
|
237
|
+
if (!isCommit(root, ref)) throw new Error(`${ref} isn't a commit in ${root}`);
|
|
238
|
+
const commit = git(root, ['rev-parse', '--short', `${ref}^{commit}`]).trim();
|
|
239
|
+
const pathspecs = resolved.map((item) => path.relative(root, item) || '.');
|
|
240
|
+
const changed = git(root, ['diff', '--name-status', '-z', '--no-renames', ref, '--', ...pathspecs]).split('\0').filter(Boolean);
|
|
241
|
+
const files = [];
|
|
242
|
+
const notes = [];
|
|
243
|
+
for (let i = 0; i < changed.length; i += 2) {
|
|
244
|
+
const [status, name] = [changed[i], changed[i + 1]];
|
|
245
|
+
if (!formatFor(name)) continue;
|
|
246
|
+
if (status === 'D') { notes.push({ file: name, note: 'deleted' }); continue; }
|
|
247
|
+
if (status === 'A') { notes.push({ file: name, note: 'new since the ref: nothing to compare; lint it instead' }); continue; }
|
|
248
|
+
const absolute = path.join(root, name);
|
|
249
|
+
const before = git(root, ['show', `${ref}:${name}`]);
|
|
250
|
+
const after = await readFile(absolute, 'utf8');
|
|
251
|
+
const { config } = await loadConfig(absolute, configPath);
|
|
252
|
+
files.push({ ...verifySource(before, after, { file: absolute, config }), file: name });
|
|
253
|
+
}
|
|
254
|
+
for (const name of git(root, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...pathspecs]).split('\0').filter(Boolean)) {
|
|
255
|
+
if (formatFor(name)) notes.push({ file: name, note: 'untracked: nothing to compare; lint it instead' });
|
|
256
|
+
}
|
|
257
|
+
return summarize({ root, ref, commit, files, notes });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Two files outside git: an original and its rewrite. */
|
|
261
|
+
export async function verifyFiles({ before, after, configPath }) {
|
|
262
|
+
const [original, revised] = await Promise.all([readFile(before, 'utf8'), readFile(after, 'utf8')]);
|
|
263
|
+
const { config } = await loadConfig(after, configPath);
|
|
264
|
+
const file = path.resolve(after);
|
|
265
|
+
return summarize({ root: path.dirname(file), ref: path.resolve(before), commit: null, files: [{ ...verifySource(original, revised, { file, config }), file: path.basename(after) }], notes: [] });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function summarize(result) {
|
|
269
|
+
const files = result.files.filter((file) => file.changedStrings || file.errors.length || file.skipped);
|
|
270
|
+
const errors = files.reduce((sum, file) => sum + file.errors.length, 0);
|
|
271
|
+
const warnings = files.reduce((sum, file) => sum + file.warnings.length, 0);
|
|
272
|
+
return { ...result, files, errors, warnings, ok: errors === 0 };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
276
|
+
|
|
277
|
+
/** The verify report as text. */
|
|
278
|
+
export function renderVerify(result, { details = true } = {}) {
|
|
279
|
+
const lines = [`Roughen verify: ${plural(result.files.length, 'changed file')} against ${result.commit ? `${result.ref} (${result.commit})` : result.ref}.`];
|
|
280
|
+
for (const file of result.files) {
|
|
281
|
+
lines.push('');
|
|
282
|
+
if (file.skipped) { lines.push(`${file.file}: skipped, ${file.skipped}.`); continue; }
|
|
283
|
+
const head = `${file.file}: ${plural(file.changedStrings, 'changed string')}${file.format === 'copy' ? '' : ` (${file.format})`}. ${file.errors.length ? plural(file.errors.length, 'error') : 'OK'}${file.warnings.length ? `, ${plural(file.warnings.length, 'warning')}` : ''}.`;
|
|
284
|
+
lines.push(head);
|
|
285
|
+
for (const [kind, items] of [['error', file.errors], ['warn ', file.warnings]]) {
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
lines.push(` ${kind} line ${item.line}: ${item.message}`);
|
|
288
|
+
if (details && item.before !== undefined) lines.push(` before: ${item.before}`, ` after: ${item.after}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (file.habits) {
|
|
292
|
+
const habits = file.habits.rules.length ? `flagged habits ${file.habits.before} → ${file.habits.after} (${file.habits.rules.join(', ')})` : 'no flagged habits';
|
|
293
|
+
lines.push(` ${habits}; banned ${file.banned.before} → ${file.banned.after}${file.changedWords ? `; changed copy ${file.changedWords.before} → ${file.changedWords.after} words` : ''}.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const note of result.notes) lines.push('', `${note.file}: ${note.note}.`);
|
|
297
|
+
lines.push('', result.ok ? `No errors${result.warnings ? `; ${plural(result.warnings, 'warning')} to review` : ''}.` : `${plural(result.errors, 'error')} in ${plural(result.files.filter((file) => file.errors.length).length, 'file')}${result.warnings ? `, and ${plural(result.warnings, 'warning')}` : ''}.`);
|
|
298
|
+
return `${lines.join('\n')}\n`;
|
|
299
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roughen/cli",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Lint prose and page copy locally with Roughen, and print revision briefs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"prose",
|
|
8
|
+
"linter",
|
|
9
|
+
"cli",
|
|
10
|
+
"writing",
|
|
11
|
+
"ai-writing",
|
|
12
|
+
"copy",
|
|
13
|
+
"nextjs",
|
|
14
|
+
"site-copy"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.13"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"roughen": "./bin/roughen.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"lib",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@babel/parser": "7.29.8",
|
|
34
|
+
"@roughen/config": "^0.2.0",
|
|
35
|
+
"@roughen/core": "^0.3.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "node --check bin/roughen.mjs && node --check lib/config.mjs && node --check lib/lint-file.mjs && node --check lib/jsx.mjs"
|
|
39
|
+
}
|
|
40
|
+
}
|