pumuki 6.3.138 → 6.3.140

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/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [6.3.140] - 2026-05-05
10
+
11
+ ### Fixed
12
+
13
+ - **PRE_PUSH atomicity por commit:** el guard de atomicidad evalúa cada commit del rango de push de forma independiente, evitando que una rama formada por commits atómicos quede bloqueada por el diff agregado.
14
+ - **Falsos positivos de metadata:** `hardcoded-values` y `magic-numbers` dejan de bloquear literales internos de policy/evidence/analytics y constantes estándar, manteniendo la detección real de configuración hardcoded.
15
+
16
+ ## [6.3.139] - 2026-05-05
17
+
18
+ ### Fixed
19
+
20
+ - **PUMUKI-INC-060 baseline test gate:** la evidencia TDD/BDD exige ahora un baseline test `passed` por slice antes del evento `red`; si falta o falla, el gate bloquea con `TDD_BASELINE_TEST_REQUIRED` o `TDD_BASELINE_TEST_MUST_PASS`.
21
+ - **Alineación all-severities en evidencia:** los tests de evidencia quedan sincronizados con el contrato publicado de bloqueo por cualquier severidad (`BLOCK` / `BLOCKED`), incluyendo findings `WARN`.
22
+
9
23
  ## [6.3.138] - 2026-05-05
10
24
 
11
25
  ### Fixed
@@ -4588,6 +4588,58 @@ const buildMagicNumberPatternMatch = (
4588
4588
  node: unknown
4589
4589
  ): TypeScriptMagicNumberMatch | undefined => {
4590
4590
  const neutralNumericLiterals = new Set([0, 1]);
4591
+ const isNamedConstantInitializer = (ancestors: ReadonlyArray<AstNode>): boolean => {
4592
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
4593
+ const ancestor = ancestors[index];
4594
+ if (ancestor.type === 'CallExpression' || ancestor.type === 'NewExpression') {
4595
+ return false;
4596
+ }
4597
+ if (ancestor.type === 'VariableDeclarator') {
4598
+ return true;
4599
+ }
4600
+ }
4601
+ return false;
4602
+ };
4603
+ const isStandardLibraryNumericArgument = (
4604
+ value: AstNode,
4605
+ ancestors: ReadonlyArray<AstNode>
4606
+ ): boolean => {
4607
+ let callExpression: AstNode | undefined;
4608
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
4609
+ if (ancestors[index].type === 'CallExpression') {
4610
+ callExpression = ancestors[index];
4611
+ break;
4612
+ }
4613
+ }
4614
+ if (!isObject(callExpression) || !Array.isArray(callExpression.arguments)) {
4615
+ return false;
4616
+ }
4617
+ const argumentIndex = callExpression.arguments.indexOf(value);
4618
+ const calleeName =
4619
+ methodNameFromNode(callExpression.callee) ?? memberExpressionPropertyName(callExpression.callee);
4620
+ const memberName = memberExpressionPropertyName(callExpression.callee);
4621
+
4622
+ if ((calleeName === 'parseInt' || memberName === 'parseInt') && argumentIndex === 1) {
4623
+ return true;
4624
+ }
4625
+ if ((memberName === 'slice' || memberName === 'substring') && argumentIndex >= 0) {
4626
+ return true;
4627
+ }
4628
+ if (memberName === 'toFixed' && argumentIndex === 0) {
4629
+ return true;
4630
+ }
4631
+ if (memberName === 'toString' && argumentIndex === 0) {
4632
+ return true;
4633
+ }
4634
+ if ((memberName === 'min' || memberName === 'max') && argumentIndex >= 0) {
4635
+ return true;
4636
+ }
4637
+ if (value.value === 1000 && memberExpressionPropertyName(callExpression.callee) === 'floor') {
4638
+ return true;
4639
+ }
4640
+ return false;
4641
+ };
4642
+
4591
4643
  const match = findFirstNodeWithAncestors(node, (value, ancestors) => {
4592
4644
  if (value.type !== 'NumericLiteral' || typeof value.value !== 'number') {
4593
4645
  return false;
@@ -4595,6 +4647,12 @@ const buildMagicNumberPatternMatch = (
4595
4647
  if (neutralNumericLiterals.has(value.value)) {
4596
4648
  return false;
4597
4649
  }
4650
+ if (isNamedConstantInitializer(ancestors)) {
4651
+ return false;
4652
+ }
4653
+ if (isStandardLibraryNumericArgument(value, ancestors)) {
4654
+ return false;
4655
+ }
4598
4656
 
4599
4657
  return !ancestors.some((ancestor) => {
4600
4658
  return (
@@ -4790,6 +4848,80 @@ const isNeutralHardcodedNumericLiteral = (node: AstNode): boolean => {
4790
4848
  return node.type === 'NumericLiteral' && (node.value === 0 || node.value === 1);
4791
4849
  };
4792
4850
 
4851
+ const isBenignHardcodedConfigLiteral = (node: AstNode): boolean => {
4852
+ if (node.type !== 'StringLiteral') {
4853
+ return false;
4854
+ }
4855
+ const value = String(node.value).trim();
4856
+ if (value.length === 0) {
4857
+ return true;
4858
+ }
4859
+ if (/^(?:0|1|true|false|yes|no|on|off)$/i.test(value)) {
4860
+ return true;
4861
+ }
4862
+ if (
4863
+ [
4864
+ 'all-severities',
4865
+ 'critical-high',
4866
+ 'default',
4867
+ 'detected',
4868
+ 'engine',
4869
+ 'gate',
4870
+ 'hard-mode',
4871
+ 'inferred',
4872
+ 'skills.policy',
4873
+ ].includes(value)
4874
+ ) {
4875
+ return true;
4876
+ }
4877
+ if (/^\d+(?:\.\d+)+$/.test(value)) {
4878
+ return true;
4879
+ }
4880
+ if (
4881
+ value.startsWith('skills.') ||
4882
+ value.startsWith('heuristics.') ||
4883
+ value.startsWith('common.') ||
4884
+ value.startsWith('workflow.')
4885
+ ) {
4886
+ return true;
4887
+ }
4888
+ if (value.startsWith('.pumuki/') || value.startsWith('openspec/')) {
4889
+ return true;
4890
+ }
4891
+ if (/^\.[a-z0-9]+$/i.test(value)) {
4892
+ return true;
4893
+ }
4894
+ if (/^[A-Z][A-Z0-9_]+$/.test(value)) {
4895
+ return true;
4896
+ }
4897
+ if (/^--[a-z0-9-]+$/i.test(value)) {
4898
+ return true;
4899
+ }
4900
+ if (/^[()[\]{}.,:;!/\-\\]+$/.test(value)) {
4901
+ return true;
4902
+ }
4903
+ return false;
4904
+ };
4905
+
4906
+ const isBenignConfigMetadataName = (value: string): boolean => {
4907
+ const normalized = value.trim();
4908
+ if (normalized.length === 0) {
4909
+ return true;
4910
+ }
4911
+ if (
4912
+ normalized.startsWith('skills.') ||
4913
+ normalized.startsWith('heuristics.') ||
4914
+ normalized.startsWith('common.') ||
4915
+ normalized.startsWith('workflow.')
4916
+ ) {
4917
+ return true;
4918
+ }
4919
+ if (/^[A-Z][A-Z0-9_]+$/.test(normalized)) {
4920
+ return true;
4921
+ }
4922
+ return false;
4923
+ };
4924
+
4793
4925
  const isTypeOnlyAstNode = (node: AstNode): boolean => {
4794
4926
  return typeof node.type === 'string' && node.type.startsWith('TS');
4795
4927
  };
@@ -4803,6 +4935,9 @@ const hardcodedValueAssignmentContextFromAncestors = (
4803
4935
  if (ancestor.type === 'VariableDeclarator') {
4804
4936
  const ownerName = hardcodedValueNameFromNode(ancestor.id);
4805
4937
  if (typeof ownerName === 'string' && ownerName.length > 0) {
4938
+ if (isBenignConfigMetadataName(ownerName)) {
4939
+ return undefined;
4940
+ }
4806
4941
  return {
4807
4942
  ownerName,
4808
4943
  ownerKind: 'member',
@@ -4814,6 +4949,9 @@ const hardcodedValueAssignmentContextFromAncestors = (
4814
4949
  if (ancestor.type === 'ObjectProperty' || ancestor.type === 'ClassProperty') {
4815
4950
  const ownerName = hardcodedValueNameFromNode(ancestor.key);
4816
4951
  if (typeof ownerName === 'string' && ownerName.length > 0) {
4952
+ if (isBenignConfigMetadataName(ownerName)) {
4953
+ return undefined;
4954
+ }
4817
4955
  return {
4818
4956
  ownerName,
4819
4957
  ownerKind: 'member',
@@ -4825,6 +4963,9 @@ const hardcodedValueAssignmentContextFromAncestors = (
4825
4963
  if (ancestor.type === 'AssignmentExpression') {
4826
4964
  const ownerName = hardcodedValueNameFromNode(ancestor.left);
4827
4965
  if (typeof ownerName === 'string' && ownerName.length > 0) {
4966
+ if (isBenignConfigMetadataName(ownerName)) {
4967
+ return undefined;
4968
+ }
4828
4969
  return {
4829
4970
  ownerName,
4830
4971
  ownerKind: 'member',
@@ -4981,6 +5122,7 @@ const buildHardcodedValuePatternMatch = (
4981
5122
  isAstNodeTypeLiteral(value) ||
4982
5123
  isPrimitiveTypeGuardLiteral(value) ||
4983
5124
  isRuntimeApiLiteral(value) ||
5125
+ isBenignHardcodedConfigLiteral(value) ||
4984
5126
  isNeutralHardcodedNumericLiteral(value)
4985
5127
  ) {
4986
5128
  return false;
@@ -4,6 +4,18 @@ This file tracks the active deterministic framework line used in this repository
4
4
  Canonical release chronology lives in `CHANGELOG.md`.
5
5
  This file keeps only the operational highlights and rollout notes that matter while running the framework.
6
6
 
7
+ ### 2026-05-05 (v6.3.140)
8
+
9
+ - **PRE_PUSH compatible con ramas largas:** la atomicidad se valida por commit individual, no por diff agregado de rama.
10
+ - **Skills sin falsos positivos de metadata:** el diff de release queda con `hardcoded-values`/`magic-numbers` a `0` para ficheros cambiados.
11
+ - **Rollout:** publicar `pumuki@6.3.140`, repinear primero RuralGo y repetir validaciones `status`, `doctor` y hooks gestionados.
12
+
13
+ ### 2026-05-05 (v6.3.139)
14
+
15
+ - **Baseline tests antes de editar:** Pumuki bloquea cambios TDD/BDD in-scope si cada slice no registra un baseline test pasado antes de RED.
16
+ - **RuralGo INC060:** cierra el gap por el que una regresión de test preexistente en un componente relacionado podía pasar desapercibida antes de iterar.
17
+ - **Rollout:** publicar `pumuki@6.3.139`, repinear RuralGo primero y revalidar `PRE_WRITE`, `PRE_COMMIT`, `PRE_PUSH` y `CI`.
18
+
7
19
  ### 2026-05-05 (v6.3.138)
8
20
 
9
21
  - **Doc-only sin worktree sucio:** cuando el índice solo contiene `*.md` / `*.mdx`, Pumuki restaura `.ai_evidence.json` trackeado a `HEAD` tras refrescar el gate, evitando el fallo del framework `pre-commit` por “files were modified by this hook”.
@@ -346,6 +346,7 @@ test('writeEvidence preserva snapshot.tdd_bdd cuando viene en evidencia', async
346
346
  slices_invalid: 0,
347
347
  integrity_ok: true,
348
348
  errors: [],
349
+ baseline: { required: true, passed: 0, missing: 0, failed: 0 },
349
350
  },
350
351
  waiver: {
351
352
  applied: true,
@@ -27,6 +27,9 @@ export type WriteEvidenceResult = {
27
27
 
28
28
  const EVIDENCE_FILE_NAME = '.ai_evidence.json';
29
29
  const TEMP_EVIDENCE_PREFIX = '.ai_evidence.json.tmp-';
30
+ const RANDOM_SUFFIX_RADIX = 16;
31
+ const RANDOM_SUFFIX_START_INDEX = 2;
32
+ const RANDOM_SUFFIX_END_INDEX = 10;
30
33
 
31
34
  const normalizeLines = (lines?: EvidenceLines): EvidenceLines | undefined => {
32
35
  if (typeof lines === 'undefined') {
@@ -272,6 +275,12 @@ const normalizeTddBddSnapshot = (snapshot: TddBddSnapshot | undefined): TddBddSn
272
275
  slices_invalid: snapshot.evidence.slices_invalid,
273
276
  integrity_ok: snapshot.evidence.integrity_ok,
274
277
  errors: [...snapshot.evidence.errors],
278
+ baseline: {
279
+ required: snapshot.evidence.baseline.required,
280
+ passed: snapshot.evidence.baseline.passed,
281
+ missing: snapshot.evidence.baseline.missing,
282
+ failed: snapshot.evidence.baseline.failed,
283
+ },
275
284
  },
276
285
  waiver: {
277
286
  applied: snapshot.waiver.applied,
@@ -429,7 +438,9 @@ const resolveRepoRoot = (): string => {
429
438
  };
430
439
 
431
440
  const buildTempEvidencePath = (repoRoot: string): string => {
432
- const uniqueSuffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
441
+ const uniqueSuffix = `${process.pid}-${Date.now()}-${Math.random()
442
+ .toString(RANDOM_SUFFIX_RADIX)
443
+ .slice(RANDOM_SUFFIX_START_INDEX, RANDOM_SUFFIX_END_INDEX)}`;
433
444
  return join(repoRoot, `${TEMP_EVIDENCE_PREFIX}${uniqueSuffix}`);
434
445
  };
435
446
 
@@ -267,55 +267,74 @@ const collectCommitSubjects = (params: {
267
267
  }
268
268
  };
269
269
 
270
- export const evaluateGitAtomicity = (params: {
271
- git?: IGitService;
272
- repoRoot?: string;
273
- stage: GitAtomicityStage;
270
+ const collectCommitHashes = (params: {
271
+ git: IGitService;
272
+ repoRoot: string;
274
273
  fromRef?: string;
275
274
  toRef?: string;
276
- }): GitAtomicityEvaluation => {
277
- const git = params.git ?? new GitService();
278
- const repoRoot = params.repoRoot ? resolve(params.repoRoot) : git.resolveRepoRoot();
279
- const config = resolveConfig(repoRoot);
280
- if (!config.enabled) {
281
- return {
282
- enabled: false,
283
- allowed: true,
284
- violations: [],
285
- };
275
+ }): ReadonlyArray<string> => {
276
+ if (!params.fromRef || !params.toRef) {
277
+ return [];
278
+ }
279
+ try {
280
+ return parseLines(
281
+ params.git.runGit(['log', '--format=%H', '--reverse', `${params.fromRef}..${params.toRef}`], params.repoRoot)
282
+ );
283
+ } catch (error) {
284
+ if (isUnresolvableRevisionError(error)) {
285
+ return [];
286
+ }
287
+ throw error;
288
+ }
289
+ };
290
+
291
+ const collectCommitChangedPaths = (params: {
292
+ git: IGitService;
293
+ repoRoot: string;
294
+ commitHash: string;
295
+ }): ReadonlyArray<string> => {
296
+ try {
297
+ return parseLines(
298
+ params.git.runGit(
299
+ ['diff-tree', '--no-commit-id', '--name-only', '-r', '--diff-filter=ACMR', params.commitHash],
300
+ params.repoRoot
301
+ )
302
+ );
303
+ } catch (error) {
304
+ if (isUnresolvableRevisionError(error)) {
305
+ return [];
306
+ }
307
+ throw error;
286
308
  }
309
+ };
287
310
 
311
+ const buildPathLimitViolations = (params: {
312
+ changedPaths: ReadonlyArray<string>;
313
+ config: GitAtomicityConfig;
314
+ stage: GitAtomicityStage;
315
+ atomicSlicesRemediation?: string;
316
+ label?: string;
317
+ }): GitAtomicityViolation[] => {
288
318
  const violations: GitAtomicityViolation[] = [];
289
- const changedPaths = collectChangedPaths({
290
- git,
291
- repoRoot,
292
- stage: params.stage,
293
- fromRef: params.fromRef,
294
- toRef: params.toRef,
295
- }).filter((path) => !isManagedEvidencePath(path));
296
- const atomicSlicesRemediation = buildAtomicSlicesRemediation({
297
- git,
298
- repoRoot,
299
- stage: params.stage,
300
- });
319
+ const labelSuffix = params.label ? ` (${params.label})` : '';
301
320
 
302
- if (changedPaths.length > config.maxFiles) {
321
+ if (params.changedPaths.length > params.config.maxFiles) {
303
322
  violations.push({
304
323
  code: 'GIT_ATOMICITY_TOO_MANY_FILES',
305
324
  message:
306
- `Git atomicity guard blocked at ${params.stage}: changed_files=${changedPaths.length} exceeds max_files=${config.maxFiles}.`,
325
+ `Git atomicity guard blocked at ${params.stage}${labelSuffix}: changed_files=${params.changedPaths.length} exceeds max_files=${params.config.maxFiles}.`,
307
326
  remediation:
308
- `Divide los cambios en commits más pequeños (máximo ${config.maxFiles} archivos por commit).`
309
- + (atomicSlicesRemediation ? ` ${atomicSlicesRemediation}` : ''),
327
+ `Divide los cambios en commits más pequeños (máximo ${params.config.maxFiles} archivos por commit).`
328
+ + (params.atomicSlicesRemediation ? ` ${params.atomicSlicesRemediation}` : ''),
310
329
  });
311
330
  }
312
331
 
313
- const scopePaths = collectScopePaths(changedPaths);
332
+ const scopePaths = collectScopePaths(params.changedPaths);
314
333
  const scopeKeys = new Set(scopePaths.keys());
315
- if (scopeKeys.size > config.maxScopes) {
334
+ if (scopeKeys.size > params.config.maxScopes) {
316
335
  const sortedScopes = [...scopeKeys].sort();
317
336
  const suggestedScopeAdds = sortedScopes
318
- .slice(0, Math.max(1, Math.min(config.maxScopes + 1, 3)))
337
+ .slice(0, Math.max(1, Math.min(params.config.maxScopes + 1, 3)))
319
338
  .map((scope) => `git add ${scope}/`)
320
339
  .join(' && ');
321
340
  const scopeBreakdown = sortedScopes
@@ -328,16 +347,109 @@ export const evaluateGitAtomicity = (params: {
328
347
  violations.push({
329
348
  code: 'GIT_ATOMICITY_TOO_MANY_SCOPES',
330
349
  message:
331
- `Git atomicity guard blocked at ${params.stage}: changed_scopes=${scopeKeys.size} exceeds max_scopes=${config.maxScopes}. ` +
350
+ `Git atomicity guard blocked at ${params.stage}${labelSuffix}: changed_scopes=${scopeKeys.size} exceeds max_scopes=${params.config.maxScopes}. ` +
332
351
  `scope_files=${scopeBreakdown}.`,
333
352
  remediation:
334
- `Agrupa cambios por ámbito funcional (máximo ${config.maxScopes} scopes por commit). ` +
353
+ `Agrupa cambios por ámbito funcional (máximo ${params.config.maxScopes} scopes por commit). ` +
335
354
  `scopes_detectados=[${sortedScopes.join(', ')}]. ` +
336
355
  `Sugerencia split: git restore --staged . && ${suggestedScopeAdds} && git commit -m "<tipo>: <scope>".`
337
- + (atomicSlicesRemediation ? ` ${atomicSlicesRemediation}` : ''),
356
+ + (params.atomicSlicesRemediation ? ` ${params.atomicSlicesRemediation}` : ''),
338
357
  });
339
358
  }
340
359
 
360
+ return violations;
361
+ };
362
+
363
+ const buildPrePushCommitPathLimitViolations = (params: {
364
+ git: IGitService;
365
+ repoRoot: string;
366
+ config: GitAtomicityConfig;
367
+ fromRef?: string;
368
+ toRef?: string;
369
+ }): GitAtomicityViolation[] | undefined => {
370
+ const commitHashes = collectCommitHashes({
371
+ git: params.git,
372
+ repoRoot: params.repoRoot,
373
+ fromRef: params.fromRef,
374
+ toRef: params.toRef,
375
+ });
376
+ if (commitHashes.length === 0) {
377
+ return undefined;
378
+ }
379
+
380
+ const violations: GitAtomicityViolation[] = [];
381
+ for (const commitHash of commitHashes) {
382
+ const changedPaths = collectCommitChangedPaths({
383
+ git: params.git,
384
+ repoRoot: params.repoRoot,
385
+ commitHash,
386
+ }).filter((path) => !isManagedEvidencePath(path));
387
+ violations.push(
388
+ ...buildPathLimitViolations({
389
+ changedPaths,
390
+ config: params.config,
391
+ stage: 'PRE_PUSH',
392
+ label: `commit=${commitHash.slice(0, 12)}`,
393
+ })
394
+ );
395
+ }
396
+
397
+ return violations;
398
+ };
399
+
400
+ export const evaluateGitAtomicity = (params: {
401
+ git?: IGitService;
402
+ repoRoot?: string;
403
+ stage: GitAtomicityStage;
404
+ fromRef?: string;
405
+ toRef?: string;
406
+ }): GitAtomicityEvaluation => {
407
+ const git = params.git ?? new GitService();
408
+ const repoRoot = params.repoRoot ? resolve(params.repoRoot) : git.resolveRepoRoot();
409
+ const config = resolveConfig(repoRoot);
410
+ if (!config.enabled) {
411
+ return {
412
+ enabled: false,
413
+ allowed: true,
414
+ violations: [],
415
+ };
416
+ }
417
+
418
+ const violations: GitAtomicityViolation[] = [];
419
+ const changedPaths = collectChangedPaths({
420
+ git,
421
+ repoRoot,
422
+ stage: params.stage,
423
+ fromRef: params.fromRef,
424
+ toRef: params.toRef,
425
+ }).filter((path) => !isManagedEvidencePath(path));
426
+ const atomicSlicesRemediation = buildAtomicSlicesRemediation({
427
+ git,
428
+ repoRoot,
429
+ stage: params.stage,
430
+ });
431
+
432
+ const prePushCommitViolations =
433
+ params.stage === 'PRE_PUSH'
434
+ ? buildPrePushCommitPathLimitViolations({
435
+ git,
436
+ repoRoot,
437
+ config,
438
+ fromRef: params.fromRef,
439
+ toRef: params.toRef,
440
+ })
441
+ : undefined;
442
+
443
+ violations.push(
444
+ ...(prePushCommitViolations
445
+ ?? buildPathLimitViolations({
446
+ changedPaths,
447
+ config,
448
+ stage: params.stage,
449
+ atomicSlicesRemediation,
450
+ }))
451
+ );
452
+
341
453
  if (config.enforceCommitMessagePattern && params.stage !== 'PRE_COMMIT') {
342
454
  let pattern: RegExp;
343
455
  try {
@@ -77,6 +77,18 @@ import { runPolicyReconcile } from './policyReconcile';
77
77
  import { runLifecycleAudit, type LifecycleAuditStage } from './audit';
78
78
  import { resolvePreWriteEnforcement, type PreWriteEnforcementResolution } from '../policy/preWriteEnforcement';
79
79
 
80
+ const POLICY_SUBCOMMAND_ARG_COUNT = 2;
81
+ const PRE_WRITE_PANEL_MIN_COLUMNS = 86;
82
+ const PRE_WRITE_PANEL_MAX_COLUMNS = 140;
83
+ const PRE_WRITE_PANEL_BORDER_COLUMNS = 2;
84
+ const PRE_WRITE_PANEL_PADDING_COLUMNS = 4;
85
+ const WORKTREE_SLICE_PLAN_COUNT = 3;
86
+ const WORKTREE_SLICE_FILE_COUNT = 4;
87
+ const CLI_JSON_INDENT = 2;
88
+ const ANALYTICS_TOOL_NAME = 'analytics';
89
+ const ANALYTICS_FEATURE_NAME = 'analytics';
90
+ const SAAS_INGESTION_FEATURE_NAME = 'saas_ingestion';
91
+
80
92
  type LifecycleCommand =
81
93
  | 'bootstrap'
82
94
  | 'install'
@@ -811,9 +823,9 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
811
823
  }
812
824
  let policyStrict = false;
813
825
  let policyApply = false;
814
- const policyFlagsOffset =
815
- typeof firstArg === 'string' && !firstArg.startsWith('--') ? 2 : 1;
816
- for (const arg of argv.slice(policyFlagsOffset)) {
826
+ const policyArgStartIndex =
827
+ typeof firstArg === 'string' && !firstArg.startsWith('--') ? POLICY_SUBCOMMAND_ARG_COUNT : 1;
828
+ for (const arg of argv.slice(policyArgStartIndex)) {
817
829
  if (arg === '--json') {
818
830
  json = true;
819
831
  continue;
@@ -1748,7 +1760,7 @@ const buildAnalyticsExperimentalDisabledEnvelope = (
1748
1760
  feature: LifecycleExperimentalFeaturesSnapshot['features']['analytics'],
1749
1761
  action: AnalyticsHotspotsCommand
1750
1762
  ): AnalyticsExperimentalDisabledEnvelope => ({
1751
- tool: 'analytics',
1763
+ tool: ANALYTICS_TOOL_NAME,
1752
1764
  dryRun: true,
1753
1765
  executed: false,
1754
1766
  success: true,
@@ -1756,7 +1768,7 @@ const buildAnalyticsExperimentalDisabledEnvelope = (
1756
1768
  code: 'ANALYTICS_EXPERIMENTAL_DISABLED',
1757
1769
  message:
1758
1770
  'Analytics hotspots está desactivado explícitamente. Usa PUMUKI_EXPERIMENTAL_ANALYTICS=advisory o strict si necesitas este flujo.',
1759
- experimental_feature: 'analytics',
1771
+ experimental_feature: ANALYTICS_FEATURE_NAME,
1760
1772
  mode: feature.mode,
1761
1773
  source: feature.source,
1762
1774
  activation_variable: feature.activationVariable,
@@ -1770,7 +1782,7 @@ const buildAnalyticsExperimentalDisabledEnvelope = (
1770
1782
  const buildSaasIngestionExperimentalDisabledEnvelope = (
1771
1783
  feature: LifecycleExperimentalFeaturesSnapshot['features']['saas_ingestion']
1772
1784
  ): SaasIngestionExperimentalDisabledEnvelope => ({
1773
- tool: 'analytics',
1785
+ tool: ANALYTICS_TOOL_NAME,
1774
1786
  dryRun: true,
1775
1787
  executed: false,
1776
1788
  success: true,
@@ -1778,7 +1790,7 @@ const buildSaasIngestionExperimentalDisabledEnvelope = (
1778
1790
  code: 'SAAS_INGESTION_EXPERIMENTAL_DISABLED',
1779
1791
  message:
1780
1792
  'SaaS ingestion/federation está desactivado explícitamente. Usa PUMUKI_EXPERIMENTAL_SAAS_INGESTION=advisory o strict si necesitas este flujo.',
1781
- experimental_feature: 'saas_ingestion',
1793
+ experimental_feature: SAAS_INGESTION_FEATURE_NAME,
1782
1794
  mode: feature.mode,
1783
1795
  source: feature.source,
1784
1796
  activation_variable: feature.activationVariable,
@@ -1908,8 +1920,8 @@ export const resolvePreWriteNextAction = (params: {
1908
1920
  if (atomicSliceViolation) {
1909
1921
  const plan = collectWorktreeAtomicSlices({
1910
1922
  repoRoot: params.aiGate.repo_state.repo_root,
1911
- maxSlices: 3,
1912
- maxFilesPerSlice: 4,
1923
+ maxSlices: WORKTREE_SLICE_PLAN_COUNT,
1924
+ maxFilesPerSlice: WORKTREE_SLICE_FILE_COUNT,
1913
1925
  });
1914
1926
  const firstSliceCommand = plan.slices[0]?.staged_command ?? 'git add -p';
1915
1927
  return {
@@ -1975,11 +1987,14 @@ const renderPreWritePanel = (lines: ReadonlyArray<string>): string => {
1975
1987
  const terminalWidth = Number.isFinite(process.stdout.columns ?? NaN)
1976
1988
  ? Number(process.stdout.columns)
1977
1989
  : 110;
1978
- const width = Math.min(140, Math.max(86, terminalWidth - 2));
1979
- const innerWidth = width - 4;
1990
+ const width = Math.min(
1991
+ PRE_WRITE_PANEL_MAX_COLUMNS,
1992
+ Math.max(PRE_WRITE_PANEL_MIN_COLUMNS, terminalWidth - PRE_WRITE_PANEL_BORDER_COLUMNS)
1993
+ );
1994
+ const innerWidth = width - PRE_WRITE_PANEL_PADDING_COLUMNS;
1980
1995
  const normalized = lines.flatMap((line) => wrapPreWritePanelLine(line, innerWidth));
1981
- const top = `╔${'═'.repeat(width - 2)}╗`;
1982
- const bottom = `╚${'═'.repeat(width - 2)}╝`;
1996
+ const top = `╔${'═'.repeat(width - PRE_WRITE_PANEL_BORDER_COLUMNS)}╗`;
1997
+ const bottom = `╚${'═'.repeat(width - PRE_WRITE_PANEL_BORDER_COLUMNS)}╝`;
1983
1998
  const body = normalized.map((line) => `║ ${line.padEnd(innerWidth, ' ')} ║`);
1984
1999
  return [top, ...body, bottom].join('\n');
1985
2000
  };
@@ -2121,7 +2136,7 @@ const writeLoopAttemptEvidence = (params: {
2121
2136
  const relativePath = `.pumuki/loop-sessions/${params.sessionId}.attempt-${params.attempt}.json`;
2122
2137
  const absolutePath = resolve(params.repoRoot, relativePath);
2123
2138
  mkdirSync(dirname(absolutePath), { recursive: true });
2124
- writeFileSync(absolutePath, `${JSON.stringify(params.payload, null, 2)}\n`, 'utf8');
2139
+ writeFileSync(absolutePath, `${JSON.stringify(params.payload, null, CLI_JSON_INDENT)}\n`, 'utf8');
2125
2140
  return relativePath;
2126
2141
  };
2127
2142
 
@@ -13,6 +13,7 @@ import { writeInfo } from './cliOutputs';
13
13
  import { formatTrackingActionableContext } from './trackingState';
14
14
 
15
15
  const DEFAULT_PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
16
+ const STRICT_ENV_VALUE = 'strict';
16
17
 
17
18
  export type GovernanceEvidenceSummary = {
18
19
  path: string;
@@ -78,7 +79,7 @@ const truthyEnv = (value: string | undefined): boolean => {
78
79
  return false;
79
80
  }
80
81
  const normalized = value.trim().toLowerCase();
81
- return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'strict';
82
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === STRICT_ENV_VALUE;
82
83
  };
83
84
 
84
85
  const readCurrentBranch = (git: ILifecycleGitService, repoRoot: string): string | null => {
@@ -20,6 +20,8 @@ type PolicyReconcileDriftCode =
20
20
  | 'POLICY_HASH_DIVERGENCE'
21
21
  | 'POLICY_STAGE_NON_STRICT';
22
22
 
23
+ const POLICY_CONTRACT_JSON_INDENT = 2;
24
+
23
25
  export type PolicyReconcileDrift = {
24
26
  code: PolicyReconcileDriftCode;
25
27
  severity: PolicyReconcileSeverity;
@@ -180,7 +182,7 @@ const tryApplyPolicyAutofix = (params: {
180
182
 
181
183
  try {
182
184
  mkdirSync(dirname(contractPath), { recursive: true });
183
- writeFileSync(contractPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
185
+ writeFileSync(contractPath, `${JSON.stringify(contract, null, POLICY_CONTRACT_JSON_INDENT)}\n`, 'utf8');
184
186
  return {
185
187
  attempted: true,
186
188
  status: 'APPLIED',
@@ -7,6 +7,9 @@ import { readSddLearningContext, type SddLearningContext } from '../sdd/learning
7
7
  type AutoExecuteAction = 'proceed' | 'ask';
8
8
  type AutoExecutePhase = 'GREEN' | 'RED';
9
9
 
10
+ const WORKTREE_SLICE_PLAN_COUNT = 3;
11
+ const WORKTREE_SLICE_FILE_COUNT = 4;
12
+
10
13
  type AutoExecuteNextAction = {
11
14
  kind: 'info' | 'run_command';
12
15
  message: string;
@@ -132,8 +135,8 @@ const nextActionFromViolation = (
132
135
  {
133
136
  const plan = collectWorktreeAtomicSlices({
134
137
  repoRoot,
135
- maxSlices: 3,
136
- maxFilesPerSlice: 4,
138
+ maxSlices: WORKTREE_SLICE_PLAN_COUNT,
139
+ maxFilesPerSlice: WORKTREE_SLICE_FILE_COUNT,
137
140
  });
138
141
  if (plan.slices.length > 0) {
139
142
  const firstSlice = plan.slices[0];
@@ -10,6 +10,9 @@ import { resolveLearningContextExperimentalFeature } from '../policy/experimenta
10
10
  import { resolvePreWriteEnforcement } from '../policy/preWriteEnforcement';
11
11
  import { readSddLearningContext, type SddLearningContext } from '../sdd/learningInsights';
12
12
 
13
+ const WORKTREE_SLICE_PLAN_COUNT = 3;
14
+ const WORKTREE_SLICE_FILE_COUNT = 4;
15
+
13
16
  const resolveTddStatus = (
14
17
  repoRoot: string
15
18
  ): 'skipped' | 'passed' | 'advisory' | 'blocked' | 'waived' | null => {
@@ -114,8 +117,8 @@ const buildPreFlightHints = (params: {
114
117
  if (params.stage === 'PRE_WRITE' && hasWorktreeViolation) {
115
118
  const plan = collectWorktreeAtomicSlices({
116
119
  repoRoot: params.repoRoot,
117
- maxSlices: 3,
118
- maxFilesPerSlice: 4,
120
+ maxSlices: WORKTREE_SLICE_PLAN_COUNT,
121
+ maxFilesPerSlice: WORKTREE_SLICE_FILE_COUNT,
119
122
  });
120
123
  if (plan.slices.length > 0) {
121
124
  hints.push('ATOMIC_SLICES: staging sugerido por scope.');
@@ -14,6 +14,7 @@ const tddEventSchema = z.object({
14
14
  const tddSliceSchema = z.object({
15
15
  id: z.string().min(1),
16
16
  scenario_ref: z.string().min(1),
17
+ baseline: tddEventSchema.optional(),
17
18
  red: tddEventSchema,
18
19
  green: tddEventSchema,
19
20
  refactor: tddEventSchema,
@@ -104,6 +104,12 @@ export const enforceTddBddPolicy = (params: {
104
104
  slices_invalid: 0,
105
105
  integrity_ok: true,
106
106
  errors: [],
107
+ baseline: {
108
+ required: scope.inScope,
109
+ passed: 0,
110
+ missing: 0,
111
+ failed: 0,
112
+ },
107
113
  },
108
114
  waiver: {
109
115
  applied: false,
@@ -207,6 +213,9 @@ export const enforceTddBddPolicy = (params: {
207
213
  const sliceFindings: Finding[] = [];
208
214
  const seenSliceIds = new Set<string>();
209
215
  let validSlices = 0;
216
+ let baselinePassed = 0;
217
+ let baselineMissing = 0;
218
+ let baselineFailed = 0;
210
219
 
211
220
  if (evidenceRead.evidence.slices.length === 0) {
212
221
  sliceFindings.push(
@@ -257,6 +266,30 @@ export const enforceTddBddPolicy = (params: {
257
266
  );
258
267
  }
259
268
 
269
+ if (!slice.baseline) {
270
+ baselineMissing += 1;
271
+ sliceFindings.push(
272
+ buildFinding({
273
+ ruleId: 'generic_tdd_baseline_required',
274
+ code: 'TDD_BASELINE_TEST_REQUIRED',
275
+ message: `Slice ${slice.id} must include passing baseline test evidence before RED.`,
276
+ filePath: evidenceRead.path,
277
+ })
278
+ );
279
+ } else if (slice.baseline.status !== 'passed') {
280
+ baselineFailed += 1;
281
+ sliceFindings.push(
282
+ buildFinding({
283
+ ruleId: 'generic_tdd_baseline_required',
284
+ code: 'TDD_BASELINE_TEST_MUST_PASS',
285
+ message: `Slice ${slice.id} baseline test evidence must pass before editing related code.`,
286
+ filePath: evidenceRead.path,
287
+ })
288
+ );
289
+ } else {
290
+ baselinePassed += 1;
291
+ }
292
+
260
293
  if (slice.red.status !== 'failed') {
261
294
  sliceFindings.push(
262
295
  buildFinding({
@@ -281,6 +314,7 @@ export const enforceTddBddPolicy = (params: {
281
314
 
282
315
  if (
283
316
  !isTimelineOrdered([
317
+ slice.baseline?.timestamp,
284
318
  slice.red.timestamp,
285
319
  slice.green.timestamp,
286
320
  slice.refactor.timestamp,
@@ -320,6 +354,12 @@ export const enforceTddBddPolicy = (params: {
320
354
  slices_invalid: invalidSlices,
321
355
  integrity_ok: evidenceRead.integrity.valid,
322
356
  errors: sliceFindings.map((finding) => finding.code),
357
+ baseline: {
358
+ required: true,
359
+ passed: baselinePassed,
360
+ missing: baselineMissing,
361
+ failed: baselineFailed,
362
+ },
323
363
  },
324
364
  waiver: {
325
365
  applied: false,
@@ -21,6 +21,12 @@ export type TddBddSnapshot = {
21
21
  slices_invalid: number;
22
22
  integrity_ok: boolean;
23
23
  errors: string[];
24
+ baseline: {
25
+ required: boolean;
26
+ passed: number;
27
+ missing: number;
28
+ failed: number;
29
+ };
24
30
  };
25
31
  waiver: {
26
32
  applied: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.138",
3
+ "version": "6.3.140",
4
4
  "description": "Enterprise-grade AST Intelligence System with multi-platform support (iOS, Android, Backend, Frontend) and Feature-First + DDD + Clean Architecture enforcement. Includes dynamic violations API for intelligent querying.",
5
5
  "main": "index.js",
6
6
  "bin": {