@ncukondo/search-hub 0.9.4 → 0.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/dist/cli/commands/review/extract.d.ts +3 -1
- package/dist/cli/commands/review/extract.d.ts.map +1 -1
- package/dist/cli/commands/review/extract.js +100 -44
- package/dist/cli/commands/review/extract.js.map +1 -1
- package/dist/cli/commands/review/mark.d.ts.map +1 -1
- package/dist/cli/commands/review/mark.js +69 -22
- package/dist/cli/commands/review/mark.js.map +1 -1
- package/dist/cli/commands/review/merge.d.ts +6 -0
- package/dist/cli/commands/review/merge.d.ts.map +1 -1
- package/dist/cli/commands/review/merge.js +52 -4
- package/dist/cli/commands/review/merge.js.map +1 -1
- package/dist/cli/commands/review/types.d.ts +5 -0
- package/dist/cli/commands/review/types.d.ts.map +1 -1
- package/dist/cli/commands/review/types.js +57 -8
- package/dist/cli/commands/review/types.js.map +1 -1
- package/dist/cli/commands/search-executor.d.ts.map +1 -1
- package/dist/cli/commands/search-executor.js +10 -1
- package/dist/cli/commands/search-executor.js.map +1 -1
- package/dist/cli/commands/search-utils.d.ts +9 -0
- package/dist/cli/commands/search-utils.d.ts.map +1 -1
- package/dist/cli/commands/search-utils.js +16 -0
- package/dist/cli/commands/search-utils.js.map +1 -1
- package/dist/cli/commands/search.d.ts +2 -0
- package/dist/cli/commands/search.d.ts.map +1 -1
- package/dist/cli/commands/search.js +3 -0
- package/dist/cli/commands/search.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -8
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -7,12 +7,14 @@ export interface ReviewExtractOptions {
|
|
|
7
7
|
seed?: number;
|
|
8
8
|
limit?: number;
|
|
9
9
|
offset?: number;
|
|
10
|
-
/** Basis for the review (title, abstract). When specified, outputs
|
|
10
|
+
/** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */
|
|
11
11
|
basis?: ReviewBasis;
|
|
12
12
|
/** Reviewer identifier (e.g., "ai:claude"). Required for all extract modes. */
|
|
13
13
|
reviewer?: string;
|
|
14
14
|
/** Name for the review subset (output goes to for-review/<name>/review.yaml) */
|
|
15
15
|
name: string;
|
|
16
|
+
/** When true, outputs final decision format with reviewHistory and finalDecision fields. */
|
|
17
|
+
finalize?: boolean;
|
|
16
18
|
}
|
|
17
19
|
export interface ReviewExtractResult {
|
|
18
20
|
outputPath: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/extract.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAsD,KAAK,YAAY,EAAE,KAAK,WAAW,
|
|
1
|
+
{"version":3,"file":"extract.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/extract.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,EAAsD,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAErH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kGAAkG;IAClG,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAGD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;CACvB;AAgLD;;GAEG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAU/C;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,oBAAoB,EAC7B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,mBAAmB,CAAC,CAuG9B"}
|
|
@@ -20,13 +20,87 @@ function seededShuffle(array, seed) {
|
|
|
20
20
|
}
|
|
21
21
|
return result;
|
|
22
22
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
function getBasisGuidanceComment(basis) {
|
|
24
|
+
const schemaLine = "# yaml-language-server: $schema=./review.schema.json";
|
|
25
|
+
switch (basis) {
|
|
26
|
+
case "title":
|
|
27
|
+
return [
|
|
28
|
+
schemaLine,
|
|
29
|
+
"# Screening by title only.",
|
|
30
|
+
'# Mark clearly irrelevant items as "exclude" with a comment explaining the reason.',
|
|
31
|
+
'# Leave everything else as "uncertain".',
|
|
32
|
+
""
|
|
33
|
+
].join("\n");
|
|
34
|
+
case "abstract":
|
|
35
|
+
return [
|
|
36
|
+
schemaLine,
|
|
37
|
+
"# Screening by title and abstract.",
|
|
38
|
+
'# You should be able to decide "include" or "exclude" for most items at this stage.',
|
|
39
|
+
'# Mark remaining ambiguous items as "uncertain" with a comment explaining why.',
|
|
40
|
+
""
|
|
41
|
+
].join("\n");
|
|
42
|
+
case "fulltext":
|
|
43
|
+
return [
|
|
44
|
+
schemaLine,
|
|
45
|
+
"# Screening by full text. This is the final decision stage.",
|
|
46
|
+
'# Decide "include" or "exclude" for each item.',
|
|
47
|
+
'# Use "uncertain" only when absolutely unavoidable, with a comment explaining why.',
|
|
48
|
+
""
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function getDecisionInlineComment(basis) {
|
|
53
|
+
switch (basis) {
|
|
54
|
+
case "title":
|
|
55
|
+
return "# exclude / uncertain";
|
|
56
|
+
case "abstract":
|
|
57
|
+
case "fulltext":
|
|
58
|
+
return "# include / exclude / uncertain";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function buildScreeningArticle(article, basis) {
|
|
62
|
+
const result = { title: article.title, reviews: [] };
|
|
63
|
+
if (article.doi) result.doi = article.doi;
|
|
64
|
+
if (article.pmid) result.pmid = article.pmid;
|
|
65
|
+
if (article.scopusId) result.scopusId = article.scopusId;
|
|
66
|
+
if (article.arxivId) result.arxivId = article.arxivId;
|
|
67
|
+
if (article.ericId) result.ericId = article.ericId;
|
|
68
|
+
if ((basis === "abstract" || basis === "fulltext") && article.abstract) {
|
|
69
|
+
result.abstract = article.abstract;
|
|
70
|
+
}
|
|
71
|
+
if (basis === "fulltext" && article.fulltext) {
|
|
72
|
+
result.fulltext = article.fulltext;
|
|
73
|
+
}
|
|
74
|
+
result.reviews = [{ decision: "uncertain", comment: "" }];
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
function buildFinalizeArticle(article, basis) {
|
|
78
|
+
const result = { title: article.title, reviews: [] };
|
|
79
|
+
if (article.doi) result.doi = article.doi;
|
|
80
|
+
if (article.pmid) result.pmid = article.pmid;
|
|
81
|
+
if (article.scopusId) result.scopusId = article.scopusId;
|
|
82
|
+
if (article.arxivId) result.arxivId = article.arxivId;
|
|
83
|
+
if (article.ericId) result.ericId = article.ericId;
|
|
84
|
+
if (article.authors) result.authors = article.authors;
|
|
85
|
+
if (article.year) result.year = article.year;
|
|
86
|
+
if (!basis || basis === "abstract" || basis === "fulltext") {
|
|
87
|
+
if (article.abstract) result.abstract = article.abstract;
|
|
88
|
+
}
|
|
89
|
+
if (!basis || basis === "fulltext") {
|
|
90
|
+
if (article.fulltext) result.fulltext = article.fulltext;
|
|
91
|
+
}
|
|
92
|
+
result.reviewHistory = article.reviews ?? [];
|
|
93
|
+
result.reviews = [];
|
|
94
|
+
result.finalDecision = null;
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
function getFinalDecisionGuidanceComment() {
|
|
98
|
+
return [
|
|
99
|
+
"# yaml-language-server: $schema=./review.schema.json",
|
|
100
|
+
"# Final decision file: set finalDecision on each article",
|
|
101
|
+
"# Valid decisions: include / exclude / null",
|
|
102
|
+
""
|
|
103
|
+
].join("\n");
|
|
30
104
|
}
|
|
31
105
|
function sortArticles(articles, sort, seed) {
|
|
32
106
|
switch (sort) {
|
|
@@ -80,56 +154,38 @@ async function executeReviewExtract(options, sessionsDir) {
|
|
|
80
154
|
if (options.limit !== void 0 && options.limit > 0) {
|
|
81
155
|
paginated = paginated.slice(0, options.limit);
|
|
82
156
|
}
|
|
157
|
+
if (!options.reviewer) {
|
|
158
|
+
throw new Error("--reviewer is required for review file extract");
|
|
159
|
+
}
|
|
83
160
|
let finalContent;
|
|
84
|
-
if (options.basis && options.
|
|
85
|
-
const
|
|
161
|
+
if (options.basis && !options.finalize) {
|
|
162
|
+
const outputFile = {
|
|
86
163
|
sessionId: options.sessionId,
|
|
87
164
|
basis: options.basis,
|
|
88
165
|
reviewer: options.reviewer,
|
|
89
|
-
articles: paginated.map((article) =>
|
|
90
|
-
const workArticle = {
|
|
91
|
-
id: getArticleId(article),
|
|
92
|
-
title: article.title,
|
|
93
|
-
decision: "uncertain",
|
|
94
|
-
comment: ""
|
|
95
|
-
};
|
|
96
|
-
if ((options.basis === "abstract" || options.basis === "fulltext") && article.abstract) {
|
|
97
|
-
workArticle.abstract = article.abstract;
|
|
98
|
-
}
|
|
99
|
-
if (options.basis === "fulltext" && article.fulltext) {
|
|
100
|
-
workArticle.fulltext = article.fulltext.dirName;
|
|
101
|
-
}
|
|
102
|
-
return workArticle;
|
|
103
|
-
})
|
|
166
|
+
articles: paginated.map((article) => buildScreeningArticle(article, options.basis))
|
|
104
167
|
};
|
|
105
|
-
const yamlContent = stringify(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
168
|
+
const yamlContent = stringify(outputFile, { lineWidth: 0 });
|
|
169
|
+
const decisionComment = getDecisionInlineComment(options.basis);
|
|
170
|
+
const yamlWithComments = yamlContent.replace(
|
|
171
|
+
/^(\s*-?\s*)decision: uncertain$/gm,
|
|
172
|
+
`$1decision: uncertain ${decisionComment}`
|
|
173
|
+
);
|
|
174
|
+
const guidanceComment = getBasisGuidanceComment(options.basis);
|
|
175
|
+
finalContent = guidanceComment + yamlWithComments;
|
|
109
176
|
} else {
|
|
110
|
-
if (!options.reviewer) {
|
|
111
|
-
throw new Error("--reviewer is required for review file extract");
|
|
112
|
-
}
|
|
113
177
|
const outputFile = {
|
|
114
178
|
sessionId: options.sessionId,
|
|
115
|
-
|
|
116
|
-
articles: paginated.map((article) => (
|
|
117
|
-
...article,
|
|
118
|
-
reviewHistory: article.reviews ?? [],
|
|
119
|
-
reviews: [],
|
|
120
|
-
finalDecision: null
|
|
121
|
-
}))
|
|
179
|
+
reviewer: options.reviewer,
|
|
180
|
+
articles: paginated.map((article) => buildFinalizeArticle(article, options.basis))
|
|
122
181
|
};
|
|
123
|
-
const yamlContent = stringify(outputFile, {
|
|
124
|
-
lineWidth: 0
|
|
125
|
-
});
|
|
182
|
+
const yamlContent = stringify(outputFile, { lineWidth: 0 });
|
|
126
183
|
const yamlWithComments = yamlContent.replace(
|
|
127
184
|
/^(\s*)finalDecision: null$/gm,
|
|
128
185
|
"$1finalDecision: # include / exclude"
|
|
129
186
|
);
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
finalContent = schemaComment + yamlWithComments;
|
|
187
|
+
const guidanceComment = getFinalDecisionGuidanceComment();
|
|
188
|
+
finalContent = guidanceComment + yamlWithComments;
|
|
133
189
|
}
|
|
134
190
|
const outputDir = dirname(outputPath);
|
|
135
191
|
await mkdir(outputDir, { recursive: true });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extract.js","sources":["../../../../src/cli/commands/review/extract.ts"],"sourcesContent":["/**\n * review extract command - Extract subset of articles for distributed review\n */\n\nimport { join, dirname } from 'node:path';\nimport { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ArticleEntry, type ReviewStatus, type ReviewBasis, type WorkFile, type WorkFileArticle } from './types.js';\n\nexport type SortOption = 'year' | 'title' | 'random' | 'none';\n\nexport interface ReviewExtractOptions {\n sessionId: string;\n filter?: ReviewStatus[];\n sort?: SortOption;\n seed?: number;\n limit?: number;\n offset?: number;\n /** Basis for the review (title, abstract). When specified, outputs work file format. */\n basis?: ReviewBasis;\n /** Reviewer identifier (e.g., \"ai:claude\"). Required for all extract modes. */\n reviewer?: string;\n /** Name for the review subset (output goes to for-review/<name>/review.yaml) */\n name: string;\n}\n\n\nexport interface ReviewExtractResult {\n outputPath: string;\n extractedCount: number;\n totalMatching: number;\n}\n\n/**\n * Load review file from session directory\n */\nasync function loadReviewFile(sessionDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Seeded random number generator (Fisher-Yates shuffle with LCG)\n */\nfunction seededShuffle<T>(array: T[], seed: number): T[] {\n const result = [...array];\n let currentSeed = seed;\n\n // Linear congruential generator\n function random(): number {\n currentSeed = (currentSeed * 1664525 + 1013904223) % 4294967296;\n return currentSeed / 4294967296;\n }\n\n // Fisher-Yates shuffle\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1));\n [result[i], result[j]] = [result[j]!, result[i]!];\n }\n\n return result;\n}\n\n/**\n * Get the best identifier for an article (doi > pmid > scopusId > arxivId > ericId > title)\n */\nfunction getArticleId(article: ArticleEntry): string {\n if (article.doi) return article.doi;\n if (article.pmid) return article.pmid;\n if (article.scopusId) return article.scopusId;\n if (article.arxivId) return article.arxivId;\n if (article.ericId) return article.ericId;\n return article.title;\n}\n\n/**\n * Sort articles based on sort option\n */\nfunction sortArticles(articles: ArticleEntry[], sort: SortOption, seed?: number): ArticleEntry[] {\n switch (sort) {\n case 'year':\n return [...articles].sort((a, b) => {\n const yearA = a.year ?? '';\n const yearB = b.year ?? '';\n return yearA.localeCompare(yearB);\n });\n case 'title':\n return [...articles].sort((a, b) => a.title.localeCompare(b.title));\n case 'random':\n return seededShuffle(articles, seed ?? Date.now());\n case 'none':\n default:\n return articles;\n }\n}\n\n/**\n * Validate the name parameter for extract\n */\nexport function validateName(name: string): void {\n if (!name || name.trim() === '') {\n throw new Error('--name must not be empty');\n }\n if (name.includes('/') || name.includes('\\\\')) {\n throw new Error(`--name must not contain path separators: \"${name}\"`);\n }\n if (name.includes('..')) {\n throw new Error(`--name must not contain \"..\": \"${name}\"`);\n }\n}\n\n/**\n * Execute review extract command\n */\nexport async function executeReviewExtract(\n options: ReviewExtractOptions,\n sessionsDir: string\n): Promise<ReviewExtractResult> {\n validateName(options.name);\n\n const sessionDir = join(sessionsDir, options.sessionId);\n const outputPath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n const reviewFile = await loadReviewFile(sessionDir);\n\n // Filter articles by status\n const reviewers = reviewFile.reviewers;\n let filtered: ArticleEntry[];\n if (options.filter && options.filter.length > 0) {\n filtered = reviewFile.articles.filter((article) => {\n const status = classifyStatus(article, reviewers);\n return options.filter!.includes(status);\n });\n } else {\n filtered = [...reviewFile.articles];\n }\n\n const totalMatching = filtered.length;\n\n // Sort articles\n const sorted = sortArticles(filtered, options.sort ?? 'none', options.seed);\n\n // Apply pagination\n let paginated = sorted;\n if (options.offset !== undefined && options.offset > 0) {\n paginated = paginated.slice(options.offset);\n }\n if (options.limit !== undefined && options.limit > 0) {\n paginated = paginated.slice(0, options.limit);\n }\n\n let finalContent: string;\n\n // If basis is specified, output work file format\n if (options.basis && options.reviewer) {\n const workFile: WorkFile = {\n sessionId: options.sessionId,\n basis: options.basis,\n reviewer: options.reviewer,\n articles: paginated.map((article) => {\n const workArticle: WorkFileArticle = {\n id: getArticleId(article),\n title: article.title,\n decision: 'uncertain',\n comment: '',\n };\n // Include abstract for abstract and fulltext basis\n if ((options.basis === 'abstract' || options.basis === 'fulltext') && article.abstract) {\n workArticle.abstract = article.abstract;\n }\n // Include fulltext dirName for fulltext basis\n if (options.basis === 'fulltext' && article.fulltext) {\n workArticle.fulltext = article.fulltext.dirName;\n }\n return workArticle;\n }),\n };\n\n const yamlContent = stringifyYaml(workFile, {\n lineWidth: 0,\n });\n finalContent = yamlContent;\n } else {\n if (!options.reviewer) {\n throw new Error('--reviewer is required for review file extract');\n }\n // Build output review file with reviewHistory separation\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n ...(options.reviewer && { reviewer: options.reviewer }),\n articles: paginated.map((article) => ({\n ...article,\n reviewHistory: article.reviews ?? [],\n reviews: [],\n finalDecision: null,\n })),\n };\n\n // Generate YAML with schema reference\n const yamlContent = stringifyYaml(outputFile, {\n lineWidth: 0,\n });\n\n // Replace finalDecision: null with a commented placeholder for user guidance\n const yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\n );\n\n // Schema reference pointing to adjacent file\n const schemaComment = `# yaml-language-server: $schema=./review.schema.json\\n`;\n finalContent = schemaComment + yamlWithComments;\n }\n\n // Ensure output directory exists\n const outputDir = dirname(outputPath);\n await mkdir(outputDir, { recursive: true });\n\n // Write output YAML\n await writeFile(outputPath, finalContent, 'utf-8');\n\n // Copy schema file to output directory if it exists\n const schemasDir = join(dirname(sessionsDir), '.search-hub', 'schemas');\n const schemaSourcePath = join(schemasDir, 'review.schema.json');\n const schemaDestPath = join(outputDir, 'review.schema.json');\n\n try {\n await access(schemaSourcePath);\n await copyFile(schemaSourcePath, schemaDestPath);\n } catch {\n // Schema file doesn't exist, skip copying\n }\n\n return {\n outputPath,\n extractedCount: paginated.length,\n totalMatching,\n };\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"extract.js","sources":["../../../../src/cli/commands/review/extract.ts"],"sourcesContent":["/**\n * review extract command - Extract subset of articles for distributed review\n */\n\nimport { join, dirname } from 'node:path';\nimport { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport { classifyStatus, type ReviewFile, type ArticleEntry, type ReviewStatus, type ReviewBasis } from './types.js';\n\nexport type SortOption = 'year' | 'title' | 'random' | 'none';\n\nexport interface ReviewExtractOptions {\n sessionId: string;\n filter?: ReviewStatus[];\n sort?: SortOption;\n seed?: number;\n limit?: number;\n offset?: number;\n /** Basis for the review (title, abstract, fulltext). When specified, outputs screening format. */\n basis?: ReviewBasis;\n /** Reviewer identifier (e.g., \"ai:claude\"). Required for all extract modes. */\n reviewer?: string;\n /** Name for the review subset (output goes to for-review/<name>/review.yaml) */\n name: string;\n /** When true, outputs final decision format with reviewHistory and finalDecision fields. */\n finalize?: boolean;\n}\n\n\nexport interface ReviewExtractResult {\n outputPath: string;\n extractedCount: number;\n totalMatching: number;\n}\n\n/**\n * Load review file from session directory\n */\nasync function loadReviewFile(sessionDir: string): Promise<ReviewFile> {\n const reviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const content = await readFile(reviewsPath, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Seeded random number generator (Fisher-Yates shuffle with LCG)\n */\nfunction seededShuffle<T>(array: T[], seed: number): T[] {\n const result = [...array];\n let currentSeed = seed;\n\n // Linear congruential generator\n function random(): number {\n currentSeed = (currentSeed * 1664525 + 1013904223) % 4294967296;\n return currentSeed / 4294967296;\n }\n\n // Fisher-Yates shuffle\n for (let i = result.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1));\n [result[i], result[j]] = [result[j]!, result[i]!];\n }\n\n return result;\n}\n\n/**\n * Get the best identifier for an article (doi > pmid > scopusId > arxivId > ericId > title)\n */\nfunction getArticleId(article: ArticleEntry): string {\n if (article.doi) return article.doi;\n if (article.pmid) return article.pmid;\n if (article.scopusId) return article.scopusId;\n if (article.arxivId) return article.arxivId;\n if (article.ericId) return article.ericId;\n return article.title;\n}\n\nfunction getBasisGuidanceComment(basis: ReviewBasis): string {\n const schemaLine = '# yaml-language-server: $schema=./review.schema.json';\n switch (basis) {\n case 'title':\n return [\n schemaLine,\n '# Screening by title only.',\n '# Mark clearly irrelevant items as \"exclude\" with a comment explaining the reason.',\n '# Leave everything else as \"uncertain\".',\n '',\n ].join('\\n');\n case 'abstract':\n return [\n schemaLine,\n '# Screening by title and abstract.',\n '# You should be able to decide \"include\" or \"exclude\" for most items at this stage.',\n '# Mark remaining ambiguous items as \"uncertain\" with a comment explaining why.',\n '',\n ].join('\\n');\n case 'fulltext':\n return [\n schemaLine,\n '# Screening by full text. This is the final decision stage.',\n '# Decide \"include\" or \"exclude\" for each item.',\n '# Use \"uncertain\" only when absolutely unavoidable, with a comment explaining why.',\n '',\n ].join('\\n');\n }\n}\n\nfunction getDecisionInlineComment(basis: ReviewBasis): string {\n switch (basis) {\n case 'title':\n return '# exclude / uncertain';\n case 'abstract':\n case 'fulltext':\n return '# include / exclude / uncertain';\n }\n}\n\n/** Build a screening article for --basis mode: only include fields relevant to the basis */\nfunction buildScreeningArticle(article: ArticleEntry, basis: ReviewBasis): ArticleEntry {\n // Start with identifiers\n const result: ArticleEntry = { title: article.title, reviews: [] };\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Include abstract for abstract and fulltext basis\n if ((basis === 'abstract' || basis === 'fulltext') && article.abstract) {\n result.abstract = article.abstract;\n }\n\n // Include fulltext ref for fulltext basis\n if (basis === 'fulltext' && article.fulltext) {\n result.fulltext = article.fulltext;\n }\n\n // Pre-populate reviews (reviewer omitted; filled from top-level field on merge)\n result.reviews = [{ decision: 'uncertain' as const, comment: '' } as ArticleEntry['reviews'][0]];\n\n return result;\n}\n\n/** Build a finalize article with reviewHistory and finalDecision, optionally scoped by basis */\nfunction buildFinalizeArticle(article: ArticleEntry, basis?: ReviewBasis): ArticleEntry {\n const result: ArticleEntry = { title: article.title, reviews: [] };\n\n // Always include identifiers\n if (article.doi) result.doi = article.doi;\n if (article.pmid) result.pmid = article.pmid;\n if (article.scopusId) result.scopusId = article.scopusId;\n if (article.arxivId) result.arxivId = article.arxivId;\n if (article.ericId) result.ericId = article.ericId;\n\n // Always include bibliographic metadata\n if (article.authors) result.authors = article.authors;\n if (article.year) result.year = article.year;\n\n // Scope content by basis (or include all if no basis)\n if (!basis || basis === 'abstract' || basis === 'fulltext') {\n if (article.abstract) result.abstract = article.abstract;\n }\n if (!basis || basis === 'fulltext') {\n if (article.fulltext) result.fulltext = article.fulltext;\n }\n\n // Add reviewHistory (existing reviews, read-only)\n result.reviewHistory = article.reviews ?? [];\n\n // Empty reviews for new reviews\n result.reviews = [];\n\n // Null finalDecision as placeholder\n result.finalDecision = null;\n\n return result;\n}\n\nfunction getFinalDecisionGuidanceComment(): string {\n return [\n '# yaml-language-server: $schema=./review.schema.json',\n '# Final decision file: set finalDecision on each article',\n '# Valid decisions: include / exclude / null',\n '',\n ].join('\\n');\n}\n\n/**\n * Sort articles based on sort option\n */\nfunction sortArticles(articles: ArticleEntry[], sort: SortOption, seed?: number): ArticleEntry[] {\n switch (sort) {\n case 'year':\n return [...articles].sort((a, b) => {\n const yearA = a.year ?? '';\n const yearB = b.year ?? '';\n return yearA.localeCompare(yearB);\n });\n case 'title':\n return [...articles].sort((a, b) => a.title.localeCompare(b.title));\n case 'random':\n return seededShuffle(articles, seed ?? Date.now());\n case 'none':\n default:\n return articles;\n }\n}\n\n/**\n * Validate the name parameter for extract\n */\nexport function validateName(name: string): void {\n if (!name || name.trim() === '') {\n throw new Error('--name must not be empty');\n }\n if (name.includes('/') || name.includes('\\\\')) {\n throw new Error(`--name must not contain path separators: \"${name}\"`);\n }\n if (name.includes('..')) {\n throw new Error(`--name must not contain \"..\": \"${name}\"`);\n }\n}\n\n/**\n * Execute review extract command\n */\nexport async function executeReviewExtract(\n options: ReviewExtractOptions,\n sessionsDir: string\n): Promise<ReviewExtractResult> {\n validateName(options.name);\n\n const sessionDir = join(sessionsDir, options.sessionId);\n const outputPath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n const reviewFile = await loadReviewFile(sessionDir);\n\n // Filter articles by status\n const reviewers = reviewFile.reviewers;\n let filtered: ArticleEntry[];\n if (options.filter && options.filter.length > 0) {\n filtered = reviewFile.articles.filter((article) => {\n const status = classifyStatus(article, reviewers);\n return options.filter!.includes(status);\n });\n } else {\n filtered = [...reviewFile.articles];\n }\n\n const totalMatching = filtered.length;\n\n // Sort articles\n const sorted = sortArticles(filtered, options.sort ?? 'none', options.seed);\n\n // Apply pagination\n let paginated = sorted;\n if (options.offset !== undefined && options.offset > 0) {\n paginated = paginated.slice(options.offset);\n }\n if (options.limit !== undefined && options.limit > 0) {\n paginated = paginated.slice(0, options.limit);\n }\n\n if (!options.reviewer) {\n throw new Error('--reviewer is required for review file extract');\n }\n\n let finalContent: string;\n\n if (options.basis && !options.finalize) {\n // Screening mode: basis-scoped content with pre-populated reviews\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n basis: options.basis,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildScreeningArticle(article, options.basis!)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Add decision inline comments\n const decisionComment = getDecisionInlineComment(options.basis);\n const yamlWithComments = yamlContent.replace(\n /^(\\s*-?\\s*)decision: uncertain$/gm,\n `$1decision: uncertain ${decisionComment}`\n );\n\n const guidanceComment = getBasisGuidanceComment(options.basis);\n finalContent = guidanceComment + yamlWithComments;\n } else {\n // Final decision mode: --finalize, or no --basis (backward compat)\n const outputFile: ReviewFile = {\n sessionId: options.sessionId,\n reviewer: options.reviewer,\n articles: paginated.map((article) => buildFinalizeArticle(article, options.basis)),\n };\n\n const yamlContent = stringifyYaml(outputFile, { lineWidth: 0 });\n\n // Replace finalDecision: null with a commented placeholder for user guidance\n const yamlWithComments = yamlContent.replace(\n /^(\\s*)finalDecision: null$/gm,\n '$1finalDecision: # include / exclude'\n );\n\n const guidanceComment = getFinalDecisionGuidanceComment();\n finalContent = guidanceComment + yamlWithComments;\n }\n\n // Ensure output directory exists\n const outputDir = dirname(outputPath);\n await mkdir(outputDir, { recursive: true });\n\n // Write output YAML\n await writeFile(outputPath, finalContent, 'utf-8');\n\n // Copy schema file to output directory if it exists\n const schemasDir = join(dirname(sessionsDir), '.search-hub', 'schemas');\n const schemaSourcePath = join(schemasDir, 'review.schema.json');\n const schemaDestPath = join(outputDir, 'review.schema.json');\n\n try {\n await access(schemaSourcePath);\n await copyFile(schemaSourcePath, schemaDestPath);\n } catch {\n // Schema file doesn't exist, skip copying\n }\n\n return {\n outputPath,\n extractedCount: paginated.length,\n totalMatching,\n };\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mark.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/mark.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,cAAc,
|
|
1
|
+
{"version":3,"file":"mark.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/mark.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAsC,MAAM,YAAY,CAAC;AAGrF,MAAM,WAAW,iBAAiB;IAChC,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,wBAAwB;IACxB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,uBAAuB;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAuDD;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA4D7F"}
|
|
@@ -1,36 +1,83 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { parse, stringify } from "yaml";
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
function extractLeadingComments(content) {
|
|
4
|
+
const lines = content.split("\n");
|
|
5
|
+
const commentLines = [];
|
|
6
|
+
for (const line of lines) {
|
|
7
|
+
if (line.startsWith("#") || line.trim() === "") {
|
|
8
|
+
commentLines.push(line);
|
|
9
|
+
} else {
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
8
12
|
}
|
|
9
|
-
return
|
|
13
|
+
if (commentLines.length === 0) return "";
|
|
14
|
+
return commentLines.join("\n") + "\n";
|
|
10
15
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
function isWorkFile(file) {
|
|
17
|
+
if (typeof file !== "object" || file === null || !("basis" in file) || !("articles" in file) || !Array.isArray(file.articles)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const articles = file.articles;
|
|
21
|
+
if (articles.length === 0) return false;
|
|
22
|
+
const first = articles[0];
|
|
23
|
+
return "id" in first && "decision" in first && !("reviews" in first);
|
|
24
|
+
}
|
|
25
|
+
function findArticleByIdentifier(id, articles) {
|
|
26
|
+
for (const article of articles) {
|
|
27
|
+
if (article.doi && article.doi.toLowerCase() === id.toLowerCase()) return article;
|
|
28
|
+
if (article.pmid && article.pmid === id) return article;
|
|
29
|
+
if (article.scopusId && article.scopusId === id) return article;
|
|
30
|
+
if (article.arxivId && article.arxivId === id) return article;
|
|
31
|
+
if (article.ericId && article.ericId === id) return article;
|
|
32
|
+
if (article.title.toLowerCase() === id.toLowerCase()) return article;
|
|
33
|
+
}
|
|
34
|
+
return void 0;
|
|
14
35
|
}
|
|
15
36
|
async function executeReviewMark(options) {
|
|
16
|
-
|
|
37
|
+
if (!options.id || !options.decision) {
|
|
38
|
+
throw new Error("--id and --decision must be specified");
|
|
39
|
+
}
|
|
40
|
+
const rawContent = await readFile(options.file, "utf-8");
|
|
41
|
+
const parsed = parse(rawContent);
|
|
17
42
|
const result = {
|
|
18
43
|
marked: 0,
|
|
19
44
|
warnings: []
|
|
20
45
|
};
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
if (isWorkFile(parsed)) {
|
|
47
|
+
const workFile = parsed;
|
|
48
|
+
const article = workFile.articles.find((a) => a.id === options.id);
|
|
49
|
+
if (!article) {
|
|
50
|
+
throw new Error(`Article not found: ${options.id}`);
|
|
51
|
+
}
|
|
52
|
+
article.decision = options.decision;
|
|
53
|
+
if (options.comment !== void 0) {
|
|
54
|
+
article.comment = options.comment;
|
|
55
|
+
}
|
|
56
|
+
result.marked = 1;
|
|
57
|
+
const yamlContent = stringify(workFile, { lineWidth: 0 });
|
|
58
|
+
const leadingComments = extractLeadingComments(rawContent);
|
|
59
|
+
await writeFile(options.file, leadingComments + yamlContent, "utf-8");
|
|
60
|
+
} else {
|
|
61
|
+
const reviewFile = parsed;
|
|
62
|
+
if (!reviewFile.basis) {
|
|
63
|
+
throw new Error('Invalid review file: missing "basis" field. This command only works with files generated by "review extract --basis".');
|
|
64
|
+
}
|
|
65
|
+
const article = findArticleByIdentifier(options.id, reviewFile.articles);
|
|
66
|
+
if (!article) {
|
|
67
|
+
throw new Error(`Article not found: ${options.id}`);
|
|
68
|
+
}
|
|
69
|
+
if (!article.reviews || article.reviews.length === 0) {
|
|
70
|
+
article.reviews = [{ decision: "uncertain", comment: "" }];
|
|
71
|
+
}
|
|
72
|
+
article.reviews[0].decision = options.decision;
|
|
73
|
+
if (options.comment !== void 0) {
|
|
74
|
+
article.reviews[0].comment = options.comment;
|
|
75
|
+
}
|
|
76
|
+
result.marked = 1;
|
|
77
|
+
const yamlContent = stringify(reviewFile, { lineWidth: 0 });
|
|
78
|
+
const leadingComments = extractLeadingComments(rawContent);
|
|
79
|
+
await writeFile(options.file, leadingComments + yamlContent, "utf-8");
|
|
31
80
|
}
|
|
32
|
-
result.marked = 1;
|
|
33
|
-
await saveWorkFile(options.file, workFile);
|
|
34
81
|
return result;
|
|
35
82
|
}
|
|
36
83
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mark.js","sources":["../../../../src/cli/commands/review/mark.ts"],"sourcesContent":["/**\n * review mark command - Mark decisions in work files\n */\n\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport type { ReviewDecision, WorkFile } from './types.js';\n\n\nexport interface ReviewMarkOptions {\n /** Path to the work file */\n file: string;\n /** Article ID to mark */\n id?: string;\n /** Decision to apply */\n decision?: ReviewDecision;\n /** Optional comment */\n comment?: string;\n}\n\nexport interface ReviewMarkResult {\n /** Number of articles marked */\n marked: number;\n /** Warnings (e.g., articles not found) */\n warnings: string[];\n}\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"mark.js","sources":["../../../../src/cli/commands/review/mark.ts"],"sourcesContent":["/**\n * review mark command - Mark decisions in review/work files\n */\n\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport type { ReviewDecision, ReviewFile, WorkFile, ArticleEntry } from './types.js';\n\n\nexport interface ReviewMarkOptions {\n /** Path to the work file */\n file: string;\n /** Article ID to mark */\n id?: string;\n /** Decision to apply */\n decision?: ReviewDecision;\n /** Optional comment */\n comment?: string;\n}\n\nexport interface ReviewMarkResult {\n /** Number of articles marked */\n marked: number;\n /** Warnings (e.g., articles not found) */\n warnings: string[];\n}\n\n/**\n * Extract leading comment lines from YAML content (lines starting with #)\n */\nfunction extractLeadingComments(content: string): string {\n const lines = content.split('\\n');\n const commentLines: string[] = [];\n for (const line of lines) {\n if (line.startsWith('#') || line.trim() === '') {\n commentLines.push(line);\n } else {\n break;\n }\n }\n // Return empty string if no comments found\n if (commentLines.length === 0) return '';\n return commentLines.join('\\n') + '\\n';\n}\n\n/** @deprecated Detects old WorkFile format (flat id/decision fields). Kept for backward compat. */\nfunction isWorkFile(file: unknown): file is WorkFile {\n if (\n typeof file !== 'object' ||\n file === null ||\n !('basis' in file) ||\n !('articles' in file) ||\n !Array.isArray((file as WorkFile).articles)\n ) {\n return false;\n }\n const articles = (file as WorkFile).articles;\n if (articles.length === 0) return false;\n const first = articles[0]!;\n return 'id' in first && 'decision' in first && !('reviews' in first);\n}\n\n/**\n * Find article by identifier (doi, pmid, scopusId, arxivId, ericId, title)\n */\nfunction findArticleByIdentifier(\n id: string,\n articles: ArticleEntry[]\n): ArticleEntry | undefined {\n for (const article of articles) {\n if (article.doi && article.doi.toLowerCase() === id.toLowerCase()) return article;\n if (article.pmid && article.pmid === id) return article;\n if (article.scopusId && article.scopusId === id) return article;\n if (article.arxivId && article.arxivId === id) return article;\n if (article.ericId && article.ericId === id) return article;\n if (article.title.toLowerCase() === id.toLowerCase()) return article;\n }\n return undefined;\n}\n\n/**\n * Execute review mark command\n */\nexport async function executeReviewMark(options: ReviewMarkOptions): Promise<ReviewMarkResult> {\n if (!options.id || !options.decision) {\n throw new Error('--id and --decision must be specified');\n }\n\n const rawContent = await readFile(options.file, 'utf-8');\n const parsed = parseYaml(rawContent);\n\n const result: ReviewMarkResult = {\n marked: 0,\n warnings: [],\n };\n\n if (isWorkFile(parsed)) {\n // Old WorkFile format (backward compat)\n const workFile = parsed as WorkFile;\n const article = workFile.articles.find((a) => a.id === options.id);\n if (!article) {\n throw new Error(`Article not found: ${options.id}`);\n }\n article.decision = options.decision;\n if (options.comment !== undefined) {\n article.comment = options.comment;\n }\n result.marked = 1;\n\n const yamlContent = stringifyYaml(workFile, { lineWidth: 0 });\n const leadingComments = extractLeadingComments(rawContent);\n await writeFile(options.file, leadingComments + yamlContent, 'utf-8');\n } else {\n // New ReviewFile screening format\n const reviewFile = parsed as ReviewFile;\n\n if (!reviewFile.basis) {\n throw new Error('Invalid review file: missing \"basis\" field. This command only works with files generated by \"review extract --basis\".');\n }\n\n const article = findArticleByIdentifier(options.id, reviewFile.articles);\n if (!article) {\n throw new Error(`Article not found: ${options.id}`);\n }\n\n // Ensure reviews array exists with at least one entry\n if (!article.reviews || article.reviews.length === 0) {\n article.reviews = [{ decision: 'uncertain', comment: '' } as ArticleEntry['reviews'][0]];\n }\n\n // Update first review entry\n article.reviews[0]!.decision = options.decision;\n if (options.comment !== undefined) {\n article.reviews[0]!.comment = options.comment;\n }\n result.marked = 1;\n\n const yamlContent = stringifyYaml(reviewFile, { lineWidth: 0 });\n const leadingComments = extractLeadingComments(rawContent);\n await writeFile(options.file, leadingComments + yamlContent, 'utf-8');\n }\n\n return result;\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
@@ -8,6 +8,12 @@ export interface ReviewMergeOptions {
|
|
|
8
8
|
export interface ReviewMergeResult {
|
|
9
9
|
reviewsAdded: number;
|
|
10
10
|
decisionsSet: number;
|
|
11
|
+
includeCount: number;
|
|
12
|
+
excludeCount: number;
|
|
13
|
+
uncertainCount: number;
|
|
14
|
+
finalDecisionsSet: number;
|
|
15
|
+
finalDecisionsIncludeCount: number;
|
|
16
|
+
finalDecisionsExcludeCount: number;
|
|
11
17
|
warnings: string[];
|
|
12
18
|
}
|
|
13
19
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/merge.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAkC,WAAW,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/review/merge.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAkC,WAAW,EAAE,MAAM,YAAY,CAAC;AA0B1F,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,0BAA0B,EAAE,MAAM,CAAC;IACnC,0BAA0B,EAAE,MAAM,CAAC;IACnC,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAmBD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,CAQ/F;AA8ND;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,kBAAkB,EAC3B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,iBAAiB,CAAC,CAoC5B;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAgDpF"}
|
|
@@ -3,7 +3,13 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
4
|
import { validateName } from "./extract.js";
|
|
5
5
|
function isWorkFile(file) {
|
|
6
|
-
|
|
6
|
+
if (typeof file !== "object" || file === null || !("basis" in file) || !("reviewer" in file) || !("articles" in file) || !Array.isArray(file.articles)) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const articles = file.articles;
|
|
10
|
+
if (articles.length === 0) return false;
|
|
11
|
+
const first = articles[0];
|
|
12
|
+
return "id" in first && "decision" in first && !("reviews" in first);
|
|
7
13
|
}
|
|
8
14
|
async function loadReviewFile(path) {
|
|
9
15
|
const content = await readFile(path, "utf-8");
|
|
@@ -70,6 +76,12 @@ function processWorkFile(workFile, mainFile, options) {
|
|
|
70
76
|
const result = {
|
|
71
77
|
reviewsAdded: 0,
|
|
72
78
|
decisionsSet: 0,
|
|
79
|
+
includeCount: 0,
|
|
80
|
+
excludeCount: 0,
|
|
81
|
+
uncertainCount: 0,
|
|
82
|
+
finalDecisionsSet: 0,
|
|
83
|
+
finalDecisionsIncludeCount: 0,
|
|
84
|
+
finalDecisionsExcludeCount: 0,
|
|
73
85
|
warnings: []
|
|
74
86
|
};
|
|
75
87
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -101,6 +113,9 @@ function processWorkFile(workFile, mainFile, options) {
|
|
|
101
113
|
mainArticle.reviews.push(review);
|
|
102
114
|
}
|
|
103
115
|
result.reviewsAdded++;
|
|
116
|
+
if (workArticle.decision === "include") result.includeCount++;
|
|
117
|
+
else if (workArticle.decision === "exclude") result.excludeCount++;
|
|
118
|
+
else if (workArticle.decision === "uncertain") result.uncertainCount++;
|
|
104
119
|
}
|
|
105
120
|
return result;
|
|
106
121
|
}
|
|
@@ -108,6 +123,12 @@ function processReviewFile(extractedFile, mainFile, options) {
|
|
|
108
123
|
const result = {
|
|
109
124
|
reviewsAdded: 0,
|
|
110
125
|
decisionsSet: 0,
|
|
126
|
+
includeCount: 0,
|
|
127
|
+
excludeCount: 0,
|
|
128
|
+
uncertainCount: 0,
|
|
129
|
+
finalDecisionsSet: 0,
|
|
130
|
+
finalDecisionsIncludeCount: 0,
|
|
131
|
+
finalDecisionsExcludeCount: 0,
|
|
111
132
|
warnings: []
|
|
112
133
|
};
|
|
113
134
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -127,7 +148,7 @@ function processReviewFile(extractedFile, mainFile, options) {
|
|
|
127
148
|
if (!reviewer) {
|
|
128
149
|
throw new Error("reviewer is required: set reviewer on individual review or top-level ReviewFile");
|
|
129
150
|
}
|
|
130
|
-
const basis = review.basis ?? detectBasis(extracted);
|
|
151
|
+
const basis = review.basis ?? extractedFile.basis ?? detectBasis(extracted);
|
|
131
152
|
if (!options.dryRun) {
|
|
132
153
|
const mergedReview = {
|
|
133
154
|
...review,
|
|
@@ -139,12 +160,18 @@ function processReviewFile(extractedFile, mainFile, options) {
|
|
|
139
160
|
registerReviewer(mainFile, reviewer, basis);
|
|
140
161
|
}
|
|
141
162
|
result.reviewsAdded++;
|
|
163
|
+
if (review.decision === "include") result.includeCount++;
|
|
164
|
+
else if (review.decision === "exclude") result.excludeCount++;
|
|
165
|
+
else if (review.decision === "uncertain") result.uncertainCount++;
|
|
142
166
|
}
|
|
143
167
|
if (extracted.finalDecision !== void 0 && extracted.finalDecision !== null) {
|
|
144
168
|
if (!options.dryRun) {
|
|
145
169
|
mainArticle.finalDecision = extracted.finalDecision;
|
|
146
170
|
}
|
|
147
171
|
result.decisionsSet++;
|
|
172
|
+
result.finalDecisionsSet++;
|
|
173
|
+
if (extracted.finalDecision === "include") result.finalDecisionsIncludeCount++;
|
|
174
|
+
else if (extracted.finalDecision === "exclude") result.finalDecisionsExcludeCount++;
|
|
148
175
|
}
|
|
149
176
|
}
|
|
150
177
|
return result;
|
|
@@ -182,8 +209,29 @@ function formatMergeOutput(result, dryRun) {
|
|
|
182
209
|
lines.push("");
|
|
183
210
|
}
|
|
184
211
|
lines.push("Merge Summary:");
|
|
185
|
-
|
|
186
|
-
|
|
212
|
+
if (result.reviewsAdded === 0) {
|
|
213
|
+
lines.push(` Reviews added: 0`);
|
|
214
|
+
} else {
|
|
215
|
+
const parts = [];
|
|
216
|
+
if (result.excludeCount > 0) parts.push(`${result.excludeCount} exclude`);
|
|
217
|
+
if (result.includeCount > 0) parts.push(`${result.includeCount} include`);
|
|
218
|
+
if (result.uncertainCount > 0) parts.push(`${result.uncertainCount} uncertain`);
|
|
219
|
+
if (parts.length > 0) {
|
|
220
|
+
lines.push(` Reviews added: ${result.reviewsAdded} (${parts.join(", ")})`);
|
|
221
|
+
} else {
|
|
222
|
+
lines.push(` Reviews added: ${result.reviewsAdded}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (result.finalDecisionsSet > 0) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
if (result.finalDecisionsIncludeCount > 0) parts.push(`${result.finalDecisionsIncludeCount} include`);
|
|
228
|
+
if (result.finalDecisionsExcludeCount > 0) parts.push(`${result.finalDecisionsExcludeCount} exclude`);
|
|
229
|
+
if (parts.length > 0) {
|
|
230
|
+
lines.push(` Final decisions set: ${result.finalDecisionsSet} (${parts.join(", ")})`);
|
|
231
|
+
} else {
|
|
232
|
+
lines.push(` Final decisions set: ${result.finalDecisionsSet}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
187
235
|
if (result.warnings.length > 0) {
|
|
188
236
|
lines.push("");
|
|
189
237
|
lines.push("Warnings:");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"merge.js","sources":["../../../../src/cli/commands/review/merge.ts"],"sourcesContent":["/**\n * review merge command - Merge edited file back into main reviews.yaml\n */\n\nimport { join } from 'node:path';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport type { ReviewFile, ArticleEntry, Review, WorkFile, ReviewBasis } from './types.js';\nimport { validateName } from './extract.js';\n\n\n/**\n * Check if a file is a work file (has basis field)\n */\nfunction isWorkFile(file: unknown): file is WorkFile {\n return (\n typeof file === 'object' &&\n file !== null &&\n 'basis' in file &&\n 'reviewer' in file &&\n 'articles' in file &&\n Array.isArray((file as WorkFile).articles)\n );\n}\n\nexport interface ReviewMergeOptions {\n sessionId: string;\n /** Name of the review subset (reads from for-review/<name>/review.yaml) */\n name: string;\n dryRun?: boolean;\n}\n\nexport interface ReviewMergeResult {\n reviewsAdded: number;\n decisionsSet: number;\n warnings: string[];\n}\n\n/**\n * Load review file from path\n */\nasync function loadReviewFile(path: string): Promise<ReviewFile> {\n const content = await readFile(path, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Auto-detect review basis from article data: fulltext > abstract > title\n */\nfunction detectBasis(article: ArticleEntry): ReviewBasis {\n if (article.fulltext) return 'fulltext';\n if (article.abstract) return 'abstract';\n return 'title';\n}\n\n/**\n * Register a reviewer in the review file's reviewers registry.\n * Deduplicates by name+basis pair.\n */\nexport function registerReviewer(reviewFile: ReviewFile, name: string, basis: ReviewBasis): void {\n if (!reviewFile.reviewers) {\n reviewFile.reviewers = [];\n }\n const exists = reviewFile.reviewers.some((r) => r.name === name && r.basis === basis);\n if (!exists) {\n reviewFile.reviewers.push({ name, basis });\n }\n}\n\n/**\n * Match article from extracted file to main file\n */\nfunction findMatchingArticle(\n extracted: ArticleEntry,\n mainArticles: ArticleEntry[]\n): ArticleEntry | undefined {\n // Try matching by various identifiers\n for (const main of mainArticles) {\n if (extracted.pmid && main.pmid && extracted.pmid === main.pmid) {\n return main;\n }\n if (extracted.doi && main.doi && extracted.doi.toLowerCase() === main.doi.toLowerCase()) {\n return main;\n }\n if (extracted.scopusId && main.scopusId && extracted.scopusId === main.scopusId) {\n return main;\n }\n if (extracted.arxivId && main.arxivId && extracted.arxivId === main.arxivId) {\n return main;\n }\n if (extracted.ericId && main.ericId && extracted.ericId === main.ericId) {\n return main;\n }\n }\n return undefined;\n}\n\n/**\n * Match work file article by id to main file\n * The id can be a DOI, PMID, ScopusId, ArxivId, EricId, or title\n */\nfunction findMatchingArticleById(\n id: string,\n mainArticles: ArticleEntry[]\n): ArticleEntry | undefined {\n for (const main of mainArticles) {\n // Match by DOI (case-insensitive)\n if (main.doi && main.doi.toLowerCase() === id.toLowerCase()) {\n return main;\n }\n // Match by PMID\n if (main.pmid && main.pmid === id) {\n return main;\n }\n // Match by ScopusId\n if (main.scopusId && main.scopusId === id) {\n return main;\n }\n // Match by ArxivId\n if (main.arxivId && main.arxivId === id) {\n return main;\n }\n // Match by EricId\n if (main.ericId && main.ericId === id) {\n return main;\n }\n // Match by title (fallback, case-insensitive)\n if (main.title.toLowerCase() === id.toLowerCase()) {\n return main;\n }\n }\n return undefined;\n}\n\n/**\n * Process work file format (with basis/reviewer)\n */\nfunction processWorkFile(\n workFile: WorkFile,\n mainFile: ReviewFile,\n options: ReviewMergeOptions\n): ReviewMergeResult {\n const result: ReviewMergeResult = {\n reviewsAdded: 0,\n decisionsSet: 0,\n warnings: [],\n };\n\n const timestamp = new Date().toISOString();\n\n // Register the reviewer for this work file's basis\n if (!options.dryRun) {\n registerReviewer(mainFile, workFile.reviewer, workFile.basis);\n }\n\n for (const workArticle of workFile.articles) {\n // Skip articles with null decision (not yet reviewed)\n if (workArticle.decision === null) {\n continue;\n }\n\n const mainArticle = findMatchingArticleById(workArticle.id, mainFile.articles);\n\n if (!mainArticle) {\n result.warnings.push(`Article not found in main file: id=\"${workArticle.id}\"`);\n continue;\n }\n\n // Ensure mainArticle.reviews is an array\n if (!mainArticle.reviews) {\n mainArticle.reviews = [];\n }\n\n // Create review from work file article\n const review: Review = {\n reviewer: workFile.reviewer,\n decision: workArticle.decision,\n basis: workFile.basis,\n timestamp,\n };\n\n // Add comment if provided\n if (workArticle.comment) {\n review.comment = workArticle.comment;\n }\n\n if (!options.dryRun) {\n mainArticle.reviews.push(review);\n }\n result.reviewsAdded++;\n }\n\n return result;\n}\n\n/**\n * Process review file format (with reviewHistory separation)\n */\nfunction processReviewFile(\n extractedFile: ReviewFile,\n mainFile: ReviewFile,\n options: ReviewMergeOptions\n): ReviewMergeResult {\n const result: ReviewMergeResult = {\n reviewsAdded: 0,\n decisionsSet: 0,\n warnings: [],\n };\n\n const timestamp = new Date().toISOString();\n const topLevelReviewer = extractedFile.reviewer;\n\n for (const extracted of extractedFile.articles) {\n const mainArticle = findMatchingArticle(extracted, mainFile.articles);\n\n if (!mainArticle) {\n result.warnings.push(`Article not found in main file: \"${extracted.title}\"`);\n continue;\n }\n\n // Merge only reviews[] (reviewHistory is ignored)\n const extractedReviews = extracted.reviews ?? [];\n\n // Ensure mainArticle.reviews is an array\n if (!mainArticle.reviews) {\n mainArticle.reviews = [];\n }\n\n for (const review of extractedReviews) {\n // Fill in reviewer from top-level field if not on individual review\n const reviewer = review.reviewer ?? topLevelReviewer;\n if (!reviewer) {\n throw new Error('reviewer is required: set reviewer on individual review or top-level ReviewFile');\n }\n // Fill in basis from review or auto-detect from article data\n const basis = review.basis ?? detectBasis(extracted);\n\n if (!options.dryRun) {\n const mergedReview: Review = {\n ...review,\n reviewer,\n basis,\n timestamp: review.timestamp ?? timestamp,\n };\n mainArticle.reviews.push(mergedReview);\n\n // Register reviewer\n registerReviewer(mainFile, reviewer, basis);\n }\n result.reviewsAdded++;\n }\n\n // Overwrite finalDecision if set in extracted (null means unset)\n if (extracted.finalDecision !== undefined && extracted.finalDecision !== null) {\n if (!options.dryRun) {\n mainArticle.finalDecision = extracted.finalDecision;\n }\n result.decisionsSet++;\n }\n }\n\n return result;\n}\n\n/**\n * Execute review merge command\n */\nexport async function executeReviewMerge(\n options: ReviewMergeOptions,\n sessionsDir: string\n): Promise<ReviewMergeResult> {\n validateName(options.name);\n const sessionDir = join(sessionsDir, options.sessionId);\n const mainReviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const filePath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n\n // Load both files\n const mainFile = await loadReviewFile(mainReviewsPath);\n const content = await readFile(filePath, 'utf-8');\n const inputFile = parseYaml(content);\n\n let result: ReviewMergeResult;\n\n // Detect file format and process accordingly\n if (isWorkFile(inputFile)) {\n result = processWorkFile(inputFile, mainFile, options);\n } else {\n result = processReviewFile(inputFile as ReviewFile, mainFile, options);\n }\n\n // Write back if not dry-run\n if (!options.dryRun) {\n const yamlContent = stringifyYaml(mainFile, {\n lineWidth: 0,\n });\n\n // Preserve schema reference comment\n // Path from sessions/{id}/.internal/ to .search-hub/schemas/\n const schemaPath = '../../../../.search-hub/schemas/review.schema.json';\n const schemaComment = `# yaml-language-server: $schema=${schemaPath}\\n`;\n const finalContent = schemaComment + yamlContent;\n\n await writeFile(mainReviewsPath, finalContent, 'utf-8');\n }\n\n return result;\n}\n\n/**\n * Format merge result as human-readable string\n */\nexport function formatMergeOutput(result: ReviewMergeResult, dryRun: boolean): string {\n const lines: string[] = [];\n\n if (dryRun) {\n lines.push('Dry run - no changes made');\n lines.push('');\n }\n\n lines.push('Merge Summary:');\n lines.push(` Reviews added: ${result.reviewsAdded}`);\n lines.push(` Decisions set: ${result.decisionsSet}`);\n\n if (result.warnings.length > 0) {\n lines.push('');\n lines.push('Warnings:');\n for (const warning of result.warnings) {\n lines.push(` - ${warning}`);\n }\n }\n\n return lines.join('\\n');\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"merge.js","sources":["../../../../src/cli/commands/review/merge.ts"],"sourcesContent":["/**\n * review merge command - Merge edited file back into main reviews.yaml\n */\n\nimport { join } from 'node:path';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { parse as parseYaml, stringify as stringifyYaml } from 'yaml';\nimport type { ReviewFile, ArticleEntry, Review, WorkFile, ReviewBasis } from './types.js';\nimport { validateName } from './extract.js';\n\n\n/**\n * Check if a file is a work file (has basis field)\n */\n/** @deprecated Detects old WorkFile format (flat id/decision fields). Kept for backward compat. */\nfunction isWorkFile(file: unknown): file is WorkFile {\n if (\n typeof file !== 'object' ||\n file === null ||\n !('basis' in file) ||\n !('reviewer' in file) ||\n !('articles' in file) ||\n !Array.isArray((file as WorkFile).articles)\n ) {\n return false;\n }\n // Distinguish from new ReviewFile-with-basis: old WorkFile has articles with flat `id` + `decision` fields\n const articles = (file as WorkFile).articles;\n if (articles.length === 0) return false;\n const first = articles[0]!;\n return 'id' in first && 'decision' in first && !('reviews' in first);\n}\n\nexport interface ReviewMergeOptions {\n sessionId: string;\n /** Name of the review subset (reads from for-review/<name>/review.yaml) */\n name: string;\n dryRun?: boolean;\n}\n\nexport interface ReviewMergeResult {\n reviewsAdded: number;\n decisionsSet: number;\n includeCount: number;\n excludeCount: number;\n uncertainCount: number;\n finalDecisionsSet: number;\n finalDecisionsIncludeCount: number;\n finalDecisionsExcludeCount: number;\n warnings: string[];\n}\n\n/**\n * Load review file from path\n */\nasync function loadReviewFile(path: string): Promise<ReviewFile> {\n const content = await readFile(path, 'utf-8');\n return parseYaml(content) as ReviewFile;\n}\n\n/**\n * Auto-detect review basis from article data: fulltext > abstract > title\n */\nfunction detectBasis(article: ArticleEntry): ReviewBasis {\n if (article.fulltext) return 'fulltext';\n if (article.abstract) return 'abstract';\n return 'title';\n}\n\n/**\n * Register a reviewer in the review file's reviewers registry.\n * Deduplicates by name+basis pair.\n */\nexport function registerReviewer(reviewFile: ReviewFile, name: string, basis: ReviewBasis): void {\n if (!reviewFile.reviewers) {\n reviewFile.reviewers = [];\n }\n const exists = reviewFile.reviewers.some((r) => r.name === name && r.basis === basis);\n if (!exists) {\n reviewFile.reviewers.push({ name, basis });\n }\n}\n\n/**\n * Match article from extracted file to main file\n */\nfunction findMatchingArticle(\n extracted: ArticleEntry,\n mainArticles: ArticleEntry[]\n): ArticleEntry | undefined {\n // Try matching by various identifiers\n for (const main of mainArticles) {\n if (extracted.pmid && main.pmid && extracted.pmid === main.pmid) {\n return main;\n }\n if (extracted.doi && main.doi && extracted.doi.toLowerCase() === main.doi.toLowerCase()) {\n return main;\n }\n if (extracted.scopusId && main.scopusId && extracted.scopusId === main.scopusId) {\n return main;\n }\n if (extracted.arxivId && main.arxivId && extracted.arxivId === main.arxivId) {\n return main;\n }\n if (extracted.ericId && main.ericId && extracted.ericId === main.ericId) {\n return main;\n }\n }\n return undefined;\n}\n\n/**\n * Match work file article by id to main file\n * The id can be a DOI, PMID, ScopusId, ArxivId, EricId, or title\n */\nfunction findMatchingArticleById(\n id: string,\n mainArticles: ArticleEntry[]\n): ArticleEntry | undefined {\n for (const main of mainArticles) {\n // Match by DOI (case-insensitive)\n if (main.doi && main.doi.toLowerCase() === id.toLowerCase()) {\n return main;\n }\n // Match by PMID\n if (main.pmid && main.pmid === id) {\n return main;\n }\n // Match by ScopusId\n if (main.scopusId && main.scopusId === id) {\n return main;\n }\n // Match by ArxivId\n if (main.arxivId && main.arxivId === id) {\n return main;\n }\n // Match by EricId\n if (main.ericId && main.ericId === id) {\n return main;\n }\n // Match by title (fallback, case-insensitive)\n if (main.title.toLowerCase() === id.toLowerCase()) {\n return main;\n }\n }\n return undefined;\n}\n\n/**\n * Process work file format (with basis/reviewer)\n */\nfunction processWorkFile(\n workFile: WorkFile,\n mainFile: ReviewFile,\n options: ReviewMergeOptions\n): ReviewMergeResult {\n const result: ReviewMergeResult = {\n reviewsAdded: 0,\n decisionsSet: 0,\n includeCount: 0,\n excludeCount: 0,\n uncertainCount: 0,\n finalDecisionsSet: 0,\n finalDecisionsIncludeCount: 0,\n finalDecisionsExcludeCount: 0,\n warnings: [],\n };\n\n const timestamp = new Date().toISOString();\n\n // Register the reviewer for this work file's basis\n if (!options.dryRun) {\n registerReviewer(mainFile, workFile.reviewer, workFile.basis);\n }\n\n for (const workArticle of workFile.articles) {\n // Skip articles with null decision (not yet reviewed)\n if (workArticle.decision === null) {\n continue;\n }\n\n const mainArticle = findMatchingArticleById(workArticle.id, mainFile.articles);\n\n if (!mainArticle) {\n result.warnings.push(`Article not found in main file: id=\"${workArticle.id}\"`);\n continue;\n }\n\n // Ensure mainArticle.reviews is an array\n if (!mainArticle.reviews) {\n mainArticle.reviews = [];\n }\n\n // Create review from work file article\n const review: Review = {\n reviewer: workFile.reviewer,\n decision: workArticle.decision,\n basis: workFile.basis,\n timestamp,\n };\n\n // Add comment if provided\n if (workArticle.comment) {\n review.comment = workArticle.comment;\n }\n\n if (!options.dryRun) {\n mainArticle.reviews.push(review);\n }\n result.reviewsAdded++;\n\n // Count decision types\n if (workArticle.decision === 'include') result.includeCount++;\n else if (workArticle.decision === 'exclude') result.excludeCount++;\n else if (workArticle.decision === 'uncertain') result.uncertainCount++;\n }\n\n return result;\n}\n\n/**\n * Process review file format (with reviewHistory separation)\n */\nfunction processReviewFile(\n extractedFile: ReviewFile,\n mainFile: ReviewFile,\n options: ReviewMergeOptions\n): ReviewMergeResult {\n const result: ReviewMergeResult = {\n reviewsAdded: 0,\n decisionsSet: 0,\n includeCount: 0,\n excludeCount: 0,\n uncertainCount: 0,\n finalDecisionsSet: 0,\n finalDecisionsIncludeCount: 0,\n finalDecisionsExcludeCount: 0,\n warnings: [],\n };\n\n const timestamp = new Date().toISOString();\n const topLevelReviewer = extractedFile.reviewer;\n\n for (const extracted of extractedFile.articles) {\n const mainArticle = findMatchingArticle(extracted, mainFile.articles);\n\n if (!mainArticle) {\n result.warnings.push(`Article not found in main file: \"${extracted.title}\"`);\n continue;\n }\n\n // Merge only reviews[] (reviewHistory is ignored)\n const extractedReviews = extracted.reviews ?? [];\n\n // Ensure mainArticle.reviews is an array\n if (!mainArticle.reviews) {\n mainArticle.reviews = [];\n }\n\n for (const review of extractedReviews) {\n // Fill in reviewer from top-level field if not on individual review\n const reviewer = review.reviewer ?? topLevelReviewer;\n if (!reviewer) {\n throw new Error('reviewer is required: set reviewer on individual review or top-level ReviewFile');\n }\n // Fill in basis from review, top-level field, or auto-detect from article data\n const basis = review.basis ?? extractedFile.basis ?? detectBasis(extracted);\n\n if (!options.dryRun) {\n const mergedReview: Review = {\n ...review,\n reviewer,\n basis,\n timestamp: review.timestamp ?? timestamp,\n };\n mainArticle.reviews.push(mergedReview);\n\n // Register reviewer\n registerReviewer(mainFile, reviewer, basis);\n }\n result.reviewsAdded++;\n\n // Count decision types\n if (review.decision === 'include') result.includeCount++;\n else if (review.decision === 'exclude') result.excludeCount++;\n else if (review.decision === 'uncertain') result.uncertainCount++;\n }\n\n // Overwrite finalDecision if set in extracted (null means unset)\n if (extracted.finalDecision !== undefined && extracted.finalDecision !== null) {\n if (!options.dryRun) {\n mainArticle.finalDecision = extracted.finalDecision;\n }\n result.decisionsSet++;\n result.finalDecisionsSet++;\n if (extracted.finalDecision === 'include') result.finalDecisionsIncludeCount++;\n else if (extracted.finalDecision === 'exclude') result.finalDecisionsExcludeCount++;\n }\n }\n\n return result;\n}\n\n/**\n * Execute review merge command\n */\nexport async function executeReviewMerge(\n options: ReviewMergeOptions,\n sessionsDir: string\n): Promise<ReviewMergeResult> {\n validateName(options.name);\n const sessionDir = join(sessionsDir, options.sessionId);\n const mainReviewsPath = join(sessionDir, '.internal', 'reviews.yaml');\n const filePath = join(sessionDir, 'for-review', options.name, 'review.yaml');\n\n // Load both files\n const mainFile = await loadReviewFile(mainReviewsPath);\n const content = await readFile(filePath, 'utf-8');\n const inputFile = parseYaml(content);\n\n let result: ReviewMergeResult;\n\n // Detect file format and process accordingly\n if (isWorkFile(inputFile)) {\n result = processWorkFile(inputFile, mainFile, options);\n } else {\n result = processReviewFile(inputFile as ReviewFile, mainFile, options);\n }\n\n // Write back if not dry-run\n if (!options.dryRun) {\n const yamlContent = stringifyYaml(mainFile, {\n lineWidth: 0,\n });\n\n // Preserve schema reference comment\n // Path from sessions/{id}/.internal/ to .search-hub/schemas/\n const schemaPath = '../../../../.search-hub/schemas/review.schema.json';\n const schemaComment = `# yaml-language-server: $schema=${schemaPath}\\n`;\n const finalContent = schemaComment + yamlContent;\n\n await writeFile(mainReviewsPath, finalContent, 'utf-8');\n }\n\n return result;\n}\n\n/**\n * Format merge result as human-readable string\n */\nexport function formatMergeOutput(result: ReviewMergeResult, dryRun: boolean): string {\n const lines: string[] = [];\n\n if (dryRun) {\n lines.push('Dry run - no changes made');\n lines.push('');\n }\n\n lines.push('Merge Summary:');\n\n // Build reviews line with decision breakdown\n if (result.reviewsAdded === 0) {\n lines.push(` Reviews added: 0`);\n } else {\n const parts: string[] = [];\n if (result.excludeCount > 0) parts.push(`${result.excludeCount} exclude`);\n if (result.includeCount > 0) parts.push(`${result.includeCount} include`);\n if (result.uncertainCount > 0) parts.push(`${result.uncertainCount} uncertain`);\n\n if (parts.length > 0) {\n lines.push(` Reviews added: ${result.reviewsAdded} (${parts.join(', ')})`);\n } else {\n lines.push(` Reviews added: ${result.reviewsAdded}`);\n }\n }\n\n // Show final decisions only when some were set\n if (result.finalDecisionsSet > 0) {\n const parts: string[] = [];\n if (result.finalDecisionsIncludeCount > 0) parts.push(`${result.finalDecisionsIncludeCount} include`);\n if (result.finalDecisionsExcludeCount > 0) parts.push(`${result.finalDecisionsExcludeCount} exclude`);\n\n if (parts.length > 0) {\n lines.push(` Final decisions set: ${result.finalDecisionsSet} (${parts.join(', ')})`);\n } else {\n lines.push(` Final decisions set: ${result.finalDecisionsSet}`);\n }\n }\n\n if (result.warnings.length > 0) {\n lines.push('');\n lines.push('Warnings:');\n for (const warning of result.warnings) {\n lines.push(` - ${warning}`);\n }\n }\n\n return lines.join('\\n');\n}\n"],"names":["parseYaml","stringifyYaml"],"mappings":"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"}
|