Skip to content

improvement(fluid-build): Ignore some tsconfig fields when determining incremental build #23197

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Task, TaskExec } from "../task";

const { log } = defaultLogger;
const traceTaskTrigger = registerDebug("fluid-build:task:trigger");
const traceTaskTriggerDebug = registerDebug("fluid-build:task:trigger:debug");
const traceTaskCheck = registerDebug("fluid-build:task:check");
const traceTaskInitDep = registerDebug("fluid-build:task:init:dep");
const traceTaskInitWeight = registerDebug("fluid-build:task:init:weight");
Expand Down Expand Up @@ -428,6 +429,11 @@ export abstract class LeafTask extends Task {
traceTaskTrigger(msg);
}

protected traceTriggerDebug(reason: string) {
const msg = `${this.nameColored}: [${reason}]`;
traceTaskTriggerDebug(msg);
}

protected traceError(msg: string) {
traceError(`${this.nameColored}: ${msg}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
* Licensed under the MIT License.
*/

import * as assert from "node:assert";
import assert from "node:assert";
import { type BigIntStats, type Stats, existsSync, lstatSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";

import isEqual from "lodash.isequal";
import * as tsTypes from "typescript";

import { TscUtil, getTscUtils } from "../../tscUtils";
import { getInstalledPackageVersion } from "../taskUtils";
import { diffObjects, getInstalledPackageVersion } from "../taskUtils";
import { LeafTask, LeafWithDoneFileTask } from "./leafTask";

interface ITsBuildInfo {
Expand Down Expand Up @@ -258,13 +259,56 @@ export class TscTask extends LeafTask {
path.resolve,
);

if (!isEqual(configOptions, tsBuildInfoOptions)) {
this.traceTrigger(`ts option changed ${configFileFullPath}`);
this.traceTrigger("Config:");
this.traceTrigger(JSON.stringify(configOptions, undefined, 2));
this.traceTrigger("BuildInfo:");
this.traceTrigger(JSON.stringify(tsBuildInfoOptions, undefined, 2));
return false;
// The following code checks that the cached buildInfo options -- that is, the tsconfig options used to build
// originally -- match the current tsconfig. If they don't match, then the cache is outdated and a rebuild is
// needed.
//
// However, there are some tsconfig settings that don't seem to be cached in the tsbuildinfo. This makes any builds
// including these settings always fail this check. We special-case the properties in this set -- any differences in
// those fields are ignored.
//
// IMPORTANT: This is a somewhat unsafe action. Technically speaking, we don't know for sure the incremental build
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand, is anyone going to commonly change allowJs/checkJs and then re-run and incremental build? On the other hand, why is this opt-out instead of opt-in if it is potentially unsafe?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On one hand, is anyone going to commonly change allowJs/checkJs and then re-run and incremental build?

This isn't quite the situation. Without this change, projects like property-properties and ai-collab have builds that are effectively never cached, regardless of whether people change those projects' settings.

This is because we use the on-disk tsconfig settings to compare with what the tsbuildInfo has. Any project with those settings in their tsconfig is always going to build.

Fortunately these projects are sort of at the edge of the build graph, so most people's day-to-day is not impacted. However, I monitor the cached full build time informally over time, and this is one of the last remaining issues to get to a 100% cached full build. I'd like to get there almost out of spite to the universe at this point. 😆

On the other hand, why is this opt-out instead of opt-in if it is potentially unsafe?

Good point. I remember being proud that I had added an escape hatch, which we've never done with any build-tools changes. But in retrospect I chose a safER option but not the safEST option. I'll make it opt-in.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll make it opt-in.

Done in latest.

// status when there is ANY difference between these settings. Thus the safest thing to do is to assume a rebuild is
// needed, Projects using these ignored settings may exhibit strange incremental build behavior.
//
// For that reason, this behavior can be disabled completely using the _FLUID_BUILD_DISABLE_IGNORE_TSC_OPTIONS_
// environment variable. If that variable is set to any non-empty value, the list of ignored options will not be
// checked.
const tsConfigOptionsIgnored: Set<string> =
(process.env._FLUID_BUILD_DISABLE_IGNORE_TSC_OPTIONS_ ?? "" !== "")
? new Set()
: new Set(["allowJs", "checkJs"]);

const diffResults = diffObjects(configOptions, tsBuildInfoOptions);
if (diffResults.length > 0) {
// Track whether the diff is "real"; that is, the diff is in properties that are not ignored. By default,
// assume that it is.
let diffIsReal = true;

// We only need to check the different properties if the number of differences is <= the ignored options;
// any properties over that count will be a non-ignored difference.
if (diffResults.length <= tsConfigOptionsIgnored.size) {
// The diff is "real" if any different property is not ignored
diffIsReal = diffResults.some((diffResult) => {
const isIgnoredOption = tsConfigOptionsIgnored.has(diffResult.path);
if (isIgnoredOption) {
this.traceTrigger(`ignoring tsbuildinfo property: ${diffResult.path}`);
}
return !isIgnoredOption;
});
}

if (diffIsReal) {
this.traceTrigger(`ts options changed ${configFileFullPath}`);
this.traceTrigger("Diff:");
this.traceTrigger(JSON.stringify(diffResults, undefined, 2));

this.traceTriggerDebug("Config:");
this.traceTriggerDebug(JSON.stringify(configOptions, undefined, 2));
this.traceTriggerDebug("BuildInfo:");
this.traceTriggerDebug(JSON.stringify(tsBuildInfoOptions, undefined, 2));
return false;
}
}
return true;
}
Expand Down
53 changes: 53 additions & 0 deletions build-tools/packages/build-tools/src/fluidBuild/tasks/taskUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,56 @@ export async function loadModule(modulePath: string, moduleType?: string) {
}
return require(modulePath);
}

export type DiffResult = {
path: string;
type: "added" | "removed" | "changed";
oldValue?: any;
newValue?: any;
value?: any;
};

/**
* Takes two objects and diffs them, returning a list of each property that differs between the two.
*
* @param obj1 - The first object to compare.
* @param obj2 - The second object to compare.
* @returns An array of {@link DiffResult}s with the changed properties, their old and new values, and the type of
* change.
*/
export function diffObjects(obj1: object, obj2: object): DiffResult[] {
const diffs: Map<string, DiffResult> = new Map();

function findDiffs(o1: object, o2: object, path: string) {
for (const key of Object.keys(o1)) {
if (Object.prototype.hasOwnProperty.call(o1, key)) {
const newPath = path ? `${path}.${key}` : key;
if (!Object.prototype.hasOwnProperty.call(o2, key)) {
diffs.set(newPath, { path: newPath, type: "removed", value: o1[key] });
} else if (typeof o1[key] === "object" && typeof o2[key] === "object") {
findDiffs(o1[key], o2[key], newPath);
} else if (o1[key] !== o2[key]) {
diffs.set(newPath, {
path: newPath,
type: "changed",
oldValue: o1[key],
newValue: o2[key],
});
}
}
}

for (const key in o2) {
if (
Object.prototype.hasOwnProperty.call(o2, key) &&
!Object.prototype.hasOwnProperty.call(o1, key)
) {
const newPath = path ? `${path}.${key}` : key;
diffs.set(newPath, { path: newPath, type: "added", value: o2[key] });
}
}
}

findDiffs(obj1, obj2, "");
return [...diffs.values()];
}
Loading