qa-engineer 0.9.2 → 0.11.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 +26 -12
- package/README.md +182 -84
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +560 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/engine/lib/analysis/contracts.mjs +149 -0
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +124 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +76 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +14 -0
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +13 -20
- package/packages/installer/lib/commands/install.mjs +160 -91
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/manifest.mjs +48 -57
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +49 -31
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +161 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- package/skills/qa-api/scripts/qa-tool.mjs +162 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -32
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
- package/skills/qa-explore/SKILL.md +31 -15
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/deterministic-tooling.md +34 -32
- package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
- package/skills/qa-explore/references/report-pipeline.md +266 -96
- package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -32
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
- package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -32
- package/skills/qa-init/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/scripts/qa-tool.mjs +162 -0
- package/skills/qa-report/SKILL.md +7 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -32
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +5 -5
- package/skills/qa-report/scripts/qa-tool.mjs +162 -0
- package/skills/qa-review/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -32
- package/skills/qa-run/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/scripts/qa-tool.mjs +162 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -144
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -87
- package/shared/analysis/lib/qa_analysis/junit.py +0 -104
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
- package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
|
@@ -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, '&')
|
|
83
|
+
.replace(/</g, '<')
|
|
84
|
+
.replace(/>/g, '>')
|
|
85
|
+
.replace(/"/g, '"')
|
|
86
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
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) — but
|
|
6
|
+
// nothing could check a *real* `.qa/context.md` against it, because the engine
|
|
7
|
+
// takes no dependencies and the frontmatter is YAML. Validation ran against a
|
|
8
|
+
// hand-written JSON fixture instead, so the contract was unenforced exactly where
|
|
9
|
+
// it mattered.
|
|
10
|
+
//
|
|
11
|
+
// This closes that gap without adding a dependency or changing the file format: it
|
|
12
|
+
// parses the **explicit subset** of YAML the context contract uses, then hands the
|
|
13
|
+
// result to the 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
|
+
import fs from 'node:fs';
|
|
34
|
+
|
|
35
|
+
import { validate } from './contracts.mjs';
|
|
36
|
+
|
|
37
|
+
const FENCE = '---';
|
|
38
|
+
const TRUE = new Set(['true', 'True', 'TRUE']);
|
|
39
|
+
const FALSE = new Set(['false', 'False', 'FALSE']);
|
|
40
|
+
const NULL = new Set(['null', 'Null', 'NULL', '~', '']);
|
|
41
|
+
const UNSUPPORTED_SCALARS = new Set(['|', '>']);
|
|
42
|
+
const INT = /^-?\d+$/;
|
|
43
|
+
const FLOAT = /^-?\d+\.\d+$/;
|
|
44
|
+
|
|
45
|
+
export class MalformedContext extends Error {}
|
|
46
|
+
|
|
47
|
+
/** Return `{frontmatter, body}`. Throws if the fences are missing. */
|
|
48
|
+
export function splitFrontmatter(text) {
|
|
49
|
+
const lines = String(text).split('\n');
|
|
50
|
+
let start = null;
|
|
51
|
+
for (const [index, line] of lines.entries()) {
|
|
52
|
+
if (line.trim() === '') continue;
|
|
53
|
+
if (trimEnd(line) === FENCE) start = index;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (start === null) {
|
|
57
|
+
throw new MalformedContext(
|
|
58
|
+
"no frontmatter: the file must open with a '---' fence " +
|
|
59
|
+
'(see the qa-init context template)',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
63
|
+
if (trimEnd(lines[index]) === FENCE) {
|
|
64
|
+
return {
|
|
65
|
+
frontmatter: lines.slice(start + 1, index).join('\n'),
|
|
66
|
+
body: lines.slice(index + 1).join('\n'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new MalformedContext("unterminated frontmatter: no closing '---' fence");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Remove a trailing `#` comment that is not inside quotes. */
|
|
74
|
+
function stripComment(value) {
|
|
75
|
+
let quote = null;
|
|
76
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
77
|
+
const char = value[index];
|
|
78
|
+
if (quote) {
|
|
79
|
+
if (char === quote) quote = null;
|
|
80
|
+
} else if (char === "'" || char === '"') {
|
|
81
|
+
quote = char;
|
|
82
|
+
} else if (char === '#' && (index === 0 || value[index - 1] === ' ' || value[index - 1] === '\t')) {
|
|
83
|
+
return value.slice(0, index);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function scalar(raw, lineNumber) {
|
|
90
|
+
const text = stripComment(raw).trim();
|
|
91
|
+
const first = text.slice(0, 1);
|
|
92
|
+
if (UNSUPPORTED_SCALARS.has(first)) {
|
|
93
|
+
throw new MalformedContext(
|
|
94
|
+
`line ${lineNumber}: block scalars ('|', '>') are outside the supported subset`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (first === '&' || first === '*') {
|
|
98
|
+
throw new MalformedContext(
|
|
99
|
+
`line ${lineNumber}: anchors and aliases are outside the supported subset`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (text.length >= 2 && text[0] === text[text.length - 1] && (text[0] === "'" || text[0] === '"')) {
|
|
103
|
+
return text.slice(1, -1);
|
|
104
|
+
}
|
|
105
|
+
if (text === '[]') return [];
|
|
106
|
+
if (text === '{}') return {};
|
|
107
|
+
if (text.startsWith('[') || text.startsWith('{')) {
|
|
108
|
+
throw new MalformedContext(
|
|
109
|
+
`line ${lineNumber}: only empty flow collections ('[]', '{}') are supported; ` +
|
|
110
|
+
'use a block sequence or mapping',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (TRUE.has(text)) return true;
|
|
114
|
+
if (FALSE.has(text)) return false;
|
|
115
|
+
if (NULL.has(text)) return null;
|
|
116
|
+
if (INT.test(text)) return Number.parseInt(text, 10);
|
|
117
|
+
if (FLOAT.test(text)) return Number.parseFloat(text);
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A line must sit at exactly the indent of the container it lands in.
|
|
123
|
+
*
|
|
124
|
+
* Without this, a document YAML itself rejects was silently misread. Given
|
|
125
|
+
*
|
|
126
|
+
* list:
|
|
127
|
+
* - one
|
|
128
|
+
* key: inside a sequence
|
|
129
|
+
*
|
|
130
|
+
* PyYAML reports "expected <block end>, but found '?'" — a mapping key cannot
|
|
131
|
+
* share indentation with a sequence entry in the same block. The parser closed the
|
|
132
|
+
* sequence and put `key` in the *root* mapping, two levels out from where it was
|
|
133
|
+
* written, turning an invalid file into a plausible one.
|
|
134
|
+
*/
|
|
135
|
+
function requireIndentMatches(stack, indent, lineNumber) {
|
|
136
|
+
if (stack[stack.length - 1][0] !== indent) {
|
|
137
|
+
throw new MalformedContext(
|
|
138
|
+
`line ${lineNumber}: indentation ${indent} matches no open block ` +
|
|
139
|
+
`(the enclosing block starts at column ${stack[stack.length - 1][0]})`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse the supported YAML subset into an object.
|
|
146
|
+
*
|
|
147
|
+
* The parser keeps a stack of `[keyIndent, container]` frames. `keyIndent` is the
|
|
148
|
+
* column at which that container's own entries start, so a line's indent alone
|
|
149
|
+
* decides which container it belongs to: pop every frame deeper than the line, and
|
|
150
|
+
* the top of the stack is the target.
|
|
151
|
+
*/
|
|
152
|
+
export function parseFrontmatter(text) {
|
|
153
|
+
const root = {};
|
|
154
|
+
const stack = [[0, root]];
|
|
155
|
+
let pending = null; // [key, indent] — a key whose value is a block that follows
|
|
156
|
+
|
|
157
|
+
const resolvePendingAsNull = () => {
|
|
158
|
+
// A key with no value and no nested block is a null field.
|
|
159
|
+
if (pending !== null) {
|
|
160
|
+
stack[stack.length - 1][1][pending[0]] = null;
|
|
161
|
+
pending = null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const lines = String(text).split('\n');
|
|
166
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
167
|
+
const rawLine = lines[index];
|
|
168
|
+
const number = index + 1;
|
|
169
|
+
const leading = rawLine.slice(0, rawLine.length - trimStart(rawLine).length);
|
|
170
|
+
if (leading.includes('\t')) {
|
|
171
|
+
throw new MalformedContext(`line ${number}: tab indentation is not supported`);
|
|
172
|
+
}
|
|
173
|
+
const stripped = rawLine.trim();
|
|
174
|
+
if (!stripped || stripped.startsWith('#')) continue;
|
|
175
|
+
if (stripped === FENCE) {
|
|
176
|
+
throw new MalformedContext(`line ${number}: unexpected '---' inside frontmatter`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const indent = rawLine.length - trimStartSpaces(rawLine).length;
|
|
180
|
+
|
|
181
|
+
// --- sequence item ---
|
|
182
|
+
if (stripped.startsWith('- ') || stripped === '-') {
|
|
183
|
+
const item = scalar(stripped.length > 1 ? stripped.slice(2) : '', number);
|
|
184
|
+
if (pending !== null) {
|
|
185
|
+
// A sequence may sit at the key's indent or deeper.
|
|
186
|
+
const parent = stack[stack.length - 1][1];
|
|
187
|
+
parent[pending[0]] = [];
|
|
188
|
+
stack.push([indent, parent[pending[0]]]);
|
|
189
|
+
pending = null;
|
|
190
|
+
}
|
|
191
|
+
while (stack.length > 1 && stack[stack.length - 1][0] > indent) stack.pop();
|
|
192
|
+
const container = stack[stack.length - 1][1];
|
|
193
|
+
if (!Array.isArray(container)) {
|
|
194
|
+
throw new MalformedContext(`line ${number}: sequence item outside a sequence`);
|
|
195
|
+
}
|
|
196
|
+
requireIndentMatches(stack, indent, number);
|
|
197
|
+
container.push(item);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- mapping entry ---
|
|
202
|
+
if (!stripped.includes(':')) {
|
|
203
|
+
throw new MalformedContext(
|
|
204
|
+
`line ${number}: expected 'key: value' or '- item', got '${stripped}'`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (pending !== null) {
|
|
209
|
+
if (indent > pending[1]) {
|
|
210
|
+
// The pending key opens a nested mapping at this indent.
|
|
211
|
+
const parent = stack[stack.length - 1][1];
|
|
212
|
+
parent[pending[0]] = {};
|
|
213
|
+
stack.push([indent, parent[pending[0]]]);
|
|
214
|
+
pending = null;
|
|
215
|
+
} else {
|
|
216
|
+
resolvePendingAsNull();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Leave the frames this line does not belong to. A sequence frame at the same
|
|
221
|
+
// indent as a key also ends here (`packages:` / `- a` / `ci:`).
|
|
222
|
+
while (
|
|
223
|
+
stack.length > 1 &&
|
|
224
|
+
(stack[stack.length - 1][0] > indent ||
|
|
225
|
+
(Array.isArray(stack[stack.length - 1][1]) && stack[stack.length - 1][0] >= indent))
|
|
226
|
+
) {
|
|
227
|
+
stack.pop();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const separator = stripped.indexOf(':');
|
|
231
|
+
const key = stripped.slice(0, separator).trim();
|
|
232
|
+
const value = stripped.slice(separator + 1);
|
|
233
|
+
if (!key) throw new MalformedContext(`line ${number}: empty key`);
|
|
234
|
+
const container = stack[stack.length - 1][1];
|
|
235
|
+
if (Array.isArray(container)) {
|
|
236
|
+
throw new MalformedContext(`line ${number}: mapping key inside a sequence`);
|
|
237
|
+
}
|
|
238
|
+
requireIndentMatches(stack, indent, number);
|
|
239
|
+
|
|
240
|
+
if (stripComment(value).trim() === '') {
|
|
241
|
+
pending = [key, indent]; // a block or a null follows
|
|
242
|
+
} else {
|
|
243
|
+
container[key] = scalar(value, number);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
resolvePendingAsNull();
|
|
248
|
+
return root;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parse `.qa/context.md` text and, when a schema is given, validate it.
|
|
253
|
+
*
|
|
254
|
+
* A parse failure throws: it is not a validation result. "This file is not
|
|
255
|
+
* parseable" and "this file is parseable but wrong" are different answers, and
|
|
256
|
+
* collapsing them would report a broken file as a merely invalid one.
|
|
257
|
+
*/
|
|
258
|
+
export function parse(text, { schema = null } = {}) {
|
|
259
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
260
|
+
const context = parseFrontmatter(frontmatter);
|
|
261
|
+
const result = { context, body, valid: true, errors: [] };
|
|
262
|
+
if (schema !== null) {
|
|
263
|
+
const errors = validate(context, schema);
|
|
264
|
+
result.valid = errors.length === 0;
|
|
265
|
+
result.errors = errors;
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read and parse a `.qa/context.md` file. */
|
|
271
|
+
export function parseFile(path, { schema = null } = {}) {
|
|
272
|
+
let text;
|
|
273
|
+
try {
|
|
274
|
+
text = fs.readFileSync(path, 'utf8');
|
|
275
|
+
} catch (error) {
|
|
276
|
+
throw new MalformedContext(`could not read ${path}: ${error.message}`);
|
|
277
|
+
}
|
|
278
|
+
return parse(text, { schema });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Python's str.rstrip()/lstrip() strip all whitespace; JavaScript's trimEnd and
|
|
282
|
+
// trimStart do the same, but the indent calculation needs spaces only — a line
|
|
283
|
+
// indented with a form feed is not indented two columns.
|
|
284
|
+
function trimEnd(value) {
|
|
285
|
+
return value.replace(/\s+$/, '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function trimStart(value) {
|
|
289
|
+
return value.replace(/^\s+/, '');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function trimStartSpaces(value) {
|
|
293
|
+
return value.replace(/^ +/, '');
|
|
294
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A dependency-free validator for the JSON Schema subset this project uses.
|
|
2
|
+
//
|
|
3
|
+
// This began as the JavaScript *twin* of a Python validator, kept in step by a
|
|
4
|
+
// shared corpus. The move to a Node-only engine (ADR-0012) made it the single
|
|
5
|
+
// implementation: the installer's config and lockfile checks and the analysis
|
|
6
|
+
// engine's contract checks now go through this one function, rather than two
|
|
7
|
+
// copies that agreed by test.
|
|
8
|
+
//
|
|
9
|
+
// The corpus outlived the twin. packages/engine/test/corpus/validator-cases.json
|
|
10
|
+
// still runs every case here, because it encodes which subset of JSON Schema the
|
|
11
|
+
// pack's contracts may use — and that is worth pinning whether or not a second
|
|
12
|
+
// validator exists to compare against.
|
|
13
|
+
//
|
|
14
|
+
// Anything outside the subset is a programming error in the schema, reported
|
|
15
|
+
// rather than silently ignored. `allOf`/`if`/`then`/`else` are in the subset
|
|
16
|
+
// because the pack's safety invariants are cross-field implications.
|
|
17
|
+
|
|
18
|
+
// Keep in sync with SUPPORTED_KEYWORDS in
|
|
19
|
+
// shared/analysis/lib/qa_analysis/contracts.py and the table in
|
|
20
|
+
// docs/skills/output-contracts.md (checked by scripts/check-spec-code-sync.mjs).
|
|
21
|
+
const SUPPORTED = new Set([
|
|
22
|
+
'$schema', '$id', 'title', 'description', 'type', 'properties', 'required',
|
|
23
|
+
'additionalProperties', 'items', 'enum', 'const', 'pattern', 'minimum',
|
|
24
|
+
'maximum', 'minItems', 'maxItems', 'minLength', 'maxLength', 'default',
|
|
25
|
+
'examples', 'format', 'allOf', 'if', 'then', 'else',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// RFC 3339 date-time — the same rule as the Python twin.
|
|
29
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
30
|
+
|
|
31
|
+
function typeOf(value) {
|
|
32
|
+
if (value === null) return 'null';
|
|
33
|
+
if (Array.isArray(value)) return 'array';
|
|
34
|
+
if (Number.isInteger(value)) return 'integer';
|
|
35
|
+
return typeof value; // string | number | boolean | object
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function matchesType(value, type) {
|
|
39
|
+
if (type === 'integer') return Number.isInteger(value) && typeof value !== 'boolean';
|
|
40
|
+
if (type === 'number') return typeof value === 'number';
|
|
41
|
+
if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
if (type === 'array') return Array.isArray(value);
|
|
43
|
+
return typeOf(value) === type;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when value satisfies schema; used for if/then branch selection. */
|
|
47
|
+
function satisfies(value, schema) {
|
|
48
|
+
const probe = [];
|
|
49
|
+
validateNode(value, schema, '', probe);
|
|
50
|
+
return probe.length === 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateNode(value, schema, pointer, errors) {
|
|
54
|
+
for (const key of Object.keys(schema)) {
|
|
55
|
+
if (!SUPPORTED.has(key)) {
|
|
56
|
+
errors.push(`${pointer || '/'}: schema uses unsupported keyword "${key}"`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (schema.type) {
|
|
61
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
62
|
+
if (!types.some((t) => matchesType(value, t))) {
|
|
63
|
+
errors.push(`${pointer || '/'}: expected ${types.join(' | ')}, got ${typeOf(value)}`);
|
|
64
|
+
return; // further checks assume the type held
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(schema, 'const') && JSON.stringify(value) !== JSON.stringify(schema.const)) {
|
|
69
|
+
errors.push(`${pointer || '/'}: must equal ${JSON.stringify(schema.const)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (schema.enum && !schema.enum.some((option) => JSON.stringify(option) === JSON.stringify(value))) {
|
|
73
|
+
errors.push(`${pointer || '/'}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof value === 'string') {
|
|
77
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
78
|
+
errors.push(`${pointer || '/'}: "${value}" does not match /${schema.pattern}/`);
|
|
79
|
+
}
|
|
80
|
+
if (schema.format === 'date-time' && !DATE_TIME.test(value)) {
|
|
81
|
+
errors.push(`${pointer || '/'}: "${value}" is not a valid date-time`);
|
|
82
|
+
}
|
|
83
|
+
if (schema.minLength != null && value.length < schema.minLength) {
|
|
84
|
+
errors.push(`${pointer || '/'}: shorter than minLength ${schema.minLength}`);
|
|
85
|
+
}
|
|
86
|
+
if (schema.maxLength != null && value.length > schema.maxLength) {
|
|
87
|
+
errors.push(`${pointer || '/'}: longer than maxLength ${schema.maxLength}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof value === 'number') {
|
|
92
|
+
if (schema.minimum != null && value < schema.minimum) {
|
|
93
|
+
errors.push(`${pointer || '/'}: ${value} is below minimum ${schema.minimum}`);
|
|
94
|
+
}
|
|
95
|
+
if (schema.maximum != null && value > schema.maximum) {
|
|
96
|
+
errors.push(`${pointer || '/'}: ${value} is above maximum ${schema.maximum}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
if (schema.minItems != null && value.length < schema.minItems) {
|
|
102
|
+
errors.push(`${pointer || '/'}: has ${value.length} items, needs at least ${schema.minItems}`);
|
|
103
|
+
}
|
|
104
|
+
if (schema.maxItems != null && value.length > schema.maxItems) {
|
|
105
|
+
errors.push(`${pointer || '/'}: has ${value.length} items, allows at most ${schema.maxItems}`);
|
|
106
|
+
}
|
|
107
|
+
if (schema.items) {
|
|
108
|
+
value.forEach((item, index) => validateNode(item, schema.items, `${pointer}/${index}`, errors));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (matchesType(value, 'object') && (schema.properties || schema.required || schema.additionalProperties === false)) {
|
|
113
|
+
const properties = schema.properties ?? {};
|
|
114
|
+
for (const name of schema.required ?? []) {
|
|
115
|
+
if (!Object.prototype.hasOwnProperty.call(value, name)) {
|
|
116
|
+
errors.push(`${pointer || '/'}: missing required property "${name}"`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const [name, child] of Object.entries(value)) {
|
|
120
|
+
if (properties[name]) {
|
|
121
|
+
validateNode(child, properties[name], `${pointer}/${name}`, errors);
|
|
122
|
+
} else if (schema.additionalProperties === false) {
|
|
123
|
+
errors.push(`${pointer || '/'}: unexpected property "${name}"`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Applicators last: the invariant layer. `additionalProperties` deliberately
|
|
129
|
+
// does not see properties introduced by these subschemas (JSON Schema
|
|
130
|
+
// 2020-12 rule, matching the Python twin).
|
|
131
|
+
for (const sub of schema.allOf ?? []) {
|
|
132
|
+
validateNode(value, sub, pointer, errors);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (Object.prototype.hasOwnProperty.call(schema, 'if')) {
|
|
136
|
+
const branch = satisfies(value, schema.if) ? schema.then : schema.else;
|
|
137
|
+
if (branch != null) validateNode(value, branch, pointer, errors);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Validate `value` against `schema`; returns an array of error strings (empty = valid). */
|
|
142
|
+
export function validate(value, schema) {
|
|
143
|
+
const errors = [];
|
|
144
|
+
validateNode(value, schema, '', errors);
|
|
145
|
+
return errors;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The supported keyword subset, exported so tooling can assert parity. */
|
|
149
|
+
export const SUPPORTED_KEYWORDS = Object.freeze([...SUPPORTED].sort());
|