docrev 0.2.0 → 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/lib/git.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Git integration utilities
3
+ * Compare sections against git history
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { diffWords } from 'diff';
10
+
11
+ /**
12
+ * Check if current directory is a git repository
13
+ * @returns {boolean}
14
+ */
15
+ export function isGitRepo() {
16
+ try {
17
+ execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Get the current git branch
26
+ * @returns {string|null}
27
+ */
28
+ export function getCurrentBranch() {
29
+ try {
30
+ return execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' })
31
+ .toString()
32
+ .trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Get the default branch (main or master)
40
+ * @returns {string}
41
+ */
42
+ export function getDefaultBranch() {
43
+ try {
44
+ // Try to get the remote default branch
45
+ const remote = execSync('git remote show origin', { stdio: 'pipe' })
46
+ .toString();
47
+ const match = remote.match(/HEAD branch:\s*(\S+)/);
48
+ if (match) return match[1];
49
+ } catch {
50
+ // Fall through
51
+ }
52
+
53
+ // Check if main or master exists
54
+ try {
55
+ execSync('git rev-parse --verify main', { stdio: 'pipe' });
56
+ return 'main';
57
+ } catch {
58
+ try {
59
+ execSync('git rev-parse --verify master', { stdio: 'pipe' });
60
+ return 'master';
61
+ } catch {
62
+ return 'main'; // Default fallback
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get file content from a specific git ref
69
+ * @param {string} filePath
70
+ * @param {string} ref - Git reference (branch, tag, commit)
71
+ * @returns {string|null}
72
+ */
73
+ export function getFileAtRef(filePath, ref) {
74
+ try {
75
+ return execSync(`git show ${ref}:${filePath}`, { stdio: 'pipe' }).toString();
76
+ } catch {
77
+ return null; // File doesn't exist at that ref
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Get list of changed files between refs
83
+ * @param {string} fromRef
84
+ * @param {string} toRef - Default: HEAD
85
+ * @returns {Array<{file: string, status: string}>}
86
+ */
87
+ export function getChangedFiles(fromRef, toRef = 'HEAD') {
88
+ try {
89
+ const output = execSync(`git diff --name-status ${fromRef}..${toRef}`, { stdio: 'pipe' })
90
+ .toString()
91
+ .trim();
92
+
93
+ if (!output) return [];
94
+
95
+ return output.split('\n').map(line => {
96
+ const [status, file] = line.split('\t');
97
+ return {
98
+ file,
99
+ status: status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified',
100
+ };
101
+ });
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Get commit history for a file
109
+ * @param {string} filePath
110
+ * @param {number} limit
111
+ * @returns {Array<{hash: string, date: string, message: string}>}
112
+ */
113
+ export function getFileHistory(filePath, limit = 10) {
114
+ try {
115
+ const output = execSync(
116
+ `git log --format="%h|%ci|%s" -n ${limit} -- "${filePath}"`,
117
+ { stdio: 'pipe' }
118
+ ).toString().trim();
119
+
120
+ if (!output) return [];
121
+
122
+ return output.split('\n').map(line => {
123
+ const [hash, date, message] = line.split('|');
124
+ return { hash, date, message };
125
+ });
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Compare file content between two refs
133
+ * @param {string} filePath
134
+ * @param {string} fromRef
135
+ * @param {string} toRef
136
+ * @returns {{added: number, removed: number, changes: Array}}
137
+ */
138
+ export function compareFileVersions(filePath, fromRef, toRef = 'HEAD') {
139
+ const oldContent = getFileAtRef(filePath, fromRef) || '';
140
+ const newContent = toRef === 'HEAD'
141
+ ? fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''
142
+ : getFileAtRef(filePath, toRef) || '';
143
+
144
+ const diffs = diffWords(oldContent, newContent);
145
+
146
+ let added = 0;
147
+ let removed = 0;
148
+ const changes = [];
149
+
150
+ for (const part of diffs) {
151
+ if (part.added) {
152
+ added += part.value.split(/\s+/).filter(w => w).length;
153
+ changes.push({ type: 'add', text: part.value });
154
+ } else if (part.removed) {
155
+ removed += part.value.split(/\s+/).filter(w => w).length;
156
+ changes.push({ type: 'remove', text: part.value });
157
+ }
158
+ }
159
+
160
+ return { added, removed, changes };
161
+ }
162
+
163
+ /**
164
+ * Get word count difference between refs
165
+ * @param {string[]} files
166
+ * @param {string} fromRef
167
+ * @param {string} toRef
168
+ * @returns {{total: {added: number, removed: number}, byFile: Object}}
169
+ */
170
+ export function getWordCountDiff(files, fromRef, toRef = 'HEAD') {
171
+ let totalAdded = 0;
172
+ let totalRemoved = 0;
173
+ const byFile = {};
174
+
175
+ for (const file of files) {
176
+ const { added, removed } = compareFileVersions(file, fromRef, toRef);
177
+ totalAdded += added;
178
+ totalRemoved += removed;
179
+ byFile[file] = { added, removed };
180
+ }
181
+
182
+ return {
183
+ total: { added: totalAdded, removed: totalRemoved },
184
+ byFile,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Get recent commits
190
+ * @param {number} limit
191
+ * @returns {Array<{hash: string, date: string, message: string, author: string}>}
192
+ */
193
+ export function getRecentCommits(limit = 10) {
194
+ try {
195
+ const output = execSync(
196
+ `git log --format="%h|%ci|%an|%s" -n ${limit}`,
197
+ { stdio: 'pipe' }
198
+ ).toString().trim();
199
+
200
+ if (!output) return [];
201
+
202
+ return output.split('\n').map(line => {
203
+ const [hash, date, author, message] = line.split('|');
204
+ return { hash, date, author, message };
205
+ });
206
+ } catch {
207
+ return [];
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Check if there are uncommitted changes
213
+ * @returns {boolean}
214
+ */
215
+ export function hasUncommittedChanges() {
216
+ try {
217
+ const output = execSync('git status --porcelain', { stdio: 'pipe' }).toString();
218
+ return output.trim().length > 0;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Get tags
226
+ * @returns {string[]}
227
+ */
228
+ export function getTags() {
229
+ try {
230
+ return execSync('git tag --sort=-creatordate', { stdio: 'pipe' })
231
+ .toString()
232
+ .trim()
233
+ .split('\n')
234
+ .filter(t => t);
235
+ } catch {
236
+ return [];
237
+ }
238
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Journal validation profiles
3
+ * Check manuscripts against journal-specific requirements
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+
9
+ /**
10
+ * Journal requirement profiles
11
+ * Based on publicly available author guidelines
12
+ */
13
+ export const JOURNAL_PROFILES = {
14
+ nature: {
15
+ name: 'Nature',
16
+ url: 'https://www.nature.com/nature/for-authors',
17
+ requirements: {
18
+ wordLimit: { main: 3000, abstract: 150, title: 90 },
19
+ references: { max: 50, doiRequired: true },
20
+ figures: { max: 6, combinedWithTables: true },
21
+ sections: {
22
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion', 'Methods'],
23
+ methodsPosition: 'end',
24
+ },
25
+ authors: { maxInitial: null, correspondingRequired: true },
26
+ },
27
+ },
28
+
29
+ science: {
30
+ name: 'Science',
31
+ url: 'https://www.science.org/content/page/instructions-preparing-initial-manuscript',
32
+ requirements: {
33
+ wordLimit: { main: 2500, abstract: 125, title: 120 },
34
+ references: { max: 40, doiRequired: true },
35
+ figures: { max: 4, combinedWithTables: true },
36
+ sections: {
37
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion'],
38
+ supplementary: true,
39
+ },
40
+ authors: { maxInitial: null, correspondingRequired: true },
41
+ },
42
+ },
43
+
44
+ 'plos-one': {
45
+ name: 'PLOS ONE',
46
+ url: 'https://journals.plos.org/plosone/s/submission-guidelines',
47
+ requirements: {
48
+ wordLimit: { main: null, abstract: 300, title: 250 },
49
+ references: { max: null, doiRequired: false },
50
+ figures: { max: null, combinedWithTables: false },
51
+ sections: {
52
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
53
+ methodsPosition: 'before-results',
54
+ },
55
+ authors: { maxInitial: null, correspondingRequired: true },
56
+ dataAvailability: true,
57
+ },
58
+ },
59
+
60
+ 'pnas': {
61
+ name: 'PNAS',
62
+ url: 'https://www.pnas.org/author-center/submitting-your-manuscript',
63
+ requirements: {
64
+ wordLimit: { main: 4500, abstract: 250, title: null },
65
+ references: { max: 50, doiRequired: true },
66
+ figures: { max: 6, combinedWithTables: true },
67
+ sections: {
68
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion'],
69
+ significanceStatement: true,
70
+ },
71
+ authors: { maxInitial: null, correspondingRequired: true },
72
+ },
73
+ },
74
+
75
+ 'ecology-letters': {
76
+ name: 'Ecology Letters',
77
+ url: 'https://onlinelibrary.wiley.com/page/journal/14610248/homepage/forauthors.html',
78
+ requirements: {
79
+ wordLimit: { main: 5000, abstract: 150, title: null },
80
+ references: { max: 50, doiRequired: true },
81
+ figures: { max: 6, combinedWithTables: true },
82
+ sections: {
83
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
84
+ },
85
+ authors: { maxInitial: null, correspondingRequired: true },
86
+ keywords: { min: 3, max: 10 },
87
+ },
88
+ },
89
+
90
+ 'ecological-applications': {
91
+ name: 'Ecological Applications',
92
+ url: 'https://esajournals.onlinelibrary.wiley.com/hub/journal/19395582/author-guidelines',
93
+ requirements: {
94
+ wordLimit: { main: 7000, abstract: 350, title: null },
95
+ references: { max: null, doiRequired: true },
96
+ figures: { max: null, combinedWithTables: false },
97
+ sections: {
98
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
99
+ },
100
+ dataAvailability: true,
101
+ },
102
+ },
103
+
104
+ 'molecular-ecology': {
105
+ name: 'Molecular Ecology',
106
+ url: 'https://onlinelibrary.wiley.com/page/journal/1365294x/homepage/forauthors.html',
107
+ requirements: {
108
+ wordLimit: { main: 8000, abstract: 250, title: null },
109
+ references: { max: null, doiRequired: true },
110
+ figures: { max: 8, combinedWithTables: false },
111
+ sections: {
112
+ required: ['Abstract', 'Introduction', 'Methods', 'Results', 'Discussion'],
113
+ },
114
+ dataAvailability: true,
115
+ keywords: { min: 4, max: 8 },
116
+ },
117
+ },
118
+
119
+ 'elife': {
120
+ name: 'eLife',
121
+ url: 'https://reviewer.elifesciences.org/author-guide/full',
122
+ requirements: {
123
+ wordLimit: { main: null, abstract: 150, title: null },
124
+ references: { max: null, doiRequired: true },
125
+ figures: { max: null, combinedWithTables: false },
126
+ sections: {
127
+ required: ['Abstract', 'Introduction', 'Results', 'Discussion', 'Methods'],
128
+ methodsPosition: 'end',
129
+ },
130
+ impactStatement: true,
131
+ },
132
+ },
133
+ };
134
+
135
+ /**
136
+ * List all available journal profiles
137
+ * @returns {Array<{id: string, name: string, url: string}>}
138
+ */
139
+ export function listJournals() {
140
+ return Object.entries(JOURNAL_PROFILES).map(([id, profile]) => ({
141
+ id,
142
+ name: profile.name,
143
+ url: profile.url,
144
+ }));
145
+ }
146
+
147
+ /**
148
+ * Get a specific journal profile
149
+ * @param {string} journalId
150
+ * @returns {Object|null}
151
+ */
152
+ export function getJournalProfile(journalId) {
153
+ const normalized = journalId.toLowerCase().replace(/\s+/g, '-');
154
+ return JOURNAL_PROFILES[normalized] || null;
155
+ }
156
+
157
+ /**
158
+ * Count words in text (excluding markdown syntax)
159
+ * @param {string} text
160
+ * @returns {number}
161
+ */
162
+ function countWords(text) {
163
+ // Remove markdown syntax
164
+ let clean = text
165
+ .replace(/^---[\s\S]*?---/m, '') // YAML frontmatter
166
+ .replace(/!\[.*?\]\(.*?\)/g, '') // Images
167
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
168
+ .replace(/#+\s*/g, '') // Headers
169
+ .replace(/\*\*|__/g, '') // Bold
170
+ .replace(/\*|_/g, '') // Italic
171
+ .replace(/`[^`]+`/g, '') // Inline code
172
+ .replace(/```[\s\S]*?```/g, '') // Code blocks
173
+ .replace(/\{[^}]+\}/g, '') // CriticMarkup and attributes
174
+ .replace(/@\w+:\w+/g, '') // Cross-references
175
+ .replace(/@\w+/g, '') // Citations
176
+ .replace(/\|[^|]+\|/g, ' ') // Table cells
177
+ .replace(/[-=]{3,}/g, '') // Horizontal rules
178
+ .replace(/\n+/g, ' ') // Newlines
179
+ .replace(/\s+/g, ' ') // Multiple spaces
180
+ .trim();
181
+
182
+ return clean.split(/\s+/).filter(w => w.length > 0).length;
183
+ }
184
+
185
+ /**
186
+ * Extract abstract from markdown
187
+ * @param {string} text
188
+ * @returns {string|null}
189
+ */
190
+ function extractAbstract(text) {
191
+ // Try to find abstract section
192
+ const patterns = [
193
+ /^#+\s*Abstract\s*\n([\s\S]*?)(?=^#+|\Z)/mi,
194
+ /^Abstract[:\s]*\n([\s\S]*?)(?=^#+|\n\n)/mi,
195
+ ];
196
+
197
+ for (const pattern of patterns) {
198
+ const match = text.match(pattern);
199
+ if (match) {
200
+ return match[1].trim();
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Extract title from markdown
209
+ * @param {string} text
210
+ * @returns {string|null}
211
+ */
212
+ function extractTitle(text) {
213
+ // Try YAML frontmatter
214
+ const yamlMatch = text.match(/^---\n[\s\S]*?title:\s*["']?([^"'\n]+)["']?[\s\S]*?\n---/m);
215
+ if (yamlMatch) {
216
+ return yamlMatch[1].trim();
217
+ }
218
+
219
+ // Try first H1
220
+ const h1Match = text.match(/^#\s+(.+)$/m);
221
+ if (h1Match) {
222
+ return h1Match[1].trim();
223
+ }
224
+
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * Extract sections from markdown
230
+ * @param {string} text
231
+ * @returns {string[]}
232
+ */
233
+ function extractSections(text) {
234
+ const sections = [];
235
+ const headerPattern = /^#+\s+(.+)$/gm;
236
+ let match;
237
+
238
+ while ((match = headerPattern.exec(text)) !== null) {
239
+ sections.push(match[1].trim());
240
+ }
241
+
242
+ return sections;
243
+ }
244
+
245
+ /**
246
+ * Count figures in markdown
247
+ * @param {string} text
248
+ * @returns {number}
249
+ */
250
+ function countFigures(text) {
251
+ // Count images with figure captions
252
+ const figurePattern = /!\[.*?\]\(.*?\)(\{#fig:[^}]+\})?/g;
253
+ const matches = text.match(figurePattern) || [];
254
+ return matches.length;
255
+ }
256
+
257
+ /**
258
+ * Count tables in markdown
259
+ * @param {string} text
260
+ * @returns {number}
261
+ */
262
+ function countTables(text) {
263
+ // Count tables (lines starting with |)
264
+ const tablePattern = /^\|[^|]+\|/gm;
265
+ const matches = text.match(tablePattern) || [];
266
+ // Divide by approximate rows per table
267
+ return Math.ceil(matches.length / 5);
268
+ }
269
+
270
+ /**
271
+ * Count references/citations in markdown
272
+ * @param {string} text
273
+ * @returns {number}
274
+ */
275
+ function countReferences(text) {
276
+ // Count unique citation keys
277
+ const citationPattern = /@(\w+)/g;
278
+ const citations = new Set();
279
+ let match;
280
+
281
+ while ((match = citationPattern.exec(text)) !== null) {
282
+ // Exclude cross-refs like @fig:label
283
+ if (!match[0].includes(':')) {
284
+ citations.add(match[1]);
285
+ }
286
+ }
287
+
288
+ return citations.size;
289
+ }
290
+
291
+ /**
292
+ * Validate manuscript against journal requirements
293
+ * @param {string} text - Markdown content
294
+ * @param {string} journalId - Journal profile ID
295
+ * @returns {{valid: boolean, errors: string[], warnings: string[], stats: Object}}
296
+ */
297
+ export function validateManuscript(text, journalId) {
298
+ const profile = getJournalProfile(journalId);
299
+
300
+ if (!profile) {
301
+ return {
302
+ valid: false,
303
+ errors: [`Unknown journal: ${journalId}`],
304
+ warnings: [],
305
+ stats: null,
306
+ };
307
+ }
308
+
309
+ const req = profile.requirements;
310
+ const errors = [];
311
+ const warnings = [];
312
+
313
+ // Extract content
314
+ const abstract = extractAbstract(text);
315
+ const title = extractTitle(text);
316
+ const sections = extractSections(text);
317
+ const mainWordCount = countWords(text);
318
+ const figureCount = countFigures(text);
319
+ const tableCount = countTables(text);
320
+ const refCount = countReferences(text);
321
+
322
+ const stats = {
323
+ wordCount: mainWordCount,
324
+ abstractWords: abstract ? countWords(abstract) : 0,
325
+ titleChars: title ? title.length : 0,
326
+ figures: figureCount,
327
+ tables: tableCount,
328
+ references: refCount,
329
+ sections: sections.length,
330
+ };
331
+
332
+ // Word limits
333
+ if (req.wordLimit) {
334
+ if (req.wordLimit.main && mainWordCount > req.wordLimit.main) {
335
+ errors.push(`Main text exceeds ${req.wordLimit.main} words (current: ${mainWordCount})`);
336
+ }
337
+ if (req.wordLimit.abstract && abstract) {
338
+ const absWords = countWords(abstract);
339
+ if (absWords > req.wordLimit.abstract) {
340
+ errors.push(`Abstract exceeds ${req.wordLimit.abstract} words (current: ${absWords})`);
341
+ }
342
+ }
343
+ if (req.wordLimit.title && title) {
344
+ if (title.length > req.wordLimit.title) {
345
+ warnings.push(`Title exceeds ${req.wordLimit.title} characters (current: ${title.length})`);
346
+ }
347
+ }
348
+ }
349
+
350
+ // References
351
+ if (req.references) {
352
+ if (req.references.max && refCount > req.references.max) {
353
+ errors.push(`References exceed ${req.references.max} (current: ${refCount})`);
354
+ }
355
+ if (req.references.doiRequired) {
356
+ warnings.push('DOI required for all references - run "rev doi check" to verify');
357
+ }
358
+ }
359
+
360
+ // Figures/tables
361
+ if (req.figures) {
362
+ const totalVisual = req.figures.combinedWithTables
363
+ ? figureCount + tableCount
364
+ : figureCount;
365
+ const label = req.figures.combinedWithTables ? 'figures + tables' : 'figures';
366
+
367
+ if (req.figures.max && totalVisual > req.figures.max) {
368
+ errors.push(`${label} exceed ${req.figures.max} (current: ${totalVisual})`);
369
+ }
370
+ }
371
+
372
+ // Required sections
373
+ if (req.sections?.required) {
374
+ for (const reqSection of req.sections.required) {
375
+ const found = sections.some(s =>
376
+ s.toLowerCase().includes(reqSection.toLowerCase())
377
+ );
378
+ if (!found) {
379
+ warnings.push(`Missing required section: ${reqSection}`);
380
+ }
381
+ }
382
+ }
383
+
384
+ // Data availability
385
+ if (req.dataAvailability) {
386
+ const hasDataStatement = sections.some(s =>
387
+ s.toLowerCase().includes('data') ||
388
+ text.toLowerCase().includes('data availability') ||
389
+ text.toLowerCase().includes('data statement')
390
+ );
391
+ if (!hasDataStatement) {
392
+ warnings.push('Data availability statement may be required');
393
+ }
394
+ }
395
+
396
+ return {
397
+ valid: errors.length === 0,
398
+ errors,
399
+ warnings,
400
+ stats,
401
+ journal: profile.name,
402
+ url: profile.url,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Validate multiple files against journal requirements
408
+ * @param {string[]} files - Markdown file paths
409
+ * @param {string} journalId - Journal profile ID
410
+ * @returns {Object}
411
+ */
412
+ export function validateProject(files, journalId) {
413
+ // Combine all file contents
414
+ const combined = files
415
+ .filter(f => fs.existsSync(f))
416
+ .map(f => fs.readFileSync(f, 'utf-8'))
417
+ .join('\n\n');
418
+
419
+ return validateManuscript(combined, journalId);
420
+ }