svharness 0.8.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 (134) hide show
  1. package/README.md +531 -0
  2. package/bin/cli.js +3 -0
  3. package/dist/adapters/_frontmatter.js +24 -0
  4. package/dist/adapters/claude-code.js +12 -0
  5. package/dist/adapters/codechat.js +12 -0
  6. package/dist/adapters/cursor.js +19 -0
  7. package/dist/adapters/generic.js +19 -0
  8. package/dist/adapters/index.js +26 -0
  9. package/dist/adapters/qoder.js +12 -0
  10. package/dist/commands/apply.js +272 -0
  11. package/dist/commands/init.js +420 -0
  12. package/dist/core/agent-injector.js +192 -0
  13. package/dist/core/next-steps.js +91 -0
  14. package/dist/core/render-meta.js +81 -0
  15. package/dist/core/repomix-pack.js +54 -0
  16. package/dist/core/scaffold.js +93 -0
  17. package/dist/core/state.js +80 -0
  18. package/dist/index.js +239 -0
  19. package/dist/types.js +5 -0
  20. package/dist/utils/baseline-copy.js +591 -0
  21. package/dist/utils/baseline-defaults.js +106 -0
  22. package/dist/utils/logger.js +56 -0
  23. package/dist/utils/validate-args.js +132 -0
  24. package/dist/utils/version.js +23 -0
  25. package/dist/wiki/abort.js +30 -0
  26. package/dist/wiki/config.js +79 -0
  27. package/dist/wiki/defaults.js +16 -0
  28. package/dist/wiki/envLoader.js +78 -0
  29. package/dist/wiki/index.js +29 -0
  30. package/dist/wiki/openaiCompat.js +219 -0
  31. package/dist/wiki/repowikiCanonicalSections.js +67 -0
  32. package/dist/wiki/repowikiCheckpoint.js +106 -0
  33. package/dist/wiki/repowikiConfig.js +9 -0
  34. package/dist/wiki/repowikiGit.js +73 -0
  35. package/dist/wiki/repowikiIndexer.js +824 -0
  36. package/dist/wiki/repowikiMarkdownPost.js +123 -0
  37. package/dist/wiki/repowikiMetadataContent.js +64 -0
  38. package/dist/wiki/repowikiMetadataJson.js +15 -0
  39. package/dist/wiki/repowikiScanner.js +156 -0
  40. package/dist/wiki/repowikiStructureNav.js +286 -0
  41. package/dist/wiki/repowikiStructureNormalize.js +218 -0
  42. package/dist/wiki/wikiStructureXml.js +316 -0
  43. package/dist/wiki/wikiTasksWriter.js +127 -0
  44. package/package.json +57 -0
  45. package/templates/_shared/apply-skills/harness-apply-skills-main.md +91 -0
  46. package/templates/_shared/build-rules/harness-build-rule-agent-agnostic.md +35 -0
  47. package/templates/_shared/build-rules/harness-build-rule-chinese-only.md +49 -0
  48. package/templates/_shared/build-rules/harness-build-rule-memory-write.md +31 -0
  49. package/templates/_shared/build-rules/harness-build-rule-orchestrator-flow.md +25 -0
  50. package/templates/_shared/build-rules/harness-build-rule-skills-tasks-output.md +35 -0
  51. package/templates/_shared/build-rules/harness-build-rule-specs-schema.md +32 -0
  52. package/templates/_shared/build-rules/harness-build-rule-user-interaction.md +63 -0
  53. package/templates/_shared/build-skills/harness-build-skill-knowledge-builder.md +120 -0
  54. package/templates/_shared/build-skills/harness-build-skill-orchestrator.md +87 -0
  55. package/templates/_shared/build-skills/harness-build-skill-spec-builder.md +85 -0
  56. package/templates/_shared/build-skills/harness-build-skill-wiki-writer.md +77 -0
  57. package/templates/_shared/meta/AGENTS.md.ejs +53 -0
  58. package/templates/_shared/meta/CHANGELOG.md.ejs +15 -0
  59. package/templates/_shared/meta/README.md.ejs +51 -0
  60. package/templates/_shared/meta/VERSION.ejs +1 -0
  61. package/templates/_shared/meta/harness.yaml.ejs +52 -0
  62. package/templates/_shared/skeleton/agent-env/memory/categories/.gitkeep +1 -0
  63. package/templates/_shared/skeleton/agent-env/memory/inbox/.gitkeep +1 -0
  64. package/templates/_shared/skeleton/agent-env/skills/.gitkeep +1 -0
  65. package/templates/_shared/skeleton/agent-env/tools/.gitkeep +1 -0
  66. package/templates/_shared/skeleton/assets/baseline/code/.gitkeep +1 -0
  67. package/templates/_shared/skeleton/assets/baseline/repomix/.gitkeep +1 -0
  68. package/templates/_shared/skeleton/assets/baseline/wiki/.gitkeep +1 -0
  69. package/templates/_shared/skeleton/assets/raw/.gitkeep +1 -0
  70. package/templates/_shared/skeleton/assets/requirements/.gitkeep +1 -0
  71. package/templates/_shared/skeleton/commands/install/.gitkeep +1 -0
  72. package/templates/_shared/skeleton/commands/update/.gitkeep +1 -0
  73. package/templates/_shared/skeleton/specs/behavior/schema.json +39 -0
  74. package/templates/_shared/skeleton/specs/interfaces/schema.json +38 -0
  75. package/templates/_shared/skeleton/specs/signals/schema.json +37 -0
  76. package/templates/_shared/skeleton/specs/ui/schema.json +44 -0
  77. package/templates/_shared/skeleton/tasks/templates/.gitkeep +0 -0
  78. package/templates/android-compose/skeleton/agent-env/rules/harness-compose-mandatory.mdc +49 -0
  79. package/templates/android-compose/skeleton/agent-env/rules/harness-coroutines-scope.mdc +52 -0
  80. package/templates/android-compose/skeleton/agent-env/rules/harness-hilt-injection.mdc +47 -0
  81. package/templates/android-compose/skeleton/agent-env/rules/harness-mvi-layering.mdc +58 -0
  82. package/templates/android-compose/skeleton/agent-env/skills/harness-android-architecture/SKILL.md +260 -0
  83. package/templates/android-compose/skeleton/agent-env/skills/harness-android-architecture/references/gradle-module-patterns.md +66 -0
  84. package/templates/android-compose/skeleton/agent-env/skills/harness-android-architecture/references/implementation-checklist.md +45 -0
  85. package/templates/android-compose/skeleton/agent-env/skills/harness-android-architecture/references/udf-data-flow.md +80 -0
  86. package/templates/android-compose/skeleton/agent-env/skills/harness-android-cli/SKILL.md +79 -0
  87. package/templates/android-compose/skeleton/agent-env/skills/harness-android-cli/references/interact.md +83 -0
  88. package/templates/android-compose/skeleton/agent-env/skills/harness-android-cli/references/journeys.md +97 -0
  89. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/SKILL.md +162 -0
  90. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/references/canonical-sources.md +116 -0
  91. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/references/diagnostics.md +182 -0
  92. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/references/report-template.md +135 -0
  93. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/references/scoring.md +277 -0
  94. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/references/search-playbook.md +303 -0
  95. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-audit/scripts/compose-reports.init.gradle +58 -0
  96. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-state/SKILL.md +196 -0
  97. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-ui/SKILL.md +192 -0
  98. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-ui/references/composable-api-guide.md +123 -0
  99. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-ui/references/performance-recipes.md +97 -0
  100. package/templates/android-compose/skeleton/agent-env/skills/harness-compose-ui/references/state-patterns.md +93 -0
  101. package/templates/android-compose/skeleton/agent-env/skills/harness-kotlin-coroutines/SKILL.md +167 -0
  102. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/SKILL.md +45 -0
  103. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/references/CONFIGURATION.md +44 -0
  104. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/references/KEEP-RULES-IMPACT-HIERARCHY.md +83 -0
  105. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/references/REDUNDANT-RULES.md +222 -0
  106. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/references/REFLECTION-GUIDE.md +139 -0
  107. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/references/android/topic/performance/app-optimization/enable-app-optimization.md +176 -0
  108. package/templates/android-compose/skeleton/agent-env/skills/harness-r8-analyzer/references/android/training/testing/other-components/ui-automator.md +312 -0
  109. package/templates/android-compose/skeleton/agent-env/skills/harness-xml-to-compose/SKILL.md +87 -0
  110. package/templates/android-compose/skeleton/agent-env/skills/harness-xml-to-compose/references/analysis-of-the-project-and-layout.md +42 -0
  111. package/templates/android-compose/skeleton/agent-env/skills/harness-xml-to-compose/references/android/develop/ui/compose/designsystems/migrate-xml-theme-to-compose.md +168 -0
  112. package/templates/android-compose/skeleton/agent-env/skills/harness-xml-to-compose/references/android/develop/ui/compose/setup-compose-dependencies-and-compiler.md +183 -0
  113. package/templates/android-compose/skeleton/agent-env/skills/harness-xml-to-compose/references/identify-optimal-xml-candidate.md +31 -0
  114. package/templates/android-compose/skeleton/agent-env/skills/harness-xml-to-compose/references/xml-layout-migration.md +86 -0
  115. package/templates/android-xml/skeleton/agent-env/rules/seed-aidl-thread.md +29 -0
  116. package/templates/android-xml/skeleton/agent-env/rules/seed-lifecycle-awareness.md +32 -0
  117. package/templates/android-xml/skeleton/agent-env/rules/seed-mvc-layering.md +32 -0
  118. package/templates/android-xml/skeleton/agent-env/rules/seed-view-binding.md +33 -0
  119. package/templates/android-xml/skeleton/agent-env/rules/seed-xml-styling.md +27 -0
  120. package/templates/cpp/skeleton/agent-env/rules/seed-cmake-explicit-sources.md +31 -0
  121. package/templates/cpp/skeleton/agent-env/rules/seed-header-guards.md +34 -0
  122. package/templates/cpp/skeleton/agent-env/rules/seed-include-layering.md +39 -0
  123. package/templates/cpp/skeleton/agent-env/rules/seed-no-cyclic-deps.md +29 -0
  124. package/templates/cpp/skeleton/agent-env/rules/seed-raii.md +30 -0
  125. package/templates/python/skeleton/agent-env/rules/seed-context-managers.md +60 -0
  126. package/templates/python/skeleton/agent-env/rules/seed-docstrings.md +48 -0
  127. package/templates/python/skeleton/agent-env/rules/seed-import-order.md +49 -0
  128. package/templates/python/skeleton/agent-env/rules/seed-pep8-naming.md +45 -0
  129. package/templates/python/skeleton/agent-env/rules/seed-type-annotations.md +43 -0
  130. package/templates/web-react/skeleton/agent-env/rules/seed-controlled-component.md +43 -0
  131. package/templates/web-react/skeleton/agent-env/rules/seed-effect-cleanup.md +43 -0
  132. package/templates/web-react/skeleton/agent-env/rules/seed-hook-rules.md +42 -0
  133. package/templates/web-react/skeleton/agent-env/rules/seed-key-stability.md +39 -0
  134. package/templates/web-react/skeleton/agent-env/rules/seed-no-props-drilling.md +43 -0
@@ -0,0 +1,591 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.EXT_BLACKLIST = exports.DIR_BLACKLIST = void 0;
40
+ exports.copyBaseline = copyBaseline;
41
+ const fs_extra_1 = __importDefault(require("fs-extra"));
42
+ const node_os_1 = __importDefault(require("node:os"));
43
+ const node_path_1 = __importDefault(require("node:path"));
44
+ const node_https_1 = __importDefault(require("node:https"));
45
+ const node_http_1 = __importDefault(require("node:http"));
46
+ const node_child_process_1 = require("node:child_process");
47
+ const promises_1 = require("node:stream/promises");
48
+ const node_fs_1 = require("node:fs");
49
+ const tar = __importStar(require("tar"));
50
+ const baseline_defaults_1 = require("./baseline-defaults");
51
+ // Re-exported for any legacy import sites.
52
+ exports.DIR_BLACKLIST = baseline_defaults_1.DEFAULT_DIR_BLACKLIST;
53
+ exports.EXT_BLACKLIST = baseline_defaults_1.DEFAULT_EXT_BLACKLIST;
54
+ async function copyBaseline(input) {
55
+ const log = input.onLog ?? (() => { });
56
+ await fs_extra_1.default.emptyDir(input.destDir);
57
+ if (input.mode === 'git') {
58
+ return copyFromGit(input, log);
59
+ }
60
+ return copyFromLocal(input, log);
61
+ }
62
+ async function copyFromLocal(input, log) {
63
+ const src = input.source;
64
+ if (!fs_extra_1.default.existsSync(src)) {
65
+ throw new Error(`baseline 本地路径不存在: ${src}`);
66
+ }
67
+ if (!fs_extra_1.default.statSync(src).isDirectory()) {
68
+ throw new Error(`baseline 本地路径不是目录: ${src}`);
69
+ }
70
+ log(`基线模式: local · 源: ${src}`);
71
+ // Prefer `git ls-files` when the source is a git working tree. This respects
72
+ // .gitignore, skips every untracked cache/artifact (e.g. Android build
73
+ // outputs, IDE caches, 9000+ untracked drawable pngs) and finishes in a
74
+ // fraction of the time a full walk would take on Windows.
75
+ if (isGitWorkingTree(src)) {
76
+ try {
77
+ log(' 检测到 .git · 走 git ls-files(仅拷贝已追踪文件)');
78
+ const stats = await copyFromLocalGitTracked(src, input.destDir, input.maxSizeKB, log);
79
+ return { mode: 'local', source: src, stats };
80
+ }
81
+ catch (err) {
82
+ log(` git ls-files 失败,回退到全量 walk:${err.message}`);
83
+ }
84
+ }
85
+ log(' 执行目录 walk(黑名单过滤 + 扩展名过滤 + 单文件大小上限)');
86
+ const stats = await walkAndCopy(src, input.destDir, input.maxSizeKB, log);
87
+ return { mode: 'local', source: src, stats };
88
+ }
89
+ function isGitWorkingTree(dir) {
90
+ // `.git` is normally a directory, but for submodules/worktrees it can be a
91
+ // regular file that contains a `gitdir:` pointer — both count as "git here".
92
+ try {
93
+ return fs_extra_1.default.existsSync(node_path_1.default.join(dir, '.git'));
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * Copy only git-tracked files from a local working tree. Path-based blacklists
101
+ * and the extension blacklist still apply as a safety net against checked-in
102
+ * binaries.
103
+ */
104
+ async function copyFromLocalGitTracked(srcRoot, destRoot, maxSizeKB, log) {
105
+ ensureGitAvailable();
106
+ const maxBytes = Math.max(1, maxSizeKB) * 1024;
107
+ const stats = { files: 0, sizeKB: 0, skipped: 0 };
108
+ let totalBytes = 0;
109
+ const res = (0, node_child_process_1.spawnSync)('git', ['-C', srcRoot, 'ls-files', '-z'], {
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ encoding: 'buffer',
112
+ maxBuffer: 256 * 1024 * 1024,
113
+ });
114
+ if (res.status !== 0) {
115
+ const stderr = (res.stderr?.toString('utf8') || '').trim();
116
+ throw new Error(`git ls-files 失败 (exit=${res.status}): ${stderr || '未知错误'}`);
117
+ }
118
+ const raw = res.stdout ?? Buffer.alloc(0);
119
+ const relPaths = raw
120
+ .toString('utf8')
121
+ .split('\0')
122
+ .map((s) => s.trim())
123
+ .filter(Boolean);
124
+ log(` git ls-files 命中 ${relPaths.length} 个已追踪文件`);
125
+ await fs_extra_1.default.ensureDir(destRoot);
126
+ const progress = createProgress(log, relPaths.length, 'local-git');
127
+ const ensuredDirs = new Set();
128
+ ensuredDirs.add(node_path_1.default.resolve(destRoot));
129
+ for (const relPosix of relPaths) {
130
+ progress.tick();
131
+ if ((0, baseline_defaults_1.anySegmentBlacklisted)(relPosix)) {
132
+ stats.skipped++;
133
+ continue;
134
+ }
135
+ const ext = node_path_1.default.extname(relPosix).toLowerCase();
136
+ if ((0, baseline_defaults_1.isBlacklistedExt)(ext)) {
137
+ stats.skipped++;
138
+ continue;
139
+ }
140
+ const absSrc = node_path_1.default.join(srcRoot, relPosix);
141
+ let size = 0;
142
+ try {
143
+ const st = fs_extra_1.default.statSync(absSrc);
144
+ if (!st.isFile()) {
145
+ stats.skipped++;
146
+ continue;
147
+ }
148
+ size = st.size;
149
+ }
150
+ catch {
151
+ stats.skipped++;
152
+ continue;
153
+ }
154
+ if (size > maxBytes) {
155
+ stats.skipped++;
156
+ continue;
157
+ }
158
+ const absDest = node_path_1.default.join(destRoot, relPosix);
159
+ const parent = node_path_1.default.dirname(absDest);
160
+ if (!ensuredDirs.has(parent)) {
161
+ await fs_extra_1.default.ensureDir(parent);
162
+ ensuredDirs.add(parent);
163
+ }
164
+ await fs_extra_1.default.copyFile(absSrc, absDest);
165
+ stats.files++;
166
+ totalBytes += size;
167
+ }
168
+ progress.finish(stats.files, stats.skipped);
169
+ stats.sizeKB = Math.round(totalBytes / 1024);
170
+ return stats;
171
+ }
172
+ /**
173
+ * Git mode: prefer tarball over HTTPS (fast, CDN-backed, no .git metadata).
174
+ * Fall back to `git clone --depth 1 --no-checkout --filter=...` when the
175
+ * host is unknown or tarball download fails (private repos, self-hosted, etc).
176
+ */
177
+ async function copyFromGit(input, log) {
178
+ const branch = input.branch;
179
+ log(`基线模式: git · 源: ${input.source}${branch ? ` · 分支: ${branch}` : ''}`);
180
+ const tarballUrl = resolveTarballUrl(input.source, branch);
181
+ if (tarballUrl) {
182
+ try {
183
+ log(` 尝试 tarball 直下: ${tarballUrl}`);
184
+ return await copyViaTarball(input, tarballUrl, log);
185
+ }
186
+ catch (err) {
187
+ log(` tarball 下载失败,回退到 git clone:${err.message}`);
188
+ }
189
+ }
190
+ else {
191
+ log(' 未识别为已知托管服务,直接走 git clone');
192
+ }
193
+ return copyViaGitClone(input, log);
194
+ }
195
+ /**
196
+ * Parse common git URL shapes into (host, owner, repo):
197
+ * - git@github.com:owner/repo.git
198
+ * - https://github.com/owner/repo(.git)?
199
+ * - ssh://git@github.com/owner/repo.git
200
+ * Only recognized, well-known public hosting providers are returned.
201
+ */
202
+ function parseRepoUrl(url) {
203
+ const stripGit = (s) => s.replace(/\.git$/i, '');
204
+ // SSH style: git@host:owner/repo(.git)
205
+ const sshMatch = /^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/i.exec(url);
206
+ if (sshMatch) {
207
+ const host = hostKind(sshMatch[1]);
208
+ if (host)
209
+ return { host, owner: sshMatch[2], repo: stripGit(sshMatch[3]) };
210
+ }
211
+ // ssh://git@host/owner/repo(.git)
212
+ const sshSchemeMatch = /^ssh:\/\/[^@]+@([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/i.exec(url);
213
+ if (sshSchemeMatch) {
214
+ const host = hostKind(sshSchemeMatch[1]);
215
+ if (host)
216
+ return { host, owner: sshSchemeMatch[2], repo: stripGit(sshSchemeMatch[3]) };
217
+ }
218
+ // https://host/owner/repo(.git)(/...)?
219
+ const httpsMatch = /^https?:\/\/([^/]+)\/([^/]+)\/([^/?#]+?)(?:\.git)?(?:[/?#].*)?$/i.exec(url);
220
+ if (httpsMatch) {
221
+ const host = hostKind(httpsMatch[1]);
222
+ if (host)
223
+ return { host, owner: httpsMatch[2], repo: stripGit(httpsMatch[3]) };
224
+ }
225
+ return undefined;
226
+ }
227
+ function hostKind(host) {
228
+ const h = host.toLowerCase();
229
+ if (h === 'github.com' || h.endsWith('.github.com'))
230
+ return 'github';
231
+ if (h === 'gitee.com' || h.endsWith('.gitee.com'))
232
+ return 'gitee';
233
+ if (h === 'gitlab.com' || h.endsWith('.gitlab.com'))
234
+ return 'gitlab';
235
+ if (h === 'bitbucket.org' || h.endsWith('.bitbucket.org'))
236
+ return 'bitbucket';
237
+ return undefined;
238
+ }
239
+ /**
240
+ * Build a tarball download URL for the given repo + branch.
241
+ * Branch defaults to 'HEAD' (GitHub/GitLab) when not provided.
242
+ */
243
+ function resolveTarballUrl(source, branch) {
244
+ const parsed = parseRepoUrl(source);
245
+ if (!parsed)
246
+ return undefined;
247
+ const { host, owner, repo } = parsed;
248
+ const ref = branch && branch.trim() ? branch.trim() : 'HEAD';
249
+ switch (host) {
250
+ case 'github':
251
+ // codeload serves gzipped tarballs directly from CDN
252
+ return ref === 'HEAD'
253
+ ? `https://codeload.github.com/${owner}/${repo}/tar.gz/HEAD`
254
+ : `https://codeload.github.com/${owner}/${repo}/tar.gz/refs/heads/${encodeURIComponent(ref)}`;
255
+ case 'gitee':
256
+ return `https://gitee.com/${owner}/${repo}/repository/archive/${encodeURIComponent(ref)}.tar.gz`;
257
+ case 'gitlab':
258
+ return `https://gitlab.com/${owner}/${repo}/-/archive/${encodeURIComponent(ref)}/${repo}-${encodeURIComponent(ref)}.tar.gz`;
259
+ case 'bitbucket':
260
+ return `https://bitbucket.org/${owner}/${repo}/get/${encodeURIComponent(ref)}.tar.gz`;
261
+ }
262
+ }
263
+ async function copyViaTarball(input, tarballUrl, log) {
264
+ const tmpRoot = fs_extra_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'svhb-tarball-'));
265
+ const extractDir = node_path_1.default.join(tmpRoot, 'extracted');
266
+ await fs_extra_1.default.ensureDir(extractDir);
267
+ const tarFile = node_path_1.default.join(tmpRoot, 'archive.tar.gz');
268
+ try {
269
+ log(` 下载到: ${tarFile}`);
270
+ await downloadToFile(tarballUrl, tarFile, log);
271
+ log(' 解压 tarball(strip 顶层目录)');
272
+ // `strip: 1` removes the single top-level directory that all git-host
273
+ // tarballs wrap their content in (e.g. "repo-branch/").
274
+ await tar.x({ file: tarFile, cwd: extractDir, strip: 1 });
275
+ log(' 按规则过滤拷贝到 baseline/code/');
276
+ const stats = await walkAndCopy(extractDir, input.destDir, input.maxSizeKB, log);
277
+ return {
278
+ mode: 'git',
279
+ source: input.source,
280
+ branch: input.branch,
281
+ stats,
282
+ };
283
+ }
284
+ finally {
285
+ try {
286
+ fs_extra_1.default.removeSync(tmpRoot);
287
+ }
288
+ catch {
289
+ /* best effort */
290
+ }
291
+ }
292
+ }
293
+ /**
294
+ * HTTPS GET with redirect follow-through. Streams the response body straight
295
+ * to disk so we never hold the whole archive in memory.
296
+ */
297
+ async function downloadToFile(url, filePath, log, redirectsLeft = 5) {
298
+ const parsed = new URL(url);
299
+ const client = parsed.protocol === 'http:' ? node_http_1.default : node_https_1.default;
300
+ await new Promise((resolve, reject) => {
301
+ const req = client.get(url, {
302
+ headers: {
303
+ 'User-Agent': 'svharnessbuild',
304
+ Accept: 'application/gzip, application/octet-stream, */*',
305
+ },
306
+ }, (res) => {
307
+ const status = res.statusCode ?? 0;
308
+ // follow redirects
309
+ if (status >= 300 && status < 400 && res.headers.location) {
310
+ res.resume();
311
+ if (redirectsLeft <= 0) {
312
+ reject(new Error(`tarball 重定向次数超限: ${url}`));
313
+ return;
314
+ }
315
+ const next = new URL(res.headers.location, url).toString();
316
+ downloadToFile(next, filePath, log, redirectsLeft - 1).then(resolve, reject);
317
+ return;
318
+ }
319
+ if (status !== 200) {
320
+ res.resume();
321
+ reject(new Error(`tarball HTTP ${status} · ${url}`));
322
+ return;
323
+ }
324
+ const total = Number(res.headers['content-length'] ?? 0);
325
+ let received = 0;
326
+ let lastPct = -1;
327
+ res.on('data', (chunk) => {
328
+ received += chunk.length;
329
+ if (total > 0) {
330
+ const pct = Math.floor((received / total) * 100);
331
+ if (pct >= lastPct + 10) {
332
+ log(` 下载进度: ${pct}% (${mib(received)} / ${mib(total)})`);
333
+ lastPct = pct;
334
+ }
335
+ }
336
+ });
337
+ (0, promises_1.pipeline)(res, (0, node_fs_1.createWriteStream)(filePath)).then(resolve, reject);
338
+ });
339
+ req.on('error', reject);
340
+ req.setTimeout(60_000, () => {
341
+ req.destroy(new Error('tarball 下载超时(60s 无数据)'));
342
+ });
343
+ });
344
+ }
345
+ function mib(bytes) {
346
+ return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // Git clone fallback path
350
+ // ---------------------------------------------------------------------------
351
+ async function copyViaGitClone(input, log) {
352
+ ensureGitAvailable();
353
+ const tmpRoot = fs_extra_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'svhb-baseline-'));
354
+ log(` 浅克隆到临时目录: ${tmpRoot}`);
355
+ try {
356
+ // Partial clone + no-checkout: don't materialize working tree, so oversize
357
+ // blobs (skipped by --filter) never trigger lazy fetch.
358
+ const blobLimit = `${Math.max(1, input.maxSizeKB)}k`;
359
+ const cloneArgs = [
360
+ 'clone',
361
+ '--depth', '1',
362
+ '--single-branch',
363
+ '--no-tags',
364
+ '--no-checkout',
365
+ '--shallow-submodules',
366
+ `--filter=blob:limit=${blobLimit}`,
367
+ '--progress',
368
+ ];
369
+ if (input.branch) {
370
+ cloneArgs.push('-b', input.branch);
371
+ }
372
+ cloneArgs.push(input.source, tmpRoot);
373
+ const cloneEnv = {
374
+ ...process.env,
375
+ GIT_TERMINAL_PROMPT: '0',
376
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o StrictHostKeyChecking=accept-new',
377
+ };
378
+ const cloneRes = (0, node_child_process_1.spawnSync)('git', cloneArgs, {
379
+ stdio: 'inherit',
380
+ env: cloneEnv,
381
+ });
382
+ if (cloneRes.error) {
383
+ throw new Error(`git clone 启动失败: ${cloneRes.error.message}`);
384
+ }
385
+ if (cloneRes.status !== 0) {
386
+ throw new Error(`git clone 失败 (exit=${cloneRes.status}). ` +
387
+ '请检查:网络连通性、SSH 密钥是否就绪、分支名是否存在、仓库是否可访问。');
388
+ }
389
+ const commit = resolveGitHead(tmpRoot);
390
+ if (commit)
391
+ log(` HEAD commit: ${commit}`);
392
+ const stats = await copyFromGitTree(tmpRoot, input.destDir, input.maxSizeKB, log);
393
+ return {
394
+ mode: 'git',
395
+ source: input.source,
396
+ branch: input.branch,
397
+ commit,
398
+ stats,
399
+ };
400
+ }
401
+ finally {
402
+ try {
403
+ fs_extra_1.default.removeSync(tmpRoot);
404
+ }
405
+ catch {
406
+ /* best effort */
407
+ }
408
+ }
409
+ }
410
+ function ensureGitAvailable() {
411
+ const res = (0, node_child_process_1.spawnSync)('git', ['--version'], { stdio: 'ignore' });
412
+ if (res.status !== 0) {
413
+ throw new Error('未检测到 git 命令,请先安装 git 并确保其在 PATH 中');
414
+ }
415
+ }
416
+ function resolveGitHead(repoDir) {
417
+ const res = (0, node_child_process_1.spawnSync)('git', ['-C', repoDir, 'rev-parse', 'HEAD'], {
418
+ stdio: ['ignore', 'pipe', 'pipe'],
419
+ encoding: 'utf8',
420
+ });
421
+ if (res.status !== 0)
422
+ return undefined;
423
+ return (res.stdout || '').trim() || undefined;
424
+ }
425
+ /**
426
+ * Walk srcRoot recursively and copy files to destRoot under the same relative
427
+ * path, subject to the directory/extension blacklist and size cap.
428
+ *
429
+ * Emits heartbeat logs via `createProgress` so the user can see forward motion
430
+ * even when most of the time is spent `stat`ing thousands of filtered files.
431
+ */
432
+ async function walkAndCopy(srcRoot, destRoot, maxSizeKB, log) {
433
+ const maxBytes = Math.max(1, maxSizeKB) * 1024;
434
+ const stats = { files: 0, sizeKB: 0, skipped: 0 };
435
+ let totalBytes = 0;
436
+ await fs_extra_1.default.ensureDir(destRoot);
437
+ const progress = createProgress(log, -1, 'walk');
438
+ async function walk(relDir) {
439
+ const absDir = node_path_1.default.join(srcRoot, relDir);
440
+ let entries;
441
+ try {
442
+ entries = await fs_extra_1.default.readdir(absDir, { withFileTypes: true });
443
+ }
444
+ catch {
445
+ return;
446
+ }
447
+ for (const entry of entries) {
448
+ const name = entry.name;
449
+ const relPath = relDir ? node_path_1.default.join(relDir, name) : name;
450
+ const absPath = node_path_1.default.join(absDir, name);
451
+ if (entry.isDirectory()) {
452
+ if ((0, baseline_defaults_1.isBlacklistedDir)(name))
453
+ continue;
454
+ await walk(relPath);
455
+ continue;
456
+ }
457
+ if (!entry.isFile())
458
+ continue;
459
+ progress.tick();
460
+ const ext = node_path_1.default.extname(name).toLowerCase();
461
+ if ((0, baseline_defaults_1.isBlacklistedExt)(ext)) {
462
+ stats.skipped++;
463
+ continue;
464
+ }
465
+ let size = 0;
466
+ try {
467
+ size = (await fs_extra_1.default.stat(absPath)).size;
468
+ }
469
+ catch {
470
+ stats.skipped++;
471
+ continue;
472
+ }
473
+ if (size > maxBytes) {
474
+ stats.skipped++;
475
+ continue;
476
+ }
477
+ const destPath = node_path_1.default.join(destRoot, relPath);
478
+ await fs_extra_1.default.ensureDir(node_path_1.default.dirname(destPath));
479
+ await fs_extra_1.default.copyFile(absPath, destPath);
480
+ stats.files++;
481
+ totalBytes += size;
482
+ }
483
+ }
484
+ await walk('');
485
+ progress.finish(stats.files, stats.skipped);
486
+ stats.sizeKB = Math.round(totalBytes / 1024);
487
+ return stats;
488
+ }
489
+ /**
490
+ * Lightweight progress reporter.
491
+ *
492
+ * Emits a heartbeat every `intervalMs` milliseconds OR every `everyN`
493
+ * `tick()` calls, whichever comes first. `total < 0` means the total is
494
+ * unknown (common while walking) and the message omits the total.
495
+ */
496
+ function createProgress(log, total, label, intervalMs = 1500, everyN = 500) {
497
+ let seen = 0;
498
+ let lastTs = Date.now();
499
+ return {
500
+ tick() {
501
+ seen++;
502
+ const now = Date.now();
503
+ if (seen % everyN === 0 || now - lastTs >= intervalMs) {
504
+ lastTs = now;
505
+ if (total > 0) {
506
+ log(` [${label}] 已处理 ${seen}/${total}`);
507
+ }
508
+ else {
509
+ log(` [${label}] 已扫描 ${seen} 个文件…`);
510
+ }
511
+ }
512
+ },
513
+ finish(files, skipped) {
514
+ log(` [${label}] 完成:拷贝 ${files},跳过 ${skipped},共扫描 ${seen}`);
515
+ },
516
+ };
517
+ }
518
+ /**
519
+ * Read files directly from a git repo's object store via ls-tree + cat-file,
520
+ * bypassing the working tree so that blobs skipped by --filter are never
521
+ * lazy-fetched from the remote. Used only by the git-clone fallback path.
522
+ */
523
+ async function copyFromGitTree(repoDir, destDir, maxSizeKB, log) {
524
+ const maxBytes = Math.max(1, maxSizeKB) * 1024;
525
+ const stats = { files: 0, sizeKB: 0, skipped: 0 };
526
+ let totalBytes = 0;
527
+ await fs_extra_1.default.ensureDir(destDir);
528
+ const ls = (0, node_child_process_1.spawnSync)('git', ['-C', repoDir, 'ls-tree', '-r', '-l', 'HEAD'], {
529
+ stdio: ['ignore', 'pipe', 'pipe'],
530
+ encoding: 'utf8',
531
+ maxBuffer: 256 * 1024 * 1024,
532
+ });
533
+ if (ls.status !== 0) {
534
+ const stderr = (ls.stderr || '').trim();
535
+ throw new Error(`git ls-tree 失败 (exit=${ls.status}): ${stderr || '未知错误'}`);
536
+ }
537
+ const lines = (ls.stdout || '').split('\n');
538
+ const toExtract = [];
539
+ for (const line of lines) {
540
+ if (!line)
541
+ continue;
542
+ const tabIdx = line.indexOf('\t');
543
+ if (tabIdx < 0)
544
+ continue;
545
+ const metaPart = line.slice(0, tabIdx);
546
+ const relPath = line.slice(tabIdx + 1);
547
+ const meta = metaPart.trim().split(/\s+/);
548
+ if (meta.length < 4)
549
+ continue;
550
+ const [, type, sha, sizeStr] = meta;
551
+ if (type !== 'blob')
552
+ continue;
553
+ if ((0, baseline_defaults_1.anySegmentBlacklisted)(relPath)) {
554
+ stats.skipped++;
555
+ continue;
556
+ }
557
+ const basename = relPath.split('/').pop() ?? relPath;
558
+ const ext = node_path_1.default.extname(basename).toLowerCase();
559
+ if ((0, baseline_defaults_1.isBlacklistedExt)(ext)) {
560
+ stats.skipped++;
561
+ continue;
562
+ }
563
+ const size = Number.parseInt(sizeStr, 10);
564
+ if (!Number.isFinite(size) || size > maxBytes) {
565
+ stats.skipped++;
566
+ continue;
567
+ }
568
+ toExtract.push({ sha, size, relPath });
569
+ }
570
+ log(` 对象索引: 命中 ${toExtract.length} 个文件,跳过 ${stats.skipped} 个`);
571
+ const progress = createProgress(log, toExtract.length, 'git-tree');
572
+ for (const item of toExtract) {
573
+ progress.tick();
574
+ const cat = (0, node_child_process_1.spawnSync)('git', ['-C', repoDir, 'cat-file', '-p', item.sha], {
575
+ stdio: ['ignore', 'pipe', 'pipe'],
576
+ maxBuffer: maxBytes + 64 * 1024,
577
+ });
578
+ if (cat.status !== 0 || !cat.stdout) {
579
+ stats.skipped++;
580
+ continue;
581
+ }
582
+ const destPath = node_path_1.default.join(destDir, item.relPath);
583
+ await fs_extra_1.default.ensureDir(node_path_1.default.dirname(destPath));
584
+ await fs_extra_1.default.writeFile(destPath, cat.stdout);
585
+ stats.files++;
586
+ totalBytes += item.size;
587
+ }
588
+ progress.finish(stats.files, stats.skipped);
589
+ stats.sizeKB = Math.round(totalBytes / 1024);
590
+ return stats;
591
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ /**
3
+ * Baseline copy defaults.
4
+ *
5
+ * Centralises the "what to skip" rules used by baseline-copy.ts so that new
6
+ * host projects or new file types can be added in one place without touching
7
+ * the walking / cloning logic.
8
+ *
9
+ * Philosophy:
10
+ * - Text / source files are ALWAYS preserved (they are the baseline).
11
+ * - Binary / media / archive / IDE cache / generated-output files are the
12
+ * only things we skip.
13
+ * - Directory rules are strictly "name match" — they prune an entire subtree
14
+ * and save us from walking millions of ignored files on Windows.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.DEFAULT_EXT_BLACKLIST = exports.DEFAULT_DIR_BLACKLIST_PATTERNS = exports.DEFAULT_DIR_BLACKLIST = void 0;
18
+ exports.isBlacklistedDir = isBlacklistedDir;
19
+ exports.anySegmentBlacklisted = anySegmentBlacklisted;
20
+ exports.isBlacklistedExt = isBlacklistedExt;
21
+ /**
22
+ * Directory names pruned entirely from baseline copy (case-insensitive).
23
+ */
24
+ exports.DEFAULT_DIR_BLACKLIST = new Set([
25
+ // VCS metadata
26
+ '.git', '.hg', '.svn',
27
+ // Node
28
+ 'node_modules',
29
+ // Build outputs
30
+ 'build', '.gradle', 'dist', 'out', 'target',
31
+ // Android native build cache
32
+ '.cxx', '.externalNativeBuild',
33
+ // Web framework outputs
34
+ '.next', '.nuxt',
35
+ // IDE / editor state
36
+ '.idea', '.vscode', '.qoder', '.vhub',
37
+ // Python
38
+ '__pycache__', '.venv',
39
+ // Coverage & temp
40
+ 'coverage', 'tmp', 'temp',
41
+ // svharnessbuild verification fixtures
42
+ '_tmp-verify', '_tmp-verify2',
43
+ ]);
44
+ /**
45
+ * Regex directory rules (matched against directory basename, case-insensitive).
46
+ * Primarily used to prune previously generated harness artifacts such as
47
+ * `demo-harness/`, `.dsv-carsettings-harness/`, etc.
48
+ */
49
+ exports.DEFAULT_DIR_BLACKLIST_PATTERNS = [
50
+ /-harness$/i,
51
+ /\.harness$/i,
52
+ ];
53
+ /**
54
+ * File extensions to skip. Never contains text / source-code extensions.
55
+ * Keep in sync with the doc block of `copyBaseline`.
56
+ */
57
+ exports.DEFAULT_EXT_BLACKLIST = new Set([
58
+ // Native binaries & object files
59
+ '.so', '.dll', '.exe', '.dylib', '.a', '.lib', '.o', '.obj', '.class',
60
+ // Packaged artifacts
61
+ '.apk', '.aar', '.jar', '.war', '.ear', '.ipa', '.app',
62
+ // Archives
63
+ '.zip', '.tar', '.gz', '.tgz', '.7z', '.rar', '.bz2', '.xz', '.iso', '.dmg',
64
+ // Video
65
+ '.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v',
66
+ // Audio
67
+ '.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac',
68
+ // Images
69
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.svg',
70
+ '.tif', '.tiff', '.heic', '.avif',
71
+ // Fonts
72
+ '.ttf', '.otf', '.woff', '.woff2', '.eot',
73
+ // Docs / design binaries
74
+ '.pdf', '.psd', '.ai', '.sketch', '.fig', '.xd',
75
+ // Databases & binary dumps
76
+ '.db', '.sqlite', '.sqlite3', '.mdb', '.dat', '.bin',
77
+ ]);
78
+ /**
79
+ * True if the given directory basename should be pruned from baseline copy.
80
+ */
81
+ function isBlacklistedDir(name) {
82
+ const lower = name.toLowerCase();
83
+ if (exports.DEFAULT_DIR_BLACKLIST.has(lower))
84
+ return true;
85
+ return exports.DEFAULT_DIR_BLACKLIST_PATTERNS.some((re) => re.test(lower));
86
+ }
87
+ /**
88
+ * True if any segment of the given POSIX-style relative path is a blacklisted
89
+ * directory. Used when a file list is already flat (e.g. `git ls-files`).
90
+ */
91
+ function anySegmentBlacklisted(relPosixPath) {
92
+ const segments = relPosixPath.split('/');
93
+ // Last segment is the file basename → exclude from dir check.
94
+ for (let i = 0; i < segments.length - 1; i++) {
95
+ if (isBlacklistedDir(segments[i]))
96
+ return true;
97
+ }
98
+ return false;
99
+ }
100
+ /**
101
+ * True if the given file extension (including leading dot, lowercase) is in
102
+ * the binary/media skip list.
103
+ */
104
+ function isBlacklistedExt(extLower) {
105
+ return exports.DEFAULT_EXT_BLACKLIST.has(extLower);
106
+ }