@tiangong-ai/cli 0.0.34 → 0.0.36
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/AGENTS.md +18 -6
- package/README.md +147 -2
- package/dist/research/orchestration.js +651 -24
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/workspace/audit-bundle.d.ts +44 -0
- package/dist/research/workspace/audit-bundle.js +357 -0
- package/dist/research/workspace/audit-bundle.js.map +1 -0
- package/dist/research/workspace/input-plan.js +27 -1
- package/dist/research/workspace/input-plan.js.map +1 -1
- package/dist/research/workspace/preflight.d.ts +34 -2
- package/dist/research/workspace/preflight.js +145 -1
- package/dist/research/workspace/preflight.js.map +1 -1
- package/dist/research/workspace/projects.d.ts +32 -5
- package/dist/research/workspace/projects.js +263 -7
- package/dist/research/workspace/projects.js.map +1 -1
- package/dist/research/workspace/publication-workflow.d.ts +146 -0
- package/dist/research/workspace/publication-workflow.js +994 -0
- package/dist/research/workspace/publication-workflow.js.map +1 -0
- package/dist/research/workspace/publication.d.ts +110 -0
- package/dist/research/workspace/publication.js +246 -0
- package/dist/research/workspace/publication.js.map +1 -0
- package/dist/research/workspace/research-policy-wizard.d.ts +40 -0
- package/dist/research/workspace/research-policy-wizard.js +167 -0
- package/dist/research/workspace/research-policy-wizard.js.map +1 -0
- package/dist/research/workspace/research-policy.d.ts +70 -0
- package/dist/research/workspace/research-policy.js +886 -0
- package/dist/research/workspace/research-policy.js.map +1 -0
- package/dist/research/workspace/runtime.d.ts +17 -0
- package/dist/research/workspace/runtime.js +84 -2
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/sanitization.js +9 -3
- package/dist/research/workspace/sanitization.js.map +1 -1
- package/dist/research/workspace/scientific-design.d.ts +336 -0
- package/dist/research/workspace/scientific-design.js +1845 -0
- package/dist/research/workspace/scientific-design.js.map +1 -0
- package/dist/research/workspace/scientific-review.d.ts +101 -0
- package/dist/research/workspace/scientific-review.js +1167 -0
- package/dist/research/workspace/scientific-review.js.map +1 -0
- package/dist/research/workspace/setup-catalog.js +2 -2
- package/dist/research/workspace/setup-wizard.d.ts +18 -1
- package/dist/research/workspace/setup-wizard.js +1 -1
- package/dist/research/workspace/setup-wizard.js.map +1 -1
- package/dist/research/workspace/setup.d.ts +1 -0
- package/dist/research/workspace/setup.js +64 -0
- package/dist/research/workspace/setup.js.map +1 -1
- package/dist/research/workspace/types.d.ts +58 -0
- package/dist/research/workspace/workspace.js +25 -7
- package/dist/research/workspace/workspace.js.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
import { appendJournalEvent } from "./journal.js";
|
|
6
|
+
import { canonicalJson, hashRegularTree, isObject, pathExists, readJsonFile, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
|
|
7
|
+
const PROJECT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,63}$/;
|
|
8
|
+
const POLICY_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{2,127}$/;
|
|
9
|
+
const RULE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,127}$/;
|
|
10
|
+
const MAX_POLICY_DOCUMENT_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_POLICY_STACK_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const PLACEHOLDER_PATTERN = /__[A-Z0-9_]+__|\b(?:TODO|TBD)\b/;
|
|
13
|
+
const SENSITIVE_POLICY_QUERY_KEY = /^(?:access[_-]?token|api[_-]?key|apikey|auth|authorization|code|cookie|credential|key|password|secret|session(?:[_-]?id)?|sig|signature|token)$/i;
|
|
14
|
+
const KNOWN_RULES = new Set([
|
|
15
|
+
"all-material-claims-traceable",
|
|
16
|
+
"alternative-explanations-tested",
|
|
17
|
+
"authoritative-synthesis-required",
|
|
18
|
+
"availability-contract-required",
|
|
19
|
+
"baseline-comparison-required",
|
|
20
|
+
"benchmark-comparison-required",
|
|
21
|
+
"biological-or-clinical-relevance-required",
|
|
22
|
+
"broad-editorial-significance-required",
|
|
23
|
+
"central-claim-directly-supported",
|
|
24
|
+
"central-claims-directly-supported",
|
|
25
|
+
"central-outcome-observed",
|
|
26
|
+
"characterization-and-uncertainty-required",
|
|
27
|
+
"closest-prior-work-compared",
|
|
28
|
+
"complete-candidate-disposition-required",
|
|
29
|
+
"complete-domain-comparison-required",
|
|
30
|
+
"conceptual-contribution-required",
|
|
31
|
+
"construct-validity-required",
|
|
32
|
+
"contribution-non-incremental",
|
|
33
|
+
"counterargument-required",
|
|
34
|
+
"cross-field-accessibility-required",
|
|
35
|
+
"cross-field-significance-required",
|
|
36
|
+
"design-supports-central-claim",
|
|
37
|
+
"economic-significance-required",
|
|
38
|
+
"empirical-boundaries-explicit",
|
|
39
|
+
"ethics-and-governance-reviewed",
|
|
40
|
+
"evidence-composition-reported",
|
|
41
|
+
"exact-journal-article-type-confirmed",
|
|
42
|
+
"exact-journal-guidelines-verified",
|
|
43
|
+
"exact-journal-scope-confirmed",
|
|
44
|
+
"field-agenda-contribution-required",
|
|
45
|
+
"field-shaping-contribution-required",
|
|
46
|
+
"final-frozen-manuscript-required",
|
|
47
|
+
"final-manuscript-reviewed",
|
|
48
|
+
"identification-and-context-required",
|
|
49
|
+
"identification-strategy-required",
|
|
50
|
+
"independent-recall-challenge-required",
|
|
51
|
+
"independent-reuse-demonstrated",
|
|
52
|
+
"independent-validation-required",
|
|
53
|
+
"interpretive-contribution-required",
|
|
54
|
+
"material-results-reproduced",
|
|
55
|
+
"model-calibrated-or-justified",
|
|
56
|
+
"novelty-comparison-required",
|
|
57
|
+
"physical-mechanism-tested",
|
|
58
|
+
"primary-material-context-required",
|
|
59
|
+
"project-brief-complete",
|
|
60
|
+
"protocol-required",
|
|
61
|
+
"readiness-language-bounded",
|
|
62
|
+
"realistic-operating-conditions-required",
|
|
63
|
+
"recall-audit-required",
|
|
64
|
+
"reuse-value-demonstrated",
|
|
65
|
+
"risk-of-bias-required",
|
|
66
|
+
"robustness-and-uncertainty-reviewed",
|
|
67
|
+
"robustness-evidence-required",
|
|
68
|
+
"scale-and-boundary-effects-tested",
|
|
69
|
+
"spatial-temporal-applicability-required",
|
|
70
|
+
"specialist-methods-scrutiny-required",
|
|
71
|
+
"specialist-state-of-art-advance-required",
|
|
72
|
+
"strong-baseline-required",
|
|
73
|
+
"synthesis-method-justified",
|
|
74
|
+
"target-fit-reviewed",
|
|
75
|
+
"terminology-accessible-across-fields",
|
|
76
|
+
"uncertainty-propagated",
|
|
77
|
+
]);
|
|
78
|
+
const KNOWN_REVIEWERS = new Set([
|
|
79
|
+
"evidence",
|
|
80
|
+
"methods-reproducibility",
|
|
81
|
+
"domain-novelty",
|
|
82
|
+
"journal-editor",
|
|
83
|
+
]);
|
|
84
|
+
const POLICY_KINDS = new Set([
|
|
85
|
+
"baseline",
|
|
86
|
+
"article-type",
|
|
87
|
+
"field",
|
|
88
|
+
"journal-class",
|
|
89
|
+
"exact-journal",
|
|
90
|
+
"reviewer-rubric",
|
|
91
|
+
"publication-brief",
|
|
92
|
+
]);
|
|
93
|
+
const KNOWN_CONSTRAINTS = new Map([
|
|
94
|
+
["minDirectPeerReviewedFullText", "integer"],
|
|
95
|
+
["minDirectEmpiricalFullText", "integer"],
|
|
96
|
+
["minDirectModelFullText", "integer"],
|
|
97
|
+
["requireCompleteCandidateDisposition", "boolean"],
|
|
98
|
+
["requireRecallAudit", "boolean"],
|
|
99
|
+
["requireCentralDimensionsCovered", "boolean"],
|
|
100
|
+
["requireIndependentReproduction", "boolean"],
|
|
101
|
+
]);
|
|
102
|
+
export async function inspectResearchPolicyCatalog(sourceRoot) {
|
|
103
|
+
const root = await requirePolicySourceRoot(sourceRoot);
|
|
104
|
+
const policyRoot = policyTemplateRoot(root);
|
|
105
|
+
const baseline = join(policyRoot, "baseline", "top-journal.md");
|
|
106
|
+
const brief = join(policyRoot, "project", "publication-brief.md");
|
|
107
|
+
const reviewers = await markdownBasenames(join(policyRoot, "reviewer-rubrics"));
|
|
108
|
+
await Promise.all([
|
|
109
|
+
parsePolicyFile(baseline, false),
|
|
110
|
+
parsePolicyFile(brief, false),
|
|
111
|
+
...reviewers.map((name) => parsePolicyFile(join(policyRoot, "reviewer-rubrics", `${name}.md`), false)),
|
|
112
|
+
]);
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
sourceTreeSha256: await hashRegularTree(root),
|
|
116
|
+
categories: {
|
|
117
|
+
articleTypes: await markdownBasenames(join(policyRoot, "article-types")),
|
|
118
|
+
fields: await markdownBasenames(join(policyRoot, "fields")),
|
|
119
|
+
journalClasses: await markdownBasenames(join(policyRoot, "journal-classes")),
|
|
120
|
+
},
|
|
121
|
+
defaults: {
|
|
122
|
+
baseline: "top-journal",
|
|
123
|
+
reviewers,
|
|
124
|
+
publicationBrief: "publication-brief",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function initializeResearchPolicy(input) {
|
|
129
|
+
validateProjectId(input.projectId);
|
|
130
|
+
const root = resolve(input.root);
|
|
131
|
+
const sourceRoot = await requirePolicySourceRoot(input.sourceRoot);
|
|
132
|
+
const catalog = await inspectResearchPolicyCatalog(sourceRoot);
|
|
133
|
+
requireCatalogSelection("article type", input.articleType, catalog.categories.articleTypes);
|
|
134
|
+
requireCatalogSelection("field", input.field, catalog.categories.fields);
|
|
135
|
+
requireCatalogSelection("journal class", input.journalClass, catalog.categories.journalClasses);
|
|
136
|
+
const userRoot = policyUserRoot(root, input.projectId);
|
|
137
|
+
if (await pathExists(userRoot)) {
|
|
138
|
+
throw policyError("RESEARCH_POLICY_EXISTS", `Research Policy directory already exists for ${input.projectId}.`, 2);
|
|
139
|
+
}
|
|
140
|
+
const templateRoot = policyTemplateRoot(sourceRoot);
|
|
141
|
+
const selections = [
|
|
142
|
+
["baseline/top-journal.md", "baseline.md"],
|
|
143
|
+
[`article-types/${input.articleType}.md`, "article-type.md"],
|
|
144
|
+
[`fields/${input.field}.md`, "field.md"],
|
|
145
|
+
[`journal-classes/${input.journalClass}.md`, "journal-class.md"],
|
|
146
|
+
...(await markdownBasenames(join(templateRoot, "reviewer-rubrics"))).map((reviewer) => [
|
|
147
|
+
`reviewer-rubrics/${reviewer}.md`,
|
|
148
|
+
`reviewers/${reviewer}.md`,
|
|
149
|
+
]),
|
|
150
|
+
["project/publication-brief.md", "publication-brief.md"],
|
|
151
|
+
...(input.includeExactJournalTemplate
|
|
152
|
+
? [["journals/exact-journal-template.md", "journal.md"]]
|
|
153
|
+
: []),
|
|
154
|
+
];
|
|
155
|
+
const manifestDocuments = [];
|
|
156
|
+
let totalBytes = 0;
|
|
157
|
+
for (const [sourceLogicalPath, destinationLogicalPath] of selections) {
|
|
158
|
+
const source = join(templateRoot, sourceLogicalPath);
|
|
159
|
+
const parsed = await parsePolicyFile(source, false);
|
|
160
|
+
totalBytes += Buffer.byteLength(parsed.content, "utf8");
|
|
161
|
+
if (totalBytes > MAX_POLICY_STACK_BYTES) {
|
|
162
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy defaults exceed the stack limit.");
|
|
163
|
+
}
|
|
164
|
+
const materializedContent = materializePolicyTemplate(parsed.content, {
|
|
165
|
+
articleType: input.articleType,
|
|
166
|
+
field: input.field,
|
|
167
|
+
journalClass: input.journalClass,
|
|
168
|
+
exactJournal: input.includeExactJournalTemplate === true,
|
|
169
|
+
});
|
|
170
|
+
await writeTextAtomic(join(userRoot, destinationLogicalPath), materializedContent, 0o600);
|
|
171
|
+
manifestDocuments.push({
|
|
172
|
+
logicalPath: destinationLogicalPath,
|
|
173
|
+
id: parsed.id,
|
|
174
|
+
kind: parsed.kind,
|
|
175
|
+
templateClass: parsed.templateClass,
|
|
176
|
+
templateSha256: sha256Text(materializedContent),
|
|
177
|
+
templateBodySha256: sha256Text(parsed.body),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const manifestCore = {
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
projectId: input.projectId,
|
|
183
|
+
initializedAt: new Date().toISOString(),
|
|
184
|
+
sourceTreeSha256: catalog.sourceTreeSha256,
|
|
185
|
+
selection: {
|
|
186
|
+
articleType: input.articleType,
|
|
187
|
+
field: input.field,
|
|
188
|
+
journalClass: input.journalClass,
|
|
189
|
+
exactJournal: input.includeExactJournalTemplate === true,
|
|
190
|
+
},
|
|
191
|
+
documents: manifestDocuments.sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)),
|
|
192
|
+
};
|
|
193
|
+
const manifest = {
|
|
194
|
+
...manifestCore,
|
|
195
|
+
manifestSha256: sha256Text(canonicalJson(manifestCore)),
|
|
196
|
+
};
|
|
197
|
+
await writeJsonAtomic(policyManifestPath(root, input.projectId), manifest);
|
|
198
|
+
await appendJournalEvent(workspacePaths(root).journal, "research.policy.initialized", input.projectId, {
|
|
199
|
+
projectId: input.projectId,
|
|
200
|
+
sourceTreeSha256: manifest.sourceTreeSha256,
|
|
201
|
+
articleType: input.articleType,
|
|
202
|
+
field: input.field,
|
|
203
|
+
journalClass: input.journalClass,
|
|
204
|
+
documentCount: manifest.documents.length,
|
|
205
|
+
});
|
|
206
|
+
return inspectResearchPolicyStatus(root, input.projectId);
|
|
207
|
+
}
|
|
208
|
+
export async function completeResearchPublicationBrief(rootInput, projectId, input) {
|
|
209
|
+
validateProjectId(projectId);
|
|
210
|
+
const root = resolve(rootInput);
|
|
211
|
+
await loadPolicyManifest(root, projectId);
|
|
212
|
+
const values = {
|
|
213
|
+
centralQuestion: policyHumanValue("central research question", input.centralQuestion, 8, 4_000),
|
|
214
|
+
centralClaim: policyHumanValue("central claim", input.centralClaim, 8, 4_000),
|
|
215
|
+
centralOutcome: policyHumanValue("central outcome", input.centralOutcome, 3, 1_000),
|
|
216
|
+
contributionType: policyHumanValue("contribution type", input.contributionType, 3, 500),
|
|
217
|
+
...(input.targetJournal === undefined
|
|
218
|
+
? {}
|
|
219
|
+
: {
|
|
220
|
+
targetJournal: input.targetJournal === null
|
|
221
|
+
? "none"
|
|
222
|
+
: policyHumanValue("target journal", input.targetJournal, 2, 200),
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
await updatePolicyFrontmatter(join(policyUserRoot(root, projectId), "publication-brief.md"), values);
|
|
226
|
+
await appendJournalEvent(workspacePaths(root).journal, "research.policy.publication-brief.completed", projectId, { projectId, fields: Object.keys(values).sort() });
|
|
227
|
+
return inspectResearchPolicyStatus(root, projectId);
|
|
228
|
+
}
|
|
229
|
+
export async function completeResearchExactJournalPolicy(rootInput, projectId, input) {
|
|
230
|
+
validateProjectId(projectId);
|
|
231
|
+
const root = resolve(rootInput);
|
|
232
|
+
const manifest = await loadPolicyManifest(root, projectId);
|
|
233
|
+
if (!manifest.selection.exactJournal) {
|
|
234
|
+
throw policyError("RESEARCH_POLICY_TARGET_MISSING", "This Research Policy was not initialized with an exact-journal template.", 2);
|
|
235
|
+
}
|
|
236
|
+
const journalName = policyHumanValue("target journal", input.journalName, 2, 200);
|
|
237
|
+
const officialGuidelinesUrl = policyOfficialUrl(input.officialGuidelinesUrl);
|
|
238
|
+
const officialGuidelinesRetrievedAt = policyIsoDate(input.officialGuidelinesRetrievedAt);
|
|
239
|
+
const sections = {
|
|
240
|
+
Scope: policyHumanValue("journal scope", input.scope, 12, 8_000),
|
|
241
|
+
"Editorial significance": policyHumanValue("journal editorial significance", input.editorialSignificance, 12, 8_000),
|
|
242
|
+
"Evidence expectations": policyHumanValue("journal evidence expectations", input.evidenceExpectations, 12, 8_000),
|
|
243
|
+
"Methods and validation": policyHumanValue("journal methods and validation", input.methodsAndValidation, 12, 8_000),
|
|
244
|
+
Reproducibility: policyHumanValue("journal reproducibility requirements", input.reproducibility, 12, 8_000),
|
|
245
|
+
"Desk-reject triggers": policyHumanValue("journal desk-reject triggers", input.deskRejectTriggers, 12, 8_000),
|
|
246
|
+
"Required reviewer questions": policyHumanValue("journal reviewer questions", input.requiredReviewerQuestions, 12, 8_000),
|
|
247
|
+
"Permitted pivots": policyHumanValue("journal permitted pivots", input.permittedPivots, 12, 8_000),
|
|
248
|
+
};
|
|
249
|
+
await updatePolicyDocument(join(policyUserRoot(root, projectId), "journal.md"), { journalName, officialGuidelinesUrl, officialGuidelinesRetrievedAt }, [
|
|
250
|
+
`# Exact target-journal policy: ${journalName}`,
|
|
251
|
+
"",
|
|
252
|
+
...Object.entries(sections).flatMap(([heading, body]) => [`## ${heading}`, "", body, ""]),
|
|
253
|
+
].join("\n"));
|
|
254
|
+
await appendJournalEvent(workspacePaths(root).journal, "research.policy.exact-journal.completed", projectId, {
|
|
255
|
+
projectId,
|
|
256
|
+
journalName,
|
|
257
|
+
officialGuidelinesHost: new URL(officialGuidelinesUrl).hostname,
|
|
258
|
+
officialGuidelinesRetrievedAt,
|
|
259
|
+
});
|
|
260
|
+
return inspectResearchPolicyStatus(root, projectId);
|
|
261
|
+
}
|
|
262
|
+
export async function inspectResearchPolicyStatus(rootInput, projectId, options = {}) {
|
|
263
|
+
validateProjectId(projectId);
|
|
264
|
+
const root = resolve(rootInput);
|
|
265
|
+
const userRoot = policyUserRoot(root, projectId);
|
|
266
|
+
const manifestPath = policyManifestPath(root, projectId);
|
|
267
|
+
if (!(await pathExists(userRoot)) || !(await pathExists(manifestPath))) {
|
|
268
|
+
return statusResult(root, projectId, "missing", [], [], null, ["Research Policy is missing."]);
|
|
269
|
+
}
|
|
270
|
+
let manifest;
|
|
271
|
+
let documents = [];
|
|
272
|
+
const invalidDocuments = [];
|
|
273
|
+
try {
|
|
274
|
+
manifest = await loadPolicyManifest(root, projectId);
|
|
275
|
+
documents = await loadCurrentPolicyDocuments(root, manifest);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
invalidDocuments.push(error instanceof Error ? error.message : String(error));
|
|
279
|
+
return statusResult(root, projectId, "invalid", [], invalidDocuments, null, [
|
|
280
|
+
"Repair the reported policy file and validate again.",
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
const conflicts = policyConflicts(manifest, documents);
|
|
284
|
+
if (conflicts.length) {
|
|
285
|
+
return statusResult(root, projectId, "conflict", documents, invalidDocuments, null, ["Resolve the conflicting policy identities before approval."], conflicts);
|
|
286
|
+
}
|
|
287
|
+
const approval = await loadPolicyApproval(root, projectId);
|
|
288
|
+
const policySetSha256 = policySetHash(manifest, documents);
|
|
289
|
+
if (!approval) {
|
|
290
|
+
const status = documents.some((document) => document.sourceClass === "human-customized")
|
|
291
|
+
? "custom-draft"
|
|
292
|
+
: "default-unapproved";
|
|
293
|
+
return statusResult(root, projectId, status, documents, invalidDocuments, null, [
|
|
294
|
+
"Review the generic defaults, complete publication-brief.md, customize the field or target-journal requirements where needed, then explicitly approve the exact policy hash.",
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
if (approval.policySetSha256 !== policySetSha256) {
|
|
298
|
+
return statusResult(root, projectId, "changed", documents, invalidDocuments, approval, [
|
|
299
|
+
"Policy content changed after approval. Review the diff and approve the new exact hash before continuing.",
|
|
300
|
+
]);
|
|
301
|
+
}
|
|
302
|
+
if (Date.parse(approval.expiresAt) <= (options.now ?? new Date()).getTime()) {
|
|
303
|
+
return statusResult(root, projectId, "stale", documents, invalidDocuments, approval, [
|
|
304
|
+
"The approved policy review window expired. Verify current field and journal requirements, then approve a refreshed policy.",
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
return statusResult(root, projectId, documents.some((document) => document.sourceClass === "human-customized")
|
|
308
|
+
? "custom-approved"
|
|
309
|
+
: "default-approved", documents, invalidDocuments, approval, approval.targetJournal
|
|
310
|
+
? [
|
|
311
|
+
"The exact target-journal policy is approved; final readiness still requires all bound reviewers to pass the final frozen manuscript.",
|
|
312
|
+
]
|
|
313
|
+
: [
|
|
314
|
+
"No exact target journal is approved. This policy cannot establish target-journal submission readiness.",
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
export async function approveResearchPolicy(rootInput, projectId, options) {
|
|
318
|
+
validateProjectId(projectId);
|
|
319
|
+
if (!options.confirm) {
|
|
320
|
+
throw policyError("RESEARCH_POLICY_UNAPPROVED", "Research Policy approval requires explicit confirmation of the exact current content.", 2);
|
|
321
|
+
}
|
|
322
|
+
const root = resolve(rootInput);
|
|
323
|
+
const manifest = await loadPolicyManifest(root, projectId);
|
|
324
|
+
const documents = await loadCurrentPolicyDocuments(root, manifest, false);
|
|
325
|
+
const conflicts = policyConflicts(manifest, documents);
|
|
326
|
+
if (conflicts.length) {
|
|
327
|
+
throw policyError("RESEARCH_POLICY_CONFLICT", "Research Policy identities conflict with the reviewed selection.", 2, { conflicts });
|
|
328
|
+
}
|
|
329
|
+
const brief = documents.find((document) => document.kind === "publication-brief");
|
|
330
|
+
if (!brief || PLACEHOLDER_PATTERN.test(brief.content)) {
|
|
331
|
+
throw policyError("RESEARCH_PUBLICATION_BRIEF_INCOMPLETE", "The project publication brief is missing or contains unresolved placeholders.", 2);
|
|
332
|
+
}
|
|
333
|
+
for (const field of ["centralQuestion", "centralClaim", "centralOutcome", "contributionType"]) {
|
|
334
|
+
if (!nonPlaceholderString(brief.metadata[field])) {
|
|
335
|
+
throw policyError("RESEARCH_PUBLICATION_BRIEF_INCOMPLETE", `The project publication brief must define ${field}.`, 2);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const defaultDocuments = documents.filter((document) => document.sourceClass === "bundled-default");
|
|
339
|
+
if (defaultDocuments.length && !options.acknowledgeDefaults) {
|
|
340
|
+
throw policyError("RESEARCH_POLICY_UNAPPROVED", "Bundled default policies require explicit acknowledgement that they are generic and not target-journal endorsement.", 2, { defaultDocumentIds: defaultDocuments.map((document) => document.id) });
|
|
341
|
+
}
|
|
342
|
+
const exactJournal = documents.find((document) => document.kind === "exact-journal");
|
|
343
|
+
const targetJournal = normalizeTargetJournal(exactJournal?.metadata.journalName);
|
|
344
|
+
if (exactJournal && !targetJournal) {
|
|
345
|
+
throw policyError("RESEARCH_POLICY_TARGET_MISMATCH", "An exact-journal policy must name the exact target journal.", 2);
|
|
346
|
+
}
|
|
347
|
+
if (exactJournal) {
|
|
348
|
+
const expected = manifest.documents.find((document) => document.kind === "exact-journal");
|
|
349
|
+
if (!expected || sha256Text(exactJournal.body) === expected.templateBodySha256) {
|
|
350
|
+
throw policyError("RESEARCH_POLICY_EXACT_JOURNAL_INCOMPLETE", "The exact-journal policy still contains the generic template body. Review current official guidance and customize its substantive requirements before approval.", 2);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const unresolvedDocuments = documents.filter((document) => PLACEHOLDER_PATTERN.test(document.content));
|
|
354
|
+
if (unresolvedDocuments.length) {
|
|
355
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy documents contain unresolved placeholders.", 2, { documentIds: unresolvedDocuments.map((document) => document.id) });
|
|
356
|
+
}
|
|
357
|
+
const policySetSha256 = policySetHash(manifest, documents);
|
|
358
|
+
const approvedAt = new Date().toISOString();
|
|
359
|
+
const reviewAfterDays = Math.min(...documents.map((document) => document.reviewAfterDays));
|
|
360
|
+
const expiresAt = new Date(Date.parse(approvedAt) + reviewAfterDays * 86_400_000).toISOString();
|
|
361
|
+
const resolvedRules = [...new Set(documents.flatMap((document) => document.rules))].sort();
|
|
362
|
+
const resolvedConstraints = mergePolicyConstraints(documents);
|
|
363
|
+
const requiredReviewers = [
|
|
364
|
+
...new Set(documents.flatMap((document) => document.requiredReviewers)),
|
|
365
|
+
].sort();
|
|
366
|
+
const objectDocuments = [];
|
|
367
|
+
for (const document of documents) {
|
|
368
|
+
const locator = join("policies", "objects", "sha256", `${document.sha256}.md`).replaceAll("\\", "/");
|
|
369
|
+
const objectPath = join(workspacePaths(root).control, locator);
|
|
370
|
+
if (!(await pathExists(objectPath)))
|
|
371
|
+
await writeTextAtomic(objectPath, document.content, 0o444);
|
|
372
|
+
else if (sha256Text(await readFile(objectPath, "utf8")) !== document.sha256) {
|
|
373
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Research Policy object failed its content hash: ${document.id}.`);
|
|
374
|
+
}
|
|
375
|
+
objectDocuments.push({
|
|
376
|
+
id: document.id,
|
|
377
|
+
kind: document.kind,
|
|
378
|
+
logicalPath: document.logicalPath,
|
|
379
|
+
sha256: document.sha256,
|
|
380
|
+
sourceClass: document.sourceClass === "bundled-default" ? "bundled-default" : "human-customized",
|
|
381
|
+
objectLocator: locator,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const customizedKinds = new Set(documents
|
|
385
|
+
.filter((document) => document.sourceClass === "human-customized")
|
|
386
|
+
.map((document) => document.kind));
|
|
387
|
+
const classPolicyCustomized = [
|
|
388
|
+
"article-type",
|
|
389
|
+
"field",
|
|
390
|
+
"journal-class",
|
|
391
|
+
"publication-brief",
|
|
392
|
+
].every((kind) => customizedKinds.has(kind));
|
|
393
|
+
const verdictCeiling = targetJournal && classPolicyCustomized && customizedKinds.has("exact-journal")
|
|
394
|
+
? "target-journal-submission-ready"
|
|
395
|
+
: classPolicyCustomized
|
|
396
|
+
? "top-journal-class-ready"
|
|
397
|
+
: "top-journal-candidate";
|
|
398
|
+
const bindingBase = {
|
|
399
|
+
schemaVersion: 1,
|
|
400
|
+
goal: "top-journal",
|
|
401
|
+
projectId,
|
|
402
|
+
articleType: manifest.selection.articleType,
|
|
403
|
+
field: manifest.selection.field,
|
|
404
|
+
journalClass: manifest.selection.journalClass,
|
|
405
|
+
targetJournal,
|
|
406
|
+
policySetSha256,
|
|
407
|
+
resolvedPolicySha256: sha256Text(canonicalJson({
|
|
408
|
+
policySetSha256,
|
|
409
|
+
resolvedRules,
|
|
410
|
+
resolvedConstraints,
|
|
411
|
+
requiredReviewers,
|
|
412
|
+
articleType: manifest.selection.articleType,
|
|
413
|
+
field: manifest.selection.field,
|
|
414
|
+
journalClass: manifest.selection.journalClass,
|
|
415
|
+
targetJournal,
|
|
416
|
+
})),
|
|
417
|
+
verdictCeiling,
|
|
418
|
+
documents: objectDocuments,
|
|
419
|
+
resolvedRules,
|
|
420
|
+
resolvedConstraints,
|
|
421
|
+
requiredReviewers,
|
|
422
|
+
approvedAt,
|
|
423
|
+
expiresAt,
|
|
424
|
+
};
|
|
425
|
+
const approval = {
|
|
426
|
+
...bindingBase,
|
|
427
|
+
approvalSha256: sha256Text(canonicalJson(bindingBase)),
|
|
428
|
+
};
|
|
429
|
+
await writeJsonAtomic(policyApprovalPath(root, projectId), approval, 0o444);
|
|
430
|
+
await appendJournalEvent(workspacePaths(root).journal, "research.policy.approved", projectId, {
|
|
431
|
+
projectId,
|
|
432
|
+
policySetSha256,
|
|
433
|
+
resolvedPolicySha256: approval.resolvedPolicySha256,
|
|
434
|
+
approvalSha256: approval.approvalSha256,
|
|
435
|
+
defaultDocuments: defaultDocuments.length,
|
|
436
|
+
customDocuments: documents.length - defaultDocuments.length,
|
|
437
|
+
targetJournalDeclared: targetJournal !== null,
|
|
438
|
+
verdictCeiling,
|
|
439
|
+
expiresAt,
|
|
440
|
+
});
|
|
441
|
+
return inspectResearchPolicyStatus(root, projectId);
|
|
442
|
+
}
|
|
443
|
+
export async function loadApprovedResearchPolicy(root, projectId) {
|
|
444
|
+
const status = await inspectResearchPolicyStatus(root, projectId);
|
|
445
|
+
if (status.status === "missing") {
|
|
446
|
+
throw policyError("RESEARCH_POLICY_REQUIRED", `Top-journal project ${projectId} requires an initialized Research Policy.`, 2, {
|
|
447
|
+
minimumAction: `Initialize and review research-policy/${projectId} before project creation.`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if (status.status === "changed") {
|
|
451
|
+
throw policyError("RESEARCH_POLICY_CHANGED", `Research Policy for ${projectId} changed after approval.`, 3, { minimumAction: "Review the changed Markdown and approve its new exact hash." });
|
|
452
|
+
}
|
|
453
|
+
if (status.status === "stale") {
|
|
454
|
+
throw policyError("RESEARCH_POLICY_STALE", `Research Policy for ${projectId} is stale.`, 3, {
|
|
455
|
+
minimumAction: "Verify current field and journal requirements, then reapprove.",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (status.status === "invalid") {
|
|
459
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Research Policy for ${projectId} is invalid.`, 2, { invalidDocuments: status.invalidDocuments });
|
|
460
|
+
}
|
|
461
|
+
if (status.status === "conflict") {
|
|
462
|
+
throw policyError("RESEARCH_POLICY_CONFLICT", `Research Policy for ${projectId} contains conflicting identities.`, 2, { conflicts: status.conflicts });
|
|
463
|
+
}
|
|
464
|
+
if (status.status !== "custom-approved" && status.status !== "default-approved") {
|
|
465
|
+
throw policyError("RESEARCH_POLICY_UNAPPROVED", `Research Policy for ${projectId} has not been approved.`, 2, { minimumAction: "Complete the publication brief and explicitly approve the current hash." });
|
|
466
|
+
}
|
|
467
|
+
const approval = await loadPolicyApproval(resolve(root), projectId);
|
|
468
|
+
if (!approval)
|
|
469
|
+
throw policyError("RESEARCH_POLICY_UNAPPROVED", "Policy approval is missing.");
|
|
470
|
+
const { schemaVersion: _schemaVersion, policySetSha256: _policySetSha256, ...binding } = approval;
|
|
471
|
+
return binding;
|
|
472
|
+
}
|
|
473
|
+
export async function assertResearchPolicyBinding(root, binding) {
|
|
474
|
+
const current = await loadApprovedResearchPolicy(root, binding.projectId);
|
|
475
|
+
if (current.resolvedPolicySha256 !== binding.resolvedPolicySha256 ||
|
|
476
|
+
current.approvalSha256 !== binding.approvalSha256) {
|
|
477
|
+
throw policyError("RESEARCH_POLICY_CHANGED", `Research Policy binding changed for ${binding.projectId}.`, 3);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function policyTemplateRoot(sourceRoot) {
|
|
481
|
+
return join(sourceRoot, "assets", "research-policy", "defaults");
|
|
482
|
+
}
|
|
483
|
+
function policyUserRoot(root, projectId) {
|
|
484
|
+
return join(resolve(root), "research-policy", projectId);
|
|
485
|
+
}
|
|
486
|
+
function policyControlRoot(root, projectId) {
|
|
487
|
+
return join(workspacePaths(root).control, "policies", "projects", projectId);
|
|
488
|
+
}
|
|
489
|
+
function policyManifestPath(root, projectId) {
|
|
490
|
+
return join(policyControlRoot(root, projectId), "template-manifest.json");
|
|
491
|
+
}
|
|
492
|
+
function policyApprovalPath(root, projectId) {
|
|
493
|
+
return join(policyControlRoot(root, projectId), "approval.json");
|
|
494
|
+
}
|
|
495
|
+
async function requirePolicySourceRoot(input) {
|
|
496
|
+
if (resolve(input) !== input) {
|
|
497
|
+
throw policyError("RESEARCH_POLICY_SOURCE_INVALID", "Research Policy source root must be an absolute path.", 2);
|
|
498
|
+
}
|
|
499
|
+
const info = await lstat(input).catch(() => undefined);
|
|
500
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) {
|
|
501
|
+
throw policyError("RESEARCH_POLICY_SOURCE_INVALID", "Research Policy source root must be a regular non-symlink directory.", 2);
|
|
502
|
+
}
|
|
503
|
+
const policyRootInfo = await lstat(policyTemplateRoot(input)).catch(() => undefined);
|
|
504
|
+
if (!policyRootInfo?.isDirectory() || policyRootInfo.isSymbolicLink()) {
|
|
505
|
+
throw policyError("RESEARCH_POLICY_SOURCE_INVALID", "Research Policy source does not contain a regular default policy directory.", 2);
|
|
506
|
+
}
|
|
507
|
+
return input;
|
|
508
|
+
}
|
|
509
|
+
function materializePolicyTemplate(content, selection) {
|
|
510
|
+
return content
|
|
511
|
+
.replaceAll("__SELECT_ARTICLE_TYPE__", selection.articleType)
|
|
512
|
+
.replaceAll("__SELECT_FIELD__", selection.field)
|
|
513
|
+
.replaceAll("__SELECT_JOURNAL_CLASS__", selection.journalClass)
|
|
514
|
+
.replaceAll("__SELECT_EXACT_JOURNAL_OR_NONE__", selection.exactJournal ? "__REPLACE_JOURNAL_NAME__" : "none");
|
|
515
|
+
}
|
|
516
|
+
async function updatePolicyFrontmatter(path, values) {
|
|
517
|
+
await updatePolicyDocument(path, values);
|
|
518
|
+
}
|
|
519
|
+
async function updatePolicyDocument(path, values, body) {
|
|
520
|
+
await parsePolicyFile(path, false);
|
|
521
|
+
const content = await readFile(path, "utf8");
|
|
522
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(content);
|
|
523
|
+
if (!match) {
|
|
524
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy frontmatter is missing: ${basename(path)}`);
|
|
525
|
+
}
|
|
526
|
+
const document = parseDocument(match[1], {
|
|
527
|
+
schema: "core",
|
|
528
|
+
uniqueKeys: true,
|
|
529
|
+
version: "1.2",
|
|
530
|
+
});
|
|
531
|
+
if (document.errors.length || document.warnings.length) {
|
|
532
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy frontmatter is invalid: ${basename(path)}`);
|
|
533
|
+
}
|
|
534
|
+
for (const [key, value] of Object.entries(values))
|
|
535
|
+
document.set(key, value);
|
|
536
|
+
const frontmatter = document.toString({ lineWidth: 0 }).trimEnd();
|
|
537
|
+
const nextBody = body === undefined ? match[2] : `\n${body.trim()}\n`;
|
|
538
|
+
await writeTextAtomic(path, `---\n${frontmatter}\n---\n${nextBody}`, 0o600);
|
|
539
|
+
}
|
|
540
|
+
function policyHumanValue(label, input, minimum, maximum) {
|
|
541
|
+
const value = input.trim();
|
|
542
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
543
|
+
if (bytes < minimum ||
|
|
544
|
+
bytes > maximum ||
|
|
545
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value) ||
|
|
546
|
+
PLACEHOLDER_PATTERN.test(value)) {
|
|
547
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Research Policy ${label} must be explicit, bounded, and free of placeholders.`, 2);
|
|
548
|
+
}
|
|
549
|
+
return value;
|
|
550
|
+
}
|
|
551
|
+
function policyOfficialUrl(input) {
|
|
552
|
+
const value = policyHumanValue("official guidelines URL", input, 10, 2_048);
|
|
553
|
+
let url;
|
|
554
|
+
try {
|
|
555
|
+
url = new URL(value);
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Official guidelines URL must be a valid URL.", 2);
|
|
559
|
+
}
|
|
560
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
561
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Official guidelines URL must use HTTPS and must not contain credentials or a fragment.", 2);
|
|
562
|
+
}
|
|
563
|
+
if ([...url.searchParams.keys()].some((key) => SENSITIVE_POLICY_QUERY_KEY.test(key))) {
|
|
564
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Official guidelines URL must not contain sensitive query parameters.", 2);
|
|
565
|
+
}
|
|
566
|
+
return url.toString();
|
|
567
|
+
}
|
|
568
|
+
function policyIsoDate(input) {
|
|
569
|
+
const value = input.trim();
|
|
570
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
571
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Guidelines retrieval date must be YYYY-MM-DD.", 2);
|
|
572
|
+
}
|
|
573
|
+
const parsed = new Date(`${value}T00:00:00.000Z`);
|
|
574
|
+
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
|
575
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Guidelines retrieval date is invalid.", 2);
|
|
576
|
+
}
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
async function markdownBasenames(directory) {
|
|
580
|
+
const info = await lstat(directory).catch(() => undefined);
|
|
581
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) {
|
|
582
|
+
throw policyError("RESEARCH_POLICY_SOURCE_INVALID", "Policy category directory is invalid.");
|
|
583
|
+
}
|
|
584
|
+
const names = [];
|
|
585
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
586
|
+
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
587
|
+
continue;
|
|
588
|
+
names.push(basename(entry.name, ".md"));
|
|
589
|
+
}
|
|
590
|
+
return names.sort();
|
|
591
|
+
}
|
|
592
|
+
async function parsePolicyFile(path, requireComplete) {
|
|
593
|
+
const info = await lstat(path).catch(() => undefined);
|
|
594
|
+
if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_POLICY_DOCUMENT_BYTES) {
|
|
595
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy document is not a bounded regular file: ${basename(path)}`);
|
|
596
|
+
}
|
|
597
|
+
const content = await readFile(path, "utf8");
|
|
598
|
+
if (/\u0000/.test(content)) {
|
|
599
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy document contains invalid bytes: ${basename(path)}`);
|
|
600
|
+
}
|
|
601
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(content);
|
|
602
|
+
if (!match) {
|
|
603
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy frontmatter is missing: ${basename(path)}`);
|
|
604
|
+
}
|
|
605
|
+
const document = parseDocument(match[1], {
|
|
606
|
+
schema: "core",
|
|
607
|
+
uniqueKeys: true,
|
|
608
|
+
version: "1.2",
|
|
609
|
+
});
|
|
610
|
+
if (document.errors.length || document.warnings.length) {
|
|
611
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy frontmatter is invalid: ${basename(path)}`, 2, {
|
|
612
|
+
diagnostics: [...document.errors, ...document.warnings].map((item) => item.code),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
let metadata;
|
|
616
|
+
try {
|
|
617
|
+
metadata = document.toJS({ maxAliasCount: 0 });
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy aliases are not allowed: ${basename(path)}`);
|
|
621
|
+
}
|
|
622
|
+
if (!isObject(metadata)) {
|
|
623
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy frontmatter must be a mapping: ${basename(path)}`);
|
|
624
|
+
}
|
|
625
|
+
const id = metadata.id;
|
|
626
|
+
const kind = metadata.kind;
|
|
627
|
+
const templateClass = metadata.templateClass;
|
|
628
|
+
if (metadata.schemaVersion !== 1 ||
|
|
629
|
+
typeof id !== "string" ||
|
|
630
|
+
!POLICY_ID_PATTERN.test(id) ||
|
|
631
|
+
typeof kind !== "string" ||
|
|
632
|
+
!POLICY_KINDS.has(kind) ||
|
|
633
|
+
!["bundled-default", "exact-journal-template", "project-template"].includes(String(templateClass)) ||
|
|
634
|
+
metadata.policyVersion !== 1 ||
|
|
635
|
+
metadata.targetTier !== "top") {
|
|
636
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy identity is invalid: ${basename(path)}`);
|
|
637
|
+
}
|
|
638
|
+
const rules = stringArray(metadata.rules, "rules", basename(path));
|
|
639
|
+
const constraints = policyConstraints(metadata.constraints, basename(path));
|
|
640
|
+
const requiredReviewers = stringArray(metadata.requiredReviewers ?? [], "requiredReviewers", basename(path));
|
|
641
|
+
for (const rule of rules) {
|
|
642
|
+
if (!RULE_ID_PATTERN.test(rule) || !KNOWN_RULES.has(rule)) {
|
|
643
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy contains an unknown rule ID: ${rule}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
for (const reviewer of requiredReviewers) {
|
|
647
|
+
if (!KNOWN_REVIEWERS.has(reviewer)) {
|
|
648
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy contains an unknown reviewer role: ${reviewer}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const reviewAfterDays = metadata.reviewAfterDays;
|
|
652
|
+
if (typeof reviewAfterDays !== "number" ||
|
|
653
|
+
!Number.isInteger(reviewAfterDays) ||
|
|
654
|
+
reviewAfterDays < 1 ||
|
|
655
|
+
reviewAfterDays > 3650) {
|
|
656
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy reviewAfterDays is invalid: ${basename(path)}`);
|
|
657
|
+
}
|
|
658
|
+
const body = match[2].trim();
|
|
659
|
+
if (!body.startsWith("# ") || body.length < 20) {
|
|
660
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy body is incomplete: ${basename(path)}`);
|
|
661
|
+
}
|
|
662
|
+
if (requireComplete && PLACEHOLDER_PATTERN.test(content)) {
|
|
663
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy contains unresolved placeholders: ${id}`);
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
id,
|
|
667
|
+
kind,
|
|
668
|
+
templateClass: templateClass,
|
|
669
|
+
metadata,
|
|
670
|
+
body,
|
|
671
|
+
content,
|
|
672
|
+
sha256: sha256Text(content),
|
|
673
|
+
rules,
|
|
674
|
+
constraints,
|
|
675
|
+
requiredReviewers,
|
|
676
|
+
reviewAfterDays,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function stringArray(value, label, file) {
|
|
680
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
681
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy ${label} must be a string array: ${file}`);
|
|
682
|
+
}
|
|
683
|
+
if (new Set(value).size !== value.length) {
|
|
684
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy ${label} contains duplicates: ${file}`);
|
|
685
|
+
}
|
|
686
|
+
return [...value].sort();
|
|
687
|
+
}
|
|
688
|
+
async function loadPolicyManifest(root, projectId) {
|
|
689
|
+
const manifest = await readJsonFile(policyManifestPath(root, projectId), "Research Policy template manifest");
|
|
690
|
+
if (manifest.schemaVersion !== 1 ||
|
|
691
|
+
manifest.projectId !== projectId ||
|
|
692
|
+
!Array.isArray(manifest.documents) ||
|
|
693
|
+
manifest.documents.some((document) => !isObject(document) ||
|
|
694
|
+
typeof document.logicalPath !== "string" ||
|
|
695
|
+
typeof document.id !== "string" ||
|
|
696
|
+
typeof document.kind !== "string" ||
|
|
697
|
+
typeof document.templateSha256 !== "string" ||
|
|
698
|
+
!/^[a-f0-9]{64}$/.test(document.templateSha256) ||
|
|
699
|
+
typeof document.templateBodySha256 !== "string" ||
|
|
700
|
+
!/^[a-f0-9]{64}$/.test(document.templateBodySha256)) ||
|
|
701
|
+
!isObject(manifest.selection) ||
|
|
702
|
+
typeof manifest.sourceTreeSha256 !== "string" ||
|
|
703
|
+
typeof manifest.manifestSha256 !== "string" ||
|
|
704
|
+
!/^[a-f0-9]{64}$/.test(manifest.manifestSha256)) {
|
|
705
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy template manifest is invalid.");
|
|
706
|
+
}
|
|
707
|
+
const { manifestSha256, ...manifestCore } = manifest;
|
|
708
|
+
if (sha256Text(canonicalJson(manifestCore)) !== manifestSha256) {
|
|
709
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy template manifest failed its content hash.");
|
|
710
|
+
}
|
|
711
|
+
return manifest;
|
|
712
|
+
}
|
|
713
|
+
async function loadCurrentPolicyDocuments(root, manifest, requireComplete = false) {
|
|
714
|
+
let totalBytes = 0;
|
|
715
|
+
const ids = new Set();
|
|
716
|
+
const documents = [];
|
|
717
|
+
for (const expected of manifest.documents) {
|
|
718
|
+
const parsed = await parsePolicyFile(join(policyUserRoot(root, manifest.projectId), expected.logicalPath), requireComplete);
|
|
719
|
+
totalBytes += Buffer.byteLength(parsed.content, "utf8");
|
|
720
|
+
if (totalBytes > MAX_POLICY_STACK_BYTES) {
|
|
721
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy stack exceeds its byte limit.");
|
|
722
|
+
}
|
|
723
|
+
if (parsed.id !== expected.id || parsed.kind !== expected.kind || ids.has(parsed.id)) {
|
|
724
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy identity changed or is duplicated: ${expected.id}`);
|
|
725
|
+
}
|
|
726
|
+
ids.add(parsed.id);
|
|
727
|
+
documents.push({
|
|
728
|
+
...parsed,
|
|
729
|
+
logicalPath: expected.logicalPath,
|
|
730
|
+
sourceClass: parsed.sha256 === expected.templateSha256 ? "bundled-default" : "human-customized",
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return documents.sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
|
|
734
|
+
}
|
|
735
|
+
function policySetHash(manifest, documents) {
|
|
736
|
+
return sha256Text(canonicalJson({
|
|
737
|
+
projectId: manifest.projectId,
|
|
738
|
+
selection: manifest.selection,
|
|
739
|
+
documents: documents.map((document) => ({
|
|
740
|
+
id: document.id,
|
|
741
|
+
kind: document.kind,
|
|
742
|
+
logicalPath: document.logicalPath,
|
|
743
|
+
sha256: document.sha256,
|
|
744
|
+
})),
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
async function loadPolicyApproval(root, projectId) {
|
|
748
|
+
const path = policyApprovalPath(root, projectId);
|
|
749
|
+
if (!(await pathExists(path)))
|
|
750
|
+
return null;
|
|
751
|
+
const approval = await readJsonFile(path, "Research Policy approval");
|
|
752
|
+
if (approval.schemaVersion !== 1 ||
|
|
753
|
+
approval.goal !== "top-journal" ||
|
|
754
|
+
approval.projectId !== projectId ||
|
|
755
|
+
!/^[a-f0-9]{64}$/.test(approval.policySetSha256) ||
|
|
756
|
+
!/^[a-f0-9]{64}$/.test(approval.resolvedPolicySha256) ||
|
|
757
|
+
!/^[a-f0-9]{64}$/.test(approval.approvalSha256) ||
|
|
758
|
+
!Array.isArray(approval.documents) ||
|
|
759
|
+
!isObject(approval.resolvedConstraints)) {
|
|
760
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy approval is invalid.");
|
|
761
|
+
}
|
|
762
|
+
const { approvalSha256, ...base } = approval;
|
|
763
|
+
if (sha256Text(canonicalJson(base)) !== approvalSha256) {
|
|
764
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy approval hash is invalid.");
|
|
765
|
+
}
|
|
766
|
+
return approval;
|
|
767
|
+
}
|
|
768
|
+
function statusResult(root, projectId, status, documents, invalidDocuments, approval, guidanceParts, conflicts = []) {
|
|
769
|
+
const defaultDocuments = documents.filter((document) => document.sourceClass === "bundled-default").length;
|
|
770
|
+
return {
|
|
771
|
+
schemaVersion: 1,
|
|
772
|
+
projectId,
|
|
773
|
+
status,
|
|
774
|
+
policyDirectory: policyUserRoot(root, projectId),
|
|
775
|
+
defaultDocuments,
|
|
776
|
+
customDocuments: documents.length - defaultDocuments,
|
|
777
|
+
invalidDocuments,
|
|
778
|
+
conflicts,
|
|
779
|
+
approvedAt: approval?.approvedAt ?? null,
|
|
780
|
+
expiresAt: approval?.expiresAt ?? null,
|
|
781
|
+
resolvedPolicySha256: approval?.resolvedPolicySha256 ?? null,
|
|
782
|
+
verdictCeiling: approval?.verdictCeiling ?? null,
|
|
783
|
+
targetJournal: approval?.targetJournal ?? null,
|
|
784
|
+
guidance: guidanceParts.join(" "),
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
function policyConstraints(value, file) {
|
|
788
|
+
if (value === undefined)
|
|
789
|
+
return {};
|
|
790
|
+
if (!isObject(value)) {
|
|
791
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy constraints must be a mapping: ${file}`);
|
|
792
|
+
}
|
|
793
|
+
const result = {};
|
|
794
|
+
for (const [key, constraint] of Object.entries(value)) {
|
|
795
|
+
const kind = KNOWN_CONSTRAINTS.get(key);
|
|
796
|
+
if (!kind) {
|
|
797
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy contains an unknown constraint: ${key}`);
|
|
798
|
+
}
|
|
799
|
+
if ((kind === "boolean" && typeof constraint !== "boolean") ||
|
|
800
|
+
(kind === "integer" &&
|
|
801
|
+
(typeof constraint !== "number" ||
|
|
802
|
+
!Number.isInteger(constraint) ||
|
|
803
|
+
constraint < 0 ||
|
|
804
|
+
constraint > 10_000))) {
|
|
805
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Policy constraint is invalid: ${key}`);
|
|
806
|
+
}
|
|
807
|
+
result[key] = constraint;
|
|
808
|
+
}
|
|
809
|
+
return result;
|
|
810
|
+
}
|
|
811
|
+
function mergePolicyConstraints(documents) {
|
|
812
|
+
const resolved = {};
|
|
813
|
+
for (const document of documents) {
|
|
814
|
+
for (const [key, value] of Object.entries(document.constraints)) {
|
|
815
|
+
const current = resolved[key];
|
|
816
|
+
resolved[key] =
|
|
817
|
+
typeof value === "number"
|
|
818
|
+
? Math.max(typeof current === "number" ? current : 0, value)
|
|
819
|
+
: Boolean(current) || value;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return Object.fromEntries(Object.entries(resolved).sort(([left], [right]) => left.localeCompare(right)));
|
|
823
|
+
}
|
|
824
|
+
function policyConflicts(manifest, documents) {
|
|
825
|
+
const conflicts = [];
|
|
826
|
+
const expectedIdentity = [
|
|
827
|
+
["article-type", "articleType", manifest.selection.articleType],
|
|
828
|
+
["field", "field", manifest.selection.field],
|
|
829
|
+
["journal-class", "journalClass", manifest.selection.journalClass],
|
|
830
|
+
];
|
|
831
|
+
for (const [kind, key, expected] of expectedIdentity) {
|
|
832
|
+
const document = documents.find((candidate) => candidate.kind === kind);
|
|
833
|
+
const actual = document?.metadata[key];
|
|
834
|
+
if (!document || typeof actual !== "string" || actual !== expected) {
|
|
835
|
+
conflicts.push(`${kind}.${key} must equal ${expected}.`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const brief = documents.find((document) => document.kind === "publication-brief");
|
|
839
|
+
for (const [key, expected] of [
|
|
840
|
+
["articleType", manifest.selection.articleType],
|
|
841
|
+
["field", manifest.selection.field],
|
|
842
|
+
["journalClass", manifest.selection.journalClass],
|
|
843
|
+
]) {
|
|
844
|
+
const actual = brief?.metadata[key];
|
|
845
|
+
if (typeof actual === "string" && PLACEHOLDER_PATTERN.test(actual))
|
|
846
|
+
continue;
|
|
847
|
+
if (!brief || actual !== expected)
|
|
848
|
+
conflicts.push(`publication-brief.${key} must equal ${expected}.`);
|
|
849
|
+
}
|
|
850
|
+
const exactJournal = documents.find((document) => document.kind === "exact-journal");
|
|
851
|
+
const exactTarget = normalizeTargetJournal(exactJournal?.metadata.journalName);
|
|
852
|
+
const briefTarget = normalizeTargetJournal(brief?.metadata.targetJournal);
|
|
853
|
+
if (exactJournal && exactTarget && briefTarget && exactTarget !== briefTarget) {
|
|
854
|
+
conflicts.push("publication-brief.targetJournal must match exact-journal.journalName.");
|
|
855
|
+
}
|
|
856
|
+
return conflicts.sort();
|
|
857
|
+
}
|
|
858
|
+
function normalizeTargetJournal(value) {
|
|
859
|
+
if (typeof value !== "string")
|
|
860
|
+
return null;
|
|
861
|
+
const normalized = value.trim();
|
|
862
|
+
if (!normalized || normalized.toLowerCase() === "none")
|
|
863
|
+
return null;
|
|
864
|
+
if (PLACEHOLDER_PATTERN.test(normalized) || normalized.length > 200)
|
|
865
|
+
return null;
|
|
866
|
+
return normalized;
|
|
867
|
+
}
|
|
868
|
+
function nonPlaceholderString(value) {
|
|
869
|
+
return typeof value === "string" && value.trim().length >= 3 && !PLACEHOLDER_PATTERN.test(value);
|
|
870
|
+
}
|
|
871
|
+
function requireCatalogSelection(label, selected, values) {
|
|
872
|
+
if (!values.includes(selected)) {
|
|
873
|
+
throw policyError("RESEARCH_POLICY_INVALID", `Unsupported Research Policy ${label}: ${selected}`, 2, {
|
|
874
|
+
supported: values,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function validateProjectId(projectId) {
|
|
879
|
+
if (!PROJECT_ID_PATTERN.test(projectId)) {
|
|
880
|
+
throw policyError("RESEARCH_POLICY_INVALID", "Research Policy project ID is invalid.", 2);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function policyError(code, message, exitCode = 2, details) {
|
|
884
|
+
return new CliError(message, { code, exitCode, ...(details ? { details } : {}) });
|
|
885
|
+
}
|
|
886
|
+
//# sourceMappingURL=research-policy.js.map
|