qa-engineer 0.9.1 → 0.10.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 (109) hide show
  1. package/COMPATIBILITY.md +50 -26
  2. package/README.md +40 -32
  3. package/package.json +12 -20
  4. package/packages/engine/bin/qa-engine.mjs +336 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/{installer/lib/core/schema-validate.mjs → engine/lib/analysis/contracts.mjs} +11 -4
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +90 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  14. package/packages/engine/lib/analysis/report-html.mjs +763 -0
  15. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  16. package/packages/engine/lib/analysis/xml.mjs +153 -0
  17. package/packages/engine/lib/analysis/zip.mjs +107 -0
  18. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  19. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  20. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  21. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  22. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  23. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  24. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  25. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  26. package/packages/engine/package.json +14 -0
  27. package/packages/installer/lib/agents/registry.mjs +46 -0
  28. package/packages/installer/lib/commands/doctor.mjs +7 -16
  29. package/packages/installer/lib/commands/install.mjs +59 -4
  30. package/packages/installer/lib/core/bundle.mjs +69 -92
  31. package/packages/installer/lib/core/config.mjs +1 -1
  32. package/packages/installer/lib/core/lockfile.mjs +1 -1
  33. package/packages/installer/lib/core/manifest.mjs +48 -54
  34. package/packages/installer/lib/core/validate-install.mjs +12 -19
  35. package/packages/installer/package.json +1 -1
  36. package/shared/tooling/qa-tool.mjs +125 -0
  37. package/skills/qa-api/SKILL.md +5 -5
  38. package/skills/qa-api/references/deterministic-tooling.md +34 -30
  39. package/skills/qa-api/references/evidence-and-reporting.md +29 -4
  40. package/skills/qa-api/scripts/qa-tool.mjs +126 -0
  41. package/skills/qa-audit/SKILL.md +5 -5
  42. package/skills/qa-audit/references/deterministic-tooling.md +34 -30
  43. package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
  44. package/skills/qa-audit/scripts/qa-tool.mjs +126 -0
  45. package/skills/qa-debug/SKILL.md +6 -6
  46. package/skills/qa-debug/references/deterministic-tooling.md +34 -30
  47. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  48. package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
  49. package/skills/qa-debug/scripts/qa-tool.mjs +126 -0
  50. package/skills/qa-explore/SKILL.md +22 -5
  51. package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
  52. package/skills/qa-explore/references/deterministic-tooling.md +132 -0
  53. package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
  54. package/skills/qa-explore/references/report-pipeline.md +89 -8
  55. package/skills/qa-explore/scripts/qa-tool.mjs +126 -0
  56. package/skills/qa-fix/SKILL.md +4 -4
  57. package/skills/qa-fix/references/deterministic-tooling.md +34 -30
  58. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  59. package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
  60. package/skills/qa-fix/scripts/qa-tool.mjs +126 -0
  61. package/skills/qa-flaky/SKILL.md +4 -4
  62. package/skills/qa-flaky/references/deterministic-tooling.md +34 -30
  63. package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
  64. package/skills/qa-flaky/scripts/qa-tool.mjs +126 -0
  65. package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
  66. package/skills/qa-init/SKILL.md +4 -4
  67. package/skills/qa-init/references/deterministic-tooling.md +34 -30
  68. package/skills/qa-init/references/evidence-and-reporting.md +29 -4
  69. package/skills/qa-init/scripts/qa-tool.mjs +126 -0
  70. package/skills/qa-report/SKILL.md +8 -7
  71. package/skills/qa-report/references/deterministic-tooling.md +34 -30
  72. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  73. package/skills/qa-report/references/evidence-and-reporting.md +29 -4
  74. package/skills/qa-report/scripts/qa-tool.mjs +126 -0
  75. package/skills/qa-review/references/evidence-and-reporting.md +29 -4
  76. package/skills/qa-run/SKILL.md +6 -6
  77. package/skills/qa-run/references/deterministic-tooling.md +34 -30
  78. package/skills/qa-run/references/evidence-and-reporting.md +29 -4
  79. package/skills/qa-run/scripts/qa-tool.mjs +126 -0
  80. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  81. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  82. package/shared/analysis/lib/qa_analysis/cli.py +0 -129
  83. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  84. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  85. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  86. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  87. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  88. package/shared/analysis/lib/qa_analysis/har.py +0 -58
  89. package/shared/analysis/lib/qa_analysis/junit.py +0 -80
  90. package/shared/analysis/lib/qa_analysis/redaction.py +0 -99
  91. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  92. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  93. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  94. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  95. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  96. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  97. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  98. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  99. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  100. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  101. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  102. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  103. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  104. package/shared/tooling/qa_tool.py +0 -127
  105. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  106. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  107. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  108. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  109. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ // The deterministic engine's command line — how a skill reaches it.
3
+ //
4
+ // One entry point for all three tool groups, replacing three Python CLIs and a
5
+ // launcher. Every subcommand writes JSON to stdout, so an agent never has to invent
6
+ // glue code, and the shapes are checked rather than assumed.
7
+ //
8
+ // qa-engine analysis <subcommand> [args]
9
+ // qa-engine diagnostics <subcommand> [args]
10
+ // qa-engine playwright <subcommand> [args]
11
+ //
12
+ // Exit codes are the contract, not an implementation detail. A skill's fallback
13
+ // logic reads them:
14
+ //
15
+ // 0 success
16
+ // 1 a document was read fine but failed its contract (`validate`, `context`)
17
+ // 2 unreadable input, malformed JSON, a failed seam contract, or bad usage
18
+ //
19
+ // Nothing here reasons: it reads files, calls the engine, prints the result. That
20
+ // separation is the whole architecture — deterministic code owns the facts, the
21
+ // model owns the explanation.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+
26
+ import * as junit from '../lib/analysis/junit.mjs';
27
+ import * as har from '../lib/analysis/har.mjs';
28
+ import * as discovery from '../lib/analysis/discovery.mjs';
29
+ import * as diffGuard from '../lib/analysis/diff-guard.mjs';
30
+ import * as redaction from '../lib/analysis/redaction.mjs';
31
+ import * as contracts from '../lib/analysis/contracts.mjs';
32
+ import * as taxonomy from '../lib/analysis/taxonomy.mjs';
33
+ import * as contextModule from '../lib/analysis/context.mjs';
34
+ import * as branding from '../lib/analysis/branding.mjs';
35
+ import * as reportHtml from '../lib/analysis/report-html.mjs';
36
+ import * as diagnostics from '../lib/diagnostics/engine.mjs';
37
+ import {
38
+ InternalContractError, validateAnalysisResult, validateExecutionResultMin, validateDiagnosis,
39
+ } from '../lib/diagnostics/internal-contracts.mjs';
40
+ import * as playwright from '../lib/frameworks/playwright.mjs';
41
+ import * as junitFrameworks from '../lib/frameworks/junit-frameworks.mjs';
42
+
43
+ const USAGE = `usage: qa-engine <tool> <subcommand> [args]
44
+
45
+ analysis parse artifacts, classify errors, validate contracts, diff-guard,
46
+ read .qa/context.md, render an HTML report, print the footer
47
+ diagnostics root cause, timeline, priority, repair plans, release readiness
48
+ playwright normalize a Playwright report or summarize a trace
49
+
50
+ analysis subcommands
51
+ junit <report.xml> normalized {tests, executed}
52
+ har <file.har> [--slow-ms N] redacted network summary
53
+ discover [--root DIR] [--path P ...] artifacts found, by state
54
+ diff-guard <diff-file> {issues, safe} — safe:false blocks a change
55
+ redact <file> the file's text with secrets masked
56
+ validate <instance.json> <schema.json> {valid, errors}; exit 1 when invalid
57
+ classify "<message>" [--http-status N] {classification, confidence, reason}
58
+ context [--root DIR] [--path P] parsed, schema-checked project context
59
+ report-html <result.json> [--out FILE] a self-contained HTML report
60
+ branding [--format html|markdown|text] the exact footer bytes for a report
61
+
62
+ diagnostics subcommands
63
+ diagnose --execution-result P [--analysis-result P]
64
+ plan-repairs --diagnosis P
65
+ summarize --execution-result P --diagnosis P
66
+ report --execution-result P [--analysis-result P] all three in one call
67
+
68
+ playwright subcommands
69
+ report <results.json> the same shape as analysis junit
70
+ trace <trace.zip> actions, errors, and a classification
71
+
72
+ examples
73
+ qa-engine analysis junit test-results/results.xml
74
+ qa-engine analysis report-html qa-artifacts/explore-result.json --out report.html
75
+ qa-engine diagnostics report --execution-result qa-artifacts/run.json
76
+ `;
77
+
78
+ function emit(value) {
79
+ process.stdout.write(`${JSON.stringify(value, sortedReplacer(), 2)}\n`);
80
+ }
81
+
82
+ /**
83
+ * Keys are emitted in sorted order, matching the Python CLIs' `sort_keys=True`.
84
+ * The output is read by people and diffed in CI, so a stable order is part of it.
85
+ */
86
+ function sortedReplacer() {
87
+ return function replacer(key, value) {
88
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
89
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
90
+ };
91
+ }
92
+
93
+ function fail(error, detail) {
94
+ emit({ error, detail });
95
+ return 2;
96
+ }
97
+
98
+ function readJson(file) {
99
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
100
+ }
101
+
102
+ /** Flags, parsed the way the Python argparse CLIs accepted them. */
103
+ function parseFlags(argv, { repeatable = [] } = {}) {
104
+ const flags = {};
105
+ const positional = [];
106
+ for (let index = 0; index < argv.length; index += 1) {
107
+ const token = argv[index];
108
+ if (!token.startsWith('--')) {
109
+ positional.push(token);
110
+ continue;
111
+ }
112
+ const [name, inline] = token.slice(2).split('=', 2);
113
+ const camel = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
114
+ const value = inline !== undefined ? inline : argv[++index];
115
+ if (repeatable.includes(camel)) {
116
+ flags[camel] = [...(flags[camel] ?? []), value];
117
+ } else {
118
+ flags[camel] = value;
119
+ }
120
+ }
121
+ return { flags, positional };
122
+ }
123
+
124
+ // Which flags may appear more than once, per subcommand. `discover` accepts a list
125
+ // of explicit paths; `context` names one file. Treating `--path` as repeatable
126
+ // everywhere handed `context` an array and it failed on "path must be a string".
127
+ const REPEATABLE = { discover: ['path'] };
128
+
129
+ function analysis(argv) {
130
+ const [subcommand, ...rest] = argv;
131
+ const { flags, positional } = parseFlags(rest, { repeatable: REPEATABLE[subcommand] ?? [] });
132
+
133
+ switch (subcommand) {
134
+ case 'junit':
135
+ requirePositional(positional, 1, 'junit <report.xml>');
136
+ emit(junit.parseJUnit(positional[0]));
137
+ return 0;
138
+
139
+ case 'har':
140
+ requirePositional(positional, 1, 'har <file.har>');
141
+ emit(har.parseHar(positional[0], { slowMs: Number(flags.slowMs ?? 1000) }));
142
+ return 0;
143
+
144
+ case 'discover':
145
+ emit(discovery.discover({ root: flags.root ?? '.', explicit: flags.path ?? null }));
146
+ return 0;
147
+
148
+ case 'diff-guard': {
149
+ requirePositional(positional, 1, 'diff-guard <diff-file>');
150
+ const issues = diffGuard.checkDiff(fs.readFileSync(positional[0], 'utf8'));
151
+ emit({ issues, safe: !issues.some((issue) => issue.severity === 'high') });
152
+ return 0;
153
+ }
154
+
155
+ case 'redact':
156
+ requirePositional(positional, 1, 'redact <file>');
157
+ process.stdout.write(redaction.redactText(fs.readFileSync(positional[0], 'utf8')));
158
+ return 0;
159
+
160
+ case 'validate': {
161
+ requirePositional(positional, 2, 'validate <instance.json> <schema.json>');
162
+ const errors = contracts.validate(readJson(positional[0]), readJson(positional[1]));
163
+ emit({ valid: errors.length === 0, errors });
164
+ return errors.length === 0 ? 0 : 1;
165
+ }
166
+
167
+ case 'classify': {
168
+ requirePositional(positional, 1, 'classify "<message>"');
169
+ const status = flags.httpStatus === undefined ? null : Number(flags.httpStatus);
170
+ emit(taxonomy.classify(positional[0], status));
171
+ return 0;
172
+ }
173
+
174
+ case 'context': {
175
+ const file = flags.path ?? path.join(flags.root ?? '.', '.qa', 'context.md');
176
+ const schema = contextSchema();
177
+ const parsed = contextModule.parseFile(file, { schema });
178
+ emit({
179
+ path: file,
180
+ context: parsed.context,
181
+ valid: parsed.valid,
182
+ errors: parsed.errors,
183
+ schemaChecked: schema !== null,
184
+ });
185
+ return parsed.valid ? 0 : 1;
186
+ }
187
+
188
+ case 'report-html': {
189
+ requirePositional(positional, 1, 'report-html <result.json>');
190
+ const document = reportHtml.renderFile(positional[0], { title: flags.title ?? null });
191
+ if (flags.out) {
192
+ fs.writeFileSync(flags.out, document);
193
+ emit({ written: flags.out, bytes: document.length });
194
+ } else {
195
+ process.stdout.write(document);
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ case 'branding':
201
+ // Written to stdout verbatim, not as JSON: the caller embeds these exact
202
+ // bytes in a rendered report.
203
+ if (flags.metadata !== undefined) emit(branding.metadata());
204
+ else process.stdout.write(branding.footer(flags.format ?? 'text'));
205
+ return 0;
206
+
207
+ default:
208
+ throw new UsageError(`unknown analysis subcommand: ${subcommand ?? '(none)'}`);
209
+ }
210
+ }
211
+
212
+ function diagnosticsCommand(argv) {
213
+ const [subcommand, ...rest] = argv;
214
+ const { flags } = parseFlags(rest);
215
+
216
+ const execution = () => {
217
+ if (!flags.executionResult) throw new UsageError('--execution-result is required');
218
+ return validateExecutionResultMin(readJson(flags.executionResult));
219
+ };
220
+ const analysisResult = () =>
221
+ flags.analysisResult ? validateAnalysisResult(readJson(flags.analysisResult)) : null;
222
+ // A diagnosis handed back in is held to the same seam contract it was emitted
223
+ // under, so a hand-edited file is refused rather than half-understood.
224
+ const diagnosisInput = () => {
225
+ if (!flags.diagnosis) throw new UsageError('--diagnosis is required');
226
+ return validateDiagnosis(readJson(flags.diagnosis));
227
+ };
228
+
229
+ switch (subcommand) {
230
+ case 'diagnose':
231
+ emit(diagnostics.diagnose(execution(), { analysisResult: analysisResult() }));
232
+ return 0;
233
+
234
+ case 'plan-repairs':
235
+ // Wrapped in {plans}, matching the shape skills already parse. A bare array
236
+ // would be tidier and would break every caller.
237
+ emit({ plans: diagnostics.planRepairs(diagnosisInput()) });
238
+ return 0;
239
+
240
+ case 'summarize': {
241
+ const result = execution();
242
+ emit(diagnostics.summarize(result, diagnosisInput()));
243
+ return 0;
244
+ }
245
+
246
+ case 'report': {
247
+ // The one-shot path: diagnose, plan, and summarize in a single call, so the
248
+ // common case is one command instead of three.
249
+ const result = execution();
250
+ const diagnosis = diagnostics.diagnose(result, { analysisResult: analysisResult() });
251
+ emit({
252
+ diagnosis,
253
+ plans: diagnostics.planRepairs(diagnosis),
254
+ summary: diagnostics.summarize(result, diagnosis),
255
+ });
256
+ return 0;
257
+ }
258
+
259
+ default:
260
+ throw new UsageError(`unknown diagnostics subcommand: ${subcommand ?? '(none)'}`);
261
+ }
262
+ }
263
+
264
+ function playwrightCommand(argv) {
265
+ const [subcommand, ...rest] = argv;
266
+ const { positional } = parseFlags(rest);
267
+ if (subcommand === 'report') {
268
+ requirePositional(positional, 1, 'report <results.json>');
269
+ emit(playwright.parseReport(positional[0]));
270
+ return 0;
271
+ }
272
+ if (subcommand === 'trace') {
273
+ requirePositional(positional, 1, 'trace <trace.zip>');
274
+ emit(playwright.analyzeTrace(positional[0]));
275
+ return 0;
276
+ }
277
+ throw new UsageError(`unknown playwright subcommand: ${subcommand ?? '(none)'}`);
278
+ }
279
+
280
+ class UsageError extends Error {}
281
+
282
+ function requirePositional(positional, count, shape) {
283
+ if (positional.length < count) throw new UsageError(`usage: qa-engine analysis ${shape}`);
284
+ }
285
+
286
+ /** The context contract, in whichever layout the engine is running from. */
287
+ function contextSchema() {
288
+ const candidates = [new URL('../lib/analysis/schemas/context.schema.json', import.meta.url)];
289
+ for (const candidate of candidates) {
290
+ const file = candidate.pathname;
291
+ if (fs.existsSync(file)) return readJson(file);
292
+ }
293
+ return null;
294
+ }
295
+
296
+ export function main(argv = process.argv.slice(2)) {
297
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
298
+ process.stdout.write(USAGE);
299
+ return argv.length === 0 ? 2 : 0;
300
+ }
301
+ if (argv[0] === '--version') {
302
+ process.stdout.write(`${engineVersion()}\n`);
303
+ return 0;
304
+ }
305
+
306
+ const [tool, ...rest] = argv;
307
+ try {
308
+ if (tool === 'analysis') return analysis(rest);
309
+ if (tool === 'diagnostics') return diagnosticsCommand(rest);
310
+ if (tool === 'playwright') return playwrightCommand(rest);
311
+ process.stderr.write(USAGE);
312
+ return fail('usage-error', `unknown tool: ${tool}`);
313
+ } catch (error) {
314
+ if (error instanceof junit.MalformedArtifact) return fail('malformed-artifact', error.message);
315
+ if (error instanceof contextModule.MalformedContext) return fail('malformed-context', error.message);
316
+ if (error instanceof reportHtml.ReportError) return fail('report-error', error.message);
317
+ if (error instanceof branding.BrandingError) return fail('branding-error', error.message);
318
+ if (error instanceof InternalContractError) return fail('invalid-payload', error.message);
319
+ if (error instanceof UsageError) return fail('usage-error', error.message);
320
+ if (error instanceof SyntaxError) return fail('io-error', error.message);
321
+ if (error.code === 'ENOENT' || error.code === 'EACCES' || error.code === 'EISDIR') {
322
+ return fail('io-error', error.message);
323
+ }
324
+ throw error;
325
+ }
326
+ }
327
+
328
+ function engineVersion() {
329
+ try {
330
+ return readJson(new URL('../package.json', import.meta.url).pathname).version;
331
+ } catch {
332
+ return '0.0.0';
333
+ }
334
+ }
335
+
336
+ process.exitCode = main();
@@ -0,0 +1,187 @@
1
+ // Product attribution for human-readable reports.
2
+ //
3
+ // One renderer, one metadata file, three output formats. Every report the pack
4
+ // produces for a human ends with the same footer, and changing it means editing
5
+ // `branding.json` — nothing else.
6
+ //
7
+ // ## Why this is code and not a string in each skill
8
+ //
9
+ // A footer typed by a model is a footer that drifts: the tagline gains a word, the
10
+ // URL loses a scheme, one report says "Developed by" and the next says "Built by".
11
+ // Rendering it deterministically makes every report byte-identical, and makes a
12
+ // change to the branding a one-file edit that CI can verify
13
+ // (scripts/check-branding.mjs fails if any branding string is hardcoded elsewhere).
14
+ //
15
+ // ## What gets a footer, and what must not
16
+ //
17
+ // Attribution belongs on documents a person reads. It is noise — or worse, a
18
+ // parsing hazard — anywhere else.
19
+ //
20
+ // Branded | Not branded
21
+ // ---------------------------------|--------------------------------------------
22
+ // HTML and PDF reports | JSON and YAML artifacts under qa-artifacts/
23
+ // Markdown meant for people | Markdown written for a machine to read
24
+ // Generated documentation | CLI stdout, progress output, --json output
25
+ // Audit and evaluation renderings | The system under test, the user's own source
26
+ //
27
+ // The rule behind the table: **if a program will parse it, it gets no footer.** A
28
+ // contract artifact is an interface, and appending prose to an interface breaks it.
29
+
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const here = path.dirname(fileURLToPath(import.meta.url));
35
+
36
+ // One metadata file, beside this module — so it travels with the engine into a
37
+ // bundled skill without a second copy to keep in step.
38
+ const METADATA_CANDIDATES = [path.join(here, 'branding.json')];
39
+
40
+ // The visual width of the plain-text rules. Wide enough to frame the four lines,
41
+ // narrow enough to survive an 80-column terminal or a PDF margin.
42
+ const RULE_WIDTH = 60;
43
+
44
+ const ALLOWED_SCHEMES = ['https://', 'http://'];
45
+
46
+ export class BrandingError extends Error {}
47
+
48
+ /** The branding metadata. Read fresh, so a change needs no reinstall. */
49
+ export function metadata() {
50
+ const source = METADATA_CANDIDATES.find((candidate) => fs.existsSync(candidate));
51
+ if (!source) {
52
+ throw new BrandingError(
53
+ `could not find branding metadata (looked in: ${METADATA_CANDIDATES.join(', ')})`,
54
+ );
55
+ }
56
+
57
+ let data;
58
+ try {
59
+ data = JSON.parse(fs.readFileSync(source, 'utf8'));
60
+ } catch (error) {
61
+ throw new BrandingError(`could not read branding metadata at ${source}: ${error.message}`);
62
+ }
63
+
64
+ const required = ['projectName', 'tagline', 'author', 'website', 'attributionPrefix', 'authorPrefix'];
65
+ const missing = required.filter((key) => !data[key]);
66
+ if (missing.length > 0) {
67
+ throw new BrandingError(`branding metadata is missing: ${missing.join(', ')}`);
68
+ }
69
+ if (!ALLOWED_SCHEMES.some((scheme) => data.website.startsWith(scheme))) {
70
+ // A footer is rendered into HTML, so a non-http scheme here would be an
71
+ // injection vector rather than a typo.
72
+ throw new BrandingError(
73
+ `branding website must start with http:// or https://, got '${data.website}'`,
74
+ );
75
+ }
76
+ return data;
77
+ }
78
+
79
+ /** HTML-escape, matching Python's `html.escape(s, quote=True)` character for character. */
80
+ function escape(value) {
81
+ return String(value)
82
+ .replace(/&/g, '&amp;')
83
+ .replace(/</g, '&lt;')
84
+ .replace(/>/g, '&gt;')
85
+ .replace(/"/g, '&quot;')
86
+ .replace(/'/g, '&#x27;');
87
+ }
88
+
89
+ /** The four footer lines, in order, as plain strings. */
90
+ function lines(data) {
91
+ return [
92
+ `${data.attributionPrefix} ${data.projectName}`,
93
+ data.tagline,
94
+ `${data.authorPrefix} ${data.author}`,
95
+ data.website,
96
+ ];
97
+ }
98
+
99
+ /**
100
+ * Rule-separated plain text, for PDF and any non-markup rendering.
101
+ *
102
+ * Centering follows Python's `str.center`: the odd space goes on the right, which
103
+ * then falls off with the trailing trim. Getting that backwards shifts every line
104
+ * by a character and breaks the snapshot the branding tests pin.
105
+ */
106
+ export function footerText({ width = RULE_WIDTH } = {}) {
107
+ const data = metadata();
108
+ const rule = '-'.repeat(width);
109
+ const body = lines(data)
110
+ .map((line) => {
111
+ if (line.length >= width) return line;
112
+ return ' '.repeat(Math.floor((width - line.length) / 2)) + line;
113
+ })
114
+ .join('\n');
115
+ return `${rule}\n${body}\n${rule}\n`;
116
+ }
117
+
118
+ /** A thematic break and the four lines, with the site as a link. */
119
+ export function footerMarkdown() {
120
+ const data = metadata();
121
+ return (
122
+ '---\n\n' +
123
+ `<sub>${data.attributionPrefix} **${data.projectName}** — ` +
124
+ `${data.tagline}<br>\n` +
125
+ `${data.authorPrefix} ` +
126
+ `[${data.author}](${data.website})</sub>\n`
127
+ );
128
+ }
129
+
130
+ /**
131
+ * A self-contained `<footer>`: inline styles, muted, centered, small.
132
+ *
133
+ * The author's site opens in a new tab. `rel="noopener noreferrer"` is not
134
+ * optional — a report may be opened from anywhere, and a new tab that can reach
135
+ * back into `window.opener` is a real hazard.
136
+ */
137
+ export function footerHtml({ className = 'qa-pack-attribution' } = {}) {
138
+ const data = metadata();
139
+ return (
140
+ `<footer class="${escape(className)}" style="margin-top:2.5rem;padding-top:1rem;` +
141
+ 'border-top:1px solid rgba(128,128,128,0.25);font-size:0.75rem;line-height:1.6;' +
142
+ 'color:#6b7280;text-align:center;font-family:system-ui,-apple-system,' +
143
+ 'Segoe UI,Roboto,sans-serif;">\n' +
144
+ ` <div>${escape(data.attributionPrefix)} <strong>${escape(data.projectName)}</strong></div>\n` +
145
+ ` <div>${escape(data.tagline)}</div>\n` +
146
+ ` <div>${escape(data.authorPrefix)} ` +
147
+ `<a href="${escape(data.website)}" target="_blank" rel="noopener noreferrer" ` +
148
+ 'style="color:inherit;text-decoration:underline;">' +
149
+ `${escape(data.author)}</a></div>\n` +
150
+ '</footer>\n'
151
+ );
152
+ }
153
+
154
+ const RENDERERS = {
155
+ html: footerHtml,
156
+ markdown: footerMarkdown,
157
+ md: footerMarkdown,
158
+ text: footerText,
159
+ txt: footerText,
160
+ pdf: footerText, // what a PDF writer embeds when it cannot render markup
161
+ };
162
+
163
+ export const FORMATS = ['html', 'markdown', 'text'];
164
+
165
+ /** Render the footer in `format`. Throws BrandingError on an unknown format. */
166
+ export function footer(format = 'text') {
167
+ const renderer = RENDERERS[String(format).toLowerCase()];
168
+ if (!renderer) {
169
+ throw new BrandingError(
170
+ `unknown branding format '${format}'; expected one of: ${FORMATS.join(', ')}`,
171
+ );
172
+ }
173
+ return renderer();
174
+ }
175
+
176
+ /**
177
+ * Return `document` with the footer appended, idempotently.
178
+ *
179
+ * Idempotence matters: a report assembled in stages, or regenerated over its own
180
+ * output, must not accumulate footers.
181
+ */
182
+ export function appendTo(document, format = 'markdown') {
183
+ const rendered = footer(format);
184
+ if (rendered.trim() && document.includes(rendered.trim())) return document;
185
+ const separator = document.endsWith('\n') ? '' : '\n';
186
+ return `${document}${separator}\n${rendered}`;
187
+ }