pumuki 6.3.37 → 6.3.39

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.
@@ -0,0 +1,274 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { GitService, type IGitService } from './GitService';
4
+
5
+ export type GitAtomicityStage = 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
6
+
7
+ export type GitAtomicityViolation = {
8
+ code:
9
+ | 'GIT_ATOMICITY_TOO_MANY_FILES'
10
+ | 'GIT_ATOMICITY_TOO_MANY_SCOPES'
11
+ | 'GIT_ATOMICITY_COMMIT_MESSAGE_TRACEABILITY';
12
+ message: string;
13
+ remediation: string;
14
+ };
15
+
16
+ export type GitAtomicityEvaluation = {
17
+ enabled: boolean;
18
+ allowed: boolean;
19
+ violations: ReadonlyArray<GitAtomicityViolation>;
20
+ };
21
+
22
+ type GitAtomicityConfig = {
23
+ enabled: boolean;
24
+ maxFiles: number;
25
+ maxScopes: number;
26
+ enforceCommitMessagePattern: boolean;
27
+ commitMessagePattern: string;
28
+ };
29
+
30
+ const ATOMICITY_CONFIG_FILE = '.pumuki/git-atomicity.json';
31
+ const DEFAULT_COMMIT_PATTERN =
32
+ '^(feat|fix|chore|refactor|docs|test|perf|build|ci|revert)(\\([^)]+\\))?:\\s.+$';
33
+
34
+ const defaultConfig: GitAtomicityConfig = {
35
+ enabled: true,
36
+ maxFiles: 25,
37
+ maxScopes: 2,
38
+ enforceCommitMessagePattern: true,
39
+ commitMessagePattern: DEFAULT_COMMIT_PATTERN,
40
+ };
41
+
42
+ const parseBooleanEnv = (value: string | undefined): boolean | undefined => {
43
+ if (typeof value !== 'string') {
44
+ return undefined;
45
+ }
46
+ const normalized = value.trim().toLowerCase();
47
+ if (normalized.length === 0) {
48
+ return undefined;
49
+ }
50
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') {
51
+ return true;
52
+ }
53
+ if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') {
54
+ return false;
55
+ }
56
+ return undefined;
57
+ };
58
+
59
+ const parsePositiveIntegerEnv = (value: string | undefined): number | undefined => {
60
+ if (typeof value !== 'string') {
61
+ return undefined;
62
+ }
63
+ const parsed = Number.parseInt(value.trim(), 10);
64
+ if (!Number.isFinite(parsed) || parsed <= 0) {
65
+ return undefined;
66
+ }
67
+ return parsed;
68
+ };
69
+
70
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
71
+ typeof value === 'object' && value !== null;
72
+
73
+ const isNonEmptyString = (value: unknown): value is string =>
74
+ typeof value === 'string' && value.trim().length > 0;
75
+
76
+ const parseFileConfig = (repoRoot: string): Partial<GitAtomicityConfig> | undefined => {
77
+ const filePath = resolve(repoRoot, ATOMICITY_CONFIG_FILE);
78
+ if (!existsSync(filePath)) {
79
+ return undefined;
80
+ }
81
+ try {
82
+ const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'));
83
+ if (!isRecord(parsed)) {
84
+ return undefined;
85
+ }
86
+ const fromFile: Partial<GitAtomicityConfig> = {};
87
+ if (typeof parsed.enabled === 'boolean') {
88
+ fromFile.enabled = parsed.enabled;
89
+ }
90
+ if (Number.isInteger(parsed.maxFiles) && Number(parsed.maxFiles) > 0) {
91
+ fromFile.maxFiles = Number(parsed.maxFiles);
92
+ }
93
+ if (Number.isInteger(parsed.maxScopes) && Number(parsed.maxScopes) > 0) {
94
+ fromFile.maxScopes = Number(parsed.maxScopes);
95
+ }
96
+ if (typeof parsed.enforceCommitMessagePattern === 'boolean') {
97
+ fromFile.enforceCommitMessagePattern = parsed.enforceCommitMessagePattern;
98
+ }
99
+ if (isNonEmptyString(parsed.commitMessagePattern)) {
100
+ fromFile.commitMessagePattern = parsed.commitMessagePattern;
101
+ }
102
+ return fromFile;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ };
107
+
108
+ const resolveConfig = (repoRoot: string): GitAtomicityConfig => {
109
+ const fromFile = parseFileConfig(repoRoot) ?? {};
110
+
111
+ return {
112
+ enabled:
113
+ parseBooleanEnv(process.env.PUMUKI_GIT_ATOMICITY_ENABLED)
114
+ ?? fromFile.enabled
115
+ ?? defaultConfig.enabled,
116
+ maxFiles:
117
+ parsePositiveIntegerEnv(process.env.PUMUKI_GIT_ATOMICITY_MAX_FILES)
118
+ ?? fromFile.maxFiles
119
+ ?? defaultConfig.maxFiles,
120
+ maxScopes:
121
+ parsePositiveIntegerEnv(process.env.PUMUKI_GIT_ATOMICITY_MAX_SCOPES)
122
+ ?? fromFile.maxScopes
123
+ ?? defaultConfig.maxScopes,
124
+ enforceCommitMessagePattern:
125
+ parseBooleanEnv(process.env.PUMUKI_GIT_ATOMICITY_ENFORCE_COMMIT_PATTERN)
126
+ ?? fromFile.enforceCommitMessagePattern
127
+ ?? defaultConfig.enforceCommitMessagePattern,
128
+ commitMessagePattern:
129
+ process.env.PUMUKI_GIT_ATOMICITY_COMMIT_PATTERN?.trim()
130
+ || fromFile.commitMessagePattern
131
+ || defaultConfig.commitMessagePattern,
132
+ };
133
+ };
134
+
135
+ const parseLines = (value: string): ReadonlyArray<string> =>
136
+ value
137
+ .split('\n')
138
+ .map((line) => line.trim())
139
+ .filter((line) => line.length > 0);
140
+
141
+ const resolveScopeKey = (filePath: string): string => {
142
+ const normalized = filePath.replace(/\\/g, '/').trim();
143
+ const segments = normalized.split('/').filter((segment) => segment.length > 0);
144
+ if (segments.length === 0) {
145
+ return '';
146
+ }
147
+ if (
148
+ (segments[0] === 'apps' || segments[0] === 'packages' || segments[0] === 'services')
149
+ && segments.length >= 2
150
+ ) {
151
+ return `${segments[0]}/${segments[1]}`;
152
+ }
153
+ return segments[0] ?? '';
154
+ };
155
+
156
+ const collectChangedPaths = (params: {
157
+ git: IGitService;
158
+ repoRoot: string;
159
+ stage: GitAtomicityStage;
160
+ fromRef?: string;
161
+ toRef?: string;
162
+ }): ReadonlyArray<string> => {
163
+ if (params.stage === 'PRE_COMMIT') {
164
+ return parseLines(
165
+ params.git.runGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], params.repoRoot)
166
+ );
167
+ }
168
+ if (!params.fromRef || !params.toRef) {
169
+ return [];
170
+ }
171
+ return parseLines(
172
+ params.git.runGit(
173
+ ['diff', '--name-only', '--diff-filter=ACMR', `${params.fromRef}..${params.toRef}`],
174
+ params.repoRoot
175
+ )
176
+ );
177
+ };
178
+
179
+ const collectCommitSubjects = (params: {
180
+ git: IGitService;
181
+ repoRoot: string;
182
+ fromRef?: string;
183
+ toRef?: string;
184
+ }): ReadonlyArray<string> => {
185
+ if (!params.fromRef || !params.toRef) {
186
+ return [];
187
+ }
188
+ return parseLines(
189
+ params.git.runGit(['log', '--format=%s', `${params.fromRef}..${params.toRef}`], params.repoRoot)
190
+ );
191
+ };
192
+
193
+ export const evaluateGitAtomicity = (params: {
194
+ git?: IGitService;
195
+ repoRoot?: string;
196
+ stage: GitAtomicityStage;
197
+ fromRef?: string;
198
+ toRef?: string;
199
+ }): GitAtomicityEvaluation => {
200
+ const git = params.git ?? new GitService();
201
+ const repoRoot = params.repoRoot ? resolve(params.repoRoot) : git.resolveRepoRoot();
202
+ const config = resolveConfig(repoRoot);
203
+ if (!config.enabled) {
204
+ return {
205
+ enabled: false,
206
+ allowed: true,
207
+ violations: [],
208
+ };
209
+ }
210
+
211
+ const violations: GitAtomicityViolation[] = [];
212
+ const changedPaths = collectChangedPaths({
213
+ git,
214
+ repoRoot,
215
+ stage: params.stage,
216
+ fromRef: params.fromRef,
217
+ toRef: params.toRef,
218
+ });
219
+
220
+ if (changedPaths.length > config.maxFiles) {
221
+ violations.push({
222
+ code: 'GIT_ATOMICITY_TOO_MANY_FILES',
223
+ message:
224
+ `Git atomicity guard blocked at ${params.stage}: changed_files=${changedPaths.length} exceeds max_files=${config.maxFiles}.`,
225
+ remediation: `Divide los cambios en commits más pequeños (máximo ${config.maxFiles} archivos por commit).`,
226
+ });
227
+ }
228
+
229
+ const scopeKeys = new Set(
230
+ changedPaths
231
+ .map((filePath) => resolveScopeKey(filePath))
232
+ .filter((scopeKey) => scopeKey.length > 0)
233
+ );
234
+ if (scopeKeys.size > config.maxScopes) {
235
+ violations.push({
236
+ code: 'GIT_ATOMICITY_TOO_MANY_SCOPES',
237
+ message:
238
+ `Git atomicity guard blocked at ${params.stage}: changed_scopes=${scopeKeys.size} exceeds max_scopes=${config.maxScopes}.`,
239
+ remediation: `Agrupa cambios por ámbito funcional (máximo ${config.maxScopes} scopes por commit).`,
240
+ });
241
+ }
242
+
243
+ if (config.enforceCommitMessagePattern && params.stage !== 'PRE_COMMIT') {
244
+ let pattern: RegExp;
245
+ try {
246
+ pattern = new RegExp(config.commitMessagePattern);
247
+ } catch {
248
+ pattern = new RegExp(DEFAULT_COMMIT_PATTERN);
249
+ }
250
+ const subjects = collectCommitSubjects({
251
+ git,
252
+ repoRoot,
253
+ fromRef: params.fromRef,
254
+ toRef: params.toRef,
255
+ });
256
+ const invalidSubjects = subjects.filter((subject) => !pattern.test(subject));
257
+ if (invalidSubjects.length > 0) {
258
+ const sample = invalidSubjects.slice(0, 3).join(' | ');
259
+ violations.push({
260
+ code: 'GIT_ATOMICITY_COMMIT_MESSAGE_TRACEABILITY',
261
+ message:
262
+ `Git atomicity guard blocked at ${params.stage}: commit messages without traceable pattern detected (${invalidSubjects.length}). sample=[${sample}].`,
263
+ remediation:
264
+ 'Reescribe los commits para cumplir el patrón de trazabilidad configurado (por ejemplo Conventional Commits).',
265
+ });
266
+ }
267
+ }
268
+
269
+ return {
270
+ enabled: true,
271
+ allowed: violations.length === 0,
272
+ violations,
273
+ };
274
+ };
@@ -132,6 +132,13 @@ const defaultDependencies: GateDependencies = {
132
132
  };
133
133
 
134
134
  const resolveCurrentBranch = (git: IGitService, repoRoot: string): string | null => {
135
+ try {
136
+ const symbolicBranch = git.runGit(['symbolic-ref', '--short', 'HEAD'], repoRoot).trim();
137
+ if (symbolicBranch.length > 0) {
138
+ return symbolicBranch;
139
+ }
140
+ } catch {
141
+ }
135
142
  try {
136
143
  const branch = git.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).trim();
137
144
  if (branch.length === 0 || branch === 'HEAD') {
@@ -228,6 +235,10 @@ const PLATFORM_REQUIRED_SKILLS_BUNDLES: Record<
228
235
  frontend: ['frontend-guidelines'],
229
236
  };
230
237
 
238
+ const isCriticalProfileSeverity = (severity: string): boolean => {
239
+ return severity === 'CRITICAL' || severity === 'ERROR';
240
+ };
241
+
231
242
  const toNormalizedPath = (value: string): string => value.replace(/\\/g, '/').trim();
232
243
 
233
244
  const collectObservedPathsFromFacts = (
@@ -504,6 +515,66 @@ const toPlatformSkillsCoverageBlockingFinding = (params: {
504
515
  };
505
516
  };
506
517
 
518
+ const toCrossPlatformCriticalEnforcementBlockingFinding = (params: {
519
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
520
+ detectedPlatforms: DetectedPlatforms;
521
+ skillsRules: SkillsRuleSetLoadResult['rules'];
522
+ evaluatedRuleIds: ReadonlyArray<string>;
523
+ }): Finding | undefined => {
524
+ const detectedPlatformKeys = (
525
+ ['ios', 'android', 'backend', 'frontend'] as const
526
+ ).filter((platform) => params.detectedPlatforms[platform]?.detected === true);
527
+
528
+ if (detectedPlatformKeys.length === 0) {
529
+ return undefined;
530
+ }
531
+
532
+ const evaluatedRuleIds = new Set(params.evaluatedRuleIds);
533
+ const gaps: string[] = [];
534
+
535
+ for (const platform of detectedPlatformKeys) {
536
+ const rulePrefix = PLATFORM_SKILLS_RULE_PREFIXES[platform];
537
+ const criticalSkillRules = params.skillsRules
538
+ .filter(
539
+ (rule) =>
540
+ rule.id.startsWith(rulePrefix) &&
541
+ isCriticalProfileSeverity(rule.severity)
542
+ )
543
+ .map((rule) => rule.id)
544
+ .sort();
545
+
546
+ if (criticalSkillRules.length === 0) {
547
+ gaps.push(`${platform}{critical_profile_rules=missing}`);
548
+ continue;
549
+ }
550
+
551
+ const evaluatedCriticalSkillRules = criticalSkillRules.filter((ruleId) =>
552
+ evaluatedRuleIds.has(ruleId)
553
+ );
554
+ if (evaluatedCriticalSkillRules.length === 0) {
555
+ gaps.push(
556
+ `${platform}{critical_profile_rules=${criticalSkillRules.length}; evaluated=0}`
557
+ );
558
+ }
559
+ }
560
+
561
+ if (gaps.length === 0) {
562
+ return undefined;
563
+ }
564
+
565
+ return {
566
+ ruleId: 'governance.skills.cross-platform-critical.incomplete',
567
+ severity: 'ERROR',
568
+ code: 'SKILLS_CROSS_PLATFORM_CRITICAL_INCOMPLETE_S0',
569
+ message:
570
+ `Cross-platform critical enforcement incomplete at ${params.stage}: ${gaps.join(' | ')}. ` +
571
+ 'Ensure each detected platform has critical-profile skill rules active and evaluated.',
572
+ filePath: '.ai_evidence.json',
573
+ matchedBy: 'SkillsCrossPlatformCriticalGuard',
574
+ source: 'skills-cross-platform-critical',
575
+ };
576
+ };
577
+
507
578
  export async function runPlatformGate(params: {
508
579
  policy: GatePolicy;
509
580
  auditMode?: 'gate' | 'engine';
@@ -635,6 +706,17 @@ export async function runPlatformGate(params: {
635
706
  evaluatedRuleIds: coverage?.evaluatedRuleIds ?? [],
636
707
  })
637
708
  : undefined;
709
+ const crossPlatformCriticalFinding =
710
+ params.policy.stage === 'PRE_COMMIT' ||
711
+ params.policy.stage === 'PRE_PUSH' ||
712
+ params.policy.stage === 'CI'
713
+ ? toCrossPlatformCriticalEnforcementBlockingFinding({
714
+ stage: params.policy.stage,
715
+ detectedPlatforms,
716
+ skillsRules: skillsRuleSet.rules,
717
+ evaluatedRuleIds: coverage?.evaluatedRuleIds ?? [],
718
+ })
719
+ : undefined;
638
720
  const skillsScopeComplianceFinding =
639
721
  params.policy.stage === 'PRE_COMMIT' ||
640
722
  params.policy.stage === 'PRE_PUSH' ||
@@ -713,6 +795,7 @@ export async function runPlatformGate(params: {
713
795
  ...(policyAsCodeBlockingFinding ? [policyAsCodeBlockingFinding] : []),
714
796
  ...(unsupportedSkillsMappingFinding ? [unsupportedSkillsMappingFinding] : []),
715
797
  ...(platformSkillsCoverageFinding ? [platformSkillsCoverageFinding] : []),
798
+ ...(crossPlatformCriticalFinding ? [crossPlatformCriticalFinding] : []),
716
799
  ...(skillsScopeComplianceFinding ? [skillsScopeComplianceFinding] : []),
717
800
  ...(coverageBlockingFinding ? [coverageBlockingFinding] : []),
718
801
  ...tddBddEvaluation.findings,
@@ -720,6 +803,7 @@ export async function runPlatformGate(params: {
720
803
  ]
721
804
  : unsupportedSkillsMappingFinding
722
805
  || platformSkillsCoverageFinding
806
+ || crossPlatformCriticalFinding
723
807
  || skillsScopeComplianceFinding
724
808
  || coverageBlockingFinding
725
809
  || policyAsCodeBlockingFinding
@@ -730,6 +814,7 @@ export async function runPlatformGate(params: {
730
814
  ...(policyAsCodeBlockingFinding ? [policyAsCodeBlockingFinding] : []),
731
815
  ...(unsupportedSkillsMappingFinding ? [unsupportedSkillsMappingFinding] : []),
732
816
  ...(platformSkillsCoverageFinding ? [platformSkillsCoverageFinding] : []),
817
+ ...(crossPlatformCriticalFinding ? [crossPlatformCriticalFinding] : []),
733
818
  ...(skillsScopeComplianceFinding ? [skillsScopeComplianceFinding] : []),
734
819
  ...(coverageBlockingFinding ? [coverageBlockingFinding] : []),
735
820
  ...tddBddEvaluation.findings,
@@ -743,6 +828,7 @@ export async function runPlatformGate(params: {
743
828
  policyAsCodeBlockingFinding ||
744
829
  unsupportedSkillsMappingFinding ||
745
830
  platformSkillsCoverageFinding ||
831
+ crossPlatformCriticalFinding ||
746
832
  skillsScopeComplianceFinding ||
747
833
  coverageBlockingFinding ||
748
834
  hasTddBddBlockingFinding