@tsplc/compiler 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +16 -2
- package/backends/codesys/config.ts +54 -0
- package/backends/codesys/emit.ts +1063 -0
- package/backends/codesys/engineering.ts +88 -0
- package/backends/codesys/http.ts +598 -0
- package/backends/codesys/index.ts +12 -0
- package/backends/codesys/io-paths.ts +103 -0
- package/backends/codesys/names.ts +76 -0
- package/backends/codesys/numeric.ts +356 -0
- package/backends/codesys/plan.ts +512 -0
- package/backends/codesys/publication.ts +59 -0
- package/backends/codesys/storage.ts +63 -0
- package/backends/codesys/task-access.ts +118 -0
- package/backends/codesys/types.ts +178 -0
- package/backends/codesys/vendor.ts +108 -0
- package/dist/cli.js +740 -0
- package/dist/cli.js.map +13 -0
- package/dist/clock.js +8 -0
- package/dist/clock.js.map +10 -0
- package/dist/codesys.js +2587 -0
- package/dist/codesys.js.map +22 -0
- package/dist/elaborate-capture.js +118 -0
- package/dist/elaborate-capture.js.map +10 -0
- package/dist/index.js +6859 -0
- package/dist/index.js.map +25 -0
- package/dist/ir.js +1888 -0
- package/dist/ir.js.map +22 -0
- package/dist/math.js +24 -0
- package/dist/math.js.map +10 -0
- package/dist/project.js +423 -0
- package/dist/project.js.map +11 -0
- package/ir/aggregates.ts +333 -0
- package/ir/allocation.ts +74 -0
- package/ir/core-model.ts +116 -0
- package/ir/core.ts +63 -0
- package/ir/declarations.ts +201 -0
- package/ir/diagnostics.ts +43 -0
- package/ir/domains.ts +96 -0
- package/ir/index.ts +7 -0
- package/ir/link.ts +176 -0
- package/ir/model.ts +268 -0
- package/ir/normalization-error.ts +10 -0
- package/ir/routine.ts +826 -0
- package/ir/scalars.ts +675 -0
- package/ir/types.ts +70 -0
- package/package.json +105 -4
- package/project/artifacts.ts +277 -0
- package/project/cli.ts +282 -0
- package/project/index.ts +1 -0
- package/project/project.ts +298 -0
- package/project/server.ts +83 -0
- package/src/frontend/async.ts +1060 -0
- package/src/frontend/blocks.ts +419 -0
- package/src/frontend/configuration.ts +167 -0
- package/src/frontend/const-functions.ts +153 -0
- package/src/frontend/cooperative.ts +75 -0
- package/src/frontend/elaborate/capture.ts +138 -0
- package/src/frontend/elaborate/graph.ts +94 -0
- package/src/frontend/elaborate/index.ts +302 -0
- package/src/frontend/elaborate/layouts.ts +390 -0
- package/src/frontend/elaborate/model.ts +177 -0
- package/src/frontend/elaborate/specialize.ts +2280 -0
- package/src/frontend/exposure.ts +147 -0
- package/src/frontend/module-graph.ts +63 -0
- package/src/frontend/promises.ts +751 -0
- package/src/frontend/services.ts +435 -0
- package/src/frontend/types.ts +578 -0
- package/src/frontend/typescript.ts +2442 -0
- package/src/index.ts +7 -0
- package/stdlib/clock.ts +2 -0
- package/stdlib/math.ts +19 -0
- package/types/async.d.ts +81 -0
- package/types/clock.d.ts +4 -0
- package/types/config.d.ts +2 -0
- package/types/data-types.d.ts +31 -0
- package/types/globals.d.ts +40 -0
- package/types/index.d.ts +111 -0
- package/types/plc.d.ts +5 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import {
|
|
2
|
+
instancedBlock,
|
|
3
|
+
type Constant,
|
|
4
|
+
type CoreExpression,
|
|
5
|
+
type CorePath,
|
|
6
|
+
type CoreStatement,
|
|
7
|
+
type Deployment,
|
|
8
|
+
type Diagnostic,
|
|
9
|
+
isVerifiedCore,
|
|
10
|
+
type PlcType,
|
|
11
|
+
type SourceSpan,
|
|
12
|
+
type VerifiedCoreModule,
|
|
13
|
+
} from "@tsplc/compiler/ir";
|
|
14
|
+
import { type CodesysOptions, optionsSchema } from "./config.ts";
|
|
15
|
+
import { ioLayout } from "@tsplc/typegen/hardware";
|
|
16
|
+
import { iecSpelling, isStoragePath, resolveStoragePath, type StorageBinding } from "./io-paths.ts";
|
|
17
|
+
import { isIecIdentifier } from "./names.ts";
|
|
18
|
+
import { storageLayout } from "./storage.ts";
|
|
19
|
+
import { validateHttpLayout, resolveHttpEndpoint, type HttpEndpoint } from "./http.ts";
|
|
20
|
+
import { accessedStorage } from "./task-access.ts";
|
|
21
|
+
import { resolvePublication } from "./publication.ts";
|
|
22
|
+
import type { Publication } from "@tsplc/config";
|
|
23
|
+
|
|
24
|
+
// Only this planner can admit a target configuration for emission. Cloning a
|
|
25
|
+
// branded-looking object does not give it membership in this private identity set.
|
|
26
|
+
const planned: unique symbol = Symbol("CODESYS plan");
|
|
27
|
+
export interface CodesysPlan {
|
|
28
|
+
readonly [planned]: true;
|
|
29
|
+
readonly core: VerifiedCoreModule;
|
|
30
|
+
readonly deployment: Deployment;
|
|
31
|
+
readonly options: CodesysOptions;
|
|
32
|
+
/** `io` keys that name authored storage, resolved against the verified types. */
|
|
33
|
+
readonly storageBindings: readonly StorageBinding[];
|
|
34
|
+
readonly publication: Publication;
|
|
35
|
+
readonly http: Readonly<Record<string, HttpEndpoint>>;
|
|
36
|
+
}
|
|
37
|
+
const plans = new WeakSet<object>();
|
|
38
|
+
export function diagnostic(message: string, span?: SourceSpan): Diagnostic {
|
|
39
|
+
return { origin: "backend", code: "PLC7001", severity: "error", message, ...(span && { span }) };
|
|
40
|
+
}
|
|
41
|
+
export function isCodesysPlan(plan: CodesysPlan): boolean {
|
|
42
|
+
return plans.has(plan);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function planCodesys(
|
|
46
|
+
core: VerifiedCoreModule,
|
|
47
|
+
deployment: Deployment,
|
|
48
|
+
value: unknown,
|
|
49
|
+
): { plan?: CodesysPlan; diagnostics: readonly Diagnostic[] } {
|
|
50
|
+
if (!isVerifiedCore(core))
|
|
51
|
+
return { diagnostics: [diagnostic("CODESYS requires a verified, normalized core module.")] };
|
|
52
|
+
|
|
53
|
+
const parsed = optionsSchema.safeParse(value);
|
|
54
|
+
if (!parsed.success)
|
|
55
|
+
return {
|
|
56
|
+
diagnostics: parsed.error.issues.map((issue) =>
|
|
57
|
+
diagnostic(`${issue.path.join(".")}: ${issue.message}`),
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const backgroundTasks = new Map<string, Deployment["tasks"][number]>();
|
|
62
|
+
for (const program of core.programs) {
|
|
63
|
+
if (!program.background) continue;
|
|
64
|
+
const { priority, intervalNanoseconds } = program.background;
|
|
65
|
+
const name = `TsplcBackground_${priority}_${BigInt(intervalNanoseconds) / 1000000n}`;
|
|
66
|
+
const existing = backgroundTasks.get(name);
|
|
67
|
+
backgroundTasks.set(name, {
|
|
68
|
+
name,
|
|
69
|
+
priority,
|
|
70
|
+
intervalNanoseconds,
|
|
71
|
+
programs: [...(existing?.programs ?? []), program.name],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
deployment = { tasks: [...deployment.tasks, ...backgroundTasks.values()] };
|
|
75
|
+
const options = parsed.data;
|
|
76
|
+
const diagnostics: Diagnostic[] = [];
|
|
77
|
+
const report = (message: string, span?: SourceSpan): void => {
|
|
78
|
+
diagnostics.push(diagnostic(message, span));
|
|
79
|
+
};
|
|
80
|
+
const callerTasks = deployment.tasks;
|
|
81
|
+
for (const continuation of core.programs.filter((program) => program.continuation)) {
|
|
82
|
+
const scope = continuation.continuation!;
|
|
83
|
+
const root = core.variables.find(
|
|
84
|
+
(variable) => variable.name === scope.instance && variable.span.file === scope.file,
|
|
85
|
+
);
|
|
86
|
+
if (!root) {
|
|
87
|
+
report(`Missing continuation owner ${scope.instance}.`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const owners = callerTasks.filter((task) =>
|
|
91
|
+
task.programs.some((name) => {
|
|
92
|
+
const program = core.programs.find((program) => program.name === name);
|
|
93
|
+
if (!program || program.background) return false;
|
|
94
|
+
const fn = core.functions.find((fn) => fn.id === program.entry);
|
|
95
|
+
return fn && accessedStorage(fn, core).has(root.id);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
if (owners.length !== 1) {
|
|
99
|
+
report(
|
|
100
|
+
`Continuation owner ${scope.instance} must belong to exactly one caller task.`,
|
|
101
|
+
root.span,
|
|
102
|
+
);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
deployment = {
|
|
106
|
+
tasks: deployment.tasks.map((task) =>
|
|
107
|
+
task.name === owners[0]!.name
|
|
108
|
+
? { ...task, programs: [...task.programs, continuation.name] }
|
|
109
|
+
: task,
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const http = new Map<string, HttpEndpoint>();
|
|
114
|
+
for (const block of core.blocks)
|
|
115
|
+
if (block.runtime?.kind === "http" || block.runtime?.kind === "rpc") {
|
|
116
|
+
try {
|
|
117
|
+
validateHttpLayout(block, new Map(core.types.map((type) => [type.id, type])));
|
|
118
|
+
http.set(
|
|
119
|
+
block.id,
|
|
120
|
+
block.runtime.kind === "rpc"
|
|
121
|
+
? {
|
|
122
|
+
transport: "opcua",
|
|
123
|
+
host: "",
|
|
124
|
+
port: 0,
|
|
125
|
+
authority: "",
|
|
126
|
+
path: block.runtime.path,
|
|
127
|
+
token: "",
|
|
128
|
+
timeoutMs: options.http.timeoutMs,
|
|
129
|
+
tls: false,
|
|
130
|
+
}
|
|
131
|
+
: resolveHttpEndpoint(block.runtime.url, options.http),
|
|
132
|
+
);
|
|
133
|
+
} catch (cause) {
|
|
134
|
+
report(cause instanceof Error ? cause.message : String(cause), block.span);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const storageBindings = validateIo(core, options, report);
|
|
139
|
+
validateTasks(core, deployment, report);
|
|
140
|
+
validateStorage(core, options, report);
|
|
141
|
+
validateConstants(core, report);
|
|
142
|
+
const { publication, errors } = resolvePublication(core, options.publish);
|
|
143
|
+
for (const error of errors) report(error);
|
|
144
|
+
|
|
145
|
+
if (diagnostics.length) return { diagnostics };
|
|
146
|
+
|
|
147
|
+
const plan: CodesysPlan = Object.freeze({
|
|
148
|
+
core,
|
|
149
|
+
deployment: structuredClone(deployment),
|
|
150
|
+
options: structuredClone(options),
|
|
151
|
+
storageBindings,
|
|
152
|
+
publication,
|
|
153
|
+
http: Object.fromEntries(http),
|
|
154
|
+
[planned]: true as const,
|
|
155
|
+
});
|
|
156
|
+
// Parsed options can contain nested engineering settings; detach and freeze the complete plan.
|
|
157
|
+
const freeze = (value: unknown): void => {
|
|
158
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return;
|
|
159
|
+
for (const child of Object.values(value)) freeze(child);
|
|
160
|
+
Object.freeze(value);
|
|
161
|
+
};
|
|
162
|
+
freeze(plan.deployment);
|
|
163
|
+
freeze(plan.options);
|
|
164
|
+
freeze(plan.storageBindings);
|
|
165
|
+
freeze(plan.publication);
|
|
166
|
+
plans.add(plan);
|
|
167
|
+
return { plan, diagnostics: [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type Report = (message: string, span?: SourceSpan) => void;
|
|
171
|
+
|
|
172
|
+
/** Validate every `io` binding and return the ones that name authored storage. */
|
|
173
|
+
function validateIo(
|
|
174
|
+
core: VerifiedCoreModule,
|
|
175
|
+
options: CodesysOptions,
|
|
176
|
+
report: Report,
|
|
177
|
+
): StorageBinding[] {
|
|
178
|
+
// Compare physical bindings as bit intervals, regardless of their spelling.
|
|
179
|
+
// Different widths can still overlap.
|
|
180
|
+
const types = new Map(core.types.map((type) => [type.id, type]));
|
|
181
|
+
const targets: {
|
|
182
|
+
name: string;
|
|
183
|
+
binding: CodesysOptions["io"][string];
|
|
184
|
+
type: PlcType;
|
|
185
|
+
region: "I" | "Q";
|
|
186
|
+
span?: SourceSpan;
|
|
187
|
+
}[] = [];
|
|
188
|
+
const names = new Set<string>();
|
|
189
|
+
for (const item of core.variables.filter(
|
|
190
|
+
(item) => item.storage === "input" || item.storage === "output",
|
|
191
|
+
)) {
|
|
192
|
+
if (names.has(item.name))
|
|
193
|
+
report(`Ambiguous I/O name ${item.name}; logical I/O names must be unique.`, item.span);
|
|
194
|
+
names.add(item.name);
|
|
195
|
+
const binding = Object.hasOwn(options.io, item.name) ? options.io[item.name] : undefined;
|
|
196
|
+
if (!binding) {
|
|
197
|
+
report(`Missing I/O binding for ${item.name}.`, item.span);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
targets.push({
|
|
201
|
+
name: item.name,
|
|
202
|
+
binding,
|
|
203
|
+
type: types.get(item.type)!,
|
|
204
|
+
region: item.storage === "input" ? "I" : "Q",
|
|
205
|
+
span: item.span,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const scheduled = new Set<string>();
|
|
210
|
+
for (const program of core.programs) {
|
|
211
|
+
const fn = core.functions.find((item) => item.id === program.entry)!;
|
|
212
|
+
for (const id of accessedStorage(fn, core)) scheduled.add(id);
|
|
213
|
+
}
|
|
214
|
+
const storageBindings: StorageBinding[] = [];
|
|
215
|
+
for (const [name, binding] of Object.entries(options.io)) {
|
|
216
|
+
if (names.has(name)) continue;
|
|
217
|
+
if (!isStoragePath(name)) {
|
|
218
|
+
report(`Unknown I/O binding ${name}.`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const resolved = resolveStoragePath(core, name);
|
|
222
|
+
if ("error" in resolved) {
|
|
223
|
+
report(resolved.error);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const overlapping = storageBindings.find((other) => nested(other, resolved));
|
|
227
|
+
if (overlapping) report(`${name} overlaps the binding ${overlapping.key}.`);
|
|
228
|
+
if (!scheduled.has(resolved.root.id))
|
|
229
|
+
report(
|
|
230
|
+
`${name}: no scheduled program uses ${resolved.root.name}, so it would never be copied.`,
|
|
231
|
+
);
|
|
232
|
+
storageBindings.push(resolved);
|
|
233
|
+
targets.push({
|
|
234
|
+
name,
|
|
235
|
+
binding,
|
|
236
|
+
type: resolved.type,
|
|
237
|
+
region: binding.address[1] === "I" ? "I" : "Q",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const addresses: {
|
|
242
|
+
region: string;
|
|
243
|
+
start: bigint;
|
|
244
|
+
end: bigint;
|
|
245
|
+
name: string;
|
|
246
|
+
shared?: boolean;
|
|
247
|
+
}[] = [];
|
|
248
|
+
for (const { name, binding, type, region, span } of targets) {
|
|
249
|
+
// An array binds at its first element; the remaining elements follow at
|
|
250
|
+
// consecutive addresses of the same width, like an EDS byte/word span.
|
|
251
|
+
const array = type.kind === "array" ? type : undefined;
|
|
252
|
+
const element = array ? types.get(array.element) : type;
|
|
253
|
+
// Typegen spells channels as IEC types; equal layouts bind (an EDS UINT flag word into a WORD).
|
|
254
|
+
const layout = ioLayout(iecSpelling(type, types) ?? "");
|
|
255
|
+
if (binding.type && (!layout || ioLayout(binding.type) !== layout))
|
|
256
|
+
report(`I/O type mismatch for ${name}: ${type.id} cannot bind to ${binding.type}.`, span);
|
|
257
|
+
if (!binding.address.startsWith(`%${region}`))
|
|
258
|
+
report(`Wrong address region for ${name}: expected %${region}X.`, span);
|
|
259
|
+
const match = /^%([IQ])([XBWD])([0-9]+)(?:\.([0-7]))?$/.exec(binding.address);
|
|
260
|
+
if (!match) continue;
|
|
261
|
+
if (region === "I" && element?.kind === "float")
|
|
262
|
+
report(
|
|
263
|
+
"Floating logical inputs require an explicit runtime nonfinite policy, which is not supported yet.",
|
|
264
|
+
span,
|
|
265
|
+
);
|
|
266
|
+
if (array && element?.kind === "bool")
|
|
267
|
+
report(
|
|
268
|
+
`BOOL arrays are not bit-packed in CODESYS I/O; bind each element of ${name} separately.`,
|
|
269
|
+
span,
|
|
270
|
+
);
|
|
271
|
+
const size = match[2] === "X" ? 1 : match[2] === "B" ? 8 : match[2] === "W" ? 16 : 32;
|
|
272
|
+
if (ioBits(element) !== size) report(`Address size does not match ${name}:${type.id}.`, span);
|
|
273
|
+
// CODESYS numbers %IW/%ID by element: %IW12 is bytes 24-25. See verify.py.
|
|
274
|
+
const unit = size === 1 ? 8n : BigInt(size);
|
|
275
|
+
const offset = BigInt(match[3]!);
|
|
276
|
+
const start = offset * unit + BigInt(match[4] ?? 0),
|
|
277
|
+
end = start + BigInt(size) * BigInt(array?.length ?? 1);
|
|
278
|
+
if (binding.inverted && (region !== "I" || element?.kind !== "bool" || array))
|
|
279
|
+
report(`Only a BOOL input can be inverted: ${name}.`, span);
|
|
280
|
+
if (binding.shared && region !== "I") report(`Only an input can be shared: ${name}.`, span);
|
|
281
|
+
const collision = addresses.find(
|
|
282
|
+
(address) =>
|
|
283
|
+
address.region === match[1] &&
|
|
284
|
+
address.start < end &&
|
|
285
|
+
start < address.end &&
|
|
286
|
+
!(binding.shared && address.shared && address.start === start && address.end === end),
|
|
287
|
+
);
|
|
288
|
+
if (collision)
|
|
289
|
+
report(`Overlapping I/O address ${binding.address} with ${collision.name}.`, span);
|
|
290
|
+
addresses.push({ region: match[1]!, start, end, name, shared: binding.shared });
|
|
291
|
+
if (offset > 0x7fff_ffffn) report(`I/O offset is too large: ${binding.address}.`, span);
|
|
292
|
+
}
|
|
293
|
+
return storageBindings;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Two storage bindings conflict when one target contains the other. */
|
|
297
|
+
function nested(left: StorageBinding, right: StorageBinding): boolean {
|
|
298
|
+
return (
|
|
299
|
+
left.root.id === right.root.id &&
|
|
300
|
+
left.segments.every((a, index) => {
|
|
301
|
+
const b = right.segments[index];
|
|
302
|
+
if (!b) return true;
|
|
303
|
+
return a.kind === "field"
|
|
304
|
+
? b.kind === "field" && a.name === b.name
|
|
305
|
+
: b.kind === "index" && a.value === b.value;
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Width of one bound element; BOOL is a single bit, numeric scalars their storage width. */
|
|
311
|
+
function ioBits(type: PlcType | undefined): number {
|
|
312
|
+
if (type?.kind === "bool") return 1;
|
|
313
|
+
return type && "bits" in type ? type.bits : 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function validateTasks(core: VerifiedCoreModule, deployment: Deployment, report: Report): void {
|
|
317
|
+
// One cyclic task owns each mutable storage root, including transitive calls
|
|
318
|
+
// and borrowed globals. Sharing state within an ordered task remains valid.
|
|
319
|
+
const programs = new Map(core.programs.map((program) => [program.name, program]));
|
|
320
|
+
const assignment = new Map<string, string>();
|
|
321
|
+
const taskNames = new Set<string>();
|
|
322
|
+
const stateOwners = new Map<string, string>();
|
|
323
|
+
const globals = new Map(core.variables.map((item) => [item.id, item]));
|
|
324
|
+
const functions = new Map(core.functions.map((item) => [item.id, item]));
|
|
325
|
+
const blockRuntimes = new Map(
|
|
326
|
+
core.types.flatMap((type) =>
|
|
327
|
+
type.kind === "block"
|
|
328
|
+
? [[type.id, core.blocks.find((block) => block.id === type.block)?.runtime?.kind] as const]
|
|
329
|
+
: [],
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
for (const task of deployment.tasks) {
|
|
333
|
+
if (!isIecIdentifier(task.name)) report(`Invalid task name ${task.name}.`);
|
|
334
|
+
if (taskNames.has(task.name.toUpperCase())) report(`Duplicate task name ${task.name}.`);
|
|
335
|
+
taskNames.add(task.name.toUpperCase());
|
|
336
|
+
if (!Number.isInteger(task.priority) || task.priority < 0 || task.priority > 31)
|
|
337
|
+
report(`Task ${task.name} priority must be 0..31.`);
|
|
338
|
+
if (!/^[1-9][0-9]*$/.test(task.intervalNanoseconds))
|
|
339
|
+
report(`Invalid task interval for ${task.name}.`);
|
|
340
|
+
else {
|
|
341
|
+
const ns = BigInt(task.intervalNanoseconds);
|
|
342
|
+
if (ns % 1_000_000n || ns > 4_294_967_295_000_000n)
|
|
343
|
+
report(`Task ${task.name} requires an exact positive TIME interval in milliseconds.`);
|
|
344
|
+
}
|
|
345
|
+
if (!task.programs.length) report(`Task ${task.name} has no programs.`);
|
|
346
|
+
for (const name of task.programs) {
|
|
347
|
+
const program = programs.get(name);
|
|
348
|
+
if (!program) {
|
|
349
|
+
report(`Unknown task program ${name}.`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (assignment.has(name)) report(`Program ${name} must belong to exactly one task.`);
|
|
353
|
+
assignment.set(name, task.name);
|
|
354
|
+
const fn = functions.get(program.entry);
|
|
355
|
+
if (!fn) throw new Error("Verified core has a missing entry.");
|
|
356
|
+
const host =
|
|
357
|
+
program.background &&
|
|
358
|
+
core.variables.find(
|
|
359
|
+
(item) =>
|
|
360
|
+
item.name === program.background?.worker &&
|
|
361
|
+
item.span.file === fn.span.file &&
|
|
362
|
+
blockRuntimes.get(item.type) === "backgroundHost",
|
|
363
|
+
);
|
|
364
|
+
for (const id of accessedStorage(fn, core, host ? host.id : undefined)) {
|
|
365
|
+
const item = globals.get(id);
|
|
366
|
+
if (!item || item.storage === "constant") continue;
|
|
367
|
+
if (blockRuntimes.get(item.type) === "mailbox") continue;
|
|
368
|
+
const ownsWorker =
|
|
369
|
+
program.background?.worker === item.name && item.span.file === fn.span.file;
|
|
370
|
+
if (program.background && (!ownsWorker || item.id === host?.id))
|
|
371
|
+
report(
|
|
372
|
+
`Background task ${program.name} accesses scan-owned storage ${item.name}; pass value snapshots through the mailbox.`,
|
|
373
|
+
item.span,
|
|
374
|
+
);
|
|
375
|
+
const owner = stateOwners.get(id);
|
|
376
|
+
if (owner && owner !== task.name)
|
|
377
|
+
report(
|
|
378
|
+
`Storage ${item.name} is shared across cyclic tasks ${owner} and ${task.name}.`,
|
|
379
|
+
item.span,
|
|
380
|
+
);
|
|
381
|
+
stateOwners.set(id, task.name);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const name of programs.keys())
|
|
386
|
+
if (!assignment.has(name)) report(`Program ${name} has no cyclic task.`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function validateStorage(core: VerifiedCoreModule, options: CodesysOptions, report: Report): void {
|
|
390
|
+
const programs = new Map(core.programs.map((program) => [program.name, program]));
|
|
391
|
+
if (core.blocks.some((block) => block.standard === "Blinker")) {
|
|
392
|
+
for (const target of Object.values(options.programNames ?? {})) {
|
|
393
|
+
if (target.toUpperCase() === "UTIL")
|
|
394
|
+
report(`IEC object name ${target} shadows the Util library namespace required by Blinker.`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const explicitNames = new Set<string>();
|
|
398
|
+
for (const [name, target] of Object.entries(options.programNames ?? {})) {
|
|
399
|
+
if (!programs.has(name)) report(`Unknown program name override ${name}.`);
|
|
400
|
+
if (explicitNames.has(target.toUpperCase())) report(`Duplicate IEC program name ${target}.`);
|
|
401
|
+
explicitNames.add(target.toUpperCase());
|
|
402
|
+
}
|
|
403
|
+
const types = new Map(core.types.map((type) => [type.id, type]));
|
|
404
|
+
const blocks = new Map(core.blocks.map((block) => [block.id, block]));
|
|
405
|
+
|
|
406
|
+
// InOut addresses must not survive a retained restart, including nested children.
|
|
407
|
+
const blockHasInOut = (id: string): boolean => {
|
|
408
|
+
const block = blocks.get(id)!;
|
|
409
|
+
return (
|
|
410
|
+
block.inputs.some((input) => input.mode === "inOut") ||
|
|
411
|
+
block.state.some((field) => {
|
|
412
|
+
const type = instancedBlock(field.type, types);
|
|
413
|
+
return type !== undefined && blockHasInOut(type.block);
|
|
414
|
+
})
|
|
415
|
+
);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const layout = storageLayout(core);
|
|
419
|
+
for (const block of core.blocks)
|
|
420
|
+
if (layout.retainedBlocks.has(block.id) && blockHasInOut(block.id))
|
|
421
|
+
report(
|
|
422
|
+
"Retained function blocks cannot contain direct or nested InOut references.",
|
|
423
|
+
block.span,
|
|
424
|
+
);
|
|
425
|
+
for (const variable of core.variables) {
|
|
426
|
+
const type = instancedBlock(variable.type, types);
|
|
427
|
+
if (variable.retain && type && blockHasInOut(type.block))
|
|
428
|
+
report(
|
|
429
|
+
"Retained function blocks cannot contain direct or nested InOut references.",
|
|
430
|
+
variable.span,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function validateConstants(core: VerifiedCoreModule, report: Report): void {
|
|
436
|
+
const types = new Map(core.types.map((type) => [type.id, type]));
|
|
437
|
+
const constant = (value: Constant, id: string, span?: SourceSpan): void => {
|
|
438
|
+
const type = types.get(id)!;
|
|
439
|
+
if (type.kind === "string" && value.kind === "string") {
|
|
440
|
+
if (
|
|
441
|
+
type.encoding === "utf8" &&
|
|
442
|
+
[...value.value].some(
|
|
443
|
+
(character) => character.charCodeAt(0) === 0 || character.charCodeAt(0) > 127,
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
report(
|
|
447
|
+
"Non-ASCII STRING literals require a verified project-wide UTF8 setting; this profile supports ASCII literals.",
|
|
448
|
+
span,
|
|
449
|
+
);
|
|
450
|
+
if (type.encoding === "utf16" && /[\uD800-\uDFFF]/.test(value.value))
|
|
451
|
+
report("CODESYS WSTRING uses UCS-2 and cannot represent surrogate code units.", span);
|
|
452
|
+
}
|
|
453
|
+
if (type.kind === "array" && value.kind === "array")
|
|
454
|
+
for (const item of value.items) constant(item, type.element, span);
|
|
455
|
+
if (type.kind === "record" && value.kind === "record")
|
|
456
|
+
for (const field of type.fields) constant(value.fields[field.name]!, field.type, span);
|
|
457
|
+
};
|
|
458
|
+
const constantsInPath = (path: CorePath = []): void => {
|
|
459
|
+
for (const segment of path) if (segment.kind === "index") constantsInExpression(segment.value);
|
|
460
|
+
};
|
|
461
|
+
const constantsInExpression = (value: CoreExpression): void => {
|
|
462
|
+
if (value.kind === "constant") constant(value.value, value.type, value.span);
|
|
463
|
+
if (value.kind === "load") constantsInPath(value.path);
|
|
464
|
+
if (value.kind === "borrow") constantsInPath(value.place.path);
|
|
465
|
+
if (value.kind === "unary" || value.kind === "convert") constantsInExpression(value.operand);
|
|
466
|
+
if (value.kind === "binary") {
|
|
467
|
+
constantsInExpression(value.left);
|
|
468
|
+
constantsInExpression(value.right);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
const constantsInBody = (items: readonly CoreStatement[]): void => {
|
|
472
|
+
for (const item of items) {
|
|
473
|
+
if (item.kind === "assign") {
|
|
474
|
+
constantsInExpression(item.value);
|
|
475
|
+
constantsInPath(item.path);
|
|
476
|
+
}
|
|
477
|
+
if (item.kind === "if") {
|
|
478
|
+
constantsInExpression(item.condition);
|
|
479
|
+
constantsInBody(item.then);
|
|
480
|
+
constantsInBody(item.else);
|
|
481
|
+
}
|
|
482
|
+
if (item.kind === "switch") {
|
|
483
|
+
constantsInExpression(item.value);
|
|
484
|
+
item.cases.forEach((branch) => {
|
|
485
|
+
for (const value of branch.values) constant(value, item.value.type, item.span);
|
|
486
|
+
constantsInBody(branch.body);
|
|
487
|
+
});
|
|
488
|
+
constantsInBody(item.fallback);
|
|
489
|
+
}
|
|
490
|
+
if (item.kind === "for") constantsInBody(item.body);
|
|
491
|
+
if (item.kind === "invoke") {
|
|
492
|
+
if (item.index) constantsInExpression(item.index);
|
|
493
|
+
item.arguments.forEach(constantsInExpression);
|
|
494
|
+
}
|
|
495
|
+
if (item.kind === "blockUpdate") {
|
|
496
|
+
if (item.index) constantsInExpression(item.index);
|
|
497
|
+
Object.values(item.inputs).forEach(constantsInExpression);
|
|
498
|
+
}
|
|
499
|
+
if (item.kind === "return" && item.value) constantsInExpression(item.value);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
core.variables.forEach((variable) => {
|
|
503
|
+
if (variable.initial) constant(variable.initial, variable.type, variable.span);
|
|
504
|
+
});
|
|
505
|
+
for (const fn of core.functions) constantsInBody(fn.body);
|
|
506
|
+
core.blocks.forEach((block) => {
|
|
507
|
+
[...block.outputs, ...block.state, ...block.locals].forEach((variable) => {
|
|
508
|
+
if (variable.initial) constant(variable.initial, variable.type, variable.span);
|
|
509
|
+
});
|
|
510
|
+
constantsInBody(block.body);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Publication } from "@tsplc/config";
|
|
2
|
+
import type { VerifiedCoreModule } from "@tsplc/compiler/ir";
|
|
3
|
+
import { moduleName } from "./storage.ts";
|
|
4
|
+
|
|
5
|
+
const globals = (core: VerifiedCoreModule) =>
|
|
6
|
+
core.variables.filter((variable) => variable.storage === "module" && variable.exported);
|
|
7
|
+
|
|
8
|
+
/** Source names are independent of CODESYS name allocation and the active build target. */
|
|
9
|
+
export const publicationSelectors = (core: VerifiedCoreModule): string[] => {
|
|
10
|
+
const variables = globals(core);
|
|
11
|
+
return [
|
|
12
|
+
...new Set(
|
|
13
|
+
variables.flatMap((variable) => [
|
|
14
|
+
moduleName(variable),
|
|
15
|
+
`${moduleName(variable)}.${variable.name}`,
|
|
16
|
+
]),
|
|
17
|
+
),
|
|
18
|
+
].sort();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const resolvePublication = (
|
|
22
|
+
core: VerifiedCoreModule,
|
|
23
|
+
policy: Publication,
|
|
24
|
+
): { publication: Publication; errors: string[] } => {
|
|
25
|
+
const variables = globals(core);
|
|
26
|
+
const errors: string[] = [];
|
|
27
|
+
const select = (selectors: readonly string[]) => {
|
|
28
|
+
const selected = new Set(
|
|
29
|
+
variables.filter((variable) => variable.exposure).map((variable) => variable.id),
|
|
30
|
+
);
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
for (const selector of selectors) {
|
|
33
|
+
if (seen.has(selector)) errors.push(`Duplicate publication selector ${selector}.`);
|
|
34
|
+
seen.add(selector);
|
|
35
|
+
const candidates = variables.filter(
|
|
36
|
+
(variable) =>
|
|
37
|
+
selector === moduleName(variable) ||
|
|
38
|
+
selector === `${moduleName(variable)}.${variable.name}`,
|
|
39
|
+
);
|
|
40
|
+
if (!candidates.length) {
|
|
41
|
+
errors.push(
|
|
42
|
+
`Unknown publication selector ${selector}; select an exported mutable global or its module.`,
|
|
43
|
+
);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (new Set(candidates.map((variable) => variable.span.file)).size !== 1) {
|
|
47
|
+
errors.push(`Ambiguous publication selector ${selector}.`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const variable of candidates) selected.add(variable.id);
|
|
51
|
+
}
|
|
52
|
+
return variables.filter((variable) => selected.has(variable.id)).map((variable) => variable.id);
|
|
53
|
+
};
|
|
54
|
+
const publication = {
|
|
55
|
+
production: select(policy.production),
|
|
56
|
+
development: select(policy.development),
|
|
57
|
+
};
|
|
58
|
+
return { publication, errors };
|
|
59
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { instancedBlock, type Variable, type VerifiedCoreModule } from "@tsplc/compiler/ir";
|
|
3
|
+
|
|
4
|
+
interface GlobalList {
|
|
5
|
+
id: string;
|
|
6
|
+
module: string;
|
|
7
|
+
storage: "constants" | "retained" | "state";
|
|
8
|
+
variables: Variable[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The source module that owns a module-level variable: its file name without extension. */
|
|
12
|
+
export const moduleName = (variable: Variable): string =>
|
|
13
|
+
basename(variable.span.file).replace(/\.[^.]+$/, "");
|
|
14
|
+
|
|
15
|
+
/** CODESYS promotes an enclosing instance when private child state is retained. */
|
|
16
|
+
export function storageLayout(core: VerifiedCoreModule) {
|
|
17
|
+
const types = new Map(core.types.map((type) => [type.id, type]));
|
|
18
|
+
const blocks = new Map(core.blocks.map((block) => [block.id, block]));
|
|
19
|
+
const retention = new Map<string, boolean>();
|
|
20
|
+
const blockRetained = (id: string): boolean => {
|
|
21
|
+
const cached = retention.get(id);
|
|
22
|
+
if (cached !== undefined) return cached;
|
|
23
|
+
const block = blocks.get(id);
|
|
24
|
+
if (!block) throw new Error("Missing verified block during retention planning.");
|
|
25
|
+
const retained = block.state.some((field) => {
|
|
26
|
+
const type = instancedBlock(field.type, types);
|
|
27
|
+
return field.retain || (type !== undefined && blockRetained(type.block));
|
|
28
|
+
});
|
|
29
|
+
retention.set(id, retained);
|
|
30
|
+
return retained;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const retainedBlocks = new Set(
|
|
34
|
+
core.blocks.filter((block) => blockRetained(block.id)).map((block) => block.id),
|
|
35
|
+
);
|
|
36
|
+
const retainedVariables = new Set(
|
|
37
|
+
[...core.variables, ...core.blocks.flatMap((block) => block.state)]
|
|
38
|
+
.filter((variable) => {
|
|
39
|
+
const type = instancedBlock(variable.type, types);
|
|
40
|
+
return variable.retain || (type !== undefined && retainedBlocks.has(type.block));
|
|
41
|
+
})
|
|
42
|
+
.map((variable) => variable.id),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const groups = new Map<string, GlobalList>();
|
|
46
|
+
for (const variable of core.variables) {
|
|
47
|
+
if (variable.storage === "input" || variable.storage === "output") continue;
|
|
48
|
+
const storage =
|
|
49
|
+
variable.storage === "constant"
|
|
50
|
+
? "constants"
|
|
51
|
+
: retainedVariables.has(variable.id)
|
|
52
|
+
? "retained"
|
|
53
|
+
: "state";
|
|
54
|
+
const id = `${variable.span.file}:${storage}`;
|
|
55
|
+
let group = groups.get(id);
|
|
56
|
+
if (!group) {
|
|
57
|
+
group = { id, module: moduleName(variable), storage, variables: [] };
|
|
58
|
+
groups.set(id, group);
|
|
59
|
+
}
|
|
60
|
+
group.variables.push(variable);
|
|
61
|
+
}
|
|
62
|
+
return { retainedBlocks, retainedVariables, globalLists: [...groups.values()] };
|
|
63
|
+
}
|