@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.
Files changed (91) hide show
  1. package/.diagram/contracts/machine-command-coverage.json +73 -0
  2. package/.diagram/migration/finalization-policy.json +20 -0
  3. package/LICENSE +202 -21
  4. package/README.md +132 -339
  5. package/package.json +46 -13
  6. package/scripts/refresh-diagram-context.sh +274 -182
  7. package/src/analyzers/default-analyzer.js +11 -0
  8. package/src/analyzers/index.js +34 -0
  9. package/src/artifacts/agent-context.js +105 -0
  10. package/src/artifacts/artifact-budget.js +224 -0
  11. package/src/artifacts/brief.js +153 -0
  12. package/src/artifacts/evidence-manifest.js +206 -0
  13. package/src/artifacts/evidence-summary.js +29 -0
  14. package/src/commands/analyze.js +125 -0
  15. package/src/commands/changed.js +185 -0
  16. package/src/commands/context.js +110 -0
  17. package/src/commands/diff.js +142 -0
  18. package/src/commands/doctor.js +335 -0
  19. package/src/commands/explain.js +273 -0
  20. package/src/commands/generate-all.js +170 -0
  21. package/src/commands/generate-animated.js +50 -0
  22. package/src/commands/generate-video.js +65 -0
  23. package/src/commands/generate.js +522 -0
  24. package/src/commands/init.js +123 -0
  25. package/src/commands/output.js +76 -0
  26. package/src/commands/scan.js +624 -0
  27. package/src/commands/shared.js +396 -0
  28. package/src/commands/validate.js +328 -0
  29. package/src/commands/video-shared.js +105 -0
  30. package/src/commands/workflow-pr.js +26 -0
  31. package/src/confidence/pipeline.js +186 -0
  32. package/src/config/diagramrc.js +79 -0
  33. package/src/context/build-context-pack.js +291 -0
  34. package/src/context/normalize-diagram-manifest.js +282 -0
  35. package/src/core/analysis-generation-analyze-components.js +102 -0
  36. package/src/core/analysis-generation-analyze-dependencies.js +33 -0
  37. package/src/core/analysis-generation-analyze-files.js +48 -0
  38. package/src/core/analysis-generation-analyze-options.js +73 -0
  39. package/src/core/analysis-generation-analyze.js +63 -0
  40. package/src/core/analysis-generation-constants.js +53 -0
  41. package/src/core/analysis-generation-diagrams-core-architecture.js +105 -0
  42. package/src/core/analysis-generation-diagrams-core-dependency.js +68 -0
  43. package/src/core/analysis-generation-diagrams-core-sequence.js +142 -0
  44. package/src/core/analysis-generation-diagrams-core-shapes.js +104 -0
  45. package/src/core/analysis-generation-diagrams-core.js +12 -0
  46. package/src/core/analysis-generation-diagrams-empty.js +68 -0
  47. package/src/core/analysis-generation-diagrams-erd.js +59 -0
  48. package/src/core/analysis-generation-diagrams-limit.js +27 -0
  49. package/src/core/analysis-generation-diagrams-role-ai-agent.js +103 -0
  50. package/src/core/analysis-generation-diagrams-role-ai-context.js +186 -0
  51. package/src/core/analysis-generation-diagrams-role-ai.js +11 -0
  52. package/src/core/analysis-generation-diagrams-role-data.js +182 -0
  53. package/src/core/analysis-generation-diagrams-role-helpers.js +129 -0
  54. package/src/core/analysis-generation-diagrams-role-security.js +129 -0
  55. package/src/core/analysis-generation-diagrams-role.js +25 -0
  56. package/src/core/analysis-generation-diagrams.js +182 -0
  57. package/src/core/analysis-generation-role-tags-constants.js +55 -0
  58. package/src/core/analysis-generation-role-tags-imports.js +32 -0
  59. package/src/core/analysis-generation-role-tags-infer.js +49 -0
  60. package/src/core/analysis-generation-role-tags-match.js +19 -0
  61. package/src/core/analysis-generation-role-tags.js +7 -0
  62. package/src/core/analysis-generation-utils-core.js +308 -0
  63. package/src/core/analysis-generation-utils-graph.js +321 -0
  64. package/src/core/analysis-generation-utils-resolution.js +76 -0
  65. package/src/core/analysis-generation-utils.js +9 -0
  66. package/src/core/analysis-generation.js +44 -0
  67. package/src/diagram.js +180 -1760
  68. package/src/formatters/console.js +198 -0
  69. package/src/formatters/index.js +41 -0
  70. package/src/formatters/json.js +113 -0
  71. package/src/formatters/junit.js +123 -0
  72. package/src/graph.js +159 -0
  73. package/src/incremental/cache.js +210 -0
  74. package/src/ir/architecture-ir.js +48 -0
  75. package/src/migration/evidence.js +262 -0
  76. package/src/migration/finalization-policy.js +35 -0
  77. package/src/renderers/report-html.js +265 -0
  78. package/src/rules/factory.js +108 -0
  79. package/src/rules/types/base.js +54 -0
  80. package/src/rules/types/import-rule.js +286 -0
  81. package/src/rules.js +380 -0
  82. package/src/schema/erd-confidence.js +56 -0
  83. package/src/schema/erd-extractor.js +504 -0
  84. package/src/schema/erd-model.js +176 -0
  85. package/src/schema/rules-schema.js +170 -0
  86. package/src/utils/suggestions.js +67 -0
  87. package/src/video.js +4 -5
  88. package/src/workflow/git-helpers.js +576 -0
  89. package/src/workflow/pr-command.js +694 -0
  90. package/src/workflow/pr-impact.js +848 -0
  91. 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
+ };