@boyingliu01/xp-gate 0.35.1 → 0.35.3

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 (47) hide show
  1. package/adapters/gate-4.sh +33 -4
  2. package/bin/xp-gate.js +15 -0
  3. package/lib/__tests__/doctor-drift-direction.test.js +104 -0
  4. package/lib/__tests__/gate-coverage.test.js +137 -0
  5. package/lib/__tests__/test-alignment.test.ts +45 -4
  6. package/lib/doctor.js +82 -8
  7. package/lib/gate-coverage.js +315 -0
  8. package/lib/mattpocock-license-verdict.json +1 -1
  9. package/lib/test-alignment.ts +27 -7
  10. package/mock-policy/AGENTS.md +2 -2
  11. package/mutation/AGENTS.md +2 -2
  12. package/package.json +1 -1
  13. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  15. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  16. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  17. package/plugins/dsh/lib/command.d.ts +1 -1
  18. package/plugins/dsh/lib/command.d.ts.map +1 -1
  19. package/plugins/dsh/lib/command.js +9 -3
  20. package/plugins/dsh/lib/dialect.d.ts +22 -2
  21. package/plugins/dsh/lib/dialect.d.ts.map +1 -1
  22. package/plugins/dsh/lib/dialect.js +37 -2
  23. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -1
  24. package/plugins/dsh/lib/gate-runner.js +22 -0
  25. package/plugins/dsh/lib/index.d.ts.map +1 -1
  26. package/plugins/dsh/lib/index.js +7 -4
  27. package/plugins/dsh/package.json +1 -1
  28. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  31. package/plugins/dsh/src/command.ts +9 -3
  32. package/plugins/dsh/src/dialect.ts +45 -2
  33. package/plugins/dsh/src/exit-code.test.ts +83 -0
  34. package/plugins/dsh/src/gate-runner.ts +23 -0
  35. package/plugins/dsh/src/index.ts +7 -4
  36. package/plugins/opencode/package.json +1 -1
  37. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/qoder/plugin.json +1 -1
  41. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/skills/delphi-review/AGENTS.md +2 -2
  46. package/skills/sprint-flow/AGENTS.md +2 -2
  47. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -165,13 +165,42 @@ else
165
165
  rm -f "$PRINCIPLES_STDERR"
166
166
 
167
167
  if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
168
- echo "⚠️ Warning: Principles checker execution failed"
169
- echo "⏭️ SKIPPED - Principles check (execution issue)"
170
- GATE_4_STATUS="SKIP"
171
- # Consumed by the sourcing parent, not here (see #548).
168
+ # Fail-closed (#661), same rule as Gate 8 (#499): a gate that cannot run
169
+ # must not report success.
170
+ #
171
+ # This branch used to settle on SKIP, which the caller reads as
172
+ # non-blocking -- so a checker that crashed on startup
173
+ # (`Cannot find module 'C:\c\Users\...'`, the POSIX-path-to-native-node
174
+ # bug) still printed "PASSED - Principles checker (no errors found)".
175
+ # The path conversion fixed that one cause; any other way of breaking
176
+ # the launch (missing tsx, corrupt module, OOM) lands here too, and all
177
+ # of them would have been absorbed into a green light.
178
+ #
179
+ # SKIP stays reserved for "language not applicable / tool not
180
+ # installed", which is not what this is: the checker exists and failed.
181
+ echo ""
182
+ echo "❌ BLOCKED - Principles checker failed to execute (exit $PRINCIPLES_EXIT)"
183
+ echo "This is a tool fault, NOT a clean result. A gate that cannot run must not pass."
184
+ echo " Fix the checker, or set PRINCIPLES_SKIP_TOOL_FAULT=1 to downgrade to a warning."
185
+ echo " (downgrade is audit-logged and never allowed on main/master)"
186
+ GATE_4_STATUS="FAIL"
172
187
  # shellcheck disable=SC2034
173
188
  GATE_4_REASON="checker_execution_error"
174
189
  rm -f "$PRINCIPLES_JSON"
190
+ if [ "${PRINCIPLES_SKIP_TOOL_FAULT:-0}" = "1" ]; then
191
+ echo "⚠️ ESCAPE VALVE: PRINCIPLES_SKIP_TOOL_FAULT=1 — downgrading to SKIP"
192
+ # Same asymmetry as Gate 5a: a downgrade is tolerable on a feature
193
+ # branch, never on main/master, where it would green-light the trunk.
194
+ _g4_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
195
+ if [ "$_g4_branch" = "main" ] || [ "$_g4_branch" = "master" ]; then
196
+ echo "❌ ESCAPE VALVE BLOCKED: PRINCIPLES_SKIP_TOOL_FAULT not allowed on $_g4_branch"
197
+ GATE_4_STATUS="FAIL"
198
+ exit 1
199
+ fi
200
+ GATE_4_STATUS="SKIP"
201
+ else
202
+ exit 1
203
+ fi
175
204
  else
176
205
  # Check severity levels. The reporter emits JSON.stringify(out, null, 2),
177
206
  # i.e. `"severity": "warning"` WITH a space; tolerate any whitespace so a
package/bin/xp-gate.js CHANGED
@@ -350,6 +350,21 @@ const COMMANDS = {
350
350
  },
351
351
  usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
352
352
  },
353
+ 'gate-coverage': {
354
+ description: 'One view of compliance against every gate: PASS / DEBT / SKIP / FAIL, with debt and evidence (#636)',
355
+ run: subargs => {
356
+ const { main } = require('../lib/gate-coverage.js');
357
+ const { execFileSync } = require('node:child_process');
358
+ let headCommit;
359
+ try {
360
+ headCommit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
361
+ } catch {
362
+ headCommit = undefined;
363
+ }
364
+ process.exit(main(subargs, { headCommit }));
365
+ },
366
+ usage: 'xp-gate gate-coverage [--json] [--debt] [--strict] [--branch <name>]'
367
+ },
353
368
  'rework': {
354
369
  description: 'Report rework: first-pass rate, rounds before landing, top blocking reasons (#651)',
355
370
  run: subargs => {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @test REQ-670 doctor: classify module drift by DIRECTION, not by line counts
3
+ * @intent `xp-gate doctor` used to report five gate modules as
4
+ *
5
+ * diverged both ways — githooks/ has 70 line(s) the install lacks,
6
+ * the install has 2 line(s) githooks/ lacks; sync refuses without an
7
+ * explicit decision
8
+ *
9
+ * I checked every "<" line (installed-only) by hand: all of them were
10
+ * OLDER versions of code the repository had already rewritten. The
11
+ * "install has 2 lines githooks lacks" was purely the shape a re-wrapped
12
+ * line leaves in a line-level diff — the same logic, with a `&&` or a
13
+ * pipe moved onto a new line, produces a line present on neither side.
14
+ *
15
+ * The wording matters more than it looks. "diverged both ways" plus
16
+ * "sync refuses without an explicit decision" tells the reader that both
17
+ * sides hold something worth keeping and a human must choose. The
18
+ * consequence is not extra keystrokes: it makes people AVOID syncing,
19
+ * because syncing appears to discard the installed copy's work. And not
20
+ * syncing is exactly #451 — editing githooks/ and having nothing happen.
21
+ *
22
+ * So the classifier must ask "does the installed copy hold anything the
23
+ * repo genuinely lacks?" instead of "are both line counts non-zero?".
24
+ * Token-level comparison answers the real question: whitespace and line
25
+ * breaks are formatting, and formatting is not work.
26
+ * @covers AC-670-64, AC-670-65, AC-670-66
27
+ */
28
+ 'use strict';
29
+
30
+ const { moduleDriftDetail } = require('../doctor');
31
+
32
+ /** Join lines the way a formatter re-wraps them: same tokens, different breaks. */
33
+ const wrap = (...lines) => lines.join('\n');
34
+
35
+ describe('moduleDriftDetail direction classification (#667)', () => {
36
+ it('AC-670-64: a pure re-wrap is "behind", not "diverged both ways"', () => {
37
+ // Same logic; the `&&` moved to the next line. A line-level diff calls this
38
+ // two-sided drift. Nothing was authored on the installed side.
39
+ // Real shape measured on gate-3.sh: an older install re-wrapped the same
40
+ // statements, so a line-level diff reports lines unique to BOTH sides while
41
+ // the token content is identical (measured: repoOnly=0, installedOnly=0).
42
+ const repo = wrap(
43
+ 'RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"',
44
+ 'if [ -f "$RATCHET_LIB" ]; then . "$RATCHET_LIB"; fi',
45
+ 'known.set(norm(f.file) + "::" + f.name, Number(f.ccn) || 0);',
46
+ );
47
+ const installed = wrap(
48
+ ' RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"',
49
+ ' if [ -f "$RATCHET_LIB" ]; then',
50
+ ' . "$RATCHET_LIB"',
51
+ ' fi',
52
+ 'known.set(norm(f.file) + "::" + f.name,',
53
+ ' Number(f.ccn) || 0);',
54
+ );
55
+ const detail = moduleDriftDetail(repo, installed);
56
+ expect(detail).not.toMatch(/diverged both ways/i);
57
+ expect(detail).toMatch(/formatting|stale|behind/i);
58
+ });
59
+
60
+ it('AC-670-65: a genuine two-sided edit still refuses to sync silently', () => {
61
+ // The installed copy carries a real, machine-only hardening: a fail-closed
62
+ // branch that exists nowhere in the repo. Overwriting it would destroy work.
63
+ const repo = wrap(
64
+ 'if [ -z "$COV" ]; then',
65
+ ' echo "no coverage tool";',
66
+ ' return 0;',
67
+ 'fi',
68
+ );
69
+ const installed = wrap(
70
+ 'if [ -z "$COV" ]; then',
71
+ ' # FAIL CLOSED: cannot determine coverage means the check failed.',
72
+ ' echo "❌ BLOCKED - could not determine coverage";',
73
+ ' return 1;',
74
+ 'fi',
75
+ );
76
+ const detail = moduleDriftDetail(repo, installed);
77
+ expect(detail).toMatch(/diverged both ways/i);
78
+ expect(detail).toMatch(/refuses|explicit decision/i);
79
+ });
80
+
81
+ it('AC-670-66: an unreadable pair is reported honestly, never as identical', () => {
82
+ // readPair() returns null when either side cannot be read. Reporting that as
83
+ // "matches" would be a fail-open: the drift could be real and unseen.
84
+ const detail = moduleDriftDetail(null, null);
85
+ expect(detail).toMatch(/could not be read|cannot be compared/i);
86
+ expect(detail).not.toMatch(/matches|identical/i);
87
+ });
88
+ });
89
+
90
+ describe('re-wrap is not drift (#667)', () => {
91
+ it('AC-670-64: a pure re-wrap yields no unique tokens on either side', () => {
92
+ // The end-to-end property: formatting-only differences must not count as an
93
+ // issue, because failing on them teaches the reader to distrust `doctor`
94
+ // and to skip the sync that #451 exists to encourage.
95
+ const { tokenDelta } = require('../doctor.js');
96
+ const NL = String.fromCharCode(10);
97
+ const repo = 'a &&' + NL + ' b' + NL;
98
+ const installed = 'a' + NL + ' && b' + NL;
99
+ const delta = tokenDelta(repo, installed);
100
+ expect(delta.repoOnly).toBe(0);
101
+ expect(delta.installedOnly).toBe(0);
102
+ expect(moduleDriftDetail(repo, installed)).toMatch(/formatting/i);
103
+ });
104
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @test REQ-636 One command that shows compliance against EVERY gate
3
+ * @intent Today the answer to "how healthy is this project against xp-gate's
4
+ * gates" takes six commands and mental arithmetic:
5
+ *
6
+ * xp-gate gate-list && xp-gate check . --all && xp-gate doctor
7
+ * node scripts/ccn-report.cjs --check
8
+ * node -e "...sum .warnings-baseline.json..."
9
+ * gh run list
10
+ *
11
+ * Every existing command answers "did THIS gate pass". None answers
12
+ * "where is the debt and what should I fix first".
13
+ *
14
+ * The single most important property is that the view has FOUR states,
15
+ * not three. `passed / skipped / failed` merges "did not run" into
16
+ * "ran and was fine", and that is exactly where technical debt hides.
17
+ * A gate that never executed renders identically to a gate that passed
18
+ * clean -- a false green, which is the most dangerous output this
19
+ * tooling can produce.
20
+ *
21
+ * Two data hazards drive the rest of the design:
22
+ *
23
+ * - The producer artifact numbers its gates `gate9_build_integrity` and
24
+ * `gate10_sast`, but the CLI calls Build Integrity Gate 10 and SAST
25
+ * Gate 9 -- the two are SWAPPED. Rendering the artifact naively shows
26
+ * the user the wrong gate name, which is worse than showing nothing.
27
+ * The mapping is therefore keyed on the artifact's own taxonomy and
28
+ * pinned by a test.
29
+ *
30
+ * - `quality-status/<branch>.json` records the commit it was generated
31
+ * from. Rendering it for a different HEAD silently presents stale
32
+ * results as current. This repo has had that artifact sit at an old
33
+ * commit for days, so staleness must be explicit, not inferred away.
34
+ * @covers AC-636-01, AC-636-02, AC-636-03, AC-636-04, AC-636-05, AC-636-06
35
+ */
36
+ 'use strict';
37
+
38
+ const { renderGateCoverage, buildCoverageRows, STATUS } = require('../gate-coverage');
39
+
40
+ /** A `quality-status` artifact shaped like the real producer's output. */
41
+ function artifact(overrides) {
42
+ return {
43
+ reportVersion: '2.0',
44
+ generatedAt: '2026-10-27T03:06:05Z',
45
+ commit: 'a0bceb9c0000000000000000000000000000000',
46
+ branch: 'main',
47
+ language: 'typescript iac',
48
+ overall: {
49
+ gatesPassed: 6,
50
+ gatesTotal: 12,
51
+ gatesSkipped: 6,
52
+ gatesWarned: 0,
53
+ gatesFailed: 0,
54
+ gatesEffective: 6,
55
+ effectivePassRate: 100,
56
+ scoreAllGates: 5,
57
+ score: 10,
58
+ verdict: 'PARTIAL',
59
+ ...(overrides && overrides.overall ? overrides.overall : {}),
60
+ },
61
+ gates: {
62
+ gate1_static_analysis: { name: 'Static Analysis', status: 'PASS', tool: 'eslint' },
63
+ gate9_build_integrity: { name: 'Build Integrity', status: 'PASS', tool: 'tsc' },
64
+ gate10_sast: { name: 'SAST Security', status: 'PASS', tool: 'semgrep' },
65
+ ...(overrides && overrides.gates ? overrides.gates : {}),
66
+ },
67
+ };
68
+ }
69
+
70
+ describe('gate coverage — four states, never a false green (#636)', () => {
71
+ it('AC-636-01: a gate that never ran is SKIP with a reason, never folded into PASS', () => {
72
+ const rows = buildCoverageRows(artifact(), { headCommit: 'a0bceb9c0000000000000000000000000000000' });
73
+ // Gates 0/1/5/11 are hook-only: they exist but the CLI cannot run them.
74
+ const version = rows.find((r) => r.gate === 0);
75
+ expect(version, 'Gate 0 must appear even though it never ran').toBeDefined();
76
+ expect(version.status).toBe(STATUS.SKIP);
77
+ // A bare SKIP is the false green. The reason is mandatory.
78
+ expect(version.reason).toBeTruthy();
79
+ // The reason must say WHY it did not run -- "hook only" and "not run here"
80
+ // are both honest; a bare SKIP is not.
81
+ expect(version.reason).toMatch(/hook|git commit|not run/i);
82
+ });
83
+
84
+ it('AC-636-02: the artifact gate-number swap does not mislabel the gates', () => {
85
+ // The producer says gate9_* = Build Integrity; the CLI says Gate 10 is Build
86
+ // Integrity. Rendering the artifact's own key would show SAST as Gate 10.
87
+ const rows = buildCoverageRows(artifact(), { headCommit: 'a0bceb9c0000000000000000000000000000000' });
88
+ const build = rows.find((r) => r.name === 'Build Integrity');
89
+ const sast = rows.find((r) => r.name === 'SAST Security');
90
+ expect(build, 'Build Integrity row must exist').toBeDefined();
91
+ expect(sast, 'SAST row must exist').toBeDefined();
92
+ // Both rows exist and carry the CLI's numbering, not the artifact's.
93
+ expect(build.gate).toBe(10);
94
+ expect(sast.gate).toBe(9);
95
+ });
96
+
97
+ it('AC-636-03: a stale artifact is marked STALE instead of presented as current', () => {
98
+ const rows = buildCoverageRows(artifact(), { headCommit: 'ffffffffffffffffffffffffffffffffffffffff' });
99
+ const rendered = renderGateCoverage(rows, { stale: true, headCommit: 'ffffffffffffffffffffffffffffffffffffffff' });
100
+ expect(rendered).toMatch(/STALE/i);
101
+ });
102
+
103
+ it('AC-636-04: DEBT is observable and quantified, not rendered as a clean PASS', () => {
104
+ // Gate 6 holds no warnings of its own; the debt lives in the Boy Scout
105
+ // baseline. A view that only reads gate statuses reports "PASS" and hides it.
106
+ const rows = buildCoverageRows(artifact(), {
107
+ headCommit: 'a0bceb9c0000000000000000000000000000000',
108
+ boyScout: { files: 14, warnings: 129 },
109
+ });
110
+ const boyScout = rows.find((r) => /boy scout/i.test(r.name));
111
+ expect(boyScout).toBeDefined();
112
+ expect(boyScout.status).toBe(STATUS.DEBT);
113
+ expect(String(boyScout.debt)).toMatch(/129/);
114
+ expect(boyScout.evidence).toMatch(/warnings-baseline/);
115
+ });
116
+
117
+ it('AC-636-05: default exit does not fail on DEBT, but --strict does', () => {
118
+ const debtRows = [{ gate: 6, name: 'Boy Scout', status: STATUS.DEBT, debt: '1 warning' }];
119
+ expect(buildCoverageRows).toBeDefined();
120
+ const { exitCodeFor } = require('../gate-coverage');
121
+ // Debt is budgeted and known: failing on it by default makes the command
122
+ // unusable as a CI dashboard.
123
+ expect(exitCodeFor(debtRows, {})).toBe(0);
124
+ expect(exitCodeFor(debtRows, { strict: true })).toBe(1);
125
+ // A real failure always fails.
126
+ expect(exitCodeFor([{ gate: 7, name: 'x', status: STATUS.FAIL }], {})).toBe(1);
127
+ });
128
+
129
+ it('AC-636-06: every row carries evidence pointing at the artifact that decided it', () => {
130
+ const rows = buildCoverageRows(artifact(), { headCommit: 'a0bceb9c0000000000000000000000000000000' });
131
+ // Without Evidence the user can only trust a one-line claim; with it they
132
+ // can go check. Same reason `doctor` prints paths.
133
+ for (const row of rows) {
134
+ expect(row.evidence, `Gate ${row.gate} (${row.name}) has no evidence pointer`).toBeTruthy();
135
+ }
136
+ });
137
+ });
@@ -263,7 +263,7 @@ test.each([['hello', 5], ['world', 5]])('%s has length %i', (str, len) => {
263
263
  * @covers AC-SKIP-001-01
264
264
  */
265
265
  it.skip('should parse skipped tests', () => {
266
- expect(true).toBe(true);
266
+ expect(subject).toBe('fixture');
267
267
  });
268
268
 
269
269
  /**
@@ -292,7 +292,7 @@ test.todo('should parse todo tests');
292
292
  */
293
293
  const featureName = 'Login';
294
294
  it(\`should authenticate \${featureName} user\`, () => {
295
- expect(true).toBe(true);
295
+ expect(subject).toBe('fixture');
296
296
  });
297
297
  `;
298
298
 
@@ -665,7 +665,7 @@ specification:
665
665
  * @covers AC-PUNCT-001-01
666
666
  */
667
667
  test('punctuation handling', () => {
668
- expect(true).toBe(true);
668
+ expect(subject).toBe('fixture');
669
669
  });
670
670
 
671
671
  /**
@@ -674,7 +674,7 @@ test('punctuation handling', () => {
674
674
  * @covers AC-PUNCT-002-01
675
675
  */
676
676
  test('comma handling', () => {
677
- expect(true).toBe(true);
677
+ expect(subject).toBe('fixture');
678
678
  });
679
679
  `;
680
680
 
@@ -796,3 +796,44 @@ specification:
796
796
  expect(report.alignment_status).toBe('FAIL');
797
797
  });
798
798
  });
799
+
800
+ describe('writeReport error handling (Boy Scout debt, #641)', () => {
801
+ it('AC-670-63: surfaces a write failure instead of throwing out of the run', () => {
802
+ // writeReport() runs as the LAST step of runFullAlignment — after the spec is
803
+ // parsed and every test file is scanned. An unguarded throw here discards all
804
+ // of that work and turns a reportable condition (disk full, read-only mount,
805
+ // permission denied) into a crashed command with no report at all. The report
806
+ // schema already carries an `errors` array for exactly this.
807
+ const fs = require('node:fs');
808
+ const path = require('node:path');
809
+ const os = require('node:os');
810
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-wr-'));
811
+ // A path whose PARENT is a regular file cannot be created as a directory.
812
+ const blocker = path.join(tmp, 'blocker');
813
+ fs.writeFileSync(blocker, 'not a directory');
814
+ const outputPath = path.join(blocker, 'nested', 'report.json');
815
+
816
+ const report = {
817
+ alignment_status: 'FAIL' as const,
818
+ phase: 1,
819
+ score: 0,
820
+ head_commit: 'abc123',
821
+ spec_hash: sha256('x'),
822
+ timestamp: new Date().toISOString(),
823
+ misaligned_tests: [],
824
+ anti_pattern_detected: false,
825
+ errors: [] as string[],
826
+ };
827
+
828
+ try {
829
+ // Assert the OBSERVABLE outcome, not merely "did not throw": the contract is
830
+ // that the failure is reported through the report object the caller holds.
831
+ const written = writeReport(report, outputPath);
832
+ expect(written).toBe(false);
833
+ expect(report.errors.length).toBe(1);
834
+ expect(report.errors[0]).toContain('Failed to write report');
835
+ } finally {
836
+ fs.rmSync(tmp, { recursive: true, force: true });
837
+ }
838
+ });
839
+ });
package/lib/doctor.js CHANGED
@@ -885,7 +885,14 @@ function diagnoseModuleDrift(ctx = {}) {
885
885
  continue;
886
886
  }
887
887
  const hint = TIER_HINTS[tierFor(executed, dirs).tier] || '';
888
- checks.push({ name: label, status: 'FAIL', detail: `${moduleDriftDetail(repoOnly, installedOnly)}${hint}` });
888
+ const driftDetail = moduleDriftDetail(texts.repoText, texts.installedText);
889
+ // A re-wrap is not drift: both copies say the same thing, so FAILing (and
890
+ // refusing to sync) over it teaches the reader to distrust the check.
891
+ if (/differ only in formatting/.test(driftDetail)) {
892
+ checks.push({ name: label, status: 'PASS', detail: driftDetail });
893
+ continue;
894
+ }
895
+ checks.push({ name: label, status: 'FAIL', detail: `${driftDetail}${hint}` });
889
896
  issues++;
890
897
  }
891
898
 
@@ -935,22 +942,87 @@ function nonExecutedCopies(dirs, executedFor) {
935
942
  return found;
936
943
  }
937
944
 
945
+ /**
946
+ * Split source into comparable tokens, discarding formatting.
947
+ *
948
+ * Whitespace, line breaks and blank lines are dropped so a re-wrapped statement
949
+ * and its one-line original compare equal. Crucially this keeps `x &&` + `y` on
950
+ * two lines equal to `x && y` on one: a line-level set difference reports BOTH
951
+ * of those lines as unique to their side, which is what made every re-wrap look
952
+ * like two-sided drift (#667).
953
+ *
954
+ * @param {string} text
955
+ * @returns {string[]}
956
+ */
957
+ function tokenizeForDrift(text) {
958
+ return String(text).split(/\s+/).filter((t) => t.length > 0);
959
+ }
960
+
961
+ /**
962
+ * Token-level delta between two copies: how many tokens each side holds that
963
+ * the other does not.
964
+ *
965
+ * @param {string} repoText
966
+ * @param {string} installedText
967
+ * @returns {{repoOnly: number, installedOnly: number}}
968
+ */
969
+ function tokenDelta(repoText, installedText) {
970
+ const repo = tokenizeForDrift(repoText);
971
+ const installed = tokenizeForDrift(installedText);
972
+ const repoSet = new Set(repo);
973
+ const installedSet = new Set(installed);
974
+ let repoOnly = 0;
975
+ let installedOnly = 0;
976
+ for (const t of repoSet) if (!installedSet.has(t)) repoOnly++;
977
+ for (const t of installedSet) if (!repoSet.has(t)) installedOnly++;
978
+ return { repoOnly, installedOnly };
979
+ }
980
+
981
+ /**
982
+ * NO tolerance: any installed-only token blocks the silent sync.
983
+ *
984
+ * A threshold looked tempting (a re-wrap can leave a stray token), but it gets
985
+ * the risk backwards. The case the refusal exists for is precisely a ONE-token
986
+ * hardening -- `return 0` -> `return 1`, the difference between a gate that
987
+ * fails open and one that fails closed. Measured on the real gate modules, a
988
+ * pure re-wrap produces ZERO unique tokens on either side, so the tolerance
989
+ * would have bought nothing and could have destroyed the exact fix it is meant
990
+ * to protect.
991
+ */
992
+ const FORMATTING_NOISE_TOKENS = 0;
993
+
938
994
  /**
939
995
  * Describe one drifted module by the direction of its drift.
940
996
  *
941
997
  * Only a module the *repo* is ahead of is safe to overwrite; anything where the
942
- * installed copy holds its own lines needs an explicit decision, because syncing
998
+ * installed copy holds its own work needs an explicit decision, because syncing
943
999
  * would delete work that exists nowhere else.
1000
+ *
1001
+ * The question is NOT "are both line counts non-zero" -- every re-wrapped
1002
+ * statement satisfies that. It is "does the installed copy hold anything the
1003
+ * repo genuinely lacks", which is a token question (#667).
1004
+ *
1005
+ * @param {string|null} repoText
1006
+ * @param {string|null} installedText
1007
+ * @returns {string}
944
1008
  */
945
- function moduleDriftDetail(repoOnly, installedOnly) {
946
- if (repoOnly > 0 && installedOnly > 0) {
947
- return `diverged both ways — githooks/ has ${repoOnly} line(s) the install lacks, `
948
- + `the install has ${installedOnly} line(s) githooks/ lacks; sync refuses without an explicit decision`;
1009
+ function moduleDriftDetail(repoText, installedText) {
1010
+ if (typeof repoText !== 'string' || typeof installedText !== 'string') {
1011
+ return 'executed copy could not be read for comparison';
1012
+ }
1013
+ const { repoOnly, installedOnly } = tokenDelta(repoText, installedText);
1014
+ if (repoOnly > 0 && installedOnly > FORMATTING_NOISE_TOKENS) {
1015
+ return `diverged both ways — githooks/ has ${repoOnly} token(s) the install lacks, `
1016
+ + `the install has ${installedOnly} token(s) githooks/ lacks; sync refuses without an explicit decision`;
949
1017
  }
950
1018
  if (repoOnly > 0) {
951
- return `installed copy is stale (${repoOnly} line(s) in githooks/ absent from the install)`;
1019
+ return `installed copy is stale (${repoOnly} token(s) in githooks/ absent from the install); `
1020
+ + 'sync will fast-forward it from githooks/';
1021
+ }
1022
+ if (installedOnly > 0) {
1023
+ return `installed copy is newer than githooks/ (${installedOnly} token(s) absent from the repo)`;
952
1024
  }
953
- return `installed copy is newer than githooks/ (${installedOnly} line(s) absent from the repo)`;
1025
+ return 'copies differ only in formatting; content is equivalent';
954
1026
  }
955
1027
 
956
1028
  /**
@@ -3409,6 +3481,8 @@ module.exports = {
3409
3481
  classifyHooksDir,
3410
3482
  syncGlobalHooksFromRepo,
3411
3483
  diagnoseModuleDrift,
3484
+ moduleDriftDetail,
3485
+ tokenDelta,
3412
3486
  diagnoseAuditLedger,
3413
3487
  syncModulesFromRepo,
3414
3488
  resolveExecutedModuleDirs,