From 90fcae0197781b582ef576f97d12da7b981006fd Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 30 Sep 2026 14:57:12 +0200 Subject: [PATCH 1/6] Update the wisdom hive-mind snapshot. --- .gitattributes | 5 + wisdom/data/findings/staging.md | 1611 ++++++++++++++++++++++++++++++- 2 files changed, 1601 insertions(+), 15 deletions(-) diff --git a/.gitattributes b/.gitattributes index 5441e169..36c36995 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,8 @@ # Windows checkout, say -- hands the hook to the kernel, which reads the # carriage return after #!/bin/sh as part of the interpreter's name. .githooks/* text eol=lf + +# The Workflow tool refuses a script that contains a carriage return, and the +# Wisdom extract script is passed to it by path, so a CRLF checkout of it +# cannot be run. +wisdom/extract/workflow.mjs text eol=lf diff --git a/wisdom/data/findings/staging.md b/wisdom/data/findings/staging.md index 9d14efb7..ec665e47 100644 --- a/wisdom/data/findings/staging.md +++ b/wisdom/data/findings/staging.md @@ -1432,11 +1432,11 @@ _Reviewer note: No WinRT page exists in the docs. These findings (package: Core, ## UNMAPPED · after-remarks > [!NOTE] -> If a project is named `SafeArray` and a function with the same name from another package is called without its package qualifier (for example, writing `SafeArray(...)` instead of `WinRT.SafeArray(...)`), an access violation occurs and the compiler enters a crash loop. This was fixed in BETA 965. +> Before BETA 965, giving a project the same name as a function that the code calls without its package qualifier, for example a project named `SafeArray` calling `SafeArray(VarPtr(arr))`, made the compiler fail with an access violation and enter a crash loop. BETA 965 fixed it. In older builds, avoid naming a project after a symbol that the code calls unqualified. _Source threads: 1464634730321543168 · confidence: high_ _Date range: 2026-01-26 to 2026-01-30_ -_Reviewer note: Compiler bug fixed in BETA 965 -- no clear target page. Possible placements: a 'Known Issues' or 'Release Notes' page if one exists, the twinBASIC-Additions page, or a dedicated compiler-quirks section. Package: Core, symbol: null (compiler name-resolution collision with 'SafeArray')._ +_Reviewer note: Package Core, symbol SafeArray. A fixed compiler bug, so it probably belongs in no reference page; if kept, a compatibility or known-issues note in docs/IDE/Project Settings.md (project name) is the nearest home. It may be better dropped, and it is not a candidate for BUGS-TO-REPORT.md because it is already fixed._ --- @@ -1856,11 +1856,11 @@ _Reviewer note: Package: VB, symbol: null. No existing Shell32 reference page. P ## UNMAPPED · after-remarks > [!NOTE] -> When a project references a twinBASIC package that defines a `UUID` type (such as a WinRT package), and the project also declares its own `UUID` variables, assigning to the `.GuidString` property may raise a compiler error stating *UUID does not contain GuidString*. This happens because the compiler resolves the bare name `UUID` to the package's internal definition rather than the user's own type. The fix is to qualify the type with its declaring module or package name so the compiler resolves to the correct definition. Observed in twinBASIC build v975 when using WinRT-related COM interface declarations alongside a third-party WinRT package. +> In some recent builds (reported on build 975), a project that declares `Dim x As UUID` and sets `x.GuidString` fails with the error 'UUID does not contain GuidString'. The name **UUID** resolves to an internal twinBASIC type that has no **GuidString** member, and not to the type of the package that the project references. Qualifying the type name with its package name removes the errors. The same code compiled on build 973. -_Source threads: 1384721515542479049 · confidence: medium_ +_Source threads: 1384721515542479049 · confidence: low_ _Date range: 2026-03-23_ -_Reviewer note: Package=VB, no specific symbol. This is a general twinBASIC compiler name-resolution gotcha for UUID type conflicts when mixing packages that each define a UUID type. Reviewer should identify the most appropriate target page --- possibly a 'known issues' or 'COM/WinRT interop' page, or the VB Package index. No such page currently exists._ +_Reviewer note: UNMAPPED: package Core, symbol UUID. No UUID page exists in the page index; it is a build-specific regression report from users with no maintainer comment, so it may be already fixed. Probably not suitable for the reference; triage placement or discard._ --- @@ -2066,6 +2066,80 @@ _Reviewer note: UNMAPPED -- package: VB, symbol: null. No existing page covers W --- +## UNMAPPED · after-remarks + +> [!NOTE] +> In BETA 984, a project that used the tabbed control (`NewTab`) from the EduardoVB package crashed when it was saved, closed and loaded again. BETA 985 fixes this. If a form still misbehaves after the upgrade, remove the control from the form and add it again. + +_Source threads: 1552758205635887204 · confidence: high_ +_Date range: 2026-09-24 to 2026-09-26_ +_Reviewer note: UNMAPPED: package Core, symbol null. EduardoVB is a third-party package and no page in docs/ mentions it. A release-notes or known-issues page, or the Package Server pages under docs/Features/Packages/, could take it; otherwise consider dropping it._ + +--- + +## UNMAPPED · new-section + +> [!NOTE] +> In a XAML Island application, the WinRT `FileOpenPicker` fails when the process runs elevated (as administrator). The asynchronous operation ends with `AsyncStatus_Error` and error `80004005` (Unspecified error). To read the error, cast the operation to `IAsyncInfo` and display `Hex$(ErrorCode)`. When elevation cannot be avoided, use the classic `GetOpenFileNameW` dialog instead. + +_Source threads: 1487268961061044338 · confidence: medium_ +_Date range: 2026-04-06_ +_Reviewer note: Package Core, no symbol. The docs have no XAML page, so triage placement (perhaps a future XAML Islands topic). One other user saw the same failure without elevation and the cause was not found, so the draft may be too narrow._ + +--- + +## UNMAPPED · new-section + +> [!NOTE] +> A VB6 ActiveX control added through **References** may appear in the reference list but fail to load into the form designer, so the project does not compile. One report involved the third-party `CommX.OCX`, and a bug report was filed. Two workarounds were suggested: +> +> - Open the project in VB6, remove any broken reference, add the control again under **Components**, place it on the forms again, and re-import the project into twinBASIC. +> - Register the control on the twinBASIC machine with `regsvr32 CommX.ocx`, run from the folder that holds it, then re-import the VB6 source from scratch. +> +> In the References dialog, the ActiveX-controls column must be ticked for a control to appear in the toolbox. Neither workaround was confirmed to work. + +_Source threads: 1473790364539420864 · confidence: low_ +_Date range: 2026-02-18 to 2026-02-27_ +_Reviewer note: UNMAPPED: package Core, symbol none. Suitable homes are a VB6 import / References page under docs/ (for example the Import from VBP topic or a migration guide), which was not located in the page index. The thread had no confirmed resolution, so treat as tentative or drop._ + +--- + +## UNMAPPED · new-section + +### XAML Islands are not WPF + +XAML Islands controls used from twinBASIC come from the native WinRT UWP/WinUI stack. They are not WPF, which is a .NET framework, although the XAML syntax is similar and some details differ significantly. The controls are native code, so they work from twinBASIC and from VB6. + +> [!NOTE] +> When an AI assistant writes XAML for a twinBASIC project, tell it explicitly to exclude WPF and .NET references. Otherwise it tends to mix the two frameworks. + +In the demo project, tooltip texts and other XAML are stored in a `XamlTemplate` resource in the project's Resources (`CUSTOM` folder). A code search across the project does not find text stored there. + +_Source threads: 1522642394472775700 · confidence: medium_ +_Date range: 2026-07-03 to 2026-07-06_ +_Reviewer note: UNMAPPED: package Core, symbol null. No page in docs/ mentions XAML. Reviewer should decide whether a XAML Islands page belongs in Features or a tutorial; the XamlTemplate resource detail is specific to one demo project and may not be worth documenting._ + +--- + +## UNMAPPED · new-section + +### Touchscreen gestures + +A twinBASIC form can respond to touch input in two ways. Both were demonstrated in a sample that pinches, rotates and pans a Direct2D-rendered image. + +- **WM_GESTURE and GetGestureInfo.** The window receives a `WM_GESTURE` message, and the handler calls the `GetGestureInfo` API to read the gesture. This approach is simpler and works on Windows 7 and later. +- **WM_POINTER messages and ManipulationProcessor.** The window handles `WM_POINTERDOWN`, `WM_POINTERUPDATE` and `WM_POINTERUP` and passes each pointer to the system `ManipulationProcessor` class. This approach supports more advanced features, but needs Windows 8 or later. The handler can count the active touch points, which multitouch features need. + +Inertia --- letting the image keep moving and slow down after a flick --- was difficult with both approaches. In the `ManipulationProcessor` version, adding an `InertiaProcessor` class provides it for both panning and rotation. + +The sample needs the community Windows Development Library (WinDevLib) for twinBASIC, version 9.4.730 or later. + +_Source threads: 1550313939492143166 · confidence: low_ +_Date range: 2026-09-18 to 2026-09-20_ +_Reviewer note: UNMAPPED. Package: Core, no symbol. No existing page covers touch input (no hits for WM_GESTURE or WM_POINTER). Suggested placement: a short section in docs/Tutorials/Windows-API.md or a new page under Features/Advanced. The author's sample project link should be added from the thread. Facts about API availability (Windows 7 versus 8) come from the author's description and are unverified._ + +--- + ## docs/Features/64bit.md · after-remarks [DUPLICATE? -- see also thread 1083431012417155154, 1448280876780752936, 1417830539024662548, 1437514645354315888] > [!NOTE] @@ -2122,6 +2196,17 @@ _Date range: 2025-11-28_ --- +## docs/Features/64bit.md · after-remarks + +> [!NOTE] +> WinDevLib supersedes the oleexp type library and covers more APIs and interfaces, but it is not a drop-in replacement. Newer DirectX APIs use `ByVal` UDT parameters and same-interface overloads, which twinBASIC supports and VB6 does not, so VB6 workarounds such as passing points through **Currency** conflict with them. Switching an existing VB6 project to WinDevLib can therefore produce hundreds of errors. WinDevLib can be built with the `ByVal` UDT declarations disabled, which reduces the errors where VB6 compatibility must be kept. Type libraries such as oleexp still work in twinBASIC, but only in 32-bit projects. + +_Source threads: 1513860400305672302 · confidence: medium_ +_Date range: 2026-06-09 to 2026-06-10_ +_Reviewer note: Placed after the WinDevLib paragraph on the 64-bit page because of the 32-bit-only typelib point. Package-specific, third-party statement from a Discord thread; verify the build option for disabling ByVal UDT declarations against the WinDevLib README before publishing._ + +--- + ## docs/Features/Advanced/API-Declarations.md · after-remarks [DUPLICATE? -- see also thread 1086977208171634708] > [!NOTE] @@ -2144,6 +2229,17 @@ _Reviewer note: Insert after the DeclareWide section or as a closing note within --- +## docs/Features/Advanced/API-Declarations.md · after-remarks + +> [!NOTE] +> `DeclareWide` changes only how **String** arguments are passed: they go to the API as wide (UTF-16) text, without conversion to ANSI. It does not choose which function is called. The `A` or `W` suffix comes from the declared name or its **Alias**, as in the example above. Using `DeclareWide` on an ANSI entry point such as `PostMessageA` passes wide text to a function that expects ANSI, and caused edge-case problems in WinDevLib. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2023-12-18 to 2026-09-02_ +_Reviewer note: There is no Core/DeclareWide page, so this targets the page that documents DeclareWide. Insert directly after the DeclareWide WARNING callout. The last sentence is an inference from a WinDevLib bug report about PostMessageA; verify the wording. The thread's remark about the built-in hidden-module declarations is omitted because docs/Reference/Attributes.md already shows that form._ + +--- + ## docs/Features/Advanced/Assembly.md · after-remarks [DUPLICATE? -- see also thread 1155949699585540216, 1503093611246391338, 1507659583437668512] ## Porting VB6 Assembly Thunks @@ -2263,6 +2359,19 @@ _Reviewer note: The finding mentions SQLite Library 1.2 as an affected package d --- +## docs/Features/Advanced/Static-Linking.md · after-remarks + +> [!NOTE] +> The **Miscellaneous** folder is not ignored by the compiler. It holds arbitrary files, but the compiler links the `.obj` and `.lib` files named by an **Import Library** line into the executable. The files need not be in that folder: a project can keep them in a folder of its own, such as `StaticLibraries`, and give that path to **Import Library**. +> +> Files in **Miscellaneous** are not available at run time. To embed a binary that the program reads while it runs, put it in the **Resources** folder instead. See [How do I use resources in twinBASIC?](../../FAQ#resources). + +_Source threads: 1522084764817948903 · confidence: high_ +_Date range: 2026-07-02 to 2026-07-06_ +_Reviewer note: Finding was filed under package Core with no symbol. The page index has no Features pages, so the target was chosen by reading nearby files (Static-Linking.md, which names the Miscellaneous folder). Verify the claim that Import Library accepts a path outside Miscellaneous, and that the FAQ anchor #resources exists (the summary element has id="resources")._ + +--- + ## docs/Features/Compiler-IDE/CodeLens.md · after-remarks > [!NOTE] @@ -3032,6 +3141,25 @@ _Reviewer note: The Overloading.md page is a feature overview page, not a refere --- +## docs/Features/Language/Overloading.md · after-remarks + +> [!NOTE] +> When a derived COM interface redeclares a method with the same name as one on its base interface, a call can fail with TB5073 (*Unable to disambiguate between overloaded methods*). For example, `d2dDevice1.CreateDeviceContext` with a variable declared as `ID2D1Device1` and an out-parameter declared as `ID2D1DeviceContext1` gives this error, with both the oleexp type library and WinDevLib. This is a known compiler limitation in overload resolution. +> +> Declaring the variable as the base type removes the error, but in that case the call crashed at run time with an access violation inside `D2d1.dll`. A workaround is to cast the value to a separate variable of the required interface first: +> +> ```tb +> Set d2dDevice1 = CType(Of ID2D1Device1)(d2dDevice1) +> ``` +> +> Place the cast directly after the `D2D1CreateDevice` call, which creates a base `ID2D1Device`. + +_Source threads: 1513860400305672302 · confidence: medium_ +_Date range: 2026-06-09 to 2026-06-10_ +_Reviewer note: Not compiled here: needs a WinDevLib project to reproduce, so the code block is not marked check_build. The workaround is a suggestion from the thread and the outcome was not confirmed. The fenced code inside the callout should be checked against the build's code-region handling. Consider whether this belongs in BUGS-TO-REPORT.md instead, since the thread says it is tracked upstream (twinbasic issue 2107)._ + +--- + ## docs/Features/Packages/Creating a TWINPACK package.md · after-remarks > [!NOTE] @@ -3071,6 +3199,41 @@ _Reviewer note: Verify the exact name of the project-settings option ("Auto expo --- +## docs/Features/Packages/Import-export tool.md · after-remarks + +> [!NOTE] +> The IDE expects source files with Windows (CRLF) line endings and UTF-8 encoding. A `.twin`, `.bas` or `.cls` file that enters a project with LF-only line endings raises no error, but the editor loses track of the syntax: highlighting goes wrong partway through the file, and edits misbehave. A file written by a tool on a system that uses LF endings is a typical cause. Both programs described here convert LF to CRLF in these three file types on `import`. When a file reaches a project by any other route, convert it to CRLF before opening it. + +_Source threads: 1542250945574871071 · confidence: high_ +_Date range: 2026-08-26 to 2026-09-23_ +_Reviewer note: The page already states that import converts LF to CRLF, so only the symptom description and the UTF-8 requirement are new. The thread says the script was fixed to convert on import and now shares the tB executable's command vocabulary; both are already described on this page. Verify the UTF-8 claim and the 'any other route' advice against the IDE. The thread also mentions a related importer/exporter bug in tB itself that is not identified, so it is left out._ + +--- + +## docs/Features/Packages/Import-export tool.md · after-remarks + +> [!NOTE] +> A project is still stored inside its `.twinproj` file; the folder the tool produces is a copy. Keeping the project's source files outside the `.twinproj` file, so that source control and team work need no export step, is planned for version 1.0. + +The folder form is also not a way back to VB6. Plain `.bas` and `.cls` files can be imported into twinBASIC, at the cost of the newer language features and Unicode support. Forms and controls (`.frm` and `.ctl`) cannot be stored in the VB6 format without losing information, because twinBASIC forms have many new properties and required attributes that the format has no place for. Moving a project from VB6 to twinBASIC is effectively one way: form changes made in the twinBASIC designer cannot be carried back. + +_Source threads: 1554511541443891293 · confidence: medium_ +_Date range: 2026-08-13 to 2026-09-29_ +_Reviewer note: Merged from two threads (v1.0 external source files, dated 2026-09-29; VB6 file-format limits, 2026-08-13 to 2026-08-15). Both are statements by a community expert, not from the sources. Check the 'planned for v1.0' wording against the current roadmap before publishing; consider splitting the VB6 paragraph into docs/Miscellaneous/FAQs.md near the import instructions. Insert in the 'Keeping a project in Git' section._ + +--- + +## docs/Features/Packages/Import-export tool.md · after-remarks + +> [!NOTE] +> The `--self-test` option is for the script's developers. It needs a `sample.twinpack` file, which is not part of the download, so it cannot run in an ordinary installation. Do not use it to check a copy of the script; run one of the commands above on a project of your own. + +_Source threads: 1524049386953375946 · confidence: medium_ +_Date range: 2026-07-07 to 2026-07-22_ +_Reviewer note: The rest of the finding (using the tools to keep a project in git) is already covered on this page, including the .gitignore section. Only the self-test caveat is new. The page index has no Features pages, so the target was found by reading nearby files. Confirm the sample.twinpack requirement against scripts/impexp.mjs before publishing; the finding is medium confidence and the page may want a different wording if the file is meant to ship with the repository._ + +--- + ## docs/Features/Packages/Importing a package from a TWINPACK file.md · after-remarks [DUPLICATE? -- see also thread 1333426890291023884] > [!NOTE] @@ -3117,10 +3280,11 @@ _Reviewer note: The existing 'Opening a project with missing linked package' sec ## docs/Features/Packages/Linked Packages.md · after-remarks [DUPLICATE? -- see also thread 1392532961366376469, 1427261753544409168, 1495944076632002572] +Linking also keeps a project or a package you build small. In one reported case, a package that embedded WinDevLib was 27 MB, and less than 800 kB with WinDevLib linked instead. A linked dependency also removes the need to copy its definitions into the project. _Source threads: 1460777854714515728 · confidence: medium_ _Date range: 2026-02-15_ -_Reviewer note: The finding notes that linked packages keep project file size small by avoiding embedding a dependency's type, enum, and Declare definitions. This is already covered by the existing Linked Packages page (the benefit of not storing a copy in every .twinproj and sharing from a common location). No new content is needed; this addition is a no-op and can be discarded._ +_Reviewer note: Insert after the opening paragraph of the page. The sizes come from a single user's report, not a measurement; verify that a .twinpack that references a linked package stores only the reference. Also check whether a package that links a dependency needs that dependency to be linked on the consuming machine._ --- @@ -3144,6 +3308,40 @@ _Date range: 2026-04-05 to 2026-04-21_ --- +## docs/Features/Packages/Linked Packages.md · after-remarks + +The IDE has no command that rolls a linked package back to an older build. To downgrade, replace the package's `.twinpack` file in `%APPDATA%\twinBASIC\packages` with the older file, then restart the compiler. If the package is published in a GitHub repository that has no Releases page, an older `.twinpack` can be downloaded from the repository's commit history, using the raw file at the commit in question. + +A package opened in the IDE is read-only. When the package has a `changelog.md`, it is visible in the Project Explorer under `Packages\`, which helps in choosing the build to go back to. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2025-11-19 to 2025-12-29_ +_Reviewer note: Insert at the end of the Manual management section. The restart-the-compiler sentence comes from the existing page text, not from the thread._ + +--- + +## docs/Features/Packages/Linked Packages.md · after-remarks + +### Repairing several broken linked references + +The **Fix** button on a broken linked reference is not implemented. It reports that the feature is not yet available and asks for the reference to be removed and added again by hand. + +Unticking a linked reference hides it from the list, so note the exact names of the packages before removing them. When several linked packages depend on each other --- for example the WinRT packages and the Windows Development Library packages --- the order of the references matters. This procedure restores them reliably: + +1. Untick the broken linked references and press **Apply Changes**. +2. On the **Available Packages** tab, add the packages back one at a time. +3. On the **Enabled Libraries** tab, use the up and down arrows to put each package after the packages it depends on. + +> [!NOTE] +> +> Add a package from the **Available Packages** tab, not from **Available COM References**. A user reported that adding the VB6 WinRT type library through **Available COM References** makes the IDE extremely slow. + +_Source threads: 1551367356507226154 · confidence: medium_ +_Date range: 2026-09-23_ +_Reviewer note: Findings were tagged package Core with no symbol, so the page was chosen by hand: Linked Packages already says Fix is not implemented, and this adds the multi-package ordering procedure. The dependency-order advice and the slow-IDE report come from a single user; verify before publishing. Step 3 assumes the up/down arrows are on the Enabled Libraries tab (Project Settings.md says that tab lists references in priority order); the thread does not name the tab._ + +--- + ## docs/Features/Packages/TWINPACK file format.md · after-remarks [DUPLICATE? -- see also thread 1368264887448371372] > [!NOTE] @@ -3247,6 +3445,17 @@ _Date range: 2026-05-13 to 2026-05-18_ --- +## docs/Features/Packages/index.md · after-remarks + +> [!NOTE] +> A package adds no DLL dependency to the built executable. Its components are source code, and the compiler builds them into the executable together with the project's own code. Only the components the project actually uses are included, so referencing a large package, such as one with tens of thousands of Windows API and COM declarations, does not enlarge the executable. + +_Source threads: 1536079009673257010 · confidence: medium_ +_Date range: 2026-08-10_ +_Reviewer note: The existing page says a referencing project imports the whole package into its file system; the added text is about the compiled output, not the project file. Confirm with the compiler team that unused package symbols are always excluded from the executable, and that this holds for every package type._ + +--- + ## docs/Features/Project-Configuration/ActiveX-Registration.md · after-remarks [DUPLICATE? -- see also thread 1117993757170749490, 1261148532157714432, 1388075026892455987] > [!NOTE] @@ -3298,6 +3507,17 @@ _Date range: 2025-06-27_ --- +## docs/Features/Project-Configuration/ActiveX-Registration.md · after-remarks + +> [!NOTE] +> As in VB6, building an ActiveX DLL project generates new GUIDs, unregisters the previous build and registers the new one. If the new DLL cannot start, its registration code never runs, and the component then stays unregistered. + +_Source threads: 1550087924027236442 · confidence: medium_ +_Date range: 2026-09-17_ +_Reviewer note: Based on one experienced user's statement, not a maintainer's. Verify against the compiler, in particular whether the GUIDs change on every build or only without a fixed-GUID or compatibility setting, and whether the old build is unregistered first. Insert under Build-Time Registration._ + +--- + ## docs/Features/Project-Configuration/Compiler-Options.md · after-remarks ## Stack Reserve Size @@ -3460,6 +3680,17 @@ _Reviewer note: Verify that the 'Restart the compiler' toolbar button name match --- +## docs/IDE/Editor.md · after-remarks + +> [!NOTE] +> When **Auto Prettify Code** is ticked, the editor reformats each line as it is edited. Opening an **Enum** or **Type** block near the top of a module before typing its **End Enum** or **End Type** line can cause damage elsewhere in the file. The parser temporarily treats every line below as a member of that block, reports many syntax errors, and formats those lines as members. Calls such as `myfunction(1)` become `myfunction (1)`, and unwanted `()` pairs may appear at the end of the line being edited. To avoid this, type the closing **End Enum** or **End Type** line first, or check the code below the block for stray spaces after adding one. + +_Source threads: 1526407024529768509 · confidence: high_ +_Date range: 2026-07-14 to 2026-07-23_ +_Reviewer note: Finding had no symbol (package Core); placed on the IDE Editor page, after the Options list, because the behaviour belongs to the Auto Prettify Code option. The link between this behaviour and that specific option is inferred; confirm. Users also reported spurious parentheses after `Load FormX` and `[Object].Clear`, not included._ + +--- + ## docs/IDE/FindReplace.md · after-remarks > [!NOTE] @@ -3524,6 +3755,28 @@ _Reviewer note: Reviewer should confirm placement -- this note belongs near the --- +## docs/IDE/Menu/Window.md · after-remarks + +### Changing the colour of inline value hints +{: .no_toc } + +The dim text that IntelliSense shows after an enum member declaration (the member's value) is coloured by the theme property `InlineDecorationColor`. To change it, set the property in the theme file: + +```css +InlineDecorationColor: red; +``` + +> [!NOTE] +> Edit the theme file that belongs to the twinBASIC installation actually in use. Edits to a theme file of another installation have no effect. + +One user reported that an edited and saved theme took effect only after twinBASIC was restarted, and that switching to another theme and back loaded the original version instead. If an edit does not appear, restart the IDE, or try **Reload from disk** in the **Theme** submenu. + +_Source threads: 1548997779148382328 · confidence: high_ +_Date range: 2026-09-14_ +_Reviewer note: Insert at the end of the Theme section. The InlineDecorationColor property was confirmed by Wayne Phillips; the restart behaviour is a single-user observation, and the suggestion that Reload from disk helps is an inference from the menu entry, not stated in the thread. Consider whether a dedicated theme-authoring page (or IDE-Features.md, Theme System) is a better home, and confirm the exact theme-file syntax (CSS-style) against a shipped theme._ + +--- + ## docs/IDE/New Project.md · after-remarks [DUPLICATE? -- see also thread 1203088725605621801] > [!NOTE] @@ -3548,6 +3801,16 @@ _Reviewer note: Target page is docs/IDE/New Project.md, which is not in the page --- +## docs/IDE/New Project.md · after-remarks + +The list on the **Recent** tab is stored in the Windows registry, under `HKEY_CURRENT_USER\Software\VB and VBA Program Settings\twinBASIC_IDE\RecentlyOpened`. To remove many entries at once, close the IDE and delete the entries under that key with a registry editor such as RegEdit, instead of clicking the X beside each one. + +_Source threads: 1511584056893112360 · confidence: high_ +_Date range: 2026-06-03_ +_Reviewer note: Registry path comes from a maintainer's Discord answer. Closing the IDE first is a precaution added by the drafter, not stated in the thread. Placed at the end of the Recent section._ + +--- + ## docs/IDE/Project Settings.md · after-remarks [DUPLICATE? -- see also thread 1111079336079020102, 1114028559451828314, 1126479798156394516, 1205564031482593380, 1208711450240090122, 1339525725778345997, 1440729215682220074] > [!NOTE] @@ -3640,6 +3903,65 @@ _Reviewer note: Page is docs/IDE/Project Settings.md -- not in page-index. Revie --- +## docs/IDE/Project Settings.md · after-remarks + +> [!NOTE] +> A built program that Windows refuses to start with *This app can't run on your PC* usually has one of two causes. First, a manifest imported from a VB6 project keeps `processorArchitecture="X86"`, which a 64-bit build cannot use; change it to `*`. Second, *Target OS Version* is higher than the Windows version that runs the program: a project set to Windows 10 does not run on Windows 7. When both are correct, delete the manifest and create it again. If the program still fails to start, check whether security software or file permissions on that machine block it. + +_Source threads: 1540209036325298176 · confidence: medium_ +_Date range: 2026-08-21 to 2026-08-22_ +_Reviewer note: Place after the Target OS Version section. The thread's title also mentions an 'Access is denied' message that the draft does not reproduce. The diagnosis of each cause is by the thread's participants, not confirmed against the compiler._ + +--- + +## docs/IDE/Project Settings.md · after-remarks + +> [!NOTE] +> Compression does not work together with an overridden entry point. A build that sets a custom entry point, such as a `DllMain` in a Standard DLL project, reports "Build Error: Internal error, compression". Untick the three compression boxes in [Feature Flags](#feature-flags) --- **Compress Runtime Class Dispatch Info**, **Compress Runtime Error Tables** and **Compress Misc Data** --- to remove the error. In the reported case, the built DLL's entry point was still not the custom procedure, so custom entry points did not work completely at that time. + +_Source threads: 1550752244469735475 · confidence: high_ +_Date range: 2026-09-19_ +_Reviewer note: Insert at the end of the Override Entry Point section. The maintainer said to turn off 'the compression options among the feature flags'; the three names are taken from the existing Feature Flags - continued list, so confirm that all three are needed. The final sentence records an unresolved defect (entry point at 0x1000, 'entry point not found') and may be dated quickly._ + +--- + +## docs/IDE/Project Settings.md · after-remarks + +> [!NOTE] +> The 32-bit compiler is limited to 4 GB of address space, so a very large project, such as an imported VB6 project of over a million lines, can fail with "INTERNAL ERROR: OUT OF MEMORY". The limit is address space, not physical memory, so adding RAM does not help. Two ways to reduce the compiler's memory use are to untick the features the project does not use in Feature Flags, and to split the program into smaller projects. A 64-bit compiler is planned. + +_Source threads: 1551873936898261022 · confidence: high_ +_Date range: 2026-09-22_ +_Reviewer note: Placed after the Feature Flags section. It may fit better in a compiler-limits or FAQ page. The claim that the compiler is x86 Large Address Aware comes from one process sample in the thread; the maintainer's 20-30% reduction and the 64-bit compiler plan are stated intentions and are worded loosely. Suggestions to disable features and split the program came from other users, not the maintainer._ + +--- + +## docs/IDE/Project Settings.md · after-remarks + +> [!NOTE] +> The LLVM compiler did not support this option in BETA 984 to 987. A project with it on could report "a feature used in your code is not yet supported with the LLVM compiler" and produce a DLL or OCX that crashed when the affected procedure was called. BETA 988 fixes it. See [Language support](../../../LLVM/Getting-Started#language-support) in the LLVM section. + +_Source threads: 1553387151842877491 · confidence: high_ +_Date range: 2026-09-26 to 2026-09-28_ +_Reviewer note: Place at the end of the 'Sanitize Booleans' section, before the 'Constant Function Folding' heading. Overlaps with the note added to the LLVM Getting Started page; keep only one if the reviewer prefers less duplication._ + +--- + +## docs/IDE/Project Settings.md · after-remarks + +When two referenced packages define the same name, an unqualified use of that name resolves to the package that is higher in the priority order. A use of the other package's name must be prefixed with the package name. For example, WinDevLib and a WinRT package can each define `IPersistFile` with a different signature. + +To change the order, use the arrow buttons in the References dialog, as in VB6. Putting the package with fewer clashing names first keeps the number of prefixes small. + +> [!NOTE] +> Updating a package removes it and adds it again, so it may not return to its earlier position in the order. Check the order after each update. Diagnostics does not report the case where the order silently changes which type a name refers to. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2026-06-17 to 2026-06-18_ +_Reviewer note: The thread says an updated package lands at the end of the resolve chain, but Features/Packages/Updating a package.md says the entry moves to the top, below the built-in packages. Reconcile before publishing; the draft avoids stating the new position. The exact caption of the arrow buttons in the Library References dialog is not confirmed._ + +--- + ## docs/IDE/Toolbar.md · after-remarks > [!NOTE] @@ -3651,6 +3973,17 @@ _Reviewer note: Toolbar.md is a sparse list-based page with no prose sections. V --- +## docs/IDE/Toolbar.md · after-remarks + +> [!NOTE] +> A *Compiler crash loop* message means the compiler keeps crashing and restarting, and its progress bar loops near 5%. A single compiler restart is normal: changing project settings restarts the compiler by design. In BETA 985 to 991 the loop had two known causes. One was a hard crash while the [Project Explorer](Project%20Explorer) was showing the object view instead of the file view. The other was a fault in error reporting, which appeared when the [Debug Console](Debug%20Console) had errors to show, for example after the VBA reference was removed. BETA 992 fixes both for the reporters. On one machine, restarting Windows cleared the loop, which is a workaround and not a fix. To recover a project meanwhile, use **safeMode**. + +_Source threads: 1553605375423553679 · confidence: high_ +_Date range: 2026-09-25 to 2026-09-29_ +_Reviewer note: Place after the paragraph about **safeMode** at the end of the page. Verify the relative links to Project Explorer and Debug Console resolve (the pages use spaces in their filenames; use their permalinks instead if the build's link check objects). The two causes are from developer statements in the thread; the 'object view' name for the Project Explorer mode should be checked against the IDE, since the Project Explorer page calls the toggle 'Toggle file view'._ + +--- + ## docs/IDE/index.md · after-remarks [DUPLICATE? -- see also thread 1336104425520893972, 1118376310725673091] > [!NOTE] @@ -3898,6 +4231,31 @@ _Reviewer note: This entry belongs in the Installation section of the FAQs page, --- +## docs/Miscellaneous/FAQs.md · new-section + +
+The IDE window opens off-screen after I disconnect a monitor. How do I get it back? + +If the IDE was last used on an extra monitor that is no longer connected, its window can open outside the visible area of the screen. Right-click the IDE's title bar or its taskbar button and choose **Move**, then use the arrow keys or the mouse to bring the window back onto the screen. + +
+ +_Source threads: 1363863830437822484 · confidence: low_ +_Date range: 2025-04-21 to 2026-08-21_ +_Reviewer note: Add under 'Using twinBASIC' in the FAQs. Low confidence: a user reported it still happening in August 2026, so it may be fixed or not; the suggestion of a default window.left=0 setting was not confirmed to exist. Verify the Move steps on Windows 10 and 11._ + +--- + +## docs/Miscellaneous/FAQs.md · after-remarks + +If the extraction fails or a file cannot be replaced, a `twinBASIC_win64.exe` process may still be running from the previous session. It is the LLVM cache process, which keeps running after the IDE closes. End it in Task Manager, or see [General LLVM options](LLVM/Getting-Started#general-llvm-options) to turn the behaviour off. + +_Source threads: 1553387151842877491 · confidence: medium_ +_Date range: 2026-09-26 to 2026-09-27_ +_Reviewer note: Place at the end of the 'How do I install twinBASIC?' FAQ entry, after the sentence about not overwriting a previous version. The thread's community advice (extract into a new folder or empty the old one, because overwriting old betas has caused hard-to-trace bugs) is already on that page. Verify the relative link resolves from /FAQ._ + +--- + ## docs/Reference/Assert/index.md · after-remarks > [!NOTE] @@ -4419,12 +4777,11 @@ _Reviewer note: This draft is a description body for the sparse [COMControl] ent ## docs/Reference/Attributes.md · after-remarks [DUPLICATE? -- see also thread 1120535095279886416, 1172641655631908884, 1202939463630594079, 1212815197501395054, 1218599950359855186, 1230934350959345735, 1252891072774803487, 1333587859474747413, 1352268312117252126, 1079642242429489262, 1082767362253668474, 1083119492789448824, 1103494252811526164, 1113738858408988713, 1135126325783449711, 1146837274357678203, 1165150931554418698, 1167815153253892156, 1169508594341916712, 1173257226610290699, 1179991719551442975, 1206670811172306954, 1358081642052190318, 1398715673508843731, 1396063342417674291, 1421815804172701708, 1432054854016045096, 1432054854016045096, 1291780674772275220, 1357558567447695391, 1382015106380075078, 1387416025892393042, 1414587677574959165, 1431348682271363182, 1455320119239643390, 1459605334502150206, 1462420288280068323, 1491119090306256906, 1106637635818094733, 1222086643737956494, 1292578507674746951, 1427261753544409168] -> [!NOTE] -> An `[AppObject]` class is not instantiated at program startup. `Class_Initialize` runs only when one of the class's members is first accessed --- initialization is deferred until first use. A project may define more than one `[AppObject]` class; they do not conflict with each other. This makes `[AppObject]` suitable for lazy-loading expensive resources, such as querying extension entry points at runtime, because the cost is paid only on first access. +Setting the attribute to **False** avoids the cost of capturing the last-error value after every call, so it suits APIs for which the last error is not meaningful. WinDevLib applies `[UseGetLastError(False)]` to its declarations for this reason. -_Source threads: 1125359845936205844 · confidence: high_ -_Date range: 2026-01-09_ -_Reviewer note: This note targets the AppObject section of Attributes.md (the `## AppObject` heading at line 31). Confirm that the lazy-initialization behavior is specific to [AppObject] on CoClass and not a general [PredeclaredID] trait, and verify it applies to user-defined [AppObject] CoClasses, not just the built-in Global._ +_Source threads: 1125359845936205844 · confidence: low_ +_Date range: 2026-05-16_ +_Reviewer note: Insert at the end of the UseGetLastError entry. Based on a single remark in the thread; the size of the performance gain is not measured._ --- @@ -4587,6 +4944,81 @@ _Reviewer note: This example uses hypothetical Win32 type names for illustration --- +## docs/Reference/Attributes.md · after-remarks + +> [!NOTE] +> Before BETA 984, an array declared with a negative lower bound, such as `Public Keyb(-3 To 255) As Boolean`, crashed with an access violation on x64 when [**ArrayBoundsChecks**](#arrayboundschecks) was disabled and an element at a negative index was read or written, even though the index was in bounds. The problem is fixed in BETA 984. On earlier builds, keep bounds checking enabled for code that uses such arrays. + +_Source threads: 1514851886656917664 · confidence: high_ +_Date range: 2026-06-12 to 2026-09-24_ +_Reviewer note: Insert directly after the ArrayBoundsChecks description. Fix version (BETA 984) comes from the thread; confirm against the release notes._ + +--- + +## docs/Reference/Attributes.md · after-remarks + +> [!NOTE] +> Use an empty string, `[CompilerOptions("")]`, to turn LLVM off for one procedure. A string such as `"-llvm"` can appear to work, but it is not a documented form. The compiler seems to ignore option words it does not recognize, so a later version that validates the string more strictly could reject it. + +_Source threads: 1487268961061044338 · confidence: medium_ +_Date range: 2026-09-25_ +_Reviewer note: Place after the list of compiler options in the CompilerOptions section. The exact parsing rules were not confirmed, so the draft says only that the form is undocumented._ + +--- + +## docs/Reference/Attributes.md · after-remarks + +> [!NOTE] +> The attribute is not accepted on a [**Type**](Type) or on a procedure written inside a **Type**: the compiler reports it as invalid syntax. Warnings raised by a UDT's member procedures cannot be suppressed at that level. Apply the attribute to the enclosing class or module instead, or change the warning's level in the project settings. + +_Source threads: 1542590154810851338 · confidence: low_ +_Date range: 2026-08-27_ +_Reviewer note: Based on a feature request open on 2026-08-27; probe the current build (scripts/tbbuild.mjs) to confirm the attribute is still refused on a Type and on a Type's member procedure, and that the suggested workaround (module-level attribute) works. Drop the note if a later build accepts it. Insert after the IgnoreWarnings code sample and its Applicable-to line._ + +--- + +## docs/Reference/Attributes.md · after-remarks + +A referenced twinBASIC package behaves like source code added to the referencing project, not like a compiled boundary. Scope takes hard effect only where the package contents end up in an ActiveX DLL or control. Two consequences apply when a package tries to hide its internals: + +- **Friend** members of package classes appear in IntelliSense in the referencing project. +- The public members of a **Private** class or standard module are still exposed to the referencing project, but must be qualified with the class or module name to be called. + +> [!NOTE] +> **MustBeQualified** on a module is meant to hide its members unless they are qualified with the module name, but it was reported not to work reliably at the time of writing, and a bug report was filed. A class with a predeclared instance is an alternative to a standard module. When internals must be fully hidden, ship them as an ActiveX DLL and reference that from twinBASIC. + +_Source threads: 1516861763042279534 · confidence: medium_ +_Date range: 2026-06-17 to 2026-09-22_ +_Reviewer note: Existing MustBeQualified section says 'Applicable to: procedure' and has no description, while the thread discusses it on a module (and the Assert package tags members). Verify applicability against the .twin sources and by probe, and confirm the reported bug before keeping the 'unreliable' note. Whether [NonBrowsable] hides Friend members in a package was unconfirmed and is omitted. Insert after the MustBeQualified heading's applicability line._ + +--- + +## docs/Reference/Attributes.md · example + +A class tagged **AppObject** exposes its **Public** members as globals. Its `Class_Initialize` procedure runs when one of those members is first used, not when the program starts. That makes the class suitable for one-time initialization that depends on run-time state. The following class resolves an OpenGL extension function once a GL context exists. A module-level variable cannot do this, because module-level initialization runs before the context exists, and **wglGetProcAddress** then returns Null. + +```tb +[AppObject] +Class WdlGLInit + Public glArrayElementEXT As PFNGLARRAYELEMENTEXTPROC + + Private Sub Class_Initialize() + glArrayElementEXT = wglGetProcAddress("glArrayElementEXT") + End Sub +End Class +``` + +A project can contain more than one **AppObject** class. + +> [!NOTE] +> If a global of the same name is still defined elsewhere in the project, such as a leftover **Public** variable in a module, unqualified references bind to that global first. The **AppObject** member is then never used, and its value stays 0. + +_Source threads: 1125359845936205844 · confidence: high_ +_Date range: 2026-01-09_ +_Reviewer note: The Attributes page lists AppObject as applicable to CoClass only, while the thread applies it to a Class. Verify against the compiler and adjust the 'Applicable to' line if a Class is valid. The class and type names in the sample are adapted from WinDevLib; the sample is not compiled (it needs the OpenGL declarations)._ + +--- + ## docs/Reference/CEF/index.md · after-remarks > [!NOTE] @@ -4700,11 +5132,22 @@ _Reviewer note: Verify whether this 64-bit uninitialized-address behavior still --- -## docs/Reference/Core/Alias.md · after-remarks [DUPLICATE? -- see also thread 1459159860875624595] +## docs/Reference/Core/AddressOf.md · after-remarks > [!NOTE] -> -> **POINTAPI compatibility change (BETA 947 / v9.2.634.0):** `POINTAPI` was changed from a standalone UDT to a type alias (`Public Alias POINTAPI As POINT`). Because type aliases are interchangeable for simple assignments and scalar parameters, most code continues to work; however, passing a `POINTAPI()` array to an API parameter declared as `POINT()` raised Runtime Error 5 until BETA 953, where the underlying mismatch was corrected. If porting code that uses `POINTAPI` arrays, prefer declaring both the array variable and the API parameter with the same type name (`POINT` or `POINTAPI`) to avoid the issue on intermediate builds. +> A callback installed with **AddressOf** can also serve as a vectored exception handler, registered through the `AddVectoredExceptionHandler` API. Such a handler can recover from a CPU exception, such as an access violation, by advancing the instruction pointer in the `CONTEXT` structure (`Eip` in a 32-bit process, `Rip` in a 64-bit process). One report says this protection works only in a compiled EXE and does not work while the project runs under the IDE debugger, so test any handler of this kind in a compiled build. + +_Source threads: 1507659583437668512 · confidence: medium_ +_Date range: 2026-05-23_ +_Reviewer note: Single-source claim from a Discord show-and-tell; the author corrected an earlier statement that it also worked in the IDE. Verify by probe (tbrun) that a VEH handler registered via AddressOf fails to catch access violations in the IDE debug run but works in a compiled EXE. Placed on AddressOf because the finding named it (package VBA, symbol AddressOf); the page index has AddressOf only under Core._ + +--- + +## docs/Reference/Core/Alias.md · after-remarks [DUPLICATE? -- see also thread 1459159860875624595] + +> [!NOTE] +> +> **POINTAPI compatibility change (BETA 947 / v9.2.634.0):** `POINTAPI` was changed from a standalone UDT to a type alias (`Public Alias POINTAPI As POINT`). Because type aliases are interchangeable for simple assignments and scalar parameters, most code continues to work; however, passing a `POINTAPI()` array to an API parameter declared as `POINT()` raised Runtime Error 5 until BETA 953, where the underlying mismatch was corrected. If porting code that uses `POINTAPI` arrays, prefer declaring both the array variable and the API parameter with the same type name (`POINT` or `POINTAPI`) to avoid the issue on intermediate builds. _Source threads: 1455209665733464247 · confidence: high_ _Date range: 2025-12-29 to 2026-01-17_ @@ -4721,6 +5164,30 @@ _Date range: 2026-01-09 to 2026-01-16_ --- +## docs/Reference/Core/Alias.md · after-remarks + +> [!NOTE] +> In builds around BETA 983 and 984, a function whose return type is an alias of an alias of a user-defined **Type** larger than 8 bytes crashed the compiler at project start, even if nothing called the function. Returning the base type, or an alias one level deep, avoids the crash. One user reported this, and no fix was confirmed. + +_Source threads: 1548875667045621801 · confidence: medium_ +_Date range: 2026-09-14_ +_Reviewer note: Single unconfirmed report of a compiler defect. Consider moving it to BUGS-TO-REPORT.md instead, or dropping it once a later build is tested with the reproduction (Type over 8 bytes; Alias aliasA As myType; Alias aliasB As aliasA; Function test() As aliasB)._ + +--- + +## docs/Reference/Core/Alias.md · after-remarks + +> [!IMPORTANT] +> In some recent BETA builds, an **Alias** whose target is another **Alias** crashes the program at startup when a function returns it. Any code that refers to such a function is enough, even if the function is never called. For example, with `Public Alias D2D_COLOR_F As D3DCOLORVALUE` and `Public Alias D2D1_COLOR_F As D2D_COLOR_F`, code that uses a function returning `D2D1_COLOR_F` crashes at startup. As a workaround, make each alias refer directly to a real **Type** or intrinsic type, so that no alias has another alias as its target. + +An alias of a pointer type is not supported. A **ByRef** parameter that is an array of an alias type also had a bug in some builds around January 2026. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2026-01-13 to 2026-09-14_ +_Reviewer note: The page states that an Alias may target another Alias, so this is a compiler bug that may be fixed in a later BETA; check the current build and remove or date the note accordingly. The thread also says WinDevLib's alias use needs BETA 923 or newer. The pointer-type and array-parameter remarks are from single reports and are unverified._ + +--- + ## docs/Reference/Core/Class.md · after-remarks [DUPLICATE? -- see also thread 1157647794484543589, 1481282096877146244, 1458787436607045693, 1459196491380818071, 1485008719719698604] > [!NOTE] @@ -5402,6 +5869,74 @@ _Reviewer note: The CDecl native support finding (twinBASIC eliminates the need --- +## docs/Reference/Core/Declare.md · after-remarks + +> [!NOTE] +> A **Declare** statement converts each **String** argument to an ANSI string (the system code page) before the call. This suits a parameter typed `LPSTR` or `char*`. +> +> **DeclareWide** disables that conversion and passes the **String** as UTF-16. A C function that expects `char*` then sees only the first character, because the second byte of each UTF-16 character is zero. Use plain **Declare** for functions that take ANSI strings, and **DeclareWide** only for functions that take wide strings (`LPWSTR` or `wchar_t*`). See [Enhanced API Declarations](../../Features/Advanced/API-Declarations#declarewide). + +_Source threads: 1530026813537914880 · confidence: high_ +_Date range: 2026-07-24_ + +--- + +## docs/Reference/Core/Declare.md · after-remarks + +> [!IMPORTANT] +> Since BETA 896, a **Declare** statement can take a user-defined type (UDT) by value. When a parameter is declared as a UDT, the argument must be a variable of that UDT. Passing a value of another type, such as a **LongLong** where a `LARGE_INTEGER` is expected, can crash at run time, and Diagnostics does not warn about it. Declare the variable with the UDT type instead, or pass the member that has the right type, for example `li.QuadPart`. + +Code written before a declaration changed to a by-value UDT needs review. In particular, check calls that used `VarPtr` for an argument that is now passed by value, and calls that placed `ByVal` before `vbNullPtr` for a final UDT parameter, such as the `lpOverlapped As OVERLAPPED` parameter of **ReadFile**. + +A UDT parameter cannot be **Optional**. To make it optional, declare the API again locally with the parameter as `Optional ByVal lpOverlapped As LongPtr`. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2025-11-18 to 2025-11-22_ +_Reviewer note: Verify the crash with a LongLong argument and the claim that an Optional UDT parameter is not supported against the compiler. Consider moving the first paragraph to the 'Support for Passing User-Defined Types ByVal' section of docs/Features/Advanced/API-Declarations.md._ + +--- + +## docs/Reference/Core/Declare.md · after-remarks + +> [!NOTE] +> Only a **Declare** statement can use **As Any** for a parameter. Neither VB6 nor twinBASIC accepts **As Any** in a procedure defined in Basic code. A wrapper around an API such as `CopyMemory` therefore takes pointer-typed parameters (**LongPtr**) instead, and the caller passes addresses obtained with [**VarPtr**](../Default/VBA/Information/VarPtr), **StrPtr** or **ObjPtr**. + +_Source threads: 1507659583437668512 · confidence: medium_ +_Date range: 2026-05-23_ +_Reviewer note: Verify against the compiler that As Any is rejected in a non-Declare Sub/Function parameter list. The relative link to VarPtr is a guess: check the VarPtr page's real permalink (use its /tB/... permalink form) and that StrPtr/ObjPtr pages exist before linking them._ + +--- + +## docs/Reference/Core/Declare.md · after-remarks + +> [!NOTE] +> A C or C++ API often accepts a null pointer for an optional interface argument. When the twinBASIC declaration of such an argument is **ByRef** to an interface type, passing `ByVal Nothing` at the call site supplies a true null pointer. Passing a variable that holds **Nothing** supplies a pointer to that variable instead, which is not the same thing, and some declarations do not accept it. +> +> ```tb +> ' pOptionalInterface is declared ByRef As IUnknown +> hr = SomeApi(hWnd, ByVal Nothing) +> ``` +> +> If the declaration marks the argument **Optional**, omitting it at the call site also passes a null pointer. `ByVal Nothing` is twinBASIC syntax, and VB6 may not accept it. + +_Source threads: 1539249403079172156 · confidence: medium_ +_Date range: 2026-08-23_ +_Reviewer note: Verify against the compiler that ByVal Nothing is legal for a ByRef interface parameter, and how it interacts with Optional. Discord discussion is informal and does not name the exact declaration. Declare.md may not be the ideal home (no dedicated Nothing or argument-passing page exists); Core package, symbol 'ByVal Nothing'. The illustrative sample is invented and has not been compiled; replace or drop it after checking._ + +--- + +## docs/Reference/Core/Declare.md · after-remarks + +> [!NOTE] +> +> When a VB6 project that contains many Win32 **Declare** statements is imported for a 64-bit build, adding the community package WinDevLib to the project can replace the hand-converted declarations. The package declares common Windows APIs and COM interfaces with 64-bit-correct types. Add it through **Project → References → Available Packages**; with **Embedded** unticked, the package is linked and shared by all projects. Code that handles pointers still needs review, because pointer and handle sizes differ between 32-bit and 64-bit builds. See [64bit Compilation](../../Features/64bit) for the full list of considerations. + +_Source threads: 1546564453514874930 · confidence: low_ +_Date range: 2026-09-07_ +_Reviewer note: Advice comes from one community member. WinDevLib is a third-party package, not shipped with twinBASIC. 64bit.md and Windows-API.md already link to it, so this may be redundant on the Declare page; consider dropping it or moving it to the VB6 migration material instead._ + +--- + ## docs/Reference/Core/Deftype.md · after-remarks [DUPLICATE? -- see also thread 1186715657568534628] > [!NOTE] @@ -5527,6 +6062,17 @@ _Reviewer note: Add as an additional example block after the existing three exam --- +## docs/Reference/Core/Delegate.md · after-remarks + +> [!NOTE] +> One report describes crashes when a **Delegate** points at code generated at run time, such as an API-hooking trampoline allocated with `VirtualAlloc`, in 64-bit builds. The behaviour varied between machines and between runs, and worked in BETA 979 but not in BETA 983. Test such delegate calls in both the IDE and a compiled build, in 32-bit and 64-bit. + +_Source threads: 1503093611246391338 · confidence: low_ +_Date range: 2026-05-10 to 2026-09-18_ +_Reviewer note: Low confidence: anecdotal report with no confirmed root cause and an undescribed fix by the author; likely a compiler bug that belongs in BUGS-TO-REPORT.md (with a narrowed reproduction) rather than in the reference page. Recommend not publishing without verification._ + +--- + ## docs/Reference/Core/Dim.md · after-remarks [DUPLICATE? -- see also thread 1099362561482301523, 1172828722081058897, 1225747919072923678, 1434184886280786001, 1326158767561248768, 1468334726770200800, 1384557209232478240, 1402639656574648331] > [!NOTE] @@ -6491,6 +7037,17 @@ _Reviewer note: New.md currently documents only the `New` expression keyword. Th --- +## docs/Reference/Core/New.md · after-remarks + +> [!NOTE] +> The **As New** form does not respect a class's parameterized constructor. For a `[COMCreatable(False)]` class whose only constructor is `Private Sub New(ByVal lTest As Long)`, `Set obj = New MyClass` is rejected, but `Dim obj As New MyClass` is accepted in a build without LLVM. The object is then created without calling the constructor. In an LLVM build the same declaration fails with *LLVM compilation error in MyClass.{default_constructor}*. This is a reported compiler bug: the inline form is expected to require `New MyClass(1)`, as the **Set** form does. Until it is fixed, create such a class with **Set** or with an initializer (`Dim obj As MyClass = New MyClass(1)`), and do not use **As New** on it. + +_Source threads: 1554826871047458878 · confidence: medium_ +_Date range: 2026-09-30_ +_Reviewer note: Unfixed bug report from a single thread; verify against the current BETA before publishing, and remove or reword once fixed. The initializer workaround is inferred from the page's own documented forms, not stated in the thread. Also a candidate for BUGS-TO-REPORT.md._ + +--- + ## docs/Reference/Core/On-Error.md · after-remarks [DUPLICATE? -- see also thread 1113589601232240741, 1351545697060524033, 1478843597578174514] > [!NOTE] @@ -6538,6 +7095,41 @@ _Date range: 2026-03-04_ --- +## docs/Reference/Core/On-Error.md · after-remarks + +> [!NOTE] +> With the IDE's **Break On All Errors** option on, the debugger also stops on errors raised inside the built-in packages, wherever their code uses **On Error Resume Next**. An example is a late-bound property probe such as `WhatsThisHelpID`. There is currently no setting to exclude a package, and the `[Debuggable(False)]` attribute does not suppress these stops. No `[BreakOnAllErrors(False)]` attribute exists. The twinBASIC maintainers plan to remove these sites from the built-in packages by testing for an interface instead of relying on late-bound dispatch. In code of your own, test a condition first rather than relying on **On Error Resume Next**, so that the option does not stop there either. See [Debugger Options](../../IDE/Menu/Debug#debugger-options). + +_Source threads: 1520349031325368410 · confidence: medium_ +_Date range: 2026-06-27 to 2026-09-17_ +_Reviewer note: Reads as the single source for this gotcha; the [Debuggable(False)] claim rests on one user's test in the thread and should be verified against a current build. Consider also adding a one-line pointer in docs/IDE/Menu/Debug.md under Debugger Options. Link path ../../IDE/Menu/Debug assumes the rendered URL prefix; the build link check will confirm._ + +--- + +## docs/Reference/Core/On-Error.md · after-remarks + +> [!NOTE] +> In VB6 and VBA, a common pattern is an `On Error GoTo` handler in every procedure that calls a central error-reporting routine. twinBASIC offers alternatives. [**SetThreadGlobalErrorTrap**](../Modules/HiddenModule/SetThreadGlobalErrorTrap) registers a callback for unhandled run-time errors on a thread, which suits application-wide logging. The [**ErrorCallstack**](../Packages/VBRUN/ErrorCallstack/) class gives access to the chain of procedures that were active when an error was raised, so a handler can write a stack trace to an error log. +> +> Hard crashes that are not COM HRESULT errors, such as access violations from `CopyMemory`, are not raised as run-time errors. Community members have experimented with low-level exception handlers for these. + +_Source threads: 1543073287729578134 · confidence: low_ +_Date range: 2026-08-29_ +_Reviewer note: The thread only says twinBASIC has 'built-in functionality similar to vbWatchDog' (stack access and a global handler) and links to other Discord posts; mapping it to SetThreadGlobalErrorTrap and ErrorCallstack is an inference from existing pages. Verify against the .twin sources, and consider dropping the last paragraph (exception handlers) since no API is documented._ + +--- + +## docs/Reference/Core/On-Error.md · after-remarks + +> [!NOTE] +> **On Error** does not catch a CPU exception. An access violation caused by an invalid pointer passed to an API such as `CopyMemory` is a hardware exception, not a run-time error, so the application crashes whatever handler is enabled. Protection against it needs a vectored exception handler registered with the `AddVectoredExceptionHandler` API. The handler must change the `CONTEXT` structure to skip the faulting instruction (`Eip` in a 32-bit process, `Rip` in a 64-bit process), which requires knowing the instruction's length. The `CONTEXT` layouts differ completely between 32-bit and 64-bit processes. + +_Source threads: 1507659583437668512 · confidence: medium_ +_Date range: 2026-05-23 to 2026-07-06_ +_Reviewer note: Overlaps with the existing NOTE on this page about DLL system errors not raising exceptions; consider merging into it. Also cross-check with the VEH note drafted for AddressOf._ + +--- + ## docs/Reference/Core/On-GoTo.md · after-remarks > [!NOTE] @@ -6595,6 +7187,17 @@ _Reviewer note: The existing page states the Encoding clause 'has no effect on B --- +## docs/Reference/Core/Open.md · after-remarks + +> [!NOTE] +> **Open** is a reserved keyword, so a procedure cannot be named **Open**. A declaration such as `Public Function Open() As Long` fails with an "End of line" error, as it does in VB6. Choose a different name, for example `OpenFile` or `OpenDatabase`. The error is common in code copied from another source, including AI-generated code, that names a wrapper function after the operation it performs. + +_Source threads: 1520345444926885978 · confidence: high_ +_Date range: 2026-06-27_ +_Reviewer note: The thread also says a maintainer suggested the Return syntax as a way to avoid the problem. It is unclear whether that means a function named Open compiles when the value is returned with Return, or only that Return avoids assigning to the reserved name inside the body. Verify with tbbuild before documenting; the draft only states the rename advice. See Return.md._ + +--- + ## docs/Reference/Core/Option.md · after-remarks [DUPLICATE? -- see also thread 1393736281879744612, 1326397182063939666, 1425788896528044032, 1445526310116917463] > [!NOTE] @@ -6761,6 +7364,34 @@ _Date range: 2024-05-04 to 2024-05-16_ --- +## docs/Reference/Core/Property.md · after-remarks + +> [!NOTE] +> Overloading is not available for properties. Two **Property Get** procedures with the same name and different arguments in one module are not accepted. Property overloading is planned for a release after version 1.0. + +_Source threads: 1526923335236063302 · confidence: high_ +_Date range: 2026-07-15_ +_Reviewer note: The source is a maintainer's statement about the roadmap, not a compiler probe. The sentence about two Property Get procedures is an inference; confirm it against the compiler, or reduce the note to its last sentence. Property.md currently says nothing about overloading; Sub and Function overloading is covered elsewhere (Features/Language)._ + +--- + +## docs/Reference/Core/Property.md · example + +A generic **Property Get** and **Property Let** pair can read and write memory at an address without a call to **CopyMemory**. With a pair named `Deref(Of T)` that takes the address as a **LongPtr**, the following statements read a **Double** stored 8 bytes into a **Variant**, and change the subtype of a **Variant** by overwriting its first two bytes: + +```tb +Dim d As Double = Deref(Of Double)(VarPtr(Var) + 8) +Deref(Of Integer)(VarPtr(Var)) = vbInteger +``` + +In preliminary tests the pair worked for any type, including user-defined types. + +_Source threads: 1125359845936205844 · confidence: low_ +_Date range: 2026-08-31_ +_Reviewer note: The thread's helper implementation (a Private Sub DerefPtrGet(Of T, T) called from the property) was garbled in the extract and is deliberately not reproduced. Do not publish until a working Deref definition has been written and compiled; the claim about UDTs was 'preliminary' and untested more widely. The two-argument form and declaration syntax need checking against the Property page's generic rules._ + +--- + ## docs/Reference/Core/Protected.md · after-remarks > [!NOTE] @@ -6804,6 +7435,16 @@ _Reviewer note: This is a known bug reported on Discord. The workaround (On Erro --- +## docs/Reference/Core/RaiseEvent.md · after-remarks + +> [!NOTE] +> Before BETA 989, a **RaiseEvent** statement executed inside a form's `Form_QueryUnload` handler was silently ignored: no error occurred and no listener was notified, although the same statement worked from a handler such as a button's **Click**. VB6 delivers the event. On earlier builds, raise the event from another place, such as a helper procedure called after the unload sequence. + +_Source threads: 1525900138206466058 · confidence: high_ +_Date range: 2026-07-12 to 2026-09-28_ + +--- + ## docs/Reference/Core/ReDim.md · after-remarks [DUPLICATE? -- see also thread 1490135534142492732, 1126698055547236443] > [!NOTE] @@ -6854,6 +7495,27 @@ _Date range: 2023-07-07_ --- +## docs/Reference/Core/ReDim.md · after-remarks + +> [!NOTE] +> Before BETA 984, calling a procedure without passing an **Optional** **Variant** parameter, when that procedure then executed **ReDim** on the parameter (for example `ReDim vData(0) As Byte`), crashed with an access violation. VB6 accepts this code. The crash is fixed in BETA 984. + +_Source threads: 1531052166117327032 · confidence: high_ +_Date range: 2026-07-26 to 2026-09-24_ + +--- + +## docs/Reference/Core/ReDim.md · after-remarks + +> [!NOTE] +> While an array element is passed **ByRef** to another procedure, twinBASIC locks the array, so a **ReDim** or **Erase** of that array inside the called procedure fails. The compiler applies the same lock to `VarPtr(arr(i))`, which it treats as a procedure call for this purpose. A fixed-size array is locked as well; VB6 does not lock it in this case. The LLVM back end behaves the same way as the default compiler. + +_Source threads: 1487268961061044338 · confidence: high_ +_Date range: 2026-09-26_ +_Reviewer note: Maintainer said the lock on VarPtr() and on fixed arrays may be relaxed later. The page has no Remarks section, so place the note before Example. Verify the lock behaviour on the current build._ + +--- + ## docs/Reference/Core/Return.md · after-remarks [DUPLICATE? -- see also thread 1101035893130809435] > [!NOTE] @@ -7069,6 +7731,49 @@ _Reviewer note: The note describes confirmed-unsupported behavior as of July 202 --- +## docs/Reference/Core/Sub.md · after-remarks + +A caller can also write **ByVal** in front of an argument, which overrides the parameter's declaration for that one call. The value is then treated as a memory address for a **ByRef** parameter, and the procedure reads and writes whatever is at that address. The pattern is useful on purpose, for example to pass the result of **VarPtr** as a pointer, but it crashes the program when the value is not a valid address. + +> [!WARNING] +> A call such as `Test(ByVal x)`, where `Test` has a **ByRef** parameter and *x* holds an ordinary number, uses the number as an address. The program crashes, in the IDE and in a built EXE, and the compiler reports no diagnostic. VB6 accepts a call-site **ByVal** only for **Declare** statements and interface methods, and reports a type mismatch elsewhere. twinBASIC allows it in ordinary code. + +The same rule applies to types of different sizes. If two **Single** variables are passed with `ByVal VarPtr(...)` to **ByRef** **Long** parameters, the procedure sees the raw bit patterns of the **Single** values, not their numeric values. For example, 1.0 is `&H3F800000` and 2.0 is `&H40000000`. + +_Source threads: 1553126919002923138 · confidence: medium_ +_Date range: 2026-09-25 to 2026-09-26_ +_Reviewer note: The thread treats a crash when a Single is passed ByVal to a ByRef Single as a possible compiler bug, while the Long-address behaviour is called expected. Verify with tbrun before publishing, and check the VB6 comparison. Place after the ByRef/ByVal example in 'Passing arguments ByRef and ByVal'. Could be cross-linked from Call.md, which mentions ByVal in its argumentlist._ + +--- + +## docs/Reference/Core/Sub.md · after-remarks + +#### Out parameters declared **ByRef** do not release the old value + +A parameter declared **ByRef** in twinBASIC code has in-out meaning only: the language has no way to declare a parameter as *out-only*. When a procedure, or a COM interface method that twinBASIC code declares, fills a **ByRef** parameter with a new object, the callee overwrites the pointer and does not release the reference that the variable already held. The old object leaks. + +This matters for calls inside a loop that reuse one variable for each result, such as an enumerator implemented by the operating system. VB6 avoids the leak only for parameters that a type library marks `[out]`: it releases and clears the variable before the call. twinBASIC does the same for `[out]` parameters that come from a type library, but it cannot do so for a parameter declared **ByRef** in twinBASIC source. + +> [!NOTE] +> To avoid the leak, set the variable to **Nothing** after each use, or before each call that fills it. + +```tb +Do While pEnum.Next(1, siChild) = S_OK + ' ... use siChild ... + Set siChild = Nothing ' Release it, or the next call overwrites the reference and leaks the object. +Loop +``` + +An out-only parameter kind has been discussed for the language, as an attribute or as a keyword, but none exists yet. + +For **Variant** variables that a call receives, twinBASIC clears the old contents at the call site. Clearing such a variable again by hand with the `VariantClear` API can therefore free the same value twice and crash. This behavior was observed by users and has not been confirmed by the maintainers; an **As Any** out parameter is not known to receive the same handling. + +_Source threads: 1384721515542479049 · confidence: high_ +_Date range: 2026-09-04 to 2026-09-05_ +_Reviewer note: The maintainer confirmed the leak (WaynePhillipsEA). The final paragraph (Variant call-site clearing and the As Any remark) is a single user's observation (low confidence); consider dropping it if unverified. The out-only attribute/keyword proposals are unimplemented, so the wording should be re-checked against the current build. The loop sample is deliberately not marked check_build because it needs an external IEnumShellItems declaration. The UUID finding did not map to any page; see the separate entry._ + +--- + ## docs/Reference/Core/Topic-Preprocessor.md · after-remarks [DUPLICATE? -- see also thread 1053877375915544576, 1401886323437998170] > [!NOTE] @@ -7523,6 +8228,50 @@ _Reviewer note: The StrRef helper function is referenced but not shown -- verify --- +## docs/Reference/Core/Type.md · example + +**Emulating a union.** A UDT can hold one real storage field and expose other views of it through **Property Get** and **Property Let** procedures. Programs that call an API still see the exact byte layout of the storage field. The following type stores a 64-bit value and provides its high half as a property: + +```tb +Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (ByRef Destination As Any, ByRef Source As Any, ByVal Length As LongPtr) + +Type LargeInt + QuadPart As LongLong ' The real storage. + + Public Property Get HighPart() As Long + CopyMemory HighPart, ByVal VarPtr(Me.QuadPart) + 4, 4 + End Property + + Public Property Let HighPart(ByVal Value As Long) + CopyMemory ByVal VarPtr(Me.QuadPart) + 4, Value, 4 + End Property +End Type +``` + +Inside the procedures of a **Type**, its members must be accessed with the `Me.` prefix. A **Type** can also define several `Type_Assignment` overloads to customize assignment from values of other types, and can declare methods such as `Sub SwapBytes()`. + +> [!NOTE] +> **VarPtr** is valid only for the real storage field. `VarPtr(x.HighPart)` does not point into the structure. Nested types are not suitable for this technique. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2025-12-09 to 2026-09-02_ +_Reviewer note: The code sample was written from the thread's description (which uses CType(Of ...)(VarPtr(...)) and CopyMemory on Me members) and has not been compiled. Compile it, then mark it check_build. Insert after the twinBASIC enhancements examples._ + +--- + +## docs/Reference/Core/Type.md · after-remarks + +> [!IMPORTANT] +> The alignment of a **Type** follows its fields. A `LARGE_INTEGER` in C is a union that contains a `LONGLONG QuadPart`, so the Windows API aligns a structure that contains it to 8 bytes. A **Type** declared with `LowPart` and `HighPart` fields of type **Long** is aligned to 4 bytes only. This rarely matters in 32-bit code but often breaks structures in 64-bit code. Declare the field as `QuadPart As LongLong` instead. + +When a byte array stands in for a union, the array must reproduce the size and alignment of the whole union. On 64-bit Windows a union that contains a pointer is aligned to 8 bytes, so it starts at offset 8 rather than 4 after an **Integer** or **Long** field. It occupies 16 bytes if it contains a structure made of an integer and a pointer, and it can add padding at the end of the enclosing structure. Unions are not supported in twinBASIC at present, and are expected after version 1.0. + +_Source threads: 1125359845936205844 · confidence: high_ +_Date range: 2023-12-16 to 2025-03-09_ +_Reviewer note: The thread says Wayne confirmed unions as a post-1.0 feature; keep or drop the last sentence depending on whether that plan is still current._ + +--- + ## docs/Reference/Core/Unload.md · after-remarks [DUPLICATE? -- see also thread 1321435629917044847] > [!NOTE] @@ -7710,10 +8459,29 @@ _Reviewer note: Confirm the post-v1.0 timeline against official roadmap material ## docs/Reference/Data-Types.md · after-remarks [DUPLICATE? -- see also thread 1052446707780169748, 1054031803087851530, 1357009211179139200, 1442172881596584078, 1148996503675879474, 1260984179051728956, 1449211458683408515] +> [!NOTE] +> A type-declaration character can also follow the name of a variable, a parameter, or a **Function** or **Property Get** procedure, where it sets the declared type in place of an **As** clause. `#` gives a **Double** and `^` gives a **LongLong**. **LongPtr** has no suffix and must always be written `As LongPtr`. +> +> ```tb +> Function Scale2#(ByVal x#) ' same as: Function Scale2(ByVal x As Double) As Double +> Scale2 = x * 2 +> End Function +> ``` _Source threads: 1460777854714515728 · confidence: high_ _Date range: 2026-01-14_ -_Reviewer note: The finding confirms that `#` is the type-suffix for Double, `^` for LongLong, and there is no suffix for LongPtr. This information is already present in the Data-Types.md table (Suffix column) and in the LongLong and LongPtr prose sections. No new content is needed; this addition is a no-op and can be discarded._ +_Reviewer note: Target chosen as Data-Types.md (which already holds the suffix table) rather than Core/Deftype.md, the page the finding's symbol maps to; Deftype.md could instead get a one-line See Also link to it. The suffix table already lists # and ^ and the LongPtr exception, so only the 'applies to parameters and function/property names' part is new. The example (Scale2#(ByVal x#)) has not been compiled; verify with tbbuild before marking check_build._ + +--- + +## docs/Reference/Data-Types.md · after-remarks + +> [!NOTE] +> twinBASIC has no native unsigned 16-bit integer type. This matters when a DLL function returns an unsigned 16-bit value, because a value above 32,767 arrives as a negative **Integer**. A conversion helper such as `CIntToUInt`, which community libraries like vbccr and WinDevLib provide, converts the result to a **Long** holding the unsigned value. Native unsigned types are planned for a release after version 1.0. + +_Source threads: 1513670031022756062 · confidence: medium_ +_Date range: 2026-06-08 to 2026-06-09_ +_Reviewer note: Place after the Byte and Integer paragraphs in the Integer types section. The 'planned after v1.0' statement is a maintainer remark in the thread and may change; verify the exact CIntToUInt signature in WinDevLib before quoting it._ --- @@ -15551,3 +16319,816 @@ _Date range: 2026-05-25 to 2026-06-04_ _Reviewer note: Reproduced with SumatraPDF v3.7 on Windows 8 and Windows 10 (tB build 982). Verify whether this has been fixed in a later tB build before publishing._ --- + +## docs/Reference/Default/VBRUN/DataObject/GetFormat.md · after-remarks + +> [!NOTE] +> **GetFormat** (and [**AvailableFormats**](AvailableFormats)) can raise run-time error -2147467263 (`&H80004001`, `E_NOTIMPL`) when the drag source does not implement the `IDataObject::EnumFormatEtc` method. Not every application implements it. Browsers, Windows Explorer and Total Commander do; a pre-release SumatraPDF 3.7 did not when dragging images from it. VB6 does not raise the error for the same source. Trap the error with **On Error** in the **OLEDragOver** and **OLEDragDrop** handlers, or read the underlying `IDataObject` interface directly --- see [Drops from sources that **DataObject** cannot read](index#drops-from-sources-that-dataobject-cannot-read). + +_Source threads: 1508415642112491590 · confidence: medium_ +_Date range: 2026-05-25 to 2026-06-11_ +_Reviewer note: The anchor #drops-from-sources-that-dataobject-cannot-read is defined by the companion new-section addition to DataObject/index.md; drop the link if that addition is not taken. The claim about which applications implement EnumFormatEtc comes from one thread and was not checked against the runtime._ + +--- + +## docs/Reference/Default/VBRUN/DataObject/AvailableFormats.md · after-remarks + +> [!NOTE] +> When the source of a drag does not implement `IDataObject::EnumFormatEtc`, **AvailableFormats** raises run-time error -2147467263 (`E_NOTIMPL`) instead of returning a collection. Handle the error with **On Error**, or see [**GetFormat**](GetFormat) and [Drops from sources that **DataObject** cannot read](index#drops-from-sources-that-dataobject-cannot-read) for the alternatives. + +_Source threads: 1508415642112491590 · confidence: medium_ +_Date range: 2026-05-25 to 2026-06-11_ +_Reviewer note: The thread reports the error from GetFormat and mentions AvailableFormats as also affected; verify AvailableFormats raises the same error on the current build._ + +--- + +## docs/Reference/Default/VBRUN/DataObject/index.md · new-section + +## Drops from sources that DataObject cannot read + +A **DataObject** wraps the `IDataObject` interface of the drag source. If the source offers formats that the **DataObject** cannot enumerate, or does not implement `EnumFormatEtc` at all, [**GetFormat**](GetFormat) and [**AvailableFormats**](AvailableFormats) can raise error -2147467263 (`E_NOTIMPL`). Two approaches work around this: + +- Implement the `IDropTarget` interface on the form and read the `IDataObject` directly. The interface is natively accessible from twinBASIC, and is the better choice for anything beyond plain text (`CF_TEXT`) or file lists (`CF_HDROP`). +- When the drop lands on a WinRT XAML Island, its `DataView` object already implements `IDataObjectProvider`, so the drop can be received on the island itself. + +Two details about the formats themselves: + +- Files that are really streams, not files on disk, are offered through the `CFSTR_FILEDESCRIPTOR` and `CFSTR_FILECONTENTS` formats rather than `CF_HDROP` (`vbCFFiles`). +- A source that creates its data only when the drop happens cannot offer `vbCFFiles`, because `CF_HDROP` needs the files to exist before the drop. + +_Source threads: 1508415642112491590 · confidence: medium_ +_Date range: 2026-06-08 to 2026-06-11_ +_Reviewer note: Draws on a single Discord thread; the WinRT XAML Island claim and the IDropTarget approach were not tested against a build. Consider adding a compiled example if a WinDevLib IDropTarget declaration can be verified._ + +--- + +## docs/Reference/Default/VB/MDIForm/index.md · new-section + +## Aligned controls and creation order + +Controls docked to the edges of an MDI form are laid out in the order they were created. This differs from VB6, which gives top- and bottom-aligned controls priority: they stretch to the full width of the form, and left- and right-aligned controls are then sized to the height that remains. + +In twinBASIC, a left- or right-aligned control created before a top- or bottom-aligned one takes priority instead, and the layout comes out differently from the VB6 original. To restore the VB6 arrangement, change the creation order of the controls --- cutting a control and pasting it back moves it to the end of the order. + +> [!NOTE] +> Full compatibility with the VB6 **Align** priority is planned. Also, hiding a control that is docked to an edge of the form does not currently refresh the layout of the form and the other controls correctly, including when some docked controls start out hidden. + +_Source threads: 1512037273149898783 · confidence: high_ +_Date range: 2026-06-04_ +_Reviewer note: The finding is about VB6-style Align on MDI forms, but the page has no Align section; the same creation-order rule for Dock is already stated in Features/GUI-Components/Anchoring-Docking.md, so check for overlap. The hidden-controls refresh problem was reported as a bug and may be fixed in a later build._ + +--- + +## docs/Reference/Default/VB/UserControl/index.md · after-remarks + +> [!NOTE] +> In VB6, **PropertyChanged** can be called without an argument. In twinBASIC the built-in implementation applies **Len** to the missing *PropertyName* value, which raises a type mismatch error, so always pass the property name, for example `PropertyChanged "Caption"`. + +_Source threads: 1522813729308414115 · confidence: medium_ +_Date range: 2026-07-04_ +_Reviewer note: Conflicts with the existing text on this page, which says to omit PropertyName for a generic notification. Verify on the current build and, if the behaviour is confirmed, also change that sentence. May already be fixed in a later BETA._ + +--- + +## docs/Reference/Default/VB/PictureBox/index.md · after-remarks + +> [!NOTE] +> Do not toggle [**AutoRedraw**](#autoredraw) at run time around drawing code when the compiled program uses the High DPI scaling override **System (Enhanced)** and the screen scaling is 125% or higher. Text drawn to the picture box's device context (for example with `DrawText`) then comes out wrong. **System (Enhanced)** is meant for programs that are not manifested as DPI aware, and it scales bitmaps, so some blurring is expected. Set **AutoRedraw** once, at design time or at start-up, rather than switching it around each drawing operation. + +_Source threads: 1475781665354678286 · confidence: medium_ +_Date range: 2026-02-24 to 2026-06-22_ +_Reviewer note: Place at the end of the 'AutoRedraw and the persistent image' section. The advice to set AutoRedraw once is inferred from the report; the thread says only that toggling is the trigger and that toggling at run time has been broken for a long time. Verify. Also relevant to the High DPI override entry in Project Settings, which the page-index does not map._ + +--- + +## docs/Reference/Default/VB/PictureBox/index.md · after-remarks + +> [!NOTE] +> Content that a program draws once with Direct2D (`BeginDraw`, `DrawBitmap`, `EndDraw`) onto a **PictureBox** window, or onto a **Form**, can flash and then disappear, and in a compiled EXE may not appear at all. twinBASIC repaints the surface in response to `WM_PAINT` and covers the Direct2D output. Two workarounds: redraw the image every time `WM_PAINT` arrives, or subclass the window's **hWnd** and discard `WM_PAINT`. Text drawn with [**Print**](#print) can also disappear when the form loses focus and is moved slightly after it regains it. + +_Source threads: 1540274884670918676 · confidence: low_ +_Date range: 2026-08-21_ +_Reviewer note: Low confidence: the cause (internal WM_PAINT handling) is a participant's guess, and no maintainer confirmation is recorded. Verify with a small Direct2D sample and consider placing the note under the AutoRedraw section instead. The Print symptom is reported as related and is unverified._ + +--- + +## docs/Reference/Default/VB/ListBox/index.md · after-remarks + +> [!NOTE] +> On a multi-select list in BETA 983, assigning `ListIndex = ListCount - 1` does not scroll the list to its last item, as it does in VB6. As a workaround, assign `TopIndex = ListCount - 1`. This raises the [**Scroll**](#scroll) event rather than [**Click**](#click), so code that relied on **Click** firing after the **ListIndex** assignment may need to handle **Scroll** as well. +> +> ```tb +> List1.TopIndex = List1.ListCount - 1 +> ``` + +_Source threads: 1532039774800449788 · confidence: medium_ +_Date range: 2026-07-29_ +_Reviewer note: Insert under the TopIndex heading. May be obsolete if the ListIndex caret-index fix has shipped; verify on a current build._ + +--- + +## docs/Reference/Default/VB/ListBox/index.md · after-remarks + +> [!NOTE] +> In BETA 983, changing a property that recreates the window, such as **IntegralHeight**, in the form designer displayed an error message and erased the items entered at design time. Enter the items again, or set them in code, on builds where this occurs. Fixed in BETA 989. + +_Source threads: 1531342293263581324 · confidence: high_ +_Date range: 2026-07-27 to 2026-09-28_ +_Reviewer note: Insert under the IntegralHeight heading, after the existing paragraph._ + +--- + +## docs/Reference/Default/VB/CommandButton/index.md · after-remarks + +> [!NOTE] +> An unset picture property is not **Nothing** in either VB6 or twinBASIC, so `Is Nothing` cannot detect it. In VB6 the **Type** of an unset picture is `0` (**vbPicTypeNone**); twinBASIC returns `-1`. To test whether a picture is set, check that the handle of its **IPicture** is not zero: +> +> ```tb +> Dim IsSet As Boolean +> IsSet = (CType(Of IPicture)(Command1.DownPicture).Handle <> 0) +> ``` + +_Source threads: 1526782440264171652 · confidence: medium_ +_Date range: 2026-07-15_ +_Reviewer note: Insert under the DownPicture heading. The snippet is untested; consider marking it check_build after confirming it compiles. The same applies to the other picture properties (Picture, DisabledPicture)._ + +--- + +## docs/Reference/Default/VBA/ErrObject/Raise.md · after-remarks + +> [!NOTE] +> In BETA 983, raising an error such as `Err.Raise vbObjectError, "Form1", "boom"` from a called procedure and trapping it with [**On Error Resume Next**](../../../Core/On-Error) could leave **Err.Description** holding the text `Invalid OLEVERB structure` instead of the supplied description. This was a regression in error propagation, fixed in BETA 984. + +_Source threads: 1526989182516334602 · confidence: high_ +_Date range: 2026-07-15 to 2026-09-24_ +_Reviewer note: Check the relative link to On-Error (page path is docs/Reference/Core/On-Error.md; permalink /tB/Core/On-Error) resolves from this page. Placed before the Example section._ + +--- + +## docs/Reference/Default/VB/index.md · after-remarks + +> [!NOTE] +> When several controls are selected in the form designer, a property change applies to all of them only if the change needs no full designer refresh (for example **TextBox.BorderStyle**). A property that does need a refresh (for example **CommandButton.Style**) changes only the first selected control, and the refresh deselects the others. This is a known designer limitation. Change such properties on one control at a time. + +_Source threads: 1529471888227832050 · confidence: medium_ +_Date range: 2026-07-22_ +_Reviewer note: Finding had package VB and no symbol; it is about the designer, not a specific control. VB package index used as fallback; the IDE tbForm page (docs/IDE/tbForm.md) or a designer page may be a better home. Reviewer to decide placement._ + +--- + +## docs/Reference/Default/VBA/Collection/Item.md · after-remarks + +> [!NOTE] +> Because **Item** is the default member, assigning an object to a property that holds a **Collection** without **Set** is read as an assignment to **Item**. With `Public Property Set Tags(ByVal vData As Collection)` declared, the line `Me.Tags = someCollection` makes the compiler look for an *index* argument, and the diagnostic reports that argument as missing, although the property takes none. The property signature is correct. Write `Set Me.Tags = someCollection` to assign the object reference. See [**Set**](../../../Core/Set). + +_Source threads: 1540125248840536175 · confidence: medium_ +_Date range: 2026-08-20_ +_Reviewer note: Placed on Collection/Item because the finding's symbol is Collection.Item. The lesson is really about the missing Set keyword, so the same note could go on docs/Reference/Core/Set.md instead. The relative link to Set assumes the permalink /tB/Modules/Collection/Item resolves ../../../Core/Set to /tB/Core/Set; verify with the build's link check. The claim that a codegen error can also appear when forced comes from the thread and is not stated in the draft._ + +--- + +## docs/Reference/Default/VBA/Collection/Item.md · after-remarks + +> [!NOTE] +> The **Item** method of the VB6 **Collection** clears a **Variant** that the caller passes in for the result before it writes the item, so an object already held in that variable is released. This matters when code calls **Item** through a hand-written interface declaration, such as a custom `ICollection`, and passes a **Variant** that already holds an object. twinBASIC sets such an output variable to empty at the call site, as VB6 does, but its **Item** does not yet release the old object, so the object receives no **Class_Terminate** call. The maintainers plan to copy the VB6 behavior in the **Item** implementation. To be safe, set the variable to **Nothing** or **Empty** before each call. + +_Source threads: 1384721515542479049 · confidence: high_ +_Date range: 2026-09-04_ +_Reviewer note: Applies only to calls through a custom VB6-style ICollection declaration, not to normal Collection.Item calls; the behavior may change in a later build. Verify against the current build before publishing._ + +--- + +## docs/Reference/Default/VBA/HiddenModule/PutMemPtr.md · after-remarks + +> [!NOTE] +> **PutMemPtr** is the usual way to overlay an array onto existing memory: fill a **SAFEARRAY** descriptor with `FADF_AUTO` in `fFeatures`, then store its address in the array variable with `PutMemPtr ArrPtr(arr), VarPtr(sa)`. In VB6, `FADF_AUTO` makes the runtime leave the data alone when the array goes out of scope. In BETA 983, an overlaid array of user-defined types behaves differently: the overlaid memory is zeroed when the array variable goes out of scope, as if **Erase** had been called. Arrays of **Long** and other simple types are not affected. Clearing the descriptor's `pvData` and `cElements` fields before the procedure exits prevents the zeroing. The thread does not confirm a fix. + +_Source threads: 1539559491966734378 · confidence: medium_ +_Date range: 2026-08-19 to 2026-08-20_ +_Reviewer note: Behaviour reported on BETA 983 only and not confirmed fixed in the thread. Re-test on a current build with an overlaid UDT array before publishing, and consider adding a small example if it reproduces._ + +--- + +## docs/Reference/Default/VB/Form/index.md · after-remarks + +> [!NOTE] +> Reading or writing any control from **Form_Terminate** raises `NATIVE EXCEPTION: ACCESS_VIOLATION`, in the IDE and in a compiled EXE, and execution stops. VB6 behaved differently: in the IDE the controls were already unloaded, and in a compiled EXE they could still be read. Code migrated from VB6 that touches a control in **Terminate**, for example to read one property of a Winsock control, fails in twinBASIC even with *Preserve all VB errors* enabled. Move that code to [**QueryUnload**](#queryunload) or [**Unload**](#unload), which run while the controls still exist. The line number in the exception report does not point at the offending access. + +_Source threads: 1534961644457693194 · confidence: medium_ +_Date range: 2026-08-06 to 2026-08-13_ +_Reviewer note: Insert after the Terminate event description (the sentence that says the controls are no longer accessible), which it extends. The exception text and the setting name 'Preserve all VB errors' come from the thread; verify the setting's exact IDE label._ + +--- + +## docs/Reference/Default/VB/Form/index.md · after-remarks + +> [!NOTE] +> Windows hides the access-key underlines until the user presses **Alt**, so a caption such as `&Normal` shows no underline at first, although its **Alt**+**N** shortcut already works. This is standard Windows behavior, not a fault in the control. Set **AlwaysShowKeyboardCues** to show the underlines all the time. + +_Source threads: 1545491050909409320 · confidence: high_ +_Date range: 2026-09-04 to 2026-09-24_ +_Reviewer note: Insert directly after the existing AlwaysShowKeyboardCues paragraph. Existing text says the property is read-only at run time and set at design time; the thread only says 'set the form property', so no conflict is expected._ + +--- + +## docs/Reference/Default/VB/Form/index.md · new-section + +## Acting as a site for COM objects + +Some COM objects, such as a shell context menu, need a *site* that supplies a host window. Their commands (for example Share) work only when a site is set. A **Form** can be that site. Its class already implements `IOleWindow`, so it needs no separate implementation. + +The form implements `IServiceProvider`, then passes itself to the object through `IObjectWithSite.SetSite`. In its `QueryService` implementation, the form returns itself when the requested interface is `IID_IOleWindow`, and fails with `E_NOTIMPL` for any other interface. + +```tb +Implements IServiceProvider + +Private Sub ShowMenu(pCtx As IContextMenu) + Dim site As IObjectWithSite + Set site = pCtx + site.SetSite Me ' The form becomes the site. +End Sub + +' In IServiceProvider_QueryService, when riid is IID_IOleWindow, +' return the form's pointer; for any other riid, set +' Err.ReturnHResult = E_NOTIMPL. +``` + +> [!NOTE] +> When the *ppvObject* parameter is declared as **LongPtr**, the form must be queried through `IUnknownUnrestricted.QueryInterface` to obtain the pointer to return. Declaring the parameter as an object type such as `IOleWindow` avoids this, but then only that one interface can be returned. + +The Share dialog appears inside the window rectangle of the site. A very small form produces a tiny or empty dialog frame. + +_Source threads: 1384721515542479049 · confidence: medium_ +_Date range: 2026-03-22 to 2026-03-24_ +_Reviewer note: Based on one user's working pattern (fafalone) using WinDevLib interface declarations, which are not part of the built-in packages. The sample is a sketch and not marked check_build; the exact IServiceProvider_QueryService signature depends on the declaration used. Consider whether this belongs on the Form page or in a Features/tutorial page._ + +--- + +## docs/Reference/Default/VB/Form/index.md · after-remarks + +> [!NOTE] +> The scale properties of a form, such as **ScaleWidth** and **ScaleHeight**, are **Double** values. If a library procedure offers overloads that take only **Single**, twinBASIC does not implicitly narrow a **Double** argument when it resolves the call. Either add an overload that takes **Double**, or convert the value explicitly with [**CSng**](../../VBA/Conversion/CSng). + +_Source threads: 1460777854714515728 · confidence: medium_ +_Date range: 2026-01-14_ +_Reviewer note: The finding names 'Form.ScaledWidth', which is not a property on the Form page; the draft assumes ScaleWidth/ScaleHeight (or a similar Scale* member) was meant. Intended for the ScaleWidth section of the Form page (or the Drawing surface paragraph). The claim about overload resolution refusing implicit Double-to-Single narrowing rests on one thread and should be verified with a probe. Check the relative CSng link resolves (path assumed docs/Reference/Default/VBA/Conversion/CSng.md, permalink /tB/Modules/Conversion/CSng)._ + +--- + +## docs/Reference/Built-In/WinNativeCommonCtls/Slider.md · after-remarks + +> [!NOTE] +> In BETA 983, assigning the named constants **ccOrientationHorizontal** and **ccOrientationVertical** to **Orientation** has no effect, although the IDE offers them in its list. Assign the numeric values instead: `0` for horizontal and `1` for vertical. BETA 984 fixes this. + +_Source threads: 1541231936104693870 · confidence: high_ +_Date range: 2026-08-23 to 2026-09-24_ +_Reviewer note: Insert directly after the Orientation property description. The wording is unchanged from the finding's claim that the constants are not recognised in 983 and fixed in 984; the local compiler is BETA 983, so it can be re-tested._ + +--- + +## docs/Reference/Default/VBA/Strings/Split.md · after-remarks + +> [!NOTE] +> **Split** always returns an array of **String** elements. Assigning the result to an array of another type, such as `Long()`, fails at run time. To get one element per character, or numbers instead of text, build the array yourself: loop over the string with [**Mid**](Mid) and convert each element. With a zero-length *delimiter*, `Split("0040", "")` returns a single element, `"0040"`, and does not split the string into characters. + +_Source threads: 1544831815330828428 · confidence: medium_ +_Date range: 2026-09-02_ +_Reviewer note: Verify against the compiler: the thread reports runtime error 0x80004005 (E_FAIL) when assigning to Long(); classic VBA would give a type mismatch. The error code is deliberately left out of the draft. Check that Mid.md exists at the relative link Mid within the Strings module. Place after the argument table, before Example._ + +--- + +## docs/Features/GUI-Components/Modernization.md · after-remarks + +> [!NOTE] +> An OCX control that is registered but draws wrongly in the form designer, such as `SSTab`, often lacks its design-time license. The full VB6 install is not needed. VB6 keeps its licenses in registry files with the `.srg` extension, which hold keys under `HKEY_CLASSES_ROOT\Licenses\`. To merge one, add the line `Windows Registry Editor Version 5.00` as the first line of the file, save it as a `.reg` file and double-click it. Without that header, Windows refuses to treat the file as a registry script. This cured the `SSTab` control in one report, but did not cure `MSFlexGrid`. [VBFlexGrid](../../Packages/Importing-TWINSERV) is the alternative for that control. + +_Source threads: 1547608775689642044 · confidence: high_ +_Date range: 2026-09-10 to 2026-09-13_ +_Reviewer note: Only one user confirmed the fix, for SSTab. The placement (after the paragraph about original Microsoft OCX controls) and the link to the Package Server page (../../Packages/Importing-TWINSERV, copied from the paragraph above it) should be checked. Registry merges need admin rights or the right hive; not stated in the thread._ + +--- + +## docs/Reference/Built-In/TwinBasicAssertions/index.md · after-remarks + +> [!NOTE] +> In BETA 984, a compiler regression made every `Assert` call fail with the error that **Assert** was not recognised, and removing and adding the package again did not help. BETA 985 fixes it. Use BETA 985 or later. + +_Source threads: 1552734546238242996 · confidence: high_ +_Date range: 2026-09-24 to 2026-09-25_ +_Reviewer note: Version-specific bug note; a release-notes page may suit it better than the package index. Insert after the Calling convention section or after the opening example. The page's indexed_from is beta-x-0983._ + +--- + +## docs/LLVM/Getting-Started.md · new-section + +## Build errors + +### The LLVM cache process failed to start + +The message `[LLVM] The LLVM cache process failed to start. Restarting the compiler...` means that the LLVM cache server is not starting at all, which is not a fault in the project. Restart the computer, then build again. Clearing the cache does not help. A very high thread count can also make LLVM crash, so use a moderate value for **LLVM Compiler: Maximum number of threads** while diagnosing the problem. + +### Unrecognized relocation type0 + +In BETA 984 to 987, a project with extremely long procedures (tens of thousands of lines each) could log this error once for each affected procedure: + +``` +LLVM compilation error in 'Module.Procedure': unrecognized relocation type0 +``` + +The linker still reported success, and the build produced an EXE despite the errors. The cause was a limit of 65,536 relocations in the code generator, not a limit on the number of lines. BETA 988 raises the limit to 4,294,967,295. Other limits, such as those of the PE file format, are reached long before that. On an earlier build, splitting the procedure into smaller ones avoids the error. + +_Source threads: 1552840889276178492 · confidence: high_ +_Date range: 2026-09-25 to 2026-09-28_ +_Reviewer note: The 'BETA 984 to 987' range is inferred: the thread reports the error on 984 and the fix in 988; verify that builds 985-987 still had the limit. The advice to restart the computer for the cache error was a maintainer suggestion that resolved it for one user (medium confidence); it may deserve softer wording or a check against later threads._ + +--- + +## docs/LLVM/Getting-Started.md · after-remarks + +> [!NOTE] +> After the first successful LLVM build, the cache stays in memory, so a rebuild after a small change is much faster. The cache is not yet saved to disk when the IDE closes. Restarting the computer therefore loses it, and the next build is slow again. Saving the cache to disk is planned. + +_Source threads: 1552840889276178492 · confidence: high_ +_Date range: 2026-09-28_ +_Reviewer note: Intended to follow the paragraph about the compiled-code cache (the one ending with the 'Flush the LLVM compiler cache' sentence) under 'LLVM in twinBASIC'. It refines the existing 'Keep cache process alive after exiting the IDE' option description: that option keeps the cache process running after the IDE closes, but the cache is still lost on reboot. The 'planned' statement was made by the maintainer on 2026-09-28; remove it if a later build implements it._ + +--- + +## docs/LLVM/Getting-Started.md · after-remarks + +A finished build is not proof that LLVM compiled every procedure. When LLVM reports "a feature used in your code is not yet supported with the LLVM compiler", the build can still complete, the linker can succeed and a DLL or OCX can still be registered, but calling the affected procedure then crashes with an access violation. The message does not say which feature caused it, and it names the procedure where the feature had an effect, which is not necessarily the procedure that was called when the crash happened. Commenting out the calls to one function can seem to help without removing the cause. + +Treat such a message as a compiler bug and [report](../FAQ#bug-reporting) it. Until it is fixed, turn LLVM off for the named procedure with `[CompilerOptions("")]`, as described in [Per-procedure LLVM options](#per-procedure-llvm-options). + +> [!NOTE] +> In BETA 984 to 987, the [**Sanitize Booleans**](../tB/IDE/Project/Settings#sanitize-booleans) project option was one cause of this message, because the LLVM compiler did not support the option. Turning **Sanitize Booleans** off avoided the error. BETA 988 fixes it. + +_Source threads: 1553387151842877491 · confidence: high_ +_Date range: 2026-09-26 to 2026-09-28_ +_Reviewer note: Place at the end of the 'Language support' subsection under 'Current limitations'. The existing text there already asks readers to report the 'feature not yet supported' message; this extends it. Verify that the anchor #sanitize-booleans resolves on the Project Settings page (permalink /tB/IDE/Project/Settings). The BETA 984-988 range comes from the thread._ + +--- + +## docs/LLVM/Getting-Started.md · after-remarks + +> [!NOTE] +> Because errors are not passed up to the calling procedure, a failure inside LLVM-compiled code can appear as an unexpected runtime error on a line that looks correct, for example `&H8000FFFF` on a `VarPtr` call. To see the error, add an `On Error GoTo` handler to the procedure and display `Hex$(Err.Number)` and `Err.Description`. + +_Source threads: 1487268961061044338 · confidence: medium_ +_Date range: 2026-09-24_ +_Reviewer note: Place at the end of the Language support subsection. It may overlap the existing text that says an unhandled error crashes the program; confirm that a handler in the failing procedure does catch the error._ + +--- + +## docs/Reference/Default/VBA/Information/IsArray.md · after-remarks + +> [!NOTE] +> When *varname* is a **Variant** holding an object, **IsArray** evaluates the object's default member, and returns **True** if that member returns an array. **IsArrayInitialized** does the same. To test only whether a **Variant** itself contains an array, without evaluating a default member, read the variant's type directly and check for the `VT_ARRAY` flag. A **Variant** passed by reference also carries `VT_BYREF`, which means the array pointer must be dereferenced first. + +_Source threads: 1553387151842877491 · confidence: high_ +_Date range: 2026-09-27_ +_Reviewer note: Behaviour is from a community thread, not from the .twin source or a probe; verify against the compiler before publishing. The page has vba_attribution: true and this addition is twinBASIC-specific, so it may need a note that the attribution applies to the derived text only. Place after the paragraph that ends 'especially useful with Variants containing arrays', before the Example._ + +--- + +## docs/Features/Standard-Library/New-Functions.md · example + +`Int3Breakpoint` inserts a breakpoint instruction at that point in the generated code. A native debugger attached to the process, such as WinDbg, the MSVC debugger or OllyDbg, stops there. It works in both LLVM and non-LLVM builds. + +```tb +Public Sub Main() + Int3Breakpoint ' an attached native debugger stops here + ' ... +End Sub +``` + +_Source threads: 1487268961061044338 · confidence: high_ +_Date range: 2026-09-26_ +_Reviewer note: No dedicated Int3Breakpoint page exists; the only mention is a one-line bullet in New-Functions.md. Consider a Core reference page. The code sample is illustrative and has not been compiled._ + +--- + +## docs/IDE/AddIns/GlobalSearch.md · after-remarks + +> [!WARNING] +> In BETA 984 to BETA 992, typing in the Global Search window with **Exclude comments** ticked crashes the IDE with a native `ACCESS_VIOLATION` exception in `twinBASIC_win64.dll`. BETA 983 is not affected. The twinBASIC maintainers have confirmed it as a regression. Leave the option unticked until it is fixed. + +_Source threads: 1554666225114812446 · confidence: high_ +_Date range: 2026-09-30_ +_Reviewer note: The finding's package is tbIDE with no symbol; placed on the Global Search add-in page, which describes the Exclude comments option. Remove once a fixed BETA is released. Consider whether a WARNING is too strong (crash, not data loss) and downgrade to IMPORTANT if preferred._ + +--- + +## docs/Reference/Default/VBA/Information/Array.md · after-remarks + +> [!NOTE] +> An array created by **Array** or passed as a **ParamArray** cannot be recognised as temporary through the `FADF_AUTO` flag of its SAFEARRAY. The flag is not a neutral marker: it means the memory that `pvData` points to must not be freed, so setting it changes how the array is destroyed. + +_Source threads: 1517219111996887120 · confidence: medium_ +_Date range: 2026-06-18 to 2026-07-21_ +_Reviewer note: Low-level, niche advice from a feature-request thread; it is not clear the page's audience needs it. Consider dropping, or moving to the ParamArray page. Verify that twinBASIC does not already set the flag._ + +--- + +## docs/Reference/Default/VBA/Information/Array.md · example + +### Several **Array** sets in one variable + +**Array** returns a **Variant**, so nesting calls such as `Array(Array(1, 2), Array(3, 4))` does not give a typed two-dimensional array, and a single row of a two-dimensional array cannot be initialised with **Array**. To hold several separately initialised sets, wrap a dynamic array in a user-defined type and assign the result of **Array** to that member: + +```tb +Type LngArr + Ar() As Long +End Type + +Sub Demo() + Dim NewIndex() As LngArr + ReDim NewIndex(8) + NewIndex(5).Ar = Array(1, 5, 11) + Debug.Print NewIndex(5).Ar(1) ' 5 +End Sub +``` + +The result is an array of independent one-dimensional **Long** arrays, any of which can be chosen at run time. + +_Source threads: 1464634730321543168 · confidence: low_ +_Date range: 2026-02-01_ +_Reviewer note: A single user's suggestion, not confirmed in the thread. Compile and run the sample with tbrun before publishing, and verify that assigning a Variant array to a Long() member works in twinBASIC. The sample carries no check_build marker until it has been compiled._ + +--- + +## docs/Reference/Default/VBA/Math/Log.md · after-remarks + +> [!NOTE] +> twinBASIC has no built-in base-10 logarithm, and no inverse or hyperbolic trigonometric functions such as **Asin**, **Acos**, **Sinh**, **Cosh** or **Tanh**. The community package *Windows Development Library for twinBASIC* (WinDevLib) supplies them. To use it, tick it in **Project → References → Available Packages**. Each function has a **Double** version and a **Single** version whose name ends in `f` --- for example `Log10` and `Log10f`, `Pow` and `powf`, `Asin` and `Asinf`, `Sinh` and `Sinhf`. The package is maintained outside the twinBASIC project and was reported as not yet thoroughly tested. + +_Source threads: 1536079009673257010 · confidence: medium_ +_Date range: 2026-08-09 to 2026-08-10_ +_Reviewer note: Verify the exact function names and the Available Packages listing against the WinDevLib source (github.com/fafalone/WinDevLib). WinDevLib is not a documented package in the reference; existing pages (Features/64bit.md, Tutorials/Windows-API.md) only link to its GitHub page. Placed on Log because the page already shows a hand-written Log10; alternatively put on the Math Module index._ + +--- + +## docs/Reference/Default/VBA/Interaction/MsgBox.md · after-remarks + +> [!NOTE] +> **MsgBox** is always modal: the calling code stops until the user closes the dialog, and there is no built-in non-blocking form. The community class `cMsgBoxAsync` (github.com/fafalone/cMsgBoxAsync) shows a message box without blocking, and raises an event with the user's response when the box is closed. Each box can be given an ID, so the event handler can tell which box was answered. + +_Source threads: 1553387824261243030 · confidence: medium_ +_Date range: 2026-09-26 to 2026-09-28_ +_Reviewer note: Third-party class; check that the repository still exists and that the ID argument and event name are as described before publishing. The existing page already says the function waits for the user; the note adds the workaround only._ + +--- + +## docs/Reference/Default/VBA/Information/VarPtr.md · after-remarks + +> [!NOTE] +> **VarPtr** accepts an array variable and returns the address of the variable itself: a pointer to the variable that holds the **SAFEARRAY** pointer, not the **SAFEARRAY** structure. VB6 code often declares `ArrPtr` as an alias of `VarPtr` for this purpose; in twinBASIC neither declaration is needed, and the intrinsic **ArrPtr** does the same job. Dereference the result once, for example with [**GetMemPtr**](../HiddenModule/GetMemPtr), to obtain the **SAFEARRAY** pointer. +> +> When the array is held in a **Variant**, use [**vbaRefVarAry**](../HiddenModule/vbaRefVarAry) instead. It works for any element type, including **String** arrays, because it performs no ANSI/Unicode conversion. It requires the array to be boxed in a **Variant**, at a small cost in speed. + +_Source threads: 1464634730321543168 · confidence: medium_ +_Date range: 2026-01-25 to 2026-01-27_ +_Reviewer note: ArrPtr is not in the page index and has no reference page; confirm from the typelib that it is an intrinsic with the signature LongPtr ArrPtr([in,out] SAFEARRAY(void)* Array), and consider adding a page for it. The WinRT package's SafeArray helper mentioned in the thread is not documented here and was left out._ + +--- + +## docs/Reference/Default/VBA/Information/VarPtr.md · example + +### Swapping two arrays + +There is no intrinsic that swaps two arrays. Exchanging the **SAFEARRAY** pointers that the two array variables hold does it in constant time, whatever the array sizes. The pointers are passed as `ByVal VarPtr(...)` into `ByRef LongPtr` parameters, so the callee reads and writes the array variables' own pointer slots: + +```tb +Private Sub SwapPtr(ByRef a As LongPtr, ByRef b As LongPtr) + Dim t As LongPtr = a + a = b + b = t +End Sub + +Sub Swap(Of T)(ByRef a() As T, ByRef b() As T) + SwapPtr ByVal VarPtr(a), ByVal VarPtr(b) +End Sub +``` + +Only the pointers are exchanged, not the **SAFEARRAY** structures, which can differ in size. + +_Source threads: 1464634730321543168 · confidence: medium_ +_Date range: 2026-01-27 to 2026-08-31_ +_Reviewer note: Reported by one user as production code; the generic-Sub syntax and the ByVal-pointer-to-ByRef-LongPtr behaviour should be checked with tbrun. Candidate alternative home: docs/Features/Language/Pointers.md. The thread notes VB6 rejects this with a type mismatch; that comparison was left out._ + +--- + +## docs/Reference/Core/Not.md · after-remarks + +> [!NOTE] +> The expression `Not Not` *array* is a known trick that yields the address of an array's **SAFEARRAY** structure, for example `Dim pSA As LongPtr = Not Not AnyArray`. It works in twinBASIC, but it is not fully general: for a **Variant** that holds an array by reference, the value differs from the true **SAFEARRAY** pointer. Prefer [**VarPtr**](../Default/VBA/Information/VarPtr) or [**vbaRefVarAry**](../Default/VBA/HiddenModule/vbaRefVarAry), which return the correct address in every case. + +_Source threads: 1464634730321543168 · confidence: low_ +_Date range: 2026-01-26_ +_Reviewer note: Behaviour reported in a discussion, not verified. Probe with tbrun, including the by-reference Variant case. Relative link paths assume the Not page's permalink /tB/Core/Not; check them with the build's link checker._ + +--- + +## docs/Reference/Built-In/WebView2/WebView2/index.md · after-remarks + +> [!IMPORTANT] +> The **WebView2** control is not compatible with the project setting [**Break On All Errors**](../../../IDE/Project/Settings#break-on-all-errors). With that setting on, a form that contains a **WebView2** control can fail to open in the form designer: loading stalls part of the way through and an error is reported in `webview2.twin`. The project itself still builds and runs. Set **Break On All Errors** to **No** in Project Settings to open the form again. + +_Source threads: 1491515655646609439 · confidence: high_ +_Date range: 2026-04-08 to 2026-06-16_ +_Reviewer note: Source is a maintainer statement in Discord ('not compatible at present'), so the incompatibility may be fixed in a later BETA; consider adding the build number. Verify the relative link to Project Settings resolves._ + +--- + +## docs/Reference/Default/VBA/Information/RGB.md · after-remarks + +> [!NOTE] +> A project whose name is `RGB` (set in Project Settings) makes calls to **RGB** fail. The identifier resolves to the project, not to this function, and the compiler reports only a generic *Compilation (Codegen) error at statement #N*. VB6 has the same conflict but gives a more descriptive message. Rename the project to fix it, and avoid project names that match built-in function names. + +_Source threads: 1516492141847777430 · confidence: medium_ +_Date range: 2026-06-16_ +_Reviewer note: Based on a single user report plus a maintainer-style explanation; the cause (project name shadowing the function) should be confirmed with a probe compile via scripts/tbbuild.mjs before publishing. The unrelated point about duplicate Form1 names was left out._ + +--- + +## docs/Reference/Default/VBA/Information/StrPtr.md · example + +### Example: converting a native string pointer + +A native API often returns a pointer to a null-terminated `char*` string rather than a **String**. To convert it, call **MultiByteToWideChar** twice. The first call passes a null output buffer and a length of zero, and returns the number of wide characters required. The second call fills a **String** that was pre-sized with `Space$`, whose buffer address comes from **StrPtr**. + +> [!IMPORTANT] +> The code page decides how the bytes are read. Use `CP_ACP` for text in the system ANSI code page and `CP_UTF8` for UTF-8. The SQLite API, for example, takes and returns UTF-8, so converting its pointers with an ANSI routine gives wrong results for any character outside 7-bit ASCII. Generated code often defaults to ANSI; check which code page it uses. + +```tb +Private Const CP_ACP As Long = 0 +Private Const CP_UTF8 As Long = 65001 + +Private Declare PtrSafe Function MultiByteToWideChar Lib "kernel32" ( _ + ByVal CodePage As Long, ByVal dwFlags As Long, _ + ByVal lpMultiByteStr As LongPtr, ByVal cbMultiByte As Long, _ + ByVal lpWideCharStr As LongPtr, ByVal cchWideChar As Long) As Long + +' Converts a null-terminated native string to a String. +Public Function StringFromPtr(ByVal Ptr As LongPtr, ByVal CodePage As Long) As String + Dim cch As Long + ' A length of -1 makes the API read up to the terminating null. + ' The result includes that null, so it is one more than the text length. + cch = MultiByteToWideChar(CodePage, 0, Ptr, -1, 0, 0) + If cch > 1 Then + StringFromPtr = Space$(cch - 1) + MultiByteToWideChar CodePage, 0, Ptr, -1, StrPtr(StringFromPtr), cch + End If +End Function +``` + +Because the required length already counts the terminating null, the function allocates `cch - 1` characters up front instead of trimming the result afterwards. A null *Ptr* needs no separate check: the API returns zero, and the function returns an empty string. + +If the documentation of the API says that the caller owns the returned memory, release the pointer with **CoTaskMemFree** (or the routine the API names) after the conversion. + +_Source threads: 1520345444926885978 · confidence: medium_ +_Date range: 2026-06-27_ +_Reviewer note: The code sample has not been compiled. Confirm with check_build (the fence is left unmarked) that passing StrPtr of the function's own return variable is accepted, and that the second call may pass cch (buffer plus the BSTR's own terminator) rather than cch - 1. Merges the SQLite UTF-8 clarification (package VBA, no symbol) with the MultiByteToWideChar example; the SQLite advice could alternatively live on a general DLL-interop page._ + +--- + +## docs/Reference/Default/VBA/Collection/index.md · after-remarks + +> [!NOTE] +> Code written for **Scripting.Dictionary**, as much VBA code found online is, can use **Collection** instead. **Dictionary** exists only when the project references the Microsoft Scripting Runtime library, which adds a COM dependency. A keyed **Collection** needs no reference: [**Add**](Add) takes a key, [**Exists**](Exists) tests for one, and [**Keys**](Keys) and [**Items**](Items) return the keys and the values. + +_Source threads: 1520345444926885978 · confidence: medium_ +_Date range: 2026-06-27_ +_Reviewer note: Users in the thread report that the twinBASIC Collection is much faster than the VB6 Collection when iterating by index, that other uses perform about the same, and that it may use more memory for a separate index table. Those are unmeasured community claims, so they are left out of the draft. The thread also names the third-party VBA-FastDictionary project as compatible with twinBASIC and x64; not included because it is an external project. Verify that Dictionary needs the Scripting Runtime reference, and that Collection lacks features (such as replacing an item's value by key) that Dictionary has._ + +--- + +## docs/Reference/Default/VBA/Collection/index.md · after-remarks + +> [!IMPORTANT] +> The internal memory layout of **Collection** differs from the VB6 one. Code that reads the layout directly breaks when ported, for example a helper class that uses **CopyMemory** at a fixed offset from `ObjPtr(data)` to walk the item pointers and read the key strings. Replace such code with the members of **Collection**: [**Keys**](Keys), [**Items**](Items), [**Exists**](Exists) and **For Each**. + +_Source threads: 1533419432376795206 · confidence: medium_ +_Date range: 2026-08-02_ +_Reviewer note: Thread also notes the VarPtr/StrPtr distinction (VarPtr gives the address of the String variable, StrPtr the address of its character data). VarPtr.md and StrPtr.md already state this, so no separate addition. Check whether the twinBASIC Collection layout is intentionally unspecified before describing it as such._ + +--- + +## docs/Reference/Default/VBA/Collection/index.md · after-remarks + +> [!WARNING] +> The members of the twinBASIC **Collection** are not in the same order in the virtual function table as those of the VB6 **Collection**. In particular, **NewEnum** sits at a different position. Code that declares its own VB6-style `ICollection` interface (**Item**, **Add**, **Count**, **Remove**, **NewEnum**) and calls it on a twinBASIC **Collection** reaches a different method: a call to **NewEnum** returns `S_OK` without producing an enumerator, and can instead run [**Clear**](Clear) or [**Exists**](Exists), which empties the collection. Use the **Collection** class directly, or [**For Each...Next**](../../Core/For-Each-Next), and do not call it through a hand-written interface. + +_Source threads: 1384721515542479049 · confidence: high_ +_Date range: 2026-09-04_ +_Reviewer note: The maintainer said the NewEnum position needs correcting, so this may be fixed in a later build. The vtable order was given in the thread as Clear, Exists, KeyCountHint get/let, KeyCompareMode get/let, Items, Keys, NewEnum; the claim that the call runs Clear is inferred from that order. Verify against the current build._ + +--- + +## docs/Reference/Default/VBA/HiddenModule/GetMemPtr.md · after-remarks + +> [!WARNING] +> Reading from an address that is not valid memory raises an access violation and ends the program. Nothing in the runtime can test whether an arbitrary address holds a particular structure, such as a **SAFEARRAY**, so the address must come from a trusted source. To guard an unavoidable read from an unknown address, install a vectored exception handler that catches the fault. The third-party CopyMemorySafe project uses this technique so that a copy to or from an invalid address does not crash the program. + +_Source threads: 1522697347463905420 · confidence: medium_ +_Date range: 2026-07-03 to 2026-07-04_ +_Reviewer note: Finding was filed under package VBA with no symbol, which resolves only to the VBA Package index. GetMemPtr was chosen as the nearest page because it already says it does no validity check; the same warning applies to the whole GetMem/PutMem family (GetMem1..8, PutMem1..8), so a reviewer may prefer the HiddenModule index or a shared note. The draft names but does not link CopyMemorySafe (github.com/fafalone/CopyMemorySafe), an external project; decide whether the docs should mention it._ + +--- + +## docs/Reference/Default/VBA/Strings/StrConv.md · after-remarks + +> [!NOTE] +> **StrConv** returns a **Variant**, and twinBASIC has no **StrConv$** form that returns a **String**. When the result is passed to an overloaded procedure that accepts both **String** and **LongPtr**, the compiler can report that it could not disambiguate the call, because a **Variant** converts to either type. Wrap the result in **CStr** or `CType(Of String)` to remove the ambiguity: +> +> ```tb +> SHSimpleIDListFromPath CStr(StrConv(sPath, vbUnicode)) +> ``` +> +> In tests with a **Variant** that holds a string, `StrPtr` of the variable, of `CType(Of String)(v)` and of `CStr(v)` all gave the same pointer, so neither wrapper copies the string data in that case. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2025-11-20_ +_Reviewer note: The page's opening sentence says StrConv 'Returns a String', which conflicts with the thread's statement that it returns a Variant; verify the declared return type in the VBA package source. The code sample was not compiled. The note about no extra copy rests on a user's pointer test and may be dropped if it cannot be confirmed._ + +--- + +## docs/Reference/Default/VBA/HiddenModule/vbaObjSetAddref.md · after-remarks + +> [!NOTE] +> The **vba** functions of the hidden module can be called without a **Declare** statement. A **Declare** written for the equivalent `msvbvm60.dll` function, such as `__vbaObjSetAddref`, is redirected to the same function, so code ported from VB6 needs no change. The type of *DstObject* affects the cost of a call. With a strong interface type, each call makes an additional **QueryInterface**, **AddRef** and **Release**. A local **Declare** that types the destination as `As Any` passes the raw pointer and avoids these. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2026-09-02 to 2026-09-03_ +_Reviewer note: The thread also says a package's own vbaObjSetAddref cannot be overloaded with an As Any declaration, so an As Any variant must be a local definition; that point is unclear and omitted. The redirect sentence is supported by docs/Miscellaneous/FAQs.md. Verify the QueryInterface claim before publishing._ + +--- + +## docs/Reference/Default/VBA/Information/VarType.md · after-remarks + +> [!NOTE] +> A **Variant** filled by an external API can hold a subtype that has no **VbVarType** constant. For example, `PropVariantToVariant` converts only some `PROPVARIANT` types, such as `VT_LPWSTR` to a string, and does not turn unsigned types into signed ones. A `PROPVARIANT` of type `VT_UI4` converted this way still gives `VarType(v) = 19`, which is not a native Variant type. Code that receives such values needs a **Select Case** on the subtype to map unsigned integers to **Integer**, **Long** or **Currency**. + +_Source threads: 1125359845936205844 · confidence: medium_ +_Date range: 2026-08-15_ +_Reviewer note: This is behaviour of the Windows propsys API rather than of VarType itself; a reviewer may prefer a shorter note or a different home. The thread's remark that a Variant used as the output of WinRTPropertyValueToPropVariant must really be a PROPVARIANT, freed with PropVariantClear, is omitted._ + +--- + +## docs/Reference/Default/VB/Clipboard/index.md · after-remarks + +> [!NOTE] +> A user reported that **GetFormat** returns **False** for a registered (custom) clipboard format, where VB6 returns **True**. The format ID came from `RegisterClipboardFormat`, for example for the `DataObject` format, and the value can be negative when truncated to an **Integer**. The report was filed as a bug. + +_Source threads: 1125359845936205844 · confidence: low_ +_Date range: 2025-11-20_ +_Reviewer note: Unconfirmed: no maintainer confirmed the bug in the thread. Reproduce it before publishing, or hold the note back and record it in BUGS-TO-REPORT.md instead. Insert after the GetFormat example._ + +--- + +## docs/Reference/Default/VB/Clipboard/index.md · after-remarks + +> [!NOTE] +> [**GetFormat**](#getformat) already reports images: `Clipboard.GetFormat(vbCFBitmap)` returns **True** for a Snipping Tool capture, even though [**GetData**](#getdata) cannot yet read the picture. To show a clipboard image in a **PictureBox**, call the Win32 and OLE APIs directly. There are two approaches: +> +> - Obtain the clipboard contents as an `IDataObject` with `OleGetClipboard` (declared in the WinDevLib package), and load the image with `GdipLoadImageFromStream`. This accepts any image format that GDI+ supports. +> - Build a picture from the clipboard bitmap with `OleCreatePictureIndirect`. +> +> Snipping Tool captures are normally plain bitmaps. + +_Source threads: 1544843894917963876 · confidence: medium_ +_Date range: 2026-09-02_ +_Reviewer note: Insert in the 'Picture data' section, after the existing NOTE about GetData and SetData. Overlaps with the existing advice to use OpenClipboard/GetClipboardData; keep both or merge. The findings gave no code, so the API names and their declaring package (WinDevLib) should be verified; a check_build example was not attempted._ + +--- + +## docs/Reference/Default/VBA/ErrObject/LastHresult.md · after-remarks + +> [!NOTE] +> A COM interface method declared as a **Sub**, or as a **Function** without the `[PreserveSig]` attribute, is reported to raise no run-time error in some cases when its **HRESULT** is a failure code, and an output variable then stays **Nothing**. An example is `&H8007000E` (out of memory) from a shell call. To see the failure, turn on error deferral around the call and read **LastHresult** afterwards: +> +> ```tb +> On Error Resume Next +> Set item = factory.GetShellItem() +> Dim hr As Long +> hr = Err.LastHresult +> On Error GoTo 0 +> ``` +> +> An error handler has a cost. If the interface declaration can be changed, declare the method with `[PreserveSig]` as a **Function** that returns **HRESULT**, and test the returned value directly. With `[PreserveSig]`, release an object variable before passing it again as the output argument. + +_Source threads: 1384721515542479049 · confidence: medium_ +_Date range: 2026-09-04_ +_Reviewer note: Single user's claim, not confirmed by a maintainer, and it conflicts with the existing page text, which says negative HRESULT values raise a run-time error. Needs verification with a probe (tbrun) before publishing; the fenced sample inside the callout is not marked check_build._ + +--- + +## docs/Reference/Default/VBA/Interaction/CreateObject.md · after-remarks + +> [!NOTE] +> **CreateObject** can fail for a class that needs an old runtime that Windows has not enabled. For example, `CreateObject("CLRMetaData.CorMetaDataDispenser.2")` failed with error -2146232576 (`&H80131700`) on one Windows 11 machine, and a direct `CoCreateInstance` call for the same class failed in the same way, so the fault lies in the operating-system setup and not in **CreateObject**. The .NET Framework 2.0/3.5 runtime that the class depends on is installed but may not be enabled by default; the exact cause was not established, and the call worked on other Windows 10 and 11 machines. Ways around it are: +> +> - the `MetaDataGetDispenser` API; +> - `CoCreateInstance` with the class `CLSID_CorMetaDataDispenserRuntime` (`{1EC2DE53-75CC-11D2-9775-00A0C9B4D50C}`); +> - `CLRCreateInstance` (from `mscoree`) to obtain an `ICLRMetaHost`, then `GetRuntime("v4.0.30319")` and `ICLRRuntimeInfo.GetInterface`. That version string is the same for all .NET 4.x runtimes, but this route loads part of the .NET runtime into the process. + +_Source threads: 1384721515542479049 · confidence: medium_ +_Date range: 2026-03-23 to 2026-03-24_ +_Reviewer note: Machine-specific environment issue rather than twinBASIC behavior, with an unconfirmed cause. Consider whether it belongs in the reference at all; a Features or FAQ page may fit better._ + +--- + +## docs/Reference/Core/Erase.md · after-remarks + +> [!NOTE] +> **Erase** is not needed to avoid a memory leak. A local dynamic array is released automatically when its procedure ends, as in VB6. Use **Erase** on a dynamic array only to release its memory earlier than the end of its scope. + +_Source threads: 1543764426350665809 · confidence: high_ +_Date range: 2026-08-31_ +_Reviewer note: Insert after the paragraph that says Erase frees the memory used by dynamic arrays. Based on a user test (200,000 calls, each with a ~10 MB local array, no Erase, no out-of-memory); not a maintainer statement._ + +--- + +## docs/IDE/Status Bar.md · after-remarks + +> [!NOTE] +> The licence badge reads **tB licence: NOT READY** while the **tB Services** badge reads **UNAVAILABLE**. This is normal: the licence is activated only when a project is loaded and the compiler is running. + +After the compiler restarts, the licence takes about one second to validate. The licence indicator turns green when validation is complete. + +> [!IMPORTANT] +> On older builds, a build started before the licence indicator turns green could produce an EXE that shows the Community Edition splash screen. Wait for the green indicator before pressing **Build**. This is believed to be fixed in recent builds. + +_Source threads: 1543958405679415379 · confidence: high_ +_Date range: 2026-08-31 to 2026-09-02_ +_Reviewer note: Insert at the end of the Licence section. The fix is described by Wayne Phillips as 'believed' fixed in a recent build; the build number is not stated, so verify before stating a version. The existing page describes the badge as COMMUNITY EDITION; check the exact wording of the NOT READY label against the IDE._ + +--- + +## docs/IDE/Menu/Help.md · after-remarks + +The IDE stores the licence key in the registry, under `HKEY_CURRENT_USER\SOFTWARE\VB and VBA Program Settings\twinBASIC_IDE\IDESettings`. The key appears to be stored in Base64-encoded form. The licence check ties the key to the CPUID of the machine, which does not need to be unique. + +The key normally needs entering only once. It needs entering again if the registry entry is cleaned or the hardware changes. Some users have also seen the IDE revert to the Community Edition without either change (builds 980 and 983), and virtual machines that have been idle for a while have needed the key again. In these cases, entering the key from the confirmation e-mail restores the licence. The cause has not been identified. + +_Source threads: 1543958405679415379 · confidence: medium_ +_Date range: 2026-08-31 to 2026-09-02_ +_Reviewer note: Insert under the 'Enter Licence Key...' heading. The registry path is already listed in Miscellaneous/FAQs; the Base64 detail and the CPUID statement come from community discussion and should be confirmed with the maintainers. The unexplained reversion reports are user observations._ + +--- + +## docs/Reference/Default/VBA/Conversion/CByte.md · after-remarks + +> [!NOTE] +> One user reported that **CByte** used directly inside the argument list of a call, in a large package, gave an overflow error at run time and, separately, made the IDE unstable. Assigning the converted value to a **Byte** variable first, and passing the variable, avoided both problems. The report could not be reproduced in a small project. + +_Source threads: 1460777854714515728 · confidence: low_ +_Date range: 2026-01-15_ +_Reviewer note: Single, unconfirmed report; the workaround (temporary variable) is inferred from 'refactor to avoid CByte in the arguments' and is not stated in the thread. Probably better not published as-is: reproduce first, and if it is a real compiler fault, record it in BUGS-TO-REPORT.md instead of the reference page._ + +--- + +## docs/Reference/Built-In/tbIDE/AddIn.md · after-remarks + +> [!NOTE] +> An addin DLL loads only from an `addins` folder. Copy it into `addins\win64` for the 64-bit IDE or `addins\win32` for the 32-bit IDE, then restart the IDE. A DLL placed in any other folder is not loaded. See [Building and loading an addin](.#building-and-loading-an-addin). + +_Source threads: 1522162642700206146 · confidence: high_ +_Date range: 2026-07-02 to 2026-07-15_ +_Reviewer note: tbIDE/index.md already documents the addins folders and the %APPDATA%\twinBASIC\addins alternative, so this is largely redundant; keep only if a pointer from the AddIn page is wanted. Folder-name case in the index is Win32/Win64; the thread wrote lower case._ + +--- + +## docs/Reference/Built-In/tbIDE/ToolWindow.md · after-remarks + +> [!NOTE] +> A tool window can become inaccessible after the user clicks its left corner and then clicks elsewhere. This happens with the IDE's own built-in tool windows as well, so it is a fault in the IDE and not in the addin. + +> [!NOTE] +> One addin author reports releasing every COM reference the addin holds when it unloads, to avoid an `ACCESS_VIOLATION` when the compiler restarts. + +_Source threads: 1522162642700206146 · confidence: medium_ +_Date range: 2026-07-02 to 2026-07-13_ +_Reviewer note: Second note conflicts in tone with tbIDE/index.md, which says the old compiler process is force-killed and Class_Terminate does not run on restart; the teardown claim may be unverifiable or better placed on the AddIn page. The stuck-window claim is an IDE bug report that may be fixed in later builds and belongs in BUGS-TO-REPORT.md rather than the reference, so reviewer should decide whether to keep either._ + +--- + +## docs/Features/Packages/Library symbols.md · new-section + +## Name clashes between libraries + +When two referenced libraries declare types with the same name, an unqualified name binds to the library that is higher in the reference order. A common case is a replacement for **Scripting.FileSystemObject** added next to **Microsoft Scripting Runtime**: both declare **Folder**, **File** and **Drive**. After the `Scripting.` qualifiers are removed from existing code, the names still bind to the older library, and the compiler reports errors such as those on `_Folder`. + +To make the preferred library win: + +1. Open *Project Settings*, find **Library References**, and select the **Enabled Libraries** tab. +2. Hover over the preferred library's row. Up and down arrows appear at the right. +3. Move the preferred library above **Microsoft Scripting Runtime**. Also move it above **Windows Script Host Object Model** if the project references it, because that library contains file-system types too. + +Leave the older reference enabled if the project still uses other members from it, such as **Scripting.Dictionary**. Check enumeration members such as **TriState** as well: each library defines its own, so an unqualified name resolves in the same way. + +_Source threads: 1535367982866505778 · confidence: medium_ +_Date range: 2026-08-11 to 2026-08-12_ +_Reviewer note: Reorder arrows and their hover behaviour come from a single Discord answer (apparently from an assistant-generated reply quoted in the thread); verify in the IDE. Page is about library symbols, not reference order, so the new section may fit better elsewhere or the page intro may need a cross-reference. No Core-package page covers this; the finding was filed under Core._ + +--- From c970ea693072cd8e5d842a3b2bdbe958773af511 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 30 Sep 2026 19:26:24 +0200 Subject: [PATCH 2/6] scripts: compileProject, tbbuild as a library function --- lib/cli.mjs | 14 +++++- scripts/check_examples.mjs | 76 +++++++++++------------------- scripts/lib/tb-build.mjs | 94 +++++++++++++++++++++++++++++++++++++ scripts/lib/tb-cdp.mjs | 7 ++- scripts/lib/tb-ide.mjs | 31 ++++++++++-- scripts/lib/tb-registry.mjs | 12 +++-- scripts/tbbuild.mjs | 86 ++++++++++++--------------------- 7 files changed, 204 insertions(+), 116 deletions(-) create mode 100644 scripts/lib/tb-build.mjs diff --git a/lib/cli.mjs b/lib/cli.mjs index 2d62e1eb..823637f9 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -242,7 +242,17 @@ export function printHelpAndExit(text, { stream = "stdout", exitCode = 0, exit = * a rejected top-level await. It installs when called, never on import, so a * module that can be imported as well as run calls it only when it is the entry * point. + * + * `cleanup(err)`, if given, runs first, for a tool that holds something the exit + * would lose. It may throw; the exit is 2 regardless. */ -export function exitOnCrash() { - process.on("uncaughtException", (err) => { console.error(err); process.exit(2); }); +export function exitOnCrash(cleanup) { + process.on("uncaughtException", (err) => { + console.error(err); + // A tool that holds something a crash would lose -- a report a long run has + // built up, a registry it has to put back -- says how to save it. The + // cleanup is not allowed to end the crash handling: the exit is 2 either way. + try { cleanup?.(err); } catch (e) { console.error(e); } + process.exit(2); + }); } diff --git a/scripts/check_examples.mjs b/scripts/check_examples.mjs index c9feba10..ac5ef4f9 100644 --- a/scripts/check_examples.mjs +++ b/scripts/check_examples.mjs @@ -67,7 +67,6 @@ // and the rest without it, a crash that names none bisects, O(log n) builds, and // one that needs several samples at once is reported with all of them. -import { spawn } from "node:child_process"; import { cpSync, existsSync, mkdirSync, promises as fs, readdirSync, readFileSync, rmSync, writeFileSync, @@ -81,6 +80,8 @@ import { BODY_SLOTS, CONCAT_KEY, HIDDEN_MARKER, MARKER, RUN_MARKER, SLOTS, classify, collectFences, concatFences, moduleName, parseInfo, partOf, resourcePath, wrapFence, } from "./lib/tb-fences.mjs"; +import { compileProject } from "./lib/tb-build.mjs"; +import { wantShow } from "./lib/tb-ide.mjs"; import { buildNumber, compilerExe, findIde, runCompiler } from "./lib/tb-install.mjs"; import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs"; @@ -627,19 +628,17 @@ const COMPILER = IDE ? compilerExe(IDE) : null; let tidy = null; /** - * The samples of a staged batch that tbbuild's crash report says the compiler - * died parsing, as fence ids. + * The samples of a staged batch that the compiler died parsing, as fence ids. * - * tbbuild names them on its `last parsing:` line by the file's base name, which - * for a sample is its generated module's: `last parsing: tbx_df66b6fa33.twin` - * for a batch of nine holding the crash fixture. A file that is no sample of - * the batch -- the template's own source -- names nothing, and neither does a - * report without the line. + * `files` is compileProject's `crashFiles`, the base names of the files the + * compiler was parsing when it went down. For a sample that is its generated + * module's: `tbx_df66b6fa33.twin` for a batch of nine holding the crash + * fixture. A file that is no sample of the batch -- the template's own + * source -- names nothing, and neither does a crash that named no file. */ -function crashedIn(report, map) { +function crashedIn(files, map) { const ids = new Set(); - const line = /^last parsing: (.+)$/m.exec(report)?.[1] ?? ""; - for (const file of line.split(",")) { + for (const file of files) { const entry = map.get(file.trim().split(/[\\/]/).pop()); if (entry) ids.add(entry.fence.id); } @@ -648,31 +647,15 @@ function crashedIn(report, map) { /** Build one staged batch; returns per-fence errors, or a crash marker. */ async function buildStaged(staged, port) { - const args = [path.join(REPO_ROOT, "scripts", "tbbuild.mjs"), staged.proj, - "--port", String(port), "--json"]; - if (IDE) args.push("--ide", IDE); - if (values.show) args.push("--show"); - if (values.hide) args.push("--hide"); - - const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] }); - let out = "", err = ""; - child.stdout.on("data", (d) => { out += d; }); - child.stderr.on("data", (d) => { err += d; }); - // A child that cannot start emits "error" and never "exit". "close" comes - // only once its output has been read to the end, which "exit" does not wait - // for. - const code = await new Promise((resolve, reject) => { - child.on("error", reject); - child.on("close", resolve); + const r = await compileProject({ + project: staged.proj, ide: IDE, port, show: wantShow({ show: values.show, hide: values.hide }), }); - if (code === 4) return { crashed: true, detail: err.trim(), named: crashedIn(err, staged.map) }; - if (code !== 0 && code !== 1) { - throw new Error(`tbbuild exited ${code} on ${staged.proj}\n${err.trim() || out.trim()}`); + if (r.code === 4) return { crashed: true, detail: r.message, named: crashedIn(r.crashFiles, staged.map) }; + if (r.code !== 0 && r.code !== 1) { + throw new Error(`tbbuild exited ${r.code} on ${staged.proj}\n${r.message}`); } - let result; - try { result = JSON.parse(out); } - catch { throw new Error(`tbbuild produced no JSON on ${staged.proj}\n${err.trim()}`); } + const result = { diagnostics: r.rows }; const perFence = new Map(); // A row in a shape this does not parse. Nothing in it names a file, so no @@ -688,8 +671,8 @@ async function buildStaged(staged, port) { const m = /^\{(\w+)\}\s+(\S+)\s+\[(\d+),(\d+)\]:\s*(.*)$/.exec(row); if (!m) { unreadable.push(row); continue; } const [, severity, file, lineRaw, , message] = m; + const base = file.split(/[\\/]/).pop(); if (severity !== "ERROR" && !VERBOSE) continue; - const base = file.split("/").pop(); const entry = staged.map.get(base); if (!entry) { if (severity === "ERROR") unattributed.push(row); @@ -1448,20 +1431,17 @@ async function runProbes() { if (takeOut(mixed, new Set(["mh"])) !== null) { failures.push("take out: a page's hidden context was taken out as a unit"); } - // What is taken out is read off tbbuild's crash report, which names a sample - // by its generated module's file. This is a report as tbbuild printed it. + // What is taken out is read off the files the compiler died parsing, which name + // a sample by its generated module's file: the `crashFiles` of a build. const staged9 = new Map([["tbx_df66b6fa33.twin", { fence: { id: "P.md#5" } }]]); - const report = "the compiler crashed 2x -- this project takes it down\n" + - "last parsing: tbx_df66b6fa33.twin\n" + - "(read the IDE's DEBUG CONSOLE with --keep for the exception detail)\n"; - if ([...crashedIn(report, staged9)].join() !== "P.md#5") { - failures.push("crash report: the sample tbbuild named was not read off it"); + if ([...crashedIn(["tbx_df66b6fa33.twin"], staged9)].join() !== "P.md#5") { + failures.push("crash files: the sample the compiler died parsing was not read off them"); } - if (crashedIn(report.replace("tbx_df66b6fa33", "tbxMain"), staged9).size) { - failures.push("crash report: the template's own file named a sample"); + if (crashedIn(["tbxMain.twin"], staged9).size) { + failures.push("crash files: the template's own file named a sample"); } - if (crashedIn("the compiler crashed 1x -- this project takes it down\n", staged9).size) { - failures.push("crash report: a report naming no file named a sample"); + if (crashedIn([], staged9).size) { + failures.push("crash files: a crash naming no file named a sample"); } // Isolation itself, run by runBatch against a fake lane. `crash` gets the ids // of a build's samples and says whether the compiler goes down, and which of @@ -1726,9 +1706,9 @@ async function main() { // Every lane's IDE records its projects in the user's recent list and saved // project state (lib/tb-registry.mjs). This process owns the tidying for all - // of them: the tbbuild children see TB_REGISTRY_OWNER and leave the registry - // alone, because each restoring its own snapshot would put back whatever the - // registry held when that lane happened to start. Everything is under `work`, + // of them: compileProject never tidies, because each build restoring its own + // snapshot would put back whatever the registry held when that lane happened + // to start. Everything is under `work`, // so one sweep by that folder at the end takes the lot -- and the sweep here // at the start takes whatever a run on this --port left when it died. tidy = startTidy({ prefixes: [work] }); diff --git a/scripts/lib/tb-build.mjs b/scripts/lib/tb-build.mjs new file mode 100644 index 00000000..5c57265d --- /dev/null +++ b/scripts/lib/tb-build.mjs @@ -0,0 +1,94 @@ +// Compile a packed .twinproj in the twinBASIC IDE and return what it said. +// +// This is the whole of `scripts/tbbuild.mjs` minus its command line, so a tool +// that builds many projects (check_examples, sweep_attributes) calls it directly +// and gets a result it does not have to parse: the diagnostics as an array, the +// files the compiler died parsing as an array, and the exit code tbbuild would +// have had. It used to start `tbbuild` as a subprocess and read its JSON and its +// stderr back, and every one of those readers carried its own copy of the parse. +// +// What it does not do is what a caller owns: +// +// * It never exits the process, and it throws only for a fault of its own +// (an unexpected exception); a build that fails comes back as a result. +// * It never tidies the registry. The IDE writes its recent list and project +// state as it runs, and `startTidy` / `finishTidy` (lib/tb-registry.mjs) +// put them back once EVERY IDE of a run has gone; a call that tidied would +// do it in the middle of the other lanes' builds. +// * It does not check that the project or the IDE exists. The command line +// refuses those with a usage error; a caller with a bad path gets code 3 +// after the timeout, exactly as tbbuild used to. +// +// It always ends its IDE before it returns, unless `keep` is set. Node holds the +// IDE's launcher in a job of its own (launchIde), so an IDE also goes when the +// process does -- a run that is stopped leaves none of its lanes' IDEs behind. +// +// Concurrency: calls on different ports are independent and can run at once. It +// ends its IDE with shutdownIdeAsync, because shutdownIde's blocking wait +// (up to five seconds) would freeze every other call's CDP timers; whatever is +// thrown inside the IDE or CDP code from an event callback is the process's, not +// one call's, so a tool that runs many calls installs a handler through +// exitOnCrash's cleanup (see sweep_attributes.mjs's `salvage`). + +import { COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, launchIde, setBuildTarget, shutdownIdeAsync, + waitForCompile } from "./tb-ide.mjs"; + +/** + * @param {object} o + * @param {string} o.project the packed .twinproj + * @param {string} o.ide twinBASIC.exe + * @param {number} [o.port] DevTools port to start the IDE on (default 9333) + * @param {string} [o.arch] win32 or win64 (default win32): the target to compile for + * @param {number} [o.timeout] ms to wait for the compile (default COMPILE_TIMEOUT) + * @param {boolean} [o.show] on the user's desktop instead of a private one + * @param {boolean} [o.keep] leave the IDE running, and report its pid + * @returns {Promise<{ + * code: number, message: string, rows: string[], counts: number[], dialogs: string[], + * openedIn: string | null, arch: string, idePid: number | null, kept: boolean, crashFiles: string[], + * }>} `code` is tbbuild's exit code: 0 clean, 1 the project has errors, 2 the IDE + * could not be started or attached, 3 the compile never settled, 4 the project + * crashes the compiler. `message` is what tbbuild prints on stderr for 2, 3 + * and 4 and is empty otherwise. `counts` is errors, warnings, hints, infos. + * `crashFiles` names, for 4, the files the compiler died parsing. + */ +export async function compileProject({ project, ide, port = 9333, arch = TARGETS[0], timeout = COMPILE_TIMEOUT, + show = false, keep = false }) { + let handle = null; + let c = null; + const result = (code, fields = {}) => ({ + code, message: "", rows: [], counts: [0, 0, 0, 0], dialogs: c ? c.dialogs.map((d) => d.message) : [], + openedIn: null, arch, idePid: handle?.pid ?? null, kept: keep, crashFiles: [], ...fields, + }); + try { + try { + handle = await launchIde({ exe: ide, project, port, show, keep }); + } catch (e) { + return result(2, { message: e.message }); + } + c = await attachIde(port); + if (!c) return result(2, { message: "the IDE never exposed a debug port" }); + + let outcome = compileOutcome(await waitForCompile(c, { project, timeout }), { name: project }); + if (!outcome.ok) return result(outcome.code, { message: outcome.message, crashFiles: outcome.crashFiles ?? [] }); + + // Set on every run, win32 included, and what is reported is the compile under + // it: see setBuildTarget. Switching restarts the compiler, which compiles the + // project again, so a run that switches takes a few seconds longer. + let openedIn; + try { + const target = await setBuildTarget(c, arch, { project, timeout }); + openedIn = target.from; + if (target.waited) { + outcome = compileOutcome(target.waited, { name: project }); + if (!outcome.ok) return result(outcome.code, { message: outcome.message, crashFiles: outcome.crashFiles ?? [] }); + } + } catch (e) { + return result(2, { message: e.message }); + } + return result(outcome.counts[0] > 0 ? 1 : 0, { rows: outcome.rows, counts: outcome.counts, openedIn }); + } finally { + // A close that threw must not skip ending the IDE, or mask what was thrown. + try { c?.close(); } catch { /* the IDE is about to be ended */ } + if (!keep) await shutdownIdeAsync(handle); + } +} diff --git a/scripts/lib/tb-cdp.mjs b/scripts/lib/tb-cdp.mjs index eed95aad..bf08a581 100644 --- a/scripts/lib/tb-cdp.mjs +++ b/scripts/lib/tb-cdp.mjs @@ -42,7 +42,12 @@ export async function attach(port, match = "main.htm", { timeout = 30 * 1000 } = const pending = new Map(); const listeners = []; ws.onmessage = (ev) => { - const m = JSON.parse(ev.data); + // A frame that is not JSON is dropped. It is an event callback, so a throw + // here is an uncaught exception, which ends the whole process -- once a tool + // builds in-process, every lane's build and not one child's. A request it + // was the answer to times out through its own timer. + let m; + try { m = JSON.parse(ev.data); } catch { return; } if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); diff --git a/scripts/lib/tb-ide.mjs b/scripts/lib/tb-ide.mjs index 6af07e65..6394bb53 100644 --- a/scripts/lib/tb-ide.mjs +++ b/scripts/lib/tb-ide.mjs @@ -7,7 +7,7 @@ // comments that explain it moved with it. See WIP.Harness.md, "Compiling a // twinBASIC project without the IDE in front of you", for the history. -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import net from "node:net"; import path from "node:path"; @@ -214,6 +214,27 @@ export function shutdownIde(ide) { waitForExit(ide.pid, 5000); } +/** + * shutdownIde without blocking the event loop, for a caller that ends an IDE + * while other work is going on in the same process. shutdownIde's `taskkill` and + * its wait each hold the process still, up to five seconds for the wait, and + * with four builds at once that is four lanes' CDP timers running out while + * their answers sit unread in a socket. The sync version stays for the paths + * that end in process.exit(), where nothing else is waiting. + */ +export async function shutdownIdeAsync(ide) { + if (!ide) return; + await new Promise((resolve) => { + execFile("taskkill", ["/PID", String(ide.pid), "/T", "/F"], { windowsHide: true }, () => resolve()); + }); + try { ide.launcher?.kill(); } catch { /* already gone */ } + const until = Date.now() + 5000; + while (Date.now() < until) { + try { process.kill(ide.pid, 0); } catch { return; } + await sleep(100); + } +} + /** * Block until a process has exited, or the time runs out. * @@ -473,9 +494,12 @@ export async function waitForCompile(c, { project, timeout }) { * @param {object} waited waitForCompile's result * @param {object} o * @param {string} o.name the project as the caller should name it in a message - * @returns {{ok: true, rows: string[], counts: number[]} | {ok: false, code: number, message: string}} + * @returns {{ok: true, rows: string[], counts: number[]} + * | {ok: false, code: number, message: string, crashFiles?: string[]}} * `counts` is errors, warnings, hints, infos. `code` is 4 for a compiler - * crash and 3 for a compile that never settled. + * crash and 3 for a compile that never settled. For a crash `crashFiles` names, + * as the array `message` prints on its `last parsing:` line, the files the + * compiler died parsing. */ export function compileOutcome({ loaded, crash, drops, last, blocked }, { name }) { // A crash is reported by the file the compiler died parsing, because in a batch @@ -487,6 +511,7 @@ export function compileOutcome({ loaded, crash, drops, last, blocked }, { name } message: `the compiler crashed ${crash.n}x -- this project takes it down` + (crash.files?.length ? `\nlast parsing: ${crash.files.join(", ")}` : "") + "\n(read the IDE's DEBUG CONSOLE with --keep for the exception detail)", + crashFiles: crash.files ?? [], }; } if (drops >= 2) { diff --git a/scripts/lib/tb-registry.mjs b/scripts/lib/tb-registry.mjs index ef3159bc..6e5e06da 100644 --- a/scripts/lib/tb-registry.mjs +++ b/scripts/lib/tb-registry.mjs @@ -33,11 +33,13 @@ // as it was, since a run can switch the target of the project it opens // (restoreArchitectureMemory). // -// ONE PROCESS OWNS THIS PER RUN. check_examples starts many tbbuild processes -// at once; each snapshotting and restoring on its own would put back whichever -// state it happened to see, in whichever order the lanes finished. The first -// process to call startTidy sets TB_REGISTRY_OWNER, the children inherit it and -// leave the registry alone, and the owner sweeps once at the end. +// ONE PROCESS OWNS THIS PER RUN. check_examples and sweep_attributes build many +// projects at once, in one process (lib/tb-build.mjs, which never tidies); each +// snapshotting and restoring on its own would put back whichever state it +// happened to see, in whichever order the lanes finished. The first process to +// call startTidy sets TB_REGISTRY_OWNER, which a child process inherits, so a +// tbbuild started from it would leave the registry alone too; the owner sweeps +// once at the end. // // The work is done by .NET's registry API through PowerShell, and not by // reg.exe. reg.exe prints value names in the console code page when its output diff --git a/scripts/tbbuild.mjs b/scripts/tbbuild.mjs index c40dcf24..886d8176 100644 --- a/scripts/tbbuild.mjs +++ b/scripts/tbbuild.mjs @@ -38,9 +38,12 @@ // The IDE's user interface, though, is a WebView2 page, and WebView2 honours // WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. So the IDE can be started with a // Chrome DevTools port, driven over CDP, and its DIAGNOSTICS pane read out. -// That is all this is. The mechanics -- starting the IDE, attaching, waiting -// for the compile, reading the diagnostics -- live in scripts/lib/tb-ide.mjs, -// which scripts/tbrun.mjs shares; this file is the command line around them. +// That is all this is. The build itself -- start the IDE, attach, wait for the +// compile, read the diagnostics, end the IDE -- is compileProject in +// scripts/lib/tb-build.mjs, over the mechanics in scripts/lib/tb-ide.mjs that +// scripts/tbrun.mjs also uses; this file is the command line around it, and +// what a command line owns: the checks on its input, the registry tidy, and the +// printing. // // The diagnostics come from the IDE's own "copy compilation error report" // walk, minus the clipboard write, so the text is exactly what that command @@ -54,8 +57,8 @@ import { existsSync, statSync } from "node:fs"; import path from "node:path"; import { choiceOption, exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; import { findIde } from "./lib/tb-install.mjs"; -import { COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, launchIde, setBuildTarget, shutdownIde, - summaryLine, waitForCompile, wantShow } from "./lib/tb-ide.mjs"; +import { compileProject } from "./lib/tb-build.mjs"; +import { COMPILE_TIMEOUT, TARGETS, summaryLine, wantShow } from "./lib/tb-ide.mjs"; import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; exitOnCrash(); @@ -164,63 +167,34 @@ if (!IDE || !existsSync(IDE)) { process.exit(2); } -let ide; let tidy = null; -// Tidies whether or not an IDE was started, as tbrun does: `ide` is unset when -// the launch failed, and shutdownIde then has nothing to end. A failed launch -// has written nothing to the registry so far, since tb-launch.ps1 never lets -// the IDE run on a path that prints no pid, but the tidy does not rely on that. -function shutdown() { - if (keep) return; - shutdownIde(ide); - finishTidy(tidy); -} - -function die(code, msg) { - if (msg) console.error(msg); - shutdown(); - process.exit(code); -} // The IDE puts the project at the top of the user's recent list and saves // state for it -- see lib/tb-registry.mjs, and WIP.Harness.md for the numbers. // Both go back as they were once the IDE has exited: an entry the run created // is deleted, and the user's own project, if this was one, gets its old state // back. Not under --keep, because a kept IDE is still writing; and not when -// check_examples started this process, because it tidies once for every lane. +// another process owns the tidy (startTidy then returns null), as a tool that +// runs many builds does for every lane. if (!keep) tidy = startTidy({ paths: [path.resolve(proj)] }); +// compileProject ends its IDE before it returns, unless --keep, so the tidy comes +// after the IDE has gone. Tidies whether or not an IDE was started, as tbrun +// does: a failed launch has written nothing to the registry so far, since +// tb-launch.ps1 never lets the IDE run on a path that prints no pid, but the +// tidy does not rely on that. +let r; try { - ide = await launchIde({ exe: IDE, project: proj, port, show, keep }); -} catch (e) { - die(2, e.message); + r = await compileProject({ project: proj, ide: IDE, port, arch, timeout, show, keep }); +} finally { + finishTidy(tidy); } -const c = await attachIde(port); -if (!c) die(2, "the IDE never exposed a debug port"); - -// Every alert the IDE opens is recorded and dismissed by the connection -// (attachIde), and reported with the diagnostics. -const dialogs = c.dialogs; - -let outcome = compileOutcome(await waitForCompile(c, { project: proj, timeout }), { name: proj }); -if (!outcome.ok) die(outcome.code, outcome.message); - -// Set on every run, win32 included, and what is reported is the compile under -// it: see setBuildTarget. Switching restarts the compiler, which compiles the -// project again, so a run that switches takes a few seconds longer. -let openedIn; -try { - const target = await setBuildTarget(c, arch, { project: proj, timeout }); - openedIn = target.from; - if (target.waited) { - outcome = compileOutcome(target.waited, { name: proj }); - if (!outcome.ok) die(outcome.code, outcome.message); - } -} catch (e) { - die(2, e.message); +if (r.code >= 2) { + if (r.message) console.error(r.message); + process.exit(r.code); } -const { rows, counts } = outcome; +const { rows, counts, dialogs, openedIn } = r; // The IDE's pid is reported so a caller can clean up precisely. It matters most // under --keep, where this process leaves the IDE running and something else has @@ -230,8 +204,8 @@ if (asJson) { console.log(JSON.stringify({ project: proj, arch, openedIn, errors: counts[0], warnings: counts[1], hints: counts[2], infos: counts[3], - idePid: ide?.pid ?? null, kept: keep, - diagnostics: rows, dialogs: dialogs.map((d) => d.message), + idePid: r.idePid, kept: keep, + diagnostics: rows, dialogs, }, null, 2)); } else { // Said only when either target is not the default, so the usual report is @@ -241,13 +215,11 @@ if (asJson) { console.log(`target: ${arch}` + (openedIn !== TARGETS[0] ? ` (the IDE remembered ${openedIn} for this project)` : "")); } - for (const r of rows) console.log(r); + for (const row of rows) console.log(row); console.log(summaryLine(counts)); - if (dialogs.length) console.log("dialogs:", JSON.stringify(dialogs.map((d) => d.message))); + if (dialogs.length) console.log("dialogs:", JSON.stringify(dialogs)); // Only under --keep, where the pid is still alive and therefore actionable. - if (keep && ide?.pid) console.log(`ide-pid: ${ide.pid}`); + if (keep && r.idePid) console.log(`ide-pid: ${r.idePid}`); } -c.close(); -shutdown(); -process.exit(counts[0] > 0 ? 1 : 0); +process.exit(r.code); From 2d29fb57b6429afc328ce3c9259c43fdbce726f4 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 30 Sep 2026 19:26:32 +0200 Subject: [PATCH 3/6] scripts: sweep_attributes asks the compiler where every attribute is legal --- .github/actions/run-gates/action.yml | 9 + WIP.Harness.md | 115 +++- WIP.md | 7 +- docs/Documentation/Building.md | 1 + docs/Documentation/Tools.md | 79 ++- scripts/check_attribute_sweep.mjs | 598 +++++++++++++++++++++ scripts/check_cli.mjs | 42 ++ scripts/gen_attribute_probes.mjs | 191 +------ scripts/lib/attribute-probe-kit.mjs | 223 ++++++++ scripts/lib/attribute-sites.mjs | 309 +++++++++++ scripts/lib/attribute-sweep.mjs | 587 +++++++++++++++++++++ scripts/sweep_attributes.mjs | 756 +++++++++++++++++++++++++++ test.bat | 8 + 13 files changed, 2721 insertions(+), 204 deletions(-) create mode 100644 scripts/check_attribute_sweep.mjs create mode 100644 scripts/lib/attribute-probe-kit.mjs create mode 100644 scripts/lib/attribute-sites.mjs create mode 100644 scripts/lib/attribute-sweep.mjs create mode 100644 scripts/sweep_attributes.mjs diff --git a/.github/actions/run-gates/action.yml b/.github/actions/run-gates/action.yml index f09bc791..9ba154d3 100644 --- a/.github/actions/run-gates/action.yml +++ b/.github/actions/run-gates/action.yml @@ -151,6 +151,15 @@ runs: - name: Verify the twinBASIC source and attribute scanners (check_twin_parsers.mjs) shell: bash run: node scripts/check_twin_parsers.mjs + # The attribute sweep asks the compiler where every attribute is legal, and + # none of its failures announces itself: a wrong site skeleton reads as + # "every attribute is refused here", an ignored control as a recognised + # attribute, a refusal taken for an acceptance as a finding about the + # compiler. The IDE is what cannot run here, so the parts that decide what + # an answer means are probed on fixed inputs: no IDE, no tree, no install. + - name: Verify the attribute sweep's logic (check_attribute_sweep.mjs) + shell: bash + run: node scripts/check_attribute_sweep.mjs # Nothing else tests how a tool reads its command line, which is how a # value flag given no value came to be read as NaN or as the next flag. # lib/cli.mjs's probes, then each tool's recorded command-line errors, each diff --git a/WIP.Harness.md b/WIP.Harness.md index 29568a48..ebc9cef5 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -143,6 +143,75 @@ confusion publishes a wrong number with nothing to notice it by. The bar is that report's unresolved count is **0**, which it currently is; a non-zero one is a scanner bug, not a corpus oddity. +## Sweeping every attribute at every site + +A census says where the packages *use* an attribute, and `gen_attribute_probes.mjs` probes +only the targets `Attributes.md` already claims, so an entry that was too short stayed too +short: `[ComExport]` was documented as "constants in a Module" because a Sub and a Const were +the two targets tried, and an API `Declare` never was. +[scripts/sweep_attributes.mjs](scripts/sweep_attributes.mjs) asks every question --- every +name (the page's, the compiler's token table's, `--names`) at each of about 60 sites in +[scripts/lib/attribute-sites.mjs](scripts/lib/attribute-sites.mjs), in each argument shape --- +and lays the answers against the page. Its reader-facing description is +[Tools and Scripts](docs/Documentation/Tools.md#sweep-attributes); this is why it is built as +it is. + +**Against BETA 987: 34,526 probes in 134 builds, 8 to 16 minutes on four lanes** (three runs +took 667, 946 and 489 seconds; the middle one carried a stage 2 inflated by a noise site since +removed). That is the whole matrix, and nothing in it was slow enough to need the sampling the +design first allowed for. **A run of one name is a minute or a few, not always one:** an +attribute the compiler allows once per project (`[RunAfterBuild]`, `[RunBeforeStartupObject]`) +goes in one probe per batch, so it needs about a hundred builds by itself. + +**The token table is a string, and it holds the names you cannot guess.** The compiler binary +has one 2,496-character run of 261 pipe-separated identifiers, from `On|Off|Explicit` to +`UserDefinedTypeIsAnAlias`, with `DllExport|ComExport` in the middle of it; it is where +`ComExport` was first seen. It mixes +keywords, attributes and object members (`Debug`, `Circle`, `PSet`), so a name in it is only a +candidate. Swept, exactly one name the page does not document was accepted anywhere: +`[PropertyPage]`, at eighteen sites, all of them members of a Class or an Interface. The +other 193 were refused at every site. + +**Six things the sweep had to learn, each of which produced a wrong report first**, found by +the Opus review that was run over the tool and by its own first output: + +- **`parseCli` camel-cases its keys.** `values["dry-run"]` is `undefined`, so `--dry-run` was + ignored and the first "dry run" was a full four-lane sweep. Read `values.dryRun`; a new + tool that takes a hyphenated option should be run once with each of them before it is + trusted. +- **A control must be refused for a site to mean anything.** An unknown name is built at every + site, and a site whose control *compiles* is voided. An Enum body accepts any own-line + `[...]` --- `[ClassId("guid")]` and `[Hidden(True)]` included, neither of which can be a + member name --- so what it does with the line is unchecked, and nothing accepted there is + evidence. Inline, `[Name] X = 1`, the compiler refuses every attribute, `[Hidden]` included, + though the page documents it on an Enum member. An Enum member target is therefore reported + as one the sweep cannot test, which is true. (The mechanism is not established; only the + behaviour was measured.) A local variable is different: an own-line `[Name]` in a Sub is a + *call* statement, so that variant was dropped and only the inline one is kept. +- **A probe that draws what the control draws is a refusal, whatever the code.** The signature + compared has the attribute's name (whole word, any case) and the probe's own generated names + (`S000062`, `S000062_U`) taken out, or the control's message never equals the probe's. +- **What a canary must draw is fixed in the script.** Reading it from the preflight lets a + build that contains the very masking the canaries exist to catch calibrate the check to it. + The tool builds the canaries alone first and stops unless they draw what is recorded. +- **Batch by shuffle, and one probe per singleton.** `[RunAfterBuild]` is once per project + (TB5114), read as acceptance if a second reaches the same batch, and `[PopulateFrom]` fills + an enum with the same two members every time, and enum members are project-global. Member + names are unique per probe (`Probe` becomes `S000062_m`) for the same reason. +- **A form nobody built cannot be refused.** A cell whose other forms were refused and whose + one form able to pass never compiled is inconclusive, not refused, and the same holds for a + run cut short. + +**It found a compiler crash the documentation never would:** a bare `[PopulateFrom]` on an +Enum kills the compiler, at all three Enum sites; the tool isolated it by halving to one +probe beside the canaries. It is in [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md). + +**Read the report's "accepted at most sites" section before believing an acceptance.** +`[Description]` is taken at 53 of 61 sites and `[Hidden]` and `[Restricted]` at 42: either +they apply nearly everywhere or the compiler tolerates what it does not check. The sweep +cannot say which, and neither can a clean build, which is also why *accepted but not +documented* is a list to read and not a list to copy into the page. + ## Compiling a twinBASIC project without the IDE in front of you Exported sources say what the compiler *accepts today*; they cannot answer a question no @@ -190,6 +259,34 @@ on it. Moving the code there was checked against 14 fixture cases run before and every exit code and every line of output the same, apart from the two fixes below --- and against a full `examples.bat` run. +**A build is also a function, [scripts/lib/tb-build.mjs](scripts/lib/tb-build.mjs)'s +`compileProject`**, which is `tbbuild` without its command line and returns +`{code, message, rows, counts, dialogs, crashFiles, ...}`. `check_examples` and +`sweep_attributes` used to start `tbbuild` as a subprocess and read its JSON and its stderr +back, each with its own copy of that parse, and `check_examples` took the crashed files out of a +regex over the stderr text. The cost of the subprocess was never speed (about 100 ms against an +IDE start of about 10 s); it was that parse, and that a harness failure and a compile error +both arrived as an exit code. Moving `tbbuild` onto the function was checked the same way as the +move above: seven cases (clean, errors, `--json`, warnings only, a compiler crash, `--arch +win64`, a relative path) run before and after with every stdout, stderr and exit code +identical, and a full `check_examples` run (1,135 samples, no findings; the old code took 174 s +for 1,134 and the new 145 s, on a machine that was not quiet, so no speedup is claimed). + +Running in one process changes four things, each of which is a rule now: + +- **The function never tidies the registry and never exits.** The caller owns both; a tool that + builds many projects calls `startTidy` once, and `tbbuild` does it for its one. +- **It ends its IDE with `shutdownIdeAsync`.** `shutdownIde`'s `taskkill` and its wait hold the + event loop still (up to five seconds), which with four lanes lets the others' CDP timers run + out with their answers unread in a socket. The sync version stays for the paths that end in + `process.exit()`. +- **An uncaught exception ends every lane's work, not one child's.** `exitOnCrash(cleanup)` runs + a cleanup first; `sweep_attributes` uses it to write the report of what it had learned + (`salvage`), and `tb-cdp` drops a frame that is not JSON instead of throwing from an event + callback. +- **The name.** tb-ide already exports a `buildProject(c)` that builds an exe through an open + connection, and the notes below mean that one; the new function is `compileProject`. + Two bugs came out of the move, and neither had been noticed: - **A relative project path never loaded.** The IDE is given the resolved path and echoes @@ -604,11 +701,12 @@ leave everything as it was found**, and it takes four forms: `JSON.stringify`, which is how the IDE writes it, so the other entries keep their exact text and order, and the write is refused if the value changed after it was read. -**One process owns the registry per run.** `check_examples` runs four lanes of `tbbuild` -children at once; each restoring its own snapshot would put back whatever the registry held -when that lane started, in whatever order the lanes finished. `startTidy` sets -`TB_REGISTRY_OWNER`, the children inherit it and leave the registry alone, and the owner -sweeps once after the last lane. An owner pid that is no longer running does not count, or a +**One process owns the registry per run.** `check_examples` and `sweep_attributes` run four +lanes of builds at once, in one process (`compileProject`, which never tidies); each +restoring its own snapshot would put back whatever the registry held when that lane started, +in whatever order the lanes finished. The tool calls `startTidy` once, which sets +`TB_REGISTRY_OWNER` --- a `tbbuild` started from that process would inherit it and leave the +registry alone --- and sweeps once after the last lane. An owner pid that is no longer running does not count, or a variable left set in a shell would switch tidying off for good. Under `--keep` nothing is tidied, because the kept IDE is still writing. `shutdownIde` waits for the IDE's process to be gone before anything is tidied, because `taskkill` only asks. @@ -789,9 +887,10 @@ WebView2 processes and two console hosts. WebView2 runs inside the job without c the children it spawns into a kill-on-close job of its own, so when the Node process ends, the launcher ends with it, closing the IDE's job. Normally that is exactly what is wanted: killing only `tbbuild` in the middle of a compile now takes its whole IDE down with it, -where before the IDE lived on, on a desktop nobody could see. `check_examples` should get -the same protection one level up, since its `tbbuild` children die with it by the same -mechanism; that step has not been measured separately. +where before the IDE lived on, on a desktop nobody could see. `check_examples` and +`sweep_attributes` build in their own process (`compileProject`), so their IDEs are +launched by it and go when it does, by the same mechanism; that has not been measured +separately. **A kept IDE is the exception, and it gets no job.** Both other arrangements were tried, and both fail: diff --git a/WIP.md b/WIP.md index 658e2339..00e474c7 100644 --- a/WIP.md +++ b/WIP.md @@ -130,6 +130,7 @@ because an install path contains a username. ```sh "$TB/bin/twinBASIC_win32.exe" export ".twinproj" "C:\out\dir\" --overwrite node scripts/census_attributes.mjs --out census.md # every attribute, by enclosing construct +node scripts/sweep_attributes.mjs --out sweep.md --dump-results sweep.json # every attribute at every site, 8 to 16 min node scripts/tbbuild.mjs C:/probe/Thing.twinproj # does it compile node scripts/tbrun.mjs # what does it print ``` @@ -141,6 +142,7 @@ node scripts/tbrun.mjs # what does it print - **Keep a probe that might crash the compiler in a project of its own.** twinBASIC runs the compiler in the same process as user code, so one bad probe can take the run down and cost the other thirty their answer. - **`tbrun` takes an exported tree, not a `.twinproj`**, because it has to pin `project.buildPath` in its own staged copy --- a project still on the default template opens a native Save dialog that is invisible on the private desktop, and the build simply never happens while every health check says the IDE is fine. The probe is a module with a `[RunAfterBuild]` Sub, and must start with `Debug.Cls`. Its exit codes: 0 the probe ran and its output was captured, 1 the project has compile errors, 2 the harness failed or the build did after a clean compile, 3 no output, 4 the compiler crashed, as `tbbuild` reports it. - **A census is evidence, not applicability.** The corpus not using an attribute somewhere does not mean the compiler refuses it there, and the reverse also holds. Only a probe settles that. +- **The sweep is the probe for a whole attribute.** Before writing or changing an attribute's `Applicable to:` line, run `sweep_attributes.mjs --names ` (a minute or a few; the once-per-project ones, `RunAfterBuild` and `RunBeforeStartupObject`, take several); it tries every declaration site, not only the ones already claimed. **Always pass `--out` and `--dump-results`**: a run piped through `tail` keeps one line of a report that took ten minutes. A clean build there means the compiler accepts the attribute, not that it does anything, and an Enum member cannot be tested at all (see [WIP.Harness.md](WIP.Harness.md#sweeping-every-attribute-at-every-site)). - **End an IDE by its pid, never by image name.** `taskkill /IM twinBASIC.exe` ends every other run's IDE, another session's included, and the user's own. `tbbuild --keep` prints the pid for this reason. Why each of those is true, what the WebView/CDP route costs, why the compiler's @@ -451,7 +453,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `build.bat` — runs `node builder\tbdocs.mjs --src docs --check-audit-index` (which implies `--check`) and produces three trees in one pass: the online copy at `_site/`, a `file://`-browsable copy at `_site-offline/`, and the sparse pagedjs source at `_site-pdf/`. The offline pass adds ~700 ms and the PDF pass adds ~150 ms on top of the ~2 s online build. Toggle `also_build_offline` / `also_build_pdf` in `_config.yml` (or pass `--no-offline` / `--no-pdf`) to skip a sibling output. `--check` adds ~1.7 s and runs the link + integrity check over the HTML while it is still in worker memory; `build.bat --no-check` gets a plain build. - `serve.bat` — runs `tbdocs --serve`: initial build, then a long-lived process with watcher, debounced rebuilds, and SSE-driven browser auto-reload. Writes to `docs/_serve/` (disjoint from `build.bat`'s `_site*/`) and skips the offline + PDF passes — so a one-off `build.bat` for the PDF or offline mirror doesn't disturb the live preview. Ctrl+C to stop. - `check.bat` — the gates that read the built site: a freshness check that refuses a stale tree (`scripts/check_tree_fresh.mjs`), the DOT diagram fit check (`scripts/check_dot_fit.mjs`), the a11y sample-coverage check (`scripts/pick_a11y_sample.mjs --check`), then the accessibility check (`scripts/check_a11y.mjs`). The link + integrity check moved into `build.bat`. ~37 s. -- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the site-search unit tests (`node --test test/search.test.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), the twinBASIC-scanner probes (`scripts/check_twin_parsers.mjs`), the command-line probes and cases (`scripts/check_cli.mjs`), the pdf-lib shim comparison (`scripts/check_pdf_shims_equiv.mjs`), the impexp parity check (`scripts/check_impexp_parity.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~23 s, of which the regex-safety gate is ~9 s and the impexp check ~4 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). +- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the site-search unit tests (`node --test test/search.test.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), the twinBASIC-scanner probes (`scripts/check_twin_parsers.mjs`), the attribute-sweep probes (`scripts/check_attribute_sweep.mjs`), the command-line probes and cases (`scripts/check_cli.mjs`), the pdf-lib shim comparison (`scripts/check_pdf_shims_equiv.mjs`), the impexp parity check (`scripts/check_impexp_parity.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~23 s, of which the regex-safety gate is ~9 s and the impexp check ~4 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). - `book.bat` — renders the PDF from `docs\_site-pdf\book.html` via `node book\render-book.mjs` into `docs\_pdf\twinBASIC Book.pdf`. Run `build.bat` first to populate `_site-pdf/`; `book.bat` refuses a tree older than its sources rather than rendering the previous book (see [The book refuses a stale source tree](WIP.Build.md#the-book-refuses-a-stale-source-tree)). - `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~120 s over the 1,129 samples marked as of 2026-09-25. Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report ` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). @@ -469,7 +471,7 @@ build.bat && check.bat On the dev box that is ~4 s of build against ~37 s of check, of which the axe scan is ~20 s. [builder/PLAN-checks.md](builder/PLAN-checks.md) records how the link checker got folded into the build's task graph, what it cost and what it saved; the axe follow-ons are designed there but not implemented. -**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~23 s. Twelve of its fifteen gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep reads `DOCS_DIR`, which is `/docs`, and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: +**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~23 s. Thirteen of its sixteen gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep reads `DOCS_DIR`, which is `/docs`, and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: ```sh build.bat && check.bat && test.bat @@ -504,6 +506,7 @@ wrapper: | `test.bat` | `check_regex_safety` | no regex in the tree can backtrack exponentially | | `test.bat` | `check_symbol_index` | the symbol index still places each kind of symbol, from fixtures | | `test.bat` | `check_twin_parsers` | every word of the shared modifier list reaches all three scanners of twinBASIC source; the census's declaration kinds and `parseTargets`' targets hold for the shapes each once misread | +| `test.bat` | `check_attribute_sweep` | the sweep's site skeletons render and are each in a family of `Applicable to:` targets or listed as in none; its reading of a probe's diagnostics puts the refusals, the control's fold and a skeleton's own errors in the right states; every real `Applicable to:` line reads to its pinned targets; probes batch once each and never two of a once-per-project attribute; a cell missing a form is not reported refused; and the isolating runner, against a scripted fake in place of the IDE, finds a crash, a hang, a disturbed canary or a stray row and stops at each cap. Twenty-three injected faults, one at a time, each fail a probe | | `test.bat` | `check_cli` | `lib/cli.mjs` parses as a strict `parseArgs` does, and also refuses an empty value unless the option allows one; every tool refuses an unknown flag, and every tool with a value option an empty value; each tool's recorded command-line errors still exit and print as recorded, run with an IDE and a browser that do not exist | | `test.bat` | `check_ci_workflows` | both CI workflows run every wrapper gate, with the same arguments and order, and build with `build.bat`'s flags | | `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | diff --git a/docs/Documentation/Building.md b/docs/Documentation/Building.md index 8d02504f..a125ae42 100644 --- a/docs/Documentation/Building.md +++ b/docs/Documentation/Building.md @@ -79,6 +79,7 @@ Each `.bat` opens with `@pushd "%~dp0"`, which is what lets it be invoked from a && node scripts/check_book_coverage.mjs \ && node scripts/check_symbol_index.mjs \ && node scripts/check_twin_parsers.mjs \ + && node scripts/check_attribute_sweep.mjs \ && node scripts/check_cli.mjs \ && node scripts/check_pdf_shims_equiv.mjs \ && node scripts/check_impexp_parity.mjs \ diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 9e8d2c02..0f8602e3 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -71,7 +71,7 @@ One of the four does not mean the same thing locally as it does in CI, on any pl test.bat -The tests the toolchain has to pass. Fifteen steps, each stopping the run if it fails: +The tests the toolchain has to pass. Sixteen steps, each stopping the run if it fails: 1. [`scripts/check_publish_policy.mjs`](#check-publish-policy) --- verifies the publish allowlist still refuses the types it is meant to. Needs neither a browser nor a built tree, so it goes first. 2. [`scripts/check_gate_lists.mjs`](#check-gate-lists) --- verifies the two gate lists on this page still match the wrappers that run them. @@ -84,10 +84,11 @@ The tests the toolchain has to pass. Fifteen steps, each stopping the run if it 9. [`scripts/check_book_coverage.mjs`](#check-book-coverage) --- verifies the build still warns about a page `docs/_book.yml` does not mention. 10. [`scripts/check_symbol_index.mjs`](#check-symbol-index) --- verifies the symbol index still places each kind of symbol, and its drift guard still refuses a lost URL. 11. [`scripts/check_twin_parsers.mjs`](#check-twin-parsers) --- verifies the scanners of twinBASIC source and of the attribute reference still read the shapes each once misread. -12. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors. -13. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, patch the members of pdf-lib it lists, and run. -14. [`scripts/check_impexp_parity.mjs`](#check-impexp-parity) --- verifies the two editions of the impexp tool pass the same built-in tests, and exit, print and write the same for one sequence of commands. Without Python it reports itself skipped and passes, except in CI. -15. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. +12. [`scripts/check_attribute_sweep.mjs`](#check-attribute-sweep) --- verifies the logic of the attribute sweep: its site skeletons, how it reads a probe's diagnostics, how it batches probes, and how it compares the answers with `Attributes.md`. +13. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors. +14. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, patch the members of pdf-lib it lists, and run. +15. [`scripts/check_impexp_parity.mjs`](#check-impexp-parity) --- verifies the two editions of the impexp tool pass the same built-in tests, and exit, print and write the same for one sequence of commands. Without Python it reports itself skipped and passes, except in CI. +16. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. POSIX: @@ -102,6 +103,7 @@ POSIX: && node scripts/check_book_coverage.mjs \ && node scripts/check_symbol_index.mjs \ && node scripts/check_twin_parsers.mjs \ + && node scripts/check_attribute_sweep.mjs \ && node scripts/check_cli.mjs \ && node scripts/check_pdf_shims_equiv.mjs \ && node scripts/check_impexp_parity.mjs \ @@ -109,7 +111,7 @@ POSIX: Exit codes: **0** every step passed; otherwise the code of the step that stopped the run, as that step's entry gives it. -**Twelve of the fifteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all fifteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. +**Thirteen of the sixteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all sixteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. The three exceptions are [`check_code_regions.mjs`](#check-code-regions), [`check_gate_lists.mjs`](#check-gate-lists), which reads this page, and [`check_lint.mjs`](#check-lint), which lints the site's scripts in `docs/assets/js/`. The first is worth knowing in detail. Its corpus sweep tokenises every markdown file under `docs/`, so a page that provokes a rewrite into altering a code region fails it. Its fixed probes are a different matter: they run against their own sources whatever the tree holds, and they cover the *mirror* fault, where a rewrite silently stops firing. The sweep cannot see that one --- text the rewrite skipped is stashed and restored unchanged, so every region still matches. Add a page with an unusual code construct and run `test.bat`, but read the built page too. @@ -588,6 +590,19 @@ The modifier words that may precede a declaration keyword are one list, in `scri Exit codes: **0** every probe passed, **1** a probe failed, **2** the gate could not run: a refused command line, or a crash. +### check_attribute_sweep.mjs +{: #check-attribute-sweep } + + node scripts/check_attribute_sweep.mjs + +Verifies the logic of [`sweep_attributes.mjs`](#sweep-attributes), the tool that asks the compiler where every attribute is legal. None of that tool's failures announces itself: a site skeleton that is wrong reads as "every attribute is refused here", a control the classifier ignores reads as a recognised attribute, and a refusal taken for an acceptance is published as a finding about the compiler. The IDE is what cannot run here, so the parts that decide what an answer *means* live in `scripts/lib/attribute-sweep.mjs` and `scripts/lib/attribute-sites.mjs`, and every probe is a fixed input: no IDE, no built tree and no twinBASIC install. Under a second. + +The probes cover, in turn: the site skeletons --- what each renders, that the attribute lands on the line `attributeLine` names, that every name a skeleton declares belongs to its probe alone, that a `$&` in an attribute is written literally, and that every site is in a family of `Applicable to:` targets or is listed in the gate as being in none, so a new site is a decision and not an accident; how a probe's diagnostics are read, including which refusal wins, what counts as accepted, the errors a skeleton draws by itself, and what the control's fold does and does not fold; which probe each of the compiler's rows belongs to; the argument shapes each name is tried in, and the batches, in which every probe appears once and none holds two of an attribute the compiler allows once per project; how probes become one cell per site, where a form nobody built and the `(False)` form must not decide the answer; and how an `Applicable to:` line is read and laid against the cells, including every `Applicable to:` line the page has, each pinned to the targets it reads to. The runner that isolates what goes wrong is probed with a scripted fake in place of the IDE, which is what lets a crash that names the probe, one that names an innocent one, one that needs two probes together, a hang, a disturbed canary, a stray error row and a build that could not run each be checked, along with the cap on every one of them; so are the preflight's verdict on a site and the comparison `--verify` makes. + +The probes were checked the way a gate should be, by breaking the code they guard: twenty-three injected faults, one at a time, each failing at least one probe. + +Exit codes: **0** every probe passed, **1** a probe failed, **2** the gate could not run: a refused command line, or a crash. + ### check_cli.mjs {: #check-cli } @@ -770,9 +785,9 @@ twinBASIC has no command-line build. The compiler executable's whole surface is **The IDE it starts ends with it.** The IDE runs inside a Windows job object, so when `tbbuild` ends --- finished, failed, or stopped with Ctrl+C --- every process the IDE started ends too. That includes a compiler the IDE was restarting after a crash, which a plain process-tree kill can miss and leave running. Two exceptions: under `--keep` the IDE runs outside the job and lives until you close it, and under `--show` it is started directly on your desktop, without the job. -**It leaves the IDE's own settings as it found them.** Every IDE it starts writes to the same registry keys as your own IDE: a saved state for the project (open tabs, watch expressions, Debug Console history), a place at the top of the recent-projects list, and, when the run switches the target, the target the IDE remembers for the project. Once the IDE has exited, `tbbuild` puts all three back. An entry the run created is deleted, and a project that already had one --- one of your own --- gets its old state, its old place in the list and its old target back. The `.twinproj` file association is restored too, if the IDE changed it. When [`check_examples.mjs`](#check-examples) runs `tbbuild`, `check_examples` does this once for all its lanes instead. +**It leaves the IDE's own settings as it found them.** Every IDE it starts writes to the same registry keys as your own IDE: a saved state for the project (open tabs, watch expressions, Debug Console history), a place at the top of the recent-projects list, and, when the run switches the target, the target the IDE remembers for the project. Once the IDE has exited, `tbbuild` puts all three back. An entry the run created is deleted, and a project that already had one --- one of your own --- gets its old state, its old place in the list and its old target back. The `.twinproj` file association is restored too, if the IDE changed it. When [`check_examples.mjs`](#check-examples) or [`sweep_attributes.mjs`](#sweep-attributes) builds many projects, it does this once for all its lanes instead. -Four files under `scripts/lib/` belong to it and are never run directly. `tb-ide.mjs` holds the mechanics `tbbuild.mjs` and `tbrun.mjs` share: starting the IDE, attaching to it, waiting for the compile, and reading the diagnostics and the DEBUG CONSOLE. `tb-registry.mjs` records and restores the registry entries described above, through .NET's registry API by way of PowerShell, because `reg.exe` mangles any path holding a character outside the console code page; [`check_tb_registry.mjs`](#check-tb-registry) is its self-test. `tb-cdp.mjs` is a minimal CDP client over Node's global `WebSocket`, raw rather than puppeteer because a pending `alert()` blocks the renderer and puppeteer's `connect()` handshake talks to the renderer --- so it hangs on precisely the state you need to recover from. Every call it makes has a time limit, so a blocked page ends a run with a message rather than holding it forever. `tb-launch.ps1` holds the Win32 calls Node cannot make without a native FFI addon: `CreateDesktop` and `CreateProcess` with `STARTUPINFO.lpDesktop` for the private desktop, and the job object described above. It is the only PowerShell file under `scripts/`, and it is not executed as a file: `tb-ide.mjs` reads the text and passes it through `-EncodedCommand`, so the execution policy never comes into it and nobody has to be told to bypass one. +Five files under `scripts/lib/` belong to it and are never run directly. `tb-build.mjs` is `tbbuild` without its command line: `compileProject` opens a project in the IDE and returns its diagnostics as an array, which is how `check_examples.mjs` and `sweep_attributes.mjs` build many projects without starting a process for each. It never exits the process and never tidies the registry, so its caller owns both. `tb-ide.mjs` holds the mechanics `tb-build.mjs` and `tbrun.mjs` share: starting the IDE, attaching to it, waiting for the compile, and reading the diagnostics and the DEBUG CONSOLE. `tb-registry.mjs` records and restores the registry entries described above, through .NET's registry API by way of PowerShell, because `reg.exe` mangles any path holding a character outside the console code page; [`check_tb_registry.mjs`](#check-tb-registry) is its self-test. `tb-cdp.mjs` is a minimal CDP client over Node's global `WebSocket`, raw rather than puppeteer because a pending `alert()` blocks the renderer and puppeteer's `connect()` handshake talks to the renderer --- so it hangs on precisely the state you need to recover from. Every call it makes has a time limit, so a blocked page ends a run with a message rather than holding it forever. `tb-launch.ps1` holds the Win32 calls Node cannot make without a native FFI addon: `CreateDesktop` and `CreateProcess` with `STARTUPINFO.lpDesktop` for the private desktop, and the job object described above. It is the only PowerShell file under `scripts/`, and it is not executed as a file: `tb-ide.mjs` reads the text and passes it through `-EncodedCommand`, so the execution policy never comes into it and nobody has to be told to bypass one. Exit codes: **0** the project compiled without errors; **1** the project has errors; **2** a refused command line (a path that is not a `.twinproj` included), no IDE, an IDE that did not start or expose a debug port, or a crash; **3** the compile never settled: the IDE did not report the project open, or its diagnostics did not match its status bar; **4** the project crashes the compiler. @@ -994,7 +1009,7 @@ usually run, and [Authoring Pages](Authoring#checking-that-a-sample-compiles) is sample opts in. Each marked sample is generated into its own `Module tbx_`, packed with a template -project, and handed to [`tbbuild.mjs`](#tbbuild). A diagnostic comes back against a +project, and built the way [`tbbuild.mjs`](#tbbuild) builds. A diagnostic comes back against a generated file and a generated line; the report converts both, so what you read is the page and the line in it: @@ -1141,6 +1156,52 @@ The report ends with what the scanner could not resolve, and **that section is e Exit codes: **0** the report was produced, **2** a refused command line, no install, an install with no compiler or no package project, or a crash (a package that fails to export is left out of the census). +### sweep_attributes.mjs +{: #sweep-attributes } + + node scripts/sweep_attributes.mjs [--ide ] [--names ] [--sites ] + [--forms bare|smart|all] [--no-tokens] [--jobs ] [--port ] + [--batch-size ] [--verify ] [--out ] + [--dump-results ] [--work ] [--keep] [--preflight] + [--dry-run] [--list-sites] [--show | --hide] [--timeout ] + +Asks the compiler where every attribute is legal. It writes each attribute name at each declaration site --- a Module, a Class member, an API `Declare`, a Type field, a parameter, an `Implements ... Via` statement, and so on --- in each argument shape, builds the projects the way [`tbbuild.mjs`](#tbbuild) does, and lays the answers against the `Applicable to:` lines in `Reference/Attributes.md`. The report lists the documented targets the compiler refuses, the targets that hold only partly, and the sites it accepts that the page never mentions. + +It exists because the two older tools each leave a gap. [`census_attributes.mjs`](#census-attributes) says where the shipped packages *use* an attribute, and [`gen_attribute_probes.mjs`](#gen-attribute-probes) probes only the targets the page already *claims*, so an entry that is too short stays too short. `[ComExport]` was documented as "constants in a Module" because a Sub and a Const were the two targets tried; an API `Declare` never was. This tool asks every question, so a missing target shows up as a row of the report and not as something a person has to think of. + +The names come from three places: every entry in `Attributes.md`, every name in the compiler's own token table (a long pipe-separated string in the compiler binary, holding keywords, attributes and object members together), and `--names`. A token-table name is a *candidate*: it is called an attribute only if some site accepts it. `Debug` and `ExecuteHostCommand` are in the table and neither is one. + +**How an answer is made readable.** A clean build is not proof by itself, so the tool guards against the ways one goes wrong: + +- **Baselines.** Every site is built with no attribute first, and one that does not build clean is voided, so a wrong skeleton cannot read as "every attribute is refused here". +- **Controls.** TB5155 and TB5182 do not separate "wrong place" from "no such attribute". An invented name is built at every site, and a probe that draws exactly what the control draws is a refusal whatever its code. A site whose control compiles is voided. +- **Canaries.** Three probes (one clean, one refused for context, one unknown) ride in every batch. What they must draw is fixed in the script, not read from a build, and the tool builds them alone first and stops unless they draw it. A batch whose canaries differ, or that holds an error row belonging to no probe, is halved rather than believed. That is what would catch a compiler that stops reporting after so many errors, or a syntax error that suppresses the diagnostics of other files. +- **Isolation.** Halving also finds the probe behind a compiler crash or hang, and a crash that needs several probes together is reported as such. +- **Batching.** Batches are shuffled, and an attribute the compiler allows once per project (`[RunAfterBuild]`) goes in one probe to a batch, or its TB5114 would read as acceptance. +- **`--verify N`** rebuilds N random probes in fresh batches and compares. + +| Flag | Effect | +|---|---| +| `--names`, `--sites` | Restrict to these attribute names (any case) or site ids. `--list-sites` prints the ids. | +| `--forms` | `bare`, `smart` (the default) or `all`. Smart gives every documented attribute every argument shape, a token-table name its bare form, and more shapes only where a site recognised it. A shape known to be required is always tried. | +| `--no-tokens` | Leave out the token table. | +| `--jobs`, `--port` | Concurrent IDE lanes (default 4) and the first DevTools port; a lane uses one more each (default 9560). | +| `--batch-size` | Probes per project (default 400). | +| `--verify N` | Rebuild N random probes and compare. | +| `--out ` | Write the Markdown report there. Without it the report goes to standard output. | +| `--dump-results ` | Also write every result, raw, as JSON: each name at each site with the answer for each argument shape. | +| `--work`, `--keep` | Where projects are staged, which must be under the system temp folder, and whether to keep them. | +| `--preflight` | Build only the canaries, baselines and controls, and stop. About 20 seconds, and the way to check a change to a site. | +| `--dry-run` | Count the probes and build nothing. | + +**An Enum member cannot be tested.** An Enum body accepts any attribute written on its own line, including one that applies nowhere, and refuses every attribute written inline, so the site is voided and a target on an Enum member is reported as one the sweep could not test. + +**A clean build says the compiler accepts an attribute at a site.** It does not say the attribute does anything, and the IDE's background compile is what is read, so a check made only when linking is not seen. Where the report points at a target worth documenting, an A/B probe like X29 to X33 in `gen_attribute_probes.mjs` is what shows the effect. + +Like [`check_examples.mjs`](#check-examples) it needs a twinBASIC install and Windows with a private desktop, so it is outside every gate and outside CI. **Pass `--out` and `--dump-results`.** The report is the only product, and a run piped through `tail` keeps one line of it. + +Exit codes: **0** the report was produced and its self-checks held; **1** a self-check found a fault --- a probe disturbed the canaries even beside nothing else, or `--verify` found a probe that answered differently the second time; **2** a refused command line, no install, canaries that do not draw what the script records, a harness failure, a run cut short (its report is written all the same, and says so at the top), or a crash. + ### impexp.mjs and impexp.py {: #impexp } diff --git a/scripts/check_attribute_sweep.mjs b/scripts/check_attribute_sweep.mjs new file mode 100644 index 00000000..4d7e7342 --- /dev/null +++ b/scripts/check_attribute_sweep.mjs @@ -0,0 +1,598 @@ +#!/usr/bin/env node +// Probes for the logic of the attribute sweep, which run in test.bat: +// +// node scripts/check_attribute_sweep.mjs +// +// Exits 0 when every probe passes, 1 when one fails, 2 on a refused command line or a +// crash. It starts no IDE and reads nothing from the tree: every probe is a fixed +// input, so it runs without a twinBASIC install. +// +// scripts/sweep_attributes.mjs builds every attribute at every declaration site +// and reports what the compiler accepted, and none of its failures announces +// itself. A site skeleton that is wrong reads as "every attribute is refused +// here"; a control the classifier ignores reads as a recognised attribute; a +// refusal taken for an acceptance is published as a finding about the compiler. +// The IDE is what is slow and what cannot run here, so the parts that decide +// what an answer MEANS are in scripts/lib/attribute-sweep.mjs and +// scripts/lib/attribute-sites.mjs, and these probe them. Each is a shape the +// tool once got wrong in review or in its first runs, or one the next edit +// could lose: +// +// - the site skeletons: what renders, where the attribute goes, that a name +// is unique to its probe, that a `$&` in an attribute is not read as a +// replacement pattern, and that every site is in a family or is listed here +// as being in none. +// - classify and signature, the reading of a probe's diagnostics: the order +// the two refusals win in, what counts as accepted, the errors a skeleton +// draws by itself, and the control's fold. +// - sortRows, which files the compiler's rows under the probe they belong to. +// - the argument shapes each name is tried in, and the batches: every probe once, +// and no batch holding two of an attribute the compiler allows once per project. +// - aggregate and formDependence, which turn probes into a cell per site and +// must not let a form nobody built, or `(False)`, decide it. +// - targetsOf and compare, which read an `Applicable to:` line and lay it +// against the cells, and the lines of the page as they read today. +// - the isolating runner, against a fake in place of the IDE: a crash that +// names its probe, one that names an innocent one and one that needs two +// together; a hang, with the canaries rebuilt alone at the top; a disturbed +// canary; a stray error row; a build that could not run; and each cap. +// - the preflight's verdicts, and the comparison `--verify` makes. + +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { NOT_FAITHFULLY_PROBEABLE, UNSYNTHESISABLE, pad } from "./lib/attribute-probe-kit.mjs"; +import { + FAMILIES, OPTIONAL_SITES, SITE_IDS, SITE_LIST, attributeLine, renderSite, +} from "./lib/attribute-sites.mjs"; +import { + CONTROL_NAME, EXPECTED_CANARIES, FAITHFUL_SITES, MAX_HARNESS_FAILURES, MAX_HUNG_PROBES, MAX_INTERFERING_PROBES, + MAX_STRAY_PROBES, SINGLETON_NAMES, aggregate, argsOf, canaryOk, classify, compare, createRunner, formDependence, + judgePreflight, makeBatches, parseTokenRun, rng, signature, singletonKey, sortRows, stage1Forms, stage2Forms, + targetsOf, verifyMismatches, +} from "./lib/attribute-sweep.mjs"; +import { createProbes } from "./lib/gate-probes.mjs"; + +exitOnCrash(); + +const USAGE = `usage: node scripts/check_attribute_sweep.mjs [-h, --help] + +Runs the probes of the attribute sweep's logic: its site skeletons, how it reads a +probe's diagnostics, batches probes, and compares the answers with Attributes.md. +No IDE and no tree. + + -h, --help print this text and exit + +Exit codes: + 0 every probe passed + 1 a probe failed + 2 the gate could not run: a refused command line, or a crash`; + +if (withUsageError(() => parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + stopAt: ["help"], +})).values.help) printHelpAndExit(USAGE); + +const { check, report } = createProbes("check_attribute_sweep"); +const show = (x) => JSON.stringify(x); +const same = (a, b) => show(a) === show(b); + +const siteOf = (id) => { + const s = SITE_LIST.find((x) => x.id === id); + if (!s) throw new Error(`no site ${id}`); + return s; +}; +const probe = (kind, siteId, name, form = "bare", id = 1) => ({ + id, kind, name, site: siteOf(siteId), form, attr: name === null ? null : `[${name}]`, tag: `S${pad(id, 6)}`, +}); +const err = (message, line = 1) => ({ severity: "ERROR", line, col: 1, message }); +const warn = (message, line = 1) => ({ severity: "WARNING", line, col: 1, message }); + +// ------------------------------------------------------------------- sites +check("sites: ids are unique", new Set(SITE_LIST.map((s) => s.id)).size === SITE_LIST.length && SITE_IDS.size === SITE_LIST.length, + `${SITE_LIST.length} sites, ${SITE_IDS.size} ids`); + +// One probe per property, over every site, naming the sites that fail it: a +// probe per site and property is three hundred lines of output. +{ + const ATTR = "[Q$&Z]"; // `$&` is a replacement pattern to a string replace + const failing = (holds) => SITE_LIST.filter((s) => !holds(s)).map((s) => s.id); + const verdict = (bad) => bad.length === 0; + + let bad = failing((s) => { + const one = renderSite(s, "S000001", ATTR); + return !one.includes("${") && one.split(ATTR).length === 2; + }); + check("sites: no placeholder is left and the attribute is written once, as given, even with `$&` in it", verdict(bad), show(bad)); + + bad = failing((s) => { + const line = attributeLine(s, "S000001"); + return line >= 1 && renderSite(s, "S000001", ATTR).split("\n")[line - 1]?.includes(ATTR); + }); + check("sites: attributeLine names the line that holds the attribute", verdict(bad), show(bad)); + + bad = failing((s) => { + const withAttr = renderSite(s, "S000001", ATTR).split("\n").length; + const without = renderSite(s, "S000001", null); + return without.split("\n").length === withAttr - (s.inline ? 0 : 1) && !without.includes("${"); + }); + check("sites: an inline site keeps its line count without the attribute, and a whole-line one loses one", verdict(bad), show(bad)); + + bad = failing((s) => { + const ids1 = new Set(renderSite(s, "S000001", ATTR).match(/S\d{6}\w*/g) ?? []); + const ids2 = new Set(renderSite(s, "S000002", ATTR).match(/S\d{6}\w*/g) ?? []); + return ids1.size > 0 && [...ids1].every((n) => !ids2.has(n)); + }); + check("sites: every name a skeleton declares is its probe's own, so no two probes collide", verdict(bad), show(bad)); + + bad = failing((s) => !/\bProbe(Const|Var)?\b/.test(renderSite(s, "S000001", ATTR))); + check("sites: no member is called Probe, which every probe would then declare", verdict(bad), show(bad)); + + bad = failing((s) => renderSite(s, "S000001", ATTR).endsWith("\n") && !renderSite(s, "S000001", null).includes("@@")); + check("sites: a skeleton ends in a newline and leaves no marker behind", verdict(bad), show(bad)); +} + +const inFamily = new Set(Object.values(FAMILIES).flat()); +check("families: name only sites that exist", Object.values(FAMILIES).flat().every((id) => SITE_IDS.has(id)), + show(Object.values(FAMILIES).flat().filter((id) => !SITE_IDS.has(id)))); +check("families: OPTIONAL_SITES names only sites that exist", [...OPTIONAL_SITES].every((id) => SITE_IDS.has(id)), + show([...OPTIONAL_SITES].filter((id) => !SITE_IDS.has(id)))); +// A site in no family is one no `Applicable to:` phrase can cover, so accepting it is +// always reported as undocumented. That is right for these and a decision for a new +// site, which is what this list is for. +const NO_FAMILY = ["DELEGATE", "TYPE_FIELD", "TYPE_FIELD_LINE", "CONST_CLASS", "VAR_LOCAL", "INHERITS"]; +check("families: the sites in no family are the ones decided", same(SITE_LIST.map((s) => s.id).filter((id) => !inFamily.has(id)), NO_FAMILY), + show(SITE_LIST.map((s) => s.id).filter((id) => !inFamily.has(id)))); +check("sites: an Enum member has no inline site, which the compiler refuses for every attribute", + !SITE_IDS.has("ENUM_MEMBER_LINE") && SITE_LIST.find((s) => s.id === "ENUM_MEMBER")?.inline === false); +check("sites: ENUM_EMPTY declares the one error an empty Enum draws by itself", + same([...siteOf("ENUM_EMPTY").baselineCodes], ["TB5233"])); + +// ---------------------------------------------------------- classification +const controlSigs = new Map([["MODULE", signature(["TB5079"], ["TB5079 Unrecognized symbol 'ZzNoSuchAttributeProbe'"], CONTROL_NAME)]]); +const stateOf = (p, rows, sigs = controlSigs) => classify(p, rows, sigs).state; +const attrProbe = (siteId = "MODULE", name = "Hidden") => probe("attr", siteId, name); + +for (const [what, rows, want] of [ + ["no rows", [], "ACCEPT"], + ["a warning only", [warn("TB0013 advice")], "ACCEPT"], + ["TB5155 is a refusal for context", [err("TB5155 This attribute is not supported in this context")], "REJECT_CONTEXT"], + ["TB5182 is a refusal as unknown", [err("TB5182 Syntax error. No handler for this symbol")], "REJECT_SYNTAX"], + ["TB5155 wins over TB5182 in one file", [err("TB5182 x"), err("TB5155 y")], "REJECT_CONTEXT"], + ["TB5114, a second [RunAfterBuild], means it was placed", [err("TB5114 only one")], "ACCEPT_LATER"], + ["TB5247, a form designer lookup, means it was placed", [err("TB5247 no designer")], "ACCEPT_LATER"], + ["TB5114 beside another error is not only a later error", [err("TB5114 a", 3), err("TB5000 b", 3)], "ACCEPT_ERR"], + ["another error on the attribute's own line is recognised", [err("TB5163 Expected String arguments", 1)], "RECOGNISED"], + ["another error elsewhere in the file is the declaration's", [err("TB5000 Missing implementation", 3)], "ACCEPT_ERR"], +]) { + check(`classify: ${what}`, stateOf(attrProbe(), rows) === want, `got ${stateOf(attrProbe(), rows)}, want ${want}`); +} + +check("classify: an error a skeleton draws with no attribute is not the attribute's", + stateOf(probe("attr", "ENUM_EMPTY", "PopulateFrom", "fixed"), [err("TB5233 Enum has no defined members", 3)]) === "ACCEPT"); +check("classify: that error beside a refusal is still the refusal", + stateOf(probe("attr", "ENUM_EMPTY", "ClassId"), [err("TB5233 x", 3), err("TB5182 y")]) === "REJECT_SYNTAX"); +check("classify: the same error at a site that does not declare it is not forgiven", + stateOf(probe("attr", "MODULE", "Hidden"), [err("TB5233 x", 3)]) === "ACCEPT_ERR"); + +const drawn = [err("TB5079 Unrecognized symbol 'On'", 1)]; +check("classify: what the control draws is a refusal, whatever the code, for a name that is not the control's", + stateOf(probe("attr", "MODULE", "On"), drawn) === "REJECT_SYNTAX"); +check("classify: without that control the same rows read as recognised, so the fold is what decides it", + stateOf(probe("attr", "MODULE", "On"), drawn, new Map()) === "RECOGNISED"); +check("classify: the fold is per site", + stateOf(probe("attr", "CLASS", "On"), drawn) === "RECOGNISED"); +check("classify: a different code from the control's is not folded", + stateOf(probe("attr", "MODULE", "On"), [err("TB5073 Unable to disambiguate 'On'", 1)]) === "RECOGNISED"); +check("classify: a probe of the tool's own kind is never folded", + stateOf(probe("control", "MODULE", CONTROL_NAME), [err("TB5079 Unrecognized symbol 'ZzNoSuchAttributeProbe'", 1)]) === "RECOGNISED"); +check("classify: a verify probe is read as an attribute probe", + stateOf(probe("verify", "MODULE", "On"), drawn) === "REJECT_SYNTAX"); +check("classify: TB5155 is never folded into anything else", + stateOf(probe("attr", "MODULE", "On"), [err("TB5155 x")]) === "REJECT_CONTEXT"); + +// ---------------------------------------------------------------- signature +check("signature: the name goes as a whole word in any case", + signature([], ["symbol 'on'", "Only one 'On'"], "On") === "|Only one '@';symbol '@'", signature([], ["symbol 'on'", "Only one 'On'"], "On")); +check("signature: a name with a regex operator in it is matched literally", + signature([], ["symbol 'a.b'", "aXb"], "a.b") === "|aXb;symbol '@'", signature([], ["symbol 'a.b'", "aXb"], "a.b")); +check("signature: a probe's generated names are the same whichever probe it is", + signature(["TB5000"], ["Missing S000062_U.Ping and S000062_m"], "X") === signature(["TB5000"], ["Missing S000063_U.Ping and S000063_m"], "X")); +check("signature: two codes in any order, once each, are one signature", + signature(["TB5182", "TB5155", "TB5182"], [], "X") === signature(["TB5155", "TB5182"], [], "X")); + +// ------------------------------------------------------------- the canaries +check("canaries: what they are expected to draw is accepted, and a masked refusal is not", + canaryOk({ ...EXPECTED_CANARIES }) && !canaryOk({ ...EXPECTED_CANARIES, context: "ACCEPT" }) + && !canaryOk({ clean: "ACCEPT", context: "REJECT_CONTEXT" })); +check("canaries: the three answers differ, or one could stand for another", + new Set(Object.values(EXPECTED_CANARIES)).size === 3); + +// ---------------------------------------------------------------- sortRows +{ + const p1 = probe("attr", "MODULE", "A", "bare", 1); + const p2 = probe("attr", "MODULE", "B", "bare", 2); + const p3 = probe("attr", "MODULE", "C", "bare", 3); + const files = new Map([["s000001.twin", p1], ["s000002.twin", p2], ["s000003.twin", p3]]); + const { byFile, strays } = sortRows([ + "{ERROR} /Proj/Sources/S000001.twin [3,9]: TB5155 refused", + "{ERROR} /Proj/Sources/S000001.twin [4,1]: TB5000 second", + "{WARNING} C:\\Proj\\Sources\\S000002.twin [1,1]: TB0013 advice", + "{ERROR} /Proj/Sources/_ProbeMain.twin [1,1]: TB5000 the template's own", + "{WARNING} /Proj/Sources/_ProbeMain.twin [1,1]: TB0013 a template note", + "{ERROR} a row of no shape", + "{INFO} a note of no shape", + "garbage", + ], files, [p1, p2, p3]); + check("sortRows: rows go under the file they name, whatever its case or separators", + byFile.get(p1).length === 2 && byFile.get(p1)[0].line === 3 && byFile.get(p2)[0].severity === "WARNING", show([...byFile.values()])); + check("sortRows: a probe with no rows has an empty list", same(byFile.get(p3), [])); + check("sortRows: only an ERROR that belongs to no probe is a stray", strays.length === 2 && strays.every((s) => s.startsWith("{ERROR}")), show(strays)); +} + +// -------------------------------------------------------------------- forms +const token = { name: "Debug", doc: null }; +const documented = { name: "Description", doc: { app: "x" } }; +check("forms: a token-table name is tried bare, and only bare, in smart mode", same(stage1Forms(token, "smart"), ["bare"])); +check("forms: in all mode it gets every shape", same(stage1Forms(token, "all"), ["bare", "true", "false", "str", "int"])); +check("forms: a documented name gets every shape", same(stage1Forms(documented, "smart"), ["bare", "fixed", "true", "false", "str", "int"])); +check("forms: bare mode still tries a shape known to be required", same(stage1Forms(documented, "bare"), ["bare", "fixed"])); +check("forms: an attribute with no fixed shape has none", !stage1Forms({ name: "Hidden", doc: { app: "x" } }, "smart").includes("fixed")); +check("forms: a once-per-project attribute gets two shapes, not six", same(stage1Forms({ name: "RunAfterBuild", doc: { app: "x" } }, "smart"), ["bare", "true"])); +check("forms: stage 2 for it is the one shape", same(stage2Forms({ name: "RunAfterBuild", doc: null }), ["true"])); +check("forms: stage 2 for a name with a fixed shape starts with it", stage2Forms({ name: "ClassId", doc: null })[0] === "fixed"); +check("args: bare has none, the Boolean shapes are written, and a GUID is unique to its probe", + argsOf("bare", "X", 1) === "" && argsOf("true", "X", 1) === "(True)" && argsOf("false", "X", 1) === "(False)" + && argsOf("fixed", "ClassId", 1) !== argsOf("fixed", "ClassId", 2) && /^\("[0-9a-f-]{36}"\)$/.test(argsOf("fixed", "ClassId", 7))); +check("args: TypeHint names the enum the tool declares", argsOf("fixed", "TypeHint", 1) === "(SweepHintEnum)"); + +// ------------------------------------------------------------------ batches +{ + const probes = []; + let id = 1; + const push = (name, form, site) => probes.push({ id: id++, kind: "attr", name, form, site: { id: site }, attr: null, tag: "" }); + for (let n = 0; n < 12; n++) for (let s = 0; s < 20; s++) push(`N${n}`, "bare", `S${s}`); + for (let s = 0; s < 20; s++) { push("RunAfterBuild", "bare", `S${s}`); push("RunAfterBuild", "true", `S${s}`); } + for (let s = 0; s < 3; s++) { push("PopulateFrom", "fixed", `S${s}`); push("PopulateFrom", "bare", `S${s}`); } + const batches = makeBatches(probes, 25); + const ids = batches.flat().map((p) => p.id).sort((a, b) => a - b); + check("batches: every probe is in exactly one", ids.length === probes.length && ids.every((v, i) => v === i + 1)); + check("batches: none is larger than asked", batches.every((b) => b.length <= 25)); + const twice = (key) => batches.some((b) => b.filter((p) => singletonKey(p) === key).length > 1); + check("batches: no batch holds two of an attribute the compiler allows once", !twice("RunAfterBuild")); + check("batches: nor two of PopulateFrom's working form", !twice("PopulateFrom")); + check("batches: the same probes make the same batches", same(makeBatches(probes, 25).map((b) => b.map((p) => p.id)), batches.map((b) => b.map((p) => p.id)))); + check("batches: they are shuffled, so no batch of two or more is one attribute", + batches.every((b) => b.length < 2 || new Set(b.map((p) => p.name)).size > 1), + show(batches.filter((b) => b.length >= 2 && new Set(b.map((p) => p.name)).size === 1).map((b) => b[0].name))); + check("batches: a singleton set that cannot fit makes more batches, not a double", + makeBatches(probes.filter((p) => p.name === "RunAfterBuild"), 100).length === 40); + check("batches: singletonKey knows the two names and PopulateFrom's fixed form only", + SINGLETON_NAMES.has("RunAfterBuild") && singletonKey({ name: "PopulateFrom", form: "fixed" }) === "PopulateFrom" + && singletonKey({ name: "PopulateFrom", form: "bare" }) === null && singletonKey({ name: null, form: "bare" }) === null); + const a = rng(1); + const b = rng(1); + check("batches: the generator is deterministic", [a(), a(), a()].join() === [b(), b(), b()].join()); +} + +// ------------------------------------------------------ aggregate and forms +{ + const mk = (id, form, site = "S1", name = "A", kind = "attr") => ({ id, kind, name, form, site: { id: site } }); + const out = (state, codes = []) => ({ state, codes, msgs: [] }); + const cell = (probes, outcomes) => aggregate(probes, new Map(outcomes)).get("A")?.get("S1"); + + check("aggregate: the best form decides", cell([mk(1, "bare"), mk(2, "true")], [[1, out("REJECT_CONTEXT")], [2, out("ACCEPT")]]).state === "ACCEPT"); + check("aggregate: a soft answer beats a refusal", + cell([mk(1, "bare"), mk(2, "true")], [[1, out("REJECT_SYNTAX")], [2, out("ACCEPT_ERR")]]).state === "ACCEPT_ERR"); + check("aggregate: (False) is not part of the answer", + cell([mk(1, "bare"), mk(2, "false")], [[1, out("REJECT_CONTEXT")], [2, out("ACCEPT")]]).state === "REJECT_CONTEXT"); + check("aggregate: a cell only (False) was built for has no answer", + cell([mk(1, "bare"), mk(2, "false")], [[2, out("ACCEPT")]]).state === "HARNESS"); + check("aggregate: a refusal beside a form nobody built cannot stand", + cell([mk(1, "bare"), mk(2, "true")], [[1, out("REJECT_CONTEXT")]]).state === "HARNESS"); + check("aggregate: a refusal beside a form whose build failed cannot stand", + cell([mk(1, "bare"), mk(2, "true")], [[1, out("REJECT_SYNTAX")], [2, out("HARNESS")]]).state === "HARNESS"); + check("aggregate: an acceptance beside a form nobody built stands", + cell([mk(1, "bare"), mk(2, "true")], [[1, out("ACCEPT")]]).state === "ACCEPT"); + check("aggregate: a missing (False) does not make a refusal pending", + cell([mk(1, "bare"), mk(2, "false")], [[1, out("REJECT_SYNTAX")]]).state === "REJECT_SYNTAX"); + check("aggregate: only an attribute probe is aggregated", + aggregate([mk(1, "bare", "S1", "A", "baseline"), mk(2, "bare", "S1", "B", "control")], new Map()).size === 0); + check("aggregate: the forms and codes are kept", + same(Object.keys(cell([mk(1, "bare"), mk(2, "true")], [[1, out("REJECT_CONTEXT", ["TB5155"])], [2, out("REJECT_CONTEXT")]]).forms), ["bare", "true"])); + + const dep = formDependence(aggregate([mk(1, "bare"), mk(2, "true"), mk(3, "bare", "S2"), mk(4, "true", "S2")], + new Map([[1, out("REJECT_CONTEXT")], [2, out("ACCEPT")], [3, out("ACCEPT")], [4, out("ACCEPT")]]))); + check("formDependence: a shape that changes the placement answer is listed, and cells with several shapes counted", + dep.list.length === 1 && dep.list[0].site === "S1" && same(dep.list[0].takes, ["true"]) && dep.multi === 2, show(dep)); + const noFalse = formDependence(aggregate([mk(1, "bare"), mk(2, "false")], new Map([[1, out("REJECT_CONTEXT")], [2, out("ACCEPT")]]))); + check("formDependence: (False) is left out of it, and does not make a cell multi-shape", + noFalse.list.length === 0 && noFalse.multi === 0, show(noFalse)); +} + +// ---------------------------------------------------------------- targetsOf +for (const [app, want] of [ + ["Class, Module, procedure", ["CLASS", "MODULE", "PROC_ANY"]], + ["procedures and constants in a module.", ["PROC_MODULE", "CONST"]], + ["Declare (API declaration) and constants, in a Module", ["DECLARE", "CONST"]], + ["an Implements ... Via statement in a Class", ["IMPLEMENTS_VIA"]], + ["procedure in a Class or Interface", ["PROC_CLASS", "PROC_INTERFACE"]], + ["procedure in a Class or Module", ["PROC_CLASS", "PROC_MODULE"]], + ["procedure parameters", ["PARAM"]], + ["procedure definitions", ["PROC_ANY"]], + ["Function in a Module, returning a Boolean", ["FUNC_MODULE_BOOL"]], + ["Interface in a Library", ["LIBRARY_INTERFACE"]], + ["Library", []], + ["variables in a Class", ["VAR_CLASS"]], + ["Class, CoClass", ["CLASS", "COCLASS"]], + [null, []], + ["", []], +]) { + check(`targetsOf: ${show(app)}`, same(targetsOf(app), want), `got ${show(targetsOf(app))}`); +} + +// ------------------------------------------------------------------ compare +{ + const cells = (o) => new Map(Object.entries(o).map(([site, state]) => [site, { state }])); + const cmp = (name, app, o) => compare({ name, doc: { app } }, cells(o)); + + let c = cmp("X", "Class", { CLASS: "ACCEPT" }); + check("compare: a documented target the compiler accepts is nothing to report", + !c.missing.length && !c.partial.length && !c.extra.length && !c.untestable.length, show(c)); + c = cmp("X", "Class", { CLASS: "REJECT_SYNTAX" }); + check("compare: a documented target refused everywhere is missing", c.missing.length === 1 && c.missing[0].target === "CLASS", show(c)); + c = cmp("X", "Class", { CLASS: "ACCEPT", INTERFACE: "ACCEPT" }); + check("compare: an accepted site the line does not name is extra", same(c.extra, ["INTERFACE"]), show(c)); + c = cmp("X", "Class", { CLASS: "ACCEPT", INTERFACE: "ACCEPT_ERR" }); + check("compare: an ACCEPT_ERR site is recognised, not accepted", !c.extra.length && same(c.extraSoft, ["INTERFACE"]), show(c)); + c = cmp("X", "Class", { CLASS: "REJECT_SYNTAX", CLASS_PRIVATE: "RECOGNISED" }); + check("compare: a refused target says where it was recognised with an error", same(c.missing[0].soft, ["CLASS_PRIVATE"]), show(c)); + + c = cmp("X", "procedure in a Class", { + PROC_CLASS: "ACCEPT", FUNC_CLASS: "REJECT_SYNTAX", PROPGET_CLASS: "ACCEPT", + PROPLET_CLASS: "REJECT_SYNTAX", CLASS_INITIALIZE: "REJECT_SYNTAX", + }); + check("compare: a target that holds for some sites is partial, naming the required ones it fails", + c.partial.length === 1 && same(c.partial[0].no, ["FUNC_CLASS"]) && !c.missing.length, show(c)); + c = cmp("X", "procedure in a Class", { PROC_CLASS: "ACCEPT", FUNC_CLASS: "ACCEPT", PROPLET_CLASS: "REJECT_SYNTAX", CLASS_INITIALIZE: "REJECT_SYNTAX" }); + check("compare: an optional site refused is not a qualification the page owes", !c.partial.length, show(c)); + + // The two regressions the review found: the wrong PROC_ANY, and a Declare in a class. + c = cmp("DllExport", "procedures and constants in a module.", { + SUB_MODULE: "ACCEPT", SUB_MODULE_PRIVATE: "ACCEPT", FUNC_MODULE: "ACCEPT", FUNC_MODULE_BOOL: "ACCEPT", + PROPGET_MODULE: "ACCEPT", CONST: "ACCEPT", CONST_PRIVATE: "ACCEPT", + PROC_CLASS: "REJECT_SYNTAX", FUNC_CLASS: "REJECT_SYNTAX", DECLARE: "REJECT_CONTEXT", + }); + check("compare: procedures in a module are not asked to hold in a class", + !c.missing.length && !c.partial.length && !c.extra.length, show(c)); + const comExport = { + DECLARE: "ACCEPT", DECLARE_SUB: "ACCEPT", DECLARE_PRIVATE: "ACCEPT", DECLARE_PTRSAFE: "ACCEPT", DECLARE_WIDE: "ACCEPT", + DECLARE_CLASS: "REJECT_SYNTAX", CONST: "ACCEPT", CONST_PRIVATE: "ACCEPT", CONST_CLASS: "REJECT_SYNTAX", + SUB_MODULE: "REJECT_CONTEXT", + }; + c = cmp("ComExport", "Declare (API declaration) and constants, in a Module", comExport); + check("compare: a Declare in a module does not owe one in a class", + !c.missing.length && !c.partial.length && !c.extra.length && !c.untestable.length, show(c)); + c = cmp("ComExport", "Declare (API declaration) and constants, in a Module", { ...comExport, CONST_CLASS: "ACCEPT" }); + check("compare: a constant in a class is not covered by constants in a module", same(c.extra, ["CONST_CLASS"]), show(c)); + + c = cmp("Hidden", "an Enum member", { CLASS: "ACCEPT" }); + check("compare: a target whose only site was voided is untestable, not agreeing", c.untestable.length === 1 && !c.missing.length, show(c)); + c = cmp("X", "Interface in a Library", { INTERFACE: "ACCEPT" }); + check("compare: a target no site can be written for is untestable", c.untestable.length === 1, show(c)); + c = cmp("X", "Library", {}); + check("compare: a line that yields no target is untestable, not agreeing", c.untestable.length === 1 && c.hasLine, show(c)); + c = cmp("X", "Class", { CLASS: "HARNESS" }); + check("compare: a site whose build failed is untestable, not refused", c.untestable.length === 1 && !c.missing.length, show(c)); + check("compare: an entry with no Applicable to: line says so", + compare({ name: "X", doc: { app: null } }, cells({})).hasLine === false); + check("compare: a name with no entry on the page is not compared", compare({ name: "X", doc: null }, cells({})) === null); + + // Enumerator is in the list and overridden: FAITHFUL_SITES gives it sites that can probe it (below). + const unprobeable = [...Object.keys(NOT_FAITHFULLY_PROBEABLE), ...Object.keys(UNSYNTHESISABLE)].filter((n) => !FAITHFUL_SITES[n]); + check("compare: an attribute a generic skeleton cannot probe is not compared", + unprobeable.length > 0 && unprobeable.every((n) => compare({ name: n, doc: { app: "Class" } }, cells({ CLASS: "REJECT_SYNTAX" })).unfaithful), + show(unprobeable)); + c = cmp("Enumerator", "procedure in a Class or Interface", { + PROC_CLASS: "REJECT_SYNTAX", FUNC_CLASS: "REJECT_SYNTAX", FUNC_CLASS_OBJECT: "ACCEPT", FUNC_INTERFACE_OBJECT: "ACCEPT", PROC_INTERFACE: "REJECT_SYNTAX", + }); + check("compare: Enumerator is judged only where a member can return an object", + !c.unfaithful && !c.missing.length && !c.partial.length, show(c)); + c = cmp("RedirectToStaticImplementation", "prototype in an Interface", { PROC_INTERFACE: "ACCEPT", FUNC_INTERFACE: "REJECT_SYNTAX", PROPGET_INTERFACE: "REJECT_SYNTAX" }); + check("compare: RedirectToStaticImplementation is judged only on a Sub prototype", !c.missing.length && !c.partial.length, show(c)); +} + +// --------------------------------------------------------- the token table +{ + // The run is what is contiguous around the anchor: a NUL ends it at both sides, and a + // token that is not an identifier (`9Bad`) or is repeated (`On`) is dropped from it. + const { tokens, why } = parseTokenRun("junk\u{0}xx|On|Off|DllExport|ComExport|Good_1|On|9Bad|Zz\u{0}more|junk"); + check("token table: the identifiers of the run around the anchor, once each", + why === null && same(tokens, ["xx", "On", "Off", "DllExport", "ComExport", "Good_1", "Zz"]), show({ tokens, why })); + check("token table: without the anchor there is a reason and no names", parseTokenRun("no table here").tokens.length === 0 && parseTokenRun("no table here").why !== null); +} + +// ---------------------------------------------- the lines of the page, as read +// Every distinct `Applicable to:` line Attributes.md carries, and the targets it +// reads to. Fixed here, not read from the page: if the page changes nothing fails, +// and if targetsOf changes, every real line is what tells. +{ + const LINES = [ + ["procedure prototype in an Interface", ["PROC_INTERFACE"]], + ["CoClass", ["COCLASS"]], + ["procedure", ["PROC_ANY"]], + ["Class", ["CLASS"]], + ["Interface", ["INTERFACE"]], + ["Declare (API declaration) and constants, in a Module", ["DECLARE", "CONST"]], + ["procedure definitions", ["PROC_ANY"]], + ["Function in a Module. The compiler rejects it on a method in a Class.", ["FUNC_MODULE"]], + ["Module, procedure in a Class or Module", ["MODULE", "PROC_CLASS", "PROC_MODULE"]], + ["Interface declaration within a CoClass", ["COCLASS_INTERFACE"]], + ["Event declaration in a Class", ["EVENT_CLASS"]], + ["procedure in a Class", ["PROC_CLASS"]], + ["Class, CoClass, Const, Declare (API declaration), Interface, Module, procedure, Type (UDT)", + ["CLASS", "COCLASS", "CONST", "DECLARE", "INTERFACE", "MODULE", "TYPE", "PROC_ANY"]], + ["procedure in an Interface", ["PROC_INTERFACE"]], + ["Declare (API declaration)", ["DECLARE"]], + ["procedures.", ["PROC_ANY"]], + ["Enum", ["ENUM"]], + ["procedure definition in a module.", ["PROC_MODULE"]], + ["variables and procedures in a Class", ["VAR_CLASS", "PROC_CLASS"]], + ["Type (UDT)", ["TYPE"]], + ["Method in an Interface, API Declarations.", ["PROC_INTERFACE", "DECLARE"]], + ["Function, Sub", ["FUNC_MODULE", "SUB_MODULE"]], + ["Declare (API declaration), Module", ["DECLARE", "MODULE"]], + ["procedure, Declare (API declaration)", ["DECLARE", "PROC_ANY"]], + ["Module", ["MODULE"]], + ["an Implements statement in a Class", ["IMPLEMENTS"]], + ]; + const wrong = LINES.filter(([app, want]) => !same(targetsOf(app), want)).map(([app]) => `${app} -> ${show(targetsOf(app))}`); + check("targetsOf: the lines of the page, each read as pinned", wrong.length === 0, wrong.join("\n")); +} + +// --------------------------------------------- what a cell that is missing a form is +{ + const mk = (id, form) => ({ id, kind: "attr", name: "A", form, site: { id: "S1" } }); + const out = (state) => ({ state, codes: [], msgs: [] }); + const cellOf = (probes, outcomes) => aggregate(probes, new Map(outcomes)).get("A").get("S1"); + check("aggregate: a soft answer beside a form nobody built cannot stand either", + cellOf([mk(1, "bare"), mk(2, "fixed")], [[1, out("RECOGNISED")]]).state === "HARNESS"); + check("aggregate: nor ACCEPT_ERR beside a form whose build failed", + cellOf([mk(1, "bare"), mk(2, "fixed")], [[1, out("ACCEPT_ERR")], [2, out("HARNESS")]]).state === "HARNESS"); + check("aggregate: a soft answer with every form built is what it is", + cellOf([mk(1, "bare"), mk(2, "fixed")], [[1, out("RECOGNISED")], [2, out("REJECT_SYNTAX")]]).state === "RECOGNISED"); + check("compare: so a target only a missing form could satisfy is untestable, not refused", + (() => { + const cells = new Map([["CLASS", cellOf([mk(1, "bare"), mk(2, "fixed")], [[1, out("RECOGNISED")]])]]); + const c = compare({ name: "ClassId", doc: { app: "Class" } }, cells); + return c.untestable.length === 1 && !c.missing.length; + })()); +} + +// ------------------------------------------------ what a code and a message are +{ + const sigs = new Map([["MODULE", signature(["TB5079"], ["TB5079 Unrecognized symbol 'ZzNoSuchAttributeProbe'"], CONTROL_NAME)]]); + const on = probe("attr", "MODULE", "On"); + check("classify: the same message under another code is not the control's", + stateOf(on, [err("TB5099 Unrecognized symbol 'On'")], sigs) === "RECOGNISED"); + check("classify: the same code with another message is not the control's", + stateOf(on, [err("TB5079 Unrecognized symbol 'On' in a call")], sigs) === "RECOGNISED"); + check("classify: a row with no code, on the attribute's line, is recognised", + stateOf(on, [err("something went wrong")], new Map()) === "RECOGNISED"); + check("classify: a row with no code, elsewhere, is the declaration's", + stateOf(on, [err("something went wrong", 3)], new Map()) === "ACCEPT_ERR"); + check("classify: a baseline shortcut is not taken for a row with no code", + stateOf(probe("attr", "ENUM_EMPTY", "PopulateFrom", "fixed"), [err("something went wrong", 3)], new Map()) !== "ACCEPT"); +} + +// ------------------------------------------------------------ the preflight +{ + const outs = (o) => new Map(Object.entries(o).map(([id, state]) => [Number(id), { state, codes: [], msgs: [`${state} message`] }])); + const base = (id, site) => probe("baseline", site, null, "bare", id); + const ctrl = (id, site) => probe("control", site, CONTROL_NAME, "bare", id); + const v = judgePreflight( + [base(1, "MODULE"), base(2, "CLASS"), base(3, "INTERFACE"), base(4, "COCLASS")], + [ctrl(11, "MODULE"), ctrl(12, "CLASS"), ctrl(13, "INTERFACE"), ctrl(14, "COCLASS")], + outs({ 1: "ACCEPT", 2: "ACCEPT_ERR", 3: "CRASH", 4: "ACCEPT", 11: "REJECT_SYNTAX", 12: "REJECT_SYNTAX", 13: "REJECT_SYNTAX", 14: "ACCEPT" })); + check("preflight: a site whose baseline does not build clean is voided, a crash included", + v.voidSites.has("CLASS") && v.voidSites.has("INTERFACE") && !v.voidSites.has("MODULE"), show([...v.voidSites])); + check("preflight: a site whose control compiles is voided", v.voidSites.get("COCLASS")?.includes("was accepted"), show([...v.voidSites])); + const w = judgePreflight([base(1, "MODULE")], [ctrl(11, "MODULE")], outs({ 1: "ACCEPT", 11: "HARNESS" })); + check("preflight: a control that could not be judged voids the site, and says so", w.voidSites.get("MODULE")?.includes("could not be judged"), show([...w.voidSites])); + check("preflight: the control's signature and state are kept for every site", + v.controlSig.size === 4 && v.controlState.size === 4 && v.controlSig.get("MODULE").includes("REJECT_SYNTAX message"), show([...v.controlSig])); +} + +// ------------------------------------------------------------------- verify +{ + const p = (id) => probe("attr", "MODULE", "A", "bare", id); + const first = [p(1), p(2), p(3), p(4)]; + const again = [p(11), p(12), p(13), p(14)]; + const before = new Map([[1, { state: "ACCEPT" }], [2, { state: "REJECT_SYNTAX" }], [3, { state: "ACCEPT" }], [4, { state: "REJECT_CONTEXT" }]]); + const got = new Map([[11, { state: "ACCEPT" }], [12, { state: "ACCEPT" }], [13, { state: "HARNESS" }]]); + const r = verifyMismatches(first, again, got, before); + check("verify: a different answer is a mismatch", r.bad.length === 1 && r.bad[0].site === "MODULE" && r.bad[0].first === "REJECT_SYNTAX" && r.bad[0].second === "ACCEPT", show(r)); + check("verify: a rebuild that failed, or that never came back, is not a mismatch but is counted", r.unjudged === 2, show(r)); +} + +// -------------------------------------------------------------- the runner +{ + const fake = (id) => ({ id, kind: "attr", name: `N${id}`, form: "bare", site: siteOf("MODULE"), attr: `[N${id}]`, tag: `S${pad(id, 6)}` }); + const many = (n) => Array.from({ length: n }, (_, i) => fake(i + 1)); + const has = (probes, ...want) => want.every((id) => probes.some((p) => p.id === id)); + const ok = (probes, extra = {}) => ({ kind: "ok", byFile: new Map(probes.map((p) => [p, []])), strays: [], canaries: { ...EXPECTED_CANARIES }, ...extra }); + const runner = (rule) => { + const built = []; + const r = createRunner({ buildOnce: async (probes) => { built.push(probes.map((p) => p.id)); return rule(probes); }, controlSig: new Map() }); + return { ...r, built }; + }; + const stateMap = (out) => new Map([...out].map(([k, v]) => [k, v.state])); + const onlyStates = (out, want, except) => [...stateMap(out)].every(([k, s]) => (except.includes(k) ? s === want : s === "ACCEPT")); + const throws = async (fn, re) => { try { await fn(); return false; } catch (e) { return re.test(e.message); } }; + const lane = {}; + + let R = runner((ps) => ok(ps)); + let out = await R.runSet(many(8), lane); + check("runner: a clean batch is one build", R.stats.builds === 1 && R.stats.splits === 0 && onlyStates(out, "ACCEPT", []), show(R.built)); + + R = runner((ps) => (has(ps, 5) ? { kind: "crash", named: new Set([5]) } : ok(ps))); + out = await R.runSet(many(8), lane); + check("runner: a crash that names its probe takes it out first: three builds, and only it is CRASH", + R.stats.builds === 3 && onlyStates(out, "CRASH", [5]) && R.stats.crashes.length === 1 && R.stats.crashes[0].id === 5 && !R.stats.comboCrashes.length, + show(R.built)); + + R = runner((ps) => (has(ps, 11) ? { kind: "crash", named: new Set([2]) } : ok(ps))); + out = await R.runSet(many(16), lane); + check("runner: a crash that names an innocent probe is still found, by halving, in a handful of builds", + onlyStates(out, "CRASH", [11]) && out.size === 16 && R.stats.builds <= 12, `${R.stats.builds} builds: ${show(R.built)}`); + + R = runner((ps) => (has(ps, 3, 12) ? { kind: "crash", named: new Set() } : ok(ps))); + out = await R.runSet(many(16), lane); + check("runner: a crash that needs two probes together keeps every answer and is recorded, not lost", + R.stats.comboCrashes.length === 1 && onlyStates(out, "ACCEPT", []) && !R.stats.crashes.length, show(R.stats.comboCrashes)); + + R = runner((ps) => (ps.length === 0 ? ok(ps) : has(ps, 7) ? { kind: "hung" } : ok(ps))); + out = await R.runSet(many(8), lane); + check("runner: a hang is isolated to its probe, and the canaries alone are rebuilt once, at the top", + onlyStates(out, "HUNG", [7]) && R.stats.hung.length === 1 && R.built.filter((b) => b.length === 0).length === 1, show(R.built)); + + R = runner(() => ({ kind: "hung" })); + check("runner: a hang beside nothing but the canaries is the IDE's, and stops the run", + await throws(() => R.runSet(many(8), lane), /never settles even with only the canaries/)); + + R = runner((ps) => (has(ps, 4) ? ok(ps, { canaries: { ...EXPECTED_CANARIES, context: "ACCEPT" } }) : ok(ps))); + out = await R.runSet(many(8), lane); + check("runner: a probe that disturbs the canaries is INTERFERES, and the rest keep their answers", + onlyStates(out, "INTERFERES", [4]) && R.stats.interferes.length === 1 && R.stats.canaryFails >= 1, show(R.built)); + + R = runner((ps) => (has(ps, 4, 9) ? ok(ps, { canaries: { ...EXPECTED_CANARIES, context: "ACCEPT" } }) : ok(ps))); + out = await R.runSet(many(16), lane); + check("runner: canaries that fail only for two probes together are recorded, and blame neither", + R.stats.comboCrashes.length === 1 && !R.stats.interferes.length && onlyStates(out, "ACCEPT", []), show(R.stats.comboCrashes)); + + R = runner((ps) => ok(ps, { canaries: { ...EXPECTED_CANARIES, clean: "REJECT_SYNTAX" } })); + check(`runner: more than ${MAX_INTERFERING_PROBES} interfering probes means the canaries are no check, and stops the run`, + await throws(() => R.runSet(many(MAX_INTERFERING_PROBES + 5), lane), new RegExp(`more than ${MAX_INTERFERING_PROBES}`))); + + R = runner((ps) => (has(ps, 6) ? ok(ps, { strays: ["{ERROR} /P/Sources/_ProbeFactory.twin [1,1]: TB5000 unresolved"] }) : ok(ps))); + out = await R.runSet(many(8), lane); + check("runner: an error row in no probe's file is put to the one probe that causes it, as RECOGNISED", + out.get(6).state === "RECOGNISED" && out.get(6).msgs[0].includes("unresolved") && onlyStates(out, "RECOGNISED", [6]) + && R.stats.strays.length === 1 && R.stats.strays[0].includes("[N6]"), show([...stateMap(out)])); + + R = runner((ps) => ok(ps, { strays: ["{ERROR} /P/x.twin [1,1]: TB5000 in every build"] })); + check(`runner: more than ${MAX_STRAY_PROBES} probes each drawing a stray row means the template does, and stops the run`, + await throws(() => R.runSet(many(MAX_STRAY_PROBES + 5), lane), new RegExp(`more than ${MAX_STRAY_PROBES}`))); + + R = runner(() => ({ kind: "harness", why: "the IDE would not start" })); + out = await R.runSet(many(6), lane); + check("runner: a build that could not run marks its probes HARNESS, without halving, and counts as a failure", + onlyStates(out, "HARNESS", [1, 2, 3, 4, 5, 6]) && out.failures === 1 && R.stats.builds === 1 && R.stats.harness.length === 1); + R = runner(() => ({ kind: "harness", why: "again" })); + check(`runner: more than ${MAX_HARNESS_FAILURES} builds that could not run stops the run`, + await throws(async () => { for (let i = 0; i < MAX_HARNESS_FAILURES + 1; i++) await R.runSet(many(2), lane); }, new RegExp(`more than ${MAX_HARNESS_FAILURES}`))); + + R = runner((ps) => (ps.length === 0 ? ok(ps) : { kind: "hung" })); + check(`runner: more than ${MAX_HUNG_PROBES} probes that hang means something is wrong with them all, and stops the run`, + await throws(async () => { for (let i = 1; i <= MAX_HUNG_PROBES + 1; i++) await R.runSet([fake(i)], lane); }, new RegExp(`more than ${MAX_HUNG_PROBES}`))); +} + +process.exit(report()); diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index a9c34659..b0d7f018 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -37,6 +37,7 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { DEFAULTS, parseCommandLine } from "../builder/command-line.mjs"; import { @@ -371,6 +372,24 @@ function capture(fn) { const err = capture((stream, exit) => printHelpAndExit("usage: tool\n", { stream, exit, exitCode: 2 })); check("printHelpAndExit takes the tool's exit code", err.text === "usage: tool\n" && err.code === 2, show(err)); } +{ + // exitOnCrash, in a process of its own since it ends the one it is in: a crash + // exits 2, the cleanup runs first, and a cleanup that throws changes neither. + const crash = (cleanup) => new Promise((resolve) => { + const cli = JSON.stringify(pathToFileURL(path.join(REPO_ROOT, "lib", "cli.mjs")).href); + execFile(process.execPath, ["--input-type=module", "-e", + `import { exitOnCrash } from ${cli}; exitOnCrash(${cleanup}); setTimeout(() => { throw new Error("boom"); }, 0);`], + (error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })); + }); + const bare = await crash(""); + check("exitOnCrash: a crash prints the error and exits 2", bare.code === 2 && bare.stderr.includes("boom"), show(bare)); + const saved = await crash(`() => console.log("saved")`); + check("exitOnCrash: the cleanup runs before the exit, and the exit is still 2", + saved.code === 2 && saved.stdout.trim() === "saved" && saved.stderr.includes("boom"), show(saved)); + const failing = await crash(`() => { throw new Error("cleanup failed"); }`); + check("exitOnCrash: a cleanup that throws does not stop the exit or hide the crash", + failing.code === 2 && failing.stderr.includes("boom") && failing.stderr.includes("cleanup failed"), show(failing)); +} // ------------------------------------------------------------ tbdocs's command line @@ -717,6 +736,7 @@ const HELP_TOOLS = { "scripts/check_a11y.mjs": null, "scripts/check_a11y_fingerprint.mjs": null, "scripts/check_axe_patch_equiv.mjs": null, + "scripts/check_attribute_sweep.mjs": null, "scripts/check_book_coverage.mjs": null, "scripts/check_ci_workflows.mjs": null, "scripts/check_cli.mjs": null, @@ -744,6 +764,7 @@ const HELP_TOOLS = { "scripts/pick_a11y_sample.mjs": null, "scripts/survey_tooling.mjs": null, "scripts/sweep_a11y.mjs": null, + "scripts/sweep_attributes.mjs": null, "scripts/tbbuild.mjs": null, "scripts/tbrun.mjs": null, }; @@ -791,6 +812,7 @@ const REFUSALS = { "scripts/check_a11y.mjs": ["root-dir"], "scripts/check_a11y_fingerprint.mjs": ["pages"], "scripts/check_axe_patch_equiv.mjs": ["patch"], + "scripts/check_attribute_sweep.mjs": [null], "scripts/check_book_coverage.mjs": [null], "scripts/check_ci_workflows.mjs": [null], "scripts/check_cli.mjs": [null], @@ -818,6 +840,7 @@ const REFUSALS = { "scripts/pick_a11y_sample.mjs": ["sweep"], "scripts/survey_tooling.mjs": ["root"], "scripts/sweep_a11y.mjs": ["out"], + "scripts/sweep_attributes.mjs": ["out"], "scripts/tbbuild.mjs": ["ide"], "scripts/tbrun.mjs": ["ide"], }; @@ -882,6 +905,25 @@ for (const [tool, first] of [["scripts/tbbuild.mjs", "x.twinproj"], ["scripts/tb bad(tool, [first, "--show", "--hide"], thenUsage("--show and --hide cannot be given together", tool)); } bad("scripts/tbrun.mjs", ["no-such-dir", "--quiet=-1"], thenUsage(NOT_WHOLE("--quiet", -1), "scripts/tbrun.mjs")); + +// sweep_attributes reads its values before it looks for an IDE, and follows the +// message with its usage. +{ + const tool = "scripts/sweep_attributes.mjs"; + bad(tool, ["--jobs", "0"], thenUsage(NOT_COUNT("--jobs", 0), tool)); + bad(tool, ["--port", "0"], thenUsage(NOT_PORT(0), tool)); + bad(tool, ["--port=65536"], thenUsage(NOT_PORT(65536), tool)); + bad(tool, ["--port", "65535", "--jobs", "2"], thenUsage("--port 65535 with --jobs 2 runs past port 65535", tool)); + bad(tool, ["--batch-size", "3"], thenUsage("--batch-size expects a whole number of at least 4, got: 3", tool)); + bad(tool, ["--verify=-1"], thenUsage(NOT_WHOLE("--verify", -1), tool)); + bad(tool, ["--timeout", "0"], thenUsage(NOT_ABOVE_ZERO("--timeout", 0), tool)); + bad(tool, ["--forms", "some"], thenUsage("--forms expects bare, smart or all, got: some", tool)); + bad(tool, ["--sites", "NO_SUCH_SITE"], thenUsage("unknown site: NO_SUCH_SITE (--list-sites names them)", tool)); + // The drive root is outside the temp folder however the case is run; a relative name would resolve + // under it, where the case runs. + bad(tool, ["--work=/"], new RegExp(`^--work must be under .+, not .+\\nusage: node ${literal(tool)} `)); + bad(tool, ["--show", "--hide"], thenUsage("--show and --hide cannot be given together", tool)); +} bad("scripts/tbrun.mjs", ["no-such-dir", "--quiet", "1.5"], thenUsage(NOT_WHOLE("--quiet", 1.5), "scripts/tbrun.mjs")); bad("scripts/addin_test.mjs", ["--only", "("], REGEX_REASON("--only", "(")); diff --git a/scripts/gen_attribute_probes.mjs b/scripts/gen_attribute_probes.mjs index 4c730ae7..3e2f2cd1 100644 --- a/scripts/gen_attribute_probes.mjs +++ b/scripts/gen_attribute_probes.mjs @@ -38,6 +38,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { parseAttributes, parseTargets } from "./lib/attributes-doc.mjs"; +import { MAIN_TWIN, NOT_FAITHFULLY_PROBEABLE, PROBE_FACTORY_TWIN, SETTINGS, UNSYNTHESISABLE, attrText, pad, writeCrlf, writeProbeResources, writeRaw } from "./lib/attribute-probe-kit.mjs"; import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; @@ -63,96 +64,11 @@ Exit codes: 0 the probe project and the key were written 2 a refused command line, or a crash`; -// --------------------------------------------------------------- arguments -// An attribute with a mandatory argument needs a value that is itself valid, or -// the compiler reports the argument instead of the applicability. GUIDs are -// unique per probe so two probes can never collide on one id. -const GUID_ATTRS = new Set([ - "ClassId", "CoClassId", "InterfaceId", "EventInterfaceId", - "EnumId", "FormDesignerId", -]); -const FIXED_ARGS = { - Description: '("attribute applicability probe")', - DispId: "(1000)", - IdeButton: '("probe")', - PackingAlignment: "(4)", - CompileIf: "(True)", - // The five below were once in UNSYNTHESISABLE, four of them for want of a - // usable argument value. `Attributes.md` states each shape but no value, and - // the shipped packages turned out to carry one apiece -- so these are copied - // from code the compiler already accepts rather than guessed: - // - // [CoClassCustomConstructor("CreatePropertyBagObject")] VBRUN/PropertyBag - // [CustomControl("/miscellaneous/frmButton.png")] CustomControlsPackage - // [PopulateFrom("json", "/Resources/MESSAGETABLE/Strings.json", - // "events", "name", "id")] Sample 22 - // [IgnoreWarnings(TB0001)] VB/QRCodeHelper - // - // The image and .json those two point at are written into the tree beside the - // probes, and the factory into `_ProbeFactory.twin`; see the emission in - // main(). All five probes build clean on BETA 983. - // - // Qualified, because the documented shape is "fully qualified path to factory - // method" and that is the claim under test. VBRUN uses the bare form too. - CoClassCustomConstructor: '("ProbeFactoryModule.ProbeFactory")', - CustomControl: '("/miscellaneous/probe.png")', - PopulateFrom: '("json", "/Resources/PROBE/Strings.json", "events", "name", "id")', - IgnoreWarnings: "(TB0001)", - // Documented as an optional Bool until the package census showed all 44 uses - // passing a toolbox image path. "no_designer" is the other accepted value and - // is what the probe uses, because it needs no file to resolve against. - WindowsControl: '("no_designer")', - // Listed as unsynthesisable on the grounds that "the option string vocabulary - // is not documented". It is: the entry documents +llvm, +optimize, - // +optimizesize and +optimizespeed. `+optimize` is used here rather than - // `+llvm`, which the page says cannot compile procedures taking objects, - // strings or dynamic arrays -- a probe should fail on its applicability or - // not at all. An empty string is also accepted, confirmed by the X04 probe. - CompilerOptions: '("+optimize")', - // Names a module procedure the compiler must resolve, and whose signature has - // to match the member carrying the attribute; `_ProbeFactory.twin` declares a - // matching `Sub ProbeRedirect()`. - // - // This probe is the reason the exercise was worth doing on entries written - // from a usage census: the census grouped uses by *declaration keyword* and - // reported "on a Property Get, a Function and a Sub", so the entry went out - // saying `procedure in a Class`. The probe put it on a class method and got - // TB5155. Grouping the same 82 uses by *enclosing construct* instead shows - // every one of them is inside an Interface -- `_App`, `_Clipboard`, `_Screen`, - // `_Forms`, `VBGlobal`. A census answers the question it was asked, and - // "which keyword" is not "where it is applicable". - RedirectToStaticImplementation: '("ProbeFactoryModule.ProbeRedirect")', -}; +// The argument shapes, the resource files they point at and the project's +// Settings are shared with sweep_attributes.mjs: see lib/attribute-probe-kit.mjs. -// Targets the packages evidence but a generic skeleton cannot probe -// faithfully. Excluded deliberately, and named in the key, because a probe that -// tests the wrong thing is worse than no probe: it fails for a reason that is -// not the documentation's and sends the reader after a defect that is not there. -const NOT_FAITHFULLY_PROBEABLE = { - CustomDesigner: "the designer name has to suit the property's type -- " + - "`designer_SpectrumWindows` is for an OLE_COLOR, `designer_MultiLineText` for a " + - "String -- so a rejection could mean the applicability or the pairing, and the " + - "probe could not tell you which. Applicability evidenced by 154 uses across " + - "four packages", - Enumerator: "the member has to return stdole.IUnknown or a Variant; the generic " + - "procedure skeleton returns neither, so the probe would test the return type " + - "rather than the applicability. Evidenced by 25 uses across five packages", - SpecialCompilerBinding: "the argument is an index into the compiler's own internal " + - "implementations -- the six uses in the VB package pass 1, 2, 3, 4 and 254 -- so " + - "there is no value a probe could pass that would test the applicability " + - "rather than the number. Evidenced by those six uses, on a Sub, a Declare " + - "and a Property Get", -}; -// Arguments that cannot be synthesised without something else being true. -// FormDesignerId earned its place the hard way: probed on a Class it reached -// TB5247 `unable to find matching form designer JSON`, which is the compiler -// accepting the applicability and then failing a lookup. That confirms the -// documented applicability and tells us nothing further, so it is not worth a -// probe. -const UNSYNTHESISABLE = { - FormDesignerId: "needs a form designer JSON to match; probing it reached TB5247, " + - "which already confirms the documented applicability on a Class", -}; +// NOT_FAITHFULLY_PROBEABLE and UNSYNTHESISABLE live in lib/attribute-probe-kit.mjs, +// where sweep_attributes.mjs reads them too. // Attributes the compiler allows only once per project, so their second and // later targets cannot share a project with the first. TB5114 for @@ -176,35 +92,6 @@ const SINGLETON = { // parser now covers it and the extra probes would only duplicate themselves. const EXTRA_PROBES = []; -// Resources the argument forms above refer to. `import` packs the whole tree, -// so these ride along into the .twinproj exactly as a hand-made project's would. -// -// The PNG is a 1x1 opaque black image, written as bytes rather than fetched: -// [CustomControl] needs a real image at the path, not merely a path. -const PROBE_PNG = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9" + - "awAAAABJRU5ErkJggg==", - "base64", -); -const PROBE_STRINGS_JSON = JSON.stringify( - { events: [{ id: 1, name: "probe_event_one" }, { id: 2, name: "probe_event_two" }] }, - null, - 4, -) + "\n"; -// [CoClassCustomConstructor] names a factory the compiler must be able to -// resolve. VBRUN's real one is `() As stdole.IUnknown`; this mirrors it. -const PROBE_FACTORY_TWIN = - "' Targets that probes name by string. ProbeFactory is for\n" + - "' [CoClassCustomConstructor] and mirrors VBRUN's CreatePropertyBagObject,\n" + - "' which is `() As stdole.IUnknown`. ProbeRedirect is for\n" + - "' [RedirectToStaticImplementation], and its signature must match the\n" + - "' PROC_CLASS skeleton's `Public Sub Probe()`.\n\n" + - "Public Module ProbeFactoryModule\n" + - " Public Function ProbeFactory() As stdole.IUnknown\n" + - " End Function\n\n" + - " Public Sub ProbeRedirect()\n" + - " End Sub\n" + - "End Module\n"; // ------------------------------------------------------------ exploratory // A third project, `AttributeExplore`, on the opposite contract to the probes @@ -863,14 +750,7 @@ const EXPLORATORY = [ }, ]; -const pad = (n, width) => String(n).padStart(width, "0"); -function attrText(name, idx) { - if (GUID_ATTRS.has(name)) return `[${name}("00000000-0000-0000-0000-${pad(idx, 12)}")]`; - if (Object.hasOwn(FIXED_ARGS, name)) return `[${name}${FIXED_ARGS[name]}]`; - if (name === "TypeHint") return `[TypeHint(ProbeHintEnum${pad(idx, 3)})]`; - return `[${name}]`; -} // --------------------------------------------------------------- renderers // Returns the body of a .twin file placing `attr` at `target`. @@ -984,60 +864,8 @@ const HUMAN = { EVENT_CLASS: "on an Event in a Class", }; -// Built as an object and serialised, rather than held as a literal blob, so the -// backslashes in the paths are escaped by JSON.stringify rather than by hand. -// Tab indent and key order match what the IDE writes. -const SETTINGS_OBJ = { - "configuration.inherits": "Defaults", - "project.appTitle": "Attribute applicability probes", - "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", - "project.buildType": "Standard EXE", - "project.description": "Generated from docs/Reference/Attributes.md. Every module is expected to compile; a diagnostic is a finding.", - "project.exportPathIsV2": true, - "project.id": "{A77B1BE0-0000-4000-8000-000000000001}", - "project.name": "AttributeProbes", - "project.optionExplicit": true, - "project.references": [ - { - id: "{00020430-0000-0000-C000-000000000046}", - lcid: 0, - name: "OLE Automation", - path32: "C:\\Windows\\SysWOW64\\stdole2.tlb", - path64: "C:\\Windows\\System32\\stdole2.tlb", - symbolId: "stdole", - versionMajor: 2, - versionMinor: 0, - }, - { - hasBeenSplit: true, - id: "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", - isCompilerPackage: true, - licence: "MIT", - name: "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", - path32: "", - path64: "", - publisher: "TWINBASIC-COMPILER", - symbolId: "VB", - versionBuild: 0, - versionMajor: 0, - versionMinor: 0, - versionRevision: 31, - }, - ], - "project.settingsVersion": 1, - "project.startupObject": "Sub Main", - "project.warnings": { errors: [], hints: [], ignored: [], info: [], warnings: [] }, - "runtime.useUnicodeStandardLibrary": true, -}; -const SETTINGS = JSON.stringify(SETTINGS_OBJ, null, "\t") + "\n"; -// The .twin sources are written CRLF; the Settings blob and the key are written -// exactly as composed. -const writeCrlf = (file, text) => fs.writeFile(file, text.replace(/\n/g, "\r\n"), "utf8"); -const writeRaw = (file, text) => fs.writeFile(file, text, "utf8"); -const MAIN_TWIN = "' Startup object for the probe project. Does nothing.\n\n" + - "Module ProbeMain\n Public Sub Main()\n End Sub\nEnd Module\n"; async function main(argv) { const { values, positionals } = withUsageError(() => parseCli(argv, { @@ -1137,14 +965,7 @@ async function main(argv) { await writeCrlf(path.join(srcDir, "_ProbeFactory.twin"), PROBE_FACTORY_TWIN); await writeRaw(path.join(out, "Settings"), SETTINGS); - // Resource folders the argument forms point at. Named to match the paths in - // FIXED_ARGS; the leading `/` in those paths is project-root-relative, and - // the lookup is case-insensitive (the packages write `/miscellaneous/` for a - // folder the IDE shows as `Miscellaneous`). - await fs.mkdir(path.join(out, "Miscellaneous"), { recursive: true }); - await fs.writeFile(path.join(out, "Miscellaneous", "probe.png"), PROBE_PNG); - await fs.mkdir(path.join(out, "Resources", "PROBE"), { recursive: true }); - await writeRaw(path.join(out, "Resources", "PROBE", "Strings.json"), PROBE_STRINGS_JSON); + await writeProbeResources(out); if (overflow.length) { await writeCrlf(path.join(overflowSrc, "_ProbeMain.twin"), MAIN_TWIN); await writeRaw( diff --git a/scripts/lib/attribute-probe-kit.mjs b/scripts/lib/attribute-probe-kit.mjs new file mode 100644 index 00000000..76e1d623 --- /dev/null +++ b/scripts/lib/attribute-probe-kit.mjs @@ -0,0 +1,223 @@ +// What an attribute probe project needs, shared by the two tools that write one: +// gen_attribute_probes.mjs (a probe for each target Attributes.md claims) and +// sweep_attributes.mjs (every attribute at every site, to find what the page does +// not say). +// +// Held once because the values are hard-won. An attribute with a mandatory +// argument needs one that is itself valid, or the compiler reports the argument +// instead of the applicability, and each entry below was found by getting it +// wrong. Two tools with two copies would drift, and the drift would read as a +// finding about the compiler. + +import { mkdirSync, promises as fs, writeFileSync } from "node:fs"; +import path from "node:path"; + +export const pad = (n, width) => String(n).padStart(width, "0"); + +// GUIDs are unique per probe so two probes can never collide on one id. +export const GUID_ATTRS = new Set([ + "ClassId", "CoClassId", "InterfaceId", "EventInterfaceId", + "EnumId", "FormDesignerId", +]); + +export const FIXED_ARGS = { + Description: '("attribute applicability probe")', + DispId: "(1000)", + IdeButton: '("probe")', + PackingAlignment: "(4)", + CompileIf: "(True)", + // The five below were once in UNSYNTHESISABLE, four of them for want of a + // usable argument value. `Attributes.md` states each shape but no value, and + // the shipped packages turned out to carry one apiece -- so these are copied + // from code the compiler already accepts rather than guessed: + // + // [CoClassCustomConstructor("CreatePropertyBagObject")] VBRUN/PropertyBag + // [CustomControl("/miscellaneous/frmButton.png")] CustomControlsPackage + // [PopulateFrom("json", "/Resources/MESSAGETABLE/Strings.json", + // "events", "name", "id")] Sample 22 + // [IgnoreWarnings(TB0001)] VB/QRCodeHelper + // + // The image and .json those two point at are written into the tree beside the + // probes (writeProbeResources), and the factory into `_ProbeFactory.twin`. + // All five probes build clean on BETA 983. + // + // Qualified, because the documented shape is "fully qualified path to factory + // method" and that is the claim under test. VBRUN uses the bare form too. + CoClassCustomConstructor: '("ProbeFactoryModule.ProbeFactory")', + CustomControl: '("/miscellaneous/probe.png")', + PopulateFrom: '("json", "/Resources/PROBE/Strings.json", "events", "name", "id")', + IgnoreWarnings: "(TB0001)", + // Documented as an optional Bool until the package census showed all 44 uses + // passing a toolbox image path. "no_designer" is the other accepted value and + // is what the probe uses, because it needs no file to resolve against. + WindowsControl: '("no_designer")', + // The entry documents +llvm, +optimize, +optimizesize and +optimizespeed. + // `+optimize` is used here rather than `+llvm`, which the page says cannot + // compile procedures taking objects, strings or dynamic arrays -- a probe + // should fail on its applicability or not at all. An empty string is also + // accepted, confirmed by the X04 probe. + CompilerOptions: '("+optimize")', + // Names a module procedure the compiler must resolve, and whose signature has + // to match the member carrying the attribute; `_ProbeFactory.twin` declares a + // matching `Sub ProbeRedirect()`. + // + // This probe is the reason the exercise was worth doing on entries written + // from a usage census: the census grouped uses by *declaration keyword* and + // reported "on a Property Get, a Function and a Sub", so the entry went out + // saying `procedure in a Class`. The probe put it on a class method and got + // TB5155. Grouping the same 82 uses by *enclosing construct* instead shows + // every one of them is inside an Interface -- `_App`, `_Clipboard`, `_Screen`, + // `_Forms`, `VBGlobal`. A census answers the question it was asked, and + // "which keyword" is not "where it is applicable". + RedirectToStaticImplementation: '("ProbeFactoryModule.ProbeRedirect")', +}; + +/** The attribute as written in a probe; `idx` keeps GUID and hint-enum ids unique. */ +export function attrText(name, idx) { + if (GUID_ATTRS.has(name)) return `[${name}("00000000-0000-0000-0000-${pad(idx, 12)}")]`; + if (Object.hasOwn(FIXED_ARGS, name)) return `[${name}${FIXED_ARGS[name]}]`; + if (name === "TypeHint") return `[TypeHint(ProbeHintEnum${pad(idx, 3)})]`; + return `[${name}]`; +} + +// The PNG is a 1x1 opaque black image, written as bytes rather than fetched: +// [CustomControl] needs a real image at the path, not merely a path. +export const PROBE_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9" + + "awAAAABJRU5ErkJggg==", + "base64", +); +export const PROBE_STRINGS_JSON = JSON.stringify( + { events: [{ id: 1, name: "probe_event_one" }, { id: 2, name: "probe_event_two" }] }, + null, + 4, +) + "\n"; +// [CoClassCustomConstructor] names a factory the compiler must be able to +// resolve. VBRUN's real one is `() As stdole.IUnknown`; this mirrors it. +export const PROBE_FACTORY_TWIN = + "' Targets that probes name by string. ProbeFactory is for\n" + + "' [CoClassCustomConstructor] and mirrors VBRUN's CreatePropertyBagObject,\n" + + "' which is `() As stdole.IUnknown`. ProbeRedirect is for\n" + + "' [RedirectToStaticImplementation], and its signature must match the\n" + + "' PROC_CLASS skeleton's `Public Sub Probe()`.\n\n" + + "Public Module ProbeFactoryModule\n" + + " Public Function ProbeFactory() As stdole.IUnknown\n" + + " End Function\n\n" + + " Public Sub ProbeRedirect()\n" + + " End Sub\n" + + "End Module\n"; + +// Built as an object and serialised, rather than held as a literal blob, so the +// backslashes in the paths are escaped by JSON.stringify rather than by hand. +// Tab indent and key order match what the IDE writes. +const SETTINGS_OBJ = { + "configuration.inherits": "Defaults", + "project.appTitle": "Attribute applicability probes", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "Generated from docs/Reference/Attributes.md. Every module is expected to compile; a diagnostic is a finding.", + "project.exportPathIsV2": true, + "project.id": "{A77B1BE0-0000-4000-8000-000000000001}", + "project.name": "AttributeProbes", + "project.optionExplicit": true, + "project.references": [ + { + id: "{00020430-0000-0000-C000-000000000046}", + lcid: 0, + name: "OLE Automation", + path32: "C:\\Windows\\SysWOW64\\stdole2.tlb", + path64: "C:\\Windows\\System32\\stdole2.tlb", + symbolId: "stdole", + versionMajor: 2, + versionMinor: 0, + }, + { + hasBeenSplit: true, + id: "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + isCompilerPackage: true, + licence: "MIT", + name: "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)", + path32: "", + path64: "", + publisher: "TWINBASIC-COMPILER", + symbolId: "VB", + versionBuild: 0, + versionMajor: 0, + versionMinor: 0, + versionRevision: 31, + }, + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { errors: [], hints: [], ignored: [], info: [], warnings: [] }, + "runtime.useUnicodeStandardLibrary": true, +}; + +/** The Settings file text for a probe project, with the name and id it needs. */ +export function settingsText(overrides = {}) { + return JSON.stringify({ ...SETTINGS_OBJ, ...overrides }, null, "\t") + "\n"; +} + +// The same Settings text with the generator's names swapped, as the overflow and +// exploratory projects need. Kept as the original text-replacement so the files +// gen_attribute_probes writes stay byte-identical. +export const SETTINGS = settingsText(); + +export const MAIN_TWIN = "' Startup object for the probe project. Does nothing.\n\n" + + "Module ProbeMain\n Public Sub Main()\n End Sub\nEnd Module\n"; + +// The .twin sources are written CRLF; the Settings blob and the key are written +// exactly as composed. +export const writeCrlf = (file, text) => fs.writeFile(file, text.replace(/\n/g, "\r\n"), "utf8"); +export const writeRaw = (file, text) => fs.writeFile(file, text, "utf8"); + +/** + * The files the argument forms above point at. Named to match the paths in + * FIXED_ARGS; the leading `/` in those paths is project-root-relative, and the + * lookup is case-insensitive (the packages write `/miscellaneous/` for a folder + * the IDE shows as `Miscellaneous`). + */ +export async function writeProbeResources(out) { + await fs.mkdir(path.join(out, "Miscellaneous"), { recursive: true }); + await fs.writeFile(path.join(out, "Miscellaneous", "probe.png"), PROBE_PNG); + await fs.mkdir(path.join(out, "Resources", "PROBE"), { recursive: true }); + await writeRaw(path.join(out, "Resources", "PROBE", "Strings.json"), PROBE_STRINGS_JSON); +} + +/** writeProbeResources for a caller that stages a project synchronously. */ +export function writeProbeResourcesSync(out) { + mkdirSync(path.join(out, "Miscellaneous"), { recursive: true }); + writeFileSync(path.join(out, "Miscellaneous", "probe.png"), PROBE_PNG); + mkdirSync(path.join(out, "Resources", "PROBE"), { recursive: true }); + writeFileSync(path.join(out, "Resources", "PROBE", "Strings.json"), PROBE_STRINGS_JSON, "utf8"); +} + +// Targets the packages evidence but a generic skeleton cannot probe +// faithfully. Excluded deliberately, and named in the key, because a probe that +// tests the wrong thing is worse than no probe: it fails for a reason that is +// not the documentation's and sends the reader after a defect that is not there. +export const NOT_FAITHFULLY_PROBEABLE = { + CustomDesigner: "the designer name has to suit the property's type -- " + + "`designer_SpectrumWindows` is for an OLE_COLOR, `designer_MultiLineText` for a " + + "String -- so a rejection could mean the applicability or the pairing, and the " + + "probe could not tell you which. Applicability evidenced by 154 uses across " + + "four packages", + Enumerator: "the member has to return stdole.IUnknown or a Variant; the generic " + + "procedure skeleton returns neither, so the probe would test the return type " + + "rather than the applicability. Evidenced by 25 uses across five packages", + SpecialCompilerBinding: "the argument is an index into the compiler's own internal " + + "implementations -- the six uses in the VB package pass 1, 2, 3, 4 and 254 -- so " + + "there is no value a probe could pass that would test the applicability " + + "rather than the number. Evidenced by those six uses, on a Sub, a Declare " + + "and a Property Get", +}; +// Arguments that cannot be synthesised without something else being true. +// FormDesignerId earned its place the hard way: probed on a Class it reached +// TB5247 `unable to find matching form designer JSON`, which is the compiler +// accepting the applicability and then failing a lookup. That confirms the +// documented applicability and tells us nothing further, so it is not worth a +// probe. +export const UNSYNTHESISABLE = { + FormDesignerId: "needs a form designer JSON to match; probing it reached TB5247, " + + "which already confirms the documented applicability on a Class", +}; diff --git a/scripts/lib/attribute-sites.mjs b/scripts/lib/attribute-sites.mjs new file mode 100644 index 00000000..16771491 --- /dev/null +++ b/scripts/lib/attribute-sites.mjs @@ -0,0 +1,309 @@ +// Every place an attribute can be written, as a skeleton that compiles without it. +// +// sweep_attributes.mjs puts each attribute at each of these sites and asks the +// compiler. A site is a claim about the compiler's grammar, so it has to be +// right on its own: the sweep first builds every skeleton with NO attribute, and +// a site that does not build clean is voided in the report rather than trusted. +// That check is what stops a wrong skeleton reading as "every attribute is +// refused here". +// +// A site id is the target name `parseTargets` (attributes-doc.mjs) and +// gen_attribute_probes.mjs already use wherever one exists -- MODULE, PROC_CLASS +// -- so a result can be laid against an `Applicable to:` line. Where the +// documentation's phrase covers several sites (`procedure in a Class` is a Sub, +// a Function and a property), FAMILIES says which, and the report can then say +// that a line is only partly true. + +/** + * The attribute goes where `${A}` is. `inline` marks a site whose attribute + * shares a line with the declaration it decorates (an Implements statement, an + * Interface line inside a CoClass), because those are the sites the compiler + * treats as a different kind of target from a free-standing declaration. + * + * `${T}` is the probe's unique module or class name, and `${U}` a second unique + * name for the helper types a skeleton declares. A Public Enum's name and + * members are project-global, so two probes sharing one collide with TB5000, + * which reads like a finding and is not. + */ +const SITES = [ + // ---- whole declarations ------------------------------------------------ + ["MODULE", "on a Module", + "${A}\nPublic Module ${T}\nEnd Module\n"], + ["CLASS", "on a Class", + "${A}\nPublic Class ${T}\nEnd Class\n"], + ["INTERFACE", "on an Interface", + "${A}\nPublic Interface ${T}\n Sub Ping()\nEnd Interface\n"], + ["COCLASS", "on a CoClass", + "Public Interface ${U}\n Sub Ping()\nEnd Interface\n\n" + + "${A}\nPublic CoClass ${T}\n Interface ${U}\nEnd CoClass\n"], + ["ENUM", "on an Enum", + "Public Module ${T}\n ${A}\n Public Enum ${U}\n ${U}_One = 1\n End Enum\nEnd Module\n"], + // [PopulateFrom] fills the enum from a .json resource, so its body is empty + // (Sample 22 declares `Enum EVENTS / End Enum`); a hand-written member beside + // the compiler's would test two things at once. + // + // An empty Enum is itself an error, TB5233, which is the whole reason the + // site exists: only an attribute that fills the enum removes it. The site + // declares that one code as its own, so a probe that draws nothing else is + // read as clean. + ["ENUM_EMPTY", "on an empty Enum", + "Public Module ${T}\n ${A}\n Public Enum ${U}\n End Enum\nEnd Module\n", { baselineCodes: ["TB5233"] }], + ["ENUM_CLASS", "on an Enum in a Class", + "Public Class ${T}\n ${A}\n Public Enum ${U}\n ${U}_One = 1\n End Enum\nEnd Class\n"], + ["TYPE", "on a Type (UDT)", + "Public Module ${T}\n ${A}\n Public Type ${U}\n Field1 As Long\n End Type\nEnd Module\n"], + ["TYPE_CLASS", "on a Type in a Class", + "Public Class ${T}\n ${A}\n Public Type ${U}\n Field1 As Long\n End Type\nEnd Class\n"], + ["DELEGATE", "on a Delegate", + "Public Module ${T}\n ${A}\n Public Delegate Sub ${U}()\nEnd Module\n"], + + // ---- members of an Enum or a Type -------------------------------------- + // An Enum MEMBER is not the Enum: [Hidden] is accepted on one and refused on + // the other, so one skeleton would answer for both and get one wrong. + // + // The Type field is written twice, inline (`[Name] Field1 As Long`) and on + // its own line, because packages write it either way and both are legal. + // + // The Enum member is written ONLY on its own line, and that site is voided by + // the sweep. Measured on BETA 987: inline, the compiler refuses every + // attribute, [Hidden] included (TB5182), though Attributes.md documents + // [Hidden] on an Enum member; on its own line it accepts ANY `[...]`, + // `[ClassId("guid")]` and `[Hidden(True)]` among them, because an Enum body + // reads a bare `[Name]` line as something else (an escaped member). The + // unknown-name control therefore compiles, the site is voided, and an Enum + // member target is reported as one the sweep cannot test. + ["ENUM_MEMBER", "on an Enum member, on its own line", + "Public Module ${T}\n Public Enum ${U}\n ${A}\n ${U}_One = 1\n End Enum\nEnd Module\n"], + ["TYPE_FIELD", "on a Type field, inline", + "Public Module ${T}\n Public Type ${U}\n ${A} Field1 As Long\n End Type\nEnd Module\n", { inline: true }], + ["TYPE_FIELD_LINE", "on a Type field, on its own line", + "Public Module ${T}\n Public Type ${U}\n ${A}\n Field1 As Long\n End Type\nEnd Module\n"], + + // ---- procedures in a Module -------------------------------------------- + ["SUB_MODULE", "on a Sub in a Module", + "Public Module ${T}\n ${A}\n Public Sub Probe()\n End Sub\nEnd Module\n"], + ["SUB_MODULE_PRIVATE", "on a Private Sub in a Module", + "Public Module ${T}\n ${A}\n Private Sub Probe()\n End Sub\nEnd Module\n"], + ["FUNC_MODULE", "on a Function in a Module", + "Public Module ${T}\n ${A}\n Public Function Probe() As Long\n End Function\nEnd Module\n"], + // [RunBeforeStartupObject] is documented as returning a Boolean, and the + // generic skeleton returns Long, so probing it there would test a signature + // the documentation does not claim. + ["FUNC_MODULE_BOOL", "on a Boolean Function in a Module", + "Public Module ${T}\n ${A}\n Public Function Probe() As Boolean\n End Function\nEnd Module\n"], + ["FUNC_MODULE_OBJECT", "on a Function returning an object in a Module", + "Public Module ${T}\n ${A}\n Public Function Probe() As stdole.IUnknown\n End Function\nEnd Module\n"], + ["PROPGET_MODULE", "on a Property Get in a Module", + "Public Module ${T}\n ${A}\n Public Property Get Probe() As Long\n End Property\nEnd Module\n"], + + // ---- declarations of API functions ------------------------------------- + ["DECLARE", "on a Declare Function in a Module", + "Public Module ${T}\n ${A}\n Public Declare Function Probe Lib \"kernel32\" Alias \"GetTickCount\" () As Long\nEnd Module\n"], + ["DECLARE_SUB", "on a Declare Sub in a Module", + "Public Module ${T}\n ${A}\n Public Declare Sub Probe Lib \"kernel32\" Alias \"Sleep\" (ByVal Milliseconds As Long)\nEnd Module\n"], + ["DECLARE_PRIVATE", "on a Private Declare in a Module", + "Public Module ${T}\n ${A}\n Private Declare Function Probe Lib \"kernel32\" Alias \"GetTickCount\" () As Long\nEnd Module\n"], + ["DECLARE_CLASS", "on a Declare in a Class", + "Public Class ${T}\n ${A}\n Private Declare Function Probe Lib \"kernel32\" Alias \"GetTickCount\" () As Long\nEnd Class\n"], + + // ---- constants and variables ------------------------------------------- + ["CONST", "on a Const in a Module", + "Public Module ${T}\n ${A}\n Public Const ProbeConst As Long = 1\nEnd Module\n"], + ["CONST_PRIVATE", "on a Private Const in a Module", + "Public Module ${T}\n ${A}\n Private Const ProbeConst As Long = 1\nEnd Module\n"], + // Private, because the compiler refuses a Public Const in a Class (TB5250). + ["CONST_CLASS", "on a Const in a Class", + "Public Class ${T}\n ${A}\n Private Const ProbeConst As Long = 1\nEnd Class\n"], + ["VAR_MODULE", "on a variable in a Module", + "Public Module ${T}\n ${A}\n Public ProbeVar As Long\nEnd Module\n"], + ["VAR_CLASS", "on a variable in a Class", + "Public Class ${T}\n ${A}\n Public ProbeVar As Long\nEnd Class\n"], + ["VAR_CLASS_PRIVATE", "on a Private variable in a Class", + "Public Class ${T}\n ${A}\n Private ProbeVar As Long\nEnd Class\n"], + ["VAR_CLASS_WITHEVENTS", "on a WithEvents variable in a Class", + "Public Class ${U}\n Public Event Pinged()\nEnd Class\n\n" + + "Public Class ${T}\n ${A}\n Private WithEvents ProbeVar As ${U}\nEnd Class\n"], + ["VAR_LOCAL", "on a local variable, inline", + "Public Module ${T}\n Public Sub Probe()\n ${A} Dim x As Long\n End Sub\nEnd Module\n", { inline: true }], + // ---- more procedures, variables, declarations, parameters ---------------- + ["VAR_MODULE_PRIVATE", "on a Private variable in a Module", + "Public Module ${T}\n ${A}\n Private ProbeVar As Long\nEnd Module\n"], + ["VAR_MODULE_INIT", "on an initialised variable in a Module", + "Public Module ${T}\n ${A}\n Public ProbeVar As Long = 5\nEnd Module\n"], + ["SUB_STATIC_MODULE", "on a Static Sub in a Module", + "Public Module ${T}\n ${A}\n Public Static Sub Probe()\n End Sub\nEnd Module\n"], + ["SUB_GENERIC", "on a generic Sub in a Module", + "Public Module ${T}\n ${A}\n Public Sub Probe(Of X)()\n End Sub\nEnd Module\n"], + ["CLASS_GENERIC", "on a generic Class", + "${A}\nPublic Class ${T}(Of X)\nEnd Class\n"], + ["CLASS_PRIVATE", "on a Private Class", + "${A}\nPrivate Class ${T}\nEnd Class\n"], + ["SUB_FRIEND_CLASS", "on a Friend Sub in a Class", + "Public Class ${T}\n ${A}\n Friend Sub Probe()\n End Sub\nEnd Class\n"], + ["FUNC_CLASS_OBJECT", "on a Function returning an object in a Class", + "Public Class ${T}\n ${A}\n Public Function Probe() As stdole.IUnknown\n End Function\nEnd Class\n"], + ["PROPSET_CLASS", "on a Property Set in a Class", + "Public Class ${T}\n Private mO As Object\n Public Property Get Probe() As Object\n Return mO\n End Property\n\n" + + " ${A}\n Public Property Set Probe(ByVal Value As Object)\n Set mO = Value\n End Property\nEnd Class\n"], + ["FUNC_INTERFACE_OBJECT", "on an object-returning Function prototype in an Interface", + "Public Interface ${T}\n ${A}\n Function Probe() As stdole.IUnknown\nEnd Interface\n"], + ["DECLARE_PTRSAFE", "on a Declare PtrSafe in a Module", + "Public Module ${T}\n ${A}\n Public Declare PtrSafe Function Probe Lib \"kernel32\" Alias \"GetTickCount\" () As Long\nEnd Module\n"], + ["DECLARE_WIDE", "on a DeclareWide in a Module", + "Public Module ${T}\n ${A}\n Public DeclareWide Function Probe Lib \"kernel32\" Alias \"GetTickCount\" () As Long\nEnd Module\n"], + ["PARAM_OPTIONAL", "on an Optional parameter", + "Public Module ${T}\n Public Sub Probe(${A} Optional ByVal Value As Long = 0)\n End Sub\nEnd Module\n", { inline: true }], + ["PARAM_PARAMARRAY", "on a ParamArray parameter", + "Public Module ${T}\n Public Sub Probe(${A} ParamArray Values() As Variant)\n End Sub\nEnd Module\n", { inline: true }], + + // ---- members of a Class ------------------------------------------------ + ["PROC_CLASS", "on a Sub in a Class", + "Public Class ${T}\n ${A}\n Public Sub Probe()\n End Sub\nEnd Class\n"], + ["FUNC_CLASS", "on a Function in a Class", + "Public Class ${T}\n ${A}\n Public Function Probe() As Long\n End Function\nEnd Class\n"], + ["PROPGET_CLASS", "on a Property Get in a Class", + "Public Class ${T}\n Private mV As Long\n ${A}\n Public Property Get Probe() As Long\n Return mV\n End Property\n\n" + + " Public Property Let Probe(ByVal Value As Long)\n mV = Value\n End Property\nEnd Class\n"], + ["PROPLET_CLASS", "on a Property Let in a Class", + "Public Class ${T}\n Private mV As Long\n Public Property Get Probe() As Long\n Return mV\n End Property\n\n" + + " ${A}\n Public Property Let Probe(ByVal Value As Long)\n mV = Value\n End Property\nEnd Class\n"], + ["EVENT_CLASS", "on an Event in a Class", + "Public Class ${T}\n ${A}\n Public Event Probed()\nEnd Class\n"], + ["CLASS_INITIALIZE", "on Class_Initialize", + "Public Class ${T}\n ${A}\n Private Sub Class_Initialize()\n End Sub\nEnd Class\n"], + + // ---- members of an Interface ------------------------------------------- + ["PROC_INTERFACE", "on a prototype in an Interface", + "Public Interface ${T}\n ${A}\n Sub Probe()\nEnd Interface\n"], + ["FUNC_INTERFACE", "on a Function prototype in an Interface", + "Public Interface ${T}\n ${A}\n Function Probe() As Long\nEnd Interface\n"], + ["PROPGET_INTERFACE", "on a Property Get prototype in an Interface", + "Public Interface ${T}\n ${A}\n Property Get Probe() As Long\nEnd Interface\n"], + + // ---- statements that share a line with their attribute ----------------- + ["COCLASS_INTERFACE", "on an Interface line inside a CoClass", + "Public Interface ${U}\n Sub Ping()\nEnd Interface\n\n" + + "Public CoClass ${T}\n ${A} Interface ${U}\nEnd CoClass\n", { inline: true }], + ["IMPLEMENTS", "on an Implements statement in a Class", + "Public Interface ${U}\n Sub Ping()\nEnd Interface\n\n" + + "Public Class ${T}\n ${A} Implements ${U}\n\n Private Sub ${U}_Ping() Implements ${U}.Ping\n End Sub\nEnd Class\n", + { inline: true }], + ["IMPLEMENTS_VIA_FIELD", "on an Implements ... Via field statement", + "Public Interface ${U}\n Sub Ping()\nEnd Interface\n\n" + + "Public Class ${U}_Base\n Implements ${U}\n\n Private Sub ${U}_Ping() Implements ${U}.Ping\n End Sub\nEnd Class\n\n" + + "Public Class ${T}\n ${A} Implements ${U} Via mBase = New ${U}_Base\nEnd Class\n", + { inline: true }], + ["IMPLEMENTS_VIA_CLASS", "on an Implements ... Via Class statement", + "Public Interface ${U}\n Sub Ping()\nEnd Interface\n\n" + + "Public Class ${U}_Base\n Implements ${U}\n\n Private Sub ${U}_Ping() Implements ${U}.Ping\n End Sub\nEnd Class\n\n" + + "Public Class ${T}\n ${A} Implements ${U} Via ${U}_Base\nEnd Class\n", + { inline: true }], + ["INHERITS", "on an Inherits statement", + "Public Class ${U}\n Public Sub Ping()\n End Sub\nEnd Class\n\n" + + "Public Class ${T}\n ${A} Inherits ${U}\nEnd Class\n", { inline: true }], + + // ---- parameters -------------------------------------------------------- + ["PARAM", "on a procedure parameter", + "Public Module ${T}\n Public Sub Probe(${A} ByVal Value As Long)\n End Sub\nEnd Module\n", { inline: true }], + ["PARAM_DECLARE", "on a Declare parameter", + "Public Module ${T}\n Public Declare Sub Probe Lib \"kernel32\" Alias \"Sleep\" (${A} ByVal Milliseconds As Long)\nEnd Module\n", + { inline: true }], + ["PARAM_INTERFACE", "on an Interface prototype parameter", + "Public Interface ${T}\n Sub Probe(${A} ByVal Value As Long)\nEnd Interface\n", { inline: true }], +]; + +// `Probe`, `ProbeConst` and `ProbeVar` become `${M}`, unique per probe. Members +// of a module are project-global, so a hundred probes each exporting `Probe` +// (or `ProbeConst`, for [DllExport] and [ComExport]) would collide with each +// other and the collision would read as a finding about the attribute. +export const SITE_LIST = SITES.map(([id, human, template, opts = {}]) => ({ + id, human, template: template.replace(/\bProbe(Const|Var)?\b/g, () => "${M}"), inline: !!opts.inline, + // Codes the skeleton draws with no attribute at all, and that are not the attribute's doing. + baselineCodes: new Set(opts.baselineCodes ?? []), +})); +export const SITE_IDS = new Set(SITE_LIST.map((s) => s.id)); + +/** + * What an `Applicable to:` target from `parseTargets` covers. The documentation + * says `procedure in a Class` and means all four of these; if the compiler + * accepts three and refuses one, that is a qualification the page does not make. + */ +export const FAMILIES = { + MODULE: ["MODULE"], + CLASS: ["CLASS", "CLASS_PRIVATE", "CLASS_GENERIC"], + INTERFACE: ["INTERFACE"], + COCLASS: ["COCLASS"], + PROC_MODULE: ["SUB_MODULE", "SUB_MODULE_PRIVATE", "SUB_STATIC_MODULE", "SUB_GENERIC", "FUNC_MODULE", "FUNC_MODULE_BOOL", + "FUNC_MODULE_OBJECT", "PROPGET_MODULE"], + SUB_MODULE: ["SUB_MODULE", "SUB_MODULE_PRIVATE", "SUB_STATIC_MODULE"], + FUNC_MODULE: ["FUNC_MODULE", "FUNC_MODULE_OBJECT"], + FUNC_MODULE_BOOL: ["FUNC_MODULE_BOOL"], + PROC_CLASS: ["PROC_CLASS", "FUNC_CLASS", "FUNC_CLASS_OBJECT", "PROPGET_CLASS", "PROPLET_CLASS", "PROPSET_CLASS", + "SUB_FRIEND_CLASS", "CLASS_INITIALIZE"], + PROC_INTERFACE: ["PROC_INTERFACE", "FUNC_INTERFACE", "FUNC_INTERFACE_OBJECT", "PROPGET_INTERFACE"], + // "Procedure" with no place named: every procedure site there is. + PROC_ANY: ["SUB_MODULE", "SUB_MODULE_PRIVATE", "SUB_STATIC_MODULE", "SUB_GENERIC", "FUNC_MODULE", "FUNC_MODULE_BOOL", + "FUNC_MODULE_OBJECT", "PROPGET_MODULE", "PROC_CLASS", "FUNC_CLASS", "FUNC_CLASS_OBJECT", "PROPGET_CLASS", + "PROPLET_CLASS", "PROPSET_CLASS", "SUB_FRIEND_CLASS", "CLASS_INITIALIZE", "PROC_INTERFACE", "FUNC_INTERFACE", + "FUNC_INTERFACE_OBJECT", "PROPGET_INTERFACE"], + DECLARE: ["DECLARE", "DECLARE_SUB", "DECLARE_PRIVATE", "DECLARE_CLASS", "DECLARE_PTRSAFE", "DECLARE_WIDE"], + TYPE: ["TYPE", "TYPE_CLASS"], + ENUM: ["ENUM", "ENUM_EMPTY", "ENUM_CLASS"], + ENUM_MEMBER: ["ENUM_MEMBER"], + // "constants in a Module": a Const in a Class is a different target, so + // accepting it is reported as undocumented rather than absorbed. + CONST: ["CONST", "CONST_PRIVATE"], + VAR_CLASS: ["VAR_CLASS", "VAR_CLASS_PRIVATE", "VAR_CLASS_WITHEVENTS"], + VAR_MODULE: ["VAR_MODULE", "VAR_MODULE_PRIVATE", "VAR_MODULE_INIT"], + PARAM: ["PARAM", "PARAM_DECLARE", "PARAM_INTERFACE", "PARAM_OPTIONAL", "PARAM_PARAMARRAY"], + IMPLEMENTS: ["IMPLEMENTS"], + // `Implements ... Via`: parseTargets reads nothing from that phrase, so + // sweep_attributes.mjs adds it (see its `targetsOf`). + IMPLEMENTS_VIA: ["IMPLEMENTS_VIA_FIELD", "IMPLEMENTS_VIA_CLASS"], + EVENT_CLASS: ["EVENT_CLASS"], + COCLASS_INTERFACE: ["COCLASS_INTERFACE"], +}; + +/** + * Sites a family lists that a documented target may leave out without being + * "partly true": a Private variant, a special form of the same thing. Accepting + * one is covered by the family; refusing one is not a qualification the page + * owes. Without this, `procedure in a Class` would be reported partly false + * because `Class_Initialize` is refused. + */ +export const OPTIONAL_SITES = new Set([ + "CLASS_INITIALIZE", "SUB_MODULE_PRIVATE", "SUB_STATIC_MODULE", "SUB_GENERIC", "FUNC_MODULE_OBJECT", "DECLARE_PRIVATE", + "DECLARE_PTRSAFE", "DECLARE_WIDE", "CONST_PRIVATE", "VAR_CLASS_PRIVATE", "VAR_MODULE_PRIVATE", "VAR_MODULE_INIT", + "ENUM_EMPTY", "TYPE_CLASS", "ENUM_CLASS", "PARAM_DECLARE", "PARAM_INTERFACE", "PARAM_OPTIONAL", "PARAM_PARAMARRAY", + "PROPLET_CLASS", "PROPSET_CLASS", "SUB_FRIEND_CLASS", "FUNC_CLASS_OBJECT", "FUNC_INTERFACE_OBJECT", "CLASS_PRIVATE", + "CLASS_GENERIC", "VAR_CLASS_WITHEVENTS", "ENUM_MEMBER", "DECLARE_CLASS", "CONST_CLASS", +]); + +/** + * The text of a probe: the skeleton with the attribute at its site. + * + * @param {object} site a SITE_LIST entry + * @param {string} tag the probe's unique name (a legal identifier) + * @param {string|null} attr the attribute as written (`[Name(...)]`), or null + * for the baseline build with no attribute + */ +export function renderSite(site, tag, attr) { + // Function replacers throughout: a string replacement reads `$&` and `$'` + // specially, and attribute text is whatever `--names` was given. + const sub = (s, key, value) => s.replaceAll(key, () => value); + let filled = sub(site.template, "${T}", tag); + filled = sub(filled, "${U}", `${tag}_U`); + filled = sub(filled, "${M}", `${tag}_m`); + if (attr !== null) return sub(filled, "${A}", attr); + // Baseline. An inline site loses the attribute and keeps its statement; a + // stand-alone one loses the whole line, which would otherwise be blank. + if (site.inline) return filled.replaceAll("${A} ", "").replaceAll("${A}", ""); + return filled.split("\n").filter((l) => l.trim() !== "${A}").join("\n"); +} + +/** Which line (1-based) of the rendered probe holds the attribute. */ +export function attributeLine(site, tag) { + const lines = renderSite(site, tag, "@@ATTR@@").split("\n"); + const i = lines.findIndex((l) => l.includes("@@ATTR@@")); + return i < 0 ? -1 : i + 1; +} diff --git a/scripts/lib/attribute-sweep.mjs b/scripts/lib/attribute-sweep.mjs new file mode 100644 index 00000000..62a58525 --- /dev/null +++ b/scripts/lib/attribute-sweep.mjs @@ -0,0 +1,587 @@ +// The logic of scripts/sweep_attributes.mjs that needs no IDE: what a probe is, +// what the compiler's rows say about it, how probes are batched, and how the +// answers are laid against Attributes.md. +// +// It is here, and not in the tool, so that scripts/check_attribute_sweep.mjs can +// probe it with fixed inputs. The tool reads its command line and starts IDEs +// as it loads; none of this does either, and none of it holds state of its own: +// what a function needs -- the control's signatures, the batch size, the +// outcomes -- is passed in. + +import { parseTargets } from "./attributes-doc.mjs"; +import { + FIXED_ARGS, GUID_ATTRS, NOT_FAITHFULLY_PROBEABLE, UNSYNTHESISABLE, pad, +} from "./attribute-probe-kit.mjs"; +import { FAMILIES, OPTIONAL_SITES, attributeLine } from "./attribute-sites.mjs"; +import { escapeRegExp } from "../../builder/escape.mjs"; + +// ----------------------------------------------------------------- canaries +export const CONTROL_NAME = "ZzNoSuchAttributeProbe"; + +// What each canary MUST draw, fixed here rather than read from a build (see 3 in +// sweep_attributes.mjs's header). `unknown` is what an invented name draws; +// recorded from BETA 983 (WIP.Harness.md) and re-checked by the canary-only build +// before every run. +export const CANARY_SPECS = [ + ["clean", "MODULE", '[Description("canary")]'], + ["context", "COCLASS_INTERFACE", "[Hidden]"], + ["unknown", "MODULE", `[${CONTROL_NAME}]`], +]; +export const EXPECTED_CANARIES = { clean: "ACCEPT", context: "REJECT_CONTEXT", unknown: "REJECT_SYNTAX" }; +export const canaryOk = (c) => Object.keys(EXPECTED_CANARIES).every((k) => c[k] === EXPECTED_CANARIES[k]); + +// ------------------------------------------------------------- the token table +/** + * The compiler's token table: one long pipe-separated string in the compiler + * binary, holding keywords, attributes and object members together. A name in it + * is a name the compiler KNOWS, not one it accepts as an attribute -- `Debug` + * and `ExecuteHostCommand` are in it, and neither is one -- so these are + * candidates, and a candidate is called an attribute only if some site accepts it. + * + * @param {string} s the compiler binary read as latin1 text + * @returns {{tokens: string[], why: string | null}} + */ +export function parseTokenRun(s) { + const anchor = s.indexOf("|DllExport|"); + if (anchor < 0) return { tokens: [], why: "the token table's `|DllExport|` anchor is not in the compiler" }; + const ok = /[A-Za-z0-9_|]/; + let a = anchor; + let b = anchor; + while (a > 0 && ok.test(s[a - 1])) a--; + while (b < s.length && ok.test(s[b])) b++; + const tokens = s.slice(a, b).split("|").filter((t) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(t)); + return { tokens: [...new Set(tokens)], why: null }; +} + +// ------------------------------------------------------------------- forms +// A form is an argument shape. The documented attributes get every shape, +// because they are also the validation set: whether a shape can change the +// answer about PLACEMENT is read off them (see `formDependence`), which is what +// lets a token-table name be tried bare first without assuming it cannot. +const BOOL_FORMS = [["true", "(True)"], ["false", "(False)"]]; +const GUESS_FORMS = [["str", '("probe")'], ["int", "(1)"]]; +export const EXTRA_FORMS = [...BOOL_FORMS, ...GUESS_FORMS].map(([k]) => k); + +// The compiler allows these once per project (TB5114 on a second), so a batch +// holds one probe of each. PopulateFrom fills an enum with the same two members +// every time, and enum members are project-global, so only its working form +// is held to one. +export const SINGLETON_NAMES = new Set(["RunAfterBuild", "RunBeforeStartupObject"]); +export const singletonKey = (p) => { + if (p.name === null) return null; + if (SINGLETON_NAMES.has(p.name)) return p.name; + if (p.name === "PopulateFrom" && p.form === "fixed") return p.name; + return null; +}; + +/** The fixed argument text for names whose shape is known and non-trivial. */ +export function fixedArgs(name, id) { + if (GUID_ATTRS.has(name)) return `("00000000-0000-0000-0000-${pad(id, 12)}")`; + if (name === "TypeHint") return "(SweepHintEnum)"; + if (Object.hasOwn(FIXED_ARGS, name)) return FIXED_ARGS[name]; + return null; +} +export const hasFixed = (name) => GUID_ATTRS.has(name) || name === "TypeHint" || Object.hasOwn(FIXED_ARGS, name); + +/** + * The form keys tried for a name in stage 1. A shape known to be required is + * tried in every mode. + * + * @param {{name: string, doc: object | null}} u a name of the universe + * @param {"bare" | "smart" | "all"} mode + */ +export function stage1Forms(u, mode) { + const keys = ["bare"]; + if (hasFixed(u.name)) keys.push("fixed"); + if (mode !== "bare" && (u.doc || mode === "all")) keys.push(...EXTRA_FORMS); + // One probe per batch means one batch per probe, so a singleton attribute + // gets two shapes, not six. + return SINGLETON_NAMES.has(u.name) ? keys.filter((k) => k === "bare" || k === "true") : keys; +} +/** The forms a token-table name gets once some site has recognised it. */ +export const stage2Forms = (u) => (SINGLETON_NAMES.has(u.name) + ? ["true"] + : [...(hasFixed(u.name) ? ["fixed"] : []), ...EXTRA_FORMS]); + +/** The argument text of a form (`""`, `"(True)"`, ...); null for a `fixed` form with no known shape. */ +export function argsOf(key, name, id) { + if (key === "bare") return ""; + if (key === "fixed") return fixedArgs(name, id); + return [...BOOL_FORMS, ...GUESS_FORMS].find(([k]) => k === key)[1]; +} + +// ------------------------------------------------------------ classification +/** + * The compiler's own words for what happened to one probe. An answer is decided + * from the ERROR rows of the probe's own file, because a probe file holds one + * attribute and (once the baseline is clean) nothing else that can fail. + * + * ACCEPT no error at all + * ACCEPT_LATER an error that can only come after the attribute was placed: + * TB5114 (a second [RunAfterBuild]), TB5247 (a form designer + * lookup) + * RECOGNISED an error on the attribute's line that is neither refusal and + * not what the control draws -- the name and place were taken + * and something about its argument was not; or an error row + * in no probe's file that this probe alone provokes + * ACCEPT_ERR an error elsewhere in the file: the attribute took and + * changed what the declaration means, or the error is the + * declaration's own + * REJECT_CONTEXT TB5155, "not supported in this context" + * REJECT_SYNTAX TB5182, "no handler for this symbol", or whatever the + * control draws at this site + * CRASH / HUNG the compiler died or never settled on this probe + * HARNESS the build could not be run at all + * INTERFERES this probe alone changes what the canaries draw + * + * Only ACCEPT and ACCEPT_LATER count as accepted. The other two "took" states + * are shown, and are enough to call a name recognised, but a documented target + * is not called true on them. + */ +const ACCEPT_LATER_CODES = new Set(["TB5114", "TB5247"]); +export const RANK = { + ACCEPT: 0, ACCEPT_LATER: 1, ACCEPT_ERR: 2, RECOGNISED: 3, + REJECT_CONTEXT: 4, REJECT_SYNTAX: 5, CRASH: 6, HUNG: 7, HARNESS: 8, INTERFERES: 9, VOID: 10, +}; +export const codeOf = (msg) => /\bTB\d{4}\b/.exec(msg)?.[0] ?? null; +export const firm = (s) => s === "ACCEPT" || s === "ACCEPT_LATER"; +export const soft = (s) => s === "ACCEPT_ERR" || s === "RECOGNISED"; +export const recognised = (s) => firm(s) || soft(s); +export const refused = (s) => s === "REJECT_CONTEXT" || s === "REJECT_SYNTAX"; +export const inconclusive = (s) => ["CRASH", "HUNG", "HARNESS", "INTERFERES", "VOID"].includes(s); +export const isAttr = (p) => p.kind === "attr" || p.kind === "verify"; + +/** + * What a diagnostic set says once what is particular to one probe is taken out: + * the attribute's name as a whole word in any case, and the probe's own + * generated names (`S000062`, `S000062_U`, `S000062_m`), which differ between + * the control and the probe though the refusal is the same. + */ +export const signature = (codes, msgs, name) => { + const nameRe = new RegExp(`\\b${escapeRegExp(name)}\\b`, "gi"); + const clean = (m) => m.replace(/S\d{6}\w*/g, "#").replace(nameRe, "@"); + return `${[...new Set(codes)].sort().join(",")}|${msgs.map(clean).sort().join(";")}`; +}; + +/** + * @param {object} probe { kind, name, site, tag, attr } + * @param {{severity: string, line: number, message: string}[]} rows the probe's own file's rows + * @param {Map} controlSigs site id -> what the unknown name draws there + * @returns {{state: string, codes: (string|null)[], msgs: string[]}} + */ +export function classify(probe, rows, controlSigs) { + const errs = rows.filter((r) => r.severity === "ERROR"); + if (!errs.length) return { state: "ACCEPT", codes: [], msgs: [] }; + const codes = errs.map((r) => codeOf(r.message)); + const msgs = [...new Set(errs.map((r) => r.message))]; + const aline = probe.attr === null ? -1 : attributeLine(probe.site, probe.tag); + // The errors a skeleton draws with no attribute at all (an empty Enum, TB5233) + // are not the attribute's doing. + if (codes.every((c) => probe.site.baselineCodes.has(c))) return { state: "ACCEPT", codes: [], msgs: [] }; + let state; + if (codes.includes("TB5155")) state = "REJECT_CONTEXT"; + else if (codes.includes("TB5182")) state = "REJECT_SYNTAX"; + else if (codes.every((c) => ACCEPT_LATER_CODES.has(c))) state = "ACCEPT_LATER"; + else if (errs.some((r) => r.line === aline)) state = "RECOGNISED"; + else state = "ACCEPT_ERR"; + // Whatever the control draws is a refusal, even where it is not one of the + // two codes: a name the parser read as something else is not recognised. + if (isAttr(probe) && !refused(state) && controlSigs.get(probe.site.id) === signature(codes, msgs, probe.name)) { + state = "REJECT_SYNTAX"; + } + return { state, codes, msgs }; +} + +const ROW = /^\{(\w+)\}\s+(\S+)\s+\[(\d+),(\d+)\]:\s*(.*)$/; + +/** + * Sort a build's diagnostic rows into the probe file each belongs to. + * + * @param {string[]} rows tbbuild's rows: `{SEVERITY} path [line,col]: message` + * @param {Map} files lower-cased file base name -> probe + * @param {object[]} probes every probe of the build, so each gets a list even if empty + * @returns {{byFile: Map, strays: string[]}} `strays` are the ERROR + * rows that belong to no probe's file + */ +export function sortRows(rows, files, probes) { + const byFile = new Map(probes.map((p) => [p, []])); + const strays = []; + for (const row of rows) { + const m = ROW.exec(row); + // Only an ERROR is a stray. A row this cannot parse and that is not one is + // a warning or a note about the project, which changes no answer. + if (!m) { if (/^\{ERROR\}/.test(row)) strays.push(row); continue; } + const p = files.get(m[2].split("/").pop().split("\\").pop().toLowerCase()); + if (!p) { if (m[1] === "ERROR") strays.push(row); continue; } + byFile.get(p).push({ severity: m[1], line: Number(m[3]), col: Number(m[4]), message: m[5] }); + } + return { byFile, strays }; +} + +// ---------------------------------------------------------------- batching +// A small deterministic generator, so the same run makes the same batches. +export function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Shuffled, so no batch is one attribute; and at most one probe per singleton + * key in a batch, because a second would draw TB5114 and read as accepted. + */ +export function makeBatches(probes, batchSize) { + const rand = rng(1); + const order = [...probes]; + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + const batches = []; + for (const p of order) { + const k = singletonKey(p); + let b = batches.find((x) => x.list.length < batchSize && (!k || !x.keys.has(k))); + if (!b) { b = { list: [], keys: new Set() }; batches.push(b); } + b.list.push(p); + if (k) b.keys.add(k); + } + return batches.map((b) => b.list); +} + +// ------------------------------------------------------------- aggregation +/** + * name -> site id -> { state, forms: { key: state } , codes, msgs, pending } + * + * @param {Iterable} probes every probe planned + * @param {Map} outcomeOf probe id -> outcome + */ +export function aggregate(probes, outcomeOf) { + const agg = new Map(); + for (const p of probes) { + if (p.kind !== "attr") continue; + const o = outcomeOf.get(p.id); + if (!agg.has(p.name)) agg.set(p.name, new Map()); + const bySite = agg.get(p.name); + if (!bySite.has(p.site.id)) bySite.set(p.site.id, { state: "VOID", forms: {}, codes: new Set(), msgs: new Set(), pending: false }); + const cell = bySite.get(p.site.id); + // A form that was planned and has no answer (the run was cut short) or whose + // build failed is pending, and a refusal from the other forms cannot stand + // for it: the form able to pass may be exactly the one never compiled. + if (!o) { if (p.form !== "false") cell.pending = true; continue; } + if (inconclusive(o.state) && p.form !== "false") cell.pending = true; + cell.forms[p.form] = o.state; + for (const c of o.codes) cell.codes.add(c); + for (const m of o.msgs) cell.msgs.add(m); + } + // The `false` form is never part of the answer: [CompileIf(False)] can remove + // the declaration before anything is checked, so a clean build of it says + // nothing about placement -- and if it is the only form that was built, the + // cell has no answer at all. + for (const bySite of agg.values()) { + for (const cell of bySite.values()) { + const use = Object.keys(cell.forms).filter((k) => k !== "false"); + for (const k of use) if (RANK[cell.forms[k]] < RANK[cell.state]) cell.state = cell.forms[k]; + // Anything short of an acceptance is unsafe to report while a form is + // missing, not only a refusal: bare RECOGNISED (its argument missing) beside a + // `fixed` form nobody built would read as "documented target refused". + if (!use.length) cell.state = "HARNESS"; + else if (!firm(cell.state) && cell.pending) cell.state = "HARNESS"; + } + } + return agg; +} + +/** Sites where forms disagree about placement: some take the attribute and some refuse the place. */ +export function formDependence(agg) { + const out = []; + let multi = 0; + for (const [name, bySite] of agg) { + for (const [site, cell] of bySite) { + const states = Object.entries(cell.forms).filter(([k]) => k !== "false"); + if (states.length > 1) multi++; + const takes = states.filter(([, s]) => recognised(s)); + const refuses = states.filter(([, s]) => refused(s)); + if (takes.length && refuses.length) out.push({ name, site, takes: takes.map(([k]) => k), refuses: refuses.map(([k]) => k) }); + } + } + return { list: out, multi }; +} + +// ------------------------------------------------------------- against the page +/** + * The targets an `Applicable to:` line names. parseTargets reads what the + * probe generator needs; three phrasings it does not spell out are read here. + */ +export function targetsOf(app) { + if (!app) return []; + let t = parseTargets(app); + if (/implements\b.*\bvia\b/i.test(app)) t = [...t.filter((x) => x !== "IMPLEMENTS"), "IMPLEMENTS_VIA"]; + // A "procedure" with no place named in ITS OWN phrase means every procedure -- + // "Class, Module, procedure" says so, though the words Class and Module appear + // on the line. A parameter is not one. + // A place named by a LATER phrase covers the earlier ones: "procedures and + // constants in a module" puts the procedures in a module too. + const phrases = app.split(/,|\band\b/); + const bareProcedure = phrases.some((p, i) => /\b(procedures?|methods?)\b/i.test(p) + && !/\b(module|class|interface|prototype|parameter)/i.test(p) + && !phrases.slice(i + 1).some((q) => /\bin an?\s+(module|class|interface)\b/i.test(q))); + if (bareProcedure) t = [...t.filter((x) => x !== "PROC_MODULE"), "PROC_ANY"]; + if (t.includes("PROC_CLASS") && /class\s+or\s+interface/i.test(app) && !t.includes("PROC_INTERFACE")) t.push("PROC_INTERFACE"); + return t; +} + +// Where a probe of this attribute tests the documented claim. RedirectToStatic- +// Implementation names a Sub, so only a Sub prototype can match its signature. +// Enumerator needs a member returning IUnknown or a Variant, which the two +// object-returning sites now provide. +export const FAITHFUL_SITES = { + RedirectToStaticImplementation: new Set(["PROC_INTERFACE"]), + Enumerator: new Set(["FUNC_CLASS_OBJECT", "FUNC_INTERFACE_OBJECT"]), +}; +// A prototype in an Interface is not a procedure definition, so `procedures` +// does not owe one. +export const optionalFor = (target, site) => OPTIONAL_SITES.has(site) || (target === "PROC_ANY" && /_INTERFACE/.test(site)); + +/** + * One documented attribute laid against what the compiler did with it. + * + * @param {{name: string, doc: {app: string | null} | null}} u + * @param {Map} bySite site id -> aggregated cell + */ +export function compare(u, bySite) { + const doc = u.doc; + if (!doc) return null; + const why = FAITHFUL_SITES[u.name] ? null : UNSYNTHESISABLE[u.name] ?? NOT_FAITHFULLY_PROBEABLE[u.name]; + if (why) return { unfaithful: why }; + const targets = targetsOf(doc.app); + const faithful = FAITHFUL_SITES[u.name]; + const firmAt = new Set([...bySite].filter(([, c]) => firm(c.state)).map(([s]) => s)); + const softAt = new Set([...bySite].filter(([, c]) => soft(c.state)).map(([s]) => s)); + const covered = new Set(); + const missing = []; + const partial = []; + const untestable = []; + // A line that is there and yields no target -- "Library" -- has been read as + // saying nothing, which is not the same as agreeing. + if (doc.app && !targets.length) untestable.push(`\`${doc.app}\`: no target this tool can read`); + for (const t of targets) { + const fam = FAMILIES[t]; + if (!fam) { untestable.push(`\`${t}\`: no site in the matrix can be written for it`); continue; } + for (const s of fam) covered.add(s); + const live = fam.filter((s) => bySite.has(s) && !inconclusive(bySite.get(s).state) && (!faithful || faithful.has(s))); + if (!live.length) { untestable.push(`\`${t}\`: every site for it was voided, left out, or inconclusive`); continue; } + const yes = live.filter((s) => firmAt.has(s)); + const required = live.filter((s) => !optionalFor(t, s)); + if (!yes.length) { + missing.push({ target: t, sites: live, soft: live.filter((s) => softAt.has(s)) }); + } else { + // A family whose sites are all optional (an Enum member, inline or on its + // own line) is true if any one takes the attribute. + const no = required.filter((s) => !firmAt.has(s)); + if (no.length) partial.push({ target: t, yes, no }); + } + } + const extra = [...firmAt].filter((s) => !covered.has(s)); + const extraSoft = [...softAt].filter((s) => !covered.has(s)); + return { targets, missing, partial, untestable, extra, extraSoft, hasLine: !!doc.app }; +} + +// ------------------------------------------------------------ the preflight +/** + * What the preflight's baselines and controls say about each site. + * + * @param {object[]} baselines a probe per site with no attribute + * @param {object[]} controls a probe per site with the unknown name + * @param {Map} outcomeOf + * @returns {{voidSites: Map, controlState: Map, controlSig: Map}} + * `voidSites` maps a site id to why it cannot be trusted; `controlSig` is what the + * unknown name draws there, for `classify`. + */ +export function judgePreflight(baselines, controls, outcomeOf) { + const voidSites = new Map(); + const controlState = new Map(); + const controlSig = new Map(); + for (const p of baselines) { + const o = outcomeOf.get(p.id); + // classify already reads a baseline that draws only the site's own declared + // codes as ACCEPT, so anything else -- a crash, a hang, a build that could + // not run -- voids the site. (Those carry no codes, so a test on the codes + // alone would pass them.) + if (o.state !== "ACCEPT") voidSites.set(p.site.id, `its baseline, with no attribute, does not build clean: ${o.state} ${o.msgs.join("; ")}`); + } + for (const p of controls) { + const o = outcomeOf.get(p.id); + controlState.set(p.site.id, o); + controlSig.set(p.site.id, signature(o.codes, o.msgs, CONTROL_NAME)); + // A site whose control compiles cannot tell an attribute from something else: + // an Enum body takes ANY own-line `[...]`, `[ClassId("guid")]` included, as + // measured on BETA 987. It is voided rather than read. + if (!voidSites.has(p.site.id) && (firm(o.state) || inconclusive(o.state))) { + voidSites.set(p.site.id, `an attribute that does not exist ${inconclusive(o.state) ? `could not be judged (${o.state})` : `was accepted here (${o.state})`}`); + } + } + return { voidSites, controlState, controlSig }; +} + +/** + * Compare a rebuilt probe with its first answer. + * + * A rebuild that came back inconclusive (its build failed, hung or crashed) says + * nothing about whether the first answer was stable, so it is counted apart and + * is not a mismatch: a flaky lane must not read as an unreliable compiler. + * + * @param {object[]} first the probes first answered + * @param {object[]} again the same probes, rebuilt as new probes in the same order + * @param {Map} got the rebuild's outcomes, by the new probe's id + * @param {Map} outcomeOf the first outcomes, by the first probe's id + */ +export function verifyMismatches(first, again, got, outcomeOf) { + const bad = []; + let unjudged = 0; + again.forEach((q, i) => { + const before = outcomeOf.get(first[i].id).state; + const after = got.get(q.id)?.state; + if (after === undefined || inconclusive(after)) { unjudged++; return; } + if (before !== after) bad.push({ probe: first[i].attr, site: first[i].site.id, first: before, second: after }); + }); + return { bad, unjudged }; +} + +// ------------------------------------------------------------------ the runner +// Caps on the ways halving could otherwise cost thousands of builds. A hang is a +// 180-second build, and one real hang costs about ten of them to isolate, so the +// builds are not what is capped: a top-level hang first rebuilds the canaries +// alone (a compile that hangs beside nothing is the IDE, not a probe), and the +// probes that end up HUNG are what the cap counts. +export const MAX_HUNG_PROBES = 12; +export const MAX_STRAY_PROBES = 40; +export const MAX_HARNESS_FAILURES = 12; +export const MAX_INTERFERING_PROBES = 25; + +/** + * The isolating runner: `runSet(probes, lane)` builds a set and returns each probe's + * outcome, halving whatever goes wrong until one probe is to blame. The IDE is behind + * `buildOnce`, which is what a test replaces. + * + * @param {object} o + * @param {(probes: object[], lane: object) => Promise} o.buildOnce one build of the + * probes plus the canaries: `{kind: "ok", byFile, strays, canaries}`, `{kind: "crash", named}`, + * `{kind: "hung"}` or `{kind: "harness", why}` + * @param {Map} o.controlSig what the unknown name draws at each site, read by classify + * @param {(line: string) => void} [o.say] progress + * @returns {{runSet: Function, stats: object}} + */ +export function createRunner({ buildOnce, controlSig, say = () => {} }) { + const stats = { + builds: 0, splits: 0, crashes: [], hung: [], strays: [], strayProbes: 0, canaryFails: 0, harness: [], interferes: [], + comboCrashes: [], cutShort: null, + }; + + /** + * Build a set of probes and return their outcomes, isolating anything that goes + * wrong by halving. A build is believed only if its canaries drew what this file + * records and it holds no error row that belongs to no probe. + * + * @returns {Promise>} + */ + async function runSet(probes, lane, depth = 0) { + stats.builds++; + const r = await buildOnce(probes, lane); + const out = new Map(); + if (r.kind === "harness") { + stats.harness.push(`${probes.length} probe(s): ${r.why}`); + for (const p of probes) out.set(p.id, { state: "HARNESS", codes: [], msgs: [r.why] }); + // A failure to run is a failure for the subtree's bookkeeping too: it is not a + // cause that "neither half reproduces". + out.failures = 1; + if (stats.harness.length > MAX_HARNESS_FAILURES) { + throw new Error(`more than ${MAX_HARNESS_FAILURES} builds could not be run; the last: ${r.why}`); + } + return out; + } + if (r.kind === "hung" && depth === 0) { + stats.builds++; + const alone = await buildOnce([], lane); + if (alone.kind !== "ok") { + throw new Error("the compile never settles even with only the canaries in it, so the IDE or the harness is unwell rather than any probe"); + } + } + // Every error row that belongs to no probe: the preflight has already refused + // a template that draws any of its own, so none is expected. + const newStrays = r.kind === "ok" ? r.strays : []; + const canariesFine = r.kind === "ok" && canaryOk(r.canaries); + if (r.kind === "ok" && canariesFine && !newStrays.length) { + for (const [p, rows] of r.byFile) out.set(p.id, classify(p, rows, controlSig)); + return out; + } + const why = r.kind === "ok" + ? (!canariesFine + ? `canaries drew ${JSON.stringify(r.canaries)}, expected ${JSON.stringify(EXPECTED_CANARIES)}` + : `${newStrays.length} error row(s) belong to no probe`) + : r.kind === "crash" ? "the compiler crashed" : "the compile never settled"; + if (r.kind === "ok" && !canariesFine) stats.canaryFails++; + // Counted per subtree, in `out.failures`, because the lanes run at once and a + // shared counter would credit one lane's isolation to another's batch. + out.failures = 1; + if (probes.length === 1) { + const p = probes[0]; + if (r.kind === "crash") { stats.crashes.push(p); out.set(p.id, { state: "CRASH", codes: [], msgs: [] }); return out; } + if (r.kind === "hung") { + stats.hung.push(p); + out.set(p.id, { state: "HUNG", codes: [], msgs: [] }); + if (stats.hung.length > MAX_HUNG_PROBES) throw new Error(`more than ${MAX_HUNG_PROBES} probes never settle`); + return out; + } + if (!canariesFine) { + stats.interferes.push(p); + out.set(p.id, { state: "INTERFERES", codes: [], msgs: [why] }); + if (stats.interferes.length > MAX_INTERFERING_PROBES) { + throw new Error(`more than ${MAX_INTERFERING_PROBES} probes each disturb the canaries beside nothing else; the canaries are not a usable check on this compiler`); + } + return out; + } + // The only thing wrong is an error row in no probe's file: this probe caused + // it, so the attribute was taken and something it names did not resolve. + const o = classify(p, r.byFile.get(p), controlSig); + stats.strays.push(...newStrays.map((s) => `${p.attr} at ${p.site.id}: ${s}`)); + if (++stats.strayProbes > MAX_STRAY_PROBES) { + throw new Error(`more than ${MAX_STRAY_PROBES} probes each draw an error row belonging to no probe; ` + + "the template project probably draws it in every build"); + } + out.set(p.id, o.state === "ACCEPT" ? { state: "RECOGNISED", codes: [], msgs: newStrays } : o); + return out; + } + // Take the probe the compiler says it died parsing out first: two builds + // where halving would need two per level. + let parts; + const named = r.kind === "crash" ? probes.filter((p) => r.named.has(p.id)) : []; + if (named.length && named.length < probes.length) { + parts = [named, probes.filter((p) => !named.includes(p))]; + } else { + const half = Math.ceil(probes.length / 2); + parts = [probes.slice(0, half), probes.slice(half)]; + } + stats.splits++; + say(` ${why} in ${probes.length} probes: splitting`); + const halves = []; + for (const part of parts) halves.push(await runSet(part, lane, depth + 1)); + for (const h of halves) for (const [k, v] of h) out.set(k, v); + const below = halves.reduce((n, h) => n + (h.failures ?? 0), 0); + out.failures = 1 + below; + // A cause that needs several probes at once leaves both halves clean, and + // their answers are still good. What is lost is the cause itself -- for a + // crash, the compiler bug -- so it is named here rather than dropped. + if (below === 0) { + stats.comboCrashes.push(`${why} in a batch of ${probes.length} probes (${probes[0].tag}..${probes.at(-1).tag}) ` + + "that neither half reproduces; it needs several probes together"); + } + return out; + } + + return { runSet, stats }; +} diff --git a/scripts/sweep_attributes.mjs b/scripts/sweep_attributes.mjs new file mode 100644 index 00000000..9745d61d --- /dev/null +++ b/scripts/sweep_attributes.mjs @@ -0,0 +1,756 @@ +#!/usr/bin/env node +// Ask the compiler where every attribute is legal, exhaustively. +// +// node scripts/sweep_attributes.mjs [options] +// +// The options, and the exit codes, are in USAGE below, which --help prints. +// +// ---------------------------------------------------------------- why this +// +// `Reference/Attributes.md` states an `Applicable to:` line per attribute, and +// three tools already touch it, none of them exhaustive: +// +// * census_attributes.mjs reports where the SHIPPED PACKAGES use an attribute. +// That is evidence of use, not of legality -- `[Hidden]` is refused on an +// Interface line inside a CoClass, and used nowhere on a whole Class though +// the compiler accepts it there. +// * gen_attribute_probes.mjs probes the targets the page CLAIMS. It cannot find +// a target the page does not name, so an entry that is too short stays too +// short. `[ComExport]` shipped documented as "constants in a Module" because +// the two targets tried were a Sub and a Const; an API `Declare` was never +// among them. +// * its EXPLORATORY list asks the questions a person thought of. +// +// This asks every question. Each attribute name x each declaration site +// (lib/attribute-sites.mjs) x each argument shape, built in batches, with the +// compiler's own answer recorded: accepted, refused, or recognised but faulty. +// The result is laid against Attributes.md, and the disagreements are the report. +// +// ------------------------------------------------- what makes an answer readable +// +// 1. A site is a claim about the grammar, so every skeleton is built with NO +// attribute first (the baseline). A site that does not build clean is +// voided, never trusted -- otherwise a wrong skeleton reads as "every +// attribute is refused here". +// 2. TB5155 and TB5182 do not separate "wrong place" from "no such attribute" +// (`[ConstantFoldable]`, plainly real, draws TB5182 on a class method). So +// an unknown name is built at every site too, as the CONTROL. A probe that +// draws exactly what the control draws at its site is a refusal, whatever +// the code -- that is what keeps `[Reset] Dim x` from reading as a +// recognised attribute because a name was parsed as something else. A site +// whose control COMPILES is voided. +// 3. A batch could be wrong even when every skeleton is right: the compiler +// might stop reporting after so many errors, or a syntax error in one file +// might suppress the semantic errors of the rest. Three CANARY probes +// (one clean, one context-refused, one unknown) ride in every batch. What +// they must draw is fixed in this file, not read from a build: a build that +// contained the masking could otherwise calibrate the check to it. The tool +// first builds the canaries alone and refuses to go on unless they draw what +// is recorded here. A batch whose canaries differ, or that holds an error +// row belonging to no probe, is halved rather than believed. +// 4. Halving is also how a compiler crash, a hang or a stray error row is +// isolated to a probe. +// 5. `--verify N` rebuilds N random probes in fresh batches and compares. +// 6. Batches are shuffled, so one attribute's probes do not share a project, +// and an attribute the compiler allows once per project ([RunAfterBuild]) +// goes in one probe to a batch, or its TB5114 would read as acceptance. +// +// What is NOT settled by a clean build: that the attribute DOES anything, or +// what. That takes an A/B probe (see the X29..X33 probes in +// gen_attribute_probes.mjs), and this tool only says where to point one. Nor +// does a clean build here prove the attribute survives a full build: the +// diagnostics are the IDE's background compile, so a check made only at link +// time (an export table) is not seen. +// +// Needs a twinBASIC install and Windows with a private desktop, like +// examples.bat, so it is outside every gate and outside CI. +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { parseAttributes } from "./lib/attributes-doc.mjs"; +import { MAIN_TWIN, PROBE_FACTORY_TWIN, pad, settingsText, writeProbeResourcesSync } from "./lib/attribute-probe-kit.mjs"; +import { + CANARY_SPECS, CONTROL_NAME, EXPECTED_CANARIES, aggregate, argsOf, canaryOk, classify, compare, createRunner, + firm, formDependence, inconclusive, judgePreflight, makeBatches, parseTokenRun, recognised, sortRows, soft, + stage1Forms, stage2Forms, verifyMismatches, +} from "./lib/attribute-sweep.mjs"; +import { SITE_LIST, renderSite } from "./lib/attribute-sites.mjs"; +import { compileProject } from "./lib/tb-build.mjs"; +import { sleep, wantShow } from "./lib/tb-ide.mjs"; +import { compilerExe, buildNumber, findIde, runCompiler } from "./lib/tb-install.mjs"; +import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; +import { CliError, choiceOption, exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; +import { DOCS_DIR } from "../lib/repo-paths.mjs"; + +// What a crash has to be able to write: the run's build number and its verify +// result, which main() fills in. A run of many builds is in one process, so an +// uncaught exception from the IDE or CDP code ends every lane's work, and the +// report of what was learned is what would be lost (see `salvage`). +const run = { build: "?", verify: null }; +exitOnCrash(salvage); + +const ATTR_DOC = path.join(DOCS_DIR, "Reference", "Attributes.md"); + +// As tbdocs and check_links name theirs: 1 is a finding, 2 is the tool unable to do its job. +const EXIT_FOUND = 1; +const EXIT_ERROR = 2; + +const USAGE = `usage: node scripts/sweep_attributes.mjs [options] + +Builds every attribute at every declaration site and reports what the compiler +accepts, laid against Reference/Attributes.md. + + --ide twinBASIC.exe (default: $TB_IDE, else the newest + twinBASIC_IDE_BETA_* on the Desktop) + --names only these attribute names, in any case (default: every + name in Attributes.md plus every name in the compiler's + token table) + --sites only these sites (default: all; --list-sites names them) + --forms argument shapes to try: bare, smart or all (default smart: + every shape for a documented attribute, the bare form for + a token-table name and more only where it was recognised; + a shape known to be required is always tried) + --no-tokens do not add the compiler's token-table names + --jobs concurrent IDE lanes (default 4) + --port first DevTools port; a lane uses one more each (default 9560) + --batch-size probes per project (default 400) + --verify afterwards rebuild n random probes in fresh batches and + compare with the first answer (default 0) + --out write the markdown report there instead of stdout + --dump-results + also write every result, raw, as JSON + --work where projects are staged; must be under the system temp + folder (default %TEMP%/tbsweep/) + --keep keep the staged projects + --preflight build only the canaries, baselines and controls, and stop + --dry-run count the probes and build nothing + --list-sites print the site ids and exit + --show, --hide show the IDEs on the desktop, or keep them private + --timeout how long to wait for one build to settle (default 180) + -h, --help print this text and exit + +A report is written even when the run is cut short, and says so at the top. + +Exit codes: + 0 the report was produced and its self-checks held + 1 a self-check found a fault: a probe disturbed the canaries even beside + nothing else, or --verify found a probe that answered differently the + second time + 2 a refused command line, no install, canaries that do not draw what this + file records, a harness failure, a run cut short (its report is written + all the same), or a crash`; + +const usageError = { format: (err) => `${err.message}\n${USAGE}` }; +const { values } = withUsageError(() => parseCli(process.argv.slice(2), { + options: { + ide: { type: "string" }, names: { type: "string" }, sites: { type: "string" }, + forms: { type: "string" }, "no-tokens": { type: "boolean", default: false }, + jobs: { type: "string" }, port: { type: "string" }, "batch-size": { type: "string" }, + verify: { type: "string" }, out: { type: "string" }, "dump-results": { type: "string" }, + work: { type: "string" }, keep: { type: "boolean", default: false }, + preflight: { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, + "list-sites": { type: "boolean", default: false }, + show: { type: "boolean", default: false }, hide: { type: "boolean", default: false }, + timeout: { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + }, + stopAt: ["help"], +}), usageError); +if (values.help) printHelpAndExit(USAGE); +if (values.listSites) { + for (const s of SITE_LIST) console.log(`${s.id.padEnd(22)} ${s.human}`); + process.exit(0); +} + +const opt = withUsageError(() => { + refuseTogether(values, ["show", "hide"]); + const forms = choiceOption(values.forms ?? "smart", { option: "--forms", choices: ["bare", "smart", "all"] }); + const csv = (v) => (v === undefined ? null : v.split(",").map((s) => s.trim()).filter(Boolean)); + const names = csv(values.names); + const sites = csv(values.sites); + for (const s of sites ?? []) { + if (!SITE_LIST.some((x) => x.id === s)) { + throw new CliError("bad-value", `unknown site: ${s} (--list-sites names them)`, { option: "--sites", value: s }); + } + } + const basePort = numberOption(values.port ?? "9560", { option: "--port", integer: true, min: 1, max: 65535 }); + const jobs = numberOption(values.jobs ?? "4", { option: "--jobs", integer: true, min: 1 }); + // A lane takes the port after the last one's. + if (basePort + jobs - 1 > 65535) { + throw new CliError("conflict", `--port ${basePort} with --jobs ${jobs} runs past port 65535`, { option: "--port" }); + } + // startTidy sweeps and restores registry entries under folders only the + // harness writes to, and refuses one outside the temp folder (asTempFolder), so + // the tidy would be lost for every build of the run. + const workRoot = path.resolve(values.work ?? path.join(tmpdir(), "tbsweep", String(basePort))); + const tmpRoot = path.resolve(tmpdir()); + if (workRoot.toLowerCase() === tmpRoot.toLowerCase() + || !(workRoot.toLowerCase() + path.sep).startsWith(tmpRoot.toLowerCase() + path.sep)) { + throw new CliError("bad-value", `--work must be under ${tmpRoot}, not ${workRoot}`, { option: "--work", value: workRoot }); + } + return { + forms, names, sites, basePort, workRoot, + jobs, + batchSize: numberOption(values.batchSize ?? "400", { option: "--batch-size", integer: true, min: 4 }), + verify: numberOption(values.verify ?? "0", { option: "--verify", integer: true, min: 0 }), + timeout: values.timeout === undefined ? null + : numberOption(values.timeout, { option: "--timeout", above: 0 }), + }; +}, usageError); + +const say = (msg) => console.error(msg); +let tidy = null; +const die = (code, msg) => { console.error(msg); finishTidy(tidy); process.exit(code); }; + +// ------------------------------------------------------------- the install +const IDE = findIde(values.ide); +if (!values.dryRun && (!IDE || !existsSync(IDE))) { + die(EXIT_ERROR, (IDE ? `no twinBASIC IDE at ${IDE}: ` : "no twinBASIC IDE found: ") + + "pass --ide , set TB_IDE, or unpack a twinBASIC_IDE_BETA_ folder on your Desktop"); +} +const COMPILER = IDE ? compilerExe(IDE) : null; +const SHOW = wantShow({ show: values.show, hide: values.hide }); + +// ------------------------------------------------------------ the universe +// The compiler's token table is parsed by lib/attribute-sweep.mjs's parseTokenRun; this reads +// the binary it is in. +function readTokenTable(ide) { + const dll = path.join(path.dirname(ide), "bin", "twinBASIC_win32.dll"); + if (!existsSync(dll)) return { tokens: [], why: `no ${dll}` }; + return parseTokenRun(readFileSync(dll).toString("latin1")); +} + +const docEntries = parseAttributes(readFileSync(ATTR_DOC, "utf8")); + +const universe = new Map(); // name -> { name, doc, token } +for (const e of docEntries) universe.set(e.name, { name: e.name, doc: e, token: false }); +let tokenNote = null; +if (!values.noTokens && IDE && existsSync(IDE)) { + const { tokens, why } = readTokenTable(IDE); + if (why) tokenNote = why; + // Matched to the page's spelling in any case, so `Comexport` is not probed as + // an undocumented name beside the documented `ComExport`. + const byLower = new Map([...universe.keys()].map((k) => [k.toLowerCase(), k])); + for (const t of tokens) { + const known = byLower.get(t.toLowerCase()); + if (known) universe.get(known).token = true; + else { universe.set(t, { name: t, doc: null, token: true }); byLower.set(t.toLowerCase(), t); } + } +} +if (opt.names) { + const byLower = new Map([...universe.keys()].map((k) => [k.toLowerCase(), k])); + const wanted = new Set(); + for (const n of opt.names) { + const known = byLower.get(n.toLowerCase()); + if (known) wanted.add(known); + else { universe.set(n, { name: n, doc: null, token: false }); wanted.add(n); } + } + for (const n of [...universe.keys()]) if (!wanted.has(n)) universe.delete(n); +} +const SITES = SITE_LIST.filter((s) => !opt.sites || opt.sites.includes(s.id)); + +// ------------------------------------------------------------------ probes +/** @typedef {{id:number, kind:string, name:string|null, site:object, form:string, attr:string|null, tag:string}} Probe */ +let nextId = 1; +function makeProbe(kind, site, name, form) { + const id = nextId++; + const tag = `S${pad(id, 6)}`; + let attr = null; + if (name !== null) attr = `[${name}${argsOf(form, name, id)}]`; + return { id, kind, name, site, form, attr, tag }; +} + +const siteById = (id) => SITE_LIST.find((s) => s.id === id); +function canariesFor() { + return CANARY_SPECS.map(([k, siteId, attr]) => { + const p = makeProbe(`canary:${k}`, siteById(siteId), null, "bare"); + p.attr = attr; + return p; + }); +} + +// Inside a Module, so no reader has to wonder whether a file-level Enum is legal. +const SUPPORT_TWIN = + "' The enum [TypeHint] names. One shared declaration, so no probe has to carry its own.\n\n" + + "Public Module SweepSupport\n Public Enum SweepHintEnum\n SweepHintValue = 1\n End Enum\nEnd Module\n"; + +// What the unknown name draws at each site, filled in by the preflight and read by classify. +const controlSig = new Map(); + +// ---------------------------------------------------------------- building +let stageCounter = 0; +const workRoot = opt.workRoot; + +function stageBatch(probes, lane) { + const index = stageCounter++; + const dir = path.join(lane.work, `b${index}`); + rmSync(dir, { recursive: true, force: true }); + const src = path.join(dir, "Sources"); + mkdirSync(src, { recursive: true }); + const name = `AttrSweep${index}`; + writeFileSync(path.join(dir, "Settings"), settingsText({ + "project.name": name, + "project.appTitle": "Attribute sweep", + "project.description": "Generated by scripts/sweep_attributes.mjs. A diagnostic is an answer, not a defect.", + // Keyed per batch: two projects sharing an id confuse the IDE's recents list. + // Role digit 6 is this tool's, after tb-project's 0-2 and check_examples' 4-5. + "project.id": `{7B247600-0000-4000-9000-7B2476${index.toString(16).padStart(6, "0")}}`, + // An explicit file, never the ${SourcePath} template: that opens a native + // Save dialog on the build, invisible on the private desktop, and the build + // simply never happens while every health check says the IDE is fine. + "project.buildPath": path.join(dir, `${name}.exe`).split("/").join("\\"), + }), "utf8"); + const crlf = (t) => t.replace(/\r\n?/g, "\n").replace(/\n/g, "\r\n"); + const put = (file, text) => writeFileSync(path.join(src, file), crlf(text), "utf8"); + put("_ProbeMain.twin", MAIN_TWIN); + put("_ProbeFactory.twin", PROBE_FACTORY_TWIN); + put("_ProbeSupport.twin", SUPPORT_TWIN); + // `import` packs the whole tree, so what [CustomControl] and [PopulateFrom] + // point at has to be in it before the pack. + writeProbeResourcesSync(dir); + const files = new Map(); + for (const p of probes) { + put(`${p.tag}.twin`, renderSite(p.site, p.tag, p.attr)); + files.set(`${p.tag}.twin`.toLowerCase(), p); + } + const proj = path.join(lane.work, `b${index}.twinproj`); + // Windows paths throughout: the compiler prefixes \\?\, which takes no forward slash. + const pack = runCompiler(COMPILER, ["import", proj.split("/").join("\\"), dir.split("/").join("\\"), "--overwrite"]); + if (!pack.done) throw new Error(`packing failed${pack.why}:\n${pack.tail}`); + return { proj, dir, files }; +} + + +/** + * One build of `probes` plus the canaries. + * + * Returns { kind: "ok", byFile, strays, canaries } where `byFile` maps a probe to + * its parsed rows, { kind: "crash", named } / { kind: "hung" }, or + * { kind: "harness", why } when the build could not be run twice running. + */ +async function buildOnce(probes, lane) { + const all = [...probes, ...canariesFor()]; + let staged; + let r; + for (let attempt = 0; ; attempt++) { + try { + staged = stageBatch(all, lane); + r = await compileProject({ + project: staged.proj, ide: IDE, port: lane.port, show: SHOW, + ...(opt.timeout ? { timeout: opt.timeout * 1000 } : {}), + }); + } catch (e) { + r = { code: 2, message: e.message, rows: [], crashFiles: [] }; + } + if (staged && !values.keep) rmSync(staged.dir, { recursive: true, force: true }); + if (staged && !values.keep) rmSync(staged.proj, { force: true }); + if (r.code === 0 || r.code === 1 || r.code === 3 || r.code === 4) break; + if (attempt === 1 || !staged) return { kind: "harness", why: `tbbuild exited ${r.code}: ${r.message.split("\n")[0]}` }; + await sleep(3000); + } + if (r.code === 4) { + const named = new Set(); + for (const f of r.crashFiles) { + const p = staged.files.get(f.trim().split(/[\\/]/).pop().toLowerCase()); + if (p && !p.kind.startsWith("canary")) named.add(p.id); + } + return { kind: "crash", named }; + } + if (r.code === 3) return { kind: "hung" }; + const { byFile, strays } = sortRows(r.rows, staged.files, all); + const canaries = {}; + for (const p of all) if (p.kind.startsWith("canary:")) canaries[p.kind.slice(7)] = classify(p, byFile.get(p), controlSig).state; + for (const p of all) if (p.kind.startsWith("canary:")) byFile.delete(p); + return { kind: "ok", byFile, strays, canaries }; +} + + +// The isolating runner is in lib/attribute-sweep.mjs, with the IDE behind `buildOnce`, so a fake can +// be probed by scripts/check_attribute_sweep.mjs. +const { runSet, stats } = createRunner({ buildOnce, controlSig, say }); + +/** Run batches over `jobs` lanes; resolves the union of their outcomes. */ +async function runAll(probes, label) { + const results = new Map(); + let done = 0; + const queue = makeBatches(probes, opt.batchSize); + const lanes = Array.from({ length: Math.min(opt.jobs, queue.length) }, async (_, i) => { + const work = path.join(workRoot, `lane${i}`); + mkdirSync(work, { recursive: true }); + const lane = { port: opt.basePort + i, work }; + while (queue.length && !stats.cutShort) { + const batch = queue.shift(); + try { + for (const [k, v] of await runSet(batch, lane)) results.set(k, v); + } catch (e) { + // One lane's failure does not take the others' work with it: the run + // stops taking batches, the lanes settle, and what is known is reported. + stats.cutShort ??= e.message; + for (const p of batch) if (!results.has(p.id)) results.set(p.id, { state: "HARNESS", codes: [], msgs: [e.message] }); + } + done += batch.length; + say(` ${label}: ${done}/${probes.length} probes (lane ${i})`); + } + }); + await Promise.allSettled(lanes); + return results; +} + +// -------------------------------------------------------------- the sweep +const outcomeOf = new Map(); // probe id -> outcome +const probeOf = new Map(); // probe id -> probe +const voidSites = new Map(); // site id -> why +const controlState = new Map(); // site id -> { state, codes } + +function register(probes) { for (const p of probes) probeOf.set(p.id, p); return probes; } + +function planStage1() { + const list = []; + for (const u of universe.values()) { + for (const key of stage1Forms(u, opt.forms)) { + for (const s of SITES) if (!voidSites.has(s.id)) list.push(makeProbe("attr", s, u.name, key)); + } + } + return register(list); +} + +function planStage2() { + if (opt.forms !== "smart") return []; + const bare = new Map(); + for (const p of probeOf.values()) if (p.kind === "attr" && p.form === "bare") bare.set(`${p.name}|${p.site.id}`, p); + const list = []; + for (const u of universe.values()) { + if (u.doc) continue; // documented names already had every form + const seen = SITES.some((s) => { + const p = bare.get(`${u.name}|${s.id}`); + return p && recognised(outcomeOf.get(p.id)?.state); + }); + if (!seen) continue; + for (const key of stage2Forms(u)) { + for (const s of SITES) if (!voidSites.has(s.id)) list.push(makeProbe("attr", s, u.name, key)); + } + } + return register(list); +} + +/** + * The checks that come before any answer is believed: the canaries alone, then + * every site with no attribute and with an unknown one. + */ +async function preflight() { + const lane = { port: opt.basePort, work: path.join(workRoot, "lane0") }; + mkdirSync(lane.work, { recursive: true }); + + // The canaries beside nothing. They must draw what this file records, or every + // later check is measuring against the wrong thing. A refusal here means the + // compiler changed, or a support file is broken, and either needs a person. + stats.builds++; + const c = await buildOnce([], lane); + if (c.kind !== "ok") die(EXIT_ERROR, `the canary-only build ${c.kind === "harness" ? c.why : c.kind === "crash" ? "crashed the compiler" : "never settled"}`); + if (!canaryOk(c.canaries)) { + die(EXIT_ERROR, `the canaries draw ${JSON.stringify(c.canaries)} on their own, and this file records ${JSON.stringify(EXPECTED_CANARIES)}.\n` + + "If the compiler has changed, check by hand what `[Description]` on a Module, `[Hidden]` on an Interface line in a\n" + + "CoClass and an invented name each draw, then update EXPECTED_CANARIES; do not just copy what was drawn."); + } + if (c.strays.length) die(EXIT_ERROR, `the template project draws error rows of its own, so no batch can be attributed:\n${c.strays.join("\n")}`); + + const base = register(SITES.map((s) => makeProbe("baseline", s, null, "bare"))); + const ctrl = register(SITES.map((s) => makeProbe("control", s, CONTROL_NAME, "bare"))); + say(`preflight: ${base.length} baselines, ${ctrl.length} controls`); + for (const p of ctrl) p.attr = `[${CONTROL_NAME}]`; + for (const [k, v] of await runSet([...base, ...ctrl], lane)) outcomeOf.set(k, v); + const verdict = judgePreflight(base, ctrl, outcomeOf); + for (const [k, v] of verdict.voidSites) voidSites.set(k, v); + for (const [k, v] of verdict.controlState) controlState.set(k, v); + for (const [k, v] of verdict.controlSig) controlSig.set(k, v); +} + +// ------------------------------------------------------------------ report + +function report(agg, dep, meta) { + const L = []; + const a = (s = "") => L.push(s); + a(`# Attribute sweep -- BETA ${meta.build}`); + a(); + if (meta.incomplete) { + a(`> **INCOMPLETE.** The run was cut short: ${meta.incomplete}. Probes not built are shown as HARNESS or absent, and nothing below is a complete answer.`); + a(); + } + a(`${meta.attrProbes} attribute probes over ${agg.size} names and ${SITES.length - voidSites.size} of ${SITES.length} sites ` + + `(${voidSites.size} voided), ${stats.builds} builds, ${stats.splits} splits. ` + + `Forms: \`${opt.forms}\`. Compared against \`Reference/Attributes.md\` (${docEntries.length} entries).`); + a(); + a("A clean build says the compiler ACCEPTS the attribute there. It does not say the attribute does anything, or that it survives a full build."); + a(); + a("## Self-checks"); + a(); + a(`- The canaries drew \`${JSON.stringify(EXPECTED_CANARIES)}\` alone, as this tool records, and ` + + `${stats.canaryFails} batch(es) drew otherwise and were split.`); + if (meta.verify) { + a(`- \`--verify ${meta.verify.n}\`: ${meta.verify.bad.length} of ${meta.verify.n} probes answered differently on a rebuild` + + (meta.verify.unjudged ? `, and ${meta.verify.unjudged} could not be rebuilt to a conclusive answer, which says nothing either way.` : ".")); + } + a(`- ${dep.multi} (name, site) cells were tried with two or more argument shapes (not counting \`false\`); ` + + (dep.list.length + ? `**${dep.list.length} of them changed the placement answer** -- listed at the end, and a token-table name tried bare only is not safe from this.` + : "none changed the placement answer. That covers the names tried with several shapes; a token-table name no site recognised was tried bare only.")); + if (stats.crashes.length) a(`- **${stats.crashes.length} probe(s) crash the compiler** -- see below; report them in BUGS-TO-REPORT.md.`); + if (stats.hung.length) a(`- **${stats.hung.length} probe(s) never settled.**`); + if (stats.comboCrashes.length) a(`- **${stats.comboCrashes.length} crash(es) or hang(s) need several probes together** -- see below.`); + if (stats.interferes.length) a(`- **${stats.interferes.length} probe(s) each change what the canaries draw** -- shown as INTERFERES.`); + if (stats.harness.length) a(`- **${stats.harness.length} build(s) could not be run**; their probes are HARNESS.`); + if (stats.strays.length) a(`- ${stats.strays.length} error row(s) belong to no probe's file -- see below.`); + if (tokenNote) a(`- The token table was not read: ${tokenNote}.`); + a(); + + a("## Sites"); + a(); + a("| site | what | control (unknown name) | status |"); + a("|---|---|---|---|"); + for (const s of SITES) { + const c = controlState.get(s.id); + a(`| \`${s.id}\` | ${s.human} | ${c ? `${c.state} ${c.codes.join(",")}` : "--"} | ${voidSites.has(s.id) ? `**voided**: ${voidSites.get(s.id)}` : "ok"} |`); + } + a(); + + const docNames = [...agg.keys()].filter((n) => universe.get(n)?.doc); + const newNames = [...agg.keys()].filter((n) => !universe.get(n)?.doc); + + a("## Documented attributes: where the page and the compiler disagree"); + a(); + let disagreements = 0; + const agree = []; + const agreeSome = []; + const notFaithful = []; + for (const name of docNames) { + const u = universe.get(name); + const c = compare(u, agg.get(name)); + if (!c) continue; + if (c.unfaithful) { notFaithful.push([name, c.unfaithful]); continue; } + const bits = []; + if (!c.hasLine) bits.push("**no `Applicable to:` line**"); + for (const m of c.missing) { + bits.push(`documented target \`${m.target}\` is **refused** at ${m.sites.map((s) => `\`${s}\``).join(", ")}` + + (m.soft.length ? ` (recognised, but with an error, at ${m.soft.map((s) => `\`${s}\``).join(", ")})` : "")); + } + for (const p of c.partial) { + bits.push(`documented target \`${p.target}\` holds only partly: accepted at ${p.yes.map((s) => `\`${s}\``).join(", ")}; ` + + `**refused** at ${p.no.map((s) => `\`${s}\``).join(", ")}`); + } + if (c.extra.length) bits.push(`**accepted but not documented**: ${c.extra.map((s) => `\`${s}\``).join(", ")}`); + if (c.extraSoft.length) bits.push(`recognised, with an error, at sites not documented: ${c.extraSoft.map((s) => `\`${s}\``).join(", ")}`); + if (!bits.length) { (c.untestable.length ? agreeSome : agree).push(name); } + if (bits.length || c.untestable.length) { + if (bits.length) disagreements++; + a(`### \`[${name}]\` (Attributes.md:${u.doc.line})`); + a(); + a(`Documented: ${u.doc.app ?? "*nothing*"}`); + a(); + for (const b of bits) a(`- ${b}`); + for (const b of c.untestable) a(`- **not testable**, so not counted as agreeing -- ${b}`); + a(); + } + } + a(`${disagreements} of ${docNames.length} documented attributes disagree. ` + + `${agree.length} agree on every target: ${agree.map((n) => `\`${n}\``).join(", ") || "none"}. ` + + `${agreeSome.length} agree on what could be tested but have a target that could not be: ${agreeSome.map((n) => `\`${n}\``).join(", ") || "none"}.`); + a(); + if (notFaithful.length) { + a("Not compared, because a generic skeleton cannot probe them faithfully (the reason gen_attribute_probes.mjs records):"); + a(); + for (const [n, why] of notFaithful) a(`- \`[${n}]\` -- ${why}`); + a(); + } + + a("## Names in the compiler's token table that the page does not document"); + a(); + const placed = newNames.filter((n) => [...agg.get(n).values()].some((c) => recognised(c.state))); + const unplaced = newNames.filter((n) => !placed.includes(n)); + if (placed.length) { + a("Recognised at a site, so **candidates for a new entry** (accepted, or recognised with an error):"); + a(); + for (const n of placed) { + const cells = [...agg.get(n)].filter(([, c]) => recognised(c.state)); + a(`- \`[${n}]\`: ` + cells.map(([s, c]) => `\`${s}\` ${c.state}${soft(c.state) ? ` (${[...c.msgs].slice(0, 1).join("")})` : ""}` + + ` via ${Object.entries(c.forms).filter(([, st]) => recognised(st)).map(([k]) => k).join("/")}`).join("; ")); + } + } else a("None was recognised at any site."); + a(); + a(`${unplaced.length} further name(s) were refused at every site, in every form tried. They are keywords, object members, ` + + "or attributes for a site this matrix lacks: " + (unplaced.map((n) => `\`${n}\``).join(" ") || "none") + "."); + a(); + + // A name taken almost everywhere is more likely a name the parser skips than + // an attribute that applies to everything. + const suspect = [...agg].filter(([, m]) => { + const probed = [...m.values()].filter((c) => !inconclusive(c.state)); + const yes = probed.filter((c) => firm(c.state)).length; + return probed.length >= 15 && yes / probed.length >= 0.6; + }).map(([n, m]) => `\`${n}\` (${[...m.values()].filter((c) => firm(c.state)).length} of ${m.size})`); + if (suspect.length) { + a("## Accepted at most sites"); + a(); + a("Accepted at 60% or more of the sites tried. Either the attribute applies nearly everywhere, or the parser skips what it does not know here; " + + `read the control column above before believing it: ${suspect.join(", ")}.`); + a(); + } + + if (dep.list.length) { + a("## Argument shape changed the placement answer"); + a(); + for (const d of dep.list) a(`- \`[${d.name}]\` at \`${d.site}\`: taken with ${d.takes.join(", ")}; refused with ${d.refuses.join(", ")}`); + a(); + } + if (stats.crashes.length || stats.hung.length || stats.comboCrashes.length) { + a("## Compiler crashes and hangs"); + a(); + for (const p of stats.crashes) a(`- crash: \`${p.attr}\` at \`${p.site.id}\``); + for (const p of stats.hung) a(`- never settled: \`${p.attr}\` at \`${p.site.id}\``); + for (const s of stats.comboCrashes) a(`- ${s}`); + a(); + } + if (stats.strays.length) { + a("## Error rows outside every probe's file"); + a(); + a("Each was caused by the single probe named, which is shown as RECOGNISED."); + a(); + for (const s of stats.strays.slice(0, 50)) a(`- ${s}`); + a(); + } + if (stats.harness.length || stats.interferes.length) { + a("## Builds that could not be judged"); + a(); + for (const s of stats.harness) a(`- ${s}`); + for (const p of stats.interferes) a(`- \`${p.attr}\` at \`${p.site.id}\` changes what the canaries draw`); + a(); + } + + a("## Full matrix, documented attributes"); + a(); + a("`A` accepted, `a` accepted after a later error, `e` recognised, the declaration then erred, `R` recognised with an error on the attribute, `.` refused, `!` crash, hang or harness failure, `-` voided."); + a(); + const ids = SITES.map((s) => s.id); + a(`| attribute | ${ids.join(" | ")} |`); + a(`|---|${ids.map(() => "---").join("|")}|`); + const glyph = { + ACCEPT: "A", ACCEPT_LATER: "a", ACCEPT_ERR: "e", RECOGNISED: "R", REJECT_CONTEXT: ".", REJECT_SYNTAX: ".", + CRASH: "!", HUNG: "!", HARNESS: "!", INTERFERES: "!", VOID: "-", + }; + for (const name of docNames) { + const bySite = agg.get(name); + a(`| \`${name}\` | ${ids.map((s) => (voidSites.has(s) ? "-" : glyph[bySite.get(s)?.state ?? "VOID"])).join(" | ")} |`); + } + a(); + return L.join("\n") + "\n"; +} + +/** Write the report and the JSON from whatever is known, complete or not. */ +/** The crash handler's cleanup: the report of what the run had learned, and the registry put back. */ +function salvage(err) { + if (outcomeOf.size) emit(run.build, run.verify, `a crash: ${err?.message ?? err}`); + finishTidy(tidy); +} + +function emit(build, verify, incomplete) { + const agg = aggregate(probeOf.values(), outcomeOf); + const dep = formDependence(agg); + const attrProbes = [...probeOf.values()].filter((p) => p.kind === "attr").length; + const text = report(agg, dep, { build, attrProbes, verify, incomplete }); + if (values.out) writeFileSync(values.out, text, "utf8"); else process.stdout.write(text); + if (values.dumpResults) { + writeFileSync(values.dumpResults, JSON.stringify({ + build, forms: opt.forms, incomplete: incomplete ?? null, canaries: EXPECTED_CANARIES, + voidSites: Object.fromEntries(voidSites), + controls: Object.fromEntries([...controlState].map(([k, v]) => [k, { state: v.state, codes: v.codes, msgs: v.msgs }])), + results: Object.fromEntries([...agg].map(([n, m]) => [n, Object.fromEntries([...m].map(([s, c]) => [s, { + state: c.state, forms: c.forms, codes: [...c.codes], msgs: [...c.msgs], + }]))])), + crashes: stats.crashes.map((p) => ({ attr: p.attr, site: p.site.id })), + hung: stats.hung.map((p) => ({ attr: p.attr, site: p.site.id })), + interferes: stats.interferes.map((p) => ({ attr: p.attr, site: p.site.id })), + comboCrashes: stats.comboCrashes, strays: stats.strays, harness: stats.harness, + formDependence: dep.list, verify, + }, null, 2), "utf8"); + } +} + +// -------------------------------------------------------------------- main +async function main() { + const build = IDE ? buildNumber(IDE) ?? "?" : "?"; + run.build = build; + say(`sweep: ${universe.size} names (${[...universe.values()].filter((u) => u.doc).length} documented), ` + + `${SITES.length} sites, BETA ${build}`); + if (values.dryRun) { + let n = 0; + for (const u of universe.values()) n += stage1Forms(u, opt.forms).length * SITES.length; + console.log(`stage 1: ${n} probes in ${Math.ceil(n / opt.batchSize)} or more batches of ${opt.batchSize}, ` + + `${opt.jobs} lane(s); the canaries ride in every batch, and stage 2 adds more for token-table names some site recognises`); + return 0; + } + const started = Date.now(); + mkdirSync(workRoot, { recursive: true }); + tidy = startTidy({ prefixes: [workRoot] }); + let verify = null; + try { + await preflight(); + if (values.preflight) { + for (const [id, why] of voidSites) console.log(`voided ${id}: ${why}`); + console.log(`canaries: ${JSON.stringify(EXPECTED_CANARIES)}; ${SITES.length - voidSites.size} of ${SITES.length} sites usable`); + finishTidy(tidy); + return 0; + } + const s1 = planStage1(); + say(`stage 1: ${s1.length} probes`); + for (const [k, v] of await runAll(s1, "stage 1")) outcomeOf.set(k, v); + if (!stats.cutShort) { + const s2 = planStage2(); + if (s2.length) { + say(`stage 2: ${s2.length} probes for names some site recognised`); + for (const [k, v] of await runAll(s2, "stage 2")) outcomeOf.set(k, v); + } + } + + if (opt.verify && !stats.cutShort) { + const pool = [...probeOf.values()].filter((p) => p.kind === "attr" && outcomeOf.get(p.id) + && !inconclusive(outcomeOf.get(p.id).state)); + const pick = []; + while (pick.length < Math.min(opt.verify, pool.length)) { + pick.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); + } + // A second identity, so the rebuild is a different file in a different + // batch beside different companions: sameness cannot come from caching. + const again = register(pick.map((p) => makeProbe("verify", p.site, p.name, p.form))); + const got = await runAll(again, "verify"); + const { bad, unjudged } = verifyMismatches(pick, again, got, outcomeOf); + verify = { n: pick.length, bad, unjudged }; + run.verify = verify; + } + + emit(build, verify, stats.cutShort); + say(`done in ${Math.round((Date.now() - started) / 1000)} s`); + finishTidy(tidy); + // A run cut short could not do its job (2), though its report is written; a + // self-check that found a fault is a finding (1). + if (stats.cutShort) return EXIT_ERROR; + return stats.interferes.length || (verify && verify.bad.length) ? EXIT_FOUND : 0; + } catch (e) { + // Whatever was learned is still worth having: a cut-short run says so at the + // top of the report rather than leaving nothing. + console.error(e.stack ?? e.message); + try { if (outcomeOf.size) emit(build, verify, e.message); } catch (e2) { console.error(`the report could not be written: ${e2.message}`); } + finishTidy(tidy); + return EXIT_ERROR; + } +} + +let code = EXIT_ERROR; +try { + code = await main(); +} catch (e) { + console.error(e.stack ?? e.message); + finishTidy(tidy); + code = EXIT_ERROR; +} +// Explicit, as census_attributes and gen_attribute_probes end: the markdown +// parser Attributes.md is read with keeps the event loop alive. +process.exit(code); diff --git a/test.bat b/test.bat index 57fc415d..eba6b641 100644 --- a/test.bat +++ b/test.bat @@ -124,6 +124,14 @@ node scripts/check_symbol_index.mjs @rem inputs only: no tree, no install. node scripts/check_twin_parsers.mjs @if errorlevel 1 goto :fail +@rem The attribute sweep asks the compiler where every attribute is legal, and +@rem none of its failures announces itself: a wrong site skeleton reads as +@rem "every attribute is refused here", an ignored control as a recognised +@rem attribute, a refusal taken for an acceptance as a finding about the +@rem compiler. The IDE is what cannot run here, so the parts that decide what an +@rem answer means are probed on fixed inputs: no IDE, no tree, no install. +node scripts/check_attribute_sweep.mjs +@if errorlevel 1 goto :fail @rem Nothing else tests how a tool reads its command line, which is how a @rem value flag given no value came to be read as NaN or as the next flag. @rem lib/cli.mjs's probes, then each tool's recorded command-line errors: From 548827199af8ac7b0fb23f4180d7c19a8dbd94b3 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Wed, 30 Sep 2026 19:26:38 +0200 Subject: [PATCH 4/6] docs: Attributes.md corrections from the sweep (ComExport, Hidden, PopulateFrom crash) --- BUGS-TO-REPORT.md | 42 ++++++++++++++++++++++++++++++++++++ docs/Reference/Attributes.md | 39 +++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index e1e99eb9..7493e337 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -1253,3 +1253,45 @@ IDE's add-in samples leaves the id out. **Observed** on 2026-09-25 with the panes probe's third button, operated by `test/addin/panes.test.mjs`, which reads `toolWindowsById` over CDP. + +## `[PopulateFrom]` with no arguments crashes the compiler + +**Build:** BETA 987 +**Severity:** the compiler process dies while the project is being parsed, which +`tbbuild` reports as a crash (its exit code 4), so a person who forgets the arguments is not +told what is missing. + +The whole reproduction is one Enum: + +``` +Public Module CrashProbe + [PopulateFrom] + Public Enum E + End Enum +End Module +``` + +The Enum's body does not matter: the same crash comes with a member in it, and with the +Enum inside a Class instead of a Module. The documented shape is five strings, +`("json", "/Resources/PROBE/Strings.json", "events", "name", "id")`, and the other wrong +shapes tried are handled: + +| argument list | result | +|---|---| +| none, `[PopulateFrom]` | **the compiler crashes** | +| `(True)`, `(False)`, `(1)` | TB5155 `This attribute is not supported in this context` | +| `("probe")`, on the reproduction above | TB5083 `unsupported data source` | +| the documented five strings, with a resource that exists | compiles | + +So a missing argument list is the one wrong shape that is not checked. + +**Observed** on 2026-09-30 in two ways. `scripts/sweep_attributes.mjs` builds every attribute +at every declaration site in batches of 400 and halves a batch the compiler crashes on; each +of the three Enum sites (an Enum with a member, an empty Enum, an Enum in a Class) was +narrowed to one probe beside the three canaries the tool adds to every batch, which build +clean without it. The four-line reproduction above was then built **exactly as written**, in +a project holding only it and a two-line `Sub Main`, with no resources: `tbbuild` exits 4, +`the compiler crashed 2x -- this project takes it down`, `last parsing: CrashProbe.twin`. +The same project with `[PopulateFrom("probe")]` builds and reports the one TB5083 row. The +rows for `(True)`, `(False)` and `(1)` come from the sweep's batches, not from that +project. diff --git a/docs/Reference/Attributes.md b/docs/Reference/Attributes.md index 56f349e0..ce8c23e6 100644 --- a/docs/Reference/Attributes.md +++ b/docs/Reference/Attributes.md @@ -167,14 +167,31 @@ Indicates whether the class can be created through COM. It does not govern [**Ne Syntax: **[ComExport** [ **( True** \| **False )** ] **]** -Applicable to: constants in a [**Module**](Module) +Applicable to: [**Declare** (API declaration)](Declare) and constants, in a [**Module**](Module) -The COM counterpart of [DllExport](#dllexport), and it takes the same target: a **Public Const**, not a procedure and not a variable. +The attribute marks a given API for export from the ActiveX control DLL being built from the project the attribute is used in. - +The **Declare** can be a **Function** or a **Sub**, including the **PtrSafe** and **DeclareWide** forms. The compiler rejects the attribute on a procedure with a body, on a variable, and on a **Declare** or a constant inside a class. + +```tb check_build +[ComExport] +Public Declare Function GetTickCount Lib "kernel32" () As Long +``` + + + +See also [DllExport](#dllexport). ## COMExtensible (optional Bool) {: #comextensible } @@ -583,7 +600,7 @@ Applicable to: [**Class**](Class), [**CoClass**](CoClass), [**Interface**](Inter Hides the declaration from certain IntelliSense and other lists. It applies to a whole type --- a **Class**, **CoClass**, **Interface** or **Module** --- and equally to a single member of one, so a member can be kept out of those lists without hiding the type that declares it. Within a **Class** that covers procedures, variables, constants and events; within an **Interface**, the member prototypes; within a **Module**, procedures, variables, constants and [**Declare**](Declare) statements. > [!NOTE] -> A **CoClass** can only be hidden whole. Its body holds nothing but **Interface** lines, and the attribute is refused there with TB5155 --- unlike [**Default**](#default) and [**Source**](#source), which are interface-line attributes. It is likewise refused on an **Enum** or [**Type**](Type) declaration, on a **Type** member, and on a procedure parameter, though an individual **Enum** *member* does accept it. +> A **CoClass** can only be hidden whole. Its body holds nothing but **Interface** lines, and the attribute is refused there with TB5155 --- unlike [**Default**](#default) and [**Source**](#source), which are interface-line attributes. It is likewise refused on an **Enum** or [**Type**](Type) declaration, on a **Type** member, and on a procedure parameter. An individual **Enum** *member* is hidden the same way: the **Encoding** constants of [**Open**](Open) are marked `[Hidden, Restricted]`.