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.
- package/COMPATIBILITY.md +71 -0
- package/LICENSE +21 -0
- package/README.md +508 -0
- package/package.json +90 -0
- package/packages/installer/README.md +46 -0
- package/packages/installer/bin/qa.mjs +136 -0
- package/packages/installer/lib/agents/registry.mjs +209 -0
- package/packages/installer/lib/cli/commands.mjs +29 -0
- package/packages/installer/lib/cli/flags.mjs +64 -0
- package/packages/installer/lib/commands/doctor.mjs +190 -0
- package/packages/installer/lib/commands/install.mjs +291 -0
- package/packages/installer/lib/commands/onboard.mjs +163 -0
- package/packages/installer/lib/commands/repair.mjs +68 -0
- package/packages/installer/lib/commands/self-test.mjs +45 -0
- package/packages/installer/lib/commands/uninstall.mjs +167 -0
- package/packages/installer/lib/commands/update.mjs +69 -0
- package/packages/installer/lib/commands/verify.mjs +62 -0
- package/packages/installer/lib/constants.mjs +34 -0
- package/packages/installer/lib/core/bundle.mjs +124 -0
- package/packages/installer/lib/core/config.mjs +73 -0
- package/packages/installer/lib/core/conflict.mjs +29 -0
- package/packages/installer/lib/core/errors.mjs +29 -0
- package/packages/installer/lib/core/fs-safe.mjs +236 -0
- package/packages/installer/lib/core/hash.mjs +19 -0
- package/packages/installer/lib/core/lockfile.mjs +75 -0
- package/packages/installer/lib/core/logger.mjs +46 -0
- package/packages/installer/lib/core/manifest.mjs +89 -0
- package/packages/installer/lib/core/paths.mjs +74 -0
- package/packages/installer/lib/core/schema-validate.mjs +142 -0
- package/packages/installer/lib/core/skill-meta.mjs +75 -0
- package/packages/installer/lib/core/validate-install.mjs +152 -0
- package/packages/installer/lib/core/wrappers.mjs +91 -0
- package/packages/installer/lib/detect/environment.mjs +54 -0
- package/packages/installer/lib/detect/frameworks.mjs +94 -0
- package/packages/installer/lib/detect/project.mjs +126 -0
- package/packages/installer/lib/detect/recommend.mjs +85 -0
- package/packages/installer/lib/detect/scan.mjs +48 -0
- package/packages/installer/lib/ui/progress.mjs +32 -0
- package/packages/installer/lib/ui/theme.mjs +81 -0
- package/packages/installer/lib/version.mjs +47 -0
- package/packages/installer/package.json +28 -0
- package/packages/installer/schemas/qa-lock.schema.json +89 -0
- package/packages/installer/schemas/qa.config.schema.json +91 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +11 -0
- package/shared/analysis/lib/qa_analysis/branding.json +9 -0
- package/shared/analysis/lib/qa_analysis/branding.py +175 -0
- package/shared/analysis/lib/qa_analysis/cli.py +129 -0
- package/shared/analysis/lib/qa_analysis/context.py +233 -0
- package/shared/analysis/lib/qa_analysis/contracts.py +158 -0
- package/shared/analysis/lib/qa_analysis/diff_guard.py +327 -0
- package/shared/analysis/lib/qa_analysis/discovery.py +113 -0
- package/shared/analysis/lib/qa_analysis/evidence.py +128 -0
- package/shared/analysis/lib/qa_analysis/har.py +58 -0
- package/shared/analysis/lib/qa_analysis/junit.py +80 -0
- package/shared/analysis/lib/qa_analysis/redaction.py +99 -0
- package/shared/analysis/lib/qa_analysis/taxonomy.py +98 -0
- package/shared/analysis/schemas/context.schema.json +82 -0
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +13 -0
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +124 -0
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +143 -0
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +75 -0
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +101 -0
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +72 -0
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +89 -0
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +71 -0
- package/shared/diagnostics/schemas/internal/analysis-result.schema.json +28 -0
- package/shared/diagnostics/schemas/internal/diagnosis.schema.json +55 -0
- package/shared/diagnostics/schemas/internal/execution-result-min.schema.json +34 -0
- package/shared/frameworks/cypress/lib/cypress_analysis.py +27 -0
- package/shared/frameworks/playwright/lib/playwright_analysis.py +167 -0
- package/shared/frameworks/registry.json +150 -0
- package/shared/frameworks/registry.mjs +37 -0
- package/shared/frameworks/registry.schema.json +74 -0
- package/shared/frameworks/selenium/lib/selenium_analysis.py +28 -0
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +24 -0
- package/shared/tooling/qa_tool.py +127 -0
- package/skills/README.md +31 -0
- package/skills/qa/README.md +19 -0
- package/skills/qa/SKILL.md +52 -0
- package/skills/qa/examples/routing.md +86 -0
- package/skills/qa/references/routing-map.md +33 -0
- package/skills/qa-api/README.md +19 -0
- package/skills/qa-api/SKILL.md +68 -0
- package/skills/qa-api/contracts/api-result.schema.json +51 -0
- package/skills/qa-api/examples/graphql-review.md +43 -0
- package/skills/qa-api/references/authentication.md +45 -0
- package/skills/qa-api/references/deterministic-tooling.md +128 -0
- package/skills/qa-api/references/evidence-and-reporting.md +66 -0
- package/skills/qa-api/references/graphql.md +44 -0
- package/skills/qa-api/references/rest.md +45 -0
- package/skills/qa-api/references/websocket.md +44 -0
- package/skills/qa-audit/README.md +19 -0
- package/skills/qa-audit/SKILL.md +70 -0
- package/skills/qa-audit/contracts/audit-result.schema.json +63 -0
- package/skills/qa-audit/examples/accessibility-audit.md +46 -0
- package/skills/qa-audit/references/accessibility.md +44 -0
- package/skills/qa-audit/references/deterministic-tooling.md +128 -0
- package/skills/qa-audit/references/evidence-and-reporting.md +66 -0
- package/skills/qa-audit/references/performance.md +44 -0
- package/skills/qa-audit/references/security.md +43 -0
- package/skills/qa-audit/references/visual-testing.md +44 -0
- package/skills/qa-debug/README.md +19 -0
- package/skills/qa-debug/SKILL.md +76 -0
- package/skills/qa-debug/contracts/debug-result.schema.json +121 -0
- package/skills/qa-debug/examples/failed-login.md +61 -0
- package/skills/qa-debug/examples/locator-break.md +59 -0
- package/skills/qa-debug/examples/network-timeout.md +61 -0
- package/skills/qa-debug/examples/successful-debug.md +62 -0
- package/skills/qa-debug/references/confidence-model.md +26 -0
- package/skills/qa-debug/references/deterministic-tooling.md +128 -0
- package/skills/qa-debug/references/diagnostic-engine.md +36 -0
- package/skills/qa-debug/references/evidence-and-reporting.md +66 -0
- package/skills/qa-debug/references/evidence-model.md +41 -0
- package/skills/qa-debug/references/failure-taxonomy.md +44 -0
- package/skills/qa-debug/references/finding-prioritization.md +43 -0
- package/skills/qa-debug/references/investigation-workflow.md +48 -0
- package/skills/qa-debug/references/recommendation-ranking.md +34 -0
- package/skills/qa-debug/references/root-cause-analysis.md +49 -0
- package/skills/qa-debug/references/timeline-builder.md +37 -0
- package/skills/qa-example/README.md +19 -0
- package/skills/qa-example/SKILL.md +60 -0
- package/skills/qa-example/contracts/self-check-report.schema.json +65 -0
- package/skills/qa-example/examples/self-check.md +53 -0
- package/skills/qa-example/references/example-domain.md +28 -0
- package/skills/qa-example/references/skill-format-notes.md +26 -0
- package/skills/qa-explore/README.md +22 -0
- package/skills/qa-explore/SKILL.md +87 -0
- package/skills/qa-explore/contracts/explore-result.schema.json +249 -0
- package/skills/qa-explore/examples/attached-test-cases.md +70 -0
- package/skills/qa-explore/examples/screenshot-proof-finding.md +50 -0
- package/skills/qa-explore/examples/url-smoke-explore.md +80 -0
- package/skills/qa-explore/references/accessibility.md +44 -0
- package/skills/qa-explore/references/api-replay.md +48 -0
- package/skills/qa-explore/references/browser-adapters.md +51 -0
- package/skills/qa-explore/references/evidence-and-reporting.md +66 -0
- package/skills/qa-explore/references/evidence-capture.md +54 -0
- package/skills/qa-explore/references/exploratory-qa.md +52 -0
- package/skills/qa-explore/references/finding-taxonomy.md +47 -0
- package/skills/qa-explore/references/performance.md +44 -0
- package/skills/qa-explore/references/pipeline.md +74 -0
- package/skills/qa-explore/references/report-pipeline.md +69 -0
- package/skills/qa-explore/references/security.md +43 -0
- package/skills/qa-explore/references/test-case-intake.md +44 -0
- package/skills/qa-fix/README.md +19 -0
- package/skills/qa-fix/SKILL.md +70 -0
- package/skills/qa-fix/contracts/fix-result.schema.json +132 -0
- package/skills/qa-fix/examples/repair-plan.md +56 -0
- package/skills/qa-fix/references/deterministic-tooling.md +128 -0
- package/skills/qa-fix/references/diagnostic-engine.md +36 -0
- package/skills/qa-fix/references/evidence-and-reporting.md +66 -0
- package/skills/qa-fix/references/repair-strategy.md +48 -0
- package/skills/qa-fix/references/root-cause-analysis.md +49 -0
- package/skills/qa-fix/references/suite-extension.md +73 -0
- package/skills/qa-flaky/README.md +19 -0
- package/skills/qa-flaky/SKILL.md +67 -0
- package/skills/qa-flaky/contracts/flaky-result.schema.json +62 -0
- package/skills/qa-flaky/examples/flaky-locator.md +46 -0
- package/skills/qa-flaky/references/deterministic-tooling.md +128 -0
- package/skills/qa-flaky/references/evidence-and-reporting.md +66 -0
- package/skills/qa-flaky/references/flakiness.md +48 -0
- package/skills/qa-flaky/references/retry.md +44 -0
- package/skills/qa-flaky/references/waiting-strategies.md +44 -0
- package/skills/qa-generate/README.md +42 -0
- package/skills/qa-generate/SKILL.md +83 -0
- package/skills/qa-generate/contracts/generation-result.schema.json +124 -0
- package/skills/qa-generate/examples/bootstrap-new-framework.md +24 -0
- package/skills/qa-generate/examples/extend-existing-suite.md +26 -0
- package/skills/qa-generate/references/code-style.md +29 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +66 -0
- package/skills/qa-generate/references/framework-selection.md +88 -0
- package/skills/qa-generate/references/generation-strategy.md +48 -0
- package/skills/qa-generate/references/naming-conventions.md +27 -0
- package/skills/qa-generate/references/playwright-conventions.md +23 -0
- package/skills/qa-generate/references/playwright-generation.md +41 -0
- package/skills/qa-generate/references/playwright-project-discovery.md +42 -0
- package/skills/qa-generate/references/project-bootstrap.md +54 -0
- package/skills/qa-generate/references/repository-analysis.md +43 -0
- package/skills/qa-generate/references/suite-extension.md +73 -0
- package/skills/qa-generate/references/template-selection.md +48 -0
- package/skills/qa-generate/templates/playwright/api.ts +36 -0
- package/skills/qa-generate/templates/playwright/base-page.ts +14 -0
- package/skills/qa-generate/templates/playwright/data.ts +25 -0
- package/skills/qa-generate/templates/playwright/env.example +16 -0
- package/skills/qa-generate/templates/playwright/example.spec.ts +14 -0
- package/skills/qa-generate/templates/playwright/fixtures.ts +28 -0
- package/skills/qa-generate/templates/playwright/framework-readme.md +39 -0
- package/skills/qa-generate/templates/playwright/login.page.ts +26 -0
- package/skills/qa-generate/templates/playwright/playwright.config.ts +28 -0
- package/skills/qa-generate/templates/playwright/utils.ts +18 -0
- package/skills/qa-init/README.md +20 -0
- package/skills/qa-init/SKILL.md +72 -0
- package/skills/qa-init/examples/initialize-a-repo.md +91 -0
- package/skills/qa-init/references/detection-guide.md +76 -0
- package/skills/qa-init/references/deterministic-tooling.md +128 -0
- package/skills/qa-init/references/evidence-and-reporting.md +66 -0
- package/skills/qa-init/templates/context.md +68 -0
- package/skills/qa-report/README.md +19 -0
- package/skills/qa-report/SKILL.md +72 -0
- package/skills/qa-report/contracts/report-result.schema.json +186 -0
- package/skills/qa-report/examples/release-report.md +68 -0
- package/skills/qa-report/references/deterministic-tooling.md +128 -0
- package/skills/qa-report/references/diagnostic-engine.md +36 -0
- package/skills/qa-report/references/evidence-and-reporting.md +66 -0
- package/skills/qa-report/references/finding-prioritization.md +43 -0
- package/skills/qa-report/references/recommendation-ranking.md +34 -0
- package/skills/qa-report/references/report-aggregation.md +44 -0
- package/skills/qa-review/README.md +19 -0
- package/skills/qa-review/SKILL.md +58 -0
- package/skills/qa-review/contracts/review-result.schema.json +59 -0
- package/skills/qa-review/examples/suite-review.md +51 -0
- package/skills/qa-review/references/anti-patterns.md +49 -0
- package/skills/qa-review/references/assertion-patterns.md +45 -0
- package/skills/qa-review/references/evidence-and-reporting.md +66 -0
- package/skills/qa-review/references/fixtures.md +45 -0
- package/skills/qa-review/references/page-objects.md +45 -0
- package/skills/qa-run/README.md +22 -0
- package/skills/qa-run/SKILL.md +92 -0
- package/skills/qa-run/contracts/execution-plan.schema.json +132 -0
- package/skills/qa-run/contracts/execution-result.schema.json +204 -0
- package/skills/qa-run/examples/execute-playwright.md +89 -0
- package/skills/qa-run/examples/plan-a-run.md +83 -0
- package/skills/qa-run/references/artifact-collector.md +40 -0
- package/skills/qa-run/references/browser-launch.md +39 -0
- package/skills/qa-run/references/command-builder.md +39 -0
- package/skills/qa-run/references/deterministic-tooling.md +128 -0
- package/skills/qa-run/references/environment-detection.md +37 -0
- package/skills/qa-run/references/evidence-and-reporting.md +66 -0
- package/skills/qa-run/references/execution-strategy.md +54 -0
- package/skills/qa-run/references/playwright-artifacts.md +29 -0
- package/skills/qa-run/references/playwright-execution.md +48 -0
- package/skills/qa-run/references/playwright-project-discovery.md +42 -0
- 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)
|