Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
138 changes: 127 additions & 11 deletions .github/hooks/scripts/itwinjs-core-pre-tool-use.mjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,123 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { compactJson, getBashCommand, isGitCommitCommand, readHookInput } from "./itwinjs-core-hook-utils.mjs";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const rushLauncherPath = resolve(repoRoot, "common/scripts/install-run-rush.js");
const fallbackTargetBranch = "origin/master";
const rushChangeHint = "Run 'rush change' to generate or update a missing change description.";

function runRushChangeVerify() {
function runCommand(command, args, timeout = 30_000) {
return new Promise((resolve, reject) => {
execFile(process.execPath, [rushLauncherPath, "change", "--verify"], { cwd: repoRoot, timeout: 30_000 }, (error, stdout, stderr) => {
execFile(command, args, { cwd: repoRoot, timeout }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || stdout || error.message));
return;
}
resolve();
resolve(stdout.trim());
});
});
}

function runRushChangeVerify() {
return runCommand(process.execPath, [rushLauncherPath, "change", "--verify"]);
}

async function resolveTargetBranch() {
try {
const remoteHead = await runCommand("git", ["symbolic-ref", "refs/remotes/origin/HEAD"]);
return remoteHead.replace(/^refs\/remotes\//, "");
} catch {
return fallbackTargetBranch;
}
}

async function loadPublishedProjects() {
const rushConfig = JSON.parse(await readFile(resolve(repoRoot, "rush.json"), "utf8"));
return rushConfig.projects
.filter((project) => project.shouldPublish !== false)
.map((project) => ({
packageName: project.packageName,
projectFolder: String(project.projectFolder).replace(/\\/g, "/"),
}));
}

function collectChangedProjects(changedPaths, projects) {
const normalizedPaths = changedPaths.map((path) => path.replace(/\\/g, "/"));
return projects.filter((project) =>
normalizedPaths.some((path) => path === project.projectFolder || path.startsWith(`${project.projectFolder}/`)));
}

function collectPackageNamesFromChangeFile(changeFile) {
const packageNames = new Set();
if (typeof changeFile.packageName === "string")
packageNames.add(changeFile.packageName);

if (Array.isArray(changeFile.changes)) {
for (const entry of changeFile.changes) {
if (typeof entry?.packageName === "string")
packageNames.add(entry.packageName);
}
}

return packageNames;
}

async function getStagedChangeDescriptionCoverage() {
const targetBranch = await resolveTargetBranch();
let baseCommit;
try {
baseCommit = await runCommand("git", ["merge-base", "HEAD", targetBranch]);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message.trim() : String(error)}\nHint: ensure '${targetBranch}' is fetched locally — try: git fetch origin`,
);
}
const stagedTree = await runCommand("git", ["write-tree"]);
const changedPathsOutput = await runCommand("git", ["diff", "--name-only", baseCommit, stagedTree]);
const changedPaths = changedPathsOutput ? changedPathsOutput.split("\n").filter(Boolean) : [];
const changedProjects = collectChangedProjects(changedPaths, await loadPublishedProjects());
if (changedProjects.length === 0)
return { ok: false, reason: "No published Rush project changes detected in the staged tree." };

const stagedChangeFilesOutput = await runCommand("git", [
"diff",
"--name-only",
"--diff-filter=AM",
baseCommit,
stagedTree,
"--",
"common/changes",
]);
const stagedChangeFiles = stagedChangeFilesOutput
? stagedChangeFilesOutput.split("\n").filter((path) => path.endsWith(".json"))
: [];

const coveredPackages = new Set();
for (const changeFilePath of stagedChangeFiles) {
const raw = await runCommand("git", ["show", `${stagedTree}:${changeFilePath}`]);
try {
const parsed = JSON.parse(raw);
for (const packageName of collectPackageNamesFromChangeFile(parsed))
coveredPackages.add(packageName);
} catch {
return { ok: false, reason: `Unable to parse staged change file: ${changeFilePath}` };
}
}

const missingProjects = changedProjects.filter((project) => !coveredPackages.has(project.packageName));
if (missingProjects.length > 0) {
return {
ok: false,
reason: `Missing staged change descriptions for: ${missingProjects.map((project) => project.packageName).join(", ")}`,
};
}

return { ok: true };
}

const input = await readHookInput();

if (String(input.toolName ?? "").toLowerCase() !== "bash") {
Expand All @@ -33,12 +133,28 @@ try {
await runRushChangeVerify();
process.exit(0);
} catch (error) {
process.stdout.write(
compactJson({
permissionDecision: "deny",
permissionDecisionReason: `rush change --verify failed before git commit: ${
error instanceof Error ? error.message.trim() : String(error)
}`,
}),
);
try {
const stagedCoverage = await getStagedChangeDescriptionCoverage();
if (stagedCoverage.ok) {
process.exit(0);
}

process.stdout.write(
compactJson({
permissionDecision: "deny",
permissionDecisionReason: `rush change --verify failed before git commit: ${
error instanceof Error ? error.message.trim() : String(error)
}\nStaged-tree fallback check: ${stagedCoverage.reason}\n${rushChangeHint}`,
}),
);
} catch (fallbackError) {
process.stdout.write(
compactJson({
permissionDecision: "deny",
permissionDecisionReason: `rush change --verify failed before git commit: ${
error instanceof Error ? error.message.trim() : String(error)
}\nStaged-tree fallback check also failed: ${fallbackError instanceof Error ? fallbackError.message.trim() : String(fallbackError)}\n${rushChangeHint}`,
}),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Stabilize generated unit conversion output across Node runtimes.",
"type": "none",
"packageName": "@itwin/core-quantity"
}
],
"packageName": "@itwin/core-quantity",
"email": "50554904+hl662@users.noreply.github.com"
}
45 changes: 36 additions & 9 deletions core/quantity/scripts/generatedModuleBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,24 @@ export interface SourceSchemaLike {
readonly items: { readonly [name: string]: any };
}

export interface GeneratedEntry {
export interface GeneratedEntry<TValue = unknown> {
readonly key: string;
readonly value: any;
readonly value: TValue;
}

export type BasicConversionValue =
| readonly [
phenomenon: string,
factor: number,
offset: number,
]
| readonly [
phenomenon: string,
factor: number,
offset: number,
invertsUnitName: string,
];

export type AssertUniqueGeneratedKeys = (entries: ReadonlyArray<{ key: string }>, description: string) => void;

function asSerializedSchema(source: SourceSchemaLike): SerializedUnitSchema {
Expand Down Expand Up @@ -111,9 +124,9 @@ function normalizeGeneratedUnitKey(name: string): string {
export function buildBasicConversionEntries(
source: SourceSchemaLike,
assertUniqueGeneratedKeys: AssertUniqueGeneratedKeys,
): GeneratedEntry[] {
): Array<GeneratedEntry<BasicConversionValue>> {
const resolved = resolveAll(source);
const entries: GeneratedEntry[] = [];
const entries: Array<GeneratedEntry<BasicConversionValue>> = [];

for (const [unqualifiedName, unit] of resolved) {
entries.push({
Expand Down Expand Up @@ -151,7 +164,7 @@ export function buildBasicConversionEntries(
export function buildDefaultPersistenceUnitEntries(
source: SourceSchemaLike,
assertUniqueGeneratedKeys: AssertUniqueGeneratedKeys,
): GeneratedEntry[] {
): Array<GeneratedEntry<string>> {
const resolved = resolveAll(source);
const qualifiedSchemaItemName = (name: string) => `${source.name}.${name}`;
// Default persistence units are generated when a phenomenon has exactly one built-in SI candidate.
Expand Down Expand Up @@ -193,7 +206,7 @@ export function buildDefaultPersistenceUnitEntries(
unitsByPhenomenon.set(phenomenon, bucket);
}

const entries: GeneratedEntry[] = [];
const entries: Array<GeneratedEntry<string>> = [];
const unresolved: Array<{ phenomenon: string; candidates: string[] }> = [];

for (const [name, item] of Object.entries(source.items).sort(([a], [b]) => a.localeCompare(b))) {
Expand Down Expand Up @@ -233,6 +246,20 @@ export function buildDefaultPersistenceUnitEntries(
return entries;
}

export function formatGeneratedNumber(value: number): string {
if (!Number.isFinite(value))
return String(value);

// Round to 15 significant decimal digits (IEEE-754 doubles carry about 15.95).
// This collapses last-bit Node/V8 runtime drift into stable emitted text without
// introducing engineering-significant precision loss for generated conversion data.
const canonicalized = Number.parseFloat(value.toPrecision(15));
Comment thread
aruniverse marked this conversation as resolved.
if (!Number.isFinite(canonicalized))
return String(value);

return String(canonicalized);
}

export function buildGeneratedBasicConversionModule(
source: SourceSchemaLike,
assertUniqueGeneratedKeys: AssertUniqueGeneratedKeys,
Expand All @@ -250,8 +277,8 @@ export function buildGeneratedBasicConversionModule(
];

for (const entry of entries) {
const tuple = (entry.value as unknown[])
.map((value) => typeof value === "string" ? JSON.stringify(value) : String(value))
const tuple = entry.value
.map((value) => typeof value === "string" ? JSON.stringify(value) : formatGeneratedNumber(value))
.join(", ");
lines.push(` ${JSON.stringify(entry.key)}: [${tuple}],`);
}
Expand Down Expand Up @@ -358,7 +385,7 @@ function collectGroupedUnitSections(source: SourceSchemaLike): Array<{ key: stri
.map((entry) => ({
key: normalizeGeneratedUnitKey(stripSchemaPrefix(entry.key, source.name)),
value: entry.key,
phenomenon: stripSchemaPrefix((entry.value as unknown[])[0] as string, source.name),
phenomenon: stripSchemaPrefix(entry.value[0], source.name),
}));

for (const entry of unitEntries) {
Expand Down
Loading
Loading