skill-family-doc-render 0.4.5 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # 变更日志
2
2
 
3
+ ## 0.5.0 - 2026-09-28
4
+
5
+ ### 新增
6
+
7
+ - `--check-project` 可以附带 `--conclusion-output <绝对路径>`。机械检查结束后,CLI 排他创建一份共同证明,不覆盖已有文件,也不改站点。
8
+ - 新增 `--read-proof --proof-root <root> --proof <相对路径>`。它读取一份本族旧证明并打印 JSON,不重新扫描目标,也不要求网站配置。该模式与 `--check-project` 互斥。
9
+
10
+ ### 变更
11
+
12
+ - 渲染器、文档插件和公开站点的本地源版本对齐为 0.5.0,作为计划中的联合发布目标。安装或 `npx` 前,先在官方 npm 确认 `skill-family-doc-render@0.5.0` 可用。
13
+ - 已发布的 0.4.5 没有 `--conclusion-output` 和 `--read-proof`。本版出证和读证示例使用精确的 `npx skill-family-doc-render@0.5.0`。
14
+
15
+ ### 说明
16
+
17
+ - 当前源码的出证和读证已在本地候选上验证。可引用的记录仍是当时的直接 CLI 与四场 Codex 代表场景,覆盖限制保持原记录。负向场景只说明当时如实停止。
18
+ - 旧证明只记录原目标当时的结论。机械检查通过只说明机械范围,链接通过不代替中文语义审阅。记载未通过、未完成或未能开展检查的证明,结论保持为未通过。
19
+ - 0.5.0 的发布、Hub 登记和四个宿主的新回复验收,以各自的官方记录为准。历史五类宿主的入口调用仍限于当时范围。
20
+
3
21
  ## 0.4.5 - 2026-09-10
4
22
 
5
23
  ### 变更
package/README.md CHANGED
@@ -16,64 +16,99 @@ publication APIs. A failure before commit removes staging and leaves the existin
16
16
  untouched. After commit, or when publication is indeterminate, staging or displaced data may
17
17
  remain for diagnosis; the renderer does not guess or roll back.
18
18
 
19
- > **Version boundary:** this source tree is version `0.4.5`. Verify that exact version in the
20
- > official npm registry before treating it as installable. Version 0.4.5 preserves the
21
- > `markdown-v1`, `editorial`, and `--check-project` behavior introduced in 0.4.0 and aligns the
22
- > renderer, documentation plugin, and public site at 0.4.5 for the joint release.
19
+ > **Version boundary:** this source tree is version `0.5.0`. That is the local version and the
20
+ > intended joint target for the renderer, documentation plugin, and public site. Confirm this
21
+ > exact version in the official npm registry before `npm install` or `npx`. Published `0.4.5`
22
+ > preserves the `markdown-v1`, `editorial`, and `--check-project` behavior introduced in 0.4.0
23
+ > and has no `--conclusion-output` or `--read-proof`. Those two commands belong to this 0.5.0
24
+ > CLI. The examples give the command shape for that version. They leave publication, Skill
25
+ > Family Hub registration, and new host-reply acceptance to the official records for `0.5.0`.
23
26
 
24
27
  ## Install
25
28
 
26
- After confirming availability, install this release with an exact version:
29
+ After confirming `skill-family-doc-render@0.5.0` in the official npm registry, install that exact version:
27
30
 
28
31
  ```sh
29
- npm install --save-exact skill-family-doc-render@0.4.5
32
+ npm install --save-exact skill-family-doc-render@0.5.0
30
33
  ```
31
34
 
32
35
  Requires Node.js `>=22.22.2 <23` (aligned with the skill-family foundation packages).
33
36
 
34
- Version 0.4.5 has exact runtime dependencies on `marked@18.0.11`,
35
- `skill-family-contracts@0.19.3`, and `skill-family-harness-node@0.19.3`. `marked` supplies the
37
+ This checkout currently pins exact runtime dependencies on `marked@18.0.11`,
38
+ `skill-family-contracts@0.22.0`, and `skill-family-harness-node@0.22.0`. `marked` supplies the
36
39
  Markdown lexer; the renderer applies its own restricted-format validation before passing semantic
37
40
  content to the template. The workspace profile check separately uses
38
- `skill-family-engineering-kit@0.19.3`.
41
+ `skill-family-engineering-kit@0.22.0`. These pins describe the local source and installed
42
+ packages; they do not republish `0.5.0`.
39
43
  The 0.2.0 baseline format and exit-code changes are breaking changes; existing 0.1.x users
40
44
  must re-render their baseline before using `--check`. See the changelog for the full migration
41
45
  notes.
42
46
 
43
47
  ## CLI
44
48
 
45
- Use the exact 0.4.5 CLI for rendering, checks, coverage maintenance, and the Git index assertion:
49
+ Use the exact 0.5.0 CLI for rendering, checks, coverage maintenance, the Git index assertion, and the proof commands. Confirm `skill-family-doc-render@0.5.0` in the official npm registry before running them. Published 0.4.5 can run the commands that omit `--conclusion-output` and `--read-proof`.
46
50
 
47
51
  ```sh
48
52
  # render all repos with a site field (writes to disk)
49
- npx skill-family-doc-render@0.4.5
53
+ npx skill-family-doc-render@0.5.0
50
54
 
51
55
  # drift check only: compare in-memory render against the on-disk site-baseline.json, write nothing
52
- npx skill-family-doc-render@0.4.5 --check
56
+ npx skill-family-doc-render@0.5.0 --check
53
57
 
54
58
  # render / check a single repo by name
55
- npx skill-family-doc-render@0.4.5 --repo <name>
59
+ npx skill-family-doc-render@0.5.0 --repo <name>
56
60
 
57
61
  # assert every rendered file is tracked in the Git index, then render
58
- npx skill-family-doc-render@0.4.5 --assert-git
62
+ npx skill-family-doc-render@0.5.0 --assert-git
59
63
 
60
64
  # run both the drift and Git index tracking checks without writing
61
- npx skill-family-doc-render@0.4.5 --check --assert-git
65
+ npx skill-family-doc-render@0.5.0 --check --assert-git
62
66
 
63
67
  # inspect or refresh the reviewed product-input snapshot
64
- npx skill-family-doc-render@0.4.5 --status --repo <name>
65
- npx skill-family-doc-render@0.4.5 --refresh-coverage --repo <name>
66
- npx skill-family-doc-render@0.4.5 --check-project --repo <name>
68
+ npx skill-family-doc-render@0.5.0 --status --repo <name>
69
+ npx skill-family-doc-render@0.5.0 --refresh-coverage --repo <name>
70
+ npx skill-family-doc-render@0.5.0 --check-project --repo <name>
71
+
72
+ # after the mechanical check, exclusive-create one proof; do not overwrite
73
+ npx skill-family-doc-render@0.5.0 --check-project --repo <name> --conclusion-output /absolute/path/conclusion.json
74
+
75
+ # read one existing proof from any working directory; write nothing
76
+ npx skill-family-doc-render@0.5.0 --read-proof --proof-root /absolute/proof-root --proof relative/conclusion.json
67
77
  ```
68
78
 
69
- The current directory must be the workspace that owns `public-release.json`. From this package's
70
- source checkout, `node bin/skill-family-doc-render.mjs` is the equivalent local CLI entry.
79
+ `--check-project` still requires the current directory to own `public-release.json`.
80
+ `--read-proof` does not read that file and does not require a site configuration.
71
81
 
72
82
  `--check-project` requires exactly one `--repo <name>`. It cannot be combined with `--check`,
73
- `--status`, `--refresh-coverage`, `--assert-git`, or `--help`. It checks input and public-content
74
- safety, the mechanical Markdown contract, coverage freshness, the in-memory render and baseline,
75
- then internal links and resources. It writes nothing. A failure identifies the repo,
76
- `public-release.json`, the reason, and a copyable `skill-family-docs-render-site` recovery prompt.
83
+ `--status`, `--refresh-coverage`, `--assert-git`, `--help`, or `--read-proof`. It checks input and
84
+ public-content safety, the mechanical Markdown contract, coverage freshness, the in-memory render
85
+ and baseline, then internal links and resources. It does not re-render to make the check pass, and
86
+ it does not run target scripts or hooks. Without `--conclusion-output` it writes nothing. A failure
87
+ identifies the repo, `public-release.json`, the reason, and a copyable
88
+ `skill-family-docs-render-site` recovery prompt.
89
+
90
+ `--conclusion-output` is optional and only valid with `--check-project`. The path must be
91
+ absolute. When present, the renderer exclusive-writes one Foundation
92
+ `professional-conclusion` file after the mechanical check. It does not replace an existing file.
93
+ The conclusion records only checks that actually ran. Domain codes are `DOCS_CLEAR`,
94
+ `DOCS_FINDINGS`, `DOCS_CHECK_INCOMPLETE`, and `DOCS_CHECK_UNAVAILABLE`. The renderer conclusion is
95
+ mechanical only; a passing link check is not a Chinese semantic review. A stored proof records
96
+ the original target's conclusion at that time.
97
+
98
+ `--read-proof` is mutually exclusive with `--check-project`. It reads one proof from
99
+ `--proof-root` + `--proof`, validates the common structure, and prints a JSON result with
100
+ `status` (`pass`, `not_pass`, or `unavailable`), `reason`, `provider`, and `conclusion`.
101
+ It does not rescan the project, follow paths inside the proof, or execute the target.
102
+ Same-version `DOCS_CLEAR` maps to `pass` only when `completion` is `complete`;
103
+ `DOCS_FINDINGS`, `DOCS_CHECK_INCOMPLETE`, and `DOCS_CHECK_UNAVAILABLE` map to `not_pass`.
104
+ That status keeps the proof's recorded non-pass. A missing file, damaged JSON, a different
105
+ provider, or an unknown same-version domain code is `unavailable` (exit `2`). Earlier
106
+ local-candidate runs exercised proof output and proof reading; those runs keep their original
107
+ scene limits. Publication, Hub registration, and new host replies for `0.5.0` stay with the
108
+ official records. These proof commands consume the Foundation
109
+ `professional-conclusion` contract from `skill-family-contracts@0.22.0`. Refreshing a proof
110
+ means running `--check-project` again to a new output path. Remediation, site creation, and
111
+ render writes stay on their original authorized commands.
77
112
 
78
113
  Exit codes: `0` success; `1` drift/status class — `--check` drift, leak-scan hit, `--assert-git`
79
114
  untracked rendered files, missing/corrupt render baseline, or missing/stale coverage snapshot; `2`
@@ -283,9 +318,11 @@ Foundation packages below, it uses exact `marked@18.0.11` for Markdown tokenizat
283
318
  Apache-2.0
284
319
 
285
320
  <!-- release-skill:capability:safe-first-command -->
286
- > **Start here:** run `npx skill-family-doc-render@0.4.5 --check-project --repo <name>` after
287
- > confirming that exact release in the official registry. From a local checkout, use
288
- > `node bin/skill-family-doc-render.mjs --check-project --repo <name>`. The check is read-only:
321
+ > **Start here:** run `npx skill-family-doc-render@0.5.0 --check-project --repo <name>` after
322
+ > confirming that exact version in the official registry. From a local checkout, stay in the
323
+ > directory that owns `public-release.json` and call
324
+ > `node /absolute/path/to/skill-family-doc-render/bin/skill-family-doc-render.mjs --check-project --repo <name>`,
325
+ > replacing the placeholder with the real source path. The check is read-only:
289
326
  > it renders in memory, compares against the on-disk `site-baseline.json`, writes
290
327
  > nothing and touches no network or credentials. The CLI performs no Git writes; only an
291
328
  > explicit `--assert-git` performs a read-only query of the Git index.
@@ -294,7 +331,9 @@ Apache-2.0
294
331
  > **External write boundary:** rendering writes the declared target and a controlled sibling
295
332
  > staging directory, both within the target's canonical parent, with contained path checks. An
296
333
  > explicit `--refresh-coverage` writes only the declared coverage lock through an atomic file
297
- > replacement. `--status`, `--check`, and `--check-project` write nothing.
334
+ > replacement. `--status`, `--check`, and `--check-project` write nothing unless
335
+ > `--check-project` is given an explicit `--conclusion-output`, which exclusive-creates that
336
+ > proof file and never overwrites it. `--read-proof` writes nothing.
298
337
  > Render, scan, baseline and `--assert-git` preflight failures happen before target changes.
299
338
  > Staging is removed after success or a clearly pre-commit failure; post-commit or
300
339
  > indeterminate failures may retain it to preserve diagnosable state.
@@ -307,6 +346,6 @@ Apache-2.0
307
346
  Display help and run a read-only project check:
308
347
 
309
348
  ```sh
310
- npx skill-family-doc-render@0.4.5 --help
311
- npx skill-family-doc-render@0.4.5 --check-project --repo <name>
349
+ npx skill-family-doc-render@0.5.0 --help
350
+ npx skill-family-doc-render@0.5.0 --check-project --repo <name>
312
351
  ```
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- // CLI 入口:全部 argv 原样委托给 main(),闭集解析与 --help/-h 呈现统一由 main() 裁决。
3
+ // CLI 入口:全部 argv 原样委托给 main()。闭集解析、--help/-h、
4
+ // --check-project / --read-proof 互斥路由均由 main() 裁决。
4
5
  import { main, RenderError } from '../scripts/render-public-site.mjs';
5
6
 
6
7
  main(process.argv.slice(2), { cwd: process.cwd() })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-family-doc-render",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Config-driven GitHub Pages knowledge-site renderer for skill-family projects (multi-repo capable).",
5
5
  "author": "广州市风荷科技有限公司",
6
6
  "license": "Apache-2.0",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "marked": "18.0.11",
30
- "skill-family-contracts": "0.19.3",
31
- "skill-family-harness-node": "0.19.3"
30
+ "skill-family-contracts": "0.22.0",
31
+ "skill-family-harness-node": "0.22.0"
32
32
  }
33
33
  }
@@ -0,0 +1,360 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // 文档族共同证明:把机械 --check-project 结果投影为 professional-conclusion,
3
+ // 并读取本族证明。结构校验与文件读写使用 Foundation 公共入口;本模块只保留
4
+ // DOCS 领域码、实际检查范围和原因转换。
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
7
+ import { findSchemaByObject, validateDocument } from 'skill-family-contracts';
8
+ import {
9
+ HARNESS_ERROR_KINDS,
10
+ publishFileExclusive,
11
+ readFileStrict,
12
+ resolveContained,
13
+ } from 'skill-family-harness-node';
14
+ import { RenderedLinksError } from './lib/rendered-links.mjs';
15
+
16
+ export const PROVIDER_ID = 'skill-family-doc-render';
17
+
18
+ export const DOCS_CODES = Object.freeze({
19
+ CLEAR: 'DOCS_CLEAR',
20
+ FINDINGS: 'DOCS_FINDINGS',
21
+ INCOMPLETE: 'DOCS_CHECK_INCOMPLETE',
22
+ UNAVAILABLE: 'DOCS_CHECK_UNAVAILABLE',
23
+ });
24
+
25
+ export const CHECK_STAGE_IDS = Object.freeze([
26
+ 'input-and-public-safety',
27
+ 'coverage-freshness',
28
+ 'in-memory-render-and-baseline',
29
+ 'internal-links-and-resources',
30
+ ]);
31
+
32
+ const MECHANICAL_LIMITATION = '本证明只覆盖渲染器机械检查,不含中文语义审阅。';
33
+
34
+ const PROVIDER_VERSION = JSON.parse(
35
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
36
+ ).version;
37
+
38
+ export class ProofError extends Error {
39
+ constructor(message, exitCode = 2, cause) {
40
+ super(message, cause === undefined ? undefined : { cause });
41
+ this.exitCode = exitCode;
42
+ }
43
+ }
44
+
45
+ export function providerInfo(entry) {
46
+ return { id: PROVIDER_ID, version: PROVIDER_VERSION, entry };
47
+ }
48
+
49
+ function professionalSchemaRegistration() {
50
+ return findSchemaByObject('professional-conclusion');
51
+ }
52
+
53
+ export function requireProfessionalSchemaId() {
54
+ const registration = professionalSchemaRegistration();
55
+ if (!registration?.$id || !registration.dialect) {
56
+ throw new ProofError(
57
+ '[skill-family-doc-render] 缺少 Foundation professional-conclusion Schema,需要精确安装 skill-family-contracts@0.22.0',
58
+ 2,
59
+ );
60
+ }
61
+ return registration;
62
+ }
63
+
64
+ export function validateProfessionalConclusion(document) {
65
+ const registration = requireProfessionalSchemaId();
66
+ return validateDocument(document, {
67
+ schemaId: registration.$id,
68
+ dialect: registration.dialect,
69
+ policy: 'strict',
70
+ });
71
+ }
72
+
73
+ function uniqueLimitations(items) {
74
+ const seen = new Set();
75
+ const result = [];
76
+ for (const item of items) {
77
+ if (typeof item !== 'string' || !item || seen.has(item)) continue;
78
+ seen.add(item);
79
+ result.push(item);
80
+ }
81
+ return result;
82
+ }
83
+
84
+ export function buildDocsConclusion({
85
+ entry,
86
+ subjectRef,
87
+ subjectRevision,
88
+ checked,
89
+ limitations,
90
+ completion,
91
+ code,
92
+ summary,
93
+ details,
94
+ }) {
95
+ const conclusion = {
96
+ schemaVersion: 1,
97
+ kind: 'skill-family.professional-conclusion',
98
+ provider: providerInfo(entry),
99
+ subject: subjectRevision
100
+ ? { ref: subjectRef, revision: subjectRevision }
101
+ : { ref: subjectRef },
102
+ scope: {
103
+ checked: [...checked],
104
+ limitations: uniqueLimitations([MECHANICAL_LIMITATION, ...limitations]),
105
+ },
106
+ outcome: { completion, code, summary },
107
+ };
108
+ if (details !== undefined) conclusion.details = details;
109
+ const validation = validateProfessionalConclusion(conclusion);
110
+ if (!validation.valid) {
111
+ const detail = (validation.errors || []).map((error) => error.message).join('; ');
112
+ throw new ProofError(
113
+ `[skill-family-doc-render] 共同证明结构校验失败: ${validation.errorCode || 'invalid'}${detail ? `: ${detail}` : ''}`,
114
+ 2,
115
+ );
116
+ }
117
+ return validation.data;
118
+ }
119
+
120
+ function harnessKind(error) {
121
+ return error?.details?.kind;
122
+ }
123
+
124
+ function errorCauseChain(error) {
125
+ const seen = new Set();
126
+ const chain = [];
127
+ let current = error;
128
+ while (current && typeof current === 'object' && !seen.has(current)) {
129
+ seen.add(current);
130
+ chain.push(current);
131
+ current = current.cause;
132
+ }
133
+ return chain;
134
+ }
135
+
136
+ function hasRenderedLinkFailures(error) {
137
+ return errorCauseChain(error).some(
138
+ (item) => item instanceof RenderedLinksError && Array.isArray(item.failures) && item.failures.length > 0,
139
+ );
140
+ }
141
+
142
+ function hasMissingResourceKind(error) {
143
+ return errorCauseChain(error).some(
144
+ (item) => harnessKind(item) === HARNESS_ERROR_KINDS.MISSING_RESOURCE,
145
+ );
146
+ }
147
+
148
+ function isDomainFinding(exitCode, cause) {
149
+ if (exitCode !== 1) return false;
150
+ if (hasRenderedLinkFailures(cause)) return true;
151
+ return !hasMissingResourceKind(cause);
152
+ }
153
+
154
+ export function mapScanToOutcome({
155
+ ok,
156
+ exitCode,
157
+ finished = [],
158
+ attempted = null,
159
+ notReached = [],
160
+ repoSelected = true,
161
+ cause = null,
162
+ }) {
163
+ if (ok) {
164
+ return {
165
+ completion: 'complete',
166
+ code: DOCS_CODES.CLEAR,
167
+ summary: '机械项目检查完成,未发现覆盖、渲染、链接或公开安全问题。',
168
+ checked: [...CHECK_STAGE_IDS],
169
+ limitations: [],
170
+ };
171
+ }
172
+ const started = Boolean(repoSelected && (finished.length || attempted));
173
+ if (!started) {
174
+ const remaining = attempted ? [attempted, ...notReached] : [...notReached];
175
+ return {
176
+ completion: 'not-performed',
177
+ code: DOCS_CODES.UNAVAILABLE,
178
+ summary: '未能开展机械项目检查。',
179
+ checked: [],
180
+ limitations: remaining.length ? remaining.map((id) => `未执行检查: ${id}`) : ['机械项目检查未开始。'],
181
+ };
182
+ }
183
+ const findings = isDomainFinding(exitCode, cause);
184
+ const checked = findings && attempted ? [...finished, attempted] : [...finished];
185
+ const limitations = [];
186
+ if (!findings && attempted) limitations.push(`未完成检查: ${attempted}`);
187
+ for (const id of notReached) limitations.push(`未执行检查: ${id}`);
188
+ if (findings) {
189
+ return {
190
+ completion: limitations.length ? 'partial' : 'complete',
191
+ code: DOCS_CODES.FINDINGS,
192
+ summary: '机械项目检查发现覆盖、渲染、链接或公开安全问题。',
193
+ checked,
194
+ limitations,
195
+ };
196
+ }
197
+ return {
198
+ completion: checked.length ? 'partial' : 'not-performed',
199
+ code: DOCS_CODES.INCOMPLETE,
200
+ summary: '机械项目检查未完成所声明范围。',
201
+ checked,
202
+ limitations: limitations.length ? limitations : ['所需项目输入不足。'],
203
+ };
204
+ }
205
+
206
+ function identifiableProvider(value) {
207
+ if (!value || typeof value !== 'object') return null;
208
+ const id = typeof value.id === 'string' && value.id ? value.id : null;
209
+ const version = typeof value.version === 'string' && value.version ? value.version : null;
210
+ const entry = typeof value.entry === 'string' && value.entry ? value.entry : null;
211
+ if (!id && !version && !entry) return null;
212
+ return {
213
+ ...(id ? { id } : {}),
214
+ ...(version ? { version } : {}),
215
+ ...(entry ? { entry } : {}),
216
+ };
217
+ }
218
+
219
+ function readResult(status, reason, provider, conclusion) {
220
+ return {
221
+ status,
222
+ reason,
223
+ provider: provider || providerInfo('skill-family-doc-render --read-proof'),
224
+ conclusion,
225
+ };
226
+ }
227
+
228
+ export function interpretDocsProof(document) {
229
+ const provider = identifiableProvider(document?.provider);
230
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
231
+ return readResult('unavailable', '证明不是 JSON 对象。', provider, null);
232
+ }
233
+ let validation;
234
+ try {
235
+ validation = validateProfessionalConclusion(document);
236
+ } catch (cause) {
237
+ if (cause instanceof ProofError) {
238
+ return readResult('unavailable', cause.message, provider, null);
239
+ }
240
+ throw cause;
241
+ }
242
+ if (!validation.valid) {
243
+ return readResult(
244
+ 'unavailable',
245
+ `证明不符合 professional-conclusion 结构: ${validation.errorCode || 'invalid'}`,
246
+ provider,
247
+ null,
248
+ );
249
+ }
250
+ const conclusion = validation.data;
251
+ const recorded = conclusion.provider;
252
+ if (recorded.id !== PROVIDER_ID) {
253
+ return readResult(
254
+ 'unavailable',
255
+ `证明提供方不是 ${PROVIDER_ID}`,
256
+ recorded,
257
+ null,
258
+ );
259
+ }
260
+ const code = conclusion.outcome.code;
261
+ const known = new Set(Object.values(DOCS_CODES));
262
+ if (!known.has(code)) {
263
+ return readResult(
264
+ 'unavailable',
265
+ `未知同版本领域码: ${code}`,
266
+ recorded,
267
+ conclusion,
268
+ );
269
+ }
270
+ if (code === DOCS_CODES.CLEAR && conclusion.outcome.completion === 'complete') {
271
+ return readResult('pass', conclusion.outcome.summary, recorded, conclusion);
272
+ }
273
+ return readResult('not_pass', conclusion.outcome.summary, recorded, conclusion);
274
+ }
275
+
276
+ export function readProofExitCode(status) {
277
+ if (status === 'pass') return 0;
278
+ if (status === 'not_pass') return 1;
279
+ return 2;
280
+ }
281
+
282
+ export async function readProfessionalProof({ proofRoot, proofPath }) {
283
+ const root = resolve(proofRoot);
284
+ const entry = 'skill-family-doc-render --read-proof';
285
+ let relative;
286
+ try {
287
+ await resolveContained(root, proofPath);
288
+ relative = proofPath;
289
+ } catch (cause) {
290
+ return readResult(
291
+ 'unavailable',
292
+ `证明路径未收容于 proof-root: ${cause.message}`,
293
+ providerInfo(entry),
294
+ null,
295
+ );
296
+ }
297
+ let receipt;
298
+ try {
299
+ receipt = await readFileStrict(root, relative, { encoding: 'utf8' });
300
+ } catch (cause) {
301
+ const kind = harnessKind(cause);
302
+ let reason = `无法读取证明: ${cause.message}`;
303
+ if (kind === HARNESS_ERROR_KINDS.MISSING_RESOURCE) reason = `证明文件不存在: ${relative}`;
304
+ if (kind === HARNESS_ERROR_KINDS.PATH_TRAVERSAL || kind === HARNESS_ERROR_KINDS.ABSOLUTE_PATH) {
305
+ reason = `证明路径未收容于 proof-root: ${cause.message}`;
306
+ }
307
+ return readResult('unavailable', reason, providerInfo(entry), null);
308
+ }
309
+ let parsed;
310
+ try {
311
+ parsed = JSON.parse(receipt.content);
312
+ } catch (cause) {
313
+ return readResult(
314
+ 'unavailable',
315
+ `证明 JSON 损坏: ${cause.message}`,
316
+ providerInfo(entry),
317
+ null,
318
+ );
319
+ }
320
+ return interpretDocsProof(parsed);
321
+ }
322
+
323
+ function splitAbsoluteOutput(absolutePath) {
324
+ const target = resolve(absolutePath);
325
+ if (!isAbsolute(target)) {
326
+ throw new ProofError('[skill-family-doc-render] --conclusion-output 必须是绝对路径', 2);
327
+ }
328
+ let directory = dirname(target);
329
+ const segments = [basename(target)];
330
+ while (!existsSync(directory)) {
331
+ const parent = dirname(directory);
332
+ if (parent === directory) {
333
+ throw new ProofError(`[skill-family-doc-render] 无法定位证明输出根目录: ${target}`, 2);
334
+ }
335
+ segments.unshift(basename(directory));
336
+ directory = parent;
337
+ }
338
+ return { root: directory, relPath: join(...segments), target };
339
+ }
340
+
341
+ export async function writeProfessionalConclusion(absolutePath, conclusion) {
342
+ const { root, relPath, target } = splitAbsoluteOutput(absolutePath);
343
+ const body = `${JSON.stringify(conclusion, null, 2)}\n`;
344
+ try {
345
+ const receipt = await publishFileExclusive(root, relPath, body, { createParents: true });
346
+ return { path: receipt.path || target, sha256: receipt.sha256 };
347
+ } catch (cause) {
348
+ if (harnessKind(cause) === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
349
+ throw new ProofError(`[skill-family-doc-render] 证明输出已存在,拒绝覆盖: ${target}`, 2, cause);
350
+ }
351
+ if (cause instanceof ProofError) throw cause;
352
+ throw new ProofError(`[skill-family-doc-render] 证明输出失败: ${cause.message}`, 2, cause);
353
+ }
354
+ }
355
+
356
+ export function conclusionEntryForRepo(repoName) {
357
+ return `skill-family-doc-render --check-project --repo ${repoName}`;
358
+ }
359
+
360
+ export { MECHANICAL_LIMITATION };
@@ -27,7 +27,7 @@
27
27
  // 可预期失败统一封装为 RenderError 携带 exitCode。
28
28
  import { existsSync, readFileSync } from 'node:fs';
29
29
  import { readFile, readdir, rm, mkdtemp, realpath, stat, glob } from 'node:fs/promises';
30
- import { resolve, relative, extname, join, dirname, basename, posix } from 'node:path';
30
+ import { resolve, relative, extname, join, dirname, basename, posix, isAbsolute } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { execFileSync } from 'node:child_process';
33
33
  import {
@@ -47,6 +47,16 @@ import { compileMarkdownV1, replaceMarkdownTokens } from './lib/markdown-v1.mjs'
47
47
  import { assertSafeSvg } from './lib/svg-safety.mjs';
48
48
  import { checkRenderedLinks } from './lib/rendered-links.mjs';
49
49
  import { renderEditorialSite } from '../templates/editorial/index.mjs';
50
+ import {
51
+ CHECK_STAGE_IDS,
52
+ ProofError,
53
+ buildDocsConclusion,
54
+ conclusionEntryForRepo,
55
+ mapScanToOutcome,
56
+ readProfessionalProof,
57
+ readProofExitCode,
58
+ writeProfessionalConclusion,
59
+ } from './professional-conclusion.mjs';
50
60
 
51
61
  const __dirname = dirname(fileURLToPath(import.meta.url));
52
62
 
@@ -284,9 +294,22 @@ async function computeCoverageSnapshot(sourceRoot, repo) {
284
294
  return { snapshot, versionSources };
285
295
  }
286
296
 
297
+ function gitReadEnv(extra = {}) {
298
+ return { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...extra };
299
+ }
300
+
301
+ function gitReadArgv(args) {
302
+ return ['--no-optional-locks', '-c', 'core.fsmonitor=false', ...args];
303
+ }
304
+
287
305
  function git(root, args, repoName) {
288
306
  try {
289
- return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
307
+ return execFileSync('git', gitReadArgv(args), {
308
+ cwd: root,
309
+ encoding: 'utf8',
310
+ stdio: ['ignore', 'pipe', 'pipe'],
311
+ env: gitReadEnv(),
312
+ }).trim();
290
313
  } catch (cause) {
291
314
  const detail = String(cause.stderr || cause.message).trim();
292
315
  throw configError(repoName, `Git 命令失败: git ${args.join(' ')}${detail ? `: ${detail}` : ''}`, cause);
@@ -519,7 +542,11 @@ function assertGitTracked(root, repo, targetRel, files) {
519
542
  let missing = 0;
520
543
  for (const f of expected) {
521
544
  try {
522
- execFileSync('git', ['ls-files', '--error-unmatch', f], { cwd: root, stdio: 'pipe' });
545
+ execFileSync('git', gitReadArgv(['ls-files', '--error-unmatch', f]), {
546
+ cwd: root,
547
+ stdio: 'pipe',
548
+ env: gitReadEnv(),
549
+ });
523
550
  } catch (cause) {
524
551
  if (cause?.status === 1) {
525
552
  console.error(`[assert-git] ${repo.name}: 未被 Git 索引跟踪(请先 git add): ${f}`);
@@ -771,26 +798,164 @@ function projectCheckError(repoName, cause, fallbackExitCode = 1) {
771
798
  );
772
799
  }
773
800
 
801
+ function attachDocsCheck(error, finished, attempted, notReached) {
802
+ error.docsCheck = { finished, attempted, notReached };
803
+ return error;
804
+ }
805
+
774
806
  async function checkProject(root, repo, release, leakLiterals) {
807
+ const finished = [];
808
+ const stages = [
809
+ {
810
+ id: CHECK_STAGE_IDS[0],
811
+ run: async () => {
812
+ await renderRepo(root, repo, release, leakLiterals, { check: false, assertGit: false, inputOnly: true });
813
+ console.log(`[render-public-site --check-project] ${repo.name}: 输入、公开安全与正文机械合同通过`);
814
+ },
815
+ },
816
+ {
817
+ id: CHECK_STAGE_IDS[1],
818
+ run: async () => {
819
+ await reportCoverageStatus(root, repo);
820
+ },
821
+ },
822
+ {
823
+ id: CHECK_STAGE_IDS[2],
824
+ run: async () => {
825
+ await renderRepo(root, repo, release, leakLiterals, { check: true, assertGit: false });
826
+ },
827
+ },
828
+ {
829
+ id: CHECK_STAGE_IDS[3],
830
+ run: async () => {
831
+ const sourceRoot = await repoSourceRoot(root, repo);
832
+ const targetDir = await containedOrConfig(sourceRoot, repo.site.target, repo.name, 'site.target');
833
+ const pagesDoc = await readJsonContained(sourceRoot, join(repo.site.dir, repo.site.pages), 'pages.json', repo.name);
834
+ await checkRenderedLinks({ targetRoot: targetDir, pageIds: pagesDoc.pages.map((page) => page.id) });
835
+ console.log(`[render-public-site --check-project] ${repo.name}: 内部链接与资源通过`);
836
+ console.log(`[render-public-site --check-project] ${repo.name}: public-release.json 只读项目检查通过`);
837
+ },
838
+ },
839
+ ];
775
840
  try {
776
- await renderRepo(root, repo, release, leakLiterals, { check: false, assertGit: false, inputOnly: true });
777
- console.log(`[render-public-site --check-project] ${repo.name}: 输入、公开安全与正文机械合同通过`);
778
- await reportCoverageStatus(root, repo);
779
- await renderRepo(root, repo, release, leakLiterals, { check: true, assertGit: false });
780
- const sourceRoot = await repoSourceRoot(root, repo);
781
- const targetDir = await containedOrConfig(sourceRoot, repo.site.target, repo.name, 'site.target');
782
- const pagesDoc = await readJsonContained(sourceRoot, join(repo.site.dir, repo.site.pages), 'pages.json', repo.name);
783
- await checkRenderedLinks({ targetRoot: targetDir, pageIds: pagesDoc.pages.map((page) => page.id) });
784
- console.log(`[render-public-site --check-project] ${repo.name}: 内部链接与资源通过`);
785
- console.log(`[render-public-site --check-project] ${repo.name}: public-release.json 只读项目检查通过`);
841
+ for (const stage of stages) {
842
+ await stage.run();
843
+ finished.push(stage.id);
844
+ }
786
845
  } catch (cause) {
787
- throw projectCheckError(repo.name, cause);
846
+ const remaining = stages.map((stage) => stage.id).filter((id) => !finished.includes(id));
847
+ throw attachDocsCheck(
848
+ projectCheckError(repo.name, cause),
849
+ finished,
850
+ remaining[0] ?? null,
851
+ remaining.slice(1),
852
+ );
788
853
  }
789
854
  }
790
855
 
856
+ function subjectRevision(root) {
857
+ try {
858
+ return execFileSync('git', gitReadArgv(['rev-parse', 'HEAD']), {
859
+ cwd: root,
860
+ encoding: 'utf8',
861
+ stdio: ['ignore', 'pipe', 'pipe'],
862
+ env: gitReadEnv(),
863
+ }).trim();
864
+ } catch {
865
+ return undefined;
866
+ }
867
+ }
868
+
869
+ function asRenderError(error) {
870
+ if (error instanceof RenderError) return error;
871
+ if (error instanceof ProofError) return new RenderError(error.message, error.exitCode, error);
872
+ return error;
873
+ }
874
+
875
+ async function emitProjectConclusion({
876
+ outputPath,
877
+ root,
878
+ repoName,
879
+ ok,
880
+ exitCode,
881
+ message,
882
+ cause = null,
883
+ docsCheck,
884
+ repoSelected = true,
885
+ }) {
886
+ const outcome = mapScanToOutcome({
887
+ ok,
888
+ exitCode,
889
+ finished: docsCheck?.finished || [],
890
+ attempted: docsCheck?.attempted || null,
891
+ notReached: docsCheck?.notReached || (ok ? [] : [...CHECK_STAGE_IDS]),
892
+ repoSelected,
893
+ cause,
894
+ });
895
+ const conclusion = buildDocsConclusion({
896
+ entry: conclusionEntryForRepo(repoName),
897
+ subjectRef: `${root}::${repoName}`,
898
+ subjectRevision: subjectRevision(root),
899
+ checked: outcome.checked,
900
+ limitations: outcome.limitations,
901
+ completion: outcome.completion,
902
+ code: outcome.code,
903
+ summary: outcome.summary,
904
+ details: {
905
+ mechanical: true,
906
+ repo: repoName,
907
+ exitCode,
908
+ findings: ok ? [] : [message],
909
+ },
910
+ });
911
+ const receipt = await writeProfessionalConclusion(outputPath, conclusion);
912
+ console.log(`[render-public-site --check-project] ${repoName}: professional conclusion written to ${receipt.path}`);
913
+ return receipt;
914
+ }
915
+
916
+ async function emitProjectConclusionOrThrow(args, primaryError) {
917
+ try {
918
+ await emitProjectConclusion(args);
919
+ } catch (cause) {
920
+ const writeError = asRenderError(cause);
921
+ if (primaryError) {
922
+ throw new RenderError(`${primaryError.message}\n${writeError.message}`, 2, writeError);
923
+ }
924
+ throw writeError;
925
+ }
926
+ if (primaryError) throw primaryError;
927
+ }
928
+
929
+ function takeValue(argv, i, flag, flags, key) {
930
+ if (flags[key] !== null) {
931
+ throw new RenderError(`[render-public-site] 参数错误: ${flag} 重复出现,取值语义不明确`, 2);
932
+ }
933
+ const value = argv[i + 1];
934
+ if (value === undefined || value === '' || value.startsWith('-')) {
935
+ throw new RenderError(`[render-public-site] ${flag} 后必须跟一个有效取值(不能缺省,且值不能是另一个选项)`, 2);
936
+ }
937
+ flags[key] = value;
938
+ return i + 1;
939
+ }
940
+
941
+ function projectModeError(flags, message) {
942
+ const error = projectCheckError(
943
+ flags.repoArg ?? '<missing>',
944
+ new RenderError(message, 2),
945
+ 2,
946
+ );
947
+ error.repoArg = flags.repoArg;
948
+ if (flags.conclusionOutput && isAbsolute(flags.conclusionOutput)) {
949
+ error.conclusionOutput = flags.conclusionOutput;
950
+ }
951
+ return error;
952
+ }
953
+
791
954
  // === 主流程 ===
792
955
  // 闭集参数解析:允许参数只有 --check / --assert-git / --status /
793
- // --refresh-coverage / --check-project / --repo <name> / --help / -h。
956
+ // --refresh-coverage / --check-project / --conclusion-output <absolute-path> /
957
+ // --read-proof / --proof-root <root> / --proof <relative> / --repo <name> /
958
+ // --help / -h。
794
959
  // 未知选项、游离位置参数、重复取值参数、--repo 缺值或值本身是另一个选项,
795
960
  // 均以 RenderError(exit 2)失败,错误信息指出具体参数。不引入 CLI 框架。
796
961
  // --help / -h 由 main() 统一呈现:闭集解析先于配置读取与写盘,打印 USAGE_TEXT 后退出 0。
@@ -809,8 +974,12 @@ Usage:
809
974
  refresh only site-coverage-lock.json
810
975
  skill-family-doc-render --check-project --repo <name>
811
976
  read-only input, coverage, render and link checks
977
+ skill-family-doc-render --check-project --repo <name> --conclusion-output <absolute-path>
978
+ same read-only checks, then exclusive-write a professional conclusion
979
+ skill-family-doc-render --read-proof --proof-root <root> --proof <relative>
980
+ read one docs-family professional conclusion; do not rescan
812
981
 
813
- Reads public-release.json from the current working directory.`;
982
+ Reads public-release.json from the current working directory except --read-proof.`;
814
983
 
815
984
  function parseArgs(argv) {
816
985
  const flags = {
@@ -819,7 +988,11 @@ function parseArgs(argv) {
819
988
  status: false,
820
989
  refreshCoverage: false,
821
990
  checkProject: false,
991
+ readProof: false,
822
992
  repoArg: null,
993
+ conclusionOutput: null,
994
+ proofRoot: null,
995
+ proof: null,
823
996
  help: false,
824
997
  };
825
998
  let i = 0;
@@ -835,6 +1008,8 @@ function parseArgs(argv) {
835
1008
  flags.refreshCoverage = true;
836
1009
  } else if (arg === '--check-project') {
837
1010
  flags.checkProject = true;
1011
+ } else if (arg === '--read-proof') {
1012
+ flags.readProof = true;
838
1013
  } else if (arg === '--help' || arg === '-h') {
839
1014
  flags.help = true;
840
1015
  } else if (arg === '--repo') {
@@ -846,7 +1021,13 @@ function parseArgs(argv) {
846
1021
  throw new RenderError('[render-public-site] --repo 后必须跟一个有效的 repo 名(不能缺省,且值不能是另一个选项)', 2);
847
1022
  }
848
1023
  flags.repoArg = value;
849
- i += 1; // 消费取值
1024
+ i += 1;
1025
+ } else if (arg === '--conclusion-output') {
1026
+ i = takeValue(argv, i, '--conclusion-output', flags, 'conclusionOutput');
1027
+ } else if (arg === '--proof-root') {
1028
+ i = takeValue(argv, i, '--proof-root', flags, 'proofRoot');
1029
+ } else if (arg === '--proof') {
1030
+ i = takeValue(argv, i, '--proof', flags, 'proof');
850
1031
  } else if (arg.startsWith('-')) {
851
1032
  throw new RenderError(`[render-public-site] 参数错误: 未知选项 ${arg}`, 2);
852
1033
  } else {
@@ -863,19 +1044,29 @@ function parseArgs(argv) {
863
1044
  if ((flags.status || flags.refreshCoverage) && flags.repoArg === null) {
864
1045
  throw new RenderError('[render-public-site] 参数错误: --status 与 --refresh-coverage 必须配合 --repo <name>', 2);
865
1046
  }
1047
+ if (flags.readProof && flags.checkProject) {
1048
+ throw new RenderError('[render-public-site] 参数错误: --check-project 与 --read-proof 互斥', 2);
1049
+ }
1050
+ if (flags.readProof && (flags.check || flags.assertGit || flags.status || flags.refreshCoverage || flags.help || flags.conclusionOutput || flags.repoArg)) {
1051
+ throw new RenderError('[render-public-site] 参数错误: --read-proof 不能与检查、覆盖、Git、帮助或 --conclusion-output 组合', 2);
1052
+ }
1053
+ if (flags.readProof && (flags.proofRoot === null || flags.proof === null)) {
1054
+ throw new RenderError('[render-public-site] 参数错误: --read-proof 必须配合 --proof-root <root> 与 --proof <relative>', 2);
1055
+ }
1056
+ if (!flags.readProof && (flags.proofRoot !== null || flags.proof !== null)) {
1057
+ throw new RenderError('[render-public-site] 参数错误: --proof-root 与 --proof 只能与 --read-proof 一起使用', 2);
1058
+ }
1059
+ if (flags.conclusionOutput && !flags.checkProject) {
1060
+ throw new RenderError('[render-public-site] 参数错误: --conclusion-output 只能与 --check-project 一起使用', 2);
1061
+ }
1062
+ if (flags.conclusionOutput && !isAbsolute(flags.conclusionOutput)) {
1063
+ throw projectModeError(flags, '参数错误: --conclusion-output 必须是绝对路径');
1064
+ }
866
1065
  if (flags.checkProject && (flags.check || flags.assertGit || flags.status || flags.refreshCoverage || flags.help)) {
867
- throw projectCheckError(
868
- flags.repoArg ?? '<missing>',
869
- new RenderError('参数错误: --check-project 不能与其他检查、覆盖、Git 或帮助模式组合', 2),
870
- 2,
871
- );
1066
+ throw projectModeError(flags, '参数错误: --check-project 不能与其他检查、覆盖、Git 或帮助模式组合');
872
1067
  }
873
1068
  if (flags.checkProject && flags.repoArg === null) {
874
- throw projectCheckError(
875
- '<missing>',
876
- new RenderError('参数错误: --check-project 必须配合 --repo <name>', 2),
877
- 2,
878
- );
1069
+ throw projectModeError(flags, '参数错误: --check-project 必须配合 --repo <name>');
879
1070
  }
880
1071
  return flags;
881
1072
  }
@@ -886,7 +1077,20 @@ function parseArgs(argv) {
886
1077
  // 单个 repo 内部(含泄漏扫描命中)保持 fail-fast。
887
1078
  export async function main(argv = process.argv.slice(2), { cwd = process.cwd() } = {}) {
888
1079
  // 闭集解析在读取任何配置或写盘之前完成;参数失败时目标树一个字节都不动。
889
- const { check, assertGit, status, refreshCoverage, checkProject: projectCheck, repoArg, help } = parseArgs(argv);
1080
+ const parsed = parseArgs(argv);
1081
+ const {
1082
+ check,
1083
+ assertGit,
1084
+ status,
1085
+ refreshCoverage,
1086
+ checkProject: projectCheck,
1087
+ readProof,
1088
+ repoArg,
1089
+ conclusionOutput,
1090
+ proofRoot,
1091
+ proof,
1092
+ help,
1093
+ } = parsed;
890
1094
  if (help) {
891
1095
  console.log(USAGE_TEXT);
892
1096
  return 0;
@@ -894,18 +1098,54 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
894
1098
 
895
1099
  const root = resolve(cwd);
896
1100
 
1101
+ if (readProof) {
1102
+ const result = await readProfessionalProof({ proofRoot, proofPath: proof });
1103
+ console.log(JSON.stringify(result, null, 2));
1104
+ const code = readProofExitCode(result.status);
1105
+ if (code === 0) return 0;
1106
+ throw new RenderError(result.reason, code);
1107
+ }
1108
+
897
1109
  // --- 读取发布配置 ---
898
1110
  const releasePath = resolve(root, 'public-release.json');
899
1111
  if (!existsSync(releasePath)) {
900
1112
  const cause = new RenderError('[render-public-site] 未找到 public-release.json', 2);
901
- throw projectCheck ? projectCheckError(repoArg, cause, 2) : cause;
1113
+ const error = projectCheck ? projectCheckError(repoArg, cause, 2) : cause;
1114
+ if (projectCheck && conclusionOutput) {
1115
+ await emitProjectConclusionOrThrow({
1116
+ outputPath: conclusionOutput,
1117
+ root,
1118
+ repoName: repoArg,
1119
+ ok: false,
1120
+ exitCode: 2,
1121
+ message: error.message,
1122
+ cause: error,
1123
+ repoSelected: false,
1124
+ }, error);
1125
+ }
1126
+ throw error;
902
1127
  }
903
1128
  let release;
904
1129
  try {
905
1130
  release = await readJsonContained(root, 'public-release.json', 'public-release.json', '(workspace)');
906
1131
  assertValid(release, publicReleaseSchema, 'public-release.json');
907
1132
  } catch (cause) {
908
- if (projectCheck) throw projectCheckError(repoArg, cause, 2);
1133
+ if (projectCheck) {
1134
+ const error = projectCheckError(repoArg, cause, 2);
1135
+ if (conclusionOutput) {
1136
+ await emitProjectConclusionOrThrow({
1137
+ outputPath: conclusionOutput,
1138
+ root,
1139
+ repoName: repoArg,
1140
+ ok: false,
1141
+ exitCode: 2,
1142
+ message: error.message,
1143
+ cause: error,
1144
+ repoSelected: Boolean(repoArg),
1145
+ }, error);
1146
+ }
1147
+ throw error;
1148
+ }
909
1149
  throw cause;
910
1150
  }
911
1151
 
@@ -921,12 +1161,38 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
921
1161
  const hit = selected.find((r) => r.name === repoArg);
922
1162
  if (!hit) {
923
1163
  const cause = new RenderError(`[render-public-site] 未找到带 site 的 repo: ${repoArg}`, 2);
924
- throw projectCheck ? projectCheckError(repoArg, cause, 2) : cause;
1164
+ const error = projectCheck ? projectCheckError(repoArg, cause, 2) : cause;
1165
+ if (projectCheck && conclusionOutput) {
1166
+ await emitProjectConclusionOrThrow({
1167
+ outputPath: conclusionOutput,
1168
+ root,
1169
+ repoName: repoArg,
1170
+ ok: false,
1171
+ exitCode: 2,
1172
+ message: error.message,
1173
+ cause: error,
1174
+ repoSelected: false,
1175
+ }, error);
1176
+ }
1177
+ throw error;
925
1178
  }
926
1179
  selected = [hit];
927
1180
  }
928
1181
  if (!selected.length) {
929
- throw new RenderError('[render-public-site] public-release.json 中没有带 site 字段的 repo', 2);
1182
+ const error = new RenderError('[render-public-site] public-release.json 中没有带 site 字段的 repo', 2);
1183
+ if (projectCheck && conclusionOutput) {
1184
+ await emitProjectConclusionOrThrow({
1185
+ outputPath: conclusionOutput,
1186
+ root,
1187
+ repoName: repoArg ?? '<missing>',
1188
+ ok: false,
1189
+ exitCode: 2,
1190
+ message: error.message,
1191
+ cause: error,
1192
+ repoSelected: false,
1193
+ }, error);
1194
+ }
1195
+ throw error;
930
1196
  }
931
1197
 
932
1198
  const results = [];
@@ -934,8 +1200,49 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
934
1200
  try {
935
1201
  if (status) await reportCoverageStatus(root, repo);
936
1202
  else if (refreshCoverage) await refreshCoverageLock(root, repo);
937
- else if (projectCheck) await checkProject(root, repo, release, leakLiterals);
938
- else await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
1203
+ else if (projectCheck) {
1204
+ let scanError = null;
1205
+ try {
1206
+ await checkProject(root, repo, release, leakLiterals);
1207
+ } catch (cause) {
1208
+ scanError = cause;
1209
+ }
1210
+ if (conclusionOutput) {
1211
+ try {
1212
+ await emitProjectConclusion({
1213
+ outputPath: conclusionOutput,
1214
+ root,
1215
+ repoName: repo.name,
1216
+ ok: !scanError,
1217
+ exitCode: scanError ? (Number.isInteger(scanError.exitCode) ? scanError.exitCode : 1) : 0,
1218
+ message: scanError ? scanError.message : 'ok',
1219
+ cause: scanError,
1220
+ docsCheck: scanError?.docsCheck || { finished: [...CHECK_STAGE_IDS], attempted: null, notReached: [] },
1221
+ repoSelected: true,
1222
+ });
1223
+ } catch (cause) {
1224
+ const writeError = asRenderError(cause);
1225
+ results.push({
1226
+ name: repo.name,
1227
+ ok: false,
1228
+ exitCode: 2,
1229
+ message: scanError ? `${scanError.message}\n${writeError.message}` : writeError.message,
1230
+ cause: writeError,
1231
+ });
1232
+ continue;
1233
+ }
1234
+ }
1235
+ if (scanError) {
1236
+ results.push({
1237
+ name: repo.name,
1238
+ ok: false,
1239
+ exitCode: Number.isInteger(scanError.exitCode) ? scanError.exitCode : 1,
1240
+ message: scanError.message,
1241
+ cause: scanError,
1242
+ });
1243
+ continue;
1244
+ }
1245
+ } else await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
939
1246
  results.push({ name: repo.name, ok: true });
940
1247
  } catch (err) {
941
1248
  results.push({