pumuki 6.3.13 → 6.3.14
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/README.md +95 -7
- package/VERSION +1 -1
- package/bin/pumuki-mcp-enterprise.js +5 -0
- package/bin/pumuki-pre-write.js +11 -0
- package/docs/API_REFERENCE.md +2 -1
- package/docs/INSTALLATION.md +101 -54
- package/docs/MCP_SERVERS.md +167 -74
- package/docs/PUMUKI_FULL_VALIDATION_CHECKLIST.md +46 -45
- package/docs/PUMUKI_OPENSPEC_SDD_ROADMAP.md +55 -0
- package/docs/README.md +5 -0
- package/docs/REFRACTOR_PROGRESS.md +102 -3
- package/docs/USAGE.md +115 -8
- package/docs/validation/README.md +2 -0
- package/docs/validation/phase12-go-no-go-report.md +73 -0
- package/docs/validation/post-phase12-next-lot-decision.md +75 -0
- package/integrations/config/skillsRuleSet.ts +53 -6
- package/integrations/evidence/buildEvidence.ts +42 -3
- package/integrations/evidence/generateEvidence.test.ts +59 -0
- package/integrations/evidence/readEvidence.test.ts +61 -0
- package/integrations/evidence/schema.test.ts +81 -0
- package/integrations/evidence/schema.ts +11 -0
- package/integrations/evidence/writeEvidence.test.ts +18 -0
- package/integrations/evidence/writeEvidence.ts +11 -0
- package/integrations/git/resolveGitRefs.ts +2 -2
- package/integrations/git/runPlatformGate.ts +64 -0
- package/integrations/git/runPlatformGateEvidence.ts +13 -0
- package/integrations/git/stageRunners.ts +10 -1
- package/integrations/lifecycle/artifacts.ts +57 -4
- package/integrations/lifecycle/cli.ts +248 -12
- package/integrations/lifecycle/constants.ts +1 -0
- package/integrations/lifecycle/gitService.ts +1 -0
- package/integrations/lifecycle/install.ts +24 -1
- package/integrations/lifecycle/openSpecBootstrap.ts +190 -0
- package/integrations/lifecycle/state.ts +57 -0
- package/integrations/lifecycle/uninstall.ts +3 -1
- package/integrations/lifecycle/update.ts +11 -0
- package/integrations/mcp/enterpriseServer.cli.ts +12 -0
- package/integrations/mcp/enterpriseServer.ts +762 -0
- package/integrations/mcp/index.ts +1 -0
- package/integrations/sdd/index.ts +11 -0
- package/integrations/sdd/openSpecCli.ts +180 -0
- package/integrations/sdd/policy.ts +190 -0
- package/integrations/sdd/sessionStore.ts +152 -0
- package/integrations/sdd/types.ts +69 -0
- package/package.json +10 -4
- package/scripts/framework-menu-runner-path-lib.ts +10 -3
- package/scripts/framework-menu.ts +86 -5
- package/scripts/package-install-smoke-gate-lib.ts +6 -1
- package/scripts/package-install-smoke-lifecycle-lib.ts +3 -0
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
LedgerEntry,
|
|
12
12
|
PlatformState,
|
|
13
13
|
RulesetState,
|
|
14
|
+
SddMetrics,
|
|
14
15
|
SnapshotFinding,
|
|
15
16
|
} from './schema';
|
|
16
17
|
import { resolveHumanIntent } from './humanIntent';
|
|
@@ -28,6 +29,7 @@ export type BuildEvidenceParams = {
|
|
|
28
29
|
humanIntent?: HumanIntentState | null;
|
|
29
30
|
detectedPlatforms: Record<string, PlatformState>;
|
|
30
31
|
loadedRulesets: ReadonlyArray<RulesetState>;
|
|
32
|
+
sddMetrics?: SddMetrics;
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
const normalizeLines = (lines?: EvidenceLines): EvidenceLines | undefined => {
|
|
@@ -96,6 +98,30 @@ const normalizeFinding = (finding: BuildFindingInput): SnapshotFinding => {
|
|
|
96
98
|
};
|
|
97
99
|
};
|
|
98
100
|
|
|
101
|
+
const pickDeterministicDuplicateFinding = (
|
|
102
|
+
current: SnapshotFinding,
|
|
103
|
+
candidate: SnapshotFinding
|
|
104
|
+
): SnapshotFinding => {
|
|
105
|
+
const bySeverity = severityRank[candidate.severity] - severityRank[current.severity];
|
|
106
|
+
if (bySeverity > 0) {
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
if (bySeverity < 0) {
|
|
110
|
+
return current;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tuple = (finding: SnapshotFinding): string => {
|
|
114
|
+
return [
|
|
115
|
+
finding.code,
|
|
116
|
+
finding.message,
|
|
117
|
+
finding.matchedBy ?? '',
|
|
118
|
+
finding.source ?? '',
|
|
119
|
+
].join('\u0000');
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return tuple(candidate).localeCompare(tuple(current)) < 0 ? candidate : current;
|
|
123
|
+
};
|
|
124
|
+
|
|
99
125
|
const severityRank: Record<Severity, number> = {
|
|
100
126
|
INFO: 0,
|
|
101
127
|
WARN: 1,
|
|
@@ -228,9 +254,11 @@ const normalizeAndDedupeFindings = (
|
|
|
228
254
|
for (const finding of findings) {
|
|
229
255
|
const normalized = normalizeFinding(finding);
|
|
230
256
|
const key = findingKey(normalized);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
257
|
+
const current = unique.get(key);
|
|
258
|
+
unique.set(
|
|
259
|
+
key,
|
|
260
|
+
current ? pickDeterministicDuplicateFinding(current, normalized) : normalized
|
|
261
|
+
);
|
|
234
262
|
}
|
|
235
263
|
const deduped = Array.from(unique.values()).sort(compareFindingEntries);
|
|
236
264
|
const consolidated = consolidateEquivalentFindings(deduped);
|
|
@@ -371,6 +399,17 @@ export function buildEvidence(params: BuildEvidenceParams): AiEvidenceV2_1 {
|
|
|
371
399
|
total_violations: normalizedFindings.length,
|
|
372
400
|
by_severity: severity,
|
|
373
401
|
},
|
|
402
|
+
sdd_metrics: params.sddMetrics
|
|
403
|
+
? {
|
|
404
|
+
enforced: params.sddMetrics.enforced,
|
|
405
|
+
stage: params.sddMetrics.stage,
|
|
406
|
+
decision: {
|
|
407
|
+
allowed: params.sddMetrics.decision.allowed,
|
|
408
|
+
code: params.sddMetrics.decision.code,
|
|
409
|
+
message: params.sddMetrics.decision.message,
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
: undefined,
|
|
374
413
|
consolidation:
|
|
375
414
|
consolidatedFindings.suppressed.length > 0
|
|
376
415
|
? { suppressed: consolidatedFindings.suppressed }
|
|
@@ -121,3 +121,62 @@ test('generateEvidence respeta gateOutcome explícito al componer build + write'
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
test('generateEvidence persiste contrato SDD cuando se informa bloqueo de policy', async () => {
|
|
126
|
+
await withTempDir('pumuki-generate-evidence-sdd-contract-', async (tempRoot) => {
|
|
127
|
+
initGitRepo(tempRoot);
|
|
128
|
+
await withCwd(tempRoot, async () => {
|
|
129
|
+
const result = generateEvidence({
|
|
130
|
+
stage: 'PRE_PUSH',
|
|
131
|
+
gateOutcome: 'BLOCK',
|
|
132
|
+
findings: [
|
|
133
|
+
{
|
|
134
|
+
ruleId: 'sdd.policy.blocked',
|
|
135
|
+
severity: 'ERROR',
|
|
136
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
137
|
+
message: 'OpenSpec validation failed',
|
|
138
|
+
filePath: 'openspec/changes',
|
|
139
|
+
matchedBy: 'SddPolicy',
|
|
140
|
+
source: 'sdd-policy',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
detectedPlatforms: {},
|
|
144
|
+
loadedRulesets: [{ platform: 'policy', bundle: 'gate-policy.default.PRE_PUSH', hash: 'hash-policy' }],
|
|
145
|
+
sddMetrics: {
|
|
146
|
+
enforced: true,
|
|
147
|
+
stage: 'PRE_PUSH',
|
|
148
|
+
decision: {
|
|
149
|
+
allowed: false,
|
|
150
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
151
|
+
message: 'OpenSpec validation failed',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
assert.equal(result.evidence.snapshot.findings[0]?.source, 'sdd-policy');
|
|
157
|
+
assert.deepEqual(result.evidence.sdd_metrics, {
|
|
158
|
+
enforced: true,
|
|
159
|
+
stage: 'PRE_PUSH',
|
|
160
|
+
decision: {
|
|
161
|
+
allowed: false,
|
|
162
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
163
|
+
message: 'OpenSpec validation failed',
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
assert.equal(result.write.ok, true);
|
|
167
|
+
|
|
168
|
+
const persisted = JSON.parse(readFileSync(join(tempRoot, '.ai_evidence.json'), 'utf8')) as AiEvidenceV2_1;
|
|
169
|
+
assert.equal(persisted.snapshot.findings[0]?.source, 'sdd-policy');
|
|
170
|
+
assert.equal(persisted.ai_gate.violations[0]?.source, 'sdd-policy');
|
|
171
|
+
assert.deepEqual(persisted.sdd_metrics, {
|
|
172
|
+
enforced: true,
|
|
173
|
+
stage: 'PRE_PUSH',
|
|
174
|
+
decision: {
|
|
175
|
+
allowed: false,
|
|
176
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
177
|
+
message: 'OpenSpec validation failed',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -57,6 +57,67 @@ test('readEvidenceResult devuelve valid cuando el archivo tiene version 2.1', as
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
test('readEvidenceResult preserva contrato SDD (sdd_metrics + source sdd-policy)', async () => {
|
|
61
|
+
await withTempDir('pumuki-read-evidence-sdd-contract-', async (tempRoot) => {
|
|
62
|
+
const evidence = sampleEvidence();
|
|
63
|
+
evidence.snapshot.stage = 'PRE_PUSH';
|
|
64
|
+
evidence.snapshot.outcome = 'BLOCK';
|
|
65
|
+
evidence.snapshot.findings = [
|
|
66
|
+
{
|
|
67
|
+
ruleId: 'sdd.policy.blocked',
|
|
68
|
+
severity: 'ERROR',
|
|
69
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
70
|
+
message: 'OpenSpec validation failed',
|
|
71
|
+
file: 'openspec/changes',
|
|
72
|
+
matchedBy: 'SddPolicy',
|
|
73
|
+
source: 'sdd-policy',
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
evidence.ledger = [
|
|
77
|
+
{
|
|
78
|
+
ruleId: 'sdd.policy.blocked',
|
|
79
|
+
file: 'openspec/changes',
|
|
80
|
+
firstSeen: '2026-02-18T10:00:00.000Z',
|
|
81
|
+
lastSeen: '2026-02-18T10:01:00.000Z',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
evidence.ai_gate.status = 'BLOCKED';
|
|
85
|
+
evidence.ai_gate.violations = [
|
|
86
|
+
{
|
|
87
|
+
ruleId: 'sdd.policy.blocked',
|
|
88
|
+
level: 'ERROR',
|
|
89
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
90
|
+
message: 'OpenSpec validation failed',
|
|
91
|
+
file: 'openspec/changes',
|
|
92
|
+
matchedBy: 'SddPolicy',
|
|
93
|
+
source: 'sdd-policy',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
evidence.severity_metrics.gate_status = 'BLOCKED';
|
|
97
|
+
evidence.severity_metrics.total_violations = 1;
|
|
98
|
+
evidence.severity_metrics.by_severity.ERROR = 1;
|
|
99
|
+
evidence.sdd_metrics = {
|
|
100
|
+
enforced: true,
|
|
101
|
+
stage: 'PRE_PUSH',
|
|
102
|
+
decision: {
|
|
103
|
+
allowed: false,
|
|
104
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
105
|
+
message: 'OpenSpec validation failed',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
writeFileSync(join(tempRoot, '.ai_evidence.json'), JSON.stringify(evidence, null, 2), 'utf8');
|
|
110
|
+
|
|
111
|
+
const result = readEvidenceResult(tempRoot);
|
|
112
|
+
assert.equal(result.kind, 'valid');
|
|
113
|
+
if (result.kind === 'valid') {
|
|
114
|
+
assert.equal(result.evidence.snapshot.findings[0]?.source, 'sdd-policy');
|
|
115
|
+
assert.equal(result.evidence.ai_gate.violations[0]?.source, 'sdd-policy');
|
|
116
|
+
assert.deepEqual(result.evidence.sdd_metrics, evidence.sdd_metrics);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
60
121
|
test('readEvidenceResult devuelve invalid y versión cuando el schema es de otra versión', async () => {
|
|
61
122
|
await withTempDir('pumuki-read-evidence-invalid-version-', async (tempRoot) => {
|
|
62
123
|
writeFileSync(
|
|
@@ -81,6 +81,87 @@ test('AiEvidenceV2_1 soporta snapshot/ledger/platforms/rulesets con contrato 2.1
|
|
|
81
81
|
assert.equal(evidence.ai_gate.violations[0]?.level, 'ERROR');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test('AiEvidenceV2_1 soporta contrato SDD en evidencia (sdd_metrics + source sdd-policy)', () => {
|
|
85
|
+
const evidence: AiEvidenceV2_1 = {
|
|
86
|
+
version: '2.1',
|
|
87
|
+
timestamp: '2026-02-18T10:00:00.000Z',
|
|
88
|
+
snapshot: {
|
|
89
|
+
stage: 'PRE_PUSH',
|
|
90
|
+
outcome: 'BLOCK',
|
|
91
|
+
findings: [
|
|
92
|
+
sampleFinding({
|
|
93
|
+
ruleId: 'sdd.policy.blocked',
|
|
94
|
+
severity: 'ERROR',
|
|
95
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
96
|
+
message: 'OpenSpec validation failed',
|
|
97
|
+
file: 'openspec/changes',
|
|
98
|
+
lines: undefined,
|
|
99
|
+
matchedBy: 'SddPolicy',
|
|
100
|
+
source: 'sdd-policy',
|
|
101
|
+
}),
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
ledger: [
|
|
105
|
+
{
|
|
106
|
+
ruleId: 'sdd.policy.blocked',
|
|
107
|
+
file: 'openspec/changes',
|
|
108
|
+
firstSeen: '2026-02-18T09:58:00.000Z',
|
|
109
|
+
lastSeen: '2026-02-18T10:00:00.000Z',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
platforms: {},
|
|
113
|
+
rulesets: [{ platform: 'policy', bundle: 'gate-policy.default.PRE_PUSH', hash: 'hash-policy' }],
|
|
114
|
+
human_intent: null,
|
|
115
|
+
ai_gate: {
|
|
116
|
+
status: 'BLOCKED',
|
|
117
|
+
violations: [
|
|
118
|
+
sampleViolation({
|
|
119
|
+
ruleId: 'sdd.policy.blocked',
|
|
120
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
121
|
+
message: 'OpenSpec validation failed',
|
|
122
|
+
file: 'openspec/changes',
|
|
123
|
+
lines: undefined,
|
|
124
|
+
matchedBy: 'SddPolicy',
|
|
125
|
+
source: 'sdd-policy',
|
|
126
|
+
}),
|
|
127
|
+
],
|
|
128
|
+
human_intent: null,
|
|
129
|
+
},
|
|
130
|
+
severity_metrics: {
|
|
131
|
+
gate_status: 'BLOCKED',
|
|
132
|
+
total_violations: 1,
|
|
133
|
+
by_severity: {
|
|
134
|
+
INFO: 0,
|
|
135
|
+
WARN: 0,
|
|
136
|
+
ERROR: 1,
|
|
137
|
+
CRITICAL: 0,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
sdd_metrics: {
|
|
141
|
+
enforced: true,
|
|
142
|
+
stage: 'PRE_PUSH',
|
|
143
|
+
decision: {
|
|
144
|
+
allowed: false,
|
|
145
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
146
|
+
message: 'OpenSpec validation failed',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
assert.equal(evidence.snapshot.findings[0]?.source, 'sdd-policy');
|
|
152
|
+
assert.equal(evidence.snapshot.findings[0]?.matchedBy, 'SddPolicy');
|
|
153
|
+
assert.equal(evidence.ai_gate.violations[0]?.source, 'sdd-policy');
|
|
154
|
+
assert.deepEqual(evidence.sdd_metrics, {
|
|
155
|
+
enforced: true,
|
|
156
|
+
stage: 'PRE_PUSH',
|
|
157
|
+
decision: {
|
|
158
|
+
allowed: false,
|
|
159
|
+
code: 'SDD_VALIDATION_FAILED',
|
|
160
|
+
message: 'OpenSpec validation failed',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
84
165
|
test('EvidenceLines acepta string, number y array numérico según contrato', () => {
|
|
85
166
|
const stringLines = sampleFinding({ lines: 'L12-L14' });
|
|
86
167
|
const numberLines = sampleFinding({ lines: 12 });
|
|
@@ -67,6 +67,16 @@ export type CompatibilityViolation = {
|
|
|
67
67
|
source?: string;
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
export type SddMetrics = {
|
|
71
|
+
enforced: boolean;
|
|
72
|
+
stage: GateStage;
|
|
73
|
+
decision: {
|
|
74
|
+
allowed: boolean;
|
|
75
|
+
code: string;
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
70
80
|
export type ConsolidationSuppressedFinding = {
|
|
71
81
|
ruleId: string;
|
|
72
82
|
file: string;
|
|
@@ -95,6 +105,7 @@ export type AiEvidenceV2_1 = {
|
|
|
95
105
|
total_violations: number;
|
|
96
106
|
by_severity: Record<Severity, number>;
|
|
97
107
|
};
|
|
108
|
+
sdd_metrics?: SddMetrics;
|
|
98
109
|
consolidation?: {
|
|
99
110
|
suppressed: ConsolidationSuppressedFinding[];
|
|
100
111
|
};
|
|
@@ -93,6 +93,15 @@ const sampleEvidence = (repoRoot: string): AiEvidenceV2_1 => ({
|
|
|
93
93
|
INFO: 0,
|
|
94
94
|
},
|
|
95
95
|
},
|
|
96
|
+
sdd_metrics: {
|
|
97
|
+
enforced: true,
|
|
98
|
+
stage: 'PRE_PUSH',
|
|
99
|
+
decision: {
|
|
100
|
+
allowed: true,
|
|
101
|
+
code: 'ALLOWED',
|
|
102
|
+
message: 'sdd policy passed',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
96
105
|
});
|
|
97
106
|
|
|
98
107
|
test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', async () => {
|
|
@@ -137,6 +146,15 @@ test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', asy
|
|
|
137
146
|
['z.rule', 'FileContent', 'git:staged'],
|
|
138
147
|
]
|
|
139
148
|
);
|
|
149
|
+
assert.deepEqual(written.sdd_metrics, {
|
|
150
|
+
enforced: true,
|
|
151
|
+
stage: 'PRE_PUSH',
|
|
152
|
+
decision: {
|
|
153
|
+
allowed: true,
|
|
154
|
+
code: 'ALLOWED',
|
|
155
|
+
message: 'sdd policy passed',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
140
158
|
});
|
|
141
159
|
});
|
|
142
160
|
});
|
|
@@ -169,6 +169,17 @@ const toStableEvidence = (
|
|
|
169
169
|
total_violations: evidence.severity_metrics.total_violations,
|
|
170
170
|
by_severity: bySeverity,
|
|
171
171
|
},
|
|
172
|
+
sdd_metrics: evidence.sdd_metrics
|
|
173
|
+
? {
|
|
174
|
+
enforced: evidence.sdd_metrics.enforced,
|
|
175
|
+
stage: evidence.sdd_metrics.stage,
|
|
176
|
+
decision: {
|
|
177
|
+
allowed: evidence.sdd_metrics.decision.allowed,
|
|
178
|
+
code: evidence.sdd_metrics.decision.code,
|
|
179
|
+
message: evidence.sdd_metrics.decision.message,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
: undefined,
|
|
172
183
|
};
|
|
173
184
|
};
|
|
174
185
|
|
|
@@ -26,11 +26,11 @@ const resolveDefaultCiBaseRef = (): string => {
|
|
|
26
26
|
return 'HEAD';
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
export const resolveUpstreamRef = (): string => {
|
|
29
|
+
export const resolveUpstreamRef = (): string | null => {
|
|
30
30
|
try {
|
|
31
31
|
return runGit(['rev-parse', '@{u}']);
|
|
32
32
|
} catch {
|
|
33
|
-
return
|
|
33
|
+
return null;
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { evaluateGate } from '../../core/gate/evaluateGate';
|
|
2
|
+
import type { Finding } from '../../core/gate/Finding';
|
|
2
3
|
import type { GatePolicy } from '../../core/gate/GatePolicy';
|
|
4
|
+
import type { RuleSet } from '../../core/rules/RuleSet';
|
|
5
|
+
import type { SkillsRuleSetLoadResult } from '../config/skillsRuleSet';
|
|
3
6
|
import type { ResolvedStagePolicy } from '../gate/stagePolicies';
|
|
7
|
+
import type { DetectedPlatforms } from '../platform/detectPlatforms';
|
|
4
8
|
import { GitService, type IGitService } from './GitService';
|
|
5
9
|
import { EvidenceService, type IEvidenceService } from './EvidenceService';
|
|
6
10
|
import { evaluatePlatformGateFindings } from './runPlatformGateEvaluation';
|
|
7
11
|
import { resolveFactsForGateScope, type GateScope } from './runPlatformGateFacts';
|
|
8
12
|
import { emitPlatformGateEvidence } from './runPlatformGateEvidence';
|
|
9
13
|
import { printGateFindings } from './runPlatformGateOutput';
|
|
14
|
+
import { evaluateSddPolicy, type SddDecision } from '../sdd';
|
|
10
15
|
|
|
11
16
|
export type GateServices = {
|
|
12
17
|
git: IGitService;
|
|
@@ -19,6 +24,10 @@ export type GateDependencies = {
|
|
|
19
24
|
resolveFactsForGateScope: typeof resolveFactsForGateScope;
|
|
20
25
|
emitPlatformGateEvidence: typeof emitPlatformGateEvidence;
|
|
21
26
|
printGateFindings: typeof printGateFindings;
|
|
27
|
+
evaluateSddForStage: (
|
|
28
|
+
stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI',
|
|
29
|
+
repoRoot: string
|
|
30
|
+
) => Pick<SddDecision, 'allowed' | 'code' | 'message'>;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
const defaultServices: GateServices = {
|
|
@@ -32,8 +41,23 @@ const defaultDependencies: GateDependencies = {
|
|
|
32
41
|
resolveFactsForGateScope,
|
|
33
42
|
emitPlatformGateEvidence,
|
|
34
43
|
printGateFindings,
|
|
44
|
+
evaluateSddForStage: (stage, repoRoot) =>
|
|
45
|
+
evaluateSddPolicy({
|
|
46
|
+
stage,
|
|
47
|
+
repoRoot,
|
|
48
|
+
}).decision,
|
|
35
49
|
};
|
|
36
50
|
|
|
51
|
+
const toSddBlockingFinding = (decision: Pick<SddDecision, 'code' | 'message'>): Finding => ({
|
|
52
|
+
ruleId: 'sdd.policy.blocked',
|
|
53
|
+
severity: 'ERROR',
|
|
54
|
+
code: decision.code,
|
|
55
|
+
message: decision.message,
|
|
56
|
+
filePath: 'openspec/changes',
|
|
57
|
+
matchedBy: 'SddPolicy',
|
|
58
|
+
source: 'sdd-policy',
|
|
59
|
+
});
|
|
60
|
+
|
|
37
61
|
export async function runPlatformGate(params: {
|
|
38
62
|
policy: GatePolicy;
|
|
39
63
|
policyTrace?: ResolvedStagePolicy['trace'];
|
|
@@ -48,6 +72,45 @@ export async function runPlatformGate(params: {
|
|
|
48
72
|
...params.dependencies,
|
|
49
73
|
};
|
|
50
74
|
const repoRoot = git.resolveRepoRoot();
|
|
75
|
+
let sddDecision:
|
|
76
|
+
| Pick<SddDecision, 'allowed' | 'code' | 'message'>
|
|
77
|
+
| undefined;
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
params.policy.stage === 'PRE_COMMIT' ||
|
|
81
|
+
params.policy.stage === 'PRE_PUSH' ||
|
|
82
|
+
params.policy.stage === 'CI'
|
|
83
|
+
) {
|
|
84
|
+
sddDecision = dependencies.evaluateSddForStage(
|
|
85
|
+
params.policy.stage,
|
|
86
|
+
repoRoot
|
|
87
|
+
);
|
|
88
|
+
if (!sddDecision.allowed) {
|
|
89
|
+
console.log(`[pumuki][sdd] ${sddDecision.code}: ${sddDecision.message}`);
|
|
90
|
+
const emptyDetectedPlatforms: DetectedPlatforms = {};
|
|
91
|
+
const emptySkillsRuleSet: SkillsRuleSetLoadResult = {
|
|
92
|
+
rules: [],
|
|
93
|
+
activeBundles: [],
|
|
94
|
+
mappedHeuristicRuleIds: new Set<string>(),
|
|
95
|
+
requiresHeuristicFacts: false,
|
|
96
|
+
};
|
|
97
|
+
const emptyRuleSet: RuleSet = [];
|
|
98
|
+
dependencies.emitPlatformGateEvidence({
|
|
99
|
+
stage: params.policy.stage,
|
|
100
|
+
policyTrace: params.policyTrace,
|
|
101
|
+
findings: [toSddBlockingFinding(sddDecision)],
|
|
102
|
+
gateOutcome: 'BLOCK',
|
|
103
|
+
repoRoot,
|
|
104
|
+
detectedPlatforms: emptyDetectedPlatforms,
|
|
105
|
+
skillsRuleSet: emptySkillsRuleSet,
|
|
106
|
+
projectRules: emptyRuleSet,
|
|
107
|
+
heuristicRules: emptyRuleSet,
|
|
108
|
+
evidenceService: evidence,
|
|
109
|
+
sddDecision,
|
|
110
|
+
});
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
51
114
|
|
|
52
115
|
const facts = await dependencies.resolveFactsForGateScope({
|
|
53
116
|
scope: params.scope,
|
|
@@ -78,6 +141,7 @@ export async function runPlatformGate(params: {
|
|
|
78
141
|
projectRules,
|
|
79
142
|
heuristicRules,
|
|
80
143
|
evidenceService: evidence,
|
|
144
|
+
sddDecision,
|
|
81
145
|
});
|
|
82
146
|
|
|
83
147
|
if (decision.outcome === 'BLOCK') {
|
|
@@ -9,6 +9,7 @@ import type { ResolvedStagePolicy } from '../gate/stagePolicies';
|
|
|
9
9
|
import type { DetectedPlatforms } from '../platform/detectPlatforms';
|
|
10
10
|
import { buildBaselineRuleSetEntries } from './baselineRuleSets';
|
|
11
11
|
import type { IEvidenceService } from './EvidenceService';
|
|
12
|
+
import type { SddDecision } from '../sdd';
|
|
12
13
|
|
|
13
14
|
export type PlatformGateEvidenceDependencies = {
|
|
14
15
|
generateEvidence: typeof generateEvidence;
|
|
@@ -29,6 +30,7 @@ export const emitPlatformGateEvidence = (params: {
|
|
|
29
30
|
projectRules: RuleSet;
|
|
30
31
|
heuristicRules: RuleSet;
|
|
31
32
|
evidenceService: IEvidenceService;
|
|
33
|
+
sddDecision?: Pick<SddDecision, 'allowed' | 'code' | 'message'>;
|
|
32
34
|
}, dependencies: Partial<PlatformGateEvidenceDependencies> = {}): void => {
|
|
33
35
|
const activeDependencies: PlatformGateEvidenceDependencies = {
|
|
34
36
|
...defaultDependencies,
|
|
@@ -50,5 +52,16 @@ export const emitPlatformGateEvidence = (params: {
|
|
|
50
52
|
policyTrace: params.policyTrace,
|
|
51
53
|
stage: params.stage,
|
|
52
54
|
}),
|
|
55
|
+
sddMetrics: params.sddDecision
|
|
56
|
+
? {
|
|
57
|
+
enforced: true,
|
|
58
|
+
stage: params.stage,
|
|
59
|
+
decision: {
|
|
60
|
+
allowed: params.sddDecision.allowed,
|
|
61
|
+
code: params.sddDecision.code,
|
|
62
|
+
message: params.sddDecision.message,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
: undefined,
|
|
53
66
|
});
|
|
54
67
|
};
|
|
@@ -2,6 +2,9 @@ import { resolvePolicyForStage } from '../gate/stagePolicies';
|
|
|
2
2
|
import { resolveCiBaseRef, resolveUpstreamRef } from './resolveGitRefs';
|
|
3
3
|
import { runPlatformGate } from './runPlatformGate';
|
|
4
4
|
|
|
5
|
+
const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
|
|
6
|
+
'pumuki pre-push blocked: branch has no upstream tracking reference. Configure upstream first (for example: git push --set-upstream origin <branch>) and retry.';
|
|
7
|
+
|
|
5
8
|
export async function runPreCommitStage(): Promise<number> {
|
|
6
9
|
const resolved = resolvePolicyForStage('PRE_COMMIT');
|
|
7
10
|
return runPlatformGate({
|
|
@@ -14,13 +17,19 @@ export async function runPreCommitStage(): Promise<number> {
|
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export async function runPrePushStage(): Promise<number> {
|
|
20
|
+
const upstreamRef = resolveUpstreamRef();
|
|
21
|
+
if (!upstreamRef) {
|
|
22
|
+
console.error(PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
const resolved = resolvePolicyForStage('PRE_PUSH');
|
|
18
27
|
return runPlatformGate({
|
|
19
28
|
policy: resolved.policy,
|
|
20
29
|
policyTrace: resolved.trace,
|
|
21
30
|
scope: {
|
|
22
31
|
kind: 'range',
|
|
23
|
-
fromRef:
|
|
32
|
+
fromRef: upstreamRef,
|
|
24
33
|
toRef: 'HEAD',
|
|
25
34
|
},
|
|
26
35
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, unlinkSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import type { ILifecycleGitService } from './gitService';
|
|
4
4
|
|
|
5
5
|
const PUMUKI_ARTIFACTS = ['.ai_evidence.json', '.AI_EVIDENCE.json'] as const;
|
|
@@ -21,9 +21,46 @@ const isTrackedArtifactAlias = (params: {
|
|
|
21
21
|
export const purgeUntrackedPumukiArtifacts = (params: {
|
|
22
22
|
git: ILifecycleGitService;
|
|
23
23
|
repoRoot: string;
|
|
24
|
+
managedOpenSpecArtifacts?: ReadonlyArray<string>;
|
|
24
25
|
}): ReadonlyArray<string> => {
|
|
25
26
|
const removed: string[] = [];
|
|
26
27
|
|
|
28
|
+
const pruneEmptyAncestors = (relativePath: string): void => {
|
|
29
|
+
let current = dirname(relativePath);
|
|
30
|
+
while (current !== '.' && current !== '') {
|
|
31
|
+
const absolute = join(params.repoRoot, current);
|
|
32
|
+
if (!existsSync(absolute)) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
if (params.git.isPathTracked(params.repoRoot, current)) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (readdirSync(absolute).length > 0) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
rmdirSync(absolute);
|
|
42
|
+
current = dirname(current);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const removeUntrackedFile = (relativePath: string): boolean => {
|
|
47
|
+
const absolutePath = join(params.repoRoot, relativePath);
|
|
48
|
+
if (!existsSync(absolutePath)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (params.git.isPathTracked(params.repoRoot, relativePath)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const stat = lstatSync(absolutePath);
|
|
55
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
unlinkSync(absolutePath);
|
|
59
|
+
removed.push(relativePath);
|
|
60
|
+
pruneEmptyAncestors(relativePath);
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
|
|
27
64
|
for (const relativePath of PUMUKI_ARTIFACTS) {
|
|
28
65
|
const absolutePath = join(params.repoRoot, relativePath);
|
|
29
66
|
if (!existsSync(absolutePath)) {
|
|
@@ -38,8 +75,24 @@ export const purgeUntrackedPumukiArtifacts = (params: {
|
|
|
38
75
|
) {
|
|
39
76
|
continue;
|
|
40
77
|
}
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
removeUntrackedFile(relativePath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const managedArtifacts = Array.from(
|
|
82
|
+
new Set((params.managedOpenSpecArtifacts ?? []).map((value) => value.trim()))
|
|
83
|
+
);
|
|
84
|
+
for (const rawPath of managedArtifacts) {
|
|
85
|
+
const normalized = rawPath.replace(/\\/g, '/');
|
|
86
|
+
if (normalized.length === 0) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (normalized.startsWith('/') || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (PUMUKI_ARTIFACTS.includes(normalized as (typeof PUMUKI_ARTIFACTS)[number])) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
removeUntrackedFile(normalized);
|
|
43
96
|
}
|
|
44
97
|
|
|
45
98
|
return removed;
|