devmethod-ai 0.2.0 → 0.3.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/.agents/skills/decision-architecture/SKILL.md +10 -0
- package/.agents/skills/decision-architecture/assets/ADR.md +6 -3
- package/.agents/skills/project-foundation/SKILL.md +2 -0
- package/.agents/skills/project-foundation/assets/CADRAGE.md +11 -0
- package/.agents/skills/project-foundation/assets/EXISTANT.md +14 -0
- package/.agents/skills/project-foundation/assets/OPPORTUNITES.md +10 -0
- package/.agents/skills/project-foundation/assets/PROJECT_PROFILE.md +3 -2
- package/.agents/skills/project-foundation/assets/REGLES.md +6 -0
- package/.agents/skills/project-foundation/assets/START_HERE.md +2 -0
- package/.agents/skills/project-foundation/references/delivery-planning.md +11 -0
- package/.agents/skills/project-foundation/references/exploration.md +11 -0
- package/.agents/skills/project-foundation/references/mission-context.md +31 -4
- package/.agents/skills/project-foundation/references/operating-commands.md +12 -4
- package/.agents/skills/project-foundation/references/work-sizing.md +1 -1
- package/.agents/skills/scoped-delivery/SKILL.md +5 -1
- package/.agents/skills/scoped-delivery/assets/MISSION.md +1 -1
- package/.agents/skills/scoped-delivery/assets/PLAN.md +20 -0
- package/.agents/skills/scoped-delivery/assets/REPRISE.md +10 -0
- package/.agents/skills/scoped-delivery/assets/REVIEW.md +34 -0
- package/.agents/skills/scoped-delivery/assets/SLICE.md +5 -0
- package/.agents/skills/scoped-delivery/assets/TICKET.md +21 -0
- package/.agents/skills/scoped-delivery/references/review-workflow.md +29 -0
- package/COMPATIBILITY.md +1 -1
- package/README.md +35 -14
- package/START_HERE.md +2 -0
- package/dist/cli.js +122 -90
- package/dist/review-app.js +462 -0
- package/dist/review-browser.js +567 -0
- package/dist/review-cli.js +68 -0
- package/dist/review-model.js +101 -0
- package/dist/review-open.js +19 -0
- package/dist/review-ui.css +782 -0
- package/dist/review.js +13 -0
- package/docs/ADR-007-conversation-and-mission-ownership.md +13 -0
- package/docs/ADR-008-review-presentation.md +13 -0
- package/docs/MISSIONS.md +1 -1
- package/docs/RELEASE-0.2.0.md +14 -3
- package/docs/RELEASE-0.3.0.md +24 -0
- package/docs/RELEASE-0.3.1.md +15 -0
- package/docs/REVIEW-GUIDE.md +68 -0
- package/docs/REVIEW-SOURCES.md +13 -0
- package/docs/REVIEW-VALIDATION.md +34 -0
- package/docs/REVIEWS.md +75 -0
- package/docs/VISUAL-WORKFLOW.md +1 -1
- package/docs/WORKFLOW-0.3-VALIDATION.md +32 -0
- package/docs/WORKFLOW-0.3.md +34 -0
- package/docs/images/devmethod-delivery.svg +1 -1
- package/docs/images/devmethod-flow.svg +1 -1
- package/docs/images/review-correction.jpg +0 -0
- package/docs/images/review-coverage.jpg +0 -0
- package/docs/images/review-interface-desktop.jpg +0 -0
- package/docs/images/review-interface-mobile.jpg +0 -0
- package/docs/media/review-extension/README.md +21 -0
- package/docs/media/review-extension/scenes.json +70 -0
- package/docs/media/visual-chain/README.md +8 -2
- package/docs/media/visual-chain/devmethod-du-besoin-au-produit.fr.srt +65 -1
- package/docs/media/visual-chain/video-preview.jpg +0 -0
- package/docs/missions/review-media-0.3.1.md +11 -0
- package/docs/missions/workflow-0.3-reviews/interface/REVIEW.md +109 -0
- package/docs/missions/workflow-0.3-reviews/interface/review.json +255 -0
- package/docs/missions/workflow-0.3.md +19 -0
- package/examples/mission-dialogue/PROJECT_PROFILE.md +9 -0
- package/examples/mission-dialogue/architecture/decisions/001-storage.md +7 -0
- package/examples/mission-dialogue/docs/missions/first-save/PLAN.md +13 -0
- package/examples/mission-dialogue/docs/missions/first-save/REPRISE.md +6 -0
- package/examples/mission-dialogue/docs/missions/first-save/tickets/SAVE-1.md +14 -0
- package/examples/mission-dialogue/docs/missions/legacy-copy.md +9 -0
- package/examples/mission-dialogue/docs/produit/REGLES.md +5 -0
- package/examples/review/README.md +14 -0
- package/examples/review/REVIEW.md +98 -0
- package/examples/review/review-demo.html +1351 -0
- package/examples/review/review.json +199 -0
- package/package.json +2 -2
- package/scripts/build-review.mjs +8 -0
- package/scripts/media/review-extension/extend.py +100 -0
- package/scripts/package-smoke.mjs +20 -5
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { checkPath, parseJson, stat } from './filesystem.js';
|
|
5
|
+
import { safePath, secretPath, readLocal } from './records.js';
|
|
6
|
+
import { sanitizedReview, summarizeReview, reviewMarkdown, reviewFreshness, redactReviewText } from './review-model.js';
|
|
7
|
+
import { renderReviewHTML } from './review.js';
|
|
8
|
+
/** Read only explicitly selected sources. No repository commands, file traversal or server. */
|
|
9
|
+
export function prepareReview(options) {
|
|
10
|
+
const root = path.resolve(options.destination);
|
|
11
|
+
checkPath(root);
|
|
12
|
+
for (const value of [options.review, options.legacy, options.output, options.markdown, options.currentRevision])
|
|
13
|
+
if (value !== undefined && !value.trim())
|
|
14
|
+
throw new Error('Review options must not be empty.');
|
|
15
|
+
if (options.currentRevision && options.currentRevision.length > 8192)
|
|
16
|
+
throw new Error('Revision label exceeds the allowed length.');
|
|
17
|
+
if (options.changedTargets && (options.changedTargets.length > 256 || options.changedTargets.some(t => !t.trim() || t.length > 8192)))
|
|
18
|
+
throw new Error('Changed targets exceed the allowed bounds.');
|
|
19
|
+
if ([options.review, options.legacy, options.demo].filter(Boolean).length > 1)
|
|
20
|
+
throw new Error('Choose one of --review, --legacy or --demo.');
|
|
21
|
+
if (options.markdown && !options.review && !options.legacy && !options.demo)
|
|
22
|
+
throw new Error('Markdown export requires a selected review.');
|
|
23
|
+
const read = (file) => { if (secretPath(file))
|
|
24
|
+
throw new Error('Secret-like review paths are excluded.'); return readLocal(root, file, 4 * 1024 * 1024).toString('utf8'); };
|
|
25
|
+
const source = options.demo ? fs.readFileSync(fileURLToPath(new URL('../examples/review/review.json', import.meta.url)), 'utf8') : options.review ? read(options.review) : null;
|
|
26
|
+
const review = source === null ? null : sanitizedReview(parseJson(source));
|
|
27
|
+
const legacy = options.legacy ? redactReviewText(read(options.legacy)) : null;
|
|
28
|
+
const outputs = [];
|
|
29
|
+
if (options.output)
|
|
30
|
+
outputs.push([options.output, renderReviewHTML({ review, legacy, currentRevision: options.currentRevision, changedTargets: options.changedTargets })]);
|
|
31
|
+
if (options.markdown)
|
|
32
|
+
outputs.push([options.markdown, review ? reviewMarkdown(review) : legacy]);
|
|
33
|
+
for (const [file] of outputs) {
|
|
34
|
+
if (!safePath(file) || secretPath(file) || (file === options.output ? !file.endsWith('.html') : !file.endsWith('.md')))
|
|
35
|
+
throw new Error('Review outputs require safe relative .html / .md paths.');
|
|
36
|
+
checkPath(path.resolve(root, file));
|
|
37
|
+
if (stat(path.resolve(root, file)))
|
|
38
|
+
throw new Error('Review output already exists; choose a fresh path. No files written.');
|
|
39
|
+
}
|
|
40
|
+
if (new Set(outputs.map(([f]) => f)).size !== outputs.length)
|
|
41
|
+
throw new Error('Review output paths must be distinct.');
|
|
42
|
+
const created = [], directories = [];
|
|
43
|
+
const mkdir = (dir) => { if (stat(dir))
|
|
44
|
+
return; mkdir(path.dirname(dir)); fs.mkdirSync(dir); directories.push(dir); };
|
|
45
|
+
try {
|
|
46
|
+
for (const [file, content] of outputs) {
|
|
47
|
+
const target = path.resolve(root, file);
|
|
48
|
+
mkdir(path.dirname(target));
|
|
49
|
+
checkPath(target);
|
|
50
|
+
const fd = fs.openSync(target, 'wx');
|
|
51
|
+
created.push(target);
|
|
52
|
+
try {
|
|
53
|
+
fs.writeFileSync(fd, content);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
fs.closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
for (const f of created.reverse())
|
|
62
|
+
fs.unlinkSync(f);
|
|
63
|
+
for (const d of directories.reverse())
|
|
64
|
+
fs.rmdirSync(d);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return { format: 1, reviewId: review?.id ?? null, status: review ? summarizeReview(review).conclusion : legacy !== null ? 'legacy' : 'empty', summary: review ? summarizeReview(review) : null, freshness: review ? reviewFreshness(review, options.currentRevision ?? null, options.changedTargets ?? []) : null, outputs: outputs.map(([file]) => path.resolve(root, file)), limitations: 'Explicit record only; no review execution or filesystem evidence discovery. Legacy Markdown has no inferred fields. Inspect privacy before sharing; automatic redaction is heuristic.' };
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export const severityLabels = { critical: 'Critique', major: 'Majeur', moderate: 'Modéré', minor: 'Mineur' };
|
|
2
|
+
export const confidenceLabels = { confirmed: 'Confirmé', suspected: 'À vérifier' };
|
|
3
|
+
export const resolutionLabels = { open: 'Ouvert', 'in-progress': 'En correction', resolved: 'Résolu et vérifié', 'accepted-risk': 'Risque accepté' };
|
|
4
|
+
export const checkLabels = { passed: 'Réussi', failed: 'En échec', 'not-run': 'Non exécuté', blocked: 'Bloqué', 'out-of-scope': 'Hors périmètre' };
|
|
5
|
+
const rec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
6
|
+
const str = (v) => typeof v === 'string' && v.trim().length > 0 && v.length <= 16384;
|
|
7
|
+
const identifier = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(v);
|
|
8
|
+
const arr = (v, test, max = 256) => Array.isArray(v) && v.length <= max && v.every(test);
|
|
9
|
+
const nullable = (v, test) => v === null || test(v);
|
|
10
|
+
const has = (v, choices) => typeof v === 'string' && choices.includes(v);
|
|
11
|
+
const shape = (v, keys) => rec(v) && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
|
|
12
|
+
const date = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)?$/.test(v) && Number.isFinite(Date.parse(v));
|
|
13
|
+
export function reviewUrl(value) {
|
|
14
|
+
if (typeof value !== 'string' || value.length > 2048 || /[\s\x00-\x1f\x7f\\<>`"\[\]]/.test(value))
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const u = new URL(value);
|
|
18
|
+
return u.protocol === 'https:' && Boolean(u.hostname) && !u.username && !u.password;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function assertReview(ok, message) { if (!ok)
|
|
25
|
+
throw new Error(message); }
|
|
26
|
+
/** Errors identify schema sections, never echo untrusted input. */
|
|
27
|
+
export function validateReview(input) {
|
|
28
|
+
assertReview(rec(input) && input.format === 1, 'Unsupported review format; expected format 1.');
|
|
29
|
+
assertReview(shape(input, ['format', 'id', 'title', 'project', 'mission', 'tickets', 'date', 'scope', 'exclusions', 'revision', 'technologies', 'sources', 'checks', 'findings', 'evidence', 'limits', 'policy', 'summary']), 'Invalid review fields.');
|
|
30
|
+
assertReview(identifier(input.id) && ['title', 'project', 'mission', 'summary'].every(k => str(input[k])) && date(input.date), 'Invalid review identity or date.');
|
|
31
|
+
assertReview(arr(input.scope, str) && input.scope.length > 0 && arr(input.exclusions, str) && arr(input.limits, str), 'Invalid review scope or limits.');
|
|
32
|
+
assertReview(shape(input.revision, ['commit', 'dirty']) && str(input.revision.commit) && arr(input.revision.dirty, str), 'Invalid inspected revision.');
|
|
33
|
+
assertReview(arr(input.tickets, v => shape(v, ['id', 'title', 'url']) && identifier(v.id) && str(v.title) && nullable(v.url, reviewUrl)), 'Invalid review ticket.');
|
|
34
|
+
assertReview(arr(input.technologies, v => shape(v, ['name', 'version', 'detectedFrom']) && Object.values(v).every(str)), 'Invalid detected technologies.');
|
|
35
|
+
assertReview(arr(input.sources, v => shape(v, ['id', 'title', 'kind', 'publisher', 'technology', 'version', 'url', 'consultedAt', 'access', 'usage', 'compatibility', 'provenance']) && identifier(v.id) && ['title', 'publisher', 'technology', 'version', 'usage', 'compatibility', 'provenance'].every(k => str(v[k])) && has(v.kind, ['documentation', 'skill', 'project']) && nullable(v.url, reviewUrl) && nullable(v.consultedAt, date) && has(v.access, ['consulted', 'unavailable', 'unverified']) && (v.access !== 'consulted' || v.consultedAt !== null)), 'Invalid review source or consultation provenance.');
|
|
36
|
+
assertReview(arr(input.evidence, v => shape(v, ['id', 'title', 'kind', 'content', 'url', 'image']) && identifier(v.id) && str(v.title) && has(v.kind, ['text', 'log', 'screenshot', 'diagram']) && str(v.content) && nullable(v.url, reviewUrl) && nullable(v.image, i => shape(i, ['mime', 'base64', 'alt', 'origin', 'privacyReviewed']) && has(i.mime, ['image/png', 'image/jpeg']) && typeof i.base64 === 'string' && i.base64.length <= 1400000 && /^[A-Za-z0-9+/]+={0,2}$/.test(i.base64) && (i.mime === 'image/png' ? i.base64.startsWith('iVBORw0KGgo') : i.base64.startsWith('/9j/')) && str(i.alt) && has(i.origin, ['captured', 'explanatory']) && i.privacyReviewed === true)), 'Invalid evidence; images require bounded PNG/JPEG data, text alternative and privacy review.');
|
|
37
|
+
assertReview(arr(input.checks, v => shape(v, ['id', 'title', 'domain', 'kind', 'status', 'result', 'reason', 'evidenceIds', 'revision', 'targets']) && identifier(v.id) && ['title', 'domain', 'result', 'revision'].every(k => str(v[k])) && has(v.kind, ['automated', 'manual']) && has(v.status, Object.keys(checkLabels)) && nullable(v.reason, str) && (!['not-run', 'blocked', 'out-of-scope'].includes(v.status) || str(v.reason)) && arr(v.evidenceIds, identifier) && arr(v.targets, str)), 'Invalid review check; unexecuted checks need a reason.');
|
|
38
|
+
assertReview(arr(input.findings, v => shape(v, ['id', 'title', 'domain', 'severity', 'severityReason', 'confidence', 'resolution', 'location', 'trigger', 'expected', 'observed', 'impact', 'reproduction', 'evidenceIds', 'correction', 'tradeoffs', 'sourceIds', 'ticketIds', 'verification', 'resolutionEvidenceIds', 'targets']) && identifier(v.id) && ['title', 'domain', 'severityReason', 'trigger', 'expected', 'observed', 'impact', 'correction', 'tradeoffs', 'verification'].every(k => str(v[k])) && has(v.severity, Object.keys(severityLabels)) && has(v.confidence, Object.keys(confidenceLabels)) && has(v.resolution, Object.keys(resolutionLabels)) && shape(v.location, ['path', 'line', 'component']) && str(v.location.path) && nullable(v.location.line, n => Number.isSafeInteger(n) && n > 0) && nullable(v.location.component, str) && ['reproduction', 'targets'].every(k => arr(v[k], str)) && ['evidenceIds', 'sourceIds', 'ticketIds', 'resolutionEvidenceIds'].every(k => arr(v[k], identifier)) && (v.reproduction.length > 0 || v.evidenceIds.length > 0) && (v.resolution !== 'resolved' || v.resolutionEvidenceIds.length > 0)), 'Invalid finding; reproduction/evidence and verified resolution references are required.');
|
|
39
|
+
assertReview(shape(input.policy, ['blockingSeverities', 'requireAllChecks', 'rationale']) && arr(input.policy.blockingSeverities, v => has(v, Object.keys(severityLabels)), 4) && typeof input.policy.requireAllChecks === 'boolean' && str(input.policy.rationale), 'Invalid review conclusion policy.');
|
|
40
|
+
const r = input;
|
|
41
|
+
for (const items of [r.tickets, r.sources, r.evidence, r.checks, r.findings])
|
|
42
|
+
assertReview(new Set(items.map(i => i.id)).size === items.length, 'Duplicate review identifiers.');
|
|
43
|
+
const ids = (items) => new Set(items.map(i => i.id));
|
|
44
|
+
const evidence = ids(r.evidence), sources = ids(r.sources), tickets = ids(r.tickets);
|
|
45
|
+
assertReview(r.checks.every(c => c.evidenceIds.every(id => evidence.has(id))) && r.findings.every(f => [...f.evidenceIds, ...f.resolutionEvidenceIds].every(id => evidence.has(id)) && f.sourceIds.every(id => sources.has(id)) && f.ticketIds.every(id => tickets.has(id))), 'Unresolved review references.');
|
|
46
|
+
assertReview(JSON.stringify(input).length <= 4 * 1024 * 1024, 'Review exceeds 4 MiB.');
|
|
47
|
+
return JSON.parse(JSON.stringify(r));
|
|
48
|
+
}
|
|
49
|
+
/** Conservative convenience redaction, not a privacy classifier or authorization. */
|
|
50
|
+
export function redactReviewText(s) {
|
|
51
|
+
return s.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]')
|
|
52
|
+
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,})\b/g, '[REDACTED TOKEN]')
|
|
53
|
+
.replace(/\b(?:[a-z][a-z0-9_-]*[_-])?(?:token|api[_-]?key|password|secret|authorization)\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|(?:Bearer|Basic)\s+[^\s,;]+|[^\s,;]+)/gi, '[REDACTED CREDENTIAL]')
|
|
54
|
+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[REDACTED EMAIL]');
|
|
55
|
+
}
|
|
56
|
+
export function sanitizedReview(input) {
|
|
57
|
+
const r = validateReview(input);
|
|
58
|
+
const walk = (v, key = '') => typeof v === 'string' ? key === 'base64' ? v : redactReviewText(v) : Array.isArray(v) ? v.map(x => walk(x)) : rec(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)])) : v;
|
|
59
|
+
// Redaction of a link can make it invalid. Remove it rather than navigating a changed URL.
|
|
60
|
+
const clean = walk(r);
|
|
61
|
+
for (const item of [...clean.sources, ...clean.tickets, ...clean.evidence])
|
|
62
|
+
if (item.url !== null && !reviewUrl(item.url))
|
|
63
|
+
item.url = null;
|
|
64
|
+
return validateReview(clean);
|
|
65
|
+
}
|
|
66
|
+
export function summarizeReview(r) {
|
|
67
|
+
const severities = { critical: 0, major: 0, moderate: 0, minor: 0 }, checks = { passed: 0, failed: 0, 'not-run': 0, blocked: 0, 'out-of-scope': 0 };
|
|
68
|
+
for (const f of r.findings)
|
|
69
|
+
severities[f.severity]++;
|
|
70
|
+
for (const c of r.checks)
|
|
71
|
+
checks[c.status]++;
|
|
72
|
+
const suspected = r.findings.filter(f => f.confidence === 'suspected' && f.resolution !== 'resolved').length;
|
|
73
|
+
const blocking = r.findings.some(f => f.confidence === 'confirmed' && ['open', 'in-progress'].includes(f.resolution) && r.policy.blockingSeverities.includes(f.severity));
|
|
74
|
+
const conclusion = blocking || checks.failed > 0 ? 'corrections' : checks.blocked > 0 ? 'blocked' : checks.passed === 0 || suspected > 0 || (r.policy.requireAllChecks && checks['not-run'] > 0) ? 'incomplete' : 'ready';
|
|
75
|
+
return { severities, suspected, checks, conclusion };
|
|
76
|
+
}
|
|
77
|
+
export const conclusionLabels = { corrections: 'Corrections nécessaires', ready: 'Prêt sur le périmètre vérifié', incomplete: 'Review incomplète', blocked: 'Vérification bloquée' };
|
|
78
|
+
export function filterFindings(r, f) {
|
|
79
|
+
const q = f.query.trim().toLocaleLowerCase();
|
|
80
|
+
return r.findings.filter(i => (!q || [i.id, i.title, i.impact, i.location.path].join(' ').toLocaleLowerCase().includes(q)) && (f.domain === '' || i.domain === f.domain) && (f.severity === '' || i.severity === f.severity) && (f.confidence === '' || i.confidence === f.confidence) && (f.resolution === '' || i.resolution === f.resolution));
|
|
81
|
+
}
|
|
82
|
+
export function reviewFreshness(r, currentRevision, changedTargets) {
|
|
83
|
+
const different = currentRevision !== null && currentRevision !== r.revision.commit;
|
|
84
|
+
const affected = (targets) => targets.some(t => changedTargets.includes(t));
|
|
85
|
+
return { state: different || changedTargets.length ? 'different' : currentRevision === null ? 'unknown' : 'same', affectedChecks: r.checks.filter(c => affected(c.targets)).map(c => c.id), affectedFindings: r.findings.filter(f => affected(f.targets)).map(f => f.id) };
|
|
86
|
+
}
|
|
87
|
+
const md = (s) => s.replace(/[\\`*_{}\[\]<>|#]/g, c => `\\${c}`).replace(/\n/g, '\\' + '\n');
|
|
88
|
+
export function reviewMarkdown(r) {
|
|
89
|
+
const s = summarizeReview(r);
|
|
90
|
+
const lines = [`# ${md(r.title)}`, '', `${md(r.project)} · ${md(r.mission)} · ${md(r.date)}`, `Revision: ${md(r.revision.commit)}; uncommitted changes: ${r.revision.dirty.map(md).join(', ') || 'none recorded'}`, '', `Conclusion: **${conclusionLabels[s.conclusion]}**`, md(r.summary), `Policy: ${md(r.policy.rationale)}`, '', '## Scope', ...r.scope.map(v => `- ${md(v)}`), '', '## Exclusions and limits', ...[...r.exclusions, ...r.limits].map(v => `- ${md(v)}`), '', '## Counts (whole review)', ...Object.entries(s.severities).map(([severity, count]) => `- ${severityLabels[severity]}: ${count}`), `- À vérifier: ${s.suspected}`, ...Object.entries(s.checks).map(([status, count]) => `- ${checkLabels[status]}: ${count}`), '', '## Coverage', ...r.checks.map(c => `- **${md(c.id)} — ${md(c.title)}** (${md(c.domain)}, ${c.kind}): ${checkLabels[c.status]}. ${md(c.result)}${c.reason ? ` Reason: ${md(c.reason)}` : ''} Revision: ${md(c.revision)}. Evidence: ${c.evidenceIds.map(md).join(', ') || 'none'}`), '', '## Findings'];
|
|
91
|
+
for (const f of r.findings)
|
|
92
|
+
lines.push('', `### ${md(f.id)} — ${md(f.title)}`, `${severityLabels[f.severity]} / ${confidenceLabels[f.confidence]} / ${resolutionLabels[f.resolution]}`, `Severity rationale: ${md(f.severityReason)}`, `Location: ${md(f.location.path)}${f.location.line ? `:${f.location.line}` : ''}`, `Impact: ${md(f.impact)}`, `Trigger: ${md(f.trigger)}`, `Expected: ${md(f.expected)}`, `Observed: ${md(f.observed)}`, 'Reproduction:', ...f.reproduction.map(v => `- ${md(v)}`), `Evidence: ${f.evidenceIds.map(md).join(', ') || 'none'}`, `Correction: ${md(f.correction)}`, `Trade-offs: ${md(f.tradeoffs)}`, `Resolution verification: ${md(f.verification)}`, `Resolution evidence: ${f.resolutionEvidenceIds.map(md).join(', ') || 'none'}`, `Sources: ${f.sourceIds.map(md).join(', ') || 'none'}`, `Tickets: ${f.ticketIds.map(md).join(', ') || 'none'}`);
|
|
93
|
+
lines.push('', '## Evidence');
|
|
94
|
+
for (const e of r.evidence)
|
|
95
|
+
lines.push('', `### ${md(e.id)} — ${md(e.title)}`, `${e.kind}${e.image ? ` (${e.image.origin}; image is embedded in HTML/JSON, text alternative: ${md(e.image.alt)})` : ''}`, md(e.content), e.url ? `Full evidence: ${e.url}` : 'No external evidence destination.');
|
|
96
|
+
lines.push('', '## Sources');
|
|
97
|
+
for (const source of r.sources)
|
|
98
|
+
lines.push(`- ${md(source.id)}: ${md(source.title)} — ${md(source.publisher)}; ${md(source.technology)} ${md(source.version)}; ${source.access}; ${source.consultedAt || 'not consulted'}; ${source.url || 'no destination'}. ${md(source.usage)} Compatibility: ${md(source.compatibility)} Provenance: ${md(source.provenance)}`);
|
|
99
|
+
lines.push('', '## Technologies', ...r.technologies.map(t => `- ${md(t.name)} ${md(t.version)} (${md(t.detectedFrom)})`), '', '## Tickets', ...r.tickets.map(t => `- ${md(t.id)} — ${md(t.title)}: ${t.url || 'no published destination'}`), '', 'Generated from review format 1. Counts describe the whole review. Historical evidence does not certify a later revision.', '');
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
/** Fixed OS handler and argument boundaries: never a shell or a repository command. */
|
|
4
|
+
export function reviewOpenCommand(file, platform = process.platform) {
|
|
5
|
+
const url = pathToFileURL(file).href;
|
|
6
|
+
if (platform === 'darwin')
|
|
7
|
+
return ['/usr/bin/open', [url]];
|
|
8
|
+
if (platform === 'win32')
|
|
9
|
+
return ['rundll32.exe', ['url.dll,FileProtocolHandler', url]];
|
|
10
|
+
if (platform === 'linux')
|
|
11
|
+
return ['xdg-open', [url]];
|
|
12
|
+
throw new Error(`Automatic review opening is unavailable on ${platform}; open the generated HTML in your browser.`);
|
|
13
|
+
}
|
|
14
|
+
export function openReview(file) {
|
|
15
|
+
const [command, args] = reviewOpenCommand(file);
|
|
16
|
+
const result = spawnSync(command, args, { shell: false, stdio: 'ignore', timeout: 15000 });
|
|
17
|
+
if (result.error || result.status !== 0)
|
|
18
|
+
throw new Error(`The browser could not be opened. The generated report is preserved at ${file}; open it manually.`);
|
|
19
|
+
}
|