@brandon_m_behring/book-scaffold-astro 5.0.0 → 5.1.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/CLAUDE.md +27 -3
- package/README.md +34 -0
- package/bin/book-scaffold.mjs +4 -0
- package/dist/index.mjs +6 -2
- package/package.json +2 -1
- package/recipes/25-qa-readiness.md +197 -0
- package/recipes/README.md +1 -0
- package/scripts/init-qa.mjs +209 -0
- package/scripts/qa-core.mjs +1511 -0
- package/scripts/qa.mjs +293 -0
- package/scripts/resolve-book-config.mjs +39 -0
- package/scripts/validate-core.mjs +1675 -0
- package/scripts/validate.mjs +18 -1492
- package/scripts/validation-artifacts.mjs +23 -0
package/CLAUDE.md
CHANGED
|
@@ -50,9 +50,11 @@ those directories belong to scaffold content collections, not chapter owners.
|
|
|
50
50
|
Keep every lookup book-scoped: navigation/previous-next, labels, references,
|
|
51
51
|
tips, exercises, questions, and glossary entries must select the current
|
|
52
52
|
manifest book. Corpus JSON is `{ schemaVersion: 1, books: { [id]: payload } }`;
|
|
53
|
-
single-book JSON remains flat.
|
|
54
|
-
`build-
|
|
55
|
-
|
|
53
|
+
single-book JSON remains flat. The content-derived `build-labels`, `build-bib`,
|
|
54
|
+
`build-tips`, `build-exercises`, and `validate` commands accept `--book <id>`
|
|
55
|
+
in corpus mode. `qa` defaults to all manifest books, accepts either one exact
|
|
56
|
+
`--book <id>` or `--all`, and rejects `--book` in single-book mode. Figures and
|
|
57
|
+
notebooks remain application-wide.
|
|
56
58
|
|
|
57
59
|
Convergence collateral lives at `changelog/<book>/patterns.yaml` and
|
|
58
60
|
`changelog/<book>/tools/*.yaml`; root-level v4 collateral is single-book only.
|
|
@@ -201,6 +203,7 @@ npm install # once after clone
|
|
|
201
203
|
npm run dev # localhost:4321
|
|
202
204
|
npm run build # astro build + pagefind index → dist/
|
|
203
205
|
npm run validate # pre-flight check (recipe 09)
|
|
206
|
+
npm exec -- book-scaffold qa # content-readiness verdict (recipe 25)
|
|
204
207
|
npm run build:bib # rebuild references.json after .bib edit
|
|
205
208
|
npm run pdf # render dist-pdf/book.pdf via Paged.js
|
|
206
209
|
```
|
|
@@ -227,6 +230,27 @@ A consumer `public/_headers` wins unchanged. Use
|
|
|
227
230
|
or `securityHeaders: false` when another layer owns all headers. Recipe 05 has
|
|
228
231
|
the full precedence and customization contract.
|
|
229
232
|
|
|
233
|
+
## QA and content readiness
|
|
234
|
+
|
|
235
|
+
Run `npm exec -- book-scaffold qa` after substantive content changes. It calls
|
|
236
|
+
the same content-contract library as `validate`, then reports stable chapter,
|
|
237
|
+
link, learning-objective, component, and JSON-fixture facts. Human output is
|
|
238
|
+
the default. For CI use `npm --offline exec -- book-scaffold qa --format json`;
|
|
239
|
+
JSON stdout contains only the schema-v1 result, while progress/fatal details go
|
|
240
|
+
to stderr. Exit `0` means no blocking failure, `1` means a red selected/shared
|
|
241
|
+
result, and `2` means the invocation or execution failed.
|
|
242
|
+
|
|
243
|
+
In a corpus, omission checks every manifest book in order; `--book <id>` checks
|
|
244
|
+
one and `--all` states the default explicitly. Schema v1 always includes a
|
|
245
|
+
top-level `shared: { verdict, checks, diagnostics }` aggregate for corpus-wide
|
|
246
|
+
fixture data. Its exact single-book value is
|
|
247
|
+
`{ "verdict": "not_applicable", "checks": {}, "diagnostics": [] }`.
|
|
248
|
+
|
|
249
|
+
Run `npm exec -- book-scaffold init-qa` only when a portfolio QA engine needs
|
|
250
|
+
`guide_qa.yaml`. It emits one deterministic offline command per manifest book,
|
|
251
|
+
or one implicit `book` entry without `--book`. It preserves an existing file;
|
|
252
|
+
`--force` replaces that whole file and no other path. See Recipe 25.
|
|
253
|
+
|
|
230
254
|
## Validation
|
|
231
255
|
|
|
232
256
|
`npm run validate` (also runs in prebuild) catches:
|
package/README.md
CHANGED
|
@@ -49,6 +49,40 @@ local `<BookLink>` targets, and Pagefind results remain book-scoped. See
|
|
|
49
49
|
[Recipe 21](./recipes/21-multi-guide-single-app.md) for content layout, route
|
|
50
50
|
ownership, `--book` tooling, shared sources, and migration.
|
|
51
51
|
|
|
52
|
+
## Content readiness and CLI
|
|
53
|
+
|
|
54
|
+
`book-scaffold qa` combines the existing validation contract with stable
|
|
55
|
+
chapter, link, learning-objective, component, and JSON-fixture metrics. Human
|
|
56
|
+
output is the default; schema-v1 JSON is deterministic and keeps stdout clean
|
|
57
|
+
for CI:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm exec -- book-scaffold qa
|
|
61
|
+
npm --offline exec -- book-scaffold qa --format json
|
|
62
|
+
npm exec -- book-scaffold init-qa
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
In corpus mode `qa` checks every manifest book by default, while `--book <id>`
|
|
66
|
+
selects one exact id. `init-qa` creates the portfolio-engine interoperability
|
|
67
|
+
file `guide_qa.yaml`; its generated checks use `npm --offline exec` and an
|
|
68
|
+
existing file is preserved unless `--force` is explicit. See
|
|
69
|
+
[Recipe 25](./recipes/25-qa-readiness.md) for verdicts, exit codes, the
|
|
70
|
+
top-level `shared` aggregate, and CI wiring.
|
|
71
|
+
|
|
72
|
+
The installed `book-scaffold` dispatcher owns these commands:
|
|
73
|
+
|
|
74
|
+
| Command | Purpose |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `validate` | Pre-flight authored content, references, figures, and links |
|
|
77
|
+
| `qa` | Emit a human or schema-v1 readiness verdict |
|
|
78
|
+
| `init-qa` | Generate deterministic `guide_qa.yaml` interoperability config |
|
|
79
|
+
| `build-labels` | Build cross-reference and heading indexes |
|
|
80
|
+
| `build-bib` | Build bibliography and source-manifest data |
|
|
81
|
+
| `build-tips` | Build the tips index |
|
|
82
|
+
| `build-exercises` | Build the exercises index |
|
|
83
|
+
| `build-figures` | Convert and theme application-wide figure assets |
|
|
84
|
+
| `render-notebooks` | Render application-wide notebook companions |
|
|
85
|
+
|
|
52
86
|
Astro builds emit a Cloudflare-compatible `dist/_headers` with audited
|
|
53
87
|
security defaults. A consumer-owned `public/_headers` wins unchanged;
|
|
54
88
|
`defineBookConfig({ securityHeaders: false })` disables scaffold emission,
|
package/bin/book-scaffold.mjs
CHANGED
|
@@ -14,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
14
14
|
|
|
15
15
|
const handlers = {
|
|
16
16
|
validate: '../scripts/validate.mjs',
|
|
17
|
+
qa: '../scripts/qa.mjs',
|
|
18
|
+
'init-qa': '../scripts/init-qa.mjs',
|
|
17
19
|
'build-labels': '../scripts/build-labels.mjs',
|
|
18
20
|
'build-bib': '../scripts/build-bib.mjs',
|
|
19
21
|
'build-figures': '../scripts/build-figures.mjs',
|
|
@@ -26,6 +28,8 @@ const HELP = `Usage: book-scaffold <sub-command> [args...]
|
|
|
26
28
|
|
|
27
29
|
Sub-commands:
|
|
28
30
|
validate Pre-flight content validator (XRef ids, Cite keys, Figure srcs).
|
|
31
|
+
qa Content-health and readiness report (human or schema-v1 JSON).
|
|
32
|
+
init-qa Generate deterministic guide_qa.yaml interoperability config.
|
|
29
33
|
build-labels Emit src/data/labels.json for cross-references (Phase C).
|
|
30
34
|
build-bib BibTeX -> references.json (+ sources/manifest.yaml -> sources.json).
|
|
31
35
|
build-figures PDF -> SVG via pdftocairo / pdftoppm fallback (+ TikZ in v4.2.0).
|
package/dist/index.mjs
CHANGED
|
@@ -1623,6 +1623,9 @@ function bookScaffoldIntegration(opts) {
|
|
|
1623
1623
|
),
|
|
1624
1624
|
frontmatter: fmEnabled
|
|
1625
1625
|
};
|
|
1626
|
+
const enabledRouteNames = Object.freeze(
|
|
1627
|
+
Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name)
|
|
1628
|
+
);
|
|
1626
1629
|
if (corpus) {
|
|
1627
1630
|
for (const book of corpus.books) {
|
|
1628
1631
|
for (const route of book.apparatus ?? []) {
|
|
@@ -1693,7 +1696,6 @@ function bookScaffoldIntegration(opts) {
|
|
|
1693
1696
|
const resolvedGithubRepo = resolveBookGithubRepo(githubRepo, consumerRoot);
|
|
1694
1697
|
const resolvedGithubBranch = githubBranch ?? DEFAULT_GITHUB_BRANCH;
|
|
1695
1698
|
const presetLiteral = JSON.stringify(profile);
|
|
1696
|
-
const enabledRouteNames = Object.entries(enabledRoutes).filter(([, on]) => on).map(([name]) => name);
|
|
1697
1699
|
updateConfig({
|
|
1698
1700
|
vite: {
|
|
1699
1701
|
plugins: [
|
|
@@ -1768,7 +1770,9 @@ function bookScaffoldIntegration(opts) {
|
|
|
1768
1770
|
chapterRoute: corpus ? "/chapters/:id/" : chapterRoute ?? "/chapters/:id/",
|
|
1769
1771
|
bookField: bookField ?? "book",
|
|
1770
1772
|
apparatusRoute: corpus ? "/:book/:route/" : apparatusRoute ?? "/:route/",
|
|
1771
|
-
apparatusRoutes: corpus ? inheritedCorpusApparatusRoutes : apparatusRoutes ?? []
|
|
1773
|
+
apparatusRoutes: corpus ? inheritedCorpusApparatusRoutes : apparatusRoutes ?? [],
|
|
1774
|
+
enabledRoutes: enabledRouteNames,
|
|
1775
|
+
frontmatterRoute: frontmatterPatternFromPrefix(fmPrefix)
|
|
1772
1776
|
}),
|
|
1773
1777
|
enumerable: false,
|
|
1774
1778
|
configurable: false,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brandon_m_behring/book-scaffold-astro",
|
|
3
3
|
"description": "Astro 6 + MDX toolkit for long-form technical books with five typed presets, Tufte typography, citations, search, PDF, and Cloudflare deployment.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.1.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Brandon Behring",
|
|
@@ -203,6 +203,7 @@
|
|
|
203
203
|
"@citation-js/plugin-bibtex": "^0.7.21",
|
|
204
204
|
"@fontsource-variable/roboto": "^5.2.10",
|
|
205
205
|
"@fontsource-variable/source-code-pro": "^5.2.7",
|
|
206
|
+
"ajv": "^8.20.0",
|
|
206
207
|
"pagefind": "^1.5.2",
|
|
207
208
|
"parse5": "^7.3.0",
|
|
208
209
|
"remark-math": "^6.0.0",
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Recipe 25 — QA and content readiness
|
|
2
|
+
|
|
3
|
+
**Profile**: any, in single-book or corpus mode.
|
|
4
|
+
|
|
5
|
+
**TL;DR**: `book-scaffold qa` turns the scaffold's content contract and stable
|
|
6
|
+
MDX facts into one CI-safe readiness verdict. Human output is the default;
|
|
7
|
+
`--format json` emits deterministic schema-v1 JSON. Use
|
|
8
|
+
`book-scaffold init-qa` only when a portfolio QA engine needs a network-free
|
|
9
|
+
`guide_qa.yaml` registry.
|
|
10
|
+
|
|
11
|
+
## Run the readiness check
|
|
12
|
+
|
|
13
|
+
Run the locally installed binary from the book root:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm exec -- book-scaffold qa
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This checks the implicit book in a single-book project. In a corpus, omission
|
|
20
|
+
of a selector checks every manifest book in manifest order. Select one exact
|
|
21
|
+
registered id or spell out the all-books default with:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm exec -- book-scaffold qa --book evaluation
|
|
25
|
+
npm exec -- book-scaffold qa --all
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`--book` is invalid in single-book mode. `--book` and `--all` are mutually
|
|
29
|
+
exclusive; an unknown book id is an invocation error rather than an empty
|
|
30
|
+
report.
|
|
31
|
+
|
|
32
|
+
## What QA means
|
|
33
|
+
|
|
34
|
+
QA reuses the same content-contract library as `book-scaffold validate`, then
|
|
35
|
+
adds deterministic readiness facts:
|
|
36
|
+
|
|
37
|
+
| Check | Blocking condition |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `content_contract` | Any validation error |
|
|
40
|
+
| `chapters` | No non-draft chapter |
|
|
41
|
+
| `links` | Any broken internal target or fragment |
|
|
42
|
+
| `learning_objectives` | Less than 100% anchor coverage when objectives apply |
|
|
43
|
+
| `components` | Never blocking; counts scaffold MDX components |
|
|
44
|
+
| `demo_fixtures` | Invalid non-generated JSON or a failing referenced schema |
|
|
45
|
+
|
|
46
|
+
An unavailable metric is `not_applicable`, not a fabricated zero. Component
|
|
47
|
+
counts are inventory facts, not universal prose-quality scores. `qa` also does
|
|
48
|
+
not replace `astro build`: rendering, content-collection Zod checks, and KaTeX
|
|
49
|
+
remain build responsibilities.
|
|
50
|
+
|
|
51
|
+
The traffic-light states are `green`, `amber`, `red`, and `not_applicable`.
|
|
52
|
+
Amber advisories stay visible but do not fail CI. Exit status is:
|
|
53
|
+
|
|
54
|
+
| Exit | Meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `0` | No blocking failure (`green` or `amber`) |
|
|
57
|
+
| `1` | At least one selected book or corpus-shared check is `red` |
|
|
58
|
+
| `2` | Invalid invocation, unresolved configuration, or internal failure |
|
|
59
|
+
|
|
60
|
+
The link check inspects internal links authored in chapter Markdown/MDX. It
|
|
61
|
+
uses the resolved scaffold route toggles, Astro page conventions, and public
|
|
62
|
+
assets as its route oracle. Fragments that depend on a consumer-defined MDX
|
|
63
|
+
component or a known non-chapter route are reported as amber
|
|
64
|
+
`fragment_unverified` advisories instead of guessed successes or blocking
|
|
65
|
+
failures.
|
|
66
|
+
|
|
67
|
+
Schema-v1 fixture validation supports JSON Schema draft-07 (also the default
|
|
68
|
+
when `$schema` is absent), 2019-09, and 2020-12. Recursive `$ref` resources must
|
|
69
|
+
remain inside the project after symlink resolution; QA never fetches network
|
|
70
|
+
schemas. The JSON Schema `format` keyword is annotation-only in v1. Unsupported
|
|
71
|
+
or mixed dialects and out-of-project references make that fixture red.
|
|
72
|
+
|
|
73
|
+
## Human and JSON output
|
|
74
|
+
|
|
75
|
+
Human output is compact and terminal-oriented:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm exec -- book-scaffold qa --format human
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For automation, use the stable JSON form (`--json` is an alias):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm --offline exec -- book-scaffold qa --format json > qa-result.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
JSON stdout contains only the schema-v1 document; progress and fatal
|
|
88
|
+
diagnostics use stderr. The document omits timestamps and durations, preserves
|
|
89
|
+
manifest/source order, and always contains `books`, `shared`, and `summary`.
|
|
90
|
+
|
|
91
|
+
`books` contains only the implicit `book` result or registered manifest ids.
|
|
92
|
+
`shared` is never a synthetic book. It is an always-present aggregate shaped
|
|
93
|
+
like a book result:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"verdict": "not_applicable",
|
|
98
|
+
"checks": {},
|
|
99
|
+
"diagnostics": []
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
That exact value is used in single-book mode. In corpus mode,
|
|
104
|
+
`shared.checks.demo_fixtures` holds the normal `{ state, metrics,
|
|
105
|
+
diagnosticIds }` check payload for JSON under `src/data/` outside a registered
|
|
106
|
+
book directory. A shared failure affects the top-level corpus verdict and
|
|
107
|
+
summary, never an individual book verdict. Its diagnostics use
|
|
108
|
+
`book: "corpus"` rather than borrowing a manifest id.
|
|
109
|
+
|
|
110
|
+
## Generate `guide_qa.yaml`
|
|
111
|
+
|
|
112
|
+
Portfolio-level QA engines can discover the scaffold check without a custom
|
|
113
|
+
consumer wrapper:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm exec -- book-scaffold init-qa
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A single-book project gets this deterministic file:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
# Generated by book-scaffold init-qa.
|
|
123
|
+
# Regenerate with: book-scaffold init-qa --force
|
|
124
|
+
version: 1
|
|
125
|
+
guides:
|
|
126
|
+
- id: book
|
|
127
|
+
check_cmd: npm --offline exec -- book-scaffold qa --format json
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A corpus gets one entry per manifest book, in manifest order, with an exact
|
|
131
|
+
selector:
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
# Generated by book-scaffold init-qa.
|
|
135
|
+
# Regenerate with: book-scaffold init-qa --force
|
|
136
|
+
version: 1
|
|
137
|
+
guides:
|
|
138
|
+
- id: evaluation
|
|
139
|
+
check_cmd: npm --offline exec -- book-scaffold qa --book evaluation --format json
|
|
140
|
+
- id: llm-app-engineering
|
|
141
|
+
check_cmd: npm --offline exec -- book-scaffold qa --book llm-app-engineering --format json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The generated `npm --offline exec --` commands can use only the installed
|
|
145
|
+
toolkit; they never fetch a package. `book-scaffold qa` does not read or
|
|
146
|
+
execute `guide_qa.yaml` itself.
|
|
147
|
+
|
|
148
|
+
An existing file is preserved and makes `init-qa` fail. Regenerate explicitly
|
|
149
|
+
with:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
npm exec -- book-scaffold init-qa --force
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`--force` replaces the whole `guide_qa.yaml` and changes no other file. A
|
|
156
|
+
portfolio engine may add presentation fields, but forced regeneration removes
|
|
157
|
+
those edits.
|
|
158
|
+
|
|
159
|
+
## CI wiring
|
|
160
|
+
|
|
161
|
+
After the dependency install, offline execution prevents a typo or missing
|
|
162
|
+
local binary from becoming an implicit registry download:
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
- run: npm ci
|
|
166
|
+
- run: npm --offline exec -- book-scaffold qa --format json > qa-result.json
|
|
167
|
+
- run: npm run build
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Keep the build step. QA supplies a content-health verdict; the production
|
|
171
|
+
build remains the render/deployment gate.
|
|
172
|
+
|
|
173
|
+
## Common gotchas
|
|
174
|
+
|
|
175
|
+
- **Treating N/A as zero** — unavailable profile/content metrics are explicitly
|
|
176
|
+
`not_applicable`.
|
|
177
|
+
- **Expecting `components` to grade prose** — counts are informational and
|
|
178
|
+
never impose cross-preset quotas.
|
|
179
|
+
- **Attributing shared corpus JSON to a book** — only files beneath
|
|
180
|
+
`src/data/<book>/` belong to that book. Other non-generated JSON is checked
|
|
181
|
+
once through the top-level `shared` result.
|
|
182
|
+
- **Counting generated indexes as fixtures** — scaffold outputs including
|
|
183
|
+
`labels.json`, `references.json`, `tips.json`, and `exercises.json` are
|
|
184
|
+
excluded.
|
|
185
|
+
- **Hand-editing generated commands** — use engine-owned presentation fields
|
|
186
|
+
if needed; `init-qa --force` intentionally restores canonical commands.
|
|
187
|
+
- **Using `--book` for a one-book project** — the implicit id is represented as
|
|
188
|
+
`book` in results and `guide_qa.yaml`, but it is not a selectable corpus id.
|
|
189
|
+
|
|
190
|
+
## Canonical files
|
|
191
|
+
|
|
192
|
+
- `scripts/qa-core.mjs` — deterministic check and aggregate engine
|
|
193
|
+
- `scripts/init-qa.mjs` — deterministic, overwrite-safe registry generator
|
|
194
|
+
- `scripts/validate-core.mjs` — shared validation library used by QA's
|
|
195
|
+
content contract
|
|
196
|
+
- `recipes/09-validation.md` — individual diagnostics and prebuild wiring
|
|
197
|
+
- `../../docs/plans/active/qa-contract.md` — accepted schema and selector contract
|
package/recipes/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Terse pointers into canonical code for the most common book-authoring workflows.
|
|
|
30
30
|
| 22 | [Responsive navigation and custom routing](22-responsive-nav-and-multibook-routing.md) | any | Mobile/desktop nav and the v4-compatible consumer-owned route-token API |
|
|
31
31
|
| 23 | [Interactive demo substrate](23-interactive-demo-substrate.md) | any | Opt-in Preact shell, slider, stat cards, theme colors, a11y, and reduced motion |
|
|
32
32
|
| 24 | [Figure authoring standard](24-figure-authoring-standard.md) | any | Warm–Tol semantics, Okabe–Ito series, dual-theme exports, contrast, and accessible descriptions |
|
|
33
|
+
| 25 | [QA and content readiness](25-qa-readiness.md) | any | Stable human/JSON readiness verdicts, corpus selection, CI, and `guide_qa.yaml` |
|
|
33
34
|
|
|
34
35
|
## How to read recipes
|
|
35
36
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate the portfolio QA engine's deterministic guide registry.
|
|
4
|
+
*
|
|
5
|
+
* This module deliberately owns only guide_qa.yaml generation. The scaffold
|
|
6
|
+
* QA command does not execute the generated shell commands, and this script
|
|
7
|
+
* never spawns npm or performs network work itself.
|
|
8
|
+
*/
|
|
9
|
+
import { access, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { constants as fsConstants } from 'node:fs';
|
|
11
|
+
import { relative, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
14
|
+
|
|
15
|
+
export const GUIDE_QA_FILENAME = 'guide_qa.yaml';
|
|
16
|
+
export const GUIDE_QA_HEADER =
|
|
17
|
+
'# Generated by book-scaffold init-qa.\n' +
|
|
18
|
+
'# Regenerate with: book-scaffold init-qa --force\n';
|
|
19
|
+
|
|
20
|
+
export const INIT_QA_USAGE = `Usage: book-scaffold init-qa [--force]
|
|
21
|
+
|
|
22
|
+
Create a deterministic guide_qa.yaml for the implicit single book or every
|
|
23
|
+
registered corpus book. Generated checks use the locally installed scaffold
|
|
24
|
+
binary in npm offline mode.
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--force Replace an existing guide_qa.yaml. No other file is changed.
|
|
28
|
+
--help, -h Print this message and exit without writing a file.
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const BOOK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
|
+
const COMMAND_PREFIX = 'npm --offline exec -- book-scaffold qa';
|
|
33
|
+
|
|
34
|
+
export class InitQaUsageError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'InitQaUsageError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class GuideQaExistsError extends Error {
|
|
42
|
+
constructor(path) {
|
|
43
|
+
super(`${GUIDE_QA_FILENAME} already exists; rerun with --force to replace it.`);
|
|
44
|
+
this.name = 'GuideQaExistsError';
|
|
45
|
+
this.code = 'EEXIST';
|
|
46
|
+
this.path = path;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse only the flags owned by init-qa. */
|
|
51
|
+
export function parseInitQaArgs(argv) {
|
|
52
|
+
if (!Array.isArray(argv)) {
|
|
53
|
+
throw new TypeError('init-qa arguments must be an array.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let force = false;
|
|
57
|
+
let help = false;
|
|
58
|
+
for (const arg of argv) {
|
|
59
|
+
if (arg === '--force') {
|
|
60
|
+
if (force) throw new InitQaUsageError('--force may be specified only once.');
|
|
61
|
+
force = true;
|
|
62
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
63
|
+
help = true;
|
|
64
|
+
} else {
|
|
65
|
+
throw new InitQaUsageError(`unknown argument ${JSON.stringify(arg)}.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({ force, help });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function guideIds(toolingConfig) {
|
|
72
|
+
if (toolingConfig === null || typeof toolingConfig !== 'object' || Array.isArray(toolingConfig)) {
|
|
73
|
+
throw new TypeError('init-qa requires resolved book tooling configuration.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (toolingConfig.corpus == null) return Object.freeze(['book']);
|
|
77
|
+
const books = toolingConfig.corpus.books;
|
|
78
|
+
if (!Array.isArray(books) || books.length === 0) {
|
|
79
|
+
throw new TypeError('init-qa requires a non-empty resolved corpus book list.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ids = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const [index, book] of books.entries()) {
|
|
85
|
+
const id = book?.id;
|
|
86
|
+
if (typeof id !== 'string' || !BOOK_ID_PATTERN.test(id)) {
|
|
87
|
+
throw new TypeError(`init-qa received an invalid corpus book id at index ${index}.`);
|
|
88
|
+
}
|
|
89
|
+
if (seen.has(id)) {
|
|
90
|
+
throw new TypeError(`init-qa received duplicate corpus book id ${JSON.stringify(id)}.`);
|
|
91
|
+
}
|
|
92
|
+
seen.add(id);
|
|
93
|
+
ids.push(id);
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze(ids);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Render the exact, stable interoperability document for resolved config. */
|
|
99
|
+
export function renderGuideQa(toolingConfig) {
|
|
100
|
+
const ids = guideIds(toolingConfig);
|
|
101
|
+
const corpus = toolingConfig.corpus != null;
|
|
102
|
+
const lines = [GUIDE_QA_HEADER.trimEnd(), 'version: 1', 'guides:'];
|
|
103
|
+
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
lines.push(` - id: ${id}`);
|
|
106
|
+
const selector = corpus ? ` --book ${id}` : '';
|
|
107
|
+
lines.push(` check_cmd: ${COMMAND_PREFIX}${selector} --format json`);
|
|
108
|
+
}
|
|
109
|
+
return `${lines.join('\n')}\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function pathExists(path) {
|
|
113
|
+
try {
|
|
114
|
+
await access(path, fsConstants.F_OK);
|
|
115
|
+
return true;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error?.code === 'ENOENT') return false;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the consumer config and write only guide_qa.yaml.
|
|
124
|
+
*
|
|
125
|
+
* `loadConfig` is injectable so callers and tests can reuse this filesystem
|
|
126
|
+
* boundary without evaluating an Astro config more than once.
|
|
127
|
+
*/
|
|
128
|
+
export async function initGuideQa({
|
|
129
|
+
projectRoot = process.cwd(),
|
|
130
|
+
force = false,
|
|
131
|
+
loadConfig = loadResolvedBookConfig,
|
|
132
|
+
} = {}) {
|
|
133
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
134
|
+
throw new TypeError('init-qa projectRoot must be a non-empty string.');
|
|
135
|
+
}
|
|
136
|
+
if (typeof force !== 'boolean') {
|
|
137
|
+
throw new TypeError('init-qa force must be a boolean.');
|
|
138
|
+
}
|
|
139
|
+
if (typeof loadConfig !== 'function') {
|
|
140
|
+
throw new TypeError('init-qa loadConfig must be a function.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const root = resolve(projectRoot);
|
|
144
|
+
const outputPath = resolve(root, GUIDE_QA_FILENAME);
|
|
145
|
+
if (!force && (await pathExists(outputPath))) {
|
|
146
|
+
throw new GuideQaExistsError(outputPath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const toolingConfig = await loadConfig(root);
|
|
150
|
+
const contents = renderGuideQa(toolingConfig);
|
|
151
|
+
try {
|
|
152
|
+
await writeFile(outputPath, contents, {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
flag: force ? 'w' : 'wx',
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!force && error?.code === 'EEXIST') throw new GuideQaExistsError(outputPath);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
path: outputPath,
|
|
163
|
+
contents,
|
|
164
|
+
guideIds: guideIds(toolingConfig),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Run the command with injectable streams and return its contract exit code. */
|
|
169
|
+
export async function runInitQaCli({
|
|
170
|
+
argv = process.argv.slice(2),
|
|
171
|
+
projectRoot = process.cwd(),
|
|
172
|
+
stdout = process.stdout,
|
|
173
|
+
stderr = process.stderr,
|
|
174
|
+
loadConfig = loadResolvedBookConfig,
|
|
175
|
+
} = {}) {
|
|
176
|
+
let options;
|
|
177
|
+
try {
|
|
178
|
+
options = parseInitQaArgs(argv);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
181
|
+
stderr.write(`init-qa: ${detail}\n\n${INIT_QA_USAGE}`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (options.help) {
|
|
186
|
+
stdout.write(INIT_QA_USAGE);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const result = await initGuideQa({ projectRoot, force: options.force, loadConfig });
|
|
192
|
+
const label = relative(resolve(projectRoot), result.path) || GUIDE_QA_FILENAME;
|
|
193
|
+
stdout.write(`init-qa: wrote ${label}\n`);
|
|
194
|
+
return 0;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
197
|
+
if (error instanceof GuideQaExistsError || error?.code === 'EEXIST') {
|
|
198
|
+
stderr.write(`init-qa: ${detail}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
stderr.write(`init-qa: fatal: ${detail}\n`);
|
|
202
|
+
return 2;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
207
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
208
|
+
process.exitCode = await runInitQaCli();
|
|
209
|
+
}
|