@brainwav/diagram 1.0.7 → 1.1.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/.diagram/contracts/machine-command-coverage.json +73 -0
- package/.diagram/migration/finalization-policy.json +20 -0
- package/LICENSE +202 -21
- package/README.md +132 -339
- package/package.json +46 -13
- package/scripts/refresh-diagram-context.sh +274 -182
- package/src/analyzers/default-analyzer.js +11 -0
- package/src/analyzers/index.js +34 -0
- package/src/artifacts/agent-context.js +105 -0
- package/src/artifacts/artifact-budget.js +224 -0
- package/src/artifacts/brief.js +153 -0
- package/src/artifacts/evidence-manifest.js +206 -0
- package/src/artifacts/evidence-summary.js +29 -0
- package/src/commands/analyze.js +125 -0
- package/src/commands/changed.js +185 -0
- package/src/commands/context.js +110 -0
- package/src/commands/diff.js +142 -0
- package/src/commands/doctor.js +335 -0
- package/src/commands/explain.js +273 -0
- package/src/commands/generate-all.js +170 -0
- package/src/commands/generate-animated.js +50 -0
- package/src/commands/generate-video.js +65 -0
- package/src/commands/generate.js +522 -0
- package/src/commands/init.js +123 -0
- package/src/commands/output.js +76 -0
- package/src/commands/scan.js +624 -0
- package/src/commands/shared.js +396 -0
- package/src/commands/validate.js +328 -0
- package/src/commands/video-shared.js +105 -0
- package/src/commands/workflow-pr.js +26 -0
- package/src/confidence/pipeline.js +186 -0
- package/src/config/diagramrc.js +79 -0
- package/src/context/build-context-pack.js +291 -0
- package/src/context/normalize-diagram-manifest.js +282 -0
- package/src/core/analysis-generation-analyze-components.js +102 -0
- package/src/core/analysis-generation-analyze-dependencies.js +33 -0
- package/src/core/analysis-generation-analyze-files.js +48 -0
- package/src/core/analysis-generation-analyze-options.js +73 -0
- package/src/core/analysis-generation-analyze.js +63 -0
- package/src/core/analysis-generation-constants.js +53 -0
- package/src/core/analysis-generation-diagrams-core-architecture.js +105 -0
- package/src/core/analysis-generation-diagrams-core-dependency.js +68 -0
- package/src/core/analysis-generation-diagrams-core-sequence.js +142 -0
- package/src/core/analysis-generation-diagrams-core-shapes.js +104 -0
- package/src/core/analysis-generation-diagrams-core.js +12 -0
- package/src/core/analysis-generation-diagrams-empty.js +68 -0
- package/src/core/analysis-generation-diagrams-erd.js +59 -0
- package/src/core/analysis-generation-diagrams-limit.js +27 -0
- package/src/core/analysis-generation-diagrams-role-ai-agent.js +103 -0
- package/src/core/analysis-generation-diagrams-role-ai-context.js +186 -0
- package/src/core/analysis-generation-diagrams-role-ai.js +11 -0
- package/src/core/analysis-generation-diagrams-role-data.js +182 -0
- package/src/core/analysis-generation-diagrams-role-helpers.js +129 -0
- package/src/core/analysis-generation-diagrams-role-security.js +129 -0
- package/src/core/analysis-generation-diagrams-role.js +25 -0
- package/src/core/analysis-generation-diagrams.js +182 -0
- package/src/core/analysis-generation-role-tags-constants.js +55 -0
- package/src/core/analysis-generation-role-tags-imports.js +32 -0
- package/src/core/analysis-generation-role-tags-infer.js +49 -0
- package/src/core/analysis-generation-role-tags-match.js +19 -0
- package/src/core/analysis-generation-role-tags.js +7 -0
- package/src/core/analysis-generation-utils-core.js +308 -0
- package/src/core/analysis-generation-utils-graph.js +321 -0
- package/src/core/analysis-generation-utils-resolution.js +76 -0
- package/src/core/analysis-generation-utils.js +9 -0
- package/src/core/analysis-generation.js +44 -0
- package/src/diagram.js +180 -1760
- package/src/formatters/console.js +198 -0
- package/src/formatters/index.js +41 -0
- package/src/formatters/json.js +113 -0
- package/src/formatters/junit.js +123 -0
- package/src/graph.js +159 -0
- package/src/incremental/cache.js +210 -0
- package/src/ir/architecture-ir.js +48 -0
- package/src/migration/evidence.js +262 -0
- package/src/migration/finalization-policy.js +35 -0
- package/src/renderers/report-html.js +265 -0
- package/src/rules/factory.js +108 -0
- package/src/rules/types/base.js +54 -0
- package/src/rules/types/import-rule.js +286 -0
- package/src/rules.js +380 -0
- package/src/schema/erd-confidence.js +56 -0
- package/src/schema/erd-extractor.js +504 -0
- package/src/schema/erd-model.js +176 -0
- package/src/schema/rules-schema.js +170 -0
- package/src/utils/suggestions.js +67 -0
- package/src/video.js +4 -5
- package/src/workflow/git-helpers.js +576 -0
- package/src/workflow/pr-command.js +694 -0
- package/src/workflow/pr-impact.js +848 -0
- package/src/workflow/sort-utils.js +16 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const CACHE_SCHEMA_VERSION = '1.0';
|
|
6
|
+
|
|
7
|
+
function normalizeOptions(options = {}) {
|
|
8
|
+
return {
|
|
9
|
+
patterns: options.patterns || null,
|
|
10
|
+
exclude: options.exclude || null,
|
|
11
|
+
maxFiles: options.maxFiles || null,
|
|
12
|
+
analyzer: options.analyzer || 'default',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildCacheKey(command, options = {}) {
|
|
17
|
+
const payload = {
|
|
18
|
+
command,
|
|
19
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
20
|
+
options: normalizeOptions(options),
|
|
21
|
+
};
|
|
22
|
+
const hash = crypto
|
|
23
|
+
.createHash('sha256')
|
|
24
|
+
.update(JSON.stringify(payload))
|
|
25
|
+
.digest('hex')
|
|
26
|
+
.slice(0, 16);
|
|
27
|
+
return `${command}-${hash}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveCacheDir(rootPath) {
|
|
31
|
+
if (process.env.DIAGRAM_CACHE_DIR) {
|
|
32
|
+
const resolved = path.resolve(process.env.DIAGRAM_CACHE_DIR);
|
|
33
|
+
// Security: ensure the override stays inside the project root
|
|
34
|
+
const rel = path.relative(rootPath, resolved);
|
|
35
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`DIAGRAM_CACHE_DIR must be inside the project root.\n` +
|
|
38
|
+
` Root: ${rootPath}\n` +
|
|
39
|
+
` Resolved: ${resolved}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
return path.join(rootPath, '.diagram', 'cache');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a directory-mtime signature from the parent directories of all analyzed
|
|
49
|
+
* files. Adding or deleting any file inside a tracked directory changes that
|
|
50
|
+
* directory's mtime, causing a cache miss on the next read.
|
|
51
|
+
*
|
|
52
|
+
* This is cheaper than re-globbing and doesn't require passing options into the
|
|
53
|
+
* read path. It correctly detects file additions and deletions.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} filePaths - Relative file paths from analysis.components
|
|
56
|
+
* @param {string} rootPath - Project root (used to resolve relative paths)
|
|
57
|
+
* @returns {string} SHA-256 hex digest of sorted dir:mtime:nlink entries
|
|
58
|
+
*/
|
|
59
|
+
function buildDirectoryMtimeSignature(filePaths, rootPath) {
|
|
60
|
+
// Collect unique parent directories, always including the root itself
|
|
61
|
+
const dirs = new Set([rootPath]);
|
|
62
|
+
for (const fp of filePaths) {
|
|
63
|
+
const abs = path.isAbsolute(fp) ? fp : path.join(rootPath, fp);
|
|
64
|
+
dirs.add(path.dirname(abs));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const entries = [];
|
|
68
|
+
for (const dir of dirs) {
|
|
69
|
+
try {
|
|
70
|
+
const st = fs.statSync(dir);
|
|
71
|
+
// nlink tracks the number of directory entries; mtimeMs tracks last change
|
|
72
|
+
entries.push(`${dir}:${st.mtimeMs}:${st.nlink}`);
|
|
73
|
+
} catch {
|
|
74
|
+
// Directory gone — treat as a change
|
|
75
|
+
entries.push(`${dir}:MISSING`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
entries.sort();
|
|
79
|
+
return crypto.createHash('sha256').update(entries.join('\n')).digest('hex');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a content signature for the set of files that were analyzed.
|
|
84
|
+
* Uses file mtime + size — fast enough for hundreds of files, no content read required.
|
|
85
|
+
* Detects modifications to existing files; pair with buildFileListSignature to
|
|
86
|
+
* also detect additions and deletions.
|
|
87
|
+
*
|
|
88
|
+
* @param {string[]} filePaths - Absolute or relative (to rootPath) file paths
|
|
89
|
+
* @param {string} rootPath - Project root
|
|
90
|
+
* @returns {string} SHA-256 hex digest of the sorted mtime+size tuples
|
|
91
|
+
*/
|
|
92
|
+
function buildContentSignature(filePaths, rootPath) {
|
|
93
|
+
const entries = [];
|
|
94
|
+
for (const fp of filePaths) {
|
|
95
|
+
const abs = path.isAbsolute(fp) ? fp : path.join(rootPath, fp);
|
|
96
|
+
try {
|
|
97
|
+
const st = fs.statSync(abs);
|
|
98
|
+
// Include mtime in ms and size; sorting ensures stable ordering
|
|
99
|
+
entries.push(`${fp}:${st.mtimeMs}:${st.size}`);
|
|
100
|
+
} catch {
|
|
101
|
+
// File gone — treat as a change
|
|
102
|
+
entries.push(`${fp}:MISSING`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
entries.sort();
|
|
106
|
+
return crypto.createHash('sha256').update(entries.join('\n')).digest('hex');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate the stored content signature against current file state.
|
|
111
|
+
* Returns true when the cache is still fresh.
|
|
112
|
+
*
|
|
113
|
+
* @param {string[]} storedFilePaths - Relative file paths stored in analysis.components
|
|
114
|
+
* @param {string} storedSignature - Digest stored in the cache entry
|
|
115
|
+
* @param {string} rootPath - Project root
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function isCacheContentFresh(storedFilePaths, storedSignature, rootPath) {
|
|
119
|
+
if (!storedSignature || !Array.isArray(storedFilePaths)) return false;
|
|
120
|
+
const current = buildContentSignature(storedFilePaths, rootPath);
|
|
121
|
+
return current === storedSignature;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readCachedAnalysis(rootPath, key) {
|
|
125
|
+
const cacheDir = resolveCacheDir(rootPath);
|
|
126
|
+
const cachePath = path.join(cacheDir, `${key}.json`);
|
|
127
|
+
if (!fs.existsSync(cachePath)) {
|
|
128
|
+
return { hit: false, reason: 'cache_miss', data: null, cachePath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
133
|
+
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION) {
|
|
134
|
+
return { hit: false, reason: 'schema_mismatch', data: null, cachePath };
|
|
135
|
+
}
|
|
136
|
+
if (!parsed.analysis || !parsed.savedAt) {
|
|
137
|
+
return { hit: false, reason: 'invalid_cache_payload', data: null, cachePath };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const storedFilePaths = (parsed.analysis.components || []).map((c) => c.filePath);
|
|
141
|
+
|
|
142
|
+
// Step 1: Directory-mtime check — detects file additions and deletions.
|
|
143
|
+
// At write time we snapshot each parent directory's mtime+nlink. Any new
|
|
144
|
+
// or deleted file changes its parent directory, so this hash will differ.
|
|
145
|
+
const storedDirSig = parsed.directoryMtimeSignature;
|
|
146
|
+
if (!storedDirSig) {
|
|
147
|
+
// Old cache entry — force a refresh to pick up the new signature.
|
|
148
|
+
return { hit: false, reason: 'no_directory_mtime_signature', data: null, cachePath };
|
|
149
|
+
}
|
|
150
|
+
const currentDirSig = buildDirectoryMtimeSignature(storedFilePaths, rootPath);
|
|
151
|
+
if (currentDirSig !== storedDirSig) {
|
|
152
|
+
return { hit: false, reason: 'directory_changed', data: null, cachePath };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Step 2: Content check — detects modifications to existing files.
|
|
156
|
+
const storedSignature = parsed.contentSignature;
|
|
157
|
+
if (storedSignature) {
|
|
158
|
+
if (!isCacheContentFresh(storedFilePaths, storedSignature, rootPath)) {
|
|
159
|
+
return { hit: false, reason: 'content_changed', data: null, cachePath };
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
return { hit: false, reason: 'no_content_signature', data: null, cachePath };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
hit: true,
|
|
167
|
+
reason: 'cache_hit',
|
|
168
|
+
data: parsed.analysis,
|
|
169
|
+
cachePath,
|
|
170
|
+
savedAt: parsed.savedAt,
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { hit: false, reason: 'cache_parse_error', data: null, cachePath, error: error.message };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function writeCachedAnalysis(rootPath, key, analysis) {
|
|
178
|
+
const cacheDir = resolveCacheDir(rootPath);
|
|
179
|
+
const cachePath = path.join(cacheDir, `${key}.json`);
|
|
180
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
181
|
+
|
|
182
|
+
const filePaths = (analysis.components || []).map((c) => c.filePath);
|
|
183
|
+
// directoryMtimeSignature: detects new/deleted files by watching parent dir mtimes.
|
|
184
|
+
const directoryMtimeSignature = buildDirectoryMtimeSignature(filePaths, rootPath);
|
|
185
|
+
// contentSignature: detects modifications to the existing file set.
|
|
186
|
+
const contentSignature = buildContentSignature(filePaths, rootPath);
|
|
187
|
+
|
|
188
|
+
fs.writeFileSync(
|
|
189
|
+
cachePath,
|
|
190
|
+
`${JSON.stringify({
|
|
191
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
192
|
+
savedAt: new Date().toISOString(),
|
|
193
|
+
directoryMtimeSignature,
|
|
194
|
+
contentSignature,
|
|
195
|
+
analysis,
|
|
196
|
+
}, null, 2)}\n`
|
|
197
|
+
);
|
|
198
|
+
return cachePath;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
CACHE_SCHEMA_VERSION,
|
|
203
|
+
buildCacheKey,
|
|
204
|
+
buildDirectoryMtimeSignature,
|
|
205
|
+
buildContentSignature,
|
|
206
|
+
isCacheContentFresh,
|
|
207
|
+
readCachedAnalysis,
|
|
208
|
+
writeCachedAnalysis,
|
|
209
|
+
resolveCacheDir,
|
|
210
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const IR_SCHEMA_VERSION = '1.0';
|
|
5
|
+
|
|
6
|
+
function toArchitectureIR(analysisResult, metadata = {}) {
|
|
7
|
+
const components = Array.isArray(analysisResult?.components)
|
|
8
|
+
? analysisResult.components.map((component) => ({
|
|
9
|
+
name: component.name,
|
|
10
|
+
originalName: component.originalName,
|
|
11
|
+
filePath: component.filePath,
|
|
12
|
+
type: component.type,
|
|
13
|
+
roleTags: Array.isArray(component.roleTags) ? [...component.roleTags].sort() : [],
|
|
14
|
+
dependencies: Array.isArray(component.dependencies) ? [...component.dependencies].sort() : [],
|
|
15
|
+
}))
|
|
16
|
+
: [];
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
schemaVersion: IR_SCHEMA_VERSION,
|
|
20
|
+
generatedAt: new Date().toISOString(),
|
|
21
|
+
rootPath: analysisResult?.rootPath || metadata.rootPath || '',
|
|
22
|
+
analyzer: metadata.analyzer || { name: 'default', version: 'unknown' },
|
|
23
|
+
summary: {
|
|
24
|
+
componentCount: components.length,
|
|
25
|
+
languageCount: Object.keys(analysisResult?.languages || {}).length,
|
|
26
|
+
entryPointCount: Array.isArray(analysisResult?.entryPoints) ? analysisResult.entryPoints.length : 0,
|
|
27
|
+
},
|
|
28
|
+
languages: analysisResult?.languages || {},
|
|
29
|
+
entryPoints: analysisResult?.entryPoints || [],
|
|
30
|
+
components,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeArchitectureIR(rootPath, ir, explicitPath) {
|
|
35
|
+
const destination = explicitPath
|
|
36
|
+
? path.resolve(explicitPath)
|
|
37
|
+
: path.join(rootPath, '.diagram', 'ir', 'architecture-ir.json');
|
|
38
|
+
|
|
39
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
40
|
+
fs.writeFileSync(destination, `${JSON.stringify(ir, null, 2)}\n`);
|
|
41
|
+
return destination;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
IR_SCHEMA_VERSION,
|
|
46
|
+
toArchitectureIR,
|
|
47
|
+
writeArchitectureIR,
|
|
48
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const RC_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$/;
|
|
4
|
+
const RELEASE_ID_PATTERN = /^(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$/;
|
|
5
|
+
const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
|
6
|
+
const MINIMUM_WINDOW_DAYS = 30;
|
|
7
|
+
const MINIMUM_RELEASE_CANDIDATES = 2;
|
|
8
|
+
const DEFAULT_EVIDENCE_REFS = [
|
|
9
|
+
{
|
|
10
|
+
gate: 'dual_command_identity_available',
|
|
11
|
+
evidence: 'test/command-identity.test.js',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
gate: 'compatibility_path_regression_passed',
|
|
15
|
+
evidence: 'scripts/validate-archscope-readiness.js compatibilityDrill',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
gate: 'machine_command_coverage_manifest_valid',
|
|
19
|
+
evidence: 'scripts/validate-machine-contracts.js',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
gate: 'machine_envelope_conformance_passed',
|
|
23
|
+
evidence: 'test/workflow-pr-machine-envelope.test.js',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
gate: 'migration_evidence_hash_valid',
|
|
27
|
+
evidence: 'scripts/validate-migration-artifacts.js',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
gate: 'append_only_ledger_valid',
|
|
31
|
+
evidence: 'scripts/validate-migration-artifacts.js',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
gate: 'rollback_drill_passed',
|
|
35
|
+
evidence: 'scripts/validate-archscope-readiness.js compatibilityDrill',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function sortObjectDeep(value) {
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.map(sortObjectDeep);
|
|
42
|
+
}
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
const sorted = {};
|
|
45
|
+
for (const key of Object.keys(value).sort()) {
|
|
46
|
+
sorted[key] = sortObjectDeep(value[key]);
|
|
47
|
+
}
|
|
48
|
+
return sorted;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function canonicalize(value) {
|
|
54
|
+
return JSON.stringify(sortObjectDeep(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function withoutContentHash(record) {
|
|
58
|
+
const clone = JSON.parse(JSON.stringify(record));
|
|
59
|
+
delete clone.contentHash;
|
|
60
|
+
return clone;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function computeContentHash(record) {
|
|
64
|
+
return crypto
|
|
65
|
+
.createHash('sha256')
|
|
66
|
+
.update(canonicalize(withoutContentHash(record)))
|
|
67
|
+
.digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function attachContentHash(record) {
|
|
71
|
+
return {
|
|
72
|
+
...record,
|
|
73
|
+
contentHash: computeContentHash(record),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseRcTag(tag) {
|
|
78
|
+
const match = RC_TAG_PATTERN.exec(String(tag || ''));
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const [, major, minor, patch, rc] = match;
|
|
81
|
+
return {
|
|
82
|
+
tag,
|
|
83
|
+
baseVersion: `${major}.${minor}.${patch}`,
|
|
84
|
+
rcNumber: Number.parseInt(rc, 10),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function releaseIdToTag(releaseId) {
|
|
89
|
+
if (!RELEASE_ID_PATTERN.test(String(releaseId || ''))) {
|
|
90
|
+
throw new Error(`Invalid releaseId: ${releaseId}`);
|
|
91
|
+
}
|
|
92
|
+
return `v${releaseId}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function daysBetweenUtc(startUtc, endUtc) {
|
|
96
|
+
if (!UTC_TIMESTAMP_PATTERN.test(String(startUtc || '')) || !UTC_TIMESTAMP_PATTERN.test(String(endUtc || ''))) {
|
|
97
|
+
throw new Error('compatibility window timestamps must be UTC ISO-8601 strings ending in Z');
|
|
98
|
+
}
|
|
99
|
+
const start = Date.parse(startUtc);
|
|
100
|
+
const end = Date.parse(endUtc);
|
|
101
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
102
|
+
throw new Error('compatibility window timestamps must be valid UTC date strings');
|
|
103
|
+
}
|
|
104
|
+
return Math.floor((end - start) / (24 * 60 * 60 * 1000));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function evaluateRcSequence(releaseCandidateTags, baseVersion) {
|
|
108
|
+
const parsed = releaseCandidateTags
|
|
109
|
+
.map(parseRcTag)
|
|
110
|
+
.filter((entry) => entry && (!baseVersion || entry.baseVersion === baseVersion))
|
|
111
|
+
.sort((a, b) => a.rcNumber - b.rcNumber);
|
|
112
|
+
const rcNumbers = parsed.map((entry) => entry.rcNumber);
|
|
113
|
+
const consecutive = rcNumbers.every((number, index) => (
|
|
114
|
+
index === 0 || number === rcNumbers[index - 1] + 1
|
|
115
|
+
));
|
|
116
|
+
return {
|
|
117
|
+
baseVersion,
|
|
118
|
+
releaseCandidateTags: parsed.map((entry) => entry.tag),
|
|
119
|
+
releaseCandidateCount: parsed.length,
|
|
120
|
+
rcNumbers,
|
|
121
|
+
consecutive,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function evaluateMigrationWindow({
|
|
126
|
+
releaseId,
|
|
127
|
+
compatibilityDeclaredAtUtc,
|
|
128
|
+
evaluatedAtUtc,
|
|
129
|
+
releaseCandidateTags,
|
|
130
|
+
minimumWindowDays = MINIMUM_WINDOW_DAYS,
|
|
131
|
+
minimumReleaseCandidates = MINIMUM_RELEASE_CANDIDATES,
|
|
132
|
+
}) {
|
|
133
|
+
const releaseTag = releaseIdToTag(releaseId);
|
|
134
|
+
const releaseRc = parseRcTag(releaseTag);
|
|
135
|
+
const daysElapsed = daysBetweenUtc(compatibilityDeclaredAtUtc, evaluatedAtUtc);
|
|
136
|
+
const rcSequence = evaluateRcSequence(releaseCandidateTags, releaseRc.baseVersion);
|
|
137
|
+
const rcSatisfied = rcSequence.consecutive && rcSequence.releaseCandidateCount >= minimumReleaseCandidates;
|
|
138
|
+
const dayWindowSatisfied = daysElapsed >= minimumWindowDays;
|
|
139
|
+
const satisfied = rcSatisfied && dayWindowSatisfied;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
compatibilityDeclaredAtUtc,
|
|
143
|
+
evaluatedAtUtc,
|
|
144
|
+
minimumWindowDays,
|
|
145
|
+
minimumReleaseCandidates,
|
|
146
|
+
daysElapsed,
|
|
147
|
+
windowSatisfiedAtUtc: dayWindowSatisfied ? evaluatedAtUtc : null,
|
|
148
|
+
rcSequence,
|
|
149
|
+
satisfied,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildMigrationReadinessRecord({
|
|
154
|
+
releaseId,
|
|
155
|
+
sourceCommit,
|
|
156
|
+
compatibilityDeclaredAtUtc,
|
|
157
|
+
releaseCandidateTags,
|
|
158
|
+
generatedAtUtc = new Date().toISOString(),
|
|
159
|
+
}) {
|
|
160
|
+
const releaseTag = releaseIdToTag(releaseId);
|
|
161
|
+
const compatibilityWindow = evaluateMigrationWindow({
|
|
162
|
+
releaseId,
|
|
163
|
+
compatibilityDeclaredAtUtc,
|
|
164
|
+
evaluatedAtUtc: generatedAtUtc,
|
|
165
|
+
releaseCandidateTags,
|
|
166
|
+
});
|
|
167
|
+
const record = {
|
|
168
|
+
schemaVersion: '1.0',
|
|
169
|
+
releaseId,
|
|
170
|
+
releaseTag,
|
|
171
|
+
sourceCommit,
|
|
172
|
+
generatedAtUtc,
|
|
173
|
+
evaluatedAt: generatedAtUtc,
|
|
174
|
+
migrationState: 'compatibility',
|
|
175
|
+
status: compatibilityWindow.satisfied ? 'eligible' : 'blocked',
|
|
176
|
+
finalizationEligible: compatibilityWindow.satisfied,
|
|
177
|
+
criteria: {
|
|
178
|
+
minimumWindowDays: compatibilityWindow.minimumWindowDays,
|
|
179
|
+
minimumReleaseCandidates: compatibilityWindow.minimumReleaseCandidates,
|
|
180
|
+
releaseCandidateCount: compatibilityWindow.rcSequence.releaseCandidateCount,
|
|
181
|
+
releaseCandidatesConsecutive: compatibilityWindow.rcSequence.consecutive,
|
|
182
|
+
daysElapsed: compatibilityWindow.daysElapsed,
|
|
183
|
+
satisfied: compatibilityWindow.satisfied,
|
|
184
|
+
},
|
|
185
|
+
evidenceRefs: DEFAULT_EVIDENCE_REFS,
|
|
186
|
+
approvals: [],
|
|
187
|
+
compatibilityWindow,
|
|
188
|
+
checks: {
|
|
189
|
+
immutableRecord: true,
|
|
190
|
+
contentHashAlgorithm: 'sha256-canonical-json-without-contentHash',
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
return attachContentHash(record);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateMigrationReadinessRecord(record) {
|
|
197
|
+
const errors = [];
|
|
198
|
+
if (record?.schemaVersion !== '1.0') errors.push('schemaVersion must be 1.0');
|
|
199
|
+
if (!RELEASE_ID_PATTERN.test(String(record?.releaseId || ''))) errors.push('releaseId must match X.Y.Z-rc.N');
|
|
200
|
+
if (record?.releaseTag !== `v${record?.releaseId}`) errors.push('releaseTag must equal v<releaseId>');
|
|
201
|
+
if (!record?.sourceCommit) errors.push('sourceCommit is required');
|
|
202
|
+
if (!UTC_TIMESTAMP_PATTERN.test(String(record?.evaluatedAt || ''))) errors.push('evaluatedAt must be a UTC ISO-8601 string ending in Z');
|
|
203
|
+
if (record?.migrationState !== 'compatibility') errors.push('migrationState must be compatibility');
|
|
204
|
+
if (!['eligible', 'blocked'].includes(record?.status)) errors.push('status must be eligible or blocked');
|
|
205
|
+
if (!Array.isArray(record?.evidenceRefs)) errors.push('evidenceRefs must be an array');
|
|
206
|
+
if (!Array.isArray(record?.approvals)) errors.push('approvals must be an array');
|
|
207
|
+
if (record?.contentHash !== computeContentHash(record || {})) errors.push('contentHash mismatch');
|
|
208
|
+
const window = record?.compatibilityWindow || {};
|
|
209
|
+
if (window.minimumWindowDays !== MINIMUM_WINDOW_DAYS) errors.push('minimumWindowDays must equal 30');
|
|
210
|
+
if (window.minimumReleaseCandidates !== MINIMUM_RELEASE_CANDIDATES) errors.push('minimumReleaseCandidates must equal 2');
|
|
211
|
+
if (!window.rcSequence?.consecutive) errors.push('release candidates must be consecutive');
|
|
212
|
+
if ((window.rcSequence?.releaseCandidateCount || 0) < MINIMUM_RELEASE_CANDIDATES) {
|
|
213
|
+
errors.push('releaseCandidateCount must meet minimumReleaseCandidates');
|
|
214
|
+
}
|
|
215
|
+
if ((window.daysElapsed || 0) < MINIMUM_WINDOW_DAYS) {
|
|
216
|
+
errors.push('daysElapsed must meet minimumWindowDays');
|
|
217
|
+
}
|
|
218
|
+
const expectedSatisfied = window.rcSequence?.consecutive === true
|
|
219
|
+
&& (window.rcSequence?.releaseCandidateCount || 0) >= MINIMUM_RELEASE_CANDIDATES
|
|
220
|
+
&& (window.daysElapsed || 0) >= MINIMUM_WINDOW_DAYS;
|
|
221
|
+
if (window.satisfied !== expectedSatisfied) {
|
|
222
|
+
errors.push('compatibilityWindow.satisfied does not match derived eligibility');
|
|
223
|
+
}
|
|
224
|
+
if (record?.status !== (expectedSatisfied ? 'eligible' : 'blocked')) {
|
|
225
|
+
errors.push('status does not match compatibilityWindow.satisfied');
|
|
226
|
+
}
|
|
227
|
+
if (record?.finalizationEligible !== expectedSatisfied) {
|
|
228
|
+
errors.push('finalizationEligible does not match compatibilityWindow.satisfied');
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
valid: errors.length === 0,
|
|
232
|
+
errors,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function validateLedgerAppendOnly(previousLedger, nextLedger) {
|
|
237
|
+
const previous = Array.isArray(previousLedger) ? previousLedger : [];
|
|
238
|
+
const next = Array.isArray(nextLedger) ? nextLedger : [];
|
|
239
|
+
if (next.length < previous.length) {
|
|
240
|
+
return { valid: false, errors: ['ledger cannot shrink'] };
|
|
241
|
+
}
|
|
242
|
+
for (let index = 0; index < previous.length; index += 1) {
|
|
243
|
+
if (canonicalize(previous[index]) !== canonicalize(next[index])) {
|
|
244
|
+
return { valid: false, errors: [`ledger entry changed at index ${index}`] };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { valid: true, errors: [] };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
MINIMUM_RELEASE_CANDIDATES,
|
|
252
|
+
MINIMUM_WINDOW_DAYS,
|
|
253
|
+
DEFAULT_EVIDENCE_REFS,
|
|
254
|
+
attachContentHash,
|
|
255
|
+
buildMigrationReadinessRecord,
|
|
256
|
+
computeContentHash,
|
|
257
|
+
evaluateMigrationWindow,
|
|
258
|
+
parseRcTag,
|
|
259
|
+
releaseIdToTag,
|
|
260
|
+
validateLedgerAppendOnly,
|
|
261
|
+
validateMigrationReadinessRecord,
|
|
262
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const REQUIRED_GATES = [
|
|
2
|
+
'dual_command_identity_available',
|
|
3
|
+
'compatibility_path_regression_passed',
|
|
4
|
+
'machine_command_coverage_manifest_valid',
|
|
5
|
+
'machine_envelope_conformance_passed',
|
|
6
|
+
'migration_evidence_hash_valid',
|
|
7
|
+
'append_only_ledger_valid',
|
|
8
|
+
'rollback_drill_passed',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function validateFinalizationPolicy(policy) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
if (policy?.schemaVersion !== '1.0') errors.push('schemaVersion must be 1.0');
|
|
14
|
+
if (policy?.migrationState !== 'compatibility') errors.push('migrationState must be compatibility');
|
|
15
|
+
if (policy?.effectiveFromState !== 'compatibility') errors.push('effectiveFromState must be compatibility');
|
|
16
|
+
if (policy?.targetState !== 'finalized') errors.push('targetState must be finalized');
|
|
17
|
+
if (policy?.minimumWindow?.releaseCandidates !== 2) errors.push('minimumWindow.releaseCandidates must equal 2');
|
|
18
|
+
if (policy?.minimumWindow?.days !== 30) errors.push('minimumWindow.days must equal 30');
|
|
19
|
+
if (policy?.minimumWindow?.clock !== 'UTC') errors.push('minimumWindow.clock must be UTC');
|
|
20
|
+
const gates = Array.isArray(policy?.gatingCriteria) ? policy.gatingCriteria : [];
|
|
21
|
+
for (const gate of REQUIRED_GATES) {
|
|
22
|
+
if (!gates.includes(gate)) {
|
|
23
|
+
errors.push(`missing required gate: ${gate}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
valid: errors.length === 0,
|
|
28
|
+
errors,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
REQUIRED_GATES,
|
|
34
|
+
validateFinalizationPolicy,
|
|
35
|
+
};
|