Skip to content

Commit 15a5ed4

Browse files
committed
fix(build-tools): ensure TaskDependencies copies
Add broad `readonly` attribute to Task Definitions and Configs that shouldn't be changed. Add cloning where mutation may occur. (Note that TypeScript doesn't detect all cases where something readonly is assigned to non-readonly context. See microsoft/TypeScript#18770) Where mutability is needed in policy fix-up handler cast away to writeable.
1 parent 7f295e8 commit 15a5ed4

File tree

4 files changed

+75
-42
lines changed

4 files changed

+75
-42
lines changed

build-tools/packages/build-cli/src/library/repoPolicyCheck/fluidBuildTasks.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import {
1515
Package,
1616
PackageJson,
1717
TscUtils,
18+
type WriteableTaskDefinitionsOnDisk,
1819
getEsLintConfigFilePath,
1920
getFluidBuildConfig,
2021
getTaskDefinitions,
22+
isTaskDependencies,
2123
normalizeGlobalTaskDefinitions,
2224
} from "@fluidframework/build-tools";
2325
import JSON5 from "json5";
@@ -468,6 +470,12 @@ function checkTaskDeps(
468470
: undefined;
469471
}
470472

473+
type DeeplyMutable<T> = { -readonly [K in keyof T]: DeeplyMutable<T[K]> };
474+
475+
function asWriteable<T>(onlyReadable: T): DeeplyMutable<T> {
476+
return onlyReadable as DeeplyMutable<T>;
477+
}
478+
471479
/**
472480
* Fix up the actual dependencies of a task against an expected set of dependent tasks
473481
* @param root - directory of the Fluid repo root
@@ -488,23 +496,25 @@ function patchTaskDeps(
488496
);
489497

490498
if (missingTaskDependencies.length > 0) {
491-
const fileDep = json.fluidBuild?.tasks?.[taskName];
499+
const fileDep = json.fluidBuild?.tasks?.[taskName] as
500+
| WriteableTaskDefinitionsOnDisk[string]
501+
| undefined;
492502
if (fileDep === undefined) {
493-
let tasks: Exclude<Exclude<PackageJson["fluidBuild"], undefined>["tasks"], undefined>;
503+
let tasks: WriteableTaskDefinitionsOnDisk;
494504
if (json.fluidBuild === undefined) {
495505
tasks = {};
496506
json.fluidBuild = { tasks, version: 1 };
497507
} else if (json.fluidBuild.tasks === undefined) {
498508
tasks = {};
499509
json.fluidBuild.tasks = tasks;
500510
} else {
501-
tasks = json.fluidBuild.tasks;
511+
tasks = asWriteable(json.fluidBuild.tasks);
502512
}
503513

504514
tasks[taskName] = taskDeps.map((dep) => {
505515
if (Array.isArray(dep)) {
506516
throw new TypeError(
507-
`build-tools patchTaskDeps for ${taskName} will not auto select single dependency from choice of ${dep.join(
517+
`build-cli patchTaskDeps for ${taskName} will not auto select single dependency from choice of ${dep.join(
508518
" or ",
509519
)}`,
510520
);
@@ -513,18 +523,18 @@ function patchTaskDeps(
513523
});
514524
} else {
515525
let depArray: string[];
516-
if (Array.isArray(fileDep)) {
526+
if (isTaskDependencies(fileDep)) {
517527
depArray = fileDep;
518528
} else if (fileDep.dependsOn === undefined) {
519529
depArray = [];
520530
fileDep.dependsOn = depArray;
521531
} else {
522-
depArray = fileDep.dependsOn;
532+
depArray = asWriteable(fileDep.dependsOn);
523533
}
524534
for (const missingDep of missingTaskDependencies) {
525535
if (Array.isArray(missingDep)) {
526536
throw new TypeError(
527-
`build-tools patchTaskDeps for ${taskName} will not auto select single dependency from choice of ${missingDep.join(
537+
`build-cli patchTaskDeps for ${taskName} will not auto select single dependency from choice of ${missingDep.join(
528538
" or ",
529539
)}`,
530540
);

build-tools/packages/build-tools/src/fluidBuild/buildGraph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export class BuildPackage {
276276
}
277277

278278
// Create or get the task with names in the `deps` array
279-
private getMatchedTasks(deps: string[], pendingInitDep?: Task[]) {
279+
private getMatchedTasks(deps: readonly string[], pendingInitDep?: Task[]) {
280280
const matchedTasks: Task[] = [];
281281
for (const dep of deps) {
282282
// If pendingInitDep is undefined, that mean we don't expect the task to be found, so pretend that we already found it.
@@ -355,7 +355,7 @@ export class BuildPackage {
355355
};
356356

357357
// Expand the star entry to all scheduled tasks
358-
const expandStar = (deps: string[], getTaskNames: () => string[]) => {
358+
const expandStar = (deps: readonly string[], getTaskNames: () => string[]) => {
359359
const newDeps = deps.filter((dep) => dep !== "*");
360360
if (newDeps.length === deps.length) {
361361
return newDeps;

build-tools/packages/build-tools/src/fluidBuild/fluidTaskDefinitions.ts

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { isConcurrentlyCommand, parseConcurrentlyCommand } from "./parseCommands
1313
* dependencies or a full description (type `TaskConfigFull`).
1414
*/
1515
export interface TaskDefinitions {
16-
[name: string]: TaskConfig;
16+
readonly [name: string]: TaskConfig;
1717
}
1818

1919
/**
@@ -44,14 +44,14 @@ type TaskDependency =
4444
| `${PackageName}#${TaskName | AnyTaskName}`
4545
| "...";
4646

47-
export type TaskDependencies = TaskDependency[];
47+
export type TaskDependencies = readonly TaskDependency[];
4848

4949
export interface TaskConfig {
5050
/**
5151
* Task dependencies as a plain string array. Matched task will be scheduled to run before the current task.
5252
* The strings specify dependencies for the task. See Task Dependencies Expansion above for details.
5353
*/
54-
dependsOn: TaskDependencies;
54+
readonly dependsOn: TaskDependencies;
5555

5656
/**
5757
* Tasks that needs to run before the current task (example clean). See Task Dependencies Expansion above for
@@ -61,7 +61,7 @@ export interface TaskConfig {
6161
* Notes 'before' is disallowed for non-script tasks since it has no effect on non-script tasks as they has no
6262
* action to perform.
6363
*/
64-
before: TaskDependencies;
64+
readonly before: TaskDependencies;
6565

6666
/**
6767
* Tasks that this task includes. The included tasks will be scheduled to
@@ -70,7 +70,7 @@ export interface TaskConfig {
7070
*
7171
* This should not be custom specified but derived from definition.
7272
*/
73-
includes: TaskName[];
73+
readonly includes: readonly TaskName[];
7474

7575
/**
7676
* Tasks that needs to run after the current task (example copy tasks). See Task Dependencies Expansion above for
@@ -80,7 +80,7 @@ export interface TaskConfig {
8080
* Notes 'after' is disallowed for non-script tasks since it has no effect on non-script tasks as they has no
8181
* action to perform.
8282
*/
83-
after: TaskDependencies;
83+
readonly after: TaskDependencies;
8484

8585
/**
8686
* Specify whether this is a script task or not. Default to true when this is omitted
@@ -92,30 +92,48 @@ export interface TaskConfig {
9292
* If false, the task will only trigger the dependencies (and not look for the script in package.json).
9393
* It can be used as an alias to a group of tasks.
9494
*/
95-
script: boolean;
95+
readonly script: boolean;
96+
}
97+
98+
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
99+
100+
type MutableTaskConfig = Mutable<TaskConfig>;
101+
interface MutableTaskDefinitions {
102+
[name: TaskName]: MutableTaskConfig;
96103
}
97104

98105
// On file versions that allow fields to be omitted
99106
export type TaskConfigOnDisk = TaskDependencies | Omit<Partial<TaskConfig>, "includes">;
100107
export interface TaskDefinitionsOnDisk {
101-
[name: string]: TaskConfigOnDisk;
108+
readonly [name: TaskName]: TaskConfigOnDisk;
109+
}
110+
111+
export interface WriteableTaskDefinitionsOnDisk {
112+
[name: TaskName]: Mutable<TaskConfigOnDisk>;
102113
}
103114

115+
export const isTaskDependencies = (value: TaskConfigOnDisk): value is TaskDependencies => {
116+
return Array.isArray(value);
117+
};
118+
119+
const makeClonedOrEmptyArray = <T>(value: readonly T[] | undefined): T[] =>
120+
value ? [...value] : [];
121+
104122
/**
105123
* Convert and fill out default values from TaskConfigOnDisk to TaskConfig in memory
106124
* @param config TaskConfig info loaded from a file
107125
* @returns TaskConfig filled out with default values
108126
*/
109-
function getFullTaskConfig(config: TaskConfigOnDisk): TaskConfig {
110-
if (Array.isArray(config)) {
127+
function getFullTaskConfig(config: TaskConfigOnDisk): MutableTaskConfig {
128+
if (isTaskDependencies(config)) {
111129
return { dependsOn: config, script: true, includes: [], before: [], after: [] };
112130
} else {
113131
return {
114-
dependsOn: config.dependsOn ?? [],
132+
dependsOn: makeClonedOrEmptyArray(config.dependsOn),
115133
script: config.script ?? true,
116-
before: config.before ?? [],
134+
before: makeClonedOrEmptyArray(config.before),
117135
includes: [],
118-
after: config.after ?? [],
136+
after: makeClonedOrEmptyArray(config.after),
119137
};
120138
}
121139
}
@@ -136,7 +154,7 @@ export const defaultCleanTaskName = "clean";
136154
// subtasks that has no name inherit the dependency. (where as normally, all subtask does)
137155
// (i.e. isDefault: true)
138156

139-
export type TaskDefinition = TaskConfig & { isDefault?: boolean };
157+
export type TaskDefinition = TaskConfig & { readonly isDefault?: boolean };
140158

141159
/**
142160
* Get the default task definition for the given task name
@@ -156,17 +174,17 @@ const defaultTaskDefinition = {
156174
includes: [],
157175
after: ["^*"], // TODO: include "*" so the user configured task will run first, but we need to make sure it doesn't cause circular dependency first
158176
isDefault: true, // only propagate to unnamed sub tasks if it is a group task
159-
} satisfies TaskDefinition;
177+
} as const satisfies TaskDefinition;
160178
const defaultCleanTaskDefinition = {
161179
dependsOn: [],
162180
script: true,
163181
before: ["*"], // clean are ran before all the tasks, add a week dependency.
164182
includes: [],
165183
after: [],
166-
} satisfies TaskDefinition;
184+
} as const satisfies TaskDefinition;
167185

168186
const detectInvalid = (
169-
config: string[],
187+
config: readonly string[],
170188
isInvalid: (value: string) => boolean,
171189
name: string,
172190
kind: string,
@@ -186,7 +204,7 @@ export function normalizeGlobalTaskDefinitions(
186204
globalTaskDefinitionsOnDisk: TaskDefinitionsOnDisk | undefined,
187205
): TaskDefinitions {
188206
// Normalize all on disk config to full config and validate
189-
const taskDefinitions: TaskDefinitions = {};
207+
const taskDefinitions: MutableTaskDefinitions = {};
190208
if (globalTaskDefinitionsOnDisk) {
191209
for (const name in globalTaskDefinitionsOnDisk) {
192210
const full = getFullTaskConfig(globalTaskDefinitionsOnDisk[name]);
@@ -224,7 +242,7 @@ export function normalizeGlobalTaskDefinitions(
224242
return taskDefinitions;
225243
}
226244

227-
function expandDotDotDot(config: string[], inherited: string[]) {
245+
function expandDotDotDot(config: readonly string[], inherited: readonly string[]) {
228246
const expanded = config.filter((value) => value !== "...");
229247
if (inherited !== undefined && expanded.length !== config.length) {
230248
return expanded.concat(inherited);
@@ -285,7 +303,13 @@ export function getTaskDefinitions(
285303
): TaskDefinitions {
286304
const packageScripts = json.scripts ?? {};
287305
const packageTaskDefinitions = json.fluidBuild?.tasks;
288-
const taskDefinitions: TaskDefinitions = {};
306+
const taskDefinitions: MutableTaskDefinitions = {};
307+
308+
const globalAllow = (value) =>
309+
value.startsWith("^") ||
310+
(globalTaskDefinitions[value] !== undefined && !globalTaskDefinitions[value].script) ||
311+
packageScripts[value] !== undefined;
312+
const globalAllowExpansionsStar = (value) => value === "*" || globalAllow(value);
289313

290314
// Initialize from global TaskDefinition, and filter out script tasks if the package doesn't have the script
291315
for (const name in globalTaskDefinitions) {
@@ -294,19 +318,16 @@ export function getTaskDefinitions(
294318
// Skip script tasks if the package doesn't have the script
295319
continue;
296320
}
297-
taskDefinitions[name] = { ...globalTaskDefinition };
298-
}
299-
const globalAllow = (value) =>
300-
value.startsWith("^") ||
301-
taskDefinitions[value] !== undefined ||
302-
packageScripts[value] !== undefined;
303-
const globalAllowExpansionsStar = (value) => value === "*" || globalAllow(value);
304-
// Only keep task or script references that exists
305-
for (const name in taskDefinitions) {
306-
const taskDefinition = taskDefinitions[name];
307-
taskDefinition.dependsOn = taskDefinition.dependsOn.filter(globalAllow);
308-
taskDefinition.before = taskDefinition.before.filter(globalAllowExpansionsStar);
309-
taskDefinition.after = taskDefinition.after.filter(globalAllowExpansionsStar);
321+
// Only keep task or script references that exists
322+
// and make array clones in the process.
323+
taskDefinitions[name] = {
324+
dependsOn: globalTaskDefinition.dependsOn.filter(globalAllow),
325+
script: globalTaskDefinition.script,
326+
before: globalTaskDefinition.before.filter(globalAllowExpansionsStar),
327+
// `includes` are not inherited from the global task definitions (which should always be empty anyway)
328+
includes: [],
329+
after: globalTaskDefinition.after.filter(globalAllowExpansionsStar),
330+
};
310331
}
311332

312333
// Override from the package.json, and resolve "..." to the global dependencies if any

build-tools/packages/build-tools/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export {
1818
export {
1919
normalizeGlobalTaskDefinitions,
2020
getTaskDefinitions,
21+
isTaskDependencies,
22+
WriteableTaskDefinitionsOnDisk,
2123
} from "./fluidBuild/fluidTaskDefinitions";
2224
export {
2325
getApiExtractorConfigFilePath,

0 commit comments

Comments
 (0)