qa-boot 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/dist/cli/commands/generate.js +7 -0
- package/dist/cli/commands/init.js +24 -0
- package/dist/cli/commands/scan.js +30 -0
- package/dist/cli/commands/tell.js +27 -0
- package/dist/cli/index.js +67 -0
- package/dist/cli/version.js +5 -0
- package/dist/config/config.js +34 -0
- package/dist/core/fact-store.js +50 -0
- package/dist/core/fact.js +30 -0
- package/dist/core/fs-utils.js +39 -0
- package/dist/core/scan-context.js +13 -0
- package/dist/core/slug.js +8 -0
- package/dist/domains/agent-facts.js +31 -0
- package/dist/domains/build-facts.js +31 -0
- package/dist/domains/ci-facts.js +43 -0
- package/dist/domains/docs-facts.js +28 -0
- package/dist/domains/repo-facts.js +52 -0
- package/dist/domains/repo-quality-facts.js +34 -0
- package/dist/domains/test-facts.js +65 -0
- package/dist/generate/claude-qa.js +20 -0
- package/dist/generate/domain-summaries.js +20 -0
- package/dist/generate/generate.js +68 -0
- package/dist/generate/known-unknown.js +33 -0
- package/dist/generate/skills.js +83 -0
- package/dist/generate/special-renderers.js +99 -0
- package/dist/maturity/rubric.js +114 -0
- package/dist/pipeline/run-scan.js +53 -0
- package/dist/providers/evidence-provider.js +1 -0
- package/dist/providers/qaradar-contract.js +1 -0
- package/dist/providers/qaradar-parse.js +51 -0
- package/dist/providers/qaradar-provider.js +30 -0
- package/dist/scanners/agent-config-scanner.js +23 -0
- package/dist/scanners/build-scanner.js +27 -0
- package/dist/scanners/ci-scanner.js +51 -0
- package/dist/scanners/docs-scanner.js +24 -0
- package/dist/scanners/raw-evidence.js +1 -0
- package/dist/scanners/repo-scanner.js +41 -0
- package/dist/scanners/test-scanner.js +85 -0
- package/dist/tell/interview-log.js +22 -0
- package/dist/tell/tell.js +77 -0
- package/dist/unknowns/unknowns.js +101 -0
- package/package.json +44 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { parseQaradar } from "./qaradar-parse.js";
|
|
3
|
+
import { qaradarEnabled } from "../config/config.js";
|
|
4
|
+
function which() {
|
|
5
|
+
const probe = spawnSync("qaradar", ["--version"], { encoding: "utf8" });
|
|
6
|
+
return probe.status === 0;
|
|
7
|
+
}
|
|
8
|
+
export const qaradarProvider = {
|
|
9
|
+
name: "qaradar",
|
|
10
|
+
async isAvailable(ctx) {
|
|
11
|
+
if (qaradarEnabled(ctx.config) === "false")
|
|
12
|
+
return false;
|
|
13
|
+
return which();
|
|
14
|
+
},
|
|
15
|
+
async collect(ctx) {
|
|
16
|
+
const { days, top } = ctx.config.evidence_providers.qaradar;
|
|
17
|
+
const run = spawnSync("qaradar", ["analyze", ctx.repoPath, "--json-output", "--days", String(days), "--top", String(top)], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
18
|
+
if (run.status !== 0 || !run.stdout) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
let report;
|
|
22
|
+
try {
|
|
23
|
+
report = JSON.parse(run.stdout);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
return parseQaradar(report);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const MARKERS = [
|
|
2
|
+
["CLAUDE.md", "CLAUDE.md"],
|
|
3
|
+
["AGENTS.md", "AGENTS.md"],
|
|
4
|
+
[".claude/**", ".claude"],
|
|
5
|
+
[".cursor/**", ".cursor"],
|
|
6
|
+
[".cursorrules", ".cursorrules"],
|
|
7
|
+
];
|
|
8
|
+
export function scanAgentConfig(ctx) {
|
|
9
|
+
const ev = [];
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
for (const [glob, name] of MARKERS) {
|
|
12
|
+
const hits = ctx.match(glob);
|
|
13
|
+
if (hits.length && !seen.has(name)) {
|
|
14
|
+
seen.add(name);
|
|
15
|
+
ev.push({ kind: "agent-config", path: hits[0], detail: { name } });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (ctx.has(".claude/skills/**"))
|
|
19
|
+
ev.push({ kind: "agent-skills", path: ".claude/skills" });
|
|
20
|
+
if (ctx.has(".claude/**/*mcp*"))
|
|
21
|
+
ev.push({ kind: "agent-mcp", path: ".claude" });
|
|
22
|
+
return ev;
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const SCRIPT_KINDS = ["build", "start", "test", "package"];
|
|
2
|
+
export function scanBuild(ctx) {
|
|
3
|
+
const ev = [];
|
|
4
|
+
const pkg = ctx.readJson("package.json");
|
|
5
|
+
for (const name of SCRIPT_KINDS) {
|
|
6
|
+
const cmd = pkg?.scripts?.[name];
|
|
7
|
+
if (cmd)
|
|
8
|
+
ev.push({ kind: "build-command", path: "package.json", detail: { name, cmd } });
|
|
9
|
+
}
|
|
10
|
+
if (ctx.has("Makefile"))
|
|
11
|
+
ev.push({ kind: "build-file", path: "Makefile", detail: { name: "make" } });
|
|
12
|
+
if (ctx.has("Dockerfile"))
|
|
13
|
+
ev.push({ kind: "build-file", path: "Dockerfile", detail: { name: "docker" } });
|
|
14
|
+
for (const f of ctx.match("docker-compose*.{yml,yaml}")) {
|
|
15
|
+
ev.push({ kind: "build-file", path: f, detail: { name: "docker-compose" } });
|
|
16
|
+
}
|
|
17
|
+
if (ctx.has("fastlane/**") || ctx.has("Fastfile")) {
|
|
18
|
+
ev.push({ kind: "build-file", path: "fastlane", detail: { name: "fastlane" } });
|
|
19
|
+
}
|
|
20
|
+
if (ctx.has("build.gradle") || ctx.has("build.gradle.kts") || ctx.has("gradlew") || ctx.has("settings.gradle") || ctx.has("settings.gradle.kts")) {
|
|
21
|
+
ev.push({ kind: "build-file", path: "build.gradle", detail: { name: "gradle" } });
|
|
22
|
+
}
|
|
23
|
+
if (ctx.has("pom.xml") || ctx.has("mvnw")) {
|
|
24
|
+
ev.push({ kind: "build-file", path: "pom.xml", detail: { name: "maven" } });
|
|
25
|
+
}
|
|
26
|
+
return ev;
|
|
27
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
const CI_SYSTEMS = [
|
|
3
|
+
[".github/workflows/*.yml", "github-actions"],
|
|
4
|
+
[".github/workflows/*.yaml", "github-actions"],
|
|
5
|
+
[".gitlab-ci.yml", "gitlab-ci"],
|
|
6
|
+
["Jenkinsfile", "jenkins"],
|
|
7
|
+
[".circleci/config.yml", "circleci"],
|
|
8
|
+
[".buildkite/**", "buildkite"],
|
|
9
|
+
["bitbucket-pipelines.yml", "bitbucket-pipelines"],
|
|
10
|
+
];
|
|
11
|
+
const JOB_HINTS = [
|
|
12
|
+
[/test|spec|lint|check/i, "test"],
|
|
13
|
+
[/build|compile|package/i, "build"],
|
|
14
|
+
[/deploy|release|publish/i, "deploy"],
|
|
15
|
+
];
|
|
16
|
+
function classifyJobName(name) {
|
|
17
|
+
for (const [re, kind] of JOB_HINTS)
|
|
18
|
+
if (re.test(name))
|
|
19
|
+
return kind;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function scanCi(ctx) {
|
|
23
|
+
const ev = [];
|
|
24
|
+
const systems = new Set();
|
|
25
|
+
for (const [glob, name] of CI_SYSTEMS) {
|
|
26
|
+
const hits = ctx.match(glob);
|
|
27
|
+
if (hits.length && !systems.has(name)) {
|
|
28
|
+
systems.add(name);
|
|
29
|
+
ev.push({ kind: "ci-system", path: hits[0], detail: { name } });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const wf of ctx.match(".github/workflows/*.{yml,yaml}")) {
|
|
33
|
+
const text = ctx.read(wf);
|
|
34
|
+
if (!text)
|
|
35
|
+
continue;
|
|
36
|
+
let doc;
|
|
37
|
+
try {
|
|
38
|
+
doc = parseYaml(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const jobs = doc?.jobs;
|
|
44
|
+
for (const jobName of Object.keys(jobs ?? {})) {
|
|
45
|
+
const kind = classifyJobName(jobName);
|
|
46
|
+
if (kind)
|
|
47
|
+
ev.push({ kind: "ci-job", path: wf, detail: { name: jobName, kind } });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return ev;
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const DOC_MARKERS = [
|
|
2
|
+
["README*", "readme"],
|
|
3
|
+
["CONTRIBUTING*", "contributing"],
|
|
4
|
+
["docs/**", "docs-dir"],
|
|
5
|
+
["CODEOWNERS", "codeowners"],
|
|
6
|
+
[".github/CODEOWNERS", "codeowners"],
|
|
7
|
+
[".github/PULL_REQUEST_TEMPLATE*", "pr-template"],
|
|
8
|
+
[".github/ISSUE_TEMPLATE/**", "issue-template"],
|
|
9
|
+
["CHANGELOG*", "changelog"],
|
|
10
|
+
["docs/decisions/**", "adr"],
|
|
11
|
+
["docs/adr/**", "adr"],
|
|
12
|
+
];
|
|
13
|
+
export function scanDocs(ctx) {
|
|
14
|
+
const ev = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const [glob, kind] of DOC_MARKERS) {
|
|
17
|
+
const hits = ctx.match(glob);
|
|
18
|
+
if (hits.length && !seen.has(kind)) {
|
|
19
|
+
seen.add(kind);
|
|
20
|
+
ev.push({ kind: "doc", path: hits[0], detail: { kind } });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return ev;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function scanRepo(ctx) {
|
|
2
|
+
const ev = [];
|
|
3
|
+
const langMarkers = [
|
|
4
|
+
["package.json", "language:javascript/typescript", "npm"],
|
|
5
|
+
["pyproject.toml", "python", "pip"],
|
|
6
|
+
["go.mod", "go", "go"],
|
|
7
|
+
["pom.xml", "java", "maven"],
|
|
8
|
+
["build.gradle", "java/kotlin", "gradle"],
|
|
9
|
+
["build.gradle.kts", "java/kotlin", "gradle"],
|
|
10
|
+
["Cargo.toml", "rust", "cargo"],
|
|
11
|
+
["Gemfile", "ruby", "bundler"],
|
|
12
|
+
];
|
|
13
|
+
for (const [file, lang, pm] of langMarkers) {
|
|
14
|
+
if (ctx.has(file)) {
|
|
15
|
+
const name = lang.startsWith("language:") ? lang.slice("language:".length) : lang;
|
|
16
|
+
ev.push({ kind: "language", path: file, detail: { name } });
|
|
17
|
+
ev.push({ kind: "package-manager", path: file, detail: { name: pm } });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const pkg = ctx.readJson("package.json");
|
|
21
|
+
if (pkg) {
|
|
22
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
23
|
+
for (const [name] of Object.entries(deps)) {
|
|
24
|
+
ev.push({ kind: "dependency", path: "package.json", detail: { name } });
|
|
25
|
+
}
|
|
26
|
+
for (const [script, cmd] of Object.entries(pkg.scripts ?? {})) {
|
|
27
|
+
ev.push({ kind: "npm-script", path: "package.json", detail: { name: script, cmd } });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (ctx.has("Dockerfile"))
|
|
31
|
+
ev.push({ kind: "dockerfile", path: "Dockerfile" });
|
|
32
|
+
if (ctx.has("Makefile"))
|
|
33
|
+
ev.push({ kind: "makefile", path: "Makefile" });
|
|
34
|
+
for (const marker of ["pnpm-workspace.yaml", "lerna.json", "nx.json"]) {
|
|
35
|
+
if (ctx.has(marker))
|
|
36
|
+
ev.push({ kind: "monorepo", path: marker, detail: { name: marker } });
|
|
37
|
+
}
|
|
38
|
+
for (const r of ctx.match("README*"))
|
|
39
|
+
ev.push({ kind: "readme", path: r });
|
|
40
|
+
return ev;
|
|
41
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const FRAMEWORK_CONFIGS = [
|
|
2
|
+
["playwright.config.*", "playwright"],
|
|
3
|
+
["cypress.config.*", "cypress"],
|
|
4
|
+
["jest.config.*", "jest"],
|
|
5
|
+
["vitest.config.*", "vitest"],
|
|
6
|
+
["pytest.ini", "pytest"],
|
|
7
|
+
["tox.ini", "pytest"],
|
|
8
|
+
];
|
|
9
|
+
const DEP_FRAMEWORKS = [
|
|
10
|
+
["@playwright/test", "playwright"],
|
|
11
|
+
["cypress", "cypress"],
|
|
12
|
+
["jest", "jest"],
|
|
13
|
+
["vitest", "vitest"],
|
|
14
|
+
["mocha", "mocha"],
|
|
15
|
+
];
|
|
16
|
+
const FRAMEWORK_KEYWORDS = [
|
|
17
|
+
["junit", "junit"],
|
|
18
|
+
["testng", "testng"],
|
|
19
|
+
["rest-assured", "rest-assured"],
|
|
20
|
+
["restassured", "rest-assured"],
|
|
21
|
+
["selenium", "selenium"],
|
|
22
|
+
["seleniumhq", "selenium"],
|
|
23
|
+
["selenide", "selenide"],
|
|
24
|
+
["cucumber", "cucumber"],
|
|
25
|
+
["appium", "appium"],
|
|
26
|
+
["espresso", "espresso"],
|
|
27
|
+
["xcuitest", "xcuitest"],
|
|
28
|
+
["k6", "k6"],
|
|
29
|
+
["jmeter", "jmeter"],
|
|
30
|
+
["gatling", "gatling"],
|
|
31
|
+
];
|
|
32
|
+
const BUILD_FILES = ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "pom.xml"];
|
|
33
|
+
const TEST_DIRS = ["test/", "tests/", "__tests__/", "spec/", "e2e/", "src/test/"];
|
|
34
|
+
const COVERAGE_MARKERS = [
|
|
35
|
+
["**/coverage/**", "coverage-dir"],
|
|
36
|
+
["**/.nyc_output/**", "nyc"],
|
|
37
|
+
["**/jacoco*.xml", "jacoco"],
|
|
38
|
+
];
|
|
39
|
+
export function scanTests(ctx) {
|
|
40
|
+
const ev = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
for (const [glob, name] of FRAMEWORK_CONFIGS) {
|
|
43
|
+
if (ctx.has(glob) && !seen.has(name)) {
|
|
44
|
+
seen.add(name);
|
|
45
|
+
ev.push({ kind: "test-framework", path: glob, detail: { name, source: "config" } });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const pkg = ctx.readJson("package.json");
|
|
49
|
+
if (pkg) {
|
|
50
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
51
|
+
for (const [dep, name] of DEP_FRAMEWORKS) {
|
|
52
|
+
if (deps[dep] && !seen.has(name)) {
|
|
53
|
+
seen.add(name);
|
|
54
|
+
ev.push({ kind: "test-framework", path: "package.json", detail: { name, source: "dependency" } });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const pyproject = ctx.read("pyproject.toml");
|
|
59
|
+
if (pyproject && /\[tool\.pytest/.test(pyproject) && !seen.has("pytest")) {
|
|
60
|
+
seen.add("pytest");
|
|
61
|
+
ev.push({ kind: "test-framework", path: "pyproject.toml", detail: { name: "pytest", source: "config" } });
|
|
62
|
+
}
|
|
63
|
+
for (const buildFile of BUILD_FILES) {
|
|
64
|
+
const text = ctx.read(buildFile);
|
|
65
|
+
if (!text)
|
|
66
|
+
continue;
|
|
67
|
+
const lower = text.toLowerCase();
|
|
68
|
+
for (const [keyword, name] of FRAMEWORK_KEYWORDS) {
|
|
69
|
+
if (lower.includes(keyword) && !seen.has(name)) {
|
|
70
|
+
seen.add(name);
|
|
71
|
+
ev.push({ kind: "test-framework", path: buildFile, detail: { name, source: "build-file" } });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const dir of TEST_DIRS) {
|
|
76
|
+
if (ctx.has(`${dir}**`) || ctx.has(dir)) {
|
|
77
|
+
ev.push({ kind: "test-dir", path: dir });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [glob, name] of COVERAGE_MARKERS) {
|
|
81
|
+
if (ctx.has(glob))
|
|
82
|
+
ev.push({ kind: "coverage-tool", detail: { name } });
|
|
83
|
+
}
|
|
84
|
+
return ev;
|
|
85
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
const HEADER = "# QA Interview Log\n\nAppend-only record of human-told QA knowledge. Written by `qa-boot tell`; do not hand-edit.\n";
|
|
4
|
+
export function formatLogEntry(e) {
|
|
5
|
+
const lines = ["", `## ${e.date} — ${e.by}`];
|
|
6
|
+
if (e.unknownId && e.question) {
|
|
7
|
+
lines.push(`**Q (${e.unknownId}):** ${e.question}`, `**A:** ${e.statement}`);
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
lines.push(`**Told (${e.domain}):** ${e.statement}`);
|
|
11
|
+
}
|
|
12
|
+
lines.push(`→ fact \`${e.factId}\` (scope: ${e.scope})`, "");
|
|
13
|
+
return lines.join("\n");
|
|
14
|
+
}
|
|
15
|
+
export function appendInterviewLog(repoPath, entry) {
|
|
16
|
+
const path = join(repoPath, "qa-context", "qa-interview-log.md");
|
|
17
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
writeFileSync(path, HEADER, "utf8");
|
|
20
|
+
appendFileSync(path, formatLogEntry(entry), "utf8");
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
import { slug } from "../core/slug.js";
|
|
3
|
+
import { knownUnknownIds, unknownSpec } from "../unknowns/unknowns.js";
|
|
4
|
+
export const TELLABLE_DOMAINS = [
|
|
5
|
+
"build",
|
|
6
|
+
"release",
|
|
7
|
+
"environment",
|
|
8
|
+
"test_data",
|
|
9
|
+
"ownership",
|
|
10
|
+
"business_priority",
|
|
11
|
+
"agent_permissions",
|
|
12
|
+
"test_trust",
|
|
13
|
+
"knowledge_sources",
|
|
14
|
+
];
|
|
15
|
+
export class TellError extends Error {
|
|
16
|
+
}
|
|
17
|
+
export function applyTell(store, input, today) {
|
|
18
|
+
if (!input.by?.trim())
|
|
19
|
+
throw new TellError("Missing --by <who>. Told facts must record who said them.");
|
|
20
|
+
if (!input.statement?.trim())
|
|
21
|
+
throw new TellError("Missing statement text.");
|
|
22
|
+
if (input.unknownId)
|
|
23
|
+
return answerMode(store, input, today);
|
|
24
|
+
if (input.domain)
|
|
25
|
+
return freeformMode(store, input, today);
|
|
26
|
+
throw new TellError(`Provide an unknown id to answer, or --domain for free-form knowledge.\nKnown unknown ids:\n ${knownUnknownIds().join("\n ")}`);
|
|
27
|
+
}
|
|
28
|
+
function answerMode(store, input, today) {
|
|
29
|
+
const spec = unknownSpec(input.unknownId);
|
|
30
|
+
if (!spec) {
|
|
31
|
+
throw new TellError(`Unknown id "${input.unknownId}" is not one of qa-boot's unknowns. Valid ids:\n ${knownUnknownIds().join("\n ")}`);
|
|
32
|
+
}
|
|
33
|
+
const fact = buildToldFact({ factId: `${spec.id}.answer`, domain: spec.domain, input, answersUnknown: spec.id }, today);
|
|
34
|
+
const removedUnknown = store.remove(spec.id);
|
|
35
|
+
store.upsert([fact]);
|
|
36
|
+
return { fact, question: spec.question, removedUnknown, remainingUnknowns: countOpenUnknowns(store) };
|
|
37
|
+
}
|
|
38
|
+
function freeformMode(store, input, today) {
|
|
39
|
+
const domain = input.domain;
|
|
40
|
+
if (!TELLABLE_DOMAINS.includes(domain)) {
|
|
41
|
+
throw new TellError(`Domain "${domain}" is not tellable. Valid domains: ${TELLABLE_DOMAINS.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
const s = input.idSlug ? slug(input.idSlug) : slug(input.statement);
|
|
44
|
+
if (!s)
|
|
45
|
+
throw new TellError("Could not derive an id from the statement; pass --id <slug>.");
|
|
46
|
+
const factId = `${domain}.${s}`;
|
|
47
|
+
if (unknownSpec(factId)) {
|
|
48
|
+
throw new TellError(`Fact id "${factId}" collides with a known unknown. To answer that question run \`qa-boot tell ${factId} "..." --by <who>\`; otherwise pass --id <slug> to pick a different id.`);
|
|
49
|
+
}
|
|
50
|
+
const existing = store.byId(factId);
|
|
51
|
+
if (existing && existing.provenance !== "told") {
|
|
52
|
+
throw new TellError(`Fact id "${factId}" already exists with provenance "${existing.provenance}". Pass --id <slug> to pick a different id.`);
|
|
53
|
+
}
|
|
54
|
+
const fact = buildToldFact({ factId, domain, input }, today);
|
|
55
|
+
store.upsert([fact]);
|
|
56
|
+
return { fact, removedUnknown: false, remainingUnknowns: countOpenUnknowns(store) };
|
|
57
|
+
}
|
|
58
|
+
function buildToldFact(args, today) {
|
|
59
|
+
return makeFact({
|
|
60
|
+
id: args.factId,
|
|
61
|
+
domain: args.domain,
|
|
62
|
+
statement: args.input.statement.trim(),
|
|
63
|
+
provenance: "told",
|
|
64
|
+
confidence: 0.9,
|
|
65
|
+
evidence_provider: "human-interview",
|
|
66
|
+
evidence: [`qa-interview-log.md ${today}`],
|
|
67
|
+
limitations: ["Human-stated knowledge; may go stale. Re-confirm when expired."],
|
|
68
|
+
expires_after_days: 180,
|
|
69
|
+
told_by: args.input.by.trim(),
|
|
70
|
+
scope: "repo",
|
|
71
|
+
...(args.input.source ? { source: args.input.source } : {}),
|
|
72
|
+
...(args.answersUnknown ? { answers_unknown: args.answersUnknown } : {}),
|
|
73
|
+
}, today);
|
|
74
|
+
}
|
|
75
|
+
function countOpenUnknowns(store) {
|
|
76
|
+
return store.all().filter((f) => f.provenance === "unknown").length;
|
|
77
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
const PROVIDER = "unknowns-generator";
|
|
3
|
+
const SPECS = [
|
|
4
|
+
{
|
|
5
|
+
id: "build.qa-build-process",
|
|
6
|
+
domain: "build",
|
|
7
|
+
statement: "The QA build / artifact generation process was not found.",
|
|
8
|
+
question: "Who or what system produces QA builds, where are artifacts stored, and who owns that workflow?",
|
|
9
|
+
risk_if_wrong: "The agent may invent wrong build instructions or point QA at the wrong artifact.",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "release.blocking-gates",
|
|
13
|
+
domain: "release",
|
|
14
|
+
statement: "It is unclear which checks block a release.",
|
|
15
|
+
question: "Which CI checks or approvals actually block a release, and who can override them?",
|
|
16
|
+
risk_if_wrong: "The agent may claim a change is releasable when a real gate is unmet.",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "environment.list",
|
|
20
|
+
domain: "environment",
|
|
21
|
+
statement: "Which environments exist and which is stable is unknown.",
|
|
22
|
+
question: "What test environments exist (dev/staging/prod), which is stable, and how is test data reset?",
|
|
23
|
+
risk_if_wrong: "The agent may direct QA to test on an unstable or wrong environment.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "test_data.reset-process",
|
|
27
|
+
domain: "test_data",
|
|
28
|
+
statement: "The test data setup/reset process is unknown.",
|
|
29
|
+
question: "How is test data created and reset, and are there fixtures or seed scripts?",
|
|
30
|
+
risk_if_wrong: "The agent may assume a clean dataset that does not exist.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "ownership.approvers",
|
|
34
|
+
domain: "ownership",
|
|
35
|
+
statement: "Who approves risky changes is unknown.",
|
|
36
|
+
question: "Who owns and approves risky changes in this repo?",
|
|
37
|
+
risk_if_wrong: "The agent may route review to the wrong people or skip required approval.",
|
|
38
|
+
suppressIfPrefix: ["knowledge_sources.codeowners"],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "business_priority.critical-areas",
|
|
42
|
+
domain: "business_priority",
|
|
43
|
+
statement: "Business criticality of repo areas is unknown.",
|
|
44
|
+
question: "Which areas of this repo are business-critical or customer-facing?",
|
|
45
|
+
risk_if_wrong: "The agent may treat technical risk as business priority.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "agent_permissions.boundaries",
|
|
49
|
+
domain: "agent_permissions",
|
|
50
|
+
statement: "What an AI agent is permitted to do here is unknown.",
|
|
51
|
+
question: "What is an AI agent allowed to do in this repo (comment on PRs, trigger CI, open PRs)?",
|
|
52
|
+
risk_if_wrong: "The agent may take an action it is not authorized to take.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "test_trust.confidence",
|
|
56
|
+
domain: "test_trust",
|
|
57
|
+
statement: "Whether the team trusts the existing tests is unknown.",
|
|
58
|
+
question: "Do you trust the current test suite, and are failures treated as real?",
|
|
59
|
+
risk_if_wrong: "The agent may over-trust a flaky or ignored suite.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "repo_quality.risk-analysis",
|
|
63
|
+
domain: "repo_quality",
|
|
64
|
+
statement: "Deterministic repo-risk analysis (QA Radar) was not available, so technical risk hotspots are unknown.",
|
|
65
|
+
question: "Is QA Radar (or similar churn/coverage risk analysis) available to run on this repo?",
|
|
66
|
+
risk_if_wrong: "The agent may give generic 'what to test first' advice without knowing where technical risk concentrates.",
|
|
67
|
+
suppressIfPrefix: ["repo_quality.high-churn-untested"],
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
export function knownUnknownIds() {
|
|
71
|
+
return SPECS.map((s) => s.id);
|
|
72
|
+
}
|
|
73
|
+
export function unknownSpec(id) {
|
|
74
|
+
const s = SPECS.find((x) => x.id === id);
|
|
75
|
+
return s ? { id: s.id, domain: s.domain, question: s.question } : undefined;
|
|
76
|
+
}
|
|
77
|
+
export function deriveUnknownFacts(facts, today) {
|
|
78
|
+
const ids = facts.map((f) => f.id);
|
|
79
|
+
const answered = new Set(facts.filter((f) => f.provenance === "told" && f.answers_unknown && !f.stale).map((f) => f.answers_unknown));
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const spec of SPECS) {
|
|
82
|
+
if (answered.has(spec.id))
|
|
83
|
+
continue;
|
|
84
|
+
const suppressed = (spec.suppressIfPrefix ?? []).some((p) => ids.some((id) => id.startsWith(p)));
|
|
85
|
+
if (suppressed)
|
|
86
|
+
continue;
|
|
87
|
+
out.push(makeFact({
|
|
88
|
+
id: spec.id,
|
|
89
|
+
domain: spec.domain,
|
|
90
|
+
statement: spec.statement,
|
|
91
|
+
provenance: "unknown",
|
|
92
|
+
confidence: 0,
|
|
93
|
+
evidence_provider: PROVIDER,
|
|
94
|
+
evidence: [],
|
|
95
|
+
value: { question: spec.question },
|
|
96
|
+
risk_if_wrong: spec.risk_if_wrong,
|
|
97
|
+
needs_human_confirmation: true,
|
|
98
|
+
}, today));
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qa-boot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bootstrap QA context for AI coding agents.",
|
|
5
|
+
"keywords": ["qa", "testing", "claude-code", "ai-agents", "onboarding", "qa-context", "cli"],
|
|
6
|
+
"author": "Murat Kus",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MuratKus/qa-boot.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/MuratKus/qa-boot#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/MuratKus/qa-boot/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"qa-boot": "dist/cli/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": ["dist"],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"dev": "tsx src/cli/index.ts",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"commander": "^12.1.0",
|
|
34
|
+
"fast-glob": "^3.3.2",
|
|
35
|
+
"minimatch": "^9.0.9",
|
|
36
|
+
"yaml": "^2.5.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.14.0",
|
|
40
|
+
"tsx": "^4.16.0",
|
|
41
|
+
"typescript": "^5.5.0",
|
|
42
|
+
"vitest": "^2.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|