@haiyangbg/buildbeat 2.0.1 → 3.0.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +24 -301
  2. package/README.en.md +6 -19
  3. package/README.md +6 -19
  4. package/SKILL.md +168 -219
  5. package/bin/buildbeat.js +14 -2
  6. package/docs/CAPABILITY-MATRIX.md +13 -55
  7. package/docs/README.md +14 -14
  8. package/docs/RELEASING.md +8 -9
  9. package/docs/v2/RFC-0001-product-definition.md +2 -0
  10. package/docs/v2/RFC-0003-workflow-policy.md +2 -0
  11. package/docs/v2/guide/00-how-to-talk.md +3 -3
  12. package/docs/v2/guide/01-quickstart.md +12 -12
  13. package/docs/v2/guide/03-policy-guide.md +1 -1
  14. package/docs/v2/guide/06-evidence-guide.md +3 -3
  15. package/docs/v2/guide/07-approval-guide.md +10 -10
  16. package/docs/v2/guide/10-recovery.md +6 -6
  17. package/docs/v2/guide/11-session-handoff.en.md +2 -2
  18. package/docs/v2/guide/11-session-handoff.md +2 -2
  19. package/docs/v2/guide/README.md +0 -6
  20. package/lessons.md +52 -71
  21. package/package.json +16 -8
  22. package/src/v2/cli/run.js +33 -23
  23. package/src/v2/engine/risk-preset.js +1 -1
  24. package/src/v2/runtime/notify.js +5 -5
  25. package/src/v2/runtime/overview.js +7 -7
  26. package/templates/ARCHITECTURE.md +1 -1
  27. package/templates/contracts/PROTOCOL.md +2 -10
  28. package/templates/gitignore.template +0 -3
  29. package/templates/pm/adr/README.md +1 -1
  30. package/templates/pm/decisions.md +4 -5
  31. package/templates/standards/CODE.md +1 -1
  32. package/templates/standards/DESIGN.md +1 -1
  33. package/templates/standards/REVIEW.md +2 -2
  34. package/templates/standards/STACK.md +2 -8
  35. package/templates/v2/AGENTS.md +18 -18
  36. package/templates/v2/BUILDBEAT.md +2 -3
  37. package/templates/v2/CLAUDE.md +1 -1
  38. package/templates/v2/run-config.example.yaml +1 -1
  39. package/templates/v2//346/214/207/346/214/245/345/217/260.md +6 -6
  40. package/bin/buildbeat-v2.js +0 -18
  41. package/bin/solobaton.js +0 -6
  42. package/docs/BuildBeat v2/357/274/232AI /345/216/237/347/224/237/350/275/257/344/273/266/344/272/244/344/273/230/346/216/247/345/210/266/345/271/263/351/235/242.md" +0 -2053
  43. package/docs/CHECKS.md +0 -326
  44. package/docs/CLI-PILOT-2026-08-23.md +0 -25
  45. package/docs/CLI-STRATEGY-2026-08.md +0 -55
  46. package/docs/CLI.md +0 -244
  47. package/docs/EXECUTION-PLAN.md +0 -487
  48. package/docs/LEGACY-V1.16-MIGRATION.md +0 -54
  49. package/docs/PHASE1-PILOT-2026-08-24.md +0 -32
  50. package/docs/PHASE2-BUILDBEAT-PILOT-2026-08-25.md +0 -75
  51. package/docs/PHASE2-PILOT-2026-08-25.md +0 -88
  52. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +0 -42
  53. package/docs/PHASE4-STABILITY-AUDIT-2026-08-25.md +0 -35
  54. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +0 -56
  55. package/docs/ROADMAP.md +0 -875
  56. package/docs/V1.21-RELEASE-EVIDENCE-2026-08-25.md +0 -55
  57. package/docs/V2-D2-DECISION-CARD.md +0 -37
  58. package/docs/V2-DECISIONS.md +0 -11
  59. package/docs/V2-ITERATION-01.md +0 -60
  60. package/docs/V2-ITERATION-02.md +0 -32
  61. package/docs/V2-ITERATION-03.md +0 -30
  62. package/docs/V2-ITERATION-04.md +0 -29
  63. package/docs/V2-ITERATION-05.md +0 -20
  64. package/docs/V2-ITERATION-06.md +0 -18
  65. package/docs/V2-ITERATION-07.md +0 -36
  66. package/docs/V2-ITERATION-08.md +0 -62
  67. package/docs/V2-PLAN.md +0 -335
  68. package/docs/V2-PROPOSAL.md +0 -319
  69. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +0 -41
  70. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +0 -8
  71. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +0 -8
  72. package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +0 -9
  73. package/docs/V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md +0 -10
  74. package/docs/V2.0.0-RELEASE-EVIDENCE-2026-09-05.md +0 -11
  75. package/docs/WP4.3-RELEASE-EVIDENCE-2026-08-25.md +0 -73
  76. package/docs/v2/M1-ACCEPTANCE-2026-08-28.md +0 -38
  77. package/docs/v2/M2-DOD-2026-08-28.md +0 -34
  78. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +0 -46
  79. package/docs/v2/M4-PILOT-APP-2026-08-28.md +0 -44
  80. package/docs/v2/M4-SELFHOST-2026-08-28.md +0 -53
  81. package/docs/v2/guide/08-migration-v1.md +0 -72
  82. package/example/.buildbeat/manifest.json +0 -45
  83. package/example/AGENTS.md +0 -19
  84. package/example/ARCHITECTURE.md +0 -39
  85. package/example/BUILDBEAT.md +0 -17
  86. package/example/CLAUDE.md +0 -7
  87. package/example/README.md +0 -75
  88. package/example/contracts/PROTOCOL.md +0 -38
  89. package/example/pm/NOW.md +0 -22
  90. package/example/pm/adr/ADR-0001-local-first-sqlite.md +0 -25
  91. package/example/pm/adr/README.md +0 -7
  92. package/example/pm/archive//344/270/200/346/234/237/evidence/gate1.md +0 -5
  93. package/example/pm/archive//344/270/200/346/234/237/evidence/gate2.md +0 -5
  94. package/example/pm/archive//344/270/200/346/234/237/evidence/gate3.md +0 -5
  95. package/example/pm/archive//344/270/200/346/234/237/evidence/gate4.md +0 -5
  96. package/example/pm/archive//344/270/200/346/234/237/evidence/implementation.md +0 -5
  97. package/example/pm/decisions.md +0 -20
  98. package/example/pm/status//344/272/247/345/223/201.md +0 -20
  99. package/example/pm/status//345/205/250/346/240/210.md +0 -15
  100. package/example/pm/status//346/265/213/350/257/225.md +0 -15
  101. package/example/pm//344/270/200/346/234/237-/347/234/213/346/235/277.md +0 -97
  102. package/example/standards/CODE.md +0 -18
  103. package/example/standards/DESIGN.md +0 -34
  104. package/example/standards/REVIEW.md +0 -16
  105. package/example/standards/STACK.md +0 -31
  106. package/src/cli.js +0 -323
  107. package/src/constants.js +0 -202
  108. package/src/doctor.js +0 -267
  109. package/src/planner.js +0 -251
  110. package/src/project.js +0 -844
  111. package/src/upgrader.js +0 -1249
  112. package/src/v2/presets/risk/legacy-four-gates.yaml +0 -44
  113. package/src/writer.js +0 -534
  114. package/templates/.claude/agents/reviewer.md +0 -62
  115. package/templates/AGENTS.md +0 -85
  116. package/templates/BUILDBEAT.md +0 -13
  117. package/templates/CLAUDE.md +0 -7
  118. package/templates/pm/NOW.md +0 -26
  119. package/templates/pm/changes/README.md +0 -44
  120. package/templates/pm/status/README.md +0 -32
  121. package/templates/pm//345/275/223/346/234/237/347/234/213/346/235/277.md +0 -62
  122. package/templates/scripts/bus-check.sh +0 -1875
  123. package/templates/scripts/design-preview.sh +0 -44
  124. package/templates/scripts/drift-check.sh +0 -112
  125. package/templates/scripts/pre-commit.sh +0 -74
  126. package/templates/scripts/verify-status.sh +0 -105
  127. package/templates//346/214/207/346/214/245/345/217/260.md +0 -58
package/src/project.js DELETED
@@ -1,844 +0,0 @@
1
- import {
2
- accessSync,
3
- constants as fsConstants,
4
- existsSync,
5
- lstatSync,
6
- readFileSync,
7
- readdirSync,
8
- } from "node:fs";
9
- import { spawnSync } from "node:child_process";
10
- import path from "node:path";
11
- import { fileURLToPath } from "node:url";
12
-
13
- import {
14
- FILE_POLICIES,
15
- GITIGNORE_BEGIN_MARKER,
16
- GITIGNORE_END_MARKER,
17
- GITIGNORE_MARKER_PAIRS,
18
- IGNORED_SCAN_DIRECTORIES,
19
- LEGACY_MANIFEST_PATH,
20
- LEGACY_MARKER_FILE,
21
- MANIFEST_PATH,
22
- MANIFEST_PATHS,
23
- MARKER_FILE,
24
- OPTIONAL_TEMPLATE_PREFIXES,
25
- RENDER_REQUIRED_PLACEHOLDERS,
26
- SCRIPT_NAMES,
27
- SUPPORTED_MANIFEST_SCHEMA_VERSIONS,
28
- filePolicy,
29
- } from "./constants.js";
30
-
31
- const TEMPLATE_ROOT = fileURLToPath(new URL("../templates/", import.meta.url));
32
- const MAX_SCAN_DEPTH = 4;
33
- const MAX_SCAN_ENTRIES = 5000;
34
- const TEXT_LIMIT_BYTES = 512 * 1024;
35
- const MAX_MANIFEST_FILES = 5000;
36
- const MANIFEST_TOP_LEVEL_KEYS = new Set([
37
- "schemaVersion",
38
- "scaffoldVersion",
39
- "cliVersion",
40
- "layout",
41
- "installedAt",
42
- "files",
43
- "integrations",
44
- ]);
45
- const MANIFEST_FILE_KEYS = new Set(["policy", "baselineSha256"]);
46
- const MANIFEST_INTEGRATION_KEYS = ["gitignore", "hooks"];
47
- const MANIFEST_GITIGNORE_KEYS = new Set([
48
- "path",
49
- "beginMarker",
50
- "endMarker",
51
- "baselineSha256",
52
- ]);
53
- const MANIFEST_POLICIES = {
54
- 1: new Set(Object.values(FILE_POLICIES)),
55
- 2: new Set([
56
- FILE_POLICIES.REPLACE_IF_UNMODIFIED,
57
- FILE_POLICIES.PROJECT_OWNED,
58
- FILE_POLICIES.MERGE_ONLY,
59
- ]),
60
- };
61
-
62
- function posixPath(value) {
63
- return value.split(path.sep).join("/");
64
- }
65
-
66
- function safeReadText(filename) {
67
- try {
68
- const stat = lstatSync(filename);
69
- if (!stat.isFile() || stat.size > TEXT_LIMIT_BYTES) {
70
- return null;
71
- }
72
- return readFileSync(filename, "utf8");
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- function safeLabel(value) {
79
- return String(value)
80
- .replace(/[\u0000-\u001f\u007f]+/g, " ")
81
- .replace(/\s+/g, " ")
82
- .trim()
83
- .slice(0, 120);
84
- }
85
-
86
- function isPlainObject(value) {
87
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
88
- }
89
-
90
- function isCanonicalTimestamp(value) {
91
- if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) {
92
- return false;
93
- }
94
- try {
95
- return new Date(value).toISOString() === value;
96
- } catch {
97
- return false;
98
- }
99
- }
100
-
101
- function isSafeManifestPath(target, value) {
102
- if (
103
- typeof value !== "string" ||
104
- value.length === 0 ||
105
- value.endsWith("/") ||
106
- value.includes("\\") ||
107
- /[\u0000-\u001f\u007f]/.test(value) ||
108
- path.posix.isAbsolute(value) ||
109
- path.win32.isAbsolute(value) ||
110
- path.posix.normalize(value) !== value
111
- ) {
112
- return false;
113
- }
114
- const segments = value.split("/");
115
- if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
116
- return false;
117
- }
118
-
119
- const resolved = path.resolve(target, ...segments);
120
- const relative = path.relative(target, resolved);
121
- if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
122
- return false;
123
- }
124
-
125
- let current = target;
126
- for (const segment of segments) {
127
- current = path.join(current, segment);
128
- try {
129
- if (lstatSync(current).isSymbolicLink()) {
130
- return false;
131
- }
132
- } catch (error) {
133
- if (error.code === "ENOENT" || error.code === "ENOTDIR") {
134
- break;
135
- }
136
- return false;
137
- }
138
- }
139
- return true;
140
- }
141
-
142
- export function validateManifest(data, target) {
143
- const issues = [];
144
- const seen = new Set();
145
- const addIssue = (code, message) => {
146
- if (!seen.has(code)) {
147
- seen.add(code);
148
- issues.push({ code, message });
149
- }
150
- };
151
-
152
- if (!isPlainObject(data)) {
153
- addIssue("manifest.invalid_root", "Manifest root must be a JSON object.");
154
- return issues;
155
- }
156
- if (!Number.isInteger(data.schemaVersion)) {
157
- addIssue("manifest.invalid_schema_version", "Manifest schemaVersion must be an integer.");
158
- return issues;
159
- }
160
- if (!SUPPORTED_MANIFEST_SCHEMA_VERSIONS.includes(data.schemaVersion)) {
161
- return issues;
162
- }
163
- const schemaVersion = data.schemaVersion;
164
-
165
- if (Object.keys(data).some((key) => !MANIFEST_TOP_LEVEL_KEYS.has(key))) {
166
- addIssue(
167
- "manifest.unknown_field",
168
- `Manifest contains a field not defined by schema ${schemaVersion}.`,
169
- );
170
- }
171
- if (typeof data.scaffoldVersion !== "string" || !/^v\d+\.\d+(?:\.\d+)?$/.test(data.scaffoldVersion)) {
172
- addIssue("manifest.invalid_scaffold_version", "Manifest scaffoldVersion is missing or invalid.");
173
- }
174
- // Three-part SemVer with an optional pre-release tag: v2 ships beta package
175
- // versions (e.g. 2.0.0-beta.1) while the v1 scaffold surface stays frozen.
176
- if (
177
- typeof data.cliVersion !== "string" ||
178
- !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/.test(data.cliVersion)
179
- ) {
180
- addIssue("manifest.invalid_cli_version", "Manifest cliVersion is missing or invalid.");
181
- }
182
- if (!["default", "compact"].includes(data.layout)) {
183
- addIssue("manifest.invalid_layout", "Manifest layout must be default or compact.");
184
- }
185
- if (!isCanonicalTimestamp(data.installedAt)) {
186
- addIssue("manifest.invalid_installed_at", "Manifest installedAt must be a canonical UTC timestamp.");
187
- }
188
-
189
- if (!isPlainObject(data.files)) {
190
- addIssue("manifest.invalid_files", "Manifest files must be an object record.");
191
- } else {
192
- const entries = Object.entries(data.files);
193
- if (entries.length > MAX_MANIFEST_FILES) {
194
- addIssue("manifest.too_many_files", `Manifest files cannot exceed ${MAX_MANIFEST_FILES} entries.`);
195
- }
196
- for (const [filename, record] of entries.slice(0, MAX_MANIFEST_FILES)) {
197
- if (!isSafeManifestPath(target, filename)) {
198
- addIssue(
199
- "manifest.invalid_file_path",
200
- "Manifest file paths must be normalized repository-relative paths without symlink traversal.",
201
- );
202
- }
203
- if (
204
- schemaVersion === 2 &&
205
- [...MANIFEST_PATHS, ".gitignore", "gitignore.template"].includes(filename)
206
- ) {
207
- addIssue(
208
- "manifest.reserved_file_path",
209
- "Schema 2 files must exclude the manifest, source-only ignore template, and .gitignore host.",
210
- );
211
- }
212
- if (!isPlainObject(record)) {
213
- addIssue("manifest.invalid_file_record", "Each manifest file entry must be an object record.");
214
- continue;
215
- }
216
- if (Object.keys(record).some((key) => !MANIFEST_FILE_KEYS.has(key))) {
217
- addIssue("manifest.invalid_file_record", "Manifest file entries contain only policy and baselineSha256.");
218
- }
219
- if (!MANIFEST_POLICIES[schemaVersion].has(record.policy)) {
220
- addIssue("manifest.invalid_file_policy", "Manifest file policy is missing or unsupported.");
221
- }
222
- if (typeof record.baselineSha256 !== "string" || !/^[0-9a-f]{64}$/.test(record.baselineSha256)) {
223
- addIssue(
224
- "manifest.invalid_file_hash",
225
- "Manifest baselineSha256 must be exactly 64 lowercase hexadecimal characters.",
226
- );
227
- }
228
- }
229
- }
230
-
231
- if (!isPlainObject(data.integrations)) {
232
- addIssue("manifest.invalid_integrations", "Manifest integrations must be an object record.");
233
- } else {
234
- const keys = Object.keys(data.integrations);
235
- if (
236
- keys.length !== MANIFEST_INTEGRATION_KEYS.length ||
237
- MANIFEST_INTEGRATION_KEYS.some((key) => !Object.hasOwn(data.integrations, key)) ||
238
- keys.some((key) => !MANIFEST_INTEGRATION_KEYS.includes(key))
239
- ) {
240
- addIssue(
241
- "manifest.invalid_integrations",
242
- `Schema ${schemaVersion} integrations must contain exactly gitignore and hooks.`,
243
- );
244
- }
245
- if (schemaVersion === 1) {
246
- if (MANIFEST_INTEGRATION_KEYS.some((key) => data.integrations[key] !== null)) {
247
- addIssue(
248
- "manifest.invalid_integration_record",
249
- "Schema 1 integration records must be null until a write-capable schema defines them.",
250
- );
251
- }
252
- } else {
253
- if (data.integrations.hooks !== null) {
254
- addIssue(
255
- "manifest.invalid_integration_record",
256
- "Schema 2 hooks must remain null; hook installation is a manual Skill step.",
257
- );
258
- }
259
- const gitignore = data.integrations.gitignore;
260
- if (gitignore !== null) {
261
- if (!isPlainObject(gitignore)) {
262
- addIssue(
263
- "manifest.invalid_gitignore_integration",
264
- "Schema 2 gitignore integration must be null or an object record.",
265
- );
266
- } else {
267
- const keys = Object.keys(gitignore);
268
- if (
269
- keys.length !== MANIFEST_GITIGNORE_KEYS.size ||
270
- keys.some((key) => !MANIFEST_GITIGNORE_KEYS.has(key))
271
- ) {
272
- addIssue(
273
- "manifest.invalid_gitignore_integration",
274
- "Schema 2 gitignore integration must contain exactly path, beginMarker, endMarker, and baselineSha256.",
275
- );
276
- }
277
- if (gitignore.path !== ".gitignore" || !isSafeManifestPath(target, gitignore.path)) {
278
- addIssue(
279
- "manifest.invalid_gitignore_path",
280
- "Schema 2 gitignore integration path must be the safe .gitignore host path.",
281
- );
282
- }
283
- if (!GITIGNORE_MARKER_PAIRS.some(
284
- ([beginMarker, endMarker]) =>
285
- gitignore.beginMarker === beginMarker && gitignore.endMarker === endMarker,
286
- )) {
287
- addIssue(
288
- "manifest.invalid_gitignore_markers",
289
- "Schema 2 gitignore integration must use one complete BuildBeat or legacy Solobaton marker pair.",
290
- );
291
- }
292
- if (
293
- typeof gitignore.baselineSha256 !== "string" ||
294
- !/^[0-9a-f]{64}$/.test(gitignore.baselineSha256)
295
- ) {
296
- addIssue(
297
- "manifest.invalid_gitignore_hash",
298
- "Schema 2 gitignore baselineSha256 must be exactly 64 lowercase hexadecimal characters.",
299
- );
300
- }
301
- }
302
- }
303
- }
304
- }
305
-
306
- return issues;
307
- }
308
-
309
- function walk(root, { maxDepth = MAX_SCAN_DEPTH, ignore = true } = {}) {
310
- const files = [];
311
- const directories = [];
312
- const symlinks = [];
313
- const warnings = [];
314
- let entriesSeen = 0;
315
- let truncated = false;
316
-
317
- function visit(current, relative, depth) {
318
- let entries;
319
- try {
320
- entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
321
- a.name.localeCompare(b.name),
322
- );
323
- } catch (error) {
324
- warnings.push({ path: posixPath(relative || "."), reason: error.code || "unreadable" });
325
- return;
326
- }
327
-
328
- for (const entry of entries) {
329
- entriesSeen += 1;
330
- if (entriesSeen > MAX_SCAN_ENTRIES) {
331
- truncated = true;
332
- return;
333
- }
334
-
335
- const childRelative = relative ? path.join(relative, entry.name) : entry.name;
336
- const normalized = posixPath(childRelative);
337
- const child = path.join(current, entry.name);
338
-
339
- if (entry.isSymbolicLink()) {
340
- symlinks.push(normalized);
341
- continue;
342
- }
343
- if (entry.isDirectory()) {
344
- directories.push(normalized);
345
- if (depth >= maxDepth) {
346
- continue;
347
- }
348
- if (ignore && IGNORED_SCAN_DIRECTORIES.has(entry.name)) {
349
- continue;
350
- }
351
- visit(child, childRelative, depth + 1);
352
- } else if (entry.isFile()) {
353
- files.push(normalized);
354
- }
355
- }
356
- }
357
-
358
- visit(root, "", 0);
359
- return { directories, files, symlinks, warnings, entriesSeen, truncated };
360
- }
361
-
362
- export function listTemplateFiles() {
363
- return walk(TEMPLATE_ROOT, { maxDepth: 12, ignore: false }).files;
364
- }
365
-
366
- export function templateTarget(templatePath, layout) {
367
- if (templatePath === "gitignore.template") {
368
- return ".gitignore";
369
- }
370
- if (layout === "compact") {
371
- if (templatePath.startsWith("scripts/")) {
372
- return `pm/${templatePath}`;
373
- }
374
- if (templatePath === "BUILDBEAT.md" || templatePath === "指挥台.md") {
375
- return `pm/${templatePath}`;
376
- }
377
- }
378
- return templatePath;
379
- }
380
-
381
- export function plannedFiles(layout) {
382
- return listTemplateFiles()
383
- .filter(
384
- (templatePath) =>
385
- !OPTIONAL_TEMPLATE_PREFIXES.some((prefix) => templatePath.startsWith(prefix)),
386
- )
387
- .map((templatePath) => ({
388
- template: templatePath,
389
- target: templateTarget(templatePath, layout),
390
- policy: filePolicy(templatePath),
391
- }));
392
- }
393
-
394
- function detectPlaceholders(target, layout) {
395
- const findings = [];
396
- for (const item of plannedFiles(layout)) {
397
- const requiredTokens = RENDER_REQUIRED_PLACEHOLDERS[item.template] || [];
398
- if (requiredTokens.length === 0) {
399
- continue;
400
- }
401
- const text = safeReadText(path.join(target, item.target));
402
- if (text === null) {
403
- continue;
404
- }
405
- const tokens = requiredTokens.filter((token) => text.includes(token));
406
- if (tokens.length > 0) {
407
- findings.push({ path: item.target, tokens });
408
- }
409
- }
410
- return findings;
411
- }
412
-
413
- function parseVersion(markerPath) {
414
- const text = safeReadText(markerPath);
415
- if (text === null) {
416
- return null;
417
- }
418
- const match = text.match(/本项目使用 (?:BuildBeat|Solobaton) `(v\d+\.\d+(?:\.\d+)?)`/);
419
- return match ? match[1] : null;
420
- }
421
-
422
- function detectInstallation(target) {
423
- const markers = [
424
- { layout: "default", namespace: "buildbeat", relative: MARKER_FILE },
425
- { layout: "compact", namespace: "buildbeat", relative: `pm/${MARKER_FILE}` },
426
- { layout: "default", namespace: "solobaton", relative: LEGACY_MARKER_FILE },
427
- { layout: "compact", namespace: "solobaton", relative: `pm/${LEGACY_MARKER_FILE}` },
428
- ];
429
- const presentMarkers = markers.filter((item) => existsSync(path.join(target, item.relative)));
430
- const defaultEvidence =
431
- presentMarkers.some((item) => item.layout === "default") ||
432
- existsSync(path.join(target, "scripts", "bus-check.sh"));
433
- const compactEvidence =
434
- presentMarkers.some((item) => item.layout === "compact") ||
435
- existsSync(path.join(target, "pm", "scripts", "bus-check.sh"));
436
-
437
- if ((defaultEvidence && compactEvidence) || presentMarkers.length > 1) {
438
- return { state: "mixed", layout: null, version: null, namespace: null, markerPath: null };
439
- }
440
- if (presentMarkers.length === 1) {
441
- const marker = presentMarkers[0];
442
- return {
443
- state: "installed",
444
- layout: marker.layout,
445
- version: parseVersion(path.join(target, marker.relative)),
446
- namespace: marker.namespace,
447
- markerPath: marker.relative,
448
- };
449
- }
450
- if (defaultEvidence) {
451
- return { state: "partial", layout: "default", version: null, namespace: null, markerPath: null };
452
- }
453
- if (compactEvidence) {
454
- return { state: "partial", layout: "compact", version: null, namespace: null, markerPath: null };
455
- }
456
- return { state: "not-installed", layout: null, version: null, namespace: null, markerPath: null };
457
- }
458
-
459
- function inspectManifest(target) {
460
- const presentPaths = MANIFEST_PATHS.filter((relative) => existsSync(path.join(target, relative)));
461
- if (presentPaths.length === 0) {
462
- return { state: "missing", path: MANIFEST_PATH };
463
- }
464
- if (presentPaths.length > 1) {
465
- return { state: "ambiguous", path: MANIFEST_PATH, paths: presentPaths };
466
- }
467
- const relativePath = presentPaths[0];
468
- const filename = path.join(target, relativePath);
469
- const text = safeReadText(filename);
470
- if (text === null) {
471
- return { state: "invalid", path: relativePath, reason: "unreadable" };
472
- }
473
- try {
474
- const data = JSON.parse(text);
475
- const root = isPlainObject(data) ? data : {};
476
- const validationIssues = validateManifest(data, target);
477
- return {
478
- state: "present",
479
- path: relativePath,
480
- namespace: relativePath === LEGACY_MANIFEST_PATH ? "solobaton" : "buildbeat",
481
- schemaVersion: Number.isInteger(root.schemaVersion) ? root.schemaVersion : null,
482
- scaffoldVersion:
483
- typeof root.scaffoldVersion === "string" ? safeLabel(root.scaffoldVersion).slice(0, 40) : null,
484
- cliVersion: typeof root.cliVersion === "string" ? safeLabel(root.cliVersion).slice(0, 40) : null,
485
- layout: typeof root.layout === "string" ? safeLabel(root.layout).slice(0, 40) : null,
486
- installedAt: typeof root.installedAt === "string" ? safeLabel(root.installedAt).slice(0, 40) : null,
487
- validationIssues,
488
- };
489
- } catch {
490
- return { state: "invalid", path: relativePath, reason: "invalid-json" };
491
- }
492
- }
493
-
494
- function commandVersion(command, args = ["--version"]) {
495
- const result = spawnSync(command, args, {
496
- encoding: "utf8",
497
- shell: false,
498
- timeout: 3000,
499
- });
500
- if (result.error || result.status !== 0) {
501
- return { available: false, version: null };
502
- }
503
- const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
504
- return { available: true, version: output.split(/\r?\n/, 1)[0] || null };
505
- }
506
-
507
- let dependencyCache = null;
508
-
509
- function dependencyStatus() {
510
- if (dependencyCache !== null) {
511
- return dependencyCache;
512
- }
513
- dependencyCache = {
514
- bash: commandVersion("bash"),
515
- git: commandVersion("git"),
516
- gitleaks: commandVersion("gitleaks", ["version"]),
517
- jq: commandVersion("jq"),
518
- python3: commandVersion("python3"),
519
- };
520
- return dependencyCache;
521
- }
522
-
523
- function inferProjectName(target, files) {
524
- const packagePath = files.find((filename) => filename === "package.json");
525
- if (packagePath) {
526
- const text = safeReadText(path.join(target, packagePath));
527
- if (text !== null) {
528
- try {
529
- const value = JSON.parse(text).name;
530
- if (typeof value === "string" && value.trim()) {
531
- return { value: safeLabel(value), source: packagePath };
532
- }
533
- } catch {
534
- // A broken package file is reported through scan evidence, not echoed.
535
- }
536
- }
537
- }
538
- const readme = safeReadText(path.join(target, "README.md"));
539
- const heading = readme?.match(/^#\s+(.+)$/m)?.[1]?.trim();
540
- if (heading) {
541
- return { value: safeLabel(heading), source: "README.md" };
542
- }
543
- return { value: safeLabel(path.basename(target)), source: "directory-name" };
544
- }
545
-
546
- function browserExtensionHasUi(target, files) {
547
- const uiKeys = [
548
- "action",
549
- "browser_action",
550
- "content_scripts",
551
- "options_page",
552
- "options_ui",
553
- "page_action",
554
- "side_panel",
555
- ];
556
-
557
- for (const filename of files.filter(
558
- (item) => path.basename(item).toLowerCase() === "manifest.json",
559
- )) {
560
- const text = safeReadText(path.join(target, filename));
561
- if (text === null) {
562
- continue;
563
- }
564
- try {
565
- const data = JSON.parse(text);
566
- if (
567
- data &&
568
- typeof data === "object" &&
569
- !Array.isArray(data) &&
570
- [2, 3].includes(data.manifest_version) &&
571
- uiKeys.some((key) => Object.hasOwn(data, key))
572
- ) {
573
- return true;
574
- }
575
- } catch {
576
- // A malformed or unrelated manifest is not a trustworthy UI signal.
577
- }
578
- }
579
- return false;
580
- }
581
-
582
- function packageSignals(target, files) {
583
- const uiPackages = new Set([
584
- "@angular/core",
585
- "@sveltejs/kit",
586
- "next",
587
- "nuxt",
588
- "react",
589
- "svelte",
590
- "vite",
591
- "vue",
592
- ]);
593
- let hasUi =
594
- files.some((filename) => /(?:^|\/)index\.html$/i.test(filename)) ||
595
- browserExtensionHasUi(target, files);
596
- let hasTests = files.some((filename) =>
597
- /(?:^|\/)(?:test|tests|spec)(?:\/|$)|\.(?:test|spec)\.[^.\/]+$/i.test(filename),
598
- );
599
-
600
- for (const filename of files.filter((item) => item.endsWith("package.json"))) {
601
- const text = safeReadText(path.join(target, filename));
602
- if (text === null) {
603
- continue;
604
- }
605
- try {
606
- const data = JSON.parse(text);
607
- const dependencies = {
608
- ...(data.dependencies || {}),
609
- ...(data.devDependencies || {}),
610
- };
611
- hasUi ||= Object.keys(dependencies).some((name) => uiPackages.has(name));
612
- const testScript = data.scripts?.test;
613
- hasTests ||=
614
- typeof testScript === "string" &&
615
- testScript.trim() !== "" &&
616
- !/no test specified/i.test(testScript);
617
- } catch {
618
- // Only structural signals are needed here.
619
- }
620
- }
621
- return { hasTests, hasUi };
622
- }
623
-
624
- function hookStatus(target, hasGit) {
625
- const directHook = path.join(target, ".git", "hooks", "pre-commit");
626
- let hooksPath = null;
627
- if (hasGit) {
628
- const result = spawnSync("git", ["-C", target, "config", "--get", "core.hooksPath"], {
629
- encoding: "utf8",
630
- shell: false,
631
- timeout: 3000,
632
- });
633
- if (result.status === 0 && result.stdout.trim()) {
634
- hooksPath = result.stdout.trim();
635
- }
636
- }
637
- const directExists = existsSync(directHook);
638
- let directExecutable = directExists;
639
- if (directExists && process.platform !== "win32") {
640
- try {
641
- accessSync(directHook, fsConstants.X_OK);
642
- } catch {
643
- directExecutable = false;
644
- }
645
- }
646
- const mode = hooksPath
647
- ? "core.hooksPath"
648
- : directExists && directExecutable
649
- ? "direct"
650
- : directExists
651
- ? "direct-non-executable"
652
- : "missing";
653
- return {
654
- configured: Boolean(hooksPath) || directExecutable,
655
- mode,
656
- path: hooksPath || (directExists ? ".git/hooks/pre-commit" : null),
657
- };
658
- }
659
-
660
- function executableStatus(target, layout) {
661
- if (process.platform === "win32") {
662
- return { checked: false, missing: [], nonExecutable: [] };
663
- }
664
- const base = layout === "compact" ? path.join("pm", "scripts") : "scripts";
665
- const missing = [];
666
- const nonExecutable = [];
667
- for (const name of SCRIPT_NAMES) {
668
- const relative = posixPath(path.join(base, name));
669
- const filename = path.join(target, relative);
670
- if (!existsSync(filename)) {
671
- missing.push(relative);
672
- continue;
673
- }
674
- try {
675
- accessSync(filename, fsConstants.X_OK);
676
- } catch {
677
- nonExecutable.push(relative);
678
- }
679
- }
680
- return { checked: true, missing, nonExecutable };
681
- }
682
-
683
- function gitWorktreeStatus(target, hasGit) {
684
- if (!hasGit) {
685
- return { state: "not-initialized", changes: 0 };
686
- }
687
- const result = spawnSync(
688
- "git",
689
- ["-C", target, "status", "--porcelain=v1", "--untracked-files=all"],
690
- {
691
- encoding: "utf8",
692
- shell: false,
693
- timeout: 5000,
694
- },
695
- );
696
- if (result.error || result.status !== 0) {
697
- return { state: "unavailable", changes: 0 };
698
- }
699
- const changes = result.stdout
700
- .split(/\r?\n/)
701
- .filter((line) => line.length > 0)
702
- .length;
703
- return { state: changes === 0 ? "clean" : "dirty", changes };
704
- }
705
-
706
- function countOccurrences(text, token) {
707
- let count = 0;
708
- let offset = 0;
709
- while (true) {
710
- const index = text.indexOf(token, offset);
711
- if (index === -1) {
712
- return count;
713
- }
714
- count += 1;
715
- offset = index + token.length;
716
- }
717
- }
718
-
719
- function gitignoreStatus(target) {
720
- const filename = path.join(target, ".gitignore");
721
- if (!existsSync(filename)) {
722
- return { state: "missing", beginMarkers: 0, endMarkers: 0 };
723
- }
724
- try {
725
- const stat = lstatSync(filename);
726
- if (!stat.isFile() || stat.isSymbolicLink()) {
727
- return { state: "unsafe", beginMarkers: 0, endMarkers: 0 };
728
- }
729
- const text = readFileSync(filename, "utf8");
730
- const beginMarkers = GITIGNORE_MARKER_PAIRS.reduce(
731
- (count, [beginMarker]) => count + countOccurrences(text, beginMarker),
732
- 0,
733
- );
734
- const endMarkers = GITIGNORE_MARKER_PAIRS.reduce(
735
- (count, [, endMarker]) => count + countOccurrences(text, endMarker),
736
- 0,
737
- );
738
- return { state: "present", beginMarkers, endMarkers };
739
- } catch {
740
- return { state: "unsafe", beginMarkers: 0, endMarkers: 0 };
741
- }
742
- }
743
-
744
- function plannedPathCollision(target, item) {
745
- const segments = item.target.split("/");
746
- let current = target;
747
- for (const segment of segments.slice(0, -1)) {
748
- current = path.join(current, segment);
749
- if (!existsSync(current)) {
750
- break;
751
- }
752
- try {
753
- const stat = lstatSync(current);
754
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
755
- return true;
756
- }
757
- } catch {
758
- return true;
759
- }
760
- }
761
- return item.policy !== FILE_POLICIES.MERGE_ONLY && existsSync(path.join(target, item.target));
762
- }
763
-
764
- export function inspectProject(
765
- targetInput,
766
- { collisionLayout = "default", includeDependencies = true } = {},
767
- ) {
768
- const target = path.resolve(targetInput);
769
- const exists = existsSync(target);
770
- if (!exists) {
771
- return {
772
- target,
773
- exists: false,
774
- installation: {
775
- state: "not-installed",
776
- layout: null,
777
- version: null,
778
- namespace: null,
779
- markerPath: null,
780
- },
781
- manifest: { state: "missing", path: MANIFEST_PATH },
782
- scan: { files: [], directories: [], symlinks: [], warnings: [], entriesSeen: 0, truncated: false },
783
- repositories: [],
784
- deploymentMarkers: [],
785
- projectName: { value: path.basename(target), source: "directory-name" },
786
- signals: { hasGit: false, hasTests: false, hasUi: false, nonEmpty: false },
787
- gitWorktree: { state: "not-initialized", changes: 0 },
788
- gitignore: { state: "missing", beginMarkers: 0, endMarkers: 0 },
789
- collisions: [],
790
- placeholders: [],
791
- dependencies: includeDependencies ? dependencyStatus() : null,
792
- hook: { configured: false, mode: "missing", path: null },
793
- executables: { checked: process.platform !== "win32", missing: [], nonExecutable: [] },
794
- };
795
- }
796
-
797
- if (!lstatSync(target).isDirectory()) {
798
- throw new Error(`Target is not a directory: ${target}`);
799
- }
800
-
801
- const scan = walk(target);
802
- const installation = detectInstallation(target);
803
- const hasGit = existsSync(path.join(target, ".git"));
804
- const repositories = scan.directories
805
- .filter((relative) => existsSync(path.join(target, relative, ".git")))
806
- .sort();
807
- const deploymentMarkers = scan.files
808
- .filter((filename) =>
809
- /(?:^|\/)(?:Dockerfile(?:\.[^/]*)?|compose\.ya?ml|docker-compose\.ya?ml|fly\.toml|vercel\.json|app\.yaml)$/i.test(
810
- filename,
811
- ),
812
- )
813
- .sort();
814
- const signals = packageSignals(target, scan.files);
815
- const collisions = plannedFiles(collisionLayout)
816
- .filter((item) => plannedPathCollision(target, item))
817
- .map((item) => item.target)
818
- .sort();
819
- const layoutForInspection = installation.layout || collisionLayout;
820
- const executables = executableStatus(target, layoutForInspection);
821
-
822
- return {
823
- target,
824
- exists: true,
825
- installation,
826
- manifest: inspectManifest(target),
827
- scan,
828
- repositories,
829
- deploymentMarkers,
830
- projectName: inferProjectName(target, scan.files),
831
- signals: {
832
- ...signals,
833
- hasGit,
834
- nonEmpty: scan.files.length > 0 || scan.directories.some((item) => item !== ".git"),
835
- },
836
- gitWorktree: gitWorktreeStatus(target, hasGit),
837
- gitignore: gitignoreStatus(target),
838
- collisions,
839
- placeholders: detectPlaceholders(target, layoutForInspection),
840
- dependencies: includeDependencies ? dependencyStatus() : null,
841
- hook: hookStatus(target, hasGit),
842
- executables,
843
- };
844
- }