diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26834d1452..cdec07f83d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,9 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc + - name: Validate source architecture before dependency installation + if: needs.changes.outputs.code == 'true' + run: node scripts/verify/architecture-guards.mjs - name: Cache npm package downloads id: npm-cache uses: ./.github/actions/npm-cache diff --git a/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts b/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts index be3267b48f..fd5fb24321 100644 --- a/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts @@ -5,6 +5,7 @@ import { } from "../../domain/javascript/javascriptSourceCoordinates.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; import { addSemanticGraphRelation, @@ -71,10 +72,11 @@ const operatorLiteralIndex = ( return node; seen.add(node.node_id); const entry = { node, range, order: order++ }; - const id = `${literalValueKey(value)}\0${javascriptSourceRangeKey(range)}`; - const group = byValue.get(literalValueKey(value)) ?? []; + const valueKey = literalValueKey(value); + const id = compositeKey([valueKey, javascriptSourceRangeKey(range)]); + const group = byValue.get(valueKey) ?? []; group.push(entry); - byValue.set(literalValueKey(value), group); + byValue.set(valueKey, group); if (!exact.has(id)) exact.set(id, entry); return node; }; @@ -92,7 +94,7 @@ const operatorLiteralIndex = ( ) best = entry; }; - consider(exact.get(`${valueKey}\0${operandKey}`)); + consider(exact.get(compositeKey([valueKey, operandKey]))); const group = byValue.get(valueKey) ?? []; let low = 0; let high = group.length; diff --git a/tests/boundary/filesystem/ciCachePolicy.test.ts b/tests/boundary/filesystem/ciCachePolicy.test.ts index 6b5b22f913..058c47c94b 100644 --- a/tests/boundary/filesystem/ciCachePolicy.test.ts +++ b/tests/boundary/filesystem/ciCachePolicy.test.ts @@ -361,7 +361,7 @@ printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache' ); }); - it("replaces every inline setup-node npm cache immediately and preserves conditions", async () => { + it("pairs npm caches with preceding Node setup and preserves conditions", async () => { const workflows = await readWorkflows(); let npmCacheSteps = 0; @@ -377,12 +377,17 @@ printf '%s\n' 'C:\Users\runner with spaces\AppData\Local\npm-cache' const step = steps[index]; if (step?.uses !== "./.github/actions/npm-cache") continue; npmCacheSteps++; - const setup = steps[index - 1]; + const setup = steps + .slice(0, index) + .findLast( + (candidate) => + typeof candidate.uses === "string" && + candidate.uses.startsWith("actions/setup-node@"), + ); expect( - typeof setup?.uses === "string" && - setup.uses.startsWith("actions/setup-node@"), - `${file}:${jobName} npm cache action must immediately follow setup-node`, - ).toBe(true); + setup, + `${file}:${jobName} npm cache action requires preceding setup-node`, + ).toBeDefined(); expect(step.if).toBe(setup?.if); expect(setup?.with).not.toHaveProperty("cache", "npm"); const npmCiIndex = steps.findIndex( diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index 157c3454ee..8e994e4385 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -180,6 +180,33 @@ it("makes the required gate wait for every CI lane, including future additions", ); }); +it("runs architecture guards for fast code feedback before installing dependencies", async () => { + const steps = z + .array( + z + .object({ + run: z.string().optional(), + uses: z.string().optional(), + if: z.string().optional(), + }) + .passthrough(), + ) + .parse((await workflow()).jobs.static?.steps); + const guard = steps.findIndex( + (step) => step.run === "node scripts/verify/architecture-guards.mjs", + ); + const setup = steps.findIndex((step) => + step.uses?.startsWith("actions/setup-node@"), + ); + const install = steps.findIndex((step) => step.run === "npm ci"); + expect(guard).toBeGreaterThanOrEqual(0); + expect(setup).toBeGreaterThanOrEqual(0); + expect(install).toBeGreaterThan(guard); + expect(guard).toBeGreaterThan(setup); + expect(steps[guard]?.if).toBe("needs.changes.outputs.code == 'true'"); + expect(steps[guard]?.["continue-on-error"]).toBeUndefined(); +}); + it("holds native and provider fan-out behind static validation while portable feedback stays independent", async () => { const { jobs } = await workflow(); const earlyJobs = new Set([