artifact-graph 0.3.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/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "artifact-graph",
3
+ "version": "0.3.0",
4
+ "description": "Git-native Markdown artifact graph scanner and validator",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "packageManager": "pnpm@10.17.1",
8
+ "keywords": [
9
+ "artifact-graph",
10
+ "traceability",
11
+ "markdown",
12
+ "agent-tools",
13
+ "codex"
14
+ ],
15
+ "bin": {
16
+ "artifact-graph": "./dist/cli.js"
17
+ },
18
+ "main": "./dist/index.cjs",
19
+ "module": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "import": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "require": {
28
+ "types": "./dist/index.d.cts",
29
+ "default": "./dist/index.cjs"
30
+ }
31
+ }
32
+ },
33
+ "engines": {
34
+ "node": ">=22.0.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup src/index.ts --format esm,cjs --dts --splitting false && tsup src/cli.ts --format esm --dts --splitting false",
38
+ "test": "vitest run --config vitest.config.ts",
39
+ "test:ci": "ARTIFACT_GRAPH_PUBLIC_REPO=1 vitest run --config vitest.config.ts",
40
+ "test:watch": "vitest",
41
+ "typecheck": "tsc --noEmit",
42
+ "clean": "rm -rf dist coverage",
43
+ "packet:audit": "node scripts/run-packet-audit.mjs",
44
+ "packet:prompt-audit": "node scripts/run-packet-prompt-audit.mjs"
45
+ },
46
+ "dependencies": {
47
+ "better-sqlite3": "^11.9.0",
48
+ "commander": "^13.1.0",
49
+ "gray-matter": "^4.0.3",
50
+ "js-yaml": "^4.2.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/better-sqlite3": "^7.6.12",
54
+ "@types/js-yaml": "^4.0.9",
55
+ "@types/node": "^22.19.20",
56
+ "tsup": "^8.4.0",
57
+ "typescript": "^5.7.0",
58
+ "vitest": "^3.1.0"
59
+ },
60
+ "files": [
61
+ "README.md",
62
+ "README.zh-CN.md",
63
+ "CHANGELOG.md",
64
+ "LICENSE",
65
+ "NOTICE",
66
+ "INSTALL.md",
67
+ "dist",
68
+ "scripts",
69
+ "templates"
70
+ ],
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/mzdbxqh/artifact-graph.git"
74
+ },
75
+ "bugs": {
76
+ "url": "https://github.com/mzdbxqh/artifact-graph/issues"
77
+ },
78
+ "homepage": "https://github.com/mzdbxqh/artifact-graph#readme",
79
+ "pnpm": {
80
+ "onlyBuiltDependencies": [
81
+ "better-sqlite3",
82
+ "esbuild"
83
+ ]
84
+ }
85
+ }
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-packet-audit.mjs
4
+ *
5
+ * Packet audit 回归测试脚本。运行以下场景并输出中文摘要:
6
+ * 1. full discover summary-only(全量扫描,不写 packet 文件)
7
+ * 2. compact summary 输出(体积治理验证)
8
+ * 3. sample capture(指定样例写入)
9
+ * 4. 非法 targets smoke test(exit 1)
10
+ * 5. 非命中 --sample-targets smoke test(exit 1)
11
+ * 6. 裸命令不带 --root(验证 root 自动发现)
12
+ * 7. 错误 root 不会静默 total=0
13
+ *
14
+ * v1.13: 默认 outDirBase 改为每次唯一目录,避免旧结果干扰
15
+ *
16
+ * 用法: node scripts/run-packet-audit.mjs [--root <path>] [--out-dir <path>]
17
+ */
18
+ import { execFile } from 'node:child_process';
19
+ import { existsSync, readdirSync } from 'node:fs';
20
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
21
+ import { tmpdir } from 'node:os';
22
+ import { dirname, join, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { promisify } from 'node:util';
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
29
+ const CLI = resolve(__dirname, '../dist/cli.js');
30
+
31
+ // v1.13: 临时目录前缀,用于识别和清理
32
+ const TMP_PREFIX = 'packet-audit-regression-';
33
+ const MAX_KEPT_DIRS = 5;
34
+
35
+ /**
36
+ * 从脚本位置向上查找同时包含 artifact-graph.config.yaml 和 artifacts/ 的目录。
37
+ * 找不到时返回 null(不再 fallback 到猜测路径)。
38
+ */
39
+ function discoverRoot(startDir) {
40
+ let dir = resolve(startDir);
41
+ for (let i = 0; i < 10; i++) {
42
+ if (
43
+ existsSync(join(dir, 'artifact-graph.config.yaml')) &&
44
+ existsSync(join(dir, 'artifacts'))
45
+ ) {
46
+ return dir;
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // ── Helpers ──
56
+
57
+ /**
58
+ * v1.13: 清理旧的临时目录,保留最近 MAX_KEPT_DIRS 个
59
+ */
60
+ async function cleanupOldTmpDirs() {
61
+ try {
62
+ const tmpBase = tmpdir();
63
+ const entries = readdirSync(tmpBase, { withFileTypes: true })
64
+ .filter((e) => e.isDirectory() && e.name.startsWith(TMP_PREFIX))
65
+ .map((e) => ({
66
+ name: e.name,
67
+ path: join(tmpBase, e.name),
68
+ // 从目录名提取时间戳
69
+ ts: parseInt(e.name.slice(TMP_PREFIX.length), 10) || 0,
70
+ }))
71
+ .sort((a, b) => b.ts - a.ts); // 最新的在前
72
+
73
+ // 删除超出保留数量的旧目录
74
+ for (const dir of entries.slice(MAX_KEPT_DIRS)) {
75
+ await rm(dir.path, { recursive: true, force: true });
76
+ }
77
+ } catch {
78
+ // 忽略清理错误
79
+ }
80
+ }
81
+
82
+ async function run(args, opts = {}) {
83
+ try {
84
+ const { stdout, stderr } = await execFileAsync('node', [CLI, ...args], {
85
+ timeout: 120_000,
86
+ maxBuffer: 50 * 1024 * 1024,
87
+ ...opts,
88
+ });
89
+ return { code: 0, stdout, stderr };
90
+ } catch (err) {
91
+ // execFile throws on non-zero exit
92
+ return {
93
+ code: err.code ?? 1,
94
+ stdout: err.stdout ?? '',
95
+ stderr: err.stderr ?? '',
96
+ };
97
+ }
98
+ }
99
+
100
+ function log(msg) {
101
+ process.stdout.write(`[packet-audit-regression] ${msg}\n`);
102
+ }
103
+
104
+ function pass(msg) {
105
+ log(`✅ ${msg}`);
106
+ }
107
+
108
+ function fail(msg) {
109
+ log(`❌ ${msg}`);
110
+ }
111
+
112
+ // ── Main ──
113
+
114
+ async function main() {
115
+ const args = process.argv.slice(2);
116
+ let root = null;
117
+ let outDirBase = null;
118
+ let userSpecifiedOutDir = false;
119
+
120
+ for (let i = 0; i < args.length; i++) {
121
+ if (args[i] === '--root' && args[i + 1]) root = resolve(args[++i]);
122
+ else if (args[i] === '--out-dir' && args[i + 1]) {
123
+ outDirBase = resolve(args[++i]);
124
+ userSpecifiedOutDir = true;
125
+ }
126
+ }
127
+
128
+ // v1.13: 清理旧临时目录
129
+ await cleanupOldTmpDirs();
130
+
131
+ // v1.13: 默认使用唯一临时目录
132
+ if (!outDirBase) {
133
+ outDirBase = await mkdtemp(join(tmpdir(), TMP_PREFIX));
134
+ } else {
135
+ await mkdir(outDirBase, { recursive: true });
136
+ }
137
+
138
+ // 如果用户没传 --root,尝试自动发现
139
+ if (!root) {
140
+ root = discoverRoot(__dirname);
141
+ if (!root) {
142
+ log('错误:无法自动发现 root 目录(找不到 artifact-graph.config.yaml 和 artifacts/)');
143
+ log('请使用 --root <path> 显式指定');
144
+ process.exit(1);
145
+ }
146
+ }
147
+
148
+ // 验证 root 有效性
149
+ const hasConfig = existsSync(join(root, 'artifact-graph.config.yaml'));
150
+ const hasArtifacts = existsSync(join(root, 'artifacts'));
151
+ if (!hasConfig || !hasArtifacts) {
152
+ log(`错误:root=${root} 缺少 ${!hasConfig ? 'artifact-graph.config.yaml' : ''}${!hasConfig && !hasArtifacts ? ' 和 ' : ''}${!hasArtifacts ? 'artifacts/' : ''}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ let failures = 0;
157
+ const startTime = Date.now();
158
+
159
+ log(`根目录: ${root}`);
160
+ log(`输出目录: ${outDirBase}${userSpecifiedOutDir ? '(用户指定)' : '(自动创建)'}`);
161
+ log('─'.repeat(60));
162
+
163
+ // ── 场景 1: full discover summary-only ──
164
+ {
165
+ log('场景 1: full discover summary-only(全量扫描)');
166
+ const outDir = join(outDirBase, 'discover-full');
167
+ const res = await run([
168
+ 'packet-audit', '--root', root, '--discover', '--limit', '0',
169
+ '--summary-only', '--format', 'json',
170
+ ]);
171
+ if (res.code !== 0) {
172
+ fail(`discover summary-only exit ${res.code}`);
173
+ failures++;
174
+ } else {
175
+ const summary = JSON.parse(res.stdout);
176
+ log(` 总计: ${summary.total} | 通过: ${summary.passed} | 失败: ${summary.failed} | 缺失: ${summary.missing}`);
177
+ log(` schemaVersion: ${summary.schemaVersion}`);
178
+ log(` packetOutputMode: ${summary.packetOutputMode}`);
179
+ if (summary.total > 0 && summary.packetOutputMode === 'summary-only') {
180
+ pass('discover summary-only 正常');
181
+ } else {
182
+ fail('discover summary-only 结果异常');
183
+ failures++;
184
+ }
185
+ }
186
+ }
187
+
188
+ // ── 场景 2: compact summary 输出 ──
189
+ {
190
+ log('场景 2: compact summary 输出(体积治理)');
191
+ const outDir = join(outDirBase, 'discover-compact');
192
+ await mkdir(outDir, { recursive: true });
193
+ const res = await run([
194
+ 'packet-audit', '--root', root, '--discover', '--limit', '0',
195
+ '--summary-only', '--summary-detail', 'compact', '--format', 'json',
196
+ ]);
197
+ if (res.code !== 0) {
198
+ fail(`compact summary exit ${res.code}`);
199
+ failures++;
200
+ } else {
201
+ const summary = JSON.parse(res.stdout);
202
+ log(` targets 数量: ${summary.targets.length}(全部: ${summary.total})`);
203
+ log(` countsByType: ${JSON.stringify(summary.countsByType)}`);
204
+ log(` summaryDetail: ${summary.summaryDetail}`);
205
+ const fullSize = JSON.stringify(summary).length;
206
+ log(` compact summary 体积: ${(fullSize / 1024).toFixed(1)} KB`);
207
+ if (summary.summaryDetail === 'compact' && summary.countsByType) {
208
+ pass('compact summary 正常');
209
+ } else {
210
+ fail('compact summary 缺少字段');
211
+ failures++;
212
+ }
213
+ }
214
+ }
215
+
216
+ // ── 场景 3: sample capture ──
217
+ {
218
+ log('场景 3: sample capture(指定样例写入)');
219
+ const outDir = join(outDirBase, 'sample-capture');
220
+ // 先 discover 获取前两个 target
221
+ const discoverRes = await run([
222
+ 'packet-audit', '--root', root, '--discover', '--limit', '2',
223
+ '--summary-only', '--format', 'json',
224
+ ]);
225
+ if (discoverRes.code !== 0) {
226
+ fail(`discover for sample exit ${discoverRes.code}`);
227
+ failures++;
228
+ } else {
229
+ const disc = JSON.parse(discoverRes.stdout);
230
+ if (disc.targets.length >= 1) {
231
+ const sampleTarget = `${disc.targets[0].type}:${disc.targets[0].id}`;
232
+ log(` 使用样例 target: ${sampleTarget}`);
233
+ const res = await run([
234
+ 'packet-audit', '--root', root, '--discover', '--limit', '2',
235
+ '--out-dir', outDir, '--sample-targets', sampleTarget, '--format', 'json',
236
+ ]);
237
+ if (res.code !== 0) {
238
+ fail(`sample capture exit ${res.code}`);
239
+ failures++;
240
+ } else {
241
+ const summary = JSON.parse(res.stdout);
242
+ log(` sampleTargets: ${JSON.stringify(summary.sampleTargets)}`);
243
+ log(` sampleOutputPaths: ${summary.sampleOutputPaths?.length ?? 0} 个文件`);
244
+ if (summary.packetOutputMode === 'sample' && summary.sampleTargets?.includes(sampleTarget)) {
245
+ pass('sample capture 正常');
246
+ } else {
247
+ fail('sample capture 结果异常');
248
+ failures++;
249
+ }
250
+ }
251
+ } else {
252
+ fail('无可用于 sample 的 target');
253
+ failures++;
254
+ }
255
+ }
256
+ }
257
+
258
+ // ── 场景 4: 非法 targets smoke test ──
259
+ {
260
+ log('场景 4: 非法 targets 文件(应 exit 1)');
261
+ const tmpDir = await mkdtemp(join(tmpdir(), 'bad-targets-'));
262
+ const badFile = join(tmpDir, 'bad.txt');
263
+ await writeFile(badFile, 'feature:A1\ninvalid:B2\n');
264
+ const res = await run([
265
+ 'packet-audit', '--root', root, '--targets-file', badFile,
266
+ '--summary-only', '--format', 'json',
267
+ ]);
268
+ await rm(tmpDir, { recursive: true, force: true });
269
+ if (res.code === 1) {
270
+ pass('非法 targets 正确 exit 1');
271
+ } else {
272
+ fail(`非法 targets exit ${res.code},期望 1`);
273
+ failures++;
274
+ }
275
+ }
276
+
277
+ // ── 场景 5: 非命中 --sample-targets smoke test ──
278
+ {
279
+ log('场景 5: 非命中 --sample-targets(应 exit 1)');
280
+ const tmpDir = await mkdtemp(join(tmpdir(), 'bad-sample-'));
281
+ const targetsFile = join(tmpDir, 'targets.txt');
282
+ await writeFile(targetsFile, 'feature:A1\nscenario:S-01\n');
283
+ const res = await run([
284
+ 'packet-audit', '--root', root, '--targets-file', targetsFile,
285
+ '--summary-only', '--sample-targets', 'feature:NO_SUCH_TARGET', '--format', 'json',
286
+ ]);
287
+ await rm(tmpDir, { recursive: true, force: true });
288
+ if (res.code === 1) {
289
+ pass('非命中 sample-targets 正确 exit 1');
290
+ } else {
291
+ fail(`非命中 sample-targets exit ${res.code},期望 1`);
292
+ failures++;
293
+ }
294
+ }
295
+
296
+ // ── 场景 6: 裸命令不带 --root(验证 root 自动发现) ──
297
+ {
298
+ log('场景 6: 裸命令不带 --root(验证脚本自动发现 root 并传递给 CLI)');
299
+ // 验证 discoverRoot 产出的 root 包含必要的标识文件
300
+ const { existsSync: exists } = await import('node:fs');
301
+ const hasConfig = exists(join(root, 'artifact-graph.config.yaml'));
302
+ const hasArtifacts = exists(join(root, 'artifacts'));
303
+ if (!hasConfig || !hasArtifacts) {
304
+ fail(`自动发现的 root=${root} 缺少 artifact-graph.config.yaml 或 artifacts/`);
305
+ failures++;
306
+ } else {
307
+ // 用自动发现的 root 运行 CLI(模拟 pnpm packet:audit 裸命令场景)
308
+ const res = await run([
309
+ 'packet-audit', '--root', root, '--discover', '--limit', '0',
310
+ '--summary-only', '--format', 'json',
311
+ ]);
312
+ if (res.code !== 0) {
313
+ fail(`自动发现 root 后 CLI exit ${res.code}`);
314
+ failures++;
315
+ } else {
316
+ const summary = JSON.parse(res.stdout);
317
+ log(` 自动发现 root=${root}, total=${summary.total}`);
318
+ if (summary.total > 0) {
319
+ pass(`自动发现 root 成功(total=${summary.total})`);
320
+ } else {
321
+ fail('自动发现 root 后 total=0');
322
+ failures++;
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ // ── 场景 7: 错误 root 不会静默 total=0 ──
329
+ {
330
+ log('场景 7: 错误 root 目录(CLI 必须报错或 total=0 明确失败)');
331
+ const tmpDir = await mkdtemp(join(tmpdir(), 'fake-root-'));
332
+ const res = await run([
333
+ 'packet-audit', '--root', tmpDir, '--discover', '--limit', '0',
334
+ '--summary-only', '--format', 'json',
335
+ ]);
336
+ await rm(tmpDir, { recursive: true, force: true });
337
+ // 错误 root 下 discover CLI 必须返回非零,或 total=0 视为失败
338
+ if (res.code !== 0) {
339
+ pass('错误 root CLI 正确报错(exit != 0)');
340
+ } else {
341
+ const summary = JSON.parse(res.stdout);
342
+ if (summary.total === 0) {
343
+ // total=0 + exit 0 = 静默成功,这是不可接受的
344
+ fail(`错误 root 下 CLI exit 0 且 total=0(静默成功,应报错)`);
345
+ failures++;
346
+ } else {
347
+ fail(`错误 root 下 total=${summary.total},不应有结果`);
348
+ failures++;
349
+ }
350
+ }
351
+ }
352
+
353
+ // ── 总结 ──
354
+ log('─'.repeat(60));
355
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
356
+ if (failures === 0) {
357
+ log(`全部通过(${elapsed}s)`);
358
+ } else {
359
+ log(`${failures} 个场景失败(${elapsed}s)`);
360
+ process.exitCode = 1;
361
+ }
362
+ }
363
+
364
+ main().catch((err) => {
365
+ console.error(err);
366
+ process.exitCode = 1;
367
+ });