harness-everything 0.25.1 → 0.27.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +37 -0
  4. package/ci/mechanism-34-worktree-submodule-reachability.test.js +307 -0
  5. package/ci/mechanism-35-verification-contract.test.js +416 -0
  6. package/ci/mutation-check.js +4 -0
  7. package/ci/mutation-loader.js +8 -0
  8. package/docs/audit.md +2 -0
  9. package/git-commit/SKILL.md +2 -2
  10. package/git-commit/guides/SUBMODULES.md +54 -1
  11. package/harness-everything/SKILL.md +2 -2
  12. package/harness-everything/scripts/verify-gate.js +223 -75
  13. package/hooks/scripts/lib/workflow-isolation.js +3 -4
  14. package/hooks/scripts/state-persist.js +1 -1
  15. package/opencode-plugin/plugin.json +1 -1
  16. package/package.json +1 -1
  17. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  18. package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +3 -4
  19. package/plugins/harness-everything/hooks/scripts/state-persist.js +1 -1
  20. package/plugins/harness-everything/plugin.json +1 -1
  21. package/plugins/harness-everything/scripts/lib/verification-contract.js +357 -0
  22. package/plugins/harness-everything/skills/git-commit/SKILL.md +2 -2
  23. package/plugins/harness-everything/skills/git-commit/guides/SUBMODULES.md +54 -1
  24. package/plugins/harness-everything/skills/harness-everything/SKILL.md +2 -2
  25. package/plugins/harness-everything/skills/harness-everything/scripts/verify-gate.js +223 -75
  26. package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +1 -1
  27. package/plugins/harness-everything/skills/using-git-worktrees/references/submodules-in-worktrees.md +11 -1
  28. package/plugins/harness-everything/skills/using-git-worktrees/scripts/submodule-reachability.js +265 -0
  29. package/plugins/harness-everything/skills/verification-loop/SKILL.md +15 -23
  30. package/plugins/harness-everything/skills/verification-loop/references/project-verification-contract.md +112 -0
  31. package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +4 -3
  32. package/plugins/harness-everything/skills/verification-loop/references/verify-contract.schema.json +32 -0
  33. package/plugins/harness-everything/skills/verification-loop/references/verify-result.schema.json +65 -0
  34. package/sbom.cdx.json +7 -7
  35. package/scripts/lib/verification-contract.js +357 -0
  36. package/using-git-worktrees/SKILL.md +1 -1
  37. package/using-git-worktrees/references/submodules-in-worktrees.md +11 -1
  38. package/using-git-worktrees/scripts/submodule-reachability.js +265 -0
  39. package/verification-loop/SKILL.md +15 -23
  40. package/verification-loop/references/project-verification-contract.md +112 -0
  41. package/verification-loop/references/verification-phases.md +4 -3
  42. package/verification-loop/references/verify-contract.schema.json +32 -0
  43. package/verification-loop/references/verify-result.schema.json +65 -0
@@ -0,0 +1,357 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const CONTRACT_RELATIVE = path.join('.harness', 'verify.json');
8
+ const CONTRACT_VERSION = 1;
9
+ const MAX_TIMEOUT_SEC = 3600;
10
+
11
+ function runGit(cwd, args) {
12
+ return spawnSync('git', args, {
13
+ cwd,
14
+ encoding: 'utf8',
15
+ windowsHide: true,
16
+ timeout: 5000,
17
+ });
18
+ }
19
+
20
+ function findGitRoot(cwd = process.cwd()) {
21
+ const result = runGit(cwd, ['rev-parse', '--show-toplevel']);
22
+ return result.status === 0 ? path.resolve(result.stdout.trim()) : path.resolve(cwd);
23
+ }
24
+
25
+ function within(root, target) {
26
+ const relative = path.relative(path.resolve(root), path.resolve(target));
27
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
28
+ }
29
+
30
+ function resolveContained(root, value, label, { mustExist = true } = {}) {
31
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty relative path`);
32
+ if (path.isAbsolute(value)) throw new Error(`${label} must stay inside the repository`);
33
+ const resolved = path.resolve(root, value);
34
+ if (!within(root, resolved)) throw new Error(`${label} escapes the repository`);
35
+ if (mustExist && !fs.existsSync(resolved)) throw new Error(`${label} does not exist: ${value}`);
36
+ if (mustExist) {
37
+ const physicalRoot = fs.realpathSync.native(path.resolve(root));
38
+ const physicalTarget = fs.realpathSync.native(resolved);
39
+ if (!within(physicalRoot, physicalTarget)) throw new Error(`${label} escapes the repository through a symbolic link`);
40
+ return physicalTarget;
41
+ }
42
+ return resolved;
43
+ }
44
+
45
+ function assertKnownFields(value, allowed, label) {
46
+ for (const key of Object.keys(value)) {
47
+ if (!allowed.has(key)) throw new Error(`${label} contains unknown field: ${key}`);
48
+ }
49
+ }
50
+
51
+ function readContract(repoRoot) {
52
+ const contractPath = path.join(repoRoot, CONTRACT_RELATIVE);
53
+ if (!fs.existsSync(contractPath)) return null;
54
+
55
+ let raw;
56
+ try {
57
+ raw = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
58
+ } catch (error) {
59
+ throw new Error(`verify.json is invalid JSON: ${error.message}`);
60
+ }
61
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('verify.json must contain a JSON object');
62
+ assertKnownFields(raw, new Set(['version', 'roots', 'checks']), 'verify.json');
63
+ if (raw.version !== CONTRACT_VERSION) throw new Error(`verify.json version must be ${CONTRACT_VERSION}`);
64
+ if (!Array.isArray(raw.checks) || raw.checks.length === 0) throw new Error('verify.json checks must be a non-empty array');
65
+
66
+ const rootValues = raw.roots === undefined ? ['.'] : raw.roots;
67
+ if (!Array.isArray(rootValues) || rootValues.length === 0) throw new Error('verify.json roots must be a non-empty array');
68
+ const seenRoots = new Set();
69
+ const roots = rootValues.map((value, index) => {
70
+ const resolved = resolveContained(repoRoot, value, `verify.json roots[${index}]`);
71
+ const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
72
+ if (seenRoots.has(key)) throw new Error(`verify.json has duplicate root: ${value}`);
73
+ seenRoots.add(key);
74
+ return { declared: value, path: resolved };
75
+ });
76
+
77
+ const ids = new Set();
78
+ const checks = raw.checks.map((check, index) => {
79
+ if (!check || typeof check !== 'object' || Array.isArray(check)) throw new Error(`verify.json checks[${index}] must be an object`);
80
+ assertKnownFields(check, new Set(['id', 'run', 'cwd', 'timeoutSec']), `verify.json checks[${index}]`);
81
+ if (typeof check.id !== 'string' || !check.id.trim()) throw new Error(`verify.json checks[${index}].id must be non-empty`);
82
+ if (ids.has(check.id)) throw new Error(`verify.json check id is duplicated: ${check.id}`);
83
+ ids.add(check.id);
84
+ if (typeof check.run !== 'string' || !check.run.trim()) throw new Error(`verify.json checks[${index}].run must be non-empty`);
85
+ const cwdValue = check.cwd === undefined ? '.' : check.cwd;
86
+ const cwd = resolveContained(repoRoot, cwdValue, `verify.json checks[${index}].cwd`);
87
+ const timeoutSec = check.timeoutSec === undefined ? 600 : check.timeoutSec;
88
+ if (!Number.isInteger(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_TIMEOUT_SEC) {
89
+ throw new Error(`verify.json checks[${index}].timeoutSec must be an integer from 1 to ${MAX_TIMEOUT_SEC}`);
90
+ }
91
+ return {
92
+ id: check.id,
93
+ run: check.run.trim(),
94
+ cwd,
95
+ cwdDeclared: cwdValue,
96
+ timeoutSec,
97
+ source: 'contract',
98
+ };
99
+ });
100
+
101
+ for (const check of checks) {
102
+ const owners = roots.filter(root => within(root.path, check.cwd))
103
+ .sort((a, b) => b.path.length - a.path.length);
104
+ if (!owners.length) throw new Error(`verify.json check ${check.id} cwd is outside declared roots`);
105
+ check.root = owners[0].path;
106
+ }
107
+
108
+ return { path: contractPath, roots, checks };
109
+ }
110
+
111
+ function detectRunner(dir) {
112
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
113
+ if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
114
+ if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
115
+ return 'npm';
116
+ }
117
+
118
+ function packageCommand(runner, name) {
119
+ return `${runner} run ${name}`;
120
+ }
121
+
122
+ function addCandidate(list, seen, candidate) {
123
+ const key = [candidate.source, candidate.run, candidate.cwd].join('\0');
124
+ if (seen.has(key)) return;
125
+ seen.add(key);
126
+ list.push(candidate);
127
+ }
128
+
129
+ function readTextIfExists(file) {
130
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
131
+ }
132
+
133
+ function detectRoot(root) {
134
+ const checks = [];
135
+ const candidates = [];
136
+ const seen = new Set();
137
+ const pkgPath = path.join(root, 'package.json');
138
+
139
+ if (fs.existsSync(pkgPath)) {
140
+ let pkg;
141
+ try {
142
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
143
+ } catch (error) {
144
+ throw new Error(`package.json at ${root} is unreadable/invalid JSON (${error.message})`);
145
+ }
146
+ const scripts = pkg && typeof pkg === 'object' && pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
147
+ const runner = detectRunner(root);
148
+ for (const name of ['lint', 'test']) {
149
+ const script = scripts[name];
150
+ if (typeof script === 'string' && script.trim() && !/no test specified/i.test(script)) {
151
+ checks.push({
152
+ id: `package-${name}`,
153
+ run: packageCommand(runner, name),
154
+ cwd: root,
155
+ timeoutSec: 600,
156
+ source: 'package-auto',
157
+ root,
158
+ });
159
+ }
160
+ }
161
+ for (const name of ['check', 'typecheck', 'verify', 'build']) {
162
+ const script = scripts[name];
163
+ if (typeof script === 'string' && script.trim()) {
164
+ addCandidate(candidates, seen, { source: `package:${name}`, run: packageCommand(runner, name), cwd: root });
165
+ }
166
+ }
167
+ }
168
+
169
+ if (fs.existsSync(path.join(root, '.pre-commit-config.yaml')) || fs.existsSync(path.join(root, '.pre-commit-config.yml'))) {
170
+ addCandidate(candidates, seen, { source: 'pre-commit', run: 'pre-commit run --all-files', cwd: root });
171
+ }
172
+
173
+ const makeText = readTextIfExists(path.join(root, 'Makefile'));
174
+ if (makeText) {
175
+ const targets = new Set([...makeText.matchAll(/^([A-Za-z0-9_.-]+)\s*:(?![=])/gm)].map(match => match[1]));
176
+ for (const target of ['test', 'check', 'lint', 'ci']) {
177
+ if (targets.has(target)) addCandidate(candidates, seen, { source: 'make', run: `make ${target}`, cwd: root });
178
+ }
179
+ }
180
+
181
+ const justText = readTextIfExists(path.join(root, 'justfile')) || readTextIfExists(path.join(root, 'Justfile'));
182
+ if (justText) {
183
+ const recipes = new Set([...justText.matchAll(/^([A-Za-z0-9_-]+)\s*(?:[^:=\r\n]*)?:\s*$/gm)].map(match => match[1]));
184
+ for (const recipe of ['test', 'check', 'ci']) {
185
+ if (recipes.has(recipe)) addCandidate(candidates, seen, { source: 'just', run: `just ${recipe}`, cwd: root });
186
+ }
187
+ }
188
+
189
+ const pyproject = readTextIfExists(path.join(root, 'pyproject.toml'));
190
+ if (/\[tool\.pytest(?:\.|\])/m.test(pyproject)) {
191
+ addCandidate(candidates, seen, { source: 'pyproject:pytest', run: 'pytest', cwd: root });
192
+ addCandidate(candidates, seen, { source: 'pyproject:pytest', run: 'uv run pytest', cwd: root });
193
+ }
194
+ if (/\[tool\.ruff(?:\.|\])/m.test(pyproject)) {
195
+ addCandidate(candidates, seen, { source: 'pyproject:ruff', run: 'ruff check .', cwd: root });
196
+ addCandidate(candidates, seen, { source: 'pyproject:ruff', run: 'uv run ruff check .', cwd: root });
197
+ }
198
+ if (fs.existsSync(path.join(root, 'tsconfig.json'))) addCandidate(candidates, seen, { source: 'typescript', run: 'npx tsc --noEmit', cwd: root });
199
+ if (['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', '.eslintrc', '.eslintrc.json', '.eslintrc.js', '.eslintrc.cjs'].some(name => fs.existsSync(path.join(root, name)))) {
200
+ addCandidate(candidates, seen, { source: 'eslint', run: 'npx eslint .', cwd: root });
201
+ }
202
+ if (fs.existsSync(path.join(root, 'tox.ini'))) addCandidate(candidates, seen, { source: 'tox', run: 'tox', cwd: root });
203
+ if (fs.existsSync(path.join(root, 'noxfile.py'))) addCandidate(candidates, seen, { source: 'nox', run: 'nox', cwd: root });
204
+ if (fs.existsSync(path.join(root, 'Cargo.toml'))) addCandidate(candidates, seen, { source: 'cargo', run: 'cargo test', cwd: root });
205
+ if (fs.existsSync(path.join(root, 'go.mod'))) addCandidate(candidates, seen, { source: 'go', run: 'go test ./...', cwd: root });
206
+
207
+ if (hasCommitHook(root)) {
208
+ addCandidate(candidates, seen, { source: 'git-hook', run: 'git commit (pre-commit hook configured)', cwd: root });
209
+ }
210
+
211
+ return { checks, candidates };
212
+ }
213
+
214
+ function enumerateSubmoduleRoots(repoRoot) {
215
+ const roots = [path.resolve(repoRoot)];
216
+ const result = runGit(repoRoot, ['submodule', 'status', '--recursive']);
217
+ if (result.status !== 0 || !result.stdout.trim()) return roots;
218
+ for (const line of result.stdout.split(/\r?\n/)) {
219
+ if (!line.trim()) continue;
220
+ const match = line.match(/^[ +-U]?([0-9a-fA-F]{40,64})\s+(.+?)(?:\s+\(.+\))?$/);
221
+ if (!match) continue;
222
+ const candidate = path.resolve(repoRoot, match[2]);
223
+ if (within(repoRoot, candidate) && fs.existsSync(candidate)) roots.push(candidate);
224
+ }
225
+ return [...new Set(roots)];
226
+ }
227
+
228
+ function hookIsRunnable(stat, platform = process.platform) {
229
+ return !!stat && stat.isFile() && (platform === 'win32' || (stat.mode & 0o111) !== 0);
230
+ }
231
+
232
+ function hasCommitHook(repoRoot, platform = process.platform) {
233
+ const result = runGit(repoRoot, ['rev-parse', '--git-path', 'hooks/pre-commit']);
234
+ if (result.status !== 0) return false;
235
+ const value = result.stdout.trim();
236
+ if (!value) return false;
237
+ const hook = path.isAbsolute(value) ? value : path.resolve(repoRoot, value);
238
+ let stat;
239
+ try { stat = fs.statSync(hook); }
240
+ catch (_) { return false; }
241
+ return hookIsRunnable(stat, platform);
242
+ }
243
+
244
+ function normalizeCommand(command) {
245
+ return String(command || '').trim().replace(/\s+/g, ' ');
246
+ }
247
+
248
+ function hasControlOperators(command) {
249
+ return /(?:&&|\|\||[;|\r\n])/.test(command);
250
+ }
251
+
252
+ function commandMatches(command, expected) {
253
+ const actual = normalizeCommand(command);
254
+ const target = normalizeCommand(expected);
255
+ if (!actual || !target || hasControlOperators(actual)) return false;
256
+ return actual === target || actual.startsWith(target + ' ');
257
+ }
258
+
259
+ function isGitCommit(command) {
260
+ const actual = normalizeCommand(command);
261
+ return /^git\s+commit(?:\s|$)/i.test(actual);
262
+ }
263
+
264
+ function bypassesHooks(command) {
265
+ const actual = normalizeCommand(command);
266
+ if (/(?:^|\s)--no-verify(?:\s|$)/i.test(actual) || /(?:^|\s)-n(?:\s|$)/.test(actual)) return true;
267
+
268
+ // Git accepts boolean short options in clusters, e.g. `git commit -an`.
269
+ // Treat a cluster containing -n as hook bypass only when every clustered
270
+ // option is a no-argument commit flag; this avoids mistaking attached
271
+ // arguments such as `-S<key>` or `-m<message>` for --no-verify.
272
+ return actual.split(/\s+/).some(token =>
273
+ /^-[avqseipon]+$/i.test(token) && token.length > 2 && token.slice(1).toLowerCase().includes('n')
274
+ );
275
+ }
276
+
277
+ function legacyVerificationCommand(command) {
278
+ const value = normalizeCommand(command);
279
+ return /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|lint|build|typecheck|check|verify)\b|npx\s+(?:jest|vitest|mocha|tsc|eslint)\b|(?:jest|vitest|mocha|pytest|rspec|phpunit)\b|uv\s+run\s+(?:pytest|ruff)\b|(?:make|just)\s+(?:test|check|lint|ci)\b|cargo\s+(?:test|check)\b|go\s+test\b|dotnet\s+(?:test|build)\b)/i.test(value);
280
+ }
281
+
282
+ function verificationCommandSet(cwd, options = {}) {
283
+ const repoRoot = path.resolve(options.root || findGitRoot(cwd));
284
+ let contract = null;
285
+ try { contract = readContract(repoRoot); }
286
+ catch (error) {
287
+ // The verify gate treats an invalid authoritative contract as FAILED.
288
+ // Preserve that fail-closed state here instead of falling through to
289
+ // legacy recognition and manufacturing contradictory verification evidence.
290
+ return {
291
+ repoRoot,
292
+ contract: null,
293
+ contractInvalid: true,
294
+ contractError: error && error.message ? error.message : String(error),
295
+ commands: [],
296
+ commitHookDeclared: false,
297
+ };
298
+ }
299
+
300
+ if (contract) {
301
+ return {
302
+ repoRoot,
303
+ contract,
304
+ contractInvalid: false,
305
+ commands: contract.checks.map(check => check.run),
306
+ commitHookDeclared: contract.checks.some(check => /^pre-commit\s+/i.test(normalizeCommand(check.run))),
307
+ };
308
+ }
309
+
310
+ const root = findGitRoot(cwd);
311
+ let detected;
312
+ try { detected = detectRoot(root); }
313
+ catch (_) { detected = { checks: [], candidates: [] }; }
314
+ return {
315
+ repoRoot,
316
+ contract: null,
317
+ contractInvalid: false,
318
+ commands: [
319
+ ...detected.checks.map(check => check.run),
320
+ ...detected.candidates.filter(candidate => candidate.source !== 'git-hook').map(candidate => candidate.run),
321
+ ],
322
+ commitHookDeclared: false,
323
+ };
324
+ }
325
+
326
+ function isVerificationCommand(command, options = {}) {
327
+ const cwd = path.resolve(options.cwd || process.cwd());
328
+ const actual = normalizeCommand(command);
329
+ if (!actual || hasControlOperators(actual)) return false;
330
+ const set = verificationCommandSet(cwd, options);
331
+ if (set.contractInvalid) return false;
332
+
333
+ if (isGitCommit(actual)) {
334
+ return !!set.commitHookDeclared && !bypassesHooks(actual) && hasCommitHook(set.repoRoot);
335
+ }
336
+ if (set.commands.some(expected => commandMatches(actual, expected))) return true;
337
+ return !set.contract && legacyVerificationCommand(actual);
338
+ }
339
+
340
+ module.exports = {
341
+ CONTRACT_RELATIVE,
342
+ MAX_TIMEOUT_SEC,
343
+ addCandidate,
344
+ commandMatches,
345
+ detectRoot,
346
+ enumerateSubmoduleRoots,
347
+ findGitRoot,
348
+ hasCommitHook,
349
+ hookIsRunnable,
350
+ isVerificationCommand,
351
+ legacyVerificationCommand,
352
+ normalizeCommand,
353
+ readContract,
354
+ resolveContained,
355
+ verificationCommandSet,
356
+ within,
357
+ };
@@ -4,7 +4,7 @@ description: Use when work needs isolation or before major implementation plans
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.25.1
7
+ version: 0.27.0
8
8
  ---
9
9
 
10
10
  # Using Git Worktrees
@@ -111,9 +111,19 @@ git -C <primary> submodule update
111
111
 
112
112
  The PowerShell form is identical except paths/refs should be quoted as shown above.
113
113
 
114
+ ## Preferred automated check
115
+
116
+ From the superproject worktree, run the cross-platform helper before staging a gitlink and again before removing the worktree:
117
+
118
+ ```bash
119
+ node <this-skill-dir>/scripts/submodule-reachability.js --json
120
+ ```
121
+
122
+ Exit 0 means every linked-worktree submodule commit was proven reachable outside the current worktree (or the superproject is not a linked worktree). An uninitialized gitlink is checked against the primary checkout and fails closed if that object is absent; initialize it to inspect remote refs, or fetch the commit into the primary checkout. Exit 1 means at least one commit is not proven externally reachable. Exit 2 is a usage/Git inspection error, including an unmerged submodule gitlink. The check MUST cover the current submodule HEAD and the gitlink SHAs in both the superproject index and HEAD. Checking out an older published submodule commit MUST NOT hide an unpublished staged or committed gitlink. The JSON report retains the working HEAD fields and adds `referencedCommits`, with each SHA, its `sources` (`index` and/or `HEAD`), and its external reachability evidence. Every referenced commit must pass before removal; a clean submodule status is valid input.
123
+
114
124
  ## Pre-removal check
115
125
 
116
- For every changed gitlink SHA, prove at least one external home exists:
126
+ Prefer the helper above. Raw fallback: for every changed gitlink SHA, prove at least one external home exists:
117
127
 
118
128
  ```bash
119
129
  git -C <submodule> branch -r --contains <sha>
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ function runGit(args, cwd, { allowFailure = false } = {}) {
9
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
10
+ if (!allowFailure && result.status !== 0) {
11
+ const message = (result.stderr || result.stdout || '').trim();
12
+ throw new Error(`git ${args.join(' ')} failed${message ? `: ${message}` : ''}`);
13
+ }
14
+ return result;
15
+ }
16
+
17
+ function gitText(args, cwd, options) {
18
+ const result = runGit(args, cwd, options);
19
+ return result.status === 0 ? result.stdout.trim() : '';
20
+ }
21
+
22
+ function absoluteCommonDir(root) {
23
+ const absolute = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], root, { allowFailure: true });
24
+ if (absolute.status === 0) return path.resolve(absolute.stdout.trim());
25
+ const raw = gitText(['rev-parse', '--git-common-dir'], root);
26
+ return path.resolve(root, raw);
27
+ }
28
+
29
+ function isInside(child, parent) {
30
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
31
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
32
+ }
33
+
34
+ function samePath(a, b) {
35
+ const normalize = value => {
36
+ let resolved = path.resolve(value);
37
+ try { resolved = fs.realpathSync.native(resolved); } catch { /* keep resolved */ }
38
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
39
+ };
40
+ return normalize(a) === normalize(b);
41
+ }
42
+
43
+ function parseSubmoduleStatus(text) {
44
+ if (!text.trim()) return [];
45
+ return text.split(/\r?\n/).filter(Boolean).map(line => {
46
+ // gitText() trims the whole command output, so the first clean-status
47
+ // marker (a leading space) may already be gone. Accept only the four Git
48
+ // status states here; an omitted marker is normalized back to clean.
49
+ const match = line.match(/^([ +U-]?)([0-9a-fA-F]{40,64})\s+(.+?)(?:\s+\(.+\))?$/);
50
+ if (!match) throw new Error(`cannot parse git submodule status line: ${line}`);
51
+ return {
52
+ prefix: match[1] || ' ',
53
+ recordedSha: match[2],
54
+ path: match[3],
55
+ };
56
+ });
57
+ }
58
+
59
+ function primaryHasCommit(primaryRoot, commonDir, submodulePath, sha, moduleGitDir = null) {
60
+ if (!primaryRoot) return false;
61
+ const primarySub = path.join(primaryRoot, ...submodulePath.split('/'));
62
+ // A deinitialized submodule leaves an empty directory, and `git -C` then
63
+ // resolves to the enclosing repository. Only the exact submodule repo counts.
64
+ const toplevel = runGit(['-C', primarySub, 'rev-parse', '--show-toplevel'], primaryRoot, { allowFailure: true });
65
+ let result = null;
66
+ if (toplevel.status === 0 && samePath(toplevel.stdout.trim(), primarySub)) {
67
+ result = runGit(['-C', primarySub, 'cat-file', '-e', `${sha}^{commit}`], primaryRoot, { allowFailure: true });
68
+ if (result.status === 0) return true;
69
+ }
70
+
71
+ // A nested submodule repository is stored below its parent's module repo,
72
+ // e.g. .git/modules/parent/modules/child. Callers pass that exact location
73
+ // when recursion is known; direct submodules retain the historical fallback.
74
+ const candidateGitDir = moduleGitDir || path.join(commonDir, 'modules', ...submodulePath.split('/'));
75
+ result = runGit(['--git-dir', candidateGitDir, 'cat-file', '-e', `${sha}^{commit}`], primaryRoot, { allowFailure: true });
76
+ return result.status === 0;
77
+ }
78
+
79
+ // Read the recorded gitlinks from their owning repository, not from the
80
+ // submodule working HEAD (which may have been checked out to an older commit).
81
+ function recordedGitlinks(owner, relativePath) {
82
+ const commits = new Map();
83
+ function add(sha, source) {
84
+ if (!commits.has(sha)) commits.set(sha, { sha, sources: [] });
85
+ commits.get(sha).sources.push(source);
86
+ }
87
+ const index = runGit(['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', relativePath], owner).stdout;
88
+ for (const record of index.split('\0').filter(Boolean)) {
89
+ const match = record.match(/^160000 ([0-9a-f]{40,64}) ([0-3])\t(.*)$/s);
90
+ if (!match || match[3] !== relativePath) continue;
91
+ if (match[2] !== '0') throw new Error(`unmerged submodule gitlink cannot be verified: ${relativePath}`);
92
+ add(match[1], 'index');
93
+ }
94
+ if (runGit(['rev-parse', '--verify', 'HEAD'], owner, { allowFailure: true }).status === 0) {
95
+ const tree = runGit(['--literal-pathspecs', 'ls-tree', '-z', 'HEAD', '--', relativePath], owner).stdout;
96
+ for (const record of tree.split('\0').filter(Boolean)) {
97
+ const match = record.match(/^160000 commit ([0-9a-f]{40,64})\t(.*)$/s);
98
+ if (match && match[2] === relativePath) add(match[1], 'HEAD');
99
+ }
100
+ }
101
+ return [...commits.values()];
102
+ }
103
+
104
+ function inspect(root = process.cwd()) {
105
+ root = path.resolve(gitText(['rev-parse', '--show-toplevel'], path.resolve(root)));
106
+ const superGitDir = path.resolve(gitText(['rev-parse', '--absolute-git-dir'], root));
107
+ const commonDir = absoluteCommonDir(root);
108
+ const linkedWorktree = superGitDir !== commonDir;
109
+ const primaryRoot = linkedWorktree && path.basename(commonDir).toLowerCase() === '.git'
110
+ ? path.dirname(commonDir)
111
+ : null;
112
+ const status = gitText(['submodule', 'status', '--recursive'], root);
113
+ const submodules = [];
114
+ const initializedPaths = [];
115
+ const primaryModuleDirs = new Map();
116
+
117
+ for (const entry of parseSubmoduleStatus(status)) {
118
+ if (entry.prefix === 'U') {
119
+ throw new Error(`unmerged submodule gitlink cannot be verified: ${entry.path}`);
120
+ }
121
+
122
+ const parentPath = initializedPaths.filter(parent => entry.path.startsWith(parent + '/'))
123
+ .sort((a, b) => b.length - a.length)[0];
124
+ const owner = parentPath ? path.join(root, ...parentPath.split('/')) : root;
125
+ const relativePath = parentPath ? entry.path.slice(parentPath.length + 1) : entry.path;
126
+ const subPath = path.join(root, ...entry.path.split('/'));
127
+ const parentModuleGitDir = parentPath ? primaryModuleDirs.get(parentPath) : null;
128
+ const primaryModuleGitDir = parentPath
129
+ ? path.join(parentModuleGitDir, 'modules', ...relativePath.split('/'))
130
+ : path.join(commonDir, 'modules', ...entry.path.split('/'));
131
+ primaryModuleDirs.set(entry.path, primaryModuleGitDir);
132
+ const referencedCommits = recordedGitlinks(owner, relativePath).map(reference => {
133
+ // A recorded object may be absent locally but already recovered in primary.
134
+ const hasObject = entry.prefix !== '-' && runGit(
135
+ ['cat-file', '-e', `${reference.sha}^{commit}`], subPath, { allowFailure: true }
136
+ ).status === 0;
137
+ const remoteRefs = hasObject ? gitText(
138
+ ['for-each-ref', '--contains', reference.sha, '--format=%(refname:short)', 'refs/remotes/'], subPath
139
+ ).split(/\r?\n/).filter(Boolean) : [];
140
+ const presentInPrimary = linkedWorktree
141
+ ? primaryHasCommit(primaryRoot, commonDir, entry.path, reference.sha, primaryModuleGitDir) : true;
142
+ return {
143
+ ...reference,
144
+ remoteRefs,
145
+ reachableFromRemote: remoteRefs.length > 0,
146
+ presentInPrimary,
147
+ externallyReachable: !linkedWorktree || remoteRefs.length > 0 || presentInPrimary,
148
+ };
149
+ });
150
+
151
+ if (entry.prefix === '-') {
152
+ const presentInPrimary = linkedWorktree
153
+ ? primaryHasCommit(primaryRoot, commonDir, entry.path, entry.recordedSha, primaryModuleGitDir)
154
+ : true;
155
+ const externallyReachable = !linkedWorktree || presentInPrimary;
156
+ submodules.push({
157
+ path: entry.path,
158
+ sha: entry.recordedSha,
159
+ initialized: false,
160
+ detached: null,
161
+ perWorktreeModuleDir: null,
162
+ reachableFromRemote: false,
163
+ remoteRefs: [],
164
+ presentInPrimary,
165
+ referencedCommits,
166
+ externallyReachable: externallyReachable && referencedCommits.every(reference => reference.externallyReachable),
167
+ });
168
+ continue;
169
+ }
170
+
171
+ initializedPaths.push(entry.path);
172
+ const head = runGit(['-C', subPath, 'rev-parse', 'HEAD'], root, { allowFailure: true });
173
+ if (head.status !== 0) {
174
+ throw new Error(`cannot inspect initialized submodule ${entry.path}: ${(head.stderr || head.stdout || '').trim()}`);
175
+ }
176
+
177
+ const sha = head.stdout.trim();
178
+ const branch = gitText(['-C', subPath, 'branch', '--show-current'], root);
179
+ const subGitDir = path.resolve(gitText(['-C', subPath, 'rev-parse', '--absolute-git-dir'], root));
180
+ const remoteRefs = gitText(
181
+ ['-C', subPath, 'for-each-ref', '--contains', sha, '--format=%(refname:short)', 'refs/remotes/'],
182
+ root
183
+ ).split(/\r?\n/).filter(Boolean);
184
+ const reachableFromRemote = remoteRefs.length > 0;
185
+ const presentInPrimary = linkedWorktree
186
+ ? primaryHasCommit(primaryRoot, commonDir, entry.path, sha, primaryModuleGitDir)
187
+ : true;
188
+ const externallyReachable = !linkedWorktree || reachableFromRemote || presentInPrimary;
189
+
190
+ submodules.push({
191
+ path: entry.path,
192
+ sha,
193
+ initialized: true,
194
+ detached: branch === '',
195
+ branch: branch || null,
196
+ gitDir: subGitDir,
197
+ perWorktreeModuleDir: linkedWorktree && isInside(subGitDir, path.join(commonDir, 'worktrees')),
198
+ reachableFromRemote,
199
+ remoteRefs,
200
+ presentInPrimary,
201
+ referencedCommits,
202
+ externallyReachable: externallyReachable && referencedCommits.every(reference => reference.externallyReachable),
203
+ });
204
+ }
205
+
206
+ return {
207
+ root,
208
+ linkedWorktree,
209
+ gitDir: superGitDir,
210
+ commonDir,
211
+ primaryRoot,
212
+ ok: submodules.every(entry => entry.externallyReachable),
213
+ submodules,
214
+ };
215
+ }
216
+
217
+ function printHuman(report) {
218
+ console.log(`Superproject: ${report.root}`);
219
+ console.log(`Linked worktree: ${report.linkedWorktree ? 'yes' : 'no'}`);
220
+ if (!report.submodules.length) {
221
+ console.log('Submodules: none');
222
+ return;
223
+ }
224
+ for (const entry of report.submodules) {
225
+ const state = entry.externallyReachable ? 'OK' : 'UNREACHABLE';
226
+ console.log(`[${state}] ${entry.path} @ ${entry.sha}`);
227
+ for (const reference of entry.referencedCommits) {
228
+ console.log(` ${reference.sources.join('/')} gitlink: ${reference.sha} (${reference.externallyReachable ? 'OK' : 'UNREACHABLE'})`);
229
+ }
230
+ if (!entry.initialized) {
231
+ console.log(' initialized: no');
232
+ console.log(` present in primary checkout: ${entry.presentInPrimary ? 'yes' : 'no'}`);
233
+ if (report.linkedWorktree && !entry.presentInPrimary) {
234
+ console.log(' remote reachability: unverified; initialize the submodule or fetch the commit into the primary checkout');
235
+ }
236
+ continue;
237
+ }
238
+ console.log(` branch: ${entry.branch || '(detached)'}`);
239
+ console.log(` per-worktree git dir: ${entry.perWorktreeModuleDir ? 'yes' : 'no'}`);
240
+ console.log(` remote reachability: ${entry.remoteRefs.length ? entry.remoteRefs.join(', ') : 'none'}`);
241
+ console.log(` present in primary checkout: ${entry.presentInPrimary ? 'yes' : 'no'}`);
242
+ }
243
+ }
244
+
245
+ function main(argv = process.argv.slice(2)) {
246
+ const json = argv.includes('--json');
247
+ const rootIndex = argv.indexOf('--root');
248
+ const root = rootIndex >= 0 ? argv[rootIndex + 1] : process.cwd();
249
+ if (rootIndex >= 0 && !root) throw new Error('--root requires a path');
250
+ const report = inspect(root);
251
+ if (json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
252
+ else printHuman(report);
253
+ process.exitCode = report.ok ? 0 : 1;
254
+ }
255
+
256
+ if (require.main === module) {
257
+ try {
258
+ main();
259
+ } catch (error) {
260
+ console.error(`submodule-reachability: ${error.message}`);
261
+ process.exit(2);
262
+ }
263
+ }
264
+
265
+ module.exports = { inspect, parseSubmoduleStatus, primaryHasCommit };