dw-kit 1.3.5 → 1.4.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/.claude/hooks/supply-chain-scan.sh +16 -14
- package/.claude/skills/dw-archive/SKILL.md +14 -0
- package/.claude/skills/dw-review/SKILL.md +33 -2
- package/.dw/config/config.schema.json +149 -121
- package/.dw/config/dw.config.yml +14 -0
- package/.dw/security/advisory-snapshot.json +157 -0
- package/.dw/security/ioc-namespaces.json +20 -8
- package/CLAUDE.md +1 -1
- package/README.md +15 -2
- package/package.json +2 -1
- package/src/cli.mjs +20 -2
- package/src/commands/doctor.mjs +41 -1
- package/src/commands/init.mjs +45 -1
- package/src/commands/review-render.mjs +255 -0
- package/src/commands/security-scan.mjs +367 -52
- package/src/lib/config.mjs +120 -104
- package/src/lib/gitignore.mjs +5 -1
- package/src/lib/npm-registry.mjs +159 -0
- package/src/lib/review/manifest-schema.json +149 -0
- package/src/lib/review/manifest-validator.mjs +93 -0
- package/src/lib/review/scope-slug.mjs +68 -0
- package/src/lib/sc-heuristic.mjs +263 -0
- package/src/lib/sc-scanner.mjs +60 -11
- package/src/lib/sc-sync.mjs +98 -8
- package/src/lib/telemetry.mjs +20 -0
package/src/lib/config.mjs
CHANGED
|
@@ -1,104 +1,120 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import yaml from 'js-yaml';
|
|
3
|
-
|
|
4
|
-
export function loadConfig(configPath) {
|
|
5
|
-
if (!existsSync(configPath)) return null;
|
|
6
|
-
const content = readFileSync(configPath, 'utf-8');
|
|
7
|
-
return yaml.load(content);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Load config với local override (v1.2+).
|
|
12
|
-
* dw.config.yml — shared, committed
|
|
13
|
-
* dw.config.local.yml — machine-specific, gitignored
|
|
14
|
-
* Local values win over base values (shallow merge per top-level key).
|
|
15
|
-
*/
|
|
16
|
-
export function loadConfigWithLocal(configDir) {
|
|
17
|
-
const basePath = `${configDir}/dw.config.yml`;
|
|
18
|
-
const localPath = `${configDir}/dw.config.local.yml`;
|
|
19
|
-
|
|
20
|
-
const base = loadConfig(basePath);
|
|
21
|
-
if (!base) return null;
|
|
22
|
-
|
|
23
|
-
if (!existsSync(localPath)) return base;
|
|
24
|
-
|
|
25
|
-
const local = loadConfig(localPath);
|
|
26
|
-
if (!local) return base;
|
|
27
|
-
|
|
28
|
-
const merged = { ...base };
|
|
29
|
-
for (const key of Object.keys(local)) {
|
|
30
|
-
if (typeof local[key] === 'object' && !Array.isArray(local[key]) && local[key] !== null) {
|
|
31
|
-
merged[key] = { ...(merged[key] || {}), ...local[key] };
|
|
32
|
-
} else {
|
|
33
|
-
merged[key] = local[key];
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return merged;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function writeConfig(configPath, data) {
|
|
40
|
-
const content = yaml.dump(data, {
|
|
41
|
-
indent: 2,
|
|
42
|
-
lineWidth: -1,
|
|
43
|
-
quotingType: '"',
|
|
44
|
-
forceQuotes: false,
|
|
45
|
-
noRefs: true,
|
|
46
|
-
});
|
|
47
|
-
writeFileSync(configPath, content, 'utf-8');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function loadSchema(schemaPath) {
|
|
51
|
-
if (!existsSync(schemaPath)) return null;
|
|
52
|
-
return JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function buildConfig({ projectName, language, depth, roles }) {
|
|
56
|
-
const today = new Date().toISOString().split('T')[0];
|
|
57
|
-
return {
|
|
58
|
-
project: {
|
|
59
|
-
name: projectName,
|
|
60
|
-
language: language,
|
|
61
|
-
},
|
|
62
|
-
workflow: {
|
|
63
|
-
default_depth: depth,
|
|
64
|
-
},
|
|
65
|
-
team: {
|
|
66
|
-
roles: roles,
|
|
67
|
-
},
|
|
68
|
-
quality: {
|
|
69
|
-
test_command: '',
|
|
70
|
-
lint_command: '',
|
|
71
|
-
block_on_fail: false,
|
|
72
|
-
},
|
|
73
|
-
tracking: {
|
|
74
|
-
estimation: depth !== 'quick',
|
|
75
|
-
log_work: depth === 'thorough',
|
|
76
|
-
estimation_unit: 'hours',
|
|
77
|
-
},
|
|
78
|
-
paths: {
|
|
79
|
-
tasks: '.dw/tasks',
|
|
80
|
-
docs: '.dw/docs',
|
|
81
|
-
},
|
|
82
|
-
claude: {
|
|
83
|
-
models: { plan: '', execute: '', review: '' },
|
|
84
|
-
structured_output: depth !== 'quick',
|
|
85
|
-
worktree_execution: false,
|
|
86
|
-
mcp: [],
|
|
87
|
-
},
|
|
88
|
-
_toolkit: {
|
|
89
|
-
core_version: '1.2',
|
|
90
|
-
platform_version: '1.0',
|
|
91
|
-
capability_version: '1.2',
|
|
92
|
-
installed: today,
|
|
93
|
-
last_upgrade: today,
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function getToolkitVersions(config) {
|
|
99
|
-
return {
|
|
100
|
-
core: config?._toolkit?.core_version || 'unknown',
|
|
101
|
-
platform: config?._toolkit?.platform_version || 'unknown',
|
|
102
|
-
capability: config?._toolkit?.capability_version || 'unknown',
|
|
103
|
-
};
|
|
104
|
-
}
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
export function loadConfig(configPath) {
|
|
5
|
+
if (!existsSync(configPath)) return null;
|
|
6
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
7
|
+
return yaml.load(content);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load config với local override (v1.2+).
|
|
12
|
+
* dw.config.yml — shared, committed
|
|
13
|
+
* dw.config.local.yml — machine-specific, gitignored
|
|
14
|
+
* Local values win over base values (shallow merge per top-level key).
|
|
15
|
+
*/
|
|
16
|
+
export function loadConfigWithLocal(configDir) {
|
|
17
|
+
const basePath = `${configDir}/dw.config.yml`;
|
|
18
|
+
const localPath = `${configDir}/dw.config.local.yml`;
|
|
19
|
+
|
|
20
|
+
const base = loadConfig(basePath);
|
|
21
|
+
if (!base) return null;
|
|
22
|
+
|
|
23
|
+
if (!existsSync(localPath)) return base;
|
|
24
|
+
|
|
25
|
+
const local = loadConfig(localPath);
|
|
26
|
+
if (!local) return base;
|
|
27
|
+
|
|
28
|
+
const merged = { ...base };
|
|
29
|
+
for (const key of Object.keys(local)) {
|
|
30
|
+
if (typeof local[key] === 'object' && !Array.isArray(local[key]) && local[key] !== null) {
|
|
31
|
+
merged[key] = { ...(merged[key] || {}), ...local[key] };
|
|
32
|
+
} else {
|
|
33
|
+
merged[key] = local[key];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return merged;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeConfig(configPath, data) {
|
|
40
|
+
const content = yaml.dump(data, {
|
|
41
|
+
indent: 2,
|
|
42
|
+
lineWidth: -1,
|
|
43
|
+
quotingType: '"',
|
|
44
|
+
forceQuotes: false,
|
|
45
|
+
noRefs: true,
|
|
46
|
+
});
|
|
47
|
+
writeFileSync(configPath, content, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadSchema(schemaPath) {
|
|
51
|
+
if (!existsSync(schemaPath)) return null;
|
|
52
|
+
return JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildConfig({ projectName, language, depth, roles }) {
|
|
56
|
+
const today = new Date().toISOString().split('T')[0];
|
|
57
|
+
return {
|
|
58
|
+
project: {
|
|
59
|
+
name: projectName,
|
|
60
|
+
language: language,
|
|
61
|
+
},
|
|
62
|
+
workflow: {
|
|
63
|
+
default_depth: depth,
|
|
64
|
+
},
|
|
65
|
+
team: {
|
|
66
|
+
roles: roles,
|
|
67
|
+
},
|
|
68
|
+
quality: {
|
|
69
|
+
test_command: '',
|
|
70
|
+
lint_command: '',
|
|
71
|
+
block_on_fail: false,
|
|
72
|
+
},
|
|
73
|
+
tracking: {
|
|
74
|
+
estimation: depth !== 'quick',
|
|
75
|
+
log_work: depth === 'thorough',
|
|
76
|
+
estimation_unit: 'hours',
|
|
77
|
+
},
|
|
78
|
+
paths: {
|
|
79
|
+
tasks: '.dw/tasks',
|
|
80
|
+
docs: '.dw/docs',
|
|
81
|
+
},
|
|
82
|
+
claude: {
|
|
83
|
+
models: { plan: '', execute: '', review: '' },
|
|
84
|
+
structured_output: depth !== 'quick',
|
|
85
|
+
worktree_execution: false,
|
|
86
|
+
mcp: [],
|
|
87
|
+
},
|
|
88
|
+
_toolkit: {
|
|
89
|
+
core_version: '1.2',
|
|
90
|
+
platform_version: '1.0',
|
|
91
|
+
capability_version: '1.2',
|
|
92
|
+
installed: today,
|
|
93
|
+
last_upgrade: today,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getToolkitVersions(config) {
|
|
99
|
+
return {
|
|
100
|
+
core: config?._toolkit?.core_version || 'unknown',
|
|
101
|
+
platform: config?._toolkit?.platform_version || 'unknown',
|
|
102
|
+
capability: config?._toolkit?.capability_version || 'unknown',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Renderer config for /dw:review --visual + `dw review render` (ADR-0007).
|
|
108
|
+
* Falls back to defaults when keys are absent so existing projects work
|
|
109
|
+
* without re-running `dw init`.
|
|
110
|
+
*/
|
|
111
|
+
export function getReviewRendererConfig(config) {
|
|
112
|
+
const r = config?.claude?.review?.renderer || {};
|
|
113
|
+
return {
|
|
114
|
+
strategy: r.strategy || 'auto',
|
|
115
|
+
theme: r.theme || 'github-dark',
|
|
116
|
+
font: r.font || 'JetBrains Mono',
|
|
117
|
+
formats: Array.isArray(r.formats) && r.formats.length ? r.formats : ['svg', 'png'],
|
|
118
|
+
output_dir: r.output_dir || '.dw/reviews',
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/lib/gitignore.mjs
CHANGED
|
@@ -53,7 +53,11 @@ function applyManagedBlock(content, blockLines) {
|
|
|
53
53
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
54
54
|
const before = content.slice(0, startIdx);
|
|
55
55
|
const after = content.slice(endIdx + MARKER_END.length);
|
|
56
|
-
|
|
56
|
+
// Collapse multiple consecutive blanks AND normalize trailing whitespace
|
|
57
|
+
// so re-running the helper is byte-identical (required by smoke idempotency test).
|
|
58
|
+
return (before + block + after)
|
|
59
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
60
|
+
.replace(/\n+$/, '\n');
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
// Markers not present — append block
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const REGISTRY_BASE = 'https://registry.npmjs.org';
|
|
5
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
6
|
+
const CACHE_REL = '.dw/security/npm-registry-cache.jsonl';
|
|
7
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour per ADR-0006
|
|
8
|
+
|
|
9
|
+
export function cachePath(rootDir = process.cwd()) {
|
|
10
|
+
return join(rootDir, CACHE_REL);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ensureCacheDir(rootDir) {
|
|
14
|
+
const dir = dirname(cachePath(rootDir));
|
|
15
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadCache(rootDir = process.cwd()) {
|
|
19
|
+
const p = cachePath(rootDir);
|
|
20
|
+
if (!existsSync(p)) return new Map();
|
|
21
|
+
const map = new Map();
|
|
22
|
+
try {
|
|
23
|
+
const lines = readFileSync(p, 'utf-8').split('\n').filter(Boolean);
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
try {
|
|
26
|
+
const e = JSON.parse(line);
|
|
27
|
+
if (e && e.key) map.set(e.key, e);
|
|
28
|
+
} catch {
|
|
29
|
+
// skip malformed line
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// unreadable cache → ignore
|
|
34
|
+
}
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cacheGet(rootDir, key, ttlMs = CACHE_TTL_MS) {
|
|
39
|
+
const map = loadCache(rootDir);
|
|
40
|
+
const entry = map.get(key);
|
|
41
|
+
if (!entry) return null;
|
|
42
|
+
if (ttlMs <= 0) return null;
|
|
43
|
+
if (Date.now() - entry.cached_at > ttlMs) return null;
|
|
44
|
+
return entry.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function cachePut(rootDir, key, value) {
|
|
48
|
+
ensureCacheDir(rootDir);
|
|
49
|
+
const line = JSON.stringify({ key, cached_at: Date.now(), value }) + '\n';
|
|
50
|
+
appendFileSync(cachePath(rootDir), line, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pruneCache(rootDir, ttlMs = CACHE_TTL_MS) {
|
|
54
|
+
const p = cachePath(rootDir);
|
|
55
|
+
if (!existsSync(p)) return;
|
|
56
|
+
const map = loadCache(rootDir);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const fresh = [];
|
|
59
|
+
for (const e of map.values()) {
|
|
60
|
+
if (now - e.cached_at <= ttlMs) fresh.push(e);
|
|
61
|
+
}
|
|
62
|
+
ensureCacheDir(rootDir);
|
|
63
|
+
writeFileSync(p, fresh.map((e) => JSON.stringify(e)).join('\n') + (fresh.length ? '\n' : ''), 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchJson(url, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
headers: { accept: 'application/json' },
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const err = new Error(`npm registry HTTP ${res.status}`);
|
|
76
|
+
err.status = res.status;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return await res.json();
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetch the full registry document for a package. Cached.
|
|
87
|
+
* Returns the raw npm registry response or null on hard failure.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchPackageMetadata(packageName, rootDir = process.cwd(), { useCache = true } = {}) {
|
|
90
|
+
const key = `pkg:${packageName}`;
|
|
91
|
+
if (useCache) {
|
|
92
|
+
const cached = cacheGet(rootDir, key);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const data = await fetchJson(`${REGISTRY_BASE}/${encodeURIComponent(packageName).replace('%40', '@')}`);
|
|
97
|
+
cachePut(rootDir, key, data);
|
|
98
|
+
return data;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.status === 404) return null;
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extract the signals needed by the heuristic scorer (ADR-0006 pillar 3).
|
|
107
|
+
* Pure function — call after fetchPackageMetadata returned a document.
|
|
108
|
+
*/
|
|
109
|
+
export function extractSignals(metadata, version) {
|
|
110
|
+
if (!metadata) return null;
|
|
111
|
+
const timeMap = metadata.time || {};
|
|
112
|
+
const publishIso = timeMap[version] || null;
|
|
113
|
+
const publishedAt = publishIso ? new Date(publishIso).getTime() : null;
|
|
114
|
+
const ageHours = publishedAt ? (Date.now() - publishedAt) / (1000 * 60 * 60) : null;
|
|
115
|
+
|
|
116
|
+
// Last modified time on the package as a whole — proxy for maintainer activity
|
|
117
|
+
const modifiedIso = timeMap.modified || null;
|
|
118
|
+
const modifiedAt = modifiedIso ? new Date(modifiedIso).getTime() : null;
|
|
119
|
+
|
|
120
|
+
// Maintainer set
|
|
121
|
+
const maintainers = Array.isArray(metadata.maintainers)
|
|
122
|
+
? metadata.maintainers.map((m) => (typeof m === 'string' ? m : m.name)).filter(Boolean)
|
|
123
|
+
: [];
|
|
124
|
+
|
|
125
|
+
// Latest version on dist-tags
|
|
126
|
+
const latestVersion = metadata['dist-tags']?.latest || null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
package: metadata.name,
|
|
130
|
+
version,
|
|
131
|
+
publish_iso: publishIso,
|
|
132
|
+
publish_age_hours: ageHours,
|
|
133
|
+
latest_version: latestVersion,
|
|
134
|
+
modified_iso: modifiedIso,
|
|
135
|
+
package_modified_age_days: modifiedAt ? (Date.now() - modifiedAt) / (1000 * 60 * 60 * 24) : null,
|
|
136
|
+
maintainer_count: maintainers.length,
|
|
137
|
+
maintainers,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Lightweight popularity probe via downloads API. Cached separately.
|
|
143
|
+
* Returns weekly downloads count or null on failure.
|
|
144
|
+
*/
|
|
145
|
+
export async function fetchWeeklyDownloads(packageName, rootDir = process.cwd(), { useCache = true } = {}) {
|
|
146
|
+
const key = `dl:${packageName}`;
|
|
147
|
+
if (useCache) {
|
|
148
|
+
const cached = cacheGet(rootDir, key);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const data = await fetchJson(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName).replace('%40', '@')}`);
|
|
153
|
+
const count = typeof data?.downloads === 'number' ? data.downloads : null;
|
|
154
|
+
cachePut(rootDir, key, count);
|
|
155
|
+
return count;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/dv-workflow/dv-workflow/schemas/review-manifest.json",
|
|
4
|
+
"title": "dw-kit Review Render Manifest",
|
|
5
|
+
"description": "Structured findings manifest produced by /dw:review --visual and consumed by `dw review render` + dw-kit-render. Versioned public API surface — see ADR-0007.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema_version", "scope", "generated_at", "findings"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {
|
|
11
|
+
"type": "integer",
|
|
12
|
+
"const": 1,
|
|
13
|
+
"description": "Manifest schema version. Renderer rejects mismatched versions with clear error."
|
|
14
|
+
},
|
|
15
|
+
"scope": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"maxLength": 200,
|
|
19
|
+
"description": "Review scope identifier (branch slug, task slug, or arbitrary label). NOT used directly as path — caller must sanitize via scope-slug util."
|
|
20
|
+
},
|
|
21
|
+
"scope_slug": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1,
|
|
24
|
+
"maxLength": 80,
|
|
25
|
+
"pattern": "^[a-zA-Z0-9._-]+$",
|
|
26
|
+
"description": "Sanitized scope used as directory name under .dw/reviews/. Filesystem-safe on Windows + POSIX."
|
|
27
|
+
},
|
|
28
|
+
"generated_at": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"format": "date-time",
|
|
31
|
+
"description": "ISO-8601 timestamp when manifest was produced."
|
|
32
|
+
},
|
|
33
|
+
"task_id": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Optional link to .dw/tasks/{task_id}/ — enables /dw:execute to load findings as fix targets."
|
|
36
|
+
},
|
|
37
|
+
"review_meta": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"properties": {
|
|
41
|
+
"reviewer": { "type": "string", "description": "Skill or agent name that produced this manifest (e.g. 'dw-review')." },
|
|
42
|
+
"depth": { "type": "string", "enum": ["quick", "standard", "thorough"] },
|
|
43
|
+
"diff_base": { "type": "string", "description": "Git ref the review was computed against (e.g. 'main', 'origin/dev')." },
|
|
44
|
+
"files_reviewed": { "type": "integer", "minimum": 0 }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"findings": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"minItems": 0,
|
|
50
|
+
"items": { "$ref": "#/definitions/finding" }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"definitions": {
|
|
54
|
+
"finding": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"required": ["id", "severity", "title", "location", "body"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"id": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"pattern": "^[a-zA-Z0-9._-]+$",
|
|
62
|
+
"minLength": 1,
|
|
63
|
+
"maxLength": 64,
|
|
64
|
+
"description": "Unique finding ID within the manifest. Used as artifact filename: finding-{id}.svg / .png."
|
|
65
|
+
},
|
|
66
|
+
"severity": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"enum": ["critical", "warning", "suggestion"],
|
|
69
|
+
"description": "Drives banner color in rendered artifact."
|
|
70
|
+
},
|
|
71
|
+
"title": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"minLength": 1,
|
|
74
|
+
"maxLength": 200,
|
|
75
|
+
"description": "One-line summary shown as the card heading."
|
|
76
|
+
},
|
|
77
|
+
"location": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"additionalProperties": false,
|
|
80
|
+
"required": ["file"],
|
|
81
|
+
"properties": {
|
|
82
|
+
"file": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"minLength": 1,
|
|
85
|
+
"description": "Repo-relative path. Forward slashes regardless of OS."
|
|
86
|
+
},
|
|
87
|
+
"line_start": {
|
|
88
|
+
"type": "integer",
|
|
89
|
+
"minimum": 1,
|
|
90
|
+
"description": "First relevant line (1-indexed)."
|
|
91
|
+
},
|
|
92
|
+
"line_end": {
|
|
93
|
+
"type": "integer",
|
|
94
|
+
"minimum": 1,
|
|
95
|
+
"description": "Last relevant line (1-indexed, inclusive)."
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"rule_ref": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"maxLength": 200,
|
|
102
|
+
"description": "Optional rule/standard reference shown in card subhead."
|
|
103
|
+
},
|
|
104
|
+
"body": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"minLength": 1,
|
|
107
|
+
"maxLength": 4000,
|
|
108
|
+
"description": "Finding explanation (markdown allowed in rendered output, plain text safe fallback)."
|
|
109
|
+
},
|
|
110
|
+
"fix": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"maxLength": 4000,
|
|
113
|
+
"description": "Optional concrete fix suggestion shown as action banner."
|
|
114
|
+
},
|
|
115
|
+
"code_snippet": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"maxLength": 8000,
|
|
118
|
+
"description": "Raw code lines from `location` range. Pulled by the LLM during manifest generation; renderer does not re-read source files. Cap at ~50 lines around finding."
|
|
119
|
+
},
|
|
120
|
+
"language": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"maxLength": 32,
|
|
123
|
+
"description": "Language identifier for syntax highlighting (e.g. javascript, python, go). Defaults to plain text in renderer if absent or unknown."
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"allOf": [
|
|
127
|
+
{
|
|
128
|
+
"if": {
|
|
129
|
+
"properties": {
|
|
130
|
+
"location": {
|
|
131
|
+
"required": ["line_start", "line_end"]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"then": {
|
|
136
|
+
"properties": {
|
|
137
|
+
"location": {
|
|
138
|
+
"properties": {
|
|
139
|
+
"line_end": { "type": "integer" },
|
|
140
|
+
"line_start": { "type": "integer" }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import Ajv from 'ajv';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const SCHEMA_PATH = resolve(__dirname, 'manifest-schema.json');
|
|
8
|
+
|
|
9
|
+
let _schema = null;
|
|
10
|
+
let _ajv = null;
|
|
11
|
+
let _validate = null;
|
|
12
|
+
|
|
13
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
16
|
+
|
|
17
|
+
function getSchema() {
|
|
18
|
+
if (!_schema) {
|
|
19
|
+
_schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
return _schema;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getValidator() {
|
|
25
|
+
if (_validate) return _validate;
|
|
26
|
+
_ajv = new Ajv({ allErrors: true, strict: false });
|
|
27
|
+
_ajv.addFormat('date-time', ISO_DATE_TIME);
|
|
28
|
+
_validate = _ajv.compile(getSchema());
|
|
29
|
+
return _validate;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a manifest object against the schema.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} manifest - parsed manifest JSON
|
|
36
|
+
* @returns {{ok: boolean, errors: Array<{path: string, message: string}>}}
|
|
37
|
+
*/
|
|
38
|
+
export function validateManifest(manifest) {
|
|
39
|
+
if (manifest == null || typeof manifest !== 'object') {
|
|
40
|
+
return { ok: false, errors: [{ path: '', message: 'manifest must be an object' }] };
|
|
41
|
+
}
|
|
42
|
+
if (Number.isInteger(manifest.schema_version) && manifest.schema_version !== CURRENT_SCHEMA_VERSION) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
errors: [{
|
|
46
|
+
path: '/schema_version',
|
|
47
|
+
message: `unsupported schema_version=${manifest.schema_version}; renderer supports ${CURRENT_SCHEMA_VERSION}. Upgrade dw-kit or regenerate manifest.`,
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const validate = getValidator();
|
|
52
|
+
const valid = validate(manifest);
|
|
53
|
+
if (valid) return { ok: true, errors: [] };
|
|
54
|
+
const errors = (validate.errors || []).map((e) => ({
|
|
55
|
+
path: e.instancePath || '/',
|
|
56
|
+
message: `${e.message}${e.params && Object.keys(e.params).length ? ' (' + JSON.stringify(e.params) + ')' : ''}`,
|
|
57
|
+
}));
|
|
58
|
+
return { ok: false, errors };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse + validate from JSON string.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} jsonText
|
|
65
|
+
* @returns {{ok: boolean, manifest?: object, errors: Array}}
|
|
66
|
+
*/
|
|
67
|
+
export function parseManifest(jsonText) {
|
|
68
|
+
let manifest;
|
|
69
|
+
try {
|
|
70
|
+
manifest = JSON.parse(jsonText);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { ok: false, errors: [{ path: '', message: `invalid JSON: ${e.message}` }] };
|
|
73
|
+
}
|
|
74
|
+
const result = validateManifest(manifest);
|
|
75
|
+
if (!result.ok) return { ok: false, errors: result.errors };
|
|
76
|
+
return { ok: true, manifest, errors: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read + validate manifest file from disk.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} manifestPath
|
|
83
|
+
* @returns {{ok: boolean, manifest?: object, errors: Array}}
|
|
84
|
+
*/
|
|
85
|
+
export function readManifest(manifestPath) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readFileSync(manifestPath, 'utf-8');
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return { ok: false, errors: [{ path: '', message: `cannot read manifest: ${e.message}` }] };
|
|
91
|
+
}
|
|
92
|
+
return parseManifest(text);
|
|
93
|
+
}
|