Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,10 +648,10 @@ async function detectFrameworks(
} catch {}
}

// Roku / SceneGraph — plain-text `manifest` file at root with title + major_version.
// Also matches the rokucommunity/brighterscript-template layout where the
// manifest lives under `src/` and root carries a bsconfig.json marker.
if (await hasRokuManifest(root) || await detectBrighterScriptTemplateRoot(root)) {
// Roku / SceneGraph — plain-text `manifest` at root, bsconfig.json template
// layouts, or `brighterscript` package in devDependencies (enterprise builds
// that generate the manifest at package time).
if (await hasRokuManifest(root) || await detectBrighterScriptTemplateRoot(root) || deps["brighterscript"]) {
frameworks.push("roku-scenegraph");
}

Expand Down Expand Up @@ -817,7 +817,7 @@ async function detectLanguage(
const hasCsproj = await (async () => {
try { return (await readdir(root)).some((e) => e.endsWith(".csproj") || e.endsWith(".sln")); } catch { return false; }
})();
const hasRokuChannel = await hasRokuManifest(root) || await detectBrighterScriptTemplateRoot(root);
const hasRokuChannel = await hasRokuManifest(root) || await detectBrighterScriptTemplateRoot(root) || !!deps["brighterscript"];

const langs: string[] = [];
if (hasTsConfig || deps["typescript"]) langs.push("typescript");
Expand Down Expand Up @@ -1315,14 +1315,18 @@ export async function hasRokuManifest(dir: string): Promise<boolean> {
}

/**
* Detect the `rokucommunity/brighterscript-template` layout:
* - no manifest at root
* - bsconfig.json at root (BrighterScript project marker)
* - exactly one `src/manifest` with the standard channel signature
* Detect BrighterScript-based Roku channel roots without a `manifest` file.
*
* Two layouts are recognized:
*
* Treat the root as a single Roku channel rooted at `src/`. Prevents the
* generic monorepo walker from promoting `src/` to a workspace and leaving
* the root framework as `raw-http`.
* 1. rokucommunity/brighterscript-template — bsconfig.json at root,
* channel under `src/manifest`.
*
* 2. Enterprise / custom layout — bsconfig.json at root with `rootDir: ""`
* (channel root IS the project root). Manifest is absent because it is
* generated at build time (e.g. python/gulp build scripts). The canonical
* Roku directories `source/` and `components/` with at least one .brs
* file serve as the structural signal instead.
*/
export async function detectBrighterScriptTemplateRoot(dir: string): Promise<boolean> {
if (await hasRokuManifest(dir)) return false;
Expand All @@ -1332,7 +1336,20 @@ export async function detectBrighterScriptTemplateRoot(dir: string): Promise<boo
} catch {
return false;
}
return hasRokuManifest(join(dir, "src"));
// Layout 1: rokucommunity template — manifest lives under src/
if (await hasRokuManifest(join(dir, "src"))) return true;
// Layout 2: channel-at-root without manifest — source/ or components/ with .brs
const hasBrsIn = async (subdir: string): Promise<boolean> => {
try {
const entries = await readdir(join(dir, subdir), { withFileTypes: true });
return entries.some((e) => e.isFile() && (e.name.endsWith(".brs") || e.name.endsWith(".bs")));
} catch {
return false;
}
};
if (await hasBrsIn("source")) return true;
if (await hasBrsIn("components")) return true;
return false;
}

/**
Expand Down
107 changes: 107 additions & 0 deletions tests/detectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,4 +1192,111 @@ end sub
`expected m.global.token middleware, got ${middleware.map((mw: any) => mw.name).join(", ")}`
);
});

it("detects bsconfig.json + source/*.brs layout without a manifest file (Layout 2)", async () => {
// Enterprise layout: manifest is generated at build time so it is absent
// from the repo. Detection must fall back to bsconfig.json + source/*.brs.
const dir = await writeFixture("roku-bsconfig-no-manifest", {
"package.json": JSON.stringify({
name: "my-roku-app",
devDependencies: { brighterscript: "^0.70.3" },
}),
"bsconfig.json": JSON.stringify({ rootDir: "" }),
"source/main.brs": `sub Main()\n screen = CreateObject("roSGScreen")\nend sub\n`,
"source/utils/StringUtils.brs": `function trim(s as string) as string\n return s.trim()\nend function\n`,
"components/MainScene.xml": `<?xml version="1.0" encoding="utf-8" ?>
<component name="MainScene" extends="Scene">
<interface>
<field id="ready" type="boolean" />
</interface>
</component>
`,
});

const project = await mods.detectProject(dir);
assert.ok(
project.frameworks.includes("roku-scenegraph"),
`expected roku-scenegraph, got: ${project.frameworks.join(", ")}`
);
assert.equal(project.language, "brightscript", `expected brightscript, got ${project.language}`);
assert.ok(!project.isMonorepo, "no-manifest channel must not be promoted to monorepo");

const files = await mods.collectFiles(dir);
const libs = await mods.detectLibs(files, project);
assert.ok(
libs.some((l: any) => l.file.includes("StringUtils.brs")),
`expected StringUtils.brs in libs, got: ${libs.map((l: any) => l.file).join(", ")}`
);
});

it("detects apmc-roku-style layout: bsconfig.json at root, source/ + components/ subdirs, lib/, no manifest", async () => {
// Models the actual apmc-roku project: bsconfig.json with rootDir:"",
// source/ for app code, components/ for SceneGraph XML, lib/ for
// third-party BRS, and no manifest (generated by build scripts).
const dir = await writeFixture("roku-apmc-style", {
"package.json": JSON.stringify({
name: "apmc-roku",
devDependencies: {
brighterscript: "^0.70.3",
"@rokucommunity/bslint": "^0.8.38",
},
}),
"bsconfig.json": JSON.stringify({ extends: "./configs/brightscript/bsconfig.base.json" }),
"source/main.brs": `sub Main()\n screen = CreateObject("roSGScreen")\nend sub\n`,
"source/utils/NavUtils.brs": `function getNavNode(item as object) as object\n return invalid\nend function\n`,
"source/utils/AnalyticsUtils.brs": `function trackEvent(name as string) as void\nend function\n`,
"lib/rafxssai.brs": `function RafInit() as void\nend function\n`,
"components/MainScene.xml": `<?xml version="1.0" encoding="utf-8" ?>
<component name="MainScene" extends="Scene">
<interface>
<field id="screenManager" type="node" />
<field id="exitApp" type="boolean" value="false" />
</interface>
</component>
`,
"components/MainScene.brs": `sub init()\n m.top.observeField("exitApp", "onExitApp")\nend sub\nfunction onExitApp() as void\nend function\n`,
"components/nodes/HomeView.xml": `<?xml version="1.0" encoding="utf-8" ?>
<component name="HomeView" extends="Group">
<interface>
<field id="content" type="assocarray" />
</interface>
</component>
`,
});

const project = await mods.detectProject(dir);
assert.ok(
project.frameworks.includes("roku-scenegraph"),
`expected roku-scenegraph, got: ${project.frameworks.join(", ")}`
);
assert.equal(project.language, "brightscript", `expected brightscript, got ${project.language}`);
assert.ok(!project.isMonorepo, "single-channel repo must not be promoted to monorepo");

const files = await mods.collectFiles(dir);

// lib/ third-party BRS should appear in libs
const libs = await mods.detectLibs(files, project);
assert.ok(
libs.some((l: any) => l.file.includes("rafxssai.brs")),
`expected lib/rafxssai.brs in libs, got: ${libs.map((l: any) => l.file).join(", ")}`
);
assert.ok(
libs.some((l: any) => l.file.includes("NavUtils.brs")),
`expected NavUtils.brs in libs, got: ${libs.map((l: any) => l.file).join(", ")}`
);

// observeField in MainScene.brs should surface as middleware
const middleware = await mods.detectMiddleware(files, project);
assert.ok(
middleware.some((mw: any) => mw.name.includes("observeField(exitApp)")),
`expected observeField(exitApp) middleware, got: ${middleware.map((mw: any) => mw.name).join(", ")}`
);

// HomeView schema from nodes/ subdirectory
const schemas = await mods.detectSchemas(files, project);
assert.ok(
schemas.some((s: any) => s.name === "HomeView"),
`expected HomeView schema, got: ${schemas.map((s: any) => s.name).join(", ")}`
);
});
});
2 changes: 2 additions & 0 deletions tests/fixtures/celery-detect/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
celery
redis
6 changes: 6 additions & 0 deletions tests/fixtures/celery-detect/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from celery import Celery
app = Celery("worker")

@app.task
def ping():
return "pong"
1 change: 1 addition & 0 deletions tests/fixtures/celery-events/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
celery
14 changes: 14 additions & 0 deletions tests/fixtures/celery-events/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from celery import Celery, shared_task
app = Celery("worker")

@app.task
def add(x, y):
return x + y

@shared_task
def cleanup():
return True

@app.task(bind=True, name="billing.report_usage_to_stripe", max_retries=3)
def report_usage_to_stripe_task(self):
return None
6 changes: 6 additions & 0 deletions tests/fixtures/celery-pyproject-detect/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[project]
name = "celery-worker"
dependencies = [
"celery>=5.4.0",
"redis>=5.0.0",
]
3 changes: 3 additions & 0 deletions tests/fixtures/config-app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DATABASE_URL=
JWT_SECRET=
PORT=3000
1 change: 1 addition & 0 deletions tests/fixtures/config-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test"}
2 changes: 2 additions & 0 deletions tests/fixtures/config-app/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const db = process.env.DATABASE_URL;
const port = process.env.PORT || 3000;
1 change: 1 addition & 0 deletions tests/fixtures/django-app/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
django
5 changes: 5 additions & 0 deletions tests/fixtures/django-app/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.urls import path
urlpatterns = [
path("api/users/", views.UserList.as_view()),
path("api/users/<int:id>/", views.UserDetail.as_view()),
]
1 change: 1 addition & 0 deletions tests/fixtures/drizzle-schema/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"drizzle-orm":"^0.30.0"}}
12 changes: 12 additions & 0 deletions tests/fixtures/drizzle-schema/src/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { pgTable, text, uuid, timestamp, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
active: boolean("active").default(true),
});
export const posts = pgTable("posts", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
userId: uuid("user_id").references(() => users.id),
});
1 change: 1 addition & 0 deletions tests/fixtures/elysia-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"elysia":"^1.0.0"}}
4 changes: 4 additions & 0 deletions tests/fixtures/elysia-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Elysia } from "elysia";
const app = new Elysia()
.get("/api/health", () => "ok")
.post("/api/items", () => ({ created: true }));
1 change: 1 addition & 0 deletions tests/fixtures/elysia-detect/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"elysia":"^1.0.0"}}
1 change: 1 addition & 0 deletions tests/fixtures/express-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"express":"^4.0.0"}}
6 changes: 6 additions & 0 deletions tests/fixtures/express-app/src/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Router } from "express";
const router = Router();
router.get("/users", (req, res) => res.json([]));
router.post("/users", (req, res) => res.json({}));
router.delete("/users/:id", (req, res) => res.json({}));
export default router;
8 changes: 8 additions & 0 deletions tests/fixtures/fastapi-app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import FastAPI
app = FastAPI()
@app.get("/users")
def get_users():
return []
@app.post("/users")
def create_user():
return {}
2 changes: 2 additions & 0 deletions tests/fixtures/fastapi-app/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fastapi
uvicorn
1 change: 1 addition & 0 deletions tests/fixtures/fastify-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"fastify":"^4.0.0"}}
5 changes: 5 additions & 0 deletions tests/fixtures/fastify-app/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import fastify from "fastify";
const app = fastify();
app.get("/health", async () => ({ status: "ok" }));
app.post("/items", async (req) => ({ created: true }));
export default app;
1 change: 1 addition & 0 deletions tests/fixtures/graph-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"hono":"^4.0.0"}}
2 changes: 2 additions & 0 deletions tests/fixtures/graph-app/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { db } from "./db.js";
export const auth = {};
1 change: 1 addition & 0 deletions tests/fixtures/graph-app/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const db = {};
3 changes: 3 additions & 0 deletions tests/fixtures/graph-app/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { auth } from "./auth.js";
import { db } from "./db.js";
export const mw = {};
3 changes: 3 additions & 0 deletions tests/fixtures/graph-app/src/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { db } from "./db.js";
import { auth } from "./auth.js";
export const routes = {};
1 change: 1 addition & 0 deletions tests/fixtures/hono-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"hono":"^4.0.0"}}
6 changes: 6 additions & 0 deletions tests/fixtures/hono-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Hono } from "hono";
const app = new Hono();
app.get("/api/users", (c) => c.json([]));
app.post("/api/users", (c) => c.json({}));
app.get("/api/users/:id", (c) => c.json({}));
export default app;
1 change: 1 addition & 0 deletions tests/fixtures/js-imports/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test"}
2 changes: 2 additions & 0 deletions tests/fixtures/js-imports/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { helper } from "./utils.js";
console.log(helper);
1 change: 1 addition & 0 deletions tests/fixtures/js-imports/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const helper = () => {};
1 change: 1 addition & 0 deletions tests/fixtures/middleware-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"express":"^4.0.0"}}
5 changes: 5 additions & 0 deletions tests/fixtures/middleware-app/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function authMiddleware(req, res, next) {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: "unauthorized" });
next();
}
4 changes: 4 additions & 0 deletions tests/fixtures/middleware-app/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function rateLimiter(req, res, next) {
// rate limiting logic
next();
}
1 change: 1 addition & 0 deletions tests/fixtures/monorepo-deps-empty/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import { x } from './local'; import React from 'react';
6 changes: 6 additions & 0 deletions tests/fixtures/monorepo-deps/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

import { foo } from '@scope/pkg-a';
import { bar } from '@scope/pkg-b';
import { baz } from 'lodash';
import { qux } from './local';

4 changes: 4 additions & 0 deletions tests/fixtures/monorepo-deps/src/other.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

import { x } from '@scope/pkg-a';
import { y } from '@scope/pkg-c';

1 change: 1 addition & 0 deletions tests/fixtures/monorepo-detect/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","workspaces":["packages/*"]}
1 change: 1 addition & 0 deletions tests/fixtures/monorepo-detect/packages/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@test/api","dependencies":{"hono":"^4.0.0","drizzle-orm":"^0.30.0"}}
1 change: 1 addition & 0 deletions tests/fixtures/monorepo-detect/packages/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@test/web","dependencies":{"react":"^18.0.0"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@scope/pkg-force-included"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const x = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const y = 2;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const z = 3;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@scope/pkg-large"}
Loading