@mhosaic/feedback-cli 0.20.0 → 0.22.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.
Files changed (31) hide show
  1. package/dist/bin.js +6 -1
  2. package/dist/bin.js.map +1 -1
  3. package/dist/build-RCLWV2WL.js +565 -0
  4. package/dist/build-RCLWV2WL.js.map +1 -0
  5. package/dist/check-FOJPEE4Q.js +342 -0
  6. package/dist/check-FOJPEE4Q.js.map +1 -0
  7. package/dist/chunk-AZQSQJNQ.js +83 -0
  8. package/dist/chunk-AZQSQJNQ.js.map +1 -0
  9. package/dist/chunk-IHJPCMYF.js +126 -0
  10. package/dist/chunk-IHJPCMYF.js.map +1 -0
  11. package/dist/chunk-SSLQOK2Z.js +456 -0
  12. package/dist/chunk-SSLQOK2Z.js.map +1 -0
  13. package/dist/config-JE3QRZVH.js +8 -0
  14. package/dist/config-JE3QRZVH.js.map +1 -0
  15. package/dist/generate-VFRQNPOI.js +14 -0
  16. package/dist/generate-VFRQNPOI.js.map +1 -0
  17. package/dist/{install-skill-QJ4ZDVVR.js → install-skill-PO5YSXWY.js} +4 -2
  18. package/dist/{install-skill-QJ4ZDVVR.js.map → install-skill-PO5YSXWY.js.map} +1 -1
  19. package/dist/qa-YR4GCV6T.js +43 -0
  20. package/dist/qa-YR4GCV6T.js.map +1 -0
  21. package/dist/sitemap-react-QTEAUKN5.js +330 -0
  22. package/dist/sitemap-react-QTEAUKN5.js.map +1 -0
  23. package/dist/sitemap-vue-EJDQTCYM.js +243 -0
  24. package/dist/sitemap-vue-EJDQTCYM.js.map +1 -0
  25. package/package.json +4 -3
  26. package/skills/integrate-feedback/SKILL.md +7 -0
  27. package/skills/integrate-qa-meter/SKILL.md +201 -0
  28. package/skills/integrate-qa-meter/references/qa-meter-config.md +168 -0
  29. package/skills/integrate-qa-meter/references/qa-meter-troubleshooting.md +37 -0
  30. package/skills/integrate-qa-meter/references/qa-meter.config.example.json +20 -0
  31. package/skills/integrate-qa-meter/references/qa-meter.config.example.react.json +19 -0
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ buildSitemap
4
+ } from "./chunk-AZQSQJNQ.js";
5
+ import {
6
+ loadQaConfig
7
+ } from "./chunk-IHJPCMYF.js";
8
+
9
+ // src/qa/sitemap-vue.ts
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
11
+ import { dirname, isAbsolute, join, resolve } from "path";
12
+ import vm from "vm";
13
+ import ts from "typescript";
14
+ var STUB = new Proxy(function stub() {
15
+ }, {
16
+ get: (_t, prop) => prop === Symbol.toPrimitive || prop === Symbol.toStringTag ? () => "" : STUB,
17
+ apply: () => STUB,
18
+ construct: () => ({})
19
+ });
20
+ function isStub(v) {
21
+ return v === STUB;
22
+ }
23
+ function transpileToCjs(source, fileName) {
24
+ return ts.transpileModule(source, {
25
+ fileName,
26
+ compilerOptions: {
27
+ module: ts.ModuleKind.CommonJS,
28
+ target: ts.ScriptTarget.ES2020,
29
+ esModuleInterop: true,
30
+ isolatedModules: true
31
+ }
32
+ }).outputText;
33
+ }
34
+ function resolveInTree(specifier, fromDir, alias, confineDir) {
35
+ if (/\.(vue|json|css|scss|sass|less|svg|png|jpe?g|gif|webp|woff2?|ttf)$/i.test(specifier)) {
36
+ return null;
37
+ }
38
+ let base = null;
39
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
40
+ base = resolve(fromDir, specifier);
41
+ } else if (alias) {
42
+ for (const [key, target] of Object.entries(alias)) {
43
+ if (specifier === key || specifier.startsWith(`${key}/`)) {
44
+ const rest = specifier === key ? "" : specifier.slice(key.length + 1);
45
+ base = rest ? join(target, rest) : target;
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ if (base === null) return null;
51
+ const candidates = [
52
+ base,
53
+ `${base}.ts`,
54
+ `${base}.mjs`,
55
+ `${base}.js`,
56
+ join(base, "index.ts"),
57
+ join(base, "index.mjs"),
58
+ join(base, "index.js")
59
+ ];
60
+ for (const cand of candidates) {
61
+ try {
62
+ if (existsSync(cand) && statSync(cand).isFile()) {
63
+ return isWithin(confineDir, cand) ? cand : null;
64
+ }
65
+ } catch {
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ function isWithin(dir, child) {
71
+ const d = resolve(dir);
72
+ const c = resolve(child);
73
+ return c === d || c.startsWith(d.endsWith("/") ? d : `${d}/`);
74
+ }
75
+ function evaluateModule(absPath, alias, cache, confineDir) {
76
+ const cached = cache.get(absPath);
77
+ if (cached) return cached;
78
+ const moduleObj = { exports: {} };
79
+ cache.set(absPath, moduleObj.exports);
80
+ const source = readFileSync(absPath, "utf8");
81
+ const cjs = transpileToCjs(source, absPath);
82
+ const fromDir = dirname(absPath);
83
+ const requireFn = (specifier) => {
84
+ const resolved = resolveInTree(specifier, fromDir, alias, confineDir);
85
+ if (resolved) {
86
+ const exp = evaluateModule(resolved, alias, cache, confineDir);
87
+ return cache.get(resolved) ?? exp;
88
+ }
89
+ return STUB;
90
+ };
91
+ const context = vm.createContext({
92
+ module: moduleObj,
93
+ exports: moduleObj.exports,
94
+ require: requireFn,
95
+ console
96
+ });
97
+ vm.runInContext(cjs, context, { filename: absPath });
98
+ cache.set(absPath, moduleObj.exports);
99
+ return moduleObj.exports;
100
+ }
101
+ function findEntry(dir) {
102
+ for (const name of ["index.ts", "index.mjs", "index.js"]) {
103
+ const p = join(dir, name);
104
+ if (existsSync(p) && statSync(p).isFile()) return p;
105
+ }
106
+ const cache = /* @__PURE__ */ new Map();
107
+ for (const file of routerFiles(dir)) {
108
+ try {
109
+ const ns = evaluateModule(file, void 0, cache, dir);
110
+ if (pickRoutes(ns)) return file;
111
+ } catch {
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ function routerFiles(dir) {
117
+ const out = [];
118
+ const walk = (d) => {
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(d, { withFileTypes: true, encoding: "utf8" });
122
+ } catch {
123
+ return;
124
+ }
125
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
126
+ const full = join(d, entry.name);
127
+ if (entry.isDirectory()) {
128
+ walk(full);
129
+ } else if (/\.(ts|mjs|js)$/.test(entry.name)) {
130
+ out.push(full);
131
+ }
132
+ }
133
+ };
134
+ walk(dir);
135
+ return out;
136
+ }
137
+ function pickRoutes(ns) {
138
+ if (Array.isArray(ns.routes)) return ns.routes;
139
+ if (Array.isArray(ns.default)) return ns.default;
140
+ return null;
141
+ }
142
+ function flattenAndCheck(raw, warnings) {
143
+ const out = [];
144
+ for (const entry of raw) {
145
+ if (Array.isArray(entry)) {
146
+ out.push(...flattenAndCheck(entry, warnings));
147
+ continue;
148
+ }
149
+ if (isStub(entry)) {
150
+ warnings.push(
151
+ "a top-level route entry resolved to a STUB (an import could not be followed in-tree) \u2014 its subtree is missing from the sitemap"
152
+ );
153
+ continue;
154
+ }
155
+ if (entry === null || typeof entry !== "object") {
156
+ warnings.push(
157
+ `a top-level route entry is not a route object (got ${entry === null ? "null" : typeof entry}) \u2014 skipped`
158
+ );
159
+ continue;
160
+ }
161
+ out.push(entry);
162
+ }
163
+ return out;
164
+ }
165
+ function makeTitleFor(titlesFrom) {
166
+ if (!titlesFrom || !existsSync(titlesFrom)) return void 0;
167
+ let messages = {};
168
+ try {
169
+ const parsed = JSON.parse(readFileSync(titlesFrom, "utf8"));
170
+ if (parsed && typeof parsed === "object") messages = parsed;
171
+ } catch {
172
+ return void 0;
173
+ }
174
+ const pages = messages.pages && typeof messages.pages === "object" ? messages.pages : messages;
175
+ return (routeName) => {
176
+ if (!routeName) return void 0;
177
+ const v = pages[routeName];
178
+ return typeof v === "string" ? v : void 0;
179
+ };
180
+ }
181
+ function extractWithWarnings(opts) {
182
+ const warnings = [];
183
+ const dir = isAbsolute(opts.routerDir) ? opts.routerDir : resolve(opts.routerDir);
184
+ const entry = findEntry(dir);
185
+ let rawRoutes = null;
186
+ if (!entry) {
187
+ warnings.push(`No router entry (index.{ts,mjs,js}) found under ${dir}`);
188
+ } else {
189
+ try {
190
+ const cache = /* @__PURE__ */ new Map();
191
+ const ns = evaluateModule(entry, opts.alias, cache, dir);
192
+ rawRoutes = pickRoutes(ns);
193
+ if (!rawRoutes) {
194
+ warnings.push(`No \`routes\` export found in ${entry}`);
195
+ }
196
+ } catch (err) {
197
+ warnings.push(`${entry}: ${err instanceof Error ? err.message : String(err)}`);
198
+ }
199
+ }
200
+ const routes = rawRoutes ? flattenAndCheck(rawRoutes, warnings) : [];
201
+ const titleFor = makeTitleFor(opts.titlesFrom);
202
+ const payload = buildSitemap(routes, titleFor ? { titleFor } : {});
203
+ return { payload, warnings };
204
+ }
205
+ function extractVueRouterSitemap(opts) {
206
+ return extractWithWarnings(opts).payload;
207
+ }
208
+ async function runSitemap(_args) {
209
+ const cfg = loadQaConfig(process.cwd());
210
+ if (!cfg.sitemap || cfg.sitemap.framework !== "vue-router") {
211
+ process.stderr.write(
212
+ 'qa sitemap: config.sitemap.framework must be "vue-router". Add a `sitemap` block with `framework: "vue-router"`, `routerDir`, and `file` to qa-meter.config.json.\n'
213
+ );
214
+ process.exitCode = 1;
215
+ return;
216
+ }
217
+ if (!cfg.sitemap.routerDir) {
218
+ process.stderr.write("qa sitemap: config.sitemap.routerDir is required for the vue-router adapter.\n");
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+ const { payload, warnings } = extractWithWarnings({
223
+ routerDir: cfg.sitemap.routerDir,
224
+ ...cfg.sitemap.titlesFrom ? { titlesFrom: cfg.sitemap.titlesFrom } : {},
225
+ ...cfg.sitemap.alias ? { alias: cfg.sitemap.alias } : {}
226
+ });
227
+ const outFile = cfg.sitemap.file;
228
+ mkdirSync(dirname(outFile), { recursive: true });
229
+ writeFileSync(outFile, `${JSON.stringify(payload, null, 2)}
230
+ `, "utf8");
231
+ process.stderr.write(`qa sitemap \u2192 ${outFile} (${payload.pages.length} pages)
232
+ `);
233
+ for (const warning of warnings) {
234
+ process.stderr.write(` warning: ${warning}
235
+ `);
236
+ }
237
+ }
238
+ export {
239
+ extractVueRouterSitemap,
240
+ extractWithWarnings,
241
+ runSitemap
242
+ };
243
+ //# sourceMappingURL=sitemap-vue-EJDQTCYM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/qa/sitemap-vue.ts"],"sourcesContent":["/**\n * vue-router adapter for `qa sitemap` (Track A3, generalized from the Vigilance\n * donor `scripts/sync-sitemap.mjs`).\n *\n * Statically evaluates the router declaration files (`routes` export) without\n * booting the app, real `.vue`/store imports, or dev deps (`vite-node`/`tsx`).\n *\n * How: each source file is transpiled ESM/TS → CommonJS with `ts.transpileModule`\n * (the real TS compiler — handles default/named/re-exports, casts, multi-line\n * imports, every syntax for free), then evaluated in a `node:vm` context with a\n * `module`/`exports` sandbox and a custom `require`. The custom require:\n * 1. resolves IN-TREE relative/aliased imports (`./x`, `../x`, `@/x`) to a real\n * file under the router tree and evaluates THAT module recursively (cached by\n * absolute path; cycles return the in-progress `exports`), so sibling route\n * modules contribute their REAL exported arrays — embedded + spread survive;\n * 2. returns a harmless callable STUB Proxy for everything else (`.vue`\n * components, `vue-router`, unresolved aliases, bare/node_modules specifiers)\n * so `component:`/library references never explode.\n *\n * The honesty pass then scans the resolved `routes` tree: any top-level entry that\n * resolved to a STUB (a silently-truncated subtree) is surfaced as a warning.\n */\nimport { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'\nimport { dirname, isAbsolute, join, resolve } from 'node:path'\nimport vm from 'node:vm'\nimport ts from 'typescript'\nimport { buildSitemap, type RawRoute, type SitemapPayload } from './sitemap-core'\nimport { loadQaConfig } from './config'\n\nexport interface VueRouterAdapterOptions {\n /** Directory holding the router declaration file(s). Absolute. */\n routerDir: string\n /** Optional locale/title JSON; `pages.<routeName>` keys resolve human titles. Absolute. */\n titlesFrom?: string\n /**\n * Optional import-alias map (e.g. `{ '@': '/abs/path/to/src' }`). When a\n * specifier starts with an alias key + '/', it resolves to the mapped absolute\n * base before the in-tree check. Default: none (so `@/…` falls through to the\n * stub).\n */\n alias?: Record<string, string>\n}\n\n/**\n * Replacement value for any non-relevant import (`.vue` components, stores,\n * enums, `vue-router`, lodash…). Callable, constructable, indexable — and never\n * actually read by the builder, which only walks `path`/`name`/`children`.\n * Lifted verbatim from the donor.\n */\nconst STUB: unknown = new Proxy(function stub() {}, {\n get: (_t, prop) => (prop === Symbol.toPrimitive || prop === Symbol.toStringTag ? () => '' : STUB),\n apply: () => STUB,\n construct: () => ({}),\n})\n\n/** True if `v` is the shared STUB Proxy. */\nfunction isStub(v: unknown): boolean {\n return v === STUB\n}\n\n/** Transpile one TS/ESM source to CommonJS via the real compiler (no type-check). */\nfunction transpileToCjs(source: string, fileName: string): string {\n return ts.transpileModule(source, {\n fileName,\n compilerOptions: {\n module: ts.ModuleKind.CommonJS,\n target: ts.ScriptTarget.ES2020,\n esModuleInterop: true,\n isolatedModules: true,\n },\n }).outputText\n}\n\n/**\n * Resolve an import specifier to an absolute file UNDER the router tree, or null\n * if it is out-of-tree / unresolvable (→ caller stubs it).\n *\n * - relative `./x`, `../x` → resolved against the importing file's dir;\n * - aliased `@/x` (when `alias` maps `@`) → resolved against the mapped base;\n * - bare / node_modules / `.vue` / unmapped alias → null.\n *\n * Candidate extensions tried in order: as-is, `.ts`, `.mjs`, `.js`, `/index.ts`,\n * `/index.mjs`, `/index.js`. `.vue` and non-JS/TS asset specifiers (`.json`,\n * `.css`, images, …) are always treated as out-of-tree: the evaluator only\n * transpiles JS/TS, so feeding it raw JSON crashes `ts.transpileModule`\n * (\"Output generation failed\"). Routes never depend on such assets\n * structurally, so stubbing them is the honest, lossless choice.\n */\nfunction resolveInTree(\n specifier: string,\n fromDir: string,\n alias: Record<string, string> | undefined,\n confineDir: string,\n): string | null {\n if (/\\.(vue|json|css|scss|sass|less|svg|png|jpe?g|gif|webp|woff2?|ttf)$/i.test(specifier)) {\n return null\n }\n\n let base: string | null = null\n if (specifier.startsWith('./') || specifier.startsWith('../')) {\n base = resolve(fromDir, specifier)\n } else if (alias) {\n for (const [key, target] of Object.entries(alias)) {\n if (specifier === key || specifier.startsWith(`${key}/`)) {\n const rest = specifier === key ? '' : specifier.slice(key.length + 1)\n base = rest ? join(target, rest) : target\n break\n }\n }\n }\n if (base === null) return null\n\n const candidates = [\n base,\n `${base}.ts`,\n `${base}.mjs`,\n `${base}.js`,\n join(base, 'index.ts'),\n join(base, 'index.mjs'),\n join(base, 'index.js'),\n ]\n for (const cand of candidates) {\n try {\n if (existsSync(cand) && statSync(cand).isFile()) {\n // Confine the follow to the router subtree. Alias maps (`@`) point at\n // the WHOLE `src` tree, so without this an `@/stores/…` or `@/plugins/…`\n // import in a route module would drag the adapter into the app graph —\n // Vite-only code (`import.meta`), real stores, services — that crashes\n // the vm sandbox and is irrelevant to route SHAPE. Route MODULES live\n // under routerDir; everything else (components, stores, models) is a\n // leaf reference the builder never reads, so stubbing it is lossless.\n return isWithin(confineDir, cand) ? cand : null\n }\n } catch {\n // ignore and try the next candidate\n }\n }\n return null\n}\n\n/** True if absolute `child` is `dir` itself or nested under it (path-segment safe). */\nfunction isWithin(dir: string, child: string): boolean {\n const d = resolve(dir)\n const c = resolve(child)\n return c === d || c.startsWith(d.endsWith('/') ? d : `${d}/`)\n}\n\n/**\n * Evaluate a CommonJS-shaped module in a `vm` context, recursively following\n * in-tree `require`s. `cache` keys by absolute path; a module already in-flight\n * (cycle) returns its in-progress `exports`.\n */\nfunction evaluateModule(\n absPath: string,\n alias: Record<string, string> | undefined,\n cache: Map<string, Record<string, unknown>>,\n confineDir: string,\n): Record<string, unknown> {\n const cached = cache.get(absPath)\n if (cached) return cached\n\n const moduleObj: { exports: Record<string, unknown> } = { exports: {} }\n // Register BEFORE evaluating so a cycle resolves to the in-progress exports.\n cache.set(absPath, moduleObj.exports)\n\n const source = readFileSync(absPath, 'utf8')\n const cjs = transpileToCjs(source, absPath)\n const fromDir = dirname(absPath)\n\n const requireFn = (specifier: string): unknown => {\n const resolved = resolveInTree(specifier, fromDir, alias, confineDir)\n if (resolved) {\n const exp = evaluateModule(resolved, alias, cache, confineDir)\n // After evaluating, the module object may have been reassigned\n // (`module.exports = …`); read the freshest value from cache.\n return cache.get(resolved) ?? exp\n }\n return STUB\n }\n\n const context = vm.createContext({\n module: moduleObj,\n exports: moduleObj.exports,\n require: requireFn,\n console,\n })\n vm.runInContext(cjs, context, { filename: absPath })\n\n // `module.exports = …` may have replaced the object; refresh the cache entry.\n cache.set(absPath, moduleObj.exports)\n return moduleObj.exports\n}\n\n/** Locate the router-tree ENTRY file: `<dir>/index.{ts,mjs,js}`, else the first scanned file with a `routes`/`default` array. */\nfunction findEntry(dir: string): string | null {\n for (const name of ['index.ts', 'index.mjs', 'index.js']) {\n const p = join(dir, name)\n if (existsSync(p) && statSync(p).isFile()) return p\n }\n // Fallback: scan recursively, return the first file exporting routes.\n const cache = new Map<string, Record<string, unknown>>()\n for (const file of routerFiles(dir)) {\n try {\n const ns = evaluateModule(file, undefined, cache, dir)\n if (pickRoutes(ns)) return file\n } catch {\n // ignore unreadable candidates during discovery\n }\n }\n return null\n}\n\n/** List candidate router declaration files under `dir` (recursively, sorted). */\nfunction routerFiles(dir: string): string[] {\n const out: string[] = []\n const walk = (d: string): void => {\n let entries: import('node:fs').Dirent[]\n try {\n entries = readdirSync(d, { withFileTypes: true, encoding: 'utf8' })\n } catch {\n return\n }\n for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {\n const full = join(d, entry.name)\n if (entry.isDirectory()) {\n walk(full)\n } else if (/\\.(ts|mjs|js)$/.test(entry.name)) {\n out.push(full)\n }\n }\n }\n walk(dir)\n return out\n}\n\n/** Pull the `routes` array out of an evaluated namespace (named export, then default). */\nfunction pickRoutes(ns: Record<string, unknown>): ReadonlyArray<unknown> | null {\n if (Array.isArray(ns.routes)) return ns.routes as ReadonlyArray<unknown>\n if (Array.isArray(ns.default)) return ns.default as ReadonlyArray<unknown>\n return null\n}\n\n/**\n * Flatten the raw `routes` array: a sibling route MODULE imported as a single\n * embedded element resolves to an ARRAY (`routes: [ adminRoutes, … ]` where\n * `adminRoutes` is `[{…}]`), so we splice nested arrays up one level. Honesty\n * warnings are collected for any top-level entry that is the STUB (a silently\n * truncated import) or otherwise not a plain route object.\n */\nfunction flattenAndCheck(\n raw: ReadonlyArray<unknown>,\n warnings: string[],\n): RawRoute[] {\n const out: RawRoute[] = []\n for (const entry of raw) {\n if (Array.isArray(entry)) {\n // Embedded in-tree route module (its default/named export is an array).\n out.push(...flattenAndCheck(entry, warnings))\n continue\n }\n if (isStub(entry)) {\n warnings.push(\n 'a top-level route entry resolved to a STUB (an import could not be ' +\n 'followed in-tree) — its subtree is missing from the sitemap',\n )\n continue\n }\n if (entry === null || typeof entry !== 'object') {\n warnings.push(\n `a top-level route entry is not a route object (got ${entry === null ? 'null' : typeof entry}) — skipped`,\n )\n continue\n }\n out.push(entry as RawRoute)\n }\n return out\n}\n\n/** Build a `titleFor` resolver from a flat `pages.<routeName>` JSON, or undefined. */\nfunction makeTitleFor(\n titlesFrom: string | undefined,\n): ((routeName: string | undefined, pattern: string) => string | undefined) | undefined {\n if (!titlesFrom || !existsSync(titlesFrom)) return undefined\n let messages: Record<string, unknown> = {}\n try {\n const parsed = JSON.parse(readFileSync(titlesFrom, 'utf8'))\n if (parsed && typeof parsed === 'object') messages = parsed as Record<string, unknown>\n } catch {\n return undefined\n }\n const pages =\n messages.pages && typeof messages.pages === 'object'\n ? (messages.pages as Record<string, unknown>)\n : messages\n return (routeName) => {\n if (!routeName) return undefined\n const v = pages[routeName]\n return typeof v === 'string' ? v : undefined\n }\n}\n\n/** Result of a static extraction: the payload plus any honesty/IO warnings. */\ninterface ExtractResult {\n payload: SitemapPayload\n warnings: string[]\n}\n\n/**\n * Internal worker that also reports warnings (consumed by `runSitemap`).\n * Exported for tests — the public extractor (`extractVueRouterSitemap`) returns\n * only the `SitemapPayload`; this exposes the honesty warnings alongside it.\n * @internal\n */\nexport function extractWithWarnings(opts: VueRouterAdapterOptions): ExtractResult {\n const warnings: string[] = []\n const dir = isAbsolute(opts.routerDir) ? opts.routerDir : resolve(opts.routerDir)\n const entry = findEntry(dir)\n\n let rawRoutes: ReadonlyArray<unknown> | null = null\n if (!entry) {\n warnings.push(`No router entry (index.{ts,mjs,js}) found under ${dir}`)\n } else {\n try {\n const cache = new Map<string, Record<string, unknown>>()\n const ns = evaluateModule(entry, opts.alias, cache, dir)\n rawRoutes = pickRoutes(ns)\n if (!rawRoutes) {\n warnings.push(`No \\`routes\\` export found in ${entry}`)\n }\n } catch (err) {\n warnings.push(`${entry}: ${err instanceof Error ? err.message : String(err)}`)\n }\n }\n\n const routes = rawRoutes ? flattenAndCheck(rawRoutes, warnings) : []\n const titleFor = makeTitleFor(opts.titlesFrom)\n const payload = buildSitemap(routes, titleFor ? { titleFor } : {})\n return { payload, warnings }\n}\n\n/**\n * Statically evaluate the vue-router declarations under `routerDir` and return\n * the doc-03 §3.1 sitemap payload. Pure with respect to the inputs (idempotent).\n */\nexport function extractVueRouterSitemap(opts: VueRouterAdapterOptions): SitemapPayload {\n return extractWithWarnings(opts).payload\n}\n\n/** CLI entry: `qa sitemap` — load config, extract, write pretty JSON, report. */\nexport async function runSitemap(_args: string[]): Promise<void> {\n const cfg = loadQaConfig(process.cwd())\n if (!cfg.sitemap || cfg.sitemap.framework !== 'vue-router') {\n process.stderr.write(\n 'qa sitemap: config.sitemap.framework must be \"vue-router\". ' +\n 'Add a `sitemap` block with `framework: \"vue-router\"`, `routerDir`, and `file` to qa-meter.config.json.\\n',\n )\n process.exitCode = 1\n return\n }\n if (!cfg.sitemap.routerDir) {\n process.stderr.write('qa sitemap: config.sitemap.routerDir is required for the vue-router adapter.\\n')\n process.exitCode = 1\n return\n }\n\n const { payload, warnings } = extractWithWarnings({\n routerDir: cfg.sitemap.routerDir,\n ...(cfg.sitemap.titlesFrom ? { titlesFrom: cfg.sitemap.titlesFrom } : {}),\n ...(cfg.sitemap.alias ? { alias: cfg.sitemap.alias } : {}),\n })\n\n const outFile = cfg.sitemap.file\n mkdirSync(dirname(outFile), { recursive: true })\n writeFileSync(outFile, `${JSON.stringify(payload, null, 2)}\\n`, 'utf8')\n\n process.stderr.write(`qa sitemap → ${outFile} (${payload.pages.length} pages)\\n`)\n for (const warning of warnings) {\n process.stderr.write(` warning: ${warning}\\n`)\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhosaic/feedback-cli",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI to install @mhosaic/feedback into a host app, verify the integration, and drop a guided Claude Code skill (/integrate-feedback) into ~/.claude/skills.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,13 +13,14 @@
13
13
  ],
14
14
  "dependencies": {
15
15
  "kleur": "^4.1.5",
16
- "prompts": "^2.4.2"
16
+ "prompts": "^2.4.2",
17
+ "typescript": "5.6.3",
18
+ "yaml": "^2.5.0"
17
19
  },
18
20
  "devDependencies": {
19
21
  "@types/node": "^22.5.0",
20
22
  "@types/prompts": "^2.4.9",
21
23
  "tsup": "^8.3.0",
22
- "typescript": "5.6.3",
23
24
  "vitest": "^2.1.0"
24
25
  },
25
26
  "scripts": {
@@ -58,6 +58,13 @@ The project's `allowed_origins` doesn't include the URL the request comes from.
58
58
  **"Do clients get email digests?"**
59
59
  Not by default — notification dispatcher is `NoopDispatcher` on prod. Ping the operator if the client wants the weekly digest (it's a backend env var change).
60
60
 
61
+ **"How do I also enable the QA Meter?"**
62
+ The QA Meter is an opt-in second FAB (half size, stacked above the feedback button) that shows test-coverage status. It's host-served and host-gated — no backend involvement. Build the artifact with `mhosaic-feedback qa refresh`, serve `qa-status.json` as a static asset (or from your own endpoint), then enable it per surface:
63
+ - **npm / React provider:** `createFeedback({ …, qaMeter: { source: '/qa-status.json' } })` — `<FeedbackProvider>` forwards the `qaMeter` prop automatically.
64
+ - **CDN / loader embed:** add `data-qa-source="/qa-status.json"` to the `<script>` tag (optional `data-qa-locale`).
65
+
66
+ Only enable it where you want it (e.g. staging); omit the option in production to ship feedback-only.
67
+
61
68
  **"Where's the human-readable doc?"**
62
69
  `docs/INTEGRATING.md` in this repo. Covers the skill, the manual fallback, the CLI command reference, and a troubleshooting matrix.
63
70
 
@@ -0,0 +1,201 @@
1
+ ---
2
+ name: integrate-qa-meter
3
+ description: Guided setup for the @mhosaic/feedback QA Meter — the opt-in second FAB (half size, stacked above the feedback button) that shows per-page test-coverage status. Covers detecting the host's Playwright + router setup, scaffolding qa-meter.config.json, running `mhosaic-feedback qa refresh` to build the host-served qa-status.json artifact, serving it as a static asset, enabling it via the qaMeter config (npm / React) or data-qa-source (CDN / loader), wiring it into CI after the e2e run, and troubleshooting. Use whenever the user mentions: the QA Meter, the test-coverage FAB, the second/secondary FAB, qa-status.json, qa-meter.config.json, `qa refresh` / `qa build` / `qa sitemap` / `qa generate` / `qa check`, the sitemap modal, the honesty model, or showing test coverage inside the widget. Both a slash-command runbook and a reference Claude reads to answer ad-hoc QA-Meter questions.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # /integrate-qa-meter — guided QA Meter setup
8
+
9
+ The **QA Meter** is an opt-in *second FAB* that stacks above the feedback button at half size and shows the current page's test-coverage status: a heartbeat pastille → hover panel (per-page scenarios, with code/bot/human lanes) → full sitemap modal. It's fed by a single static `qa-status.json` the CLI builds from your test suite — **no backend involvement, host-served and host-gated.**
10
+
11
+ It rides on top of an already-integrated feedback widget: you turn it on by adding a `qaMeter` block to the same `createFeedback(…)` / `<FeedbackProvider>` / embed `<script>` you already wired. So this skill assumes the widget is in. If it isn't, run **`/integrate-feedback` first**, then come back.
12
+
13
+ This skill is **both** a slash-command runbook AND a reference doc. On `/integrate-qa-meter`, run the procedural flow below. On an ad-hoc question, answer from the Q&A.
14
+
15
+ ---
16
+
17
+ ## Quick Q&A
18
+
19
+ **"What does the QA Meter need?"**
20
+ Three things: (1) the feedback widget already integrated (the QA Meter is a `qaMeter` option on it), on a build of `@mhosaic/feedback` that ships the second-FAB integration; (2) a `qa-status.json` artifact, built by `mhosaic-feedback qa refresh`; (3) that artifact served as a static file your app already exposes (e.g. `public/qa-status.json`). No backend, no new key, no CORS.
21
+
22
+ **"Does it need a backend or a project key?"**
23
+ No. Unlike the feedback FAB, the QA Meter is 100% client-side + host-served. It reads a JSON file over plain `fetch`. It uses the *same* `createFeedback` call only as a mounting point.
24
+
25
+ **"Does it need `identify()`?"**
26
+ No. The feedback FAB is identity-gated (hidden until `identify()` fires); the QA pastille is **not** — it mounts as soon as `qaMeter.source` is set, for anonymous and identified users alike. (It's host-gated instead: you only set the source where you want it.)
27
+
28
+ **"What does `qa refresh` actually do?"**
29
+ Three deterministic steps, in order: `qa sitemap` (build the page tree from your router — vue-router adapter, or a hand-provided sitemap) → `qa generate` (static analysis of your Playwright specs → per-page scenario suites) → `qa build` (merge suites + sitemap + optional test results into `qa-status.json`). No tests are executed; it reads source files only.
30
+
31
+ **"Why is my meter all gray / 0% passing?"**
32
+ That's the **honesty model** working: with no Playwright results fed in (`resultsFile: null`), every scenario is `never_run` → gray. Only real evidence turns it green. To get color, run your e2e suite and point `resultsFile` at Playwright's JSON report, then re-run `qa build`. See `references/qa-meter-troubleshooting.md`.
33
+
34
+ **"What test frameworks / routers are supported?"**
35
+ Spec analysis is **Playwright**-specific. The sitemap adapter ships **vue-router**; for any other router (or no router) you hand-provide a `sitemap.json` (or skip the sitemap for a flat page list). Full matrix in `references/qa-meter-config.md`.
36
+
37
+ **"Where's the human-readable doc?"**
38
+ `docs/qa-meter/INTEGRATING.md` (this flow, end to end) and `docs/qa-meter/SUITE-FORMAT.md` (the test-suite YAML contract, for hand-authored suites + annotations).
39
+
40
+ **"Can I run the QA Meter without the feedback widget at all?"**
41
+ Yes, via the standalone subpath `import { createQaMeter } from '@mhosaic/feedback/qa-meter'`, mounted yourself. But the *second-FAB* experience this skill sets up is the `qaMeter` option on the feedback widget — prefer that unless you specifically want QA-only with no feedback button.
42
+
43
+ ---
44
+
45
+ ## Step 0 — Pre-flight
46
+
47
+ 1. **Confirm the widget is integrated.** Grep the host for an existing install: `createFeedback(`, `@mhosaic/feedback`, `FeedbackProvider`, or `embed.min.js` / `data-key`. If none is found, stop and redirect:
48
+
49
+ > "The QA Meter is an option on the feedback widget, which isn't integrated here yet. Run `/integrate-feedback` first to wire the widget, then re-run `/integrate-qa-meter`."
50
+
51
+ 2. **Confirm the package build supports it.** The `qaMeter` option exists only on builds of `@mhosaic/feedback` that ship the second-FAB integration. If the host pins an older version, note that it must be bumped (or, for an unreleased build, consume a local `pnpm pack` tarball — see `references/qa-meter-config.md`).
52
+
53
+ 3. **Confirm the CLI is reachable.** `npx @mhosaic/feedback-cli@latest qa` (prints the usage line). A global `npm i -g @mhosaic/feedback-cli@latest` saves typing if you'll run it often / in CI.
54
+
55
+ Don't continue until the widget is confirmed present.
56
+
57
+ ---
58
+
59
+ ## Step 1 — Detect the project shape
60
+
61
+ Read enough of the host to fill in the config without guessing. Determine and record:
62
+
63
+ - **Integration surface** — how `createFeedback` is called: bare npm (`createFeedback({…})`), React (`<FeedbackProvider>`), or CDN/loader (`<script … data-key>`). This decides *where* you add the source in Step 4.
64
+ - **Static-asset dir + its public URL** — where the app serves static files from (`public/`, `static/`, `dist/`…) and what URL a file there resolves to at runtime (usually `/<filename>`). This is the `outFile` target and the `qaMeter.source` value.
65
+ - **E2E layout** — the Playwright root (`e2e/`?), the spec glob (`tests/**/*.spec.ts`), the fixtures file that maps fixture name → page-object class, and the page-objects dir. Check `playwright.config.*` for `testDir`.
66
+ - **Router** — framework (`vue-router` in deps?) and the dir holding the route declarations + entry (`src/router` with an `index.ts` exporting `routes`). Note the import alias (e.g. `@` → `src`) and any locale JSON for human page titles.
67
+
68
+ If there's no Playwright suite, or the router isn't vue-router, read `references/qa-meter-config.md` for the escape hatches (sitemap-only, hand-provided `sitemap.json`, hand-authored suites) before proceeding.
69
+
70
+ ---
71
+
72
+ ## Step 2 — Scaffold `qa-meter.config.json`
73
+
74
+ Create `qa-meter.config.json` **in the directory you'll run the CLI from** (config discovery is cwd-only, no walk-up). All path fields resolve *relative to that file's directory*; `testsGlob` / `fixturesFile` / `pageObjectsDir` resolve relative to `e2eDir`.
75
+
76
+ Copy the shipped starter **`references/qa-meter.config.example.json`** to the project root as `qa-meter.config.json`, then adjust the paths (field-by-field reference in **`references/qa-meter-config.md`**). It's a vue-router + Playwright shape:
77
+
78
+ ```json
79
+ {
80
+ "e2eDir": "e2e",
81
+ "suitesDir": "qa-meter/suites",
82
+ "outFile": "frontend/public/qa-status.json",
83
+ "testsGlob": "tests/**/*.spec.ts",
84
+ "fixturesFile": "helpers/fixtures.ts",
85
+ "pageObjectsDir": "pages",
86
+ "environment": "staging",
87
+ "resultsFile": null,
88
+ "sitemap": {
89
+ "file": "qa-meter/sitemap.json",
90
+ "framework": "vue-router",
91
+ "routerDir": "frontend/src/router",
92
+ "titlesFrom": "frontend/src/locales/fr.json",
93
+ "alias": { "@": "frontend/src" }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Confirm the placement with the user if more than one root is plausible (e.g. a monorepo where e2e and the app live in sibling dirs — put the config at their common parent and use relative paths into each, exactly as above).
99
+
100
+ ---
101
+
102
+ ## Step 3 — Generate the artifact
103
+
104
+ From the dir holding `qa-meter.config.json`:
105
+
106
+ ```bash
107
+ npx @mhosaic/feedback-cli@latest qa refresh
108
+ ```
109
+
110
+ Read the output. Expect, on a healthy first run:
111
+
112
+ - `qa sitemap → …/sitemap.json (N pages)`
113
+ - `qa generate: N suite(s), M case(s) → <suitesDir>/generated`
114
+ - `qa build → <outFile>` with a `X% pages with scenarios · Y% scenarios passing` line.
115
+
116
+ **Warnings are normal, not failures.** `qa generate` reports specs it can't statically map (`unmappable`), specs that produce no cases (`zero-case` — factory/loop/dynamic-title tests), and tests skipped for a non-static title. The artifact still builds. To improve coverage of those, add `@mhosaic:pages /route, /other` or `@mhosaic:scenario <id>` annotations to the spec headers, or give tests string-literal titles — see `docs/qa-meter/SUITE-FORMAT.md`. Surface the counts to the user; don't silently ignore a large unmappable set.
117
+
118
+ If a step errors (commonly `qa sitemap`), see the adapter section of `references/qa-meter-troubleshooting.md`.
119
+
120
+ ---
121
+
122
+ ## Step 4 — Serve it + enable the FAB
123
+
124
+ 1. **Serve.** Confirm `outFile` lands inside the app's static dir so it's reachable at runtime. Verify once the dev server is up: `curl -s <origin>/qa-status.json -o /dev/null -w "%{http_code}\n"` → `200`.
125
+
126
+ 2. **Enable** on the surface from Step 1 (apply with `Edit`):
127
+
128
+ ```ts
129
+ // npm
130
+ createFeedback({ apiKey: '…', endpoint: '…', qaMeter: { source: '/qa-status.json' } })
131
+
132
+ // React — FeedbackProvider forwards the prop
133
+ <FeedbackProvider apiKey="…" endpoint="…" qaMeter={{ source: '/qa-status.json' }}>
134
+ ```
135
+ ```html
136
+ <!-- CDN / loader embed -->
137
+ <script src="…/embed.min.js" data-key="pk_proj_…" data-endpoint="…"
138
+ data-qa-source="/qa-status.json" defer></script>
139
+ ```
140
+
141
+ Optional knobs (npm/React): `locale: 'fr' | 'en'`, `size: 'sm' | 'md'` (default `sm`, the half-size second FAB), `position`, `getCurrentPage`. CDN: `data-qa-locale`. All documented in `references/qa-meter-config.md`.
142
+
143
+ 3. **Host-gate it.** Only set the source where you want the meter — staging / internal envs. Omit it (or gate the env var) in production to ship feedback-only. Confirm with the user which environments should show it.
144
+
145
+ ---
146
+
147
+ ## Step 5 — Verify
148
+
149
+ Start the dev server and confirm, in the browser (or via the Preview/Chrome MCP if available):
150
+
151
+ - The QA pastille mounts: `document.querySelector('[data-mqa-host]')` is non-null, and `[data-mqa-host]`'s `data-mqa-size` is `sm`.
152
+ - It's stacked above the feedback FAB (bottom-right), at half size.
153
+ - Clicking it opens the **sitemap modal**: the page tree, the `X% pages with scenarios · Y% passing` KPIs, and the freshness/version/env stamp from the artifact. The current route is marked "you are here".
154
+
155
+ If the pastille is missing, the source wasn't wired or the artifact 404s; if the modal is empty, the artifact loaded but has no pages. Diagnostics: `references/qa-meter-troubleshooting.md`.
156
+
157
+ Don't claim "done" without a screenshot of the mounted pastille and the opened modal (or an enumerated list of what's still broken).
158
+
159
+ ---
160
+
161
+ ## Step 6 — Wire CI (recommended)
162
+
163
+ The artifact is a build output; rebuild it whenever the suite or routes change so it doesn't drift. The high-value version runs **after** the e2e job and feeds real results:
164
+
165
+ ```bash
166
+ # after `playwright test --reporter=json > e2e/results.json`
167
+ npx @mhosaic/feedback-cli@latest qa refresh --version "$APP_VERSION"
168
+ # ↳ with resultsFile pointed at e2e/results.json, scenarios light up green/red
169
+ ```
170
+
171
+ Set `resultsFile` in the config to the Playwright JSON report path. Optionally add a tripwire that fails CI when the inputs drift out of sync with the artifact:
172
+
173
+ ```bash
174
+ npx @mhosaic/feedback-cli@latest qa check --strict
175
+ ```
176
+
177
+ Publish the refreshed `qa-status.json` with your static assets (it ships in the build output / CDN like any other file in `public/`). Full CI recipe in `references/qa-meter-config.md`.
178
+
179
+ ---
180
+
181
+ ## Principles
182
+
183
+ - **Honesty model.** Gray = no evidence. Only a real passing Playwright run (or a recorded human validation) turns a scenario green. Never fabricate a `resultsFile` to make the meter look better — an empty/gray meter is the truthful state of an unrun suite.
184
+ - **Host-served, host-gated, no backend.** The meter never talks to the feedback backend. It reads one static JSON. Enable it per environment; it's a staging/dev tool by default.
185
+ - **Independent of identity.** The QA pastille shows regardless of `identify()`. Don't gate it behind login.
186
+ - **Warnings ≠ failures, but don't hide them.** A big unmappable/zero-case count means real specs aren't represented — report it and offer annotations, don't bury it.
187
+ - **Confirm at every fork.** Use `AskUserQuestion` for choices (which env, which surface, where the config lives); plain text for free-form paths.
188
+
189
+ ---
190
+
191
+ ## Skill files
192
+
193
+ ```
194
+ .claude/skills/integrate-qa-meter/
195
+ ├── SKILL.md # this file (runbook + Q&A)
196
+ └── references/
197
+ ├── qa-meter-config.md # config schema, framework matrix, surfaces, CI
198
+ └── qa-meter-troubleshooting.md # diagnostics: gray meter, 404, adapter, unmappable, missing FAB
199
+ ```
200
+
201
+ Human-readable docs in the repo: `docs/qa-meter/INTEGRATING.md` (this flow) and `docs/qa-meter/SUITE-FORMAT.md` (suite YAML + annotations).