diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index d1709b025..fcbd5b3d3 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -379,7 +379,9 @@ const relationshipProperties = ( resolution_status: target?.status ?? null, target_file_kind: target?.file?.kind ?? null, target_json_status: - target?.file?.kind === "json" ? jsonStatus(target.file) : null, + target?.file?.kind === "json" || target?.file?.kind === "package-json" + ? jsonStatus(target.file) + : null, }); const relationshipEvidence = ( diff --git a/tests/acceptance/applications/packageJsonImports.test.ts b/tests/acceptance/applications/packageJsonImports.test.ts new file mode 100644 index 000000000..9de31735c --- /dev/null +++ b/tests/acceptance/applications/packageJsonImports.test.ts @@ -0,0 +1,105 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reports JSON availability for imported package manifests through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-package-json-imports-"); + const files = { + "main.cjs": [ + 'const version = require("./package.json").version;', + 'const data = require("./data.json");', + 'const helper = require("./helper.cjs");', + 'const broken = require("./broken/package.json");', + 'const nonObject = require("./non-object/package.json");', + 'const unavailable = require("./unavailable/package.json");', + ].join("\n"), + "package.json": '{"name":"app","version":"1.0.0"}', + "data.json": "[]", + "helper.cjs": "module.exports = 1;", + "broken/package.json": "{", + "non-object/package.json": "[]", + "unavailable/package.json": Buffer.from([0xff]), + }; + await Promise.all( + Object.entries(files).map(async ([path, content]) => { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content); + }), + ); + const nativeRequire = createRequire(join(root, "main.cjs")); + expect(nativeRequire("./package.json").version).toBe("1.0.0"); + expect(nativeRequire("./non-object/package.json")).toEqual([]); + expect(nativeRequire("./data.json")).toEqual([]); + + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + const imports = graph.edges.filter( + ({ properties }) => properties.module_link_kind === "require", + ); + for (const [path, kind, status, metadataStatus] of [ + ["package.json", "package-json", "included", "included"], + ["data.json", "json", "included", null], + ["helper.cjs", "javascript", null, null], + ["broken/package.json", "package-json", "invalid", "invalid"], + ["non-object/package.json", "package-json", "included", "invalid"], + [ + "unavailable/package.json", + "package-json", + "unavailable", + "unavailable", + ], + ]) { + expect(imports).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + specifier: `./${path}`, + resolved_path: path, + resolution_status: "resolved", + target_file_kind: kind, + target_json_status: status, + }), + }), + ); + if (kind === "package-json") + expect( + graph.nodes + .filter(({ kind }) => kind === "package") + .flatMap(({ observations }) => observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + path, + parse_status: metadataStatus, + }), + }), + ); + } + }, +); diff --git a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts index 8bc5f2aa3..17c47c1c9 100644 --- a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts +++ b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts @@ -1,11 +1,80 @@ import { setImmediate } from "node:timers"; -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; +it("preserves imported manifest status when cancelled before visiting manifests", async () => { + const root = await createTestTempDirectory("rea-js-cancel-package-"); + const manifests = [ + ["valid", '{"version":"1.0.0"}', "included"], + ["malformed", "{", "invalid"], + ["non-object", "[]", "included"], + ["unavailable", Buffer.from([0xff]), "unavailable"], + ] as const; + await Promise.all( + manifests.map(async ([name, content]) => { + const directory = join(root, "z", name); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "package.json"), content); + }), + ); + await writeFile( + join(root, "a.cjs"), + manifests + .map( + ([name], index) => + `const manifest${index} = require("./z/${name}/package.json");`, + ) + .join("\n"), + ); + await writeFile(join(root, "b.cjs"), "module.exports = 1;"); + const controller = new AbortController(); + const visited: string[] = []; + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + if (event.phase !== "parse_javascript_source") return; + visited.push(event.message); + if (event.message.includes("b.cjs")) controller.abort(); + }, + }, + }, + ); + if (result.ok) throw new Error("Cancelled analysis must return a failure"); + const failure = projectAnalysisError(result.error); + expect(failure.code).toBe("cancelled"); + expect(visited).toHaveLength(2); + const { graph } = z + .object({ + details: z.object({ + partial_observation: z.object({ + normalized_result: javascriptApplicationAnalysisResultSchema, + }), + }), + }) + .parse(failure).details.partial_observation.normalized_result; + for (const [name, , status] of manifests) + expect( + graph.edges.find( + ({ properties }) => + properties.module_link_kind === "require" && + properties.specifier === `./z/${name}/package.json`, + )?.properties, + ).toMatchObject({ + resolution_status: "resolved", + target_file_kind: "package-json", + target_json_status: status, + }); +}); + describe("JavaScript application cancellation", () => { it("interrupts semantic graph commitment before result validation", async () => { const root = await createTestTempDirectory("rea-js-cancel-semantic-");