qa-engineer 0.9.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 (232) hide show
  1. package/COMPATIBILITY.md +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +508 -0
  4. package/package.json +90 -0
  5. package/packages/installer/README.md +46 -0
  6. package/packages/installer/bin/qa.mjs +136 -0
  7. package/packages/installer/lib/agents/registry.mjs +209 -0
  8. package/packages/installer/lib/cli/commands.mjs +29 -0
  9. package/packages/installer/lib/cli/flags.mjs +64 -0
  10. package/packages/installer/lib/commands/doctor.mjs +190 -0
  11. package/packages/installer/lib/commands/install.mjs +291 -0
  12. package/packages/installer/lib/commands/onboard.mjs +163 -0
  13. package/packages/installer/lib/commands/repair.mjs +68 -0
  14. package/packages/installer/lib/commands/self-test.mjs +45 -0
  15. package/packages/installer/lib/commands/uninstall.mjs +167 -0
  16. package/packages/installer/lib/commands/update.mjs +69 -0
  17. package/packages/installer/lib/commands/verify.mjs +62 -0
  18. package/packages/installer/lib/constants.mjs +34 -0
  19. package/packages/installer/lib/core/bundle.mjs +124 -0
  20. package/packages/installer/lib/core/config.mjs +73 -0
  21. package/packages/installer/lib/core/conflict.mjs +29 -0
  22. package/packages/installer/lib/core/errors.mjs +29 -0
  23. package/packages/installer/lib/core/fs-safe.mjs +236 -0
  24. package/packages/installer/lib/core/hash.mjs +19 -0
  25. package/packages/installer/lib/core/lockfile.mjs +75 -0
  26. package/packages/installer/lib/core/logger.mjs +46 -0
  27. package/packages/installer/lib/core/manifest.mjs +89 -0
  28. package/packages/installer/lib/core/paths.mjs +74 -0
  29. package/packages/installer/lib/core/schema-validate.mjs +142 -0
  30. package/packages/installer/lib/core/skill-meta.mjs +75 -0
  31. package/packages/installer/lib/core/validate-install.mjs +152 -0
  32. package/packages/installer/lib/core/wrappers.mjs +91 -0
  33. package/packages/installer/lib/detect/environment.mjs +54 -0
  34. package/packages/installer/lib/detect/frameworks.mjs +94 -0
  35. package/packages/installer/lib/detect/project.mjs +126 -0
  36. package/packages/installer/lib/detect/recommend.mjs +85 -0
  37. package/packages/installer/lib/detect/scan.mjs +48 -0
  38. package/packages/installer/lib/ui/progress.mjs +32 -0
  39. package/packages/installer/lib/ui/theme.mjs +81 -0
  40. package/packages/installer/lib/version.mjs +47 -0
  41. package/packages/installer/package.json +28 -0
  42. package/packages/installer/schemas/qa-lock.schema.json +89 -0
  43. package/packages/installer/schemas/qa.config.schema.json +91 -0
  44. package/shared/analysis/lib/qa_analysis/__init__.py +11 -0
  45. package/shared/analysis/lib/qa_analysis/branding.json +9 -0
  46. package/shared/analysis/lib/qa_analysis/branding.py +175 -0
  47. package/shared/analysis/lib/qa_analysis/cli.py +129 -0
  48. package/shared/analysis/lib/qa_analysis/context.py +233 -0
  49. package/shared/analysis/lib/qa_analysis/contracts.py +158 -0
  50. package/shared/analysis/lib/qa_analysis/diff_guard.py +327 -0
  51. package/shared/analysis/lib/qa_analysis/discovery.py +113 -0
  52. package/shared/analysis/lib/qa_analysis/evidence.py +128 -0
  53. package/shared/analysis/lib/qa_analysis/har.py +58 -0
  54. package/shared/analysis/lib/qa_analysis/junit.py +80 -0
  55. package/shared/analysis/lib/qa_analysis/redaction.py +99 -0
  56. package/shared/analysis/lib/qa_analysis/taxonomy.py +98 -0
  57. package/shared/analysis/schemas/context.schema.json +82 -0
  58. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +13 -0
  59. package/shared/diagnostics/lib/qa_diagnostics/cli.py +124 -0
  60. package/shared/diagnostics/lib/qa_diagnostics/engine.py +143 -0
  61. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +75 -0
  62. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +101 -0
  63. package/shared/diagnostics/lib/qa_diagnostics/repair.py +72 -0
  64. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +89 -0
  65. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +71 -0
  66. package/shared/diagnostics/schemas/internal/analysis-result.schema.json +28 -0
  67. package/shared/diagnostics/schemas/internal/diagnosis.schema.json +55 -0
  68. package/shared/diagnostics/schemas/internal/execution-result-min.schema.json +34 -0
  69. package/shared/frameworks/cypress/lib/cypress_analysis.py +27 -0
  70. package/shared/frameworks/playwright/lib/playwright_analysis.py +167 -0
  71. package/shared/frameworks/registry.json +150 -0
  72. package/shared/frameworks/registry.mjs +37 -0
  73. package/shared/frameworks/registry.schema.json +74 -0
  74. package/shared/frameworks/selenium/lib/selenium_analysis.py +28 -0
  75. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +24 -0
  76. package/shared/tooling/qa_tool.py +127 -0
  77. package/skills/README.md +31 -0
  78. package/skills/qa/README.md +19 -0
  79. package/skills/qa/SKILL.md +52 -0
  80. package/skills/qa/examples/routing.md +86 -0
  81. package/skills/qa/references/routing-map.md +33 -0
  82. package/skills/qa-api/README.md +19 -0
  83. package/skills/qa-api/SKILL.md +68 -0
  84. package/skills/qa-api/contracts/api-result.schema.json +51 -0
  85. package/skills/qa-api/examples/graphql-review.md +43 -0
  86. package/skills/qa-api/references/authentication.md +45 -0
  87. package/skills/qa-api/references/deterministic-tooling.md +128 -0
  88. package/skills/qa-api/references/evidence-and-reporting.md +66 -0
  89. package/skills/qa-api/references/graphql.md +44 -0
  90. package/skills/qa-api/references/rest.md +45 -0
  91. package/skills/qa-api/references/websocket.md +44 -0
  92. package/skills/qa-audit/README.md +19 -0
  93. package/skills/qa-audit/SKILL.md +70 -0
  94. package/skills/qa-audit/contracts/audit-result.schema.json +63 -0
  95. package/skills/qa-audit/examples/accessibility-audit.md +46 -0
  96. package/skills/qa-audit/references/accessibility.md +44 -0
  97. package/skills/qa-audit/references/deterministic-tooling.md +128 -0
  98. package/skills/qa-audit/references/evidence-and-reporting.md +66 -0
  99. package/skills/qa-audit/references/performance.md +44 -0
  100. package/skills/qa-audit/references/security.md +43 -0
  101. package/skills/qa-audit/references/visual-testing.md +44 -0
  102. package/skills/qa-debug/README.md +19 -0
  103. package/skills/qa-debug/SKILL.md +76 -0
  104. package/skills/qa-debug/contracts/debug-result.schema.json +121 -0
  105. package/skills/qa-debug/examples/failed-login.md +61 -0
  106. package/skills/qa-debug/examples/locator-break.md +59 -0
  107. package/skills/qa-debug/examples/network-timeout.md +61 -0
  108. package/skills/qa-debug/examples/successful-debug.md +62 -0
  109. package/skills/qa-debug/references/confidence-model.md +26 -0
  110. package/skills/qa-debug/references/deterministic-tooling.md +128 -0
  111. package/skills/qa-debug/references/diagnostic-engine.md +36 -0
  112. package/skills/qa-debug/references/evidence-and-reporting.md +66 -0
  113. package/skills/qa-debug/references/evidence-model.md +41 -0
  114. package/skills/qa-debug/references/failure-taxonomy.md +44 -0
  115. package/skills/qa-debug/references/finding-prioritization.md +43 -0
  116. package/skills/qa-debug/references/investigation-workflow.md +48 -0
  117. package/skills/qa-debug/references/recommendation-ranking.md +34 -0
  118. package/skills/qa-debug/references/root-cause-analysis.md +49 -0
  119. package/skills/qa-debug/references/timeline-builder.md +37 -0
  120. package/skills/qa-example/README.md +19 -0
  121. package/skills/qa-example/SKILL.md +60 -0
  122. package/skills/qa-example/contracts/self-check-report.schema.json +65 -0
  123. package/skills/qa-example/examples/self-check.md +53 -0
  124. package/skills/qa-example/references/example-domain.md +28 -0
  125. package/skills/qa-example/references/skill-format-notes.md +26 -0
  126. package/skills/qa-explore/README.md +22 -0
  127. package/skills/qa-explore/SKILL.md +87 -0
  128. package/skills/qa-explore/contracts/explore-result.schema.json +249 -0
  129. package/skills/qa-explore/examples/attached-test-cases.md +70 -0
  130. package/skills/qa-explore/examples/screenshot-proof-finding.md +50 -0
  131. package/skills/qa-explore/examples/url-smoke-explore.md +80 -0
  132. package/skills/qa-explore/references/accessibility.md +44 -0
  133. package/skills/qa-explore/references/api-replay.md +48 -0
  134. package/skills/qa-explore/references/browser-adapters.md +51 -0
  135. package/skills/qa-explore/references/evidence-and-reporting.md +66 -0
  136. package/skills/qa-explore/references/evidence-capture.md +54 -0
  137. package/skills/qa-explore/references/exploratory-qa.md +52 -0
  138. package/skills/qa-explore/references/finding-taxonomy.md +47 -0
  139. package/skills/qa-explore/references/performance.md +44 -0
  140. package/skills/qa-explore/references/pipeline.md +74 -0
  141. package/skills/qa-explore/references/report-pipeline.md +69 -0
  142. package/skills/qa-explore/references/security.md +43 -0
  143. package/skills/qa-explore/references/test-case-intake.md +44 -0
  144. package/skills/qa-fix/README.md +19 -0
  145. package/skills/qa-fix/SKILL.md +70 -0
  146. package/skills/qa-fix/contracts/fix-result.schema.json +132 -0
  147. package/skills/qa-fix/examples/repair-plan.md +56 -0
  148. package/skills/qa-fix/references/deterministic-tooling.md +128 -0
  149. package/skills/qa-fix/references/diagnostic-engine.md +36 -0
  150. package/skills/qa-fix/references/evidence-and-reporting.md +66 -0
  151. package/skills/qa-fix/references/repair-strategy.md +48 -0
  152. package/skills/qa-fix/references/root-cause-analysis.md +49 -0
  153. package/skills/qa-fix/references/suite-extension.md +73 -0
  154. package/skills/qa-flaky/README.md +19 -0
  155. package/skills/qa-flaky/SKILL.md +67 -0
  156. package/skills/qa-flaky/contracts/flaky-result.schema.json +62 -0
  157. package/skills/qa-flaky/examples/flaky-locator.md +46 -0
  158. package/skills/qa-flaky/references/deterministic-tooling.md +128 -0
  159. package/skills/qa-flaky/references/evidence-and-reporting.md +66 -0
  160. package/skills/qa-flaky/references/flakiness.md +48 -0
  161. package/skills/qa-flaky/references/retry.md +44 -0
  162. package/skills/qa-flaky/references/waiting-strategies.md +44 -0
  163. package/skills/qa-generate/README.md +42 -0
  164. package/skills/qa-generate/SKILL.md +83 -0
  165. package/skills/qa-generate/contracts/generation-result.schema.json +124 -0
  166. package/skills/qa-generate/examples/bootstrap-new-framework.md +24 -0
  167. package/skills/qa-generate/examples/extend-existing-suite.md +26 -0
  168. package/skills/qa-generate/references/code-style.md +29 -0
  169. package/skills/qa-generate/references/evidence-and-reporting.md +66 -0
  170. package/skills/qa-generate/references/framework-selection.md +88 -0
  171. package/skills/qa-generate/references/generation-strategy.md +48 -0
  172. package/skills/qa-generate/references/naming-conventions.md +27 -0
  173. package/skills/qa-generate/references/playwright-conventions.md +23 -0
  174. package/skills/qa-generate/references/playwright-generation.md +41 -0
  175. package/skills/qa-generate/references/playwright-project-discovery.md +42 -0
  176. package/skills/qa-generate/references/project-bootstrap.md +54 -0
  177. package/skills/qa-generate/references/repository-analysis.md +43 -0
  178. package/skills/qa-generate/references/suite-extension.md +73 -0
  179. package/skills/qa-generate/references/template-selection.md +48 -0
  180. package/skills/qa-generate/templates/playwright/api.ts +36 -0
  181. package/skills/qa-generate/templates/playwright/base-page.ts +14 -0
  182. package/skills/qa-generate/templates/playwright/data.ts +25 -0
  183. package/skills/qa-generate/templates/playwright/env.example +16 -0
  184. package/skills/qa-generate/templates/playwright/example.spec.ts +14 -0
  185. package/skills/qa-generate/templates/playwright/fixtures.ts +28 -0
  186. package/skills/qa-generate/templates/playwright/framework-readme.md +39 -0
  187. package/skills/qa-generate/templates/playwright/login.page.ts +26 -0
  188. package/skills/qa-generate/templates/playwright/playwright.config.ts +28 -0
  189. package/skills/qa-generate/templates/playwright/utils.ts +18 -0
  190. package/skills/qa-init/README.md +20 -0
  191. package/skills/qa-init/SKILL.md +72 -0
  192. package/skills/qa-init/examples/initialize-a-repo.md +91 -0
  193. package/skills/qa-init/references/detection-guide.md +76 -0
  194. package/skills/qa-init/references/deterministic-tooling.md +128 -0
  195. package/skills/qa-init/references/evidence-and-reporting.md +66 -0
  196. package/skills/qa-init/templates/context.md +68 -0
  197. package/skills/qa-report/README.md +19 -0
  198. package/skills/qa-report/SKILL.md +72 -0
  199. package/skills/qa-report/contracts/report-result.schema.json +186 -0
  200. package/skills/qa-report/examples/release-report.md +68 -0
  201. package/skills/qa-report/references/deterministic-tooling.md +128 -0
  202. package/skills/qa-report/references/diagnostic-engine.md +36 -0
  203. package/skills/qa-report/references/evidence-and-reporting.md +66 -0
  204. package/skills/qa-report/references/finding-prioritization.md +43 -0
  205. package/skills/qa-report/references/recommendation-ranking.md +34 -0
  206. package/skills/qa-report/references/report-aggregation.md +44 -0
  207. package/skills/qa-review/README.md +19 -0
  208. package/skills/qa-review/SKILL.md +58 -0
  209. package/skills/qa-review/contracts/review-result.schema.json +59 -0
  210. package/skills/qa-review/examples/suite-review.md +51 -0
  211. package/skills/qa-review/references/anti-patterns.md +49 -0
  212. package/skills/qa-review/references/assertion-patterns.md +45 -0
  213. package/skills/qa-review/references/evidence-and-reporting.md +66 -0
  214. package/skills/qa-review/references/fixtures.md +45 -0
  215. package/skills/qa-review/references/page-objects.md +45 -0
  216. package/skills/qa-run/README.md +22 -0
  217. package/skills/qa-run/SKILL.md +92 -0
  218. package/skills/qa-run/contracts/execution-plan.schema.json +132 -0
  219. package/skills/qa-run/contracts/execution-result.schema.json +204 -0
  220. package/skills/qa-run/examples/execute-playwright.md +89 -0
  221. package/skills/qa-run/examples/plan-a-run.md +83 -0
  222. package/skills/qa-run/references/artifact-collector.md +40 -0
  223. package/skills/qa-run/references/browser-launch.md +39 -0
  224. package/skills/qa-run/references/command-builder.md +39 -0
  225. package/skills/qa-run/references/deterministic-tooling.md +128 -0
  226. package/skills/qa-run/references/environment-detection.md +37 -0
  227. package/skills/qa-run/references/evidence-and-reporting.md +66 -0
  228. package/skills/qa-run/references/execution-strategy.md +54 -0
  229. package/skills/qa-run/references/playwright-artifacts.md +29 -0
  230. package/skills/qa-run/references/playwright-execution.md +48 -0
  231. package/skills/qa-run/references/playwright-project-discovery.md +42 -0
  232. package/skills/qa-run/references/report-normalization.md +49 -0
@@ -0,0 +1,175 @@
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 parsing
18
+ hazard — anywhere else.
19
+
20
+ | Branded | Not branded |
21
+ | --- | --- |
22
+ | HTML reports | JSON and YAML artifacts (`qa-artifacts/*.json`) |
23
+ | PDF reports | CLI stdout, progress output, `--json` output |
24
+ | Rendered Markdown reports meant for people | Markdown written for machine consumption |
25
+ | Generated documentation | Log files, API responses |
26
+ | Evaluation and audit report renderings | The system under test, and anything in a user's own source tree |
27
+
28
+ The rule behind the table: **if a program will parse it, it gets no footer.** A
29
+ contract artifact is an interface, and appending prose to an interface breaks it.
30
+
31
+ ## Formats
32
+
33
+ - `footer_html()` — a `<footer>` element with inline styling (so a standalone
34
+ report file needs no external stylesheet) and the author's site as a link that
35
+ opens in a new tab with `rel="noopener noreferrer"`.
36
+ - `footer_markdown()` — a thematic break, the four lines, the site as a link.
37
+ - `footer_text()` — rule-separated plain text for PDF writers and terminals-of-last-resort.
38
+ A PDF library that supports hyperlinks should link the URL; one that does not
39
+ renders this as-is, which is why the URL appears in full rather than as anchor text.
40
+
41
+ Standard library only, like the rest of `qa_analysis`.
42
+ """
43
+
44
+ import html
45
+ import json
46
+ import pathlib
47
+
48
+ _METADATA_PATH = pathlib.Path(__file__).resolve().parent / "branding.json"
49
+
50
+ # The visual width of the plain-text rules. Wide enough to frame the four lines,
51
+ # narrow enough to survive an 80-column terminal or a PDF margin.
52
+ _RULE_WIDTH = 60
53
+
54
+ _ALLOWED_SCHEMES = ("https://", "http://")
55
+
56
+
57
+ class BrandingError(ValueError):
58
+ """Raised when the branding metadata is missing or unusable."""
59
+
60
+
61
+ def metadata():
62
+ """The branding metadata, as a dict. Read fresh so a change needs no reinstall."""
63
+ try:
64
+ with open(_METADATA_PATH, "r", encoding="utf-8") as handle:
65
+ data = json.load(handle)
66
+ except (OSError, json.JSONDecodeError) as exc:
67
+ raise BrandingError(f"could not read branding metadata at {_METADATA_PATH}: {exc}") from exc
68
+
69
+ required = ("projectName", "tagline", "author", "website", "attributionPrefix", "authorPrefix")
70
+ missing = [key for key in required if not data.get(key)]
71
+ if missing:
72
+ raise BrandingError(f"branding metadata is missing: {', '.join(missing)}")
73
+ if not data["website"].startswith(_ALLOWED_SCHEMES):
74
+ # A footer is rendered into HTML, so a non-http scheme here would be an
75
+ # injection vector rather than a typo.
76
+ raise BrandingError(
77
+ f"branding website must start with http:// or https://, got {data['website']!r}"
78
+ )
79
+ return data
80
+
81
+
82
+ def _lines(data):
83
+ """The four footer lines, in order, as plain strings."""
84
+ return [
85
+ f"{data['attributionPrefix']} {data['projectName']}",
86
+ data["tagline"],
87
+ f"{data['authorPrefix']} {data['author']}",
88
+ data["website"],
89
+ ]
90
+
91
+
92
+ def footer_text(width=_RULE_WIDTH):
93
+ """Rule-separated plain text. Used for PDF and any non-markup rendering."""
94
+ data = metadata()
95
+ rule = "-" * width
96
+ body = "\n".join(line.center(width).rstrip() for line in _lines(data))
97
+ return f"{rule}\n{body}\n{rule}\n"
98
+
99
+
100
+ def footer_markdown():
101
+ """A thematic break and the four lines, with the site as a link."""
102
+ data = metadata()
103
+ return (
104
+ "---\n\n"
105
+ f"<sub>{data['attributionPrefix']} **{data['projectName']}** — "
106
+ f"{data['tagline']}<br>\n"
107
+ f"{data['authorPrefix']} "
108
+ f"[{data['author']}]({data['website']})</sub>\n"
109
+ )
110
+
111
+
112
+ def footer_html(class_name="qa-pack-attribution"):
113
+ """A self-contained `<footer>`: inline styles, muted, centered, small.
114
+
115
+ The author's site opens in a new tab. `rel="noopener noreferrer"` is not
116
+ optional — a report may be opened from anywhere, and a new tab that can reach
117
+ back into `window.opener` is a real hazard.
118
+ """
119
+ data = metadata()
120
+ site = html.escape(data["website"], quote=True)
121
+ project = html.escape(data["projectName"])
122
+ tagline = html.escape(data["tagline"])
123
+ author = html.escape(data["author"])
124
+ attribution_prefix = html.escape(data["attributionPrefix"])
125
+ author_prefix = html.escape(data["authorPrefix"])
126
+ css_class = html.escape(class_name, quote=True)
127
+
128
+ return (
129
+ f'<footer class="{css_class}" style="margin-top:2.5rem;padding-top:1rem;'
130
+ 'border-top:1px solid rgba(128,128,128,0.25);font-size:0.75rem;line-height:1.6;'
131
+ 'color:#6b7280;text-align:center;font-family:system-ui,-apple-system,'
132
+ 'Segoe UI,Roboto,sans-serif;">\n'
133
+ f' <div>{attribution_prefix} <strong>{project}</strong></div>\n'
134
+ f' <div>{tagline}</div>\n'
135
+ f' <div>{author_prefix} '
136
+ f'<a href="{site}" target="_blank" rel="noopener noreferrer" '
137
+ 'style="color:inherit;text-decoration:underline;">'
138
+ f'{author}</a></div>\n'
139
+ '</footer>\n'
140
+ )
141
+
142
+
143
+ _RENDERERS = {
144
+ "html": footer_html,
145
+ "markdown": footer_markdown,
146
+ "md": footer_markdown,
147
+ "text": footer_text,
148
+ "txt": footer_text,
149
+ "pdf": footer_text, # what a PDF writer embeds when it cannot render markup
150
+ }
151
+
152
+ FORMATS = ("html", "markdown", "text")
153
+
154
+
155
+ def footer(fmt="text"):
156
+ """Render the footer in `fmt`. Raises BrandingError on an unknown format."""
157
+ renderer = _RENDERERS.get(fmt.lower())
158
+ if renderer is None:
159
+ raise BrandingError(
160
+ f"unknown branding format {fmt!r}; expected one of: {', '.join(FORMATS)}"
161
+ )
162
+ return renderer()
163
+
164
+
165
+ def append_to(document, fmt="markdown"):
166
+ """Return `document` with the footer appended, idempotently.
167
+
168
+ Idempotence matters: a report assembled in stages, or regenerated over its own
169
+ output, must not accumulate footers.
170
+ """
171
+ rendered = footer(fmt)
172
+ if rendered.strip() and rendered.strip() in document:
173
+ return document
174
+ separator = "" if document.endswith("\n") else "\n"
175
+ return f"{document}{separator}\n{rendered}"
@@ -0,0 +1,129 @@
1
+ """Unified CLI for the analysis core.
2
+
3
+ Every analyzer is reachable as a subcommand that writes JSON to stdout, so the
4
+ toolkit is usable from a shell, from CI, and (once bundled) by a skill. Exit
5
+ code 0 on success, 2 on a malformed artifact or bad usage.
6
+
7
+ Usage:
8
+ python -m qa_analysis.cli junit <path>
9
+ python -m qa_analysis.cli har <path> [--slow-ms N]
10
+ python -m qa_analysis.cli discover [--root DIR] [--path P ...]
11
+ python -m qa_analysis.cli diff-guard <diff-file>
12
+ python -m qa_analysis.cli redact <file>
13
+ python -m qa_analysis.cli validate <instance.json> <schema.json>
14
+ python -m qa_analysis.cli classify "<error message>" [--http-status N]
15
+ python -m qa_analysis.cli context [--root DIR] [--path .qa/context.md]
16
+ python -m qa_analysis.cli branding [--format html|markdown|text]
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import pathlib
22
+ import sys
23
+
24
+ from . import junit, har, discovery, diff_guard, redaction, contracts, taxonomy
25
+ from . import branding as branding_module
26
+ from . import context as context_module
27
+ from .context import MalformedContext
28
+ from .junit import MalformedArtifact
29
+
30
+ _SCHEMA_DIR_CANDIDATES = (
31
+ # Repository layout: shared/analysis/lib/qa_analysis -> shared/analysis/schemas
32
+ pathlib.Path(__file__).resolve().parents[2] / "schemas",
33
+ # Bundled layout: package data travels with the package.
34
+ pathlib.Path(__file__).resolve().parent / "schemas",
35
+ )
36
+
37
+
38
+ def _context_schema():
39
+ """The context contract, in whichever layout this package is running from."""
40
+ for base in _SCHEMA_DIR_CANDIDATES:
41
+ candidate = base / "context.schema.json"
42
+ if candidate.is_file():
43
+ return contracts.load_schema(candidate)
44
+ return None
45
+
46
+
47
+ def _emit(obj):
48
+ json.dump(obj, sys.stdout, indent=2, sort_keys=True)
49
+ sys.stdout.write("\n")
50
+
51
+
52
+ def main(argv=None):
53
+ parser = argparse.ArgumentParser(prog="qa-analysis", description="Deterministic QA analysis toolkit")
54
+ sub = parser.add_subparsers(dest="command", required=True)
55
+
56
+ p = sub.add_parser("junit"); p.add_argument("path")
57
+ p = sub.add_parser("har"); p.add_argument("path"); p.add_argument("--slow-ms", type=int, default=1000)
58
+ p = sub.add_parser("discover"); p.add_argument("--root", default="."); p.add_argument("--path", action="append", default=[])
59
+ p = sub.add_parser("diff-guard"); p.add_argument("path")
60
+ p = sub.add_parser("redact"); p.add_argument("path")
61
+ p = sub.add_parser("validate"); p.add_argument("instance"); p.add_argument("schema")
62
+ p = sub.add_parser("classify"); p.add_argument("message"); p.add_argument("--http-status", type=int, default=None)
63
+ p = sub.add_parser("context"); p.add_argument("--root", default="."); p.add_argument("--path", default=None)
64
+ p = sub.add_parser("branding")
65
+ p.add_argument("--format", default="text", choices=list(branding_module.FORMATS) + ["pdf", "md", "txt"])
66
+ p.add_argument("--metadata", action="store_true", help="emit the branding metadata as JSON instead")
67
+
68
+ args = parser.parse_args(argv)
69
+
70
+ try:
71
+ if args.command == "junit":
72
+ _emit(junit.parse_junit(args.path))
73
+ elif args.command == "har":
74
+ _emit(har.parse_har(args.path, slow_ms=args.slow_ms))
75
+ elif args.command == "discover":
76
+ result = discovery.discover(root=args.root, explicit=args.path or None)
77
+ _emit({k: [a.to_dict() if hasattr(a, "to_dict") else a for a in v] for k, v in result.items()})
78
+ elif args.command == "diff-guard":
79
+ with open(args.path, "r", encoding="utf-8") as handle:
80
+ issues = diff_guard.check_diff(handle.read())
81
+ _emit({"issues": issues, "safe": not any(i["severity"] == "high" for i in issues)})
82
+ elif args.command == "redact":
83
+ with open(args.path, "r", encoding="utf-8") as handle:
84
+ sys.stdout.write(redaction.redact_text(handle.read()))
85
+ elif args.command == "validate":
86
+ with open(args.instance, "r", encoding="utf-8") as handle:
87
+ instance = json.load(handle)
88
+ ok, errors = contracts.validate(instance, contracts.load_schema(args.schema))
89
+ _emit({"valid": ok, "errors": errors})
90
+ return 0 if ok else 1
91
+ elif args.command == "classify":
92
+ classification, confidence, reason = taxonomy.classify(args.message, http_status=args.http_status)
93
+ _emit({"classification": classification, "confidence": confidence, "reason": reason})
94
+ elif args.command == "context":
95
+ path = args.path or str(pathlib.Path(args.root) / ".qa" / "context.md")
96
+ schema = _context_schema()
97
+ parsed = context_module.parse_file(path, schema=schema)
98
+ _emit({
99
+ "path": path,
100
+ "context": parsed["context"],
101
+ "valid": parsed["valid"],
102
+ "errors": parsed["errors"],
103
+ "schemaChecked": schema is not None,
104
+ })
105
+ return 0 if parsed["valid"] else 1
106
+ elif args.command == "branding":
107
+ # Written to stdout verbatim, not as JSON: the caller pastes this into
108
+ # a rendered report, so it must be the exact bytes to embed.
109
+ if args.metadata:
110
+ _emit(branding_module.metadata())
111
+ else:
112
+ sys.stdout.write(branding_module.footer(args.format))
113
+ except branding_module.BrandingError as exc:
114
+ _emit({"error": "branding-error", "detail": str(exc)})
115
+ return 2
116
+ except MalformedContext as exc:
117
+ _emit({"error": "malformed-context", "detail": str(exc)})
118
+ return 2
119
+ except MalformedArtifact as exc:
120
+ _emit({"error": "malformed-artifact", "detail": str(exc)})
121
+ return 2
122
+ except (OSError, json.JSONDecodeError) as exc:
123
+ _emit({"error": "io-error", "detail": str(exc)})
124
+ return 2
125
+ return 0
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(main())
@@ -0,0 +1,233 @@
1
+ """Deterministic parsing of `.qa/context.md`.
2
+
3
+ `qa-init` writes the project profile as a Markdown file whose frontmatter holds
4
+ the machine-readable facts, and every other skill reads it. The contract for
5
+ those facts is a JSON Schema (`shared/analysis/schemas/context.schema.json`) —
6
+ but nothing could check a *real* `.qa/context.md` against it, because the
7
+ toolkit is standard-library-only and the frontmatter is YAML. Validation ran
8
+ against a hand-written JSON fixture instead, so the contract was unenforced
9
+ exactly where it mattered.
10
+
11
+ This module closes that gap without adding a dependency or changing the file
12
+ format: it parses the **explicit subset** of YAML the context contract uses,
13
+ then hands the result to the existing contract validator.
14
+
15
+ ## The supported subset
16
+
17
+ Deliberately small, and everything outside it is an error rather than a guess:
18
+
19
+ - `key: value` mappings, nested by two-space indentation
20
+ - block sequences (`- item`), including nested under a key
21
+ - flow collections only when empty: `[]` and `{}`
22
+ - scalars: double- or single-quoted strings, bare strings, integers, floats,
23
+ `true`/`false`, `null`/`~`/empty
24
+ - `#` comments on their own line or after a value
25
+ - the leading/trailing `---` fences
26
+
27
+ Not supported, and rejected loudly: anchors/aliases, multi-line block scalars
28
+ (`|`, `>`), non-empty flow collections, multi-document streams, and tabs for
29
+ indentation. A generator that needs one of those has outgrown the contract, and
30
+ the right response is to change the contract deliberately — not to have a parser
31
+ quietly misread it.
32
+ """
33
+
34
+ import re
35
+
36
+ from . import contracts
37
+
38
+ _FENCE = "---"
39
+ _TRUE = ("true", "True", "TRUE")
40
+ _FALSE = ("false", "False", "FALSE")
41
+ _NULL = ("null", "Null", "NULL", "~", "")
42
+ _UNSUPPORTED_SCALARS = ("|", ">")
43
+ _INT = re.compile(r"^-?\d+$")
44
+ _FLOAT = re.compile(r"^-?\d+\.\d+$")
45
+
46
+
47
+ class MalformedContext(ValueError):
48
+ """Raised when `.qa/context.md` cannot be parsed or fails its contract."""
49
+
50
+
51
+ def split_frontmatter(text):
52
+ """Return (frontmatter_text, body_text). Raises if the fences are missing."""
53
+ lines = text.split("\n")
54
+ start = None
55
+ for index, line in enumerate(lines):
56
+ if line.strip() == "":
57
+ continue
58
+ if line.rstrip() == _FENCE:
59
+ start = index
60
+ break
61
+ if start is None:
62
+ raise MalformedContext(
63
+ "no frontmatter: the file must open with a '---' fence "
64
+ "(see the qa-init context template)"
65
+ )
66
+ for index in range(start + 1, len(lines)):
67
+ if lines[index].rstrip() == _FENCE:
68
+ return "\n".join(lines[start + 1:index]), "\n".join(lines[index + 1:])
69
+ raise MalformedContext("unterminated frontmatter: no closing '---' fence")
70
+
71
+
72
+ def _strip_comment(value):
73
+ """Remove a trailing `#` comment that is not inside quotes."""
74
+ quote = None
75
+ for index, char in enumerate(value):
76
+ if quote:
77
+ if char == quote:
78
+ quote = None
79
+ elif char in ("'", '"'):
80
+ quote = char
81
+ elif char == "#" and (index == 0 or value[index - 1] in " \t"):
82
+ return value[:index]
83
+ return value
84
+
85
+
86
+ def _scalar(raw, line_number):
87
+ text = _strip_comment(raw).strip()
88
+ if text[:1] in _UNSUPPORTED_SCALARS:
89
+ raise MalformedContext(
90
+ f"line {line_number}: block scalars ('|', '>') are outside the supported subset"
91
+ )
92
+ if text[:1] in ("&", "*"):
93
+ raise MalformedContext(
94
+ f"line {line_number}: anchors and aliases are outside the supported subset"
95
+ )
96
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
97
+ return text[1:-1]
98
+ if text == "[]":
99
+ return []
100
+ if text == "{}":
101
+ return {}
102
+ if text.startswith("[") or text.startswith("{"):
103
+ raise MalformedContext(
104
+ f"line {line_number}: only empty flow collections ('[]', '{{}}') are supported; "
105
+ "use a block sequence or mapping"
106
+ )
107
+ if text in _TRUE:
108
+ return True
109
+ if text in _FALSE:
110
+ return False
111
+ if text in _NULL:
112
+ return None
113
+ if _INT.match(text):
114
+ return int(text)
115
+ if _FLOAT.match(text):
116
+ return float(text)
117
+ return text
118
+
119
+
120
+ def parse_frontmatter(text):
121
+ """Parse the supported YAML subset into a dict. Raises MalformedContext.
122
+
123
+ The parser keeps a stack of `(key_indent, container)` frames. `key_indent` is
124
+ the column at which that container's own entries start, so a line's indent
125
+ alone decides which container it belongs to: pop every frame deeper than the
126
+ line, and the top of the stack is the target.
127
+ """
128
+ root = {}
129
+ stack = [(0, root)]
130
+ pending = None # (key, indent) — a key whose value is a block that follows
131
+
132
+ def resolve_pending_as_null():
133
+ """A key with no value and no nested block is a null field."""
134
+ nonlocal pending
135
+ if pending is not None:
136
+ stack[-1][1][pending[0]] = None
137
+ pending = None
138
+
139
+ for number, raw_line in enumerate(text.split("\n"), start=1):
140
+ leading = raw_line[: len(raw_line) - len(raw_line.lstrip())]
141
+ if "\t" in leading:
142
+ raise MalformedContext(f"line {number}: tab indentation is not supported")
143
+ stripped = raw_line.strip()
144
+ if not stripped or stripped.startswith("#"):
145
+ continue
146
+ if stripped == _FENCE:
147
+ raise MalformedContext(f"line {number}: unexpected '---' inside frontmatter")
148
+
149
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
150
+
151
+ # --- sequence item ---
152
+ if stripped.startswith("- ") or stripped == "-":
153
+ item = _scalar(stripped[2:] if len(stripped) > 1 else "", number)
154
+ if pending is not None:
155
+ # A sequence may sit at the key's indent or deeper.
156
+ parent = stack[-1][1]
157
+ parent[pending[0]] = []
158
+ stack.append((indent, parent[pending[0]]))
159
+ pending = None
160
+ while len(stack) > 1 and stack[-1][0] > indent:
161
+ stack.pop()
162
+ container = stack[-1][1]
163
+ if not isinstance(container, list):
164
+ raise MalformedContext(f"line {number}: sequence item outside a sequence")
165
+ container.append(item)
166
+ continue
167
+
168
+ # --- mapping entry ---
169
+ if ":" not in stripped:
170
+ raise MalformedContext(
171
+ f"line {number}: expected 'key: value' or '- item', got {stripped!r}"
172
+ )
173
+
174
+ if pending is not None:
175
+ if indent > pending[1]:
176
+ # The pending key opens a nested mapping at this indent.
177
+ parent = stack[-1][1]
178
+ parent[pending[0]] = {}
179
+ stack.append((indent, parent[pending[0]]))
180
+ pending = None
181
+ else:
182
+ resolve_pending_as_null()
183
+
184
+ # Leave the frames this line does not belong to. A sequence frame at the
185
+ # same indent as a key also ends here (`packages:` / `- a` / `ci:`).
186
+ while len(stack) > 1 and (
187
+ stack[-1][0] > indent
188
+ or (isinstance(stack[-1][1], list) and stack[-1][0] >= indent)
189
+ ):
190
+ stack.pop()
191
+
192
+ key, _, value = stripped.partition(":")
193
+ key = key.strip()
194
+ if not key:
195
+ raise MalformedContext(f"line {number}: empty key")
196
+ container = stack[-1][1]
197
+ if not isinstance(container, dict):
198
+ raise MalformedContext(f"line {number}: mapping key inside a sequence")
199
+
200
+ if _strip_comment(value).strip() == "":
201
+ pending = (key, indent) # a block or a null follows
202
+ else:
203
+ container[key] = _scalar(value, number)
204
+
205
+ resolve_pending_as_null()
206
+ return root
207
+
208
+
209
+ def parse(text, schema=None):
210
+ """Parse `.qa/context.md` text and, when a schema is given, validate it.
211
+
212
+ Returns {"context": <dict>, "body": <str>, "valid": bool, "errors": [...]}.
213
+ Raises MalformedContext when the text cannot be parsed at all — a parse
214
+ failure is not a validation result.
215
+ """
216
+ frontmatter, body = split_frontmatter(text)
217
+ context = parse_frontmatter(frontmatter)
218
+ result = {"context": context, "body": body, "valid": True, "errors": []}
219
+ if schema is not None:
220
+ ok, errors = contracts.validate(context, schema)
221
+ result["valid"] = ok
222
+ result["errors"] = errors
223
+ return result
224
+
225
+
226
+ def parse_file(path, schema=None):
227
+ """Read and parse a `.qa/context.md` file."""
228
+ try:
229
+ with open(path, "r", encoding="utf-8") as handle:
230
+ text = handle.read()
231
+ except OSError as exc:
232
+ raise MalformedContext(f"could not read {path}: {exc}") from exc
233
+ return parse(text, schema=schema)