session-orchestrator 3.20.0 → 3.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +414 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +1 -1
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +616 -61
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +61 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +49 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +44 -2
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +13 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +42 -12
- package/scripts/lib/mission-status-schema.mjs +0 -114
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-config-drift.mjs — the invisible half of a contaminated working copy.
|
|
3
|
+
*
|
|
4
|
+
* ## The incident this exists for (2026-08-19)
|
|
5
|
+
*
|
|
6
|
+
* A diagnostic command exported `GIT_DIR="$PWD/.git"` to run a pre-push hook
|
|
7
|
+
* "the way git runs it". The export outlived the command and applied to
|
|
8
|
+
* everything downstream, including the test suite. `GIT_DIR` outranks `-C`, so
|
|
9
|
+
* every `git -C <tmpdir> …` in every fixture helper retargeted at the REAL
|
|
10
|
+
* repository. Three consequences landed, in ascending cost:
|
|
11
|
+
*
|
|
12
|
+
* 1. HEAD went detached (a fixture ran `checkout --detach`).
|
|
13
|
+
* 2. Three fixture commits landed in the real `.git`.
|
|
14
|
+
* 3. **Three entries landed in `.git/config`** — a fixture remote
|
|
15
|
+
* (`git@gitlab.example.com:…`), a fixture identity (`user.email`
|
|
16
|
+
* `test@example.com`, `user.name` `Test`) and `commit.gpgsign=false`.
|
|
17
|
+
*
|
|
18
|
+
* Item 3 was by far the most expensive, for one reason: **`git status` cannot
|
|
19
|
+
* see it.** The first recovery pass checked HEAD, the index and all 1614
|
|
20
|
+
* tracked files and reported the tree clean; the config entries were found two
|
|
21
|
+
* hours later, by an agent measuring something unrelated. By then two commits
|
|
22
|
+
* had been pushed with the fixture author to both GitLab and the public GitHub
|
|
23
|
+
* mirror, where branch protection refuses the force-push that would fix them —
|
|
24
|
+
* the correction now lives in `.mailmap` permanently. The fixture remote would
|
|
25
|
+
* additionally have pointed every `glab -R` call of that session at a
|
|
26
|
+
* non-existent instance, because `REMOTE_PREFERENCE.gitlab` in
|
|
27
|
+
* `scripts/lib/vcs-repo-spec.mjs` prefers the platform-named remote over
|
|
28
|
+
* `origin`.
|
|
29
|
+
*
|
|
30
|
+
* This probe is the cheap symptom detector for that class: one `git config
|
|
31
|
+
* --local --list` per session, matched against an expectation list.
|
|
32
|
+
*
|
|
33
|
+
* ## Named ceiling (BV-004) — read this before trusting a `null`
|
|
34
|
+
*
|
|
35
|
+
* This is an EXPECTATION LIST, not a semantic analysis of git configuration.
|
|
36
|
+
* It catches a fixture-shaped remote and a fixture-shaped identity. It does
|
|
37
|
+
* **NOT** catch:
|
|
38
|
+
*
|
|
39
|
+
* - a plausibly-named remote pointing at a real foreign host
|
|
40
|
+
* (`git@gitlab.internal.acme/…` reads as legitimate here);
|
|
41
|
+
* - anything an operator set deliberately — a per-repo work identity and a
|
|
42
|
+
* fixture identity are indistinguishable from the config file alone, so a
|
|
43
|
+
* deliberate override is reported and must be read, not silenced;
|
|
44
|
+
* - contamination in files other than `.git/config` (fixture commits, a
|
|
45
|
+
* detached HEAD, stray refs) — `git status` and `git log` cover those, and
|
|
46
|
+
* duplicating them here would buy nothing;
|
|
47
|
+
* - a `.git/config` written AFTER this probe ran. It is a session-start
|
|
48
|
+
* snapshot, not a watcher.
|
|
49
|
+
*
|
|
50
|
+
* **Revisit trigger:** if an operator reports a standing false positive on a
|
|
51
|
+
* deliberate local override, add an explicit allow-list (a `[sessionOrchestrator]`
|
|
52
|
+
* local-config key naming the accepted keys) — do NOT widen or delete a rule,
|
|
53
|
+
* which would re-open the exact hole the probe was built to close.
|
|
54
|
+
*
|
|
55
|
+
* ## Fail-open is forbidden here
|
|
56
|
+
*
|
|
57
|
+
* A failed query is NOT a clean repo. That fold — `null` meaning both "asked
|
|
58
|
+
* and clean" and "could not ask" — is the defect class this whole session
|
|
59
|
+
* worked against; see `isQueryFailure` in `scripts/lib/vcs-repo-spec.mjs:485`
|
|
60
|
+
* for the canonical statement, and `mirror-issues-banner.mjs`'s `degraded`
|
|
61
|
+
* field for the same discipline in the banner contract. A query failure here
|
|
62
|
+
* returns a `warn` result carrying `degraded`, never `null`.
|
|
63
|
+
*
|
|
64
|
+
* Plain-JS — no Zod dependency. Never throws. Synchronous, so it can sit in a
|
|
65
|
+
* session-start phase alongside the other probes without an await.
|
|
66
|
+
*
|
|
67
|
+
* Mirrors the Phase 4 banner contract (`scripts/lib/loop-readiness-banner.mjs`,
|
|
68
|
+
* `scripts/lib/mirror-issues-banner.mjs`): a single `checkXxx()` entry point
|
|
69
|
+
* returning `null` or `{ severity, message, ... }`.
|
|
70
|
+
*
|
|
71
|
+
* Cross-references:
|
|
72
|
+
* - `scripts/lib/validate/check-test-git-config-target.mjs` — the static
|
|
73
|
+
* counterpart, which finds the test call sites that can cause this.
|
|
74
|
+
* - `scripts/lib/validate/check-banner-parity.mjs:59-63` — prior art for the
|
|
75
|
+
* filtered git environment reproduced below.
|
|
76
|
+
* - `skills/session-start/SKILL.md` Phase 4 — banner render site.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import { spawnSync } from 'node:child_process';
|
|
80
|
+
|
|
81
|
+
/** Default timeout in ms for each `git config` invocation. */
|
|
82
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Closed set of `degraded` reasons. A degraded result means the local config
|
|
86
|
+
* was NOT successfully read — never that it is clean.
|
|
87
|
+
*
|
|
88
|
+
* `config-unreadable` is a deliberate fifth member rather than a fold into
|
|
89
|
+
* `not-a-git-repo`: git exits 128 for BOTH a missing repository and a
|
|
90
|
+
* syntactically broken `.git/config`, and the second is a corruption signal in
|
|
91
|
+
* exactly this probe's neighbourhood. Labelling it "not a git repo" would send
|
|
92
|
+
* the operator looking in the wrong place.
|
|
93
|
+
*
|
|
94
|
+
* @type {readonly ['git-unavailable','not-a-git-repo','config-unreadable','timeout','git-error']}
|
|
95
|
+
*/
|
|
96
|
+
export const DEGRADED_REASONS = Object.freeze([
|
|
97
|
+
'git-unavailable',
|
|
98
|
+
'not-a-git-repo',
|
|
99
|
+
'config-unreadable',
|
|
100
|
+
'timeout',
|
|
101
|
+
'git-error',
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Closed set of finding kinds, frozen so consumers can switch on it.
|
|
106
|
+
*
|
|
107
|
+
* - `ambient-git-env` — `GIT_DIR`/`GIT_WORK_TREE` present in the process
|
|
108
|
+
* environment. Not a config entry at all, but the PROXIMATE CAUSE of the
|
|
109
|
+
* incident above, and free to detect. Reported first because it makes every
|
|
110
|
+
* other git command in the session suspect.
|
|
111
|
+
* - `local-identity` — a local `user.email`/`user.name` overriding the
|
|
112
|
+
* global identity. The costliest entry of the incident.
|
|
113
|
+
* - `local-gpgsign` — a local `commit.gpgsign` differing from the global.
|
|
114
|
+
* - `fixture-remote` — a remote URL on a reserved/fixture host.
|
|
115
|
+
* - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`.
|
|
116
|
+
*
|
|
117
|
+
* @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path']}
|
|
118
|
+
*/
|
|
119
|
+
export const FINDING_KINDS = Object.freeze([
|
|
120
|
+
'ambient-git-env',
|
|
121
|
+
'local-identity',
|
|
122
|
+
'local-gpgsign',
|
|
123
|
+
'fixture-remote',
|
|
124
|
+
'hooks-path',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Environment variables that redirect git's repository discovery. Their mere
|
|
129
|
+
* presence is a finding — `GIT_DIR` outranks `-C`, which is precisely why the
|
|
130
|
+
* incident's fixture helpers, all of which passed a correct `-C <tmpdir>`,
|
|
131
|
+
* still wrote into the real repository.
|
|
132
|
+
*/
|
|
133
|
+
const REPO_REDIRECT_ENV_VARS = Object.freeze(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The only environment variables forwarded to the `git` child. Everything else
|
|
137
|
+
* — `GIT_DIR` above all — is dropped, so this probe cannot be redirected at a
|
|
138
|
+
* foreign repository by the very contamination it is looking for. Same
|
|
139
|
+
* allowlist as `check-banner-parity.mjs:63`; deliberately duplicated rather
|
|
140
|
+
* than lifted into a shared module, because a 6-entry constant is cheaper to
|
|
141
|
+
* copy than a new module is to maintain (BV-001).
|
|
142
|
+
*/
|
|
143
|
+
const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Host suffixes reserved for documentation and testing, plus loopback. These
|
|
147
|
+
* are MEASURED against this repo's own fixtures rather than guessed: on
|
|
148
|
+
* 2026-08-19 a census of `tests/` (`grep -rhoE '(git@|ssh://|https?://)[A-Za-z0-9.@_-]+'`)
|
|
149
|
+
* returned `gitlab.example.com` (189), `github.example.com` (25),
|
|
150
|
+
* `example.com` (19), `example.test` (11), `127.0.0.1` (7), `gitlab.example`
|
|
151
|
+
* (4), plus `test.local` from the fixture identities. `.invalid` is included
|
|
152
|
+
* from RFC 2606 for completeness though this repo has no instance of it.
|
|
153
|
+
*
|
|
154
|
+
* `.local` is the most false-positive-prone member (an operator MAY have a
|
|
155
|
+
* genuine mDNS/LAN git host) — it stays in because a WARN naming the remote
|
|
156
|
+
* costs one glance, and because `test.local` is a shape this repo's own
|
|
157
|
+
* fixtures produce.
|
|
158
|
+
*/
|
|
159
|
+
const FIXTURE_HOST_SUFFIXES = Object.freeze([
|
|
160
|
+
'.example.com',
|
|
161
|
+
'.example.org',
|
|
162
|
+
'.example.net',
|
|
163
|
+
'.example',
|
|
164
|
+
'.test',
|
|
165
|
+
'.invalid',
|
|
166
|
+
'.local',
|
|
167
|
+
'.localhost',
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
/** Exact hosts treated as fixture/loopback targets. */
|
|
171
|
+
const FIXTURE_HOSTS = Object.freeze([
|
|
172
|
+
'example.com',
|
|
173
|
+
'example.org',
|
|
174
|
+
'example.net',
|
|
175
|
+
'example',
|
|
176
|
+
'localhost',
|
|
177
|
+
'127.0.0.1',
|
|
178
|
+
'::1',
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
/** The only `core.hooksPath` this repo expects, matched as a path tail. */
|
|
182
|
+
const EXPECTED_HOOKS_PATH_TAIL = '.husky/_';
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build the filtered child environment. See {@link GIT_ENV_ALLOWLIST}.
|
|
186
|
+
*
|
|
187
|
+
* @param {Record<string, string|undefined>} sourceEnv
|
|
188
|
+
* @returns {Record<string, string>}
|
|
189
|
+
*/
|
|
190
|
+
function filteredGitEnv(sourceEnv) {
|
|
191
|
+
/** @type {Record<string, string>} */
|
|
192
|
+
const out = {};
|
|
193
|
+
for (const key of GIT_ENV_ALLOWLIST) {
|
|
194
|
+
const value = sourceEnv?.[key];
|
|
195
|
+
if (typeof value === 'string') out[key] = value;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Run one `git config` query.
|
|
202
|
+
*
|
|
203
|
+
* @param {string[]} args argv after the leading `git`
|
|
204
|
+
* @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
|
|
205
|
+
* @returns {{ok: true, stdout: string} | {ok: false, reason: 'git-unavailable'|'not-a-git-repo'|'config-unreadable'|'timeout'|'git-error', status: number|null}}
|
|
206
|
+
*/
|
|
207
|
+
function runGitConfig(args, ctx) {
|
|
208
|
+
const res = ctx.spawn('git', ['-C', ctx.repoRoot, ...args], {
|
|
209
|
+
encoding: 'utf8',
|
|
210
|
+
timeout: ctx.timeoutMs,
|
|
211
|
+
env: filteredGitEnv(ctx.env),
|
|
212
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (res?.error) {
|
|
216
|
+
const code = /** @type {any} */ (res.error).code;
|
|
217
|
+
if (code === 'ENOENT') return { ok: false, reason: 'git-unavailable', status: null };
|
|
218
|
+
if (code === 'ETIMEDOUT') return { ok: false, reason: 'timeout', status: null };
|
|
219
|
+
return { ok: false, reason: 'git-error', status: null };
|
|
220
|
+
}
|
|
221
|
+
if (res?.signal) return { ok: false, reason: 'timeout', status: null };
|
|
222
|
+
|
|
223
|
+
const status = typeof res?.status === 'number' ? res.status : null;
|
|
224
|
+
if (status === 0) return { ok: true, stdout: String(res?.stdout ?? '') };
|
|
225
|
+
|
|
226
|
+
// 128 covers BOTH "not a git repository" and "bad config line in
|
|
227
|
+
// .git/config". Discriminate on stderr so a corrupted config is not
|
|
228
|
+
// mislabelled as a missing repo — see DEGRADED_REASONS.
|
|
229
|
+
if (status === 128) {
|
|
230
|
+
const stderr = String(res?.stderr ?? '').toLowerCase();
|
|
231
|
+
if (stderr.includes('bad config') || stderr.includes('bad numeric config')) {
|
|
232
|
+
return { ok: false, reason: 'config-unreadable', status };
|
|
233
|
+
}
|
|
234
|
+
return { ok: false, reason: 'not-a-git-repo', status };
|
|
235
|
+
}
|
|
236
|
+
return { ok: false, reason: 'git-error', status };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse `git config --list -z` output: NUL-separated entries, each `key\nvalue`
|
|
241
|
+
* (a valueless boolean key has no `\n`).
|
|
242
|
+
*
|
|
243
|
+
* The `-z` form is deliberate: the plain `--list` form is `key=value` split on
|
|
244
|
+
* newlines, which mis-parses any multi-line config value — and a multi-line
|
|
245
|
+
* value in a remote URL is exactly the corrupted-config shape this probe is
|
|
246
|
+
* most likely to meet.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} stdout
|
|
249
|
+
* @returns {Array<{key: string, value: string}>}
|
|
250
|
+
*/
|
|
251
|
+
export function parseNulConfigList(stdout) {
|
|
252
|
+
/** @type {Array<{key: string, value: string}>} */
|
|
253
|
+
const entries = [];
|
|
254
|
+
for (const record of String(stdout ?? '').split('\0')) {
|
|
255
|
+
if (record === '') continue;
|
|
256
|
+
const nl = record.indexOf('\n');
|
|
257
|
+
if (nl === -1) entries.push({ key: record, value: '' });
|
|
258
|
+
else entries.push({ key: record.slice(0, nl), value: record.slice(nl + 1) });
|
|
259
|
+
}
|
|
260
|
+
return entries;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Extract the host from a git remote URL — both the `scheme://[user@]host/…`
|
|
265
|
+
* and the scp-like `[user@]host:path` forms.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} url
|
|
268
|
+
* @returns {string|null} lowercased host, or null when no host is discernible
|
|
269
|
+
* (a local path remote, which is never a fixture-host finding)
|
|
270
|
+
*/
|
|
271
|
+
export function remoteHost(url) {
|
|
272
|
+
const raw = String(url ?? '').trim();
|
|
273
|
+
if (raw === '') return null;
|
|
274
|
+
|
|
275
|
+
const schemeMatch = /^[a-z][a-z0-9+.-]*:\/\/([^/]*)/i.exec(raw);
|
|
276
|
+
if (schemeMatch) {
|
|
277
|
+
const authority = schemeMatch[1];
|
|
278
|
+
const afterUserinfo = authority.slice(authority.lastIndexOf('@') + 1);
|
|
279
|
+
return afterUserinfo.replace(/:\d+$/, '').toLowerCase() || null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// scp-like: `[user@]host:path`. A Windows drive letter (`C:\…`) and an
|
|
283
|
+
// absolute/relative local path have no `@` and no dot-bearing host, so they
|
|
284
|
+
// fall through to null rather than being reported as a fixture host.
|
|
285
|
+
const scp = /^([^/\\:]+):(?!\/\/)/.exec(raw);
|
|
286
|
+
if (scp) {
|
|
287
|
+
const host = scp[1].slice(scp[1].lastIndexOf('@') + 1).toLowerCase();
|
|
288
|
+
return host.includes('.') || host === 'localhost' ? host : null;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string|null} host
|
|
295
|
+
* @returns {boolean} true when `host` is a reserved documentation/testing or
|
|
296
|
+
* loopback host per {@link FIXTURE_HOST_SUFFIXES} / {@link FIXTURE_HOSTS}.
|
|
297
|
+
*/
|
|
298
|
+
export function isFixtureHost(host) {
|
|
299
|
+
if (typeof host !== 'string' || host === '') return false;
|
|
300
|
+
if (FIXTURE_HOSTS.includes(host)) return true;
|
|
301
|
+
return FIXTURE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @param {string} value a `core.hooksPath` value
|
|
306
|
+
* @returns {boolean} true when it points at `.husky/_` (absolute or relative)
|
|
307
|
+
*/
|
|
308
|
+
function hooksPathIsExpected(value) {
|
|
309
|
+
const normalized = String(value ?? '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
310
|
+
return normalized === EXPECTED_HOOKS_PATH_TAIL || normalized.endsWith(`/${EXPECTED_HOOKS_PATH_TAIL}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Build the degraded result. Distinct from `null` on purpose — see the header.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} reason
|
|
317
|
+
* @returns {{severity:'warn', message:string, degraded:string, findings:[]}}
|
|
318
|
+
*/
|
|
319
|
+
function degradedResult(reason) {
|
|
320
|
+
return {
|
|
321
|
+
severity: 'warn',
|
|
322
|
+
message:
|
|
323
|
+
`⚠ git-config-drift: .git/config konnte nicht gelesen werden (${reason}) ` +
|
|
324
|
+
`— Zustand unbekannt, nicht "sauber". \`git config --local --list\` von Hand prüfen.`,
|
|
325
|
+
degraded: reason,
|
|
326
|
+
findings: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Inspect `.git/config` (and the ambient git environment) for fixture-shaped
|
|
332
|
+
* contamination.
|
|
333
|
+
*
|
|
334
|
+
* Return contract — three states, not two:
|
|
335
|
+
* - `null` when the query SUCCEEDED and matched the expectation list.
|
|
336
|
+
* - `{ severity:'warn', message, findings: [...] }` when at least one
|
|
337
|
+
* expectation is violated.
|
|
338
|
+
* - `{ severity:'warn', message, degraded, findings: [] }` when the local
|
|
339
|
+
* config could not be read. `degraded` is present ONLY in this case, so its
|
|
340
|
+
* absence proves the config was actually read.
|
|
341
|
+
*
|
|
342
|
+
* Never throws.
|
|
343
|
+
*
|
|
344
|
+
* @param {{repoRoot?: string, env?: Record<string,string|undefined>, timeoutMs?: number}} [opts]
|
|
345
|
+
* @param {{spawn?: Function}} [deps] DI for tests only.
|
|
346
|
+
* @returns {null | {severity:'warn', message:string, findings: Array<{kind:string, key:string, value:string, detail:string}>, degraded?: string}}
|
|
347
|
+
*/
|
|
348
|
+
export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
349
|
+
try {
|
|
350
|
+
const { repoRoot, env, timeoutMs = DEFAULT_TIMEOUT_MS } = opts ?? {};
|
|
351
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
352
|
+
|
|
353
|
+
const activeEnv = env && typeof env === 'object' ? env : process.env;
|
|
354
|
+
const spawn = deps?.spawn ?? spawnSync;
|
|
355
|
+
const ctx = { repoRoot, env: activeEnv, timeoutMs, spawn };
|
|
356
|
+
|
|
357
|
+
/** @type {Array<{kind:string, key:string, value:string, detail:string}>} */
|
|
358
|
+
const findings = [];
|
|
359
|
+
|
|
360
|
+
// Finding 0 — the proximate cause. Checked BEFORE the query, because a set
|
|
361
|
+
// GIT_DIR makes every OTHER git command of this session suspect, including
|
|
362
|
+
// ones this probe never sees.
|
|
363
|
+
for (const name of REPO_REDIRECT_ENV_VARS) {
|
|
364
|
+
const value = activeEnv?.[name];
|
|
365
|
+
if (typeof value === 'string' && value !== '') {
|
|
366
|
+
findings.push({
|
|
367
|
+
kind: 'ambient-git-env',
|
|
368
|
+
key: name,
|
|
369
|
+
value,
|
|
370
|
+
detail:
|
|
371
|
+
`${name} ist gesetzt (${value}) — es schlägt \`-C\` und \`cwd\`, ` +
|
|
372
|
+
`jedes git-Kommando dieser Sitzung kann ein fremdes Repo treffen.`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const local = runGitConfig(['config', '--local', '--list', '-z'], ctx);
|
|
378
|
+
if (!local.ok) return degradedResult(local.reason);
|
|
379
|
+
|
|
380
|
+
// Best-effort global read, for the identity/gpgsign comparison only. Its
|
|
381
|
+
// failure never degrades the result — the local query, which IS the
|
|
382
|
+
// measurement, succeeded.
|
|
383
|
+
const globalRes = runGitConfig(['config', '--global', '--list', '-z'], ctx);
|
|
384
|
+
/** @type {Map<string,string>} */
|
|
385
|
+
const globals = new Map();
|
|
386
|
+
if (globalRes.ok) {
|
|
387
|
+
for (const { key, value } of parseNulConfigList(globalRes.stdout)) {
|
|
388
|
+
globals.set(key.toLowerCase(), value);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const { key, value } of parseNulConfigList(local.stdout)) {
|
|
393
|
+
const lower = key.toLowerCase();
|
|
394
|
+
|
|
395
|
+
if (lower === 'user.email' || lower === 'user.name') {
|
|
396
|
+
const globalValue = globals.get(lower);
|
|
397
|
+
if (globalValue === value) continue; // local restates the global — no override
|
|
398
|
+
findings.push({
|
|
399
|
+
kind: 'local-identity',
|
|
400
|
+
key,
|
|
401
|
+
value,
|
|
402
|
+
detail:
|
|
403
|
+
`lokales ${key}=${value} überschreibt die Identität` +
|
|
404
|
+
(globalValue === undefined
|
|
405
|
+
? ' (global nicht gesetzt)'
|
|
406
|
+
: ` (global: ${globalValue})`) +
|
|
407
|
+
' — Commits dieses Repos tragen den lokalen Wert.',
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (lower === 'commit.gpgsign') {
|
|
413
|
+
const globalValue = globals.get(lower);
|
|
414
|
+
if (globalValue === value) continue;
|
|
415
|
+
findings.push({
|
|
416
|
+
kind: 'local-gpgsign',
|
|
417
|
+
key,
|
|
418
|
+
value,
|
|
419
|
+
detail:
|
|
420
|
+
`lokales ${key}=${value} weicht vom globalen Wert ab` +
|
|
421
|
+
(globalValue === undefined ? ' (global nicht gesetzt)' : ` (${globalValue})`) +
|
|
422
|
+
'.',
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (/^remote\..+\.url$/i.test(key)) {
|
|
428
|
+
const host = remoteHost(value);
|
|
429
|
+
if (isFixtureHost(host)) {
|
|
430
|
+
findings.push({
|
|
431
|
+
kind: 'fixture-remote',
|
|
432
|
+
key,
|
|
433
|
+
value,
|
|
434
|
+
detail:
|
|
435
|
+
`${key} zeigt auf den Fixture-/Loopback-Host ${host} — ` +
|
|
436
|
+
`ein plattformbenanntes Remote wird von REMOTE_PREFERENCE VOR origin gewählt.`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (lower === 'core.hookspath' && !hooksPathIsExpected(value)) {
|
|
443
|
+
findings.push({
|
|
444
|
+
kind: 'hooks-path',
|
|
445
|
+
key,
|
|
446
|
+
value,
|
|
447
|
+
detail: `core.hooksPath=${value} zeigt nicht auf ${EXPECTED_HOOKS_PATH_TAIL} — Husky-Hooks laufen nicht.`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (findings.length === 0) return null;
|
|
453
|
+
|
|
454
|
+
const plural = findings.length === 1 ? 'Eintrag' : 'Einträge';
|
|
455
|
+
return {
|
|
456
|
+
severity: 'warn',
|
|
457
|
+
message:
|
|
458
|
+
`⚠ git-config-drift: ${findings.length} unerwartete(r) ${plural} in der lokalen ` +
|
|
459
|
+
`git-Konfiguration — für \`git status\` unsichtbar: ` +
|
|
460
|
+
findings.map((f) => f.detail).join(' '),
|
|
461
|
+
findings,
|
|
462
|
+
};
|
|
463
|
+
} catch {
|
|
464
|
+
// Defensive catch-all: a session-start probe must never break the session.
|
|
465
|
+
// Distinct from the degraded path above — this is an internal defect, not a
|
|
466
|
+
// failed query, and there is nothing truthful left to report.
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export default checkGitConfigDrift;
|
|
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
13
13
|
|
|
14
14
|
import { safeRead, lineCount, pass, fail } from './helpers.mjs';
|
|
15
15
|
import { resolveInstructionFile } from '../../common.mjs';
|
|
16
|
+
import { listRemotes } from '../../vcs-repo-spec.mjs';
|
|
16
17
|
|
|
17
18
|
export function runCategory6(root) {
|
|
18
19
|
const checks = [];
|
|
@@ -114,9 +115,31 @@ export function runCategory6(root) {
|
|
|
114
115
|
// repo's "github" remote (GitLab-primary / GitHub-mirror setups). A repo
|
|
115
116
|
// with no github remote is not required to mirror anything, so that state
|
|
116
117
|
// is skip-as-pass (full points) rather than a failure. Every git call is
|
|
117
|
-
// wrapped defensively —
|
|
118
|
-
//
|
|
119
|
-
//
|
|
118
|
+
// wrapped defensively — an unfetched tracking ref or any other git-edge-case
|
|
119
|
+
// degrades to skip-as-pass so the audit never crashes or hard-fails on
|
|
120
|
+
// ambient repo state it can't inspect.
|
|
121
|
+
//
|
|
122
|
+
// TWO states are deliberately NOT skip-as-pass (#1039), and the rule that
|
|
123
|
+
// separates them from the rest is a single question: could the git call have
|
|
124
|
+
// returned null because the thing it asked about is legitimately ABSENT?
|
|
125
|
+
//
|
|
126
|
+
// 1. A FAILED remote query. Until #1039 the remote list came from a bare
|
|
127
|
+
// `git remote` whose every failure mode collapsed to `null`, so a root
|
|
128
|
+
// that is not a git repository — or a host without git on PATH — scored
|
|
129
|
+
// 2 of 2 with the message "no github mirror remote configured". The audit
|
|
130
|
+
// rewarded its own blindness. The remote list now comes from
|
|
131
|
+
// `listRemotes`, whose contract separates ABSENCE (`ok:true, remotes:[]`
|
|
132
|
+
// — a legitimate repo state) from QUERY FAILURE (`ok:false`, every reason
|
|
133
|
+
// satisfying `isQueryFailure`). Absence keeps full points.
|
|
134
|
+
// 2. An unmeasurable AHEAD-COUNT (see the fold at the `rev-list` call). The
|
|
135
|
+
// two `rev-parse --verify --quiet` folds above stay skip-as-pass because
|
|
136
|
+
// their null genuinely means "ref not fetched"; `rev-list` has no such
|
|
137
|
+
// state left once the ref is verified, so its null means git failed.
|
|
138
|
+
//
|
|
139
|
+
// Both are a `fail`, because a check that could not measure has not passed —
|
|
140
|
+
// and because the audit's own `checks_passed` counter
|
|
141
|
+
// (`scripts/harness-audit.mjs`) counts `status === 'pass'`, so recording an
|
|
142
|
+
// unmeasurable check as a pass inflates the number that summarises audit trust.
|
|
120
143
|
//
|
|
121
144
|
// Mirror-branch resolution tries two strategies, in order:
|
|
122
145
|
// 1. `github/HEAD` symbolic ref — only populated by an explicit
|
|
@@ -142,14 +165,23 @@ export function runCategory6(root) {
|
|
|
142
165
|
}
|
|
143
166
|
};
|
|
144
167
|
|
|
145
|
-
const
|
|
146
|
-
const hasGithubRemote =
|
|
147
|
-
remotesOutput.split('\n').map((l) => l.trim()).includes('github');
|
|
168
|
+
const remotes = listRemotes({ repoRoot: root });
|
|
169
|
+
const hasGithubRemote = remotes.ok && remotes.remotes.some((r) => r.name === 'github');
|
|
148
170
|
|
|
149
|
-
if (!
|
|
171
|
+
if (!remotes.ok) {
|
|
172
|
+
// `ok:false` from listRemotes ⟺ the query itself failed (its contract
|
|
173
|
+
// emits only isQueryFailure reasons: not-a-git-repo / git-unavailable /
|
|
174
|
+
// git-error). A repo with no remotes is `ok:true` with an empty list and
|
|
175
|
+
// takes the skip-as-pass branch below, exactly as before.
|
|
176
|
+
checks.push(fail({
|
|
177
|
+
checkId, maxPoints: 2, path: relPath,
|
|
178
|
+
evidence: { hasGithubRemote: null, remoteQuery: remotes.reason },
|
|
179
|
+
message: `git remote query failed (${remotes.reason}) — github mirror sync not measurable`,
|
|
180
|
+
}));
|
|
181
|
+
} else if (!hasGithubRemote) {
|
|
150
182
|
checks.push(pass({
|
|
151
183
|
checkId, points: 2, maxPoints: 2, path: relPath,
|
|
152
|
-
evidence: { hasGithubRemote: false },
|
|
184
|
+
evidence: { hasGithubRemote: false, remoteCount: remotes.remotes.length },
|
|
153
185
|
message: 'no github mirror remote configured — skipped',
|
|
154
186
|
}));
|
|
155
187
|
} else {
|
|
@@ -181,14 +213,35 @@ export function runCategory6(root) {
|
|
|
181
213
|
message: 'github mirror ref not fetched locally — skipped',
|
|
182
214
|
}));
|
|
183
215
|
} else {
|
|
216
|
+
// The SECOND #1039 fold, and it is NOT symmetric with the one above —
|
|
217
|
+
// which is why the `rev-parse` folds stay skip-as-pass and this one does
|
|
218
|
+
// not. Up there, a null means the ref is simply absent: `git rev-parse
|
|
219
|
+
// --verify --quiet github/<branch>` exits 1 SILENTLY for an unfetched
|
|
220
|
+
// mirror ref, a legitimate repo state. Down here there is no legitimate
|
|
221
|
+
// absence left to express — `mirrorBranch` was already proven to exist by
|
|
222
|
+
// that very --verify, and `git rev-list --count <existing>..HEAD` exits 0
|
|
223
|
+
// with an integer or 128 with a fatal (measured 2026-08-19 in this repo:
|
|
224
|
+
// `HEAD~1..HEAD` → exit 0 "1"; `PLACEHOLDER-missing..HEAD` → exit 128).
|
|
225
|
+
// So a null here means git FAILED, not that the answer is "nothing", and
|
|
226
|
+
// booking it 2/2 was the same "the audit rewarded its own blindness"
|
|
227
|
+
// shape the remote-query branch above was fixed for.
|
|
228
|
+
//
|
|
229
|
+
// Both NaN shapes are folded into one `fail` on purpose. A successful
|
|
230
|
+
// `rev-list --count` always prints an integer, so non-numeric output is
|
|
231
|
+
// git not honouring its own contract — equally unmeasurable, and
|
|
232
|
+
// splitting it off would leave the identical fail-open standing behind a
|
|
233
|
+
// smaller trigger. `aheadCountQuery` records which of the two occurred.
|
|
184
234
|
const revListOut = runGit(['rev-list', '--count', `github/${mirrorBranch}..HEAD`]);
|
|
185
235
|
const aheadCount = revListOut !== null ? Number.parseInt(revListOut, 10) : NaN;
|
|
186
236
|
|
|
187
237
|
if (Number.isNaN(aheadCount)) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
238
|
+
const aheadCountQuery = revListOut === null ? 'git-error' : 'unparseable-output';
|
|
239
|
+
checks.push(fail({
|
|
240
|
+
checkId, maxPoints: 2, path: relPath,
|
|
241
|
+
evidence: { hasGithubRemote: true, mirrorBranch, resolvedVia, aheadCount: null, aheadCountQuery },
|
|
242
|
+
message:
|
|
243
|
+
`unable to determine ahead-count vs github/${mirrorBranch} (${aheadCountQuery}) — ` +
|
|
244
|
+
'mirror drift not measurable',
|
|
192
245
|
}));
|
|
193
246
|
} else if (aheadCount === 0) {
|
|
194
247
|
checks.push(pass({
|