From a1b3e92bbe308e7f244c8adf5682ba9a6827ce2e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:46:17 +0000 Subject: [PATCH 01/10] fix(#1928): banner reads planned requirements off the emission plan registerPlannedToolchain copied the float-assert and IRQ-wrapper blocks' requirements from IEmissionPlan into ToolchainRequirements, so the banner read a transcription of 2.2's decision. The plan is now held on TranspileState for the file's turn (cleared per file), and CodeGenerator.getToolchainRequirements composes the one per-file list: what Render recorded at its emission sites, unioned with EmissionPlan.requirements(plan). The banner and IFileResult.requirements both read that list. The copy and its stale CodeGenState comment are gone. RequirementAggregator.union is the shared key/site union that merge now builds on. Co-Authored-By: Joshua Austill --- src/TRANSPILE/2-Plan/EmissionPlan.ts | 14 +++++++ .../2-Plan/__tests__/EmissionPlan.test.ts | 27 ++++++++++++++ .../3-Render/codegen/CodeGenerator.ts | 18 ++++++--- src/TRANSPILE/CodeGenWalker.ts | 35 ++---------------- src/TRANSPILE/TranspileState.ts | 10 +++++ src/instrumentation/ToolchainRequirements.ts | 2 +- src/types/IFileResult.ts | 3 +- src/types/IRecordedRequirement.ts | 5 ++- src/utils/RequirementAggregator.ts | 22 +++++++++-- .../__tests__/RequirementAggregator.test.ts | 37 +++++++++++++++++++ 10 files changed, 129 insertions(+), 44 deletions(-) diff --git a/src/TRANSPILE/2-Plan/EmissionPlan.ts b/src/TRANSPILE/2-Plan/EmissionPlan.ts index 8fdc50629..d07b52795 100644 --- a/src/TRANSPILE/2-Plan/EmissionPlan.ts +++ b/src/TRANSPILE/2-Plan/EmissionPlan.ts @@ -47,6 +47,7 @@ import type IEmissionFacts from "../../types/IEmissionFacts"; import type IEmissionPlan from "../../types/IEmissionPlan"; import type IPlannedBlock from "../../types/IPlannedBlock"; +import type IRecordedRequirement from "../../types/IRecordedRequirement"; import type TRequirementKey from "../../types/TRequirementKey"; import SYSTEM_INCLUDE_TARGETS from "./SYSTEM_INCLUDE_TARGETS"; import CTypeIncludes from "./CTypeIncludes"; @@ -90,6 +91,19 @@ class EmissionPlan { * * @param facts one file's accumulated questions, frozen at the warm moment */ + /** + * The requirements the plan's blocks carry, each with the sites that asked + * for its block. A view of decisions already in the plan, not a new one. + */ + static requirements(plan: IEmissionPlan): readonly IRecordedRequirement[] { + const blocks = [plan.floatStaticAssert, plan.irqWrappers]; + return blocks.flatMap((block) => + block === null + ? [] + : block.requirements.map((key) => ({ key, sites: block.sites })), + ); + } + static build(facts: IEmissionFacts): IEmissionPlan { const plan: IEmissionPlan = { systemIncludes: EmissionPlan.decideSystemIncludes(facts), diff --git a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts index 65be0e41d..342c2a93a 100644 --- a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts +++ b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts @@ -155,6 +155,33 @@ describe("EmissionPlan (2.2 Plan)", () => { }); }); + describe("requirements -- read off the blocks, never copied (#1928)", () => { + it("lists every planned block's keys with that block's sites", () => { + const site = { sourcePath: "a.cnx", line: 3 }; + const plan = EmissionPlan.build( + facts({ + needsFloatStaticAssert: true, + needsIrqWrappers: true, + floatAssertSites: [site], + }), + ); + + expect(EmissionPlan.requirements(plan)).toEqual([ + { key: "float-assert-c11", sites: [site] }, + { key: "critical-arm-gnu", sites: [] }, + { key: "critical-arduino", sites: [] }, + { key: "critical-avr-libc", sites: [] }, + { key: "critical-cmsis-fallback", sites: [] }, + ]); + }); + + it("is empty when the plan has no block that costs anything", () => { + expect(EmissionPlan.requirements(EmissionPlan.build(NOTHING))).toEqual( + [], + ); + }); + }); + describe("ISR typedef", () => { it.each([ [false, true], diff --git a/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts b/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts index 2e23976b5..ac7673948 100644 --- a/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts +++ b/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts @@ -74,6 +74,8 @@ import PassByValueAnalyzer from "../../2-Plan/PassByValueAnalyzer"; // Issue #797: Centralized C-style name generation import type IRecordedRequirement from "../../../types/IRecordedRequirement"; import ToolchainRequirements from "../../../instrumentation/ToolchainRequirements"; +import RequirementAggregator from "../../../utils/RequirementAggregator"; +import EmissionPlan from "../../2-Plan/EmissionPlan"; import TranspileState from "../../TranspileState"; import StructDefaultInitializer from "./helpers/StructDefaultInitializer"; import StructDefault from "../../../utils/StructDefault"; @@ -232,14 +234,20 @@ export default class CodeGenerator implements IOrchestrator { } /** - * Issue #1143: Snapshot the toolchain requirements recorded during the last - * generate() call. + * Issue #1143: what the last generate() call's output requires. * - * Must be read before the next file's this.state.reset(), which clears the - * recording map. + * Two sources, each the owner of its facts: what Render recorded at the + * sites that emitted text, and what 2.2's planned blocks carry, read off the + * plan (#1928). The banner and the file result both read this one list. + * + * Must be read before the next file's this.state.reset(), which clears both. */ getToolchainRequirements(): readonly IRecordedRequirement[] { - return ToolchainRequirements.collect(); + const plan = this.state.emissionPlanOrNull; + return RequirementAggregator.union([ + ToolchainRequirements.collect(), + plan === null ? [] : EmissionPlan.requirements(plan), + ]); } /** diff --git a/src/TRANSPILE/CodeGenWalker.ts b/src/TRANSPILE/CodeGenWalker.ts index 7a22207fc..62d006d56 100644 --- a/src/TRANSPILE/CodeGenWalker.ts +++ b/src/TRANSPILE/CodeGenWalker.ts @@ -1650,11 +1650,9 @@ class CodeGenWalker { this.captureEmissionFacts(sourceIncludeTargets), ); - // The plan decided WHICH toolchain capabilities these helpers need and at - // which sites; registering them is part of that decision, not part of - // formatting. `addGeneratedHelpers` used to do it while pushing the text, - // which made the renderer a writer of state something downstream reads. - CodeGenWalker.registerPlannedToolchain(plan); + // #1928: the banner and the file result read the planned blocks' + // requirements off the plan itself, so nothing copies them anywhere. + this.host.state.emissionPlanOrNull = plan; // 2.3 Render: format the plan. These two decide nothing. this.addAutoIncludes(output, plan); @@ -1887,9 +1885,6 @@ class CodeGenWalker { return match ? match[1] : null; } - /** - * Add generated helpers (static asserts, IRQ wrappers, typedefs, etc.). - */ /** * Print the deferred blocks and helpers the plan decided on. * @@ -1898,31 +1893,7 @@ class CodeGenWalker { * the two cannot disagree -- #1143 kept them in step by computing both from * one ternary at this site; the plan keeps them in step by making them two * fields of one record, and the ternary is gone from here. - * - * Requirements are still recorded into `CodeGenState` rather than read off - * the plan by the banner, because the banner also carries requirements this - * plan does not yet own (the mode baseline, C++ initializer forms, atomics). - * Recording a decision someone else made is transcription, not derivation. */ - /** - * Register the toolchain capabilities the plan's helper blocks need. - * - * Separated from `addGeneratedHelpers` so that rendering a block and - * declaring what the block requires are not the same act: the plan already - * holds both the keys and the sites, and a renderer that writes them is a - * renderer making state visible downstream. Order is unchanged -- this runs - * immediately after the plan is built, and nothing between reads a toolchain - * requirement. - */ - private static registerPlannedToolchain(plan: IEmissionPlan): void { - for (const block of [plan.floatStaticAssert, plan.irqWrappers]) { - if (block === null) continue; - for (const key of block.requirements) { - ToolchainRequirements.record(key, block.sites); - } - } - } - private addGeneratedHelpers(output: string[], plan: IEmissionPlan): void { const floatAssert = plan.floatStaticAssert; if (floatAssert !== null) { diff --git a/src/TRANSPILE/TranspileState.ts b/src/TRANSPILE/TranspileState.ts index d78fc3a77..59d39ace5 100644 --- a/src/TRANSPILE/TranspileState.ts +++ b/src/TRANSPILE/TranspileState.ts @@ -20,6 +20,7 @@ import type ITypeBindingDeps from "../types/ITypeBindingDeps"; import type IProgram from "../types/IProgram"; import type ITypingContext from "../types/ITypingContext"; import type IDeclarationPlan from "../types/IDeclarationPlan"; +import type IEmissionPlan from "../types/IEmissionPlan"; import type IFunctionSignature from "../types/IFunctionSignature"; import invariant from "../utils/invariant"; import emittedLocalName from "../utils/emittedLocalName"; @@ -309,6 +310,14 @@ class TranspileState { * silently-wrong default. */ declarationPlanOrNull: IDeclarationPlan | null = null; + /** + * 2.2 Plan's emission decisions for the file being generated. + * + * Held for the rest of the file's turn so the requirements its planned + * blocks carry are read off the plan, never copied into + * `ToolchainRequirements` (#1928). Null until the declarations have rendered. + */ + emissionPlanOrNull: IEmissionPlan | null = null; /** ADR-013: Track function parameter const-ness for call-site validation */ functionSignatures: Map = new Map(); /** Callback field types: "Struct.field" -> callbackTypeName */ @@ -1219,6 +1228,7 @@ class TranspileState { this.callbackTypeReferences = new Set(); this.publicCallbackTypeReferences = new Set(); this.declarationPlanOrNull = null; + this.emissionPlanOrNull = null; this.usedClampOps = new Set(); this.usedSafeDivOps = new Set(); this.usedCastHelpers = new Set(); diff --git a/src/instrumentation/ToolchainRequirements.ts b/src/instrumentation/ToolchainRequirements.ts index 499d1a561..267a24ca0 100644 --- a/src/instrumentation/ToolchainRequirements.ts +++ b/src/instrumentation/ToolchainRequirements.ts @@ -28,7 +28,7 @@ * * | Write site | Pass | * | ------------------------------------- | ------------- | - * | `CodeGenWalker` (4 sites) | 2.3 Render | + * | `CodeGenWalker` (3 sites) | 2.3 Render | * | `CodeGenerator.applyEffects` | 2.3 Render | * | `HelperGenerator` | 2.3 Render | * | `CodeGenState.requireInclude` | 2.3 Render | diff --git a/src/types/IFileResult.ts b/src/types/IFileResult.ts index 3c9852fae..46e816577 100644 --- a/src/types/IFileResult.ts +++ b/src/types/IFileResult.ts @@ -40,7 +40,8 @@ interface IFileResult { /** * Issue #1143: Toolchain requirements this file's generated output actually - * carries, recorded by the emitters that produced the text. + * carries: recorded by the emitters that produced the text, plus what the + * emission plan's blocks carry (#1928). */ requirements?: readonly IRecordedRequirement[]; } diff --git a/src/types/IRecordedRequirement.ts b/src/types/IRecordedRequirement.ts index 361e6ce8d..315ab1c43 100644 --- a/src/types/IRecordedRequirement.ts +++ b/src/types/IRecordedRequirement.ts @@ -5,8 +5,9 @@ import type TRequirementKey from "./TRequirementKey"; * Issue #1143: A requirement that generated output actually carries, together * with every source location that incurred it. * - * Recorded by the emitter at the moment the text is produced, so a consumer can - * never disagree with what was emitted. + * Recorded by the emitter at the moment the text is produced, or carried by the + * 2.2 plan block that decided the text (#1928), so a consumer can never + * disagree with what was emitted. */ interface IRecordedRequirement { /** Registry key; look up the details in TOOLCHAIN_REQUIREMENTS. */ diff --git a/src/utils/RequirementAggregator.ts b/src/utils/RequirementAggregator.ts index ccba15a12..210548898 100644 --- a/src/utils/RequirementAggregator.ts +++ b/src/utils/RequirementAggregator.ts @@ -14,10 +14,26 @@ import RequirementSites from "./RequirementSites"; class RequirementAggregator { /** Union file requirements by key, concatenating their source sites. */ static merge(files: readonly IFileResult[]): readonly IRecordedRequirement[] { + return RequirementAggregator.union( + files.map((file) => file.requirements ?? []), + ).map((entry) => ({ + key: entry.key, + sites: RequirementAggregator.sortSites(entry.sites), + })); + } + + /** + * Union requirement lists by key, keeping every distinct site in the order + * first seen. One file's list is built this way too (#1928): what Render + * recorded, plus what the emission plan's blocks carry. + */ + static union( + lists: readonly (readonly IRecordedRequirement[])[], + ): readonly IRecordedRequirement[] { const merged = new Map(); - for (const file of files) { - for (const entry of file.requirements ?? []) { + for (const list of lists) { + for (const entry of list) { const sites = merged.get(entry.key); if (sites === undefined) { merged.set(entry.key, [...entry.sites]); @@ -31,7 +47,7 @@ class RequirementAggregator { return Array.from(merged.entries()).map(([key, sites]) => ({ key, - sites: RequirementAggregator.sortSites(sites), + sites, })); } diff --git a/src/utils/__tests__/RequirementAggregator.test.ts b/src/utils/__tests__/RequirementAggregator.test.ts index c9ad0cc62..d2a03306b 100644 --- a/src/utils/__tests__/RequirementAggregator.test.ts +++ b/src/utils/__tests__/RequirementAggregator.test.ts @@ -145,3 +145,40 @@ describe("RequirementAggregator.merge", () => { expect(merged[0]!.sites[0]).toEqual({ sourcePath: "a.cnx", line: null }); }); }); + +describe("RequirementAggregator.union (#1928: one file's list)", () => { + it("joins the recorded list with the plan's, keeping first-seen site order", () => { + const union = RequirementAggregator.union([ + [ + { key: "baseline-c", sites: [] }, + { key: "critical-arm-gnu", sites: [{ sourcePath: "b.cnx", line: 9 }] }, + ], + [ + { key: "critical-arm-gnu", sites: [{ sourcePath: "a.cnx", line: 4 }] }, + { key: "float-assert-c11", sites: [] }, + ], + ]); + + expect(union).toEqual([ + { key: "baseline-c", sites: [] }, + { + key: "critical-arm-gnu", + sites: [ + { sourcePath: "b.cnx", line: 9 }, + { sourcePath: "a.cnx", line: 4 }, + ], + }, + { key: "float-assert-c11", sites: [] }, + ]); + }); + + it("does not repeat a site both lists carry", () => { + const site = { sourcePath: "a.cnx", line: 4 }; + const union = RequirementAggregator.union([ + [{ key: "critical-arm-gnu", sites: [site] }], + [{ key: "critical-arm-gnu", sites: [site] }], + ]); + + expect(union[0]!.sites).toEqual([site]); + }); +}); From 9b2e59460f97f717d1f3b79cc0f871af2fa0d1b2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:25:22 +0000 Subject: [PATCH 02/10] fix(#1928): assert the emission plan; docs name the one requirement list Review of #1996: - TranspileState.emissionPlan() asserts the plan exists, like declarationPlan(); getToolchainRequirements() reads it instead of defaulting a missing plan to no requirements. A unit test calls it before generate() and expects the invariant's message. - EmissionPlan.requirements() moves below build(), so build() keeps its own JSDoc. - ToolchainRequirements' header, deferred-sites and collect() docs say it holds Render's records only; the table names TranspileState.requireInclude and the tree it was measured on. - Transpiler.ts's read comment and RequirementSites' history stop naming the deleted requireToolchain/CodeGenState as current. - docs/architecture/README.md: the ledger read-back example is the C++20 line from cpp-designated-initializer, and planned-block features are noted as 2.2's. Co-Authored-By: Joshua Austill --- docs/architecture/README.md | 6 +++-- src/TRANSPILE/2-Plan/EmissionPlan.ts | 26 +++++++++---------- .../3-Render/codegen/CodeGenerator.ts | 3 +-- src/TRANSPILE/TranspileState.ts | 15 +++++++++++ src/TRANSPILE/__tests__/CodeGenWalker.test.ts | 8 ++++++ src/cli/Transpiler.ts | 24 +++++++++-------- src/instrumentation/ToolchainRequirements.ts | 25 +++++++++++++----- src/utils/RequirementSites.ts | 5 ++-- 8 files changed, 75 insertions(+), 37 deletions(-) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 96d7054ba..484bb27dc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -123,7 +123,8 @@ files at the root: an entry point lives inside the root it starts. construct the pipeline. Each is entered through its own `index.ts`: the command-line tool and the library are separate concerns and do not share a starting point. - **Instrumentation** -- `instrumentation/`. Records facts about the RUN, never about the - program: where an ADR's rule fired, which toolchain features a run required. Written + program: where an ADR's rule fired, which toolchain features a run's Render emitters + required (a 2.2 planned block's features are 2.2's decision, read off the plan, #1928). Written from any layer, read once by the host, delivered on the run's result. It authors no fact the program has, so it is not a state container, and it decides nothing, so it is not a pass. @@ -141,7 +142,8 @@ files at the root: an entry point lives inside the root it starts. **"Read once at the end" is not the test -- "nothing branches on it" is**, and the distinction matters because the ledger IS read back. `CodeGenWalker.buildBanner` puts - ` * Requires: C11.` into the generated `.c`, and `captureEmissionFacts` carries + ` * Requires: C++20.` into a generated `.cpp` that uses a designated initializer + (`cpp-designated-initializer`, recorded where `CodeGenWalker` emits it), and `captureEmissionFacts` carries `takeDeferredSites(...)` into `IEmissionFacts`. Checked, because a rule stated loosely reads as violated: `EmissionPlan` branches on `facts.needsFloatStaticAssert` and `facts.needsIrqWrappers` -- both render state -- and the sites travel beside them as diff --git a/src/TRANSPILE/2-Plan/EmissionPlan.ts b/src/TRANSPILE/2-Plan/EmissionPlan.ts index d07b52795..bc5c0c538 100644 --- a/src/TRANSPILE/2-Plan/EmissionPlan.ts +++ b/src/TRANSPILE/2-Plan/EmissionPlan.ts @@ -91,19 +91,6 @@ class EmissionPlan { * * @param facts one file's accumulated questions, frozen at the warm moment */ - /** - * The requirements the plan's blocks carry, each with the sites that asked - * for its block. A view of decisions already in the plan, not a new one. - */ - static requirements(plan: IEmissionPlan): readonly IRecordedRequirement[] { - const blocks = [plan.floatStaticAssert, plan.irqWrappers]; - return blocks.flatMap((block) => - block === null - ? [] - : block.requirements.map((key) => ({ key, sites: block.sites })), - ); - } - static build(facts: IEmissionFacts): IEmissionPlan { const plan: IEmissionPlan = { systemIncludes: EmissionPlan.decideSystemIncludes(facts), @@ -120,6 +107,19 @@ class EmissionPlan { return Object.freeze(plan); } + /** + * The requirements the plan's blocks carry, each with the sites that asked + * for its block. A view of decisions already in the plan, not a new one. + */ + static requirements(plan: IEmissionPlan): readonly IRecordedRequirement[] { + const blocks = [plan.floatStaticAssert, plan.irqWrappers]; + return blocks.flatMap((block) => + block === null + ? [] + : block.requirements.map((key) => ({ key, sites: block.sites })), + ); + } + /** * Which system headers the implementation file emits, already deduplicated * against what its own source includes. diff --git a/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts b/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts index ac7673948..6b7e73e76 100644 --- a/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts +++ b/src/TRANSPILE/3-Render/codegen/CodeGenerator.ts @@ -243,10 +243,9 @@ export default class CodeGenerator implements IOrchestrator { * Must be read before the next file's this.state.reset(), which clears both. */ getToolchainRequirements(): readonly IRecordedRequirement[] { - const plan = this.state.emissionPlanOrNull; return RequirementAggregator.union([ ToolchainRequirements.collect(), - plan === null ? [] : EmissionPlan.requirements(plan), + EmissionPlan.requirements(this.state.emissionPlan()), ]); } diff --git a/src/TRANSPILE/TranspileState.ts b/src/TRANSPILE/TranspileState.ts index 59d39ace5..68ba187fc 100644 --- a/src/TRANSPILE/TranspileState.ts +++ b/src/TRANSPILE/TranspileState.ts @@ -372,6 +372,21 @@ class TranspileState { ); return plan; } + /** + * 2.2 Plan's emission decisions, asserted present. + * + * Same reason as `declarationPlan()`: answering "no planned block costs + * anything" before the plan exists would drop the interrupt-masking and C11 + * lines from the banner without failing. + */ + emissionPlan(): IEmissionPlan { + const plan = this.emissionPlanOrNull; + invariant( + plan !== null, + "2.2 Plan builds the emission plan before its requirements are read", + ); + return plan; + } /** * Compute unmodified parameters for all functions on-demand. * Returns a map of function name -> Set of parameter names NOT modified. diff --git a/src/TRANSPILE/__tests__/CodeGenWalker.test.ts b/src/TRANSPILE/__tests__/CodeGenWalker.test.ts index 72ce4181c..b61d4e121 100644 --- a/src/TRANSPILE/__tests__/CodeGenWalker.test.ts +++ b/src/TRANSPILE/__tests__/CodeGenWalker.test.ts @@ -17715,3 +17715,11 @@ describe("CodeGenWalker", () => { }); }); }); + +describe("CodeGenerator.getToolchainRequirements (#1928)", () => { + it("refuses to answer before 2.2 has built the emission plan", () => { + expect(() => new CodeGenerator().getToolchainRequirements()).toThrow( + "2.2 Plan builds the emission plan before its requirements are read", + ); + }); +}); diff --git a/src/cli/Transpiler.ts b/src/cli/Transpiler.ts index a881fe02e..db8b0c67b 100644 --- a/src/cli/Transpiler.ts +++ b/src/cli/Transpiler.ts @@ -1018,17 +1018,19 @@ class Transpiler { this.headerEmissionFactsByPath.set(sourcePath, headerFacts); } - // Issue #1143: read after header-facts CAPTURE, and before the next - // file's TranspileState.reset() clears the recording map. This covers a - // requirement that capturing a header's facts triggers (e.g. through - // HeaderEmissionCapture.convertToHeaderSymbols) -- it does NOT cover one the RENDER might - // trigger, since #1323 moved rendering to Stage 5.5, after every file's - // requirements have already been read here and reset() has run N times. - // Currently unreachable rather than wrong: TranspileState.requireToolchain - // has no caller under output/headers/, so no render path records one -- - // but this read does not guarantee that stays true, and #1143 is - // precisely the bug class where an ordering assumption like that broke - // under a later refactor. + // Issue #1143: the file's one requirement list -- Render's records plus + // what the emission plan's blocks carry (#1928). Read after header-facts + // CAPTURE, and before the next file's TranspileState.reset() clears both. + // This covers a requirement that capturing a header's facts triggers + // (e.g. through HeaderEmissionCapture.convertToHeaderSymbols) -- it does + // NOT cover one the header RENDER might trigger, since #1323 moved + // rendering to Stage 5.5, after every file's requirements have already + // been read here and reset() has run N times. Currently unreachable + // rather than wrong: nothing under TRANSPILE/3-Render/headers/ calls + // ToolchainRequirements, so no header render records one -- but this + // read does not guarantee that stays true, and #1143 is precisely the + // bug class where an ordering assumption like that broke under a later + // refactor. const requirements = this.codeGenerator.getToolchainRequirements(); return this.buildSuccessResult( diff --git a/src/instrumentation/ToolchainRequirements.ts b/src/instrumentation/ToolchainRequirements.ts index 267a24ca0..82546a285 100644 --- a/src/instrumentation/ToolchainRequirements.ts +++ b/src/instrumentation/ToolchainRequirements.ts @@ -1,8 +1,13 @@ /** * Issue #1143: what the generated output for one file costs the reader's - * toolchain, recorded by each emitter at the moment it produces the text. + * toolchain, recorded by each 2.3 Render emitter at the moment it produces the + * text. * - * Consumers read this; nothing re-derives it. PR #1141 failed by re-deriving -- + * This holds Render's records only. The requirements 2.2's planned blocks + * carry (float asserts, IRQ wrappers) are read off the emission plan, never + * recorded here (#1928). The file's one list is + * `CodeGenerator.getToolchainRequirements()`, which unions the two; consumers + * read that, and nothing re-derives it. PR #1141 failed by re-deriving -- * its `#error` guard keyed on `usedClampOps.size > 0` while the emission it * described keyed on the template family, so 96 snapshots shipped a guard for a * builtin they did not contain. Recording at the emission site is what makes a @@ -24,14 +29,14 @@ * genuinely cross-pass, written at 17 `record` sites spanning 2.1 Analyze and * 2.3 Render and read once at the end of the run. This module claims no such * exemption, because it is not cross-pass state at all. Measured on - * `e16b5f03`, every write is inside 2.3 Render: + * `a1b3e92b`, every write is inside 2.3 Render: * * | Write site | Pass | * | ------------------------------------- | ------------- | * | `CodeGenWalker` (3 sites) | 2.3 Render | * | `CodeGenerator.applyEffects` | 2.3 Render | * | `HelperGenerator` | 2.3 Render | - * | `CodeGenState.requireInclude` | 2.3 Render | + * | `TranspileState.requireInclude` | 2.3 Render | * * and every read -- `takeDeferredSites` at plan-build, `collect` for the banner * and for the result -- happens inside the same `generate()` call for the same @@ -61,8 +66,10 @@ class ToolchainRequirements { * the deferral ("irq_wrappers", "float_static_assert"). * * Kept separate from `recorded` because at request time the code has not been - * emitted yet, so there is nothing to record a requirement for. The emitter - * claims these sites when it actually produces the block. + * emitted yet, so there is nothing to record a requirement for. + * `captureEmissionFacts` takes these sites into `IEmissionFacts`, and the + * plan block that decides the emission carries them from there (#1928); they + * never come back to `recorded`. */ private static deferred: Map = new Map(); @@ -112,7 +119,11 @@ class ToolchainRequirements { } /** - * Snapshot what this file's generated output requires. + * Snapshot what Render recorded for this file. + * + * Not the file's whole list: the planned blocks' requirements live on the + * emission plan. `CodeGenerator.getToolchainRequirements()` is the one list, + * and its only reader of this (#1928). * * Must be read before the next file's reset, which clears the recording map. */ diff --git a/src/utils/RequirementSites.ts b/src/utils/RequirementSites.ts index 385a56b6e..d74826c3d 100644 --- a/src/utils/RequirementSites.ts +++ b/src/utils/RequirementSites.ts @@ -4,8 +4,9 @@ import type IRequirementSite from "../types/IRequirementSite"; * Issue #1143: Identity and accumulation for requirement source sites. * * "Is this the same site?" was written out three times -- in - * `CodeGenState.requireToolchain`, in `CodeGenState.noteDeferredSite`, and in - * `RequirementAggregator.merge`. Adding a column to `IRequirementSite`, or + * `CodeGenState.requireToolchain`, in `CodeGenState.noteDeferredSite` (now + * `ToolchainRequirements.record` and `.noteDeferredSite`, #1452), and in + * `RequirementAggregator.merge` (now `.union`, #1928). Adding a column to `IRequirementSite`, or * deciding that an unlocated site should merge with a located one, would have * been three edits that must agree. jscpd cannot see it: each copy is four * lines. From cc8fd41cf05bd8083f9325a9304a13cc8f73bfe2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:39:56 +0000 Subject: [PATCH 03/10] test(#1928): re-pin ToolchainRequirements holders after its doc grew The two holders (recorded, deferred) are unchanged; the header and deferred-sites docs added in the previous commit moved them from lines 56/67 to 61/74. Co-Authored-By: Joshua Austill --- scripts/__tests__/passes-hold-no-mutable-state.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/passes-hold-no-mutable-state.test.ts b/scripts/__tests__/passes-hold-no-mutable-state.test.ts index 9824c8ed4..8f5bba0ce 100644 --- a/scripts/__tests__/passes-hold-no-mutable-state.test.ts +++ b/scripts/__tests__/passes-hold-no-mutable-state.test.ts @@ -100,8 +100,8 @@ const INSTRUMENTATION_ROOT = "src/instrumentation"; const INSTRUMENTATION_HOLDERS = [ "src/instrumentation/AdrProvenance.ts:58", "src/instrumentation/AdrProvenance.ts:59", - "src/instrumentation/ToolchainRequirements.ts:56", - "src/instrumentation/ToolchainRequirements.ts:67", + "src/instrumentation/ToolchainRequirements.ts:61", + "src/instrumentation/ToolchainRequirements.ts:74", ]; /** From f374a51ecc6f5d3404b88fb4d9cfd375958a1b9a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:11:15 +0000 Subject: [PATCH 04/10] fix(#1928): planned blocks keyed by the plan's type; cite #1997 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1996: - EmissionPlan.requirements() reads PLANNED_BLOCKS, a map keyed by every IEmissionPlan field typed IPlannedBlock | null, so a block added to the plan without an entry fails to compile (TS2741) instead of shipping with its cost missing from the banner. - docs/architecture/README.md and the ToolchainRequirements header cite #1997 for the keys Render still authors against §1's Ownership table. - Re-pin the ToolchainRequirements holders the header growth moved. Co-Authored-By: Joshua Austill --- docs/architecture/README.md | 3 ++- .../passes-hold-no-mutable-state.test.ts | 4 ++-- src/TRANSPILE/2-Plan/EmissionPlan.ts | 24 +++++++++++++++---- src/instrumentation/ToolchainRequirements.ts | 11 +++++---- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 484bb27dc..5ca100ff3 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -124,7 +124,8 @@ files at the root: an entry point lives inside the root it starts. tool and the library are separate concerns and do not share a starting point. - **Instrumentation** -- `instrumentation/`. Records facts about the RUN, never about the program: where an ADR's rule fired, which toolchain features a run's Render emitters - required (a 2.2 planned block's features are 2.2's decision, read off the plan, #1928). Written + required (a 2.2 planned block's features are 2.2's decision, read off the plan, #1928; + that Render still authors the rest, against the Ownership table, is tracked by #1997). Written from any layer, read once by the host, delivered on the run's result. It authors no fact the program has, so it is not a state container, and it decides nothing, so it is not a pass. diff --git a/scripts/__tests__/passes-hold-no-mutable-state.test.ts b/scripts/__tests__/passes-hold-no-mutable-state.test.ts index 8f5bba0ce..d158c73c5 100644 --- a/scripts/__tests__/passes-hold-no-mutable-state.test.ts +++ b/scripts/__tests__/passes-hold-no-mutable-state.test.ts @@ -100,8 +100,8 @@ const INSTRUMENTATION_ROOT = "src/instrumentation"; const INSTRUMENTATION_HOLDERS = [ "src/instrumentation/AdrProvenance.ts:58", "src/instrumentation/AdrProvenance.ts:59", - "src/instrumentation/ToolchainRequirements.ts:61", - "src/instrumentation/ToolchainRequirements.ts:74", + "src/instrumentation/ToolchainRequirements.ts:64", + "src/instrumentation/ToolchainRequirements.ts:77", ]; /** diff --git a/src/TRANSPILE/2-Plan/EmissionPlan.ts b/src/TRANSPILE/2-Plan/EmissionPlan.ts index bc5c0c538..5e1855186 100644 --- a/src/TRANSPILE/2-Plan/EmissionPlan.ts +++ b/src/TRANSPILE/2-Plan/EmissionPlan.ts @@ -107,17 +107,31 @@ class EmissionPlan { return Object.freeze(plan); } + /** + * One accessor per planned block, keyed by its `IEmissionPlan` field. A + * block added to the plan and not listed here fails to compile, instead of + * rendering with its cost missing from the banner -- #1143's failure. + */ + private static readonly PLANNED_BLOCKS: { + readonly [K in keyof IEmissionPlan as IEmissionPlan[K] extends IPlannedBlock | null + ? K + : never]: (plan: IEmissionPlan) => IPlannedBlock | null; + } = { + floatStaticAssert: (plan) => plan.floatStaticAssert, + irqWrappers: (plan) => plan.irqWrappers, + }; + /** * The requirements the plan's blocks carry, each with the sites that asked * for its block. A view of decisions already in the plan, not a new one. */ static requirements(plan: IEmissionPlan): readonly IRecordedRequirement[] { - const blocks = [plan.floatStaticAssert, plan.irqWrappers]; - return blocks.flatMap((block) => - block === null + return Object.values(EmissionPlan.PLANNED_BLOCKS).flatMap((blockOf) => { + const block = blockOf(plan); + return block === null ? [] - : block.requirements.map((key) => ({ key, sites: block.sites })), - ); + : block.requirements.map((key) => ({ key, sites: block.sites })); + }); } /** diff --git a/src/instrumentation/ToolchainRequirements.ts b/src/instrumentation/ToolchainRequirements.ts index 82546a285..ca960fdec 100644 --- a/src/instrumentation/ToolchainRequirements.ts +++ b/src/instrumentation/ToolchainRequirements.ts @@ -7,10 +7,13 @@ * carry (float asserts, IRQ wrappers) are read off the emission plan, never * recorded here (#1928). The file's one list is * `CodeGenerator.getToolchainRequirements()`, which unions the two; consumers - * read that, and nothing re-derives it. PR #1141 failed by re-deriving -- - * its `#error` guard keyed on `usedClampOps.size > 0` while the emission it - * described keyed on the template family, so 96 snapshots shipped a guard for a - * builtin they did not contain. Recording at the emission site is what makes a + * read that, and nothing re-derives it. That Render still authors these keys + * at all, where §1's Ownership table gives toolchain requirements to 2.2, is + * tracked by #1997, not settled as the design. + * + * PR #1141 failed by re-deriving -- its `#error` guard keyed on + * `usedClampOps.size > 0` while the emission it described keyed on the template + * family, so 96 snapshots shipped a guard for a builtin they did not contain. Recording at the emission site is what makes a * consumer unable to disagree with what was emitted. * * ## Why this is instrumentation (#1452) From f59644ad1a600e41584a57920f0fe0936bc663f7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:11:38 +0000 Subject: [PATCH 05/10] docs(#1928): rewrap ToolchainRequirements header; re-pin its holders Co-Authored-By: Joshua Austill --- scripts/__tests__/passes-hold-no-mutable-state.test.ts | 4 ++-- src/instrumentation/ToolchainRequirements.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/passes-hold-no-mutable-state.test.ts b/scripts/__tests__/passes-hold-no-mutable-state.test.ts index d158c73c5..3929cff06 100644 --- a/scripts/__tests__/passes-hold-no-mutable-state.test.ts +++ b/scripts/__tests__/passes-hold-no-mutable-state.test.ts @@ -100,8 +100,8 @@ const INSTRUMENTATION_ROOT = "src/instrumentation"; const INSTRUMENTATION_HOLDERS = [ "src/instrumentation/AdrProvenance.ts:58", "src/instrumentation/AdrProvenance.ts:59", - "src/instrumentation/ToolchainRequirements.ts:64", - "src/instrumentation/ToolchainRequirements.ts:77", + "src/instrumentation/ToolchainRequirements.ts:65", + "src/instrumentation/ToolchainRequirements.ts:78", ]; /** diff --git a/src/instrumentation/ToolchainRequirements.ts b/src/instrumentation/ToolchainRequirements.ts index ca960fdec..c69f65dee 100644 --- a/src/instrumentation/ToolchainRequirements.ts +++ b/src/instrumentation/ToolchainRequirements.ts @@ -13,8 +13,9 @@ * * PR #1141 failed by re-deriving -- its `#error` guard keyed on * `usedClampOps.size > 0` while the emission it described keyed on the template - * family, so 96 snapshots shipped a guard for a builtin they did not contain. Recording at the emission site is what makes a - * consumer unable to disagree with what was emitted. + * family, so 96 snapshots shipped a guard for a builtin they did not contain. + * Recording at the emission site is what makes a consumer unable to disagree + * with what was emitted. * * ## Why this is instrumentation (#1452) * From 49349834cbecbf538272d7004c09e3bb96075020 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:44:00 +0000 Subject: [PATCH 06/10] fix(#1928): plan copies its sites; one key-to-sites fold Review of #1996: - decideFloatStaticAssert/decideIrqWrappers copy the facts' site lists, as build() already does for its helper lists, so a site noted after the plan is frozen cannot join a block it did not cause. - RequirementSites.accumulate is the one key-to-sites fold; record, noteDeferredSite and RequirementAggregator.union call it. It drops repeats in the first list for a key too, so union no longer depends on list order. - PLANNED_BLOCKS is keyed by field and read as plan[field]: an optional block without an entry fails to compile, and there is no accessor to mis-read. - ToolchainRequirements.deferred no longer lists clamp helpers. Co-Authored-By: Joshua Austill --- src/TRANSPILE/2-Plan/EmissionPlan.ts | 28 ++++++----- .../2-Plan/__tests__/EmissionPlan.test.ts | 23 ++++++++++ src/instrumentation/ToolchainRequirements.ts | 23 +++------- src/utils/RequirementAggregator.ts | 9 +--- src/utils/RequirementSites.ts | 36 +++++++++++++-- .../__tests__/RequirementAggregator.test.ts | 13 ++++++ src/utils/__tests__/RequirementSites.test.ts | 46 +++++++++++++++++++ 7 files changed, 137 insertions(+), 41 deletions(-) create mode 100644 src/utils/__tests__/RequirementSites.test.ts diff --git a/src/TRANSPILE/2-Plan/EmissionPlan.ts b/src/TRANSPILE/2-Plan/EmissionPlan.ts index 5e1855186..e741be2ee 100644 --- a/src/TRANSPILE/2-Plan/EmissionPlan.ts +++ b/src/TRANSPILE/2-Plan/EmissionPlan.ts @@ -108,17 +108,20 @@ class EmissionPlan { } /** - * One accessor per planned block, keyed by its `IEmissionPlan` field. A - * block added to the plan and not listed here fails to compile, instead of - * rendering with its cost missing from the banner -- #1143's failure. + * Every `IEmissionPlan` field that holds a planned block, nullable or + * optional. A block added to the plan and not listed here fails to compile, + * instead of rendering with its cost missing from the banner -- #1143's + * failure. */ private static readonly PLANNED_BLOCKS: { - readonly [K in keyof IEmissionPlan as IEmissionPlan[K] extends IPlannedBlock | null + readonly [K in keyof IEmissionPlan as NonNullable< + IEmissionPlan[K] + > extends IPlannedBlock ? K - : never]: (plan: IEmissionPlan) => IPlannedBlock | null; + : never]-?: true; } = { - floatStaticAssert: (plan) => plan.floatStaticAssert, - irqWrappers: (plan) => plan.irqWrappers, + floatStaticAssert: true, + irqWrappers: true, }; /** @@ -126,8 +129,11 @@ class EmissionPlan { * for its block. A view of decisions already in the plan, not a new one. */ static requirements(plan: IEmissionPlan): readonly IRecordedRequirement[] { - return Object.values(EmissionPlan.PLANNED_BLOCKS).flatMap((blockOf) => { - const block = blockOf(plan); + const fields = Object.keys( + EmissionPlan.PLANNED_BLOCKS, + ) as (keyof typeof EmissionPlan.PLANNED_BLOCKS)[]; + return fields.flatMap((field) => { + const block = plan[field] ?? null; return block === null ? [] : block.requirements.map((key) => ({ key, sites: block.sites })); @@ -179,7 +185,7 @@ class EmissionPlan { return { keyword: facts.cppMode ? "static_assert" : "_Static_assert", requirements: [facts.cppMode ? "float-assert-cpp11" : "float-assert-c11"], - sites: facts.floatAssertSites, + sites: [...facts.floatAssertSites], }; } @@ -191,7 +197,7 @@ class EmissionPlan { return { keyword: null, requirements: IRQ_WRAPPER_REQUIREMENTS, - sites: facts.irqWrapperSites, + sites: [...facts.irqWrapperSites], }; } } diff --git a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts index 342c2a93a..e3b3607ae 100644 --- a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts +++ b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts @@ -175,6 +175,29 @@ describe("EmissionPlan (2.2 Plan)", () => { ]); }); + // The facts' site lists are 1.x-era accumulators that can still grow; a + // site noted after the decision must not join a block it did not cause. + it("keeps the sites as decided when the facts' lists grow later", () => { + const early = { sourcePath: "a.cnx", line: 3 }; + const irqWrapperSites = [early]; + const floatAssertSites = [early]; + const plan = EmissionPlan.build( + facts({ + needsIrqWrappers: true, + irqWrapperSites, + needsFloatStaticAssert: true, + floatAssertSites, + }), + ); + + irqWrapperSites.push({ sourcePath: "a.cnx", line: 9 }); + floatAssertSites.push({ sourcePath: "a.cnx", line: 9 }); + + for (const entry of EmissionPlan.requirements(plan)) { + expect(entry.sites).toEqual([early]); + } + }); + it("is empty when the plan has no block that costs anything", () => { expect(EmissionPlan.requirements(EmissionPlan.build(NOTHING))).toEqual( [], diff --git a/src/instrumentation/ToolchainRequirements.ts b/src/instrumentation/ToolchainRequirements.ts index c69f65dee..5119bc022 100644 --- a/src/instrumentation/ToolchainRequirements.ts +++ b/src/instrumentation/ToolchainRequirements.ts @@ -65,8 +65,8 @@ class ToolchainRequirements { private static recorded: Map = new Map(); /** - * Source sites for emissions DEFERRED to assembleGeneratedOutput -- clamp - * helpers, IRQ wrappers, float asserts. Keyed by the request that triggered + * Source sites for emissions DEFERRED to assembleGeneratedOutput -- IRQ + * wrappers and float asserts. Keyed by the request that triggered * the deferral ("irq_wrappers", "float_static_assert"). * * Kept separate from `recorded` because at request time the code has not been @@ -89,14 +89,7 @@ class ToolchainRequirements { key: TRequirementKey, sites: readonly IRequirementSite[] = [], ): void { - const existing = this.recorded.get(key); - if (existing === undefined) { - this.recorded.set(key, [...sites]); - return; - } - for (const site of sites) { - RequirementSites.addUnique(existing, site); - } + RequirementSites.accumulate(this.recorded, key, sites); } /** @@ -108,13 +101,9 @@ class ToolchainRequirements { sourcePath: string, line: number | null, ): void { - const site: IRequirementSite = { sourcePath, line }; - const existing = this.deferred.get(requestKey); - if (existing === undefined) { - this.deferred.set(requestKey, [site]); - return; - } - RequirementSites.addUnique(existing, site); + RequirementSites.accumulate(this.deferred, requestKey, [ + { sourcePath, line }, + ]); } /** Sites recorded for a deferred emission. */ diff --git a/src/utils/RequirementAggregator.ts b/src/utils/RequirementAggregator.ts index 210548898..03a88e727 100644 --- a/src/utils/RequirementAggregator.ts +++ b/src/utils/RequirementAggregator.ts @@ -34,14 +34,7 @@ class RequirementAggregator { for (const list of lists) { for (const entry of list) { - const sites = merged.get(entry.key); - if (sites === undefined) { - merged.set(entry.key, [...entry.sites]); - continue; - } - for (const site of entry.sites) { - RequirementSites.addUnique(sites, site); - } + RequirementSites.accumulate(merged, entry.key, entry.sites); } } diff --git a/src/utils/RequirementSites.ts b/src/utils/RequirementSites.ts index d74826c3d..9d7f7d4df 100644 --- a/src/utils/RequirementSites.ts +++ b/src/utils/RequirementSites.ts @@ -6,10 +6,14 @@ import type IRequirementSite from "../types/IRequirementSite"; * "Is this the same site?" was written out three times -- in * `CodeGenState.requireToolchain`, in `CodeGenState.noteDeferredSite` (now * `ToolchainRequirements.record` and `.noteDeferredSite`, #1452), and in - * `RequirementAggregator.merge` (now `.union`, #1928). Adding a column to `IRequirementSite`, or - * deciding that an unlocated site should merge with a located one, would have - * been three edits that must agree. jscpd cannot see it: each copy is four - * lines. + * `RequirementAggregator.merge` (now `.union`, #1928). Adding a column to + * `IRequirementSite`, or deciding that an unlocated site should merge with a + * located one, would have been three edits that must agree. jscpd cannot see + * it: each copy is four lines. + * + * The key-to-sites fold around it was three copies too, and they shared a + * quirk: the first list seen for a key went in without dedup, so the result + * depended on list order. All three now call `accumulate` (#1928). */ class RequirementSites { /** Two sites are the same when they name the same place. */ @@ -17,8 +21,30 @@ class RequirementSites { return left.sourcePath === right.sourcePath && left.line === right.line; } + /** + * Add `sites` under `key`, skipping any already there -- the first list for + * a key included. A key with no sites is still recorded. Mutates `into`. + */ + static accumulate( + into: Map, + key: K, + sites: readonly IRequirementSite[], + ): void { + let existing = into.get(key); + if (existing === undefined) { + existing = []; + into.set(key, existing); + } + for (const site of sites) { + RequirementSites.addUnique(existing, site); + } + } + /** Append `site` unless an equal one is already present. Mutates `sites`. */ - static addUnique(sites: IRequirementSite[], site: IRequirementSite): void { + private static addUnique( + sites: IRequirementSite[], + site: IRequirementSite, + ): void { if (sites.some((seen) => RequirementSites.same(seen, site))) return; sites.push(site); } diff --git a/src/utils/__tests__/RequirementAggregator.test.ts b/src/utils/__tests__/RequirementAggregator.test.ts index d2a03306b..d1b69e31b 100644 --- a/src/utils/__tests__/RequirementAggregator.test.ts +++ b/src/utils/__tests__/RequirementAggregator.test.ts @@ -181,4 +181,17 @@ describe("RequirementAggregator.union (#1928: one file's list)", () => { expect(union[0]!.sites).toEqual([site]); }); + + // The first list seen for a key used to go in as given, repeats and all, so + // the answer depended on which list came first. + it("drops repeats in the first list for a key too, so list order cannot matter", () => { + const s = { sourcePath: "a.cnx", line: 4 }; + const twice = { key: "baseline-c" as const, sites: [s, s] }; + const empty = { key: "baseline-c" as const, sites: [] }; + + expect(RequirementAggregator.union([[twice]])[0]!.sites).toEqual([s]); + expect(RequirementAggregator.union([[empty], [twice]])[0]!.sites).toEqual([ + s, + ]); + }); }); diff --git a/src/utils/__tests__/RequirementSites.test.ts b/src/utils/__tests__/RequirementSites.test.ts new file mode 100644 index 000000000..1d709d740 --- /dev/null +++ b/src/utils/__tests__/RequirementSites.test.ts @@ -0,0 +1,46 @@ +/** + * #1928: the one key-to-sites fold `ToolchainRequirements.record`, + * `.noteDeferredSite` and `RequirementAggregator.union` all share. + */ +import { describe, it, expect } from "vitest"; +import RequirementSites from "../RequirementSites"; +import type IRequirementSite from "../../types/IRequirementSite"; + +describe("RequirementSites.accumulate", () => { + const s: IRequirementSite = { sourcePath: "a.cnx", line: 4 }; + + it("drops repeats in the first list for a key, not just later ones", () => { + const into = new Map(); + + RequirementSites.accumulate(into, "k", [s, s]); + + expect(into.get("k")).toEqual([s]); + }); + + it("does not repeat a site a later list carries again", () => { + const into = new Map(); + + RequirementSites.accumulate(into, "k", [s]); + RequirementSites.accumulate(into, "k", [s, { ...s, line: 5 }]); + + expect(into.get("k")).toEqual([s, { ...s, line: 5 }]); + }); + + it("records a key that arrives with no sites", () => { + const into = new Map(); + + RequirementSites.accumulate(into, "k", []); + + expect(into.get("k")).toEqual([]); + }); + + it("does not alias the caller's list", () => { + const into = new Map(); + const given = [s]; + + RequirementSites.accumulate(into, "k", given); + given.push({ ...s, line: 9 }); + + expect(into.get("k")).toEqual([s]); + }); +}); From 829a957b8ac30b1ab7c99cb6a7a3f2f2785fce2c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:24:46 +0000 Subject: [PATCH 07/10] docs(#1928): deferred sites are carried by the plan block, not claimed TranspileState.requireInclude, its test comments and the TOOLCHAIN_REQUIREMENTS header still described the emitter claiming the deferred sites / recording every key; planned-block keys are carried by the 2.2 block that decided the text. Co-Authored-By: Joshua Austill --- src/TRANSPILE/TranspileState.ts | 6 +++--- src/TRANSPILE/__tests__/TranspileState.includes.test.ts | 4 ++-- src/utils/constants/TOOLCHAIN_REQUIREMENTS.ts | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/TRANSPILE/TranspileState.ts b/src/TRANSPILE/TranspileState.ts index 68ba187fc..283148fdb 100644 --- a/src/TRANSPILE/TranspileState.ts +++ b/src/TRANSPILE/TranspileState.ts @@ -141,12 +141,12 @@ class TranspileState { */ requireInclude(header: TIncludeHeader, line: number | null = null): void { // Issue #1143: three of these "headers" are really deferred code-emission - // requests. Record where they were asked for, so the emitter that finally - // produces the block can attribute its requirement to a .cnx line. No + // requests. Record where they were asked for, so the 2.2 plan block that + // decides the emission carries them as its requirements' sites (#1928). No // requirement is recorded here -- the code does not exist yet, and // recording a requirement for text that may never be emitted is exactly // the mistake that made #1141's guard fire on files without the construct. - // Only the two headers that have a claiming emitter. "isr" was noted here + // Only the two headers whose sites a plan block carries. "isr" was noted here // and never read: ToolchainRequirements.takeDeferredSites is called for // float_static_assert and irq_wrappers alone, and the ISR typedef carries // no requirement. Keeping diff --git a/src/TRANSPILE/__tests__/TranspileState.includes.test.ts b/src/TRANSPILE/__tests__/TranspileState.includes.test.ts index 9713f0998..b81fdcff2 100644 --- a/src/TRANSPILE/__tests__/TranspileState.includes.test.ts +++ b/src/TRANSPILE/__tests__/TranspileState.includes.test.ts @@ -5,7 +5,7 @@ * `CodeGenState.test.ts` while the members were statics; the behavior they * pin is unchanged, and the negative controls came with them -- the include * funnel must raise ONE flag rather than blanket them, and only the two - * headers with a claiming emitter record a deferred site. + * headers whose sites a plan block carries record a deferred site. */ import { describe, it, expect, beforeEach } from "vitest"; import TranspileState from "../TranspileState"; @@ -49,7 +49,7 @@ describe("TranspileState", () => { expect(state.needsIrqWrappers).toBe(false); }); - // #1143: only the two headers with a claiming emitter are recorded as + // #1143: only the two headers whose sites a plan block carries are recorded as // deferred sites. "isr" is deliberately NOT one -- // ToolchainRequirements.takeDeferredSites is called for // float_static_assert and irq_wrappers alone. diff --git a/src/utils/constants/TOOLCHAIN_REQUIREMENTS.ts b/src/utils/constants/TOOLCHAIN_REQUIREMENTS.ts index 74b95f976..ce5644c6b 100644 --- a/src/utils/constants/TOOLCHAIN_REQUIREMENTS.ts +++ b/src/utils/constants/TOOLCHAIN_REQUIREMENTS.ts @@ -5,8 +5,9 @@ import type TRequirementKey from "../../types/TRequirementKey"; * Issue #1143: The single source of truth for what generated output costs. * * Every construct the code generator emits whose acceptance depends on the - * toolchain has an entry here, and the emitting code records that entry's key - * at the moment it produces the text. The transpile-time report, the per-file + * toolchain has an entry here. Its key is recorded by the Render emitter at + * the moment it produces the text, or carried by the 2.2 plan block that + * decided the text (#1928). The transpile-time report, the per-file * banner, docs/compatibility.md, the MISRA guideline rows and any compile-time * guard are all projections of this table -- none of them re-derives the * answer, so none of them can contradict what was emitted. From eae03552084b21e7e0830731b7ab06ee76b7c5bd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:24:58 +0000 Subject: [PATCH 08/10] docs(#1928): rewrap TranspileState.includes test comment Co-Authored-By: Joshua Austill --- src/TRANSPILE/__tests__/TranspileState.includes.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TRANSPILE/__tests__/TranspileState.includes.test.ts b/src/TRANSPILE/__tests__/TranspileState.includes.test.ts index b81fdcff2..96d877d7b 100644 --- a/src/TRANSPILE/__tests__/TranspileState.includes.test.ts +++ b/src/TRANSPILE/__tests__/TranspileState.includes.test.ts @@ -49,8 +49,8 @@ describe("TranspileState", () => { expect(state.needsIrqWrappers).toBe(false); }); - // #1143: only the two headers whose sites a plan block carries are recorded as - // deferred sites. "isr" is deliberately NOT one -- + // #1143: only the two headers whose sites a plan block carries are + // recorded as deferred sites. "isr" is deliberately NOT one -- // ToolchainRequirements.takeDeferredSites is called for // float_static_assert and irq_wrappers alone. // From 74bffdab56a8db36a2899f759d75b4c9de7ca655 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:12:01 +0000 Subject: [PATCH 09/10] test(#1928): name the uncopied deferred arrays the snapshot test guards Co-Authored-By: Joshua Austill --- src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts index e3b3607ae..037cd055e 100644 --- a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts +++ b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts @@ -175,8 +175,9 @@ describe("EmissionPlan (2.2 Plan)", () => { ]); }); - // The facts' site lists are 1.x-era accumulators that can still grow; a - // site noted after the decision must not join a block it did not cause. + // `takeDeferredSites` hands the facts `ToolchainRequirements.deferred`'s + // own arrays, uncopied, so they can still grow after `build()`; a site + // noted after the decision must not join a block it did not cause. it("keeps the sites as decided when the facts' lists grow later", () => { const early = { sourcePath: "a.cnx", line: 3 }; const irqWrapperSites = [early]; From 1a3f0b1a843c97cf268090a431fbc99bc075f77c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:12:14 +0000 Subject: [PATCH 10/10] test(#1928): spell out 'without copying' for cspell Co-Authored-By: Joshua Austill --- src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts index 037cd055e..44904db88 100644 --- a/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts +++ b/src/TRANSPILE/2-Plan/__tests__/EmissionPlan.test.ts @@ -176,8 +176,9 @@ describe("EmissionPlan (2.2 Plan)", () => { }); // `takeDeferredSites` hands the facts `ToolchainRequirements.deferred`'s - // own arrays, uncopied, so they can still grow after `build()`; a site - // noted after the decision must not join a block it did not cause. + // own arrays without copying them, so they can still grow after + // `build()`; a site noted after the decision must not join a block it did + // not cause. it("keeps the sites as decided when the facts' lists grow later", () => { const early = { sourcePath: "a.cnx", line: 3 }; const irqWrapperSites = [early];