diff --git a/.cspell/project.txt b/.cspell/project.txt index a4b0d430..71a7ec85 100644 --- a/.cspell/project.txt +++ b/.cspell/project.txt @@ -47,6 +47,7 @@ hashtable headlessly HKLM INCDIR +inlinehilite inspectable intedit jsontype @@ -56,6 +57,7 @@ launchable ldoc leafo libaegisub +linenums linkified linkify lpeg @@ -96,6 +98,7 @@ phoscity pkgs PreciseTimer prereq +pymdownx refetch refetched refetches @@ -112,6 +115,7 @@ rvars settimeout shouty stdlib +superfences Tbls templater tmpl diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 357ec663..c187e095 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ # Release DependencyControl itself: a thin caller over the reusable publish-release workflow (the same # building block third-party package repos use), mapping this repo's channel layout onto its inputs. -# `main` is the dev/default branch; `master` is the protected publish branch that serves the feed URL -# (…/DependencyControl/master/DependencyControl.json) and the schema $id URLs. +# `main` is the dev/default branch; `publish` is the protected publish branch that serves the feed URL +# (…/DependencyControl/publish/DependencyControl.json) and the schema $id URLs. # -# Pushing to protected `master`/`main` and their tags uses a GitHub App on their bypass lists, wired +# Pushing to protected `publish`/`main` and their tags uses a GitHub App on their bypass lists, wired # through the DEPCTRL_CI_APP_CLIENT_ID variable and DEPCTRL_CI_APP_PRIVATE_KEY secret. With those unset # the run falls back to GITHUB_TOKEN, usable only while the branches are unprotected (e.g. a dry-run # before locking them down). See the README's "Release Automation → Branch protection" for App setup. @@ -44,7 +44,7 @@ jobs: stable-channel: stable release-channels: stable # cut a GitHub Release for stable versions only (not alpha) dev-branch: main - publish-branch: master # historical: our feed URL is on master + publish-branch: publish depctrl-ref: ${{ github.event.inputs.ref }} # release DepCtrl with its own CLI at that ref app-client-id: ${{ vars.DEPCTRL_CI_APP_CLIENT_ID }} secrets: diff --git a/.gitignore b/.gitignore index f09748f5..c64d7459 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /ctrf/*.json /dist/ /DependencyControl-*.zip +# resolve-feed output (the one-time master upgrade feed); pushed to master by hand, never to main/publish +/DependencyControl.resolved.json /types/ # generated API reference (the rest of docs/ is committed source); mkdocs build output /docs/reference/ diff --git a/AGENTS.md b/AGENTS.md index 314b852d..8fdeefca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,16 @@ These are prose habits common in AI output but rare in human contributors', so t ``` - **WR3.** Prefer natural language over backticked identifier names in prose. A declared `---@param feedUrl` reads as "the given feed URL", not "`feedUrl`"; drop reflexive "(see `otherMethod`)" pointers. Reserve backticks for when the exact name is what the caller acts on — a config key they set (`extraFeeds`), an enum value the result equals, a field they inspect (`stats.truncated`) — not as an echo of a declared parameter. LuaCATS has no inline link syntax, so a backticked name is just literal clutter. -- **WR4.** Clustered punctuation is a smell — in `--` comments, doc comments, and Markdown alike. No single mark is banned, but when parentheses, semicolons, colons, and em dashes pile up in one sentence, reorganize it. Watch for a semicolon nested inside a parenthetical, stacked parentheticals, an em-dash aside in a clause that already carries parens or a semicolon, a closing paren shoved against a semicolon, or a `label:` headline hung off a colon. The fix is one aside per sentence. +- **WR4.** Clustered punctuation is a smell — in `--` comments, doc comments, and Markdown alike. No single mark is banned, but when parentheses, semicolons, colons, and em dashes pile up in one sentence, reorganize it. Watch for a semicolon nested inside a parenthetical, stacked parentheticals, an em-dash aside in a clause that already carries parens or a semicolon, or a closing paren shoved against a semicolon. The fix is one aside per sentence. - **WR5.** Keep annotation prose short. A doc comment **SHOULD** default to one line, gaining a second only for a genuinely separate contract (a precondition, a side effect), never to pad. - **WR6.** Don't manufacture detail to fill space. The summary and the `---@return` **MUST NOT** be padded with invented specifics to force a difference between them; a concise `@return` shape label that echoes the summary is fine. - **WR7.** A comment **MUST** stay inside the method it documents — state this call's contract, not what another method does ("(run acquires the lock first)"). +- **WR8.** Never front a comment or sentence with a `label:` colon that reads like a blog subhead. The tell is a short topic label, then a colon, then the real point standing in as the subhead's body, as in `next, not pairs: a subclass has no instance to iterate`. Write the point as a plain sentence. A `label:` headline is a smell in its own right, distinct from WR4 clustering — it is wrong even as the only punctuation on the line. Colons that genuinely introduce a list or an appositive are fine. + + ```moon + -- BAD — a label: headline standing in for the sentence: + -- next, not pairs: a subclass has no instance to iterate + + -- GOOD — a plain sentence: + -- iterate a class base with next, not pairs, since a subclass has no instance + ``` diff --git a/DependencyControl.json b/DependencyControl.json index 1c04004a..4914f588 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -16,7 +16,8 @@ "vars": { "tagSuffix": { "alpha": "-alpha", - "stable": "" + "stable": "", + "main": "" } }, "maintainer": "line0", @@ -60,12 +61,12 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "E9E7B3C7D0129478A6285F57DE3A06C3C97D4046" + "sha1": "D98DB9A983627E49B5991B108F0D77513779D0E5" }, { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "4E323C80C4FAA70FED2470BB4E363CD811400B3B", + "sha1": "59B666472F097BE5D71ADB788A96D7A650830BB5", "type": "test" } ], @@ -102,7 +103,8 @@ ], "0.7.0": [ "feat: Added a Global Configuration macro to view and edit DependencyControl's global settings — update behavior, feed discovery and caching, logging, and file paths — in one dialog, so common tweaks no longer need hand-editing the config file. The feed lists stay in the Manage Feeds macro.", - "change: The Toolbox now updates from a `stable` channel by default; installations on the previous `alpha` channel are migrated automatically." + "change: The Toolbox now updates from a `stable` channel by default; installations on the previous `alpha` channel are migrated automatically.", + "fix: The Install, Uninstall, and Update script dropdowns now start empty on macOS instead of defaulting to, and being stuck on, their first entry." ] } } @@ -122,7 +124,7 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "D27AA2A2EB8B0C498F6AA4F4E5025A87F1DCCC6F" + "sha1": "27D105863DE6D5B8055420AA467AF4131635B0BC" }, { "name": "/Constants.moon", @@ -142,7 +144,7 @@ { "name": "/hash.moon", "url": "@{fileBaseUrl}", - "sha1": "5EF0E977CFA14E9DDBCD9A71016A269FD789FFF2" + "sha1": "452C471ADC2C3BD916B706637E412BB97D27C8DA" }, { "name": "/Downloader.moon", @@ -182,12 +184,12 @@ { "name": "/FileLock.moon", "url": "@{fileBaseUrl}", - "sha1": "E3CFA85E0FE60096AC1E87F0A41961946EF606C0" + "sha1": "28F79DB790CF4746441B6127A8953708D1A52501" }, { "name": "/file-ops.moon", "url": "@{fileBaseUrl}", - "sha1": "E4DF3728939D7B52675C300FE9B97B610DED98CD" + "sha1": "2FB92DBDC1AA861B7D5A859B165CE71C39E8FBD0" }, { "name": "/FileOps.moon", @@ -203,7 +205,7 @@ { "name": "/Host.moon", "url": "@{fileBaseUrl}", - "sha1": "4C5DA5F5BC21C4B06D651CFFFB80D76CF3FF2608" + "sha1": "3A05ABCE9F229CDA731DD0F83A93AC2E58BEA3AD" }, { "name": "/JsonSchema.moon", @@ -218,7 +220,7 @@ { "name": "/Logger.moon", "url": "@{fileBaseUrl}", - "sha1": "84939ADA4D8C32551AA50CC4B8D1E9E7B9EB8E7F" + "sha1": "CBFC4B9C5699D3606D7AEE92137DA91DEC1B4EBD" }, { "name": "/ModuleLoader.moon", @@ -233,17 +235,17 @@ { "name": "/NamedSemaphore.moon", "url": "@{fileBaseUrl}", - "sha1": "AD68DF342CF0A6068B22F75D9BC44D4B115FE1CE" + "sha1": "E86D0C92DAC01797DA4A043814CDE252244657D3" }, { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "443CC1EBE9AC1D0A9696A0BCA8129487E2888FA2" + "sha1": "235139F889F0E32B3B1F5EB62A82AB094E24AD4F" }, { "name": "/ScriptTargetFilter.moon", "url": "@{fileBaseUrl}", - "sha1": "E6E4988292DF9A8F5CA37A2E03027340A40FCDB1" + "sha1": "4AD5EA4A952D81AFA4AF5200378444A7D129BB2B" }, { "name": "/ScriptUpdateRecord.moon", @@ -278,12 +280,12 @@ { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "5201D06833AA9D180680B3DE8014575FCA3C989A" + "sha1": "D7296244D4B9DAEAD0463CF6FE699AAF5CDF39A8" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}", - "sha1": "1E881F094A15652D2B56391BF372B040498BE0A0" + "sha1": "C4BAFF29F7FBE58EBF91C687B34D204B9AF49927" }, { "name": "/ZipArchiver.moon", @@ -298,7 +300,7 @@ { "name": "/helpers/ffi-windows.moon", "url": "@{fileBaseUrl}", - "sha1": "171419FCEB50B258571A9A4D72C590858743AD6F" + "sha1": "C7A8C868F2F5A49A8A50754492C934DE12086AD8" }, { "name": "/helpers/open-url.moon", @@ -323,7 +325,7 @@ { "name": "/shims/PreciseTimer.moon", "url": "@{fileBaseUrl}", - "sha1": "FD5E729C378EA7216AF7C0E2EBAB42AF4A88339D" + "sha1": "DC13468D9A31DC5E3DF4E1C20641C1F5853B1777" }, { "name": "/Accessors.moon", @@ -353,7 +355,7 @@ { "name": "/domain.moon", "url": "@{fileBaseUrl}", - "sha1": "A3A82DEB64487204E4771EC9222029FC55B446B3" + "sha1": "C5D9BC686DFA4A85A28EE6E2E392B1E012A83909" }, { "name": "/environment.moon", @@ -363,12 +365,12 @@ { "name": "/utils.moon", "url": "@{fileBaseUrl}", - "sha1": "063074381094F2F26408CA9DCA90D7270ACC02B7" + "sha1": "F42018262A7E4E92333A33F8287FB3CBA8AF6986" }, { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "987B9AA2FF029430233E2A01A8EC3926E9D074E8", + "sha1": "30C9DAB080A711CAAF8C8C7AC6B89E0BA9292290", "type": "test" }, { @@ -392,7 +394,7 @@ { "name": "/hash.moon", "url": "@{fileBaseUrl}", - "sha1": "EAB8280045E3CC4EA8D3D58B30922D74FCF953D4", + "sha1": "B1C6AC7433296675E6A686F16D977510D922BF26", "type": "test" }, { @@ -428,7 +430,7 @@ { "name": "/FeedTrust.moon", "url": "@{fileBaseUrl}", - "sha1": "7377B4375F3576BF0ECEA720205A2BAF54CA84F1", + "sha1": "FF605ACEC47E863CE5DACFB80760119F15608931", "type": "test" }, { @@ -506,7 +508,7 @@ { "name": "/PackageRecord.moon", "url": "@{fileBaseUrl}", - "sha1": "2C74D8E781798D954A94C77D2FB327DBEF0EA5CA", + "sha1": "9AB3488762804B65BF48CE33C44BA99628DF3F9D", "type": "test" }, { @@ -542,19 +544,19 @@ { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}", - "sha1": "EAA098F1A2B4D096A56E6D50DF9C87CBF313FF08", + "sha1": "16FA94A0BBAE41051C3312425890E2054609AF56", "type": "test" }, { "name": "/UpdateTask.moon", "url": "@{fileBaseUrl}", - "sha1": "36ABF5C3392E2630CA9AA51FF39D3FF2F975FBFF", + "sha1": "5CA6FC899341677F702788F543373C8F3EDCFAA0", "type": "test" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}", - "sha1": "195C4E1CFA2D360B418E5EA4D6D869F552A49D93", + "sha1": "7945FCE5A2DC11257301DE5A2B2423E56A0AC4CB", "type": "test" }, { @@ -566,7 +568,7 @@ { "name": "/integration/Downloader.moon", "url": "@{fileBaseUrl}", - "sha1": "A5D67A70DBC896256430015764899AF98DECD585", + "sha1": "30B80FCF880C7139A0C86E26CD04F6DB41162698", "type": "test" }, { @@ -617,12 +619,6 @@ "sha1": "ED12F95454B98E86A207D02EEDD42F84B19E2E3C", "type": "test" }, - { - "name": "/integration/Logger.moon", - "url": "@{fileBaseUrl}", - "sha1": "83655761675C1EF3F21C15A6348E5AD65BB40FE1", - "type": "test" - }, { "name": "/release-notes.moon", "url": "@{fileBaseUrl}", @@ -638,7 +634,7 @@ { "name": "/utils.moon", "url": "@{fileBaseUrl}", - "sha1": "0DEFEDA3EBBE4F4F8A0F8BF1F7426C48B638BF36", + "sha1": "F78238492CB05F9F1568C14DE363A83CE1CAB6D8", "type": "test" }, { @@ -646,12 +642,12 @@ "url": "@{fileBaseUrl}", "sha1": "1889EFB487A85EF9CD9838312B36CAB97D332119", "type": "test" - }, + } + ], + "requiredModules": [ { - "name": "/dkjson.moon", - "url": "@{fileBaseUrl}", - "sha1": "8FC93EF2FCE158FBF32EA4303098F51E90DFA3D4", - "type": "test" + "moduleName": "l0.dkjson", + "version": "0.7.0" } ], "provides": [ @@ -745,7 +741,7 @@ "change: DependencyControl's settings file is now organized into `updates`, `feeds`, `logging`, and `paths` sections, and an existing config is migrated into the new layout automatically on first load. Several settings were renamed in the process, for example `updaterEnabled` to `updates.mode` and `feedCacheMaxAge` to `feeds.cacheMaxAge`. The separate feed-cache directory gave way to a single `paths.cache` location that DependencyControl organizes by namespace.", "change: The config file is now easier to read and maintain by hand. Each installed script's version is stored as a semantic-version string such as \"1.2.3\" rather than a packed integer, only the values you have actually changed are written, and the file is saved as indented, pretty-printed JSON. Existing configs are migrated automatically on first load.", "change: DependencyControl now refuses to load on an Aegisub build it can't run on, showing an error that directs you to update. It needs a MoonScript at or above its minimum version, and a LuaJIT built with Lua 5.2 compatibility, which its computed properties and cross-type version comparisons rely on.", - "feat: Feed format v0.4.0 reduces repetition for feed authors. Per-file-type URL and path templates can be set once at the feed root or on a macros/modules section container and roll down to every package, while the single-string `fileBaseUrl` and `localFileBasePath` keep working as the fallback. Feeds can also define their own template variables, including lookup tables indexed by a computed key such as `@{tagSuffix:@{channel}}`, and the new `@{scriptType}` and `@{scriptTypeSection}` variables expose each package's type and feed section.", + "feat: Feed format v0.4.0 reduces repetition for feed authors. Per-file-type URL and path templates can be set once at the feed root or on a macros/modules section container and roll down to every package, while the single-string `fileBaseUrl` and `localFileBasePath` keep working as the fallback. Feeds can also define their own template variables, including lookup tables indexed by a computed key such as a per-channel tag suffix, and the new `scriptType` and `scriptTypeSection` variables expose each package's type and feed section.", "feat(SemanticVersion): SemanticVersion is now a value object. You can construct it from a version string or from major, minor, and patch components, compare instances with the standard operators, read and bump the individual parts, and test it against a version range. A record's version is backed by one and exposed as `record.semanticVersion`, while `record.version` still returns the packed integer as before.", "feat: Added the Accessors module for declaring metatable-backed computed properties through one standardized transform, instead of hand-rolled metatable code. Each declared property is recorded on the class for inspection tooling.", "change: The monolithic `DependencyControl.moon` was split into focused sub-modules as groundwork for a future registry backend. `PackageRecord`, `ModuleLoader`, `SemanticVersion`, and the `domain`, `environment`, and `utils` tables now own version records, module loading, version handling, and the shared enums and helpers. Script and record types are represented by proper enums, `ScriptType` and `RecordType`, rather than bare booleans.", @@ -754,12 +750,14 @@ "fix(Tests): Fixed a cluster of long-broken assertions and behaviors. `assertContains` now honors its case-insensitive option, `assertItemsEqual`, `assertContinuous`, `assertNotAlmostEquals`, and `assertError` report correct results and messages, `ut\\stub` works inside a class `_setup`, a failing setup is reported as a failure instead of aborting the whole run, and the CTRF report records accurate timings.", "feat(FileOps): Added `getAttributes()`, which returns a file or directory's attributes together with the parsed components of its path, keeping a genuine lookup failure out of the returned value. The previous `attributes()` remains as a deprecated wrapper. A hard attribute-lookup failure is no longer mistaken for a missing or wrong-typed file, so `writeFile`, `listDir`, `rmdir`, and `remove` surface the real error.", "fix(FileOps): Uninstalling a package no longer deletes files belonging to a sibling package whose name shares a prefix, and namespaces containing hyphens now match only their own files.", + "fix(PackageRecord): registerMacro now fails at registration with a clear message when its process callback is missing or not a function, instead of an opaque \"attempt to call a nil value\" when the macro is run.", "fix: Installs and updates inside Aegisub no longer fail with a spurious \"failed to create temporary download directory\" error, which came from Aegisub's bundled lfs reporting directory creation differently from stock LuaFileSystem.", "feat(Timer): Exposed a shared monotonic `Timer.getTime()` clock, added stopwatch `start()`, `stop()`, and `reset()` methods and an `elapsed` property in milliseconds, and routed PreciseTimer through the same bundled-provider mechanism as BadMutex and DownloadManager.", "feat(Hash): Added `getObjectHash()`, which reduces an arbitrary, possibly nested Lua value to a stable, order-independent SHA-1 signature.", "feat(utils): Added the `makeSet`, `addDefaults`, `trim`, and `escapePattern` helpers, shared with the Functional module.", + "feat(utils): Added `seedRandom` and `getRandomSeed` for seeding Lua's random number generator with a value unique to the current script's Lua state. A script can call `seedRandom` to reseed the generator itself rather than depend on DependencyControl's load-time seeding.", "change(Lock): Locks are now per-resource, so distinct resources no longer contend over a single global mutex. An optional `Global` scope enforces mutual exclusion across separate Aegisub instances, used when reading and writing shared config files. A Global lock is backed by an OS advisory lock that the system releases if the holder crashes, so a config file can never stay permanently locked. A held lock records its holder for troubleshooting, warns when it waits on a lapsed lease, and adds a `renew()` method and a `Lock.guard` helper that always releases the lock.", - "change: Scripts using DependencyControl no longer pause about half a second while loading. The wait that spread the random seed across simultaneously loading scripts is gone, replaced by a monotonic-clock seed that is unique per script environment on its own.", + "change: Scripts using DependencyControl no longer pause about half a second while loading. The wait that spread the random seed across simultaneously loading scripts is gone. Each script's Lua state now seeds from its own identity together with the monotonic clock and process id, so states loading at the same instant still get distinct log file names and temp paths.", "fix: Many updater and installer messages and error reports were corrected. Among them, \"Couldn't installation module\" and \"Starting nil of ...\" are fixed, dependency-resolution errors no longer print \"of nil Module\" or drop the failing sub-requirement, and a failing optional dependency or module initializer now surfaces with its name and the real error instead of being mislabeled or dropped.", "fix(Logger): Progress bars now fill incrementally instead of jumping from empty to full, the log window no longer repeats the indentation prefix inside incrementally written output, and the `usePrefix` option applies to both the log file and the window.", "fix: Several smaller correctness fixes. `mkdir` no longer reports success when creation failed, the temporary download directory is removed after a successful install, and `SemanticVersion.toString` renders nil as \"0.0.0\" instead of erroring.", @@ -768,7 +766,9 @@ "feat: Releases now version feed packages in lockstep, advancing only the packages that changed to a single shared repo-level version. New `bump-version`, `merge-feed`, and `release-notes` CLI tasks and an `update-feed --mark-released` option drive the release workflow. Changelog entries can also carry conventional-commit markers that the generated release notes group by category and use to flag breaking changes.", "change: A short-form required-module dependency that names more than one module (such as `{\"ffi\", \"json\"}`) is now detected and reported with a warning. Only the first name is honored, as before.", "fix: When several scripts install modules at the same time, a dependency that isn't installed yet is no longer mistaken for an installed one while waiting for another update to finish.", - "refactor: Reinstalling a managed module that exposes a plain version string instead of its DependencyControl record no longer demotes it to unmanaged, so it keeps receiving scheduled update checks." + "refactor: Reinstalling a managed module that exposes a plain version string instead of its DependencyControl record no longer demotes it to unmanaged, so it keeps receiving scheduled update checks.", + "feat: New `resolve-feed` CLI command expands a feed's templates into a static, template-free feed compatible with the legacy v0.3.0 feed format.", + "fix: Reloading a freshly updated module also evicts its cached submodules, so a multi-file module no longer reloads into a mix of new and stale code." ] } }, @@ -792,6 +792,12 @@ "name": "/vendor/dkjson.lua", "url": "@{fileBaseUrl}", "sha1": "A0597E1AEEB14D42DABB3A0E8C05129EB024EDCA" + }, + { + "name": ".moon", + "url": "@{fileBaseUrl}", + "sha1": "46F563422E13C4D74FA630887DE0DEA917D767EB", + "type": "test" } ], "provides": [ @@ -824,17 +830,17 @@ { "name": ".moon", "url": "@{fileBaseUrl}", - "sha1": "6E00B5AA21371547CD145B1D742EDD74231326C2" + "sha1": "6DCB5356005FC7048A4D15C5F65D017C47B35476" }, { "name": "/Parser.moon", "url": "@{fileBaseUrl}", - "sha1": "456935E97E00950AF0EF8793C67075D42CCCE579" + "sha1": "555504F2E546FFD9DBA5A9756AEAAC9A486C99A8" }, { "name": "/Emitter.moon", "url": "@{fileBaseUrl}", - "sha1": "343E8A59EA72F5C8B79FD5DCD202D0B6306BDFC7" + "sha1": "FBE53CF7A471A1CAB92B87BFB6DEBF991A4E2D43" }, { "name": "/Diagnostics.moon", @@ -849,7 +855,7 @@ { "name": "/DocRenderer.moon", "url": "@{fileBaseUrl}", - "sha1": "72B6ED68B610D2ECBBB77A3666FFE5822AEC1A04" + "sha1": "371B726FB05EE39BD6DF1E086588E3A31A6AE1DB" }, { "name": ".moon", @@ -866,7 +872,7 @@ { "name": "/DocRenderer.moon", "url": "@{fileBaseUrl}", - "sha1": "DD4F9F38B07944BFC66A379AE68C6D5C5612F1D3", + "sha1": "E8EBB732700725A7214BDEA0A82AC1020575A3C2", "type": "test" }, { @@ -878,7 +884,7 @@ { "name": "/Emitter.moon", "url": "@{fileBaseUrl}", - "sha1": "ADC78F3D7B3811775E3A44C19D40EEB07802D853", + "sha1": "51FF7EC64C90F549F7849FE2171D53F093A18FED", "type": "test" }, { diff --git a/README.md b/README.md index 55654bf9..92bbc9a3 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,45 @@ DependencyControl provides versioning, automatic script update, dependency manag - Supports optional modules and private module copies for cases where an older or custom version of a module is required - Resolves circular dependencies (limitations apply) -**Requirements**: - -- Aegisub [v3.4.0+](https://github.com/TypesettingTools/Aegisub/releases) or releases of [arch1t3cht's Aegisub fork](https://github.com/arch1t3cht/Aegisub/releases) based on v3.4.0+. Older versions of Aegisub may work, but you're on your own if you run into any issues. - DependencyControl is self-contained: it bundles a JSON library ([dkjson](https://dkolf.de/dkjson-lua/)), though if you have another `json` module installed, it is used instead. It also now ships with pure-FFI implementations of functionality previously provided by [ffi-experiments](https://github.com/torque/ffi-experiments) modules (_DownloadManager_, _BadMutex_, _PreciseTimer_). --- ## Table of Contents -1. [DependencyControl for Users](#dependency-control-for-users) -2. [Usage for Automation Scripts](#usage-for-automation-scripts) -3. [Namespaces and Paths](#namespaces-and-paths) -4. [The Updater Feed](#the-updater-feed) -5. [Reference](#reference) -6. [CLI](#cli) -7. [Release Automation](#release-automation) +1. [Prerequisites](#prerequisites) +2. [DependencyControl for Users](#dependencycontrol-for-users) +3. [DependencyControl for Script Authors](#dependencycontrol-for-script-authors) +4. [Namespaces and Paths](#namespaces-and-paths) +5. [The Updater Feed](#the-updater-feed) +6. [Reference](#reference) +7. [CLI](#cli) +8. [Release Automation](#release-automation) + +--- + +## Prerequisites + +DependencyControl downloads and verifies packages, which on some systems relies on a couple of standard shared libraries. What (if anything) you need to install depends on your platform and on whether you use DependencyControl inside Aegisub or through its standalone [CLI](#cli). + +### For Aegisub Users + +On **Windows** and **macOS** there is nothing to install beyond Aegisub [v3.4.0+](https://github.com/TypesettingTools/Aegisub/releases) itself — or a release of [arch1t3cht's Aegisub fork](https://github.com/arch1t3cht/Aegisub/releases) based on v3.4.0+ (older versions may work, but you're on your own if you run into issues). + +On **Linux**, two additional system libraries are used to download and verify packages. Both are almost certainly already installed on any distribution, so you would only need to add them on a very bare-bones system: + +- **libcurl 7.30.0 or newer** (`libcurl.so.4`) — downloads packages and updates. +- **OpenSSL 1.1.0 or newer** (`libcrypto.so.1.1` or `libcrypto.so.3`) — computes the SHA-1 hashes DependencyControl relies on, both to verify downloaded files and internally for its locking between concurrent Aegisub instances. + +DependencyControl also guards against [server-side request forgery](https://en.wikipedia.org/wiki/Server-side_request_forgery) — a hostile feed trying to point a download at an address inside your own network — through the [`blockPrivateHosts`](#1-global-configuration) setting. For that guard to cover redirects as well, **libcurl 7.80.0 or newer** is needed; with an older libcurl the address you start from is still checked, but the targets of any redirects along the way are not. + +### For CLI Users (Script Authors & Contributors) + +The [CLI](#cli) runs outside Aegisub and needs its own Lua toolchain (LuaJIT, LuaRocks, and a set of rocks) on top of the system libraries above. See [CLI → Toolchain setup](#toolchain-setup) for the full requirements and install commands. --- -## Dependency Control for Users +## DependencyControl for Users As an end-user you don't get to decide whether your scripts use DependencyControl or not, but you can control many aspects of its operation. The updater works out-of-the-box (for any script with an update feed) and is run automatically. @@ -153,9 +171,9 @@ When a package can be installed from more than one source and DependencyControl A remembered choice follows the source even if its feed URL changes, so moving a feed won't break it. When the picked source is reached through a [provider](#providing-module-aliases), it's the provider that's remembered, so version bumps update it in place instead of switching to a different one. -## Usage for Automation Scripts +## DependencyControl for Script Authors -### For Macros +### Usage for Automation Scripts Load DependencyControl at the start of your macro and create a version record. Script and version information is automatically pulled from the `script_*` variables (the additional `script_namespace` variable is **required**). @@ -173,12 +191,12 @@ local version = DependencyControl{ feed = "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", { "aegisub.util", - {"a-mo.LineCollection", version="1.0.1", url="https://github.com/torque/Aegisub-Motion"}, - {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - {"a-mo.Log", url="https://github.com/torque/Aegisub-Motion"}, - {"l0.ASSFoundation", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", + {"a-mo.LineCollection", version = "1.0.1", url = "https://github.com/torque/Aegisub-Motion"}, + {"a-mo.Line", version = "1.0.0", url = "https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.Log", url = "https://github.com/torque/Aegisub-Motion"}, + {"l0.ASSFoundation", version = "0.1.1", url = "https://github.com/TypesettingTools/ASSFoundation", feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, - {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", + {"l0.ASSFoundation.Common", version = "0.1.1", url = "https://github.com/TypesettingTools/ASSFoundation", feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, "YUtils" } @@ -207,7 +225,7 @@ version:registerMacros{ Using this method for macro registration is a requirement for the **custom submenus** feature to work with your script and lets DependencyControl hook your macro processing function to run an update check when your macro is run. -### For Modules +### Usage for Modules Creating a record for a module is very similar to how it does for macros, with the key difference being that name and version information is passed to DependencyControl correctly and a _moduleName_ is required. @@ -224,14 +242,15 @@ local version = DependencyControl{ { "l0.ASSFoundation.ClassFactory", "aegisub.re", "aegisub.util", "aegisub.unicode", - {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", + {"l0.ASSFoundation.Common", version = "0.1.1", url = "https://github.com/TypesettingTools/ASSFoundation", feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, - {"a-mo.LineCollection", version="1.0.1", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, - {"a-mo.Log", url="https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.LineCollection", version = "1.0.1", url = "https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.Line", version ="1.0.0", url = "https://github.com/TypesettingTools/Aegisub-Motion"}, + {"a-mo.Log", url = "https://github.com/TypesettingTools/Aegisub-Motion"}, "ASSInspector.Inspector", - {"YUtils", optional=true}, - } + {"YUtils", optional = true}, + } +} local createASSClass, re, util, unicode, Common, LineCollection, Line, Log, ASSInspector, YUtils = version:requireModules() ``` @@ -406,7 +425,7 @@ An automation script or module object looks like this: { "name": ".lua", // the file name relative to the path assigned to the script by your namespace choice - // (see 3. Namespaces and Paths for more information). Available as the @{fileName} template variable + // (see the Namespaces and Paths section for more information). Available as the @{fileName} template variable // for use in the url field below. "url": "@{fileBaseUrl}@{fileName}", // URL from which the **raw** file can be downloaded from (no archives, no javascript @@ -437,7 +456,7 @@ An automation script or module object looks like this: "requiredModules": [ // an exhaustive list of modules required by this script. Must be identical to the required // module entries in your DependencyControl record, but you may not use short style here. - // (see 2. Usage for Automation Scripts for more information) + // (see the DependencyControl for Script Authors section for more information) { "moduleName": "a-mo.LineCollection", "name": "Aegisub-Motion (LineCollection)", @@ -534,7 +553,7 @@ For an example that serves updates from the HEAD of a GitHub repository's main b ## Reference -The full API reference — every class, method, parameter, and return, for every module — is published at **[typesettingtools.github.io/DependencyControl](https://typesettingtools.github.io/DependencyControl/)**, showing the latest release (with a version picker for earlier releases and the in-development `master`). To build it from your own checkout, or to get the same annotations as editor IntelliSense, see [MoonCATS](#mooncats) below. +The full API reference — every class, method, parameter, and return, for every module — is published at **[typesettingtools.github.io/DependencyControl](https://typesettingtools.github.io/DependencyControl/)**, showing the latest release (with a version picker for earlier releases and the in-development `main`). To build it from your own checkout, or to get the same annotations as editor IntelliSense, see [MoonCATS](#mooncats) below. ### MoonCATS @@ -544,19 +563,36 @@ The `l0.MoonCats` module extracts the [LuaCATS](https://luals.github.io/wiki/ann DependencyControl ships a CLI launcher (`depctrl.lua`) for running tests, building release bundles, and deploying to a local Aegisub installation — all **without** a running Aegisub process. All commands read their package list from a feed JSON file and can operate on any DepCtrl-managed package, not only DependencyControl itself. -### Prerequisites +### Toolchain setup + +Beyond the native runtime libraries the CLI shares with Aegisub (see [Prerequisites](#prerequisites)), running the CLI needs a standalone Lua toolchain: -- _LuaJIT_ on your `PATH`, built with `DLUAJIT_ENABLE_LUA52COMPAT` +- _LuaJIT_ on your `PATH`, built with `-DLUAJIT_ENABLE_LUA52COMPAT` - _LuaRocks_, configured for Lua v5.1, which _LuaJIT_ is ABI-compatible with. You may have to select the Lua version explicitly via `luarocks --lua-version=5.1` -- The [moonscript](https://luarocks.org/modules/leafo/moonscript), [LuaFileSystem](https://luarocks.org/modules/hisham/luafilesystem) and [argparse](https://luarocks.org/modules/mpeterv/argparse) rocks, installed into that 5.1 tree: +- Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). - ```sh - luarocks --lua-version=5.1 install moonscript - luarocks --lua-version=5.1 install luafilesystem - luarocks --lua-version=5.1 install argparse - ``` +Every command needs three core rocks: [moonscript](https://luarocks.org/modules/leafo/moonscript) (to load the `.moon` sources), [LuaFileSystem](https://luarocks.org/modules/hisham/luafilesystem), and [argparse](https://luarocks.org/modules/mpeterv/argparse). -- Your `LUA_PATH` / `LUA_CPATH` must let `luajit` find the LuaRocks-installed modules (`luarocks --lua-version=5.1 path --bin` prints the correct values). +```sh +luarocks --lua-version=5.1 install moonscript +luarocks --lua-version=5.1 install luafilesystem +luarocks --lua-version=5.1 install argparse +``` + +Individual commands pull in a few more rocks; install a group only if you run the commands that need it. The [test workflow](.github/workflows/test.yml) installs the full superset. + +- **Feed validation:** commands that refresh or write the feed (led by `update-feed`) validate it against the bundled JSON schema using `lua-schema`, with `lpeg` for its pattern rules. Without `lua-schema` present, validation is skipped with a warning rather than failing. +- **`test` with the mock HTTP server:** test suites that exercise download and update code spin up a small mock web server built on `luasocket`, `copas`, and `pegasus`. DependencyControl's own suite needs all three; a package whose tests don't reach the network does not. + +```sh +# feed validation +luarocks --lua-version=5.1 install lua-schema +luarocks --lua-version=5.1 install lpeg +# mock HTTP server for network tests +luarocks --lua-version=5.1 install luasocket +luarocks --lua-version=5.1 install copas +luarocks --lua-version=5.1 install pegasus +``` General form: @@ -754,7 +790,7 @@ Renders browsable API documentation straight from the module sources' LuaCATS an For a **standalone API site**, `--site mkdocs` emits a ready-to-serve, self-contained [MkDocs](https://www.mkdocs.org/) project, and `--site mdbook` the [mdBook](https://rust-lang.github.io/mdBook/) equivalent. The **default, `--site none`**, is the advanced path DependencyControl's own site takes: it writes only an **embeddable reference section**, which a committed `mkdocs.yml` folds into a larger, hand-written site through the [`mkdocs-literate-nav`](https://github.com/oprypin/mkdocs-literate-nav) plugin, keeping `docs/` and `mkdocs.yml` as committed source with only the generated `docs/reference/` gitignored. -Published API docs are versioned on GitHub Pages by the `Docs` workflow (`.github/workflows/docs.yml`), which regenerates the docs and deploys them with [mike](https://github.com/jimporter/mike): every merge to master updates `/master/`, every `v*` tag publishes an immutable `//` copy and moves the `latest` alias, and a manual workflow dispatch on any branch publishes a `//` preview. The site's root URL redirects to `latest` once the first release exists, and to `/master/` before then, so it is never a dead link. The Material theme's version picker lists all published versions; a stale preview can be dropped with `mike delete --push` from the repository root (where the committed `mkdocs.yml` lives). Serving requires the repository's Pages source to be set to the `gh-pages` branch (root). +Published API docs are versioned on GitHub Pages by the `Docs` workflow (`.github/workflows/docs.yml`), which regenerates the docs and deploys them with [mike](https://github.com/jimporter/mike): every merge to main updates `/main/`, every `v*` tag publishes an immutable `//` copy and moves the `latest` alias, and a manual workflow dispatch on any branch publishes a `//` preview. The site's root URL redirects to `latest` once the first release exists, and to `/main/` before then, so it is never a dead link. The Material theme's version picker lists all published versions; a stale preview can be dropped with `mike delete --push` from the repository root (where the committed `mkdocs.yml` lives). Serving requires the repository's Pages source to be set to the `gh-pages` branch (root). | Option | Default | Description | | ------------------- | ------------------------------- | -------------------------------------------------------- | @@ -783,7 +819,7 @@ The feed URL baked into installed configs points at one branch, so that branch m A release _copies_ the dev channel onto a chosen published channel and leaves the channels it isn't releasing untouched, so the published channels can sit at different versions. Each channel's files resolve from a `v` tag (with the channel's tag suffix) that the release workflow creates. -DependencyControl's own repository keeps this branch as `master` for historical reasons — its published feed URL predates this layout and can't move — so its `release.yml` overrides `publish-branch: master`. New packages need no such override: the workflow defaults to `publish`, and any name works as long as it's the branch your feed URL points at. +DependencyControl's own feed URL served from `master` through v0.6.x; from v0.7.0 it serves from `publish`, the workflow's default. Because clients older than v0.7.0 can't read the current feed format, a one-time resolved feed left on `master` advertises v0.7.0 so they can upgrade onto `publish`. Any branch name works, as long as it's the one your feed URL points at. ### Using the workflows in your own package diff --git a/depctrl.lua b/depctrl.lua index eab99aaa..4df7fbc0 100644 --- a/depctrl.lua +++ b/depctrl.lua @@ -80,6 +80,27 @@ updateFeedCmd:option("--release-date", "Date to stamp with --mark-released (default: today, UTC)"):argname("") addTargets(updateFeedCmd) +local resolveFeedCmd = parser:command("resolve-feed", + "Expand a feed's templates into a static feed with no template variables. Useful to downgrade a feed that uses template variables added in feed version 0.4.0 that older DependencyControl versions don't support.") +resolveFeedCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") +resolveFeedCmd:option("--file-base-url", + "Override the feed's fileBaseUrl before expansion, re-homing every file download (e.g. onto a local mirror)") + :argname("") +resolveFeedCmd:option("--feed-schema-version", + "Stamp this dependencyControlFeedFormatVersion on the output (default: keep the source's)"):argname("") +resolveFeedCmd:option("--out-file", + "Destination path (default: DependencyControl.resolved.json beside the feed)"):argname("") + +local serveCmd = parser:command("serve-updates", + "Serve a resolved feed and its working-copy files over local HTTP, for end-to-end update testing in a real Aegisub install") +serveCmd:option("-f --feed", + "Source feed JSON path, next to its files (a resolvable-channel feed, e.g. alpha)"):default("DependencyControl.json") +serveCmd:option("--lifetime", "Seconds to serve before the server self-terminates"):argname(""):default("3600") +serveCmd:option("--serve-channel", + "Graft the feed's dev channel onto this channel before serving, so a client tracking it matches") + :argname("") +serveCmd:option("--from-channel", "Dev channel to graft from when --serve-channel is given"):argname(""):default("main") + local bumpCmd = parser:command("bump-version", "Bump package version(s) to the feed's lockstep version, then refresh the feed") bumpCmd:option("-f --feed", "Feed JSON path"):default("DependencyControl.json") @@ -507,6 +528,132 @@ elseif args.command == "update-feed" then end os.exit(stats.errored > 0 and 1 or 0) +-- ─── resolve-feed ───────────────────────────────────────────────────────────── +elseif args.command == "resolve-feed" then + local feedPath = resolveAbsPath(args.feed) + + setupDepCtrl("resolve-feed") + + local UpdateFeed = require "l0.DependencyControl.UpdateFeed" + local feed = UpdateFeed(nil, false, feedPath) + + local outPath, err = feed:resolve({ + fileBaseUrl = args.file_base_url, + feedSchemaVersion = args.feed_schema_version, + outPath = args.out_file and resolveAbsPath(args.out_file) or nil, + }) + if not outPath then + io.stderr:write("resolve-feed: " .. tostring(err) .. "\n") + os.exit(1) + end + + io.stdout:write(("Resolved feed written to %s\n"):format(outPath)) + os.exit(0) + +-- ─── serve-updates ──────────────────────────────────────────────────────────── +elseif args.command == "serve-updates" then + local feedPath = resolveAbsPath(args.feed) + local lifetime = tonumber(args.lifetime) or 3600 + + setupDepCtrl("serve-updates") + + local UpdateFeed = require "l0.DependencyControl.UpdateFeed" + local FileOps = require "l0.DependencyControl.file-ops" + local json = require "l0.dkjson" + local socket = require "socket" + local copas = require "copas" + local Handler = require "pegasus.handler" + + -- Refresh the feed's hashes/versions from the working copy into a throwaway copy (never the input), so + -- the served feed matches what's on disk and vanished files drop out; then resolve it into a static + -- feed an older client can read. + local sourceFeed = UpdateFeed(nil, false, feedPath) + registerFeedSearcher(sourceFeed) + local refreshedPath = FileOps.joinPath(assert(FileOps.createTempDir()), "refreshed.json") + local refreshed, refreshErr = sourceFeed:updateFeed({ outPath = refreshedPath }) + if not refreshed then + io.stderr:write("serve-updates: couldn't refresh the feed: " .. tostring(refreshErr) .. "\n") + os.exit(1) + end + + -- Optionally graft the dev channel onto a channel a client tracks (e.g. main -> alpha), so an older + -- Aegisub can be tested against this feed without merging channels by hand first. + local feedToServe = refreshedPath + if args.serve_channel then + local mergedPath = FileOps.joinPath(dirname(refreshedPath), "merged.json") + local merged, mergeErr = UpdateFeed(nil, false, refreshedPath):mergeChannels(UpdateFeed(nil, false, refreshedPath), { + from = args.from_channel, + to = { args.serve_channel }, + outPath = mergedPath, + }) + if not merged or #merged == 0 then + io.stderr:write(("serve-updates: couldn't graft channel '%s' -> '%s' (%s)\n"):format( + args.from_channel, args.serve_channel, merged and "no package uses that channel" or tostring(mergeErr))) + os.exit(1) + end + feedToServe = mergedPath + end + + -- Bind first so the feed's file URLs can carry the real port. + local listener = assert(socket.bind("127.0.0.1", 0)) + local _, port = listener:getsockname() + local base = "http://127.0.0.1:" .. port + + local feed = UpdateFeed(nil, false, feedToServe) + local resolved, resolveErr = feed:resolve({ fileBaseUrl = base .. "/", outPath = false }) + if not resolved then + io.stderr:write("serve-updates: " .. tostring(resolveErr) .. "\n") + os.exit(1) + end + -- the refreshed feed sits in a temp dir; point its file lookups back at the working copy + feed.feedDir = dirname(feedPath) + + -- Map each URL path to the on-disk source it serves live (no copies); the feed itself is served from + -- memory. A file dropped on refresh (missing source) simply isn't mapped. + local manifest = { ["/DependencyControl.json"] = { body = json.encode(resolved, { indent = true }), mime = "application/json" } } + local served = 0 + for file in feed:walkFiles() do + if not (file.delete or not file.localFilePath) then + manifest[file.url:sub(#base + 1)] = { path = file.localFilePath } + served = served + 1 + end + end + local feedUrl = base .. "/DependencyControl.json" + + local function handleRequest(req, res) + local entry = manifest[req:path()] + local body = entry and (entry.body or FileOps.readFile(entry.path)) + if not body then + res:statusCode(404):write("not found") + return res:close() + end + res:statusCode(200) + res:addHeader("Content-Type", entry.mime or "application/octet-stream") + res:write(body) + res:close() + end + + -- Self-terminate after the lifetime so a forgotten server can't linger. + copas.addthread(function() + local startedAt = os.time() + while os.time() - startedAt < lifetime do copas.sleep(1) end + os.exit(0) + end) + -- Point pegasus' static doc-root at an empty dir: it serves real files there before falling to our + -- handler, and a repo-root doc-root would shadow our in-memory feed with the on-disk DependencyControl.json. + local handler = Handler:new(handleRequest, assert(FileOps.createTempDir()), {}, nil) + copas.addserver(listener, copas.handler(function(client) handler:processRequest(port, client) end)) + + io.stdout:write(("\nServing %d file(s) live from %s\n"):format(served, dirname(feedPath))) + io.stdout:write("\n Feed URL:\n " .. feedUrl .. "\n") + io.stdout:write("\n Point a package at it via its userFeed in the DepCtrl config, e.g.:\n") + io.stdout:write((' "userFeed": "%s"\n'):format(feedUrl)) + io.stdout:write("\n For a 0.7.0+ client, also set updates.blockPrivateHosts = false (loopback is blocked by default).\n") + io.stdout:write(("\nServing for up to %d s. Press Ctrl-C to stop.\n"):format(lifetime)) + io.stdout:flush() -- flush so a caller reading our output sees the URL before copas blocks + + copas.loop() + -- ─── bump-version ───────────────────────────────────────────────────────────── elseif args.command == "bump-version" then local levels = {} diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 8d87f3a0..c4f26ab4 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -11,7 +11,7 @@ constants = require "l0.DependencyControl.Constants" FileCache = require "l0.DependencyControl.FileCache" UpdateTask = require "l0.DependencyControl.UpdateTask" depRec = DepCtrl { - feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json", { {"l0.DependencyControl", version: "0.7.0"} } @@ -136,6 +136,10 @@ buildFeedInventory = -> DepCtrl.FeedInventory {c: mergedC}, DepCtrl.updater.feedTrust, DepCtrl.updater.feedLoader getScriptListDlg = (macros, modules) -> + -- Lead each list with an empty entry so the dropdowns open on "nothing selected". On macOS a + -- dropdown whose value isn't among its items shows the first real item instead and can't be cleared. + macros = {"", unpack macros} + modules = {"", unpack modules} { {label: "Automation Scripts: ", class: "label", x: 0, y: 0, width: 1, height: 1 }, {name: "macro", class: "dropdown", x: 1, y: 0, width: 1, height: 1, items: macros, value: "" }, @@ -147,10 +151,10 @@ runUpdaterTask = (scriptData, isInstall) -> return unless scriptData task, code, extErr = DepCtrl.updater\addTask scriptData, nil, nil, nil, scriptData.channel, - DepCtrl.Updater.UpdateReason.UserRequested + DepCtrl.UpdateTask.UpdateReason.UserRequested return task\run! if task with scriptData - logger\log DepCtrl.Updater.getUpdaterErrorMsg code, .moduleName or .name, + logger\log DepCtrl.UpdateTask.getUpdaterErrorMsg code, .moduleName or .name, .moduleName and ScriptType.Module or ScriptType.Automation, isInstall, extErr -- our feeds all live under raw.githubusercontent.com; abbreviate that host in the UI and expand it back on input @@ -600,9 +604,9 @@ scheduleUpdatesAndRegisterTests = -> elseif errMsgOrErrCode < 0 -- a structured status is a deliberate refusal or an already-reported run result, -- and its message stands on its own - logger\trace DepCtrl.Updater.getUpdaterErrorMsg errMsgOrErrCode, record.name or record.namespace, record.scriptType, false, errDetail + logger\trace DepCtrl.UpdateTask.getUpdaterErrorMsg errMsgOrErrCode, record.name or record.namespace, record.scriptType, false, errDetail - if record.tests and record.scriptType == ScriptType.Module + if record.testSuiteInitialized and record.scriptType == ScriptType.Module success, errMsg = pcall record.tests\registerMacros unless success logger\trace msgs.scheduleUpdatesAndRegisterTests.registerMacrosError, record.name or record.namespace, errMsg diff --git a/macros/l0.DependencyControl.Toolbox/test.moon b/macros/l0.DependencyControl.Toolbox/test.moon index 966b40b2..b9531410 100644 --- a/macros/l0.DependencyControl.Toolbox/test.moon +++ b/macros/l0.DependencyControl.Toolbox/test.moon @@ -604,14 +604,15 @@ UnitTestSuite "l0.DependencyControl.Toolbox", (macros, dependencies, testExports StartupSweep: { _description: "scheduleUpdatesAndRegisterTests: the startup sweep schedules every record, registers module test menus, and survives per-record failures." - -- every registered record gets a schedule attempt, module suites get their test menus registered - -- (automation scripts register their own), an installed module that fails to load is tolerated, - -- and the updater lock is released at the end + -- Every registered record gets a schedule attempt. A module whose test suite initialized gets its + -- test menu registered, while an uninitialized suite and automation scripts (which register their + -- own) are skipped. An installed module that fails to load is tolerated, and the lock is released. sweepsRecordsAndRegistersModuleTests: (ut) -> ut\stub(DepCtrl.config, "getSectionHandler")\returns {c: {modules: {"toolbox.test.notARealModule": {}}}} registered = {} records = { - {name: "Mod", namespace: "a.mod", scriptType: domain.ScriptType.Module, tests: {registerMacros: => registered.mdl = true}} + {name: "Mod", namespace: "a.mod", scriptType: domain.ScriptType.Module, testSuiteInitialized: true, tests: {registerMacros: => registered.mdl = true}} + {name: "Broken", namespace: "a.broken", scriptType: domain.ScriptType.Module, tests: {registerMacros: => registered.broken = true}} {name: "Mac", namespace: "a.mac", scriptType: domain.ScriptType.Automation, tests: {registerMacros: => registered.macro = true}} } ut\stub(DepCtrl, "getAllRegisteredRecords")\returns records @@ -621,8 +622,9 @@ UnitTestSuite "l0.DependencyControl.Toolbox", (macros, dependencies, testExports 0 released = ut\stub DepCtrl.updater, "releaseLock" scheduleUpdatesAndRegisterTests! - ut\assertEquals #scheduled, 2 - ut\assertTrue registered.mdl -- module suites are registered by the sweep + ut\assertEquals #scheduled, 3 + ut\assertTrue registered.mdl -- an initialized module suite is registered by the sweep + ut\assertNil registered.broken -- a module whose suite failed to initialize is skipped ut\assertNil registered.macro -- automation scripts register their own released\assertCalledOnce! diff --git a/mkdocs.yml b/mkdocs.yml index 8d092410..5e6d941c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,30 @@ repo_url: https://github.com/TypesettingTools/DependencyControl theme: name: material + features: + - content.code.copy # copy button on code blocks + - content.tabs.link # selecting a language tab syncs every tab on the page + - navigation.instant # SPA-style page loads + - navigation.top # back-to-top button + - search.suggest # autocomplete in the search box + - search.highlight # highlight matches on the opened page + - toc.follow # sidebar TOC tracks the reading position + palette: + # auto follows the OS setting; the toggle cycles auto -> light -> dark + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to system preference plugins: - search @@ -11,6 +35,24 @@ plugins: - literate-nav: nav_file: SUMMARY.md +markdown_extensions: + # Material's code-block stack; superfences also hosts the mermaid custom fence + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences: + # ```mermaid fences render as diagrams; Material bundles mermaid.js and themes it to the palette + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + # language-labeled code tabs; content.tabs.link keeps a reader's choice across the page + - pymdownx.tabbed: + alternate_style: true + # anchor links on headings, so the in-page cross-references resolve + - toc: + permalink: true + extra: # mike maintains versions.json on the published site; Material shows its version picker when present version: diff --git a/modules/l0/AegisubShims/aegisub.moon b/modules/l0/AegisubShims/aegisub.moon index 4694fbc5..f0167138 100644 --- a/modules/l0/AegisubShims/aegisub.moon +++ b/modules/l0/AegisubShims/aegisub.moon @@ -7,6 +7,7 @@ -- DEPCTRL_TEMP_DIR — base for ?temp (default: %TEMP% / /tmp) ffi = require "ffi" +utils = require "l0.DependencyControl.utils" isWindows = ffi.os == "Windows" pathSep = isWindows and "\\" or "/" @@ -63,27 +64,14 @@ setPathToken = (spec, dir) -> rebuildSortedTokens! if normalizedToken == "?user" - -- undo our previous additions to path list, add new ones that aren't already present, - -- and ensure the order of existing entries is unchanged to avoid messing up module shadowing - rebuildUserPaths = (pathStr, previouslyAdded, ext) -> - removed = {p, true for p in *previouslyAdded} - seen, ordered = {}, {} - for path in pathStr\gmatch "[^;]+" - continue if removed[path] or seen[path] - seen[path] = true - ordered[#ordered + 1] = path - - added = {} - for path in *makePackagePaths "#{dir}/automation/modules", ext - continue if seen[path] - seen[path] = true - ordered[#ordered + 1] = path - added[#added + 1] = path - - table.concat(ordered, ";"), added - - package.path, userPathsAddedToPackagePathLua = rebuildUserPaths package.path, userPathsAddedToPackagePathLua, "lua" - package.moonpath, userPathsAddedToPackagePathMoon = rebuildUserPaths package.moonpath, userPathsAddedToPackagePathMoon, "moon" + -- Re-point the module search paths at the new ?user dir, dropping our prior additions so they + -- don't pile up. Order of the surviving entries is preserved, so module shadowing (first match + -- wins) holds. + modulesDir = "#{dir}/automation/modules" + package.path, userPathsAddedToPackagePathLua = utils.mergeSearchPath( + package.path, makePackagePaths(modulesDir, "lua"), userPathsAddedToPackagePathLua) + package.moonpath, userPathsAddedToPackagePathMoon = utils.mergeSearchPath( + package.moonpath, makePackagePaths(modulesDir, "moon"), userPathsAddedToPackagePathMoon) return dir ---Returns the directory an Aegisub path token currently resolves to. diff --git a/modules/l0/DependencyControl.moon b/modules/l0/DependencyControl.moon index 00b167fc..f5c6ec25 100644 --- a/modules/l0/DependencyControl.moon +++ b/modules/l0/DependencyControl.moon @@ -68,6 +68,7 @@ Stub = require "l0.DependencyControl.Stub" Timer = require "l0.DependencyControl.Timer" UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" UpdateFeed = require "l0.DependencyControl.UpdateFeed" +UpdateTask = require "l0.DependencyControl.UpdateTask" Updater = require "l0.DependencyControl.Updater" ---Main DependencyControl entry point. @@ -96,6 +97,7 @@ class DependencyControl extends PackageRecord @Stub = Stub @Timer = Timer @UpdateFeed = UpdateFeed + @UpdateTask = UpdateTask @Updater = Updater @UnitTestSuite = UnitTestSuite @Finalizer = Finalizer @@ -111,11 +113,19 @@ rec = DependencyControl{ author: "line0", url: "http://github.com/TypesettingTools/DependencyControl", moduleName: "l0.DependencyControl", - feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json", provides: { {name: "BM.BadMutex", version: "^0.1.3"}, {name: "DM.DownloadManager", version: "^0.3.1"}, {name: "PT.PreciseTimer", version: "^0.1.6"}, + }, + { + { + moduleName:"l0.dkjson", + version: "0.7.0", + url: "http://github.com/TypesettingTools/DependencyControl", + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json" + } } } DependencyControl.__class.version = rec diff --git a/modules/l0/DependencyControl/Constants.moon b/modules/l0/DependencyControl/Constants.moon index 79903367..f77eff8d 100644 --- a/modules/l0/DependencyControl/Constants.moon +++ b/modules/l0/DependencyControl/Constants.moon @@ -3,5 +3,5 @@ return { DEPCTRL_SHORT_NAME: "DepCtrl" DEPCTRL_NAMESPACE: "l0.DependencyControl" DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX: "__depCtrl" - DEPCTRL_FEED_URL: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json" + DEPCTRL_FEED_URL: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json" } diff --git a/modules/l0/DependencyControl/FileLock.moon b/modules/l0/DependencyControl/FileLock.moon index 08eab846..4321b898 100644 --- a/modules/l0/DependencyControl/FileLock.moon +++ b/modules/l0/DependencyControl/FileLock.moon @@ -82,7 +82,8 @@ else ---However, unlike a semaphore, it cannot be forcibly taken from a process that is alive but hung. ---@class FileLock class FileLock - -- whether the OS file-lock FFI is isAvailable on this platform/build + -- whether the OS file-lock FFI is available on this platform/build + ---@type boolean @isAvailable = isAvailable ---Opens (creating if absent) the lock file and prepares it for locking. diff --git a/modules/l0/DependencyControl/Host.moon b/modules/l0/DependencyControl/Host.moon index 56b3406a..906a281d 100644 --- a/modules/l0/DependencyControl/Host.moon +++ b/modules/l0/DependencyControl/Host.moon @@ -54,6 +54,7 @@ class Host -- Default resolver for non-literal hosts, mapping a hostname to a list of address byte arrays. Each -- instance copies it at construction, and tests override it via the constructor. ---@private + ---@type fun(host: string): integer[][]? @__resolver = resolveHost ---@param host string A hostname or IP-literal string. diff --git a/modules/l0/DependencyControl/Logger.moon b/modules/l0/DependencyControl/Logger.moon index 613a9225..e8bb802c 100644 --- a/modules/l0/DependencyControl/Logger.moon +++ b/modules/l0/DependencyControl/Logger.moon @@ -1,6 +1,5 @@ -Timer = require "l0.DependencyControl.Timer" -NamedSemaphore = require "l0.DependencyControl.NamedSemaphore" lfs = require "lfs" +utils = require "l0.DependencyControl.utils" ---Structured logger that writes to Aegisub's log window and optional log files. ---@class Logger @@ -36,12 +35,8 @@ class Logger if args.usePrefix ~= nil @usePrefixFile, @usePrefixWindow = args.usePrefix, args.usePrefix - -- scripts load simultaneously in separate Lua states, so a whole-second seed would collide; - -- the monotonic clock's sub-microsecond reading diverges per state, and the pid separates - -- whole processes unless seeded - math.randomseed Timer.getTime! * 1000 + NamedSemaphore.pid - math.random! for i = 1, 3 + utils.seedRandom! seeded = true @lastHadLineFeed = true diff --git a/modules/l0/DependencyControl/ModuleLoader.moon b/modules/l0/DependencyControl/ModuleLoader.moon index 86d28446..579dbee6 100644 --- a/modules/l0/DependencyControl/ModuleLoader.moon +++ b/modules/l0/DependencyControl/ModuleLoader.moon @@ -81,7 +81,7 @@ class ModuleLoader ---`mdl._error` with the load error. ---@param mdl table The module descriptor to load; mutated in place with the result and error flags. ---@param usePrivate? boolean Load this script's private copy (namespaced under its own name) instead of the shared module. - ---@param reload? boolean Discard any cached reference and load the module afresh. + ---@param reload? boolean Discard cached references to the module and its submodules and load afresh. ---@return table? ref The loaded module reference, or nil on failure. @loadModule = (mdl, usePrivate, reload) => with mdl @@ -91,7 +91,12 @@ class ModuleLoader name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" if .outdated or reload - -- clear old references + -- Submodules cached under the module's namespace belong to the version being replaced, + -- so the fresh load must not pick them up from the require cache. + submodulePrefix = "#{moduleName}." + for cachedName in pairs package.loaded + if type(cachedName) == "string" and cachedName\sub(1, #submodulePrefix) == submodulePrefix + package.loaded[cachedName] = nil package.loaded[moduleName], LOADED_MODULES[moduleName] = nil elseif ._ref = LOADED_MODULES[moduleName] diff --git a/modules/l0/DependencyControl/NamedSemaphore.moon b/modules/l0/DependencyControl/NamedSemaphore.moon index eb535179..2b196667 100644 --- a/modules/l0/DependencyControl/NamedSemaphore.moon +++ b/modules/l0/DependencyControl/NamedSemaphore.moon @@ -99,13 +99,16 @@ else ---Usable as a per-process or cross-process lock primitive. ---@class NamedSemaphore class NamedSemaphore - -- whether the OS semaphore FFI is isAvailable at all on this platform/build + -- whether the OS semaphore FFI is available at all on this platform/build + ---@type boolean @isAvailable = isAvailable -- this process's id, exposed so callers can build process-scoped names and holder records + ---@type integer @pid = pid -- anchor the teardown-unlink finalizer to the class so it lives as long as the module (nil on Windows) + ---@type userdata? @__unlinkFinalizer = unlinkAtExit ---Gets a handle to the named semaphore for the given token, creating it if it doesn't exist. diff --git a/modules/l0/DependencyControl/PackageRecord.moon b/modules/l0/DependencyControl/PackageRecord.moon index e7f6ce21..4658d5ab 100644 --- a/modules/l0/DependencyControl/PackageRecord.moon +++ b/modules/l0/DependencyControl/PackageRecord.moon @@ -79,6 +79,9 @@ msgs = { registerTests: { initFailed: "Couldn't initialize the test suite for %s '%s': %s" } + registerMacro: { + badProcess: "Can't register macro '%s': its process callback must be a function, got a %s." + } new: { badRecordError: "Bad #{constants.DEPCTRL_NAME} record (%s)." strayRequiredModuleNames: "A required-module entry for %s carries extra names that are ignored: %s. Give each required module its own entry." @@ -237,6 +240,10 @@ class PackageRecord shouldWriteConfig = @loadConfig! @writeConfig! if shouldWriteConfig and saveRecordToConfig + ---Validates optional module availability for the requested feature set. + ---@param modules string|string[] Feature name(s) whose optional modules to check. + ---@return boolean available + ---@return string? err Error message listing missing modules. checkOptionalModules: ModuleLoader.checkOptionalModules ---Loads global DependencyControl configuration. @@ -429,8 +436,11 @@ class PackageRecord ---@param ... any Forwarded to registerTests(). ---@return table selfRef register: (selfRef, ...) => - -- replace dummy refs with real refs to own module - @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef + -- forward dummy refs to the real module for dependencies that already captured it. + -- A module that defers creating its DepCtrl record to the __depCtrlInit hook doesn't + -- get a dummy ref because it already finished loading by the time the hook is called. + @ref.__index = selfRef if @ref + @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef @registerTests selfRef, ... return selfRef @@ -451,6 +461,8 @@ class PackageRecord process, validate, isActive, submenu = name, description, process, validate name, description = @name, @description + @@logger\assert type(process) == "function", msgs.registerMacro.badProcess, name, type process + -- use automation script name for submenu by default submenu = @name if submenu == true diff --git a/modules/l0/DependencyControl/ScriptTargetFilter.moon b/modules/l0/DependencyControl/ScriptTargetFilter.moon index 94091d28..d13a10bd 100644 --- a/modules/l0/DependencyControl/ScriptTargetFilter.moon +++ b/modules/l0/DependencyControl/ScriptTargetFilter.moon @@ -17,6 +17,7 @@ table.sort scriptTypeList ---@class ScriptTargetFilter ---@field scriptTypes ScriptType[] The script types this filter would process (those carrying any rule), sorted. Read-only. class ScriptTargetFilter + ---@type ScriptType[] @scriptTypeList = scriptTypeList ---@param spec? table Initial rules keyed by script type. diff --git a/modules/l0/DependencyControl/UpdateFeed.moon b/modules/l0/DependencyControl/UpdateFeed.moon index ca82448a..c0087a38 100644 --- a/modules/l0/DependencyControl/UpdateFeed.moon +++ b/modules/l0/DependencyControl/UpdateFeed.moon @@ -78,6 +78,41 @@ attachLocalFilePath = (file, feedDirPath, localPath, isFullPath) -> stripNulls = (tbl) -> {k, (type(v) == "table" and stripNulls(v) or v) for k, v in pairs tbl when v != dkjson.null} +-- Feed keys holding URL/path templates or the root `vars` map — plumbing, not resolved data. A resolved +-- feed drops them so an older client's templater sees only concrete values. localFileBasePaths in +-- particular still holds unexpanded @{…} after a remote-mode expansion, so it must go. +resolvePlumbingKeys = {key, true for key in *{"fileBaseUrl", "fileBaseUrls", "localFileBasePath", "localFileBasePaths", "vars"}} + +---Removes template-plumbing keys from a feed tree in place, at every level they can occur. +---@param tbl table The feed subtree to clean, mutated in place. +---@return table tbl The same table. +stripTemplatePlumbing = (tbl) -> + for key in pairs resolvePlumbingKeys + tbl[key] = nil + for _, value in pairs tbl + stripTemplatePlumbing value if type(value) == "table" + tbl + +-- Feed keys whose string values are URLs, so they must be fully resolved. Other strings such as changelog +-- prose may legitimately carry a literal @{…} the templater never expands (its variables aren't in scope +-- at that depth), so scanning them would report false positives. +resolveUrlKeys = {key, true for key in *{"url", "feed", "baseUrl"}} + +---Finds the first URL-bearing string still carrying an unexpanded @{…} marker in a feed tree. +---@param tbl table The feed subtree to scan. +---@param path? string Location prefix accumulated for the report. +---@return string? location Dotted path to the offending value, or nil when none remains. +---@return string? value The offending string. +findResidualMarker = (tbl, path = "") -> + for key, value in pairs tbl + location = "#{path}.#{key}" + if type(value) == "string" + return location, value if resolveUrlKeys[key] and value\find "@{", 1, true + elseif type(value) == "table" + markerLocation, markerValue = findResidualMarker value, location + return markerLocation, markerValue if markerLocation + return nil + ---The rolling-template values in effect at one channel, captured during local-mode expansion ---so the local path templates can be inverted for file discovery. ---@class UpdateFeedChannelTemplateState @@ -105,6 +140,9 @@ msgs = { parse: "Error parsing feed." invalidScriptType: "Invalid or unsupported script type: '%s'. Supported types: %s." } + resolve: { + residualMarker: "Feed resolution left an unexpanded template variable at %s (%s). Resolve a channel whose variables are all defined." + } bundle: { invalidSourcePath: "invalid source path for %s (%s): %s" invalidDeployPath: "couldn't generate a valid deploy path for %s (channel %s) file '%s' with root dir '%s': %s" @@ -168,6 +206,7 @@ msgs = { ---@class UpdateFeed ---@field url string This feed's source URL, or a file:// URL over its local file when it has no remote URL (read-only). ---@field knownFeeds string[] URLs referenced in this feed's `knownFeeds` section; empty when the feed isn't loaded. Read-only. +---@field feedDir? string Directory of the loaded feed file; local file paths resolve against it during local-mode expansion. ---@field private __channelTemplateState table>> Captured channel template state, keyed by section, namespace, and channel name. class UpdateFeed ---Declares one template variable. A regular template captures its value at a fixed tree @@ -1084,6 +1123,43 @@ class UpdateFeed return nil, writeErr unless wrote return {:channel, :target, :bumped} + ---Expands every template variable in the feed to a concrete value and drops the template plumbing + ---(`fileBaseUrl(s)`, `localFileBasePath(s)`, root `vars`), so a client whose feed templater predates + ---those features reads it literally. File SHA-1s are carried over untouched — no local files are read. + ---@param opts? { fileBaseUrl?: string, feedSchemaVersion?: string, outPath?: string|false } `fileBaseUrl` overrides the download base before expansion, adding a trailing slash when missing; `feedSchemaVersion` stamps `dependencyControlFeedFormatVersion`; `outPath` is the destination (default `.resolved.json` beside the source), or false to return the resolved table without writing. + ---@return string|table|nil result The path written to, the resolved table when `outPath` is false, or nil on error. + ---@return string? err Error message on failure, including a URL field that still carries an unexpanded variable. + resolve: (opts = {}) => + srcPath = @feedPath or @fileName + content, readErr = fileOps.readFile srcPath + return nil, msgs.errors.cantOpen\format readErr unless content + @unexpandedData = @@.deserialize content + return nil, msgs.errors.parse unless @unexpandedData + -- record the source location as loadFile does, so the feed is left consistently loaded for a caller + -- that follows up with a local-mode walk (which resolves each file's on-disk path against feedDir) + @feedPath = srcPath + @feedDir = srcPath\match("^(.*)[/\\][^/\\]*$") or "." + + if opts.fileBaseUrl + base = opts.fileBaseUrl + base ..= "/" unless base\sub(-1) == "/" + @unexpandedData.fileBaseUrl = base + + resolved = @expand @@ExpansionMode.Remote + stripTemplatePlumbing resolved + + location, value = findResidualMarker resolved + return nil, msgs.resolve.residualMarker\format location, value if location + + resolved.dependencyControlFeedFormatVersion = opts.feedSchemaVersion if opts.feedSchemaVersion + return resolved if opts.outPath == false + + outPath = opts.outPath or srcPath\gsub "%.json$", ".resolved.json" + encoded = dkjson.encode resolved, {indentMode: "prettier", keyorder: feedKeyOrder} + wrote, writeErr = fileOps.writeFile outPath, encoded, true + return nil, writeErr unless wrote + return outPath + ---Renders a GitHub-flavored markdown release body from one version's changelog across every package ---that has entries for it. Marker scopes become bold lead-ins; the package whose name matches the ---feed name is treated as primary, so its unscoped entries render with no lead-in, while other diff --git a/modules/l0/DependencyControl/UpdateTask.moon b/modules/l0/DependencyControl/UpdateTask.moon index dda82993..00fef9c2 100644 --- a/modules/l0/DependencyControl/UpdateTask.moon +++ b/modules/l0/DependencyControl/UpdateTask.moon @@ -71,6 +71,7 @@ UpdateReason = Enum "UpdateReason", { ---| "auto-update" # AutoUpdate: also background scheduled update checks ContextCeiling = Enum "UpdateContextCeiling", { Off: "off" + -- the three shared rungs carry UpdateReason's own values, so a reason ranks directly against a ceiling UserRequested: UpdateReason.UserRequested DependencyResolution: UpdateReason.DependencyResolution AutoUpdate: UpdateReason.AutoUpdate diff --git a/modules/l0/DependencyControl/Updater.moon b/modules/l0/DependencyControl/Updater.moon index e419c6d6..fa2da4ec 100644 --- a/modules/l0/DependencyControl/Updater.moon +++ b/modules/l0/DependencyControl/Updater.moon @@ -217,14 +217,4 @@ class Updater })\getActiveHolder! return holder != nil, holder and holder.holderName --- Re-expose UpdateTask, its version-related enums, and its error-message decoder on the public API, --- so callers holding only an Updater reference (e.g. ModuleLoader, the Toolbox) can reach them. -Updater.UpdateStatus = UpdateTask.UpdateStatus -Updater.ContextCeiling = UpdateTask.ContextCeiling -Updater.UpdateReason = UpdateTask.UpdateReason -Updater.SourceChoiceStickiness = UpdateTask.SourceChoiceStickiness -Updater.SourceFeedKind = UpdateTask.SourceFeedKind -Updater.FeedTrustDecision = UpdateTask.FeedTrustDecision -Updater.getUpdaterErrorMsg = UpdateTask.getUpdaterErrorMsg -Updater.UpdateTask = UpdateTask return Updater diff --git a/modules/l0/DependencyControl/config-schema.moon b/modules/l0/DependencyControl/config-schema.moon index 7bd05836..061043dd 100644 --- a/modules/l0/DependencyControl/config-schema.moon +++ b/modules/l0/DependencyControl/config-schema.moon @@ -7,7 +7,7 @@ domain = require "l0.DependencyControl.domain" utils = require "l0.DependencyControl.utils" SemanticVersion = require "l0.DependencyControl.SemanticVersion" -CONFIG_SCHEMA_ID_CURRENT = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.7.0.json" +CONFIG_SCHEMA_ID_CURRENT = "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.7.0.json" -- Per-section defaults. Each section under `sections` is loaded as its own ConfigView with these defaults -- and handed to the classes of that domain. A section key is kept even when the section has no shared diff --git a/modules/l0/DependencyControl/domain.moon b/modules/l0/DependencyControl/domain.moon index cfbf58f8..50e8f676 100644 --- a/modules/l0/DependencyControl/domain.moon +++ b/modules/l0/DependencyControl/domain.moon @@ -6,6 +6,7 @@ msgs = { } } +---Whether a record is managed (installed and updated) or unmanaged (tracked only). ---@alias RecordType ---| "managed" # Managed: a script/module DependencyControl installs and keeps up to date ---| "unmanaged" # Unmanaged: a record describing a module DependencyControl tracks but does not update @@ -14,6 +15,7 @@ RecordType = Enum "RecordType", { Unmanaged: "unmanaged" } +---Whether a script is an automation script or a require()-able module. ---@alias ScriptType ---| "automation" # Automation: an automation script (macro / applied filter) ---| "module" # Module: a require()-able module @@ -22,6 +24,7 @@ ScriptType = Enum "ScriptType", { Module: "module" } +---The config/feed section a script type is stored under. ---@alias ScriptTypeSection ---| "macros" # Automation scripts are stored in the "macros" section ---| "modules" # Modules are stored in the "modules" section @@ -30,6 +33,9 @@ ScriptTypeSection = Enum "ScriptTypeSection", { [ScriptType.Module]: "modules" } +---User policy for fetching feeds that are neither trusted nor blocked. +---`prompt` only applies to feed discovery; dependency resolution always fetches and instead +---gates *installing* from an untrusted feed via the `feedTrustPromptThreshold` setting. ---@alias FetchUntrustedFeeds ---| "always" # Always: fetch untrusted feeds without asking (the default) ---| "never" # Never: never fetch untrusted feeds @@ -44,15 +50,11 @@ FetchUntrustedFeeds = Enum "FetchUntrustedFeeds", { ---manages, the human-readable terms for them, namespace rules, and install/test locations. ---@class Domain Domain = { + :FetchUntrustedFeeds :RecordType :ScriptType :ScriptTypeSection - ---User policy for whether DependencyControl fetches feeds that are neither trusted nor blocked. - ---`prompt` only applies to feed discovery; dependency resolution always fetches and instead - ---gates *installing* from an untrusted feed via the `feedTrustPromptThreshold` setting. - :FetchUntrustedFeeds - terms: { scriptType: { singular: { diff --git a/modules/l0/DependencyControl/file-ops.moon b/modules/l0/DependencyControl/file-ops.moon index 0c675bbd..8170188f 100644 --- a/modules/l0/DependencyControl/file-ops.moon +++ b/modules/l0/DependencyControl/file-ops.moon @@ -222,6 +222,7 @@ FileOps = { sepAll: ffi.os == "Windows" and "[\\/]" or "/" invalidChars: '[<>:"|%?%*%z%c;]' } + ---@type integer pathMaxLength: pathMaxLength pathMaxSegmentLength: MAX_PATH_COMPONENT -- true when running on Windows but capped at the legacy MAX_PATH limit because this process diff --git a/modules/l0/DependencyControl/hash.moon b/modules/l0/DependencyControl/hash.moon index aad3b703..86375aef 100644 --- a/modules/l0/DependencyControl/hash.moon +++ b/modules/l0/DependencyControl/hash.moon @@ -234,7 +234,7 @@ Hash = { ---@private ---@param msg string The input bytes to hash (assumed to be a string; not validated). ---@return string digest A 40-character lowercase hex digest. - _sha1Lua: sha1Lua + __sha1Lua: sha1Lua } return Hash diff --git a/modules/l0/DependencyControl/helpers/ffi-windows.moon b/modules/l0/DependencyControl/helpers/ffi-windows.moon index 4fa987b9..7a681ada 100644 --- a/modules/l0/DependencyControl/helpers/ffi-windows.moon +++ b/modules/l0/DependencyControl/helpers/ffi-windows.moon @@ -15,6 +15,7 @@ haveKernel32, kernel32 = pcall ffi.load, "kernel32" kernel32: haveKernel32 and kernel32 or nil -- whether kernel32 loaded successfully; gate any use of `kernel32`/`toWide` on this + ---@type boolean haveKernel32: haveKernel32 ---Converts a UTF-8 string to a NUL-terminated wide-char (UTF-16) buffer for the *W Win32 APIs. diff --git a/modules/l0/DependencyControl/shims/PreciseTimer.moon b/modules/l0/DependencyControl/shims/PreciseTimer.moon index 08811fb0..8e3c4e6d 100644 --- a/modules/l0/DependencyControl/shims/PreciseTimer.moon +++ b/modules/l0/DependencyControl/shims/PreciseTimer.moon @@ -11,6 +11,7 @@ class PreciseTimer -- mirrors the native PT.PreciseTimer version this shim is API-compatible with @version = 0x000106 @version_string = "0.1.6" + ---@type string @loadedLibraryPath = selfPath ---Starts a new timer, capturing the current time as its start point. diff --git a/modules/l0/DependencyControl/test.moon b/modules/l0/DependencyControl/test.moon index b5dda10e..a51fe951 100644 --- a/modules/l0/DependencyControl/test.moon +++ b/modules/l0/DependencyControl/test.moon @@ -11,10 +11,17 @@ return UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> isWindows = ffi.os == "Windows" basePath = aegisub.decode_path "?temp/l0.#{DepCtrl.__name}.#{UnitTestSuite.__name}_#{'%04X'\format math.random 0, 16^4-1}" + -- Shared test helpers are themselves sibling test modules, so they load through requireTest + -- and resolve under both the Aegisub-default and custom (CI) suite roots. requireHelper stays + -- lazy for the optional integration deps, which must fail soft when their rocks aren't installed. + requireHelper = (name) -> controls\requireTest "helpers.#{name}" + stubHelpers = requireHelper "stub-helpers" + -- Each test class lives in its own sibling module under `test/`, loaded via the suite's -- requireTest helper so the same call resolves in both the Aegisub-default and custom (CI) -- test locations. Classes needing shared fixtures receive them as arguments (basePath for - -- temp-file paths, DepCtrl for the live record/logger, isWindows for platform branches). + -- temp-file paths, DepCtrl for the live record/logger, isWindows for platform branches, + -- stubHelpers for the shared stub-self/null-logger/feed-trust builders). { Timer: (controls\requireTest "Timer")! BadMutex: (controls\requireTest "BadMutex")! @@ -34,15 +41,14 @@ return UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> Lock: (controls\requireTest "Lock")! FileLock: (controls\requireTest "FileLock")! NamedSemaphore: (controls\requireTest "NamedSemaphore")! - DkJson: (controls\requireTest "dkjson")! ConfigHandler: (controls\requireTest "ConfigHandler")! ConfigView: (controls\requireTest "ConfigView")! ConfigSchema: (controls\requireTest "config-schema")! ModuleLoader: (controls\requireTest "ModuleLoader")! - PackageRecord: (controls\requireTest "PackageRecord") basePath - UpdateTask: (controls\requireTest "UpdateTask")! - Updater: (controls\requireTest "Updater")! - FeedTrust: (controls\requireTest "FeedTrust")! + PackageRecord: (controls\requireTest "PackageRecord") basePath, stubHelpers + UpdateTask: (controls\requireTest "UpdateTask") stubHelpers + Updater: (controls\requireTest "Updater") stubHelpers + FeedTrust: (controls\requireTest "FeedTrust") stubHelpers FeedLoader: (controls\requireTest "FeedLoader") basePath, DepCtrl Host: (controls\requireTest "Host")! FeedInventory: (controls\requireTest "FeedInventory")! @@ -50,14 +56,13 @@ return UnitTestSuite constants.DEPCTRL_NAMESPACE, (DepCtrl, ...) -> FileCache: (controls\requireTest "FileCache") basePath ScriptUpdateRecord: (controls\requireTest "ScriptUpdateRecord")! ReleaseNotes: (controls\requireTest "release-notes")! - UpdateFeed: (controls\requireTest "UpdateFeed") basePath, DepCtrl + UpdateFeed: (controls\requireTest "UpdateFeed") basePath, DepCtrl, stubHelpers GitRepository: (controls\requireTest "GitRepository")! ScriptTargetFilter: (controls\requireTest "ScriptTargetFilter")! ZipArchiver: (controls\requireTest "ZipArchiver") basePath JsonSchema: (controls\requireTest "JsonSchema") basePath FfiPosix: (controls\requireTest "ffi-posix")! OpenUrl: (controls\requireTest "open-url")! - DownloaderIntegration: (controls\requireTest "integration.Downloader") basePath - LoggerIntegration: (controls\requireTest "integration.Logger") basePath + DownloaderIntegration: (controls\requireTest "integration.Downloader") basePath, requireHelper ZipArchiverIntegration: (controls\requireTest "integration.ZipArchiver") basePath } diff --git a/modules/l0/DependencyControl/test/FeedTrust.moon b/modules/l0/DependencyControl/test/FeedTrust.moon index 59a4d606..c8a9379e 100644 --- a/modules/l0/DependencyControl/test/FeedTrust.moon +++ b/modules/l0/DependencyControl/test/FeedTrust.moon @@ -1,9 +1,9 @@ -- FeedTrust tests: the consolidated feed-trust model — official/user merge, trust queries, and mutations. --- Called from test.moon as: (controls\requireTest "FeedTrust")! -() -> +-- Called from test.moon as: (controls\requireTest "FeedTrust") stubHelpers +(stubHelpers) -> FeedTrust = require "l0.DependencyControl.FeedTrust" TrustStatus = FeedTrust.TrustStatus - {:makeSeededFeedTrust} = require "l0.DependencyControl.test.helpers.stub-helpers" + {:makeSeededFeedTrust} = stubHelpers -- A FeedTrust seeded with the official sets (so it never loads the live DepCtrl feed) over a stub config. -- opts: officialTrusted (set), officialBlocked (list), extraFeeds, trustedFeeds, blockedFeeds, onSave. diff --git a/modules/l0/DependencyControl/test/ModuleLoader.moon b/modules/l0/DependencyControl/test/ModuleLoader.moon index 6d0154d3..fc631245 100644 --- a/modules/l0/DependencyControl/test/ModuleLoader.moon +++ b/modules/l0/DependencyControl/test/ModuleLoader.moon @@ -143,6 +143,27 @@ ut\assertFalse mdl._missing ut\assertString mdl._error + -- a reload purges the module's cached submodules so the fresh chunk can't be stitched + -- to code from the replaced version; a module merely sharing the name prefix is kept. + loadModule_reloadPurgesSubmoduleCache: (ut) -> + ns = "test.ModuleLoader.reloadPurge" + mockRef = {reloaded: true} + mdl = {moduleName: ns} + rec = {namespace: "host.Module", __class: {ScriptType: domain.ScriptType, __name: "DependencyControl"}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = {stale: true} + package.loaded[ns] = {stale: true} + package.loaded["#{ns}.Helper"] = {stale: true} + package.loaded["#{ns}.sub.Deep"] = {stale: true} + package.loaded["#{ns}Sibling"] = {sibling: true} -- shares the name prefix but not the namespace + (ut\stub _G, "require")\calls (name) -> mockRef + result = ModuleLoader.loadModule rec, mdl, false, true + ut\assertEquals result, mockRef + ut\assertNil package.loaded["#{ns}.Helper"] + ut\assertNil package.loaded["#{ns}.sub.Deep"] + ut\assertNotNil package.loaded["#{ns}Sibling"] + package.loaded[ns], package.loaded["#{ns}Sibling"], LOADED_MODULES[ns] = nil + -- loadModules: stubs loadModule to control loading behavior loadModules_skipsModule: (ut) -> @@ -310,6 +331,7 @@ "createDummyRef_nonModule", "createDummyRef_newRef", "createDummyRef_existingRef", "removeDummyRef_nonModule", "removeDummyRef_dummy", "removeDummyRef_nonDummy", "loadModule_cached", "loadModule_success", "loadModule_missing", "loadModule_error", + "loadModule_reloadPurgesSubmoduleCache", "loadModules_skipsModule", "loadModules_allLoaded", "loadModules_missingFetchedViaUpdater", "loadModules_missingRequiredFails", "loadModules_missingOptionalSkipped", "loadModules_requirementsUnmetSurfacesNestedReason", diff --git a/modules/l0/DependencyControl/test/PackageRecord.moon b/modules/l0/DependencyControl/test/PackageRecord.moon index e2252035..dd2994b6 100644 --- a/modules/l0/DependencyControl/test/PackageRecord.moon +++ b/modules/l0/DependencyControl/test/PackageRecord.moon @@ -1,17 +1,20 @@ -- PackageRecord tests: extracted from the main test suite. --- Called from test.moon as: (controls\requireTest "PackageRecord") basePath -(basePath) -> +-- Called from test.moon as: (controls\requireTest "PackageRecord") basePath, stubHelpers +(basePath, stubHelpers) -> ffi = require "ffi" constants = require "l0.DependencyControl.Constants" domain = require "l0.DependencyControl.domain" fileOps = require "l0.DependencyControl.file-ops" PackageRecord = require "l0.DependencyControl.PackageRecord" Stub = require "l0.DependencyControl.Stub" - {:stubSelf} = require "l0.DependencyControl.test.helpers.stub-helpers" + {:stubSelf} = stubHelpers DEPCTRL_RECORDS_GLOBAL_KEY = "#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}Records" FILEOPS_MODULE_NAME = "l0.DependencyControl.file-ops" + -- a logger stub mirroring Logger\assert: raises on a falsy condition, returns otherwise + assertingLogger = { assert: (cond, msg, ...) => error msg unless cond } + uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" -- Drive prefix so stubbed paths are recognized as absolute on Windows too. @@ -321,7 +324,7 @@ config: {c: {customMenu: "Automation"}}, registerTests: registerTestsStub, registeredMacros: {}, - __class: {updater: updaterMock} + __class: {updater: updaterMock, logger: assertingLogger} } process = (->) PackageRecord.registerMacro rec, "MyMacro", "My macro", process @@ -331,6 +334,19 @@ -- the macro is recorded under its name, exposing the unhooked process to the test suite ut\assertIs rec.registeredMacros.MyMacro.process, process + -- a missing (or non-function) process callback is a common mistake, so it fails at registration + -- with a clear message instead of an "attempt to call a nil value" when the macro is later run + registerMacro_rejectsNonFunctionProcess: (ut) -> + ut\stub aegisub, "register_macro" + rec = { + name: "TestScript", description: "desc", + config: {c: {customMenu: "Automation"}}, + registerTests: Stub!, registeredMacros: {}, + __class: {updater: {scheduleUpdate: (->), releaseLock: ->}, logger: assertingLogger} + } + err = ut\assertError -> PackageRecord.registerMacro rec, "MyMacro", "My macro" -- process omitted + ut\assertContains err, "must be a function" + -- namespace registry: getRegisteredRecord is the public lookup; registration happens -- internally (via the constructor), so these seed the process-global registry directly -- with unique namespaces. Teardown removes every key not present at setup time. @@ -487,7 +503,7 @@ "uninstall_virtual", "uninstall_unmanaged", "uninstall_moduleSparesPrefixSiblings", "uninstall_automationEscapesAndTerminates", "getSubmodules_virtual", "getSubmodules_unmanaged", "getSubmodules_nonModule", - "getConfigFileName_basic", "registerMacro_basic", + "getConfigFileName_basic", "registerMacro_basic", "registerMacro_rejectsNonFunctionProcess", "registry_getReturnsRegistered", "registry_getMissing", "registry_getSkipsVirtual", "registry_returnsAfterUnvirtualized", "registry_getRegisteredReturnsCopy", "registry_getRegisteredIncludesVirtual", diff --git a/modules/l0/DependencyControl/test/UpdateFeed.moon b/modules/l0/DependencyControl/test/UpdateFeed.moon index e678717a..8749125e 100644 --- a/modules/l0/DependencyControl/test/UpdateFeed.moon +++ b/modules/l0/DependencyControl/test/UpdateFeed.moon @@ -1,13 +1,13 @@ -- UpdateFeed tests: feed data access, script record retrieval, file deployment, -- and feed refresh. --- Called from Tests.moon as: (require "...test.UpdateFeed") basePath, DepCtrl -(basePath, DepCtrl) -> +-- Called from test.moon as: (controls\requireTest "UpdateFeed") basePath, DepCtrl, stubHelpers +(basePath, DepCtrl, stubHelpers) -> domain = require "l0.DependencyControl.domain" fileOps = require "l0.DependencyControl.file-ops" FileCache = require "l0.DependencyControl.FileCache" UpdateFeed = require "l0.DependencyControl.UpdateFeed" dkjson = require "l0.dkjson" - {:stubSelf} = require "l0.DependencyControl.test.helpers.stub-helpers" + {:stubSelf} = stubHelpers FILEOPS_MODULE_NAME = "l0.DependencyControl.file-ops" -- Builds a stub feed around unexpanded data for driving expand directly. @@ -23,6 +23,34 @@ normalizePath = (path) -> path\gsub "[/\\]", "/" + -- A small feed whose file-URL template resolves on any channel (no tagSuffix), for resolve() tests. Its + -- changelog holds a literal @{fileName} the templater never expands, to prove prose keeps its markers. + resolveFeedJson = [[{ + "dependencyControlFeedFormatVersion": "0.4.0", + "name": "T", + "baseUrl": "https://example.com/proj", + "url": "@{baseUrl}", + "fileBaseUrl": "https://x.test/", + "fileBaseUrls": {"script": "@{fileBaseUrl}v@{version}/@{scriptTypeSection}/@{namespacePath}@{fileName}"}, + "localFileBasePaths": {"script": "@{localFileBasePath}@{scriptTypeSection}/@{namespacePath}@{fileName}"}, + "vars": {}, + "modules": {"l0.Thing": { + "name": "Thing", "author": "a", "url": "@{baseUrl}#@{namespace}", + "channels": {"release": {"version": "1.2.3", "default": true, "files": [ + {"name": ".moon", "url": "@{fileBaseUrl}", "sha1": "1111111111111111111111111111111111111111"}, + {"name": "/Sub.moon", "url": "@{fileBaseUrl}", "sha1": "2222222222222222222222222222222222222222"} + ]}}, + "changelog": {"1.2.3": ["Documented the @{fileName} template variable."]} + }} + }]] + + writeResolveFeed = (dirName, json = resolveFeedJson) -> + root = fileOps.joinPath basePath, dirName + fileOps.mkdir root, false, true + feedPath = fileOps.joinPath root, "feed.json" + fileOps.writeFile feedPath, json, true + feedPath, root + { _description: "Tests for UpdateFeed feed data access, script record retrieval, and file deployment." @@ -824,6 +852,66 @@ ut\assertEquals #result.errors, 1 ut\assertContains result.errors[1], "no record" + -- resolve: flatten a feed's templates into a static feed a pre-0.4.0 client can read + + resolve_expandsUrlsAndStripsPlumbing: (ut) -> + feedPath = writeResolveFeed "resolve1" + feed = UpdateFeed nil, false, feedPath + resolved = feed\resolve {outPath: false} + ut\assertTable resolved + files = resolved.modules["l0.Thing"].channels.release.files + ut\assertEquals files[1].url, "https://x.test/v1.2.3/modules/l0/Thing.moon" + ut\assertEquals files[2].url, "https://x.test/v1.2.3/modules/l0/Thing/Sub.moon" + ut\assertNil resolved.fileBaseUrl + ut\assertNil resolved.fileBaseUrls + ut\assertNil resolved.localFileBasePaths + ut\assertNil resolved.vars + + resolve_fileBaseUrlOverrideRedirectsAndNormalizesSlash: (ut) -> + feedPath = writeResolveFeed "resolve2" + feed = UpdateFeed nil, false, feedPath + -- override lacks a trailing slash; resolve adds one so the template joins cleanly + resolved = feed\resolve {fileBaseUrl: "http://127.0.0.1:9000", outPath: false} + files = resolved.modules["l0.Thing"].channels.release.files + ut\assertEquals files[1].url, "http://127.0.0.1:9000/v1.2.3/modules/l0/Thing.moon" + + resolve_carriesSha1AndStampsSchemaVersion: (ut) -> + feedPath = writeResolveFeed "resolve3" + feed = UpdateFeed nil, false, feedPath + resolved = feed\resolve {feedSchemaVersion: "0.3.0", outPath: false} + ut\assertEquals resolved.dependencyControlFeedFormatVersion, "0.3.0" + ut\assertEquals resolved.modules["l0.Thing"].channels.release.files[1].sha1, + "1111111111111111111111111111111111111111" + + resolve_keepsLiteralMarkerInProse: (ut) -> + feedPath = writeResolveFeed "resolve4" + feed = UpdateFeed nil, false, feedPath + resolved = feed\resolve {outPath: false} + ut\assertTable resolved + ut\assertContains resolved.modules["l0.Thing"].changelog["1.2.3"][1], "@{fileName}" + + resolve_unresolvedUrlVariableErrors: (ut) -> + -- a file-URL template referencing an undefined variable must fail rather than ship a broken URL + json = resolveFeedJson\gsub "v@{version}/", "v@{version}/@{noSuchVar}/" + feedPath = writeResolveFeed "resolve5", json + feed = UpdateFeed nil, false, feedPath + result, err = feed\resolve {outPath: false} + ut\assertNil result + ut\assertString err + ut\assertContains err, "noSuchVar" + + resolve_writesResolvedFeedToOutFile: (ut) -> + feedPath, root = writeResolveFeed "resolve6" + feed = UpdateFeed nil, false, feedPath + outPath = fileOps.joinPath root, "out.json" + result, err = feed\resolve {outPath: outPath, feedSchemaVersion: "0.3.0"} + ut\assertNil err + ut\assertEquals result, outPath + decoded = dkjson.decode fileOps.readFile outPath + ut\assertEquals decoded.dependencyControlFeedFormatVersion, "0.3.0" + ut\assertEquals decoded.modules["l0.Thing"].channels.release.files[1].url, + "https://x.test/v1.2.3/modules/l0/Thing.moon" + _order: { "knownFeeds_noData", "knownFeeds_withData", "getScript_invalidType", "getScript_missing", "getScript_found", @@ -850,6 +938,9 @@ "refreshFiles_updatesChangedSha", "refreshFiles_unchangedSha", "refreshFiles_missingFileFlagsDelete", "refreshFiles_sha1FailureCollectsError", "refreshFiles_noLocalPathCollectsError", "updatePackage_notInRaw", "updatePackage_collectsResultAndResetsReleased", - "updatePackage_collectsRefreshError" + "updatePackage_collectsRefreshError", + "resolve_expandsUrlsAndStripsPlumbing", "resolve_fileBaseUrlOverrideRedirectsAndNormalizesSlash", + "resolve_carriesSha1AndStampsSchemaVersion", "resolve_keepsLiteralMarkerInProse", + "resolve_unresolvedUrlVariableErrors", "resolve_writesResolvedFeedToOutFile" } } diff --git a/modules/l0/DependencyControl/test/UpdateTask.moon b/modules/l0/DependencyControl/test/UpdateTask.moon index 7b742951..f7ae518e 100644 --- a/modules/l0/DependencyControl/test/UpdateTask.moon +++ b/modules/l0/DependencyControl/test/UpdateTask.moon @@ -1,6 +1,6 @@ -- UpdateTask tests: extracted from the main test suite. --- Called from test.moon as: (controls\requireTest "UpdateTask")! -() -> +-- Called from test.moon as: (controls\requireTest "UpdateTask") stubHelpers +(stubHelpers) -> domain = require "l0.DependencyControl.domain" environment = require "l0.DependencyControl.environment" UpdateTask = require "l0.DependencyControl.UpdateTask" @@ -11,7 +11,7 @@ Downloader = require "l0.DependencyControl.Downloader" ModuleLoader = require "l0.DependencyControl.ModuleLoader" FeedTrust = require "l0.DependencyControl.FeedTrust" - {:stubSelf, :makeNullLogger, :makeSeededFeedTrust} = require "l0.DependencyControl.test.helpers.stub-helpers" + {:stubSelf, :makeNullLogger, :makeSeededFeedTrust} = stubHelpers UpdateStatus = UpdateTask.UpdateStatus ContextCeiling = UpdateTask.ContextCeiling diff --git a/modules/l0/DependencyControl/test/Updater.moon b/modules/l0/DependencyControl/test/Updater.moon index 2c432b4a..d79cf8b2 100644 --- a/modules/l0/DependencyControl/test/Updater.moon +++ b/modules/l0/DependencyControl/test/Updater.moon @@ -1,6 +1,6 @@ -- Updater tests. --- Called from test.moon as: (controls\requireTest "Updater")! -() -> +-- Called from test.moon as: (controls\requireTest "Updater") stubHelpers +(stubHelpers) -> Updater = require "l0.DependencyControl.Updater" domain = require "l0.DependencyControl.domain" ModuleLoader = require "l0.DependencyControl.ModuleLoader" @@ -8,8 +8,8 @@ Lock = require "l0.DependencyControl.Lock" UpdateTask = require "l0.DependencyControl.UpdateTask" DependencyControl = require "l0.DependencyControl" - {:stubSelf, :makeNullLogger} = require "l0.DependencyControl.test.helpers.stub-helpers" - UpdateStatus = Updater.UpdateStatus + {:stubSelf, :makeNullLogger} = stubHelpers + UpdateStatus = UpdateTask.UpdateStatus UpdateReason = UpdateTask.UpdateReason ContextCeiling = UpdateTask.ContextCeiling diff --git a/modules/l0/DependencyControl/test/hash.moon b/modules/l0/DependencyControl/test/hash.moon index a9fdb15e..920c0cd8 100644 --- a/modules/l0/DependencyControl/test/hash.moon +++ b/modules/l0/DependencyControl/test/hash.moon @@ -32,7 +32,7 @@ -- whichever backend is active (native or lua) must match the reference impl sha1_backendMatchesReference: (ut) -> for input in *{"", "abc", "The quick brown fox jumps over the lazy dog", "\0\1\2\254\255"} - ut\assertEquals Hash.getDigest(ht.Sha1, input), Hash._sha1Lua input + ut\assertEquals Hash.getDigest(ht.Sha1, input), Hash.__sha1Lua input verify_matchIsCaseInsensitive: (ut) -> digest = "a9993e364706816aba3e25717850c26c9cd0d89d" diff --git a/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon b/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon index 0d10d91e..5a4c6836 100644 --- a/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon +++ b/modules/l0/DependencyControl/test/helpers/MockHttpServerController.moon @@ -1,18 +1,17 @@ --- Controls a mock HTTP server subprocess for the Downloader integration tests. --- --- Safe to require anywhere (including Aegisub): loading it only defines the class. luasocket is --- pulled in lazily, when a server is actually started/stopped. The server itself runs in a --- separate process (await blocks, so it can't share our thread); we compile mock-http-server.moon --- to plain Lua up front so that process needs only a bare interpreter, no MoonScript. - ffi = require "ffi" moonbase = require "moonscript.base" fileOps = require "l0.DependencyControl.file-ops" +Timer = require "l0.DependencyControl.Timer" MOCK_SERVER_FILE_BASENAME = "mock-http-server" isWindows = ffi.os == "Windows" -interpreter = (arg and arg[-1]) or "luajit" -- run the server under the interpreter running us +interpreter = (arg and arg[-1]) or "luajit" + +-- Under CLI, luasocket loads in our own process, so we pick the port and poll for a real HTTP response. +-- In Aegisub, the internal LuaJIT is incompatible with the luasocket rock, so the server picks an ephemeral port, +-- reports it through a ready-file, and stops when a quit-file appears, keeping luasocket out of our process. +canDriveWithSocket = pcall require, "socket" -- mock-http-server.moon sits next to this file; locate it from our own source path. _, device, dir = fileOps.validateFullPath debug.getinfo(1, "S").source\gsub("^@", ""), true @@ -27,8 +26,10 @@ spawnDetached = (cmd) -> -- is wrapped in one more pair of quotes (cmd /c then strips the outer pair). runBlocking = (cmd) -> os.execute isWindows and quote(cmd) or cmd +---Controls a mock HTTP server subprocess for the Downloader integration tests. +---@class MockHttpServerController class MockHttpServerController - -- Compile mock-http-server.moon to a throwaway .lua once and cache the path. + -- Compile mock-http-server.moon to a throwaway .lua once, so we can spawn it with a bare interpreter. @compileServer = => return @compiledServerPath if @compiledServerPath @@ -40,45 +41,59 @@ class MockHttpServerController @compiledServerPath, @compiledServerTempDir = path, tempDir return @compiledServerPath - --- Whether the server can be launched here, i.e. its Lua dependencies (luasocket, copas, - --- pegasus) are installed. Spawns the server with --check, which loads the deps and exits - --- 0/1 without serving — so this needs no luasocket in our own process. + ---Whether the server can be launched here: its subprocess must load luasocket, copas and pegasus. + ---Checked by spawning it with --check, so this needs no luasocket in our own process. @isReady: => success, errMsg = pcall @compileServer, @ return false, "mock server compilation failed: #{errMsg}" unless success return true if runBlocking("#{quote interpreter} #{quote @compiledServerPath} --check") return false, "mock server dependencies (luasocket/copas/pegasus) not available" - --- @param[opt] opts table: dir (directory whose files to serve), maxLifetime (server - --- self-destruct timeout in seconds), timeout (readiness wait in seconds) + ---@param opts? table Options: serveDir (directory to serve), maxLifetime (server self-destruct + ---timeout in seconds), timeout (readiness wait in seconds). new: (opts = {}) => @serveDir = opts.serveDir or "." @maxLifetime = opts.maxLifetime or 120 @timeout = opts.timeout or 10 - --- Picks a free loopback port, starts the server on it and waits until it's listening. - -- @return self + ---Starts the server and blocks until it answers on its loopback port. + ---@return MockHttpServerController self start: => + return @__startViaSocket! if canDriveWithSocket + return @__startViaControlFiles! + + ---Signals the server to stop, best-effort. Its max-lifetime cleans it up regardless. + stop: => + return unless @port + if @quitFile + fileOps.writeFile @quitFile, "", true + else + @__quitViaSocket! + @port, @baseUrl, @quitFile = nil, nil, nil + + ---Assembles the launch command shared by both modes and spawns it detached. + ---@private + ---@param portArgs string[] The port-related flags for the chosen mode. + __spawn: (portArgs) => + args = { quote(interpreter), quote(@@compileServer!), "--dir", quote(@serveDir), "--max-lifetime", tostring(@maxLifetime) } + args[#args + 1] = flag for flag in *portArgs + spawnDetached table.concat args, " " + + ---Starts the server and blocks until it answers on its loopback port, using a socket probe to detect readiness. + ---@private + ---@return MockHttpServerController self + __startViaSocket: => socket = require "socket" - -- Grab a free port by binding to 0, then hand it to the server via --port. (Tiny race - -- between closing and the server re-binding, but it's loopback and a throwaway server.) + -- Tiny race between closing the probe and the server re-binding the port, but it's loopback and + -- a throwaway server. probe = assert socket.bind "127.0.0.1", 0 _, port = probe\getsockname! probe\close! @port, @baseUrl = port, "http://127.0.0.1:#{port}" + @__spawn { "--port", tostring(port) } - startCommand = table.concat { - quote(interpreter), quote(@@compileServer!), - "--port", tostring(port), - "--dir", quote(@serveDir), - "--max-lifetime", tostring(@maxLifetime), - }, " " - io.stderr\write "Starting mock HTTP server with command: #{startCommand}...\n" - spawnDetached startCommand - - -- Ready only once the server actually answers HTTP. A bare TCP connect succeeds as - -- soon as the kernel accepts into the listen backlog (which can happen before copas - -- starts dispatching requests). + -- Ready only once the server answers HTTP: a bare TCP connect succeeds as soon as the kernel + -- accepts into the listen backlog, which can happen before copas starts dispatching. isServing = -> conn = socket.tcp! conn\settimeout 0.5 @@ -88,18 +103,35 @@ class MockHttpServerController conn\send "GET /status/200 HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" statusLine = conn\receive "*l" conn\close! - return statusLine != nil and statusLine\match("^HTTP/") != nil + statusLine != nil and statusLine\match("^HTTP/") != nil deadline = socket.gettime! + @timeout while socket.gettime! < deadline return @ if isServing! socket.sleep 0.05 - error "mock HTTP server didn't start on port #{port} within #{@timeout}s" - - --- Stops the server via its /__quit route. Best-effort: if luasocket isn't available here, - --- the server's max-lifetime cleans it up regardless. - stop: => - return unless @port + error "mock HTTP server didn't answer on port #{port} within #{@timeout}s" + + ---Starts the server and blocks until it writes its ephemeral port to a ready-file or the timeout expires. + ---@private + ---@return MockHttpServerController self + __startViaControlFiles: => + controlDir = assert fileOps.createTempDir! + readyFile = fileOps.joinPath controlDir, "ready" + @quitFile = fileOps.joinPath controlDir, "quit" + @__spawn { "--port", "0", "--ready-file", quote(readyFile), "--quit-file", quote(@quitFile) } + + deadline = Timer.getTime! + @timeout * 1000 + while Timer.getTime! < deadline + data = fileOps.readFile readyFile + if port = data and tonumber data + @port, @baseUrl = port, "http://127.0.0.1:#{port}" + return @ + Timer.sleep 50 + error "mock HTTP server didn't report a port within #{@timeout}s" + + ---Signals the server to stop via a TCP connection to its loopback port. + ---@private + __quitViaSocket: => pcall -> socket = require "socket" conn = socket.tcp! @@ -108,4 +140,5 @@ class MockHttpServerController conn\send "GET /__quit HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" conn\receive "*a" -- wait for the response / server exit conn\close! - @port, @baseUrl = nil, nil + +return MockHttpServerController diff --git a/modules/l0/DependencyControl/test/helpers/mock-http-server.moon b/modules/l0/DependencyControl/test/helpers/mock-http-server.moon index 43f2f116..48d50e93 100644 --- a/modules/l0/DependencyControl/test/helpers/mock-http-server.moon +++ b/modules/l0/DependencyControl/test/helpers/mock-http-server.moon @@ -14,8 +14,10 @@ -- GET /redirect-to/ 302 with a relative Location of / -- GET /__quit stop the server (the clean shutdown route) -- --- Flags: --port (loopback port to listen on), --dir , --max-lifetime (orphan --- safety, default 120), --check (verify deps load, then exit 0/1 without starting a server). +-- Flags: --dir , --port (loopback port; 0 or omitted binds an ephemeral one), --ready-file +--

(the bound port is written here once the server is dispatching), --quit-file

(the server +-- exits when this file appears), --max-lifetime (orphan safety, default 120), --check (verify +-- deps load, then exit 0/1 without starting a server). -- "--flag value" / "--flag" parser (a flag with no following value is a boolean) @@ -54,9 +56,11 @@ socket = require "socket" copas = require "copas" Handler = require "pegasus.handler" -listenPort = assert tonumber(opts.port), "--port is required" +requestedPort = tonumber(opts.port) or 0 serveDir = opts.dir or "." maxLifetime = tonumber(opts["max-lifetime"]) or 120 +readyFile = opts["ready-file"] +quitFile = opts["quit-file"] readFile = (path) -> f = io.open path, "rb" @@ -65,6 +69,13 @@ readFile = (path) -> f\close! data +fileExists = (path) -> + return false unless path + f = io.open path, "r" + return false unless f + f\close! + true + -- map a request name to a file inside serveDir, rejecting traversal resolve = (name) -> return nil if not name or name == "" or name\find "%.%." @@ -116,19 +127,27 @@ handleRequest = (req, res) -> res\write "unknown endpoint" res\close! --- bind to the loopback port the controller chose for us -server = assert socket.bind LOCALHOST_IP, listenPort +-- bind the loopback port the controller chose, or an ephemeral one when it passed 0 +server = assert socket.bind LOCALHOST_IP, requestedPort +_, listenPort = server\getsockname! +listenPort = assert tonumber(listenPort), "couldn't determine the bound port" handler = Handler\new handleRequest, serveDir, {}, nil copas.addserver server, copas.handler (client) -> handler\processRequest listenPort, client -io.stderr\write "mock-http-server listening on #{LOCALHOST_IP}:#{listenPort} (dir=#{serveDir})\n" +-- announce readiness from inside the copas loop: the ready-file appears once the scheduler is +-- dispatching, so the controller never reads a port before the server can serve +if readyFile + copas.addthread -> + f = assert io.open readyFile, "w" + f\write tostring listenPort + f\close! --- shut down on the /__quit route, or after max-lifetime so we can never orphan +-- shut down on the /__quit route or the controller's quit-file, or after max-lifetime so we never orphan startedAt = os.time! copas.addthread -> while true copas.sleep 0.1 - os.exit 0 if quitRequested or os.time! - startedAt > maxLifetime + os.exit 0 if quitRequested or (quitFile and fileExists quitFile) or os.time! - startedAt > maxLifetime copas.loop! diff --git a/modules/l0/DependencyControl/test/integration/Downloader.moon b/modules/l0/DependencyControl/test/integration/Downloader.moon index d8dc9e8b..4f166ad3 100644 --- a/modules/l0/DependencyControl/test/integration/Downloader.moon +++ b/modules/l0/DependencyControl/test/integration/Downloader.moon @@ -2,8 +2,8 @@ -- (test/helpers/mock-http-server). Self-gating via _condition: skipped unless the -- server's Lua deps are installed, so the default offline run never needs -- luasocket/copas/pegasus. --- Called from Tests.moon as: (require "...test.integration.Downloader") basePath -(basePath) -> +-- Called from test.moon as: (controls\requireTest "integration.Downloader") basePath, requireHelper +(basePath, requireHelper) -> Downloader = require "l0.DependencyControl.Downloader" fileOps = require "l0.DependencyControl.file-ops" @@ -13,14 +13,14 @@ -- The controller is required lazily and pcall-guarded, so this is harmless where the test -- helpers aren't reachable (e.g. a stripped-down install) — it just skips. _condition: -> - ok, MockServerController = pcall require, "l0.DependencyControl.test.helpers.MockHttpServerController" + ok, MockServerController = pcall requireHelper, "MockHttpServerController" return false, "mock server helper unavailable (#{MockServerController})" unless ok isReady, err = MockServerController\isReady! return false, "mock server is not ready to start: #{err}" unless isReady return true _setup: (ut) -> - MockServerController = require "l0.DependencyControl.test.helpers.MockHttpServerController" + MockServerController = requireHelper "MockHttpServerController" base = "#{basePath}_downloader" serveDir, downloadDir = "#{base}/fixtures", "#{base}/out" fileOps.mkdir d, false, true for d in *{base, serveDir, downloadDir} diff --git a/modules/l0/DependencyControl/test/integration/Logger.moon b/modules/l0/DependencyControl/test/integration/Logger.moon deleted file mode 100644 index 49a9be32..00000000 --- a/modules/l0/DependencyControl/test/integration/Logger.moon +++ /dev/null @@ -1,56 +0,0 @@ --- RNG seed divergence across isolated Lua states, replicating Aegisub loading one DependencyControl copy --- per automation script simultaneously: each spawned luajit child seeds through the real Logger --- construction and reports the head of its random stream, which must be unique per state — this is what --- keeps per-script log file names and temp paths from colliding. --- Self-gating via _condition: skipped where a luajit child process can't be spawned. --- Called from Tests.moon as: (require "...test.integration.Logger") basePath -(basePath) -> - fileOps = require "l0.DependencyControl.file-ops" - - STATE_COUNT = 50 - - -- The probe a child state runs: bootstrap the parent's module paths, load the headless shim, construct - -- a Logger (which seeds the state's rng), and print the head of the resulting random stream. - buildProbeSource = -> - table.concat { - ("package.path = %q")\format package.path - ("package.cpath = %q")\format package.cpath - 'require "moonscript"' - 'require "l0.AegisubShims.aegisub"' - 'local Logger = require "l0.DependencyControl.Logger"' - 'Logger{fileBaseName = "seedProbe"}' - 'io.write(("%04x%04x%04x"):format(math.random(0, 0xFFFF), math.random(0, 0xFFFF), math.random(0, 0xFFFF)))' - }, "\n" - - { - _description: "Logger rng seeding: simultaneously created Lua states get distinct random streams (runs where a luajit child can be spawned)." - - _condition: -> - ok, handle = pcall io.popen, 'luajit -e "io.write(_VERSION)"' - return false, "io.popen unavailable (#{tostring handle})" unless ok and handle - out = handle\read "*a" - handle\close! - return false, "no runnable luajit on PATH" unless out and #out > 0 - return true - - isolatedStates_getDistinctRandomStreams: (ut) -> - fileOps.mkdir basePath, false, true - probePath = fileOps.joinPath basePath, "logger-seed-probe.lua" - ut\assertTruthy fileOps.writeFile probePath, buildProbeSource! - - -- spawn every child before reading any, so their module loads and seedings overlap in time - handles = [io.popen "luajit \"#{probePath}\" 2>&1" for _ = 1, STATE_COUNT] - streams = for handle in *handles - out = handle\read "*a" - handle\close! - out - - unique = {} - for stream in *streams - -- a failed child prints an error dump instead of hex; the mismatch surfaces it verbatim - ut\assertEquals stream\match("^%x+$"), stream - unique[stream] = true - count = 0 - count += 1 for _ in pairs unique - ut\assertEquals count, STATE_COUNT - } diff --git a/modules/l0/DependencyControl/test/utils.moon b/modules/l0/DependencyControl/test/utils.moon index ba5d5ee8..042f9b39 100644 --- a/modules/l0/DependencyControl/test/utils.moon +++ b/modules/l0/DependencyControl/test/utils.moon @@ -80,11 +80,33 @@ escapePattern_plainStringUnchanged: (ut) -> ut\assertEquals utils.escapePattern("l0_Functional"), "l0_Functional" + -- Aegisub loads each automation script concurrently, each into its own Lua state, and those states + -- seed their rng at the same instant while sharing one pid, so the clock and pid don't tell them + -- apart. Hold both fixed and give each the identity of a state at a neighbouring address; the seeds + -- and their first draws must still come out distinct, or per-script log file names and temp paths + -- would collide. + seedRandom_divergesAcrossSimultaneousStates: (ut) -> + ut\assertNumber utils.seedRandom! + seeds, heads = {}, {} + base = 0x1a2b00000000 -- a plausible Lua state address; neighbours differ by an allocation offset + for i = 0, 199 + seed = utils.__deriveSeed base + i * 0x10, 1234.5, 4242 -- frozen clock and pid + ut\assertNil seeds[seed] + seeds[seed] = true + math.randomseed seed + heads[math.random 0, 16^4 - 1] = true + distinctHeads = 0 + distinctHeads += 1 for _ in pairs heads + -- the head is a 16-bit draw, so a few of the 200 may collide by birthday chance; requiring the + -- vast majority distinct still fails hard for a repeating seed, which collapses to a single head + ut\assertGreaterThan distinctHeads, 190 + _order: { "flatten_depth2Array", "flatten_depth1StopsEarly", "flatten_depth0NoFlatten", "flatten_scalar", "flatten_returnsCount", "flatten_toArrayTable", "listIncludes_found", "listIncludes_notFoundAndEmpty", "equals_cyclicRefs", "itemsEqual_duplicateScalars", - "escapePattern_matchesLiterally", "escapePattern_plainStringUnchanged" + "escapePattern_matchesLiterally", "escapePattern_plainStringUnchanged", + "seedRandom_divergesAcrossSimultaneousStates" } } diff --git a/modules/l0/DependencyControl/utils.moon b/modules/l0/DependencyControl/utils.moon index d6e09f48..93505928 100644 --- a/modules/l0/DependencyControl/utils.moon +++ b/modules/l0/DependencyControl/utils.moon @@ -1,6 +1,9 @@ --- General-purpose Lua helpers with no DependencyControl dependencies: deep equality, table --- copying, set and list operations, string trimming and pattern escaping, array flattening, --- and UUID generation. Sits at the bottom of the dependency graph. +-- General-purpose Lua helpers: deep equality, table copying, set and list operations, string +-- trimming and pattern escaping, array flattening, UUID generation, and rng seeding. Loads at the +-- bottom of the dependency graph with no DependencyControl requires; seedRandom and getRandomSeed +-- lazily pull Timer and NamedSemaphore when first called. + +local Timer, NamedSemaphore -- Compares two values for deep equality. Tables are compared recursively; -- other types use == except that two identical values always compare equal. @@ -139,6 +142,43 @@ flatten = (value, depth = 1, toArrayTable) -> deepCopy = (tbl) -> {k, (type(v) == "table" and deepCopy(v) or v) for k, v in pairs tbl} +mergeSearchPath = (pathStr, add, remove) -> + removed = remove and {p, true for p in *remove} or {} + seen, ordered = {}, {} + for path in pathStr\gmatch "[^;]+" + continue if removed[path] or seen[path] + seen[path] = true + ordered[#ordered + 1] = path + + added = {} + for path in *add + continue if seen[path] + seen[path] = true + ordered[#ordered + 1] = path + added[#added + 1] = path + + return table.concat(ordered, ";"), added + +-- Aegisub loads automation scripts concurrently, each into its own Lua state that seeds its rng as +-- DependencyControl loads. Two states can seed at the same instant and share a process id, so the +-- clock and PID don't tell them apart. Each live Lua state has a distinct address, read here from the +-- running state's main thread, whose tostring yields "thread: 0x

". +getStateToken = -> + addr = tostring(coroutine.running!)\match "0x(%x+)" + addr and tonumber(addr, 16) or 0 + +__deriveSeed = (token, clockMs, pid) -> clockMs * 1000 + pid + token + +getRandomSeed = -> + Timer or= require "l0.DependencyControl.Timer" + NamedSemaphore or= require "l0.DependencyControl.NamedSemaphore" + __deriveSeed getStateToken!, Timer.getTime!, NamedSemaphore.pid + +seedRandom = -> + seed = getRandomSeed! + math.randomseed seed + return seed + ---@class Utils Utils = { ---Deep equality comparison. Tables compared recursively; other types use ==. @@ -218,6 +258,42 @@ Utils = { ---@return number flattenedCount The number of elements in the flattened array. flatten: flatten + ---Merges new entries into a semicolon-separated Lua search path, appending only the ones not + ---already present and dropping any listed for removal, with the order of kept entries preserved. + ---@param pathStr string The existing search path, e.g. `package.path`. + ---@param add string[] Path entries to append, each only when not already present. + ---@param remove? string[] Path entries to drop before merging, e.g. to undo an earlier addition. + ---@return string pathStr The merged search-path string. + ---@return string[] added The entries actually appended, empty when all were already present. + mergeSearchPath: mergeSearchPath + + ---Extends one of package's search-path fields in place, folding in the entries of a semicolon- + ---separated string and appending only those not already present. Does nothing when there is nothing to add. + ---@param field "path"|"cpath"|"moonpath" Which package search path to extend. + ---@param entries? string A semicolon-separated path string to fold in, such as an env var's value. + ---@return string[] added The entries actually added, empty when all were present or nothing was passed. + extendPackagePath: (field, entries) -> + return {} unless entries and entries != "" + package[field], added = mergeSearchPath (package[field] or ""), [entry for entry in entries\gmatch "[^;]+"] + return added + + ---Returns a random-number seed unique to this script's Lua state, differing from one launch to the next. + ---@return number seed + getRandomSeed: getRandomSeed + + ---Reseeds this Lua state's random number generator so `math.random` yields a stream unique to the + ---script and to this launch. DependencyControl seeds it on load; call this to reseed it yourself. + ---@return number seed The applied seed. + seedRandom: seedRandom + + ---Pure (token, clockMs, pid) seed combiner, reachable for the seed-divergence test. + ---@param token number Distinct per-Lua-state identity token. + ---@param clockMs number Clock reading in milliseconds. + ---@param pid number The current process id. + ---@return number seed + ---@private + __deriveSeed: __deriveSeed + ---Generates a random RFC-4122 version-4 UUID string. ---@return string uuid uuid: -> diff --git a/modules/l0/MoonCats.moon b/modules/l0/MoonCats.moon index b45d87d6..d4394743 100644 --- a/modules/l0/MoonCats.moon +++ b/modules/l0/MoonCats.moon @@ -37,8 +37,11 @@ buildPackageSymbols = (irs) -> when Parser.ExportKind.Class ir.export.class and ir.export.class.typeName when Parser.ExportKind.Table - -- table modules declare a synthesized class named after their require identifier - ir.requireId + -- the module's own declared @class name when it has one, else the synthesized require-id class + declared = nil + for seg in *ir.segments + declared = ir.export.name if seg.kind == Parser.SegmentKind.Class and seg.name == ir.export.name + declared or ir.requireId packageSymbols.typeNameByRequireId[ir.requireId] = typeName if typeName for name in pairs ir.aliases packageSymbols.aliases[name] = true diff --git a/modules/l0/MoonCats/DocRenderer.moon b/modules/l0/MoonCats/DocRenderer.moon index 8960a508..99477c94 100644 --- a/modules/l0/MoonCats/DocRenderer.moon +++ b/modules/l0/MoonCats/DocRenderer.moon @@ -141,17 +141,32 @@ pushProse = (out, blockLines) -> table.insert out, "" true ----Emits a fenced signature block with aligned MoonScript and Lua call forms. +---Emits a construct's MoonScript and Lua forms as linked content tabs, falling back to one aligned +---block where the host lacks tab support (mdbook and the standalone mkdocs scaffold). ---@param out string[] +---@param tabbed boolean ---@param moonLine string ---@param luaLine string -pushSignature = (out, moonLine, luaLine) -> - width = math.max #moonLine, #luaLine - pad = (line) -> line .. (" ")\rep width - #line - table.insert out, "```lua" - table.insert out, "#{pad moonLine} -- MoonScript" - table.insert out, "#{pad luaLine} -- Lua" - table.insert out, "```" +pushLanguageForms = (out, tabbed, moonLine, luaLine) -> + if tabbed + table.insert out, "=== \"MoonScript\"" + table.insert out, "" + table.insert out, " ```moonscript" + table.insert out, " #{moonLine}" + table.insert out, " ```" + table.insert out, "" + table.insert out, "=== \"Lua\"" + table.insert out, "" + table.insert out, " ```lua" + table.insert out, " #{luaLine}" + table.insert out, " ```" + else + width = math.max #moonLine, #luaLine + pad = (line) -> line .. (" ")\rep width - #line + table.insert out, "```lua" + table.insert out, "#{pad moonLine} -- MoonScript" + table.insert out, "#{pad luaLine} -- Lua" + table.insert out, "```" table.insert out, "" ---Resolves the documented type of a plain-data member, chasing identifier references @@ -175,7 +190,9 @@ resolveDataType = (state, ir, cls, member) -> switch sym.kind when SymbolKind.Class then sym.class.typeName when SymbolKind.Require then state.typeNameByRequireId[sym.requireId] - when SymbolKind.Enum then sym.enum.exportedAs and "#{sym.enum.name}Enum" or nil + when SymbolKind.Enum + -- unexported module-local enums have no synthesized Enum class, so they use base Enum + sym.enum.exportedAs and "#{sym.enum.name}Enum" or "Enum" when SymbolKind.Reference then chaseRef sym.target, depth + 1 when SymbolKind.Literal sym.literalKind != "nil" and inferLiteralType(sym.token, sym.literalKind) or nil @@ -187,7 +204,8 @@ resolveDataType = (state, ir, cls, member) -> when ValueKind.Reference chaseRef(valueInfo.refName) or "any" when ValueKind.Call - chaseRef(valueInfo.baseName) or "any" + -- setmetatable(t, mt) returns t; the common `setmetatable {}, …` form is a table + valueInfo.baseName == "setmetatable" and "table" or chaseRef(valueInfo.baseName) or "any" when ValueKind.Table then "table" when ValueKind.Expression then inferExpressionType valueInfo.node else "any" @@ -251,7 +269,7 @@ renderFunctionMember = (out, state, member, moonCallee, luaCallee, anchorPrefix) else [tag.name for tag in *tags] moonLine, luaLine = callForms moonCallee, luaCallee, paramNames - pushSignature out, moonLine, luaLine + pushLanguageForms out, state.tabbedForms, moonLine, luaLine pushProse out, member.block @@ -297,6 +315,9 @@ renderFieldsTable = (out, state, rows) -> ---@param block? string[] The exporting member's annotation block, for lead prose. ---@param anchorPrefix string renderEnum = (out, state, enum, block, anchorPrefix) -> + -- a bare-name anchor so a `param: Name` value-type link resolves here; the heading keeps the + -- prefixed anchor used by the enum-object type (Enum) + table.insert out, "" table.insert out, memberHeading enum.name, "#{anchorPrefix}.#{enum.name}", "####" table.insert out, "" pushProse out, block @@ -313,7 +334,7 @@ renderEnum = (out, state, enum, block, anchorPrefix) -> for enumMember in *enum.members variant = variantsByKey[enumMember.key] or variantsByValue[enumMember.literal] description = variant and variant.description or nil - table.insert out, "| #{enumMember.key} | `#{enumMember.literal}` | #{cellText description} |" + table.insert out, "| #{enumMember.display or enumMember.key} | `#{enumMember.literal}` | #{cellText description} |" table.insert out, "" ---Renders a standalone alias or annotation-only class segment in a module's Types section. @@ -448,7 +469,7 @@ renderClass = (out, state, ir, cls) -> tags, _ = collectBlockTags ctor.block paramNames = [param.name for param in *ctor.params or {}] moonLine, luaLine = callForms typeName, typeName, paramNames, receiver - pushSignature out, moonLine, luaLine + pushLanguageForms out, state.tabbedForms, moonLine, luaLine pushProse out, ctor.block if #tags > 0 table.insert out, "| param | type | description |" @@ -511,11 +532,18 @@ renderClass = (out, state, ir, cls) -> ---@param ir MoonCatsModuleIR renderTableModule = (out, state, ir) -> varName = ir.export.name and ir.export.name\match("^[%w_]+$") and ir.export.name or moduleVarName ir.requireId + -- anchor for the module's own declared type, so a re-export elsewhere links here. Its @class is + -- dropped from the Types listing, which would otherwise carry the anchor. + table.insert out, "" if ir.export.name fieldRows = {} fnMembers = {} + enumMembers = {} collect = (entry) -> return if isHiddenPrivate entry, state + if entry.enum + table.insert enumMembers, entry + return fnMember = resolveFunctionMember ir, nil, entry if fnMember table.insert fnMembers, fnMember @@ -545,6 +573,12 @@ renderTableModule = (out, state, ir) -> table.insert out, "" renderFieldsTable out, state, fieldRows + if #enumMembers > 0 + table.insert out, "## Enums" + table.insert out, "" + for entry in *enumMembers + renderEnum out, state, entry.enum, entry.block, varName + ---Renders a function-module's single exported function. ---@param out string[] ---@param state table @@ -572,11 +606,9 @@ renderModulePage = (state, ir) -> ir.export.name else moduleVarName ir.requireId - table.insert out, "```lua" - table.insert out, "#{varName} = require \"#{ir.requireId}\" -- MoonScript" - table.insert out, "local #{varName} = require(\"#{ir.requireId}\") -- Lua" - table.insert out, "```" - table.insert out, "" + moonRequire = "#{varName} = require \"#{ir.requireId}\"" + luaRequire = "local #{varName} = require(\"#{ir.requireId}\")" + pushLanguageForms out, state.tabbedForms, moonRequire, luaRequire pushProse out, ir.moduleDoc if ir.moduleDoc @@ -589,7 +621,16 @@ renderModulePage = (state, ir) -> for cls in *ir.classes renderClass out, state, ir, cls - namedSegments = [segment for segment in *ir.segments when segment.name] + -- an exported enum's @alias is already shown with its members under Enums, and a table module's own + -- @class describes the module itself — drop both from the Types listing to avoid the duplicate + enumNames = {} + for enum in *ir.enums + enumNames[enum.name] = true if enum.exportedAs + shouldKeep = (seg) -> + return false if seg.kind == SegmentKind.Alias and enumNames[seg.name] + return false if seg.kind == SegmentKind.Class and seg.name == ir.export.name + true + namedSegments = [segment for segment in *ir.segments when segment.name and shouldKeep segment] if #namedSegments > 0 table.insert out, "## Types" table.insert out, "" @@ -666,6 +707,16 @@ renderIndexPage = (state, irs, opts) -> {path: "#{state.docsPrefix}index.md", title: state.siteName, text: table.concat(out, "\n") .. "\n"} +---Derives a package-grouped module's sidebar label by dropping the namespace prefix its package +---header already shows. +---@param requireId string +---@param namespace string +---@return string label +navLabel = (requireId, namespace) -> + prefix = "#{namespace}." + return requireId\sub #prefix + 1 if requireId\sub(1, #prefix) == prefix + requireId + ---Builds a literate-nav SUMMARY.md for the embeddable reference section, grouping module pages by ---their feed package. A host site includes it with a `- Reference: /` nav entry and the ---mkdocs-literate-nav plugin. Links are bare filenames, resolved against the section's own dir. @@ -682,12 +733,14 @@ buildLiterateNav = (pages, opts) -> table.sort namespaces for namespace in *namespaces pkg = opts.packages[namespace] - table.insert out, "* #{pkg.name or namespace}" moduleIds = [id for id in *pkg.modules or {}] table.sort moduleIds + table.insert out, "* #{pkg.name or namespace}" + -- the root module (id == namespace) leads as "Overview"; the rest drop the namespace prefix for id in *moduleIds covered[id] = true - table.insert out, " * [#{id}](#{id}.md)" + label = if id == namespace then "Overview" else navLabel id, namespace + table.insert out, " * [#{label}](#{id}.md)" leftovers = [id for id in *requireIds when not covered[id]] if #leftovers > 0 table.insert out, "* Other modules" if opts.packages @@ -759,6 +812,9 @@ class MoonCatsDocRenderer -- standalone sites (mkdocs/mdbook) keep pages in a docs/ subdir beside their config; -- the embeddable "none" section drops them flat, to sit inside a host site's docs dir docsPrefix: (opts.site or "mkdocs") == "none" and "" or "docs/" + -- content tabs need pymdownx.tabbed, configured only on the embeddable "none" host; + -- the standalone scaffolds fall back to one inline block + tabbedForms: (opts.site or "mkdocs") == "none" } buildLinkIndex state, irs diff --git a/modules/l0/MoonCats/Emitter.moon b/modules/l0/MoonCats/Emitter.moon index f2282c93..e95d27d8 100644 --- a/modules/l0/MoonCats/Emitter.moon +++ b/modules/l0/MoonCats/Emitter.moon @@ -82,11 +82,15 @@ resolveRefType = (name, state, cls, depth = 0) -> report state, FindingCode.ExternalRequire, nil, sym.requireId typeName when SymbolKind.Enum - sym.enum.exportedAs and "#{sym.enum.name}Enum" or nil + -- An exported enum resolves to its synthesized Enum class. An unexported module-local, + -- reached only through the returned table, resolves to the base Enum type. + sym.enum.exportedAs and "#{sym.enum.name}Enum" or "Enum" when SymbolKind.Reference resolveRefType sym.target, state, cls, depth + 1 when SymbolKind.Literal sym.literalKind != "nil" and inferLiteralType(sym.token, sym.literalKind) or nil + when SymbolKind.Table + "table" else nil @@ -111,10 +115,14 @@ typeFromValueInfo = (valueInfo, state, cls, name, line) -> report state, FindingCode.UnresolvedReference, line, valueInfo.refName typeName or "any" when ValueKind.Call - typeName = resolveRefType valueInfo.baseName, state, cls - unless typeName - report state, FindingCode.UnresolvedReference, line, name - typeName or "any" + -- setmetatable(t, mt) returns t; the common `setmetatable {}, …` form is a table + if valueInfo.baseName == "setmetatable" + "table" + else + typeName = resolveRefType valueInfo.baseName, state, cls + unless typeName + report state, FindingCode.UnresolvedReference, line, name + typeName or "any" when ValueKind.Table "table" when ValueKind.Expression @@ -564,6 +572,9 @@ class MoonCatsEmitter -- the module table must precede its fields; plain-data fields live on its header add -1, -> emitTableModuleHeader state, varName for field in *exportIR.fields or {} + if field.enum + add field.line, -> emitEnumClass state, field.enum + continue fnRef = resolveFunctionReference state, nil, field if fnRef add field.line, -> emitFunction state, varName, fnRef, fnRef.arrow == "fat" and ":" or "." diff --git a/modules/l0/MoonCats/Parser.moon b/modules/l0/MoonCats/Parser.moon index 70b25fb8..433c7219 100644 --- a/modules/l0/MoonCats/Parser.moon +++ b/modules/l0/MoonCats/Parser.moon @@ -143,7 +143,7 @@ metamethodNames = utils.makeSet { ---@class MoonCatsEnumIR ---@field name string The Enum's declared runtime name. ---@field line integer ----@field members {key: string, literal: string}[] Literal-valued members in definition order. +---@field members {key: string, literal: string, display?: string}[] Literal-valued members in definition order; display holds a computed key's source `Enum.Member` expression. ---@field computedKeyCount integer Number of members skipped for non-literal keys or values. ---@field exportedAs? string Member/field name the Enum is exported under, when it is. @@ -176,6 +176,7 @@ metamethodNames = utils.makeSet { ---@field line integer ---@field block? string[] ---@field valueInfo MoonCatsValueInfo +---@field enum? MoonCatsEnumIR Set when the field holds an Enum, mirroring a class-static enum export. ---@field params? MoonCatsParam[] Signature when the value is a function literal. ---@field arrow? string ---@field hasExplicitValueReturn? boolean @@ -505,6 +506,20 @@ fndefHasValueReturn = (fndefNode) -> search stm found +---Resolves an `SomeEnum.Member` reference, in key or value position, against an already-parsed enum. +---@param node any +---@param resolveSymbol fun(name: string): MoonCatsSymbol? +---@return string? literal The referenced member's literal token. +---@return string? expr The source `Enum.Member` expression, for display. +resolveEnumMemberRef = (node, resolveSymbol) -> + info = describeChain node + return nil unless info and info.baseName and info.accessor and info.accessorKind == "dot" + sym = resolveSymbol info.baseName + return nil unless sym and sym.kind == SymbolKind.Enum and sym.enum + for member in *sym.enum.members + return member.literal, "#{info.baseName}.#{info.accessor}" if member.key == info.accessor + nil + ---Recognizes an `Enum "Name", {...}` definition chain. ---@param node any ---@param resolveSymbol fun(name: string): MoonCatsSymbol? @@ -520,11 +535,19 @@ enumSpecFromChain = (node, resolveSymbol) -> members, computedKeyCount = {}, 0 for pair in *tableNode[2] key, value = pair[1], pair[2] - token = value != nil and literalTokenFromNode value - if nodeType(key) == "key_literal" and token - table.insert members, {key: key[2], literal: token} - else + -- a value referencing another enum's member carries that member's literal + valueToken = value != nil and (literalTokenFromNode(value) or resolveEnumMemberRef(value, resolveSymbol)) + if not valueToken computedKeyCount += 1 + elseif nodeType(key) == "key_literal" + table.insert members, {key: key[2], literal: valueToken} + else + -- a `[SomeEnum.Member]` key is that member's value at runtime, so key the field by the resolved literal + keyToken, keyExpr = resolveEnumMemberRef key, resolveSymbol + if keyToken + table.insert members, {key: "[#{keyToken}]", display: keyExpr, literal: valueToken} + else + computedKeyCount += 1 {:name, :members, :computedKeyCount} ---Recognizes an `Accessors.property {...}` computed-property value. @@ -545,6 +568,9 @@ valueInfoFromNode = (value) -> token, literalKind = literalTokenFromNode value return {kind: ValueKind.Literal, :token, :literalKind} if token switch nodeType value + when "string" + -- an empty or interpolated string still types as string, even when its token can't be reconstructed + {kind: ValueKind.Literal, literalKind: "string"} when "ref" {kind: ValueKind.Reference, refName: value[2]} when "table" @@ -775,6 +801,10 @@ walkModuleAssign = (stm, ctx) -> if #names == 1 and firstType == "chain" info = describeChain first if info and info.baseName and info.accessor and info.accessorKind == "dot" and not info.callArgs + -- a `Class.__class.field =` path manipulates the runtime class object, not the documented type surface + for i = 3, #first + item = first[i] + return if "table" == type(item) and item[1] == "dot" and item[2] == "__class" value = values[1] aug = { targetName: info.baseName @@ -896,6 +926,16 @@ buildExportFields = (ctx) -> continue line = nodeLine(value, ctx.source) or 0 field = {name: key[2], :line, valueInfo: valueInfoFromNode value} + -- a field holding an Enum (inline or a module-local reference) is an enum export, like a class static + spec = enumSpecFromChain value, (n) -> ctx.symbols[n] + if spec + field.enum = recordEnum ctx, spec, line + field.enum.exportedAs = field.name + elseif field.valueInfo.kind == ValueKind.Reference + sym = ctx.symbols[field.valueInfo.refName] + if sym and sym.kind == SymbolKind.Enum + sym.enum.exportedAs or= field.name + field.enum = sym.enum if field.valueInfo.kind == ValueKind.Function field.params, field.arrow = field.valueInfo.params, field.valueInfo.arrow field.hasExplicitValueReturn = fndefHasValueReturn value diff --git a/modules/l0/MoonCats/test/DocRenderer.moon b/modules/l0/MoonCats/test/DocRenderer.moon index 1f1ac61b..8be701d6 100644 --- a/modules/l0/MoonCats/test/DocRenderer.moon +++ b/modules/l0/MoonCats/test/DocRenderer.moon @@ -159,6 +159,10 @@ ut\assertMatches text, "| logger | %[Logger%]%(#Logger%) |" ut\assertMatches text, "| maxSize | `integer` |" + fields_setmetatableTypedTable: (ut) -> + text = render ut, "class Foo\n @instances = setmetatable {}, {__mode: \"v\"}\nreturn Foo" + ut\assertMatches text, "| instances | `table` |" + -- ── enums and aliases ─────────────────────────────────────────────────── enum_tableWithAliasDescriptions: (ut) -> @@ -173,6 +177,13 @@ ut\assertMatches text, "| Ok | `1` |" ut\assertMatches text, "| Failed | `%-1` |" + enum_computedKeyShowsSourceExpression: (ut) -> + -- a key referencing another enum's member shows the source Enum.Member expression, value joined from the alias + src = 'Enum = require "l0.DependencyControl.Enum"\nKind = Enum "Kind", {A: "a", B: "b"}\n---@alias Section\n---| "macros" # automation scripts\n---| "modules" # modules\nSection = Enum "Section", {[Kind.A]: "macros", [Kind.B]: "modules"}\n---@class Domain\nreturn {:Kind, :Section}' + text = render ut, src + ut\assertMatches text, "| Kind%.A | `\"macros\"` | automation scripts |" + ut\assertMatches text, "| Kind%.B | `\"modules\"` | modules |" + types_aliasVariantTable: (ut) -> text = render ut, "---Precision selector.\n---@alias Precision\n---| 'major' # Major: whole releases\n---| 'minor' # Minor: feature releases\n\nx = 1\n\nf = -> 1\nreturn {f: f}" ut\assertMatches text, "## Types" @@ -221,6 +232,26 @@ ut\assertMatches text, "## Fields" ut\assertMatches text, "| NAME | `string` |" + module_tableEnumFieldUnderEnumsSection: (ut) -> + -- a table field holding a module-local enum renders under its own Enums section, like a class enum + text = render ut, 'Enum = require "l0.DependencyControl.Enum"\nOp = Enum "Op", {Eq: "="}\nreturn {:Op}', "l0.Test.ops" + ut\assertMatches text, "## Enums" + ut\assertFalse text\match("| Op |") != nil + + module_enumAliasAndOwnClassNotUnderTypes: (ut) -> + -- an exported enum's alias shows under Enums, not duplicated under Types, and the table's own + -- @class is dropped from Types too — leaving no Types section here, while the value type still links + src = 'Enum = require "l0.DependencyControl.Enum"\n---@alias Mode\n---| "a" # A: first\nMode = Enum "Mode", {A: "a"}\n---@class Ops\nOps = {:Mode}\nreturn Ops' + text = render ut, src, "l0.Test.ops" + ut\assertMatches text, "## Enums" + ut\assertFalse text\match("## Types") != nil + ut\assertMatches text, '' + + module_ownTypeGetsAnchor: (ut) -> + -- a table module's own declared type gets an anchor so a re-export elsewhere can link to it + text = render ut, '---@class Ops\nOps = {NAME: "x"}\nreturn Ops', "l0.Test.ops" + ut\assertMatches text, '' + module_functionExport: (ut) -> text = render ut, "---Resolves a host.\n---@param host string The host.\n---@return string resolved The resolution.\nresolveHost = (host) -> host\nreturn resolveHost", "l0.Test.resolve-host" ut\assertMatches text, "#### resolveHost" @@ -276,4 +307,30 @@ ut\assertEquals result.scaffold[1].path, "SUMMARY.md" ut\assertMatches result.scaffold[1].text, "%* %[Overview%]%(index%.md%)" ut\assertMatches result.scaffold[1].text, "%* %[l0%.Test%.Foo%]%(l0%.Test%.Foo%.md%)" + + -- Under a plain section header, the root module leads as "Overview" and the rest drop the + -- namespace. Ungrouped leftovers stay fully qualified. + scaffold_literateNavRootAsOverview: (ut) -> + result = renderModules ut, { + {source: "class Root\n go: => 1\nreturn Root", requireId: "l0.Test"} + {source: "class Foo\n go: => 1\nreturn Foo", requireId: "l0.Test.Foo"} + {source: "class Bar\n go: => 1\nreturn Bar", requireId: "l0.Other.Bar"} + }, { + site: "none" + packages: {"l0.Test": {name: "Test Package", modules: {"l0.Test", "l0.Test.Foo"}}} + } + summary = result.scaffold[1].text + ut\assertMatches summary, "%* %[Overview%]%(l0%.Test%.md%)" + ut\assertMatches summary, "%* %[Foo%]%(l0%.Test%.Foo%.md%)" + ut\assertMatches summary, "%* %[l0%.Other%.Bar%]%(l0%.Other%.Bar%.md%)" + + -- In the embeddable "none" mode the require snippet and signatures render as linked MoonScript/Lua + -- content tabs. Other modes keep one inline block. + page_noneModeRendersLanguageTabs: (ut) -> + tabbed = render ut, "class Foo\n go: (x) => x\nreturn Foo", "l0.Test.Foo", {site: "none"} + ut\assertMatches tabbed, '=== "MoonScript"' + ut\assertMatches tabbed, '=== "Lua"' + inline = render ut, "class Foo\n go: (x) => x\nreturn Foo", "l0.Test.Foo", {site: "mkdocs"} + ut\assertMatches inline, "%-%- MoonScript" + ut\assertFalse inline\match('=== "MoonScript"') != nil } diff --git a/modules/l0/MoonCats/test/Emitter.moon b/modules/l0/MoonCats/test/Emitter.moon index 416352d3..fea144b1 100644 --- a/modules/l0/MoonCats/test/Emitter.moon +++ b/modules/l0/MoonCats/test/Emitter.moon @@ -141,6 +141,19 @@ ut\assertMatches text, "%-%-%-@field thing any" ut\assertTrue hasFinding diagnostics, FindingCode.UnresolvedReference + data_emptyAndInterpolatedStringsTypedString: (ut) -> + -- their token can't be reconstructed, but they are still strings, not any + text, diagnostics = emit ut, 'x = "y"\nclass Foo\n @blank = ""\n @interpolated = "#{x}z"\nreturn Foo' + ut\assertMatches text, "%-%-%-@field blank string" + ut\assertMatches text, "%-%-%-@field interpolated string" + ut\assertFalse hasFinding diagnostics, FindingCode.UnresolvedReference + + data_setmetatableFieldTypedTable: (ut) -> + -- a field seeded with setmetatable is a table, not an unresolved call + text, diagnostics = emit ut, "class Foo\n @instances = setmetatable {}, {__mode: \"v\"}\nreturn Foo" + ut\assertMatches text, "%-%-%-@field instances table" + ut\assertFalse hasFinding diagnostics, FindingCode.UnresolvedReference + data_privateSynthesizedForDunder: (ut) -> text = emit ut, "class Foo\n @__instances = 42\nreturn Foo" ut\assertMatches text, "%-%-%-@field private __instances integer" @@ -163,15 +176,43 @@ text = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nOp = Enum "Op", {Eq: "="}\nclass Foo\n go: => 1\nreturn Foo' ut\assertFalse text\match("OpEnum") != nil + enum_tableFieldExportsSynthesizedClass: (ut) -> + -- a table field holding a module-local enum is an enum export: typed Enum, class synthesized + text, diagnostics = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nOp = Enum "Op", {Eq: "="}\n---@class Ops\nOps = {:Op}\nreturn Ops' + ut\assertMatches text, "%-%-%-@field Op OpEnum" + ut\assertMatches text, "%-%-%-@class OpEnum: Enum" + ut\assertFalse hasFinding diagnostics, FindingCode.UnresolvedReference + enum_computedKeysReported: (ut) -> _, diagnostics = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nkey = "K"\nclass Foo\n @Map = Enum "Map", {[key]: "v", Plain: "p"}\nreturn Foo' ut\assertTrue hasFinding diagnostics, FindingCode.ComputedKeySkipped + enum_computedKeyResolvesViaReferencedEnum: (ut) -> + -- a `[OtherEnum.Member]` key resolves to that member's value, keying the field by the literal + src = 'Enum = require "l0.DependencyControl.Enum"\nKind = Enum "Kind", {A: "a", B: "b"}\nclass Foo\n @Section = Enum "Section", {[Kind.A]: "macros", [Kind.B]: "modules"}\nreturn Foo' + text, diagnostics = emit ut, src + ut\assertMatches text, '%-%-%-@field %["a"%] "macros"' + ut\assertMatches text, '%-%-%-@field %["b"%] "modules"' + ut\assertFalse hasFinding diagnostics, FindingCode.ComputedKeySkipped + + enum_computedValueResolvesViaReferencedEnum: (ut) -> + -- a value referencing another enum's member resolves to that member's literal + src = 'Enum = require "l0.DependencyControl.Enum"\nBase = Enum "Base", {A: "a", B: "b"}\nclass Foo\n @Derived = Enum "Derived", {X: Base.A, Y: Base.B}\nreturn Foo' + text, diagnostics = emit ut, src + ut\assertMatches text, '%-%-%-@field X "a"' + ut\assertMatches text, '%-%-%-@field Y "b"' + ut\assertFalse hasFinding diagnostics, FindingCode.ComputedKeySkipped + enum_augmentationEmitsOntoClass: (ut) -> text = emit ut, 'Enum = require "l0.DependencyControl.Enum"\nStatus = Enum "Status", {Ok: 0}\nclass Task\n go: => 1\nTask.Status = Status\nreturn Task' ut\assertMatches text, "%-%-%-@class StatusEnum: Enum" ut\assertMatches text, "%-%-%-@field Status StatusEnum" + augment_classDunderPathSkipped: (ut) -> + -- a `Class.__class.field =` path manipulates the runtime class object, not the type surface + text = emit ut, 'class Foo\n ---does\n go: => 1\nFoo.__class.tag = 42\nreturn Foo' + ut\assertFalse text\match("field tag") != nil + -- ── duplicate members ─────────────────────────────────────────────────── duplicate_documentedBeatsUndocumented: (ut) -> @@ -194,6 +235,12 @@ ut\assertMatches text, "%-%-%-@class l0%.Test%.Constants\n%-%-%-@field NAME string\n%-%-%-@field COUNT integer\nlocal Constants = {}" ut\assertMatches text, "return Constants\n$" + module_tableLocalReferenceTypedTable: (ut) -> + -- an export referencing a table-literal local resolves to table, not any + text, diagnostics = emit ut, 'cfg = {a: 1, b: 2}\nreturn {:cfg}', "l0.Test.Config" + ut\assertMatches text, "%-%-%-@field cfg table" + ut\assertFalse hasFinding diagnostics, FindingCode.UnresolvedReference + module_tableWithFunctionField: (ut) -> text = emit ut, 'return {\n ---Builds a mode string.\n ---@param user? string\n ---@return string mode\n getFileMode: (user = "") -> user\n}', "l0.Test.ffi-posix" ut\assertMatches text, "function ffi_posix%.getFileMode%(user%) end" diff --git a/modules/l0/dkjson.moon b/modules/l0/dkjson.moon index 1f7bd569..e673fb7c 100644 --- a/modules/l0/dkjson.moon +++ b/modules/l0/dkjson.moon @@ -180,8 +180,9 @@ wrapper.__depCtrlInit = (DependencyControl) -> author: "David Kolf" moduleName: "l0.dkjson" url: "http://dkolf.de/dkjson-lua/" - feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json" + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/DependencyControl.json" provides: {"json", "dkjson"} } + wrapper.version\register wrapper return wrapper diff --git a/modules/l0/DependencyControl/test/dkjson.moon b/modules/l0/dkjson/test.moon similarity index 96% rename from modules/l0/DependencyControl/test/dkjson.moon rename to modules/l0/dkjson/test.moon index dba37104..3ad41690 100644 --- a/modules/l0/DependencyControl/test/dkjson.moon +++ b/modules/l0/dkjson/test.moon @@ -5,10 +5,10 @@ -- since round-trips alone would pass even if the formatting were completely broken. A non-string -- object key — e.g. a stray integer left on a malformed required-module spec — has to be quoted, or -- the file it writes can no longer be parsed. --- Called from test.moon as: (controls\requireTest "dkjson")! --> - dkjson = require "l0.dkjson" +-- The l0.dkjson package's own test suite; run via `depctrl test` or the DepUnit test menu. +UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" +return UnitTestSuite "l0.dkjson", (dkjson) -> prettier = (value, extra) -> state = {indentMode: "prettier"} if extra then state[k] = v for k, v in pairs extra @@ -18,7 +18,7 @@ -- Prettier document ends with lines = (...) -> "#{table.concat {...}, '\n'}\n" - { + encoderTests = { _description: "Tests for the DependencyControl dkjson wrapper's Prettier encoder." -- layout: indentation, colon spacing, one property per line @@ -185,3 +185,5 @@ ut\assertEquals decoded.a.x, 1 ut\assertEquals decoded.b.x, 1 } + + {Encoder: encoderTests} diff --git a/schemas/config/v0.6.3.json b/schemas/config/v0.6.3.json index c199d1da..fa0e2a34 100644 --- a/schemas/config/v0.6.3.json +++ b/schemas/config/v0.6.3.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.6.3.json", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.6.3.json", "title": "DependencyControl Config File v0.6.3 (legacy)", "description": "The pre-0.7.0 DependencyControl configuration file, whose global settings under `config` use a flat, un-sectioned layout.", "type": "object", diff --git a/schemas/config/v0.7.0.json b/schemas/config/v0.7.0.json index b9f32847..7009ff59 100644 --- a/schemas/config/v0.7.0.json +++ b/schemas/config/v0.7.0.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.7.0.json", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.7.0.json", "title": "DependencyControl Config File v0.7.0", "description": "DependencyControl's on-disk configuration file (by default `/config/l0.DependencyControl.json`).", "type": "object", @@ -9,7 +9,7 @@ "properties": { "$schema": { "description": "Identifies the config format: the URL of the DependencyControl config schema this file conforms to. DependencyControl stamps it when it migrates a pre-0.7.0 config, and editors use it to validate.", - "const": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/config/v0.7.0.json" + "const": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/config/v0.7.0.json" }, "config": { "$ref": "#/$defs/GlobalConfig" }, "macros": { diff --git a/schemas/feed/v0.4.0.json b/schemas/feed/v0.4.0.json index 46732025..2428850a 100644 --- a/schemas/feed/v0.4.0.json +++ b/schemas/feed/v0.4.0.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/schemas/feed/v0.4.0.json", + "$id": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/publish/schemas/feed/v0.4.0.json", "title": "DependencyControl Feed Format v0.4.0", "description": "An index for a repository of automation macros and modules available for installation or update via DependencyControl.", "type": "object",