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.
This commit is contained in:
Martin Klein 2026-10-12 22:52:27 +02:00 committed by GitHub
parent 3ee6ff43c1
commit 7b15762d09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 231 additions and 44 deletions

View File

@ -215,7 +215,9 @@ rea import-reference-source /absolute/path/to/source
```
The import records hashes and metadata for the supplied files, separately
from observations of the current app. It honors the source tree's `.gitignore`
from observations of the current app. For a directory inside a Git checkout,
it records the HEAD of the nearest enclosing repository; whether the working
tree matches that commit remains unknown. It honors the source tree's `.gitignore`
and applies default exclusions for common generated and dependency paths such
as `node_modules/`, `dist/`, and `*.log`. Pattern-based exclusions record the
matched pattern and whether it came from project, default, caller, or sensitive

View File

@ -109,12 +109,18 @@ const resolveInternalSpecifier = (
: { to: match, resolution: "internal" };
};
type ReadFile = Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>;
const appendFile = (
entry: Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>,
entry: ReadFile,
lookupPath: ReferenceSourcePathLookup,
output: ParsedReferenceSourceEntries,
): void => {
const language = detectReferenceSourceLanguage(entry.path);
const parsed =
language === null || !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language)
? undefined
: parseReferenceSourceImports(entry.path, entry.bytes, language);
output.entries.push({
path: entry.path,
kind: "file",
@ -123,11 +129,9 @@ const appendFile = (
language,
classifications: classifyReferenceSourcePath(entry.path),
content_state: "hashed",
limitations: [],
limitations: [...(parsed?.limitations ?? [])],
});
if (language === null || !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language))
return;
const parsed = parseReferenceSourceImports(entry.path, entry.bytes, language);
if (parsed === undefined) return;
for (const relationship of parsed.relationships) {
if (relationship.resolution !== "internal") {
output.relationships.push(relationship);

View File

@ -1,6 +1,6 @@
import fs from "node:fs";
import { lstat } from "node:fs/promises";
import { join, resolve } from "node:path";
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";
@ -32,18 +32,24 @@ export const readReferenceSourceVcs = async (
): Promise<Result<ReferenceSourceVcsInfo, ReferenceSourceReaderError>> => {
try {
return await resources.run(async () => {
if (isAborted(signal)) return ok(unknownVcs());
try {
await lstat(join(root, ".git"));
} catch (cause: unknown) {
return ok(
errorCode(cause) === "ENOENT"
? { kind: "none", head: null, dirty: null }
: unknownVcs(),
);
// 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(root, resources, signal);
const head = await resolveSourceHead(repository, resources, signal);
if (isAborted(signal)) return ok(unknownVcs());
return ok({ kind: "git", head, dirty: null });
} catch (cause: unknown) {

View File

@ -255,7 +255,7 @@ const runReferenceSourceImport = async (
caller: "rea-cli",
policy,
importer: PRODUCT_IDENTITY.packageName,
importerVersion: null,
importerVersion: PRODUCT_IDENTITY.packageVersion,
},
resources,
),

View File

@ -1,4 +1,4 @@
import { parse, type ParserPlugin } from "@babel/parser";
import { parse, type ParserOptions, type ParserPlugin } from "@babel/parser";
/** Babel AST produced by REA's inert JavaScript parser boundary. */
export type ParsedJavaScriptSource = ReturnType<typeof parse>;
@ -37,6 +37,67 @@ export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => {
: ["decorators-legacy", typescript];
};
/** `.mjs` and `.mts` are modules by extension; other paths leave the mode to syntax. */
export const isExplicitModuleSourcePath = (path?: string): boolean =>
/\.m[jt]s$/iu.test(path ?? "");
export interface RecoveredJavaScriptParse {
readonly file: ParsedJavaScriptSource;
/** Errors that apply only if a source of unknown mode is an ES module. */
readonly moduleOnlyErrors: readonly string[];
}
/**
* Parse a module or script with recovery. Babel tries module mode first and
* keeps its strict-mode errors after falling back to a script, so a script
* with errors is parsed again as a script. Strict errors that only the module
* parse reports stay visible because the source may still be a module.
*/
export const parseRecoveredJavaScript = (
source: string,
sourcePath: string | undefined,
options: Omit<ParserOptions, "sourceType" | "errorRecovery">,
): RecoveredJavaScriptParse => {
// `.cjs` is CommonJS by extension, so module-only errors cannot apply.
if (/\.cjs$/iu.test(sourcePath ?? ""))
return {
file: parse(source, {
...options,
sourceType: "script",
errorRecovery: true,
}),
moduleOnlyErrors: [],
};
if (isExplicitModuleSourcePath(sourcePath))
return {
file: parse(source, {
...options,
sourceType: "module",
errorRecovery: true,
}),
moduleOnlyErrors: [],
};
const file = parse(source, {
...options,
sourceType: "unambiguous",
errorRecovery: true,
});
if (file.program.sourceType !== "script" || file.errors.length === 0)
return { file, moduleOnlyErrors: [] };
const script = parse(source, {
...options,
sourceType: "script",
errorRecovery: true,
});
const scriptErrors = new Set(script.errors.map((error) => error.message));
return {
file: script,
moduleOnlyErrors: file.errors
.map((error) => error.message)
.filter((message) => !scriptErrors.has(message)),
};
};
/** Parse JavaScript or TypeScript once without attaching comments to AST nodes. */
export const parseJavaScriptSource = (
source: string,

View File

@ -37,6 +37,8 @@ const CODE_EXTENSIONS = new Set([
"m",
"mm",
"sh",
"cs",
"php",
]);
const LANGUAGE_BY_EXTENSION = new Map<string, string>([
@ -59,6 +61,8 @@ const LANGUAGE_BY_EXTENSION = new Map<string, string>([
["cc", "C++"],
["cxx", "C++"],
["swift", "Swift"],
["cs", "C#"],
["php", "PHP"],
["m", "Objective-C"],
["mm", "Objective-C++"],
["sh", "Shell"],

View File

@ -1,4 +1,3 @@
import { parse } from "@babel/parser";
import type {
CallExpression,
File,
@ -7,7 +6,10 @@ import type {
StringLiteral,
} from "@babel/types";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { parserPluginsForPath } from "./javascript/javascriptSourceParser.js";
import {
parseRecoveredJavaScript,
parserPluginsForPath,
} from "./javascript/javascriptSourceParser.js";
import { unshadowedReferenceSourceRequireCalls } from "./referenceSourceRequireBindings.js";
import {
isCallExpression,
@ -55,6 +57,7 @@ interface ReferenceSourceParseFailure {
export interface ReferenceSourceImportParseResult {
readonly relationships: readonly ReferenceSourceImportRelationship[];
readonly parse_failures: readonly ReferenceSourceParseFailure[];
readonly limitations: readonly string[];
}
const codeSourceLanguages = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
@ -272,6 +275,7 @@ const parseWithBabel = (
): {
ast: File | undefined;
reasons: readonly string[];
moduleOnlyErrors: readonly string[];
} => {
// Single parser-mode owner: path-driven plugins (dts/JSX/mts) shared with
// the semantic pipeline. `language` only gates non-JS/TS callers upstream;
@ -280,19 +284,26 @@ const parseWithBabel = (
const plugins = parserPluginsForPath(path);
try {
const ast = parse(source, {
sourceType: "unambiguous",
allowImportExportEverywhere: false,
allowReturnOutsideFunction: true,
allowUndeclaredExports: true,
errorRecovery: true,
plugins,
});
return { ast, reasons: ast.errors.map((error) => error.message) };
const { file: ast, moduleOnlyErrors } = parseRecoveredJavaScript(
source,
path,
{
allowImportExportEverywhere: false,
allowReturnOutsideFunction: true,
allowUndeclaredExports: true,
plugins,
},
);
return {
ast,
reasons: ast.errors.map((error) => error.message),
moduleOnlyErrors,
};
} catch (cause: unknown) {
return {
ast: undefined,
reasons: [cause instanceof Error ? cause.message : "Parse failed"],
moduleOnlyErrors: [],
};
}
};
@ -304,7 +315,7 @@ export const parseReferenceSourceImports = (
language: string | null,
): ReferenceSourceImportParseResult => {
if (language !== null && !codeSourceLanguages.has(language))
return { relationships: [], parse_failures: [] };
return { relationships: [], parse_failures: [], limitations: [] };
let source: string;
try {
@ -320,9 +331,20 @@ export const parseReferenceSourceImports = (
"Source bytes are not valid UTF-8; import targets were not parsed.",
},
],
limitations: [],
};
}
const { ast, reasons } = parseWithBabel(path, source, language);
const { ast, reasons, moduleOnlyErrors } = parseWithBabel(
path,
source,
language,
);
const limitations =
moduleOnlyErrors.length === 0
? []
: [
`Strict-mode errors apply only if this file is an ES module: ${moduleOnlyErrors.join("; ")}`,
];
const parseFailures = reasons.map((reason) => ({
path,
parser: "babel",
@ -333,6 +355,7 @@ export const parseReferenceSourceImports = (
return {
relationships: [],
parse_failures: parseFailures,
limitations,
};
}
@ -354,5 +377,6 @@ export const parseReferenceSourceImports = (
: relationship.parse_state,
})),
parse_failures: parseFailures,
limitations,
};
};

View File

@ -7,7 +7,10 @@ import {
} from "./javascript/javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import type { JavaScriptSemanticReference } from "./javascript/javascriptSemanticIr.js";
import type { ParsedJavaScriptSource } from "./javascript/javascriptSourceParser.js";
import {
isExplicitModuleSourcePath,
type ParsedJavaScriptSource,
} from "./javascript/javascriptSourceParser.js";
/**
* Keep calls that resolve either to Node's CommonJS wrapper loader or to a
@ -29,7 +32,10 @@ export const unshadowedReferenceSourceRequireCalls = (
semanticScopeId("program", file.program),
"require",
);
const allowsUnboundRequire = !isExplicitModuleSource(path);
// Syntax alone cannot establish Node's runtime mode: `.js` depends on its
// package scope, and `.ts` may be compiled with a CommonJS module target.
// Keep unmarked paths runtime-unknown and preserve candidate require edges.
const allowsUnboundRequire = !isExplicitModuleSourcePath(path);
const hasTopLevelVarRequire =
allowsUnboundRequire && hasUninitializedTopLevelVarRequire(file.program);
const createRequireBinding = directCreateRequireBinding(
@ -82,16 +88,6 @@ const contains = (container: t.Node, node: t.Node): boolean =>
container.start <= node.start &&
node.end <= container.end;
const isExplicitModuleSource = (path: string): boolean => {
const lower = path.toLowerCase();
if (lower.endsWith(".mjs") || lower.endsWith(".mts")) return true;
if (lower.endsWith(".cjs") || lower.endsWith(".cts")) return false;
// Syntax alone cannot establish Node's runtime mode: `.js` depends on its
// package scope, and `.ts` may be compiled with a CommonJS module target.
// Keep unmarked paths runtime-unknown and preserve candidate require edges.
return false;
};
const hasUninitializedTopLevelVarRequire = (program: t.Program): boolean => {
let found = false;
let initialized = false;

View File

@ -0,0 +1,90 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"imports a legacy subtree of a Git checkout with its own source facts",
async ({ cli }) => {
const repository = await createTestTempDirectory("rea-reference-legacy-");
const source = join(repository, "source");
await mkdir(source);
await Promise.all([
// `.cjs` is CommonJS, so the strict-mode rule does not apply.
writeFile(
join(source, "legacy.cjs"),
"var messages = {};\ndelete messages;\n",
),
// Deleting a plain binding is valid only in a sloppy-mode script.
writeFile(
join(source, "legacy.js"),
"var messages = {};\ndelete messages;\n",
),
// An explicit module keeps the strict-mode error.
writeFile(
join(source, "module.mjs"),
"var messages = {};\ndelete messages;\n",
),
writeFile(join(source, "index.php"), "<?php echo 'legacy';\n"),
writeFile(join(source, "Program.cs"), "class Program {}\n"),
]);
const git = (...args: string[]) =>
execFileOutput("git", args, { cwd: repository });
await git("init", "--initial-branch=main");
await git("add", ".");
await git(
"-c",
"user.name=REA fixture",
"-c",
"user.email=fixture@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"fixture",
);
const head = (await git("rev-parse", "HEAD")).stdout.trim();
const result = await cli.run({
arguments: ["import-reference-source", source, "--json"],
environment: {
HOME: repository,
XDG_CONFIG_HOME: repository,
XDG_CACHE_HOME: repository,
},
});
expect(result.exitCode).toBe(0);
const graph = historicalSourceGraphSchema.parse(result.json);
expect(graph.parse_failures).toEqual([
{
path: "module.mjs",
parser: "babel",
reason: "Deleting local variable in strict mode. (2:0)",
},
]);
// Outside `.mjs`/`.mts`/`.cjs` the mode stays unknown, not forced to script.
const limitations = (path: string) =>
graph.entries.find((entry) => entry.path === path)?.limitations;
for (const path of ["legacy.js"])
expect(limitations(path)).toEqual([
"Strict-mode errors apply only if this file is an ES module: Deleting local variable in strict mode. (2:0)",
]);
expect(limitations("legacy.cjs")).toEqual([]);
expect(graph.languages).toEqual(["C#", "JavaScript", "PHP"]);
for (const path of ["Program.cs", "index.php"])
expect(
graph.entries.find((entry) => entry.path === path)?.classifications,
).toContain("source");
expect(graph.vcs).toEqual({ kind: "git", head, dirty: null });
expect(graph.provenance.importer_version).toBe(
PRODUCT_IDENTITY.packageVersion,
);
},
);