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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Murat Kus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# QA Boot
|
|
2
|
+
|
|
3
|
+
Bootstrap QA context for AI coding agents.
|
|
4
|
+
|
|
5
|
+
QA Boot scans a repository or multi-repo workspace, collects deterministic testing/build/CI evidence, captures unknowns, and generates Claude Code-compatible QA context files.
|
|
6
|
+
|
|
7
|
+
It helps QA engineers and quality leads onboard into messy projects without letting AI agents invent missing process knowledge.
|
|
8
|
+
|
|
9
|
+
QA Boot is not a test generator or QA automation framework. It is the context and safety layer before AI-assisted QA work.
|
|
10
|
+
|
|
11
|
+
## Core idea
|
|
12
|
+
|
|
13
|
+
Most AI QA tooling focuses on generating, executing, or reviewing tests.
|
|
14
|
+
|
|
15
|
+
QA Boot focuses on the missing layer before that:
|
|
16
|
+
|
|
17
|
+
- What does this project know?
|
|
18
|
+
- What is unknown?
|
|
19
|
+
- Which facts are observed, inferred, or human-provided?
|
|
20
|
+
- Which quality risks are technical versus business-critical?
|
|
21
|
+
- What is Claude allowed to assume?
|
|
22
|
+
- What should Claude ask before acting?
|
|
23
|
+
|
|
24
|
+
## V1 product boundary
|
|
25
|
+
|
|
26
|
+
V1 is a CLI-first file generator.
|
|
27
|
+
|
|
28
|
+
It should:
|
|
29
|
+
|
|
30
|
+
- scan repos deterministically,
|
|
31
|
+
- consume evidence providers like QA Radar,
|
|
32
|
+
- generate `qa-context/`,
|
|
33
|
+
- generate Claude Code project skills,
|
|
34
|
+
- generate unknowns and maturity summaries,
|
|
35
|
+
- avoid external writes by default.
|
|
36
|
+
|
|
37
|
+
V1 should not:
|
|
38
|
+
|
|
39
|
+
- auto-comment on PRs,
|
|
40
|
+
- auto-create Jira tickets,
|
|
41
|
+
- update TestRail,
|
|
42
|
+
- trigger CI,
|
|
43
|
+
- approve releases,
|
|
44
|
+
- access secrets,
|
|
45
|
+
- act as a full autonomous QA agent.
|
|
46
|
+
|
|
47
|
+
## Install & usage
|
|
48
|
+
|
|
49
|
+
Requires Node 20+. Run qa-boot directly in any repo — no install step needed:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx qa-boot init --project-name my-service
|
|
53
|
+
npx qa-boot scan # auto-runs generate
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`scan` detects languages/tests/CI/docs/build/agent-config, writes
|
|
57
|
+
`qa-context/facts.json`, and (unless `--no-generate`) renders the `qa-context/*.md`
|
|
58
|
+
summaries, `unknowns.md`, `maturity.md`, `CLAUDE.qa.md`, and the `.claude/skills`.
|
|
59
|
+
|
|
60
|
+
Record human-told QA knowledge (answers to open unknowns, or free-form facts):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx qa-boot tell <unknown-id> "the answer" --by alice
|
|
64
|
+
npx qa-boot tell "we release every Tuesday" --domain release --by alice
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Prefer a permanent command? `npm install -g qa-boot` gives you `qa-boot` on your
|
|
68
|
+
PATH; `npm install -D qa-boot` pins a version per-project.
|
|
69
|
+
|
|
70
|
+
QA Radar is consumed live when installed:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx qa-boot scan --with-qaradar # spawns `qaradar analyze --json-output`
|
|
74
|
+
npx qa-boot scan --skip-qaradar # built-in scanners only
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
When `qaradar` is absent the scan still succeeds and records the gap as an
|
|
78
|
+
unknown (`repo-risk.md` is only written when QA Radar ran).
|
|
79
|
+
|
|
80
|
+
### Developing qa-boot itself
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/MuratKus/qa-boot.git
|
|
84
|
+
cd qa-boot && npm install
|
|
85
|
+
npm test # vitest
|
|
86
|
+
npm run dev -- scan # run the CLI from source via tsx
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Design and plan: [`docs/superpowers/specs/2026-06-02-qa-boot-v0-design.md`](./docs/superpowers/specs/2026-06-02-qa-boot-v0-design.md)
|
|
90
|
+
and [`docs/superpowers/plans/2026-06-02-qa-boot-v0.md`](./docs/superpowers/plans/2026-06-02-qa-boot-v0.md).
|
|
91
|
+
|
|
92
|
+
## Start here
|
|
93
|
+
|
|
94
|
+
Read [`SPEC_INDEX.md`](./SPEC_INDEX.md) first.
|
|
95
|
+
|
|
96
|
+
For implementation planning, start with:
|
|
97
|
+
|
|
98
|
+
- [`docs/00-overview.md`](./docs/00-overview.md)
|
|
99
|
+
- [`docs/02-v1-scope.md`](./docs/02-v1-scope.md)
|
|
100
|
+
- [`docs/03-architecture.md`](./docs/03-architecture.md)
|
|
101
|
+
|
|
102
|
+
## Tagline
|
|
103
|
+
|
|
104
|
+
> Make Claude more grounded before making it more autonomous.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { loadConfig } from "../../config/config.js";
|
|
2
|
+
import { runGenerate } from "../../generate/generate.js";
|
|
3
|
+
export async function cmdGenerate(repoPath, opts) {
|
|
4
|
+
const config = loadConfig(repoPath);
|
|
5
|
+
const written = runGenerate({ repoPath, config, claude: opts.claude });
|
|
6
|
+
console.log(`Generated ${written.length} files.`);
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defaultConfig } from "../../config/config.js";
|
|
4
|
+
const README = `# QA Context
|
|
5
|
+
|
|
6
|
+
This directory is generated and owned by \`qa-boot\`. Do not hand-edit these files —
|
|
7
|
+
they are regenerated from \`facts.json\` on every \`qa-boot generate\`.
|
|
8
|
+
|
|
9
|
+
- \`facts.json\` — the source of truth (written by \`qa-boot scan\`).
|
|
10
|
+
- \`unknowns.md\` — what QA Boot could not determine, with questions to ask a human.
|
|
11
|
+
- other \`*.md\` — per-domain summaries.
|
|
12
|
+
`;
|
|
13
|
+
export async function cmdInit(repoPath, opts) {
|
|
14
|
+
const configPath = join(repoPath, "qa-boot.config.json");
|
|
15
|
+
if (existsSync(configPath) && !opts.force) {
|
|
16
|
+
throw new Error("qa-boot.config.json already exists. Pass --force to overwrite.");
|
|
17
|
+
}
|
|
18
|
+
const config = defaultConfig(opts.projectName);
|
|
19
|
+
config.claude.enabled = opts.claude;
|
|
20
|
+
config.evidence_providers.qaradar.enabled = opts.qaradar;
|
|
21
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
22
|
+
mkdirSync(join(repoPath, "qa-context"), { recursive: true });
|
|
23
|
+
writeFileSync(join(repoPath, "qa-context", "README.md"), README, "utf8");
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadConfig } from "../../config/config.js";
|
|
3
|
+
import { buildScanContext } from "../../core/scan-context.js";
|
|
4
|
+
import { FactStore } from "../../core/fact-store.js";
|
|
5
|
+
import { todayISO } from "../../core/fact.js";
|
|
6
|
+
import { runScanDetailed } from "../../pipeline/run-scan.js";
|
|
7
|
+
import { cmdGenerate } from "./generate.js";
|
|
8
|
+
export async function cmdScan(repoPath, opts) {
|
|
9
|
+
const config = loadConfig(repoPath);
|
|
10
|
+
if (opts.skipQaradar)
|
|
11
|
+
config.evidence_providers.qaradar.enabled = "false";
|
|
12
|
+
if (opts.withQaradar)
|
|
13
|
+
config.evidence_providers.qaradar.enabled = "true";
|
|
14
|
+
const ctx = buildScanContext(repoPath, config);
|
|
15
|
+
const today = todayISO();
|
|
16
|
+
const factsPath = join(repoPath, "qa-context", "facts.json");
|
|
17
|
+
const store = FactStore.load(factsPath);
|
|
18
|
+
store.markTimeStaleness(today);
|
|
19
|
+
const priorTold = store.all().filter((f) => f.provenance === "told" && !f.stale);
|
|
20
|
+
const { facts, qaradarRan } = await runScanDetailed(ctx, today, (m) => console.log(m), priorTold);
|
|
21
|
+
if (qaradarRan)
|
|
22
|
+
console.log("QA Radar: included its analysis.");
|
|
23
|
+
store.upsert(facts);
|
|
24
|
+
store.markTimeStaleness(today);
|
|
25
|
+
store.save(factsPath);
|
|
26
|
+
console.log(`Wrote ${store.all().length} facts to qa-context/facts.json`);
|
|
27
|
+
if (opts.generate) {
|
|
28
|
+
await cmdGenerate(repoPath, { claude: opts.claude });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { FactStore } from "../../core/fact-store.js";
|
|
3
|
+
import { todayISO } from "../../core/fact.js";
|
|
4
|
+
import { applyTell } from "../../tell/tell.js";
|
|
5
|
+
import { appendInterviewLog } from "../../tell/interview-log.js";
|
|
6
|
+
export async function cmdTell(repoPath, unknownId, statement, opts) {
|
|
7
|
+
const factsPath = join(repoPath, "qa-context", "facts.json");
|
|
8
|
+
const store = FactStore.load(factsPath);
|
|
9
|
+
const today = todayISO();
|
|
10
|
+
store.markTimeStaleness(today);
|
|
11
|
+
const result = applyTell(store, { unknownId, domain: opts.domain, statement, by: opts.by ?? "", source: opts.source, idSlug: opts.id }, today);
|
|
12
|
+
store.save(factsPath);
|
|
13
|
+
appendInterviewLog(repoPath, {
|
|
14
|
+
date: today,
|
|
15
|
+
by: result.fact.told_by ?? "",
|
|
16
|
+
unknownId: result.fact.answers_unknown,
|
|
17
|
+
question: result.question,
|
|
18
|
+
domain: result.fact.domain,
|
|
19
|
+
statement: result.fact.statement,
|
|
20
|
+
factId: result.fact.id,
|
|
21
|
+
scope: result.fact.scope ?? "repo",
|
|
22
|
+
});
|
|
23
|
+
console.log(`Recorded told fact ${result.fact.id} (scope: ${result.fact.scope ?? "repo"}).`);
|
|
24
|
+
if (result.fact.answers_unknown)
|
|
25
|
+
console.log(`Resolved unknown ${result.fact.answers_unknown}.`);
|
|
26
|
+
console.log(`${result.remainingUnknowns} unknowns remain open. Run \`qa-boot scan\` to refresh context files and maturity.`);
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { cmdInit } from "./commands/init.js";
|
|
4
|
+
import { cmdScan } from "./commands/scan.js";
|
|
5
|
+
import { cmdGenerate } from "./commands/generate.js";
|
|
6
|
+
import { cmdTell } from "./commands/tell.js";
|
|
7
|
+
import { cliVersion } from "./version.js";
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
const program = new Command();
|
|
10
|
+
program.name("qa-boot").description("Bootstrap QA context for AI coding agents.").version(cliVersion());
|
|
11
|
+
program
|
|
12
|
+
.command("init")
|
|
13
|
+
.description("Initialize qa-boot config in the current repo")
|
|
14
|
+
.option("--project-name <name>", "project name")
|
|
15
|
+
.option("--no-claude", "disable Claude output")
|
|
16
|
+
.option("--qaradar <mode>", "auto|true|false", "auto")
|
|
17
|
+
.option("--force", "overwrite existing config")
|
|
18
|
+
.action(async (o) => {
|
|
19
|
+
await cmdInit(process.cwd(), {
|
|
20
|
+
projectName: o.projectName ?? basename(process.cwd()),
|
|
21
|
+
claude: o.claude,
|
|
22
|
+
qaradar: o.qaradar,
|
|
23
|
+
force: o.force,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
program
|
|
27
|
+
.command("scan")
|
|
28
|
+
.description("Scan the repo, write facts.json, and generate context")
|
|
29
|
+
.option("--no-generate", "write facts.json only")
|
|
30
|
+
.option("--no-claude", "skip Claude output during generate")
|
|
31
|
+
.option("--with-qaradar", "force-enable QA Radar")
|
|
32
|
+
.option("--skip-qaradar", "disable QA Radar")
|
|
33
|
+
.action(async (o) => {
|
|
34
|
+
await cmdScan(process.cwd(), {
|
|
35
|
+
generate: o.generate,
|
|
36
|
+
claude: o.claude,
|
|
37
|
+
withQaradar: Boolean(o.withQaradar),
|
|
38
|
+
skipQaradar: Boolean(o.skipQaradar),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
program
|
|
42
|
+
.command("generate")
|
|
43
|
+
.description("Generate context files from qa-context/facts.json")
|
|
44
|
+
.option("--no-claude", "skip Claude output")
|
|
45
|
+
.action(async (o) => {
|
|
46
|
+
await cmdGenerate(process.cwd(), { claude: o.claude });
|
|
47
|
+
});
|
|
48
|
+
program
|
|
49
|
+
.command("tell <idOrStatement> [statement]")
|
|
50
|
+
.description("Record human-told QA knowledge (answer an unknown by id, or add --domain knowledge)")
|
|
51
|
+
.option("--domain <domain>", "domain for free-form knowledge")
|
|
52
|
+
.option("--by <who>", "who provided this knowledge (required)")
|
|
53
|
+
.option("--source <url>", "optional source link")
|
|
54
|
+
.option("--id <slug>", "explicit id slug for free-form mode")
|
|
55
|
+
.action(async (idOrStatement, statement, o) => {
|
|
56
|
+
const isAnswer = statement !== undefined;
|
|
57
|
+
await cmdTell(process.cwd(), isAnswer ? idOrStatement : undefined, isAnswer ? statement : idOrStatement, {
|
|
58
|
+
domain: o.domain,
|
|
59
|
+
by: o.by,
|
|
60
|
+
source: o.source,
|
|
61
|
+
id: o.id,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
program.parseAsync().catch((err) => {
|
|
65
|
+
console.error(String(err instanceof Error ? err.message : err));
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readJson } from "../core/fs-utils.js";
|
|
2
|
+
export function defaultConfig(projectName) {
|
|
3
|
+
return {
|
|
4
|
+
project_name: projectName,
|
|
5
|
+
mode: "single-repo",
|
|
6
|
+
claude: { enabled: true, generate_skills: true },
|
|
7
|
+
evidence_providers: { qaradar: { enabled: "auto", days: 90, top: 20 } },
|
|
8
|
+
refresh: { default_days: 30 },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function normalizeConfig(partial) {
|
|
12
|
+
const base = defaultConfig(partial.project_name ?? "project");
|
|
13
|
+
return {
|
|
14
|
+
project_name: partial.project_name ?? base.project_name,
|
|
15
|
+
mode: "single-repo",
|
|
16
|
+
claude: { ...base.claude, ...partial.claude },
|
|
17
|
+
evidence_providers: {
|
|
18
|
+
qaradar: { ...base.evidence_providers.qaradar, ...partial.evidence_providers?.qaradar },
|
|
19
|
+
},
|
|
20
|
+
refresh: { ...base.refresh, ...partial.refresh },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function loadConfig(repoPath) {
|
|
24
|
+
const raw = readJson(repoPath, "qa-boot.config.json");
|
|
25
|
+
return normalizeConfig(raw ?? { project_name: "project" });
|
|
26
|
+
}
|
|
27
|
+
export function qaradarEnabled(c) {
|
|
28
|
+
const v = c.evidence_providers.qaradar.enabled;
|
|
29
|
+
if (v === true)
|
|
30
|
+
return "true";
|
|
31
|
+
if (v === false)
|
|
32
|
+
return "false";
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { todayISO } from "./fact.js";
|
|
4
|
+
function daysBetween(a, b) {
|
|
5
|
+
const ms = Date.parse(b) - Date.parse(a);
|
|
6
|
+
return Math.floor(ms / 86_400_000);
|
|
7
|
+
}
|
|
8
|
+
export class FactStore {
|
|
9
|
+
facts = new Map();
|
|
10
|
+
constructor(initial = []) {
|
|
11
|
+
for (const f of initial)
|
|
12
|
+
this.facts.set(f.id, f);
|
|
13
|
+
}
|
|
14
|
+
static load(path) {
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
return new FactStore([]);
|
|
17
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
return new FactStore(raw.facts ?? []);
|
|
19
|
+
}
|
|
20
|
+
save(path) {
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const out = { facts: this.all() };
|
|
23
|
+
writeFileSync(path, JSON.stringify(out, null, 2) + "\n", "utf8");
|
|
24
|
+
}
|
|
25
|
+
upsert(incoming) {
|
|
26
|
+
for (const f of incoming) {
|
|
27
|
+
const next = { ...f };
|
|
28
|
+
delete next.stale;
|
|
29
|
+
delete next.stale_reason;
|
|
30
|
+
this.facts.set(f.id, next);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
markTimeStaleness(today = todayISO()) {
|
|
34
|
+
for (const f of this.facts.values()) {
|
|
35
|
+
if (daysBetween(f.last_verified, today) > f.expires_after_days) {
|
|
36
|
+
f.stale = true;
|
|
37
|
+
f.stale_reason = "expired";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
remove(id) {
|
|
42
|
+
return this.facts.delete(id);
|
|
43
|
+
}
|
|
44
|
+
byId(id) {
|
|
45
|
+
return this.facts.get(id);
|
|
46
|
+
}
|
|
47
|
+
all() {
|
|
48
|
+
return [...this.facts.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function todayISO(d = new Date()) {
|
|
2
|
+
return d.toISOString().slice(0, 10);
|
|
3
|
+
}
|
|
4
|
+
export function makeFact(input, today = todayISO()) {
|
|
5
|
+
const f = {
|
|
6
|
+
id: input.id,
|
|
7
|
+
domain: input.domain,
|
|
8
|
+
statement: input.statement,
|
|
9
|
+
value: input.value ?? null,
|
|
10
|
+
provenance: input.provenance,
|
|
11
|
+
confidence: input.confidence,
|
|
12
|
+
evidence_provider: input.evidence_provider,
|
|
13
|
+
evidence_command: input.evidence_command ?? "qa-boot scan",
|
|
14
|
+
evidence: input.evidence ?? [],
|
|
15
|
+
limitations: input.limitations ?? [],
|
|
16
|
+
risk_if_wrong: input.risk_if_wrong ?? "",
|
|
17
|
+
needs_human_confirmation: input.needs_human_confirmation ?? false,
|
|
18
|
+
last_verified: today,
|
|
19
|
+
expires_after_days: input.expires_after_days ?? 30,
|
|
20
|
+
};
|
|
21
|
+
if (input.told_by !== undefined)
|
|
22
|
+
f.told_by = input.told_by;
|
|
23
|
+
if (input.scope !== undefined)
|
|
24
|
+
f.scope = input.scope;
|
|
25
|
+
if (input.source !== undefined)
|
|
26
|
+
f.source = input.source;
|
|
27
|
+
if (input.answers_unknown !== undefined)
|
|
28
|
+
f.answers_unknown = input.answers_unknown;
|
|
29
|
+
return f;
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
import { minimatch } from "minimatch";
|
|
5
|
+
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
|
|
6
|
+
export function listFiles(root) {
|
|
7
|
+
return fg.sync("**/*", {
|
|
8
|
+
cwd: root,
|
|
9
|
+
dot: true,
|
|
10
|
+
ignore: IGNORE,
|
|
11
|
+
onlyFiles: false,
|
|
12
|
+
markDirectories: true,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function exists(files, pattern) {
|
|
16
|
+
return files.some((f) => minimatch(f, pattern, { dot: true }));
|
|
17
|
+
}
|
|
18
|
+
export function matches(files, pattern) {
|
|
19
|
+
return files.filter((f) => minimatch(f, pattern, { dot: true }));
|
|
20
|
+
}
|
|
21
|
+
export function readText(root, rel) {
|
|
22
|
+
try {
|
|
23
|
+
return readFileSync(join(root, rel), "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function readJson(root, rel) {
|
|
30
|
+
const text = readText(root, rel);
|
|
31
|
+
if (text === null)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { listFiles, exists, matches, readText, readJson } from "./fs-utils.js";
|
|
2
|
+
export function buildScanContext(repoPath, config) {
|
|
3
|
+
const files = listFiles(repoPath);
|
|
4
|
+
return {
|
|
5
|
+
repoPath,
|
|
6
|
+
config,
|
|
7
|
+
files,
|
|
8
|
+
has: (p) => exists(files, p),
|
|
9
|
+
match: (p) => matches(files, p),
|
|
10
|
+
read: (rel) => readText(repoPath, rel),
|
|
11
|
+
readJson: (rel) => readJson(repoPath, rel),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
import { slug } from "../core/slug.js";
|
|
3
|
+
const PROVIDER = "agent-config-scanner";
|
|
4
|
+
export function agentFacts(ev, today) {
|
|
5
|
+
const facts = [];
|
|
6
|
+
for (const e of ev.filter((x) => x.kind === "agent-config")) {
|
|
7
|
+
const name = String(e.detail?.name);
|
|
8
|
+
facts.push(makeFact({
|
|
9
|
+
id: `agent_permissions.config.${slug(name)}`,
|
|
10
|
+
domain: "agent_permissions",
|
|
11
|
+
statement: `Agent config ${name} is present.`,
|
|
12
|
+
provenance: "observed",
|
|
13
|
+
confidence: 0.8,
|
|
14
|
+
evidence_provider: PROVIDER,
|
|
15
|
+
evidence: e.path ? [e.path] : [],
|
|
16
|
+
limitations: ["Presence does not define what the agent is permitted to do."],
|
|
17
|
+
}, today));
|
|
18
|
+
}
|
|
19
|
+
if (ev.some((e) => e.kind === "agent-skills")) {
|
|
20
|
+
facts.push(makeFact({
|
|
21
|
+
id: "agent_permissions.skills",
|
|
22
|
+
domain: "agent_permissions",
|
|
23
|
+
statement: "Existing Claude skills were detected.",
|
|
24
|
+
provenance: "observed",
|
|
25
|
+
confidence: 0.8,
|
|
26
|
+
evidence_provider: PROVIDER,
|
|
27
|
+
evidence: [".claude/skills"],
|
|
28
|
+
}, today));
|
|
29
|
+
}
|
|
30
|
+
return facts;
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
const PROVIDER = "build-scanner";
|
|
3
|
+
export function buildFacts(ev, today) {
|
|
4
|
+
const facts = [];
|
|
5
|
+
for (const e of ev.filter((x) => x.kind === "build-command")) {
|
|
6
|
+
const name = String(e.detail?.name);
|
|
7
|
+
facts.push(makeFact({
|
|
8
|
+
id: `build.command.${name}`,
|
|
9
|
+
domain: "build",
|
|
10
|
+
statement: `A "${name}" command is defined.`,
|
|
11
|
+
provenance: "observed",
|
|
12
|
+
confidence: 0.8,
|
|
13
|
+
evidence_provider: PROVIDER,
|
|
14
|
+
evidence: e.path ? [e.path] : [],
|
|
15
|
+
value: { command: String(e.detail?.cmd) },
|
|
16
|
+
}, today));
|
|
17
|
+
}
|
|
18
|
+
for (const e of ev.filter((x) => x.kind === "build-file")) {
|
|
19
|
+
const name = String(e.detail?.name);
|
|
20
|
+
facts.push(makeFact({
|
|
21
|
+
id: `build.tool.${name}`,
|
|
22
|
+
domain: "build",
|
|
23
|
+
statement: `Build tooling detected: ${name}.`,
|
|
24
|
+
provenance: "observed",
|
|
25
|
+
confidence: 0.8,
|
|
26
|
+
evidence_provider: PROVIDER,
|
|
27
|
+
evidence: e.path ? [e.path] : [],
|
|
28
|
+
}, today));
|
|
29
|
+
}
|
|
30
|
+
return facts;
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
const PROVIDER = "ci-scanner";
|
|
3
|
+
export function ciFacts(ev, today) {
|
|
4
|
+
const facts = [];
|
|
5
|
+
for (const e of ev.filter((x) => x.kind === "ci-system")) {
|
|
6
|
+
const name = String(e.detail?.name);
|
|
7
|
+
facts.push(makeFact({
|
|
8
|
+
id: `ci.system.${name}`,
|
|
9
|
+
domain: "ci",
|
|
10
|
+
statement: `CI system ${name} detected.`,
|
|
11
|
+
provenance: "observed",
|
|
12
|
+
confidence: 0.8,
|
|
13
|
+
evidence_provider: PROVIDER,
|
|
14
|
+
evidence: e.path ? [e.path] : [],
|
|
15
|
+
}, today));
|
|
16
|
+
}
|
|
17
|
+
const jobs = ev.filter((x) => x.kind === "ci-job");
|
|
18
|
+
const hasKind = (k) => jobs.some((j) => j.detail?.kind === k);
|
|
19
|
+
if (hasKind("test")) {
|
|
20
|
+
facts.push(makeFact({
|
|
21
|
+
id: "ci.runs-tests",
|
|
22
|
+
domain: "ci",
|
|
23
|
+
statement: "CI appears to run tests.",
|
|
24
|
+
provenance: "inferred",
|
|
25
|
+
confidence: 0.5,
|
|
26
|
+
evidence_provider: PROVIDER,
|
|
27
|
+
evidence: jobs.filter((j) => j.detail?.kind === "test").map((j) => j.path).filter(Boolean),
|
|
28
|
+
limitations: ["Job name suggests tests; not confirmed to block merges."],
|
|
29
|
+
}, today));
|
|
30
|
+
}
|
|
31
|
+
if (hasKind("deploy")) {
|
|
32
|
+
facts.push(makeFact({
|
|
33
|
+
id: "ci.has-deploy-job",
|
|
34
|
+
domain: "release",
|
|
35
|
+
statement: "A deploy/release job is present in CI.",
|
|
36
|
+
provenance: "inferred",
|
|
37
|
+
confidence: 0.5,
|
|
38
|
+
evidence_provider: PROVIDER,
|
|
39
|
+
evidence: jobs.filter((j) => j.detail?.kind === "deploy").map((j) => j.path).filter(Boolean),
|
|
40
|
+
}, today));
|
|
41
|
+
}
|
|
42
|
+
return facts;
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
const PROVIDER = "docs-scanner";
|
|
3
|
+
const LABEL = {
|
|
4
|
+
readme: "README documentation",
|
|
5
|
+
contributing: "Contributing guide",
|
|
6
|
+
"docs-dir": "A docs/ directory",
|
|
7
|
+
codeowners: "CODEOWNERS file",
|
|
8
|
+
"pr-template": "Pull request template",
|
|
9
|
+
"issue-template": "Issue template",
|
|
10
|
+
changelog: "Changelog",
|
|
11
|
+
adr: "Architecture decision records",
|
|
12
|
+
};
|
|
13
|
+
export function docsFacts(ev, today) {
|
|
14
|
+
return ev
|
|
15
|
+
.filter((e) => e.kind === "doc")
|
|
16
|
+
.map((e) => {
|
|
17
|
+
const kind = String(e.detail?.kind);
|
|
18
|
+
return makeFact({
|
|
19
|
+
id: `knowledge_sources.${kind}`,
|
|
20
|
+
domain: "knowledge_sources",
|
|
21
|
+
statement: `${LABEL[kind] ?? kind} is present.`,
|
|
22
|
+
provenance: "observed",
|
|
23
|
+
confidence: 0.8,
|
|
24
|
+
evidence_provider: PROVIDER,
|
|
25
|
+
evidence: e.path ? [e.path] : [],
|
|
26
|
+
}, today);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { makeFact } from "../core/fact.js";
|
|
2
|
+
const PROVIDER = "repo-scanner";
|
|
3
|
+
export function repoFacts(ev, today) {
|
|
4
|
+
const facts = [];
|
|
5
|
+
for (const e of ev.filter((x) => x.kind === "language")) {
|
|
6
|
+
const name = String(e.detail?.name);
|
|
7
|
+
facts.push(makeFact({
|
|
8
|
+
id: `repo.language.${name}`,
|
|
9
|
+
domain: "repo",
|
|
10
|
+
statement: `Repository uses ${name}.`,
|
|
11
|
+
provenance: "observed",
|
|
12
|
+
confidence: 0.8,
|
|
13
|
+
evidence_provider: PROVIDER,
|
|
14
|
+
evidence: e.path ? [e.path] : [],
|
|
15
|
+
}, today));
|
|
16
|
+
}
|
|
17
|
+
for (const e of ev.filter((x) => x.kind === "package-manager")) {
|
|
18
|
+
const name = String(e.detail?.name);
|
|
19
|
+
facts.push(makeFact({
|
|
20
|
+
id: `repo.package-manager.${name}`,
|
|
21
|
+
domain: "repo",
|
|
22
|
+
statement: `Package manager ${name} detected.`,
|
|
23
|
+
provenance: "observed",
|
|
24
|
+
confidence: 0.8,
|
|
25
|
+
evidence_provider: PROVIDER,
|
|
26
|
+
evidence: e.path ? [e.path] : [],
|
|
27
|
+
}, today));
|
|
28
|
+
}
|
|
29
|
+
if (ev.some((e) => e.kind === "readme")) {
|
|
30
|
+
facts.push(makeFact({
|
|
31
|
+
id: "repo.readme",
|
|
32
|
+
domain: "repo",
|
|
33
|
+
statement: "A README is present.",
|
|
34
|
+
provenance: "observed",
|
|
35
|
+
confidence: 0.8,
|
|
36
|
+
evidence_provider: PROVIDER,
|
|
37
|
+
evidence: ev.filter((e) => e.kind === "readme").map((e) => e.path).filter(Boolean),
|
|
38
|
+
}, today));
|
|
39
|
+
}
|
|
40
|
+
if (ev.some((e) => e.kind === "monorepo")) {
|
|
41
|
+
facts.push(makeFact({
|
|
42
|
+
id: "repo.monorepo",
|
|
43
|
+
domain: "repo",
|
|
44
|
+
statement: "Monorepo workspace markers detected.",
|
|
45
|
+
provenance: "inferred",
|
|
46
|
+
confidence: 0.5,
|
|
47
|
+
evidence_provider: PROVIDER,
|
|
48
|
+
evidence: ev.filter((e) => e.kind === "monorepo").map((e) => e.path).filter(Boolean),
|
|
49
|
+
}, today));
|
|
50
|
+
}
|
|
51
|
+
return facts;
|
|
52
|
+
}
|