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
@@ -1,100 +1,248 @@
1
1
  #!/usr/bin/env node
2
+ 'use strict';
3
+
2
4
  /**
3
- * Verification Gatekeeper
4
- * Runs the target project's own mechanical checks (lint/test scripts from the
5
- * nearest package.json) and refuses completion (Exit 1) when any of them fail.
5
+ * Harness project verification gate.
6
6
  *
7
- * Escape hatches, both deliberate:
8
- * - `.verify-fail.tmp` in cwd forces a failure - the hermetic self-regression
9
- * suite uses it to prove the blocking mechanism without needing a real
10
- * broken project.
11
- * - HARNESS_SKIP_PROJECT_CHECKS=1 skips the project-script phase (the tmp-file
12
- * check above still applies). The gate sets it for its own children so a
13
- * project whose test suite itself drives the Harness runtime (like this repo) cannot
14
- * recurse back into the gate.
7
+ * Contract: .harness/verify.json
8
+ * Discovery: read-only candidates; legacy package lint/test remains auto-run.
9
+ * UNCHECKED_* is non-blocking control flow and is never passing-test evidence.
15
10
  */
16
- const { execSync } = require('child_process');
17
11
  const fs = require('fs');
18
12
  const path = require('path');
13
+ const { spawnSync } = require('child_process');
19
14
 
20
- console.log("=== đŸ›Ąī¸ Harness Verification Gate ===");
21
-
22
- // Phase 1: explicit failure injection (test/demo hook).
23
- if (fs.existsSync('.verify-fail.tmp')) {
24
- console.error("❌ Verification FAILED: .verify-fail.tmp present (injected failure).");
25
- console.error("Please fix the code and run this gate again.");
26
- process.exit(1);
15
+ function loadVerificationModel() {
16
+ for (const candidate of [
17
+ path.resolve(__dirname, '../../scripts/lib/verification-contract.js'),
18
+ path.resolve(__dirname, '../../../scripts/lib/verification-contract.js'),
19
+ ]) {
20
+ if (fs.existsSync(candidate)) return require(candidate);
21
+ }
22
+ throw new Error('verification-contract runtime is missing from this Harness distribution');
27
23
  }
28
24
 
29
- if (process.env.HARNESS_SKIP_PROJECT_CHECKS === '1') {
30
- console.log("â„šī¸ HARNESS_SKIP_PROJECT_CHECKS=1 - project scripts skipped (recursion guard / hermetic test mode).");
31
- console.log("✅ Verification SUCCESS (injection check only).");
32
- process.exit(0);
25
+ const {
26
+ detectRoot,
27
+ enumerateSubmoduleRoots,
28
+ findGitRoot,
29
+ readContract,
30
+ } = loadVerificationModel();
31
+
32
+ const jsonMode = process.argv.includes('--json');
33
+ const cwd = path.resolve(process.cwd());
34
+ const repoRoot = findGitRoot(cwd);
35
+
36
+ function emptyReport(status, reason) {
37
+ return {
38
+ status,
39
+ reason,
40
+ repoRoot,
41
+ roots: [],
42
+ checks: [],
43
+ candidates: [],
44
+ };
33
45
  }
34
46
 
35
- // Phase 2: locate the nearest package.json, walking up from cwd but never
36
- // above the enclosing git repository (same scoping rule as tier-router.js).
37
- function findPackageDir() {
38
- let dir = path.resolve(process.cwd());
39
- while (true) {
40
- if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
41
- if (fs.existsSync(path.join(dir, '.git'))) return null; // repo root reached, none found
42
- const parent = path.dirname(dir);
43
- if (parent === dir) return null;
44
- dir = parent;
47
+ function emit(report) {
48
+ if (jsonMode) {
49
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
50
+ return;
51
+ }
52
+
53
+ console.log('=== đŸ›Ąī¸ Harness Verification Gate ===');
54
+ console.log(`Status: ${report.status}`);
55
+ console.log(`Repository: ${report.repoRoot}`);
56
+ if (report.reason) console.log(`Reason: ${report.reason}`);
57
+
58
+ for (const root of report.roots || []) {
59
+ console.log(`\n[${root.status}] ${root.path}`);
60
+ if (root.reason) console.log(` ${root.reason}`);
61
+ for (const check of root.checks || []) {
62
+ const exit = check.exitCode === null ? 'timeout/error' : check.exitCode;
63
+ console.log(` check ${check.id}: ${check.run} (exit ${exit}, ${check.durationMs}ms)`);
64
+ if (check.stdout) process.stdout.write(check.stdout.endsWith('\n') ? check.stdout : check.stdout + '\n');
65
+ if (check.stderr) process.stderr.write(check.stderr.endsWith('\n') ? check.stderr : check.stderr + '\n');
66
+ }
67
+ for (const candidate of root.candidates || []) {
68
+ console.log(` candidate [${candidate.source}]: ${candidate.run}`);
69
+ }
45
70
  }
71
+
72
+ if (report.status === 'UNCHECKED_NO_CHECKS' || report.status === 'UNCHECKED_DISCOVERED') {
73
+ console.log('\nExit code is 0 (non-blocking), but NO COMPLETE AUTOMATED EVIDENCE was produced.');
74
+ console.log('PROHIBITED: Do NOT cite this run as proof that tests pass. Perform the listed or project-declared verification first.');
75
+ } else if (report.status === 'PASSED') {
76
+ console.log('\n✅ Verification PASSED. Objective project checks completed successfully.');
77
+ } else if (report.status === 'FAILED') {
78
+ console.error('\n❌ Verification FAILED. Fix the reported check/contract error and run this gate again.');
79
+ } else if (report.status === 'SKIPPED') {
80
+ console.log('â„šī¸ Project checks skipped by HARNESS_SKIP_PROJECT_CHECKS=1.');
81
+ }
82
+ }
83
+
84
+ function finish(report) {
85
+ emit(report);
86
+ process.exit(report.status === 'FAILED' ? 1 : 0);
87
+ }
88
+
89
+ function statusRank(status) {
90
+ return {
91
+ PASSED: 0,
92
+ UNCHECKED_NO_CHECKS: 1,
93
+ UNCHECKED_DISCOVERED: 2,
94
+ FAILED: 3,
95
+ }[status] ?? 0;
96
+ }
97
+
98
+ function aggregateStatus(roots) {
99
+ if (!roots.length) return 'UNCHECKED_NO_CHECKS';
100
+ return roots.reduce((worst, root) => statusRank(root.status) > statusRank(worst) ? root.status : worst, 'PASSED');
46
101
  }
47
102
 
48
- function detectRunner(dir) {
49
- if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
50
- if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
51
- if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
52
- return 'npm';
103
+ function executeCheck(check) {
104
+ const started = Date.now();
105
+ const result = spawnSync(check.run, {
106
+ cwd: check.cwd,
107
+ shell: true,
108
+ encoding: 'utf8',
109
+ windowsHide: true,
110
+ timeout: check.timeoutSec * 1000,
111
+ env: { ...process.env, HARNESS_SKIP_PROJECT_CHECKS: '1' },
112
+ maxBuffer: 16 * 1024 * 1024,
113
+ });
114
+ const durationMs = Date.now() - started;
115
+ const timedOut = !!(result.error && result.error.code === 'ETIMEDOUT');
116
+ const exitCode = typeof result.status === 'number' ? result.status : null;
117
+ return {
118
+ id: check.id,
119
+ run: check.run,
120
+ cwd: check.cwd,
121
+ source: check.source,
122
+ exitCode,
123
+ durationMs,
124
+ timedOut,
125
+ signal: result.signal || null,
126
+ stdout: result.stdout || '',
127
+ stderr: result.stderr || '',
128
+ passed: !timedOut && exitCode === 0,
129
+ };
53
130
  }
54
131
 
55
- const pkgDir = findPackageDir();
56
- let scripts = {};
57
- if (pkgDir) {
58
- try {
59
- scripts = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')).scripts || {};
60
- } catch (err) {
61
- console.error(`❌ Verification FAILED: package.json at ${pkgDir} is unreadable/invalid JSON (${err.message}).`);
62
- process.exit(1);
132
+ function publicCheck(check) {
133
+ return {
134
+ id: check.id,
135
+ run: check.run,
136
+ cwd: check.cwd,
137
+ source: check.source,
138
+ exitCode: check.exitCode,
139
+ durationMs: check.durationMs,
140
+ ...(check.timedOut ? { timedOut: true } : {}),
141
+ ...(check.signal ? { signal: check.signal } : {}),
142
+ ...(!jsonMode && check.stdout ? { stdout: check.stdout } : {}),
143
+ ...(!jsonMode && check.stderr ? { stderr: check.stderr } : {}),
144
+ };
145
+ }
146
+
147
+ function candidateView(candidate) {
148
+ return {
149
+ source: candidate.source,
150
+ run: candidate.run,
151
+ cwd: candidate.cwd,
152
+ };
153
+ }
154
+
155
+ function rootReason(status, checks, candidates) {
156
+ if (status === 'FAILED') {
157
+ const failed = checks.find(check => !check.passed);
158
+ if (!failed) return 'verification failed';
159
+ return failed.timedOut
160
+ ? `check ${failed.id} timed out after ${failed.durationMs}ms`
161
+ : `check ${failed.id} exited ${failed.exitCode}`;
63
162
  }
163
+ if (status === 'UNCHECKED_DISCOVERED') return `${candidates.length} verification candidate(s) detected but not executed`;
164
+ if (status === 'UNCHECKED_NO_CHECKS') return 'no project verification checks were declared or detected';
165
+ return `${checks.length} check(s) passed`;
64
166
  }
65
167
 
66
- // npm-init's placeholder ("no test specified" + exit 1) is absence of a test
67
- // suite, not a failing one - running it would block every fresh project.
68
- const runnable = ['lint', 'test'].filter(name =>
69
- typeof scripts[name] === 'string' &&
70
- scripts[name].trim() !== '' &&
71
- !/no test specified/i.test(scripts[name])
72
- );
73
-
74
- if (!pkgDir || runnable.length === 0) {
75
- console.log("âš ī¸ Verification UNCHECKED: No automated lint/test scripts detected in package.json.");
76
- console.log(" Exit code is 0 (non-blocking), but NO AUTOMATED EVIDENCE was produced.");
77
- console.log(" PROHIBITED: Do NOT cite this run as proof that tests pass. Perform manual or alternative verification first.");
78
- process.exit(0);
168
+ function contractPlan(contract) {
169
+ const rootResults = contract.roots.map(root => ({
170
+ path: root.path,
171
+ status: 'UNCHECKED_NO_CHECKS',
172
+ reason: '',
173
+ checks: [],
174
+ candidates: [],
175
+ }));
176
+ const byPath = new Map(rootResults.map(root => [path.resolve(root.path), root]));
177
+
178
+ for (const check of contract.checks) {
179
+ const executed = executeCheck(check);
180
+ const root = byPath.get(path.resolve(check.root));
181
+ root.checks.push(executed);
182
+ }
183
+
184
+ for (const root of rootResults) {
185
+ root.status = root.checks.length === 0
186
+ ? 'UNCHECKED_NO_CHECKS'
187
+ : root.checks.some(check => !check.passed) ? 'FAILED' : 'PASSED';
188
+ root.reason = rootReason(root.status, root.checks, root.candidates);
189
+ }
190
+ return rootResults;
79
191
  }
80
192
 
81
- const runner = detectRunner(pkgDir);
82
- for (const name of runnable) {
83
- console.log(`\nâ–ļ Running "${runner} run ${name}" in ${pkgDir} ...`);
84
- try {
85
- execSync(`${runner} run ${name}`, {
86
- cwd: pkgDir,
87
- stdio: 'inherit',
88
- timeout: 10 * 60 * 1000,
89
- env: { ...process.env, HARNESS_SKIP_PROJECT_CHECKS: '1' }
193
+ function discoveredPlan() {
194
+ const roots = [];
195
+ for (const rootPath of enumerateSubmoduleRoots(repoRoot)) {
196
+ const detected = detectRoot(rootPath);
197
+ const executed = detected.checks.map(executeCheck);
198
+ const candidates = detected.candidates;
199
+ const status = executed.some(check => !check.passed)
200
+ ? 'FAILED'
201
+ : candidates.length > 0 ? 'UNCHECKED_DISCOVERED'
202
+ : executed.length > 0 ? 'PASSED' : 'UNCHECKED_NO_CHECKS';
203
+ roots.push({
204
+ path: rootPath,
205
+ status,
206
+ reason: rootReason(status, executed, candidates),
207
+ checks: executed,
208
+ candidates,
90
209
  });
91
- console.log(`✅ ${name} passed.`);
92
- } catch (err) {
93
- console.error(`\n❌ Verification FAILED: "${runner} run ${name}" exited non-zero${err.signal ? ` (signal ${err.signal})` : ''}.`);
94
- console.error("Fix the failures above and run this gate again.");
95
- process.exit(1);
96
210
  }
211
+ return roots;
97
212
  }
98
213
 
99
- console.log("\n✅ Verification SUCCESS. You may now complete the task.");
100
- process.exit(0);
214
+ function finalize(roots) {
215
+ const status = aggregateStatus(roots);
216
+ const failingRoot = roots.find(root => root.status === status);
217
+ const report = {
218
+ status,
219
+ reason: failingRoot ? failingRoot.reason : '',
220
+ repoRoot,
221
+ roots: roots.map(root => ({
222
+ path: root.path,
223
+ status: root.status,
224
+ reason: root.reason,
225
+ checks: root.checks.map(publicCheck),
226
+ candidates: root.candidates.map(candidateView),
227
+ })),
228
+ checks: roots.flatMap(root => root.checks.map(publicCheck)),
229
+ candidates: roots.flatMap(root => root.candidates.map(candidateView)),
230
+ };
231
+ finish(report);
232
+ }
233
+
234
+ try {
235
+ // Injection remains first and cannot be bypassed by the recursion guard.
236
+ if (fs.existsSync(path.join(cwd, '.verify-fail.tmp'))) {
237
+ finish(emptyReport('FAILED', '.verify-fail.tmp present (injected failure)'));
238
+ }
239
+
240
+ if (process.env.HARNESS_SKIP_PROJECT_CHECKS === '1') {
241
+ finish(emptyReport('SKIPPED', 'HARNESS_SKIP_PROJECT_CHECKS=1 recursion guard'));
242
+ }
243
+
244
+ const contract = readContract(repoRoot);
245
+ finalize(contract ? contractPlan(contract) : discoveredPlan());
246
+ } catch (error) {
247
+ finish(emptyReport('FAILED', error && error.message ? error.message : String(error)));
248
+ }
@@ -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 };