@boyingliu01/xp-gate 0.31.3 → 0.32.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/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +48 -13
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +48 -13
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +9 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +269 -13
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
- package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
- package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
- package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
- package/principles/adapters/cpp.ts +102 -50
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +548 -178
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/baseline.ts +58 -70
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/lib/doctor-tui.js
CHANGED
|
@@ -117,38 +117,128 @@ function compareSemver(a, b) {
|
|
|
117
117
|
return 0;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Every skills directory `xp-gate init` deploys to, derived from
|
|
122
|
+
* PLATFORM_PROFILES so the platform-to-directory mapping has one source of
|
|
123
|
+
* truth. A hardcoded copy here would drift the moment a platform is added
|
|
124
|
+
* (Delphi R1 MA-01).
|
|
125
|
+
*
|
|
126
|
+
* init deploys to all of these at once, so doctor has to verify all of them —
|
|
127
|
+
* resolving a single "current" platform silently left the others unchecked
|
|
128
|
+
* (#635). `.dsh` is included because the package ships `plugins/dsh` and
|
|
129
|
+
* `sync-package-content.js` treats `dsh` as a first-class platform, even
|
|
130
|
+
* though `detectPlatform()` never returns it.
|
|
131
|
+
*
|
|
132
|
+
* Resolved through getSkillsDirs() at call time: the home directory and the
|
|
133
|
+
* profile list are read lazily, and tests reassign process.env.HOME.
|
|
134
|
+
*
|
|
135
|
+
* @returns {Array<{platform: string, dir: string}>}
|
|
136
|
+
*/
|
|
137
|
+
function skillsDeployTargets() {
|
|
138
|
+
const { PLATFORM_PROFILES, getSkillsDirs } = require('./detect-deps.js');
|
|
139
|
+
return Object.keys(PLATFORM_PROFILES).map((platform) => ({
|
|
140
|
+
platform,
|
|
141
|
+
// skillsDirs[0] is the skills directory; [1] is the broader platform dir.
|
|
142
|
+
dir: getSkillsDirs(platform)[0],
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* List the skill names actually present in a skills directory.
|
|
148
|
+
*
|
|
149
|
+
* A skill counts as installed when `<dir>/<name>/SKILL.md` exists — the same
|
|
150
|
+
* shape `install-skill` writes. Returns [] when the directory is missing or
|
|
151
|
+
* unreadable; a missing directory is "not deployed here", not an error.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} dir
|
|
154
|
+
* @returns {string[]}
|
|
155
|
+
*/
|
|
156
|
+
function listInstalledSkillNames(dir) {
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
return entries
|
|
164
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
165
|
+
.map((e) => e.name)
|
|
166
|
+
.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
120
169
|
/**
|
|
121
170
|
* Check 9: Installed skills vs package-bundled skills (#332).
|
|
122
171
|
* Uses semver version comparison on SKILL.md frontmatter when available;
|
|
123
172
|
* falls back to byte-for-byte content comparison for skills without version field.
|
|
173
|
+
*
|
|
174
|
+
* The skill list comes from the filesystem, not from `config.installedSkills`:
|
|
175
|
+
* `xp-gate init` never writes that field, so keying off it made this check
|
|
176
|
+
* report "No skills installed" on a fully deployed machine and return before
|
|
177
|
+
* touching disk (#635). An explicit `config.installedSkills` is still honoured
|
|
178
|
+
* when present, so callers and older tests keep their behaviour.
|
|
179
|
+
*
|
|
124
180
|
* @param {object} config
|
|
125
181
|
* @param {Array} checks
|
|
126
182
|
* @returns {number} issue count
|
|
127
183
|
*/
|
|
128
184
|
function diagnoseInstalledSkills(config, checks) {
|
|
129
|
-
const installedSkills = config.installedSkills || {};
|
|
130
|
-
const skillNames = Object.keys(installedSkills);
|
|
131
|
-
if (skillNames.length === 0) {
|
|
132
|
-
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'No skills installed' });
|
|
133
|
-
return 0;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
185
|
const bundledSkillsDir = path.join(PKG_DIR, 'skills');
|
|
137
186
|
if (!fs.existsSync(bundledSkillsDir)) {
|
|
138
187
|
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'Package skills dir not found' });
|
|
139
188
|
return 0;
|
|
140
189
|
}
|
|
141
190
|
|
|
191
|
+
const explicit = config && config.installedSkills ? Object.keys(config.installedSkills) : [];
|
|
192
|
+
const targets = skillsDeployTargets();
|
|
193
|
+
|
|
194
|
+
// An explicit `installedSkills` list is a caller saying "check THIS list, for
|
|
195
|
+
// THIS platform" — legacy single-target behaviour, kept so existing callers
|
|
196
|
+
// and their tests retain an unchanged contract (Delphi R1 MA-02).
|
|
197
|
+
//
|
|
198
|
+
// The asymmetry with the default path below is deliberate and load-bearing:
|
|
199
|
+
// the explicit list names no directory, so a platform still has to be
|
|
200
|
+
// inferred, and silently checking a different platform's directory than the
|
|
201
|
+
// caller meant would be worse than checking one. `xp-gate init` never writes
|
|
202
|
+
// this field, so in practice doctor takes the filesystem path below.
|
|
142
203
|
const platform = detectPlatform();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
} else if (platform === 'claude-code') {
|
|
147
|
-
userSkillsDir = path.join(HOME_DIR, '.claude', 'skills');
|
|
148
|
-
} else {
|
|
149
|
-
userSkillsDir = path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
204
|
+
if (explicit.length > 0) {
|
|
205
|
+
const match = targets.find((t) => t.platform === platform) || targets[0];
|
|
206
|
+
return diagnoseSkillsInDir(explicit, match.dir, bundledSkillsDir, checks, null);
|
|
150
207
|
}
|
|
151
208
|
|
|
209
|
+
// No explicit list: verify every directory that actually has skills deployed.
|
|
210
|
+
const deployed = targets.filter((t) => listInstalledSkillNames(t.dir).length > 0);
|
|
211
|
+
if (deployed.length === 0) {
|
|
212
|
+
checks.push({
|
|
213
|
+
name: 'Installed skills',
|
|
214
|
+
status: 'SKIP',
|
|
215
|
+
detail: `No skills deployed in any known location (${targets.map((t) => t.platform).join(', ')})`,
|
|
216
|
+
});
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let issues = 0;
|
|
221
|
+
for (const target of deployed) {
|
|
222
|
+
const names = listInstalledSkillNames(target.dir);
|
|
223
|
+
issues += diagnoseSkillsInDir(names, target.dir, bundledSkillsDir, checks, target.platform);
|
|
224
|
+
}
|
|
225
|
+
return issues;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Compare one deployed skills directory against the bundled copy.
|
|
230
|
+
*
|
|
231
|
+
* @param {string[]} skillNames
|
|
232
|
+
* @param {string} userSkillsDir
|
|
233
|
+
* @param {string} bundledSkillsDir
|
|
234
|
+
* @param {Array} checks
|
|
235
|
+
* @param {string|null} platformLabel - prefix for check names, or null for the
|
|
236
|
+
* legacy unprefixed names (explicit-installedSkills callers).
|
|
237
|
+
* @returns {number} issue count
|
|
238
|
+
*/
|
|
239
|
+
function diagnoseSkillsInDir(skillNames, userSkillsDir, bundledSkillsDir, checks, platformLabel) {
|
|
240
|
+
const label = (name) => (platformLabel ? `Skill [${platformLabel}]: ${name}` : `Skill: ${name}`);
|
|
241
|
+
|
|
152
242
|
let issues = 0;
|
|
153
243
|
for (const name of skillNames) {
|
|
154
244
|
const bundledSkillMd = path.join(bundledSkillsDir, name, 'SKILL.md');
|
|
@@ -156,7 +246,7 @@ function diagnoseInstalledSkills(config, checks) {
|
|
|
156
246
|
|
|
157
247
|
if (!fs.existsSync(bundledSkillMd)) continue;
|
|
158
248
|
if (!fs.existsSync(userSkillMd)) {
|
|
159
|
-
checks.push({ name:
|
|
249
|
+
checks.push({ name: label(name), status: 'FAIL', detail: 'Installed SKILL.md missing' });
|
|
160
250
|
issues++;
|
|
161
251
|
continue;
|
|
162
252
|
}
|
|
@@ -174,24 +264,24 @@ function diagnoseInstalledSkills(config, checks) {
|
|
|
174
264
|
// Version strings not valid semver — fall back to content comparison
|
|
175
265
|
if (!sameContentIgnoringLineEndings(bundledContent, userContent)) {
|
|
176
266
|
checks.push({
|
|
177
|
-
name:
|
|
267
|
+
name: label(name),
|
|
178
268
|
status: 'WARN',
|
|
179
269
|
detail: `Outdated — run xp-gate update-skill --all (installed: ${userVersion}, bundled: ${bundledVersion})`,
|
|
180
270
|
});
|
|
181
271
|
issues++;
|
|
182
272
|
} else {
|
|
183
|
-
checks.push({ name:
|
|
273
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
184
274
|
}
|
|
185
275
|
} else if (cmp < 0) {
|
|
186
276
|
checks.push({
|
|
187
|
-
name:
|
|
277
|
+
name: label(name),
|
|
188
278
|
status: 'WARN',
|
|
189
279
|
detail: `Outdated — run xp-gate update-skill --all (installed: ${userVersion}, bundled: ${bundledVersion})`,
|
|
190
280
|
});
|
|
191
281
|
issues++;
|
|
192
282
|
} else {
|
|
193
283
|
// cmp >= 0: installed version is same or newer than bundled
|
|
194
|
-
checks.push({ name:
|
|
284
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
195
285
|
}
|
|
196
286
|
} else {
|
|
197
287
|
// No version in at least one file — compare content, but not byte-for-byte:
|
|
@@ -206,13 +296,13 @@ function diagnoseInstalledSkills(config, checks) {
|
|
|
206
296
|
withoutVersionLine(userContent)
|
|
207
297
|
)) {
|
|
208
298
|
checks.push({
|
|
209
|
-
name:
|
|
299
|
+
name: label(name),
|
|
210
300
|
status: 'WARN',
|
|
211
301
|
detail: 'Content differs and version cannot be determined (missing version frontmatter) — compare manually before running update-skill (it pulls from main, which may be older than the installed copy)',
|
|
212
302
|
});
|
|
213
303
|
issues++;
|
|
214
304
|
} else {
|
|
215
|
-
checks.push({ name:
|
|
305
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
216
306
|
}
|
|
217
307
|
}
|
|
218
308
|
}
|
package/lib/gate-runner.js
CHANGED
|
@@ -129,20 +129,63 @@ function firstExisting(candidates) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Write the `--all` file list to a temporary file and return its path.
|
|
134
|
+
*
|
|
135
|
+
* The list is one line per tracked file (thousands on a real repository), so it
|
|
136
|
+
* cannot travel as a `bash -c` argument: on Windows the whole script is a single
|
|
137
|
+
* command-line string capped at 32,767 characters, and exceeding it surfaced as
|
|
138
|
+
* `spawnSync bash ENAMETOOLONG` — Gate 3 could not run at all under `--all`.
|
|
139
|
+
*
|
|
140
|
+
* Inlining was also semantically broken: `JSON.stringify` escapes the separators
|
|
141
|
+
* to a literal `\n`, and bash does not interpret escape sequences inside double
|
|
142
|
+
* quotes, so the gates received one long non-existent path instead of a list.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} contents newline-separated file list
|
|
145
|
+
* @returns {string} path to the temp file
|
|
146
|
+
*/
|
|
147
|
+
function writeAllModeFileList(contents) {
|
|
148
|
+
const listFile = path.join(os.tmpdir(), `xp-gate-all-files-${process.pid}-${Date.now()}.txt`);
|
|
149
|
+
try {
|
|
150
|
+
fs.writeFileSync(listFile, contents, 'utf8');
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw new Error(`xp-gate: cannot write the --all file list to ${listFile}: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
return listFile;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Remove the temporary file list, if one was written.
|
|
159
|
+
*
|
|
160
|
+
* Best effort: a leftover file in the temp directory is not worth failing a gate
|
|
161
|
+
* over, but a failure other than ENOENT is named so it does not go unnoticed.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} listFile path to remove, or the empty string
|
|
164
|
+
*/
|
|
165
|
+
function removeFileList(listFile) {
|
|
166
|
+
if (!listFile) return;
|
|
167
|
+
try {
|
|
168
|
+
fs.unlinkSync(listFile);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err.code !== 'ENOENT') {
|
|
171
|
+
console.error(`[xp-gate] could not remove ${listFile}: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
132
176
|
/**
|
|
133
177
|
* The shell lines that populate CHANGED_FILES, chosen by scan scope.
|
|
134
178
|
*
|
|
135
179
|
* Extracted so `runGateAdapter` stays readable: the diff form is three lines of
|
|
136
|
-
* git plumbing and the tree form is a single
|
|
137
|
-
*
|
|
180
|
+
* git plumbing and the tree form is a single read, and mixing them inline made
|
|
181
|
+
* the wrapper harder to follow than the thing it wraps.
|
|
138
182
|
*
|
|
139
183
|
* @param {boolean} allMode true for `--all` (whole tree)
|
|
140
|
-
* @param {string} allModeFiles pre-resolved file list for all mode
|
|
141
184
|
* @returns {string[]} shell lines
|
|
142
185
|
*/
|
|
143
|
-
function buildFileSourceLines(allMode
|
|
186
|
+
function buildFileSourceLines(allMode) {
|
|
144
187
|
if (allMode) {
|
|
145
|
-
return ['#589: --all scans the tracked tree, not the diff',
|
|
188
|
+
return ['#589: --all scans the tracked tree, not the diff ($5 = file list)', 'CHANGED_FILES="$(cat "$5" 2>/dev/null || true)"'];
|
|
146
189
|
}
|
|
147
190
|
// The historical diff form, unchanged: staged first, then anything vs HEAD.
|
|
148
191
|
// Interpolating the literal `${CHANGED_FILES}` brace-by-brace would be torn apart
|
|
@@ -200,16 +243,20 @@ function runGateAdapter(scriptPath, cwd = process.cwd(), opts = {}) {
|
|
|
200
243
|
// CHANGED_FILES must see the whole tree, not the diff — otherwise a full check
|
|
201
244
|
// scans less than an ordinary commit and reads like a pass.
|
|
202
245
|
const allMode = opts.scope === 'all';
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
246
|
+
const listFile = allMode ? writeAllModeFileList(resolveAllModeChangedFiles(cwd)) : '';
|
|
247
|
+
try {
|
|
248
|
+
const wrap = buildPreamble(allMode, buildFileSourceLines(allMode));
|
|
249
|
+
|
|
250
|
+
// execFileSync avoids an extra /bin/sh layer, so bash receives the script as a
|
|
251
|
+
// single argv arg (no shell-metacharacter injection through the path).
|
|
252
|
+
execFileSync(
|
|
253
|
+
bashCommand,
|
|
254
|
+
['--noprofile', '--norc', '-c', wrap, 'xp-gate', commonScript, nowMsScript, scriptPath, process.execPath, listFile],
|
|
255
|
+
{ stdio: 'inherit', cwd }
|
|
256
|
+
);
|
|
257
|
+
} finally {
|
|
258
|
+
removeFileList(listFile);
|
|
259
|
+
}
|
|
213
260
|
}
|
|
214
261
|
|
|
215
262
|
/**
|
|
@@ -253,6 +300,16 @@ function buildPreamble(allMode, fileSourceLines) {
|
|
|
253
300
|
*/
|
|
254
301
|
const TS_GATE_TIMEOUT_MS = 120000;
|
|
255
302
|
|
|
303
|
+
/**
|
|
304
|
+
* #618: sentinel for "this gate ran nothing at all" -- no TypeScript module and
|
|
305
|
+
* no bash fallback. It must never be conflated with 0 ("ran, found nothing").
|
|
306
|
+
* Returned up through runGate() so the --all summary counts it as skipped
|
|
307
|
+
* rather than passed: three advertised gates were analysing nothing and sitting
|
|
308
|
+
* inside the passed count, which is the same defect class as a vacuous pass
|
|
309
|
+
* elsewhere -- a dimension nobody measured, scored as if measured.
|
|
310
|
+
*/
|
|
311
|
+
const GATE_NOT_RUN = 'NOT_RUN';
|
|
312
|
+
|
|
256
313
|
/**
|
|
257
314
|
* Run a TypeScript gate module via npx tsx.
|
|
258
315
|
* Finds the .ts entry point, spawns tsx with --run flag.
|
|
@@ -266,11 +323,13 @@ function runTsGate(gateModule, targetPath, opts = {}) {
|
|
|
266
323
|
const gateNum = gateModule.replace('gate-', '');
|
|
267
324
|
const script = resolveGateScript(gateNum, targetCwd);
|
|
268
325
|
if (script) {
|
|
269
|
-
runGateAdapter(script, targetCwd, opts);
|
|
270
|
-
} else {
|
|
271
|
-
console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
|
|
326
|
+
return runGateAdapter(script, targetCwd, opts);
|
|
272
327
|
}
|
|
273
|
-
return 0
|
|
328
|
+
// #618: nothing ran. Before this, the function fell through to `return 0`
|
|
329
|
+
// and the --all summary counted the gate as passed.
|
|
330
|
+
console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
|
|
331
|
+
console.log(`⏭️ SKIPPED - Gate ${gateModule} did not run (no module, no bash fallback)`);
|
|
332
|
+
return GATE_NOT_RUN;
|
|
274
333
|
}
|
|
275
334
|
|
|
276
335
|
const args = ['-y', 'tsx', entry, '--run'];
|
|
@@ -484,9 +543,8 @@ function findConfig(basePath, fileName) {
|
|
|
484
543
|
/**
|
|
485
544
|
* The basename of the first `gate-<num>-*.sh` in a directory, or null.
|
|
486
545
|
*
|
|
487
|
-
* A missing directory is the normal case
|
|
488
|
-
* so it returns null rather than throwing
|
|
489
|
-
* match" because the caller falls through to the next candidate either way.
|
|
546
|
+
* A missing directory is the normal case -- most projects have no
|
|
547
|
+
* `githooks/gates/` -- so it returns null rather than throwing.
|
|
490
548
|
*
|
|
491
549
|
* @param {string} dir directory to list
|
|
492
550
|
* @param {string} gateNum gate number as a string
|
|
@@ -501,22 +559,37 @@ function matchGateFileIn(dir, gateNum) {
|
|
|
501
559
|
}
|
|
502
560
|
}
|
|
503
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Locate the bash script that implements a gate, for the TS-less fallback path.
|
|
564
|
+
*
|
|
565
|
+
* Candidate order:
|
|
566
|
+
* 1. `<cwd>/githooks/gates/gate-<n>-*.sh` -- a consumer's OWN gate scripts. The
|
|
567
|
+
* hyphenated name is what lets a project keep several variants of one gate
|
|
568
|
+
* (`gate-9-public-fallback.sh`), which the flat `gate-<n>.sh` name cannot
|
|
569
|
+
* express. `xp-gate init` does not create this directory and the npm package
|
|
570
|
+
* does not ship it, so in a stock project it simply never matches. It is
|
|
571
|
+
* still a supported extension point, pinned by the REQ-standalone-gates-05
|
|
572
|
+
* and -06 tests in lib/__tests__/gate-runner.test.js.
|
|
573
|
+
* 2. `<cwd>/githooks/gate-<n>.sh` -- the flat copy `xp-gate init` installs.
|
|
574
|
+
* 3. `~/.config/xp-gate/adapters/gate-<n>.sh` -- the global install.
|
|
575
|
+
*
|
|
576
|
+
* NOTE: this repository's own `githooks/gates/` was a different thing entirely --
|
|
577
|
+
* five drifted copies of the canonical gates that only ever matched inside this
|
|
578
|
+
* checkout, shadowing `githooks/gate-N.sh` (36 lines apart for gate-9, still
|
|
579
|
+
* carrying the GATE_10_STATUS artifact from #532). Those were deleted; the
|
|
580
|
+
* lookup path above was not, because consumer projects legitimately use it.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} gateNum gate number as a string
|
|
583
|
+
* @param {string} cwd project root to search
|
|
584
|
+
* @returns {string|null}
|
|
585
|
+
*/
|
|
504
586
|
function resolveGateScript(gateNum, cwd = process.cwd()) {
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
];
|
|
587
|
+
const gatesDir = path.join(cwd, 'githooks', 'gates');
|
|
588
|
+
const flexible = matchGateFileIn(gatesDir, gateNum);
|
|
589
|
+
if (flexible) return path.join(gatesDir, flexible);
|
|
509
590
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (pattern.includes('*')) {
|
|
513
|
-
const dir = path.dirname(pattern);
|
|
514
|
-
const match = matchGateFileIn(dir, gateNum);
|
|
515
|
-
if (match) return path.join(dir, match);
|
|
516
|
-
} else if (fs.existsSync(pattern)) {
|
|
517
|
-
return pattern;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
591
|
+
const projectScript = path.join(cwd, 'githooks', `gate-${gateNum}.sh`);
|
|
592
|
+
if (fs.existsSync(projectScript)) return projectScript;
|
|
520
593
|
|
|
521
594
|
// Check global install
|
|
522
595
|
const globalDir = path.join(os.homedir(), '.config', 'xp-gate', 'adapters');
|
|
@@ -573,6 +646,7 @@ async function runGate(gateId, targetPath, opts = {}) {
|
|
|
573
646
|
// failing gate from a passing one. Handlers that return nothing at all
|
|
574
647
|
// (gate 2, whose execFileSync throws on a non-zero exit instead) are
|
|
575
648
|
// still treated as pass, because their failure signal is the throw below.
|
|
649
|
+
if (result === GATE_NOT_RUN) return GATE_NOT_RUN;
|
|
576
650
|
return typeof result === 'number' ? result : 0;
|
|
577
651
|
} catch (err) {
|
|
578
652
|
// Covers a synchronous throw AND a rejection from the awaited handler.
|
|
@@ -585,4 +659,4 @@ async function runGate(gateId, targetPath, opts = {}) {
|
|
|
585
659
|
return 1;
|
|
586
660
|
}
|
|
587
661
|
|
|
588
|
-
module.exports = { GATE_REGISTRY, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
|
|
662
|
+
module.exports = { GATE_REGISTRY, GATE_NOT_RUN, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
|
package/lib/principles.js
CHANGED
|
@@ -44,21 +44,41 @@ function buildPrinciplesInvocation(files, format) {
|
|
|
44
44
|
const tsxCli = [findLocalTsxCli(), path.resolve(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs')].find(
|
|
45
45
|
(candidate) => candidate && fs.existsSync(candidate)
|
|
46
46
|
);
|
|
47
|
+
// Second-best shape: run npm's own npx entry with node.exe. A bare `npx` cannot be
|
|
48
|
+
// spawned without a shell on Windows (CreateProcess only resolves `.exe`, and the
|
|
49
|
+
// installed shim is `npx.cmd`), which surfaced as spawnSync ENOENT (#<new>).
|
|
50
|
+
const npxCli = tsxCli ? null : findNpxCli();
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
let command;
|
|
53
|
+
let prefix;
|
|
54
|
+
let useShell;
|
|
55
|
+
if (tsxCli) {
|
|
56
|
+
command = process.execPath;
|
|
57
|
+
prefix = [tsxCli, entry];
|
|
58
|
+
useShell = false;
|
|
59
|
+
} else if (npxCli) {
|
|
60
|
+
command = process.execPath;
|
|
61
|
+
prefix = [npxCli, '-y', 'tsx', entry];
|
|
62
|
+
useShell = false;
|
|
63
|
+
} else {
|
|
64
|
+
// Last resort: a bare npx on PATH. Only a shell can resolve it on Windows, so the
|
|
65
|
+
// flag has to travel with the plan instead of being hard-coded false.
|
|
66
|
+
command = 'npx';
|
|
67
|
+
prefix = ['-y', 'tsx', entry];
|
|
68
|
+
useShell = process.platform === 'win32';
|
|
69
|
+
}
|
|
50
70
|
|
|
51
71
|
if (listChars <= ARGV_CHAR_BUDGET) {
|
|
52
72
|
return {
|
|
53
73
|
command,
|
|
54
74
|
args: [...prefix, '--files', files.join(' '), '--format', format],
|
|
55
|
-
useShell
|
|
75
|
+
useShell,
|
|
56
76
|
cleanup: null,
|
|
57
77
|
};
|
|
58
78
|
}
|
|
59
79
|
|
|
60
80
|
// By reference: one short argument instead of thousands of characters.
|
|
61
|
-
return buildByReferenceInvocation(prefix, files, format, command);
|
|
81
|
+
return buildByReferenceInvocation(prefix, files, format, command, useShell);
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/**
|
|
@@ -73,9 +93,10 @@ function buildPrinciplesInvocation(files, format) {
|
|
|
73
93
|
* @param {string[]} files source files to analyse
|
|
74
94
|
* @param {string} format 'json' or 'text'
|
|
75
95
|
* @param {string} command executable to run
|
|
96
|
+
* @param {boolean} useShell whether the spawn needs a shell to resolve the command
|
|
76
97
|
* @returns {{command: string, args: string[], useShell: boolean, cleanup: string|null}}
|
|
77
98
|
*/
|
|
78
|
-
function buildByReferenceInvocation(prefix, files, format, command) {
|
|
99
|
+
function buildByReferenceInvocation(prefix, files, format, command, useShell) {
|
|
79
100
|
const listFile = path.join(os.tmpdir(), `xp-gate-principles-${process.pid}-${Date.now()}.txt`);
|
|
80
101
|
try {
|
|
81
102
|
fs.writeFileSync(listFile, files.join('\n'));
|
|
@@ -88,7 +109,7 @@ function buildByReferenceInvocation(prefix, files, format, command) {
|
|
|
88
109
|
command,
|
|
89
110
|
// The @ prefix is the engine's own "read the list from this file" form.
|
|
90
111
|
args: [...prefix, '--files', `@${listFile}`, '--format', format],
|
|
91
|
-
useShell
|
|
112
|
+
useShell,
|
|
92
113
|
cleanup: listFile,
|
|
93
114
|
};
|
|
94
115
|
}
|
|
@@ -120,6 +141,27 @@ function findLocalTsxCli() {
|
|
|
120
141
|
return null;
|
|
121
142
|
}
|
|
122
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Path to npm's own npx CLI entry (`npx-cli.js`), or null.
|
|
146
|
+
*
|
|
147
|
+
* `spawnSync('npx', ...)` without a shell works on POSIX (npx is a symlinked JS
|
|
148
|
+
* script) but fails on Windows with ENOENT: CreateProcess resolves only `.exe`
|
|
149
|
+
* names, and the installed entry point is `npx.cmd`. Running npm's JS entry with
|
|
150
|
+
* the current node binary is equivalent and platform-independent.
|
|
151
|
+
*
|
|
152
|
+
* @returns {string|null}
|
|
153
|
+
*/
|
|
154
|
+
function findNpxCli() {
|
|
155
|
+
const execDir = path.dirname(process.execPath);
|
|
156
|
+
const candidates = [
|
|
157
|
+
// Windows/self-contained Node: <nodeDir>/node_modules/npm/bin/npx-cli.js
|
|
158
|
+
path.join(execDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
|
|
159
|
+
// POSIX layout: <prefix>/bin/node → <prefix>/lib/node_modules/npm/bin/npx-cli.js
|
|
160
|
+
path.resolve(execDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'),
|
|
161
|
+
];
|
|
162
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
/**
|
|
124
166
|
* Was this failure the tool not running, or the code having problems?
|
|
125
167
|
*
|
package/lib/shared-utils.js
CHANGED
|
@@ -57,6 +57,14 @@ function copyAdapters(srcDir, destDir) {
|
|
|
57
57
|
if (fs.existsSync(adapterCommon)) {
|
|
58
58
|
fs.copyFileSync(adapterCommon, path.join(destDir, 'adapter-common.sh'));
|
|
59
59
|
}
|
|
60
|
+
// Shared shell helpers the fragments `source` at runtime (ccn-ratchet.sh and
|
|
61
|
+
// friends). Without this, gate-3.sh resolves $GATE_DIR/lib/ccn-ratchet.sh to a
|
|
62
|
+
// path that no installer ever created, and `.` failing there silently disabled
|
|
63
|
+
// the complexity ratchet: a CCN regression was graded PASS.
|
|
64
|
+
const libSrc = path.join(srcDir, 'hooks', 'lib');
|
|
65
|
+
if (fs.existsSync(libSrc)) {
|
|
66
|
+
copyDirRecursive(libSrc, path.join(destDir, 'lib'));
|
|
67
|
+
}
|
|
60
68
|
if (fs.existsSync(adaptersDir)) {
|
|
61
69
|
fs.readdirSync(adaptersDir).forEach(f => {
|
|
62
70
|
if (f.endsWith('.sh')) {
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-28
|
|
4
|
+
**Commit:** 0f58c300
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.32.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mock-policy/gate-m3.ts
CHANGED
|
@@ -86,10 +86,28 @@ interface ValidateImportInput {
|
|
|
86
86
|
engine: MockDecisionEngine;
|
|
87
87
|
layer: ReturnType<typeof detectTestLayer>;
|
|
88
88
|
severity: 'warning' | 'error';
|
|
89
|
+
lenientUnit: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Is this file exempt from the strategy check?
|
|
94
|
+
*
|
|
95
|
+
* A lenient unit layer only RECOMMENDS mocking every dependency. Reporting a test
|
|
96
|
+
* that injects a hand-written fake instead would flag tests that are already
|
|
97
|
+
* isolated, which is how a gate teaches people to ignore it.
|
|
98
|
+
*/
|
|
99
|
+
function isLenientUnitFile(
|
|
100
|
+
lenientUnit: boolean,
|
|
101
|
+
layer: ReturnType<typeof detectTestLayer>,
|
|
102
|
+
): boolean {
|
|
103
|
+
if (!lenientUnit) return false;
|
|
104
|
+
return layer === 'unit' || layer === 'unknown';
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
function validateImport(input: ValidateImportInput): MockPolicyViolation[] {
|
|
92
|
-
const { testFile, content, importPath, engine, layer, severity } = input;
|
|
108
|
+
const { testFile, content, importPath, engine, layer, severity, lenientUnit } = input;
|
|
109
|
+
if (isLenientUnitFile(lenientUnit, layer)) return [];
|
|
110
|
+
|
|
93
111
|
const decision = engine.decide(importPath, layer);
|
|
94
112
|
const actualStrategy = detectMockUsage(content, importPath);
|
|
95
113
|
const violations: MockPolicyViolation[] = [];
|
|
@@ -117,9 +135,22 @@ async function validateFile(
|
|
|
117
135
|
engine: MockDecisionEngine,
|
|
118
136
|
projectRoot: string,
|
|
119
137
|
severity: 'warning' | 'error',
|
|
138
|
+
lenientUnit: boolean,
|
|
120
139
|
): Promise<MockPolicyViolation[]> {
|
|
121
140
|
const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
|
|
122
|
-
|
|
141
|
+
// A changed-file list naming a file that cannot be read is broken input, and
|
|
142
|
+
// gate-m3.test.ts pins the rejection. Reporting "no violations" for a file nobody
|
|
143
|
+
// could read is the one failure a gate must never have, so the error is caught
|
|
144
|
+
// only to add the path and then rethrown.
|
|
145
|
+
let content: string;
|
|
146
|
+
try {
|
|
147
|
+
content = await readFile(fullPath, 'utf-8');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// String(error) rather than (error as Error).message: a thrower is free to
|
|
150
|
+
// reject with a non-Error, and that cast would render the cause as
|
|
151
|
+
// "undefined" — losing the only diagnostic this rethrow exists to add.
|
|
152
|
+
throw new Error(`cannot read test file ${testFile}: ${String(error)}`);
|
|
153
|
+
}
|
|
123
154
|
const layer = detectTestLayer(testFile);
|
|
124
155
|
const imports = collectImports(content);
|
|
125
156
|
|
|
@@ -130,9 +161,25 @@ async function validateFile(
|
|
|
130
161
|
engine,
|
|
131
162
|
layer,
|
|
132
163
|
severity,
|
|
164
|
+
lenientUnit,
|
|
133
165
|
}));
|
|
134
166
|
}
|
|
135
167
|
|
|
168
|
+
/** Every import mentioned across the given test files, deduplicated. */
|
|
169
|
+
async function collectAllImports(testFiles: string[], projectRoot: string): Promise<string[]> {
|
|
170
|
+
const all: string[] = [];
|
|
171
|
+
for (const testFile of testFiles) {
|
|
172
|
+
try {
|
|
173
|
+
const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
|
|
174
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
175
|
+
all.push(...collectImports(content));
|
|
176
|
+
} catch {
|
|
177
|
+
// Unreadable file — the per-file pass reports nothing for it either.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return [...new Set(all)];
|
|
181
|
+
}
|
|
182
|
+
|
|
136
183
|
export async function runGateM3(
|
|
137
184
|
changedFiles: string[],
|
|
138
185
|
projectRoot: string = process.cwd(),
|
|
@@ -151,38 +198,25 @@ export async function runGateM3(
|
|
|
151
198
|
const config = await loadMockPolicyConfig(projectRoot);
|
|
152
199
|
const severity: 'warning' | 'error' = config.severity;
|
|
153
200
|
|
|
154
|
-
// Collect all imports from all test files
|
|
155
|
-
const allImports: string[] = [];
|
|
156
|
-
for (const testFile of testFiles) {
|
|
157
|
-
try {
|
|
158
|
-
const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
|
|
159
|
-
const content = await readFile(fullPath, 'utf-8');
|
|
160
|
-
allImports.push(...collectImports(content));
|
|
161
|
-
} catch {
|
|
162
|
-
// File not readable — skip
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
201
|
const scope = await scanProjectScope({
|
|
167
202
|
projectRoot,
|
|
168
|
-
imports:
|
|
203
|
+
imports: await collectAllImports(testFiles, projectRoot),
|
|
169
204
|
boundary: config.projectBoundary,
|
|
170
205
|
});
|
|
171
206
|
|
|
172
207
|
const engine = new MockDecisionEngine(scope, config, projectRoot);
|
|
173
|
-
|
|
208
|
+
// Resolved once from config: a lenient unit layer is advisory, so its files skip
|
|
209
|
+
// strategy comparison entirely.
|
|
210
|
+
const lenientUnit = config.layers.unit.mockPolicy === 'lenient';
|
|
174
211
|
|
|
212
|
+
const allViolations: MockPolicyViolation[] = [];
|
|
175
213
|
let integrationCount = 0;
|
|
176
214
|
for (const testFile of testFiles) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const violations = await validateFile(testFile, engine, projectRoot, severity);
|
|
181
|
-
allViolations.push(...violations);
|
|
215
|
+
if (detectTestLayer(testFile) === 'integration') integrationCount++;
|
|
216
|
+
allViolations.push(...await validateFile(testFile, engine, projectRoot, severity, lenientUnit));
|
|
182
217
|
}
|
|
183
218
|
|
|
184
|
-
const
|
|
185
|
-
const blocked = severity === 'error' && hasBlockableViolations;
|
|
219
|
+
const blocked = severity === 'error' && allViolations.some(v => v.severity === 'error');
|
|
186
220
|
|
|
187
221
|
return {
|
|
188
222
|
exitCode: blocked ? 1 : 0,
|