Files
rea/src/application/ReferenceSourceVcsAdapter.ts
T
Martin Klein 7b15762d09 fix(reference): report sloppy scripts, PHP, C# and enclosing Git facts (#1770)
* fix(reference): report sloppy scripts, PHP, C# and enclosing Git facts

Importing the source/ subtree of a legacy PHP and C# checkout produced
four wrong or missing facts:

- Babel's unambiguous mode parses a module first and keeps its
  strict-mode recovery errors after falling back to a script, so a valid
  sloppy-mode `delete messages;` was reported as a parse failure and the
  semantic IR marked the file as parser-recovered. Parsing now goes
  through one owner that re-parses such a script as a script.
- .php and .cs files had no language; they are now detected as PHP and
  C# (detection only, no parsing).
- A root without its own .git was reported as having no version
  control even inside a checkout. The adapter now resolves the nearest
  enclosing repository, as Git does, and reports `none` only when no
  ancestor has one.
- The CLI recorded importer_version as null although the package
  version is known.

* fix(reference): keep module strictness and classify C# and PHP as source

Review follow-up. `.mjs` and `.mts` files are modules by extension, but
the script fallback re-parsed them in sloppy mode and erased real
strict-mode errors. The parser boundary now owns the extension rule,
parses explicit modules as modules and applies the fallback only when
the mode is ambiguous; the require-binding analysis uses the same rule.

`.cs` and `.php` received a language but stayed classified as unknown,
so source-to-bundle comparison dropped them. Both are now source
extensions, like the other detected languages.

* fix(reference): honor package module scope and keep unknown-mode strict errors

Review follow-up. A `.js` file under a package.json with `"type":
"module"` is an ES module, but it went through the script fallback that
erases strict-mode errors. The import now reads each in-inventory
package.json and parses `.js` files in a module scope as modules. Other
`.js` and `.ts` sources keep an unknown mode, because bundlers treat them
as modules without that field: they are no longer forced to script, and
errors that only the module parse reports are kept as an entry limitation.

* fix(reference): bound package scopes and keep application parsing unchanged

Review follow-up.
- `.cjs` files parse as scripts, so module-only strict errors no longer
  become limitations on CommonJS trees. `.cts` keeps an unknown mode
  because TypeScript compiles ES import syntax there.
- An unreadable or linked package.json now bounds the module scope with
  an unknown type instead of letting an outer `"type": "module"` apply.
- The application pipeline parses as on main again. Its module-only
  strict errors keep a source partial; package-mode handling stays in the
  reference importer.

* refactor(reference): keep unmarked module modes unknown instead of resolving package scope

Review follow-up. Resolving Node package scope for `.js` kept pulling in
more of Node's lookup semantics (require bindings, excluded or
directory-named manifests). Only extensions now select a mode: `.mjs`
and `.mts` parse as modules and `.cjs` as scripts. Every other source
keeps an unknown mode, so module-only strict errors stay visible as an
entry limitation instead of being dropped or asserted. This matches the
require-binding analysis, which also treats unmarked paths as unknown.
2026-10-13 05:52:27 +09:00

241 lines
8.3 KiB
TypeScript

import fs from "node:fs";
import { lstat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { err, ok, type Result } from "../domain/result.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
readRegularFile,
RegularFileCleanupFailure,
retryRegularFileCleanup,
} from "./RegularFileRead.js";
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
export type ReferenceSourceVcsInfo =
| {
readonly kind: "git";
readonly head: string;
readonly dirty: boolean | null;
}
| { readonly kind: "none"; readonly head: null; readonly dirty: null }
| { readonly kind: "unknown"; readonly head: null; readonly dirty: null };
/**
* Read Git metadata for a directory using isomorphic-git.
*
* No git subprocess or network is used; only the local `.git` object store is read.
*/
export const readReferenceSourceVcs = async (
root: string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<Result<ReferenceSourceVcsInfo, ReferenceSourceReaderError>> => {
try {
return await resources.run(async () => {
// Like Git, a subdirectory belongs to its nearest enclosing repository;
// only a missing `.git` at every ancestor means no version control.
let repository = resolve(root);
for (;;) {
if (isAborted(signal)) return ok(unknownVcs());
try {
await lstat(join(repository, ".git"));
break;
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") return ok(unknownVcs());
}
const parent = dirname(repository);
if (parent === repository)
return ok({ kind: "none", head: null, dirty: null });
repository = parent;
}
try {
const head = await resolveSourceHead(repository, resources, signal);
if (isAborted(signal)) return ok(unknownVcs());
return ok({ kind: "git", head, dirty: null });
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure) throw cause;
return ok(unknownVcs());
}
});
} catch (cause: unknown) {
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
return err({
tag: "reference-source-reader",
code: cause.reason === "cancelled" ? "cancelled" : "io",
message: cause.message,
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
cause,
});
}
};
const unknownVcs = (): ReferenceSourceVcsInfo => ({
kind: "unknown",
head: null,
dirty: null,
});
const resolveSourceHead = async (
root: string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<string> => {
// isomorphic-git is loaded on first use so CLI and MCP startup skip it.
const { resolveRef } = await import("isomorphic-git");
let failedRead: { readonly cause: unknown } | undefined;
const cleanupFailures: ArtifactReaderFailure[] = [];
const readMetadataBytes = async (path: string): Promise<Buffer> => {
try {
return await readRegularFile(path, { signal });
} catch (cause: unknown) {
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
const retry = await retryRegularFileCleanup(cause, resources);
if (
retry.outcome.kind === "failed" &&
errorCode(retry.outcome.cause) !== "ENOENT"
)
failedRead ??= { cause: retry.outcome.cause };
if (
retry.cleanup === undefined &&
retry.outcome.kind === "failed" &&
!isAborted(signal)
)
throw retry.outcome.cause;
const primary =
retry.outcome.kind === "failed"
? retry.outcome.cause
: cause.cleanupCause;
throw new ArtifactReaderFailure(
isAborted(signal) ? "cancelled" : "io",
`Git metadata could not be read safely: ${errorMessage(primary)}`,
{
cause: primary,
...(retry.cleanup === undefined ? {} : { cleanup: retry.cleanup }),
},
);
}
};
const readMetadataText = async (path: string): Promise<string> =>
(await readMetadataBytes(path)).toString("utf8");
const throwCleanupFailures = (primary: unknown): never => {
const observations = cleanupFailures.flatMap((failure) =>
failure.cleanup === undefined ? [] : [failure.cleanup],
);
throw new ArtifactReaderFailure(
isAborted(signal) ? "cancelled" : "io",
`Git metadata could not be read safely: ${errorMessage(primary)}`,
{
cause: new AggregateError(
[primary, ...cleanupFailures],
"Git metadata reads and cleanup failed",
{ cause: primary },
),
...(observations.length === 0
? {}
: {
cleanup: {
reason: observations.map(({ reason }) => reason).join("; "),
resources: [
...new Set(
observations.flatMap(({ resources }) => resources),
),
],
},
}),
},
);
};
const referenceFs = {
...fs,
promises: {
...fs.promises,
// Let isomorphic-git continue from a failed loose ref to packed-refs,
// then report the original read failure after resolution. This preserves
// both observations when packed metadata also fails cleanup.
readFile: async (
path: string | undefined,
options:
| BufferEncoding
| { readonly encoding?: BufferEncoding | null }
| null = null,
) => {
// The library probes readFile() without a path to detect promise APIs.
// Reject that invocation without attributing it to a source read.
if (path === undefined)
throw new TypeError("Git filesystem read requires a path");
try {
const bytes = await readMetadataBytes(path);
const encoding =
typeof options === "string" ? options : options?.encoding;
return encoding === undefined || encoding === null
? bytes
: bytes.toString(encoding);
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure) {
cleanupFailures.push(cause);
} else if (errorCode(cause) !== "ENOENT") failedRead ??= { cause };
throw cause;
}
},
},
};
const readRef = async (options: {
readonly gitdir?: string;
readonly ref: string;
}) => {
let head: string;
try {
head = await resolveRef({ fs: referenceFs, dir: root, ...options });
} catch (cause: unknown) {
if (cleanupFailures.length > 0)
throwCleanupFailures(failedRead?.cause ?? cause);
if (failedRead !== undefined) throw failedRead.cause;
throw cause;
}
if (cleanupFailures.length > 0)
throwCleanupFailures(failedRead?.cause ?? cleanupFailures[0]?.cause);
signal?.throwIfAborted();
if (failedRead !== undefined) throw failedRead.cause;
return head;
};
const dotgit = join(root, ".git");
if (!(await lstat(dotgit)).isFile()) return readRef({ ref: "HEAD" });
const pointer = (await readMetadataText(dotgit)).replace(/\r?\n$/u, "");
if (!pointer.startsWith("gitdir: ") || pointer.length === 8)
throw new Error("Invalid Git directory pointer");
const gitdir = resolve(root, pointer.slice(8));
let commonDirectory: string;
try {
commonDirectory = resolve(
gitdir,
(await readMetadataText(join(gitdir, "commondir"))).replace(
/\r?\n$/u,
"",
),
);
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") throw cause;
return readRef({ gitdir, ref: "HEAD" });
}
const head = (await readMetadataText(join(gitdir, "HEAD"))).trim();
if (!head.startsWith("ref: ")) return readRef({ gitdir, ref: "HEAD" });
const ref = head.slice(5);
const privateRef = ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some(
(prefix) => ref.startsWith(prefix),
);
return readRef({
gitdir: privateRef ? gitdir : commonDirectory,
ref,
});
};
const errorCode = (cause: unknown): string | undefined =>
typeof cause === "object" && cause !== null && "code" in cause
? String(cause.code)
: undefined;
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;