@koreanhole/requirement-agent 0.0.0-stage → 0.1.1
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 +74 -2
- package/bin/requirement-agent.js +10 -0
- package/package.json +35 -4
- package/public/app.js +76 -0
- package/public/index.html +40 -0
- package/schemas/findings.input.schema.json +38 -0
- package/src/agents.js +131 -0
- package/src/citations.js +114 -0
- package/src/cli.js +192 -0
- package/src/documents.js +173 -0
- package/src/findings.js +112 -0
- package/src/orchestrator.js +217 -0
- package/src/report.js +42 -0
- package/src/roles.js +75 -0
- package/src/screen.js +31 -0
- package/src/server.js +93 -0
- package/src/store.js +73 -0
- package/src/util.js +40 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { review, DEFAULTS, notices } from './orchestrator.js';
|
|
4
|
+
import { defaultStore, listRuns, loadRun, readSnapshot, writeRun } from './store.js';
|
|
5
|
+
import { compareSnapshots, snapshot, PreflightError } from './documents.js';
|
|
6
|
+
import { probeAgents, AGENT_IDS } from './agents.js';
|
|
7
|
+
import { ROLES, ROLE_IDS } from './roles.js';
|
|
8
|
+
import { serve } from './server.js';
|
|
9
|
+
import { clampInt } from './util.js';
|
|
10
|
+
|
|
11
|
+
const HELP = `requirement-agent — review requirement documents with parallel agents
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
requirement-agent review <path> [options]
|
|
15
|
+
requirement-agent runs [--limit n]
|
|
16
|
+
requirement-agent show <run-id> [--raw <role>]
|
|
17
|
+
requirement-agent export <run-id> [--format md|json]
|
|
18
|
+
requirement-agent recover <run-id>
|
|
19
|
+
requirement-agent agents
|
|
20
|
+
requirement-agent roles
|
|
21
|
+
requirement-agent serve [--port n] [--allow-root dir]
|
|
22
|
+
|
|
23
|
+
Review options:
|
|
24
|
+
--roles <a,b> roles (default: ${DEFAULTS.roles.join(',')})
|
|
25
|
+
--agent <id> force one agent (${AGENT_IDS.join(',')})
|
|
26
|
+
--model <name> force one model
|
|
27
|
+
--concurrency <n> parallel reviewers (default 3)
|
|
28
|
+
--timeout <s> per-reviewer budget, including retry (default 240)
|
|
29
|
+
--run-timeout <s> whole-run deadline (default 1200)
|
|
30
|
+
--max-doc-chars <n> per-document cap (default 60000)
|
|
31
|
+
--max-total-chars <n> total context cap (default 80000)
|
|
32
|
+
--exclude <glob> repeatable
|
|
33
|
+
--out <dir> artifact root
|
|
34
|
+
--store <dir> artifact root for runs/show/export/serve
|
|
35
|
+
--yes acknowledge egress without prompting
|
|
36
|
+
--dry-run inspect documents and agents, call none
|
|
37
|
+
--json print RunSummary JSON
|
|
38
|
+
--quiet print run id and report path only
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function parse(argv) {
|
|
42
|
+
const out = { _: [], exclude: [] };
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
if (!a.startsWith('--')) { out._.push(a); continue; }
|
|
46
|
+
const key = a.slice(2);
|
|
47
|
+
if (key === 'yes' || key === 'dry-run' || key === 'json' || key === 'quiet') { out[key] = true; continue; }
|
|
48
|
+
const val = argv[i + 1];
|
|
49
|
+
if (val == null || val.startsWith('--')) throw new Error(`--${key} needs a value`);
|
|
50
|
+
i += 1;
|
|
51
|
+
if (key === 'exclude') out.exclude.push(val); else out[key] = val;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const num = (v, d) => (v == null ? d : Number(v));
|
|
57
|
+
|
|
58
|
+
export async function main(argv) {
|
|
59
|
+
const cmd = argv[0];
|
|
60
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP); return 0; }
|
|
61
|
+
let args;
|
|
62
|
+
try { args = parse(argv.slice(1)); } catch (err) { process.stderr.write(`${err.message}\n`); return 1; }
|
|
63
|
+
const store = args.store ?? args.out ?? defaultStore();
|
|
64
|
+
try {
|
|
65
|
+
switch (cmd) {
|
|
66
|
+
case 'review': return await cmdReview(args, store);
|
|
67
|
+
case 'runs': return cmdRuns(args, store);
|
|
68
|
+
case 'show': return cmdShow(args, store);
|
|
69
|
+
case 'export': return cmdExport(args, store);
|
|
70
|
+
case 'recover': return cmdRecover(args, store);
|
|
71
|
+
case 'agents': return cmdAgents();
|
|
72
|
+
case 'roles': return cmdRoles();
|
|
73
|
+
case 'serve': return await serve({ port: num(args.port, 0), allowRoots: args['allow-root'] ? [args['allow-root']] : undefined, store });
|
|
74
|
+
default: process.stderr.write(`unknown command: ${cmd}\n\n${HELP}`); return 1;
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err instanceof PreflightError) {
|
|
78
|
+
process.stderr.write(`${err.code}: ${err.message}\n`);
|
|
79
|
+
if (err.code === 'egress_unconfirmed') process.stderr.write(`agents: ${err.details.agents.join(', ')}\n`);
|
|
80
|
+
return ['output_refused'].includes(err.code) ? 4 : 3;
|
|
81
|
+
}
|
|
82
|
+
process.stderr.write(`${err.message}\n`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function cmdReview(args, store) {
|
|
88
|
+
const input = args._[0];
|
|
89
|
+
if (!input) { process.stderr.write('review needs a path\n'); return 1; }
|
|
90
|
+
const autoAck = Boolean(args.yes || (process.stdin.isTTY && !args['dry-run'] && await askYes()));
|
|
91
|
+
const result = await review({
|
|
92
|
+
inputPath: input, store, out: args.out, roles: args.roles ? String(args.roles).split(',') : undefined,
|
|
93
|
+
agent: args.agent, model: args.model, concurrency: clampInt(args.concurrency, DEFAULTS.concurrency, 1, 6),
|
|
94
|
+
timeoutSeconds: num(args.timeout, DEFAULTS.timeoutSeconds), runTimeoutSeconds: num(args['run-timeout'], DEFAULTS.runTimeoutSeconds),
|
|
95
|
+
maxDocumentChars: num(args['max-doc-chars'], DEFAULTS.maxDocumentChars), maxTotalChars: num(args['max-total-chars'], DEFAULTS.maxTotalChars),
|
|
96
|
+
exclude: args.exclude, autoAck,
|
|
97
|
+
});
|
|
98
|
+
if (result.dryRun) {
|
|
99
|
+
process.stdout.write(`documents ${result.documentSet.documents.length} skipped ${result.documentSet.skipped.length} chars ${result.documentSet.totalChars} tokens ${result.documentSet.estimatedTokens}\n`);
|
|
100
|
+
for (const a of result.assignments) process.stdout.write(`${a.role}\t${a.agent}\n`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
if (args.json) process.stdout.write(`${JSON.stringify(result.run, null, 2)}\n`);
|
|
104
|
+
else if (args.quiet) process.stdout.write(`${result.run.runId} ${join(result.dir, 'report.md')}\n`);
|
|
105
|
+
else process.stdout.write(summarize(result.run));
|
|
106
|
+
return result.exitCode;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summarize(run) {
|
|
110
|
+
const c = run.counts ?? {};
|
|
111
|
+
const lines = [
|
|
112
|
+
`run ${run.runId} status ${run.status}`,
|
|
113
|
+
`located citations ${c.locatedCitations ?? 0} ambiguous ${c.ambiguousCitations ?? 0} unlocated ${c.unlocatedCitations ?? 0} anchor ${c.anchorCitations ?? 0}`,
|
|
114
|
+
`findings blocker ${run.counts?.findingsBySeverity?.blocker ?? 0} major ${run.counts?.findingsBySeverity?.major ?? 0} minor ${run.counts?.findingsBySeverity?.minor ?? 0}`,
|
|
115
|
+
];
|
|
116
|
+
for (const r of run.reviewers) lines.push(`${r.role}\t${r.agent}\t${r.status}${r.error ? ` (${r.error})` : ''}`);
|
|
117
|
+
if (run.coverageGaps?.length) lines.push(`coverage gaps: ${run.coverageGaps.join(', ')}`);
|
|
118
|
+
lines.push(`artifacts ${run.runId}`);
|
|
119
|
+
return `${lines.join('\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function askYes() {
|
|
123
|
+
process.stderr.write(`Agents receiving document text: ${probeAgents().filter((a) => a.state !== 'missing').map((a) => a.id).join(', ')}\n`);
|
|
124
|
+
process.stderr.write(`${notices().retention}\n${notices().injection}\n${notices().screening}\nProceed? [y/N] `);
|
|
125
|
+
const { createInterface } = await import('node:readline/promises');
|
|
126
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
127
|
+
const answer = await rl.question('');
|
|
128
|
+
rl.close();
|
|
129
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cmdRuns(args, store) {
|
|
133
|
+
for (const r of listRuns(store, clampInt(args.limit, 50, 1, 500))) {
|
|
134
|
+
process.stdout.write(`${r.runId}\t${r.status}\t${r.startedAt}\t${r.documentSet?.inputPath}\t${r.counts?.locatedCitations ?? 0}\n`);
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cmdShow(args, store) {
|
|
140
|
+
const id = args._[0];
|
|
141
|
+
const run = id && loadRun(store, id);
|
|
142
|
+
if (!run) { process.stderr.write('unknown run\n'); return 1; }
|
|
143
|
+
if (args.raw) {
|
|
144
|
+
const p = join(run.dir, 'raw', `${args.raw}.txt`);
|
|
145
|
+
if (!existsSync(p)) { process.stderr.write('no diagnostic for that role\n'); return 1; }
|
|
146
|
+
process.stdout.write(readFileSync(p, 'utf8'));
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
process.stdout.write(`${JSON.stringify({ ...run, dir: undefined }, null, 2)}\n`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cmdExport(args, store) {
|
|
154
|
+
const id = args._[0];
|
|
155
|
+
const run = id && loadRun(store, id);
|
|
156
|
+
if (!run) { process.stderr.write('unknown run\n'); return 1; }
|
|
157
|
+
const format = args.format ?? 'md';
|
|
158
|
+
if (format === 'json') {
|
|
159
|
+
const findings = JSON.parse(readFileSync(join(run.dir, 'findings.json'), 'utf8'));
|
|
160
|
+
process.stdout.write(`${JSON.stringify({ ...run, findings, dir: undefined }, null, 2)}\n`);
|
|
161
|
+
} else {
|
|
162
|
+
process.stdout.write(readFileSync(join(run.dir, 'report.md'), 'utf8'));
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cmdRecover(args, store) {
|
|
168
|
+
const id = args._[0];
|
|
169
|
+
const run = id && loadRun(store, id);
|
|
170
|
+
if (!run) { process.stderr.write('unknown run\n'); return 1; }
|
|
171
|
+
if (!['running', 'interrupted'].includes(run.status)) { process.stderr.write('not recoverable\n'); return 3; }
|
|
172
|
+
if (run.pid && run.pid !== process.pid) {
|
|
173
|
+
try { process.kill(run.pid, 0); process.stderr.write('live run; refusing to signal\n'); return 3; } catch { /* dead */ }
|
|
174
|
+
}
|
|
175
|
+
const changes = compareSnapshots(readSnapshot(run.dir), snapshot(run.documentSet.inputPath));
|
|
176
|
+
run.status = changes.length ? 'failed' : 'interrupted';
|
|
177
|
+
if (changes.length) { run.failureReason = 'tree_changed'; run.coverageGaps = changes.map((c) => `${c.kind}:${c.path}`); }
|
|
178
|
+
run.finishedAt = new Date().toISOString();
|
|
179
|
+
for (const r of run.reviewers) if (r.status === 'running') r.status = 'interrupted';
|
|
180
|
+
writeRun(run.dir, run);
|
|
181
|
+
return changes.length ? 5 : 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function cmdAgents() {
|
|
185
|
+
for (const a of probeAgents()) process.stdout.write(`${a.id}\t${a.state}\n`);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cmdRoles() {
|
|
190
|
+
for (const id of ROLE_IDS) process.stdout.write(`${id}\t${ROLES[id].title}\n`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFileSync, statSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { sha256 } from './util.js';
|
|
5
|
+
|
|
6
|
+
const EXTRACTORS = { '.md': 'text', '.markdown': 'text', '.txt': 'text', '.pdf': 'pdf', '.docx': 'docx' };
|
|
7
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const EXTRACT_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export class PreflightError extends Error {
|
|
11
|
+
constructor(code, message, details = {}) { super(message); this.code = code; this.details = details; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function globToRegExp(glob) {
|
|
15
|
+
let out = '';
|
|
16
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
17
|
+
const c = glob[i];
|
|
18
|
+
if (c === '*') {
|
|
19
|
+
if (glob[i + 1] === '*') { out += '.*'; i += 1; } else out += '[^/]*';
|
|
20
|
+
} else if (c === '?') out += '[^/]';
|
|
21
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
}
|
|
23
|
+
return new RegExp(`^${out}$`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function excluded(rel, matchers) { return matchers.some((m) => m.test(rel)); }
|
|
27
|
+
|
|
28
|
+
export function discover(inputPath, { exclude = [], maxFiles = 200, maxSourceBytes = 50 * 1024 * 1024 } = {}) {
|
|
29
|
+
const input = resolve(inputPath);
|
|
30
|
+
let st;
|
|
31
|
+
try { st = statSync(input); } catch { throw new PreflightError('missing_path', `input not found: ${inputPath}`); }
|
|
32
|
+
const matchers = exclude.map(globToRegExp);
|
|
33
|
+
const candidates = [];
|
|
34
|
+
if (st.isFile()) {
|
|
35
|
+
candidates.push(input);
|
|
36
|
+
} else if (st.isDirectory()) {
|
|
37
|
+
const walk = (dir) => {
|
|
38
|
+
for (const name of readdirSync(dir).sort()) {
|
|
39
|
+
const p = join(dir, name);
|
|
40
|
+
const ls = lstatSync(p);
|
|
41
|
+
if (ls.isSymbolicLink()) continue;
|
|
42
|
+
if (ls.isDirectory()) { if (!excluded(`${relative(input, p)}/`, matchers)) walk(p); continue; }
|
|
43
|
+
if (ls.isFile()) candidates.push(p);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(input);
|
|
47
|
+
} else {
|
|
48
|
+
throw new PreflightError('missing_path', `unsupported input type: ${inputPath}`);
|
|
49
|
+
}
|
|
50
|
+
const root = st.isFile() ? dirname(input) : input;
|
|
51
|
+
const supported = [];
|
|
52
|
+
const skipped = [];
|
|
53
|
+
for (const abs of candidates) {
|
|
54
|
+
const rel = relative(root, abs) || basename(abs);
|
|
55
|
+
if (excluded(rel, matchers)) continue;
|
|
56
|
+
const ext = extname(abs).toLowerCase();
|
|
57
|
+
if (!EXTRACTORS[ext]) { skipped.push({ path: rel, reason: 'unsupported_format', detail: ext || 'no extension' }); continue; }
|
|
58
|
+
supported.push({ abs, rel, ext });
|
|
59
|
+
}
|
|
60
|
+
if (supported.length > maxFiles) {
|
|
61
|
+
throw new PreflightError('too_many_candidates', `${supported.length} candidate files exceed the ${maxFiles} limit`, { count: supported.length, limit: maxFiles });
|
|
62
|
+
}
|
|
63
|
+
const byDigest = new Map();
|
|
64
|
+
const documents = [];
|
|
65
|
+
const duplicates = [];
|
|
66
|
+
let totalBytes = 0;
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const item of supported) {
|
|
69
|
+
if (seen.has(item.abs)) continue;
|
|
70
|
+
seen.add(item.abs);
|
|
71
|
+
let bytes;
|
|
72
|
+
try { bytes = statSync(item.abs).size; } catch { skipped.push({ path: item.rel, reason: 'unreadable' }); continue; }
|
|
73
|
+
if (bytes > MAX_FILE_BYTES) { skipped.push({ path: item.rel, reason: 'too_large', detail: `${bytes} bytes` }); continue; }
|
|
74
|
+
const raw = readFileSync(item.abs);
|
|
75
|
+
const digest = sha256(raw);
|
|
76
|
+
if (byDigest.has(digest)) { duplicates.push({ path: item.rel, duplicateOf: byDigest.get(digest) }); continue; }
|
|
77
|
+
byDigest.set(digest, item.rel);
|
|
78
|
+
let text;
|
|
79
|
+
try { text = extract(item.abs, item.ext); } catch (err) {
|
|
80
|
+
skipped.push({ path: item.rel, reason: err.code === 'ENOENT' ? 'missing_tool' : 'unreadable', detail: err.message });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!text.trim()) { skipped.push({ path: item.rel, reason: 'empty_text' }); continue; }
|
|
84
|
+
documents.push({ path: item.rel, absPath: item.abs, format: item.ext.slice(1), bytes, sha256: digest, text, chars: text.length });
|
|
85
|
+
totalBytes += bytes;
|
|
86
|
+
}
|
|
87
|
+
if (totalBytes > maxSourceBytes) {
|
|
88
|
+
throw new PreflightError('source_too_large', `sources exceed ${maxSourceBytes} bytes`, { totalBytes, limit: maxSourceBytes });
|
|
89
|
+
}
|
|
90
|
+
const estimatedTokens = documents.reduce((n, d) => n + Math.ceil(d.chars * 1.0), 0);
|
|
91
|
+
return { inputPath: input, root, inputKind: st.isFile() ? 'file' : 'directory', documents, duplicates, skipped, totalBytes, totalChars: documents.reduce((n, d) => n + d.chars, 0), estimatedTokens, truncated: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function extract(abs, ext) {
|
|
95
|
+
if (EXTRACTORS[ext] === 'text') {
|
|
96
|
+
const buf = readFileSync(abs);
|
|
97
|
+
try { return new TextDecoder('utf-8', { fatal: true }).decode(buf).replace(/^\uFEFF/, ''); }
|
|
98
|
+
catch { const e = new Error('not valid UTF-8'); e.code = 'EENCODING'; throw e; }
|
|
99
|
+
}
|
|
100
|
+
if (EXTRACTORS[ext] === 'pdf') {
|
|
101
|
+
return execFileSync('pdftotext', ['-layout', abs, '-'], { timeout: EXTRACT_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 }).toString('utf8');
|
|
102
|
+
}
|
|
103
|
+
if (EXTRACTORS[ext] === 'docx') {
|
|
104
|
+
const xml = execFileSync('unzip', ['-p', abs, 'word/document.xml'], { timeout: EXTRACT_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 }).toString('utf8');
|
|
105
|
+
return xml
|
|
106
|
+
.replace(/<w:tab[^>]*\/>/g, '\t')
|
|
107
|
+
.replace(/<\/w:p>/g, '\n')
|
|
108
|
+
.replace(/<[^>]+>/g, '')
|
|
109
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
|
110
|
+
.replace(/\n{3,}/g, '\n\n').trim();
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`unsupported: ${ext}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function truncateDocuments(documents, maxDocumentChars = 60_000, maxTotalChars = 80_000) {
|
|
116
|
+
let total = 0;
|
|
117
|
+
let anyTruncated = false;
|
|
118
|
+
const docs = [];
|
|
119
|
+
for (const d of documents) {
|
|
120
|
+
let text = d.text;
|
|
121
|
+
let truncated = false;
|
|
122
|
+
if (text.length > maxDocumentChars) {
|
|
123
|
+
let cut = text.lastIndexOf('\n', maxDocumentChars);
|
|
124
|
+
if (cut < maxDocumentChars * 0.5) cut = maxDocumentChars;
|
|
125
|
+
const omitted = text.length - cut;
|
|
126
|
+
text = `${text.slice(0, cut)}\n␃ truncated at ${cut} characters; ${omitted} characters and the remainder were not reviewed`;
|
|
127
|
+
truncated = true;
|
|
128
|
+
}
|
|
129
|
+
if (total + text.length > maxTotalChars) {
|
|
130
|
+
throw new PreflightError('over_budget', `context exceeds ${maxTotalChars} characters`, { needed: total + text.length, limit: maxTotalChars, document: d.path });
|
|
131
|
+
}
|
|
132
|
+
total += text.length;
|
|
133
|
+
if (truncated) anyTruncated = true;
|
|
134
|
+
docs.push({ ...d, text, chars: text.length, truncated });
|
|
135
|
+
}
|
|
136
|
+
return { documents: docs, totalChars: total, truncated: anyTruncated };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function snapshot(target) {
|
|
140
|
+
const st = statSync(target);
|
|
141
|
+
const files = st.isFile() ? [target] : [];
|
|
142
|
+
if (st.isDirectory()) {
|
|
143
|
+
const walk = (dir) => {
|
|
144
|
+
for (const name of readdirSync(dir).sort()) {
|
|
145
|
+
const p = join(dir, name);
|
|
146
|
+
const ls = lstatSync(p);
|
|
147
|
+
if (ls.isSymbolicLink()) continue;
|
|
148
|
+
if (ls.isDirectory()) walk(p); else if (ls.isFile()) files.push(p);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
walk(target);
|
|
152
|
+
}
|
|
153
|
+
return files.map((abs) => {
|
|
154
|
+
const s = statSync(abs);
|
|
155
|
+
return { path: relative(st.isFile() ? dirname(target) : target, abs), type: 'file', mode: s.mode & 0o777, size: s.size, mtimeMs: s.mtimeMs, sha256: sha256(readFileSync(abs)) };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function compareSnapshots(before, after) {
|
|
160
|
+
const map = new Map(before.map((e) => [e.path, e]));
|
|
161
|
+
const changed = [];
|
|
162
|
+
for (const e of after) {
|
|
163
|
+
const b = map.get(e.path);
|
|
164
|
+
if (!b) changed.push({ path: e.path, kind: 'added' });
|
|
165
|
+
else if (b.sha256 !== e.sha256) changed.push({ path: e.path, kind: 'changed' });
|
|
166
|
+
else if (b.mode !== e.mode) changed.push({ path: e.path, kind: 'mode' });
|
|
167
|
+
map.delete(e.path);
|
|
168
|
+
}
|
|
169
|
+
for (const p of map.keys()) changed.push({ path: p, kind: 'deleted' });
|
|
170
|
+
return changed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export { realpathSync };
|
package/src/findings.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { findingIdentity, normalize } from './citations.js';
|
|
2
|
+
|
|
3
|
+
export const ROLE_ORDER = ['ambiguity', 'completeness', 'testability', 'consistency', 'scope', 'feasibility'];
|
|
4
|
+
export const SEVERITY_RANK = { blocker: 0, major: 1, minor: 2 };
|
|
5
|
+
|
|
6
|
+
export class InvalidFinding extends Error {}
|
|
7
|
+
|
|
8
|
+
export function validateFinding(raw) {
|
|
9
|
+
if (!raw || typeof raw !== 'object') throw new InvalidFinding('finding is not an object');
|
|
10
|
+
if (!(raw.severity in SEVERITY_RANK)) throw new InvalidFinding(`bad severity: ${raw.severity}`);
|
|
11
|
+
if (raw.evidenceKind && !['quoted', 'absent'].includes(raw.evidenceKind)) throw new InvalidFinding(`bad evidenceKind: ${raw.evidenceKind}`);
|
|
12
|
+
if (!Array.isArray(raw.citations) || raw.citations.length === 0) throw new InvalidFinding('finding needs a citation');
|
|
13
|
+
for (const c of raw.citations) {
|
|
14
|
+
if (!c || typeof c.document !== 'string' || typeof c.quote !== 'string') throw new InvalidFinding('citation needs document and quote');
|
|
15
|
+
if (normalize(c.quote).replace(/\s/g, '').length < 8) throw new InvalidFinding('quote too short');
|
|
16
|
+
}
|
|
17
|
+
if (typeof raw.explanation !== 'string') throw new InvalidFinding('explanation required');
|
|
18
|
+
return {
|
|
19
|
+
severity: raw.severity,
|
|
20
|
+
evidenceKind: raw.evidenceKind ?? 'quoted',
|
|
21
|
+
citations: raw.citations.map((c) => ({ document: c.document, quote: c.quote, section: c.section ?? null, lineHint: c.lineHint ?? null })),
|
|
22
|
+
explanation: raw.explanation,
|
|
23
|
+
suggestion: typeof raw.suggestion === 'string' ? raw.suggestion : '',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HANGUL = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/g;
|
|
28
|
+
const LATIN = /[A-Za-z]/g;
|
|
29
|
+
|
|
30
|
+
export function languageClass(text) {
|
|
31
|
+
const s = String(text ?? '');
|
|
32
|
+
const letters = (s.match(/[A-Za-z\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]/g) ?? []).length;
|
|
33
|
+
if (!letters) return 'mixed';
|
|
34
|
+
const hangul = (s.match(HANGUL) ?? []).length;
|
|
35
|
+
const latin = (s.match(LATIN) ?? []).length;
|
|
36
|
+
if (hangul / letters >= 0.5) return 'ko';
|
|
37
|
+
if (hangul / letters < 0.1 && latin >= 20) return 'latin';
|
|
38
|
+
return 'mixed';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function referenceLanguage(finding, docsByPath) {
|
|
42
|
+
const counts = { ko: 0, latin: 0, mixed: 0 };
|
|
43
|
+
for (const c of finding.citations) {
|
|
44
|
+
const doc = docsByPath.get(c.document);
|
|
45
|
+
if (doc) counts[languageClass(doc.text.slice(0, 2000))] += 1;
|
|
46
|
+
}
|
|
47
|
+
const best = Math.max(counts.ko, counts.latin, counts.mixed);
|
|
48
|
+
if (counts.ko === best) return 'ko';
|
|
49
|
+
if (counts.latin === best) return 'latin';
|
|
50
|
+
return 'mixed';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mergeFindings(contributions, docsByPath) {
|
|
54
|
+
const groups = new Map();
|
|
55
|
+
for (const item of contributions) {
|
|
56
|
+
const base = {
|
|
57
|
+
severity: item.severity,
|
|
58
|
+
evidenceKind: item.evidenceKind ?? 'quoted',
|
|
59
|
+
citations: item.citations,
|
|
60
|
+
locatorState: item.locatorState ?? 'unlocated',
|
|
61
|
+
};
|
|
62
|
+
const identity = findingIdentity(base);
|
|
63
|
+
if (!groups.has(identity)) groups.set(identity, { identity, ...base, contributions: [] });
|
|
64
|
+
const g = groups.get(identity);
|
|
65
|
+
if (SEVERITY_RANK[item.severity] < SEVERITY_RANK[g.severity]) g.severity = item.severity;
|
|
66
|
+
const ref = referenceLanguage(item, docsByPath);
|
|
67
|
+
const lang = languageClass(item.explanation);
|
|
68
|
+
g.contributions.push({
|
|
69
|
+
role: item.role,
|
|
70
|
+
severity: item.severity,
|
|
71
|
+
explanation: item.explanation,
|
|
72
|
+
suggestion: item.suggestion ?? '',
|
|
73
|
+
languageMismatch: ref !== 'mixed' && lang !== 'mixed' && ref !== lang && item.explanation.replace(/\s/g, '').length >= 10,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const list = [...groups.values()];
|
|
77
|
+
const firstRole = (f) => Math.min(...f.contributions.map((c) => ROLE_ORDER.indexOf(c.role)).filter((n) => n >= 0));
|
|
78
|
+
const firstCitation = (f) => [...f.citations].sort((a, b) => a.document.localeCompare(b.document) || (a.lineStart ?? Infinity) - (b.lineStart ?? Infinity))[0];
|
|
79
|
+
list.sort((a, b) => {
|
|
80
|
+
const s = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
81
|
+
if (s) return s;
|
|
82
|
+
const r = firstRole(a) - firstRole(b);
|
|
83
|
+
if (r) return r;
|
|
84
|
+
const ca = firstCitation(a); const cb = firstCitation(b);
|
|
85
|
+
const d = String(ca?.document ?? '').localeCompare(String(cb?.document ?? ''));
|
|
86
|
+
if (d) return d;
|
|
87
|
+
const l = (ca?.lineStart ?? Number.MAX_SAFE_INTEGER) - (cb?.lineStart ?? Number.MAX_SAFE_INTEGER);
|
|
88
|
+
if (l) return l;
|
|
89
|
+
return a.identity.localeCompare(b.identity);
|
|
90
|
+
});
|
|
91
|
+
list.forEach((f, i) => {
|
|
92
|
+
f.id = `F-${String(i + 1).padStart(3, '0')}`;
|
|
93
|
+
f.contributions.sort((a, b) => (ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role))
|
|
94
|
+
|| SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
95
|
+
|| a.explanation.localeCompare(b.explanation));
|
|
96
|
+
f.roles = ROLE_ORDER.filter((r) => f.contributions.some((c) => c.role === r));
|
|
97
|
+
delete f.identity;
|
|
98
|
+
});
|
|
99
|
+
return list;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function countsFor(findings, reviewers) {
|
|
103
|
+
const locatedCitations = findings.reduce((n, f) => n + f.citations.filter((c) => c.status === 'located').length, 0);
|
|
104
|
+
const anchorCitations = findings.reduce((n, f) => n + f.citations.filter((c) => c.status === 'anchor_located').length, 0);
|
|
105
|
+
const ambiguousCitations = findings.reduce((n, f) => n + f.citations.filter((c) => c.status === 'ambiguous').length, 0);
|
|
106
|
+
const unlocatedCitations = findings.reduce((n, f) => n + f.citations.filter((c) => c.status === 'not_located').length, 0);
|
|
107
|
+
const bySeverity = { blocker: 0, major: 0, minor: 0 };
|
|
108
|
+
for (const f of findings) bySeverity[f.severity] += 1;
|
|
109
|
+
const byLocator = { located: 0, anchor_only: 0, ambiguous: 0, partial: 0, unlocated: 0 };
|
|
110
|
+
for (const f of findings) byLocator[f.locatorState] = (byLocator[f.locatorState] ?? 0) + 1;
|
|
111
|
+
return { locatedCitations, anchorCitations, ambiguousCitations, unlocatedCitations, findingsBySeverity: { ...bySeverity, total: findings.length }, findingsByLocatorState: byLocator, reviewers: reviewers.length };
|
|
112
|
+
}
|