@smallpen/cli 0.1.0-alpha.1 → 0.1.0-alpha.12
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/bin/command-contract.mjs +1415 -0
- package/bin/command-tree.mjs +512 -0
- package/bin/design-output.mjs +939 -0
- package/bin/help-topics.mjs +241 -0
- package/bin/help.mjs +477 -363
- package/bin/inspection-output.mjs +825 -0
- package/bin/read-output.mjs +219 -0
- package/bin/result-files.mjs +232 -0
- package/bin/reuse-advice.mjs +73 -0
- package/bin/schema.mjs +1070 -0
- package/bin/smallpen-check.cjs +0 -0
- package/bin/smallpen.mjs +4334 -1208
- package/bin/warning-output.mjs +10 -3
- package/bin/windows-artifacts.mjs +72 -0
- package/bin/workflow.mjs +55 -0
- package/package.json +3 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { SmallPenError } from "@smallpen/core";
|
|
2
|
+
|
|
3
|
+
// Public discovery fields are independent of the stored object shape.
|
|
4
|
+
export const DISCOVERY_ITEM_FIELDS = Object.freeze([
|
|
5
|
+
"id",
|
|
6
|
+
"name",
|
|
7
|
+
"path",
|
|
8
|
+
"type",
|
|
9
|
+
"kind",
|
|
10
|
+
"category",
|
|
11
|
+
"packageId",
|
|
12
|
+
"source",
|
|
13
|
+
"revision",
|
|
14
|
+
"screenId",
|
|
15
|
+
"presentationId",
|
|
16
|
+
"platform",
|
|
17
|
+
"viewport",
|
|
18
|
+
"variantCount",
|
|
19
|
+
"presentationCount",
|
|
20
|
+
"nodeCount",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const DISCOVERY_OUTPUT = Object.freeze({
|
|
24
|
+
encoding: "compact-json",
|
|
25
|
+
itemFields: DISCOVERY_ITEM_FIELDS,
|
|
26
|
+
conditionalItemFields: {
|
|
27
|
+
visibility:
|
|
28
|
+
"Only private: reuse is limited to the owning package; omission means no restriction.",
|
|
29
|
+
deprecated:
|
|
30
|
+
"Only true: avoid new reuse; omission means no deprecation warning.",
|
|
31
|
+
replacement:
|
|
32
|
+
"Only a deprecated asset's replacement {packageId,assetId} when configured.",
|
|
33
|
+
},
|
|
34
|
+
definitions:
|
|
35
|
+
"Descriptions, editable definitions and node trees require a target read or explicit --full.",
|
|
36
|
+
compatibility:
|
|
37
|
+
"Default items use this public allowlist, never stored-object passthrough. Consumers must allow omitted optional fields and use contractRevision to refresh schemas.",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export function reuseStatus(item) {
|
|
41
|
+
return {
|
|
42
|
+
...(item.visibility === "private" ? { visibility: "private" } : {}),
|
|
43
|
+
...(item.deprecated === true ? { deprecated: true } : {}),
|
|
44
|
+
...(item.deprecated === true && item.replacement
|
|
45
|
+
? {
|
|
46
|
+
replacement: {
|
|
47
|
+
packageId: item.replacement.packageId,
|
|
48
|
+
assetId: item.replacement.assetId,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function discoverySummary(item, status = item) {
|
|
56
|
+
const summary = itemSummary(item);
|
|
57
|
+
return {
|
|
58
|
+
...Object.fromEntries(
|
|
59
|
+
DISCOVERY_ITEM_FIELDS.filter(
|
|
60
|
+
(field) => summary[field] !== undefined && summary[field] !== "",
|
|
61
|
+
).map((field) => [
|
|
62
|
+
field,
|
|
63
|
+
field === "viewport"
|
|
64
|
+
? Object.fromEntries(
|
|
65
|
+
["width", "height", "scale"]
|
|
66
|
+
.filter((key) => summary.viewport[key] !== undefined)
|
|
67
|
+
.map((key) => [key, summary.viewport[key]]),
|
|
68
|
+
)
|
|
69
|
+
: summary[field],
|
|
70
|
+
]),
|
|
71
|
+
),
|
|
72
|
+
...reuseStatus(status),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pageItems(items, { limit = 20, offset = 0 } = {}) {
|
|
77
|
+
return {
|
|
78
|
+
items: items.slice(offset, offset + limit),
|
|
79
|
+
page: {
|
|
80
|
+
hasMore: offset + limit < items.length,
|
|
81
|
+
limit,
|
|
82
|
+
offset,
|
|
83
|
+
total: items.length,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Discovery returns identities and counts. Definitions belong to target reads.
|
|
89
|
+
export function itemSummary(item) {
|
|
90
|
+
const fields = [
|
|
91
|
+
"id",
|
|
92
|
+
"name",
|
|
93
|
+
"path",
|
|
94
|
+
"type",
|
|
95
|
+
"kind",
|
|
96
|
+
"category",
|
|
97
|
+
"description",
|
|
98
|
+
"packageId",
|
|
99
|
+
"source",
|
|
100
|
+
"revision",
|
|
101
|
+
"rootId",
|
|
102
|
+
"platform",
|
|
103
|
+
"viewport",
|
|
104
|
+
"screenId",
|
|
105
|
+
"presentationId",
|
|
106
|
+
"mainNodeId",
|
|
107
|
+
"basePresentationId",
|
|
108
|
+
];
|
|
109
|
+
return {
|
|
110
|
+
...Object.fromEntries(
|
|
111
|
+
fields
|
|
112
|
+
.filter((field) => item[field] !== undefined && item[field] !== "")
|
|
113
|
+
.map((field) => [field, item[field]]),
|
|
114
|
+
),
|
|
115
|
+
...reuseStatus(item),
|
|
116
|
+
...(item.variants ? { variantCount: item.variants.length } : {}),
|
|
117
|
+
...(item.presentations
|
|
118
|
+
? { presentationCount: item.presentations.length }
|
|
119
|
+
: {}),
|
|
120
|
+
...(item.nodes ? { nodeCount: Object.keys(item.nodes).length } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function packageSummary(snapshot) {
|
|
125
|
+
const screens = snapshot.manifest.entries.screens.map(
|
|
126
|
+
(entry) => snapshot.entries[entry],
|
|
127
|
+
);
|
|
128
|
+
const presentations = screens.flatMap((screen) => screen.presentations);
|
|
129
|
+
return {
|
|
130
|
+
packageId: snapshot.manifest.packageId,
|
|
131
|
+
name: snapshot.manifest.name,
|
|
132
|
+
role: snapshot.manifest.role,
|
|
133
|
+
formatVersion: snapshot.manifest.formatVersion,
|
|
134
|
+
defaultScreenId: snapshot.manifest.defaultScreenId,
|
|
135
|
+
revision: snapshot.revision,
|
|
136
|
+
counts: {
|
|
137
|
+
components:
|
|
138
|
+
snapshot.domain.componentSets.size +
|
|
139
|
+
snapshot.domain.locatedComponents.size,
|
|
140
|
+
contextAxes: snapshot.domain.contextAxes.size,
|
|
141
|
+
contextProfiles: snapshot.domain.contextProfiles.size,
|
|
142
|
+
flows: snapshot.domain.flows.size,
|
|
143
|
+
nodes: presentations.reduce(
|
|
144
|
+
(total, presentation) => total + Object.keys(presentation.nodes).length,
|
|
145
|
+
0,
|
|
146
|
+
),
|
|
147
|
+
presentations: presentations.length,
|
|
148
|
+
requirements: snapshot.domain.requirements.size,
|
|
149
|
+
scenarios: snapshot.domain.scenarios.size,
|
|
150
|
+
screens: screens.length,
|
|
151
|
+
// Token names; one Token has a value per theme option it sets.
|
|
152
|
+
tokens: new Set([...snapshot.domain.tokens.values()].map((token) => token.path)).size,
|
|
153
|
+
tokenValues: snapshot.domain.tokens.size,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function missing(kind, id) {
|
|
159
|
+
throw new SmallPenError("missing_read_target", `Unknown ${kind}: ${id}`, {
|
|
160
|
+
id,
|
|
161
|
+
kind,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
// A semantic read keeps one flat node list, with child IDs instead of subtrees.
|
|
167
|
+
export function semanticNodes(tree) {
|
|
168
|
+
const nodes = [];
|
|
169
|
+
const visit = (node) => {
|
|
170
|
+
if (!node) return;
|
|
171
|
+
nodes.push({
|
|
172
|
+
...node,
|
|
173
|
+
children: (node.children ?? []).map((child) => child.id),
|
|
174
|
+
});
|
|
175
|
+
for (const child of node.children ?? []) visit(child);
|
|
176
|
+
};
|
|
177
|
+
visit(tree.root);
|
|
178
|
+
return nodes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function viewSummary(
|
|
182
|
+
read,
|
|
183
|
+
{ nodeId, full = false, ...pagination } = {},
|
|
184
|
+
) {
|
|
185
|
+
if (typeof read.result === "string" || read.format === "screenshot")
|
|
186
|
+
return read;
|
|
187
|
+
const projection = read.result;
|
|
188
|
+
const nodes =
|
|
189
|
+
read.format === "semantic"
|
|
190
|
+
? semanticNodes(projection)
|
|
191
|
+
: Object.values(projection.nodes);
|
|
192
|
+
const selected = nodeId ? nodes.filter((node) => node.id === nodeId) : nodes;
|
|
193
|
+
if (nodeId && selected.length === 0) missing("projected node", nodeId);
|
|
194
|
+
const { items, page } = pageItems(
|
|
195
|
+
selected,
|
|
196
|
+
nodeId
|
|
197
|
+
? { limit: 1 }
|
|
198
|
+
: {
|
|
199
|
+
...pagination,
|
|
200
|
+
limit: pagination.limit ?? (full ? selected.length : 20),
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
const result =
|
|
204
|
+
read.format === "semantic"
|
|
205
|
+
? { rootId: projection.root?.id, nodes: items, page }
|
|
206
|
+
: {
|
|
207
|
+
context: projection.context,
|
|
208
|
+
diagnostics: projection.diagnostics,
|
|
209
|
+
fallbackUsed: projection.fallbackUsed,
|
|
210
|
+
nodes: Object.fromEntries(items.map((node) => [node.id, node])),
|
|
211
|
+
page,
|
|
212
|
+
presentation: itemSummary(projection.presentation ?? {}),
|
|
213
|
+
rootId: projection.rootId,
|
|
214
|
+
screen: itemSummary(projection.screen ?? {}),
|
|
215
|
+
};
|
|
216
|
+
const { discovery, ...details } = read;
|
|
217
|
+
return { ...details, ...(!nodeId ? { discovery } : {}), result };
|
|
218
|
+
}
|
|
219
|
+
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir, userInfo } from "node:os";
|
|
12
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
13
|
+
import { SmallPenError } from "@smallpen/core";
|
|
14
|
+
import { secureWindowsDirectories } from "./windows-artifacts.mjs";
|
|
15
|
+
|
|
16
|
+
export const OUTPUT_LIMIT_BYTES = 8192;
|
|
17
|
+
export const RESULT_RETENTION_MS = 30 * 60 * 1000;
|
|
18
|
+
const uuid = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
19
|
+
const role =
|
|
20
|
+
"(?:result|render(?:-matrix-\\d{2})?|outline|spec|wireframe|text|undo|changes|evidence|matrix|validation|flow-connections|flow-verification)";
|
|
21
|
+
const managedName = new RegExp(
|
|
22
|
+
`^(smallpen-\\d{13}-${uuid})-(${role}|owner-(\\d+))\\.(?:png|json|txt)(?:\\.smallpen-(\\d+)-${uuid}\\.tmp)?$`,
|
|
23
|
+
);
|
|
24
|
+
const artifactName = new RegExp(`^${role}\\.(?:png|json|txt)$`);
|
|
25
|
+
let prefix = `smallpen-${Date.now()}-${randomUUID()}`;
|
|
26
|
+
let activeMarker;
|
|
27
|
+
let windowsSecured = false;
|
|
28
|
+
|
|
29
|
+
function directories() {
|
|
30
|
+
const owner = process.getuid
|
|
31
|
+
? `-${process.getuid()}`
|
|
32
|
+
: `-${createHash("sha256").update(JSON.stringify(userInfo())).digest("hex").slice(0, 16)}`;
|
|
33
|
+
const root = join(tmpdir(), `smallpen-cli${owner}`);
|
|
34
|
+
return [root, join(root, "results")];
|
|
35
|
+
}
|
|
36
|
+
function assertPrivate(path) {
|
|
37
|
+
const stat = lstatSync(path);
|
|
38
|
+
if (
|
|
39
|
+
!stat.isDirectory() ||
|
|
40
|
+
(process.getuid &&
|
|
41
|
+
(stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0))
|
|
42
|
+
) {
|
|
43
|
+
throw new SmallPenError(
|
|
44
|
+
"invalid_artifact_directory",
|
|
45
|
+
"CLI temporary directory must be an owned private directory",
|
|
46
|
+
{ path },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function artifactDirectory({ create = false, validate = false } = {}) {
|
|
51
|
+
const paths = directories();
|
|
52
|
+
const windowsItems = [];
|
|
53
|
+
for (const path of create || validate ? paths : []) {
|
|
54
|
+
let created = false;
|
|
55
|
+
try {
|
|
56
|
+
if (create) {
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(path, { mode: 0o700 });
|
|
59
|
+
created = true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code !== "EEXIST") throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
assertPrivate(path);
|
|
65
|
+
windowsItems.push({ path, created });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (
|
|
68
|
+
error instanceof SmallPenError ||
|
|
69
|
+
(!create && error.code === "ENOENT")
|
|
70
|
+
)
|
|
71
|
+
throw error;
|
|
72
|
+
throw new SmallPenError(
|
|
73
|
+
"artifact_directory_unavailable",
|
|
74
|
+
"CLI temporary directory is not writable or accessible",
|
|
75
|
+
{ path, reason: error.code },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (process.platform === "win32" && windowsItems.length && !windowsSecured) {
|
|
80
|
+
secureWindowsDirectories(windowsItems);
|
|
81
|
+
windowsSecured = true;
|
|
82
|
+
}
|
|
83
|
+
return paths.at(-1);
|
|
84
|
+
}
|
|
85
|
+
function realDestination(path) {
|
|
86
|
+
let ancestor = path;
|
|
87
|
+
while (true) {
|
|
88
|
+
try {
|
|
89
|
+
return join(realpathSync(ancestor), relative(ancestor, path));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (
|
|
92
|
+
!["ENOENT", "ENOTDIR"].includes(error.code) ||
|
|
93
|
+
dirname(ancestor) === ancestor
|
|
94
|
+
)
|
|
95
|
+
throw error;
|
|
96
|
+
ancestor = dirname(ancestor);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function ownerAlive(pid) {
|
|
101
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return error.code !== "ESRCH";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function cleanupExpired(directory) {
|
|
110
|
+
const entries = readdirSync(directory).flatMap((name) => {
|
|
111
|
+
const match = name.match(managedName);
|
|
112
|
+
if (!match) return [];
|
|
113
|
+
const path = join(directory, name);
|
|
114
|
+
try {
|
|
115
|
+
const stat = lstatSync(path);
|
|
116
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) return [];
|
|
117
|
+
return [{ path, match, stat }];
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error.code === "ENOENT") return [];
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const active = new Set(
|
|
124
|
+
entries
|
|
125
|
+
.filter(({ match }) => match[3] && ownerAlive(Number(match[3])))
|
|
126
|
+
.map(({ match }) => match[1]),
|
|
127
|
+
);
|
|
128
|
+
const cutoff = Date.now() - RESULT_RETENTION_MS;
|
|
129
|
+
for (const { path, match, stat } of entries) {
|
|
130
|
+
if (
|
|
131
|
+
stat.mtimeMs >= cutoff ||
|
|
132
|
+
active.has(match[1]) ||
|
|
133
|
+
(match[4] && ownerAlive(Number(match[4])))
|
|
134
|
+
)
|
|
135
|
+
continue;
|
|
136
|
+
try {
|
|
137
|
+
rmSync(path, { force: true });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error.code !== "ENOENT")
|
|
140
|
+
throw new SmallPenError(
|
|
141
|
+
"artifact_cleanup_failed",
|
|
142
|
+
"An expired CLI temporary file could not be removed",
|
|
143
|
+
{ path, reason: error.code },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Every invocation has unique names in one folder. Only expired recognized CLI
|
|
150
|
+
// files are cleaned; caller-owned paths and unrelated files are never removed.
|
|
151
|
+
export function beginArtifacts(_command, args = []) {
|
|
152
|
+
prefix = `smallpen-${Date.now()}-${randomUUID()}`;
|
|
153
|
+
const root = realDestination(directories()[0]);
|
|
154
|
+
for (const option of ["--output", "--inverse-out"]) {
|
|
155
|
+
const index = args.indexOf(option);
|
|
156
|
+
if (index < 0 || !args[index + 1] || args[index + 1].startsWith("--"))
|
|
157
|
+
continue;
|
|
158
|
+
const path = resolve(args[index + 1]);
|
|
159
|
+
const child = relative(root, realDestination(path));
|
|
160
|
+
if (
|
|
161
|
+
!child ||
|
|
162
|
+
(!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`))
|
|
163
|
+
) {
|
|
164
|
+
throw new SmallPenError(
|
|
165
|
+
"reserved_output_path",
|
|
166
|
+
"Choose a retained output path outside the CLI-managed temporary tree",
|
|
167
|
+
{ option, path },
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
let directory;
|
|
172
|
+
try {
|
|
173
|
+
directory = artifactDirectory({ validate: true });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.code === "ENOENT") return;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
cleanupExpired(directory);
|
|
179
|
+
}
|
|
180
|
+
export function artifactPath(kind, extension) {
|
|
181
|
+
if (!artifactName.test(`${kind}.${extension}`))
|
|
182
|
+
throw new Error(`Unknown CLI artifact: ${kind}.${extension}`);
|
|
183
|
+
const directory = artifactDirectory({ create: true });
|
|
184
|
+
if (!activeMarker) {
|
|
185
|
+
activeMarker = join(directory, `${prefix}-owner-${process.pid}.json`);
|
|
186
|
+
writeFileSync(activeMarker, "", { mode: 0o600, flag: "wx" });
|
|
187
|
+
}
|
|
188
|
+
return join(directory, `${prefix}-${kind}.${extension}`);
|
|
189
|
+
}
|
|
190
|
+
export function releaseArtifacts() {
|
|
191
|
+
if (!activeMarker) return;
|
|
192
|
+
try {
|
|
193
|
+
rmSync(activeMarker, { force: true });
|
|
194
|
+
} catch {}
|
|
195
|
+
activeMarker = undefined;
|
|
196
|
+
}
|
|
197
|
+
process.once("exit", releaseArtifacts);
|
|
198
|
+
export function saveResult(bytes, extension = "json") {
|
|
199
|
+
const path = artifactPath("result", extension);
|
|
200
|
+
const temporary = `${path}.smallpen-${process.pid}-${randomUUID()}.tmp`;
|
|
201
|
+
try {
|
|
202
|
+
writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" });
|
|
203
|
+
renameSync(temporary, path);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
rmSync(temporary, { force: true });
|
|
206
|
+
throw new SmallPenError(
|
|
207
|
+
"result_output_failed",
|
|
208
|
+
"Complete result could not be saved to the CLI temporary directory",
|
|
209
|
+
{ path, reason: error.code },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
path,
|
|
214
|
+
format: extension,
|
|
215
|
+
bytes: Buffer.byteLength(bytes),
|
|
216
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
217
|
+
expiresAt: new Date(Date.now() + RESULT_RETENTION_MS).toISOString(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export function printText(text) {
|
|
221
|
+
if (
|
|
222
|
+
Buffer.byteLength(text) <= OUTPUT_LIMIT_BYTES ||
|
|
223
|
+
process.argv.includes("--stdout")
|
|
224
|
+
) {
|
|
225
|
+
process.stdout.write(text);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const file = saveResult(text, "txt");
|
|
229
|
+
process.stdout.write(
|
|
230
|
+
`Complete text: ${file.path}\nBytes: ${file.bytes}; SHA-256: ${file.sha256}\nTemporary result: 30 minutes. Read/search it with your usual file tools, or request --stdout.\n`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { canonicalJSON, listEffectiveTokens } from "@smallpen/core";
|
|
2
|
+
|
|
3
|
+
// Only this batch's new definitions are compared. Same-name Tokens in other
|
|
4
|
+
// options are intentional theme values, not duplicate-token suggestions.
|
|
5
|
+
export function definitionReuseAdvice(before, batch, options) {
|
|
6
|
+
if (
|
|
7
|
+
!batch.operations.some((operation) =>
|
|
8
|
+
["put-set-token", "put-component-set"].includes(operation.type),
|
|
9
|
+
)
|
|
10
|
+
)
|
|
11
|
+
return [];
|
|
12
|
+
const items = [];
|
|
13
|
+
const tokens = listEffectiveTokens(before, options);
|
|
14
|
+
const components = [before, options.foundation]
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.flatMap((snapshot) =>
|
|
17
|
+
[...snapshot.domain.componentSets.values()].map((component) => ({
|
|
18
|
+
...component,
|
|
19
|
+
packageId: snapshot.manifest.packageId,
|
|
20
|
+
})),
|
|
21
|
+
);
|
|
22
|
+
for (const [operationIndex, operation] of batch.operations.entries()) {
|
|
23
|
+
if (
|
|
24
|
+
operation.type === "put-set-token" &&
|
|
25
|
+
!before.domain.tokens.has(operation.token.id)
|
|
26
|
+
) {
|
|
27
|
+
const token = operation.token;
|
|
28
|
+
const similar = tokens.filter(
|
|
29
|
+
(item) =>
|
|
30
|
+
item.token.path !== token.name &&
|
|
31
|
+
item.token.type === token.type &&
|
|
32
|
+
canonicalJSON(item.value) === canonicalJSON(token.value),
|
|
33
|
+
);
|
|
34
|
+
if (similar.length)
|
|
35
|
+
items.push({
|
|
36
|
+
code: "existing_token_value",
|
|
37
|
+
operationIndex,
|
|
38
|
+
tokenId: token.id,
|
|
39
|
+
message:
|
|
40
|
+
"An existing Token has this type and value; consider reuse or a semantic alias",
|
|
41
|
+
candidates: similar
|
|
42
|
+
.slice(0, 3)
|
|
43
|
+
.map(({ target, token }) => ({ target, path: token.path })),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
operation.type === "put-component-set" &&
|
|
48
|
+
!before.domain.componentSets.has(operation.componentSet.id)
|
|
49
|
+
) {
|
|
50
|
+
const component = operation.componentSet;
|
|
51
|
+
const normalize = (name) => name.toLowerCase().replace(/[\s_-]+/g, "");
|
|
52
|
+
const name = normalize(component.name);
|
|
53
|
+
const similar = components.filter(
|
|
54
|
+
(candidate) =>
|
|
55
|
+
candidate.id !== component.id &&
|
|
56
|
+
(normalize(candidate.name).includes(name) ||
|
|
57
|
+
name.includes(normalize(candidate.name))),
|
|
58
|
+
);
|
|
59
|
+
if (similar.length)
|
|
60
|
+
items.push({
|
|
61
|
+
code: "similar_component",
|
|
62
|
+
operationIndex,
|
|
63
|
+
componentId: component.id,
|
|
64
|
+
message:
|
|
65
|
+
"A similarly named component exists; inspect it before adding another",
|
|
66
|
+
candidates: similar
|
|
67
|
+
.slice(0, 3)
|
|
68
|
+
.map(({ id, name, packageId }) => ({ id, name, packageId })),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return items;
|
|
73
|
+
}
|