@brainwav/diagram 1.0.8 → 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 +178 -1761
- 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,79 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
|
|
5
|
+
const DIAGRAMRC_FILENAME = '.diagramrc';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Zod schema for .diagramrc config file.
|
|
9
|
+
* All fields are optional — the file does not need to exist.
|
|
10
|
+
*/
|
|
11
|
+
const DiagramRcSchema = z.object({
|
|
12
|
+
/** Glob patterns or directory names to ignore during analysis */
|
|
13
|
+
ignore: z.array(z.string()).optional(),
|
|
14
|
+
/** Comma-separated file glob patterns (same as --patterns CLI flag) */
|
|
15
|
+
patterns: z.string().optional(),
|
|
16
|
+
/** Comma-separated file globs to exclude (same as --exclude CLI flag) */
|
|
17
|
+
exclude: z.string().optional(),
|
|
18
|
+
/** Maximum files to analyze (1–10000, same as --max-files CLI flag) */
|
|
19
|
+
maxFiles: z.number().int().positive().max(10000).optional(),
|
|
20
|
+
/** Default theme for diagram output */
|
|
21
|
+
theme: z.enum(['default', 'dark', 'forest', 'neutral', 'light']).optional(),
|
|
22
|
+
}).strict();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load and validate .diagramrc from the project root.
|
|
26
|
+
*
|
|
27
|
+
* Returns the parsed config object if found and valid, or an empty object
|
|
28
|
+
* if the file doesn't exist. Exits the process with code 2 if the file
|
|
29
|
+
* exists but is invalid JSON or fails schema validation.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} rootPath - Project root directory
|
|
32
|
+
* @param {{ quiet?: boolean }} opts
|
|
33
|
+
* @returns {z.infer<typeof DiagramRcSchema>}
|
|
34
|
+
*/
|
|
35
|
+
function loadDiagramRc(rootPath, opts = {}) {
|
|
36
|
+
const rcPath = path.join(rootPath, DIAGRAMRC_FILENAME);
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(rcPath)) {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = fs.readFileSync(rcPath, 'utf8');
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`\u274c Failed to read ${DIAGRAMRC_FILENAME}: ${err.message}`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`\u274c ${DIAGRAMRC_FILENAME} is not valid JSON: ${err.message}`);
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = DiagramRcSchema.safeParse(parsed);
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
const issues = result.error.issues
|
|
61
|
+
.map((issue) => ` • ${issue.path.join('.')||'(root)'}: ${issue.message}`)
|
|
62
|
+
.join('\n');
|
|
63
|
+
console.error(`\u274c Invalid ${DIAGRAMRC_FILENAME}:\n${issues}`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!opts.quiet) {
|
|
68
|
+
// Only log the load notice in DEBUG mode — don't clutter normal output
|
|
69
|
+
if (process.env.DEBUG) {
|
|
70
|
+
console.log(`\u2139\ufe0f Loaded ${DIAGRAMRC_FILENAME} from ${rcPath}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result.data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
loadDiagramRc,
|
|
79
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
const { readFileSync, writeFileSync } = require('node:fs');
|
|
2
|
+
const { join } = require('node:path');
|
|
3
|
+
const {
|
|
4
|
+
AGENT_DIAGRAM_PRIORITY,
|
|
5
|
+
estimateTokensFromBytes,
|
|
6
|
+
sortByPriority,
|
|
7
|
+
} = require('../artifacts/artifact-budget');
|
|
8
|
+
const { FIXED_DETERMINISTIC_TIMESTAMP } = require('../artifacts/evidence-manifest');
|
|
9
|
+
|
|
10
|
+
function parsePositiveInt(value, fallback) {
|
|
11
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
12
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function trimToMaxLines(content, maxLines) {
|
|
19
|
+
const lines = String(content || '').trimEnd().split(/\r?\n/);
|
|
20
|
+
if (lines.length <= maxLines) {
|
|
21
|
+
return { text: lines.join('\n'), truncated: false, omittedLines: 0 };
|
|
22
|
+
}
|
|
23
|
+
const kept = lines.slice(0, maxLines);
|
|
24
|
+
return {
|
|
25
|
+
text: kept.join('\n'),
|
|
26
|
+
truncated: true,
|
|
27
|
+
omittedLines: lines.length - maxLines,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildOmittedSection(omittedTypes, compact = false) {
|
|
32
|
+
if (!Array.isArray(omittedTypes) || omittedTypes.length === 0) {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (compact) {
|
|
37
|
+
return [
|
|
38
|
+
'## Omitted Diagrams',
|
|
39
|
+
'',
|
|
40
|
+
`Omitted from embedding due to budget/profile constraints: ${omittedTypes.length} diagram(s).`,
|
|
41
|
+
'',
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
'## Omitted Diagrams',
|
|
47
|
+
'',
|
|
48
|
+
`Omitted from embedding due to budget/profile constraints: ${omittedTypes.join(', ')}`,
|
|
49
|
+
'Use `.diagram/*.mmd` files directly for full-fidelity diagram content.',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildHeaderText({
|
|
55
|
+
sortedDiagrams,
|
|
56
|
+
contextMaxBytes,
|
|
57
|
+
contextMaxEmbeddedDiagrams,
|
|
58
|
+
contextMaxLinesPerDiagram,
|
|
59
|
+
}) {
|
|
60
|
+
const staticPrefix = [
|
|
61
|
+
'# Diagram Context Pack',
|
|
62
|
+
'',
|
|
63
|
+
'Machine-oriented context for agents. This pack is intentionally compact and token-budgeted.',
|
|
64
|
+
'',
|
|
65
|
+
`Context byte budget: ${contextMaxBytes}`,
|
|
66
|
+
`Max embedded diagrams: ${contextMaxEmbeddedDiagrams}`,
|
|
67
|
+
`Max lines per embedded diagram: ${contextMaxLinesPerDiagram}`,
|
|
68
|
+
'',
|
|
69
|
+
'## Diagram Index',
|
|
70
|
+
'',
|
|
71
|
+
'| Type | File | Bytes | Lines | Placeholder | Approx Tokens |',
|
|
72
|
+
'| --- | --- | ---: | ---: | --- | ---: |',
|
|
73
|
+
];
|
|
74
|
+
const staticSuffix = ['', '## Embedded Mermaid (Budgeted)', '', ''];
|
|
75
|
+
const indexRows = [];
|
|
76
|
+
|
|
77
|
+
const buildCandidate = (rows, summaryLine = '') => {
|
|
78
|
+
const summary = summaryLine ? ['', summaryLine] : [];
|
|
79
|
+
return `${staticPrefix.concat(rows, summary, staticSuffix).join('\n')}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const entry of sortedDiagrams) {
|
|
83
|
+
const row = `| ${entry.type} | ${entry.file} | ${entry.bytes} | ${entry.lines} | ${entry.isPlaceholder ? 'yes' : 'no'} | ${estimateTokensFromBytes(entry.bytes || 0)} |`;
|
|
84
|
+
const candidateWithRow = buildCandidate(indexRows.concat(row));
|
|
85
|
+
if (Buffer.byteLength(candidateWithRow, 'utf8') > contextMaxBytes) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
indexRows.push(row);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const compacted = indexRows.length < sortedDiagrams.length;
|
|
92
|
+
const compactSummary = compacted
|
|
93
|
+
? `Diagram index compacted to ${indexRows.length}/${sortedDiagrams.length} row(s) to fit budget.`
|
|
94
|
+
: '';
|
|
95
|
+
const candidate = buildCandidate(indexRows, compactSummary);
|
|
96
|
+
if (Buffer.byteLength(candidate, 'utf8') <= contextMaxBytes) {
|
|
97
|
+
return {
|
|
98
|
+
text: candidate,
|
|
99
|
+
indexRowsIncluded: indexRows.length,
|
|
100
|
+
headerCompacted: compacted,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const minimal = [
|
|
105
|
+
'# Diagram Context Pack',
|
|
106
|
+
'',
|
|
107
|
+
`Compact header due to strict budget (${contextMaxBytes} bytes).`,
|
|
108
|
+
`Diagrams available: ${sortedDiagrams.length}`,
|
|
109
|
+
`Max embedded diagrams: ${contextMaxEmbeddedDiagrams}`,
|
|
110
|
+
`Max lines per embedded diagram: ${contextMaxLinesPerDiagram}`,
|
|
111
|
+
'',
|
|
112
|
+
'## Embedded Mermaid (Budgeted)',
|
|
113
|
+
'',
|
|
114
|
+
].join('\n');
|
|
115
|
+
if (Buffer.byteLength(minimal, 'utf8') > contextMaxBytes) {
|
|
116
|
+
throw new Error(`Context byte budget (${contextMaxBytes}) is too small for header.`);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
text: minimal,
|
|
120
|
+
indexRowsIncluded: 0,
|
|
121
|
+
headerCompacted: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildContextPack({
|
|
126
|
+
rootDir,
|
|
127
|
+
tmpDir,
|
|
128
|
+
contextMaxBytes = 12000,
|
|
129
|
+
contextMaxLinesPerDiagram = 140,
|
|
130
|
+
contextMaxEmbeddedDiagrams = 3,
|
|
131
|
+
contextPath,
|
|
132
|
+
contextMetaPath,
|
|
133
|
+
deterministic = false,
|
|
134
|
+
}) {
|
|
135
|
+
if (!rootDir || !tmpDir || !contextPath) {
|
|
136
|
+
throw new Error('buildContextPack requires rootDir, tmpDir, and contextPath');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const diagramsDir = join(tmpDir, 'diagrams');
|
|
140
|
+
const manifestPath = join(diagramsDir, 'manifest.json');
|
|
141
|
+
const nowIso = deterministic ? FIXED_DETERMINISTIC_TIMESTAMP : new Date().toISOString();
|
|
142
|
+
|
|
143
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
144
|
+
const diagramEntries = Array.isArray(manifest.diagrams) ? manifest.diagrams : [];
|
|
145
|
+
const sortedDiagrams = sortByPriority(diagramEntries, AGENT_DIAGRAM_PRIORITY);
|
|
146
|
+
|
|
147
|
+
const header = buildHeaderText({
|
|
148
|
+
sortedDiagrams,
|
|
149
|
+
contextMaxBytes,
|
|
150
|
+
contextMaxEmbeddedDiagrams,
|
|
151
|
+
contextMaxLinesPerDiagram,
|
|
152
|
+
});
|
|
153
|
+
const headerText = header.text;
|
|
154
|
+
let contextText = headerText;
|
|
155
|
+
let embeddedCount = 0;
|
|
156
|
+
const omittedTypes = [];
|
|
157
|
+
const includedSections = [];
|
|
158
|
+
|
|
159
|
+
for (let index = 0; index < sortedDiagrams.length; index += 1) {
|
|
160
|
+
const entry = sortedDiagrams[index];
|
|
161
|
+
if (embeddedCount >= contextMaxEmbeddedDiagrams) {
|
|
162
|
+
omittedTypes.push(entry.type);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const diagramPath = join(diagramsDir, entry.file);
|
|
166
|
+
const rawContent = readFileSync(diagramPath, 'utf8');
|
|
167
|
+
const rawBytes = Buffer.byteLength(rawContent || '', 'utf8');
|
|
168
|
+
const { text: trimmedContent, truncated, omittedLines } = trimToMaxLines(
|
|
169
|
+
rawContent,
|
|
170
|
+
contextMaxLinesPerDiagram
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const section = [
|
|
174
|
+
`### ${entry.type}`,
|
|
175
|
+
'',
|
|
176
|
+
`Path: \`.diagram/${entry.file}\``,
|
|
177
|
+
`Approx tokens (full): ${estimateTokensFromBytes(rawBytes)}`,
|
|
178
|
+
...(truncated ? [`Note: truncated to ${contextMaxLinesPerDiagram} lines (${omittedLines} lines omitted).`] : []),
|
|
179
|
+
'',
|
|
180
|
+
'```mermaid',
|
|
181
|
+
trimmedContent,
|
|
182
|
+
'```',
|
|
183
|
+
'',
|
|
184
|
+
].join('\n');
|
|
185
|
+
|
|
186
|
+
const next = contextText + section;
|
|
187
|
+
if (Buffer.byteLength(next, 'utf8') > contextMaxBytes) {
|
|
188
|
+
omittedTypes.push(entry.type);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const remainingTypes = sortedDiagrams
|
|
193
|
+
.slice(index + 1)
|
|
194
|
+
.map((remaining) => remaining.type);
|
|
195
|
+
const reservedOmittedSection = buildOmittedSection(
|
|
196
|
+
omittedTypes.concat(remainingTypes)
|
|
197
|
+
);
|
|
198
|
+
if (Buffer.byteLength(next + reservedOmittedSection, 'utf8') > contextMaxBytes) {
|
|
199
|
+
omittedTypes.push(entry.type);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
contextText = next;
|
|
204
|
+
embeddedCount += 1;
|
|
205
|
+
includedSections.push({ type: entry.type, section });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let omittedSection = buildOmittedSection(omittedTypes);
|
|
209
|
+
while (
|
|
210
|
+
Buffer.byteLength(contextText + omittedSection, 'utf8') > contextMaxBytes
|
|
211
|
+
&& includedSections.length > 0
|
|
212
|
+
) {
|
|
213
|
+
const removed = includedSections.pop();
|
|
214
|
+
embeddedCount = Math.max(embeddedCount - 1, 0);
|
|
215
|
+
omittedTypes.push(removed.type);
|
|
216
|
+
contextText = headerText + includedSections.map((entry) => entry.section).join('');
|
|
217
|
+
omittedSection = buildOmittedSection(omittedTypes);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
Buffer.byteLength(contextText + omittedSection, 'utf8') > contextMaxBytes
|
|
222
|
+
&& omittedTypes.length > 0
|
|
223
|
+
) {
|
|
224
|
+
omittedSection = buildOmittedSection(omittedTypes, true);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (Buffer.byteLength(contextText + omittedSection, 'utf8') > contextMaxBytes) {
|
|
228
|
+
omittedSection = '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
contextText += omittedSection;
|
|
232
|
+
const result = {
|
|
233
|
+
generatedAt: nowIso,
|
|
234
|
+
embeddedCount,
|
|
235
|
+
omittedTypes: [...new Set(omittedTypes)].sort(),
|
|
236
|
+
bytes: Buffer.byteLength(contextText, 'utf8'),
|
|
237
|
+
headerCompacted: header.headerCompacted,
|
|
238
|
+
indexRowsIncluded: header.indexRowsIncluded,
|
|
239
|
+
};
|
|
240
|
+
if (!deterministic) {
|
|
241
|
+
result.rootPath = rootDir;
|
|
242
|
+
}
|
|
243
|
+
writeFileSync(contextPath, contextText);
|
|
244
|
+
if (contextMetaPath) {
|
|
245
|
+
writeFileSync(contextMetaPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function runFromEnv() {
|
|
251
|
+
const rootDir = process.env.ROOT_DIR;
|
|
252
|
+
const tmpDir = process.env.TMP_DIR;
|
|
253
|
+
const contextPath = process.env.CONTEXT_OUTPUT_PATH;
|
|
254
|
+
const contextMetaPath = process.env.CONTEXT_META_OUTPUT_PATH;
|
|
255
|
+
const contextMaxBytes = parsePositiveInt(process.env.CONTEXT_MAX_BYTES || '12000', 12000);
|
|
256
|
+
const contextMaxLinesPerDiagram = parsePositiveInt(
|
|
257
|
+
process.env.CONTEXT_MAX_LINES_PER_DIAGRAM || '140',
|
|
258
|
+
140
|
|
259
|
+
);
|
|
260
|
+
const contextMaxEmbeddedDiagrams = parsePositiveInt(
|
|
261
|
+
process.env.CONTEXT_MAX_EMBEDDED_DIAGRAMS || '3',
|
|
262
|
+
3
|
|
263
|
+
);
|
|
264
|
+
const deterministic = ['1', 'true', 'yes'].includes(
|
|
265
|
+
String(process.env.CONTEXT_DETERMINISTIC || '').toLowerCase()
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
buildContextPack({
|
|
269
|
+
rootDir,
|
|
270
|
+
tmpDir,
|
|
271
|
+
contextPath,
|
|
272
|
+
contextMetaPath,
|
|
273
|
+
contextMaxBytes,
|
|
274
|
+
contextMaxLinesPerDiagram,
|
|
275
|
+
contextMaxEmbeddedDiagrams,
|
|
276
|
+
deterministic,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (require.main === module) {
|
|
281
|
+
try {
|
|
282
|
+
runFromEnv();
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error(error.message || String(error));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
buildContextPack,
|
|
291
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const { createHash } = require('node:crypto');
|
|
2
|
+
const { readdirSync, readFileSync, writeFileSync } = require('node:fs');
|
|
3
|
+
const { join } = require('node:path');
|
|
4
|
+
const { estimateTokensFromBytes } = require('../artifacts/artifact-budget');
|
|
5
|
+
|
|
6
|
+
function ensureTrailingNewline(content) {
|
|
7
|
+
return content.endsWith('\n') ? content : `${content}\n`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stableId(prefix, value) {
|
|
11
|
+
const slug = String(value)
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
14
|
+
.replace(/^_+|_+$/g, '')
|
|
15
|
+
.slice(0, 48) || prefix;
|
|
16
|
+
const digest = createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
|
|
17
|
+
return `${prefix}_${slug}_${digest}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseArchitecture(content) {
|
|
21
|
+
const lines = String(content || '').trimEnd().split(/\r?\n/);
|
|
22
|
+
const subgraphs = [];
|
|
23
|
+
let currentSubgraph = null;
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const subgraphMatch = line.match(/^ subgraph (\S+)\["(.+)"\]$/);
|
|
27
|
+
if (subgraphMatch) {
|
|
28
|
+
currentSubgraph = {
|
|
29
|
+
rawId: subgraphMatch[1],
|
|
30
|
+
label: subgraphMatch[2],
|
|
31
|
+
nodes: [],
|
|
32
|
+
};
|
|
33
|
+
subgraphs.push(currentSubgraph);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (line === ' end') {
|
|
38
|
+
currentSubgraph = null;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nodeMatch = line.match(/^ (\S+)\["(.+)"\]$/);
|
|
43
|
+
if (nodeMatch && currentSubgraph) {
|
|
44
|
+
currentSubgraph.nodes.push({
|
|
45
|
+
rawId: nodeMatch[1],
|
|
46
|
+
label: nodeMatch[2],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return subgraphs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function firstMermaidDirective(content) {
|
|
55
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed || trimmed.startsWith('%%')) continue;
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isClassicFlowchartArchitecture(content) {
|
|
65
|
+
const firstDirective = firstMermaidDirective(content);
|
|
66
|
+
return firstDirective === 'graph TD' || firstDirective.startsWith('graph TD ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildArchitecture(subgraphs) {
|
|
70
|
+
const nodeMap = new Map();
|
|
71
|
+
const lines = ['graph TD'];
|
|
72
|
+
const sortedSubgraphs = [...subgraphs].sort((left, right) =>
|
|
73
|
+
left.label.localeCompare(right.label)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
for (const subgraph of sortedSubgraphs) {
|
|
77
|
+
const subgraphId = stableId('sg', subgraph.label);
|
|
78
|
+
lines.push(` subgraph ${subgraphId}["${subgraph.label}"]`);
|
|
79
|
+
const sortedNodes = [...subgraph.nodes].sort((left, right) =>
|
|
80
|
+
left.label.localeCompare(right.label)
|
|
81
|
+
);
|
|
82
|
+
for (const node of sortedNodes) {
|
|
83
|
+
const nodeId = stableId('node', `${subgraph.label}/${node.label}`);
|
|
84
|
+
nodeMap.set(node.rawId, { canonicalId: nodeId, label: node.label });
|
|
85
|
+
lines.push(` ${nodeId}["${node.label}"]`);
|
|
86
|
+
}
|
|
87
|
+
lines.push(' end');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
content: ensureTrailingNewline(lines.join('\n')),
|
|
92
|
+
nodeMap,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildDependency(content, nodeMap) {
|
|
97
|
+
const lines = String(content || '').trimEnd().split(/\r?\n/);
|
|
98
|
+
if (lines.length === 0) {
|
|
99
|
+
return ensureTrailingNewline(String(content || ''));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const externalNodeMap = new Map();
|
|
103
|
+
const dependencyEdges = [];
|
|
104
|
+
const styleEntries = [];
|
|
105
|
+
|
|
106
|
+
for (const line of lines.slice(1)) {
|
|
107
|
+
const edgeMatch = line.match(/^ (\S+)\["(.+)"\] --> (\S+)$/);
|
|
108
|
+
if (edgeMatch) {
|
|
109
|
+
const [, rawSourceId, sourceLabel, rawTargetId] = edgeMatch;
|
|
110
|
+
const target = nodeMap.get(rawTargetId) ?? {
|
|
111
|
+
canonicalId: stableId('node', rawTargetId),
|
|
112
|
+
label: rawTargetId,
|
|
113
|
+
};
|
|
114
|
+
const sourceCanonicalId =
|
|
115
|
+
externalNodeMap.get(rawSourceId) ?? stableId('ext', sourceLabel);
|
|
116
|
+
externalNodeMap.set(rawSourceId, sourceCanonicalId);
|
|
117
|
+
dependencyEdges.push({
|
|
118
|
+
line: ` ${sourceCanonicalId}["${sourceLabel}"] --> ${target.canonicalId}`,
|
|
119
|
+
sortKey: `${sourceLabel}::${target.label}`,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const styleMatch = line.match(/^ style (\S+) (.+)$/);
|
|
125
|
+
if (!styleMatch) continue;
|
|
126
|
+
const [, rawNodeId, styleSpec] = styleMatch;
|
|
127
|
+
const canonicalId = externalNodeMap.get(rawNodeId);
|
|
128
|
+
if (!canonicalId) continue;
|
|
129
|
+
styleEntries.push({
|
|
130
|
+
line: ` style ${canonicalId} ${styleSpec}`,
|
|
131
|
+
sortKey: canonicalId,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return ensureTrailingNewline([
|
|
136
|
+
'graph LR',
|
|
137
|
+
...dependencyEdges
|
|
138
|
+
.sort((left, right) => left.sortKey.localeCompare(right.sortKey))
|
|
139
|
+
.map((entry) => entry.line),
|
|
140
|
+
...styleEntries
|
|
141
|
+
.sort((left, right) => left.sortKey.localeCompare(right.sortKey))
|
|
142
|
+
.map((entry) => entry.line),
|
|
143
|
+
].join('\n'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isPlaceholderDiagram(content) {
|
|
147
|
+
const text = String(content || '');
|
|
148
|
+
return /placeholder/i.test(text)
|
|
149
|
+
|| /not enough/i.test(text)
|
|
150
|
+
|| /limited to/i.test(text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseNonNegativeInt(value) {
|
|
154
|
+
const parsed = Number(value);
|
|
155
|
+
if (!Number.isFinite(parsed)) return null;
|
|
156
|
+
if (parsed < 0) return null;
|
|
157
|
+
return Math.floor(parsed);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeDiagramManifest({ rootDir, tmpDir, manifestPath }) {
|
|
161
|
+
if (!rootDir || !tmpDir || !manifestPath) {
|
|
162
|
+
throw new Error('normalizeDiagramManifest requires rootDir, tmpDir, and manifestPath');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const diagramsDir = join(tmpDir, 'diagrams');
|
|
166
|
+
let sourceManifest = {};
|
|
167
|
+
try {
|
|
168
|
+
sourceManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new Error(`Failed to read diagram manifest at ${manifestPath}: ${error.message}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const diagramFiles = readdirSync(diagramsDir).filter((entry) => entry.endsWith('.mmd'));
|
|
174
|
+
const architecturePath = join(diagramsDir, 'architecture.mmd');
|
|
175
|
+
const dependencyPath = join(diagramsDir, 'dependency.mmd');
|
|
176
|
+
|
|
177
|
+
if (diagramFiles.includes('architecture.mmd')) {
|
|
178
|
+
const architectureContent = readFileSync(architecturePath, 'utf8');
|
|
179
|
+
if (!isClassicFlowchartArchitecture(architectureContent)) {
|
|
180
|
+
writeFileSync(architecturePath, ensureTrailingNewline(architectureContent.trimEnd()));
|
|
181
|
+
} else {
|
|
182
|
+
const parsedArchitecture = parseArchitecture(architectureContent);
|
|
183
|
+
const hasParsedNodes = parsedArchitecture.some((subgraph) =>
|
|
184
|
+
Array.isArray(subgraph.nodes) && subgraph.nodes.length > 0
|
|
185
|
+
);
|
|
186
|
+
if (!hasParsedNodes) {
|
|
187
|
+
throw new Error('Failed to normalize architecture.mmd: parsed structure was empty.');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { content: canonicalArchitecture, nodeMap } = buildArchitecture(parsedArchitecture);
|
|
191
|
+
if (!(nodeMap instanceof Map) || nodeMap.size === 0) {
|
|
192
|
+
throw new Error('Failed to normalize architecture.mmd: canonical node map was empty.');
|
|
193
|
+
}
|
|
194
|
+
writeFileSync(architecturePath, canonicalArchitecture);
|
|
195
|
+
|
|
196
|
+
if (diagramFiles.includes('dependency.mmd')) {
|
|
197
|
+
const dependencyContent = readFileSync(dependencyPath, 'utf8');
|
|
198
|
+
writeFileSync(dependencyPath, buildDependency(dependencyContent, nodeMap));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const file of diagramFiles) {
|
|
204
|
+
if (file === 'architecture.mmd' || file === 'dependency.mmd') {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const filePath = join(diagramsDir, file);
|
|
208
|
+
writeFileSync(filePath, ensureTrailingNewline(readFileSync(filePath, 'utf8').trimEnd()));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sourceDiagramEntries = Array.isArray(sourceManifest.diagrams)
|
|
212
|
+
? sourceManifest.diagrams.filter((entry) => entry && typeof entry === 'object')
|
|
213
|
+
: [];
|
|
214
|
+
const sourceByFile = new Map();
|
|
215
|
+
const sourceByType = new Map();
|
|
216
|
+
for (const entry of sourceDiagramEntries) {
|
|
217
|
+
if (typeof entry.file === 'string' && entry.file) {
|
|
218
|
+
sourceByFile.set(entry.file, entry);
|
|
219
|
+
}
|
|
220
|
+
if (typeof entry.type === 'string' && entry.type) {
|
|
221
|
+
sourceByType.set(entry.type, entry);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const diagrams = readdirSync(diagramsDir)
|
|
226
|
+
.filter((file) => file.endsWith('.mmd'))
|
|
227
|
+
.sort()
|
|
228
|
+
.map((file) => {
|
|
229
|
+
const content = readFileSync(join(diagramsDir, file), 'utf8');
|
|
230
|
+
const type = file.replace(/\.mmd$/, '');
|
|
231
|
+
const existingEntry = sourceByFile.get(file) || sourceByType.get(type) || {};
|
|
232
|
+
const bytes = Buffer.byteLength(content);
|
|
233
|
+
const sourceBytes = parseNonNegativeInt(existingEntry.sourceBytes) ?? bytes;
|
|
234
|
+
const tokenBaseBytes = sourceBytes > 0 ? sourceBytes : bytes;
|
|
235
|
+
return {
|
|
236
|
+
...existingEntry,
|
|
237
|
+
type,
|
|
238
|
+
file,
|
|
239
|
+
outputPath: `.diagram/${file}`,
|
|
240
|
+
lines: content.split(/\r?\n/).length,
|
|
241
|
+
bytes,
|
|
242
|
+
sourceBytes,
|
|
243
|
+
approxTokens: estimateTokensFromBytes(tokenBaseBytes),
|
|
244
|
+
isPlaceholder: isPlaceholderDiagram(content),
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const manifest = {
|
|
249
|
+
...(typeof sourceManifest.generatedAt === 'string' && sourceManifest.generatedAt
|
|
250
|
+
? { generatedAt: sourceManifest.generatedAt }
|
|
251
|
+
: {}),
|
|
252
|
+
rootPath: rootDir,
|
|
253
|
+
diagramDir: '.diagram',
|
|
254
|
+
...(sourceManifest && typeof sourceManifest.compaction === 'object'
|
|
255
|
+
? { compaction: sourceManifest.compaction }
|
|
256
|
+
: {}),
|
|
257
|
+
diagrams,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
261
|
+
return manifest;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function runFromEnv() {
|
|
265
|
+
const rootDir = process.env.ROOT_DIR;
|
|
266
|
+
const tmpDir = process.env.TMP_DIR;
|
|
267
|
+
const manifestPath = process.env.MANIFEST_PATH;
|
|
268
|
+
normalizeDiagramManifest({ rootDir, tmpDir, manifestPath });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (require.main === module) {
|
|
272
|
+
try {
|
|
273
|
+
runFromEnv();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error(error.message || String(error));
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
normalizeDiagramManifest,
|
|
282
|
+
};
|