skill-family-doc-render 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # 变更日志
2
2
 
3
+ ## 0.3.0 - 2026-09-07
4
+
5
+ ### 新增
6
+
7
+ - 增加 `site.coverage` 和最小 `site-coverage-lock.json`,记录已审阅产品输入的文件摘要、
8
+ 聚合摘要,以及启用 artifact-graph 时的版本锁摘要。
9
+ - 增加只读的 `--status --repo <name>` 和只更新覆盖快照的
10
+ `--refresh-coverage --repo <name>`。状态命令会报告覆盖提交、当前摘要、变化文件、
11
+ 权威版本值和建议执行的 Git diff 命令。
12
+ - 状态命令从 `repo.source` 发现所属 Git 仓库。目标位于外层工作区中的独立内层仓库时,
13
+ 覆盖提交、变化路径和 diff 建议只使用内层仓库历史。
14
+ - 增加 `site.versionSources`。渲染器从收容于 `repo.source` 的 JSON 文件和 JSON Pointer
15
+ 读取版本值,版本源文件自动加入覆盖输入。
16
+
17
+ ### 安全与兼容
18
+
19
+ - 覆盖输入拒绝绝对路径、越界路径、空匹配和符号链接逃逸;覆盖锁始终排除自身,
20
+ 避免形成摘要循环。
21
+ - 保留现有静态 token 和按仓库名派生版本 token 的兼容行为;同名
22
+ `site.versionSources` 取值优先。
23
+
3
24
  ## 0.2.1 - 2026-09-06
4
25
 
5
26
  ### 变更
package/README.md CHANGED
@@ -13,15 +13,21 @@ publication APIs. A failure before commit removes staging and leaves the existin
13
13
  untouched. After commit, or when publication is indeterminate, staging or displaced data may
14
14
  remain for diagnosis; the renderer does not guess or roll back.
15
15
 
16
+ > **Development status:** this worktree is the unpublished `0.3.0` candidate. The latest npm
17
+ > release is `0.2.1`; it does not contain `site.coverage`, `site.versionSources`, `--status`, or
18
+ > `--refresh-coverage`.
19
+
16
20
  ## Install
17
21
 
22
+ Install the current published release with an exact version:
23
+
18
24
  ```sh
19
- npm install skill-family-doc-render@0.2.0
25
+ npm install --save-exact skill-family-doc-render@0.2.1
20
26
  ```
21
27
 
22
28
  Requires Node.js `>=22.22.2 <23` (aligned with the skill-family foundation packages).
23
29
 
24
- 0.2.0 consumes the three Foundation packages at the exact `0.18.0` pin:
30
+ The unpublished 0.3.0 candidate consumes the three Foundation packages at the exact `0.18.0` pin:
25
31
  `skill-family-contracts@0.18.0`, `skill-family-harness-node@0.18.0` and
26
32
  `skill-family-engineering-kit@0.18.0` (the last one is used by the workspace profile check).
27
33
  The 0.2.0 baseline format and exit-code changes are breaking changes; existing 0.1.x users
@@ -30,24 +36,47 @@ notes.
30
36
 
31
37
  ## CLI
32
38
 
39
+ The published 0.2.1 release supports rendering, drift checks, repository selection, and the Git
40
+ index assertion:
41
+
33
42
  ```sh
34
43
  # render all repos with a site field (writes to disk)
35
- npx skill-family-doc-render@0.2.0
44
+ npx skill-family-doc-render@0.2.1
36
45
 
37
46
  # drift check only: compare in-memory render against committed site-baseline.json, write nothing
38
- npx skill-family-doc-render@0.2.0 --check
47
+ npx skill-family-doc-render@0.2.1 --check
39
48
 
40
49
  # render / check a single repo by name
41
- npx skill-family-doc-render@0.2.0 --repo <name>
50
+ npx skill-family-doc-render@0.2.1 --repo <name>
42
51
 
43
52
  # additionally assert every rendered file is tracked in the git index
44
- npx skill-family-doc-render@0.2.0 --assert-git
53
+ npx skill-family-doc-render@0.2.1 --assert-git
54
+ ```
55
+
56
+ Run the unpublished 0.3.0 coverage commands from a local package checkout. The current directory
57
+ must still be the workspace that owns `public-release.json`; adjust the script path when the
58
+ package is nested in a monorepo:
59
+
60
+ ```sh
61
+ # inspect whether reviewed product inputs have changed; writes nothing
62
+ node bin/skill-family-doc-render.mjs --status --repo <name>
63
+
64
+ # refresh only the reviewed-input snapshot after semantic and style review
65
+ node bin/skill-family-doc-render.mjs --refresh-coverage --repo <name>
66
+ ```
67
+
68
+ After 0.3.0 is published, consumers can install or invoke that exact release:
69
+
70
+ ```sh
71
+ npm install --save-exact skill-family-doc-render@0.3.0
72
+ npx skill-family-doc-render@0.3.0 --status --repo <name>
45
73
  ```
46
74
 
47
75
  Exit codes: `0` success; `1` drift class — `--check` drift, leak-scan hit, `--assert-git`
48
- missing files, missing/corrupt baseline; `2` configuration class — missing/invalid
49
- `public-release.json` or `pages.json`, JSON parse failures, unreplaced `@TOKEN@` placeholders,
50
- invalid `--repo` usage (the message includes the JSON field path). When several repos are
76
+ missing files, missing/corrupt render baseline, or missing/stale coverage snapshot; `2`
77
+ configuration/tool class — missing/invalid `public-release.json` or `pages.json`, JSON parse
78
+ failures, unreplaced `@TOKEN@` placeholders, invalid `--repo` usage, invalid coverage inputs, or
79
+ Git failure (the message includes the JSON field path). When several repos are
51
80
  rendered in one run, each repo is isolated: a failure in one does not stop the others, and the
52
81
  run ends with a summary of successes/failures plus a non-zero exit (2 if any failure was
53
82
  configuration-class, otherwise 1).
@@ -56,7 +85,8 @@ configuration-class, otherwise 1).
56
85
 
57
86
  The config file lives at the workspace root (resolved from `process.cwd()`). It is validated
58
87
  against `schemas/public-release.schema.json` (JSON Schema 2020-12, strict policy) before
59
- anything runs.
88
+ anything runs. `coverage` and `versionSources` in the example below belong to the unpublished
89
+ 0.3.0 candidate.
60
90
 
61
91
  ```json
62
92
  {
@@ -72,6 +102,21 @@ anything runs.
72
102
  "dir": "site-src",
73
103
  "target": "docs",
74
104
  "pages": "pages.json",
105
+ "coverage": {
106
+ "lock": "site-coverage-lock.json",
107
+ "inputs": ["package.json", "src/**", "schemas/**"]
108
+ },
109
+ "versionSources": {
110
+ "@COMPONENT_VERSION@": {
111
+ "source": "package.json",
112
+ "pointer": "/version"
113
+ },
114
+ "@COMPONENT_TAG@": {
115
+ "source": "package.json",
116
+ "pointer": "/version",
117
+ "prefix": "component-v"
118
+ }
119
+ },
75
120
  "tokens": { "@CUSTOM_NOTE@": "any static placeholder" }
76
121
  }
77
122
  }
@@ -88,6 +133,13 @@ anything runs.
88
133
  `inPager: false` excludes a page from the prev/next pager. Page `id` is restricted to
89
134
  `[A-Za-z0-9][A-Za-z0-9_-]*`.
90
135
  - `forbiddenPublicPaths` / `privateLiterals` append project-specific literals to the leak scan.
136
+ - `site.coverage` declares reviewed product inputs relative to `repo.source`. Each input accepts
137
+ `*` and `**` glob syntax and must match at least one file after the coverage lock itself is
138
+ excluded.
139
+ - `site.versionSources` maps a token to an existing JSON file and an
140
+ [RFC 6901 JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901). `prefix` and `suffix` format
141
+ the scalar value without copying the version into configuration. Every referenced JSON file
142
+ automatically joins the coverage inputs.
91
143
 
92
144
  ## Placeholders
93
145
 
@@ -103,7 +155,8 @@ Version tokens are derived automatically from the repo's `package.json` version
103
155
  - `@{NAME}_TAG@` — e.g. `my-project` → `@MY_PROJECT_TAG@` = `tagPrefix` + version
104
156
  - `@{NAME}_VERSION@` — the bare version
105
157
 
106
- `site.tokens` adds arbitrary static placeholder replacements on top. If a rendered page still
158
+ `site.tokens` adds non-version static placeholder replacements. A token also declared in
159
+ `site.versionSources` always takes its value from the JSON source. If a rendered page still
107
160
  contains an unreplaced `@TOKEN@` (`@[A-Z0-9_]+@`), the run fails fast (exit 2) naming the
108
161
  tokens and the file — placeholder typos never slip into the published site silently.
109
162
 
@@ -111,6 +164,29 @@ If a repo has no `package.json` (or no valid `version`), the version falls back
111
164
  with a loud `WARNING` on stderr, because that fallback is baked into the output and the
112
165
  baseline; a malformed `package.json` fails fast (exit 2).
113
166
 
167
+ ## Coverage status
168
+
169
+ `--refresh-coverage --repo <name>` expands `site.coverage.inputs`, adds every configured version
170
+ source, computes the file digests and closure digest, then atomically writes the declared coverage
171
+ lock. It does not render the site or modify Git. The lock contains only:
172
+
173
+ - `sha256`: the Foundation resource-closure digest;
174
+ - `inputs`: sorted relative file paths and their byte digests;
175
+ - `artifactGraphVersionLockSha256`: the current `artifacts/traceability-version-lock.json`
176
+ digest when the repository contains `artifact-graph.config.yaml`.
177
+
178
+ `--status --repo <name>` recomputes the same facts without writing. It prints the last commit that
179
+ changed the coverage lock, the current and recorded closure digests, changed covered files,
180
+ current values from `site.versionSources`, and suggested `git diff` commands. A matching snapshot
181
+ returns `0`; a missing or stale snapshot returns `1`. A site without `coverage`, a path escape, an
182
+ empty glob, an invalid JSON pointer, a symlink escape, or a Git failure returns `2`.
183
+
184
+ Coverage paths are relative to `repo.source`. Absolute paths, lexical traversal, realpath escape,
185
+ and symlink escape are rejected through Foundation path containment. The coverage lock is always
186
+ excluded from its own inputs, avoiding a digest cycle. `--status` requires a Git repository with a
187
+ resolvable `HEAD`, because its report includes committed, staged, unstaged, and covered untracked
188
+ changes.
189
+
114
190
  ## Leak scan
115
191
 
116
192
  Every output file — rendered pages **and** copied assets (`.html`/`.css`/`.js`/`.svg`) — is
@@ -151,30 +227,35 @@ dependencies:
151
227
  - [`skill-family-harness-node`](https://github.com/ifoohoo/skill-family-harness-node) — all file
152
228
  reads go through `resolveContained` / `readFileContained` (path traversal, symlink and realpath
153
229
  escapes rejected), per-file digests use `digestBytes`, the tree baseline is computed with
154
- `computeResourceClosure`. Output is staged in a sibling directory under the target's canonical
155
- parent, with contained writes through `writeFileAtomic`. A new target uses
230
+ `computeResourceClosure`, and coverage locks use `writeFileAtomic`. Output is staged in a
231
+ sibling directory under the target's canonical parent, with contained writes through
232
+ `writeFileAtomic`. A new target uses
156
233
  `createFixedSetPublicationManifest` followed by `publishFixedSet`; an existing target uses
157
234
  `replaceFixedSetAtomic`. Staging is removed after success or a clearly pre-commit failure;
158
235
  post-commit or indeterminate failures may retain it for diagnosis. These APIs do not add a
159
236
  broader atomicity guarantee than Foundation provides.
160
237
  - [`skill-family-contracts`](https://github.com/ifoohoo/skill-family-contracts) —
161
238
  `validateDocument` (JSON Schema 2020-12, strict policy) validates `public-release.json`,
162
- `pages.json` and `site-baseline.json`; validation failure aborts with exit code 2.
239
+ `pages.json`, `site-baseline.json`, and `site-coverage-lock.json`; validation failure aborts
240
+ with exit code 2.
163
241
 
164
242
  ## License
165
243
 
166
244
  Apache-2.0
167
245
 
168
246
  <!-- release-skill:capability:safe-first-command -->
169
- > **Start here:** after installing the exact `skill-family-doc-render@0.2.0`, run
170
- > `./node_modules/.bin/skill-family-doc-render --check` — the drift check is read-only:
247
+ > **Start here:** for the unpublished 0.3.0 candidate, run
248
+ > `node bin/skill-family-doc-render.mjs --check` from its local checkout. Published 0.2.1 users
249
+ > can run `./node_modules/.bin/skill-family-doc-render --check`. The drift check is read-only:
171
250
  > it renders in memory, compares against the committed `site-baseline.json`, writes
172
251
  > nothing and touches no network or credentials. The CLI performs no Git writes; only an
173
252
  > explicit `--assert-git` performs a read-only query of the Git index.
174
253
 
175
254
  <!-- release-skill:capability:external-write-boundary -->
176
- > **External write boundary:** this CLI writes the declared target and a controlled sibling
177
- > staging directory, both within the target's canonical parent, with contained path checks.
255
+ > **External write boundary:** rendering writes the declared target and a controlled sibling
256
+ > staging directory, both within the target's canonical parent, with contained path checks. An
257
+ > explicit `--refresh-coverage` writes only the declared coverage lock through an atomic file
258
+ > replacement. `--status` and `--check` write nothing.
178
259
  > Render, scan, baseline and `--assert-git` preflight failures happen before target changes.
179
260
  > Staging is removed after success or a clearly pre-commit failure; post-commit or
180
261
  > indeterminate failures may retain it to preserve diagnosable state.
@@ -184,7 +265,12 @@ Apache-2.0
184
265
 
185
266
  ## Minimal example
186
267
 
268
+ Use the local checkout while 0.3.0 remains unpublished:
269
+
187
270
  ```sh
188
- npx skill-family-doc-render@0.2.0 --help # show usage
189
- npx skill-family-doc-render@0.2.0 --check # read-only drift check — the safe first command
271
+ node bin/skill-family-doc-render.mjs --help
272
+ node bin/skill-family-doc-render.mjs --check
190
273
  ```
274
+
275
+ Replace `node bin/skill-family-doc-render.mjs` with `npx skill-family-doc-render@0.3.0` only after
276
+ the registry publishes 0.3.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-family-doc-render",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -29,6 +29,36 @@
29
29
  "dir": { "type": "string", "minLength": 1 },
30
30
  "target": { "type": "string", "minLength": 1 },
31
31
  "pages": { "type": "string", "minLength": 1 },
32
+ "coverage": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "required": ["lock", "inputs"],
36
+ "properties": {
37
+ "lock": { "type": "string", "minLength": 1, "pattern": "^[^*?\\[\\]{}()!]+$" },
38
+ "inputs": {
39
+ "type": "array",
40
+ "minItems": 1,
41
+ "uniqueItems": true,
42
+ "items": { "type": "string", "minLength": 1, "pattern": "^[^?\\[\\]{}()!]+$" }
43
+ }
44
+ }
45
+ },
46
+ "versionSources": {
47
+ "type": "object",
48
+ "minProperties": 1,
49
+ "propertyNames": { "pattern": "^@[A-Z0-9_]+@$" },
50
+ "additionalProperties": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "required": ["source", "pointer"],
54
+ "properties": {
55
+ "source": { "type": "string", "minLength": 1, "pattern": "^[^*?\\[\\]{}()!]+$" },
56
+ "pointer": { "type": "string", "pattern": "^/" },
57
+ "prefix": { "type": "string" },
58
+ "suffix": { "type": "string" }
59
+ }
60
+ }
61
+ },
32
62
  "tokens": {
33
63
  "type": "object",
34
64
  "additionalProperties": {
@@ -0,0 +1,28 @@
1
+ {
2
+ "$id": "https://github.com/ifoohoo/skill-family-doc-render/schemas/site-coverage-lock.schema.json",
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
4
+ "title": "site-coverage-lock.json",
5
+ "description": "知识站已审阅产品输入的最小字节快照;不保存版本、发布状态或人工结论。",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["sha256", "inputs"],
9
+ "properties": {
10
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
11
+ "inputs": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["path", "sha256"],
17
+ "properties": {
18
+ "path": { "type": "string", "minLength": 1 },
19
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
20
+ }
21
+ }
22
+ },
23
+ "artifactGraphVersionLockSha256": {
24
+ "type": "string",
25
+ "pattern": "^[0-9a-f]{64}$"
26
+ }
27
+ }
28
+ }
@@ -14,23 +14,24 @@
14
14
  // harness 化:所有配置 / 源文件读取经 readFileContained,staging 的相对输出经
15
15
  // writeFileAtomic 收容并原子写入(见 skill-family-harness-node);树基线摘要用
16
16
  // computeResourceClosure,逐文件摘要用 digestBytes。
17
- // 配置、pages.json 与基线文件先经 skill-family-contracts validateDocument
17
+ // 配置、pages.json、渲染基线与覆盖快照先经 skill-family-contracts validateDocument
18
18
  // (JSON Schema 2020-12,strict 策略)校验,校验失败 fail-fast。
19
19
  //
20
20
  // 错误分类(退出码语义):
21
- // exit 1 — 漂移类:--check 检出漂移、泄漏扫描命中、--assert-git 缺失、
22
- // 基线缺失 / 基线 JSON 损坏;
23
- // exit 2 — 配置类:public-release.json / pages.json 缺失、非法或 JSON 解析失败、
24
- // package.json JSON 解析失败、@TOKEN@ 占位符残留、--repo 参数无效;
21
+ // exit 1 — 漂移类:--check 检出漂移、覆盖快照缺失或落后、泄漏扫描命中、
22
+ // --assert-git 缺失、渲染基线缺失 / 基线 JSON 损坏;
23
+ // exit 2 — 配置或工具类:配置 / JSON / 覆盖输入非法、Git 调用失败、
24
+ // @TOKEN@ 占位符残留、--repo 参数无效;
25
25
  // 其余意外异常(HarnessError 等)原样抛出,CLI 归为 exit 1。
26
26
  // 可预期失败统一封装为 RenderError 携带 exitCode。
27
27
  import { existsSync, readFileSync } from 'node:fs';
28
- import { readdir, rm, mkdtemp, realpath } from 'node:fs/promises';
29
- import { resolve, relative, extname, join, dirname, basename } from 'node:path';
28
+ import { readdir, rm, mkdtemp, realpath, stat, glob } from 'node:fs/promises';
29
+ import { resolve, relative, extname, join, dirname, basename, posix } from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
  import { execFileSync } from 'node:child_process';
32
32
  import {
33
33
  resolveContained,
34
+ classifyPathInput,
34
35
  readFileContained,
35
36
  digestBytes,
36
37
  computeResourceClosure,
@@ -51,14 +52,17 @@ const publicReleaseSchema = JSON.parse(
51
52
  const siteBaselineSchema = JSON.parse(
52
53
  readFileSync(join(__dirname, '..', 'schemas', 'site-baseline.schema.json'), 'utf8'),
53
54
  );
55
+ const siteCoverageLockSchema = JSON.parse(
56
+ readFileSync(join(__dirname, '..', 'schemas', 'site-coverage-lock.schema.json'), 'utf8'),
57
+ );
54
58
  const pagesSchema = JSON.parse(
55
59
  readFileSync(join(__dirname, '..', 'schemas', 'pages.schema.json'), 'utf8'),
56
60
  );
57
61
 
58
62
  // 可预期失败(配置缺失、校验失败、check 漂移、泄漏命中等)以此类型抛出,携带退出码。
59
63
  export class RenderError extends Error {
60
- constructor(message, exitCode = 2) {
61
- super(message);
64
+ constructor(message, exitCode = 2, cause) {
65
+ super(message, cause === undefined ? undefined : { cause });
62
66
  this.exitCode = exitCode;
63
67
  }
64
68
  }
@@ -105,6 +109,301 @@ async function pkgVersion(root, source, name) {
105
109
  return doc.version;
106
110
  }
107
111
 
112
+ function configError(name, message, cause) {
113
+ return new RenderError(`[render-public-site] ${name}: ${message}`, 2, cause);
114
+ }
115
+
116
+ async function repoSourceRoot(root, repo) {
117
+ const classification = classifyPathInput(repo.source);
118
+ if (!classification.ok) {
119
+ throw configError(repo.name, `repo.source 非法 (${classification.kind}): ${repo.source}`);
120
+ }
121
+ if (resolve(root, repo.source) === root) return root;
122
+ try {
123
+ return await resolveContained(root, repo.source);
124
+ } catch (cause) {
125
+ throw configError(repo.name, `repo.source 未收容于工作区: ${repo.source}`, cause);
126
+ }
127
+ }
128
+
129
+ async function containedOrConfig(root, relPath, repoName, fieldPath) {
130
+ try {
131
+ return await resolveContained(root, relPath);
132
+ } catch (cause) {
133
+ throw configError(repoName, `${fieldPath} 必须是收容于 repo.source 的相对路径: ${relPath}`, cause);
134
+ }
135
+ }
136
+
137
+ function jsonPointerValue(document, pointer, repoName, fieldPath) {
138
+ const segments = pointer.slice(1).split('/').map((segment) => {
139
+ if (/~(?:[^01]|$)/.test(segment)) {
140
+ throw configError(repoName, `${fieldPath} 不是有效 JSON Pointer: ${pointer}`);
141
+ }
142
+ return segment.replaceAll('~1', '/').replaceAll('~0', '~');
143
+ });
144
+ let value = document;
145
+ for (const segment of segments) {
146
+ if (value === null || typeof value !== 'object' || !Object.hasOwn(value, segment)) {
147
+ throw configError(repoName, `${fieldPath} 指向的字段不存在: ${pointer}`);
148
+ }
149
+ value = value[segment];
150
+ }
151
+ if (!['string', 'number', 'boolean'].includes(typeof value)) {
152
+ throw configError(repoName, `${fieldPath} 必须指向 string、number 或 boolean: ${pointer}`);
153
+ }
154
+ return value;
155
+ }
156
+
157
+ async function loadVersionSources(sourceRoot, repo) {
158
+ const definitions = repo.site.versionSources || {};
159
+ const cache = new Map();
160
+ const tokens = {};
161
+ const values = [];
162
+ const paths = new Set();
163
+ for (const [token, definition] of Object.entries(definitions)) {
164
+ const fieldPath = `site.versionSources.${token}`;
165
+ const sourceAbs = await containedOrConfig(sourceRoot, definition.source, repo.name, `${fieldPath}.source`);
166
+ let sourceStats;
167
+ try {
168
+ sourceStats = await stat(sourceAbs);
169
+ } catch (cause) {
170
+ throw configError(repo.name, `${fieldPath}.source 不可读取: ${definition.source}`, cause);
171
+ }
172
+ if (!sourceStats.isFile()) {
173
+ throw configError(repo.name, `${fieldPath}.source 必须指向 JSON 文件: ${definition.source}`);
174
+ }
175
+ let document = cache.get(definition.source);
176
+ if (document === undefined) {
177
+ try {
178
+ document = JSON.parse(await readFileContained(sourceRoot, definition.source, { encoding: 'utf8' }));
179
+ } catch (cause) {
180
+ if (cause instanceof SyntaxError) {
181
+ throw configError(repo.name, `${fieldPath}.source JSON 解析失败: ${definition.source}: ${cause.message}`, cause);
182
+ }
183
+ throw configError(repo.name, `${fieldPath}.source 读取失败: ${definition.source}`, cause);
184
+ }
185
+ cache.set(definition.source, document);
186
+ }
187
+ const rawValue = jsonPointerValue(document, definition.pointer, repo.name, `${fieldPath}.pointer`);
188
+ const renderedValue = `${definition.prefix || ''}${rawValue}${definition.suffix || ''}`;
189
+ tokens[token] = renderedValue;
190
+ values.push({ token, source: definition.source, pointer: definition.pointer, value: rawValue, renderedValue });
191
+ paths.add(posix.normalize(definition.source.replaceAll('\\', '/')));
192
+ }
193
+ return { tokens, values, paths: [...paths].sort() };
194
+ }
195
+
196
+ async function artifactGraphLockDigest(sourceRoot, repo) {
197
+ const configPath = 'artifact-graph.config.yaml';
198
+ const lockPath = 'artifacts/traceability-version-lock.json';
199
+ const configAbs = await containedOrConfig(sourceRoot, configPath, repo.name, 'artifact-graph config');
200
+ if (!existsSync(configAbs)) return undefined;
201
+ const lockAbs = await containedOrConfig(sourceRoot, lockPath, repo.name, 'artifact-graph version lock');
202
+ if (!existsSync(lockAbs)) {
203
+ throw configError(repo.name, `已启用 artifact-graph,但未找到 ${lockPath}`);
204
+ }
205
+ try {
206
+ return digestBytes(await readFileContained(sourceRoot, lockPath));
207
+ } catch (cause) {
208
+ throw configError(repo.name, `${lockPath} 读取失败`, cause);
209
+ }
210
+ }
211
+
212
+ async function computeCoverageSnapshot(sourceRoot, repo) {
213
+ const coverage = repo.site.coverage;
214
+ if (!coverage) throw configError(repo.name, 'site.coverage 未配置');
215
+ const lockPath = posix.normalize(coverage.lock.replaceAll('\\', '/'));
216
+ await containedOrConfig(sourceRoot, coverage.lock, repo.name, 'site.coverage.lock');
217
+ const versionSources = await loadVersionSources(sourceRoot, repo);
218
+ if (versionSources.paths.includes(lockPath)) {
219
+ throw configError(repo.name, 'site.coverage.lock 不能同时作为版本源');
220
+ }
221
+
222
+ const files = new Set();
223
+ for (const [index, pattern] of coverage.inputs.entries()) {
224
+ const fieldPath = `site.coverage.inputs[${index}]`;
225
+ if (pattern.split('/').includes('..')) {
226
+ throw configError(repo.name, `${fieldPath} 不能包含 .. 路径段: ${pattern}`);
227
+ }
228
+ // 首版只接受 * / **,避免 brace、extglob 或字符类先展开再越过收容检查。
229
+ if (/[?\[\]{}()!]/.test(pattern)) {
230
+ throw configError(repo.name, `${fieldPath} 只支持 * 与 ** 通配符: ${pattern}`);
231
+ }
232
+ await containedOrConfig(sourceRoot, pattern.replaceAll('*', '__coverage_glob__'), repo.name, fieldPath);
233
+ const matches = [];
234
+ try {
235
+ for await (const candidate of glob(pattern, { cwd: sourceRoot })) {
236
+ const normalized = posix.normalize(candidate.replaceAll('\\', '/'));
237
+ if (normalized === '.' || normalized === lockPath) continue;
238
+ const candidateAbs = await containedOrConfig(
239
+ sourceRoot,
240
+ normalized,
241
+ repo.name,
242
+ fieldPath,
243
+ );
244
+ const candidateStats = await stat(candidateAbs);
245
+ if (candidateStats.isFile()) matches.push(normalized);
246
+ }
247
+ } catch (cause) {
248
+ if (cause instanceof RenderError) throw cause;
249
+ throw configError(repo.name, `${fieldPath} 无法展开: ${pattern}`, cause);
250
+ }
251
+ if (!matches.length) {
252
+ throw configError(repo.name, `${fieldPath} 没有匹配任何输入文件: ${pattern}`);
253
+ }
254
+ for (const match of matches) files.add(match);
255
+ }
256
+ for (const versionPath of versionSources.paths) {
257
+ await containedOrConfig(sourceRoot, versionPath, repo.name, 'site.versionSources.source');
258
+ files.add(versionPath);
259
+ }
260
+
261
+ let closure;
262
+ try {
263
+ closure = await computeResourceClosure({
264
+ root: sourceRoot,
265
+ resources: [...files].sort().map((path) => ({ path, role: 'input' })),
266
+ });
267
+ } catch (cause) {
268
+ throw configError(repo.name, '覆盖输入摘要计算失败', cause);
269
+ }
270
+ const snapshot = {
271
+ sha256: closure.digest,
272
+ inputs: closure.resources.map(({ path, sha256 }) => ({ path, sha256 })),
273
+ };
274
+ const versionLockSha256 = await artifactGraphLockDigest(sourceRoot, repo);
275
+ if (versionLockSha256 !== undefined) {
276
+ snapshot.artifactGraphVersionLockSha256 = versionLockSha256;
277
+ }
278
+ assertValid(snapshot, siteCoverageLockSchema, `${repo.name} site-coverage-lock.json`);
279
+ return { snapshot, versionSources };
280
+ }
281
+
282
+ function git(root, args, repoName) {
283
+ try {
284
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
285
+ } catch (cause) {
286
+ const detail = String(cause.stderr || cause.message).trim();
287
+ throw configError(repoName, `Git 命令失败: git ${args.join(' ')}${detail ? `: ${detail}` : ''}`, cause);
288
+ }
289
+ }
290
+
291
+ function splitLines(text) {
292
+ return text ? text.split(/\r?\n/).filter(Boolean) : [];
293
+ }
294
+
295
+ function shellQuote(value) {
296
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
297
+ }
298
+
299
+ function coverageGitStatus(sourceRoot, repo, snapshot, baseline) {
300
+ const gitRoot = git(sourceRoot, ['rev-parse', '--show-toplevel'], repo.name);
301
+ const head = git(sourceRoot, ['rev-parse', 'HEAD'], repo.name);
302
+ const lockAbs = resolve(sourceRoot, repo.site.coverage.lock);
303
+ const lockGitPath = relative(gitRoot, lockAbs).replaceAll('\\', '/');
304
+ const coverageCommit = git(sourceRoot, ['log', '-1', '--format=%H', '--', lockGitPath], repo.name) || null;
305
+ const sourceGitPath = relative(gitRoot, sourceRoot).replaceAll('\\', '/') || '.';
306
+ const knownInputs = new Set([...(baseline?.inputs || []), ...snapshot.inputs].map((entry) => {
307
+ return relative(gitRoot, resolve(sourceRoot, entry.path)).replaceAll('\\', '/');
308
+ }));
309
+ if (snapshot.artifactGraphVersionLockSha256 || baseline?.artifactGraphVersionLockSha256) {
310
+ knownInputs.add(
311
+ relative(gitRoot, resolve(sourceRoot, 'artifacts/traceability-version-lock.json')).replaceAll('\\', '/'),
312
+ );
313
+ }
314
+ const changed = new Set();
315
+ const collect = (lines) => {
316
+ for (const path of lines) if (knownInputs.has(path)) changed.add(path);
317
+ };
318
+ if (coverageCommit) {
319
+ collect(splitLines(git(sourceRoot, ['diff', '--name-only', '--no-renames', `${coverageCommit}..${head}`], repo.name)));
320
+ } else {
321
+ for (const path of knownInputs) changed.add(path);
322
+ }
323
+ collect(splitLines(git(sourceRoot, ['diff', '--cached', '--name-only', '--no-renames'], repo.name)));
324
+ collect(splitLines(git(sourceRoot, ['diff', '--name-only', '--no-renames'], repo.name)));
325
+ collect(splitLines(git(sourceRoot, ['ls-files', '--others', '--exclude-standard'], repo.name)));
326
+
327
+ const suggestions = coverageCommit
328
+ ? [
329
+ `git diff ${coverageCommit}..HEAD -- ${shellQuote(sourceGitPath)}`,
330
+ `git diff --cached -- ${shellQuote(sourceGitPath)}`,
331
+ `git diff -- ${shellQuote(sourceGitPath)}`,
332
+ ]
333
+ : [
334
+ `git status --short -- ${shellQuote(sourceGitPath)}`,
335
+ `git diff HEAD -- ${shellQuote(sourceGitPath)}`,
336
+ ];
337
+ return { coverageCommit, changed: [...changed].sort(), suggestions };
338
+ }
339
+
340
+ async function readCoverageBaseline(sourceRoot, repo) {
341
+ const lockPath = repo.site.coverage.lock;
342
+ const lockAbs = await containedOrConfig(sourceRoot, lockPath, repo.name, 'site.coverage.lock');
343
+ if (!existsSync(lockAbs)) return null;
344
+ let baseline;
345
+ try {
346
+ baseline = JSON.parse(await readFileContained(sourceRoot, lockPath, { encoding: 'utf8' }));
347
+ } catch (cause) {
348
+ throw configError(repo.name, `覆盖快照 JSON 解析失败: ${lockPath}: ${cause.message}`, cause);
349
+ }
350
+ assertValid(baseline, siteCoverageLockSchema, `${repo.name} site-coverage-lock.json`);
351
+ return baseline;
352
+ }
353
+
354
+ async function refreshCoverageLock(root, repo) {
355
+ const sourceRoot = await repoSourceRoot(root, repo);
356
+ // 已存在的写入目标必须已经是覆盖快照,避免错误配置把普通项目文件当作锁覆盖。
357
+ await readCoverageBaseline(sourceRoot, repo);
358
+ const { snapshot } = await computeCoverageSnapshot(sourceRoot, repo);
359
+ try {
360
+ await writeFileAtomic(sourceRoot, repo.site.coverage.lock, `${JSON.stringify(snapshot, null, 2)}\n`);
361
+ } catch (cause) {
362
+ throw configError(repo.name, `覆盖快照写入失败: ${repo.site.coverage.lock}`, cause);
363
+ }
364
+ console.log(`[render-public-site --refresh-coverage] ${repo.name}: 已写入 ${repo.site.coverage.lock}`);
365
+ console.log(`[render-public-site --refresh-coverage] ${repo.name}: coverage-sha256=${snapshot.sha256}`);
366
+ }
367
+
368
+ async function reportCoverageStatus(root, repo) {
369
+ if (!repo.site.coverage) throw configError(repo.name, 'site.coverage 未配置');
370
+ const sourceRoot = await repoSourceRoot(root, repo);
371
+ const baseline = await readCoverageBaseline(sourceRoot, repo);
372
+ const { snapshot, versionSources } = await computeCoverageSnapshot(sourceRoot, repo);
373
+ const gitStatus = coverageGitStatus(sourceRoot, repo, snapshot, baseline);
374
+ const consistent = baseline !== null
375
+ && baseline.sha256 === snapshot.sha256
376
+ && JSON.stringify(baseline.inputs) === JSON.stringify(snapshot.inputs)
377
+ && baseline.artifactGraphVersionLockSha256 === snapshot.artifactGraphVersionLockSha256;
378
+ const state = baseline === null ? '缺失' : consistent ? '一致' : '落后';
379
+ console.log(`[render-public-site --status] ${repo.name}: ${state}`);
380
+ console.log(` 覆盖提交: ${gitStatus.coverageCommit || '(覆盖快照尚未提交)'}`);
381
+ console.log(` 当前输入摘要: ${snapshot.sha256}`);
382
+ console.log(` 快照输入摘要: ${baseline?.sha256 || '(缺失)'}`);
383
+ if (snapshot.artifactGraphVersionLockSha256 !== undefined) {
384
+ console.log(` artifact-graph 版本锁摘要: ${snapshot.artifactGraphVersionLockSha256}`);
385
+ }
386
+ console.log(' 变化文件:');
387
+ if (gitStatus.changed.length) {
388
+ for (const path of gitStatus.changed) console.log(` - ${path}`);
389
+ } else {
390
+ console.log(' - (无)');
391
+ }
392
+ console.log(' 版本源当前值:');
393
+ if (versionSources.values.length) {
394
+ for (const item of versionSources.values) {
395
+ console.log(` - ${item.token}=${item.renderedValue} (${item.source}#${item.pointer})`);
396
+ }
397
+ } else {
398
+ console.log(' - (未配置)');
399
+ }
400
+ console.log(' 建议 git diff:');
401
+ for (const command of gitStatus.suggestions) console.log(` ${command}`);
402
+ if (!consistent) {
403
+ throw new RenderError(`[render-public-site --status] ${repo.name}: 覆盖快照${baseline === null ? '缺失' : '落后'}`, 1);
404
+ }
405
+ }
406
+
108
407
  // --- 树摘要(基于 computeResourceClosure,排除 site-baseline.json 自身,防 hash 循环) ---
109
408
  async function treeDigest(rootAbs, files) {
110
409
  const closure = await computeResourceClosure({
@@ -183,13 +482,15 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
183
482
  return `<footer class="site">\n <div>© ${owner} · <a href="https://www.apache.org/licenses/LICENSE-2.0" rel="noopener noreferrer">Apache-2.0</a></div>\n <div class="generated">generated by skill-family-doc-render</div>\n</footer>`;
184
483
  }
185
484
 
186
- // 版本占位符:按 repo 名自动派生(@{NAME}_TAG@ / @{NAME}_VERSION@)+ site.tokens 静态补充
485
+ // 版本占位符:保留按 repo 名自动派生的兼容 token;显式 JSON 版本源最后覆盖静态 token。
187
486
  const version = await pkgVersion(root, repo.source, repo.name);
487
+ const versionSources = await loadVersionSources(sourceAbs, repo);
188
488
  const tn = tokenName(repo.name);
189
489
  const tokens = {
190
490
  [`@${tn}_TAG@`]: (repo.tagPrefix || `${repo.name}-v`) + version,
191
491
  [`@${tn}_VERSION@`]: version,
192
492
  ...(site.tokens || {}),
493
+ ...versionSources.tokens,
193
494
  };
194
495
  function replaceTokens(s) {
195
496
  let out = s;
@@ -353,7 +654,8 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
353
654
  }
354
655
 
355
656
  // === 主流程 ===
356
- // 闭集参数解析:允许参数只有 --check / --assert-git / --repo <name> / --help / -h。
657
+ // 闭集参数解析:允许参数只有 --check / --assert-git / --status /
658
+ // --refresh-coverage / --repo <name> / --help / -h。
357
659
  // 未知选项、游离位置参数、重复取值参数、--repo 缺值或值本身是另一个选项,
358
660
  // 均以 RenderError(exit 2)失败,错误信息指出具体参数。不引入 CLI 框架。
359
661
  // --help / -h 由 main() 统一呈现:闭集解析先于配置读取与写盘,打印 USAGE_TEXT 后退出 0。
@@ -364,11 +666,22 @@ Usage:
364
666
  skill-family-doc-render --check read-only drift check against site-baseline.json
365
667
  skill-family-doc-render --repo <name> render / check a single repo by name
366
668
  skill-family-doc-render --assert-git also assert rendered files are git-tracked
669
+ skill-family-doc-render --status --repo <name>
670
+ read-only coverage status and Git changes
671
+ skill-family-doc-render --refresh-coverage --repo <name>
672
+ refresh only site-coverage-lock.json
367
673
 
368
674
  Reads public-release.json from the current working directory.`;
369
675
 
370
676
  function parseArgs(argv) {
371
- const flags = { check: false, assertGit: false, repoArg: null, help: false };
677
+ const flags = {
678
+ check: false,
679
+ assertGit: false,
680
+ status: false,
681
+ refreshCoverage: false,
682
+ repoArg: null,
683
+ help: false,
684
+ };
372
685
  let i = 0;
373
686
  while (i < argv.length) {
374
687
  const arg = argv[i];
@@ -376,6 +689,10 @@ function parseArgs(argv) {
376
689
  flags.check = true;
377
690
  } else if (arg === '--assert-git') {
378
691
  flags.assertGit = true;
692
+ } else if (arg === '--status') {
693
+ flags.status = true;
694
+ } else if (arg === '--refresh-coverage') {
695
+ flags.refreshCoverage = true;
379
696
  } else if (arg === '--help' || arg === '-h') {
380
697
  flags.help = true;
381
698
  } else if (arg === '--repo') {
@@ -395,6 +712,15 @@ function parseArgs(argv) {
395
712
  }
396
713
  i += 1;
397
714
  }
715
+ if (flags.status && flags.refreshCoverage) {
716
+ throw new RenderError('[render-public-site] 参数错误: --status 与 --refresh-coverage 不能同时使用', 2);
717
+ }
718
+ if ((flags.status || flags.refreshCoverage) && (flags.check || flags.assertGit)) {
719
+ throw new RenderError('[render-public-site] 参数错误: 覆盖命令不能与 --check 或 --assert-git 组合', 2);
720
+ }
721
+ if ((flags.status || flags.refreshCoverage) && flags.repoArg === null) {
722
+ throw new RenderError('[render-public-site] 参数错误: --status 与 --refresh-coverage 必须配合 --repo <name>', 2);
723
+ }
398
724
  return flags;
399
725
  }
400
726
 
@@ -404,7 +730,7 @@ function parseArgs(argv) {
404
730
  // 单个 repo 内部(含泄漏扫描命中)保持 fail-fast。
405
731
  export async function main(argv = process.argv.slice(2), { cwd = process.cwd() } = {}) {
406
732
  // 闭集解析在读取任何配置或写盘之前完成;参数失败时目标树一个字节都不动。
407
- const { check, assertGit, repoArg, help } = parseArgs(argv);
733
+ const { check, assertGit, status, refreshCoverage, repoArg, help } = parseArgs(argv);
408
734
  if (help) {
409
735
  console.log(USAGE_TEXT);
410
736
  return 0;
@@ -442,13 +768,15 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
442
768
  const results = [];
443
769
  for (const repo of selected) {
444
770
  try {
445
- await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
771
+ if (status) await reportCoverageStatus(root, repo);
772
+ else if (refreshCoverage) await refreshCoverageLock(root, repo);
773
+ else await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
446
774
  results.push({ name: repo.name, ok: true });
447
775
  } catch (err) {
448
776
  results.push({
449
777
  name: repo.name,
450
778
  ok: false,
451
- exitCode: err instanceof RenderError ? err.exitCode : 1,
779
+ exitCode: Number.isInteger(err?.exitCode) ? err.exitCode : 1,
452
780
  message: err.message,
453
781
  cause: err,
454
782
  });