qaify-playwright-reporter 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -7
- package/dist/audit-api.d.ts +118 -0
- package/dist/audit-api.js +628 -0
- package/dist/audit-static.d.ts +15 -6
- package/dist/audit-static.js +101 -80
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +4 -1
- package/dist/cli.js +21 -4
- package/dist/fix.d.ts +25 -1
- package/dist/fix.js +112 -14
- package/package.json +1 -1
- package/templates/qaify-audit.yml +27 -9
- package/templates/qaify-fix.yml +12 -3
package/dist/audit-static.d.ts
CHANGED
|
@@ -82,6 +82,13 @@ export interface SourceLocation {
|
|
|
82
82
|
export declare function auditStatic(dir: string, deps: StaticDeps): Promise<StaticAudit>;
|
|
83
83
|
/** Bentuk keluaran `claude -p --output-format json` agar parseAuditOutput / audit-submit tidak berubah. */
|
|
84
84
|
export declare function toAuditOutput(audit: StaticAudit, durationMs: number): string;
|
|
85
|
+
/** Advisory composer audit / npm audit (berkas lockfile). */
|
|
86
|
+
export declare const isAdvisory: (f: AuditFinding) => boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Urut severity; di severity yang sama temuan dari menjalankan aplikasi didahulukan sebelum advisory dependensi
|
|
89
|
+
* (sort stabil), dan advisory dibatasi MAX_ADVISORIES agar tidak memenuhi MAX_FINDINGS.
|
|
90
|
+
*/
|
|
91
|
+
export declare function selectFindings(all: AuditFinding[]): AuditFinding[];
|
|
85
92
|
/**
|
|
86
93
|
* Jelajah link same-origin (BFS) di desktop, lalu ulangi halaman yang sama di mobile.
|
|
87
94
|
* Setiap halaman difoto polos (galeri laporan); setiap jenis masalah difoto lagi dengan tanda (kotak merah di elemen,
|
|
@@ -98,14 +105,10 @@ export declare function smokeUi(browser: BrowserLike, baseUrl: string, evidenceD
|
|
|
98
105
|
}>;
|
|
99
106
|
/** Test gagal / error dari laporan JUnit PHPUnit / Pest. */
|
|
100
107
|
export declare function parseJunit(xml: string, root?: string): AuditFinding[];
|
|
101
|
-
/** `composer audit --format=json` → satu temuan per advisory. */
|
|
108
|
+
/** `composer audit --format=json` → satu temuan per paket (advisory duplikat digabung, severity terparah). */
|
|
102
109
|
export declare function parseComposerAudit(output: string): AuditFinding[];
|
|
103
110
|
/** `npm audit --json` (npm 7+) → satu temuan per paket berseverity high/critical. */
|
|
104
111
|
export declare function parseNpmAudit(output: string): AuditFinding[];
|
|
105
|
-
/** URI route GET tanpa parameter dan tanpa middleware autentikasi dari `route:list --json`. */
|
|
106
|
-
export declare function pickSmokeRoutes(output: string): string[];
|
|
107
|
-
/** URI → action (mis. App\Http\Controllers\ProdukController@index) dari `route:list --json`. */
|
|
108
|
-
export declare function routeActions(output: string): Map<string, string>;
|
|
109
112
|
/**
|
|
110
113
|
* Request FE (method + path, mis. GET /api/produk/12) → action route BE dari `route:list --json`.
|
|
111
114
|
* {param} cocok dengan satu segmen, {param?} opsional; bila beberapa cocok, route dengan segmen statis terbanyak menang.
|
|
@@ -120,17 +123,23 @@ export declare function controllerLocation(root: string, action: string): Promis
|
|
|
120
123
|
* 2. routing berbasis berkas (pages/, app/ ala Nuxt / Next).
|
|
121
124
|
*/
|
|
122
125
|
export declare function resolveRouteFile(root: string, path: string): Promise<SourceLocation | null>;
|
|
126
|
+
export declare function listSources(dir: string, depth: number): Promise<string[]>;
|
|
127
|
+
export declare const lineAt: (source: string, index: number) => number;
|
|
128
|
+
/** 100 dikurangi penalti severity; penalti advisory dependensi dibatasi MAX_ADVISORY_PENALTY. */
|
|
123
129
|
export declare function scoreRepo(findings: AuditFinding[], booted: boolean): number;
|
|
124
130
|
export interface Login {
|
|
125
131
|
email: string;
|
|
126
132
|
password: string;
|
|
127
133
|
}
|
|
128
|
-
/** Akun login dari catatan aplikasi, mis. "Login admin@pos.test / password". */
|
|
134
|
+
/** Akun login pertama dari catatan aplikasi, mis. "Login admin@pos.test / password" (smoke UI). */
|
|
129
135
|
export declare function parseLogin(instructions: string): Login | null;
|
|
130
136
|
/** Variabel VITE_* berisi URL http diarahkan ke BE lokal; path (mis. /api) dipertahankan. */
|
|
131
137
|
export declare function localApiEnv(example: string): string;
|
|
132
138
|
/** Ganti / tambah KEY=VALUE di isi file .env. */
|
|
133
139
|
export declare function setEnv(content: string, values: Record<string, string>): string;
|
|
140
|
+
export declare const tail: (text: string, max?: number) => string;
|
|
141
|
+
/** Keluaran composer/npm kadang diawali peringatan; ambil dari karakter JSON pertama. */
|
|
142
|
+
export declare function parseJsonLoose(output: string): any;
|
|
134
143
|
export declare const defaultShell: Shell;
|
|
135
144
|
/** playwright-core dipasang global di runner bersama qaify; Google Chrome sudah ada di ubuntu-latest. */
|
|
136
145
|
export declare function defaultLaunch(): Promise<BrowserLike | null>;
|
package/dist/audit-static.js
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
// Audit aplikasi tanpa AI (qaify audit-static): pemeriksaan deterministik di repo runner bila
|
|
2
2
|
// CLAUDE_CODE_OAUTH_TOKEN kosong. Hasilnya berbentuk sama dengan keluaran `claude -p`, jadi audit-submit,
|
|
3
|
-
// backend, dan UI tidak berubah. Temuan lebih dangkal dari mode Claude: tidak
|
|
4
|
-
//
|
|
5
|
-
// BE (Laravel): composer install, migrate --seed, php artisan test, composer audit, smoke
|
|
3
|
+
// backend, dan UI tidak berubah. Temuan lebih dangkal dari mode Claude: aturan bisnis tidak ditebak dari kode,
|
|
4
|
+
// hanya gejalanya yang diuji lewat HTTP (audit-api.ts).
|
|
5
|
+
// BE (Laravel): composer install, migrate --seed, php artisan test, composer audit, smoke API (dengan login bila ada
|
|
6
|
+
// akun), uji validasi POST/PUT/PATCH, uji otorisasi.
|
|
6
7
|
// FE (Node): npm ci, npm run build, npm audit, smoke Playwright (desktop + mobile) dengan screenshot setiap halaman,
|
|
7
|
-
// plus foto bertanda (kotak merah di elemen bermasalah / banner pesan error) untuk setiap masalah
|
|
8
|
+
// plus foto bertanda (kotak merah di elemen bermasalah / banner pesan error) untuk setiap masalah, dan cek kontrak
|
|
9
|
+
// pemanggilan API di kode FE terhadap route BE.
|
|
8
10
|
// Temuan smoke diberi berkas sumber bila bisa ditebak: route FE → komponen halaman, route BE → method controller;
|
|
9
11
|
// request FE yang gagal juga dipetakan ke controller BE, dan route BE yang error ke halaman FE pemanggilnya.
|
|
10
12
|
import { spawn } from 'node:child_process';
|
|
11
13
|
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
12
14
|
import { openSync } from 'node:fs';
|
|
13
15
|
import { dirname, join, relative } from 'node:path';
|
|
16
|
+
import { checkAuthorization, checkContract, checkValidation, openApi, parseLogins, parseRoutes, smokeApi } from './audit-api.js';
|
|
14
17
|
/** usage.model untuk audit tanpa AI; dipakai backend/PDF untuk menampilkan mode audit. */
|
|
15
18
|
export const STATIC_MODEL = 'qaify-static';
|
|
16
19
|
const MAX_FINDINGS = 40;
|
|
20
|
+
/** Advisory composer/npm audit yang masuk laporan, agar tidak menyingkirkan temuan dari menjalankan aplikasi. */
|
|
21
|
+
const MAX_ADVISORIES = 10;
|
|
22
|
+
/** Batas total penalti skor dari advisory dependensi. */
|
|
23
|
+
const MAX_ADVISORY_PENALTY = 30;
|
|
17
24
|
const MAX_SMOKE_PAGES = 15;
|
|
18
|
-
const MAX_SMOKE_ROUTES = 40;
|
|
19
25
|
/** Foto bertanda per halaman per viewport (satu per jenis masalah). */
|
|
20
26
|
const MAX_ISSUE_SHOTS = 3;
|
|
21
27
|
const BE_URL = 'http://127.0.0.1:8000';
|
|
@@ -32,25 +38,49 @@ export async function auditStatic(dir, deps) {
|
|
|
32
38
|
await mkdir(join(dir, '.qaify'), { recursive: true });
|
|
33
39
|
await mkdir(join(dir, 'evidence'), { recursive: true });
|
|
34
40
|
const context = await readFile(join(dir, 'context.json'), 'utf8').then(JSON.parse, () => ({}));
|
|
35
|
-
const
|
|
41
|
+
const accounts = parseLogins(String(context?.project?.instructions ?? ''));
|
|
42
|
+
const login = accounts[0] ? { email: accounts[0].email, password: accounts[0].password } : null;
|
|
36
43
|
const beRoot = join(dir, 'apps', 'be');
|
|
37
44
|
const feRoot = join(dir, 'apps', 'fe');
|
|
38
45
|
const be = await auditBe(beRoot, dir, deps);
|
|
46
|
+
const routes = parseRoutes(be.routes);
|
|
39
47
|
const api = be.routes
|
|
40
48
|
? async (method, path) => {
|
|
41
49
|
const action = matchRoute(be.routes, method, path);
|
|
42
50
|
return action ? controllerLocation(beRoot, action) : null;
|
|
43
51
|
}
|
|
44
52
|
: undefined;
|
|
53
|
+
let probe = null;
|
|
54
|
+
let failed = [];
|
|
55
|
+
if (be.running) {
|
|
56
|
+
deps.log('qa-factory: [be] smoke API');
|
|
57
|
+
const opened = await openApi(routes, accounts, { base: BE_URL, fetch: deps.fetch, root: beRoot });
|
|
58
|
+
probe = opened.probe;
|
|
59
|
+
const smoke = await smokeApi(probe);
|
|
60
|
+
be.result.findings.push(...smoke.findings);
|
|
61
|
+
be.result.notes.push(...opened.notes, ...smoke.notes);
|
|
62
|
+
failed = smoke.failed;
|
|
63
|
+
}
|
|
45
64
|
let fe;
|
|
46
65
|
try {
|
|
47
66
|
fe = await auditFe(feRoot, dir, deps, login, api);
|
|
67
|
+
// mengubah data: setelah smoke FE agar halaman FE tidak melihat data uji
|
|
68
|
+
if (probe) {
|
|
69
|
+
deps.log('qa-factory: [be] uji validasi & otorisasi');
|
|
70
|
+
for (const check of [await checkValidation(probe), await checkAuthorization(probe, beRoot)]) {
|
|
71
|
+
be.result.findings.push(...check.findings);
|
|
72
|
+
be.result.notes.push(...check.notes);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
48
75
|
}
|
|
49
76
|
finally {
|
|
50
77
|
be.stop();
|
|
51
78
|
}
|
|
52
|
-
await
|
|
53
|
-
|
|
79
|
+
const contract = await checkContract(feRoot, beRoot, routes);
|
|
80
|
+
fe.findings.push(...contract.findings);
|
|
81
|
+
fe.notes.push(...contract.notes);
|
|
82
|
+
await linkCallers(failed, fe, feRoot);
|
|
83
|
+
const findings = selectFindings([...be.result.findings, ...fe.findings]);
|
|
54
84
|
return {
|
|
55
85
|
summary: { be: summarize('BE', be.result), fe: summarize('FE', fe) },
|
|
56
86
|
findings,
|
|
@@ -68,6 +98,16 @@ export function toAuditOutput(audit, durationMs) {
|
|
|
68
98
|
modelUsage: { [STATIC_MODEL]: { outputTokens: 0 } },
|
|
69
99
|
});
|
|
70
100
|
}
|
|
101
|
+
/** Advisory composer audit / npm audit (berkas lockfile). */
|
|
102
|
+
export const isAdvisory = (f) => f.area === 'security' && (f.file === 'composer.lock' || f.file === 'package-lock.json');
|
|
103
|
+
/**
|
|
104
|
+
* Urut severity; di severity yang sama temuan dari menjalankan aplikasi didahulukan sebelum advisory dependensi
|
|
105
|
+
* (sort stabil), dan advisory dibatasi MAX_ADVISORIES agar tidak memenuhi MAX_FINDINGS.
|
|
106
|
+
*/
|
|
107
|
+
export function selectFindings(all) {
|
|
108
|
+
const advisories = all.filter(isAdvisory).sort(bySeverity).slice(0, MAX_ADVISORIES);
|
|
109
|
+
return [...all.filter((f) => !isAdvisory(f)), ...advisories].sort(bySeverity).slice(0, MAX_FINDINGS);
|
|
110
|
+
}
|
|
71
111
|
/**
|
|
72
112
|
* Route BE yang error saat smoke API → halaman FE yang memanggilnya: halaman jadi `page`, komponennya masuk
|
|
73
113
|
* related_files, dan foto halaman itu jadi bukti agar temuan BE juga tampak di laporan.
|
|
@@ -96,7 +136,7 @@ async function linkCallers(failed, fe, feRoot) {
|
|
|
96
136
|
}
|
|
97
137
|
async function auditBe(root, dir, deps) {
|
|
98
138
|
const result = { findings: [], booted: false, notes: [] };
|
|
99
|
-
const none = { result, stop: () => { }, routes: '',
|
|
139
|
+
const none = { result, stop: () => { }, routes: '', running: false };
|
|
100
140
|
const { shell, log } = deps;
|
|
101
141
|
if (!(await exists(root))) {
|
|
102
142
|
result.notes.push('Repo BE tidak ada di folder kerja.');
|
|
@@ -173,31 +213,7 @@ async function auditBe(root, dir, deps) {
|
|
|
173
213
|
}
|
|
174
214
|
result.booted = migrated;
|
|
175
215
|
const routes = await shell.run('php artisan route:list --json --no-interaction', { cwd: root, timeoutMs: MIN });
|
|
176
|
-
|
|
177
|
-
const actions = routeActions(routes.output);
|
|
178
|
-
const failed = [];
|
|
179
|
-
for (const uri of smoke) {
|
|
180
|
-
const url = `${BE_URL}/${uri.replace(/^\//, '')}`;
|
|
181
|
-
const res = await deps.fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) }).catch(() => null);
|
|
182
|
-
if (res && res.status >= 500) {
|
|
183
|
-
const action = actions.get(uri);
|
|
184
|
-
const location = action ? await controllerLocation(root, action) : null;
|
|
185
|
-
const finding = {
|
|
186
|
-
repo: 'be',
|
|
187
|
-
area: 'error_handling',
|
|
188
|
-
severity: 'high',
|
|
189
|
-
title: `GET /${uri} membalas ${res.status}`,
|
|
190
|
-
description: `Route publik GET /${uri}${action ? ` (${action})` : ''} membalas ${res.status} pada database hasil seed.`,
|
|
191
|
-
...location,
|
|
192
|
-
evidence: { request: `GET ${url}`, response: tail(await res.text().catch(() => ''), 2000) },
|
|
193
|
-
suggestion: 'Cek laravel.log untuk exception di route ini dan tangani kasus data kosong / parameter yang hilang.',
|
|
194
|
-
};
|
|
195
|
-
result.findings.push(finding);
|
|
196
|
-
failed.push({ finding, uri });
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
result.notes.push(`Smoke API: ${smoke.length} route GET publik dipanggil.`);
|
|
200
|
-
return { result, stop, routes: routes.code === 0 ? routes.output : '', failed };
|
|
216
|
+
return { result, stop, routes: routes.code === 0 ? routes.output : '', running: true };
|
|
201
217
|
}
|
|
202
218
|
// ---------------------------------------------------------------- FE
|
|
203
219
|
async function auditFe(root, dir, deps, login, api) {
|
|
@@ -625,26 +641,50 @@ export function parseJunit(xml, root = '') {
|
|
|
625
641
|
}
|
|
626
642
|
return findings;
|
|
627
643
|
}
|
|
628
|
-
/**
|
|
644
|
+
/** "guzzlehttp/psr7: Host confusion" → "Host confusion" (judul advisory sering sudah diawali nama paket). */
|
|
645
|
+
const stripPackage = (pkg, title) => title.replace(new RegExp(`^${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*`, 'i'), '');
|
|
646
|
+
/** Advisory sama (CVE sama, atau judul sama setelah kata nama paket dibuang) digabung; severity tak dikenal → medium. */
|
|
647
|
+
function dedupeAdvisories(pkg, list) {
|
|
648
|
+
const words = new Set(pkg.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
649
|
+
const out = [];
|
|
650
|
+
for (const raw of list) {
|
|
651
|
+
const title = stripPackage(pkg, String(raw?.title ?? 'kerentanan diketahui'));
|
|
652
|
+
const cve = typeof raw?.cve === 'string' && raw.cve ? raw.cve : null;
|
|
653
|
+
const severity = SEVERITY_ORDER.includes(raw?.severity) ? String(raw.severity) : 'medium';
|
|
654
|
+
const key = title.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w && !words.has(w)).join(' ');
|
|
655
|
+
const same = out.find((a) => (cve && a.cve === cve) || a.key === key);
|
|
656
|
+
if (same) {
|
|
657
|
+
if (rank(severity) < rank(same.severity))
|
|
658
|
+
same.severity = severity;
|
|
659
|
+
same.cve ??= cve;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
out.push({ title, cve, severity, affectedVersions: raw?.affectedVersions, link: raw?.link, key });
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
const rank = (severity) => SEVERITY_ORDER.indexOf(severity);
|
|
667
|
+
/** `composer audit --format=json` → satu temuan per paket (advisory duplikat digabung, severity terparah). */
|
|
629
668
|
export function parseComposerAudit(output) {
|
|
630
669
|
const data = parseJsonLoose(output);
|
|
631
670
|
const advisories = data?.advisories && typeof data.advisories === 'object' ? data.advisories : {};
|
|
632
671
|
const findings = [];
|
|
633
672
|
for (const [pkg, list] of Object.entries(advisories)) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
673
|
+
const unique = dedupeAdvisories(pkg, Array.isArray(list) ? list : Object.values(list ?? {}));
|
|
674
|
+
if (!unique.length)
|
|
675
|
+
continue;
|
|
676
|
+
const worst = [...unique].sort((a, b) => rank(a.severity) - rank(b.severity))[0];
|
|
677
|
+
findings.push({
|
|
678
|
+
repo: 'be',
|
|
679
|
+
area: 'security',
|
|
680
|
+
severity: worst.severity,
|
|
681
|
+
title: (unique.length > 1 ? `${pkg}: ${unique.length} kerentanan — ${worst.title}` : `${pkg}: ${worst.title}`).slice(0, 120),
|
|
682
|
+
description: unique
|
|
683
|
+
.map((a) => `- [${a.severity}] ${a.title}${a.cve ? ` (${a.cve})` : ''}${a.affectedVersions ? ` · versi terdampak ${a.affectedVersions}` : ''}${a.link ? ` · ${a.link}` : ''}`)
|
|
684
|
+
.join('\n'),
|
|
685
|
+
file: 'composer.lock',
|
|
686
|
+
suggestion: `composer update ${pkg} --with-all-dependencies ke versi yang sudah diperbaiki.`,
|
|
687
|
+
});
|
|
648
688
|
}
|
|
649
689
|
return findings;
|
|
650
690
|
}
|
|
@@ -661,7 +701,7 @@ export function parseNpmAudit(output) {
|
|
|
661
701
|
repo: 'fe',
|
|
662
702
|
area: 'security',
|
|
663
703
|
severity: vuln.severity,
|
|
664
|
-
title: `${name}: ${advisories[0]?.title ?? 'kerentanan diketahui'}`.slice(0, 120),
|
|
704
|
+
title: `${name}: ${stripPackage(name, advisories[0]?.title ?? 'kerentanan diketahui')}`.slice(0, 120),
|
|
665
705
|
description: [
|
|
666
706
|
...advisories.slice(0, 3).map((a) => `${a.title}${a.url ? ` (${a.url})` : ''}`),
|
|
667
707
|
vuln.range && `Versi terdampak: ${vuln.range}`,
|
|
@@ -675,28 +715,6 @@ export function parseNpmAudit(output) {
|
|
|
675
715
|
}
|
|
676
716
|
return findings;
|
|
677
717
|
}
|
|
678
|
-
/** URI route GET tanpa parameter dan tanpa middleware autentikasi dari `route:list --json`. */
|
|
679
|
-
export function pickSmokeRoutes(output) {
|
|
680
|
-
const routes = parseJsonLoose(output);
|
|
681
|
-
if (!Array.isArray(routes))
|
|
682
|
-
return [];
|
|
683
|
-
const uris = routes
|
|
684
|
-
.filter((r) => String(r?.method ?? '').split('|').includes('GET'))
|
|
685
|
-
.filter((r) => typeof r?.uri === 'string' && !r.uri.includes('{') && !/^(_|telescope|horizon|pulse|livewire|sanctum|storage)/.test(r.uri))
|
|
686
|
-
.filter((r) => !(Array.isArray(r?.middleware) ? r.middleware : []).some((m) => /^(auth|verified|signed|can:|password\.confirm)/.test(String(m).split('\\').pop() ?? '') || /Authenticate/.test(String(m))))
|
|
687
|
-
.map((r) => String(r.uri));
|
|
688
|
-
return [...new Set(uris)].slice(0, MAX_SMOKE_ROUTES);
|
|
689
|
-
}
|
|
690
|
-
/** URI → action (mis. App\Http\Controllers\ProdukController@index) dari `route:list --json`. */
|
|
691
|
-
export function routeActions(output) {
|
|
692
|
-
const routes = parseJsonLoose(output);
|
|
693
|
-
const actions = new Map();
|
|
694
|
-
for (const r of Array.isArray(routes) ? routes : []) {
|
|
695
|
-
if (typeof r?.uri === 'string' && typeof r?.action === 'string' && r.action !== 'Closure' && !actions.has(r.uri))
|
|
696
|
-
actions.set(r.uri, r.action);
|
|
697
|
-
}
|
|
698
|
-
return actions;
|
|
699
|
-
}
|
|
700
718
|
/**
|
|
701
719
|
* Request FE (method + path, mis. GET /api/produk/12) → action route BE dari `route:list --json`.
|
|
702
720
|
* {param} cocok dengan satu segmen, {param?} opsional; bila beberapa cocok, route dengan segmen statis terbanyak menang.
|
|
@@ -807,7 +825,7 @@ async function componentNear(root, file, source, index) {
|
|
|
807
825
|
}
|
|
808
826
|
return null;
|
|
809
827
|
}
|
|
810
|
-
async function listSources(dir, depth) {
|
|
828
|
+
export async function listSources(dir, depth) {
|
|
811
829
|
if (depth < 0)
|
|
812
830
|
return [];
|
|
813
831
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
@@ -823,15 +841,18 @@ async function listSources(dir, depth) {
|
|
|
823
841
|
}
|
|
824
842
|
return out;
|
|
825
843
|
}
|
|
826
|
-
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
|
844
|
+
export const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
|
845
|
+
/** 100 dikurangi penalti severity; penalti advisory dependensi dibatasi MAX_ADVISORY_PENALTY. */
|
|
827
846
|
export function scoreRepo(findings, booted) {
|
|
828
|
-
const
|
|
847
|
+
const penalty = (list) => list.reduce((sum, f) => sum + (PENALTY[f.severity] ?? 0), 0);
|
|
848
|
+
const advisories = findings.filter(isAdvisory);
|
|
849
|
+
const score = Math.max(0, 100 - penalty(findings.filter((f) => !isAdvisory(f))) - Math.min(MAX_ADVISORY_PENALTY, penalty(advisories)));
|
|
829
850
|
return booted ? score : Math.min(score, 40);
|
|
830
851
|
}
|
|
831
|
-
/** Akun login dari catatan aplikasi, mis. "Login admin@pos.test / password". */
|
|
852
|
+
/** Akun login pertama dari catatan aplikasi, mis. "Login admin@pos.test / password" (smoke UI). */
|
|
832
853
|
export function parseLogin(instructions) {
|
|
833
|
-
const
|
|
834
|
-
return
|
|
854
|
+
const account = parseLogins(instructions)[0];
|
|
855
|
+
return account ? { email: account.email, password: account.password } : null;
|
|
835
856
|
}
|
|
836
857
|
/** Variabel VITE_* berisi URL http diarahkan ke BE lokal; path (mis. /api) dipertahankan. */
|
|
837
858
|
export function localApiEnv(example) {
|
|
@@ -874,7 +895,7 @@ function bootFailure(repo, title, description, output, file) {
|
|
|
874
895
|
};
|
|
875
896
|
}
|
|
876
897
|
const bySeverity = (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
|
877
|
-
const tail = (text, max = 4000) => (text.length > max ? `…${text.slice(-max)}` : text);
|
|
898
|
+
export const tail = (text, max = 4000) => (text.length > max ? `…${text.slice(-max)}` : text);
|
|
878
899
|
const exists = (path) => stat(path).then(() => true, () => false);
|
|
879
900
|
function pathOf(href, origin) {
|
|
880
901
|
try {
|
|
@@ -912,7 +933,7 @@ function relativeTo(file, root) {
|
|
|
912
933
|
return base && normalized.startsWith(`${base}/`) ? normalized.slice(base.length + 1) : normalized.replace(/^.*?\/(tests|app|src)\//, '$1/');
|
|
913
934
|
}
|
|
914
935
|
/** Keluaran composer/npm kadang diawali peringatan; ambil dari karakter JSON pertama. */
|
|
915
|
-
function parseJsonLoose(output) {
|
|
936
|
+
export function parseJsonLoose(output) {
|
|
916
937
|
const start = output.search(/[[{]/);
|
|
917
938
|
if (start < 0)
|
|
918
939
|
return null;
|
package/dist/audit.d.ts
CHANGED
|
@@ -279,9 +279,13 @@ export declare const defaultExec: Exec;
|
|
|
279
279
|
export interface AuditContextResult {
|
|
280
280
|
ok: boolean;
|
|
281
281
|
commits: Record<string, string>;
|
|
282
|
+
/** auditor pilihan Qaify (static / claude); undefined = backend lama, workflow memilih sendiri */
|
|
283
|
+
mode?: AuditMode;
|
|
282
284
|
/** alasan gagal; juga ditulis ke context-error.txt agar audit-submit bisa melaporkannya */
|
|
283
285
|
error?: string;
|
|
284
286
|
}
|
|
287
|
+
export declare const AUDIT_MODES: readonly ["static", "claude"];
|
|
288
|
+
export type AuditMode = (typeof AUDIT_MODES)[number];
|
|
285
289
|
/**
|
|
286
290
|
* GET /api/ingest/audits/{id}/context → clone apps/<role> → cabut token.
|
|
287
291
|
* Token hanya lewat header git dari environment (tidak pernah di argumen, URL remote, atau disk)
|
package/dist/audit.js
CHANGED
|
@@ -174,6 +174,7 @@ export function parseAuditOutput(raw) {
|
|
|
174
174
|
const execFileAsync = promisify(execFile);
|
|
175
175
|
export const defaultExec = async (file, args, env) => (await execFileAsync(file, args, { env: { ...process.env, ...env }, maxBuffer: 10 * 1024 * 1024 })).stdout;
|
|
176
176
|
const branchOf = (repo) => repo.branch || repo.default_branch;
|
|
177
|
+
export const AUDIT_MODES = ['static', 'claude'];
|
|
177
178
|
const CONTEXT_ERROR_FILE = 'context-error.txt';
|
|
178
179
|
/**
|
|
179
180
|
* GET /api/ingest/audits/{id}/context → clone apps/<role> → cabut token.
|
|
@@ -214,12 +215,14 @@ export async function auditContext(client, auditId, dir, opts = {}) {
|
|
|
214
215
|
return contextFailed(dir, `Clone repo gagal: ${detail}`, commits);
|
|
215
216
|
}
|
|
216
217
|
await revoke(repos, opts.fetch);
|
|
218
|
+
const mode = AUDIT_MODES.includes(ctx.mode) ? ctx.mode : undefined;
|
|
217
219
|
await writeFile(join(dir, 'context.json'), JSON.stringify({
|
|
218
220
|
audit_id: ctx.audit_id,
|
|
221
|
+
mode: mode ?? null,
|
|
219
222
|
project: ctx.project,
|
|
220
223
|
repos: repos.map((r) => ({ role: r.role, full_name: r.full_name, branch: branchOf(r), commit: commits[r.role] ?? null })),
|
|
221
224
|
}, null, 2));
|
|
222
|
-
return { ok: true, commits };
|
|
225
|
+
return { ok: true, commits, mode };
|
|
223
226
|
}
|
|
224
227
|
/** Peringatan (annotation di ringkasan run) + context-error.txt untuk audit-submit. */
|
|
225
228
|
async function contextFailed(dir, error, commits) {
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Perbaikan temuan audit (qaify-fix.yml di repo runner):
|
|
17
17
|
// qaify fix-context --fix <id> --dir <folder kerja> (job fix: clone repo target, prompt Claude)
|
|
18
18
|
// qaify fix-schema --out schema.json --instructions instructions.txt
|
|
19
|
+
// qaify fix-deps --dir <folder kerja> (job fix, mode deps: update lockfile tanpa Claude)
|
|
19
20
|
// qaify fix-diff --dir <folder kerja> --out <folder artefak> (job fix: perubahan Claude → changes.patch)
|
|
20
21
|
// qaify fix-submit --fix <id> --results claude.json --patch changes.patch --dir <folder kosong> (job submit)
|
|
21
22
|
// Auto-fix test yang gagal (auto-fix.yml di repo yang diuji, dibuat `qaify init`); fix-diff dipakai bersama:
|
|
@@ -29,7 +30,7 @@ import { aiQueue, aiSubmit, analyze, CONCLUSIONS, parseArgs } from './analyze.js
|
|
|
29
30
|
import { AUDIT_INSTRUCTIONS, AUDIT_PROMPT, AUDIT_SCHEMA, auditContext, auditSubmit, readCommits, readContextError } from './audit.js';
|
|
30
31
|
import { auditStatic, defaultLaunch, defaultShell, toAuditOutput } from './audit-static.js';
|
|
31
32
|
import { createClient, describeProblem, MISSING_CONFIG, runFromEnv, warn } from './client.js';
|
|
32
|
-
import { FIX_INSTRUCTIONS, FIX_SCHEMA, fixContext, fixDiff, fixSubmit, TEST_FIX_INSTRUCTIONS, writeOutputs } from './fix.js';
|
|
33
|
+
import { FIX_INSTRUCTIONS, FIX_SCHEMA, fixContext, fixDeps, fixDiff, fixSubmit, TEST_FIX_INSTRUCTIONS, writeOutputs } from './fix.js';
|
|
33
34
|
import { init, initRunner, NEXT_STEPS, RUNNER_NEXT_STEPS } from './init.js';
|
|
34
35
|
import { check } from './check.js';
|
|
35
36
|
const USAGE = `Pemakaian:
|
|
@@ -46,6 +47,7 @@ const USAGE = `Pemakaian:
|
|
|
46
47
|
qaify audit-submit --audit <id> --results <file> --evidence <folder> --context <file>
|
|
47
48
|
qaify fix-context --fix <id> --dir <folder>
|
|
48
49
|
qaify fix-schema --out <file-schema> [--instructions <file>]
|
|
50
|
+
qaify fix-deps --dir <folder>
|
|
49
51
|
qaify fix-diff --dir <folder> --out <folder>
|
|
50
52
|
qaify fix-submit --fix <id> --results <file> --patch <file> --dir <folder>
|
|
51
53
|
qaify autofix-context --fix <id> --dir <folder>
|
|
@@ -137,8 +139,9 @@ switch (args.command) {
|
|
|
137
139
|
// step merah: tanpa ini auditor & kirim hasil dilewati diam-diam dan audit macet "running"
|
|
138
140
|
process.exitCode = 1;
|
|
139
141
|
}
|
|
142
|
+
// mode kosong = backend lama: workflow memilih dari variable / secret
|
|
140
143
|
if (process.env.GITHUB_OUTPUT)
|
|
141
|
-
await appendFile(process.env.GITHUB_OUTPUT, `ok=${result.ok}\n`);
|
|
144
|
+
await appendFile(process.env.GITHUB_OUTPUT, `ok=${result.ok}\nmode=${('mode' in result && result.mode) || ''}\n`);
|
|
142
145
|
break;
|
|
143
146
|
}
|
|
144
147
|
case 'audit-schema': {
|
|
@@ -194,13 +197,27 @@ switch (args.command) {
|
|
|
194
197
|
}
|
|
195
198
|
const result = client.enabled
|
|
196
199
|
? await fixContext(client, args.fix, args.dir, { kind: fixKind(args.command), githubRunId: run.github_run_id || undefined })
|
|
197
|
-
: (warn(MISSING_CONFIG), { ok: false, model: null });
|
|
200
|
+
: (warn(MISSING_CONFIG), { ok: false, model: null, mode: 'claude' });
|
|
198
201
|
if (!result.ok) {
|
|
199
202
|
const problem = describeProblem(client);
|
|
200
203
|
if (problem)
|
|
201
204
|
warn(problem);
|
|
202
205
|
}
|
|
203
|
-
await writeOutputs({ ok: String(result.ok), model: result.model ?? '' });
|
|
206
|
+
await writeOutputs({ ok: String(result.ok), model: result.model ?? '', mode: result.mode });
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
// job fix, tanpa token dan tanpa Claude: update advisory dependensi di lockfile
|
|
210
|
+
case 'fix-deps': {
|
|
211
|
+
if (!args.dir) {
|
|
212
|
+
usage();
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const result = await fixDeps(args.dir).catch((e) => {
|
|
216
|
+
warn(`update dependensi gagal: ${e.message.split('\n')[0]}`);
|
|
217
|
+
return null;
|
|
218
|
+
});
|
|
219
|
+
if (result)
|
|
220
|
+
console.log(`qa-factory: ${result.fixed ? 'dependensi diperbarui' : `dependensi tidak diperbarui: ${result.reason ?? result.summary}`}`);
|
|
204
221
|
break;
|
|
205
222
|
}
|
|
206
223
|
case 'fix-schema':
|
package/dist/fix.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Exec } from './audit.js';
|
|
2
2
|
import type { IngestClient } from './client.js';
|
|
3
3
|
import { type ClaudeUsage } from './usage.js';
|
|
4
|
+
/** "Model" perbaikan advisory dependensi tanpa AI; cermin config qafactory.audit_fix.deps_model. */
|
|
5
|
+
export declare const DEPS_MODEL = "qaify-deps";
|
|
4
6
|
export declare const FIX_SCHEMA: {
|
|
5
7
|
readonly type: "object";
|
|
6
8
|
readonly properties: {
|
|
@@ -59,6 +61,12 @@ interface FixContext {
|
|
|
59
61
|
max_files: number;
|
|
60
62
|
max_changed_lines: number;
|
|
61
63
|
};
|
|
64
|
+
/** Advisory dependensi: diperbaiki fix-deps tanpa Claude. Backend lama tidak mengirimnya. */
|
|
65
|
+
deps?: DepsTarget | null;
|
|
66
|
+
}
|
|
67
|
+
export interface DepsTarget {
|
|
68
|
+
package: string;
|
|
69
|
+
manager: 'composer' | 'npm';
|
|
62
70
|
}
|
|
63
71
|
/** Prompt `claude -p`: temuan + batas + percobaan sebelumnya. Detail cara kerja di FIX_INSTRUCTIONS. */
|
|
64
72
|
export declare function fixPrompt(ctx: Omit<FixContext, 'repo'> & {
|
|
@@ -108,6 +116,8 @@ export declare function testFixPrompt(ctx: Omit<TestFixContext, 'repo'> & {
|
|
|
108
116
|
export interface FixContextResult {
|
|
109
117
|
ok: boolean;
|
|
110
118
|
model: string | null;
|
|
119
|
+
/** deps = langkah Claude dilewati, fix-deps yang memperbaiki. */
|
|
120
|
+
mode: 'claude' | 'deps';
|
|
111
121
|
}
|
|
112
122
|
/**
|
|
113
123
|
* GET {base}/{id}/context → clone repo di commit sumber (yang diaudit / run yang gagal) ke <dir>/repo → cabut token
|
|
@@ -120,9 +130,22 @@ export declare function fixContext(client: IngestClient, fixId: string, dir: str
|
|
|
120
130
|
fetch?: typeof fetch;
|
|
121
131
|
log?: (msg: string) => void;
|
|
122
132
|
}): Promise<FixContextResult>;
|
|
133
|
+
/**
|
|
134
|
+
* Advisory dependensi tanpa AI: update paket di lockfile saja (tanpa install / script), cek ulang dengan audit, lalu
|
|
135
|
+
* tulis hasilnya ke <dir>/.qaify/claude.json dalam bentuk keluaran `claude -p` (model qaify-deps, token 0) agar
|
|
136
|
+
* fix-diff / fix-submit tidak berubah. npm: npm update dulu, npm audit fix bila paket masih rentan.
|
|
137
|
+
*/
|
|
138
|
+
export declare function fixDeps(dir: string, exec?: Exec): Promise<{
|
|
139
|
+
fixed: boolean;
|
|
140
|
+
summary: string;
|
|
141
|
+
reason?: string;
|
|
142
|
+
}>;
|
|
143
|
+
/** Masih ada advisory untuk paket ini? npm: hanya high / critical (sama dengan temuan audit-static). */
|
|
144
|
+
export declare function stillVulnerable(output: string, manager: 'composer' | 'npm', pkg: string): boolean;
|
|
123
145
|
/**
|
|
124
146
|
* Perubahan Claude di <dir>/repo → <out>/changes.patch (git diff --binary). Lockfile & .env yang ikut berubah saat
|
|
125
|
-
* menjalankan aplikasi dibuang dari patch
|
|
147
|
+
* menjalankan aplikasi dibuang dari patch, kecuali lockfile pada mode deps (.qaify/deps.json) yang memang hasilnya.
|
|
148
|
+
* Job ini tidak memegang token, jadi aman menjalankan git di repo hasil Claude.
|
|
126
149
|
*/
|
|
127
150
|
export declare function fixDiff(dir: string, out: string, exec?: Exec): Promise<{
|
|
128
151
|
files: string[];
|
|
@@ -135,6 +158,7 @@ export interface PatchStats {
|
|
|
135
158
|
paths: string[];
|
|
136
159
|
/** Header diff yang tidak bisa dibaca: patch ditolak, bukan dilewati diam-diam. */
|
|
137
160
|
unreadable: number;
|
|
161
|
+
/** Baris +/- di luar lockfile (lockfile hasil update paket wajar besar, tidak dihitung ke batas). */
|
|
138
162
|
changedLines: number;
|
|
139
163
|
}
|
|
140
164
|
/** Berkas dan jumlah baris +/- dari unified diff git (path ber-escape git dibungkus tanda kutip). */
|