@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 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. Use `--book <id>` only with the content-derived
54
- `build-labels`, `build-bib`, `build-tips`, `build-exercises`, and `validate`
55
- commands. Figures and notebooks remain application-wide.
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,
@@ -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.0.0",
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
+ }