@eduardbar/drift 1.2.0 → 1.4.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/.gga +50 -0
- package/.github/actions/drift-review/README.md +60 -0
- package/.github/actions/drift-review/action.yml +131 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +94 -9
- package/AGENTS.md +75 -245
- package/CHANGELOG.md +28 -0
- package/README.md +308 -51
- package/ROADMAP.md +6 -5
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +420 -159
- package/dist/benchmark.d.ts +2 -0
- package/dist/benchmark.js +204 -0
- package/dist/cli.js +693 -67
- package/dist/config.js +16 -2
- package/dist/diff.js +66 -10
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +133 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/git.js +12 -0
- package/dist/guard-types.d.ts +57 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +14 -0
- package/dist/guard.js +239 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +6 -1
- package/dist/init.d.ts +15 -0
- package/dist/init.js +273 -0
- package/dist/map-cycles.d.ts +2 -0
- package/dist/map-cycles.js +34 -0
- package/dist/map-svg.d.ts +19 -0
- package/dist/map-svg.js +97 -0
- package/dist/map.js +78 -138
- package/dist/metrics.js +70 -55
- package/dist/output-metadata.d.ts +13 -0
- package/dist/output-metadata.js +17 -0
- package/dist/plugins-capabilities.d.ts +4 -0
- package/dist/plugins-capabilities.js +21 -0
- package/dist/plugins-messages.d.ts +10 -0
- package/dist/plugins-messages.js +16 -0
- package/dist/plugins-rules.d.ts +9 -0
- package/dist/plugins-rules.js +137 -0
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +80 -28
- package/dist/printer.js +4 -0
- package/dist/reporter-constants.d.ts +16 -0
- package/dist/reporter-constants.js +39 -0
- package/dist/reporter.d.ts +3 -3
- package/dist/reporter.js +35 -55
- package/dist/review.d.ts +2 -1
- package/dist/review.js +4 -3
- package/dist/rules/comments.js +2 -2
- package/dist/rules/complexity.js +2 -7
- package/dist/rules/nesting.js +3 -13
- package/dist/rules/phase0-basic.js +10 -10
- package/dist/rules/phase3-configurable.js +23 -15
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas/constants.d.ts +15 -0
- package/dist/saas/constants.js +48 -0
- package/dist/saas/dashboard.d.ts +8 -0
- package/dist/saas/dashboard.js +132 -0
- package/dist/saas/errors.d.ts +19 -0
- package/dist/saas/errors.js +37 -0
- package/dist/saas/helpers.d.ts +21 -0
- package/dist/saas/helpers.js +110 -0
- package/dist/saas/ingest.d.ts +3 -0
- package/dist/saas/ingest.js +249 -0
- package/dist/saas/organization.d.ts +5 -0
- package/dist/saas/organization.js +82 -0
- package/dist/saas/plan-change.d.ts +10 -0
- package/dist/saas/plan-change.js +15 -0
- package/dist/saas/store.d.ts +21 -0
- package/dist/saas/store.js +159 -0
- package/dist/saas/types.d.ts +191 -0
- package/dist/saas/types.js +2 -0
- package/dist/saas.d.ts +8 -82
- package/dist/saas.js +7 -320
- package/dist/sarif.d.ts +74 -0
- package/dist/sarif.js +122 -0
- package/dist/trust-advanced.d.ts +14 -0
- package/dist/trust-advanced.js +65 -0
- package/dist/trust-kpi-fs.d.ts +3 -0
- package/dist/trust-kpi-fs.js +141 -0
- package/dist/trust-kpi-parse.d.ts +7 -0
- package/dist/trust-kpi-parse.js +186 -0
- package/dist/trust-kpi-types.d.ts +16 -0
- package/dist/trust-kpi-types.js +2 -0
- package/dist/trust-kpi.d.ts +7 -0
- package/dist/trust-kpi.js +185 -0
- package/dist/trust-policy.d.ts +32 -0
- package/dist/trust-policy.js +160 -0
- package/dist/trust-render.d.ts +9 -0
- package/dist/trust-render.js +54 -0
- package/dist/trust-scoring.d.ts +9 -0
- package/dist/trust-scoring.js +208 -0
- package/dist/trust.d.ts +37 -0
- package/dist/trust.js +168 -0
- package/dist/types/app.d.ts +30 -0
- package/dist/types/app.js +2 -0
- package/dist/types/config.d.ts +25 -0
- package/dist/types/config.js +2 -0
- package/dist/types/core.d.ts +100 -0
- package/dist/types/core.js +2 -0
- package/dist/types/diff.d.ts +55 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/plugin.d.ts +41 -0
- package/dist/types/plugin.js +2 -0
- package/dist/types/trust.d.ts +120 -0
- package/dist/types/trust.js +2 -0
- package/dist/types.d.ts +8 -211
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +87 -0
- package/package.json +6 -3
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +266 -0
- package/src/cli.ts +840 -85
- package/src/config.ts +19 -2
- package/src/diff.ts +84 -10
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/git.ts +16 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +83 -0
- package/src/init.ts +298 -0
- package/src/map-cycles.ts +38 -0
- package/src/map-svg.ts +124 -0
- package/src/map.ts +111 -142
- package/src/metrics.ts +78 -59
- package/src/output-metadata.ts +30 -0
- package/src/plugins-capabilities.ts +36 -0
- package/src/plugins-messages.ts +35 -0
- package/src/plugins-rules.ts +296 -0
- package/src/plugins.ts +148 -27
- package/src/printer.ts +4 -0
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +6 -4
- package/src/rules/comments.ts +2 -2
- package/src/rules/complexity.ts +2 -7
- package/src/rules/nesting.ts +3 -13
- package/src/rules/phase0-basic.ts +11 -12
- package/src/rules/phase3-configurable.ts +39 -26
- package/src/rules/shared.ts +31 -3
- package/src/saas/constants.ts +56 -0
- package/src/saas/dashboard.ts +172 -0
- package/src/saas/errors.ts +45 -0
- package/src/saas/helpers.ts +140 -0
- package/src/saas/ingest.ts +278 -0
- package/src/saas/organization.ts +99 -0
- package/src/saas/plan-change.ts +19 -0
- package/src/saas/store.ts +172 -0
- package/src/saas/types.ts +216 -0
- package/src/saas.ts +49 -433
- package/src/sarif.ts +232 -0
- package/src/trust-advanced.ts +99 -0
- package/src/trust-kpi-fs.ts +169 -0
- package/src/trust-kpi-parse.ts +219 -0
- package/src/trust-kpi-types.ts +19 -0
- package/src/trust-kpi.ts +210 -0
- package/src/trust-policy.ts +246 -0
- package/src/trust-render.ts +61 -0
- package/src/trust-scoring.ts +231 -0
- package/src/trust.ts +260 -0
- package/src/types/app.ts +30 -0
- package/src/types/config.ts +27 -0
- package/src/types/core.ts +105 -0
- package/src/types/diff.ts +61 -0
- package/src/types/plugin.ts +46 -0
- package/src/types/trust.ts +134 -0
- package/src/types.ts +78 -238
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/diff.test.ts +124 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +80 -1
- package/tests/phase1-init-doctor-guard.test.ts +199 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +358 -1
- package/tests/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +147 -0
- package/tests/trust.test.ts +602 -0
package/dist/map.js
CHANGED
|
@@ -3,15 +3,54 @@ import { resolve, relative } from 'node:path';
|
|
|
3
3
|
import { Project } from 'ts-morph';
|
|
4
4
|
import { detectLayerViolations } from './rules/phase3-arch.js';
|
|
5
5
|
import { RULE_WEIGHTS } from './analyzer.js';
|
|
6
|
+
import { detectCycleEdges } from './map-cycles.js';
|
|
7
|
+
import { renderArchitectureSvg } from './map-svg.js';
|
|
6
8
|
function detectLayer(relPath) {
|
|
7
9
|
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
8
10
|
const first = normalized.split('/')[0] || 'root';
|
|
9
11
|
return first;
|
|
10
12
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
+
function appendFileLayerContext(filePath, targetPath, layers, fileImportGraph) {
|
|
14
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/');
|
|
15
|
+
const layerName = detectLayer(rel);
|
|
16
|
+
if (!layers.has(layerName)) {
|
|
17
|
+
layers.set(layerName, { name: layerName, files: new Set() });
|
|
18
|
+
}
|
|
19
|
+
layers.get(layerName).files.add(rel);
|
|
20
|
+
if (!fileImportGraph.has(filePath)) {
|
|
21
|
+
fileImportGraph.set(filePath, new Set());
|
|
22
|
+
}
|
|
23
|
+
return { rel, layerName };
|
|
13
24
|
}
|
|
14
|
-
|
|
25
|
+
function registerImportEdge(layerName, importedLayer, edges, layerAdjacency) {
|
|
26
|
+
if (importedLayer === layerName)
|
|
27
|
+
return;
|
|
28
|
+
const key = `${layerName}->${importedLayer}`;
|
|
29
|
+
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
30
|
+
if (!layerAdjacency.has(layerName)) {
|
|
31
|
+
layerAdjacency.set(layerName, new Set());
|
|
32
|
+
}
|
|
33
|
+
layerAdjacency.get(layerName).add(importedLayer);
|
|
34
|
+
}
|
|
35
|
+
function buildEdgeList(edges, cycleEdges, violationEdges) {
|
|
36
|
+
const edgeList = [...edges.entries()].map(([key, count]) => {
|
|
37
|
+
const [from, to] = key.split('->');
|
|
38
|
+
const kind = violationEdges.has(key)
|
|
39
|
+
? 'violation'
|
|
40
|
+
: cycleEdges.has(key)
|
|
41
|
+
? 'cycle'
|
|
42
|
+
: 'normal';
|
|
43
|
+
return { key, from, to, count, kind };
|
|
44
|
+
});
|
|
45
|
+
for (const key of violationEdges) {
|
|
46
|
+
if (edges.has(key))
|
|
47
|
+
continue;
|
|
48
|
+
const [from, to] = key.split('->');
|
|
49
|
+
edgeList.push({ key, from, to, count: 1, kind: 'violation' });
|
|
50
|
+
}
|
|
51
|
+
return edgeList;
|
|
52
|
+
}
|
|
53
|
+
function createArchitectureProject(targetPath) {
|
|
15
54
|
const project = new Project({
|
|
16
55
|
skipAddingFilesFromTsConfig: true,
|
|
17
56
|
compilerOptions: { allowJs: true, jsx: 1 },
|
|
@@ -26,19 +65,16 @@ export function generateArchitectureSvg(targetPath, config) {
|
|
|
26
65
|
`!${targetPath}/**/.next/**`,
|
|
27
66
|
`!${targetPath}/**/*.d.ts`,
|
|
28
67
|
]);
|
|
68
|
+
return project;
|
|
69
|
+
}
|
|
70
|
+
function collectArchitectureGraph(project, targetPath) {
|
|
29
71
|
const layers = new Map();
|
|
30
72
|
const edges = new Map();
|
|
31
73
|
const layerAdjacency = new Map();
|
|
32
74
|
const fileImportGraph = new Map();
|
|
33
75
|
for (const file of project.getSourceFiles()) {
|
|
34
76
|
const filePath = file.getFilePath();
|
|
35
|
-
const
|
|
36
|
-
const layerName = detectLayer(rel);
|
|
37
|
-
if (!layers.has(layerName))
|
|
38
|
-
layers.set(layerName, { name: layerName, files: new Set() });
|
|
39
|
-
layers.get(layerName).files.add(rel);
|
|
40
|
-
if (!fileImportGraph.has(filePath))
|
|
41
|
-
fileImportGraph.set(filePath, new Set());
|
|
77
|
+
const { layerName } = appendFileLayerContext(filePath, targetPath, layers, fileImportGraph);
|
|
42
78
|
for (const decl of file.getImportDeclarations()) {
|
|
43
79
|
const imported = decl.getModuleSpecifierSourceFile();
|
|
44
80
|
if (!imported)
|
|
@@ -46,140 +82,44 @@ export function generateArchitectureSvg(targetPath, config) {
|
|
|
46
82
|
fileImportGraph.get(filePath).add(imported.getFilePath());
|
|
47
83
|
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/');
|
|
48
84
|
const importedLayer = detectLayer(importedRel);
|
|
49
|
-
|
|
50
|
-
continue;
|
|
51
|
-
const key = `${layerName}->${importedLayer}`;
|
|
52
|
-
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
53
|
-
if (!layerAdjacency.has(layerName))
|
|
54
|
-
layerAdjacency.set(layerName, new Set());
|
|
55
|
-
layerAdjacency.get(layerName).add(importedLayer);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const cycleEdges = detectCycleEdges(layerAdjacency);
|
|
59
|
-
const violationEdges = new Set();
|
|
60
|
-
if (config?.layers && config.layers.length > 0) {
|
|
61
|
-
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS);
|
|
62
|
-
for (const issues of violations.values()) {
|
|
63
|
-
for (const issue of issues) {
|
|
64
|
-
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/);
|
|
65
|
-
if (!match)
|
|
66
|
-
continue;
|
|
67
|
-
const from = match[1];
|
|
68
|
-
const to = match[2];
|
|
69
|
-
violationEdges.add(`${from}->${to}`);
|
|
70
|
-
if (!layers.has(from))
|
|
71
|
-
layers.set(from, { name: from, files: new Set() });
|
|
72
|
-
if (!layers.has(to))
|
|
73
|
-
layers.set(to, { name: to, files: new Set() });
|
|
74
|
-
}
|
|
85
|
+
registerImportEdge(layerName, importedLayer, edges, layerAdjacency);
|
|
75
86
|
}
|
|
76
87
|
}
|
|
77
|
-
|
|
78
|
-
const [from, to] = key.split('->');
|
|
79
|
-
const kind = violationEdges.has(key)
|
|
80
|
-
? 'violation'
|
|
81
|
-
: cycleEdges.has(key)
|
|
82
|
-
? 'cycle'
|
|
83
|
-
: 'normal';
|
|
84
|
-
return { key, from, to, count, kind };
|
|
85
|
-
});
|
|
86
|
-
for (const key of violationEdges) {
|
|
87
|
-
if (edges.has(key))
|
|
88
|
-
continue;
|
|
89
|
-
const [from, to] = key.split('->');
|
|
90
|
-
edgeList.push({ key, from, to, count: 1, kind: 'violation' });
|
|
91
|
-
}
|
|
92
|
-
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
93
|
-
const width = 960;
|
|
94
|
-
const rowHeight = 90;
|
|
95
|
-
const height = Math.max(180, layerList.length * rowHeight + 120);
|
|
96
|
-
const boxWidth = 240;
|
|
97
|
-
const boxHeight = 50;
|
|
98
|
-
const left = 100;
|
|
99
|
-
const boxes = layerList.map((layer, index) => {
|
|
100
|
-
const y = 60 + index * rowHeight;
|
|
101
|
-
return {
|
|
102
|
-
...layer,
|
|
103
|
-
x: left,
|
|
104
|
-
y,
|
|
105
|
-
};
|
|
106
|
-
});
|
|
107
|
-
const boxByName = new Map(boxes.map((box) => [box.name, box]));
|
|
108
|
-
const lines = edgeList.map((edge) => {
|
|
109
|
-
const a = boxByName.get(edge.from);
|
|
110
|
-
const b = boxByName.get(edge.to);
|
|
111
|
-
if (!a || !b)
|
|
112
|
-
return '';
|
|
113
|
-
const startX = a.x + boxWidth;
|
|
114
|
-
const startY = a.y + boxHeight / 2;
|
|
115
|
-
const endX = b.x;
|
|
116
|
-
const endY = b.y + boxHeight / 2;
|
|
117
|
-
const stroke = edge.kind === 'violation'
|
|
118
|
-
? '#ef4444'
|
|
119
|
-
: edge.kind === 'cycle'
|
|
120
|
-
? '#f59e0b'
|
|
121
|
-
: '#64748b';
|
|
122
|
-
const widthPx = edge.kind === 'normal' ? 2 : 3;
|
|
123
|
-
return `
|
|
124
|
-
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="${stroke}" stroke-width="${widthPx}" marker-end="url(#arrow)" data-edge="${esc(edge.key)}" data-kind="${edge.kind}" />
|
|
125
|
-
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`;
|
|
126
|
-
}).join('');
|
|
127
|
-
const nodes = boxes.map((box) => `
|
|
128
|
-
<g>
|
|
129
|
-
<rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
|
|
130
|
-
<text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
|
|
131
|
-
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
132
|
-
</g>`).join('');
|
|
133
|
-
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length;
|
|
134
|
-
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length;
|
|
135
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
136
|
-
<defs>
|
|
137
|
-
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
138
|
-
<path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
|
|
139
|
-
</marker>
|
|
140
|
-
</defs>
|
|
141
|
-
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
142
|
-
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
143
|
-
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
144
|
-
<text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
|
|
145
|
-
<line x1="520" y1="66" x2="560" y2="66" stroke="#f59e0b" stroke-width="3" /><text x="567" y="69" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
|
|
146
|
-
<line x1="630" y1="66" x2="670" y2="66" stroke="#ef4444" stroke-width="3" /><text x="677" y="69" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
|
|
147
|
-
${lines}
|
|
148
|
-
${nodes}
|
|
149
|
-
</svg>`;
|
|
88
|
+
return { layers, edges, layerAdjacency, fileImportGraph };
|
|
150
89
|
}
|
|
151
|
-
function
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
for (const neighbor of adjacency.get(node) ?? []) {
|
|
161
|
-
if (!visited.has(neighbor)) {
|
|
162
|
-
dfs(neighbor);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (!inStack.has(neighbor))
|
|
90
|
+
function collectViolationEdges(config, fileImportGraph, targetPath, layers) {
|
|
91
|
+
const violationEdges = new Set();
|
|
92
|
+
if (!config?.layers?.length)
|
|
93
|
+
return violationEdges;
|
|
94
|
+
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS);
|
|
95
|
+
for (const issues of violations.values()) {
|
|
96
|
+
for (const issue of issues) {
|
|
97
|
+
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/);
|
|
98
|
+
if (!match)
|
|
166
99
|
continue;
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
100
|
+
const from = match[1];
|
|
101
|
+
const to = match[2];
|
|
102
|
+
violationEdges.add(`${from}->${to}`);
|
|
103
|
+
if (!layers.has(from))
|
|
104
|
+
layers.set(from, { name: from, files: new Set() });
|
|
105
|
+
if (!layers.has(to))
|
|
106
|
+
layers.set(to, { name: to, files: new Set() });
|
|
174
107
|
}
|
|
175
|
-
stack.pop();
|
|
176
|
-
inStack.delete(node);
|
|
177
|
-
}
|
|
178
|
-
for (const node of adjacency.keys()) {
|
|
179
|
-
if (!visited.has(node))
|
|
180
|
-
dfs(node);
|
|
181
108
|
}
|
|
182
|
-
return
|
|
109
|
+
return violationEdges;
|
|
110
|
+
}
|
|
111
|
+
export function generateArchitectureSvg(targetPath, config) {
|
|
112
|
+
const project = createArchitectureProject(targetPath);
|
|
113
|
+
const { layers, edges, layerAdjacency, fileImportGraph } = collectArchitectureGraph(project, targetPath);
|
|
114
|
+
const cycleEdges = detectCycleEdges(layerAdjacency);
|
|
115
|
+
const violationEdges = collectViolationEdges(config, fileImportGraph, targetPath, layers);
|
|
116
|
+
const edgeList = buildEdgeList(edges, cycleEdges, violationEdges);
|
|
117
|
+
return renderArchitectureSvg({
|
|
118
|
+
layers,
|
|
119
|
+
edgeList,
|
|
120
|
+
cycleCount: edgeList.filter((edge) => edge.kind === 'cycle').length,
|
|
121
|
+
violationCount: edgeList.filter((edge) => edge.kind === 'violation').length,
|
|
122
|
+
});
|
|
183
123
|
}
|
|
184
124
|
export function generateArchitectureMap(targetPath, outputFile = 'architecture.svg', config) {
|
|
185
125
|
const resolvedTarget = resolve(targetPath);
|
package/dist/metrics.js
CHANGED
|
@@ -25,6 +25,19 @@ const AI_RULES = new Set([
|
|
|
25
25
|
'comment-contradiction',
|
|
26
26
|
'ai-code-smell',
|
|
27
27
|
]);
|
|
28
|
+
const ISSUE_WEIGHT_PER_FILE = 20;
|
|
29
|
+
const DIMENSION_COUNT = 4;
|
|
30
|
+
const MAX_COMPLEXITY_RISK = 40;
|
|
31
|
+
const COMPLEXITY_RISK_PER_ISSUE = 10;
|
|
32
|
+
const MISSING_TESTS_RISK = 25;
|
|
33
|
+
const FREQUENT_CHANGE_THRESHOLD = 8;
|
|
34
|
+
const FREQUENT_CHANGE_RISK = 20;
|
|
35
|
+
const HIGH_DRIFT_THRESHOLD = 50;
|
|
36
|
+
const HIGH_DRIFT_RISK = 15;
|
|
37
|
+
const HOTSPOT_LIMIT = 10;
|
|
38
|
+
const LEVEL_CRITICAL_THRESHOLD = 75;
|
|
39
|
+
const LEVEL_HIGH_THRESHOLD = 55;
|
|
40
|
+
const LEVEL_MEDIUM_THRESHOLD = 30;
|
|
28
41
|
function clamp(value, min, max) {
|
|
29
42
|
return Math.max(min, Math.min(max, value));
|
|
30
43
|
}
|
|
@@ -33,21 +46,20 @@ function listFilesRecursively(root) {
|
|
|
33
46
|
return [];
|
|
34
47
|
const out = [];
|
|
35
48
|
const stack = [root];
|
|
49
|
+
const shouldSkipDirectory = (name) => name === 'node_modules' || name === 'dist' || name === '.git' || name === '.next' || name === 'build';
|
|
36
50
|
while (stack.length > 0) {
|
|
37
51
|
const current = stack.pop();
|
|
38
52
|
const entries = readdirSync(current);
|
|
39
53
|
for (const entry of entries) {
|
|
40
54
|
const full = join(current, entry);
|
|
41
55
|
const stat = statSync(full);
|
|
42
|
-
if (stat.isDirectory()) {
|
|
43
|
-
if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.next' || entry === 'build') {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
stack.push(full);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
56
|
+
if (!stat.isDirectory()) {
|
|
49
57
|
out.push(full);
|
|
58
|
+
continue;
|
|
50
59
|
}
|
|
60
|
+
if (shouldSkipDirectory(entry))
|
|
61
|
+
continue;
|
|
62
|
+
stack.push(full);
|
|
51
63
|
}
|
|
52
64
|
}
|
|
53
65
|
return out;
|
|
@@ -83,7 +95,51 @@ function qualityFromIssues(totalFiles, issues, rules) {
|
|
|
83
95
|
const count = issues.filter((issue) => rules.has(issue.rule)).length;
|
|
84
96
|
if (totalFiles === 0)
|
|
85
97
|
return 100;
|
|
86
|
-
return clamp(100 - Math.round((count / totalFiles) *
|
|
98
|
+
return clamp(100 - Math.round((count / totalFiles) * ISSUE_WEIGHT_PER_FILE), 0, 100);
|
|
99
|
+
}
|
|
100
|
+
function riskLevelFromScore(score) {
|
|
101
|
+
if (score >= LEVEL_CRITICAL_THRESHOLD)
|
|
102
|
+
return 'critical';
|
|
103
|
+
if (score >= LEVEL_HIGH_THRESHOLD)
|
|
104
|
+
return 'high';
|
|
105
|
+
if (score >= LEVEL_MEDIUM_THRESHOLD)
|
|
106
|
+
return 'medium';
|
|
107
|
+
return 'low';
|
|
108
|
+
}
|
|
109
|
+
function evaluateHotspot(targetPath, file) {
|
|
110
|
+
const complexityIssues = file.issues.filter((issue) => issue.rule === 'high-complexity' ||
|
|
111
|
+
issue.rule === 'deep-nesting' ||
|
|
112
|
+
issue.rule === 'large-function' ||
|
|
113
|
+
issue.rule === 'max-function-lines').length;
|
|
114
|
+
const changeFrequency = getCommitTouchCount(targetPath, file.path);
|
|
115
|
+
const hasTests = hasNearbyTest(targetPath, file.path);
|
|
116
|
+
const reasons = [];
|
|
117
|
+
let risk = 0;
|
|
118
|
+
if (complexityIssues > 0) {
|
|
119
|
+
risk += Math.min(MAX_COMPLEXITY_RISK, complexityIssues * COMPLEXITY_RISK_PER_ISSUE);
|
|
120
|
+
reasons.push('high complexity signals');
|
|
121
|
+
}
|
|
122
|
+
if (!hasTests) {
|
|
123
|
+
risk += MISSING_TESTS_RISK;
|
|
124
|
+
reasons.push('no nearby tests');
|
|
125
|
+
}
|
|
126
|
+
if (changeFrequency >= FREQUENT_CHANGE_THRESHOLD) {
|
|
127
|
+
risk += FREQUENT_CHANGE_RISK;
|
|
128
|
+
reasons.push('frequently changed file');
|
|
129
|
+
}
|
|
130
|
+
if (file.score >= HIGH_DRIFT_THRESHOLD) {
|
|
131
|
+
risk += HIGH_DRIFT_RISK;
|
|
132
|
+
reasons.push('high drift score');
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
file: file.path,
|
|
136
|
+
driftScore: file.score,
|
|
137
|
+
complexityIssues,
|
|
138
|
+
hasNearbyTests: hasTests,
|
|
139
|
+
changeFrequency,
|
|
140
|
+
risk: clamp(risk, 0, 100),
|
|
141
|
+
reasons,
|
|
142
|
+
};
|
|
87
143
|
}
|
|
88
144
|
export function computeRepoQuality(targetPath, files) {
|
|
89
145
|
const allIssues = files.flatMap((file) => file.issues);
|
|
@@ -105,63 +161,22 @@ export function computeRepoQuality(targetPath, files) {
|
|
|
105
161
|
const overall = Math.round((dimensions.architecture +
|
|
106
162
|
dimensions.complexity +
|
|
107
163
|
dimensions['ai-patterns'] +
|
|
108
|
-
dimensions.testing) /
|
|
164
|
+
dimensions.testing) / DIMENSION_COUNT);
|
|
109
165
|
return { overall, dimensions };
|
|
110
166
|
}
|
|
111
167
|
export function computeMaintenanceRisk(report) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
.map((file) => {
|
|
115
|
-
const complexityIssues = file.issues.filter((issue) => issue.rule === 'high-complexity' ||
|
|
116
|
-
issue.rule === 'deep-nesting' ||
|
|
117
|
-
issue.rule === 'large-function' ||
|
|
118
|
-
issue.rule === 'max-function-lines').length;
|
|
119
|
-
const changeFrequency = getCommitTouchCount(report.targetPath, file.path);
|
|
120
|
-
const hasTests = hasNearbyTest(report.targetPath, file.path);
|
|
121
|
-
const reasons = [];
|
|
122
|
-
let risk = 0;
|
|
123
|
-
if (complexityIssues > 0) {
|
|
124
|
-
risk += Math.min(40, complexityIssues * 10);
|
|
125
|
-
reasons.push('high complexity signals');
|
|
126
|
-
}
|
|
127
|
-
if (!hasTests) {
|
|
128
|
-
risk += 25;
|
|
129
|
-
reasons.push('no nearby tests');
|
|
130
|
-
}
|
|
131
|
-
if (changeFrequency >= 8) {
|
|
132
|
-
risk += 20;
|
|
133
|
-
reasons.push('frequently changed file');
|
|
134
|
-
}
|
|
135
|
-
if (file.score >= 50) {
|
|
136
|
-
risk += 15;
|
|
137
|
-
reasons.push('high drift score');
|
|
138
|
-
}
|
|
139
|
-
return {
|
|
140
|
-
file: file.path,
|
|
141
|
-
driftScore: file.score,
|
|
142
|
-
complexityIssues,
|
|
143
|
-
hasNearbyTests: hasTests,
|
|
144
|
-
changeFrequency,
|
|
145
|
-
risk: clamp(risk, 0, 100),
|
|
146
|
-
reasons,
|
|
147
|
-
};
|
|
148
|
-
})
|
|
168
|
+
const hotspots = report.files
|
|
169
|
+
.map((file) => evaluateHotspot(report.targetPath, file))
|
|
149
170
|
.filter((hotspot) => hotspot.risk > 0)
|
|
150
171
|
.sort((a, b) => b.risk - a.risk)
|
|
151
|
-
.slice(0,
|
|
172
|
+
.slice(0, HOTSPOT_LIMIT);
|
|
152
173
|
const highComplexityFiles = hotspots.filter((hotspot) => hotspot.complexityIssues > 0).length;
|
|
153
174
|
const filesWithoutNearbyTests = hotspots.filter((hotspot) => !hotspot.hasNearbyTests).length;
|
|
154
|
-
const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >=
|
|
175
|
+
const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= FREQUENT_CHANGE_THRESHOLD).length;
|
|
155
176
|
const score = hotspots.length === 0
|
|
156
177
|
? 0
|
|
157
178
|
: Math.round(hotspots.reduce((sum, hotspot) => sum + hotspot.risk, 0) / hotspots.length);
|
|
158
|
-
const level = score
|
|
159
|
-
? 'critical'
|
|
160
|
-
: score >= 55
|
|
161
|
-
? 'high'
|
|
162
|
-
: score >= 30
|
|
163
|
-
? 'medium'
|
|
164
|
-
: 'low';
|
|
179
|
+
const level = riskLevelFromScore(score);
|
|
165
180
|
return {
|
|
166
181
|
score,
|
|
167
182
|
level,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const OUTPUT_SCHEMA: {
|
|
2
|
+
readonly report: "schemas/drift-report.v1.json";
|
|
3
|
+
readonly trust: "schemas/drift-trust.v1.json";
|
|
4
|
+
readonly ai: "schemas/drift-ai-output.v1.json";
|
|
5
|
+
};
|
|
6
|
+
type OutputMetadata = {
|
|
7
|
+
$schema: string;
|
|
8
|
+
toolVersion: string;
|
|
9
|
+
};
|
|
10
|
+
type JsonOutputWithMetadata<T extends object> = T & OutputMetadata;
|
|
11
|
+
export declare function withOutputMetadata<T extends object>(payload: T, schema: string): JsonOutputWithMetadata<T>;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=output-metadata.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const { version } = require('../package.json');
|
|
4
|
+
const TOOL_VERSION = version;
|
|
5
|
+
export const OUTPUT_SCHEMA = {
|
|
6
|
+
report: 'schemas/drift-report.v1.json',
|
|
7
|
+
trust: 'schemas/drift-trust.v1.json',
|
|
8
|
+
ai: 'schemas/drift-ai-output.v1.json',
|
|
9
|
+
};
|
|
10
|
+
export function withOutputMetadata(payload, schema) {
|
|
11
|
+
return {
|
|
12
|
+
...payload,
|
|
13
|
+
$schema: schema,
|
|
14
|
+
toolVersion: TOOL_VERSION,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=output-metadata.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DriftPlugin } from './types.js';
|
|
2
|
+
import type { PluginValidationContext } from './plugins-rules.js';
|
|
3
|
+
export declare function validateCapabilities(capabilitiesCandidate: unknown, context: PluginValidationContext): DriftPlugin['capabilities'] | undefined;
|
|
4
|
+
//# sourceMappingURL=plugins-capabilities.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { pushError } from './plugins-messages.js';
|
|
2
|
+
export function validateCapabilities(capabilitiesCandidate, context) {
|
|
3
|
+
const { pluginId, pluginName, errors } = context;
|
|
4
|
+
if (capabilitiesCandidate === undefined)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (!capabilitiesCandidate || typeof capabilitiesCandidate !== 'object' || Array.isArray(capabilitiesCandidate)) {
|
|
7
|
+
pushError(errors, pluginId, `Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`, { pluginName, code: 'plugin-capabilities-invalid' });
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const entries = Object.entries(capabilitiesCandidate);
|
|
11
|
+
for (const [capabilityKey, capabilityValue] of entries) {
|
|
12
|
+
const capabilityType = typeof capabilityValue;
|
|
13
|
+
if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
|
|
14
|
+
pushError(errors, pluginId, `Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`, { pluginName, code: 'plugin-capabilities-value-invalid' });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (errors.length > 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
return capabilitiesCandidate;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=plugins-capabilities.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginLoadError, PluginLoadWarning } from './types.js';
|
|
2
|
+
type PluginMessageOptions = {
|
|
3
|
+
pluginName?: string;
|
|
4
|
+
ruleId?: string;
|
|
5
|
+
code?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function pushError(errors: PluginLoadError[], pluginId: string, message: string, options?: PluginMessageOptions): void;
|
|
8
|
+
export declare function pushWarning(warnings: PluginLoadWarning[], pluginId: string, message: string, options?: PluginMessageOptions): void;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=plugins-messages.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function pushLoadMessage(pluginId, message, options) {
|
|
2
|
+
return {
|
|
3
|
+
pluginId,
|
|
4
|
+
pluginName: options?.pluginName,
|
|
5
|
+
ruleId: options?.ruleId,
|
|
6
|
+
code: options?.code,
|
|
7
|
+
message,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function pushError(errors, pluginId, message, options) {
|
|
11
|
+
errors.push(pushLoadMessage(pluginId, message, options));
|
|
12
|
+
}
|
|
13
|
+
export function pushWarning(warnings, pluginId, message, options) {
|
|
14
|
+
warnings.push(pushLoadMessage(pluginId, message, options));
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=plugins-messages.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DriftPluginRule, PluginLoadError, PluginLoadWarning } from './types.js';
|
|
2
|
+
export type PluginValidationContext = {
|
|
3
|
+
pluginId: string;
|
|
4
|
+
pluginName: string;
|
|
5
|
+
errors: PluginLoadError[];
|
|
6
|
+
warnings: PluginLoadWarning[];
|
|
7
|
+
};
|
|
8
|
+
export declare function normalizeRules(rulesCandidate: unknown, isLegacyPlugin: boolean, context: PluginValidationContext): DriftPluginRule[] | undefined;
|
|
9
|
+
//# sourceMappingURL=plugins-rules.d.ts.map
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { pushError, pushWarning } from './plugins-messages.js';
|
|
2
|
+
const VALID_SEVERITIES = ['error', 'warning', 'info'];
|
|
3
|
+
const MAX_FIX_ARITY = 3;
|
|
4
|
+
const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/;
|
|
5
|
+
function resolveRawRuleId(rawRule) {
|
|
6
|
+
if (typeof rawRule.id === 'string')
|
|
7
|
+
return rawRule.id.trim();
|
|
8
|
+
if (typeof rawRule.name === 'string')
|
|
9
|
+
return rawRule.name.trim();
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
function ensureRuleId(rawRuleId, ruleIndex, context) {
|
|
13
|
+
if (rawRuleId)
|
|
14
|
+
return true;
|
|
15
|
+
pushError(context.errors, context.pluginId, `Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`, { pluginName: context.pluginName, code: 'plugin-rule-id-missing' });
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function ensureDetectFunction(detect, context) {
|
|
19
|
+
if (typeof detect === 'function')
|
|
20
|
+
return true;
|
|
21
|
+
pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' is invalid. Expected 'detect(file, context)' function.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-detect-invalid' });
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
function warnDetectArity(detect, context) {
|
|
25
|
+
if (detect.length <= 2)
|
|
26
|
+
return;
|
|
27
|
+
pushWarning(context.warnings, context.pluginId, `Rule '${context.ruleId}' detect() declares ${detect.length} parameters. Expected 1-2 parameters (file, context).`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-detect-arity' });
|
|
28
|
+
}
|
|
29
|
+
function validateRuleIdentifierFormat(rawRuleId, strictRuleId, context) {
|
|
30
|
+
if (RULE_ID_REQUIRED.test(rawRuleId))
|
|
31
|
+
return;
|
|
32
|
+
const ruleLabel = rawRuleId || 'unknown-rule';
|
|
33
|
+
if (strictRuleId) {
|
|
34
|
+
pushError(context.errors, context.pluginId, `Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`, { pluginName: context.pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
pushWarning(context.warnings, context.pluginId, `Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: 1.`, { pluginName: context.pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' });
|
|
38
|
+
}
|
|
39
|
+
function resolveRuleSeverity(rawSeverity, context) {
|
|
40
|
+
if (rawSeverity === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (typeof rawSeverity === 'string' && VALID_SEVERITIES.includes(rawSeverity)) {
|
|
43
|
+
return rawSeverity;
|
|
44
|
+
}
|
|
45
|
+
pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' has invalid severity '${String(rawSeverity)}'. Allowed: error, warning, info.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-severity-invalid' });
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function resolveRuleWeight(rawWeight, context) {
|
|
49
|
+
if (rawWeight === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (typeof rawWeight === 'number' && Number.isFinite(rawWeight) && rawWeight >= 0 && rawWeight <= 100) {
|
|
52
|
+
return rawWeight;
|
|
53
|
+
}
|
|
54
|
+
pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' has invalid weight '${String(rawWeight)}'. Expected a finite number between 0 and 100.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-weight-invalid' });
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
function resolveRuleFix(rawFix, context) {
|
|
58
|
+
if (rawFix === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (typeof rawFix !== 'function') {
|
|
61
|
+
pushError(context.errors, context.pluginId, `Rule '${context.ruleId}' has invalid fix. Expected a function when provided.`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-fix-invalid' });
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (rawFix.length > MAX_FIX_ARITY) {
|
|
65
|
+
pushWarning(context.warnings, context.pluginId, `Rule '${context.ruleId}' fix() declares ${rawFix.length} parameters. Expected up to ${MAX_FIX_ARITY} (issue, file, context).`, { pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-fix-arity' });
|
|
66
|
+
}
|
|
67
|
+
return rawFix;
|
|
68
|
+
}
|
|
69
|
+
function normalizeRule(rawRule, context) {
|
|
70
|
+
const { pluginId, pluginName, ruleIndex, strictRuleId, errors, warnings } = context;
|
|
71
|
+
const rawRuleId = resolveRawRuleId(rawRule);
|
|
72
|
+
const messageContext = { pluginId, pluginName, ruleId: rawRuleId, errors, warnings };
|
|
73
|
+
if (!ensureRuleId(rawRuleId, ruleIndex, messageContext))
|
|
74
|
+
return undefined;
|
|
75
|
+
if (!ensureDetectFunction(rawRule.detect, messageContext))
|
|
76
|
+
return undefined;
|
|
77
|
+
validateRuleIdentifierFormat(rawRuleId, strictRuleId, messageContext);
|
|
78
|
+
warnDetectArity(rawRule.detect, messageContext);
|
|
79
|
+
const ruleValidationContext = { pluginId, pluginName, ruleId: rawRuleId, errors };
|
|
80
|
+
const severity = resolveRuleSeverity(rawRule.severity, ruleValidationContext);
|
|
81
|
+
const weight = resolveRuleWeight(rawRule.weight, ruleValidationContext);
|
|
82
|
+
const fix = resolveRuleFix(rawRule.fix, messageContext);
|
|
83
|
+
return {
|
|
84
|
+
id: rawRuleId,
|
|
85
|
+
name: rawRuleId,
|
|
86
|
+
detect: rawRule.detect,
|
|
87
|
+
severity,
|
|
88
|
+
weight,
|
|
89
|
+
fix,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function ensureUniqueRuleId(rule, seenRuleIds, context) {
|
|
93
|
+
const normalizedRuleId = rule.id ?? rule.name;
|
|
94
|
+
if (seenRuleIds.has(normalizedRuleId)) {
|
|
95
|
+
pushError(context.errors, context.pluginId, `Plugin '${context.pluginName}' defines duplicate rule id '${normalizedRuleId}'. Rule ids must be unique within a plugin.`, { pluginName: context.pluginName, ruleId: normalizedRuleId, code: 'plugin-rule-id-duplicate' });
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
seenRuleIds.add(normalizedRuleId);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function normalizeRulesArray(rulesCandidate, context, strictRuleId) {
|
|
102
|
+
const normalizedRules = [];
|
|
103
|
+
const seenRuleIds = new Set();
|
|
104
|
+
for (const [ruleIndex, rawRule] of rulesCandidate.entries()) {
|
|
105
|
+
if (!rawRule || typeof rawRule !== 'object') {
|
|
106
|
+
pushError(context.errors, context.pluginId, `Invalid rule at index ${ruleIndex} in plugin '${context.pluginName}'. Expected an object.`, { pluginName: context.pluginName, code: 'plugin-rule-shape-invalid' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const normalized = normalizeRule(rawRule, {
|
|
110
|
+
pluginId: context.pluginId,
|
|
111
|
+
pluginName: context.pluginName,
|
|
112
|
+
ruleIndex,
|
|
113
|
+
strictRuleId,
|
|
114
|
+
errors: context.errors,
|
|
115
|
+
warnings: context.warnings,
|
|
116
|
+
});
|
|
117
|
+
if (!normalized)
|
|
118
|
+
continue;
|
|
119
|
+
if (ensureUniqueRuleId(normalized, seenRuleIds, context)) {
|
|
120
|
+
normalizedRules.push(normalized);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return normalizedRules;
|
|
124
|
+
}
|
|
125
|
+
export function normalizeRules(rulesCandidate, isLegacyPlugin, context) {
|
|
126
|
+
if (!Array.isArray(rulesCandidate)) {
|
|
127
|
+
pushError(context.errors, context.pluginId, `Invalid plugin '${context.pluginName}'. Expected 'rules' to be an array.`, { pluginName: context.pluginName, code: 'plugin-rules-not-array' });
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const normalizedRules = normalizeRulesArray(rulesCandidate, context, !isLegacyPlugin);
|
|
131
|
+
if (normalizedRules.length === 0) {
|
|
132
|
+
pushError(context.errors, context.pluginId, `Plugin '${context.pluginName}' has no valid rules after validation.`, { pluginName: context.pluginName, code: 'plugin-rules-empty' });
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return normalizedRules;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=plugins-rules.js.map
|